Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts
13406 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { CancellationToken } from '../../../../../base/common/cancellation.js';
7
import { Event } from '../../../../../base/common/event.js';
8
import { IMatch } from '../../../../../base/common/filters.js';
9
import { parse as parseJSONC } from '../../../../../base/common/json.js';
10
import { ResourceMap } from '../../../../../base/common/map.js';
11
import { Schemas } from '../../../../../base/common/network.js';
12
import { OS } from '../../../../../base/common/platform.js';
13
import { basename, dirname, isEqualOrParent } from '../../../../../base/common/resources.js';
14
import { ThemeIcon } from '../../../../../base/common/themables.js';
15
import { URI } from '../../../../../base/common/uri.js';
16
import { localize } from '../../../../../nls.js';
17
import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js';
18
import { IFileService } from '../../../../../platform/files/common/files.js';
19
import { ILabelService } from '../../../../../platform/label/common/label.js';
20
import { IProductService } from '../../../../../platform/product/common/productService.js';
21
import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js';
22
import { IPathService } from '../../../../services/path/common/pathService.js';
23
import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js';
24
import { ICustomizationSyncProvider, ICustomizationItem, ICustomizationItemProvider } from '../../common/customizationHarnessService.js';
25
import { IAgentPluginService } from '../../common/plugins/agentPluginService.js';
26
import { parseHooksFromFile } from '../../common/promptSyntax/hookCompatibility.js';
27
import { formatHookCommandLabel } from '../../common/promptSyntax/hookSchema.js';
28
import { HOOK_METADATA } from '../../common/promptSyntax/hookTypes.js';
29
import { PromptsType } from '../../common/promptSyntax/promptTypes.js';
30
import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js';
31
import { storageToIcon } from './aiCustomizationIcons.js';
32
import { BUILTIN_STORAGE } from './aiCustomizationManagement.js';
33
import { extractExtensionIdFromPath } from './aiCustomizationListWidgetUtils.js';
34
35
// #region Interfaces
36
37
/**
38
* Represents an AI customization item in the list widget.
39
*/
40
export interface IAICustomizationListItem {
41
readonly id: string;
42
readonly uri: URI;
43
readonly name: string;
44
readonly filename: string;
45
readonly description?: string;
46
/** Storage origin. Set by core when items come from promptsService; omitted for external provider items. */
47
readonly storage?: PromptsStorage;
48
readonly promptType: PromptsType;
49
readonly disabled: boolean;
50
/** When set, overrides `storage` for display grouping purposes. */
51
readonly groupKey?: string;
52
/** URI of the parent plugin, when this item comes from an installed plugin. */
53
readonly pluginUri?: URI;
54
/** When set, overrides the formatted name for display. */
55
readonly displayName?: string;
56
/** When set, shows a small inline badge next to the item name. */
57
readonly badge?: string;
58
/** Tooltip shown when hovering the badge. */
59
readonly badgeTooltip?: string;
60
/** When set, overrides the default prompt-type icon. */
61
readonly typeIcon?: ThemeIcon;
62
/** True when item comes from the default chat extension (grouped under Built-in). */
63
readonly isBuiltin?: boolean;
64
/** Display name of the contributing extension (for non-built-in extension items). */
65
readonly extensionId?: string;
66
/** Server-reported loading/sync status for remote customizations. */
67
readonly status?: 'loading' | 'loaded' | 'degraded' | 'error';
68
/** Human-readable status detail (e.g. error message or warning). */
69
readonly statusMessage?: string;
70
/** When true, this item can be selected for syncing to a remote harness. */
71
readonly syncable?: boolean;
72
/** When true, this syncable item is currently selected for syncing. */
73
readonly synced?: boolean;
74
nameMatches?: IMatch[];
75
descriptionMatches?: IMatch[];
76
}
77
78
/**
79
* Browser-internal item source consumed by the list widget.
80
*
81
* Item sources fetch provider-shaped customization rows, normalize them into
82
* the browser-only list item shape, and add view-only overlays such as sync state.
83
*/
84
export interface IAICustomizationItemSource {
85
readonly onDidChange: Event<void>;
86
fetchItems(promptType: PromptsType): Promise<IAICustomizationListItem[]>;
87
}
88
89
// #endregion
90
91
// #region Utilities
92
93
/**
94
* Returns true if the given extension identifier matches the default
95
* chat extension (e.g. GitHub Copilot Chat). Used to group items from
96
* the chat extension under "Built-in" instead of "Extensions".
97
*/
98
export function isChatExtensionItem(extensionId: ExtensionIdentifier, productService: IProductService): boolean {
99
const chatExtensionId = productService.defaultChatAgent?.chatExtensionId;
100
return !!chatExtensionId && ExtensionIdentifier.equals(extensionId, chatExtensionId);
101
}
102
103
/**
104
* Derives a friendly name from a filename by removing extension suffixes.
105
*/
106
export function getFriendlyName(filename: string): string {
107
let name = filename
108
.replace(/\.instructions\.md$/i, '')
109
.replace(/\.prompt\.md$/i, '')
110
.replace(/\.agent\.md$/i, '')
111
.replace(/\.md$/i, '');
112
113
name = name
114
.replace(/[-_]/g, ' ')
115
.replace(/\b\w/g, c => c.toUpperCase());
116
117
return name || filename;
118
}
119
120
/**
121
* Expands hook file items into individual hook entries by parsing hook
122
* definitions from the file content. Falls back to the original item
123
* when parsing fails.
124
*/
125
export async function expandHookFileItems(
126
hookFileItems: readonly ICustomizationItem[],
127
workspaceService: IAICustomizationWorkspaceService,
128
fileService: IFileService,
129
pathService: IPathService,
130
): Promise<ICustomizationItem[]> {
131
const items: ICustomizationItem[] = [];
132
const activeRoot = workspaceService.getActiveProjectRoot();
133
const userHomeUri = await pathService.userHome();
134
const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path;
135
136
for (const item of hookFileItems) {
137
let parsedHooks = false;
138
try {
139
const content = await fileService.readFile(item.uri);
140
const json = parseJSONC(content.value.toString());
141
const { hooks } = parseHooksFromFile(item.uri, json, activeRoot, userHome);
142
143
if (hooks.size > 0) {
144
parsedHooks = true;
145
for (const [hookType, entry] of hooks) {
146
const hookMeta = HOOK_METADATA[hookType];
147
for (let i = 0; i < entry.hooks.length; i++) {
148
const hook = entry.hooks[i];
149
const cmdLabel = formatHookCommandLabel(hook, OS);
150
const truncatedCmd = cmdLabel.length > 60 ? cmdLabel.substring(0, 57) + '...' : cmdLabel;
151
items.push({
152
uri: item.uri,
153
type: PromptsType.hook,
154
name: hookMeta?.label ?? entry.originalId,
155
description: truncatedCmd || localize('hookUnset', "(unset)"),
156
enabled: item.enabled,
157
groupKey: item.groupKey,
158
storage: item.storage,
159
extensionId: item.extensionId,
160
pluginUri: item.pluginUri,
161
userInvocable: item.userInvocable,
162
});
163
}
164
}
165
}
166
} catch {
167
// Parse failed — fall through to show raw file.
168
}
169
170
if (!parsedHooks) {
171
items.push(item);
172
}
173
}
174
175
return items;
176
}
177
178
// #endregion
179
180
// #region Normalizer
181
182
/**
183
* Converts provider-shaped customization rows into the rich list model used by the management UI.
184
*/
185
export class AICustomizationItemNormalizer {
186
constructor(
187
private readonly workspaceContextService: IWorkspaceContextService,
188
private readonly workspaceService: IAICustomizationWorkspaceService,
189
private readonly labelService: ILabelService,
190
private readonly agentPluginService: IAgentPluginService,
191
private readonly productService: IProductService,
192
) { }
193
194
normalizeItems(items: readonly ICustomizationItem[], promptType: PromptsType): IAICustomizationListItem[] {
195
const uriUseCounts = new ResourceMap<number>();
196
return items
197
.filter(item => item.type === promptType)
198
.map(item => this.normalizeItem(item, promptType, uriUseCounts))
199
.sort((a, b) => a.name.localeCompare(b.name));
200
}
201
202
normalizeItem(item: ICustomizationItem, promptType: PromptsType, uriUseCounts = new ResourceMap<number>()): IAICustomizationListItem {
203
const { storage, groupKey, isBuiltin, extensionId, pluginUri } = this.inferStorageAndGroup(item);
204
const seenCount = uriUseCounts.get(item.uri) ?? 0;
205
uriUseCounts.set(item.uri, seenCount + 1);
206
const duplicateSuffix = seenCount === 0 ? '' : `#${seenCount}`;
207
const isWorkspaceItem = storage === PromptsStorage.local;
208
209
return {
210
id: `${item.uri.toString()}${duplicateSuffix}`,
211
uri: item.uri,
212
name: item.name,
213
filename: item.uri.scheme === Schemas.file
214
? this.labelService.getUriLabel(item.uri, { relative: isWorkspaceItem })
215
: basename(item.uri),
216
description: item.description,
217
storage,
218
promptType,
219
disabled: item.enabled === false,
220
groupKey,
221
pluginUri,
222
displayName: item.name,
223
badge: item.badge,
224
badgeTooltip: item.badgeTooltip,
225
typeIcon: promptType === PromptsType.instructions && storage ? storageToIcon(storage) : undefined,
226
isBuiltin,
227
extensionId,
228
status: item.status,
229
statusMessage: item.statusMessage,
230
};
231
}
232
233
private inferStorageAndGroup(item: ICustomizationItem): { storage: PromptsStorage; groupKey?: string; isBuiltin?: boolean; extensionId?: string; pluginUri?: URI } {
234
const groupKey = item.groupKey;
235
const isBuiltin = groupKey === BUILTIN_STORAGE;
236
237
if (item.extensionId) {
238
const extensionIdentifier = new ExtensionIdentifier(item.extensionId);
239
if (isChatExtensionItem(extensionIdentifier, this.productService)) {
240
return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true, extensionId: item.extensionId };
241
}
242
return { storage: PromptsStorage.extension, extensionId: item.extensionId, groupKey, isBuiltin };
243
}
244
if (item.pluginUri) {
245
return { storage: PromptsStorage.plugin, pluginUri: item.pluginUri, groupKey, isBuiltin };
246
}
247
248
const uri = item.uri;
249
250
const activeProjectRoot = this.workspaceService.getActiveProjectRoot();
251
if (activeProjectRoot && isEqualOrParent(uri, activeProjectRoot)) {
252
return { storage: PromptsStorage.local, groupKey, isBuiltin };
253
}
254
255
for (const folder of this.workspaceContextService.getWorkspace().folders) {
256
if (isEqualOrParent(uri, folder.uri)) {
257
return { storage: PromptsStorage.local, groupKey, isBuiltin };
258
}
259
}
260
261
for (const plugin of this.agentPluginService.plugins.get()) {
262
if (isEqualOrParent(uri, plugin.uri)) {
263
return { storage: PromptsStorage.plugin, pluginUri: plugin.uri, groupKey, isBuiltin };
264
}
265
}
266
267
const extensionId = extractExtensionIdFromPath(uri.path);
268
if (extensionId) {
269
const extensionIdentifier = new ExtensionIdentifier(extensionId);
270
if (isChatExtensionItem(extensionIdentifier, this.productService)) {
271
return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true, extensionId };
272
}
273
return { storage: PromptsStorage.extension, extensionId, groupKey, isBuiltin };
274
}
275
return { storage: PromptsStorage.user };
276
}
277
278
}
279
280
// #endregion
281
282
// #region Item Source
283
284
/**
285
* Unified item source that fetches items from a provider (extension-contributed
286
* or the promptsService adapter), normalizes them into list items, and optionally
287
* blends in local syncable items when a sync provider is present.
288
*/
289
export class ProviderCustomizationItemSource implements IAICustomizationItemSource {
290
291
readonly onDidChange: Event<void>;
292
293
constructor(
294
private readonly itemProvider: ICustomizationItemProvider | undefined,
295
private readonly syncProvider: ICustomizationSyncProvider | undefined,
296
private readonly promptsService: IPromptsService,
297
private readonly workspaceService: IAICustomizationWorkspaceService,
298
private readonly fileService: IFileService,
299
private readonly pathService: IPathService,
300
private readonly itemNormalizer: AICustomizationItemNormalizer,
301
) {
302
const onDidChangeSyncableCustomizations = this.syncProvider
303
? Event.any(
304
this.promptsService.onDidChangeCustomAgents,
305
this.promptsService.onDidChangeSlashCommands,
306
this.promptsService.onDidChangeSkills,
307
this.promptsService.onDidChangeHooks,
308
this.promptsService.onDidChangeInstructions,
309
)
310
: Event.None;
311
312
this.onDidChange = Event.any(
313
this.itemProvider?.onDidChange ?? Event.None,
314
this.syncProvider?.onDidChange ?? Event.None,
315
onDidChangeSyncableCustomizations,
316
);
317
}
318
319
async fetchItems(promptType: PromptsType): Promise<IAICustomizationListItem[]> {
320
const remoteItems = this.itemProvider
321
? await this.fetchItemsFromProvider(this.itemProvider, promptType)
322
: [];
323
if (!this.syncProvider) {
324
return remoteItems;
325
}
326
const localItems = await this.fetchLocalSyncableItems(promptType, this.syncProvider);
327
return [...remoteItems, ...localItems];
328
}
329
330
private async fetchItemsFromProvider(provider: ICustomizationItemProvider, promptType: PromptsType): Promise<IAICustomizationListItem[]> {
331
const allItems = await provider.provideChatSessionCustomizations(CancellationToken.None);
332
if (!allItems) {
333
return [];
334
}
335
336
let providerItems: readonly ICustomizationItem[];
337
if (promptType === PromptsType.hook) {
338
const hookItems = allItems.filter(item => item.type === PromptsType.hook);
339
// Plugin hooks are pre-expanded by plugin manifests — skip re-expansion.
340
const toExpand = hookItems.filter(item => item.storage !== PromptsStorage.plugin);
341
const preExpanded = hookItems.filter(item => item.storage === PromptsStorage.plugin);
342
const expanded = await expandHookFileItems(
343
toExpand, this.workspaceService, this.fileService, this.pathService,
344
);
345
providerItems = [...expanded, ...preExpanded];
346
} else {
347
providerItems = allItems.filter(item => item.type === promptType);
348
}
349
350
if (promptType === PromptsType.skill) {
351
providerItems = await this.addSkillDescriptionFallbacks(providerItems);
352
}
353
354
const normalized = this.itemNormalizer.normalizeItems(providerItems, promptType);
355
if (promptType === PromptsType.skill) {
356
return this.mergeBuiltinSkills(normalized, promptType);
357
}
358
return normalized;
359
}
360
361
/**
362
* Merges built-in skills (bundled with the app under `vs/sessions/skills/`)
363
* into the provider's items. The provider may re-discover the bundled
364
* copies when scanning disk — those duplicates are dropped (deduped by
365
* URI) and replaced with the authoritative built-in entry tagged
366
* `groupKey: BUILTIN_STORAGE` so the UI renders them in the "Built-in"
367
* group. User-authored overrides (different URI, same name) are preserved.
368
*
369
* A workbench that uses the base `PromptsService` will throw on
370
* `BUILTIN_STORAGE` — we catch and return the items unchanged in that case.
371
*/
372
private async mergeBuiltinSkills(items: readonly IAICustomizationListItem[], promptType: PromptsType): Promise<IAICustomizationListItem[]> {
373
let builtinPaths: readonly { uri: URI; name?: string; description?: string }[] = [];
374
try {
375
builtinPaths = await this.promptsService.listPromptFilesForStorage(PromptsType.skill, BUILTIN_STORAGE as unknown as PromptsStorage, CancellationToken.None);
376
} catch {
377
return [...items];
378
}
379
if (builtinPaths.length === 0) {
380
return [...items];
381
}
382
383
const builtinUris = new ResourceMap<typeof builtinPaths[number]>();
384
for (const p of builtinPaths) {
385
builtinUris.set(p.uri, p);
386
}
387
388
// Drop provider items that are the same URI as a built-in (the provider
389
// re-discovered the bundled copy by scanning disk).
390
const deduped = items.filter(item => !builtinUris.has(item.uri));
391
392
const uiIntegrations = this.workspaceService.getSkillUIIntegrations();
393
const uiIntegrationBadge = localize('uiIntegrationBadge', "UI Integration");
394
395
// Collect names of user/workspace skills so we can hide the built-in
396
// copy once the user has added an override at either level.
397
const overriddenNames = new Set<string>();
398
for (const item of deduped) {
399
if (item.storage === PromptsStorage.local || item.storage === PromptsStorage.user) {
400
if (item.name) {
401
overriddenNames.add(item.name);
402
}
403
}
404
}
405
406
// Append authoritative built-in entries (excluding any that have been
407
// overridden by a workspace or user copy with the same name).
408
const uriUseCounts = new ResourceMap<number>();
409
for (const item of deduped) {
410
uriUseCounts.set(item.uri, (uriUseCounts.get(item.uri) ?? 0) + 1);
411
}
412
const appended: IAICustomizationListItem[] = [];
413
const disabledPromptFiles = this.promptsService.getDisabledPromptFiles(PromptsType.skill);
414
for (const p of builtinPaths) {
415
const name = p.name ?? basename(p.uri);
416
if (overriddenNames.has(name)) {
417
continue;
418
}
419
const folderName = basename(dirname(p.uri));
420
const uiTooltip = uiIntegrations.get(folderName);
421
const builtinItem: ICustomizationItem = {
422
uri: p.uri,
423
type: PromptsType.skill,
424
name,
425
description: p.description,
426
storage: BUILTIN_STORAGE as unknown as PromptsStorage,
427
groupKey: BUILTIN_STORAGE,
428
enabled: !disabledPromptFiles.has(p.uri),
429
badge: uiTooltip ? uiIntegrationBadge : undefined,
430
badgeTooltip: uiTooltip,
431
extensionId: undefined,
432
pluginUri: undefined,
433
userInvocable: true,
434
};
435
appended.push(this.itemNormalizer.normalizeItem(builtinItem, promptType, uriUseCounts));
436
}
437
438
return [...deduped, ...appended];
439
}
440
441
private async addSkillDescriptionFallbacks(items: readonly ICustomizationItem[]): Promise<readonly ICustomizationItem[]> {
442
const descriptionsByUri = new Map<string, string>();
443
const skills = await this.promptsService.findAgentSkills(CancellationToken.None);
444
for (const skill of skills ?? []) {
445
if (skill.description) {
446
descriptionsByUri.set(skill.uri.toString(), skill.description);
447
}
448
}
449
450
return items.map(item => item.description
451
? item
452
: { ...item, description: descriptionsByUri.get(item.uri.toString()) });
453
}
454
455
private async fetchLocalSyncableItems(promptType: PromptsType, syncProvider: ICustomizationSyncProvider): Promise<IAICustomizationListItem[]> {
456
const files = await this.promptsService.listPromptFiles(promptType, CancellationToken.None);
457
if (!files.length) {
458
return [];
459
}
460
461
const providerItems: ICustomizationItem[] = files
462
.filter(file => file.storage === PromptsStorage.local || file.storage === PromptsStorage.user)
463
.map(file => ({
464
uri: file.uri,
465
type: promptType,
466
name: getFriendlyName(basename(file.uri)),
467
groupKey: 'sync-local',
468
enabled: true,
469
extensionId: file.extension?.id,
470
pluginUri: file.pluginUri,
471
userInvocable: undefined
472
}));
473
474
return this.itemNormalizer.normalizeItems(providerItems, promptType)
475
.map(item => ({
476
...item,
477
id: `sync-${item.id}`,
478
syncable: true,
479
synced: !syncProvider.isDisabled(item.uri),
480
}));
481
}
482
}
483
484
// #endregion
485
486