@theia/ai-ide 1.76.0-next.7 → 1.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/lib/browser/ai-allow-all-mode-chat-banner.js +1 -1
  2. package/lib/browser/ai-allow-all-mode-chat-banner.js.map +1 -1
  3. package/lib/browser/ai-aware-scm-commit-widget.d.ts +27 -0
  4. package/lib/browser/ai-aware-scm-commit-widget.d.ts.map +1 -0
  5. package/lib/browser/ai-aware-scm-commit-widget.js +142 -0
  6. package/lib/browser/ai-aware-scm-commit-widget.js.map +1 -0
  7. package/lib/browser/ai-configuration/ai-configuration-detail-widget.d.ts +10 -4
  8. package/lib/browser/ai-configuration/ai-configuration-detail-widget.d.ts.map +1 -1
  9. package/lib/browser/ai-configuration/ai-configuration-detail-widget.js +12 -7
  10. package/lib/browser/ai-configuration/ai-configuration-detail-widget.js.map +1 -1
  11. package/lib/browser/ai-configuration/ai-configuration-detail-widget.spec.js +14 -14
  12. package/lib/browser/ai-configuration/ai-configuration-detail-widget.spec.js.map +1 -1
  13. package/lib/browser/ai-configuration/ai-configuration-tree-widget.d.ts +6 -0
  14. package/lib/browser/ai-configuration/ai-configuration-tree-widget.d.ts.map +1 -1
  15. package/lib/browser/ai-configuration/ai-configuration-tree-widget.js +9 -0
  16. package/lib/browser/ai-configuration/ai-configuration-tree-widget.js.map +1 -1
  17. package/lib/browser/ai-configuration/categories/agent-capabilities-settings.d.ts.map +1 -1
  18. package/lib/browser/ai-configuration/categories/agent-capabilities-settings.js +2 -1
  19. package/lib/browser/ai-configuration/categories/agent-capabilities-settings.js.map +1 -1
  20. package/lib/browser/ai-configuration/categories/agent-chips.js +1 -1
  21. package/lib/browser/ai-configuration/categories/agent-chips.js.map +1 -1
  22. package/lib/browser/ai-configuration/categories/model-aliases-configuration-category.d.ts +8 -0
  23. package/lib/browser/ai-configuration/categories/model-aliases-configuration-category.d.ts.map +1 -1
  24. package/lib/browser/ai-configuration/categories/model-aliases-configuration-category.js +27 -8
  25. package/lib/browser/ai-configuration/categories/model-aliases-configuration-category.js.map +1 -1
  26. package/lib/browser/ai-configuration/categories/model-aliases-configuration-category.spec.js +38 -2
  27. package/lib/browser/ai-configuration/categories/model-aliases-configuration-category.spec.js.map +1 -1
  28. package/lib/browser/ai-configuration/categories/models-configuration-category.d.ts +13 -2
  29. package/lib/browser/ai-configuration/categories/models-configuration-category.d.ts.map +1 -1
  30. package/lib/browser/ai-configuration/categories/models-configuration-category.js +41 -2
  31. package/lib/browser/ai-configuration/categories/models-configuration-category.js.map +1 -1
  32. package/lib/browser/ai-configuration/categories/models-configuration-category.spec.js +32 -2
  33. package/lib/browser/ai-configuration/categories/models-configuration-category.spec.js.map +1 -1
  34. package/lib/browser/ai-configuration/categories/provider-model-discovery-section.d.ts +26 -0
  35. package/lib/browser/ai-configuration/categories/provider-model-discovery-section.d.ts.map +1 -0
  36. package/lib/browser/ai-configuration/categories/provider-model-discovery-section.js +212 -0
  37. package/lib/browser/ai-configuration/categories/provider-model-discovery-section.js.map +1 -0
  38. package/lib/browser/ai-configuration/categories/provider-model-discovery-section.spec.d.ts +2 -0
  39. package/lib/browser/ai-configuration/categories/provider-model-discovery-section.spec.d.ts.map +1 -0
  40. package/lib/browser/ai-configuration/categories/provider-model-discovery-section.spec.js +423 -0
  41. package/lib/browser/ai-configuration/categories/provider-model-discovery-section.spec.js.map +1 -0
  42. package/lib/browser/ai-configuration/language-model-renderer.d.ts.map +1 -1
  43. package/lib/browser/ai-configuration/language-model-renderer.js +10 -3
  44. package/lib/browser/ai-configuration/language-model-renderer.js.map +1 -1
  45. package/lib/browser/ai-getting-started-commands.d.ts +10 -0
  46. package/lib/browser/ai-getting-started-commands.d.ts.map +1 -0
  47. package/lib/browser/ai-getting-started-commands.js +34 -0
  48. package/lib/browser/ai-getting-started-commands.js.map +1 -0
  49. package/lib/browser/ai-getting-started-contribution.d.ts +20 -0
  50. package/lib/browser/ai-getting-started-contribution.d.ts.map +1 -0
  51. package/lib/browser/ai-getting-started-contribution.js +70 -0
  52. package/lib/browser/ai-getting-started-contribution.js.map +1 -0
  53. package/lib/browser/ai-getting-started-walkthrough.d.ts +41 -0
  54. package/lib/browser/ai-getting-started-walkthrough.d.ts.map +1 -0
  55. package/lib/browser/ai-getting-started-walkthrough.js +225 -0
  56. package/lib/browser/ai-getting-started-walkthrough.js.map +1 -0
  57. package/lib/browser/ai-getting-started-walkthrough.spec.d.ts +2 -0
  58. package/lib/browser/ai-getting-started-walkthrough.spec.d.ts.map +1 -0
  59. package/lib/browser/ai-getting-started-walkthrough.spec.js +124 -0
  60. package/lib/browser/ai-getting-started-walkthrough.spec.js.map +1 -0
  61. package/lib/browser/ai-walkthrough-context-keys.d.ts +55 -0
  62. package/lib/browser/ai-walkthrough-context-keys.d.ts.map +1 -0
  63. package/lib/browser/ai-walkthrough-context-keys.js +145 -0
  64. package/lib/browser/ai-walkthrough-context-keys.js.map +1 -0
  65. package/lib/browser/ai-walkthrough-context-keys.spec.d.ts +2 -0
  66. package/lib/browser/ai-walkthrough-context-keys.spec.d.ts.map +1 -0
  67. package/lib/browser/ai-walkthrough-context-keys.spec.js +166 -0
  68. package/lib/browser/ai-walkthrough-context-keys.spec.js.map +1 -0
  69. package/lib/browser/chat-session-item.js +2 -2
  70. package/lib/browser/chat-session-item.js.map +1 -1
  71. package/lib/browser/commit-message-agent.d.ts +31 -0
  72. package/lib/browser/commit-message-agent.d.ts.map +1 -0
  73. package/lib/browser/commit-message-agent.js +99 -0
  74. package/lib/browser/commit-message-agent.js.map +1 -0
  75. package/lib/browser/commit-message-agent.spec.d.ts +2 -0
  76. package/lib/browser/commit-message-agent.spec.d.ts.map +1 -0
  77. package/lib/browser/commit-message-agent.spec.js +82 -0
  78. package/lib/browser/commit-message-agent.spec.js.map +1 -0
  79. package/lib/browser/commit-message-command-contribution.d.ts +15 -0
  80. package/lib/browser/commit-message-command-contribution.d.ts.map +1 -0
  81. package/lib/browser/commit-message-command-contribution.js +73 -0
  82. package/lib/browser/commit-message-command-contribution.js.map +1 -0
  83. package/lib/browser/commit-message-commands.d.ts +5 -0
  84. package/lib/browser/commit-message-commands.d.ts.map +1 -0
  85. package/lib/browser/commit-message-commands.js +28 -0
  86. package/lib/browser/commit-message-commands.js.map +1 -0
  87. package/lib/browser/commit-message-prompt-template.d.ts +11 -0
  88. package/lib/browser/commit-message-prompt-template.d.ts.map +1 -0
  89. package/lib/browser/commit-message-prompt-template.js +63 -0
  90. package/lib/browser/commit-message-prompt-template.js.map +1 -0
  91. package/lib/browser/commit-message-runner.d.ts +41 -0
  92. package/lib/browser/commit-message-runner.d.ts.map +1 -0
  93. package/lib/browser/commit-message-runner.js +143 -0
  94. package/lib/browser/commit-message-runner.js.map +1 -0
  95. package/lib/browser/commit-message-runner.spec.d.ts +2 -0
  96. package/lib/browser/commit-message-runner.spec.d.ts.map +1 -0
  97. package/lib/browser/commit-message-runner.spec.js +206 -0
  98. package/lib/browser/commit-message-runner.spec.js.map +1 -0
  99. package/lib/browser/frontend-module.d.ts.map +1 -1
  100. package/lib/browser/frontend-module.js +25 -0
  101. package/lib/browser/frontend-module.js.map +1 -1
  102. package/lib/browser/git-changes-tool.d.ts +73 -0
  103. package/lib/browser/git-changes-tool.d.ts.map +1 -0
  104. package/lib/browser/git-changes-tool.js +217 -0
  105. package/lib/browser/git-changes-tool.js.map +1 -0
  106. package/lib/browser/git-changes-tool.spec.d.ts +2 -0
  107. package/lib/browser/git-changes-tool.spec.d.ts.map +1 -0
  108. package/lib/browser/git-changes-tool.spec.js +167 -0
  109. package/lib/browser/git-changes-tool.spec.js.map +1 -0
  110. package/lib/browser/ide-chat-welcome-message-provider.d.ts +26 -28
  111. package/lib/browser/ide-chat-welcome-message-provider.d.ts.map +1 -1
  112. package/lib/browser/ide-chat-welcome-message-provider.js +61 -151
  113. package/lib/browser/ide-chat-welcome-message-provider.js.map +1 -1
  114. package/lib/browser/review/pr-review-capability-contribution.js +1 -1
  115. package/lib/browser/review/pr-review-capability-contribution.js.map +1 -1
  116. package/lib/browser/user-interaction-tool-renderer.d.ts +6 -0
  117. package/lib/browser/user-interaction-tool-renderer.d.ts.map +1 -1
  118. package/lib/browser/user-interaction-tool-renderer.js +36 -7
  119. package/lib/browser/user-interaction-tool-renderer.js.map +1 -1
  120. package/lib/browser/user-interaction-tool.d.ts +23 -1
  121. package/lib/browser/user-interaction-tool.d.ts.map +1 -1
  122. package/lib/browser/user-interaction-tool.js +56 -17
  123. package/lib/browser/user-interaction-tool.js.map +1 -1
  124. package/lib/browser/user-interaction-tool.spec.js +151 -2
  125. package/lib/browser/user-interaction-tool.spec.js.map +1 -1
  126. package/lib/common/command-prompt-template.js +1 -1
  127. package/lib/common/user-interaction-tool.d.ts +19 -1
  128. package/lib/common/user-interaction-tool.d.ts.map +1 -1
  129. package/lib/common/user-interaction-tool.js +100 -29
  130. package/lib/common/user-interaction-tool.js.map +1 -1
  131. package/lib/common/user-interaction-tool.spec.js +215 -174
  132. package/lib/common/user-interaction-tool.spec.js.map +1 -1
  133. package/package.json +26 -25
  134. package/src/browser/ai-allow-all-mode-chat-banner.tsx +2 -2
  135. package/src/browser/ai-aware-scm-commit-widget.tsx +153 -0
  136. package/src/browser/ai-configuration/ai-configuration-detail-widget.spec.ts +14 -14
  137. package/src/browser/ai-configuration/ai-configuration-detail-widget.tsx +12 -7
  138. package/src/browser/ai-configuration/ai-configuration-tree-widget.tsx +8 -0
  139. package/src/browser/ai-configuration/categories/agent-capabilities-settings.tsx +7 -8
  140. package/src/browser/ai-configuration/categories/agent-chips.tsx +1 -1
  141. package/src/browser/ai-configuration/categories/model-aliases-configuration-category.spec.ts +40 -2
  142. package/src/browser/ai-configuration/categories/model-aliases-configuration-category.tsx +31 -10
  143. package/src/browser/ai-configuration/categories/models-configuration-category.spec.ts +47 -2
  144. package/src/browser/ai-configuration/categories/models-configuration-category.tsx +52 -5
  145. package/src/browser/ai-configuration/categories/provider-model-discovery-section.spec.tsx +451 -0
  146. package/src/browser/ai-configuration/categories/provider-model-discovery-section.tsx +341 -0
  147. package/src/browser/ai-configuration/language-model-renderer.tsx +11 -3
  148. package/src/browser/ai-getting-started-commands.ts +33 -0
  149. package/src/browser/ai-getting-started-contribution.ts +64 -0
  150. package/src/browser/ai-getting-started-walkthrough.spec.ts +146 -0
  151. package/src/browser/ai-getting-started-walkthrough.ts +249 -0
  152. package/src/browser/ai-walkthrough-context-keys.spec.ts +209 -0
  153. package/src/browser/ai-walkthrough-context-keys.ts +147 -0
  154. package/src/browser/chat-session-item.tsx +2 -2
  155. package/src/browser/commit-message-agent.spec.ts +97 -0
  156. package/src/browser/commit-message-agent.ts +113 -0
  157. package/src/browser/commit-message-command-contribution.ts +67 -0
  158. package/src/browser/commit-message-commands.ts +25 -0
  159. package/src/browser/commit-message-prompt-template.ts +65 -0
  160. package/src/browser/commit-message-runner.spec.ts +266 -0
  161. package/src/browser/commit-message-runner.ts +163 -0
  162. package/src/browser/frontend-module.ts +27 -0
  163. package/src/browser/git-changes-tool.spec.ts +200 -0
  164. package/src/browser/git-changes-tool.ts +239 -0
  165. package/src/browser/ide-chat-welcome-message-provider.tsx +93 -230
  166. package/src/browser/review/pr-review-capability-contribution.ts +1 -1
  167. package/src/browser/style/commit-message-overlay.css +64 -0
  168. package/src/browser/style/index.css +4 -42
  169. package/src/browser/user-interaction-tool-renderer.tsx +38 -8
  170. package/src/browser/user-interaction-tool.spec.ts +201 -2
  171. package/src/browser/user-interaction-tool.ts +69 -18
  172. package/src/common/command-prompt-template.ts +1 -1
  173. package/src/common/user-interaction-tool.spec.ts +217 -161
  174. package/src/common/user-interaction-tool.ts +127 -32
