@theia/ai-core-ui 1.75.0-next.8 → 1.75.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 (113) hide show
  1. package/lib/browser/ai-configuration/ai-configuration-category-registry.d.ts +29 -0
  2. package/lib/browser/ai-configuration/ai-configuration-category-registry.d.ts.map +1 -0
  3. package/lib/browser/ai-configuration/ai-configuration-category-registry.js +83 -0
  4. package/lib/browser/ai-configuration/ai-configuration-category-registry.js.map +1 -0
  5. package/lib/browser/ai-configuration/ai-configuration-category-registry.spec.d.ts +2 -0
  6. package/lib/browser/ai-configuration/ai-configuration-category-registry.spec.d.ts.map +1 -0
  7. package/lib/browser/ai-configuration/ai-configuration-category-registry.spec.js +122 -0
  8. package/lib/browser/ai-configuration/ai-configuration-category-registry.spec.js.map +1 -0
  9. package/lib/browser/ai-configuration/ai-configuration-category.d.ts +204 -0
  10. package/lib/browser/ai-configuration/ai-configuration-category.d.ts.map +1 -0
  11. package/lib/browser/ai-configuration/ai-configuration-category.js +86 -0
  12. package/lib/browser/ai-configuration/ai-configuration-category.js.map +1 -0
  13. package/lib/browser/ai-configuration/ai-configuration-selection-model.d.ts +15 -0
  14. package/lib/browser/ai-configuration/ai-configuration-selection-model.d.ts.map +1 -0
  15. package/lib/browser/ai-configuration/ai-configuration-selection-model.js +44 -0
  16. package/lib/browser/ai-configuration/ai-configuration-selection-model.js.map +1 -0
  17. package/lib/browser/ai-configuration/ai-configuration-setting-commands.d.ts +48 -0
  18. package/lib/browser/ai-configuration/ai-configuration-setting-commands.d.ts.map +1 -0
  19. package/lib/browser/ai-configuration/ai-configuration-setting-commands.js +98 -0
  20. package/lib/browser/ai-configuration/ai-configuration-setting-commands.js.map +1 -0
  21. package/lib/browser/ai-configuration/ai-configuration-setting-commands.spec.d.ts +2 -0
  22. package/lib/browser/ai-configuration/ai-configuration-setting-commands.spec.d.ts.map +1 -0
  23. package/lib/browser/ai-configuration/ai-configuration-setting-commands.spec.js +70 -0
  24. package/lib/browser/ai-configuration/ai-configuration-setting-commands.spec.js.map +1 -0
  25. package/lib/browser/ai-configuration/components/ai-configuration-components.spec.d.ts +2 -0
  26. package/lib/browser/ai-configuration/components/ai-configuration-components.spec.d.ts.map +1 -0
  27. package/lib/browser/ai-configuration/components/ai-configuration-components.spec.js +596 -0
  28. package/lib/browser/ai-configuration/components/ai-configuration-components.spec.js.map +1 -0
  29. package/lib/browser/ai-configuration/components/ai-configuration-controls.d.ts +118 -0
  30. package/lib/browser/ai-configuration/components/ai-configuration-controls.d.ts.map +1 -0
  31. package/lib/browser/ai-configuration/components/ai-configuration-controls.js +191 -0
  32. package/lib/browser/ai-configuration/components/ai-configuration-controls.js.map +1 -0
  33. package/lib/browser/ai-configuration/components/ai-configuration-item-row.d.ts +57 -0
  34. package/lib/browser/ai-configuration/components/ai-configuration-item-row.d.ts.map +1 -0
  35. package/lib/browser/ai-configuration/components/ai-configuration-item-row.js +48 -0
  36. package/lib/browser/ai-configuration/components/ai-configuration-item-row.js.map +1 -0
  37. package/lib/browser/ai-configuration/components/ai-configuration-origin-badge.d.ts +49 -0
  38. package/lib/browser/ai-configuration/components/ai-configuration-origin-badge.d.ts.map +1 -0
  39. package/lib/browser/ai-configuration/components/ai-configuration-origin-badge.js +79 -0
  40. package/lib/browser/ai-configuration/components/ai-configuration-origin-badge.js.map +1 -0
  41. package/lib/browser/ai-configuration/components/ai-configuration-primitives.d.ts +161 -0
  42. package/lib/browser/ai-configuration/components/ai-configuration-primitives.d.ts.map +1 -0
  43. package/lib/browser/ai-configuration/components/ai-configuration-primitives.js +131 -0
  44. package/lib/browser/ai-configuration/components/ai-configuration-primitives.js.map +1 -0
  45. package/lib/browser/ai-configuration/components/ai-configuration-setting-row.d.ts +54 -0
  46. package/lib/browser/ai-configuration/components/ai-configuration-setting-row.d.ts.map +1 -0
  47. package/lib/browser/ai-configuration/components/ai-configuration-setting-row.js +77 -0
  48. package/lib/browser/ai-configuration/components/ai-configuration-setting-row.js.map +1 -0
  49. package/lib/browser/ai-configuration/components/ai-settings-row-service.d.ts +194 -0
  50. package/lib/browser/ai-configuration/components/ai-settings-row-service.d.ts.map +1 -0
  51. package/lib/browser/ai-configuration/components/ai-settings-row-service.js +352 -0
  52. package/lib/browser/ai-configuration/components/ai-settings-row-service.js.map +1 -0
  53. package/lib/browser/ai-configuration/components/ai-settings-row-service.spec.d.ts +2 -0
  54. package/lib/browser/ai-configuration/components/ai-settings-row-service.spec.d.ts.map +1 -0
  55. package/lib/browser/ai-configuration/components/ai-settings-row-service.spec.js +233 -0
  56. package/lib/browser/ai-configuration/components/ai-settings-row-service.spec.js.map +1 -0
  57. package/lib/browser/ai-configuration/components/ai-settings-row.d.ts +50 -0
  58. package/lib/browser/ai-configuration/components/ai-settings-row.d.ts.map +1 -0
  59. package/lib/browser/ai-configuration/components/ai-settings-row.js +85 -0
  60. package/lib/browser/ai-configuration/components/ai-settings-row.js.map +1 -0
  61. package/lib/browser/ai-configuration/components/collapsible-list.d.ts +65 -0
  62. package/lib/browser/ai-configuration/components/collapsible-list.d.ts.map +1 -0
  63. package/lib/browser/ai-configuration/components/collapsible-list.js +82 -0
  64. package/lib/browser/ai-configuration/components/collapsible-list.js.map +1 -0
  65. package/lib/browser/ai-configuration/components/prompt-customization-dialogs.d.ts +35 -0
  66. package/lib/browser/ai-configuration/components/prompt-customization-dialogs.d.ts.map +1 -0
  67. package/lib/browser/ai-configuration/components/prompt-customization-dialogs.js +83 -0
  68. package/lib/browser/ai-configuration/components/prompt-customization-dialogs.js.map +1 -0
  69. package/lib/browser/ai-configuration/components/variant-set-card.d.ts +25 -0
  70. package/lib/browser/ai-configuration/components/variant-set-card.d.ts.map +1 -0
  71. package/lib/browser/ai-configuration/components/variant-set-card.js +133 -0
  72. package/lib/browser/ai-configuration/components/variant-set-card.js.map +1 -0
  73. package/lib/browser/ai-configuration/renderers/collection-category-renderer.d.ts +44 -0
  74. package/lib/browser/ai-configuration/renderers/collection-category-renderer.d.ts.map +1 -0
  75. package/lib/browser/ai-configuration/renderers/collection-category-renderer.js +106 -0
  76. package/lib/browser/ai-configuration/renderers/collection-category-renderer.js.map +1 -0
  77. package/lib/browser/ai-configuration/renderers/collection-category-renderer.spec.d.ts +2 -0
  78. package/lib/browser/ai-configuration/renderers/collection-category-renderer.spec.d.ts.map +1 -0
  79. package/lib/browser/ai-configuration/renderers/collection-category-renderer.spec.js +120 -0
  80. package/lib/browser/ai-configuration/renderers/collection-category-renderer.spec.js.map +1 -0
  81. package/lib/browser/ai-configuration/renderers/single-page-category-renderer.d.ts +15 -0
  82. package/lib/browser/ai-configuration/renderers/single-page-category-renderer.d.ts.map +1 -0
  83. package/lib/browser/ai-configuration/renderers/single-page-category-renderer.js +20 -0
  84. package/lib/browser/ai-configuration/renderers/single-page-category-renderer.js.map +1 -0
  85. package/lib/browser/ai-core-ui-frontend-module.d.ts +1 -0
  86. package/lib/browser/ai-core-ui-frontend-module.d.ts.map +1 -1
  87. package/lib/browser/ai-core-ui-frontend-module.js +11 -0
  88. package/lib/browser/ai-core-ui-frontend-module.js.map +1 -1
  89. package/package.json +6 -5
  90. package/src/browser/ai-configuration/ai-configuration-category-registry.spec.ts +137 -0
  91. package/src/browser/ai-configuration/ai-configuration-category-registry.ts +79 -0
  92. package/src/browser/ai-configuration/ai-configuration-category.ts +246 -0
  93. package/src/browser/ai-configuration/ai-configuration-selection-model.ts +42 -0
  94. package/src/browser/ai-configuration/ai-configuration-setting-commands.spec.ts +82 -0
  95. package/src/browser/ai-configuration/ai-configuration-setting-commands.ts +104 -0
  96. package/src/browser/ai-configuration/components/ai-configuration-components.spec.ts +655 -0
  97. package/src/browser/ai-configuration/components/ai-configuration-controls.tsx +382 -0
  98. package/src/browser/ai-configuration/components/ai-configuration-item-row.tsx +127 -0
  99. package/src/browser/ai-configuration/components/ai-configuration-origin-badge.tsx +114 -0
  100. package/src/browser/ai-configuration/components/ai-configuration-primitives.tsx +359 -0
  101. package/src/browser/ai-configuration/components/ai-configuration-setting-row.tsx +154 -0
  102. package/src/browser/ai-configuration/components/ai-settings-row-service.spec.ts +275 -0
  103. package/src/browser/ai-configuration/components/ai-settings-row-service.ts +401 -0
  104. package/src/browser/ai-configuration/components/ai-settings-row.tsx +176 -0
  105. package/src/browser/ai-configuration/components/collapsible-list.tsx +205 -0
  106. package/src/browser/ai-configuration/components/prompt-customization-dialogs.ts +89 -0
  107. package/src/browser/ai-configuration/components/variant-set-card.tsx +200 -0
  108. package/src/browser/ai-configuration/renderers/collection-category-renderer.spec.ts +126 -0
  109. package/src/browser/ai-configuration/renderers/collection-category-renderer.tsx +164 -0
  110. package/src/browser/ai-configuration/renderers/single-page-category-renderer.tsx +41 -0
  111. package/src/browser/ai-core-ui-frontend-module.ts +14 -1
  112. package/src/browser/style/ai-configuration-components.css +1410 -0
  113. package/src/browser/style/index.css +17 -0
