From df9f374087a7722dc2222f479c44618932833f0b Mon Sep 17 00:00:00 2001 From: Nina Doschek Date: Mon, 27 Jul 2026 11:45:09 +0000 Subject: [PATCH 1/3] feat(ai): rework AI configuration into a unified view Consolidate AI configuration into a single master-detail view with flat category navigation (General, Providers & Models, Model Aliases, Agents, Prompts & Skills, Variables, Tools, Token Usage, MCP Servers), retiring the legacy widgets and the scattered AI entries in the Settings UI. - Add the AiConfigurationService seam and the AiConfigurationCategory contribution point with an ordered registry (ai-core, ai-core-ui), so the view reads/writes AI preferences workspace-trust-aware and stays insulated from how they are stored. - Provide shared, DI-free page primitives: sections, list rows, settings rows and controls (toggle/select/number/array), a gear context menu (Copy Setting ID / Reset Setting), status badges, and a filterable collapsible list. - Render agents (prompt variant sets, language models, notifications, capabilities), providers & models, model aliases, prompts, skills & slash commands, variables, tools and MCP servers as consistent list/detail pages; complex preferences defer to settings.json. - Hide the AI preferences from the Settings UI and redirect ai-features.* deep-links to the view; show the category > item path as real Theia breadcrumbs. Refs eclipsesource/theia#310, eclipsesource/theia#311, eclipsesource/theia#313, eclipsesource/theia#314, eclipsesource/theia#316. Co-authored-by: Alexandra Muntean Resolves GH-17649 --- doc/Migration.md | 10 + examples/api-samples/package.json | 1 + ...le-chat-toolbar-configuration-category.tsx | 104 ++ .../browser/api-samples-frontend-module.ts | 4 + .../chat-node-toolbar-action-contribution.ts | 37 +- .../sample-chat-node-toolbar-preferences.ts | 43 + examples/api-samples/tsconfig.json | 3 + package-lock.json | 4 + .../src/common/anthropic-preferences.ts | 8 +- .../src/browser/chat-input-widget.tsx | 8 +- .../chat-node-toolbar-action-contribution.ts | 8 +- .../chat-tree-view/chat-view-tree-widget.tsx | 7 + .../ai-chat/src/common/ai-chat-preferences.ts | 4 + .../src/common/chat-tool-preferences.ts | 4 +- .../src/common/context-summary-variable.ts | 3 +- .../src/common/claude-code-preferences.ts | 3 +- .../ai-codex/src/common/codex-preferences.ts | 3 +- .../src/common/copilot-preferences.ts | 3 +- packages/ai-core-ui/package.json | 1 + ...ai-configuration-category-registry.spec.ts | 137 +++ .../ai-configuration-category-registry.ts | 79 ++ .../ai-configuration-category.ts | 194 ++++ .../ai-configuration-selection-model.ts | 42 + .../ai-configuration-setting-commands.spec.ts | 82 ++ .../ai-configuration-setting-commands.ts | 104 ++ .../ai-configuration-components.spec.ts | 224 ++++ .../components/ai-configuration-controls.tsx | 359 ++++++ .../components/ai-configuration-item-row.tsx | 90 ++ .../ai-configuration-primitives.tsx | 240 ++++ .../ai-configuration-setting-row.tsx | 154 +++ .../ai-settings-row-service.spec.ts | 269 +++++ .../components/ai-settings-row-service.ts | 395 +++++++ .../components/ai-settings-row.tsx | 172 +++ .../components/variant-set-card.tsx | 171 +++ .../collection-category-renderer.tsx | 117 ++ .../single-page-category-renderer.tsx | 41 + .../src/browser/ai-core-ui-frontend-module.ts | 15 +- .../style/ai-configuration-components.css | 1007 +++++++++++++++++ .../ai-core-ui/src/browser/style/index.css | 17 + packages/ai-core-ui/tsconfig.json | 3 + .../src/browser/ai-core-frontend-module.ts | 8 +- .../hide-ai-preferences-contribution.spec.ts | 46 + .../hide-ai-preferences-contribution.ts | 62 + packages/ai-core/src/browser/index.ts | 1 + .../browser/theia-variable-contribution.ts | 24 +- .../ai-core/src/common/ai-core-preferences.ts | 19 + .../src/common/today-variable-contribution.ts | 3 +- .../src/common/google-preferences.ts | 3 +- .../src/common/huggingface-preferences.ts | 3 +- packages/ai-ide/package.json | 1 + .../agent-configuration-widget.spec.ts | 71 -- .../agent-configuration-widget.tsx | 928 --------------- .../browser/ai-configuration/agent-icon.ts | 37 + ...-configuration-breadcrumbs-contribution.ts | 164 +++ .../ai-configuration-detail-widget.spec.ts | 97 ++ .../ai-configuration-detail-widget.tsx | 180 +++ ...nfiguration-label-provider-contribution.ts | 47 + ...ction.tsx => ai-configuration-resource.ts} | 28 +- .../ai-configuration-search-widget.tsx | 135 +++ .../ai-configuration-search.spec.ts | 92 ++ .../ai-configuration-search.ts | 67 ++ ...ate.tsx => ai-configuration-tree-model.ts} | 18 +- .../ai-configuration-tree-nodes.spec.ts | 124 ++ .../ai-configuration-tree-nodes.ts | 155 +++ .../ai-configuration-tree-widget.tsx | 403 +++++++ .../ai-configuration-view-contribution.ts | 197 +++- .../ai-configuration-widget.tsx | 187 +-- .../ai-card-grid-configuration-widget.tsx | 72 -- .../base/ai-configuration-base-widget.tsx | 37 - .../ai-hierarchical-configuration-widget.tsx | 51 - .../ai-list-detail-configuration-widget.tsx | 140 --- .../base/ai-table-configuration-widget.tsx | 107 -- .../agent-capabilities-settings.tsx | 206 ++++ .../categories/agent-chips.tsx | 51 + .../categories/agent-detail-model.ts | 120 ++ .../categories/agent-detail-view.tsx | 312 +++++ .../agents-configuration-category.spec.ts | 119 ++ .../agents-configuration-category.tsx | 427 +++++++ .../categories/collapsible-list.tsx | 161 +++ .../general-configuration-category.spec.ts | 100 ++ .../general-configuration-category.tsx | 463 ++++++++ ...del-aliases-configuration-category.spec.ts | 87 ++ .../model-aliases-configuration-category.tsx | 286 +++++ .../models-configuration-category.spec.ts | 167 +++ .../models-configuration-category.tsx | 272 +++++ ...-and-skills-configuration-category.spec.ts | 81 ++ ...mpts-and-skills-configuration-category.tsx | 371 ++++++ ...token-usage-configuration-category.spec.ts | 57 + .../token-usage-configuration-category.tsx | 185 +++ .../tools-configuration-category.spec.ts | 86 ++ .../tools-configuration-category.tsx | 391 +++++++ .../variables-configuration-category.spec.ts | 154 +++ .../variables-configuration-category.tsx | 320 ++++++ .../agent-notification-settings.tsx | 66 +- .../components/expandable-section.tsx | 51 - .../language-model-renderer.tsx | 213 ++-- .../model-aliases-configuration-widget.tsx | 270 ----- ...en-ai-configuration-preference-renderer.ts | 64 ++ .../prompt-fragments-configuration-widget.tsx | 736 ------------ .../skills-configuration-widget.spec.ts | 248 ---- .../skills-configuration-widget.tsx | 229 ---- .../template-settings-renderer.tsx | 125 -- .../token-usage-configuration-widget.tsx | 214 ---- .../tools-configuration-widget.tsx | 544 --------- .../variable-configuration-widget.tsx | 159 --- packages/ai-ide/src/browser/coder-agent.ts | 5 +- .../ai-ide/src/browser/frontend-module.ts | 124 +- .../ide-chat-welcome-message-provider.tsx | 22 +- .../browser/style/ai-configuration-base.css | 6 - .../browser/style/ai-configuration-cards.css | 60 - .../browser/style/ai-configuration-shell.css | 348 ++++++ .../browser/style/ai-configuration-table.css | 17 +- packages/ai-ide/src/browser/style/index.css | 582 +++++----- .../common/coder-replace-prompt-template.ts | 9 - packages/ai-ide/tsconfig.json | 3 + .../src/common/llamafile-preferences.ts | 2 + packages/ai-mcp/package.json | 1 + .../browser/mcp-configuration-widget.spec.tsx | 417 ------- .../src/browser/mcp-configuration-widget.tsx | 674 ----------- .../ai-mcp/src/browser/mcp-frontend-module.ts | 12 +- .../ai-mcp/src/browser/mcp-server-editor.ts | 29 +- ...mcp-servers-configuration-category.spec.ts | 112 ++ .../mcp-servers-configuration-category.tsx | 557 +++++++++ .../style/mcp-configuration-widget.css | 165 +-- packages/ai-mcp/tsconfig.json | 3 + .../src/common/ollama-preferences.ts | 3 +- .../src/common/openai-preferences.ts | 8 +- .../src/browser/preference-tree-model.ts | 4 + 128 files changed, 12210 insertions(+), 5965 deletions(-) create mode 100644 examples/api-samples/src/browser/ai-configuration/sample-chat-toolbar-configuration-category.tsx create mode 100644 examples/api-samples/src/browser/chat/sample-chat-node-toolbar-preferences.ts create mode 100644 packages/ai-core-ui/src/browser/ai-configuration/ai-configuration-category-registry.spec.ts create mode 100644 packages/ai-core-ui/src/browser/ai-configuration/ai-configuration-category-registry.ts create mode 100644 packages/ai-core-ui/src/browser/ai-configuration/ai-configuration-category.ts create mode 100644 packages/ai-core-ui/src/browser/ai-configuration/ai-configuration-selection-model.ts create mode 100644 packages/ai-core-ui/src/browser/ai-configuration/ai-configuration-setting-commands.spec.ts create mode 100644 packages/ai-core-ui/src/browser/ai-configuration/ai-configuration-setting-commands.ts create mode 100644 packages/ai-core-ui/src/browser/ai-configuration/components/ai-configuration-components.spec.ts create mode 100644 packages/ai-core-ui/src/browser/ai-configuration/components/ai-configuration-controls.tsx create mode 100644 packages/ai-core-ui/src/browser/ai-configuration/components/ai-configuration-item-row.tsx create mode 100644 packages/ai-core-ui/src/browser/ai-configuration/components/ai-configuration-primitives.tsx create mode 100644 packages/ai-core-ui/src/browser/ai-configuration/components/ai-configuration-setting-row.tsx create mode 100644 packages/ai-core-ui/src/browser/ai-configuration/components/ai-settings-row-service.spec.ts create mode 100644 packages/ai-core-ui/src/browser/ai-configuration/components/ai-settings-row-service.ts create mode 100644 packages/ai-core-ui/src/browser/ai-configuration/components/ai-settings-row.tsx create mode 100644 packages/ai-core-ui/src/browser/ai-configuration/components/variant-set-card.tsx create mode 100644 packages/ai-core-ui/src/browser/ai-configuration/renderers/collection-category-renderer.tsx create mode 100644 packages/ai-core-ui/src/browser/ai-configuration/renderers/single-page-category-renderer.tsx create mode 100644 packages/ai-core-ui/src/browser/style/ai-configuration-components.css create mode 100644 packages/ai-core-ui/src/browser/style/index.css create mode 100644 packages/ai-core/src/browser/hide-ai-preferences-contribution.spec.ts create mode 100644 packages/ai-core/src/browser/hide-ai-preferences-contribution.ts delete mode 100644 packages/ai-ide/src/browser/ai-configuration/agent-configuration-widget.spec.ts delete mode 100644 packages/ai-ide/src/browser/ai-configuration/agent-configuration-widget.tsx create mode 100644 packages/ai-ide/src/browser/ai-configuration/agent-icon.ts create mode 100644 packages/ai-ide/src/browser/ai-configuration/ai-configuration-breadcrumbs-contribution.ts create mode 100644 packages/ai-ide/src/browser/ai-configuration/ai-configuration-detail-widget.spec.ts create mode 100644 packages/ai-ide/src/browser/ai-configuration/ai-configuration-detail-widget.tsx create mode 100644 packages/ai-ide/src/browser/ai-configuration/ai-configuration-label-provider-contribution.ts rename packages/ai-ide/src/browser/ai-configuration/{components/configuration-section.tsx => ai-configuration-resource.ts} (52%) create mode 100644 packages/ai-ide/src/browser/ai-configuration/ai-configuration-search-widget.tsx create mode 100644 packages/ai-ide/src/browser/ai-configuration/ai-configuration-search.spec.ts create mode 100644 packages/ai-ide/src/browser/ai-configuration/ai-configuration-search.ts rename packages/ai-ide/src/browser/ai-configuration/{components/empty-state.tsx => ai-configuration-tree-model.ts} (65%) create mode 100644 packages/ai-ide/src/browser/ai-configuration/ai-configuration-tree-nodes.spec.ts create mode 100644 packages/ai-ide/src/browser/ai-configuration/ai-configuration-tree-nodes.ts create mode 100644 packages/ai-ide/src/browser/ai-configuration/ai-configuration-tree-widget.tsx delete mode 100644 packages/ai-ide/src/browser/ai-configuration/base/ai-card-grid-configuration-widget.tsx delete mode 100644 packages/ai-ide/src/browser/ai-configuration/base/ai-configuration-base-widget.tsx delete mode 100644 packages/ai-ide/src/browser/ai-configuration/base/ai-hierarchical-configuration-widget.tsx delete mode 100644 packages/ai-ide/src/browser/ai-configuration/base/ai-list-detail-configuration-widget.tsx delete mode 100644 packages/ai-ide/src/browser/ai-configuration/base/ai-table-configuration-widget.tsx create mode 100644 packages/ai-ide/src/browser/ai-configuration/categories/agent-capabilities-settings.tsx create mode 100644 packages/ai-ide/src/browser/ai-configuration/categories/agent-chips.tsx create mode 100644 packages/ai-ide/src/browser/ai-configuration/categories/agent-detail-model.ts create mode 100644 packages/ai-ide/src/browser/ai-configuration/categories/agent-detail-view.tsx create mode 100644 packages/ai-ide/src/browser/ai-configuration/categories/agents-configuration-category.spec.ts create mode 100644 packages/ai-ide/src/browser/ai-configuration/categories/agents-configuration-category.tsx create mode 100644 packages/ai-ide/src/browser/ai-configuration/categories/collapsible-list.tsx create mode 100644 packages/ai-ide/src/browser/ai-configuration/categories/general-configuration-category.spec.ts create mode 100644 packages/ai-ide/src/browser/ai-configuration/categories/general-configuration-category.tsx create mode 100644 packages/ai-ide/src/browser/ai-configuration/categories/model-aliases-configuration-category.spec.ts create mode 100644 packages/ai-ide/src/browser/ai-configuration/categories/model-aliases-configuration-category.tsx create mode 100644 packages/ai-ide/src/browser/ai-configuration/categories/models-configuration-category.spec.ts create mode 100644 packages/ai-ide/src/browser/ai-configuration/categories/models-configuration-category.tsx create mode 100644 packages/ai-ide/src/browser/ai-configuration/categories/prompts-and-skills-configuration-category.spec.ts create mode 100644 packages/ai-ide/src/browser/ai-configuration/categories/prompts-and-skills-configuration-category.tsx create mode 100644 packages/ai-ide/src/browser/ai-configuration/categories/token-usage-configuration-category.spec.ts create mode 100644 packages/ai-ide/src/browser/ai-configuration/categories/token-usage-configuration-category.tsx create mode 100644 packages/ai-ide/src/browser/ai-configuration/categories/tools-configuration-category.spec.ts create mode 100644 packages/ai-ide/src/browser/ai-configuration/categories/tools-configuration-category.tsx create mode 100644 packages/ai-ide/src/browser/ai-configuration/categories/variables-configuration-category.spec.ts create mode 100644 packages/ai-ide/src/browser/ai-configuration/categories/variables-configuration-category.tsx delete mode 100644 packages/ai-ide/src/browser/ai-configuration/components/expandable-section.tsx delete mode 100644 packages/ai-ide/src/browser/ai-configuration/model-aliases-configuration-widget.tsx create mode 100644 packages/ai-ide/src/browser/ai-configuration/open-ai-configuration-preference-renderer.ts delete mode 100644 packages/ai-ide/src/browser/ai-configuration/prompt-fragments-configuration-widget.tsx delete mode 100644 packages/ai-ide/src/browser/ai-configuration/skills-configuration-widget.spec.ts delete mode 100644 packages/ai-ide/src/browser/ai-configuration/skills-configuration-widget.tsx delete mode 100644 packages/ai-ide/src/browser/ai-configuration/template-settings-renderer.tsx delete mode 100644 packages/ai-ide/src/browser/ai-configuration/token-usage-configuration-widget.tsx delete mode 100644 packages/ai-ide/src/browser/ai-configuration/tools-configuration-widget.tsx delete mode 100644 packages/ai-ide/src/browser/ai-configuration/variable-configuration-widget.tsx delete mode 100644 packages/ai-ide/src/browser/style/ai-configuration-cards.css create mode 100644 packages/ai-ide/src/browser/style/ai-configuration-shell.css delete mode 100644 packages/ai-mcp/src/browser/mcp-configuration-widget.spec.tsx delete mode 100644 packages/ai-mcp/src/browser/mcp-configuration-widget.tsx create mode 100644 packages/ai-mcp/src/browser/mcp-servers-configuration-category.spec.ts create mode 100644 packages/ai-mcp/src/browser/mcp-servers-configuration-category.tsx diff --git a/doc/Migration.md b/doc/Migration.md index d96414120ffbd..e5d9872dad76a 100644 --- a/doc/Migration.md +++ b/doc/Migration.md @@ -9,6 +9,16 @@ Please see the latest version (`master`) for the most up-to-date information. Pl ### General +_AI Configuration View_: + +The AI Configuration view (`@theia/ai-ide`) has been reworked from a tabbed dock panel into a master–detail tree driven by a new public contribution point, `AiConfigurationCategory`. + +- **New package dependency edges:** `@theia/ai-ide` and `@theia/ai-mcp` now depend on `@theia/ai-core-ui`, which hosts the contribution point (`AiConfigurationCategory`, `AiConfigurationCategoryRegistry`), the shared selection model (`AiConfigurationSelectionModel`), and the shared page primitives (`AiSettingsRow`, `AiConfigurationSection`, `SinglePageCategoryRenderer`, `CollectionCategoryRenderer`, …). If you consume the AI configuration UI, add `@theia/ai-core-ui` to your dependencies. +- **Removed widgets:** the per-tab widgets and their `WidgetFactory` registrations were removed — `AIAgentConfigurationWidget`, `AIVariableConfigurationWidget`, `ModelAliasesConfigurationWidget`, `AIToolsConfigurationWidget`, `AISkillsConfigurationWidget`, `AITokenUsageConfigurationWidget`, `AIPromptFragmentsConfigurationWidget` (all `@theia/ai-ide`) and `AIMCPConfigurationWidget` (`@theia/ai-mcp`). The dead base classes (`AIConfigurationBaseWidget`, `AICardGridConfigurationWidget`, `AI{List,Table,Hierarchical}ConfigurationWidget`) were removed as well. Each surface now lives in an `AiConfigurationCategory` contribution with a renderer built from the shared primitives. +- **Adding custom categories:** adopters that added their own AI configuration tabs should now register an `AiConfigurationCategory` (bind it to the `AiConfigurationCategory` service identifier). Set `contributed: true` to have it grouped under "Contributed by extensions". See `examples/api-samples` (`SampleChatToolbarConfigurationCategory`) for a minimal end-to-end example. +- **Selection:** category/item navigation now routes through `AiConfigurationSelectionModel` (`@theia/ai-core-ui`). `AIConfigurationSelectionService` (`@theia/ai-ide`) is retained for the agent/alias domain events it still carries. +- **Stable entry points:** the command ids `aiConfiguration:open` (`OPEN_AI_CONFIG_VIEW`) and `aiConfiguration:openTools` (`OPEN_AI_CONFIG_VIEW_TOOLS`) and the chat-view toolbar button are unchanged. `OPEN_AI_CONFIG_VIEW(tabId)` still accepts the legacy per-tab widget ids and maps them onto the corresponding category ids. + _ESBuild_: In addition to `webpack`, Theia is now also supporting [`ESBuild`](https://esbuild.github.io/) for bundling the application (frontend+backend). We will soon deprecate and then remove the `webpack` bundling option. Adopters can already use the ESBuild based bundler simply by deleting their `webpack.config.js`, which will automatically generate an `esbuild.mjs` file upon the next build. diff --git a/examples/api-samples/package.json b/examples/api-samples/package.json index d2c5594226eb3..ee18f56d6e80b 100644 --- a/examples/api-samples/package.json +++ b/examples/api-samples/package.json @@ -9,6 +9,7 @@ "@theia/ai-chat-ui": "1.74.0", "@theia/ai-code-completion": "1.74.0", "@theia/ai-core": "1.74.0", + "@theia/ai-core-ui": "1.74.0", "@theia/ai-mcp": "1.74.0", "@theia/ai-mcp-server": "1.74.0", "@theia/core": "1.74.0", diff --git a/examples/api-samples/src/browser/ai-configuration/sample-chat-toolbar-configuration-category.tsx b/examples/api-samples/src/browser/ai-configuration/sample-chat-toolbar-configuration-category.tsx new file mode 100644 index 0000000000000..aca97975648f0 --- /dev/null +++ b/examples/api-samples/src/browser/ai-configuration/sample-chat-toolbar-configuration-category.tsx @@ -0,0 +1,104 @@ +// ***************************************************************************** +// Copyright (C) 2026 EclipseSource and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { Emitter, Event, nls } from '@theia/core'; +import { codicon } from '@theia/core/lib/browser'; +import { DisposableCollection } from '@theia/core/lib/common'; +import { inject, injectable, interfaces, postConstruct } from '@theia/core/shared/inversify'; +import * as React from '@theia/core/shared/react'; +import { + AiConfigurationCategory, + AiConfigurationRenderContext, + AiConfigurationSearchItem, + AiConfigurationSearchProvider +} from '@theia/ai-core-ui/lib/browser/ai-configuration/ai-configuration-category'; +import { SinglePageCategoryRenderer } from '@theia/ai-core-ui/lib/browser/ai-configuration/renderers/single-page-category-renderer'; +import { AiConfigurationSection } from '@theia/ai-core-ui/lib/browser/ai-configuration/components/ai-configuration-primitives'; +import { AiSettingsRow } from '@theia/ai-core-ui/lib/browser/ai-configuration/components/ai-settings-row'; +import { AiSettingsRowService } from '@theia/ai-core-ui/lib/browser/ai-configuration/components/ai-settings-row-service'; +import { SAMPLE_CHAT_NODE_TOOLBAR_ENABLED_PREF } from '../chat/sample-chat-node-toolbar-preferences'; + +/** + * Sample `single-page` AI configuration category contributed by `@theia/api-samples`. It sets + * `contributed: true`, so it appears under "Contributed by extensions" in the AI Configuration view, and it + * surfaces the {@link SAMPLE_CHAT_NODE_TOOLBAR_ENABLED_PREF} setting that gates the sample chat-response + * action button — so toggling it here has a real, observable effect on the chat UI. It uses only the public + * {@link AiConfigurationCategory} contribution point and the shared primitives, proving an extension can add a + * category with no privileged access, render like the built-ins, and participate in deep search. + */ +@injectable() +export class SampleChatToolbarConfigurationCategory extends SinglePageCategoryRenderer implements AiConfigurationCategory, AiConfigurationSearchProvider { + + readonly id = 'sample-extension'; + readonly label = nls.localize('theia/api-samples/sampleExtension/label', 'API Samples'); + readonly description = nls.localize('theia/api-samples/sampleExtension/description', + 'Example settings contributed by the @theia/api-samples extension.'); + readonly iconClass = codicon('lightbulb'); + readonly kind = 'single-page' as const; + readonly contributed = true; + + @inject(AiSettingsRowService) + protected readonly settingsRowService: AiSettingsRowService; + + protected readonly onDidChangeEmitter = new Emitter(); + readonly onDidChange: Event = this.onDidChangeEmitter.event; + protected readonly toDispose = new DisposableCollection(this.onDidChangeEmitter); + + get renderer(): this { + return this; + } + + get search(): AiConfigurationSearchProvider { + return this; + } + + @postConstruct() + protected init(): void { + this.toDispose.push(this.settingsRowService.onPreferenceChanged(() => this.onDidChangeEmitter.fire())); + } + + dispose(): void { + this.toDispose.dispose(); + } + + protected renderSections(ctx: AiConfigurationRenderContext): React.ReactNode { + return + + ; + } + + getSearchItems(): AiConfigurationSearchItem[] { + const described = this.settingsRowService.describe(SAMPLE_CHAT_NODE_TOOLBAR_ENABLED_PREF); + return [{ + label: described.label ?? SAMPLE_CHAT_NODE_TOOLBAR_ENABLED_PREF, + typeLabel: nls.localizeByDefault('Setting'), + categoryId: this.id, + target: { categoryId: this.id, highlight: { rowId: SAMPLE_CHAT_NODE_TOOLBAR_ENABLED_PREF } }, + keywords: SAMPLE_CHAT_NODE_TOOLBAR_ENABLED_PREF + }]; + } +} + +export function bindSampleChatToolbarConfigurationCategory(bind: interfaces.Bind): void { + bind(SampleChatToolbarConfigurationCategory).toSelf().inSingletonScope(); + bind(AiConfigurationCategory).toService(SampleChatToolbarConfigurationCategory); +} diff --git a/examples/api-samples/src/browser/api-samples-frontend-module.ts b/examples/api-samples/src/browser/api-samples-frontend-module.ts index 7b1bd8a1be591..bd6ed7264b8e6 100644 --- a/examples/api-samples/src/browser/api-samples-frontend-module.ts +++ b/examples/api-samples/src/browser/api-samples-frontend-module.ts @@ -38,6 +38,8 @@ import { bindOriginalStateTestAgentContribution } from './chat/original-state-te import { bindCustomResponseContentRendererContribution } from './chat/custom-response-content-agent-contribution'; import { bindSampleChatCommandContribution } from './chat/sample-chat-command-contribution'; import { bindSampleCodeCompletionVariableContribution } from './ai-code-completion/sample-code-completion-variable-contribution'; +import { bindSampleChatNodeToolbarPreferences } from './chat/sample-chat-node-toolbar-preferences'; +import { bindSampleChatToolbarConfigurationCategory } from './ai-configuration/sample-chat-toolbar-configuration-category'; import { bindSamplePreferenceContribution } from './preferences/sample-preferences-contribution'; import { MCPFrontendContribution } from '@theia/ai-mcp-server/lib/browser/mcp-frontend-contribution'; import { SampleFrontendMCPContribution } from './mcp/sample-frontend-mcp-contribution'; @@ -73,6 +75,8 @@ export default new ContainerModule(( bindSampleFileSystemCapabilitiesCommands(bind); rebindOVSXClientFactory(rebind); bindSampleCodeCompletionVariableContribution(bind); + bindSampleChatNodeToolbarPreferences(bind); + bindSampleChatToolbarConfigurationCategory(bind); bindSamplePreferenceContribution(bind); bind(CommandContribution).to(TelemetrySampleCommandContribution).inSingletonScope(); bind(MCPFrontendContribution).to(SampleFrontendMCPContribution).inSingletonScope(); diff --git a/examples/api-samples/src/browser/chat/chat-node-toolbar-action-contribution.ts b/examples/api-samples/src/browser/chat/chat-node-toolbar-action-contribution.ts index f34a8a6044184..8d3527f14262c 100644 --- a/examples/api-samples/src/browser/chat/chat-node-toolbar-action-contribution.ts +++ b/examples/api-samples/src/browser/chat/chat-node-toolbar-action-contribution.ts @@ -22,20 +22,35 @@ import { RequestNode, ResponseNode } from '@theia/ai-chat-ui/lib/browser/chat-tree-view'; +import { Emitter, PreferenceService } from '@theia/core/lib/common'; import { interfaces } from '@theia/core/shared/inversify'; +import { SAMPLE_CHAT_NODE_TOOLBAR_ENABLED_PREF } from './sample-chat-node-toolbar-preferences'; export function bindChatNodeToolbarActionContribution(bind: interfaces.Bind): void { - bind(ChatNodeToolbarActionContribution).toDynamicValue(context => ({ - getToolbarActions: (args: RequestNode | ResponseNode) => { - if (isResponseNode(args)) { - return [{ - commandId: 'sample-command', - icon: 'codicon codicon-feedback', - tooltip: 'API Samples: Example command' - }]; - } else { + bind(ChatNodeToolbarActionContribution).toDynamicValue(context => { + const preferences = context.container.get(PreferenceService); + // Fire `onDidChange` when the gating setting is toggled, so the chat view re-renders its node + // toolbars live (rather than only picking up the change on the next response). + const onDidChangeEmitter = new Emitter(); + preferences.onPreferenceChanged(event => { + if (event.preferenceName === SAMPLE_CHAT_NODE_TOOLBAR_ENABLED_PREF) { + onDidChangeEmitter.fire(); + } + }); + return { + onDidChange: onDidChangeEmitter.event, + getToolbarActions: (args: RequestNode | ResponseNode) => { + // Gated by a contributed setting (surfaced in the AI Configuration view): when disabled, the + // sample action is not rendered on response nodes. + if (isResponseNode(args) && preferences.get(SAMPLE_CHAT_NODE_TOOLBAR_ENABLED_PREF, true)) { + return [{ + commandId: 'sample-command', + icon: 'codicon codicon-feedback', + tooltip: 'API Samples: Example command' + }]; + } return []; } - } - })); + }; + }); } diff --git a/examples/api-samples/src/browser/chat/sample-chat-node-toolbar-preferences.ts b/examples/api-samples/src/browser/chat/sample-chat-node-toolbar-preferences.ts new file mode 100644 index 0000000000000..1deb7f4c99736 --- /dev/null +++ b/examples/api-samples/src/browser/chat/sample-chat-node-toolbar-preferences.ts @@ -0,0 +1,43 @@ +// ***************************************************************************** +// Copyright (C) 2026 EclipseSource and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { nls, PreferenceContribution } from '@theia/core'; +import { interfaces } from '@theia/core/shared/inversify'; +import { PreferenceSchema } from '@theia/core/lib/common/preferences/preference-schema'; + +/** + * Whether the API Samples extension shows its example action button on chat response nodes + * (see {@link bindChatNodeToolbarActionContribution}). Surfaced as a contributed setting in the AI + * Configuration view so toggling it has a real, observable effect on the chat UI. + */ +export const SAMPLE_CHAT_NODE_TOOLBAR_ENABLED_PREF = 'sampleChatNodeToolbar.enabled'; + +export const sampleChatNodeToolbarPreferenceSchema: PreferenceSchema = { + properties: { + [SAMPLE_CHAT_NODE_TOOLBAR_ENABLED_PREF]: { + type: 'boolean', + title: nls.localize('theia/api-samples/sampleChatNodeToolbar/enabled/title', 'Show Sample Chat Response Action'), + markdownDescription: nls.localize('theia/api-samples/sampleChatNodeToolbar/enabled/description', + 'When enabled, the `@theia/api-samples` extension adds an example action button to each chat response. ' + + 'Turn it off to hide that sample action.'), + default: true + } + } +}; + +export function bindSampleChatNodeToolbarPreferences(bind: interfaces.Bind): void { + bind(PreferenceContribution).toConstantValue({ schema: sampleChatNodeToolbarPreferenceSchema }); +} diff --git a/examples/api-samples/tsconfig.json b/examples/api-samples/tsconfig.json index 2bc7c1f7bbddc..b3368ad2b5479 100644 --- a/examples/api-samples/tsconfig.json +++ b/examples/api-samples/tsconfig.json @@ -24,6 +24,9 @@ { "path": "../../packages/ai-core" }, + { + "path": "../../packages/ai-core-ui" + }, { "path": "../../packages/ai-mcp" }, diff --git a/package-lock.json b/package-lock.json index 290169a40ad05..ee2e11b92dedb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -498,6 +498,7 @@ "@theia/ai-chat-ui": "1.74.0", "@theia/ai-code-completion": "1.74.0", "@theia/ai-core": "1.74.0", + "@theia/ai-core-ui": "1.74.0", "@theia/ai-mcp": "1.74.0", "@theia/ai-mcp-server": "1.74.0", "@theia/core": "1.74.0", @@ -31975,6 +31976,7 @@ "dependencies": { "@theia/ai-core": "1.74.0", "@theia/core": "1.74.0", + "@theia/preferences": "1.74.0", "tslib": "^2.8.1" }, "devDependencies": { @@ -32051,6 +32053,7 @@ "@theia/ai-chat": "1.74.0", "@theia/ai-chat-ui": "1.74.0", "@theia/ai-core": "1.74.0", + "@theia/ai-core-ui": "1.74.0", "@theia/ai-mcp": "1.74.0", "@theia/ai-terminal": "1.74.0", "@theia/core": "1.74.0", @@ -32109,6 +32112,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@theia/ai-core": "1.74.0", + "@theia/ai-core-ui": "1.74.0", "@theia/core": "1.74.0", "@theia/workspace": "1.74.0" }, diff --git a/packages/ai-anthropic/src/common/anthropic-preferences.ts b/packages/ai-anthropic/src/common/anthropic-preferences.ts index 3527715700265..f973d1583dc92 100644 --- a/packages/ai-anthropic/src/common/anthropic-preferences.ts +++ b/packages/ai-anthropic/src/common/anthropic-preferences.ts @@ -14,7 +14,9 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { AI_CORE_PREFERENCES_TITLE, PREFERENCE_NAME_SERVER_SIDE_COMPACTION } from '@theia/ai-core/lib/common/ai-core-preferences'; +import { + AI_CORE_PREFERENCES_TITLE, MODEL_PROVIDER_TYPE_DETAIL, ModelProviderTypeDetail, PREFERENCE_NAME_SERVER_SIDE_COMPACTION +} from '@theia/ai-core/lib/common/ai-core-preferences'; import { SERVER_SIDE_COMPACTION_TOKEN_THRESHOLD_MINIMUM } from '@theia/ai-core/lib/common/language-model'; import { LINUX_ENV_HINT, nls, PreferenceSchema } from '@theia/core'; @@ -28,6 +30,7 @@ export const AnthropicPreferencesSchema: PreferenceSchema = { properties: { [API_KEY_PREF]: { type: 'string', + typeDetails: { [MODEL_PROVIDER_TYPE_DETAIL]: { label: 'Anthropic' } satisfies ModelProviderTypeDetail }, markdownDescription: nls.localize('theia/ai/anthropic/apiKey/description', 'Enter an API Key of your official Anthropic Account. **Please note:** By using this preference the Anthropic API key will be stored in clear text\ on the machine running Theia. Use the environment variable `ANTHROPIC_API_KEY` to set the key securely.') + LINUX_ENV_HINT, @@ -77,6 +80,9 @@ export const AnthropicPreferencesSchema: PreferenceSchema = { }, [CUSTOM_ENDPOINTS_PREF]: { type: 'array', + typeDetails: { + [MODEL_PROVIDER_TYPE_DETAIL]: { label: nls.localize('theia/ai/anthropic/customProvider/label', '{0} (Custom)', 'Anthropic') } satisfies ModelProviderTypeDetail + }, title: AI_CORE_PREFERENCES_TITLE, markdownDescription: nls.localize('theia/ai/anthropic/customEndpoints/mdDescription', 'Integrate custom models compatible with the Anthropic API. The required attributes are `model` and `url`.\ diff --git a/packages/ai-chat-ui/src/browser/chat-input-widget.tsx b/packages/ai-chat-ui/src/browser/chat-input-widget.tsx index c0b0ef3f1a7ba..5bb02019835ed 100644 --- a/packages/ai-chat-ui/src/browser/chat-input-widget.tsx +++ b/packages/ai-chat-ui/src/browser/chat-input-widget.tsx @@ -31,7 +31,7 @@ import { ImageContextVariable } from '@theia/ai-chat/lib/common/image-context-va import { FrontendVariableService, AIActivationService } from '@theia/ai-core/lib/browser'; import { AISettingsService, PromptService } from '@theia/ai-core/lib/common'; import { CommandService, DisposableCollection, Emitter, InMemoryResources, MessageService, URI, nls, Disposable, ILogger } from '@theia/core'; -import { CommonCommands, ContextMenuRenderer, HoverService, LabelProvider, Message, OpenerService, ReactWidget } from '@theia/core/lib/browser'; +import { ContextMenuRenderer, HoverService, LabelProvider, Message, OpenerService, ReactWidget } from '@theia/core/lib/browser'; import { MarkdownString } from '@theia/core/lib/common/markdown-rendering'; import { SelectComponent, SelectOption } from '@theia/core/lib/browser/widgets/select-component'; import { ContextKey, ContextKeyService } from '@theia/core/lib/browser/context-key-service'; @@ -1221,8 +1221,10 @@ export class AIChatInputWidget extends ReactWidget { console.error(`Failed to execute '${AI_CHAT_HOME.id}' from token usage warning`, error); }); } else if (selected === openSettingsAction) { - this.commandService.executeCommand(CommonCommands.OPEN_PREFERENCES.id, CHAT_VIEW_TOKEN_USAGE_WARNING_THRESHOLD_PERCENTAGE).catch(error => { - console.error(`Failed to execute '${CommonCommands.OPEN_PREFERENCES.id}' from token usage warning`, error); + // The threshold preference lives in the AI Configuration view (AI preferences are hidden from + // the Settings UI). Referenced by command id because @theia/ai-chat-ui cannot depend on @theia/ai-ide. + this.commandService.executeCommand('aiConfiguration:open', CHAT_VIEW_TOKEN_USAGE_WARNING_THRESHOLD_PERCENTAGE).catch(error => { + console.error('Failed to open the AI Configuration view from the token usage warning', error); }); } } diff --git a/packages/ai-chat-ui/src/browser/chat-node-toolbar-action-contribution.ts b/packages/ai-chat-ui/src/browser/chat-node-toolbar-action-contribution.ts index dc37c475f6b95..a8c7feb37448a 100644 --- a/packages/ai-chat-ui/src/browser/chat-node-toolbar-action-contribution.ts +++ b/packages/ai-chat-ui/src/browser/chat-node-toolbar-action-contribution.ts @@ -13,7 +13,7 @@ // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { Command, nls } from '@theia/core'; +import { Command, Event, nls } from '@theia/core'; import { codicon } from '@theia/core/lib/browser'; import { isRequestNode, RequestNode, ResponseNode } from './chat-tree-view'; import { EditableChatRequestModel } from '@theia/ai-chat'; @@ -63,6 +63,12 @@ export interface ChatNodeToolbarActionContribution { * Returns the toolbar actions for the given node. */ getToolbarActions(node: RequestNode | ResponseNode): ChatNodeToolbarAction[]; + /** + * Optional event fired when the contributed actions may have changed (e.g. because a setting that gates + * them was toggled). The chat view listens to it and re-renders the node toolbars, so contributions can + * update live without waiting for the next response. + */ + onDidChange?: Event; } export namespace ChatNodeToolbarCommands { diff --git a/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-widget.tsx b/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-widget.tsx index 0d62af9806f96..9a00a03ec2ad3 100644 --- a/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-widget.tsx +++ b/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-widget.tsx @@ -234,6 +234,13 @@ export class ChatViewTreeWidget extends TreeWidget { } } + // Re-render node toolbars when a contribution signals its actions changed (e.g. a gating setting toggled). + for (const contribution of this.chatNodeToolbarActionContributions.getContributions()) { + if (contribution.onDidChange) { + this.toDispose.push(contribution.onDidChange(() => this.update())); + } + } + } public setEnabled(enabled: boolean): void { diff --git a/packages/ai-chat/src/common/ai-chat-preferences.ts b/packages/ai-chat/src/common/ai-chat-preferences.ts index 51dc3de6c5a3a..f40acbd542a11 100644 --- a/packages/ai-chat/src/common/ai-chat-preferences.ts +++ b/packages/ai-chat/src/common/ai-chat-preferences.ts @@ -72,6 +72,10 @@ export const aiChatPreferences: PreferenceSchema = { [SESSION_STORAGE_PREF]: { type: 'string', enum: ['workspace', 'global'] satisfies SessionStorageScope[], + enumItemLabels: [ + nls.localizeByDefault('Workspace'), + nls.localizeByDefault('Global') + ], enumDescriptions: [ nls.localize('theia/ai/chat/sessionStorageScope/workspace', 'Store chat sessions in workspace-specific metadata storage. Sessions are associated with the workspace but stored outside the workspace directory.'), diff --git a/packages/ai-chat/src/common/chat-tool-preferences.ts b/packages/ai-chat/src/common/chat-tool-preferences.ts index ea93aca74cc13..85d5adee03098 100644 --- a/packages/ai-chat/src/common/chat-tool-preferences.ts +++ b/packages/ai-chat/src/common/chat-tool-preferences.ts @@ -57,13 +57,13 @@ export const TOOL_CONFIRMATION_PREFERENCE = 'ai-features.chat.toolConfirmation'; export const DEFAULT_TOOL_CONFIRMATION_PREFERENCE = 'ai-features.chat.defaultToolConfirmation'; export const TOOL_CONFIRMATION_TIMEOUT_PREFERENCE = 'ai-features.chat.toolConfirmationTimeout'; -const TOOL_CONFIRMATION_MODE_VALUES = [ +export const TOOL_CONFIRMATION_MODE_VALUES = [ ToolConfirmationMode.ALWAYS_ALLOW, ToolConfirmationMode.CONFIRM, ToolConfirmationMode.DISABLED ]; -const TOOL_CONFIRMATION_MODE_DESCRIPTIONS = [ +export const TOOL_CONFIRMATION_MODE_DESCRIPTIONS = [ nls.localize('theia/ai/chat/toolConfirmation/alwaysAllow/description', 'Execute tools automatically without confirmation'), nls.localize('theia/ai/chat/toolConfirmation/confirm/description', 'Ask for confirmation before executing tools'), nls.localize('theia/ai/chat/toolConfirmation/disabled/description', 'Disable tool execution') diff --git a/packages/ai-chat/src/common/context-summary-variable.ts b/packages/ai-chat/src/common/context-summary-variable.ts index 2c3537ba8a4c8..0ff08c9746c74 100644 --- a/packages/ai-chat/src/common/context-summary-variable.ts +++ b/packages/ai-chat/src/common/context-summary-variable.ts @@ -22,7 +22,8 @@ import { ChatSessionContext } from './chat-agents'; export const CONTEXT_SUMMARY_VARIABLE: AIVariable = { id: 'contextSummary', - description: nls.localize('theia/ai/core/contextSummaryVariable/description', 'Describes files in the context for a given session.'), + description: nls.localize('theia/ai/core/contextSummaryVariable/description', + 'A JSON summary of every element in the current chat session context, listing each element\'s type and identifiers.'), name: 'contextSummary', }; diff --git a/packages/ai-claude-code/src/common/claude-code-preferences.ts b/packages/ai-claude-code/src/common/claude-code-preferences.ts index 34b068ec1676e..9691d318e6f4a 100644 --- a/packages/ai-claude-code/src/common/claude-code-preferences.ts +++ b/packages/ai-claude-code/src/common/claude-code-preferences.ts @@ -14,7 +14,7 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { AI_CORE_PREFERENCES_TITLE } from '@theia/ai-core/lib/common/ai-core-preferences'; +import { AI_CORE_PREFERENCES_TITLE, MODEL_PROVIDER_TYPE_DETAIL, ModelProviderTypeDetail } from '@theia/ai-core/lib/common/ai-core-preferences'; import { LINUX_ENV_HINT, nls, PreferenceSchema } from '@theia/core'; export const CLAUDE_CODE_EXECUTABLE_PATH_PREF = 'ai-features.claudeCode.executablePath'; @@ -24,6 +24,7 @@ export const ClaudeCodePreferencesSchema: PreferenceSchema = { properties: { [CLAUDE_CODE_EXECUTABLE_PATH_PREF]: { type: 'string', + typeDetails: { [MODEL_PROVIDER_TYPE_DETAIL]: { label: 'Claude Code' } satisfies ModelProviderTypeDetail }, markdownDescription: nls.localize('theia/ai/claude-code/executablePath/description', 'Path to the Claude Code executable (cli.js) of the `@anthropic-ai/claude-agent-sdk`.' + 'If not specified, the system will attempt to resolve the path automatically ' + diff --git a/packages/ai-codex/src/common/codex-preferences.ts b/packages/ai-codex/src/common/codex-preferences.ts index 5beb3f389e9f2..98e70e189b2a8 100644 --- a/packages/ai-codex/src/common/codex-preferences.ts +++ b/packages/ai-codex/src/common/codex-preferences.ts @@ -14,7 +14,7 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { AI_CORE_PREFERENCES_TITLE } from '@theia/ai-core/lib/common/ai-core-preferences'; +import { AI_CORE_PREFERENCES_TITLE, MODEL_PROVIDER_TYPE_DETAIL, ModelProviderTypeDetail } from '@theia/ai-core/lib/common/ai-core-preferences'; import { nls, PreferenceSchema } from '@theia/core'; export const CODEX_API_KEY_PREF = 'ai-features.codex.apiKey'; @@ -23,6 +23,7 @@ export const CodexPreferencesSchema: PreferenceSchema = { properties: { [CODEX_API_KEY_PREF]: { type: 'string', + typeDetails: { [MODEL_PROVIDER_TYPE_DETAIL]: { label: 'Codex' } satisfies ModelProviderTypeDetail }, markdownDescription: nls.localize('theia/ai/codex/apiKey/description', 'OpenAI API key for Codex. If not set, falls back to the shared OpenAI API key (`ai-features.openAiOfficial.openAiApiKey`). ' + 'Can also be set via `OPENAI_API_KEY` environment variable.'), diff --git a/packages/ai-copilot/src/common/copilot-preferences.ts b/packages/ai-copilot/src/common/copilot-preferences.ts index 1c9214e1e8509..56edaeedb8766 100644 --- a/packages/ai-copilot/src/common/copilot-preferences.ts +++ b/packages/ai-copilot/src/common/copilot-preferences.ts @@ -14,7 +14,7 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { AI_CORE_PREFERENCES_TITLE } from '@theia/ai-core/lib/common/ai-core-preferences'; +import { AI_CORE_PREFERENCES_TITLE, MODEL_PROVIDER_TYPE_DETAIL, ModelProviderTypeDetail } from '@theia/ai-core/lib/common/ai-core-preferences'; import { nls, PreferenceSchema } from '@theia/core'; export const COPILOT_ENABLED_PREF = 'ai-features.copilot.enabled'; @@ -25,6 +25,7 @@ export const CopilotPreferencesSchema: PreferenceSchema = { properties: { [COPILOT_ENABLED_PREF]: { type: 'boolean', + typeDetails: { [MODEL_PROVIDER_TYPE_DETAIL]: { label: 'GitHub Copilot' } satisfies ModelProviderTypeDetail }, markdownDescription: nls.localize('theia/ai/copilot/enabled/mdDescription', 'Enable the GitHub Copilot provider. When enabled, a status bar entry ' + 'appears for authentication and available models are discovered from your Copilot subscription.'), diff --git a/packages/ai-core-ui/package.json b/packages/ai-core-ui/package.json index 201109af505b7..f1c63fe47f6eb 100644 --- a/packages/ai-core-ui/package.json +++ b/packages/ai-core-ui/package.json @@ -5,6 +5,7 @@ "dependencies": { "@theia/core": "1.74.0", "@theia/ai-core": "1.74.0", + "@theia/preferences": "1.74.0", "tslib": "^2.8.1" }, "main": "lib/common", diff --git a/packages/ai-core-ui/src/browser/ai-configuration/ai-configuration-category-registry.spec.ts b/packages/ai-core-ui/src/browser/ai-configuration/ai-configuration-category-registry.spec.ts new file mode 100644 index 0000000000000..c8966c2dee942 --- /dev/null +++ b/packages/ai-core-ui/src/browser/ai-configuration/ai-configuration-category-registry.spec.ts @@ -0,0 +1,137 @@ +// ***************************************************************************** +// Copyright (C) 2026 EclipseSource and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import 'reflect-metadata'; + +import { expect } from 'chai'; +import { Emitter } from '@theia/core'; +import { Container, ContainerModule, inject, injectable } from '@theia/core/shared/inversify'; +import { bindContributionProvider } from '@theia/core/lib/common'; +import { AiConfigurationCategory, AiConfigurationSearchItem } from './ai-configuration-category'; +import { AiConfigurationCategoryRegistry } from './ai-configuration-category-registry'; + +class DummyCategory implements AiConfigurationCategory { + readonly renderer = {}; + protected readonly onDidChangeEmitter = new Emitter(); + readonly onDidChange = this.onDidChangeEmitter.event; + constructor( + readonly id: string, + readonly order?: number, + readonly contributed?: boolean, + protected readonly searchItems: AiConfigurationSearchItem[] = [] + ) { } + readonly label = this.id; + readonly iconClass = 'codicon codicon-gear'; + readonly kind = 'single-page' as const; + get search(): { getSearchItems(): AiConfigurationSearchItem[] } | undefined { + return this.searchItems.length ? { getSearchItems: () => this.searchItems } : undefined; + } + fireChange(): void { + this.onDidChangeEmitter.fire(); + } +} + +describe('AiConfigurationCategoryRegistry', () => { + + function createRegistry(...categories: AiConfigurationCategory[]): AiConfigurationCategoryRegistry { + const container = new Container(); + container.load(new ContainerModule(bind => { + bindContributionProvider(bind, AiConfigurationCategory); + categories.forEach(category => bind(AiConfigurationCategory).toConstantValue(category)); + bind(AiConfigurationCategoryRegistry).toSelf().inSingletonScope(); + })); + return container.get(AiConfigurationCategoryRegistry); + } + + it('resolves to an empty list when nothing is contributed', () => { + const registry = createRegistry(); + expect(registry.getCategories()).to.deep.equal([]); + }); + + it('aggregates an empty search index when nothing is contributed', () => { + const registry = createRegistry(); + expect(registry.getSearchItems()).to.deep.equal([]); + }); + + it('orders categories ascending by order', () => { + const registry = createRegistry( + new DummyCategory('c', 300), + new DummyCategory('a', 100), + new DummyCategory('b', 200) + ); + expect(registry.getCategories().map(c => c.id)).to.deep.equal(['a', 'b', 'c']); + }); + + it('sorts contributed categories after all built-ins regardless of order', () => { + const registry = createRegistry( + new DummyCategory('ext', 1, true), + new DummyCategory('builtin', 900) + ); + expect(registry.getCategories().map(c => c.id)).to.deep.equal(['builtin', 'ext']); + }); + + it('resolves a category by id', () => { + const registry = createRegistry(new DummyCategory('a', 100), new DummyCategory('b', 200)); + expect(registry.getCategory('b')?.id).to.equal('b'); + expect(registry.getCategory('missing')).to.equal(undefined); + }); + + it('aggregates the search index across categories that provide one', () => { + const item = (label: string, categoryId: string): AiConfigurationSearchItem => + ({ label, typeLabel: 'Setting', categoryId, target: { categoryId } }); + const registry = createRegistry( + new DummyCategory('a', 100, false, [item('a1', 'a')]), + new DummyCategory('b', 200), + new DummyCategory('c', 300, false, [item('c1', 'c'), item('c2', 'c')]) + ); + expect(registry.getSearchItems().map(i => i.label)).to.deep.equal(['a1', 'c1', 'c2']); + }); + + it('refires onDidChange when a contributed category changes', () => { + const category = new DummyCategory('a', 100); + const registry = createRegistry(category); + let fired = 0; + registry.onDidChange(() => fired++); + category.fireChange(); + expect(fired).to.equal(1); + }); + + it('resolves without recursion when a contributed category injects the registry', () => { + // A category may inject the registry (e.g. to navigate across categories). The registry must not + // resolve its contributions during its own construction, or that closes a dependency cycle. + @injectable() + class RegistryInjectingCategory implements AiConfigurationCategory { + readonly id = 'self'; + readonly label = 'self'; + readonly iconClass = 'codicon codicon-gear'; + readonly kind = 'single-page' as const; + readonly renderer = {}; + @inject(AiConfigurationCategoryRegistry) + readonly registry: AiConfigurationCategoryRegistry; + } + const container = new Container(); + container.load(new ContainerModule(bind => { + bindContributionProvider(bind, AiConfigurationCategory); + bind(RegistryInjectingCategory).toSelf().inSingletonScope(); + bind(AiConfigurationCategory).toService(RegistryInjectingCategory); + bind(AiConfigurationCategoryRegistry).toSelf().inSingletonScope(); + })); + const registry = container.get(AiConfigurationCategoryRegistry); + // Listening wires the aggregation, which resolves the contribution that injects this very registry. + expect(() => registry.onDidChange(() => { })).to.not.throw(); + expect(registry.getCategories().map(category => category.id)).to.deep.equal(['self']); + }); +}); diff --git a/packages/ai-core-ui/src/browser/ai-configuration/ai-configuration-category-registry.ts b/packages/ai-core-ui/src/browser/ai-configuration/ai-configuration-category-registry.ts new file mode 100644 index 0000000000000..6d51d755c011a --- /dev/null +++ b/packages/ai-core-ui/src/browser/ai-configuration/ai-configuration-category-registry.ts @@ -0,0 +1,79 @@ +// ***************************************************************************** +// Copyright (C) 2026 EclipseSource and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { ContributionProvider, Emitter, Event } from '@theia/core'; +import { inject, injectable, named } from '@theia/core/shared/inversify'; +import { AiConfigurationCategory, AiConfigurationSearchItem } from './ai-configuration-category'; + +/** + * Collects all contributed {@link AiConfigurationCategory} instances and exposes + * them ordered for the AI Configuration view. Refires an aggregated + * {@link onDidChange} whenever any category changes. + */ +@injectable() +export class AiConfigurationCategoryRegistry { + + @inject(ContributionProvider) @named(AiConfigurationCategory) + protected readonly contributions: ContributionProvider; + + protected readonly onDidChangeEmitter = new Emitter(); + /** + * Wires the aggregation lazily on first listen rather than in `@postConstruct`: a category may itself + * inject this registry (e.g. to navigate across categories), so resolving all contributions during the + * registry's own construction would create a dependency cycle. + */ + readonly onDidChange: Event = (listener, thisArgs, disposables) => { + this.wire(); + return this.onDidChangeEmitter.event(listener, thisArgs, disposables); + }; + + protected wired = false; + + /** Subscribes once to every category's own change event so this registry can refire an aggregated one. */ + protected wire(): void { + if (this.wired) { + return; + } + this.wired = true; + for (const category of this.contributions.getContributions()) { + category.onDidChange?.(() => this.onDidChangeEmitter.fire()); + } + } + + /** + * Returns the categories ordered ascending by {@link AiConfigurationCategory.order} + * (defaulting to `0`); `contributed` categories always sort after built-ins. + */ + getCategories(): AiConfigurationCategory[] { + return this.contributions.getContributions().slice().sort((a, b) => { + const contributedA = a.contributed ? 1 : 0; + const contributedB = b.contributed ? 1 : 0; + if (contributedA !== contributedB) { + return contributedA - contributedB; + } + return (a.order ?? 0) - (b.order ?? 0); + }); + } + + getCategory(id: string): AiConfigurationCategory | undefined { + return this.contributions.getContributions().find(category => category.id === id); + } + + /** Aggregated deep-search index across all categories that provide one. */ + getSearchItems(): AiConfigurationSearchItem[] { + return this.getCategories().flatMap(category => category.search?.getSearchItems() ?? []); + } +} diff --git a/packages/ai-core-ui/src/browser/ai-configuration/ai-configuration-category.ts b/packages/ai-core-ui/src/browser/ai-configuration/ai-configuration-category.ts new file mode 100644 index 0000000000000..d46842bbdc0d5 --- /dev/null +++ b/packages/ai-core-ui/src/browser/ai-configuration/ai-configuration-category.ts @@ -0,0 +1,194 @@ +// ***************************************************************************** +// Copyright (C) 2026 EclipseSource and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { Event } from '@theia/core'; + +/** + * Contribution point for a category shown in the AI Configuration view. + * + * Both built-in and externally contributed categories use this identical path; + * there is no privileged API. Categories are collected via a + * {@link ContributionProvider} and ordered by {@link AiConfigurationCategory.order} + * by the {@link AiConfigurationCategoryRegistry}. + */ +export const AiConfigurationCategory = Symbol('AiConfigurationCategory'); + +/** + * A `single-page` category renders one detail page; a `collection` category + * exposes tree children and renders an overview plus (optionally) per-item detail. + */ +export type AiConfigurationCategoryKind = 'single-page' | 'collection'; + +/** + * Spaced ordering scale for the built-in categories, leaving room for + * future built-ins and extensions to slot in between. Assigned to + * {@link AiConfigurationCategory.order}. Categories with `contributed: true` + * always sort after all built-ins, regardless of their `order`. + */ +export namespace AiConfigurationCategoryOrder { + export const GENERAL = 100; + export const MODELS = 200; + export const MODEL_ALIASES = 300; + export const AGENTS = 400; + export const PROMPTS_AND_SKILLS = 500; + export const VARIABLES = 600; + export const MCP_SERVERS = 700; + export const TOOLS = 800; + export const TOKEN_USAGE = 900; +} + +/** + * Canonical ids of the built-in categories. Shared so that the categories + * (contributed from `ai-ide` and `ai-mcp`), navigation targets, and the legacy + * `OPEN_AI_CONFIG_VIEW` tab-id mapping all reference the same stable strings. + */ +export namespace AiConfigurationCategoryId { + export const GENERAL = 'general'; + export const MODELS = 'models'; + export const MODEL_ALIASES = 'model-aliases'; + export const AGENTS = 'agents'; + export const PROMPTS_AND_SKILLS = 'prompts-and-skills'; + export const VARIABLES = 'variables'; + export const MCP_SERVERS = 'mcp-servers'; + export const TOOLS = 'tools'; + export const TOKEN_USAGE = 'token-usage'; +} + +/** + * The scope a category's settings are read from and written to. + * + * Render-only in this iteration; per-scope read/write behaviour is wired later. + */ +export type AiConfigurationScope = 'user' | 'workspace' | 'folder'; + +export interface AiConfigurationCategory { + /** Stable id; used as the tree-node id and in navigation targets. */ + readonly id: string; + /** Localized label shown in the tree and breadcrumb. */ + readonly label: string; + /** Optional one-line description shown under the category name in the detail-page header. */ + readonly description?: string; + /** `codicon(...)` class shown in the tree, breadcrumb, and cards. */ + readonly iconClass: string; + /** Ascending sort; defaults to `0`. Built-ins use a spaced scale. */ + readonly order?: number; + readonly kind: AiConfigurationCategoryKind; + /** When `true`, grouped under "Contributed by extensions". Built-ins omit or set `false`. */ + readonly contributed?: boolean; + + readonly renderer: AiConfigurationCategoryRenderer; + /** Optional: items contributed to the tree-level (deep) search index. */ + readonly search?: AiConfigurationSearchProvider; + + /** Fire to refresh tree children/count badge, overview, and search index. */ + readonly onDidChange?: Event; + dispose?(): void; +} + +/** + * Produces the React nodes that drop into the detail-pane primitives. + * + * A `single-page` category implements {@link renderPage}; a `collection` + * category implements {@link getTreeChildren} + {@link renderOverview} and, + * optionally, {@link renderItemDetail}. + */ +export interface AiConfigurationCategoryRenderer { + /** Single-page body (e.g. General, Models, Prompts & Skills, Tools, Token Usage). */ + renderPage?(ctx: AiConfigurationRenderContext): React.ReactNode; + + /** + * Collection overview (category node selected): category-level settings + + * filter slot + item card grid + add action + empty state. + */ + renderOverview?(ctx: AiConfigurationRenderContext): React.ReactNode; + + /** + * Collection children (items) shown inline in the tree and mirrored as + * overview cards. Order is preserved. + */ + getTreeChildren?(): AiConfigurationTreeItem[]; + + /** + * Collection item detail (child selected). `itemId` is a {@link getTreeChildren} id. + * Optional: e.g. Variables have no editable detail (read-only card only). + */ + renderItemDetail?(itemId: string, ctx: AiConfigurationRenderContext): React.ReactNode; + + /** + * The `ai-features.*` preference ids this category surfaces or manages. Used by the catch-all + * "Advanced" category to render only the preferences no dedicated category already covers, so that + * every AI preference stays reachable (AI preferences are hidden from the Settings UI) without being + * shown twice. Best-effort: a missing id merely shows up in the catch-all too, it is never lost. + */ + getOwnedPreferenceIds?(): string[]; +} + +export interface AiConfigurationTreeItem { + readonly id: string; + readonly label: string; + /** Status badge shown on the overview card and the item detail header. */ + readonly status?: AiConfigurationItemStatus; + /** Short text for the overview card. */ + readonly description?: string; + /** Overrides the category icon for this item, e.g. a per-agent icon. */ + readonly iconClass?: string; +} + +export interface AiConfigurationItemStatus { + readonly kind: 'on' | 'off' | 'warn' | 'error'; + /** Short, human-readable badge text, e.g. "Enabled", "Ready". */ + readonly label: string; + /** Optional longer hover detail; falls back to {@link label}. */ + readonly tooltip?: string; +} + +export interface AiConfigurationSearchProvider { + /** + * Called to (re)build this category's slice of the deep-search index. + * Rebuilt on the category's `onDidChange`. + */ + getSearchItems(): AiConfigurationSearchItem[]; +} + +export interface AiConfigurationSearchItem { + readonly label: string; + /** Small type badge, e.g. "MCP server", "Prompt", "Setting". */ + readonly typeLabel: string; + readonly categoryId: string; + /** Where selecting the result navigates. */ + readonly target: AiConfigurationSelection; + /** Extra text folded into the match key (ids, descriptions). */ + readonly keywords?: string; +} + +export interface AiConfigurationSelection { + readonly categoryId: string; + /** `undefined` => single-page body / collection overview. */ + readonly itemId?: string; + /** Optional row/setting to scroll-to and flash after navigating. */ + readonly highlight?: { readonly rowId: string; readonly subId?: string }; +} + +export interface AiConfigurationRenderContext { + /** Render-only in this iteration; per-scope read/write is wired later. */ + readonly scope: AiConfigurationScope; + /** Per-view filter text; slot only in this iteration. */ + readonly filter: string; + /** Select another node (used by cards, links, "used by" chips). */ + navigate(selection: AiConfigurationSelection): void; + /** Ask the detail host to re-render the current category body. */ + update(): void; +} diff --git a/packages/ai-core-ui/src/browser/ai-configuration/ai-configuration-selection-model.ts b/packages/ai-core-ui/src/browser/ai-configuration/ai-configuration-selection-model.ts new file mode 100644 index 0000000000000..866106d63d8fd --- /dev/null +++ b/packages/ai-core-ui/src/browser/ai-configuration/ai-configuration-selection-model.ts @@ -0,0 +1,42 @@ +// ***************************************************************************** +// Copyright (C) 2026 EclipseSource and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { Emitter, Event } from '@theia/core'; +import { injectable } from '@theia/core/shared/inversify'; +import { AiConfigurationSelection } from './ai-configuration-category'; + +/** + * Shared selection bus for the AI Configuration view. The tree, the detail host, + * and renderers navigate through this single model instead of talking directly + * to each other. + */ +@injectable() +export class AiConfigurationSelectionModel { + + protected selection: AiConfigurationSelection | undefined; + + protected readonly onDidChangeSelectionEmitter = new Emitter(); + readonly onDidChangeSelection: Event = this.onDidChangeSelectionEmitter.event; + + getSelection(): AiConfigurationSelection | undefined { + return this.selection; + } + + select(selection: AiConfigurationSelection | undefined): void { + this.selection = selection; + this.onDidChangeSelectionEmitter.fire(selection); + } +} diff --git a/packages/ai-core-ui/src/browser/ai-configuration/ai-configuration-setting-commands.spec.ts b/packages/ai-core-ui/src/browser/ai-configuration/ai-configuration-setting-commands.spec.ts new file mode 100644 index 0000000000000..c6df3b342432a --- /dev/null +++ b/packages/ai-core-ui/src/browser/ai-configuration/ai-configuration-setting-commands.spec.ts @@ -0,0 +1,82 @@ +// ***************************************************************************** +// Copyright (C) 2026 EclipseSource and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { expect } from 'chai'; +import { Command, CommandRegistry, PreferenceScope } from '@theia/core'; +import { + AiConfigurationSettingCommandArgs, + AiConfigurationSettingCommandContribution, + AiConfigurationSettingCommands +} from './ai-configuration-setting-commands'; + +interface Handler { execute(args: unknown): unknown; isVisible?(args: unknown): boolean; isEnabled?(args: unknown): boolean } + +function setup(): { handlers: Map; setCalls: Array<[string, unknown, PreferenceScope, string | undefined]>; copied: string[] } { + const setCalls: Array<[string, unknown, PreferenceScope, string | undefined]> = []; + const copied: string[] = []; + const contribution = new AiConfigurationSettingCommandContribution(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (contribution as any).aiConfigurationService = { + set: (id: string, value: unknown, scope: PreferenceScope, resourceUri?: string) => { setCalls.push([id, value, scope, resourceUri]); return Promise.resolve(); } + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (contribution as any).clipboardService = { writeText: (text: string) => { copied.push(text); } }; + const handlers = new Map(); + contribution.registerCommands({ registerCommand: (command: Command, handler: Handler) => handlers.set(command.id, handler) } as unknown as CommandRegistry); + return { handlers, setCalls, copied }; +} + +const args: AiConfigurationSettingCommandArgs = { id: 'ai-features.chat.foo', value: true, scope: PreferenceScope.User }; + +describe('AiConfigurationSettingCommandContribution', () => { + + it('resets a setting in the row scope via AiConfigurationService (not the Settings widget)', () => { + const { handlers, setCalls } = setup(); + handlers.get(AiConfigurationSettingCommands.RESET.id)!.execute({ ...args, scope: PreferenceScope.Workspace, resourceUri: 'file:///ws' }); + expect(setCalls).to.deep.equal([['ai-features.chat.foo', undefined, PreferenceScope.Workspace, 'file:///ws']]); + }); + + it('resets via the supplied callback for non-preference (e.g. per-agent) rows, without touching AiConfigurationService', () => { + const { handlers, setCalls } = setup(); + let called = 0; + const reset = handlers.get(AiConfigurationSettingCommands.RESET.id)!; + expect(reset.isVisible!({ reset: () => called++ })).to.equal(true); + reset.execute({ reset: () => called++ }); + expect(called).to.equal(1); + expect(setCalls).to.be.empty; + }); + + it('hides the copy commands for non-preference (callback-only) rows', () => { + const { handlers } = setup(); + expect(handlers.get(AiConfigurationSettingCommands.COPY_ID.id)!.isVisible!({ reset: () => undefined })).to.equal(false); + expect(handlers.get(AiConfigurationSettingCommands.COPY_JSON.id)!.isVisible!({ reset: () => undefined })).to.equal(false); + }); + + it('copies the setting id and the JSON entry', () => { + const { handlers, copied } = setup(); + handlers.get(AiConfigurationSettingCommands.COPY_ID.id)!.execute(args); + handlers.get(AiConfigurationSettingCommands.COPY_JSON.id)!.execute(args); + expect(copied).to.deep.equal(['ai-features.chat.foo', '"ai-features.chat.foo": true']); + }); + + it('guards the commands against malformed arguments', () => { + const { handlers } = setup(); + const reset = handlers.get(AiConfigurationSettingCommands.RESET.id)!; + expect(reset.isVisible!(args)).to.equal(true); + expect(reset.isVisible!({ id: 'x' })).to.equal(false); // missing scope + expect(reset.isVisible!(undefined)).to.equal(false); + }); +}); diff --git a/packages/ai-core-ui/src/browser/ai-configuration/ai-configuration-setting-commands.ts b/packages/ai-core-ui/src/browser/ai-configuration/ai-configuration-setting-commands.ts new file mode 100644 index 0000000000000..8ba8fb608dcb4 --- /dev/null +++ b/packages/ai-core-ui/src/browser/ai-configuration/ai-configuration-setting-commands.ts @@ -0,0 +1,104 @@ +// ***************************************************************************** +// Copyright (C) 2026 EclipseSource and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { AiConfigurationService } from '@theia/ai-core'; +import { Command, CommandContribution, CommandRegistry, MenuContribution, MenuModelRegistry, nls, PreferenceScope } from '@theia/core'; +import { ClipboardService } from '@theia/core/lib/browser/clipboard-service'; +import { inject, injectable, interfaces } from '@theia/core/shared/inversify'; + +/** Context-menu path for the AI Configuration setting-row gear. */ +export const AI_CONFIGURATION_SETTING_CONTEXT_MENU = ['ai-configuration-setting-context-menu']; + +/** + * Arguments carried by the gear menu's commands. A preference-backed row supplies `id`/`value`/`scope` + * (Reset writes `undefined` to that scope; Copy uses `id`/`value`). A row backed by non-preference data + * (e.g. per-agent settings) instead supplies a `reset` callback, so it gets the same gear/Reset affordance + * without a preference id. + */ +export interface AiConfigurationSettingCommandArgs { + readonly id?: string; + readonly value?: unknown; + readonly scope?: PreferenceScope; + readonly resourceUri?: string; + readonly reset?: () => void; +} +export namespace AiConfigurationSettingCommandArgs { + /** Preference-backed args: `id` + `scope` present (enables Copy and preference reset). */ + export function isPreference(arg: unknown): arg is AiConfigurationSettingCommandArgs & { id: string; scope: PreferenceScope } { + return typeof arg === 'object' && arg !== undefined + && typeof (arg as AiConfigurationSettingCommandArgs).id === 'string' + && typeof (arg as AiConfigurationSettingCommandArgs).scope === 'number'; + } + /** Resettable args: either preference-backed or carrying a `reset` callback. */ + export function isResettable(arg: unknown): arg is AiConfigurationSettingCommandArgs { + return isPreference(arg) || (typeof arg === 'object' && arg !== undefined && typeof (arg as AiConfigurationSettingCommandArgs).reset === 'function'); + } +} + +export namespace AiConfigurationSettingCommands { + export const RESET: Command = { id: 'ai-configuration.setting.reset', label: nls.localizeByDefault('Reset Setting') }; + export const COPY_ID: Command = { id: 'ai-configuration.setting.copyId', label: nls.localizeByDefault('Copy Setting ID') }; + export const COPY_JSON: Command = { id: 'ai-configuration.setting.copyJson', label: nls.localizeByDefault('Copy Setting as JSON') }; +} + +/** + * Commands + menu for the AI Configuration setting-row gear, mirroring the Settings UI's preference + * context menu (Reset / Copy Setting ID / Copy as JSON) but backed by {@link AiConfigurationService} and + * acting in the row's own scope. Kept self-contained here rather than reusing the Settings UI's + * `RESET_PREFERENCE` command, which is bound to the Preferences editor widget (its scope tracker) and + * misbehaves when invoked from this view. + */ +@injectable() +export class AiConfigurationSettingCommandContribution implements CommandContribution, MenuContribution { + + @inject(AiConfigurationService) + protected readonly aiConfigurationService: AiConfigurationService; + + @inject(ClipboardService) + protected readonly clipboardService: ClipboardService; + + registerCommands(commands: CommandRegistry): void { + commands.registerCommand(AiConfigurationSettingCommands.RESET, { + isEnabled: AiConfigurationSettingCommandArgs.isResettable, + isVisible: AiConfigurationSettingCommandArgs.isResettable, + execute: (args: AiConfigurationSettingCommandArgs) => args.reset + ? args.reset() + : this.aiConfigurationService.set(args.id!, undefined, args.scope!, args.resourceUri) + }); + commands.registerCommand(AiConfigurationSettingCommands.COPY_ID, { + isEnabled: AiConfigurationSettingCommandArgs.isPreference, + isVisible: AiConfigurationSettingCommandArgs.isPreference, + execute: (args: AiConfigurationSettingCommandArgs) => this.clipboardService.writeText(args.id!) + }); + commands.registerCommand(AiConfigurationSettingCommands.COPY_JSON, { + isEnabled: AiConfigurationSettingCommandArgs.isPreference, + isVisible: AiConfigurationSettingCommandArgs.isPreference, + execute: (args: AiConfigurationSettingCommandArgs) => this.clipboardService.writeText(`"${args.id}": ${JSON.stringify(args.value)}`) + }); + } + + registerMenus(menus: MenuModelRegistry): void { + menus.registerMenuAction(AI_CONFIGURATION_SETTING_CONTEXT_MENU, { commandId: AiConfigurationSettingCommands.RESET.id, order: 'a' }); + menus.registerMenuAction(AI_CONFIGURATION_SETTING_CONTEXT_MENU, { commandId: AiConfigurationSettingCommands.COPY_ID.id, order: 'b' }); + menus.registerMenuAction(AI_CONFIGURATION_SETTING_CONTEXT_MENU, { commandId: AiConfigurationSettingCommands.COPY_JSON.id, order: 'c' }); + } +} + +export function bindAiConfigurationSettingCommands(bind: interfaces.Bind): void { + bind(AiConfigurationSettingCommandContribution).toSelf().inSingletonScope(); + bind(CommandContribution).toService(AiConfigurationSettingCommandContribution); + bind(MenuContribution).toService(AiConfigurationSettingCommandContribution); +} diff --git a/packages/ai-core-ui/src/browser/ai-configuration/components/ai-configuration-components.spec.ts b/packages/ai-core-ui/src/browser/ai-configuration/components/ai-configuration-components.spec.ts new file mode 100644 index 0000000000000..f2e33ce8014df --- /dev/null +++ b/packages/ai-core-ui/src/browser/ai-configuration/components/ai-configuration-components.spec.ts @@ -0,0 +1,224 @@ +// ***************************************************************************** +// Copyright (C) 2026 EclipseSource and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom'; +let disableJSDOM = enableJSDOM(); + +// @lumino/dragdrop (pulled in transitively via `codicon` from `@theia/core/lib/browser`) extends the +// DragEvent DOM global at module load, which JSDOM does not provide; stub it so the import succeeds. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +if (!(global as any).DragEvent) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (global as any).DragEvent = class DragEvent extends (global as any).Event { }; +} + +import { expect } from 'chai'; +import * as React from '@theia/core/shared/react'; +import { flushSync } from '@theia/core/shared/react-dom'; +import { createRoot } from '@theia/core/shared/react-dom/client'; +import { AiConfigurationItemRow } from './ai-configuration-item-row'; +import { AiConfigurationEmptyState, AiConfigurationItemDetailHeader, AiConfigurationSection } from './ai-configuration-primitives'; +import { AiConfigurationSettingRow } from './ai-configuration-setting-row'; + +disableJSDOM(); + +/** Renders one level of an element: the output of a function component, or the children of a host element. */ +function contentOf(element: React.ReactElement): React.ReactNode { + if (typeof element.type === 'function') { + return (element.type as (props: unknown) => React.ReactNode)(element.props); + } + return (element.props as { children?: React.ReactNode } | undefined)?.children; +} + +/** Recursively collects the class names present anywhere in a rendered element tree. */ +function classNames(node: React.ReactNode, into: string[] = []): string[] { + if (!node || typeof node !== 'object') { + return into; + } + if (Array.isArray(node)) { + node.forEach(child => classNames(child, into)); + return into; + } + const element = node as React.ReactElement<{ className?: string }>; + if (element.props?.className) { + into.push(...element.props.className.split(/\s+/).filter(Boolean)); + } + classNames(contentOf(element), into); + return into; +} + +/** Recursively collects the string text present anywhere in a rendered element tree. */ +function textOf(node: React.ReactNode, into: string[] = []): string[] { + if (node === undefined || node === false) { + return into; + } + if (typeof node === 'string' || typeof node === 'number') { + into.push(String(node)); + return into; + } + if (Array.isArray(node)) { + node.forEach(child => textOf(child, into)); + return into; + } + if (typeof node === 'object') { + textOf(contentOf(node as React.ReactElement), into); + } + return into; +} + +describe('AI Configuration primitives', () => { + + before(() => disableJSDOM = enableJSDOM()); + after(() => disableJSDOM()); + + it('AiConfigurationSection renders its title and children', () => { + const tree = AiConfigurationSection({ title: 'General', children: React.createElement('span', {}, 'row') }); + expect(classNames(tree)).to.include('ai-configuration-section'); + expect(textOf(tree)).to.include('General').and.to.include('row'); + }); + + it('AiConfigurationItemRow exposes the label, status and an onSelect handler', () => { + let selected = false; + const tree = AiConfigurationItemRow({ + label: 'Universal', + iconClass: 'codicon-copilot', + status: { kind: 'on', label: 'Enabled' }, + onSelect: () => { selected = true; } + }) as React.ReactElement<{ onClick: () => void }>; + expect(textOf(tree)).to.include('Universal').and.to.include('Enabled'); + expect(classNames(tree)).to.include('ai-configuration-status-on'); + tree.props.onClick(); + expect(selected).to.equal(true); + }); + + it('AiConfigurationItemRow shows the modified bar and a gear that anchors its menu at the gear', () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const opened: HTMLElement[] = []; + flushSync(() => root.render(React.createElement(AiConfigurationItemRow, { + label: 'chat', + modified: true, + onOpenMenu: gearEl => { opened.push(gearEl); } + }))); + const row = host.querySelector('.ai-configuration-item-row') as HTMLElement; + expect(row.classList.contains('modified')).to.equal(true); + // A non-navigable row (no onSelect) shows no chevron. + expect(Boolean(host.querySelector('.ai-configuration-item-row-chevron'))).to.equal(false); + const gear = host.querySelector('.ai-settings-row-gear') as HTMLButtonElement; + expect(Boolean(gear)).to.equal(true); + flushSync(() => gear.click()); + expect(opened.length).to.equal(1); + expect(opened[0]).to.equal(gear); + root.unmount(); + host.remove(); + }); + + it('AiConfigurationEmptyState renders the message and an optional action', () => { + const tree = AiConfigurationEmptyState({ message: 'Nothing here', action: React.createElement('button', {}, 'Add') }); + expect(textOf(tree)).to.include('Nothing here').and.to.include('Add'); + }); + + it('AiConfigurationItemDetailHeader renders title and subtitle', () => { + const tree = AiConfigurationItemDetailHeader({ title: 'Coder', subtitle: 'agent-id-1' }); + expect(textOf(tree)).to.include('Coder').and.to.include('agent-id-1'); + }); + + it('AiConfigurationSettingRow renders title and control, marks modified rows, and hides the raw id', () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + flushSync(() => root.render(React.createElement(AiConfigurationSettingRow, { + preferenceId: 'ai-features.demo', + title: 'Demo', + renderMarkdown: () => document.createElement('div'), + modified: true, + control: React.createElement('span', {}, 'ctrl') + }))); + const row = host.querySelector('.ai-settings-row') as HTMLElement; + expect(row.classList.contains('modified')).to.equal(true); + expect(row.textContent).to.include('Demo').and.to.include('ctrl'); + // The raw preference id is no longer shown inline; it is copied through the gear menu instead. + expect(row.textContent).to.not.include('ai-features.demo'); + flushSync(() => root.render(React.createElement(AiConfigurationSettingRow, { + preferenceId: 'ai-features.demo', + title: 'Demo', + renderMarkdown: () => document.createElement('div'), + modified: false + }))); + expect((host.querySelector('.ai-settings-row') as HTMLElement).classList.contains('modified')).to.equal(false); + root.unmount(); + host.remove(); + }); + + it('the setting-row gear delegates to the preference context menu, anchored at the gear', () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const opened: HTMLElement[] = []; + flushSync(() => root.render(React.createElement(AiConfigurationSettingRow, { + preferenceId: 'ai-features.demo', + title: 'Demo', + renderMarkdown: () => document.createElement('div'), + modified: true, + onOpenMenu: gearEl => { opened.push(gearEl); }, + control: React.createElement('span', {}, 'ctrl') + }))); + + // The gear defers to the owner's opener (which renders the Settings UI's own preference context + // menu via ContextMenuRenderer), passing the gear element as the anchor. No inline popover. + const gear = host.querySelector('.ai-settings-row-gear') as HTMLButtonElement; + expect(Boolean(gear)).to.equal(true); + flushSync(() => gear.click()); + expect(opened.length).to.equal(1); + expect(opened[0]).to.equal(gear); + + root.unmount(); + host.remove(); + }); + + it('AiConfigurationSettingRow hides the gear when no menu opener is provided', () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + flushSync(() => root.render(React.createElement(AiConfigurationSettingRow, { + preferenceId: 'ai-features.demo', + title: 'Demo', + renderMarkdown: () => document.createElement('div'), + modified: true + }))); + expect(Boolean(host.querySelector('.ai-settings-row-gear'))).to.equal(false); + root.unmount(); + host.remove(); + }); + + it('AiConfigurationSettingRow places a full-width control in the below slot', () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + flushSync(() => root.render(React.createElement(AiConfigurationSettingRow, { + preferenceId: 'ai-features.demo', + title: 'Demo', + renderMarkdown: () => document.createElement('div'), + modified: false, + below: React.createElement('span', {}, 'wide') + }))); + expect(Boolean(host.querySelector('.ai-settings-row-below'))).to.equal(true); + expect(host.textContent).to.include('wide'); + root.unmount(); + host.remove(); + }); +}); diff --git a/packages/ai-core-ui/src/browser/ai-configuration/components/ai-configuration-controls.tsx b/packages/ai-core-ui/src/browser/ai-configuration/components/ai-configuration-controls.tsx new file mode 100644 index 0000000000000..df6ff3c888d63 --- /dev/null +++ b/packages/ai-core-ui/src/browser/ai-configuration/components/ai-configuration-controls.tsx @@ -0,0 +1,359 @@ +// ***************************************************************************** +// Copyright (C) 2026 EclipseSource and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { nls } from '@theia/core'; +import { codicon } from '@theia/core/lib/browser'; +import { SelectComponent, SelectOption } from '@theia/core/lib/browser/widgets/select-component'; +import * as React from '@theia/core/shared/react'; + +/** + * Presentational, DI-free form controls shared across the AI configuration pages so that a given + * logical control (a toggle, a text input, a select, a numeric stepper, a chip/tag editor) looks + * and behaves the same regardless of which category renders it. Owners pass in the current value + * and an `onChange`/`onCommit` callback; controls that edit free text commit on blur/Enter so that + * typing does not write a preference per keystroke. + * + * All classes are prefixed `ai-config-` and are styled in `ai-configuration-components.css`. + */ + +export function clampNumber(value: number, min: number | undefined, max: number | undefined): number { + let result = value; + if (min !== undefined) { + result = Math.max(min, result); + } + if (max !== undefined) { + result = Math.min(max, result); + } + return result; +} + +/** Accessible toggle switch backed by a real checkbox; `large` renders the hero-sized variant. */ +export const AiToggleSwitch: React.FC<{ + checked: boolean; + ariaLabel: string; + disabled?: boolean; + large?: boolean; + onChange: (checked: boolean) => void; +}> = ({ checked, ariaLabel, disabled, large, onChange }) => ( + +); + +/** Single-line text input committing on blur or Enter (so typing does not write a preference per keystroke). */ +export const AiTextInput: React.FC<{ + value: string; + ariaLabel: string; + placeholder?: string; + disabled?: boolean; + monospace?: boolean; + /** Masks the value (`type=password`) and disables autocomplete/spellcheck; used for API keys and secrets. */ + password?: boolean; + /** Marks the input as invalid (error border) and, with {@link errorMessage}, shows the message beneath it. */ + invalid?: boolean; + /** Validation message shown under the input when {@link invalid} is set. */ + errorMessage?: string; + onCommit: (value: string) => void; +}> = ({ value, ariaLabel, placeholder, disabled, monospace, password, invalid, errorMessage, onCommit }) => { + const [draft, setDraft] = React.useState(value); + React.useEffect(() => setDraft(value), [value]); + const commitIfChanged = (): void => { + if (draft !== value) { + onCommit(draft); + } + }; + return + setDraft(e.target.value)} + onBlur={commitIfChanged} + onKeyDown={e => { if (e.key === 'Enter') { commitIfChanged(); } }} + /> + {invalid && errorMessage && {errorMessage}} + ; +}; + +/** A `− value +` numeric stepper. Commits on blur/Enter (typing) and immediately on the buttons. */ +export const AiNumberStepper: React.FC<{ + value: number; + ariaLabel: string; + min?: number; + max?: number; + disabled?: boolean; + onCommit: (value: number) => void; +}> = ({ value, ariaLabel, min, max, disabled, onCommit }) => { + const [draft, setDraft] = React.useState(String(value)); + React.useEffect(() => setDraft(String(value)), [value]); + const commitDraft = (): void => { + const parsed = Number(draft); + const next = clampNumber(Number.isNaN(parsed) ? value : Math.round(parsed), min, max); + setDraft(String(next)); + if (next !== value) { + onCommit(next); + } + }; + const step = (delta: number): void => { + const next = clampNumber(value + delta, min, max); + if (next !== value) { + onCommit(next); + } + }; + return + + + setDraft(e.target.value)} + onBlur={commitDraft} + onKeyDown={e => { if (e.key === 'Enter') { commitDraft(); } }} + /> + + + ; +}; + +/** + * A `string[]` editor mirroring the Settings UI array input: each value is a list row with a hover-revealed + * remove button, and a final "Add Value…" input (Enter or the + button appends). It reuses the Settings UI's + * DOM shape and class names (styled by our own `.ai-config-array` rules) so it looks identical, without + * depending on the preferences package's DI-bound renderer. Values commit immediately on add/remove. + */ +export const AiArrayInput: React.FC<{ + values: string[]; + ariaLabel: string; + addPlaceholder: string; + disabled?: boolean; + onChange: (values: string[]) => void; +}> = ({ values, ariaLabel, addPlaceholder, disabled, onChange }) => { + const [draft, setDraft] = React.useState(''); + const addItem = (): void => { + const entry = draft.trim(); + if (entry && !values.includes(entry)) { + onChange([...values, entry]); + } + setDraft(''); + }; + const removeAt = (index: number): void => onChange(values.filter((_, itemIndex) => itemIndex !== index)); + return
    + {values.map((value, index) => ( +
  • + {value} + { if (!disabled) { removeAt(index); } }} + > + + +
  • + ))} +
  • + setDraft(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addItem(); } }} + /> + { if (!disabled) { addItem(); } }} + > +
  • +