@@ -0,0 +1,153 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify';
18
+ import * as React from '@theia/core/shared/react';
19
+ import { CommandService, ILogger, nls } from '@theia/core';
20
+ import { codicon } from '@theia/core/lib/browser';
21
+ import { AgentService } from '@theia/ai-core/lib/common';
22
+ import { AIActivationService } from '@theia/ai-core/lib/browser';
23
+ import { ScmCommitWidget } from '@theia/scm/lib/browser/scm-commit-widget';
24
+ import { ScmInput } from '@theia/scm/lib/browser/scm-input';
25
+ import { ScmProvider } from '@theia/scm/lib/browser/scm-provider';
26
+ import { COMMIT_MESSAGE_AGENT_ID } from './commit-message-agent';
27
+ import { CommitMessageCommands } from './commit-message-commands';
28
+ import { CommitMessageRunner } from './commit-message-runner';
29
+
30
+ /**
31
+ * Source-control provider id of the VS Code / Theia git extension. The generator runs
32
+ * `git diff --cached`, so it is git-specific by construction and the overlay is only offered
33
+ * for that provider rather than for any provider that happens to expose a matching group.
34
+ */
35
+ const GIT_PROVIDER_ID = 'git';
36
+
37
+ /** Resource group the git extension uses for staged resources. */
38
+ const STAGED_GROUP_ID = 'index';
39
+
40
+ /**
41
+ * Extends the standard SCM commit-message widget with an overlay icon that generates a commit
42
+ * message from the staged changes. Bound via a DI rebind of {@link ScmCommitWidget} so the SCM
43
+ * view picks up the AI-aware version transparently.
44
+ */
45
+ @injectable()
46
+ export class AiAwareScmCommitWidget extends ScmCommitWidget {
47
+
48
+ @inject(CommandService)
49
+ protected readonly commandService: CommandService;
50
+
51
+ @inject(CommitMessageRunner)
52
+ protected readonly commitMessageRunner: CommitMessageRunner;
53
+
54
+ @inject(AgentService)
55
+ protected readonly agentService: AgentService;
56
+
57
+ @inject(AIActivationService)
58
+ protected readonly aiActivationService: AIActivationService;
59
+
60
+ @inject(ILogger) @named('ai-ide:AiAwareScmCommitWidget')
61
+ protected readonly logger: ILogger;
62
+
63
+ @postConstruct()
64
+ protected initAiOverlay(): void {
65
+ this.toDispose.push(this.commitMessageRunner.onDidChange(() => this.update()));
66
+ this.toDispose.push(this.agentService.onDidChangeAgents(() => this.update()));
67
+ this.toDispose.push(this.aiActivationService.onDidChangeActiveStatus(() => this.update()));
68
+ // `canRun` carries conditions beyond `isActive` (e.g. workspace trust) and is what the
69
+ // command's `isEnabled` is gated on, so the button's enabled state has to follow it.
70
+ this.toDispose.push(this.aiActivationService.onDidChangeCanRun(() => this.update()));
71
+ }
72
+
73
+ protected override renderInput(input: ScmInput): React.ReactNode {
74
+ const baseInput = super.renderInput(input);
75
+ // The wrapper adds padding to make room for the button, so it must only be rendered when
76
+ // a button is actually rendered into it. Users with AI off keep the stock SCM input.
77
+ if (!this.shouldRenderAiButton(input)) {
78
+ return baseInput;
79
+ }
80
+ return <div className='theia-ai-commit-message-input-wrapper'>
81
+ {baseInput}
82
+ {this.renderAiOverlay(input)}
83
+ </div>;
84
+ }
85
+
86
+ protected shouldRenderAiButton(input: ScmInput): boolean {
87
+ if (!input.visible || !this.aiActivationService.isActive) {
88
+ return false;
89
+ }
90
+ // Without staged changes `git diff --cached` is empty, so the button would have nothing to
91
+ // work with. A running generation keeps it rendered so it stays cancellable even if the
92
+ // user unstages everything mid-run.
93
+ return this.commitMessageRunner.isRunning() || this.hasStagedChanges(this.scmService.selectedRepository?.provider);
94
+ }
95
+
96
+ protected renderAiOverlay(input: ScmInput): React.ReactNode {
97
+ const running = this.commitMessageRunner.isRunning();
98
+ const agentEnabled = this.agentService.isEnabled(COMMIT_MESSAGE_AGENT_ID);
99
+ // Mirrors `CommitMessageCommandContribution.isEnabled` as wrapped by
100
+ // `AICommandHandlerFactory`; a divergence would render an enabled button whose click ends
101
+ // in a `NO_ACTIVE_HANDLER` error. Cancelling is always allowed, so that the spinner keeps
102
+ // working while the git extension has the input disabled during a commit.
103
+ const disabled = !running && (!this.aiActivationService.canRun || !agentEnabled || !input.enabled);
104
+
105
+ let title: string;
106
+ if (running) {
107
+ title = nls.localize('theia/ai-ide/commit-message/cancel-tooltip', 'Cancel commit-message generation');
108
+ } else if (!agentEnabled) {
109
+ title = nls.localize(
110
+ 'theia/ai-ide/commit-message/agent-disabled-tooltip',
111
+ 'The Commit Message agent is disabled. Enable it in the AI Configuration view.'
112
+ );
113
+ } else {
114
+ title = nls.localize(
115
+ 'theia/ai-ide/commit-message/staged-tooltip',
116
+ 'Generate commit message from staged changes'
117
+ );
118
+ }
119
+ const iconClass = running ? `${codicon('loading')} codicon-modifier-spin` : codicon('sparkle');
120
+
121
+ return <div className='theia-ai-commit-message-overlay'>
122
+ <button
123
+ className='theia-ai-commit-message-icon'
124
+ type='button'
125
+ title={title}
126
+ aria-label={title}
127
+ disabled={disabled}
128
+ onClick={this.onAiButtonClick}>
129
+ <span className={iconClass} />
130
+ </button>
131
+ </div>;
132
+ }
133
+
134
+ protected onAiButtonClick = () => {
135
+ // Cancelling never goes through the command: its `isEnabled` requires an enabled commit
136
+ // input and a runnable AI, either of which may have flipped off since the run started.
137
+ if (this.commitMessageRunner.isRunning()) {
138
+ this.commitMessageRunner.cancel();
139
+ return;
140
+ }
141
+ this.commandService.executeCommand(CommitMessageCommands.GENERATE_FROM_STAGED.id).catch(error =>
142
+ this.logger.error('Failed to execute AI commit-message command', error)
143
+ );
144
+ };
145
+
146
+ protected hasStagedChanges(provider: ScmProvider | undefined): boolean {
147
+ if (provider?.id !== GIT_PROVIDER_ID) {
148
+ return false;
149
+ }
150
+ const staged = provider.groups.find(group => group.id === STAGED_GROUP_ID);
151
+ return !!staged && staged.resources.length > 0;
152
+ }
153
+ }
@@ -97,7 +97,7 @@ describe('AiConfigurationDetailWidget dispatch', () => {
97
97
  });
