Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,31 @@
## 1.16.0

The `:ai` and `:ide` shell commands, and the AI settings that back them, are now
public API — a sibling dashboard can offer the same terminal commands without
copying code.

Additive and backward-compatible.

### Added

- **Barrel exports for the browser shell commands.** `terminal.dart` now exports
`registerAiCommand` / `resolveAiWiring` (`:ai`) and `registerIdeCommand`
(`:ide`); `client.dart` exports `AiSettingsController`. These already shipped in
the package — they just were not reachable without importing an internal path.

- **`aiSettingsSection(AiSettingsController)`** (exported from `ui_kit.dart`) — the
portable half of the settings dialog: the AI provider/model/key, Hub-default
toggle, agent mode and reply-language controls, bound only to a controller and
free of any `AppContext`. Drop it into any settings modal.

### Changed

- The built-in settings panel now composes `aiSettingsSection` for its AI block
rather than building those controls inline, so the shell app and an embedding
app render the same UI from one source.

---

## 1.15.0

**OmnyShell Web is now a package, not only an app.** Any Dart web app can embed a
Expand Down
1 change: 1 addition & 0 deletions lib/client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,6 @@ export 'state/auth_controller.dart';
export 'state/nodes_controller.dart';
export 'state/sessions_controller.dart';
export 'state/terminal_display_controller.dart';
export 'state/ai_settings_controller.dart';
export 'state/theme_controller.dart';
export 'storage/local_storage_store.dart';
2 changes: 1 addition & 1 deletion lib/core/version.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
library;

/// This web client's version (matches `pubspec.yaml`).
const String webClientVersion = '1.15.0';
const String webClientVersion = '1.16.0';
6 changes: 6 additions & 0 deletions lib/terminal.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,9 @@ export 'terminal/terminal_fitter.dart';
export 'terminal/terminal_view.dart';
export 'terminal/web_shell_host.dart';
export 'terminal/xterm_terminal_view.dart';
// Local shell commands a browser adds on top of the omnyshell built-ins:
// `:ai` (the in-terminal agent, proxied through the Hub) and `:ide` (the
// terminal IDE on the node). A dashboard that embeds this terminal registers
// them the same way the shell app does.
export 'terminal/ai_command_factory.dart';
export 'terminal/ide_command_factory.dart';
125 changes: 125 additions & 0 deletions lib/ui/ai_settings_section.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import 'dart:async';

import 'package:web/web.dart' as web;

import '../state/ai_settings_controller.dart';
import 'dom.dart';
import 'widgets.dart';

