From 2797583c63d47d3752c4caececd238aaabfc8a67 Mon Sep 17 00:00:00 2001 From: "Graciliano M. P." Date: Tue, 14 Jul 2026 22:18:37 -0300 Subject: [PATCH] Share the :ai / :ide commands and AI settings (v1.16.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser shell commands and the AI settings that back them shipped in the package but were not reachable without importing internal paths, and the settings dialog was welded to this app's AppContext — so a sibling dashboard that embeds the same terminal could not offer :ai/:ide without copying code. - terminal.dart exports registerAiCommand/resolveAiWiring (:ai) and registerIdeCommand (:ide); client.dart exports AiSettingsController. - Extract aiSettingsSection(AiSettingsController) into ui_kit — the portable half of the settings dialog (provider/model/key, Hub-default toggle, mode, reply language), bound only to a controller, no AppContext. The built-in settings panel now composes it, so the shell app and an embedding app render the same UI from one source. Additive and backward-compatible. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 28 ++++++ lib/client.dart | 1 + lib/core/version.dart | 2 +- lib/terminal.dart | 6 ++ lib/ui/ai_settings_section.dart | 125 ++++++++++++++++++++++++++ lib/ui/settings_panel.dart | 112 +---------------------- lib/ui_kit.dart | 1 + pubspec.yaml | 2 +- test/ui/ai_settings_section_test.dart | 42 +++++++++ 9 files changed, 208 insertions(+), 111 deletions(-) create mode 100644 lib/ui/ai_settings_section.dart create mode 100644 test/ui/ai_settings_section_test.dart 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'); + }); +}