98
98
 
99
99
  /**
100
- * Pins the scrolling arithmetic of {@link AiConfigurationDetailWidget#centerInBody} and, more importantly,
100
+ * Pins the scrolling arithmetic of {@link AiConfigurationDetailWidget#revealInBody} and, more importantly,
101
101
  * that it writes only the scroll container.
102
102
  *
103
103
  * The predecessor was `element.scrollIntoView({ block: 'center' })`, which also scrolls every scrollable
@@ -106,7 +106,7 @@ describe('AiConfigurationDetailWidget dispatch', () => {
106
106
  * `clientHeight` are stubbed; it also does not implement `scrollIntoView`, so reverting to it fails every
107
107
  * assertion here (verified by patching the method back).
108
108
  */
109
- describe('AiConfigurationDetailWidget centerInBody', () => {
109
+ describe('AiConfigurationDetailWidget revealInBody', () => {
110
110
 
111
111
  before(() => disableJSDOM = enableJSDOM());
112
112
  after(() => disableJSDOM());
@@ -135,30 +135,30 @@ describe('AiConfigurationDetailWidget centerInBody', () => {
135
135
  return { ancestor, body, element };
136
136
  }
137
137
 
138
- it('scrolls the container so the row is vertically centred', () => {
138
+ it('scrolls the container so the row sits at its top', () => {
139
139
  const { body, element } = hierarchy(500);
140
- // Row sits 400px below the body's top; centring it in a 400px viewport puts it at (400 - 40) / 2 = 180.
141
- widget.centerInBody(body, element);
142
- expect(body.scrollTop).to.equal(220);
140
+ // Row sits 400px below the body's top, less the margin kept above it.
141
+ widget.revealInBody(body, element);
142
+ expect(body.scrollTop).to.equal(400 - AiConfigurationDetailWidget.TARGET_ROW_MARGIN);
143
143
  });
144
144
 
145
- it('leaves the scroll position alone when the row is already centred', () => {
146
- const { body, element } = hierarchy(280);
147
- widget.centerInBody(body, element);
145
+ it('leaves the scroll position alone when the row is already at the top', () => {
146
+ const { body, element } = hierarchy(100 + AiConfigurationDetailWidget.TARGET_ROW_MARGIN);
147
+ widget.revealInBody(body, element);
148
148
  expect(body.scrollTop).to.equal(0);
149
149
  });
150
150
 
151
- it('scrolls back up for a row above the centre', () => {
151
+ it('scrolls back up for a row above the current position', () => {
152
152
  const { body, element } = hierarchy(120);
153
- // 20px below the body's top, so it has to scroll up by 160 to reach the centre.
153
+ // 20px below the body's top, so it has to scroll up to put the row at the margin.
154
154
  body.scrollTop = 300;
155
- widget.centerInBody(body, element);
156
- expect(body.scrollTop).to.equal(140);
155
+ widget.revealInBody(body, element);
156
+ expect(body.scrollTop).to.equal(300 + 20 - AiConfigurationDetailWidget.TARGET_ROW_MARGIN);
157
157
  });
158
158
 
159
159
  it('never scrolls an ancestor of the container', () => {
160
160
  const { ancestor, body, element } = hierarchy(500);
161
- widget.centerInBody(body, element);
161
+ widget.revealInBody(body, element);
162
162
  expect(body.scrollTop, 'the container itself should have scrolled').to.not.equal(0);
163
163
  expect(ancestor.scrollTop, 'scrolling an ancestor shifts the whole shell').to.equal(0);
164
164
  });
@@ -33,6 +33,8 @@ export class AiConfigurationDetailWidget extends ReactWidget {
33
33
 
34
34
  /** Frames a pending search highlight waits for its target row to be mounted before it is dropped. */
35
35
  static readonly HIGHLIGHT_RETRY_FRAMES = 10;
36
+ /** Space kept above a row a deep link scrolled to, so that its section header stays visible. */
37
+ static readonly TARGET_ROW_MARGIN = 24;
36
38
 
37
39
  @inject(AiConfigurationCategoryRegistry)
38
40
  protected readonly registry: AiConfigurationCategoryRegistry;
@@ -201,7 +203,7 @@ export class AiConfigurationDetailWidget extends ReactWidget {
201
203
  const element = body.querySelector<HTMLElement>(`[data-ai-config-row-id="${highlight.rowId}"]`);
202
204
  if (element) {
203
205
  this.pendingHighlight = undefined;
204
- this.centerInBody(body, element);
206
+ this.revealInBody(body, element);
205
207
  element.classList.add('ai-configuration-row-flash');
206
208
  window.setTimeout(() => element.classList.remove('ai-configuration-row-flash'), 1200);
207
209
  } else if (this.pendingHighlightFrames < AiConfigurationDetailWidget.HIGHLIGHT_RETRY_FRAMES) {
@@ -218,20 +220,23 @@ export class AiConfigurationDetailWidget extends ReactWidget {
218
220
  }
219
221
 
220
222
  /**
221
- * Vertically centers `element` within `body` by scrolling that container and nothing else.
223
+ * Scrolls `body`, and nothing else, so that `element` sits at the top of it.
224
+ *
225
+ * Top rather than centered: a deep link lands on a page the user has not seen before, and a row in the
226
+ * middle of it reads as "the page opened somewhere", with the rows above it competing for the attention
227
+ * the flash is meant to direct. {@link TARGET_ROW_MARGIN} keeps the section header above the row visible.
222
228
  *
223
229
  * Deliberately not `Element.scrollIntoView`: that scrolls *every* scrollable ancestor to satisfy the
224
230
  * requested alignment, and `overflow: hidden` does not prevent programmatic scrolling. When a row cannot
225
- * be centered inside the body alone, it therefore nudges the `overflow: hidden` containers above this
226
- * widget, which have no scrollbar for the user to scroll back — shifting the whole shell, pushing the menu
231
+ * be revealed inside the body alone, it therefore nudges the `overflow: hidden` containers above this
232
+ * widget, which have no scrollbar for the user to scroll back, shifting the whole shell, pushing the menu
227
233
  * bar out of view and leaving a gap below the status bar.
228
234
  */
229
- protected centerInBody(body: HTMLElement, element: HTMLElement): void {
235
+ protected revealInBody(body: HTMLElement, element: HTMLElement): void {
230
236
  const bodyRect = body.getBoundingClientRect();
231
237
  const elementRect = element.getBoundingClientRect();
232
238
  const current = elementRect.top - bodyRect.top;
233
- const centered = (body.clientHeight - elementRect.height) / 2;
234
239
  // Assigning `scrollTop` clamps to the scrollable range, so short pages simply do not scroll.
235
- body.scrollTop += current - centered;
240
+ body.scrollTop += current - AiConfigurationDetailWidget.TARGET_ROW_MARGIN;
236
241
  }
237
242
  }
@@ -207,11 +207,19 @@ export class AiConfigurationTreeWidget extends TreeWidget {
207
207
  return match?.target;
208
208
  }
209
209
 
210
+ /**
211
+ * Selects a node on behalf of something other than a click on it — a deep link, a restored
212
+ * selection — and reveals what it holds: the category of an item, or the items of a category. A
213
+ * click needs neither, since the node it selects is already visible, and folding a category away
214
+ * only to have the next click on it unfold it again would be its own kind of rude.
215
+ */
210
216
  protected applyTreeSelection(node: SelectableTreeNode): void {
211
217
  this.updatingSelection = true;
212
218
  try {
213
219
  if (AiConfigurationItemNode.is(node) && ExpandableTreeNode.is(node.parent) && !node.parent.expanded) {
214
220
  this.model.expandNode(node.parent);
221
+ } else if (AiConfigurationCategoryNode.is(node) && ExpandableTreeNode.is(node) && !node.expanded) {
222
+ this.model.expandNode(node);
215
223
  }
216
224
  this.model.selectNode(node);
217
225
  } finally {
@@ -25,6 +25,7 @@ import { nls } from '@theia/core';
25
25
  import { codicon } from '@theia/core/lib/browser';
26
26
  import * as React from '@theia/core/shared/react';
27
27
  import { AiConfigurationItemRow } from '@theia/ai-core-ui/lib/browser/ai-configuration/components/ai-configuration-item-row';
28
+ import { AiConfigurationIconButton } from '@theia/ai-core-ui/lib/browser/ai-configuration/components/ai-configuration-primitives';
28
29
  import { AiSettingsRowService } from '@theia/ai-core-ui/lib/browser/ai-configuration/components/ai-settings-row-service';
29
30
  import { AiToggleSwitch } from '@theia/ai-core-ui/lib/browser/ai-configuration/components/ai-configuration-controls';
30
31
 
@@ -44,15 +45,13 @@ const AgentSectionHeader: React.FC<{ title: string; action?: React.ReactNode }>
44
45
  * button, so it reads as a Theia reset affordance. Its tooltip says what the section-wide reset does.
45
46
  */
46
47
  const ResetAllButton: React.FC<{ title: string; disabled?: boolean; onReset: () => void }> = ({ title, disabled, onReset }) => (
47
- <button
48
- className='ai-variant-action-button ai-agent-section-header-action'
49
- onClick={onReset}
50
- disabled={disabled}
48
+ <AiConfigurationIconButton
49
+ iconClass={codicon('discard')}
51
50
  title={title}
52
- aria-label={title}
53
- >
54
- <span aria-hidden='true' className={codicon('discard')}></span>
55
- </button>
51
+ disabled={disabled}
52
+ className='ai-agent-section-header-action'
53
+ onClick={onReset}
54
+ />
56
55
  );
57
56
 
58
57
  export interface AgentCapabilitiesSettingsProps {
@@ -21,7 +21,7 @@ import { getAgentIconClass } from '../agent-icon';
21
21
 
22
22
  /** A single navigable agent chip (icon + name); shared by the Variables, Skills and Slash Command surfaces. */
23
23
  export const AgentChip: React.FC<{ agent: Agent; onOpenAgent: (agentId: string) => void }> = ({ agent, onOpenAgent }) => {
24
- const label = nls.localize('theia/ai/ide/variableConfiguration/openAgent', 'Open agent {0}', agent.name);
24
+ const label = nls.localizeByDefault('Open agent {0}', agent.name);
25
25
  return <button
26
26
  type='button'
27
27
  className='agent-chip'
@@ -32,8 +32,8 @@ function alias(id: string, description = ''): LanguageModelAlias {
32
32
  return { id, description, defaultModelIds: [] } as unknown as LanguageModelAlias;
33
33
  }
34
34
 
35
- function readyModel(id: string): LanguageModel {
36
- return { id, status: { status: 'ready' } } as unknown as LanguageModel;
35
+ function readyModel(id: string, released?: number): LanguageModel {
36
+ return { id, released, status: { status: 'ready' } } as unknown as LanguageModel;
37
37
  }
38
38
 
39
39
  function unreadyModel(id: string): LanguageModel {
@@ -77,6 +77,44 @@ describe('ModelAliasesConfigurationCategory', () => {
77
77
  expect(children[2].status?.kind).to.equal('error');
78
78
  });
79
79
 
80
+ it('divides the models per provider, newest first, after the default-list entry', () => {
81
+ const category = createCategory([], new Map());
82
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
83
+ (category as any).languageModels = [
84
+ readyModel('openai/gpt-5.5', Date.parse('2025-08-01')),
85
+ readyModel('anthropic/claude-haiku-4-5', Date.parse('2025-10-01')),
86
+ readyModel('openai/gpt-5.6-sol', Date.parse('2026-02-01')),
87
+ readyModel('anthropic/claude-opus-5', Date.parse('2026-04-01'))
88
+ ];
89
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
90
+ const options = (category as any).getModelOptions() as Array<{ value?: string; label?: string; separator?: boolean }>;
91
+ expect(options[0].value).to.equal('');
92
+ // A rule between the providers rather than a heading: the select draws separators as plain lines.
93
+ expect(options.slice(1).map(option => option.separator ? '---' : option.value)).to.deep.equal([
94
+ '---',
95
+ 'anthropic/claude-opus-5',
96
+ 'anthropic/claude-haiku-4-5',
97
+ '---',
98
+ 'openai/gpt-5.6-sol',
99
+ 'openai/gpt-5.5'
100
+ ]);
101
+ });
102
+
103
+ it('keeps the provider rules when mapping the options onto the select', () => {
104
+ const category = createCategory([], new Map());
105
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
106
+ const mapped = (category as any).toEnumOptions([
107
+ { value: 'a', label: 'A', description: 'first' },
108
+ { separator: true },
109
+ { value: 'b' }
110
+ ]) as Array<{ value: string; label: string; title?: string; separator?: boolean }>;
111
+ // A rule that loses its `separator` becomes a selectable entry with no label that clears the alias.
112
+ expect(mapped.map(option => option.separator ? '---' : option.value)).to.deep.equal(['a', '---', 'b']);
113
+ expect(mapped[0].title).to.equal('first');
114
+ // Without a label of its own, an option falls back to its value rather than rendering empty.
115
+ expect(mapped[2].label).to.equal('b');
116
+ });
117
+
80
118
  it('indexes one search item per alias, navigating to the item', () => {
81
119
  const category = createCategory([alias('default/code', 'Coding alias')], new Map());
82
120
  const items = category.getSearchItems();
@@ -14,7 +14,7 @@
14
14
  // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
15
  // *****************************************************************************
16
16
 
17
- import { AgentService, AISettingsService, LANGUAGE_MODEL_ALIASES_PREFERENCE } from '@theia/ai-core';
17
+ import { AgentService, AISettingsService, groupModelsByProvider, LANGUAGE_MODEL_ALIASES_PREFERENCE } from '@theia/ai-core';
18
18
  import { FrontendLanguageModelRegistry, LanguageModel, LanguageModelRegistry, LanguageModelRequirement } from '@theia/ai-core/lib/common/language-model';
19
19
  import { LanguageModelAlias, LanguageModelAliasRegistry } from '@theia/ai-core/lib/common/language-model-alias';
20
20
  import { Emitter, Event, nls } from '@theia/core';
@@ -35,7 +35,7 @@ import {
35
35
  } from '@theia/ai-core-ui/lib/browser/ai-configuration/ai-configuration-category';
36
36
  import { CollectionCategoryRenderer } from '@theia/ai-core-ui/lib/browser/ai-configuration/renderers/collection-category-renderer';
37
37
  import { AiConfigurationSection } from '@theia/ai-core-ui/lib/browser/ai-configuration/components/ai-configuration-primitives';
38
- import { AiEnumSelect } from '@theia/ai-core-ui/lib/browser/ai-configuration/components/ai-configuration-controls';
38
+ import { AiEnumOption, AiEnumSelect } from '@theia/ai-core-ui/lib/browser/ai-configuration/components/ai-configuration-controls';
39
39
  import { AgentChips } from './agent-chips';
40
40
 
41
41
  /** Sentinel option value representing "use the alias' default priority list" (no explicit model). */
@@ -214,28 +214,49 @@ export class ModelAliasesConfigurationCategory extends CollectionCategoryRendere
214
214
  ariaLabel={nls.localize('theia/ai/core/modelAliasesConfiguration/selectedModelId', 'Selected Model')}
215
215
  value={isInvalid ? undefined : selected}
216
216
  invalid={isInvalid}
217
- options={options.map(option => ({ value: String(option.value ?? ''), label: option.label ?? String(option.value ?? ''), title: option.description }))}
217
+ options={this.toEnumOptions(options)}
218
218
  onCommit={value => this.setSelectedModel(alias, value)}
219
219
  />
220
220
  </AiConfigurationSection>;
221
221
  }
222
222
 
223
+ /**
224
+ * Maps the options onto what {@link AiEnumSelect} takes. The per-provider rules have to survive the
225
+ * mapping: dropping `separator` turns each of them into a selectable entry with no label that
226
+ * clears the alias when picked.
227
+ */
228
+ protected toEnumOptions(options: SelectOption[]): AiEnumOption[] {
229
+ return options.map(option => ({
230
+ value: String(option.value ?? ''),
231
+ label: option.label ?? String(option.value ?? ''),
232
+ title: option.description,
233
+ separator: option.separator
234
+ }));
235
+ }
236
+
223
237
  protected getModelOptions(): SelectOption[] {
224
238
  const options: SelectOption[] = [{
225
239
  value: DEFAULT_LIST_VALUE,
226
240
  label: nls.localize('theia/ai/core/modelAliasesConfiguration/defaultList', '[Default list]')
227
241
  }];
228
- for (const model of [...this.languageModels].sort((a, b) => (a.name ?? a.id).localeCompare(b.name ?? b.id))) {
229
- const ready = model.status.status === 'ready';
230
- options.push({
231
- value: model.id,
232
- label: `${model.name ?? model.id} ${ready ? '✓' : '✗'}`,
233
- description: !ready ? model.status.message : undefined
234
- });
242
+ // Divided per provider, newest first inside each group. An alias names a concrete model, which
243
+ // includes the older and release-pinned ones, so the whole catalogue is offered here.
244
+ for (const { models } of groupModelsByProvider(this.languageModels)) {
245
+ options.push({ separator: true });
246
+ options.push(...models.map(model => this.toModelOption(model)));
235
247
  }
236
248
  return options;
237
249
  }
238
250
 
251
+ protected toModelOption(model: LanguageModel): SelectOption {
252
+ const ready = model.status.status === 'ready';
253
+ return {
254
+ value: model.id,
255
+ label: `${model.name ?? model.id} ${ready ? '✓' : '✗'}`,
256
+ description: !ready ? model.status.message : undefined
257
+ };
258
+ }
259
+
239
260
  protected setSelectedModel(alias: LanguageModelAlias, value: string | undefined): void {
240
261
  const selectedModelId = value ? value : undefined;
241
262
  this.languageModelAliasRegistry.ready.then(() => this.languageModelAliasRegistry.addAlias({ ...alias, selectedModelId }));
@@ -23,6 +23,8 @@ FrontendApplicationConfigProvider.set({});
23
23
  import { expect } from 'chai';
24
24
  import { AiSettingsControl, AiSettingsRowService } from '@theia/ai-core-ui/lib/browser/ai-configuration/components/ai-settings-row-service';
25
25
  import { AiConfigurationCategoryId } from '@theia/ai-core-ui/lib/browser/ai-configuration/ai-configuration-category';
26
+ import { ModelDiscoveryStatusService } from '@theia/ai-core/lib/browser';
27
+ import { ModelDiscoveryStatus } from '@theia/ai-core/lib/common/model-discovery-status';
26
28
  import { ModelsConfigurationCategory } from './models-configuration-category';
27
29
 
28
30
  disableJSDOM();
@@ -69,7 +71,7 @@ describe('ModelsConfigurationCategory', () => {
69
71
  'ai-features.modelSettings.requestSettings',
70
72
  'ai-features.reasoning.defaults',
71
73
  'ai-features.anthropic.AnthropicApiKey',
72
- 'ai-features.anthropic.AnthropicModels',
74
+ 'ai-features.anthropic.serverSideCompaction',
73
75
  'ai-features.google.apiKey',
74
76
  // must be ignored: owned by other categories / unrelated
75
77
  'ai-features.chat.pinChatAgent',
@@ -84,7 +86,7 @@ describe('ModelsConfigurationCategory', () => {
84
86
  const sections = (category as any).getSections() as Array<{ id: string; preferenceIds: string[] }>;
85
87
  expect(sections.map(s => s.id)).to.deep.equal(['model-settings', 'anthropic', 'google']);
86
88
  expect(sections[0].preferenceIds).to.have.members(['ai-features.modelSettings.requestSettings', 'ai-features.reasoning.defaults']);
87
- expect(sections[1].preferenceIds).to.deep.equal(['ai-features.anthropic.AnthropicApiKey', 'ai-features.anthropic.AnthropicModels']);
89
+ expect(sections[1].preferenceIds).to.deep.equal(['ai-features.anthropic.AnthropicApiKey', 'ai-features.anthropic.serverSideCompaction']);
88
90
  expect(sections[2].preferenceIds).to.deep.equal(['ai-features.google.apiKey']);
89
91
  });
90
92
 
@@ -164,4 +166,47 @@ describe('ModelsConfigurationCategory', () => {
164
166
  highlight: { rowId: 'ai-features.anthropic.AnthropicApiKey' }
165
167
  });
166
168
  });
169
+
170
+ describe('model discovery section', () => {
171
+
172
+ function withDiscovery(statuses: ModelDiscoveryStatus[], modelIds: string[]): ModelsConfigurationCategory {
173
+ const category = createCategory([]);
174
+ const service: Partial<ModelDiscoveryStatusService> = {
175
+ getStatus: (providerId: string) => statuses.find(status => status.providerId === providerId)
176
+ };
177
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
178
+ (category as any).discoveryStatus = service;
179
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
180
+ (category as any).models = modelIds.map(id => ({ id }));
181
+ return category;
182
+ }
183
+
184
+ function discoveredModelIds(category: ModelsConfigurationCategory, providerId: string): string[] | undefined {
185
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
186
+ const rendered = (category as any).renderModelDiscovery(providerId) as
187
+ { props: { models: { id: string }[] } } | undefined;
188
+ return rendered && rendered.props.models.map(model => model.id);
189
+ }
190
+
191
+ it('renders nothing for a provider that does not participate in discovery', () => {
192
+ const category = withDiscovery([], ['anthropic/claude-opus-5']);
193
+ expect(discoveredModelIds(category, 'anthropic')).to.equal(undefined);
194
+ });
195
+
196
+ it('lists the provider\'s own models, keyed by the provider id', () => {
197
+ const category = withDiscovery(
198
+ [{ providerId: 'anthropic', label: 'Anthropic', state: 'ready' }],
199
+ ['anthropic/claude-opus-5', 'google/gemini-3.1-pro', 'anthropic-custom/foo']
200
+ );
201
+ expect(discoveredModelIds(category, 'anthropic')).to.deep.equal(['anthropic/claude-opus-5']);
202
+ });
203
+
204
+ it('honours a model-id prefix that differs from the provider id', () => {
205
+ const category = withDiscovery(
206
+ [{ providerId: 'openAiOfficial', label: 'OpenAI', modelIdPrefix: 'openai', state: 'ready' }],
207
+ ['openai/gpt-5.5', 'openAiOfficial/gpt-5.5', 'openai-custom/local']
208
+ );
209
+ expect(discoveredModelIds(category, 'openAiOfficial')).to.deep.equal(['openai/gpt-5.5']);
210
+ });
211
+ });
167
212
  });
@@ -14,7 +14,7 @@
14
14
  // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
15
  // *****************************************************************************
16
16
 
17
- import { Emitter, Event, nls } from '@theia/core';
17
+ import { CommandService, Emitter, Event, nls } from '@theia/core';
18
18
  import { codicon } from '@theia/core/lib/browser';
19
19
  import { DisposableCollection } from '@theia/core/lib/common';
20
20
  import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
@@ -32,6 +32,9 @@ import { AiSettingsRowService } from '@theia/ai-core-ui/lib/browser/ai-configura
32
32
  import { AiConfigurationSection } from '@theia/ai-core-ui/lib/browser/ai-configuration/components/ai-configuration-primitives';
33
33
  import { CollectionCategoryRenderer } from '@theia/ai-core-ui/lib/browser/ai-configuration/renderers/collection-category-renderer';
34
34
  import { AiSettingsRow } from '@theia/ai-core-ui/lib/browser/ai-configuration/components/ai-settings-row';
35
+ import { LanguageModel, LanguageModelRegistry } from '@theia/ai-core/lib/common';
36
+ import { FavoriteModelsService, ModelDiscoveryStatusService } from '@theia/ai-core/lib/browser';
37
+ import { ProviderModelDiscoverySection } from './provider-model-discovery-section';
35
38
 
36
39
  /** Prefix shared by every AI preference. */
37
40
  const AI_FEATURES_PREFIX = 'ai-features.';
@@ -73,10 +76,25 @@ export class ModelsConfigurationCategory extends CollectionCategoryRenderer impl
73
76
  @inject(AiSettingsRowService)
74
77
  protected readonly settingsRowService: AiSettingsRowService;
75
78
 
79
+ @inject(ModelDiscoveryStatusService)
80
+ protected readonly discoveryStatus: ModelDiscoveryStatusService;
81
+
82
+ @inject(FavoriteModelsService)
83
+ protected readonly favoriteModels: FavoriteModelsService;
84
+
85
+ @inject(CommandService)
86
+ protected readonly commandService: CommandService;
87
+
88
+ @inject(LanguageModelRegistry)
89
+ protected readonly languageModelRegistry: LanguageModelRegistry;
90
+
76
91
  protected readonly onDidChangeEmitter = new Emitter<void>();
77
92
  readonly onDidChange: Event<void> = this.onDidChangeEmitter.event;
78
93
  protected readonly toDispose = new DisposableCollection(this.onDidChangeEmitter);
79
94
 
95
+ /** Registered models, kept in sync so the provider detail can list them synchronously while rendering. */
96
+ protected models: LanguageModel[] = [];
97
+
80
98
  get renderer(): this {
81
99
  return this;
82
100
  }
@@ -96,6 +114,14 @@ export class ModelsConfigurationCategory extends CollectionCategoryRenderer impl
96
114
  @postConstruct()
97
115
  protected init(): void {
98
116
  this.toDispose.push(this.settingsRowService.onPreferenceChanged(() => this.onDidChangeEmitter.fire()));
117
+ this.toDispose.push(this.discoveryStatus.onDidChange(() => this.onDidChangeEmitter.fire()));
118
+ this.toDispose.push(this.languageModelRegistry.onChange(() => this.updateModels()));
119
+ this.updateModels();
120
+ }
121
+
122
+ protected async updateModels(): Promise<void> {
123
+ this.models = await this.languageModelRegistry.getLanguageModels();
124
+ this.onDidChangeEmitter.fire();
99
125
  }
100
126
 
101
127
  dispose(): void {
@@ -130,15 +156,36 @@ export class ModelsConfigurationCategory extends CollectionCategoryRenderer impl
130
156
  </AiConfigurationSection>;
131
157
  }
132
158
 
133
- /** Provider detail (node selected): that provider's settings. */
159
+ /** Provider detail (node selected): that provider's settings, followed by its model discovery. */
134
160
  protected renderItemSections(item: AiConfigurationTreeItem, ctx: AiConfigurationRenderContext): React.ReactNode {
135
161
  const section = this.getProviderSections().find(candidate => candidate.id === item.id);
136
162
  if (!section) {
137
163
  return undefined;
138
164
  }
139
- return <AiConfigurationSection title={nls.localizeByDefault('Settings')}>
140
- {section.preferenceIds.map(preferenceId => this.renderPreferenceRow(ctx, preferenceId))}
141
- </AiConfigurationSection>;
165
+ return <>
166
+ <AiConfigurationSection title={nls.localizeByDefault('Settings')}>
167
+ {section.preferenceIds.map(preferenceId => this.renderPreferenceRow(ctx, preferenceId))}
168
+ </AiConfigurationSection>
169
+ {this.renderModelDiscovery(item.id)}
170
+ </>;
171
+ }
172
+
173
+ /** Only providers that participate in dynamic model discovery contribute this section. */
174
+ protected renderModelDiscovery(providerId: string): React.ReactNode {
175
+ const status = this.discoveryStatus.getStatus(providerId);
176
+ if (!status) {
177
+ return undefined;
178
+ }
179
+ // A provider's registered model ids may carry a prefix that differs from its preference segment
180
+ // (e.g. the `openAiOfficial` block registers `openai/…` models).
181
+ const prefix = `${status.modelIdPrefix ?? providerId}/`;
182
+ return <ProviderModelDiscoverySection
183
+ status={status}
184
+ models={this.models.filter(model => model.id.startsWith(prefix))}
185
+ favorites={this.favoriteModels}
186
+ onRefresh={() => this.discoveryStatus.refresh(providerId)}
187
+ onAction={action => this.commandService.executeCommand(action.commandId, ...(action.args ?? []))}
188
+ />;
142
189
  }
143
190
 
144
191
  protected renderPreferenceRow(ctx: AiConfigurationRenderContext, preferenceId: string): React.ReactNode {