diff --git a/CHANGELOG.md b/CHANGELOG.md index c093154..c6fefe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/client.dart b/lib/client.dart index 5e99fc5..c5e4f3a 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -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'; diff --git a/lib/core/version.dart b/lib/core/version.dart index 6787740..466fa57 100644 --- a/lib/core/version.dart +++ b/lib/core/version.dart @@ -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'; diff --git a/lib/terminal.dart b/lib/terminal.dart index dedbc90..40b87dd 100644 --- a/lib/terminal.dart +++ b/lib/terminal.dart @@ -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'; diff --git a/lib/ui/ai_settings_section.dart b/lib/ui/ai_settings_section.dart new file mode 100644 index 0000000..c7eb1b2 --- /dev/null +++ b/lib/ui/ai_settings_section.dart @@ -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 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.'), + ]; +} diff --git a/lib/ui/settings_panel.dart b/lib/ui/settings_panel.dart index 203574e..814ce4c 100644 --- a/lib/ui/settings_panel.dart +++ b/lib/ui/settings_panel.dart @@ -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'; @@ -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', @@ -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), ], ); diff --git a/lib/ui_kit.dart b/lib/ui_kit.dart index ab1b6fe..3be2e40 100644 --- a/lib/ui_kit.dart +++ b/lib/ui_kit.dart @@ -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'; diff --git a/pubspec.yaml b/pubspec.yaml index 50303c5..2803df3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/ui/ai_settings_section_test.dart b/test/ui/ai_settings_section_test.dart new file mode 100644 index 0000000..11cebd3 --- /dev/null +++ b/test/ui/ai_settings_section_test.dart @@ -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'); + }); +}