/// The AI-agent settings controls, bound to an [AiSettingsController].
///
/// This is the portable half of the settings dialog: it needs no `AppContext`,
/// only the controller (which itself wraps the settings store and the shell
/// service), so any app that offers the `:ai` command can drop these rows into
/// its own settings modal. Returns the section's elements in order, ready to
/// place under the rest of a settings body.
///
/// Bindings persist on `change` through the controller; the "use Hub default"
/// checkbox enables or disables the custom provider/model/key fields, and the
/// Hub's advertised default (if any) is fetched and shown as a hint.
List<web.HTMLElement> aiSettingsSection(AiSettingsController ai) {
final providerSelect =
el('select', id: 'ai-provider') as web.HTMLSelectElement;
for (final p in const ['anthropic', 'openai', 'gemini']) {
final opt = el('option', text: p) as web.HTMLOptionElement;
opt.value = p;
opt.selected = p == ai.provider;
providerSelect.appendChild(opt);
}
on(providerSelect, 'change', (_) => ai.provider = providerSelect.value);

final aiModel = input(
id: 'ai-model',
value: ai.model,
placeholder: 'provider default',
);
on(aiModel, 'change', (_) => ai.model = aiModel.value);

final aiKey = input(
id: 'ai-key',
type: 'password',
value: ai.apiKey,
placeholder: 'sk-…',
autocomplete: 'off',
);
on(aiKey, 'change', (_) => ai.apiKey = aiKey.value);

final aiCustom = el(
'div',
classes: 'stack ai-custom',
children: [
field('Provider', providerSelect),
field('Model', aiModel, hint: 'Leave blank for the provider default.'),
field(
'Your API key',
aiKey,
hint: 'Stored in this browser only; sent via the Hub to the provider.',
),
],
);

void syncAiEnabled() {
final custom = !ai.useHubDefault;
aiCustom.classList.toggle('disabled', !custom);
providerSelect.disabled = !custom;
aiModel.disabled = !custom;
aiKey.disabled = !custom;
}

final hubHint = el('div', classes: 'hint', text: 'Checking the Hub default…');
unawaited(
ai.hubDefault().then((cfg) {
hubHint.textContent = (cfg == null || !cfg.available)
? 'The Hub has no default AI provider — add your own key below.'
: 'Hub default: ${cfg.provider} / ${cfg.model ?? 'model default'} '
'(the key stays on the Hub).';
}),
);

final useHub = checkbox(
"Use the Hub's default AI provider",
id: 'ai-use-hub',
checked: ai.useHubDefault,
);
on(useHub.box, 'change', (_) {
ai.useHubDefault = useHub.box.checked;
syncAiEnabled();
});

final aiMode = radioGroup(
name: 'ai-mode',
ariaLabel: 'Agent mode',
inline: true,
selected: ai.mode,
options: const [
(value: 'standard', label: 'Standard'),
(value: 'plan', label: 'Plan'),
(value: 'auto', label: 'Auto'),
],
onChange: (value) => ai.mode = value,
);

final aiLang = input(
id: 'ai-lang',
value: ai.language,
placeholder: 'model default',
);
on(aiLang, 'change', (_) => ai.language = aiLang.value);

syncAiEnabled();

return [
el('h3', text: 'AI agent'),
useHub.root,
hubHint,
aiCustom,
el('div', classes: 'hint', text: 'Default mode'),
aiMode,
field(
'Reply language',
aiLang,
hint: 'e.g. english, portuguese — blank for the model default.',
),
el('div', classes: 'hint', text: 'Applies to the next session you open.'),
];
}
112 changes: 3 additions & 109 deletions lib/ui/settings_panel.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import 'dart:async';

import 'package:web/web.dart' as web;

import '../app/app_context.dart';
import '../terminal/device_metrics.dart';
import '../terminal/terminal_dimensions.dart';
import 'ai_settings_section.dart';
import 'dom.dart';
import 'modal.dart';
import 'widgets.dart';
Expand Down Expand Up @@ -100,99 +97,7 @@ void showSettingsPanel(AppContext ctx) {
onChange: (value) => display.setTextSize(TerminalTextSize.parse(value)),
);

// --- AI agent -------------------------------------------------------------
final ai = ctx.ai;

final providerSelect =
el('select', id: 'ai-provider') as web.HTMLSelectElement;
for (final p in const ['anthropic', 'openai', 'gemini']) {
final opt = el('option', text: p) as web.HTMLOptionElement;
opt.value = p;
opt.selected = p == ai.provider;
providerSelect.appendChild(opt);
}
on(providerSelect, 'change', (_) => ai.provider = providerSelect.value);

final aiModel = input(
id: 'ai-model',
value: ai.model,
placeholder: 'provider default',
);
on(aiModel, 'change', (_) => ai.model = aiModel.value);

final aiKey = input(
id: 'ai-key',
type: 'password',
value: ai.apiKey,
placeholder: 'sk-…',
autocomplete: 'off',
);
on(aiKey, 'change', (_) => ai.apiKey = aiKey.value);

final aiCustom = el(
'div',
classes: 'stack ai-custom',
children: [
field('Provider', providerSelect),
field('Model', aiModel, hint: 'Leave blank for the provider default.'),
field(
'Your API key',
aiKey,
hint: 'Stored in this browser only; sent via the Hub to the provider.',
),
],
);

void syncAiEnabled() {
final custom = !ai.useHubDefault;
aiCustom.classList.toggle('disabled', !custom);
providerSelect.disabled = !custom;
aiModel.disabled = !custom;
aiKey.disabled = !custom;
}