; +}; + +/** A monospace path input plus a "Browse…" button that delegates folder selection to `onBrowse`. */ +export const AiPathInput: React.FC<{ + value: string; + placeholder: string; + browseLabel: string; + disabled?: boolean; + onCommit: (value: string) => void; + onBrowse: () => Promise; +}> = ({ value, placeholder, browseLabel, disabled, onCommit, onBrowse }) => { + const [draft, setDraft] = React.useState(value); + React.useEffect(() => setDraft(value), [value]); + const commitDraft = (): void => { + if (draft !== value) { + onCommit(draft); + } + }; + const browse = (): void => { + onBrowse().then(picked => { + if (picked !== undefined) { + setDraft(picked); + onCommit(picked); + } + }); + }; + return
+ setDraft(e.target.value)} + onBlur={commitDraft} + onKeyDown={e => { if (e.key === 'Enter') { commitDraft(); } }} + /> + +
; +}; + +/** + * A button that hands editing off to the `settings.json` file. Used for preferences whose value is a + * complex object (or array of objects) that cannot be edited meaningfully through an inline control; + * clicking it opens `settings.json` focused on the preference, mirroring the Settings view's + * "Edit in settings.json" link. + */ +export const AiEditInSettingsButton: React.FC<{ + label: string; + ariaLabel: string; + disabled?: boolean; + onClick: () => void; +}> = ({ label, ariaLabel, disabled, onClick }) => ( + +); + +/** A single option for {@link AiEnumSelect}. */ +export interface AiEnumOption { + readonly value: string; + readonly label: string; + /** Optional native tooltip, e.g. to surface an option's secondary description. */ + readonly title?: string; + readonly disabled?: boolean; +} + +/** + * A theme-styled select over a fixed set of options, backed by the shared {@link SelectComponent} so + * that each option can surface its own description (which native `