Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.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 { ResourceMap, ResourceSet } from '../../../../../base/common/map.js';
9
import { OS } from '../../../../../base/common/platform.js';
10
import { basename, dirname, isEqualOrParent } from '../../../../../base/common/resources.js';
11
import { localize } from '../../../../../nls.js';
12
import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js';
13
import { IProductService } from '../../../../../platform/product/common/productService.js';
14
import { IAICustomizationWorkspaceService, applyStorageSourceFilter } from '../../common/aiCustomizationWorkspaceService.js';
15
import { HookType, HOOK_METADATA } from '../../common/promptSyntax/hookTypes.js';
16
import { formatHookCommandLabel } from '../../common/promptSyntax/hookSchema.js';
17
import { PromptsType } from '../../common/promptSyntax/promptTypes.js';
18
import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js';
19
import { ICustomizationItem, ICustomizationItemProvider, IHarnessDescriptor, matchesInstructionFileFilter, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js';
20
import { BUILTIN_STORAGE } from './aiCustomizationManagement.js';
21
import { getFriendlyName, isChatExtensionItem } from './aiCustomizationItemSource.js';
22
23
/**
24
* Adapts the rich promptsService model to the same provider-shaped items
25
* contributed by external customization providers.
26
*/
27
export class PromptsServiceCustomizationItemProvider implements ICustomizationItemProvider {
28
29
readonly onDidChange: Event<void>;
30
31
constructor(
32
private readonly getActiveDescriptor: () => IHarnessDescriptor,
33
private readonly promptsService: IPromptsService,
34
private readonly workspaceService: IAICustomizationWorkspaceService,
35
private readonly productService: IProductService,
36
) {
37
this.onDidChange = Event.any(
38
this.promptsService.onDidChangeCustomAgents,
39
this.promptsService.onDidChangeSlashCommands,
40
this.promptsService.onDidChangeSkills,
41
this.promptsService.onDidChangeHooks,
42
this.promptsService.onDidChangeInstructions,
43
);
44
}
45
46
async provideChatSessionCustomizations(token: CancellationToken): Promise<ICustomizationItem[]> {
47
const itemSets = await Promise.all([
48
this.provideCustomizations(PromptsType.agent, token),
49
this.provideCustomizations(PromptsType.skill, token),
50
this.provideCustomizations(PromptsType.instructions, token),
51
this.provideCustomizations(PromptsType.hook, token),
52
this.provideCustomizations(PromptsType.prompt, token),
53
]);
54
return itemSets.flat();
55
}
56
57
private async provideCustomizations(promptType: PromptsType, token: CancellationToken = CancellationToken.None): Promise<ICustomizationItem[]> {
58
const items: ICustomizationItem[] = [];
59
const disabledUris = this.promptsService.getDisabledPromptFiles(promptType);
60
const extensionInfoByUri = new ResourceMap<{ id: ExtensionIdentifier; displayName?: string }>();
61
62
if (promptType === PromptsType.agent) {
63
const agents = await this.promptsService.getCustomAgents(token);
64
const allAgentFiles = await this.promptsService.listPromptFiles(PromptsType.agent, token);
65
for (const file of allAgentFiles) {
66
if (file.extension) {
67
extensionInfoByUri.set(file.uri, { id: file.extension.identifier, displayName: file.extension.displayName });
68
}
69
}
70
for (const agent of agents) {
71
items.push({
72
uri: agent.uri,
73
type: promptType,
74
name: agent.name,
75
description: agent.description,
76
storage: agent.source.storage,
77
enabled: agent.enabled,
78
extensionId: agent.source.storage === PromptsStorage.extension ? agent.source.extensionId.value : undefined,
79
pluginUri: agent.source.storage === PromptsStorage.plugin ? agent.source.pluginUri : undefined,
80
userInvocable: agent.visibility.userInvocable
81
});
82
if (agent.source.storage === PromptsStorage.extension && !extensionInfoByUri.has(agent.uri)) {
83
extensionInfoByUri.set(agent.uri, { id: agent.source.extensionId });
84
}
85
}
86
} else if (promptType === PromptsType.skill) {
87
const skills = await this.promptsService.findAgentSkills(token);
88
const allSkillFiles = await this.promptsService.listPromptFiles(PromptsType.skill, token);
89
for (const file of allSkillFiles) {
90
if (file.extension) {
91
extensionInfoByUri.set(file.uri, { id: file.extension.identifier, displayName: file.extension.displayName });
92
}
93
}
94
const uiIntegrations = this.workspaceService.getSkillUIIntegrations();
95
const seenUris = new ResourceSet();
96
for (const skill of skills || []) {
97
const skillName = skill.name || basename(dirname(skill.uri)) || basename(skill.uri);
98
seenUris.add(skill.uri);
99
const skillFolderName = basename(dirname(skill.uri));
100
const uiTooltip = uiIntegrations.get(skillFolderName);
101
items.push({
102
uri: skill.uri,
103
type: promptType,
104
name: skillName,
105
description: skill.description,
106
storage: skill.storage,
107
enabled: true,
108
badge: uiTooltip ? localize('uiIntegrationBadge', "UI Integration") : undefined,
109
badgeTooltip: uiTooltip,
110
extensionId: skill.extension?.identifier.value,
111
pluginUri: skill.pluginUri,
112
userInvocable: skill.userInvocable
113
});
114
}
115
if (disabledUris.size > 0) {
116
for (const file of allSkillFiles) {
117
if (!seenUris.has(file.uri) && disabledUris.has(file.uri)) {
118
const disabledName = file.name || basename(dirname(file.uri)) || basename(file.uri);
119
const disabledFolderName = basename(dirname(file.uri));
120
const uiTooltip = uiIntegrations.get(disabledFolderName);
121
items.push({
122
uri: file.uri,
123
type: promptType,
124
name: disabledName,
125
description: file.description,
126
storage: file.storage,
127
enabled: false,
128
badge: uiTooltip ? localize('uiIntegrationBadge', "UI Integration") : undefined,
129
badgeTooltip: uiTooltip,
130
extensionId: file.extension?.identifier.value,
131
pluginUri: file.pluginUri,
132
userInvocable: false
133
});
134
}
135
}
136
}
137
} else if (promptType === PromptsType.prompt) {
138
const commands = await this.promptsService.getPromptSlashCommands(token);
139
for (const command of commands) {
140
if (command.type === PromptsType.skill) {
141
continue;
142
}
143
items.push({
144
uri: command.uri,
145
type: promptType,
146
name: command.name,
147
description: command.description,
148
storage: command.storage,
149
enabled: !disabledUris.has(command.uri),
150
extensionId: command.extension?.identifier.value,
151
pluginUri: command.pluginUri,
152
userInvocable: command.userInvocable
153
});
154
if (command.extension) {
155
extensionInfoByUri.set(command.uri, { id: command.extension.identifier, displayName: command.extension.displayName });
156
}
157
}
158
} else if (promptType === PromptsType.hook) {
159
await this.fetchPromptServiceHooks(items, disabledUris, promptType);
160
} else {
161
await this.fetchPromptServiceInstructions(items, extensionInfoByUri, disabledUris, promptType);
162
}
163
164
return this.applyLocalFilters(this.applyBuiltinGroupKeys(items, extensionInfoByUri), promptType);
165
}
166
167
private async fetchPromptServiceHooks(items: ICustomizationItem[], disabledUris: ResourceSet, promptType: PromptsType): Promise<void> {
168
const hookFiles = await this.promptsService.listPromptFiles(PromptsType.hook, CancellationToken.None);
169
170
// Non-plugin hooks: return raw file items — expansion into individual
171
// hook entries is handled by ProviderCustomizationItemSource.fetchItemsFromProvider().
172
// Plugin hooks: add directly as-is since they're pre-expanded by
173
// plugin manifests and must NOT be re-parsed by expandHookFileItems.
174
for (const f of hookFiles) {
175
items.push({
176
uri: f.uri,
177
type: promptType,
178
name: f.name || getFriendlyName(basename(f.uri)),
179
storage: f.storage,
180
enabled: !disabledUris.has(f.uri),
181
extensionId: f.extension?.identifier.value,
182
pluginUri: f.pluginUri,
183
userInvocable: undefined
184
});
185
}
186
187
// Agent-embedded hooks (not in sessions window).
188
const agents = !this.workspaceService.isSessionsWindow ? await this.promptsService.getCustomAgents(CancellationToken.None) : [];
189
for (const agent of agents) {
190
if (!agent.hooks || !agent.enabled) {
191
continue;
192
}
193
for (const hookType of Object.values(HookType)) {
194
const hookCommands = agent.hooks[hookType];
195
if (!hookCommands || hookCommands.length === 0) {
196
continue;
197
}
198
const hookMeta = HOOK_METADATA[hookType];
199
for (let i = 0; i < hookCommands.length; i++) {
200
const hook = hookCommands[i];
201
const cmdLabel = formatHookCommandLabel(hook, OS);
202
const truncatedCmd = cmdLabel.length > 60 ? cmdLabel.substring(0, 57) + '...' : cmdLabel;
203
items.push({
204
uri: agent.uri,
205
type: promptType,
206
name: hookMeta?.label ?? hookType,
207
description: `${agent.name}: ${truncatedCmd || localize('hookUnset', "(unset)")}`,
208
storage: agent.source.storage,
209
groupKey: 'agents',
210
enabled: !disabledUris.has(agent.uri),
211
extensionId: agent.source.storage === PromptsStorage.extension ? agent.source.extensionId.value : undefined,
212
pluginUri: agent.source.storage === PromptsStorage.plugin ? agent.source.pluginUri : undefined,
213
userInvocable: undefined
214
});
215
}
216
}
217
}
218
}
219
220
private async fetchPromptServiceInstructions(items: ICustomizationItem[], extensionInfoByUri: ResourceMap<{ id: ExtensionIdentifier; displayName?: string }>, disabledUris: ResourceSet, promptType: PromptsType): Promise<void> {
221
const instructionFiles = await this.promptsService.getInstructionFiles(CancellationToken.None);
222
for (const file of instructionFiles) {
223
if (file.extension) {
224
extensionInfoByUri.set(file.uri, { id: file.extension.identifier, displayName: file.extension.displayName });
225
}
226
}
227
const agentInstructionFiles = await this.promptsService.listAgentInstructions(CancellationToken.None, undefined);
228
const agentInstructionUris = new ResourceSet(agentInstructionFiles.map(f => f.uri));
229
230
for (const file of agentInstructionFiles) {
231
const storage = PromptsStorage.local;
232
const filename = basename(file.uri);
233
items.push({
234
uri: file.uri,
235
type: promptType,
236
name: filename,
237
storage,
238
groupKey: 'agent-instructions',
239
enabled: !disabledUris.has(file.uri),
240
extensionId: undefined,
241
pluginUri: undefined,
242
userInvocable: undefined
243
});
244
}
245
246
for (const { uri, pattern, name, description, storage, extension, pluginUri } of instructionFiles) {
247
if (agentInstructionUris.has(uri)) {
248
continue;
249
}
250
251
const friendlyName = getFriendlyName(name);
252
253
if (pattern !== undefined) {
254
const badge = pattern === '**'
255
? localize('alwaysAdded', "always added")
256
: pattern;
257
const badgeTooltip = pattern === '**'
258
? localize('alwaysAddedTooltip', "This instruction is automatically included in every interaction.")
259
: localize('onContextTooltip', "This instruction is automatically included when files matching '{0}' are in context.", pattern);
260
items.push({
261
uri,
262
type: promptType,
263
name: friendlyName,
264
badge,
265
badgeTooltip,
266
description,
267
storage,
268
groupKey: 'context-instructions',
269
enabled: !disabledUris.has(uri),
270
extensionId: extension?.identifier.value,
271
pluginUri,
272
userInvocable: undefined
273
});
274
} else {
275
items.push({
276
uri,
277
type: promptType,
278
name: friendlyName,
279
description,
280
storage,
281
groupKey: 'on-demand-instructions',
282
enabled: !disabledUris.has(uri),
283
extensionId: extension?.identifier.value,
284
pluginUri,
285
userInvocable: undefined
286
});
287
}
288
}
289
}
290
291
private applyBuiltinGroupKeys(items: ICustomizationItem[], extensionInfoByUri: ResourceMap<{ id: ExtensionIdentifier; displayName?: string }>): ICustomizationItem[] {
292
return items.map(item => {
293
if (item.storage !== PromptsStorage.extension) {
294
return item;
295
}
296
const extInfo = extensionInfoByUri.get(item.uri);
297
if (!extInfo) {
298
return item;
299
}
300
if (isChatExtensionItem(extInfo.id, this.productService)) {
301
return {
302
...item,
303
groupKey: item.groupKey ?? BUILTIN_STORAGE,
304
};
305
}
306
return {
307
...item,
308
extensionLabel: extInfo.displayName || extInfo.id.value,
309
};
310
});
311
}
312
313
private applyLocalFilters(groupedItems: ICustomizationItem[], promptType: PromptsType): ICustomizationItem[] {
314
const filter = this.workspaceService.getStorageSourceFilter(promptType);
315
const withStorage = groupedItems.filter((item): item is ICustomizationItem & { readonly storage: PromptsStorage } => item.storage !== undefined);
316
const withoutStorage = groupedItems.filter(item => item.storage === undefined);
317
let items = [...applyStorageSourceFilter(withStorage, filter), ...withoutStorage];
318
319
const descriptor = this.getActiveDescriptor();
320
const subpaths = descriptor.workspaceSubpaths;
321
const instrFilter = descriptor.instructionFileFilter;
322
323
if (subpaths) {
324
const projectRoot = this.workspaceService.getActiveProjectRoot();
325
items = items.filter(item => {
326
if (item.storage !== PromptsStorage.local || !projectRoot || !isEqualOrParent(item.uri, projectRoot)) {
327
return true;
328
}
329
if (matchesWorkspaceSubpath(item.uri.path, subpaths)) {
330
return true;
331
}
332
// Keep instruction files matching the harness's native patterns
333
if (instrFilter && promptType === PromptsType.instructions && matchesInstructionFileFilter(item.uri.path, instrFilter)) {
334
return true;
335
}
336
// Keep agent instruction files (AGENTS.md, CLAUDE.md, copilot-instructions.md)
337
if (item.groupKey === 'agent-instructions') {
338
return true;
339
}
340
return false;
341
});
342
}
343
344
if (instrFilter && promptType === PromptsType.instructions) {
345
items = items.filter(item => matchesInstructionFileFilter(item.uri.path, instrFilter));
346
}
347
348
return items;
349
}
350
351
}
352
353