@@ -0,0 +1,275 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource and others.
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 { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
18
+ let disableJSDOM = enableJSDOM();
19
+
20
+ import { CommandService, PreferenceInspection, PreferenceScope } from '@theia/core/lib/common';
21
+ import { PreferenceDataProperty, PreferenceSchemaService } from '@theia/core/lib/common/preferences/preference-schema';
22
+ import { PreferencesCommands } from '@theia/preferences/lib/browser/util/preference-types';
23
+ import { AiConfigurationService } from '@theia/ai-core/lib/common/ai-configuration-service';
24
+ import { expect } from 'chai';
25
+ import { AiSettingsRowService } from './ai-settings-row-service';
26
+
27
+ disableJSDOM();
28
+
29
+ interface RecordedSet {
30
+ preferenceId: string;
31
+ value: unknown;
32
+ scope: PreferenceScope | undefined;
33
+ resourceUri: string | undefined;
34
+ }
35
+
36
+ class FakeAiConfigurationService {
37
+ inspection: Partial<PreferenceInspection<unknown>> = {};
38
+ readonly sets: RecordedSet[] = [];
39
+
40
+ inspect(): PreferenceInspection<unknown> | undefined {
41
+ return {
42
+ preferenceName: 'test',
43
+ defaultValue: undefined,
44
+ globalValue: undefined,
45
+ workspaceValue: undefined,
46
+ workspaceFolderValue: undefined,
47
+ value: undefined,
48
+ ...this.inspection
49
+ };
50
+ }
51
+
52
+ set(preferenceId: string, value: unknown, scope?: PreferenceScope, resourceUri?: string): Promise<void> {
53
+ this.sets.push({ preferenceId, value, scope, resourceUri });
54
+ return Promise.resolve();
55
+ }
56
+ }
57
+
58
+ function createService(inspection: Partial<PreferenceInspection<unknown>>): { service: AiSettingsRowService; preferences: FakeAiConfigurationService } {
59
+ const preferences = new FakeAiConfigurationService();
60
+ preferences.inspection = inspection;
61
+ const service = new AiSettingsRowService();
62
+ (service as unknown as { aiConfigurationService: AiConfigurationService }).aiConfigurationService = preferences as unknown as AiConfigurationService;
63
+ return { service, preferences };
64
+ }
65
+
66
+ function createServiceWithSchema(property: PreferenceDataProperty | undefined): AiSettingsRowService {
67
+ const service = new AiSettingsRowService();
68
+ const schemaService: Partial<PreferenceSchemaService> = {
69
+ getSchemaProperty: () => property
70
+ };
71
+ (service as unknown as { schemaService: PreferenceSchemaService }).schemaService = schemaService as PreferenceSchemaService;
72
+ return service;
73
+ }
74
+
75
+ describe('AiSettingsRowService', () => {
76
+
77
+ before(() => disableJSDOM = enableJSDOM());
78
+ after(() => disableJSDOM());
79
+
80
+ describe('inspect', () => {
81
+ it('resolves the user-scope value falling back to the default', () => {
82
+ const { service } = createService({ defaultValue: 'd', globalValue: undefined });
83
+ const result = service.inspect('pref', 'user');
84
+ expect(result.value).to.equal('d');
85
+ expect(result.scopeValue).to.equal(undefined);
86
+ expect(result.modified).to.equal(false);
87
+ });
88
+
89
+ it('reports a user-scope override as modified', () => {
90
+ const { service } = createService({ defaultValue: 'd', globalValue: 'u' });
91
+ const result = service.inspect('pref', 'user');
92
+ expect(result.value).to.equal('u');
93
+ expect(result.scopeValue).to.equal('u');
94
+ expect(result.modified).to.equal(true);
95
+ });
96
+
97
+ it('resolves the workspace value falling back through user then default', () => {
98
+ const { service } = createService({ defaultValue: 'd', globalValue: 'u' });
99
+ const workspace = service.inspect('pref', 'workspace');
100
+ expect(workspace.value).to.equal('u');
101
+ expect(workspace.scopeValue).to.equal(undefined);
102
+ expect(workspace.modified).to.equal(false);
103
+
104
+ const { service: service2 } = createService({ defaultValue: 'd', globalValue: 'u', workspaceValue: 'w' });
105
+ const workspace2 = service2.inspect('pref', 'workspace');
106
+ expect(workspace2.value).to.equal('w');
107
+ expect(workspace2.modified).to.equal(true);
108
+ });
109
+
110
+ it('resolves the folder value falling back through workspace, user then default', () => {
111
+ const { service } = createService({ defaultValue: 'd', globalValue: 'u', workspaceValue: 'w' });
112
+ const folder = service.inspect('pref', 'folder');
113
+ expect(folder.value).to.equal('w');
114
+ expect(folder.modified).to.equal(false);
115
+
116
+ const { service: service2 } = createService({ defaultValue: 'd', workspaceFolderValue: 'f' });
117
+ const folder2 = service2.inspect('pref', 'folder');
118
+ expect(folder2.value).to.equal('f');
119
+ expect(folder2.modified).to.equal(true);
120
+ });
121
+
122
+ it('treats a value of `false` as an explicit override (not inherited)', () => {
123
+ const { service } = createService({ defaultValue: true, globalValue: false });
124
+ const result = service.inspect('pref', 'user');
125
+ expect(result.value).to.equal(false);
126
+ expect(result.modified).to.equal(true);
127
+ });
128
+ });
129
+
130
+ describe('controlFor', () => {
131
+ it('maps primitive schema types to their inline controls', () => {
132
+ expect(createServiceWithSchema({ type: 'boolean' }).controlFor('pref').type).to.equal('boolean');
133
+ expect(createServiceWithSchema({ type: 'number' }).controlFor('pref').type).to.equal('number');
134
+ expect(createServiceWithSchema({ type: 'integer' }).controlFor('pref').type).to.equal('number');
135
+ expect(createServiceWithSchema({ type: 'string' }).controlFor('pref').type).to.equal('string');
136
+ expect(createServiceWithSchema(undefined).controlFor('pref').type).to.equal('string');
137
+ });
138
+
139
+ it('marks only an integer schema as whole-number, so a number keeps what the user typed', () => {
140
+ // Both render the stepper; only the integer one may round on commit.
141
+ expect(createServiceWithSchema({ type: 'integer' }).controlFor('pref')).to.deep.include({ type: 'number', integer: true });
142
+ expect(createServiceWithSchema({ type: 'number' }).controlFor('pref')).to.not.have.property('integer');
143
+ });
144
+
145
+ it('maps an enum to a select regardless of its base type', () => {
146
+ expect(createServiceWithSchema({ type: 'string', enum: ['a', 'b'] }).controlFor('pref').type).to.equal('select');
147
+ });
148
+
149
+ it('masks secret string preferences (API keys/tokens) with a password input', () => {
150
+ const control = createServiceWithSchema({ type: 'string' }).controlFor('ai-features.anthropic.apiKey');
151
+ expect(control).to.deep.include({ type: 'string', password: true });
152
+ expect(createServiceWithSchema({ type: 'string' }).controlFor('ai-features.anthropic.model'))
153
+ .to.deep.include({ type: 'string', password: false });
154
+ });
155
+
156
+ it('renders the inline list editor for string arrays', () => {
157
+ expect(createServiceWithSchema({ type: 'array', items: { type: 'string' } }).controlFor('pref').type).to.equal('array');
158
+ });
159
+
160
+ it('defers object and non-string-array preferences to the Settings UI via a json control', () => {
161
+ expect(createServiceWithSchema({ type: 'object' }).controlFor('pref').type).to.equal('json');
162
+ expect(createServiceWithSchema({ type: 'array', items: { type: 'object' } }).controlFor('pref').type).to.equal('json');
163
+ expect(createServiceWithSchema({ type: 'array', items: { type: 'number' } }).controlFor('pref').type).to.equal('json');
164
+ });
165
+ });
166
+
167
+ describe('enumOptions', () => {
168
+ it('uses the value as the label and enumDescriptions as the option description (never the description as the label)', () => {
169
+ const options = createServiceWithSchema({
170
+ type: 'string',
171
+ enum: ['default', 'enabled'],
172
+ enumDescriptions: ['Use the default', 'Force enabled']
173
+ }).enumOptions('pref');
174
+ expect(options).to.deep.equal([
175
+ { value: 'default', label: 'default', description: 'Use the default' },
176
+ { value: 'enabled', label: 'enabled', description: 'Force enabled' }
177
+ ]);
178
+ });
179
+
180
+ it('prefers an explicit enumItemLabels entry for the label', () => {
181
+ const options = createServiceWithSchema({
182
+ type: 'string',
183
+ enum: ['a'],
184
+ enumItemLabels: ['Option A'],
185
+ enumDescriptions: ['desc']
186
+ }).enumOptions('pref');
187
+ expect(options[0]).to.deep.equal({ value: 'a', label: 'Option A', description: 'desc' });
188
+ });
189
+
190
+ it('returns an empty list when the property declares no enum', () => {
191
+ expect(createServiceWithSchema({ type: 'string' }).enumOptions('pref')).to.deep.equal([]);
192
+ });
193
+ });
194
+
195
+ describe('modelProviderLabel', () => {
196
+ it('returns the provider label declared under the aiModelProvider typeDetails', () => {
197
+ const service = createServiceWithSchema({ type: 'string', typeDetails: { aiModelProvider: { label: 'Anthropic' } } });
198
+ expect(service.modelProviderLabel('pref')).to.equal('Anthropic');
199
+ });
200
+
201
+ it('returns undefined when no provider label is declared', () => {
202
+ expect(createServiceWithSchema({ type: 'string' }).modelProviderLabel('pref')).to.equal(undefined);
203
+ expect(createServiceWithSchema({ type: 'string', typeDetails: { other: 'x' } }).modelProviderLabel('pref')).to.equal(undefined);
204
+ // The metadata must be an object carrying a string `label`.
205
+ expect(createServiceWithSchema({ type: 'string', typeDetails: { aiModelProvider: 'Anthropic' } }).modelProviderLabel('pref')).to.equal(undefined);
206
+ expect(createServiceWithSchema({ type: 'string', typeDetails: { aiModelProvider: { label: 42 } } }).modelProviderLabel('pref')).to.equal(undefined);
207
+ expect(createServiceWithSchema(undefined).modelProviderLabel('pref')).to.equal(undefined);
208
+ });
209
+ });
210
+
211
+ describe('editInSettings', () => {
212
+ it('opens settings.json focused on the preference via the preferences JSON command', () => {
213
+ const executed: { command: string; args: unknown[] }[] = [];
214
+ const service = new AiSettingsRowService();
215
+ (service as unknown as { commandService: CommandService }).commandService = {
216
+ executeCommand: (command: string, ...args: unknown[]) => {
217
+ executed.push({ command, args });
218
+ return Promise.resolve(undefined);
219
+ }
220
+ } as CommandService;
221
+
222
+ service.editInSettings('my.pref');
223
+
224
+ expect(executed).to.deep.equal([{ command: PreferencesCommands.OPEN_PREFERENCES_JSON_TOOLBAR.id, args: ['my.pref'] }]);
225
+ });
226
+ });
227
+
228
+ describe('normalizeMarkdown', () => {
229
+ const normalize = (markdown: string): string =>
230
+ (new AiSettingsRowService() as unknown as { normalizeMarkdown(md: string): string }).normalizeMarkdown(markdown);
231
+
232
+ it('strips the shared indentation of continuation lines so lists render as lists', () => {
233
+ const raw = [
234
+ 'Allows custom settings.',
235
+ ' Each setting consists of:',
236
+ ' - `scope`: when it applies',
237
+ ' - `modelId` (optional)',
238
+ ' - `requestSettings`: values'
239
+ ].join('\n');
240
+ const expected = [
241
+ 'Allows custom settings.',
242
+ 'Each setting consists of:',
243
+ '- `scope`: when it applies',
244
+ ' - `modelId` (optional)',
245
+ '- `requestSettings`: values'
246
+ ].join('\n');
247
+ expect(normalize(raw)).to.equal(expected);
248
+ });
249
+
250
+ it('leaves single-line and already-flush descriptions untouched', () => {
251
+ expect(normalize('A short description.')).to.equal('A short description.');
252
+ expect(normalize('First line.\nSecond line.')).to.equal('First line.\nSecond line.');
253
+ });
254
+
255
+ it('ignores blank lines when computing the shared indentation', () => {
256
+ expect(normalize('Intro.\n\n - item')).to.equal('Intro.\n\n- item');
257
+ });
258
+ });
259
+
260
+ describe('set / reset', () => {
261
+ it('writes to the mapped preference scope', () => {
262
+ const { service, preferences } = createService({});
263
+ return service.set('pref', 42, 'workspace').then(() => {
264
+ expect(preferences.sets).to.deep.equal([{ preferenceId: 'pref', value: 42, scope: PreferenceScope.Workspace, resourceUri: undefined }]);
265
+ });
266
+ });
267
+
268
+ it('resets by writing `undefined` to the mapped scope', () => {
269
+ const { service, preferences } = createService({});
270
+ return service.reset('pref', 'user', 'file:///ws').then(() => {
271
+ expect(preferences.sets).to.deep.equal([{ preferenceId: 'pref', value: undefined, scope: PreferenceScope.User, resourceUri: 'file:///ws' }]);
272
+ });
273
+ });
274
+ });
275
+ });
@@ -0,0 +1,401 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource and others.
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 { CommandService, Event } from '@theia/core';
18
+ import { ContextMenuRenderer } from '@theia/core/lib/browser/context-menu-renderer';
19
+ import { PreferenceScope } from '@theia/core/lib/common';
20
+ import { PreferenceDataProperty, PreferenceSchemaService } from '@theia/core/lib/common/preferences/preference-schema';
21
+ import { MarkdownRenderer } from '@theia/core/lib/browser/markdown-rendering/markdown-renderer';
22
+ import { PreferencesCommands } from '@theia/preferences/lib/browser/util/preference-types';
23
+ import { AI_CONFIGURATION_SETTING_CONTEXT_MENU, AiConfigurationSettingCommandArgs } from '../ai-configuration-setting-commands';
24
+ import { MarkdownStringImpl } from '@theia/core/lib/common/markdown-rendering/markdown-string';
25
+ import { inject, injectable } from '@theia/core/shared/inversify';
26
+ import type { SelectOption } from '@theia/core/lib/browser/widgets/select-component';
27
+ import { AI_CORE_PREFERENCES_TITLE, MODEL_PROVIDER_TYPE_DETAIL, ModelProviderTypeDetail } from '@theia/ai-core/lib/common/ai-core-preferences';
28
+ import { AiConfigurationService } from '@theia/ai-core/lib/common/ai-configuration-service';
29
+ import { AiConfigurationScope } from '../ai-configuration-category';
30
+
31
+ /** Describes which control an `AiSettingsRow` renders for a preference value. */
32
+ export type AiSettingsControl =
33
+ | { readonly type: 'boolean' }
34
+ | { readonly type: 'string'; readonly placeholder?: string; readonly password?: boolean }
35
+ /** `integer` distinguishes a whole-number preference from a fractional one, which must not be rounded. */
36
+ | { readonly type: 'number'; readonly integer?: boolean; readonly min?: number; readonly max?: number; readonly step?: number }
37
+ | { readonly type: 'select'; readonly options: SelectOption[] }
38
+ /**
39
+ * `validate` refuses an entry before it is written, returning the message to show; used where the values
40
+ * are more than free text (shell command patterns, for instance, have wildcard rules).
41
+ */
42
+ | { readonly type: 'array'; readonly placeholder?: string; readonly validate?: (value: string) => string | undefined }
43
+ | { readonly type: 'json' };
44
+
45
+ /**
46
+ * The value of a single preference as seen from a particular {@link AiConfigurationScope},
47
+ * together with the information the {@link AiSettingsRow} needs to render the
48
+ * modified indicator and the reset affordance.
49
+ */
50
+ export interface AiSettingsInspection {
51
+ /** Effective value resolved for the requested scope (falling back through broader scopes and the default). */
52
+ readonly value: unknown;
53
+ /** Value explicitly set in the requested scope; `undefined` means the value is inherited or default. */
54
+ readonly scopeValue: unknown;
55
+ /** Default (schema) value. */
56
+ readonly defaultValue: unknown;
57
+ /** `true` when the requested scope explicitly overrides the value, i.e. a reset is meaningful. */
58
+ readonly modified: boolean;
59
+ }
60
+
61
+ /**
62
+ * Thin, injectable wrapper around {@link AiConfigurationService} and {@link MarkdownRenderer}
63
+ * used by the presentational {@link AiSettingsRow} component, which must not itself
64
+ * depend on the DI container. Owning renderers/widgets inject this service and pass
65
+ * it down to the row.
66
+ *
67
+ * All preference reads and writes are routed through {@link AiConfigurationService} — the AI
68
+ * configuration seam — rather than the raw `PreferenceService`, so reads are workspace-trust-aware
69
+ * and the whole view stays insulated from how AI preferences are stored (see
70
+ * {@link AiConfigurationService}). Schema introspection still uses {@link PreferenceSchemaService}
71
+ * directly, as it inspects the registered schema shape rather than stored values.
72
+ */
73
+ @injectable()
74
+ export class AiSettingsRowService {
75
+
76
+ @inject(AiConfigurationService)
77
+ protected readonly aiConfigurationService: AiConfigurationService;
78
+
79
+ @inject(MarkdownRenderer)
80
+ protected readonly markdownRenderer: MarkdownRenderer;
81
+
82
+ @inject(PreferenceSchemaService)
83
+ protected readonly schemaService: PreferenceSchemaService;
84
+
85
+ @inject(CommandService)
86
+ protected readonly commandService: CommandService;
87
+
88
+ @inject(ContextMenuRenderer)
89
+ protected readonly contextMenuRenderer: ContextMenuRenderer;
90
+
91
+ protected changed: Event<void> | undefined;
92
+
93
+ /** Fires whenever an AI preference changes (or the workspace-trust state transitions), so owning widgets can re-render their rows. */
94
+ get onPreferenceChanged(): Event<void> {
95
+ return this.changed ??= Event.map(this.aiConfigurationService.onDidChange, () => undefined);
96
+ }
97
+
98
+ inspect(preferenceId: string, scope: AiConfigurationScope, resourceUri?: string): AiSettingsInspection {
99
+ const inspection = this.aiConfigurationService.inspect(preferenceId, resourceUri);
100
+ const defaultValue = inspection?.defaultValue;
101
+ const globalValue = inspection?.globalValue;
102
+ const workspaceValue = inspection?.workspaceValue;
103
+ const folderValue = inspection?.workspaceFolderValue;
104
+
105
+ let scopeValue: unknown;
106
+ let value: unknown;
107
+ switch (scope) {
108
+ case 'user':
109
+ scopeValue = globalValue;
110
+ value = globalValue ?? defaultValue;
111
+ break;
112
+ case 'workspace':
113
+ scopeValue = workspaceValue;
114
+ value = workspaceValue ?? globalValue ?? defaultValue;
115
+ break;
116
+ case 'folder':
117
+ scopeValue = folderValue;
118
+ value = folderValue ?? workspaceValue ?? globalValue ?? defaultValue;
119
+ break;
120
+ }
121
+ return { value, scopeValue, defaultValue, modified: scopeValue !== undefined };
122
+ }
123
+
124
+ set(preferenceId: string, value: unknown, scope: AiConfigurationScope, resourceUri?: string): Promise<void> {
125
+ return this.aiConfigurationService.set(preferenceId, value, this.toPreferenceScope(scope), resourceUri);
126
+ }
127
+
128
+ reset(preferenceId: string, scope: AiConfigurationScope, resourceUri?: string): Promise<void> {
129
+ return this.aiConfigurationService.set(preferenceId, undefined, this.toPreferenceScope(scope), resourceUri);
130
+ }
131
+
132
+ /**
133
+ * Opens the underlying `settings.json` file focused on a preference, mirroring the
134
+ * Settings view's "Edit in settings.json" link. Used for `json` controls, i.e. complex
135
+ * object/array values that cannot be edited meaningfully through an inline control.
136
+ */
137
+ editInSettings(preferenceId: string): void {
138
+ this.commandService.executeCommand(PreferencesCommands.OPEN_PREFERENCES_JSON_TOOLBAR.id, preferenceId);
139
+ }
140
+
141
+ /**
142
+ * Opens the gear context menu (Reset Setting / Copy Setting ID / Copy Setting as JSON) anchored below the
143
+ * gear. Uses this view's own {@link AI_CONFIGURATION_SETTING_CONTEXT_MENU} commands, backed by
144
+ * {@link AiConfigurationService} and acting in the row's own `scope` — rather than the Settings UI's
145
+ * `RESET_PREFERENCE` command, which is bound to the Preferences editor widget and misbehaves when invoked
146
+ * from here (it resets in the Preferences widget's scope and disturbs focus in this view).
147
+ */
148
+ openSettingContextMenu(gear: HTMLElement, preferenceId: string, value: unknown, scope: AiConfigurationScope, resourceUri?: string): void {
149
+ this.openGearMenu(gear, { id: preferenceId, value, scope: this.toPreferenceScope(scope), resourceUri });
150
+ }
151
+
152
+ /**
153
+ * Opens the gear menu for a row backed by non-preference data (e.g. a per-agent setting): the same
154
+ * "Reset Setting" affordance as a preference row, but reset runs the given callback. Copy entries are
155
+ * hidden (no preference id). Lets these rows reuse the shared gear menu instead of a bespoke button.
156
+ */
157
+ openResetMenu(gear: HTMLElement, reset: () => void): void {
158
+ this.openGearMenu(gear, { reset });
159
+ }
160
+
161
+ protected openGearMenu(gear: HTMLElement, args: AiConfigurationSettingCommandArgs): void {
162
+ const rect = gear.getBoundingClientRect();
163
+ this.contextMenuRenderer.render({
164
+ menuPath: AI_CONFIGURATION_SETTING_CONTEXT_MENU,
165
+ anchor: { x: rect.left, y: rect.bottom },
166
+ args: [args],
167
+ includeAnchorArg: false,
168
+ context: gear
169
+ });
170
+ }
171
+
172
+ /** Renders a markdown description into a detached element for use in a React `ref`. */
173
+ renderMarkdown(markdown: string): HTMLElement {
174
+ return this.markdownRenderer.render(new MarkdownStringImpl(this.normalizeMarkdown(markdown))).element;
175
+ }
176
+
177
+ /**
178
+ * Strips the common leading indentation from a markdown description before rendering.
179
+ *
180
+ * Preference `markdownDescription`s are commonly authored as indented, multi-line JavaScript
181
+ * string literals, which leaves every continuation line prefixed with the source indentation.
182
+ * That accidental indentation makes markdown-it collapse bullet lists and paragraphs into a
183
+ * single dense block, so the description reads as an unstructured run-on. Removing the shared
184
+ * indentation (ignoring the first line, which starts right after the opening quote) restores
185
+ * the intended paragraph and list structure while preserving relative indentation, so nested
186
+ * lists stay nested.
187
+ */
188
+ protected normalizeMarkdown(markdown: string): string {
189
+ const lines = markdown.split('\n');
190
+ let common = Number.POSITIVE_INFINITY;
191
+ for (let i = 1; i < lines.length; i++) {
192
+ const line = lines[i];
193
+ if (!line.trim()) {
194
+ continue;
195
+ }
196
+ common = Math.min(common, line.length - line.trimStart().length);
197
+ }
198
+ if (!Number.isFinite(common) || common === 0) {
199
+ return markdown;
200
+ }
201
+ return lines.map((line, index) => (index === 0 ? line : line.slice(common))).join('\n');
202
+ }
203
+
204
+ /**
205
+ * Reads the label and (already-localized) markdown description a preference declares in its registered
206
+ * schema, so settings rows do not have to re-author those strings. Returns `undefined` fields when the
207
+ * preference is unknown.
208
+ *
209
+ * Most AI preferences share the group `title` ("AI Features") to be grouped together in the Settings UI,
210
+ * which also ignores it and derives each setting's name from its id. We mirror that: a genuinely specific
211
+ * `title` is used as-is, but the shared group title falls back to the humanized preference id (e.g.
212
+ * `ai-features.google.apiKey` → "Api Key") so rows show their own name rather than "AI Features".
213
+ */
214
+ describe(preferenceId: string): { label?: string; description?: string } {
215
+ const property = this.schemaService.getSchemaProperty(preferenceId);
216
+ if (!property) {
217
+ return { label: undefined, description: undefined };
218
+ }
219
+ const specificTitle = property.title && property.title !== AI_CORE_PREFERENCES_TITLE ? property.title : undefined;
220
+ return {
221
+ label: specificTitle ?? this.formatPreferenceName(preferenceId),
222
+ description: property.markdownDescription ?? property.description
223
+ };
224
+ }
225
+
226
+ /** The schema tags declared for a preference (e.g. `['experimental']`), shown as badges next to the title. */
227
+ tags(preferenceId: string): string[] {
228
+ return this.schemaService.getSchemaProperty(preferenceId)?.tags ?? [];
229
+ }
230
+
231
+ /**
232
+ * Human-readable name for a preference derived from its id. The `ai-features` root and the area segment
233
+ * (`chat`, `google`, …) are dropped — the category/section already conveys them — and the remaining
234
+ * segments are humanized (camelCase split, first letter upper-cased) and joined with ": ", e.g.
235
+ * `ai-features.chat.tokenUsageIndicator.enabled` → "Token Usage Indicator: Enabled",
236
+ * `ai-features.google.apiKey` → "Api Key".
237
+ */
238
+ protected formatPreferenceName(preferenceId: string): string {
239
+ const parts = preferenceId.split('.');
240
+ const segments = parts.length > 2 ? parts.slice(2) : parts.slice(-1);
241
+ return segments.map(segment => this.humanizeSegment(segment)).join(': ');
242
+ }
243
+
244
+ protected humanizeSegment(segment: string): string {
245
+ if (!segment) {
246
+ return segment;
247
+ }
248
+ let formatted = segment[0].toLocaleUpperCase();
249
+ for (let i = 1; i < segment.length; i++) {
250
+ const char = segment[i];
251
+ const previous = segment[i - 1];
252
+ if (this.isUpperCase(char) && !/\s/.test(previous) && !this.isUpperCase(previous)) {
253
+ formatted += ' ';
254
+ }
255
+ formatted += char;
256
+ }
257
+ return formatted.trim();
258
+ }
259
+
260
+ protected isUpperCase(char: string): boolean {
261
+ return char === char.toLocaleUpperCase() && char.toLocaleLowerCase() !== char.toLocaleUpperCase();
262
+ }
263
+
264
+ /**
265
+ * The human-readable language-model provider name a preference declares in its schema
266
+ * `typeDetails` (as `{ [MODEL_PROVIDER_TYPE_DETAIL]: { label } }`, see {@link ModelProviderTypeDetail}),
267
+ * or `undefined` when none is declared. The Models page uses this to label a provider block without
268
+ * hard-coding provider names.
269
+ */
270
+ modelProviderLabel(preferenceId: string): string | undefined {
271
+ const typeDetails = this.schemaService.getSchemaProperty(preferenceId)?.typeDetails;
272
+ const detail = typeDetails && typeof typeDetails === 'object'
273
+ ? (typeDetails as Record<string, unknown>)[MODEL_PROVIDER_TYPE_DETAIL]
274
+ : undefined;
275
+ if (detail && typeof detail === 'object') {
276
+ const label = (detail as Partial<ModelProviderTypeDetail>).label;
277
+ if (typeof label === 'string') {
278
+ return label;
279
+ }
280
+ }
281
+ return undefined;
282
+ }
283
+
284
+ /**
285
+ * Whether a preference declares itself part of a language-model provider block, i.e. carries the
286
+ * `aiModelProvider` {@link MODEL_PROVIDER_TYPE_DETAIL} typeDetail (in any form). The Models page uses
287
+ * this positive marker to discover provider blocks, so feature areas that never claim to be a provider
288
+ * are never mistaken for one.
289
+ */
290
+ isModelProviderPreference(preferenceId: string): boolean {
291
+ const typeDetails = this.schemaService.getSchemaProperty(preferenceId)?.typeDetails;
292
+ return !!typeDetails && typeof typeDetails === 'object' && MODEL_PROVIDER_TYPE_DETAIL in typeDetails;
293
+ }
294
+
295
+ /**
296
+ * Builds {@link SelectOption}s from a preference's `enum`, using its `enumItemLabels`
297
+ * (falling back to `enumDescriptions`, then the raw value) for the display label.
298
+ * Returns an empty array when the preference declares no enum.
299
+ */
300
+ enumOptions(preferenceId: string): SelectOption[] {
301
+ const property = this.schemaService.getSchemaProperty(preferenceId);
302
+ const values = property?.enum;
303
+ if (!values) {
304
+ return [];
305
+ }
306
+ // The label must be the value (or an explicit `enumItemLabels` entry) — never `enumDescriptions`,
307
+ // which is the option's explanatory text and belongs in `description`, not in the label.
308
+ const labels = property?.enumItemLabels;
309
+ const descriptions = property?.enumDescriptions;
310
+ return values.map((value, index) => ({
311
+ value: String(value),
312
+ label: labels?.[index] ?? String(value),
313
+ description: descriptions?.[index]
314
+ }));
315
+ }
316
+
317
+ /** All registered preference ids, e.g. to discover a provider's `ai-features.<provider>.*` block. */
318
+ preferenceIds(): string[] {
319
+ return Array.from(this.schemaService.getSchemaProperties().keys());
320
+ }
321
+
322
+ /** All displayable `ai-features.*` preference ids — every AI setting that should appear somewhere in the view. */
323
+ aiFeaturePreferenceIds(): string[] {
324
+ return this.preferenceIds().filter(id => id.startsWith('ai-features.') && this.isDisplayable(id));
325
+ }
326
+
327
+ /**
328
+ * Whether a preference is meant to be surfaced as an editable settings row in the AI Configuration
329
+ * view. Excludes only value-less placeholders (`type: 'null'`), such as the redirect entries that
330
+ * only link to this view. The Settings-UI `hidden` flag is intentionally ignored: the AI
331
+ * Configuration view is the dedicated editor for AI preferences, which are hidden from the Settings
332
+ * UI once the cutover (#316) is in effect but must still be editable here.
333
+ */
334
+ isDisplayable(preferenceId: string): boolean {
335
+ const property = this.schemaService.getSchemaProperty(preferenceId);
336
+ if (!property) {
337
+ return false;
338
+ }
339
+ return property.type !== 'null';
340
+ }
341
+
342
+ /**
343
+ * Infers a sensible {@link AiSettingsControl} for a preference from its schema `type`
344
+ * (a `select` when it declares an `enum`, a masked text input for secrets/API keys). An `array` of strings
345
+ * gets the inline list editor; more complex values — an `object` or an `array` of objects/other types —
346
+ * cannot be edited meaningfully inline, so they resolve to a `json` control that defers to the
347
+ * `settings.json` file. Falls back to a text input for unknown types.
348
+ */
349
+ controlFor(preferenceId: string): AiSettingsControl {
350
+ const property = this.schemaService.getSchemaProperty(preferenceId);
351
+ if (property?.enum) {
352
+ return { type: 'select', options: this.enumOptions(preferenceId) };
353
+ }
354
+ switch (property?.type) {
355
+ case 'boolean':
356
+ return { type: 'boolean' };
357
+ case 'number':
358
+ return { type: 'number', min: property.minimum, max: property.maximum };
359
+ case 'integer':
360
+ return { type: 'number', integer: true, min: property.minimum, max: property.maximum };
361
+ case 'array':
362
+ return this.isStringArray(property) ? { type: 'array' } : { type: 'json' };
363
+ case 'object':
364
+ return { type: 'json' };
365
+ default:
366
+ return { type: 'string', password: this.isSecret(preferenceId) };
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Whether an `array` preference holds plain string items, so it can be edited with the inline list editor
372
+ * (matching the Settings UI, which only offers its array input for `string[]`); everything else defers to
373
+ * a `json` control.
374
+ */
375
+ protected isStringArray(property: PreferenceDataProperty): boolean {
376
+ const items = property.items;
377
+ const itemSchema = Array.isArray(items) ? items[0] : items;
378
+ return itemSchema?.type === 'string';
379
+ }
380
+
381
+ /**
382
+ * Whether a preference holds a secret (an API key, token or password) that should be masked. There is no
383
+ * schema flag for this, so it is inferred from the id's leaf segment, matching how every AI provider names
384
+ * its key preference (e.g. `ai-features.anthropic.apiKey`).
385
+ */
386
+ protected isSecret(preferenceId: string): boolean {
387
+ const leaf = preferenceId.substring(preferenceId.lastIndexOf('.') + 1).toLowerCase();
388
+ return leaf.endsWith('apikey') || leaf.endsWith('token') || leaf.endsWith('secret') || leaf.endsWith('password');
389
+ }
390
+
391
+ protected toPreferenceScope(scope: AiConfigurationScope): PreferenceScope {
392
+ switch (scope) {
393
+ case 'user':
394
+ return PreferenceScope.User;
395
+ case 'workspace':
396
+ return PreferenceScope.Workspace;
397
+ case 'folder':
398
+ return PreferenceScope.Folder;
399
+ }
400
+ }
401
+ }