final hubHint = el('div', classes: 'hint', text: 'Checking the Hub default…');
unawaited(
ai.hubDefault().then((cfg) {
hubHint.textContent = (cfg == null || !cfg.available)
? 'The Hub has no default AI provider — add your own key below.'
: 'Hub default: ${cfg.provider} / ${cfg.model ?? 'model default'} '
'(the key stays on the Hub).';
}),
);

final useHub = checkbox(
"Use the Hub's default AI provider",
id: 'ai-use-hub',
checked: ai.useHubDefault,
);
on(useHub.box, 'change', (_) {
ai.useHubDefault = useHub.box.checked;
syncAiEnabled();
});

final aiMode = radioGroup(
name: 'ai-mode',
ariaLabel: 'Agent mode',
inline: true,
selected: ai.mode,
options: const [
(value: 'standard', label: 'Standard'),
(value: 'plan', label: 'Plan'),
(value: 'auto', label: 'Auto'),
],
onChange: (value) => ai.mode = value,
);

final aiLang = input(
id: 'ai-lang',
value: ai.language,
placeholder: 'model default',
);
on(aiLang, 'change', (_) => ai.language = aiLang.value);

syncAiEnabled();

// The AI-agent controls are the portable half — see [aiSettingsSection].
late final Modal modal;
final body = el(
'div',
Expand All @@ -212,18 +117,7 @@ void showSettingsPanel(AppContext ctx) {
textSize,
el('div', classes: 'hint', text: 'Applies immediately.'),
el('hr'),
el('h3', text: 'AI agent'),
useHub.root,
hubHint,
aiCustom,
el('div', classes: 'hint', text: 'Default mode'),
aiMode,
field(
'Reply language',
aiLang,
hint: 'e.g. english, portuguese — blank for the model default.',
),
el('div', classes: 'hint', text: 'Applies to the next session you open.'),
...aiSettingsSection(ctx.ai),
],
);

Expand Down
1 change: 1 addition & 0 deletions lib/ui_kit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
/// your `web/`. Without them the widgets render unstyled.
library;

export 'ui/ai_settings_section.dart';
export 'ui/dom.dart';
export 'ui/modal.dart';
export 'ui/toasts.dart';
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ description: >-
A browser client for OmnyShell — connect to a Hub, discover nodes, run
interactive sessions — and the reusable library behind it: embed a real remote
shell terminal, or its UI kit, in any Dart web app.
version: 1.15.0
version: 1.16.0
repository: https://github.com/OmnyGrid/omnyshell_web
topics:
- terminal
Expand Down
42 changes: 42 additions & 0 deletions test/ui/ai_settings_section_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
@TestOn('browser')
library;

import 'package:omnyshell_web/ui_kit.dart' show aiSettingsSection;
import 'package:test/test.dart';
import 'package:web/web.dart' as web;

import '../support/dom_harness.dart';

void main() {
// The section is the portable half of the settings dialog: it takes only an
// AiSettingsController, no AppContext, so a different app can reuse it.
test('renders the AI controls from just the controller', () {
final h = DomHarness();
final host = web.document.createElement('div') as web.HTMLElement;
for (final row in aiSettingsSection(h.ctx.ai)) {
host.appendChild(row);
}

final provider =
host.querySelector('#ai-provider') as web.HTMLSelectElement?;
expect(provider, isNotNull);
expect(provider!.value, h.ctx.ai.provider);
expect(host.querySelector('#ai-model'), isNotNull);
expect(host.querySelector('#ai-key'), isNotNull);
expect(host.querySelector('#ai-use-hub'), isNotNull);
expect(host.textContent, contains('AI agent'));
});

test('a change persists through the controller', () {
final h = DomHarness();
final host = web.document.createElement('div') as web.HTMLElement;
for (final row in aiSettingsSection(h.ctx.ai)) {
host.appendChild(row);
}

final model = host.querySelector('#ai-model') as web.HTMLInputElement;
model.value = 'claude-sonnet-5';
model.dispatchEvent(web.Event('change'));
expect(h.ctx.ai.model, 'claude-sonnet-5');
});
}