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