From 8b9eae6eb87b432b549c8481901f181cb1daf0a6 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Tue, 21 Apr 2026 16:21:28 -0700 Subject: [PATCH 01/36] first commit --- .../aiCustomizationItemSource.ts | 3 +- .../aiCustomizationListWidget.ts | 12 ++ .../aiCustomizationManagement.contribution.ts | 160 ++++++++++++++-- .../browser/aiCustomization/mcpListWidget.ts | 15 +- .../media/aiCustomizationManagement.css | 25 ++- ...promptsServiceCustomizationItemProvider.ts | 53 +++++- .../computeAutomaticInstructions.ts | 19 +- .../promptSyntax/service/promptsService.ts | 9 +- .../service/promptsServiceImpl.ts | 69 +++++-- .../computeAutomaticInstructions.test.ts | 51 ++++- .../service/mockPromptsService.ts | 1 + .../service/promptsService.test.ts | 180 +++++++++++++++++- .../aiCustomizationListWidget.fixture.ts | 1 + 13 files changed, 547 insertions(+), 51 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index f394aa0176355..f2b6fa0763078 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -476,6 +476,7 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour return []; } + const disabledUris = this.promptsService.getDisabledPromptFiles(promptType); const providerItems: ICustomizationItem[] = files .filter(file => file.storage === PromptsStorage.local || file.storage === PromptsStorage.user) .map(file => ({ @@ -483,7 +484,7 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour type: promptType, name: getFriendlyName(basename(file.uri)), groupKey: 'sync-local', - enabled: true, + enabled: !disabledUris.has(file.uri), })); return this.itemNormalizer.normalizeItems(providerItems, promptType) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index fb72fd4e6cb02..867454b6a9a30 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -126,6 +126,7 @@ interface IAICustomizationItemTemplateData { readonly typeIcon: HTMLElement; readonly nameLabel: HighlightedLabel; readonly badge: HTMLElement; + readonly disabledBadge: HTMLElement; readonly statusIcon: HTMLElement; readonly description: HighlightedLabel; readonly disposables: DisposableStore; @@ -258,6 +259,7 @@ class AICustomizationItemRenderer implements IListRenderer { + const agentPluginService = accessor.get(IAgentPluginService); + const pluginUri = extractPluginUri(context); + if (!pluginUri) { + return; + } + const plugin = agentPluginService.plugins.get().find(p => p.uri.toString() === pluginUri.toString()); + if (!plugin || isContributionDisabled(plugin.enablement.get())) { + return; + } + agentPluginService.enablementModel.setEnabled(pluginUri.toString(), ContributionEnablementState.DisabledProfile); + } +}); + +// Enable plugin action (for plugin sub-items — enables the entire parent plugin) +const ENABLE_PLUGIN_AI_CUSTOMIZATION_ID = 'aiCustomizationManagement.enablePlugin'; +registerAction2(class extends Action2 { + constructor() { + super({ + id: ENABLE_PLUGIN_AI_CUSTOMIZATION_ID, + title: localize2('enablePlugin', "Enable Plugin"), + icon: Codicon.eye, + }); + } + async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise { + const agentPluginService = accessor.get(IAgentPluginService); + const pluginUri = extractPluginUri(context); + if (!pluginUri) { + return; + } + const plugin = agentPluginService.plugins.get().find(p => p.uri.toString() === pluginUri.toString()); + if (!plugin || isContributionEnabled(plugin.enablement.get())) { + return; + } + agentPluginService.enablementModel.setEnabled(pluginUri.toString(), ContributionEnablementState.EnabledProfile); + } +}); + +// Context menu: Disable Plugin (shown for plugin items — the items are only visible when the plugin is enabled) +MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { + command: { id: DISABLE_PLUGIN_AI_CUSTOMIZATION_ID, title: localize('disablePlugin', "Disable Plugin") }, + group: '5_toggle', + order: 1, + when: WHEN_ITEM_IS_PLUGIN, +}); + +// Inline hover: Disable Plugin +MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { + command: { id: DISABLE_PLUGIN_AI_CUSTOMIZATION_ID, title: localize('disablePlugin', "Disable Plugin"), icon: Codicon.eyeClosed }, + group: 'inline', + order: 5, + when: WHEN_ITEM_IS_PLUGIN, +}); + // Disable item action const DISABLE_AI_CUSTOMIZATION_MGMT_ITEM_ID = 'aiCustomizationManagement.disableItem'; registerAction2(class extends Action2 { @@ -648,9 +723,35 @@ registerAction2(class extends Action2 { return; } - const disabled = promptsService.getDisabledPromptFiles(promptType); + // Workspace-local items are disabled at workspace scope; user-level items at profile scope + const storage = extractStorage(context); + const scope = storage === PromptsStorage.local ? StorageScope.WORKSPACE : StorageScope.PROFILE; + const disabled = promptsService.getDisabledPromptFilesForScope(promptType, scope); disabled.add(uri); - promptsService.setDisabledPromptFiles(promptType, disabled); + promptsService.setDisabledPromptFiles(promptType, disabled, scope); + } +}); + +// Disable item for workspace action +const DISABLE_WORKSPACE_AI_CUSTOMIZATION_MGMT_ITEM_ID = 'aiCustomizationManagement.disableItemForWorkspace'; +registerAction2(class extends Action2 { + constructor() { + super({ + id: DISABLE_WORKSPACE_AI_CUSTOMIZATION_MGMT_ITEM_ID, + title: localize2('disableForWorkspace', "Disable (Workspace)"), + }); + } + async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise { + const promptsService = accessor.get(IPromptsService); + const uri = extractURI(context); + const promptType = extractPromptType(context); + if (!promptType) { + return; + } + + const disabled = promptsService.getDisabledPromptFilesForScope(promptType, StorageScope.WORKSPACE); + disabled.add(uri); + promptsService.setDisabledPromptFiles(promptType, disabled, StorageScope.WORKSPACE); } }); @@ -672,57 +773,76 @@ registerAction2(class extends Action2 { return; } - const disabled = promptsService.getDisabledPromptFiles(promptType); - disabled.delete(uri); - promptsService.setDisabledPromptFiles(promptType, disabled); + // Remove from both scopes to fully re-enable + const profileDisabled = promptsService.getDisabledPromptFilesForScope(promptType, StorageScope.PROFILE); + profileDisabled.delete(uri); + promptsService.setDisabledPromptFiles(promptType, profileDisabled, StorageScope.PROFILE); + + const workspaceDisabled = promptsService.getDisabledPromptFilesForScope(promptType, StorageScope.WORKSPACE); + workspaceDisabled.delete(uri); + promptsService.setDisabledPromptFiles(promptType, workspaceDisabled, StorageScope.WORKSPACE); } }); -// Context menu: Disable (shown when builtin item is enabled) +/** + * When clause that applies to non-plugin items (plugins use their own EnablementModel). + */ +const WHEN_ITEM_IS_NOT_PLUGIN = ContextKeyExpr.notEquals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, PromptsStorage.plugin); + +// Context menu: Disable (shown when non-plugin item is enabled) MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { command: { id: DISABLE_AI_CUSTOMIZATION_MGMT_ITEM_ID, title: localize('disable', "Disable") }, group: '5_toggle', order: 1, when: ContextKeyExpr.and( ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, false), - ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, BUILTIN_STORAGE), - ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_TYPE_KEY, PromptsType.skill), + WHEN_ITEM_IS_NOT_PLUGIN, + ), +}); + +// Context menu: Disable (Workspace) (shown for user-level items only, when a workspace is open) +MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { + command: { id: DISABLE_WORKSPACE_AI_CUSTOMIZATION_MGMT_ITEM_ID, title: localize('disableForWorkspace', "Disable (Workspace)") }, + group: '5_toggle', + order: 2, + when: ContextKeyExpr.and( + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, false), + WHEN_ITEM_IS_NOT_PLUGIN, + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, PromptsStorage.user), + ContextKeyExpr.notEquals('workbenchState', 'empty'), ), }); -// Context menu: Enable (shown when builtin item is disabled) +// Context menu: Enable (shown when non-plugin item is disabled) MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { command: { id: ENABLE_AI_CUSTOMIZATION_MGMT_ITEM_ID, title: localize('enable', "Enable") }, group: '5_toggle', order: 1, when: ContextKeyExpr.and( ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, true), - ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, BUILTIN_STORAGE), - ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_TYPE_KEY, PromptsType.skill), + WHEN_ITEM_IS_NOT_PLUGIN, ), }); -// Inline hover: Disable (shown when builtin item is enabled) +// Inline hover: Disable (shown when non-plugin item is enabled) MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { command: { id: DISABLE_AI_CUSTOMIZATION_MGMT_ITEM_ID, title: localize('disable', "Disable"), icon: Codicon.eyeClosed }, group: 'inline', order: 5, when: ContextKeyExpr.and( ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, false), - ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, BUILTIN_STORAGE), - ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_TYPE_KEY, PromptsType.skill), + WHEN_ITEM_IS_NOT_PLUGIN, ), }); -// Inline hover: Enable (shown when builtin item is disabled) +// Inline hover: Enable (shown when non-plugin item is disabled) MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { command: { id: ENABLE_AI_CUSTOMIZATION_MGMT_ITEM_ID, title: localize('enable', "Enable"), icon: Codicon.eye }, group: 'inline', order: 5, when: ContextKeyExpr.and( ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, true), - ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, BUILTIN_STORAGE), - ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_TYPE_KEY, PromptsType.skill), + WHEN_ITEM_IS_NOT_PLUGIN, ), }); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index 10d6b3a41e9c4..90ebf266428b7 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -121,6 +121,7 @@ interface IMcpServerItemTemplateData { readonly description: HTMLElement; readonly status: HTMLElement; readonly bridgedBadge: HTMLElement; + readonly disabledBadge: HTMLElement; readonly disposables: DisposableStore; } @@ -151,11 +152,14 @@ class McpServerItemRenderer implements IListRenderer 0) { + const discoveryInfo = await this.promptsService.getDiscoveryInfo(PromptsType.agent, token); + for (const file of discoveryInfo.files) { + if (!seenUris.has(file.promptPath.uri) && disabledUris.has(file.promptPath.uri)) { + items.push({ + uri: file.promptPath.uri, + type: promptType, + name: file.promptPath.name || getFriendlyName(basename(file.promptPath.uri)), + description: file.promptPath.description, + storage: file.promptPath.storage, + enabled: false, + }); + } + } + } } else if (promptType === PromptsType.skill) { const skills = await this.promptsService.findAgentSkills(token); const allSkillFiles = await this.promptsService.listPromptFiles(PromptsType.skill, token); @@ -107,17 +126,18 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt }); } if (disabledUris.size > 0) { - for (const file of allSkillFiles) { - if (!seenUris.has(file.uri) && disabledUris.has(file.uri)) { - const disabledName = file.name || basename(dirname(file.uri)) || basename(file.uri); - const disabledFolderName = basename(dirname(file.uri)); + const discoveryInfo = await this.promptsService.getDiscoveryInfo(PromptsType.skill, token); + for (const file of discoveryInfo.files) { + if (!seenUris.has(file.promptPath.uri) && disabledUris.has(file.promptPath.uri)) { + const disabledName = file.promptPath.name || basename(dirname(file.promptPath.uri)) || basename(file.promptPath.uri); + const disabledFolderName = basename(dirname(file.promptPath.uri)); const uiTooltip = uiIntegrations.get(disabledFolderName); items.push({ - uri: file.uri, + uri: file.promptPath.uri, type: promptType, name: disabledName, - description: file.description, - storage: file.storage, + description: file.promptPath.description, + storage: file.promptPath.storage, enabled: false, badge: uiTooltip ? localize('uiIntegrationBadge', "UI Integration") : undefined, badgeTooltip: uiTooltip, @@ -127,10 +147,12 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt } } else if (promptType === PromptsType.prompt) { const commands = await this.promptsService.getPromptSlashCommands(token); + const seenUris = new ResourceSet(); for (const command of commands) { if (command.type === PromptsType.skill) { continue; } + seenUris.add(command.uri); items.push({ uri: command.uri, type: promptType, @@ -143,6 +165,23 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt extensionInfoByUri.set(command.uri, { id: command.extension.identifier, displayName: command.extension.displayName }); } } + // Re-add disabled prompt files so they appear in the UI as disabled. + // Use discovery info to get properly parsed names/descriptions. + if (disabledUris.size > 0) { + const discoveryInfo = await this.promptsService.getDiscoveryInfo(PromptsType.prompt, token); + for (const file of discoveryInfo.files) { + if (!seenUris.has(file.promptPath.uri) && disabledUris.has(file.promptPath.uri)) { + items.push({ + uri: file.promptPath.uri, + type: promptType, + name: file.promptPath.name || getFriendlyName(basename(file.promptPath.uri)), + description: file.promptPath.description, + storage: file.promptPath.storage, + enabled: false, + }); + } + } + } } else if (promptType === PromptsType.hook) { await this.fetchPromptServiceHooks(items, disabledUris, promptType); } else { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index bafbae1b9e09e..a8bdd105df852 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -26,7 +26,7 @@ import { ParsedPromptFile } from './promptFileParser.js'; import { AgentInstructionFileType, IAgentSkill, ICustomAgent, IInstructionFile, IPromptsService, matchesSessionType, newInstructionsCollectionEvent, newInstructionsCollectionDebugInfo, type InstructionsCollectionEvent, type InstructionsCollectionDebugInfo } from './service/promptsService.js'; export type { InstructionsCollectionEvent, InstructionsCollectionDebugInfo } from './service/promptsService.js'; export { newInstructionsCollectionEvent, newInstructionsCollectionDebugInfo } from './service/promptsService.js'; -import { AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING, TROUBLESHOOT_SKILL_PATH } from './promptTypes.js'; +import { AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING, PromptsType, TROUBLESHOOT_SKILL_PATH } from './promptTypes.js'; import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; import { ChatConfiguration, ChatModeKind, GeneralPurposeAgentName } from '../constants.js'; import { UserSelectedTools } from '../participants/chatAgents.js'; @@ -97,7 +97,11 @@ export class ComputeAutomaticInstructions { public async collect(variables: ChatRequestVariableSet, token: CancellationToken): Promise { const startTime = performance.now(); - const instructionFiles = await this._promptsService.getInstructionFiles(token); + const allInstructionFiles = await this._promptsService.getInstructionFiles(token); + const disabledInstructions = this._promptsService.getDisabledPromptFiles(PromptsType.instructions); + const instructionFiles = disabledInstructions.size > 0 + ? allInstructionFiles.filter(f => !disabledInstructions.has(f.uri)) + : allInstructionFiles; this._logService.trace(`[InstructionsContextComputer] ${instructionFiles.length} instruction files available.`); @@ -259,11 +263,18 @@ export class ComputeAutomaticInstructions { logInfo: (message: string) => this._logService.trace(`[InstructionsContextComputer] ${message}`) }; const allCandidates = await this._promptsService.listAgentInstructions(token, logger); + const disabledInstructions = this._promptsService.getDisabledPromptFiles(PromptsType.instructions); const entries: ChatRequestVariableSet = new ChatRequestVariableSet(); const copilotEntries: ChatRequestVariableSet = new ChatRequestVariableSet(); for (const { uri, type } of allCandidates) { + if (disabledInstructions.has(uri)) { + logger.logInfo(`Agent instruction file skipped (disabled): ${uri.toString()}`); + debugInfo.debugDetails.push({ category: 'skipped', name: basename(uri).toString(), uri, reason: localize('debugDetail.disabled', 'disabled by user') }); + continue; + } + const varEntry = toPromptFileVariableEntry(uri, PromptFileVariableKind.Instruction, undefined, true); entries.add(varEntry); if (type === AgentInstructionFileType.copilotInstructionsMd) { @@ -378,7 +389,11 @@ export class ComputeAutomaticInstructions { } const agentsMdFiles = await agentsMdPromise; + const disabledIdx = this._promptsService.getDisabledPromptFiles(PromptsType.instructions); for (const { uri } of agentsMdFiles) { + if (disabledIdx.has(uri)) { + continue; + } const folderName = this._labelService.getUriLabel(dirname(uri), { relative: true }); const description = folderName.trim().length === 0 ? localize('instruction.file.description.agentsmd.root', 'Instructions for the workspace') : localize('instruction.file.description.agentsmd.folder', 'Instructions for folder \'{0}\'', folderName); entries.push(''); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index f4a97b9a8b716..8d33d2c973633 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -15,6 +15,7 @@ import { IChatModeInstructions, IVariableReference } from '../../chatModes.js'; import { PromptFileSource, PromptsType, Target } from '../promptTypes.js'; import { IHandOff, ParsedPromptFile } from '../promptFileParser.js'; import { ResourceSet } from '../../../../../../base/common/map.js'; +import { StorageScope } from '../../../../../../platform/storage/common/storage.js'; import { IResolvedPromptSourceFolder } from '../config/promptFileLocations.js'; import { ChatRequestHooks } from '../hookSchema.js'; @@ -667,8 +668,14 @@ export interface IPromptsService extends IDisposable { /** * Persists the set of disabled prompt file URIs for the given type. + * @param scope Storage scope — defaults to profile. Use WORKSPACE to disable for the current workspace only. */ - setDisabledPromptFiles(type: PromptsType, uris: ResourceSet): void; + setDisabledPromptFiles(type: PromptsType, uris: ResourceSet, scope?: StorageScope): void; + + /** + * Returns the disabled prompt file URIs for a specific scope. + */ + getDisabledPromptFilesForScope(type: PromptsType, scope: StorageScope): ResourceSet; /** * Registers a prompt file provider that can provide prompt files for repositories. diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 645989eb30acd..da27f036c4c62 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -626,8 +626,9 @@ export class PromptsService extends Disposable implements IPromptsService { const useAgentSkills = this.configurationService.getValue(PromptsConfig.USE_AGENT_SKILLS); const skills = useAgentSkills ? await this.listPromptFiles(PromptsType.skill, token) : []; const disabledSkills = this.getDisabledPromptFiles(PromptsType.skill); + const disabledPrompts = this.getDisabledPromptFiles(PromptsType.prompt); const slashCommandFiles = [ - ...promptFiles, + ...promptFiles.filter(p => !disabledPrompts.has(p.uri)), ...skills.filter(s => !disabledSkills.has(s.uri)), ]; @@ -782,7 +783,15 @@ export class PromptsService extends Disposable implements IPromptsService { const uri = promptPath.uri; if (disabledAgents.has(uri)) { - return { status: 'skipped', skipReason: 'disabled', promptPath }; + // Still parse the header so we have name/description for the UI + try { + const ast = await this.parseNew(uri, token); + const name = ast.header?.name; + const description = ast.header?.description; + return { status: 'skipped', skipReason: 'disabled', promptPath: this.withPromptPathMetadata(promptPath, name ?? promptPath.name, description ?? promptPath.description) }; + } catch { + return { status: 'skipped', skipReason: 'disabled', promptPath }; + } } try { @@ -1074,12 +1083,19 @@ export class PromptsService extends Disposable implements IPromptsService { // --- Enabled Prompt Files ----------------------------------------------------------- private readonly disabledPromptsStorageKeyPrefix = 'chat.disabledPromptFiles.'; + private readonly disabledPromptsWorkspaceStorageKeyPrefix = 'chat.disabledPromptFiles.workspace.'; public getDisabledPromptFiles(type: PromptsType): ResourceSet { - // Migration: if disabled key absent but legacy enabled key present, convert once. - const disabledKey = this.disabledPromptsStorageKeyPrefix + type; - const value = this.storageService.get(disabledKey, StorageScope.PROFILE, '[]'); const result = new ResourceSet(); + // Read profile-level disabled URIs + this._readDisabledFromStorage(this.disabledPromptsStorageKeyPrefix + type, StorageScope.PROFILE, result); + // Read workspace-level disabled URIs + this._readDisabledFromStorage(this.disabledPromptsWorkspaceStorageKeyPrefix + type, StorageScope.WORKSPACE, result); + return result; + } + + private _readDisabledFromStorage(key: string, scope: StorageScope, result: ResourceSet): void { + const value = this.storageService.get(key, scope, '[]'); try { const arr = JSON.parse(value); if (Array.isArray(arr)) { @@ -1094,17 +1110,41 @@ export class PromptsService extends Disposable implements IPromptsService { } catch { // ignore invalid storage values } - return result; } - public setDisabledPromptFiles(type: PromptsType, uris: ResourceSet): void { + public setDisabledPromptFiles(type: PromptsType, uris: ResourceSet, scope: StorageScope = StorageScope.PROFILE): void { const disabled = Array.from(uris).map(uri => uri.toJSON()); - this.storageService.store(this.disabledPromptsStorageKeyPrefix + type, JSON.stringify(disabled), StorageScope.PROFILE, StorageTarget.USER); + const key = scope === StorageScope.WORKSPACE + ? this.disabledPromptsWorkspaceStorageKeyPrefix + type + : this.disabledPromptsStorageKeyPrefix + type; + this.storageService.store(key, JSON.stringify(disabled), scope, StorageTarget.USER); + this._refreshCachesForType(type); + } + + /** + * Returns the profile-level disabled URIs for a given type (excludes workspace overrides). + */ + public getDisabledPromptFilesForScope(type: PromptsType, scope: StorageScope): ResourceSet { + const result = new ResourceSet(); + const key = scope === StorageScope.WORKSPACE + ? this.disabledPromptsWorkspaceStorageKeyPrefix + type + : this.disabledPromptsStorageKeyPrefix + type; + this._readDisabledFromStorage(key, scope, result); + return result; + } + + private _refreshCachesForType(type: PromptsType): void { if (type === PromptsType.agent) { this.cachedCustomAgents.refresh(); } else if (type === PromptsType.skill) { this.cachedSkills.refresh(); this.cachedSlashCommands.refresh(); + } else if (type === PromptsType.instructions) { + this.cachedInstructions.refresh(); + } else if (type === PromptsType.prompt) { + this.cachedSlashCommands.refresh(); + } else if (type === PromptsType.hook) { + this.cachedHooks.refresh(); } } @@ -1192,17 +1232,18 @@ export class PromptsService extends Disposable implements IPromptsService { } const discoveryInfo = await this.cachedSkills.get(token); - const result = this.skillsFromDiscoveryInfo(discoveryInfo); + const disabledSkills = this.getDisabledPromptFiles(PromptsType.skill); + const result = this.skillsFromDiscoveryInfo(discoveryInfo, disabledSkills); return result; } /** * Derives IAgentSkill[] from cached discovery info. */ - private skillsFromDiscoveryInfo(discoveryInfo: IPromptDiscoveryInfo): IAgentSkill[] { + private skillsFromDiscoveryInfo(discoveryInfo: IPromptDiscoveryInfo, disabledSkills?: ResourceSet): IAgentSkill[] { const result: IAgentSkill[] = []; for (const file of discoveryInfo.files) { - if (file.status === 'loaded' && file.promptPath.name) { + if (file.status === 'loaded' && file.promptPath.name && !disabledSkills?.has(file.promptPath.uri)) { const sanitizedDescription = this.truncateAgentSkillDescription(file.promptPath.description, file.promptPath.uri); const when = isExtensionPromptPath(file.promptPath) && file.promptPath.when ? ContextKeyExpr.deserialize(file.promptPath.when) ?? undefined @@ -1404,7 +1445,11 @@ export class PromptsService extends Disposable implements IPromptsService { } const useClaudeHooks = this.configurationService.getValue(PromptsConfig.USE_CLAUDE_HOOKS); - const hookFiles = await this.listPromptFiles(PromptsType.hook, token); + const allHookFiles = await this.listPromptFiles(PromptsType.hook, token); + const disabledHooks = this.getDisabledPromptFiles(PromptsType.hook); + const hookFiles = disabledHooks.size > 0 + ? allHookFiles.filter(h => !disabledHooks.has(h.uri)) + : allHookFiles; this.logger.trace(`[PromptsService] Found ${hookFiles.length} hook file(s).`); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts index ddc024803a1cd..db2202478628c 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import * as sinon from 'sinon'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Event } from '../../../../../../base/common/event.js'; +import { ResourceSet } from '../../../../../../base/common/map.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { OperatingSystem } from '../../../../../../base/common/platform.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -33,7 +34,7 @@ import { ChatRequestVariableSet, isPromptFileVariableEntry, isPromptTextVariable import { ComputeAutomaticInstructions, getFilePath, InstructionsCollectionEvent } from '../../../common/promptSyntax/computeAutomaticInstructions.js'; import { PromptsConfig } from '../../../common/promptSyntax/config/config.js'; import { AGENTS_SOURCE_FOLDER, CLAUDE_RULES_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION } from '../../../common/promptSyntax/config/promptFileLocations.js'; -import { INSTRUCTIONS_LANGUAGE_ID, PROMPT_LANGUAGE_ID } from '../../../common/promptSyntax/promptTypes.js'; +import { INSTRUCTIONS_LANGUAGE_ID, PROMPT_LANGUAGE_ID, PromptsType } from '../../../common/promptSyntax/promptTypes.js'; import { IAgentSkill, IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { PromptsService } from '../../../common/promptSyntax/service/promptsServiceImpl.js'; import { mockFiles, TestInMemoryFileSystemProviderWithRealPath } from './testUtils/mockFilesystem.js'; @@ -2609,6 +2610,54 @@ suite('ComputeAutomaticInstructions', () => { assert.ok(!paths.includes(agentMdUri.path), 'Should not include AGENTS.md (symlink to copilot)'); assert.ok(!paths.includes(claudeMdUri.path), 'Should not include CLAUDE.md (symlink to copilot)'); }); + + test('disabled instructions are excluded from collect', async () => { + // Replace the stub storage with a real InMemoryStorageService + const realStorage = disposables.add(new InMemoryStorageService()); + instaService.stub(IStorageService, realStorage); + const localService = disposables.add(instaService.createInstance(PromptsService)); + instaService.stub(IPromptsService, localService); + + const rootFolderName = 'disabled-instructions-collect'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + const enabledUri = URI.joinPath(rootFolderUri, '.github/instructions/enabled.instructions.md'); + const disabledUri = URI.joinPath(rootFolderUri, '.github/instructions/disabled.instructions.md'); + + await mockFiles(fileService, [ + { + path: enabledUri.path, + contents: ['---', 'description: Enabled instruction', 'applyTo: "**"', '---', 'Enabled content'], + }, + { + path: disabledUri.path, + contents: ['---', 'description: Disabled instruction', 'applyTo: "**"', '---', 'Disabled content'], + }, + { + path: `${rootFolder}/src/index.ts`, + contents: ['console.log("test");'], + }, + ]); + + // Disable one instruction + const disabled = new ResourceSet(); + disabled.add(disabledUri); + localService.setDisabledPromptFiles(PromptsType.instructions, disabled); + + const computer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/index.ts'))); + + await computer.collect(variables, CancellationToken.None); + + const promptVars = variables.asArray().filter(isPromptFileVariableEntry); + const uris = promptVars.map(v => v.value.toString()); + + assert.ok(uris.includes(enabledUri.toString()), 'Enabled instruction should be added'); + assert.ok(!uris.includes(disabledUri.toString()), 'Disabled instruction should be excluded'); + }); }); suite('getFilePath', () => { diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts index c76355b016e57..02cb8fe3c982d 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts @@ -61,6 +61,7 @@ export class MockPromptsService implements IPromptsService { getAgentFileURIFromModeFile(oldURI: URI): URI | undefined { throw new Error('Not implemented'); } getDisabledPromptFiles(type: PromptsType): ResourceSet { throw new Error('Method not implemented.'); } setDisabledPromptFiles(type: PromptsType, uris: ResourceSet): void { throw new Error('Method not implemented.'); } + getDisabledPromptFilesForScope(type: PromptsType, scope: import('../../../../../../../platform/storage/common/storage.js').StorageScope): ResourceSet { throw new Error('Method not implemented.'); } registerPromptFileProvider(extension: IExtensionDescription, type: PromptsType, provider: { providePromptFiles: (context: IPromptFileContext, token: CancellationToken) => Promise }): IDisposable { throw new Error('Method not implemented.'); } findAgentSkills(token: CancellationToken): Promise { throw new Error('Method not implemented.'); } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 743114b8eaf5e..d2fb2df6d65b6 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -44,7 +44,7 @@ import { INSTRUCTIONS_LANGUAGE_ID, PROMPT_LANGUAGE_ID, PromptFileSource, Prompts import { ICustomAgent, IPromptFileContext, IPromptsService, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; import { PromptsService } from '../../../../common/promptSyntax/service/promptsServiceImpl.js'; import { mockFiles } from '../testUtils/mockFilesystem.js'; -import { InMemoryStorageService, IStorageService } from '../../../../../../../platform/storage/common/storage.js'; +import { InMemoryStorageService, IStorageService, StorageScope } from '../../../../../../../platform/storage/common/storage.js'; import { IPathService } from '../../../../../../services/path/common/pathService.js'; import { IFileMatch, IFileQuery, ISearchService } from '../../../../../../services/search/common/search.js'; import { IExtensionService } from '../../../../../../services/extensions/common/extensions.js'; @@ -4451,6 +4451,184 @@ suite('PromptsService', () => { assert.strictEqual(pluginInstruction!.name, 'deploy-tools:lint-check'); }); }); + + suite('setDisabledPromptFiles', () => { + + let storageService: InMemoryStorageService; + let localService: IPromptsService; + + setup(() => { + // Replace the stub storage with a real InMemoryStorageService instance + storageService = disposables.add(new InMemoryStorageService()); + instaService.stub(IStorageService, storageService); + localService = disposables.add(instaService.createInstance(PromptsService)); + }); + + test('disabled skills are excluded from findAgentSkills', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'disabled-skills-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + const skillUri = URI.joinPath(rootFolderUri, '.github/skills/my-skill/SKILL.md'); + + await mockFiles(fileService, [{ + path: skillUri.path, + contents: [ + '---', + 'name: my-skill', + 'description: A test skill', + '---', + 'Skill content', + ], + }]); + + const skills = await localService.findAgentSkills(CancellationToken.None); + assert.ok(skills?.some(s => s.uri.toString() === skillUri.toString()), 'Skill should be found before disabling'); + + const disabled = new ResourceSet(); + disabled.add(skillUri); + localService.setDisabledPromptFiles(PromptsType.skill, disabled); + + const skillsAfter = await localService.findAgentSkills(CancellationToken.None); + assert.ok(!skillsAfter?.some(s => s.uri.toString() === skillUri.toString()), 'Disabled skill should be excluded'); + }); + + test('disabled prompts are excluded from getPromptSlashCommands', async () => { + const rootFolderName = 'disabled-prompts-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + const promptUri = URI.joinPath(rootFolderUri, '.github/prompts/my-prompt.prompt.md'); + + await mockFiles(fileService, [{ + path: promptUri.path, + contents: [ + '---', + 'name: my-prompt', + 'description: A test prompt', + '---', + 'Prompt content', + ], + }]); + + const commands = await localService.getPromptSlashCommands(CancellationToken.None); + assert.ok(commands.some(c => c.uri.toString() === promptUri.toString()), 'Prompt should be found before disabling'); + + const disabled = new ResourceSet(); + disabled.add(promptUri); + localService.setDisabledPromptFiles(PromptsType.prompt, disabled); + + const commandsAfter = await localService.getPromptSlashCommands(CancellationToken.None); + assert.ok(!commandsAfter.some(c => c.uri.toString() === promptUri.toString()), 'Disabled prompt should be excluded'); + }); + + test('disabled agents are excluded from getCustomAgents', async () => { + const rootFolderName = 'disabled-agents-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + const agentUri = URI.joinPath(rootFolderUri, '.github/agents/my-agent.agent.md'); + + await mockFiles(fileService, [{ + path: agentUri.path, + contents: [ + '---', + 'name: my-agent', + 'description: A test agent', + '---', + 'Agent content', + ], + }]); + + const agents = await localService.getCustomAgents(CancellationToken.None); + assert.ok(agents.some(a => a.uri.toString() === agentUri.toString()), 'Agent should be found before disabling'); + + const disabled = new ResourceSet(); + disabled.add(agentUri); + localService.setDisabledPromptFiles(PromptsType.agent, disabled); + + const agentsAfter = await localService.getCustomAgents(CancellationToken.None); + assert.ok(!agentsAfter.some(a => a.uri.toString() === agentUri.toString()), 'Disabled agent should be excluded'); + }); + + test('getDisabledPromptFiles returns persisted set', () => { + const uri1 = URI.file('/test/file1.instructions.md'); + const uri2 = URI.file('/test/file2.instructions.md'); + + const disabled = new ResourceSet(); + disabled.add(uri1); + disabled.add(uri2); + localService.setDisabledPromptFiles(PromptsType.instructions, disabled); + + const result = localService.getDisabledPromptFiles(PromptsType.instructions); + assert.strictEqual(result.size, 2, 'Should persist two disabled URIs'); + assert.ok(result.has(uri1), 'Should contain first URI'); + assert.ok(result.has(uri2), 'Should contain second URI'); + }); + + test('re-enabling removes from disabled set', () => { + const uri = URI.file('/test/file.instructions.md'); + + const disabled = new ResourceSet(); + disabled.add(uri); + localService.setDisabledPromptFiles(PromptsType.instructions, disabled); + assert.strictEqual(localService.getDisabledPromptFiles(PromptsType.instructions).size, 1); + + const reEnabled = new ResourceSet(); + localService.setDisabledPromptFiles(PromptsType.instructions, reEnabled); + assert.strictEqual(localService.getDisabledPromptFiles(PromptsType.instructions).size, 0); + }); + + test('disabled types are independent', () => { + const uri = URI.file('/test/file.md'); + + const disabledInstructions = new ResourceSet(); + disabledInstructions.add(uri); + localService.setDisabledPromptFiles(PromptsType.instructions, disabledInstructions); + + assert.strictEqual(localService.getDisabledPromptFiles(PromptsType.instructions).size, 1, 'Instructions should have disabled URI'); + assert.strictEqual(localService.getDisabledPromptFiles(PromptsType.skill).size, 0, 'Skills should be unaffected'); + assert.strictEqual(localService.getDisabledPromptFiles(PromptsType.prompt).size, 0, 'Prompts should be unaffected'); + assert.strictEqual(localService.getDisabledPromptFiles(PromptsType.agent).size, 0, 'Agents should be unaffected'); + assert.strictEqual(localService.getDisabledPromptFiles(PromptsType.hook).size, 0, 'Hooks should be unaffected'); + }); + + test('workspace-scoped disablement is independent from profile', () => { + const uri = URI.file('/test/file.instructions.md'); + + const profileDisabled = new ResourceSet(); + profileDisabled.add(uri); + localService.setDisabledPromptFiles(PromptsType.instructions, profileDisabled, StorageScope.PROFILE); + + assert.strictEqual(localService.getDisabledPromptFilesForScope(PromptsType.instructions, StorageScope.PROFILE).size, 1, 'Profile scope should have the URI'); + assert.strictEqual(localService.getDisabledPromptFilesForScope(PromptsType.instructions, StorageScope.WORKSPACE).size, 0, 'Workspace scope should be empty'); + assert.strictEqual(localService.getDisabledPromptFiles(PromptsType.instructions).size, 1, 'Combined should include profile-disabled URI'); + }); + + test('workspace-scoped disablement merges with profile in getDisabledPromptFiles', () => { + const profileUri = URI.file('/test/profile-disabled.instructions.md'); + const workspaceUri = URI.file('/test/workspace-disabled.instructions.md'); + + const profileDisabled = new ResourceSet(); + profileDisabled.add(profileUri); + localService.setDisabledPromptFiles(PromptsType.instructions, profileDisabled, StorageScope.PROFILE); + + const workspaceDisabled = new ResourceSet(); + workspaceDisabled.add(workspaceUri); + localService.setDisabledPromptFiles(PromptsType.instructions, workspaceDisabled, StorageScope.WORKSPACE); + + const combined = localService.getDisabledPromptFiles(PromptsType.instructions); + assert.strictEqual(combined.size, 2, 'Combined should include both scopes'); + assert.ok(combined.has(profileUri), 'Combined should include profile-disabled URI'); + assert.ok(combined.has(workspaceUri), 'Combined should include workspace-disabled URI'); + }); + }); }); function fireConfigChange(configService: TestConfigurationService, ...key: string[]): void { diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts index ce66bde954d78..818b2e4d4fdfd 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts @@ -54,6 +54,7 @@ function createMockPromptsService(instructionFiles: IFixtureInstructionFile[], a override readonly onDidChangeInstructions = Event.None; override readonly onDidChangeHooks = Event.None; override getDisabledPromptFiles(): ResourceSet { return new ResourceSet(); } + override getDisabledPromptFilesForScope(): ResourceSet { return new ResourceSet(); } override async listPromptFiles(type: PromptsType) { if (type === PromptsType.instructions) { return instructionFiles.map(f => f.promptPath); From c978c5de06778151a7343116e40bdb93894977a8 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Tue, 21 Apr 2026 19:09:38 -0700 Subject: [PATCH 02/36] updates --- .../aiCustomizationItemSource.ts | 88 ++++++++++++++++--- .../aiCustomizationListWidget.ts | 22 ++--- .../browser/aiCustomization/mcpListWidget.ts | 12 ++- .../media/aiCustomizationManagement.css | 20 +---- ...promptsServiceCustomizationItemProvider.ts | 70 ++++++++++++++- .../service/promptsServiceImpl.ts | 20 ++--- 6 files changed, 168 insertions(+), 64 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index f2b6fa0763078..d12b08d4a2093 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -7,7 +7,7 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Event } from '../../../../../base/common/event.js'; import { IMatch } from '../../../../../base/common/filters.js'; import { parse as parseJSONC } from '../../../../../base/common/json.js'; -import { ResourceMap } from '../../../../../base/common/map.js'; +import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; import { OS } from '../../../../../base/common/platform.js'; import { basename, dirname, isEqualOrParent } from '../../../../../base/common/resources.js'; @@ -320,20 +320,18 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour private readonly pathService: IPathService, private readonly itemNormalizer: AICustomizationItemNormalizer, ) { - const onDidChangeSyncableCustomizations = this.syncProvider - ? Event.any( - this.promptsService.onDidChangeCustomAgents, - this.promptsService.onDidChangeSlashCommands, - this.promptsService.onDidChangeSkills, - this.promptsService.onDidChangeHooks, - this.promptsService.onDidChangeInstructions, - ) - : Event.None; + const promptServiceEvents = Event.any( + this.promptsService.onDidChangeCustomAgents, + this.promptsService.onDidChangeSlashCommands, + this.promptsService.onDidChangeSkills, + this.promptsService.onDidChangeHooks, + this.promptsService.onDidChangeInstructions, + ); this.onDidChange = Event.any( this.itemProvider?.onDidChange ?? Event.None, this.syncProvider?.onDidChange ?? Event.None, - onDidChangeSyncableCustomizations, + promptServiceEvents, ); } @@ -373,12 +371,80 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour } const normalized = this.itemNormalizer.normalizeItems(providerItems, promptType); + + // Overlay disabled state from storage for providers that don't track + // disablement themselves (e.g. extension-contributed harness providers). + // Also add back disabled items that the provider didn't include at all. + const disabledUris = this.promptsService.getDisabledPromptFiles(promptType); + if (disabledUris.size > 0) { + const existingUris = new ResourceSet(normalized.map(i => i.uri)); + for (let i = 0; i < normalized.length; i++) { + if (!normalized[i].disabled && disabledUris.has(normalized[i].uri)) { + normalized[i] = { ...normalized[i], disabled: true }; + } + } + const missing = await this.resolveMissingDisabledItems(promptType, disabledUris, existingUris); + normalized.push(...missing); + } + if (promptType === PromptsType.skill) { return this.mergeBuiltinSkills(normalized, promptType); } return normalized; } + /** + * Resolves disabled items that are missing from the provider's results. + * External providers (e.g. extension-contributed harness providers) may + * not include disabled items at all. This method queries discovery info + * and file listings to reconstruct them so they appear in the UI. + */ + private async resolveMissingDisabledItems( + promptType: PromptsType, + disabledUris: ResourceSet, + existingUris: ResourceSet, + ): Promise { + const missingItems: ICustomizationItem[] = []; + const resolvedUris = new ResourceSet(); + + try { + const discovery = await this.promptsService.getDiscoveryInfo(promptType, CancellationToken.None); + for (const file of discovery.files) { + if (disabledUris.has(file.promptPath.uri) && !existingUris.has(file.promptPath.uri)) { + resolvedUris.add(file.promptPath.uri); + const name = promptType === PromptsType.skill + ? (file.promptPath.name || basename(dirname(file.promptPath.uri)) || basename(file.promptPath.uri)) + : (file.promptPath.name || getFriendlyName(basename(file.promptPath.uri))); + missingItems.push({ + uri: file.promptPath.uri, + type: promptType, + name, + description: file.promptPath.description, + storage: file.promptPath.storage, + enabled: false, + }); + } + } + } catch { /* discovery info not available */ } + + // Fallback for disabled URIs not found in discovery info + for (const uri of disabledUris) { + if (!existingUris.has(uri) && !resolvedUris.has(uri)) { + const name = promptType === PromptsType.skill + ? (basename(dirname(uri)) || basename(uri)) + : getFriendlyName(basename(uri)); + missingItems.push({ + uri, + type: promptType, + name, + enabled: false, + }); + } + } + + return this.itemNormalizer.normalizeItems(missingItems, promptType); + } + /** * Merges built-in skills (bundled with the app under `vs/sessions/skills/`) * into the provider's items. The provider may re-discover the bundled diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 867454b6a9a30..4a86704ab6147 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -126,7 +126,6 @@ interface IAICustomizationItemTemplateData { readonly typeIcon: HTMLElement; readonly nameLabel: HighlightedLabel; readonly badge: HTMLElement; - readonly disabledBadge: HTMLElement; readonly statusIcon: HTMLElement; readonly description: HighlightedLabel; readonly disposables: DisposableStore; @@ -259,7 +258,6 @@ class AICustomizationItemRenderer implements IListRenderer { @@ -348,7 +349,7 @@ class AICustomizationItemRenderer implements IListRenderer 0) { const discoveryInfo = await this.promptsService.getDiscoveryInfo(PromptsType.agent, token); + const discoveredUris = new ResourceSet(discoveryInfo.files.map(f => f.promptPath.uri)); for (const file of discoveryInfo.files) { if (!seenUris.has(file.promptPath.uri) && disabledUris.has(file.promptPath.uri)) { items.push({ @@ -98,6 +99,19 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt }); } } + // Fallback: add disabled agents not found in discovery info + for (const file of allAgentFiles) { + if (!seenUris.has(file.uri) && !discoveredUris.has(file.uri) && disabledUris.has(file.uri)) { + items.push({ + uri: file.uri, + type: promptType, + name: file.name || getFriendlyName(basename(file.uri)), + description: file.description, + storage: file.storage, + enabled: false, + }); + } + } } } else if (promptType === PromptsType.skill) { const skills = await this.promptsService.findAgentSkills(token); @@ -120,13 +134,14 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt name: skillName, description: skill.description, storage: skill.storage, - enabled: true, + enabled: !disabledUris.has(skill.uri), badge: uiTooltip ? localize('uiIntegrationBadge', "UI Integration") : undefined, badgeTooltip: uiTooltip, }); } if (disabledUris.size > 0) { const discoveryInfo = await this.promptsService.getDiscoveryInfo(PromptsType.skill, token); + const discoveredUris = new ResourceSet(discoveryInfo.files.map(f => f.promptPath.uri)); for (const file of discoveryInfo.files) { if (!seenUris.has(file.promptPath.uri) && disabledUris.has(file.promptPath.uri)) { const disabledName = file.promptPath.name || basename(dirname(file.promptPath.uri)) || basename(file.promptPath.uri); @@ -144,6 +159,19 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt }); } } + // Fallback: add disabled skills not found in discovery info + for (const file of allSkillFiles) { + if (!seenUris.has(file.uri) && !discoveredUris.has(file.uri) && disabledUris.has(file.uri)) { + items.push({ + uri: file.uri, + type: promptType, + name: file.name || basename(dirname(file.uri)) || basename(file.uri), + description: file.description, + storage: file.storage, + enabled: false, + }); + } + } } } else if (promptType === PromptsType.prompt) { const commands = await this.promptsService.getPromptSlashCommands(token); @@ -169,6 +197,7 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt // Use discovery info to get properly parsed names/descriptions. if (disabledUris.size > 0) { const discoveryInfo = await this.promptsService.getDiscoveryInfo(PromptsType.prompt, token); + const discoveredUris = new ResourceSet(discoveryInfo.files.map(f => f.promptPath.uri)); for (const file of discoveryInfo.files) { if (!seenUris.has(file.promptPath.uri) && disabledUris.has(file.promptPath.uri)) { items.push({ @@ -181,6 +210,20 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt }); } } + // Fallback: add disabled prompts not found in discovery info + const allPromptFiles = await this.promptsService.listPromptFiles(PromptsType.prompt, token); + for (const file of allPromptFiles) { + if (!seenUris.has(file.uri) && !discoveredUris.has(file.uri) && disabledUris.has(file.uri)) { + items.push({ + uri: file.uri, + type: promptType, + name: file.name || getFriendlyName(basename(file.uri)), + description: file.description, + storage: file.storage, + enabled: false, + }); + } + } } } else if (promptType === PromptsType.hook) { await this.fetchPromptServiceHooks(items, disabledUris, promptType); @@ -188,7 +231,30 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt await this.fetchPromptServiceInstructions(items, extensionInfoByUri, disabledUris, promptType); } - return this.applyLocalFilters(this.applyBuiltinGroupKeys(items, extensionInfoByUri), promptType); + // Safety net: ensure every disabled URI appears in the items list. + // Some disabled files may not be found in discovery info or listPromptFiles + // (e.g. when the file's source folder is no longer in the configured locations). + if (disabledUris.size > 0) { + const itemUris = new ResourceSet(items.map(i => i.uri)); + for (const uri of disabledUris) { + const alreadyInItems = itemUris.has(uri); + const existingItem = alreadyInItems ? items.find(i => i.uri.toString() === uri.toString()) : undefined; + if (!alreadyInItems) { + items.push({ + uri, + type: promptType, + name: getFriendlyName(basename(uri)), + enabled: false, + }); + } else if (existingItem && existingItem.enabled !== false) { + // Item exists but is marked as enabled despite being in disabled list — fix it + (existingItem as { enabled?: boolean }).enabled = false; + } + } + } + + const filtered = this.applyLocalFilters(this.applyBuiltinGroupKeys(items, extensionInfoByUri), promptType); + return filtered; } private async fetchPromptServiceHooks(items: ICustomizationItem[], disabledUris: ResourceSet, promptType: PromptsType): Promise { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index da27f036c4c62..aa83ca1c16e75 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -1134,18 +1134,14 @@ export class PromptsService extends Disposable implements IPromptsService { } private _refreshCachesForType(type: PromptsType): void { - if (type === PromptsType.agent) { - this.cachedCustomAgents.refresh(); - } else if (type === PromptsType.skill) { - this.cachedSkills.refresh(); - this.cachedSlashCommands.refresh(); - } else if (type === PromptsType.instructions) { - this.cachedInstructions.refresh(); - } else if (type === PromptsType.prompt) { - this.cachedSlashCommands.refresh(); - } else if (type === PromptsType.hook) { - this.cachedHooks.refresh(); - } + // Refresh all caches to ensure the disabled state is picked up + // everywhere — the disabled set is a cross-cutting concern that + // affects discovery, slash commands, skills, and hooks. + this.cachedCustomAgents.refresh(); + this.cachedSlashCommands.refresh(); + this.cachedSkills.refresh(); + this.cachedInstructions.refresh(); + this.cachedHooks.refresh(); } // Agent skills From 9d0a1f4f18f3e3b991af46f9271d7ddc19b83079 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Tue, 21 Apr 2026 19:44:06 -0700 Subject: [PATCH 03/36] PR updates --- .../aiCustomizationManagement.contribution.ts | 58 +++++++++++++++---- ...promptsServiceCustomizationItemProvider.ts | 10 ++-- .../common/chatService/chatServiceImpl.ts | 7 ++- .../service/promptsServiceImpl.ts | 41 +++++++++---- .../tools/builtinTools/runSubagentTool.ts | 20 ++++--- 5 files changed, 100 insertions(+), 36 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index 4cd4fe03594ac..855c4253479cb 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -689,12 +689,15 @@ registerAction2(class extends Action2 { } }); -// Context menu: Disable Plugin (shown for plugin items — the items are only visible when the plugin is enabled) +// Context menu: Disable Plugin (shown for plugin items when plugin is enabled) MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { command: { id: DISABLE_PLUGIN_AI_CUSTOMIZATION_ID, title: localize('disablePlugin', "Disable Plugin") }, group: '5_toggle', order: 1, - when: WHEN_ITEM_IS_PLUGIN, + when: ContextKeyExpr.and( + WHEN_ITEM_IS_PLUGIN, + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, false), + ), }); // Inline hover: Disable Plugin @@ -702,7 +705,32 @@ MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { command: { id: DISABLE_PLUGIN_AI_CUSTOMIZATION_ID, title: localize('disablePlugin', "Disable Plugin"), icon: Codicon.eyeClosed }, group: 'inline', order: 5, - when: WHEN_ITEM_IS_PLUGIN, + when: ContextKeyExpr.and( + WHEN_ITEM_IS_PLUGIN, + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, false), + ), +}); + +// Context menu: Enable Plugin (shown for plugin items when plugin is disabled) +MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { + command: { id: ENABLE_PLUGIN_AI_CUSTOMIZATION_ID, title: localize('enablePlugin', "Enable Plugin") }, + group: '5_toggle', + order: 1, + when: ContextKeyExpr.and( + WHEN_ITEM_IS_PLUGIN, + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, true), + ), +}); + +// Inline hover: Enable Plugin +MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { + command: { id: ENABLE_PLUGIN_AI_CUSTOMIZATION_ID, title: localize('enablePlugin', "Enable Plugin"), icon: Codicon.eye }, + group: 'inline', + order: 5, + when: ContextKeyExpr.and( + WHEN_ITEM_IS_PLUGIN, + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, true), + ), }); // Disable item action @@ -773,14 +801,21 @@ registerAction2(class extends Action2 { return; } - // Remove from both scopes to fully re-enable + // Remove from both scopes to fully re-enable — but only write + // the scopes that actually contain the URI to avoid unnecessary + // cache invalidation. const profileDisabled = promptsService.getDisabledPromptFilesForScope(promptType, StorageScope.PROFILE); - profileDisabled.delete(uri); - promptsService.setDisabledPromptFiles(promptType, profileDisabled, StorageScope.PROFILE); + const wasInProfile = profileDisabled.delete(uri); const workspaceDisabled = promptsService.getDisabledPromptFilesForScope(promptType, StorageScope.WORKSPACE); - workspaceDisabled.delete(uri); - promptsService.setDisabledPromptFiles(promptType, workspaceDisabled, StorageScope.WORKSPACE); + const wasInWorkspace = workspaceDisabled.delete(uri); + + if (wasInProfile) { + promptsService.setDisabledPromptFiles(promptType, profileDisabled, StorageScope.PROFILE); + } + if (wasInWorkspace) { + promptsService.setDisabledPromptFiles(promptType, workspaceDisabled, StorageScope.WORKSPACE); + } } }); @@ -800,7 +835,7 @@ MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { ), }); -// Context menu: Disable (Workspace) (shown for user-level items only, when a workspace is open) +// Context menu: Disable (Workspace) (shown for user-level and extension items, when a workspace is open) MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { command: { id: DISABLE_WORKSPACE_AI_CUSTOMIZATION_MGMT_ITEM_ID, title: localize('disableForWorkspace', "Disable (Workspace)") }, group: '5_toggle', @@ -808,7 +843,10 @@ MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { when: ContextKeyExpr.and( ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, false), WHEN_ITEM_IS_NOT_PLUGIN, - ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, PromptsStorage.user), + ContextKeyExpr.or( + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, PromptsStorage.user), + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, PromptsStorage.extension), + ), ContextKeyExpr.notEquals('workbenchState', 'empty'), ), }); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts index 3e5d5fc2b3ef9..77130ff0fb778 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts @@ -238,7 +238,6 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt const itemUris = new ResourceSet(items.map(i => i.uri)); for (const uri of disabledUris) { const alreadyInItems = itemUris.has(uri); - const existingItem = alreadyInItems ? items.find(i => i.uri.toString() === uri.toString()) : undefined; if (!alreadyInItems) { items.push({ uri, @@ -246,9 +245,12 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt name: getFriendlyName(basename(uri)), enabled: false, }); - } else if (existingItem && existingItem.enabled !== false) { - // Item exists but is marked as enabled despite being in disabled list — fix it - (existingItem as { enabled?: boolean }).enabled = false; + } else { + const idx = items.findIndex(i => i.uri.toString() === uri.toString()); + if (idx !== -1 && items[idx].enabled !== false) { + // Item exists but is marked as enabled despite being in disabled list — fix it + items[idx] = { ...items[idx], enabled: false }; + } } } } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 2f93372484952..53e9f44ba2289 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -54,7 +54,7 @@ import { ChatMessageRole, IChatMessage, ILanguageModelsService } from '../langua import { ILanguageModelToolsService } from '../tools/languageModelToolsService.js'; import { ChatSessionOperationLog } from '../model/chatSessionOperationLog.js'; import { IPromptsService } from '../promptSyntax/service/promptsService.js'; -import { AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING, TROUBLESHOOT_COMMAND_NAME, TROUBLESHOOT_SKILL_PATH, COPILOT_SKILL_URI_SCHEME } from '../promptSyntax/promptTypes.js'; +import { AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING, TROUBLESHOOT_COMMAND_NAME, TROUBLESHOOT_SKILL_PATH, COPILOT_SKILL_URI_SCHEME, PromptsType } from '../promptSyntax/promptTypes.js'; import { ChatRequestHooks, mergeHooks } from '../promptSyntax/hookSchema.js'; import { ComputeAutomaticInstructions } from '../promptSyntax/computeAutomaticInstructions.js'; import { findLast } from '../../../../../base/common/arraysFind.js'; @@ -1119,7 +1119,10 @@ export class ChatService extends Disposable implements IChatService { const agents = await this.promptsService.getCustomAgents(token); const customAgent = agents.find(a => a.name === agentName); if (customAgent?.hooks) { - collectedHooks = mergeHooks(collectedHooks, customAgent.hooks); + const disabledHooks = this.promptsService.getDisabledPromptFiles(PromptsType.hook); + if (!disabledHooks.has(customAgent.uri)) { + collectedHooks = mergeHooks(collectedHooks, customAgent.hooks); + } } } catch (error) { this.logService.warn('[ChatService] Failed to collect agent hooks:', error); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index aa83ca1c16e75..f1721c5527761 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -625,11 +625,9 @@ export class PromptsService extends Disposable implements IPromptsService { const promptFiles = await this.listPromptFiles(PromptsType.prompt, token); const useAgentSkills = this.configurationService.getValue(PromptsConfig.USE_AGENT_SKILLS); const skills = useAgentSkills ? await this.listPromptFiles(PromptsType.skill, token) : []; - const disabledSkills = this.getDisabledPromptFiles(PromptsType.skill); - const disabledPrompts = this.getDisabledPromptFiles(PromptsType.prompt); const slashCommandFiles = [ - ...promptFiles.filter(p => !disabledPrompts.has(p.uri)), - ...skills.filter(s => !disabledSkills.has(s.uri)), + ...promptFiles, + ...skills, ]; const parseResults = await Promise.all(slashCommandFiles.map(async promptPath => { @@ -667,11 +665,19 @@ export class PromptsService extends Disposable implements IPromptsService { * Derives IChatPromptSlashCommand[] from cached discovery info. */ private slashCommandsFromDiscoveryInfo(discoveryInfo: ISlashCommandDiscoveryInfo): readonly IChatPromptSlashCommand[] { + const disabledPrompts = this.getDisabledPromptFiles(PromptsType.prompt); + const disabledSkills = this.getDisabledPromptFiles(PromptsType.skill); const result: IChatPromptSlashCommand[] = []; const seen = new ResourceSet(); for (const file of discoveryInfo.files) { if (file.status === 'loaded') { + const isDisabled = file.promptPath.type === PromptsType.prompt + ? disabledPrompts.has(file.promptPath.uri) + : disabledSkills.has(file.promptPath.uri); + if (isDisabled) { + continue; + } result.push(this.asChatPromptSlashCommand(file.argumentHint, file.userInvocable, file.promptPath)); seen.add(file.promptPath.uri); } @@ -1134,14 +1140,25 @@ export class PromptsService extends Disposable implements IPromptsService { } private _refreshCachesForType(type: PromptsType): void { - // Refresh all caches to ensure the disabled state is picked up - // everywhere — the disabled set is a cross-cutting concern that - // affects discovery, slash commands, skills, and hooks. - this.cachedCustomAgents.refresh(); - this.cachedSlashCommands.refresh(); - this.cachedSkills.refresh(); - this.cachedInstructions.refresh(); - this.cachedHooks.refresh(); + switch (type) { + case PromptsType.agent: + this.cachedCustomAgents.refresh(); + break; + case PromptsType.skill: + this.cachedSkills.refresh(); + // Skills appear in slash commands too + this.cachedSlashCommands.refresh(); + break; + case PromptsType.prompt: + this.cachedSlashCommands.refresh(); + break; + case PromptsType.instructions: + this.cachedInstructions.refresh(); + break; + case PromptsType.hook: + this.cachedHooks.refresh(); + break; + } } // Agent skills diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index 632ebeba1c557..0a084a583d7db 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -26,6 +26,7 @@ import { IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../../pa import { ComputeAutomaticInstructions } from '../../promptSyntax/computeAutomaticInstructions.js'; import { ChatRequestHooks, mergeHooks } from '../../promptSyntax/hookSchema.js'; import { HookType } from '../../promptSyntax/hookTypes.js'; +import { PromptsType } from '../../promptSyntax/promptTypes.js'; import { ICustomAgent, IPromptsService } from '../../promptSyntax/service/promptsService.js'; import { isBuiltinAgent } from '../../promptSyntax/utils/promptsServiceUtils.js'; import { @@ -317,15 +318,18 @@ export class RunSubagentTool extends Disposable implements IToolImpl { // Merge subagent-level hooks (from the agent's frontmatter) with global hooks. // Remap Stop hooks to SubagentStop since the agent is running as a subagent. if (subagent?.hooks) { - const remapped: ChatRequestHooks = { ...subagent.hooks }; - if (remapped[HookType.Stop]) { - const stopHooks = remapped[HookType.Stop]; - (remapped as Record)[HookType.SubagentStop] = remapped[HookType.SubagentStop] - ? [...remapped[HookType.SubagentStop], ...stopHooks] - : stopHooks; - (remapped as Record)[HookType.Stop] = undefined; + const disabledHooks = this.promptsService.getDisabledPromptFiles(PromptsType.hook); + if (!disabledHooks.has(subagent.uri)) { + const remapped: ChatRequestHooks = { ...subagent.hooks }; + if (remapped[HookType.Stop]) { + const stopHooks = remapped[HookType.Stop]; + (remapped as Record)[HookType.SubagentStop] = remapped[HookType.SubagentStop] + ? [...remapped[HookType.SubagentStop], ...stopHooks] + : stopHooks; + (remapped as Record)[HookType.Stop] = undefined; + } + collectedHooks = mergeHooks(collectedHooks, remapped); } - collectedHooks = mergeHooks(collectedHooks, remapped); } // Build the agent request From e5fb6abf97aa8be3c6d0c0bec7d43c27523ac9f2 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Wed, 22 Apr 2026 17:59:52 -0700 Subject: [PATCH 04/36] wip --- .../copilotCLICustomizationProvider.ts | 121 ++++++++++++-- .../copilotCLICustomizationProvider.spec.ts | 2 + .../api/browser/mainThreadChatAgents2.ts | 39 ++++- .../workbench/api/common/extHost.protocol.ts | 6 +- .../api/common/extHostChatAgents2.ts | 23 ++- .../api/common/extHostTypeConverters.ts | 3 + .../aiCustomizationItemSource.ts | 20 ++- .../aiCustomizationListWidget.ts | 17 +- .../aiCustomizationManagement.contribution.ts | 84 ++++++---- .../aiCustomizationManagement.ts | 11 ++ ...promptsServiceCustomizationItemProvider.ts | 147 +----------------- .../common/customizationHarnessService.ts | 61 +++++++- .../plugins/workspacePluginSettingsService.ts | 1 + .../agentHostChatContribution.test.ts | 1 + .../agentHostClientTools.test.ts | 1 + .../aiCustomizationListWidget.test.ts | 1 + ...osed.chatSessionCustomizationProvider.d.ts | 50 ++++++ 17 files changed, 394 insertions(+), 194 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts index fb9a238d52616..bd82885977c75 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts @@ -5,6 +5,7 @@ import * as l10n from '@vscode/l10n'; import * as vscode from 'vscode'; +import { INativeEnvService } from '../../../platform/env/common/envService'; import { ICustomInstructionsService } from '../../../platform/customInstructions/common/customInstructionsService'; import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; import { ILogService } from '../../../platform/log/common/logService'; @@ -14,7 +15,7 @@ import { CancellationToken } from '../../../util/vs/base/common/cancellation'; import { isCancellationError } from '../../../util/vs/base/common/errors'; import { Emitter } from '../../../util/vs/base/common/event'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; -import { basename } from '../../../util/vs/base/common/resources'; +import { basename, dirname } from '../../../util/vs/base/common/resources'; import { URI } from '../../../util/vs/base/common/uri'; import { ICopilotCLIAgents, isEnabledForCopilotCLI } from '../copilotcli/node/copilotCli'; @@ -34,6 +35,10 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod vscode.ChatSessionCustomizationType.Hook, vscode.ChatSessionCustomizationType.Plugins, ].filter((t): t is vscode.ChatSessionCustomizationType => t !== undefined), + disableableTypes: [ + vscode.ChatSessionCustomizationType.Skill, + vscode.ChatSessionCustomizationType.Hook, + ].filter((t): t is vscode.ChatSessionCustomizationType => t !== undefined), }; } @@ -44,6 +49,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod @ILogService private readonly logService: ILogService, @IWorkspaceService private readonly workspaceService: IWorkspaceService, @IFileSystemService private readonly fileSystemService: IFileSystemService, + @INativeEnvService private readonly envService: INativeEnvService, ) { super(); @@ -185,11 +191,20 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod * Collects all skill items from the prompt file service. */ private async getSkillItems(token: vscode.CancellationToken): Promise { - return (await this.promptsService.getSkills(token)).filter(isEnabledForCopilotCLI).map(s => ({ - uri: s.uri, - type: vscode.ChatSessionCustomizationType.Skill, - name: s.name, - })); + const settings = await this._readSettings(); + const disabledSkills = new Set( + Array.isArray(settings.disabledSkills) ? settings.disabledSkills as string[] : [], + ); + return (await this.promptsService.getSkills(token)).filter(isEnabledForCopilotCLI).map(s => { + const name = s.name; + const folderName = basename(dirname(s.uri)) || basename(s.uri); + return { + uri: s.uri, + type: vscode.ChatSessionCustomizationType.Skill, + name, + enabled: !disabledSkills.has(folderName), + }; + }); } /** @@ -197,11 +212,19 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod * Each item is a hook configuration file (JSON). */ private async getHookItems(token: vscode.CancellationToken): Promise { - return (await this.promptsService.getHooks(token)).filter(isEnabledForCopilotCLI).map(h => ({ - uri: h.uri, - type: vscode.ChatSessionCustomizationType.Hook, - name: basename(h.uri).replace(/\.json$/i, ''), - })); + const settings = await this._readSettings(); + const disabledHooks = new Set( + Array.isArray(settings.disabledHooks) ? settings.disabledHooks as string[] : [], + ); + return (await this.promptsService.getHooks(token)).filter(isEnabledForCopilotCLI).map(h => { + const name = basename(h.uri).replace(/\.json$/i, ''); + return { + uri: h.uri, + type: vscode.ChatSessionCustomizationType.Hook, + name, + enabled: !disabledHooks.has(name), + }; + }); } /** @@ -214,4 +237,80 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod name: basename(p.uri), })); } + + // --- Enablement --- + + /** + * Path to the user-level copilot settings file (`~/.copilot/settings.json`). + */ + private get _settingsUri(): URI { + return URI.joinPath(this.envService.userHome, '.copilot', 'settings.json'); + } + + /** + * Reads the user-level `~/.copilot/settings.json` as a JSON object. + * Returns an empty object if the file doesn't exist or can't be parsed. + */ + private async _readSettings(): Promise> { + try { + const bytes = await this.fileSystemService.readFile(this._settingsUri); + const parsed = JSON.parse(new TextDecoder().decode(bytes)); + return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {}; + } catch { + return {}; + } + } + + /** + * Writes the user-level `~/.copilot/settings.json`. + */ + private async _writeSettings(settings: Record): Promise { + const content = new TextEncoder().encode(JSON.stringify(settings, null, 4)); + await this.fileSystemService.writeFile(this._settingsUri, content); + } + + /** + * Resolves the CLI settings key and item name for a given customization type and URI. + * Returns `undefined` for types that don't support per-item disablement in the CLI. + */ + private _resolveDisablementKey(uri: vscode.Uri, type: vscode.ChatSessionCustomizationType): { settingsKey: string; name: string } | undefined { + if (type.id === vscode.ChatSessionCustomizationType.Skill.id) { + // Skills use the folder name as the key in disabledSkills + const name = basename(dirname(URI.from(uri))) || basename(URI.from(uri)); + return { settingsKey: 'disabledSkills', name }; + } + if (type.id === vscode.ChatSessionCustomizationType.Hook?.id) { + // Hooks use the filename (without .json) as the key in disabledHooks + const name = basename(URI.from(uri)).replace(/\.json$/i, ''); + return { settingsKey: 'disabledHooks', name }; + } + // Other types don't have per-item disablement in the CLI config + return undefined; + } + + async resolveCustomizationEnablement(uri: vscode.Uri, type: vscode.ChatSessionCustomizationType, enabled: boolean, _token: vscode.CancellationToken): Promise { + const resolved = this._resolveDisablementKey(uri, type); + if (!resolved) { + this.logService.warn(`[CopilotCLICustomizationProvider] Per-item enablement not supported for type: ${type.id}`); + return; + } + + const { settingsKey, name } = resolved; + const settings = await this._readSettings(); + const currentList = Array.isArray(settings[settingsKey]) ? settings[settingsKey] as string[] : []; + + if (enabled) { + // Remove from disabled list + settings[settingsKey] = currentList.filter(s => s !== name); + } else { + // Add to disabled list (if not already present) + if (!currentList.includes(name)) { + settings[settingsKey] = [...currentList, name]; + } + } + + await this._writeSettings(settings); + this.logService.debug(`[CopilotCLICustomizationProvider] ${enabled ? 'Enabled' : 'Disabled'} ${type.id} "${name}" in ${this._settingsUri.toString()}`); + this._onDidChange.fire(); + } } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLICustomizationProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLICustomizationProvider.spec.ts index 26b667dcbb9d9..e6705e8e453f3 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLICustomizationProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLICustomizationProvider.spec.ts @@ -119,6 +119,7 @@ describe('CopilotCLICustomizationProvider', () => { new TestLogService(), { getWorkspaceFolders: () => [] } as any, { stat: () => Promise.reject(new Error('not found')) } as any, + { userHome: URI.file('/home/testuser') } as any, )); }); @@ -340,6 +341,7 @@ describe('CopilotCLICustomizationProvider', () => { ? Promise.resolve({ type: 1, ctime: 0, mtime: 0, size: 0 }) : Promise.reject(new Error('not found')), } as any, + { userHome: URI.file('/home/testuser') } as any, )); mockPromptsService.setInstructions([]); diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index fa577f8d06c9d..b5626cd0e49ce 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -6,6 +6,7 @@ import { DeferredPromise } from '../../../base/common/async.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../base/common/event.js'; +import { ResourceSet } from '../../../base/common/map.js'; import { IMarkdownString } from '../../../base/common/htmlContent.js'; import { Disposable, DisposableMap, IDisposable } from '../../../base/common/lifecycle.js'; import { autorun } from '../../../base/common/observable.js'; @@ -46,7 +47,7 @@ import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; import { ExtHostChatAgentsShape2, ExtHostContext, IChatAgentInvokeResult, IChatSessionCustomizationItemDto, IChatSessionCustomizationProviderMetadataDto, IChatNotebookEditDto, IChatParticipantMetadata, IChatProgressDto, IChatSessionContextDto, ICustomAgentDto, IDynamicChatAgentProps, IExtensionChatAgentMetadata, IHookDto, IInstructionDto, IPluginDto, ISkillDto, ISlashCommandDto, MainContext, MainThreadChatAgentsShape2 } from '../common/extHost.protocol.js'; import { NotebookDto } from './mainThreadNotebookDto.js'; import { isUntitledChatSession } from '../../contrib/chat/common/model/chatUri.js'; -import { ICustomizationHarnessService, ICustomizationItem, ICustomizationItemProvider, IHarnessDescriptor } from '../../contrib/chat/common/customizationHarnessService.js'; +import { ICustomizationHarnessService, ICustomizationEnablementProvider, ICustomizationItem, ICustomizationItemProvider, IHarnessDescriptor } from '../../contrib/chat/common/customizationHarnessService.js'; import { AICustomizationManagementSection, BUILTIN_STORAGE } from '../../contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IAgentPlugin, IAgentPluginService } from '../../contrib/chat/common/plugins/agentPluginService.js'; import { IWorkbenchEnvironmentService } from '../../services/environment/common/environmentService.js'; @@ -708,7 +709,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA } } - async $registerChatSessionCustomizationProvider(handle: number, chatSessionType: string, metadata: IChatSessionCustomizationProviderMetadataDto, extensionId: ExtensionIdentifier): Promise { + async $registerChatSessionCustomizationProvider(handle: number, chatSessionType: string, metadata: IChatSessionCustomizationProviderMetadataDto, extensionId: ExtensionIdentifier, hasSetEnabled: boolean): Promise { // In the sessions window, only the Copilot CLI harness is accepted via the // extension API. Other harnesses (e.g. Claude) are not shown in sessions. // AHP remote servers register directly via registerExternalHarness. @@ -741,6 +742,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA groupKey: item.groupKey, badge: item.badge, badgeTooltip: item.badgeTooltip, + enabled: item.enabled, })); }, }; @@ -768,6 +770,28 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA hiddenSections = Object.values(typeToSection).filter(section => !supportedSections.has(section)); } + // Build an enablement provider when the extension implements setCustomizationEnabled. + // This delegates disable/enable to the extension instead of VS Code's StorageService. + let enablementProvider: ICustomizationEnablementProvider | undefined; + if (hasSetEnabled) { + const proxy = this._proxy; + const providerHandle = handle; + enablementProvider = { + // The extension reports enabled state via the itemProvider — the + // enablement provider's onDidChange is driven by the item provider's + // onDidChange, so we reuse the same emitter. + onDidChange: emitter.event, + getDisabledPromptFiles: (): ResourceSet => { + // Extension reports disabled state on items directly via `enabled: false`. + // No separate disabled set needed. + return new ResourceSet(); + }, + setEnabled: (uri: URI, type: PromptsType, enabled: boolean): void => { + proxy.$setCustomizationEnabled(providerHandle, uri.toJSON(), type, enabled); + }, + }; + } + const descriptor: IHarnessDescriptor = { id: chatSessionType, label: metadata.label, @@ -779,12 +803,23 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.plugin, PromptsStorage.extension, BUILTIN_STORAGE], }), itemProvider, + enablementProvider, + enablementScope: metadata.enablementScope, + disableableTypes: metadata.disableableTypes ? new Set(metadata.disableableTypes) : undefined, }; const registration = this._customizationHarnessService.registerExternalHarness(descriptor); this._customizationProviders.set(handle, registration); } + /** + * Called by the management UI when the user toggles a customization item's enabled state. + * Delegates to the extension's setCustomizationEnabled callback. + */ + $setCustomizationEnabled(handle: number, uri: UriComponents, type: string, enabled: boolean): void { + this._proxy.$setCustomizationEnabled(handle, uri, type, enabled); + } + $unregisterChatSessionCustomizationProvider(handle: number): void { this._customizationProviders.deleteAndDispose(handle); this._customizationProviderEmitters.deleteAndDispose(handle); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 922e84784d0d6..d7d04d7410395 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1588,7 +1588,7 @@ export interface MainThreadChatAgentsShape2 extends IChatAgentProgressShape, IDi $registerPromptFileProvider(handle: number, type: string, extension: ExtensionIdentifier): void; $unregisterPromptFileProvider(handle: number): void; $onDidChangePromptFiles(handle: number): void; - $registerChatSessionCustomizationProvider(handle: number, chatSessionType: string, metadata: IChatSessionCustomizationProviderMetadataDto, extension: ExtensionIdentifier): void; + $registerChatSessionCustomizationProvider(handle: number, chatSessionType: string, metadata: IChatSessionCustomizationProviderMetadataDto, extension: ExtensionIdentifier, hasSetEnabled: boolean): void; $unregisterChatSessionCustomizationProvider(handle: number): void; $onDidChangeCustomizations(handle: number): void; $registerAgentCompletionsProvider(handle: number, id: string, triggerCharacters: string[]): void; @@ -1672,6 +1672,7 @@ export interface ExtHostChatAgentsShape2 { $detectChatParticipant(handle: number, request: Dto, context: { history: IChatAgentHistoryEntryDto[] }, options: { participants: IChatParticipantMetadata[]; location: ChatAgentLocation }, token: CancellationToken): Promise; $providePromptFiles(handle: number, type: PromptsType, context: IPromptFileContext, token: CancellationToken): Promise[] | undefined>; $provideChatSessionCustomizations(handle: number, token: CancellationToken): Promise; + $setCustomizationEnabled(handle: number, uri: UriComponents, type: string, enabled: boolean): void; $setRequestTools(requestId: string, tools: UserSelectedTools): void; $setYieldRequested(requestId: string, value: boolean): void; $acceptActiveChatSession(sessionResource: UriComponents | undefined): void; @@ -1729,6 +1730,8 @@ export interface IChatSessionCustomizationProviderMetadataDto { readonly label: string; readonly iconId?: string; readonly supportedTypes?: readonly string[]; + readonly enablementScope?: 'none' | 'global' | 'workspace'; + readonly disableableTypes?: readonly string[]; } export interface IChatSessionCustomizationItemDto { @@ -1739,6 +1742,7 @@ export interface IChatSessionCustomizationItemDto { readonly groupKey?: string; readonly badge?: string; readonly badgeTooltip?: string; + readonly enabled?: boolean; } export interface IChatParticipantMetadata { participant: string; diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 58b98ea5a42df..b50a7151e73bc 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -787,13 +787,20 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS const handle = ExtHostChatAgents2._customizationProviderIdPool++; this._customizationProviders.set(handle, { extension, provider }); + const enablementScopeMap: Record = { + 0: 'none', // ChatSessionCustomizationEnablementScope.None + 1: 'global', // ChatSessionCustomizationEnablementScope.Global + 2: 'workspace', // ChatSessionCustomizationEnablementScope.Workspace + }; const metadataDto: IChatSessionCustomizationProviderMetadataDto = { label: metadata.label, iconId: metadata.iconId, supportedTypes: metadata.supportedTypes?.map(t => typeConvert.ChatSessionCustomizationType.from(t)), + enablementScope: metadata.enablementScope !== undefined ? enablementScopeMap[metadata.enablementScope] : undefined, + disableableTypes: metadata.disableableTypes?.map(t => typeConvert.ChatSessionCustomizationType.from(t)), }; - this._proxy.$registerChatSessionCustomizationProvider(handle, chatSessionType, metadataDto, extension.identifier); + this._proxy.$registerChatSessionCustomizationProvider(handle, chatSessionType, metadataDto, extension.identifier, typeof provider.resolveCustomizationEnablement === 'function'); const disposables = new DisposableStore(); @@ -831,12 +838,26 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS groupKey: item.groupKey, badge: item.badge, badgeTooltip: item.badgeTooltip, + enabled: item.enabled, })); } catch (err) { return undefined; } } + $setCustomizationEnabled(handle: number, uri: UriComponents, type: string, enabled: boolean): void { + const providerData = this._customizationProviders.get(handle); + if (!providerData?.provider.resolveCustomizationEnablement) { + return; + } + providerData.provider.resolveCustomizationEnablement( + URI.revive(uri), + typeConvert.ChatSessionCustomizationType.to(type), + enabled, + CancellationToken.None, + ); + } + async $detectChatParticipant(handle: number, requestDto: Dto, context: { history: IChatAgentHistoryEntryDto[] }, options: { location: ChatAgentLocation; participants?: vscode.ChatParticipantMetadata[] }, token: CancellationToken): Promise { const detector = this._participantDetectionProviders.get(handle); if (!detector) { diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index f4441fbc648d7..c11b634b783a1 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -3532,6 +3532,9 @@ export namespace ChatSessionCustomizationType { export function from(type: types.ChatSessionCustomizationType): string { return type.id; } + export function to(id: string): types.ChatSessionCustomizationType { + return new types.ChatSessionCustomizationType(id); + } } export namespace ChatPromptReference { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index d12b08d4a2093..c1ebaab6ae260 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -21,7 +21,7 @@ import { IProductService } from '../../../../../platform/product/common/productS import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; -import { ICustomizationSyncProvider, ICustomizationItem, ICustomizationItemProvider } from '../../common/customizationHarnessService.js'; +import { ICustomizationSyncProvider, ICustomizationItem, ICustomizationItemProvider, ICustomizationEnablementProvider } from '../../common/customizationHarnessService.js'; import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { parseHooksFromFile } from '../../common/promptSyntax/hookCompatibility.js'; import { formatHookCommandLabel } from '../../common/promptSyntax/hookSchema.js'; @@ -314,6 +314,7 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour constructor( private readonly itemProvider: ICustomizationItemProvider | undefined, private readonly syncProvider: ICustomizationSyncProvider | undefined, + private readonly enablementProvider: ICustomizationEnablementProvider | undefined, private readonly promptsService: IPromptsService, private readonly workspaceService: IAICustomizationWorkspaceService, private readonly fileService: IFileService, @@ -331,6 +332,7 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour this.onDidChange = Event.any( this.itemProvider?.onDidChange ?? Event.None, this.syncProvider?.onDidChange ?? Event.None, + this.enablementProvider?.onDidChange ?? Event.None, promptServiceEvents, ); } @@ -372,10 +374,12 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour const normalized = this.itemNormalizer.normalizeItems(providerItems, promptType); - // Overlay disabled state from storage for providers that don't track - // disablement themselves (e.g. extension-contributed harness providers). + // Overlay disabled state from the harness's enablement provider, or + // fall back to promptsService (StorageService) when no provider is set. // Also add back disabled items that the provider didn't include at all. - const disabledUris = this.promptsService.getDisabledPromptFiles(promptType); + const disabledUris = this.enablementProvider + ? this.enablementProvider.getDisabledPromptFiles(promptType) + : this.promptsService.getDisabledPromptFiles(promptType); if (disabledUris.size > 0) { const existingUris = new ResourceSet(normalized.map(i => i.uri)); for (let i = 0; i < normalized.length; i++) { @@ -497,7 +501,9 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour uriUseCounts.set(item.uri, (uriUseCounts.get(item.uri) ?? 0) + 1); } const appended: IAICustomizationListItem[] = []; - const disabledPromptFiles = this.promptsService.getDisabledPromptFiles(PromptsType.skill); + const disabledPromptFiles = this.enablementProvider + ? this.enablementProvider.getDisabledPromptFiles(PromptsType.skill) + : this.promptsService.getDisabledPromptFiles(PromptsType.skill); for (const p of builtinPaths) { const name = p.name ?? basename(p.uri); if (overriddenNames.has(name)) { @@ -542,7 +548,9 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour return []; } - const disabledUris = this.promptsService.getDisabledPromptFiles(promptType); + const disabledUris = this.enablementProvider + ? this.enablementProvider.getDisabledPromptFiles(promptType) + : this.promptsService.getDisabledPromptFiles(promptType); const providerItems: ICustomizationItem[] = files .filter(file => file.storage === PromptsStorage.local || file.storage === PromptsStorage.user) .map(file => ({ diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 4a86704ab6147..ce8b8080ad7e6 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -22,7 +22,7 @@ import { IListVirtualDelegate, IListRenderer, IListContextMenuEvent } from '../. import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, userIcon, workspaceIcon, extensionIcon, pluginIcon, builtinIcon } from './aiCustomizationIcons.js'; -import { AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AI_CUSTOMIZATION_ITEM_TYPE_KEY, AI_CUSTOMIZATION_ITEM_URI_KEY, AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY, AICustomizationManagementItemMenuId, AICustomizationManagementCreateMenuId, AICustomizationManagementSection, BUILTIN_STORAGE, AI_CUSTOMIZATION_ITEM_DISABLED_KEY, AI_CUSTOMIZATION_SUPPORTS_TROUBLESHOOT_KEY } from './aiCustomizationManagement.js'; +import { AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AI_CUSTOMIZATION_ITEM_TYPE_KEY, AI_CUSTOMIZATION_ITEM_URI_KEY, AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY, AICustomizationManagementItemMenuId, AICustomizationManagementCreateMenuId, AICustomizationManagementSection, BUILTIN_STORAGE, AI_CUSTOMIZATION_ITEM_DISABLED_KEY, AI_CUSTOMIZATION_SUPPORTS_TROUBLESHOOT_KEY, AI_CUSTOMIZATION_ENABLEMENT_SCOPE_KEY, AI_CUSTOMIZATION_ITEM_DISABLEABLE_KEY } from './aiCustomizationManagement.js'; import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { InputBox } from '../../../../../base/browser/ui/inputbox/inputBox.js'; import { defaultButtonStyles, defaultCheckboxStyles, defaultInputBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; @@ -437,11 +437,16 @@ class AICustomizationItemRenderer implements IListRenderer { const promptsService = accessor.get(IPromptsService); + const harnessService = accessor.get(ICustomizationHarnessService); const uri = extractURI(context); const promptType = extractPromptType(context); if (!promptType) { return; } - // Workspace-local items are disabled at workspace scope; user-level items at profile scope const storage = extractStorage(context); - const scope = storage === PromptsStorage.local ? StorageScope.WORKSPACE : StorageScope.PROFILE; - const disabled = promptsService.getDisabledPromptFilesForScope(promptType, scope); - disabled.add(uri); - promptsService.setDisabledPromptFiles(promptType, disabled, scope); + const enablementProvider = harnessService.getActiveEnablementProvider(); + if (enablementProvider && storage !== PromptsStorage.extension) { + enablementProvider.setEnabled(uri, promptType, false); + } else { + // Workspace-local items are disabled at workspace scope; user-level items at profile scope + const scope = storage === PromptsStorage.local ? StorageScope.WORKSPACE : StorageScope.PROFILE; + const disabled = promptsService.getDisabledPromptFilesForScope(promptType, scope); + disabled.add(uri); + promptsService.setDisabledPromptFiles(promptType, disabled, scope); + } } }); @@ -771,15 +779,22 @@ registerAction2(class extends Action2 { } async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise { const promptsService = accessor.get(IPromptsService); + const harnessService = accessor.get(ICustomizationHarnessService); const uri = extractURI(context); const promptType = extractPromptType(context); if (!promptType) { return; } - const disabled = promptsService.getDisabledPromptFilesForScope(promptType, StorageScope.WORKSPACE); - disabled.add(uri); - promptsService.setDisabledPromptFiles(promptType, disabled, StorageScope.WORKSPACE); + const storage = extractStorage(context); + const enablementProvider = harnessService.getActiveEnablementProvider(); + if (enablementProvider && storage !== PromptsStorage.extension) { + enablementProvider.setEnabled(uri, promptType, false); + } else { + const disabled = promptsService.getDisabledPromptFilesForScope(promptType, StorageScope.WORKSPACE); + disabled.add(uri); + promptsService.setDisabledPromptFiles(promptType, disabled, StorageScope.WORKSPACE); + } } }); @@ -795,26 +810,31 @@ registerAction2(class extends Action2 { } async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise { const promptsService = accessor.get(IPromptsService); + const harnessService = accessor.get(ICustomizationHarnessService); const uri = extractURI(context); const promptType = extractPromptType(context); if (!promptType) { return; } - // Remove from both scopes to fully re-enable — but only write - // the scopes that actually contain the URI to avoid unnecessary - // cache invalidation. - const profileDisabled = promptsService.getDisabledPromptFilesForScope(promptType, StorageScope.PROFILE); - const wasInProfile = profileDisabled.delete(uri); - - const workspaceDisabled = promptsService.getDisabledPromptFilesForScope(promptType, StorageScope.WORKSPACE); - const wasInWorkspace = workspaceDisabled.delete(uri); - - if (wasInProfile) { - promptsService.setDisabledPromptFiles(promptType, profileDisabled, StorageScope.PROFILE); - } - if (wasInWorkspace) { - promptsService.setDisabledPromptFiles(promptType, workspaceDisabled, StorageScope.WORKSPACE); + const storage = extractStorage(context); + const enablementProvider = harnessService.getActiveEnablementProvider(); + if (enablementProvider && storage !== PromptsStorage.extension) { + enablementProvider.setEnabled(uri, promptType, true); + } else { + // Remove from both scopes to fully re-enable + const profileDisabled = promptsService.getDisabledPromptFilesForScope(promptType, StorageScope.PROFILE); + const wasInProfile = profileDisabled.delete(uri); + + const workspaceDisabled = promptsService.getDisabledPromptFilesForScope(promptType, StorageScope.WORKSPACE); + const wasInWorkspace = workspaceDisabled.delete(uri); + + if (wasInProfile) { + promptsService.setDisabledPromptFiles(promptType, profileDisabled, StorageScope.PROFILE); + } + if (wasInWorkspace) { + promptsService.setDisabledPromptFiles(promptType, workspaceDisabled, StorageScope.WORKSPACE); + } } } }); @@ -824,7 +844,10 @@ registerAction2(class extends Action2 { */ const WHEN_ITEM_IS_NOT_PLUGIN = ContextKeyExpr.notEquals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, PromptsStorage.plugin); -// Context menu: Disable (shown when non-plugin item is enabled) +const WHEN_ENABLEMENT_SUPPORTED = ContextKeyExpr.notEquals(AI_CUSTOMIZATION_ENABLEMENT_SCOPE_KEY, 'none'); +const WHEN_ITEM_DISABLEABLE = ContextKeyExpr.and(WHEN_ENABLEMENT_SUPPORTED, ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLEABLE_KEY, true)); + +// Context menu: Disable (shown when non-plugin item is enabled and enablement is supported) MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { command: { id: DISABLE_AI_CUSTOMIZATION_MGMT_ITEM_ID, title: localize('disable', "Disable") }, group: '5_toggle', @@ -832,10 +855,12 @@ MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { when: ContextKeyExpr.and( ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, false), WHEN_ITEM_IS_NOT_PLUGIN, + WHEN_ITEM_DISABLEABLE, ), }); -// Context menu: Disable (Workspace) (shown for user-level and extension items, when a workspace is open) +// Context menu: Disable (Workspace) (shown when enablement scope is 'workspace', for user-level +// and extension items, when a workspace is open) MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { command: { id: DISABLE_WORKSPACE_AI_CUSTOMIZATION_MGMT_ITEM_ID, title: localize('disableForWorkspace', "Disable (Workspace)") }, group: '5_toggle', @@ -843,6 +868,8 @@ MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { when: ContextKeyExpr.and( ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, false), WHEN_ITEM_IS_NOT_PLUGIN, + WHEN_ITEM_DISABLEABLE, + ContextKeyExpr.equals(AI_CUSTOMIZATION_ENABLEMENT_SCOPE_KEY, 'workspace'), ContextKeyExpr.or( ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, PromptsStorage.user), ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, PromptsStorage.extension), @@ -851,7 +878,7 @@ MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { ), }); -// Context menu: Enable (shown when non-plugin item is disabled) +// Context menu: Enable (shown when non-plugin item is disabled and enablement is supported) MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { command: { id: ENABLE_AI_CUSTOMIZATION_MGMT_ITEM_ID, title: localize('enable', "Enable") }, group: '5_toggle', @@ -859,10 +886,11 @@ MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { when: ContextKeyExpr.and( ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, true), WHEN_ITEM_IS_NOT_PLUGIN, + WHEN_ITEM_DISABLEABLE, ), }); -// Inline hover: Disable (shown when non-plugin item is enabled) +// Inline hover: Disable (shown when non-plugin item is enabled and enablement is supported) MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { command: { id: DISABLE_AI_CUSTOMIZATION_MGMT_ITEM_ID, title: localize('disable', "Disable"), icon: Codicon.eyeClosed }, group: 'inline', @@ -870,10 +898,11 @@ MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { when: ContextKeyExpr.and( ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, false), WHEN_ITEM_IS_NOT_PLUGIN, + WHEN_ITEM_DISABLEABLE, ), }); -// Inline hover: Enable (shown when non-plugin item is disabled) +// Inline hover: Enable (shown when non-plugin item is disabled and enablement is supported) MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { command: { id: ENABLE_AI_CUSTOMIZATION_MGMT_ITEM_ID, title: localize('enable', "Enable"), icon: Codicon.eye }, group: 'inline', @@ -881,6 +910,7 @@ MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { when: ContextKeyExpr.and( ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, true), WHEN_ITEM_IS_NOT_PLUGIN, + WHEN_ITEM_DISABLEABLE, ), }); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts index 5d8d83d8e9cf5..ab00ea39461df 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts @@ -111,6 +111,17 @@ export const AI_CUSTOMIZATION_ITEM_DISABLED_KEY = 'aiCustomizationManagementItem */ export const AI_CUSTOMIZATION_SUPPORTS_TROUBLESHOOT_KEY = 'aiCustomizationManagementSupportsTroubleshoot'; +/** + * Context key for the resolved enablement scope of the active harness. + * Values: `'none'`, `'global'`, or `'workspace'`. + */ +export const AI_CUSTOMIZATION_ENABLEMENT_SCOPE_KEY = 'aiCustomizationManagementEnablementScope'; + +/** + * Context key indicating whether the current item's type is disableable by the active harness. + */ +export const AI_CUSTOMIZATION_ITEM_DISABLEABLE_KEY = 'aiCustomizationManagementItemDisableable'; + /** * Storage key for persisting the selected section. */ diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts index 77130ff0fb778..ef60b9050647f 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts @@ -56,7 +56,6 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt private async provideCustomizations(promptType: PromptsType, token: CancellationToken = CancellationToken.None): Promise { const items: ICustomizationItem[] = []; - const disabledUris = this.promptsService.getDisabledPromptFiles(promptType); const extensionInfoByUri = new ResourceMap<{ id: ExtensionIdentifier; displayName?: string }>(); if (promptType === PromptsType.agent) { @@ -67,52 +66,18 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt extensionInfoByUri.set(file.uri, { id: file.extension.identifier, displayName: file.extension.displayName }); } } - const seenUris = new ResourceSet(); for (const agent of agents) { - seenUris.add(agent.uri); items.push({ uri: agent.uri, type: promptType, name: agent.name, description: agent.description, storage: agent.source.storage, - enabled: !disabledUris.has(agent.uri), }); if (agent.source.storage === PromptsStorage.extension && !extensionInfoByUri.has(agent.uri)) { extensionInfoByUri.set(agent.uri, { id: agent.source.extensionId }); } } - // Re-add disabled agent files so they appear in the UI as disabled. - // Use discovery info to get properly parsed names/descriptions. - if (disabledUris.size > 0) { - const discoveryInfo = await this.promptsService.getDiscoveryInfo(PromptsType.agent, token); - const discoveredUris = new ResourceSet(discoveryInfo.files.map(f => f.promptPath.uri)); - for (const file of discoveryInfo.files) { - if (!seenUris.has(file.promptPath.uri) && disabledUris.has(file.promptPath.uri)) { - items.push({ - uri: file.promptPath.uri, - type: promptType, - name: file.promptPath.name || getFriendlyName(basename(file.promptPath.uri)), - description: file.promptPath.description, - storage: file.promptPath.storage, - enabled: false, - }); - } - } - // Fallback: add disabled agents not found in discovery info - for (const file of allAgentFiles) { - if (!seenUris.has(file.uri) && !discoveredUris.has(file.uri) && disabledUris.has(file.uri)) { - items.push({ - uri: file.uri, - type: promptType, - name: file.name || getFriendlyName(basename(file.uri)), - description: file.description, - storage: file.storage, - enabled: false, - }); - } - } - } } else if (promptType === PromptsType.skill) { const skills = await this.promptsService.findAgentSkills(token); const allSkillFiles = await this.promptsService.listPromptFiles(PromptsType.skill, token); @@ -122,10 +87,8 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt } } const uiIntegrations = this.workspaceService.getSkillUIIntegrations(); - const seenUris = new ResourceSet(); for (const skill of skills || []) { const skillName = skill.name || basename(dirname(skill.uri)) || basename(skill.uri); - seenUris.add(skill.uri); const skillFolderName = basename(dirname(skill.uri)); const uiTooltip = uiIntegrations.get(skillFolderName); items.push({ @@ -134,132 +97,37 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt name: skillName, description: skill.description, storage: skill.storage, - enabled: !disabledUris.has(skill.uri), badge: uiTooltip ? localize('uiIntegrationBadge', "UI Integration") : undefined, badgeTooltip: uiTooltip, }); } - if (disabledUris.size > 0) { - const discoveryInfo = await this.promptsService.getDiscoveryInfo(PromptsType.skill, token); - const discoveredUris = new ResourceSet(discoveryInfo.files.map(f => f.promptPath.uri)); - for (const file of discoveryInfo.files) { - if (!seenUris.has(file.promptPath.uri) && disabledUris.has(file.promptPath.uri)) { - const disabledName = file.promptPath.name || basename(dirname(file.promptPath.uri)) || basename(file.promptPath.uri); - const disabledFolderName = basename(dirname(file.promptPath.uri)); - const uiTooltip = uiIntegrations.get(disabledFolderName); - items.push({ - uri: file.promptPath.uri, - type: promptType, - name: disabledName, - description: file.promptPath.description, - storage: file.promptPath.storage, - enabled: false, - badge: uiTooltip ? localize('uiIntegrationBadge', "UI Integration") : undefined, - badgeTooltip: uiTooltip, - }); - } - } - // Fallback: add disabled skills not found in discovery info - for (const file of allSkillFiles) { - if (!seenUris.has(file.uri) && !discoveredUris.has(file.uri) && disabledUris.has(file.uri)) { - items.push({ - uri: file.uri, - type: promptType, - name: file.name || basename(dirname(file.uri)) || basename(file.uri), - description: file.description, - storage: file.storage, - enabled: false, - }); - } - } - } } else if (promptType === PromptsType.prompt) { const commands = await this.promptsService.getPromptSlashCommands(token); - const seenUris = new ResourceSet(); for (const command of commands) { if (command.type === PromptsType.skill) { continue; } - seenUris.add(command.uri); items.push({ uri: command.uri, type: promptType, name: command.name, description: command.description, storage: command.storage, - enabled: !disabledUris.has(command.uri), }); if (command.extension) { extensionInfoByUri.set(command.uri, { id: command.extension.identifier, displayName: command.extension.displayName }); } } - // Re-add disabled prompt files so they appear in the UI as disabled. - // Use discovery info to get properly parsed names/descriptions. - if (disabledUris.size > 0) { - const discoveryInfo = await this.promptsService.getDiscoveryInfo(PromptsType.prompt, token); - const discoveredUris = new ResourceSet(discoveryInfo.files.map(f => f.promptPath.uri)); - for (const file of discoveryInfo.files) { - if (!seenUris.has(file.promptPath.uri) && disabledUris.has(file.promptPath.uri)) { - items.push({ - uri: file.promptPath.uri, - type: promptType, - name: file.promptPath.name || getFriendlyName(basename(file.promptPath.uri)), - description: file.promptPath.description, - storage: file.promptPath.storage, - enabled: false, - }); - } - } - // Fallback: add disabled prompts not found in discovery info - const allPromptFiles = await this.promptsService.listPromptFiles(PromptsType.prompt, token); - for (const file of allPromptFiles) { - if (!seenUris.has(file.uri) && !discoveredUris.has(file.uri) && disabledUris.has(file.uri)) { - items.push({ - uri: file.uri, - type: promptType, - name: file.name || getFriendlyName(basename(file.uri)), - description: file.description, - storage: file.storage, - enabled: false, - }); - } - } - } } else if (promptType === PromptsType.hook) { - await this.fetchPromptServiceHooks(items, disabledUris, promptType); + await this.fetchPromptServiceHooks(items, promptType); } else { - await this.fetchPromptServiceInstructions(items, extensionInfoByUri, disabledUris, promptType); - } - - // Safety net: ensure every disabled URI appears in the items list. - // Some disabled files may not be found in discovery info or listPromptFiles - // (e.g. when the file's source folder is no longer in the configured locations). - if (disabledUris.size > 0) { - const itemUris = new ResourceSet(items.map(i => i.uri)); - for (const uri of disabledUris) { - const alreadyInItems = itemUris.has(uri); - if (!alreadyInItems) { - items.push({ - uri, - type: promptType, - name: getFriendlyName(basename(uri)), - enabled: false, - }); - } else { - const idx = items.findIndex(i => i.uri.toString() === uri.toString()); - if (idx !== -1 && items[idx].enabled !== false) { - // Item exists but is marked as enabled despite being in disabled list — fix it - items[idx] = { ...items[idx], enabled: false }; - } - } - } + await this.fetchPromptServiceInstructions(items, extensionInfoByUri, promptType); } - const filtered = this.applyLocalFilters(this.applyBuiltinGroupKeys(items, extensionInfoByUri), promptType); - return filtered; + return this.applyLocalFilters(this.applyBuiltinGroupKeys(items, extensionInfoByUri), promptType); } - private async fetchPromptServiceHooks(items: ICustomizationItem[], disabledUris: ResourceSet, promptType: PromptsType): Promise { + private async fetchPromptServiceHooks(items: ICustomizationItem[], promptType: PromptsType): Promise { const hookFiles = await this.promptsService.listPromptFiles(PromptsType.hook, CancellationToken.None); // Non-plugin hooks: return raw file items — expansion into individual @@ -272,7 +140,6 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt type: promptType, name: f.name || getFriendlyName(basename(f.uri)), storage: f.storage, - enabled: !disabledUris.has(f.uri), }); } @@ -299,14 +166,13 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt description: `${agent.name}: ${truncatedCmd || localize('hookUnset', "(unset)")}`, storage: agent.source.storage, groupKey: 'agents', - enabled: !disabledUris.has(agent.uri), }); } } } } - private async fetchPromptServiceInstructions(items: ICustomizationItem[], extensionInfoByUri: ResourceMap<{ id: ExtensionIdentifier; displayName?: string }>, disabledUris: ResourceSet, promptType: PromptsType): Promise { + private async fetchPromptServiceInstructions(items: ICustomizationItem[], extensionInfoByUri: ResourceMap<{ id: ExtensionIdentifier; displayName?: string }>, promptType: PromptsType): Promise { const instructionFiles = await this.promptsService.getInstructionFiles(CancellationToken.None); for (const file of instructionFiles) { if (file.extension) { @@ -325,7 +191,6 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt name: filename, storage, groupKey: 'agent-instructions', - enabled: !disabledUris.has(file.uri), }); } @@ -352,7 +217,6 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt description, storage, groupKey: 'context-instructions', - enabled: !disabledUris.has(uri), }); } else { items.push({ @@ -362,7 +226,6 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt description, storage, groupKey: 'on-demand-instructions', - enabled: !disabledUris.has(uri), }); } } diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index 4971dae55ce5e..2187f54cd081d 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../base/common/codicons.js'; +import { ResourceSet } from '../../../../base/common/map.js'; import { IObservable, ISettableObservable, observableValue } from '../../../../base/common/observable.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; import { Event } from '../../../../base/common/event.js'; @@ -138,6 +139,25 @@ export interface IHarnessDescriptor { * this harness is active. */ readonly syncProvider?: ICustomizationSyncProvider; + /** + * When set, this harness manages its own enablement state. When absent, + * the management UI falls back to promptsService (StorageService). + */ + readonly enablementProvider?: ICustomizationEnablementProvider; + /** + * Controls which disablement actions are available in the management UI. + * - `'none'` — No disable/enable actions are shown. + * - `'global'` — A single "Disable" / "Enable" action is shown. + * - `'workspace'` — Both "Disable" and "Disable (Workspace)" actions are shown. + * + * Defaults to `'global'` when an enablementProvider is set, `'workspace'` otherwise. + */ + readonly enablementScope?: 'none' | 'global' | 'workspace'; + /** + * When set, only items whose type is in this set will show disable/enable + * actions. When absent, all types are disableable (subject to enablementScope). + */ + readonly disableableTypes?: ReadonlySet; } /** @@ -166,6 +186,31 @@ export interface ICustomizationItem { readonly badgeTooltip?: string; } +/** + * Provider interface for per-harness enablement state. + * + * Each harness can supply its own enablement provider to control where + * disabled state is stored (e.g. StorageService for VS Code, settings.json + * for CLI). When a harness does not supply an enablement provider, the + * management UI falls back to the core promptsService storage. + */ +export interface ICustomizationEnablementProvider { + /** Fires when any enablement state changes. */ + readonly onDidChange: Event; + + /** + * Returns the merged set of disabled URIs for a given prompt type. + * Used by the item source to overlay disabled state onto provider items. + */ + getDisabledPromptFiles(type: PromptsType): ResourceSet; + + /** + * Enables or disables a single customization item. + * The provider is expected to persist the change and fire {@link onDidChange}. + */ + setEnabled(uri: URI, type: PromptsType, enabled: boolean): void; +} + /** * Provider interface for extension-contributed harnesses that supply * customization items directly from their SDK. @@ -258,6 +303,13 @@ export interface ICustomizationHarnessService { * Returns a disposable that removes the harness when disposed. */ registerExternalHarness(descriptor: IHarnessDescriptor): IDisposable; + + /** + * Returns the enablement provider of the currently active harness, or + * `undefined` when the harness has no custom enablement provider + * (in which case the caller should fall back to promptsService). + */ + getActiveEnablementProvider(): ICustomizationEnablementProvider | undefined; } /** @@ -397,6 +449,7 @@ interface IRestrictedHarnessOptions { readonly sectionOverrides?: ReadonlyMap; readonly requiredAgentId?: string; readonly instructionFileFilter?: readonly string[]; + readonly enablementProvider?: ICustomizationEnablementProvider; } function createRestrictedHarnessDescriptor( @@ -420,6 +473,7 @@ function createRestrictedHarnessDescriptor( sectionOverrides: options?.sectionOverrides, requiredAgentId: options?.requiredAgentId, instructionFileFilter: options?.instructionFileFilter, + enablementProvider: options?.enablementProvider, getStorageSourceFilter(type: PromptsType): IStorageSourceFilter { if (type === PromptsType.hook) { return HOOKS_FILTER; @@ -435,7 +489,7 @@ function createRestrictedHarnessDescriptor( /** * Creates a "Copilot CLI" harness descriptor. */ -export function createCliHarnessDescriptor(cliUserRoots: readonly URI[], extras: readonly string[]): IHarnessDescriptor { +export function createCliHarnessDescriptor(cliUserRoots: readonly URI[], extras: readonly string[], enablementProvider?: ICustomizationEnablementProvider): IHarnessDescriptor { return createRestrictedHarnessDescriptor( CustomizationHarness.CLI, localize('harness.cli', "Copilot CLI"), @@ -451,6 +505,7 @@ export function createCliHarnessDescriptor(cliUserRoots: readonly URI[], extras: rootFileShortcuts: [AGENT_MD_FILENAME], }], ]), + enablementProvider, }, ); } @@ -576,6 +631,10 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer } return all.find(h => h.id === activeId) ?? all[0]; } + + getActiveEnablementProvider(): ICustomizationEnablementProvider | undefined { + return this.getActiveDescriptor().enablementProvider; + } } // #endregion diff --git a/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts b/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts index 052b93699ae29..00f58697a9722 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts @@ -47,6 +47,7 @@ export interface IWorkspacePluginSettingsService { * Keys are `"pluginName@marketplaceName"`, values indicate recommendation. */ readonly enabledPlugins: IObservable>; + } // --- Parsing helpers --------------------------------------------------------- diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 368e910ef0811..1968cb75189ad 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -338,6 +338,7 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv instantiationService.stub(IStorageService, disposables.add(new InMemoryStorageService())); instantiationService.stub(ICustomizationHarnessService, { registerExternalHarness: () => toDisposable(() => { }), + getActiveEnablementProvider: () => undefined, }); instantiationService.stub(IAgentPluginService, { plugins: observableValue('plugins', []), diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts index 58add97bfc0d4..3c4d7e31791ef 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts @@ -397,6 +397,7 @@ suite('AgentHostClientTools', () => { instantiationService.stub(IStorageService, disposables.add(new InMemoryStorageService())); instantiationService.stub(ICustomizationHarnessService, { registerExternalHarness: () => toDisposable(() => { }), + getActiveEnablementProvider: () => undefined, }); instantiationService.stub(IAgentPluginService, { plugins: observableValue('plugins', []), diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts index 19c4aac1883a9..e9c3528ebe5dc 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts @@ -199,6 +199,7 @@ suite('aiCustomizationListWidget', () => { getStorageSourceFilter: () => ({ sources: [] }), getActiveDescriptor: () => descriptor, registerExternalHarness: () => ({ dispose() { } }), + getActiveEnablementProvider: () => undefined, }); instaService.stub(IAgentPluginService, { diff --git a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts index 33027458d2c7c..458abef16a1dc 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts @@ -41,6 +41,18 @@ declare module 'vscode' { constructor(id: string); } + /** + * Describes the scope of disablement actions available for a customization provider. + */ + export enum ChatSessionCustomizationEnablementScope { + /** No disable/enable actions are shown. Items cannot be toggled. */ + None = 0, + /** A single "Disable" / "Enable" action is shown. The provider decides how to persist the state. */ + Global = 1, + /** Both "Disable" and "Disable (Workspace)" actions are shown, allowing per-workspace overrides. */ + Workspace = 2, + } + /** * Metadata describing a customization provider and its capabilities. * This drives UI presentation (label, icon) and filtering (unsupported types, @@ -63,6 +75,24 @@ declare module 'vscode' { * when this provider is active. When omitted, all sections are shown. */ readonly supportedTypes?: readonly ChatSessionCustomizationType[]; + + /** + * Controls which disablement actions are available in the management UI. + * + * When omitted, defaults to {@link ChatSessionCustomizationEnablementScope.Global} if + * {@link ChatSessionCustomizationProvider.resolveCustomizationEnablement} is implemented, + * or {@link ChatSessionCustomizationEnablementScope.Workspace} otherwise (built-in + * storage supports both scopes). + */ + readonly enablementScope?: ChatSessionCustomizationEnablementScope; + + /** + * Customization types for which per-item disable/enable is supported. + * When set, only items matching one of these types will show disable/enable + * actions in the management UI. When omitted, all types are disableable + * (subject to {@link enablementScope}). + */ + readonly disableableTypes?: readonly ChatSessionCustomizationType[]; } /** @@ -109,6 +139,12 @@ declare module 'vscode' { * Optional tooltip text shown when hovering over the badge. */ readonly badgeTooltip?: string; + + /** + * Whether this customization is currently enabled. + * Defaults to `true` when omitted. + */ + readonly enabled?: boolean; } /** @@ -145,6 +181,20 @@ declare module 'vscode' { * @returns The list of customization items, or `undefined` if unavailable. */ provideChatSessionCustomizations(token: CancellationToken): ProviderResult; + + /** + * Called when the user enables or disables a customization in the + * management UI. The provider should persist the change and fire + * {@link onDidChange} so the UI re-queries the updated state. + * + * When this method is not implemented, the management UI falls back + * to built-in storage for disablement state. + * + * @param uri The URI of the customization item. + * @param type The type of the customization. + * @param enabled Whether the customization should be enabled (`true`) or disabled (`false`). + */ + resolveCustomizationEnablement?(uri: Uri, type: ChatSessionCustomizationType, enabled: boolean, token: CancellationToken): void; } // #endregion From 20737b55c71692b3cb34eabb2828502832a059c2 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Wed, 22 Apr 2026 18:43:35 -0700 Subject: [PATCH 05/36] nit --- .../copilotCLICustomizationProvider.ts | 52 ++++++++++++++----- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts index bd82885977c75..709813150234e 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts @@ -38,6 +38,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod disableableTypes: [ vscode.ChatSessionCustomizationType.Skill, vscode.ChatSessionCustomizationType.Hook, + vscode.ChatSessionCustomizationType.Plugins, ].filter((t): t is vscode.ChatSessionCustomizationType => t !== undefined), }; } @@ -231,11 +232,21 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod * Collects all plugin items from the prompt file service. */ private async getPluginItems(token: vscode.CancellationToken): Promise { - return (await this.promptsService.getPlugins(token)).filter(isEnabledForCopilotCLI).map(p => ({ - uri: p.uri, - type: vscode.ChatSessionCustomizationType.Plugins, - name: basename(p.uri), - })); + const settings = await this._readSettings(); + const enabledPlugins = (settings.enabledPlugins && typeof settings.enabledPlugins === 'object' && !Array.isArray(settings.enabledPlugins)) + ? settings.enabledPlugins as Record + : {}; + return (await this.promptsService.getPlugins(token)).filter(isEnabledForCopilotCLI).map(p => { + const name = basename(p.uri); + // A plugin is disabled if explicitly set to false in enabledPlugins + const enabled = enabledPlugins[name] !== false; + return { + uri: p.uri, + type: vscode.ChatSessionCustomizationType.Plugins, + name, + enabled, + }; + }); } // --- Enablement --- @@ -284,6 +295,11 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod const name = basename(URI.from(uri)).replace(/\.json$/i, ''); return { settingsKey: 'disabledHooks', name }; } + if (type.id === vscode.ChatSessionCustomizationType.Plugins?.id) { + // Plugins use enabledPlugins map (Record) + const name = basename(URI.from(uri)); + return { settingsKey: 'enabledPlugins', name }; + } // Other types don't have per-item disablement in the CLI config return undefined; } @@ -297,15 +313,27 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod const { settingsKey, name } = resolved; const settings = await this._readSettings(); - const currentList = Array.isArray(settings[settingsKey]) ? settings[settingsKey] as string[] : []; - if (enabled) { - // Remove from disabled list - settings[settingsKey] = currentList.filter(s => s !== name); + if (settingsKey === 'enabledPlugins') { + // enabledPlugins is a Record map + const map = (settings[settingsKey] && typeof settings[settingsKey] === 'object' && !Array.isArray(settings[settingsKey])) + ? { ...settings[settingsKey] as Record } + : {}; + if (enabled) { + delete map[name]; + } else { + map[name] = false; + } + settings[settingsKey] = Object.keys(map).length > 0 ? map : undefined; } else { - // Add to disabled list (if not already present) - if (!currentList.includes(name)) { - settings[settingsKey] = [...currentList, name]; + // disabledSkills / disabledHooks are string arrays + const currentList = Array.isArray(settings[settingsKey]) ? settings[settingsKey] as string[] : []; + if (enabled) { + settings[settingsKey] = currentList.filter(s => s !== name); + } else { + if (!currentList.includes(name)) { + settings[settingsKey] = [...currentList, name]; + } } } From ba2be7410eb53ab9e0e3b9f0dd0a5ebaa6aaf762 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Wed, 22 Apr 2026 18:49:58 -0700 Subject: [PATCH 06/36] nit --- .../aiCustomization/aiCustomizationListWidget.ts | 6 ++---- .../aiCustomizationManagement.contribution.ts | 10 ++++------ 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index ce8b8080ad7e6..86da86f041aa1 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -438,8 +438,7 @@ class AICustomizationItemRenderer implements IListRenderer Date: Wed, 22 Apr 2026 20:17:07 -0700 Subject: [PATCH 07/36] cleanup --- .../api/browser/mainThreadChatAgents2.ts | 8 -------- src/vs/workbench/api/common/extHost.protocol.ts | 2 +- .../workbench/api/common/extHostChatAgents2.ts | 4 ++-- .../aiCustomization/aiCustomizationItemSource.ts | 16 ++++++++++++++-- ...roposed.chatSessionCustomizationProvider.d.ts | 2 +- 5 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index b5626cd0e49ce..5b812bf663cbb 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -812,14 +812,6 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA this._customizationProviders.set(handle, registration); } - /** - * Called by the management UI when the user toggles a customization item's enabled state. - * Delegates to the extension's setCustomizationEnabled callback. - */ - $setCustomizationEnabled(handle: number, uri: UriComponents, type: string, enabled: boolean): void { - this._proxy.$setCustomizationEnabled(handle, uri, type, enabled); - } - $unregisterChatSessionCustomizationProvider(handle: number): void { this._customizationProviders.deleteAndDispose(handle); this._customizationProviderEmitters.deleteAndDispose(handle); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index d7d04d7410395..96edb7ae387e2 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1672,7 +1672,7 @@ export interface ExtHostChatAgentsShape2 { $detectChatParticipant(handle: number, request: Dto, context: { history: IChatAgentHistoryEntryDto[] }, options: { participants: IChatParticipantMetadata[]; location: ChatAgentLocation }, token: CancellationToken): Promise; $providePromptFiles(handle: number, type: PromptsType, context: IPromptFileContext, token: CancellationToken): Promise[] | undefined>; $provideChatSessionCustomizations(handle: number, token: CancellationToken): Promise; - $setCustomizationEnabled(handle: number, uri: UriComponents, type: string, enabled: boolean): void; + $setCustomizationEnabled(handle: number, uri: UriComponents, type: string, enabled: boolean): Promise; $setRequestTools(requestId: string, tools: UserSelectedTools): void; $setYieldRequested(requestId: string, value: boolean): void; $acceptActiveChatSession(sessionResource: UriComponents | undefined): void; diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index b50a7151e73bc..7247b71c5a452 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -845,12 +845,12 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS } } - $setCustomizationEnabled(handle: number, uri: UriComponents, type: string, enabled: boolean): void { + async $setCustomizationEnabled(handle: number, uri: UriComponents, type: string, enabled: boolean): Promise { const providerData = this._customizationProviders.get(handle); if (!providerData?.provider.resolveCustomizationEnablement) { return; } - providerData.provider.resolveCustomizationEnablement( + await providerData.provider.resolveCustomizationEnablement( URI.revive(uri), typeConvert.ChatSessionCustomizationType.to(type), enabled, diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index c1ebaab6ae260..cb577b3060c9b 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -376,14 +376,26 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour // Overlay disabled state from the harness's enablement provider, or // fall back to promptsService (StorageService) when no provider is set. + // Extension items are always stored in promptsService regardless of the + // harness's enablement provider, so merge both sources when a provider is active. // Also add back disabled items that the provider didn't include at all. const disabledUris = this.enablementProvider ? this.enablementProvider.getDisabledPromptFiles(promptType) : this.promptsService.getDisabledPromptFiles(promptType); - if (disabledUris.size > 0) { + const extensionDisabledUris = this.enablementProvider + ? this.promptsService.getDisabledPromptFiles(promptType) + : undefined; + if (disabledUris.size > 0 || (extensionDisabledUris && extensionDisabledUris.size > 0)) { const existingUris = new ResourceSet(normalized.map(i => i.uri)); for (let i = 0; i < normalized.length; i++) { - if (!normalized[i].disabled && disabledUris.has(normalized[i].uri)) { + if (normalized[i].disabled) { + continue; + } + const isExtension = normalized[i].storage === PromptsStorage.extension; + const isDisabled = isExtension + ? (extensionDisabledUris?.has(normalized[i].uri) ?? false) + : disabledUris.has(normalized[i].uri); + if (isDisabled) { normalized[i] = { ...normalized[i], disabled: true }; } } diff --git a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts index 458abef16a1dc..433ee495780d5 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts @@ -194,7 +194,7 @@ declare module 'vscode' { * @param type The type of the customization. * @param enabled Whether the customization should be enabled (`true`) or disabled (`false`). */ - resolveCustomizationEnablement?(uri: Uri, type: ChatSessionCustomizationType, enabled: boolean, token: CancellationToken): void; + resolveCustomizationEnablement?(uri: Uri, type: ChatSessionCustomizationType, enabled: boolean, token: CancellationToken): Thenable; } // #endregion From 1dea8b14bb2e27fb6d2e899c53ec934c06df23c2 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Thu, 23 Apr 2026 11:40:12 -0700 Subject: [PATCH 08/36] claude --- .../claudeCustomizationProvider.ts | 130 ++++++++++++++++-- .../copilotCLICustomizationProvider.ts | 9 +- .../copilotCLICustomizationProvider.spec.ts | 5 +- .../api/browser/mainThreadChatAgents2.ts | 9 +- .../workbench/api/common/extHost.api.impl.ts | 1 + .../workbench/api/common/extHost.protocol.ts | 4 +- .../api/common/extHostChatAgents2.ts | 20 +-- src/vs/workbench/api/common/extHostTypes.ts | 6 + .../aiCustomizationItemSource.ts | 6 + .../aiCustomizationListWidget.ts | 18 ++- .../aiCustomizationManagement.contribution.ts | 41 ++++++ .../aiCustomizationManagement.ts | 5 + .../common/customizationHarnessService.ts | 18 +-- ...osed.chatSessionCustomizationProvider.d.ts | 42 +++--- 14 files changed, 247 insertions(+), 67 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts index 89f766fd1a620..22ab4ed10b7ef 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts @@ -10,7 +10,7 @@ import { ILogService } from '../../../platform/log/common/logService'; import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; import { Emitter } from '../../../util/vs/base/common/event'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; -import { basename } from '../../../util/vs/base/common/resources'; +import { basename, dirname } from '../../../util/vs/base/common/resources'; import { URI } from '../../../util/vs/base/common/uri'; import { IClaudeRuntimeDataService } from '../claude/common/claudeRuntimeDataService'; import { ClaudeSessionUri } from '../claude/common/claudeSessionUri'; @@ -59,6 +59,16 @@ interface HooksSettings { readonly hooks?: Partial>; } +/** + * Shape of `.claude/settings.json` fields relevant to enablement. + */ +interface ClaudeSettings { + readonly skillOverrides?: Record; + readonly claudeMdExcludes?: string[]; + readonly disableAllHooks?: boolean; + [key: string]: unknown; +} + export class ClaudeCustomizationProvider extends Disposable implements vscode.ChatSessionCustomizationProvider { private readonly _onDidChange = this._register(new Emitter()); @@ -108,6 +118,7 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch type: vscode.ChatSessionCustomizationType.Agent, name: agent.name, description: agent.description, + enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, // No groupKey — vscode infers Built-in from non-file: scheme }); } @@ -121,6 +132,7 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch uri: agent.uri, type: vscode.ChatSessionCustomizationType.Agent, name, + enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, }); } } @@ -130,18 +142,23 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch this.logService.debug(`[ClaudeCustomizationProvider] agents (${agentItems.length}): ${agentItems.map(a => a.name).join(', ') || '(none)'}${sdkAgents.length ? ' [sdk]' : ' [files-only, no session]'}`); // Instructions from hard-coded CLAUDE.md paths (checked for existence) - const instructionItems = await this.discoverInstructions(); + const settings = await this._readSettings(); + const instructionItems = await this.discoverInstructions(settings); items.push(...instructionItems); this.logService.debug(`[ClaudeCustomizationProvider] instructions (${instructionItems.length}): ${instructionItems.map(i => i.name).join(', ') || '(none)'}`); // Skills from .claude/skills/ directories (user-defined SKILL.md files) + const skillOverrides = settings.skillOverrides ?? {}; const skillItems: vscode.ChatSessionCustomizationItem[] = []; for (const skill of await this.promptsService.getSkills(token)) { if (this.isClaudePath(skill.uri)) { + const folderName = basename(dirname(skill.uri)) || basename(skill.uri); + const override = skillOverrides[folderName]; const item: vscode.ChatSessionCustomizationItem = { uri: skill.uri, type: vscode.ChatSessionCustomizationType.Skill, name: skill.name, + enabled: override !== 'off', }; skillItems.push(item); } @@ -150,7 +167,7 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch this.logService.debug(`[ClaudeCustomizationProvider] skills (${skillItems.length}): ${skillItems.map(s => s.name).join(', ') || '(none)'}`); // Hooks from .claude/settings.json files - const hookItems = await this.discoverHooks(); + const hookItems = await this.discoverHooks(settings); items.push(...hookItems); this.logService.debug(`[ClaudeCustomizationProvider] hooks (${hookItems.length}): ${hookItems.map(h => h.name).join(', ') || '(none)'}`); @@ -158,9 +175,10 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch return items; } - private async discoverInstructions(): Promise { + private async discoverInstructions(settings: ClaudeSettings): Promise { const items: vscode.ChatSessionCustomizationItem[] = []; const candidates: URI[] = []; + const excludes = settings.claudeMdExcludes ?? []; for (const folder of this.workspaceService.getWorkspaceFolders()) { for (const entry of WORKSPACE_INSTRUCTION_PATHS) { @@ -179,10 +197,15 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch for (const uri of candidates) { if (await this.fileExists(uri)) { const name = basename(uri).replace(/\.md$/i, ''); + const excluded = excludes.some(pattern => this._matchesExclude(uri, pattern)); items.push({ uri, type: vscode.ChatSessionCustomizationType.Instructions, name, + enablementScope: excluded + ? vscode.ChatSessionCustomizationEnablementScope.Global + : vscode.ChatSessionCustomizationEnablementScope.None, + enabled: !excluded, }); } } @@ -199,20 +222,21 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch } } - private async discoverHooks(): Promise { + private async discoverHooks(settings: ClaudeSettings): Promise { const items: vscode.ChatSessionCustomizationItem[] = []; const settingsPaths = this.getSettingsFilePaths(); + const allHooksDisabled = settings.disableAllHooks === true; for (const settingsUri of settingsPaths) { try { const content = await this.fileSystemService.readFile(settingsUri); - const settings: HooksSettings = JSON.parse(new TextDecoder().decode(content)); - if (!settings.hooks) { + const fileSettings: HooksSettings = JSON.parse(new TextDecoder().decode(content)); + if (!fileSettings.hooks) { continue; } for (const eventId of HOOK_EVENT_IDS) { - const matchers = settings.hooks[eventId]; + const matchers = fileSettings.hooks[eventId]; if (!matchers || matchers.length === 0) { continue; } @@ -225,6 +249,9 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch type: vscode.ChatSessionCustomizationType.Hook, name: `${eventId}${matcherLabel}`, description: hook.command, + enabled: !allHooksDisabled, + // Individual hooks can't be toggled — only disableAllHooks + enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, }); } } @@ -273,6 +300,93 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch return false; } + + // --- Settings --- + + /** + * Path to the user-level claude settings file (`~/.claude/settings.json`). + */ + private get _settingsUri(): URI { + return URI.joinPath(this.envService.userHome, '.claude', 'settings.json'); + } + + /** + * Reads the user-level `~/.claude/settings.json` as a typed object. + * Returns an empty object if the file doesn't exist or can't be parsed. + */ + private async _readSettings(): Promise { + try { + const bytes = await this.fileSystemService.readFile(this._settingsUri); + const parsed = JSON.parse(new TextDecoder().decode(bytes)); + return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {}; + } catch { + return {}; + } + } + + /** + * Writes the user-level `~/.claude/settings.json`. + */ + private async _writeSettings(settings: ClaudeSettings): Promise { + const content = new TextEncoder().encode(JSON.stringify(settings, null, 4)); + await this.fileSystemService.writeFile(this._settingsUri, content); + } + + /** + * Checks whether a URI matches a claudeMdExcludes pattern. + * Patterns can be absolute paths or simple glob-like suffixes. + */ + private _matchesExclude(uri: URI, pattern: string): boolean { + const uriPath = uri.path; + // Absolute path match + if (pattern.startsWith('/') && !pattern.includes('*')) { + return uriPath === pattern; + } + // Simple suffix/glob match: strip leading **/ and check endsWith + const suffix = pattern.replace(/^\*\*\//, ''); + if (suffix !== pattern && !suffix.includes('*')) { + return uriPath.endsWith('/' + suffix) || uriPath === suffix; + } + // Exact match fallback + return uriPath === pattern; + } + + // --- Enablement --- + + async resolveCustomizationEnablement(uri: vscode.Uri, type: vscode.ChatSessionCustomizationType, enabled: boolean, _token: vscode.CancellationToken): Promise { + const settings = { ...await this._readSettings() }; + + if (type.id === vscode.ChatSessionCustomizationType.Skill.id) { + // skillOverrides: Record + const folderName = basename(dirname(URI.from(uri))) || basename(URI.from(uri)); + const overrides = { ...settings.skillOverrides ?? {} }; + if (enabled) { + delete overrides[folderName]; + } else { + overrides[folderName] = 'off'; + } + (settings as Record).skillOverrides = Object.keys(overrides).length > 0 ? overrides : undefined; + } else if (type.id === vscode.ChatSessionCustomizationType.Instructions.id) { + // claudeMdExcludes: string[] of absolute paths or glob patterns + const uriPath = URI.from(uri).path; + const currentExcludes = [...(settings.claudeMdExcludes ?? [])]; + if (enabled) { + (settings as Record).claudeMdExcludes = currentExcludes.filter(p => !this._matchesExclude(URI.from(uri), p)); + } else { + if (!currentExcludes.some(p => this._matchesExclude(URI.from(uri), p))) { + currentExcludes.push(uriPath); + (settings as Record).claudeMdExcludes = currentExcludes; + } + } + } else { + this.logService.warn(`[ClaudeCustomizationProvider] Per-item enablement not supported for type: ${type.id}`); + return; + } + + await this._writeSettings(settings); + this.logService.debug(`[ClaudeCustomizationProvider] ${enabled ? 'Enabled' : 'Disabled'} ${type.id} in ${this._settingsUri.toString()}`); + this._onDidChange.fire(); + } } export function isEnabledForClaudeCode(customization: { sessionTypes?: readonly string[] }): boolean { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts index 709813150234e..eff1baae1dcb6 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts @@ -35,11 +35,6 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod vscode.ChatSessionCustomizationType.Hook, vscode.ChatSessionCustomizationType.Plugins, ].filter((t): t is vscode.ChatSessionCustomizationType => t !== undefined), - disableableTypes: [ - vscode.ChatSessionCustomizationType.Skill, - vscode.ChatSessionCustomizationType.Hook, - vscode.ChatSessionCustomizationType.Plugins, - ].filter((t): t is vscode.ChatSessionCustomizationType => t !== undefined), }; } @@ -100,6 +95,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod type: vscode.ChatSessionCustomizationType.Agent, name: agent.displayName || agent.name, description: agent.description, + enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, })); } @@ -141,6 +137,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod type: vscode.ChatSessionCustomizationType.Instructions, name: basename(uri), groupKey: 'agent-instructions', + enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, }); } @@ -173,6 +170,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod groupKey: 'context-instructions', badge, badgeTooltip, + enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, }); } else { items.push({ @@ -181,6 +179,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod name, description, groupKey: 'on-demand-instructions', + enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, }); } } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLICustomizationProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLICustomizationProvider.spec.ts index e6705e8e453f3..4ab67cc1905af 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLICustomizationProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLICustomizationProvider.spec.ts @@ -15,6 +15,7 @@ import { URI } from '../../../../util/vs/base/common/uri'; import { CLIAgentInfo, ICopilotCLIAgents } from '../../copilotcli/node/copilotCli'; import { CopilotCLICustomizationProvider } from '../copilotCLICustomizationProvider'; import { MockPromptsService } from '../../../../platform/promptFiles/test/common/mockPromptsService'; +import { INativeEnvService } from '../../../../platform/env/common/envService'; class FakeChatSessionCustomizationType { static readonly Agent = new FakeChatSessionCustomizationType('agent'); @@ -119,7 +120,7 @@ describe('CopilotCLICustomizationProvider', () => { new TestLogService(), { getWorkspaceFolders: () => [] } as any, { stat: () => Promise.reject(new Error('not found')) } as any, - { userHome: URI.file('/home/testuser') } as any, + { userHome: URI.file('/home/testuser') } as unknown as INativeEnvService, )); }); @@ -341,7 +342,7 @@ describe('CopilotCLICustomizationProvider', () => { ? Promise.resolve({ type: 1, ctime: 0, mtime: 0, size: 0 }) : Promise.reject(new Error('not found')), } as any, - { userHome: URI.file('/home/testuser') } as any, + { userHome: URI.file('/home/testuser') } as unknown as INativeEnvService, )); mockPromptsService.setInstructions([]); diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 5b812bf663cbb..a5a56d139c5dc 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -734,7 +734,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA if (!items) { return undefined; } - return items.map((item: IChatSessionCustomizationItemDto): ICustomizationItem => ({ + const convertItem = (item: IChatSessionCustomizationItemDto): ICustomizationItem => ({ uri: URI.revive(item.uri), type: item.type, name: item.name, @@ -743,7 +743,10 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA badge: item.badge, badgeTooltip: item.badgeTooltip, enabled: item.enabled, - })); + enablementScope: item.enablementScope, + plugin: item.plugin ? convertItem(item.plugin) : undefined, + }); + return items.map(convertItem); }, }; @@ -804,8 +807,6 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA }), itemProvider, enablementProvider, - enablementScope: metadata.enablementScope, - disableableTypes: metadata.disableableTypes ? new Set(metadata.disableableTypes) : undefined, }; const registration = this._customizationHarnessService.registerExternalHarness(descriptor); diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index e897e8057ad82..1e8f804e38c15 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -2158,6 +2158,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I ChatLocation: extHostTypes.ChatLocation, ChatSessionStatus: extHostTypes.ChatSessionStatus, ChatSessionCustomizationType: extHostTypes.ChatSessionCustomizationType, + ChatSessionCustomizationEnablementScope: extHostTypes.ChatSessionCustomizationEnablementScope, ChatDebugLogLevel: extHostTypes.ChatDebugLogLevel, ChatDebugToolCallResult: extHostTypes.ChatDebugToolCallResult, ChatDebugHookResult: extHostTypes.ChatDebugHookResult, diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 96edb7ae387e2..fb5f627963ddc 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1730,8 +1730,6 @@ export interface IChatSessionCustomizationProviderMetadataDto { readonly label: string; readonly iconId?: string; readonly supportedTypes?: readonly string[]; - readonly enablementScope?: 'none' | 'global' | 'workspace'; - readonly disableableTypes?: readonly string[]; } export interface IChatSessionCustomizationItemDto { @@ -1743,6 +1741,8 @@ export interface IChatSessionCustomizationItemDto { readonly badge?: string; readonly badgeTooltip?: string; readonly enabled?: boolean; + readonly enablementScope?: 'none' | 'global' | 'workspace'; + readonly plugin?: IChatSessionCustomizationItemDto; } export interface IChatParticipantMetadata { participant: string; diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 7247b71c5a452..5894a0cddedeb 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -485,6 +485,11 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS private readonly _promptFileProviders = new Map(); private static _customizationProviderIdPool = 0; + private static readonly _enablementScopeMap: Record = { + 0: 'none', // ChatSessionCustomizationEnablementScope.None + 1: 'global', // ChatSessionCustomizationEnablementScope.Global + 2: 'workspace', // ChatSessionCustomizationEnablementScope.Workspace + }; private readonly _customizationProviders = new Map(); private readonly _sessionDisposables: DisposableResourceMap = this._register(new DisposableResourceMap()); @@ -787,17 +792,10 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS const handle = ExtHostChatAgents2._customizationProviderIdPool++; this._customizationProviders.set(handle, { extension, provider }); - const enablementScopeMap: Record = { - 0: 'none', // ChatSessionCustomizationEnablementScope.None - 1: 'global', // ChatSessionCustomizationEnablementScope.Global - 2: 'workspace', // ChatSessionCustomizationEnablementScope.Workspace - }; const metadataDto: IChatSessionCustomizationProviderMetadataDto = { label: metadata.label, iconId: metadata.iconId, supportedTypes: metadata.supportedTypes?.map(t => typeConvert.ChatSessionCustomizationType.from(t)), - enablementScope: metadata.enablementScope !== undefined ? enablementScopeMap[metadata.enablementScope] : undefined, - disableableTypes: metadata.disableableTypes?.map(t => typeConvert.ChatSessionCustomizationType.from(t)), }; this._proxy.$registerChatSessionCustomizationProvider(handle, chatSessionType, metadataDto, extension.identifier, typeof provider.resolveCustomizationEnablement === 'function'); @@ -830,7 +828,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS return undefined; } - return items.map(item => ({ + const convertItem = (item: vscode.ChatSessionCustomizationItem): IChatSessionCustomizationItemDto => ({ uri: item.uri, type: typeConvert.ChatSessionCustomizationType.from(item.type), name: item.name, @@ -839,7 +837,11 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS badge: item.badge, badgeTooltip: item.badgeTooltip, enabled: item.enabled, - })); + enablementScope: item.enablementScope !== undefined ? ExtHostChatAgents2._enablementScopeMap[item.enablementScope] : undefined, + plugin: item.plugin ? convertItem(item.plugin) : undefined, + }); + + return items.map(convertItem); } catch (err) { return undefined; } diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index fd9262596f8b9..5d1c1ecf1e4f2 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3590,6 +3590,12 @@ export class ChatSessionCustomizationType { constructor(public readonly id: string) { } } +export enum ChatSessionCustomizationEnablementScope { + None = 0, + Global = 1, + Workspace = 2, +} + export enum ChatDebugLogLevel { Trace = 0, Info = 1, diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index cb577b3060c9b..b75a21bd48405 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -67,6 +67,10 @@ export interface IAICustomizationListItem { readonly status?: 'loading' | 'loaded' | 'degraded' | 'error'; /** Human-readable status detail (e.g. error message or warning). */ readonly statusMessage?: string; + /** Per-item enablement scope override. When absent, falls back to the harness-level enablementScope. */ + readonly enablementScope?: 'none' | 'global' | 'workspace'; + /** Optional reference to the parent plugin item. When present, enable/disable actions target the plugin and the item's own enablementScope is ignored. */ + readonly plugin?: ICustomizationItem; /** When true, this item can be selected for syncing to a remote harness. */ readonly syncable?: boolean; /** When true, this syncable item is currently selected for syncing. */ @@ -224,6 +228,8 @@ export class AICustomizationItemNormalizer { extensionLabel, status: item.status, statusMessage: item.statusMessage, + enablementScope: item.enablementScope, + plugin: item.plugin, }; } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 86da86f041aa1..f65fd2ed0554c 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -22,7 +22,7 @@ import { IListVirtualDelegate, IListRenderer, IListContextMenuEvent } from '../. import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, userIcon, workspaceIcon, extensionIcon, pluginIcon, builtinIcon } from './aiCustomizationIcons.js'; -import { AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AI_CUSTOMIZATION_ITEM_TYPE_KEY, AI_CUSTOMIZATION_ITEM_URI_KEY, AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY, AICustomizationManagementItemMenuId, AICustomizationManagementCreateMenuId, AICustomizationManagementSection, BUILTIN_STORAGE, AI_CUSTOMIZATION_ITEM_DISABLED_KEY, AI_CUSTOMIZATION_SUPPORTS_TROUBLESHOOT_KEY, AI_CUSTOMIZATION_ENABLEMENT_SCOPE_KEY, AI_CUSTOMIZATION_ITEM_DISABLEABLE_KEY } from './aiCustomizationManagement.js'; +import { AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AI_CUSTOMIZATION_ITEM_TYPE_KEY, AI_CUSTOMIZATION_ITEM_URI_KEY, AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY, AICustomizationManagementItemMenuId, AICustomizationManagementCreateMenuId, AICustomizationManagementSection, BUILTIN_STORAGE, AI_CUSTOMIZATION_ITEM_DISABLED_KEY, AI_CUSTOMIZATION_SUPPORTS_TROUBLESHOOT_KEY, AI_CUSTOMIZATION_ENABLEMENT_SCOPE_KEY, AI_CUSTOMIZATION_ITEM_DISABLEABLE_KEY, AI_CUSTOMIZATION_ITEM_HAS_PLUGIN_KEY } from './aiCustomizationManagement.js'; import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { InputBox } from '../../../../../base/browser/ui/inputbox/inputBox.js'; import { defaultButtonStyles, defaultCheckboxStyles, defaultInputBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; @@ -434,18 +434,22 @@ class AICustomizationItemRenderer implements IListRenderer; + const uri = typeof plugin.uri === 'string' ? URI.parse(plugin.uri) : undefined; + const type = typeof plugin.type === 'string' ? plugin.type : undefined; + const name = typeof plugin.name === 'string' ? plugin.name : undefined; + if (!uri || !type || !name) { + return undefined; + } + return { uri, type, name }; +} + /** * Extracts the item name from context. */ @@ -754,6 +775,16 @@ registerAction2(class extends Action2 { return; } + // When this item has a parent plugin, disable the plugin instead + const plugin = extractPlugin(context); + if (plugin) { + const enablementProvider = harnessService.getActiveEnablementProvider(); + if (enablementProvider) { + enablementProvider.setEnabled(plugin.uri, plugin.type as PromptsType, false); + } + return; + } + const enablementProvider = harnessService.getActiveEnablementProvider(); if (enablementProvider) { enablementProvider.setEnabled(uri, promptType, false); @@ -816,6 +847,16 @@ registerAction2(class extends Action2 { return; } + // When this item has a parent plugin, enable the plugin instead + const plugin = extractPlugin(context); + if (plugin) { + const enablementProvider = harnessService.getActiveEnablementProvider(); + if (enablementProvider) { + enablementProvider.setEnabled(plugin.uri, plugin.type as PromptsType, true); + } + return; + } + const enablementProvider = harnessService.getActiveEnablementProvider(); if (enablementProvider) { enablementProvider.setEnabled(uri, promptType, true); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts index ab00ea39461df..9e70069b1ad56 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts @@ -122,6 +122,11 @@ export const AI_CUSTOMIZATION_ENABLEMENT_SCOPE_KEY = 'aiCustomizationManagementE */ export const AI_CUSTOMIZATION_ITEM_DISABLEABLE_KEY = 'aiCustomizationManagementItemDisableable'; +/** + * Context key indicating whether the current item has a parent plugin (from provider). + */ +export const AI_CUSTOMIZATION_ITEM_HAS_PLUGIN_KEY = 'aiCustomizationManagementItemHasPlugin'; + /** * Storage key for persisting the selected section. */ diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index 2187f54cd081d..b4562554226b5 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -144,20 +144,6 @@ export interface IHarnessDescriptor { * the management UI falls back to promptsService (StorageService). */ readonly enablementProvider?: ICustomizationEnablementProvider; - /** - * Controls which disablement actions are available in the management UI. - * - `'none'` — No disable/enable actions are shown. - * - `'global'` — A single "Disable" / "Enable" action is shown. - * - `'workspace'` — Both "Disable" and "Disable (Workspace)" actions are shown. - * - * Defaults to `'global'` when an enablementProvider is set, `'workspace'` otherwise. - */ - readonly enablementScope?: 'none' | 'global' | 'workspace'; - /** - * When set, only items whose type is in this set will show disable/enable - * actions. When absent, all types are disableable (subject to enablementScope). - */ - readonly disableableTypes?: ReadonlySet; } /** @@ -178,12 +164,16 @@ export interface ICustomizationItem { readonly statusMessage?: string; /** Whether this customization is currently enabled. */ readonly enabled?: boolean; + /** Per-item enablement scope override. When absent, falls back to the harness-level enablementScope. */ + readonly enablementScope?: 'none' | 'global' | 'workspace'; /** When set, items with the same groupKey are displayed under a shared collapsible header. */ readonly groupKey?: string; /** When set, shows a small inline badge next to the item name (e.g. an applyTo glob pattern). */ readonly badge?: string; /** Tooltip shown when hovering the badge. */ readonly badgeTooltip?: string; + /** Optional reference to the parent plugin. When present, enable/disable actions target the plugin and the item's own enablementScope is ignored. */ + readonly plugin?: ICustomizationItem; } /** diff --git a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts index 433ee495780d5..1e7db4068992f 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts @@ -75,24 +75,6 @@ declare module 'vscode' { * when this provider is active. When omitted, all sections are shown. */ readonly supportedTypes?: readonly ChatSessionCustomizationType[]; - - /** - * Controls which disablement actions are available in the management UI. - * - * When omitted, defaults to {@link ChatSessionCustomizationEnablementScope.Global} if - * {@link ChatSessionCustomizationProvider.resolveCustomizationEnablement} is implemented, - * or {@link ChatSessionCustomizationEnablementScope.Workspace} otherwise (built-in - * storage supports both scopes). - */ - readonly enablementScope?: ChatSessionCustomizationEnablementScope; - - /** - * Customization types for which per-item disable/enable is supported. - * When set, only items matching one of these types will show disable/enable - * actions in the management UI. When omitted, all types are disableable - * (subject to {@link enablementScope}). - */ - readonly disableableTypes?: readonly ChatSessionCustomizationType[]; } /** @@ -145,6 +127,30 @@ declare module 'vscode' { * Defaults to `true` when omitted. */ readonly enabled?: boolean; + + /** + * Controls which disablement actions are available for this item. + * + * When omitted, defaults to {@link ChatSessionCustomizationEnablementScope.Global} if + * {@link ChatSessionCustomizationProvider.resolveCustomizationEnablement} is implemented, + * or {@link ChatSessionCustomizationEnablementScope.Workspace} otherwise (built-in + * storage supports both scopes). + * + * Ignored when {@link plugin} is set — plugin items always use global-scope + * enablement targeting the plugin itself. + */ + readonly enablementScope?: ChatSessionCustomizationEnablementScope; + + /** + * Optional reference to the parent plugin of this customization item. + * + * When set, all enable/disable actions for this item target the plugin + * instead of the individual item, and the item's own + * {@link enablementScope} is ignored. The plugin item is itself a + * {@link ChatSessionCustomizationItem} so it can be passed directly to + * {@link ChatSessionCustomizationProvider.resolveCustomizationEnablement}. + */ + readonly plugin?: ChatSessionCustomizationItem; } /** From 0ab1e2148794ad260d6ac061ce5bf8ac99cd0361 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Thu, 23 Apr 2026 12:00:48 -0700 Subject: [PATCH 09/36] API update --- .../chatSessions/vscode-node/chatSessions.ts | 6 +-- .../claudeCustomizationProvider.ts | 5 +- .../copilotCLICustomizationProvider.ts | 6 +-- .../api/browser/mainThreadChatAgents2.ts | 6 +-- .../workbench/api/common/extHost.api.impl.ts | 4 +- .../workbench/api/common/extHost.protocol.ts | 2 +- .../api/common/extHostChatAgents2.ts | 19 +++++--- .../aiCustomizationItemSource.ts | 11 +++-- .../aiCustomizationListWidget.ts | 5 +- .../aiCustomizationManagement.contribution.ts | 29 +++++++----- .../common/customizationHarnessService.ts | 4 +- .../promptSyntax/service/promptsService.ts | 9 ++-- .../service/promptsServiceImpl.ts | 21 +++++---- .../service/mockPromptsService.ts | 6 +-- ...osed.chatSessionCustomizationProvider.d.ts | 47 ++++++++++++++----- 15 files changed, 105 insertions(+), 75 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts index da43cfc3bdb2e..eae85f9746311 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts @@ -154,7 +154,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib chatParticipant.iconPath = new vscode.ThemeIcon('claude'); this._register(vscode.chat.registerChatSessionContentProvider(ClaudeSessionUri.scheme, chatSessionContentProvider, chatParticipant)); const claudeCustomizationProvider = this._register(claudeAgentInstaService.createInstance(ClaudeCustomizationProvider)); - this._register(vscode.chat.registerChatSessionCustomizationProvider(ClaudeSessionUri.scheme, ClaudeCustomizationProvider.metadata, claudeCustomizationProvider)); + this._register(vscode.chat.registerChatSessionCustomizationProvider(ClaudeSessionUri.scheme, ClaudeCustomizationProvider.metadata, claudeCustomizationProvider, claudeCustomizationProvider)); // #endregion @@ -227,7 +227,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib const copilotcliParticipant = vscode.chat.createChatParticipant(this.copilotcliSessionType, copilotcliChatSessionParticipant.createHandler()); this._register(vscode.chat.registerChatSessionContentProvider(this.copilotcliSessionType, copilotcliChatSessionContentProvider, copilotcliParticipant)); const copilotcliCustomizationProvider = this._register(copilotcliAgentInstaService.createInstance(CopilotCLICustomizationProvider)); - this._register(vscode.chat.registerChatSessionCustomizationProvider(this.copilotcliSessionType, CopilotCLICustomizationProvider.metadata, copilotcliCustomizationProvider)); + this._register(vscode.chat.registerChatSessionCustomizationProvider(this.copilotcliSessionType, CopilotCLICustomizationProvider.metadata, copilotcliCustomizationProvider, copilotcliCustomizationProvider)); this._register(registerCLIChatCommands(copilotCLISessionService, copilotCLIWorktreeManagerService, gitService, copilotCLIWorkspaceFolderSessions, copilotcliChatSessionContentProvider, folderRepositoryManager, copilotCLIFolderMruService, nativeEnvService, fileSystemService, sessionTracker, terminalIntegration, logService)); // #endregion @@ -327,7 +327,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib const copilotcliParticipant = vscode.chat.createChatParticipant(this.copilotcliSessionType, copilotcliChatSessionParticipant.createHandler()); this._register(vscode.chat.registerChatSessionContentProvider(this.copilotcliSessionType, copilotcliChatSessionContentProvider, copilotcliParticipant)); const copilotcliCustomizationProvider = this._register(copilotcliAgentInstaService.createInstance(CopilotCLICustomizationProvider)); - this._register(vscode.chat.registerChatSessionCustomizationProvider(this.copilotcliSessionType, CopilotCLICustomizationProvider.metadata, copilotcliCustomizationProvider)); + this._register(vscode.chat.registerChatSessionCustomizationProvider(this.copilotcliSessionType, CopilotCLICustomizationProvider.metadata, copilotcliCustomizationProvider, copilotcliCustomizationProvider)); this._register(registerCLIChatCommandsV1(copilotcliSessionItemProvider, copilotCLISessionService, copilotCLIWorktreeManagerService, gitService, gitExtensionService, toolsService, copilotCLIWorkspaceFolderSessions, copilotcliChatSessionContentProvider, folderRepositoryManager, copilotFolderMruService, nativeEnvService, fileSystemService, logService)); // #endregion diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts index 22ab4ed10b7ef..ee6b4c85feb92 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts @@ -118,7 +118,6 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch type: vscode.ChatSessionCustomizationType.Agent, name: agent.name, description: agent.description, - enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, // No groupKey — vscode infers Built-in from non-file: scheme }); } @@ -132,7 +131,6 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch uri: agent.uri, type: vscode.ChatSessionCustomizationType.Agent, name, - enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, }); } } @@ -251,7 +249,6 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch description: hook.command, enabled: !allHooksDisabled, // Individual hooks can't be toggled — only disableAllHooks - enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, }); } } @@ -353,7 +350,7 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch // --- Enablement --- - async resolveCustomizationEnablement(uri: vscode.Uri, type: vscode.ChatSessionCustomizationType, enabled: boolean, _token: vscode.CancellationToken): Promise { + async handleCustomizationEnablement(uri: vscode.Uri, type: vscode.ChatSessionCustomizationType, enabled: boolean, _scope: vscode.ChatSessionCustomizationEnablementScope, _token: vscode.CancellationToken): Promise { const settings = { ...await this._readSettings() }; if (type.id === vscode.ChatSessionCustomizationType.Skill.id) { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts index eff1baae1dcb6..69ca4e730b14c 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts @@ -95,7 +95,6 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod type: vscode.ChatSessionCustomizationType.Agent, name: agent.displayName || agent.name, description: agent.description, - enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, })); } @@ -137,7 +136,6 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod type: vscode.ChatSessionCustomizationType.Instructions, name: basename(uri), groupKey: 'agent-instructions', - enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, }); } @@ -170,7 +168,6 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod groupKey: 'context-instructions', badge, badgeTooltip, - enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, }); } else { items.push({ @@ -179,7 +176,6 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod name, description, groupKey: 'on-demand-instructions', - enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, }); } } @@ -303,7 +299,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod return undefined; } - async resolveCustomizationEnablement(uri: vscode.Uri, type: vscode.ChatSessionCustomizationType, enabled: boolean, _token: vscode.CancellationToken): Promise { + async handleCustomizationEnablement(uri: vscode.Uri, type: vscode.ChatSessionCustomizationType, enabled: boolean, _scope: vscode.ChatSessionCustomizationEnablementScope, _token: vscode.CancellationToken): Promise { const resolved = this._resolveDisablementKey(uri, type); if (!resolved) { this.logService.warn(`[CopilotCLICustomizationProvider] Per-item enablement not supported for type: ${type.id}`); diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index a5a56d139c5dc..1300576a40101 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -773,7 +773,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA hiddenSections = Object.values(typeToSection).filter(section => !supportedSections.has(section)); } - // Build an enablement provider when the extension implements setCustomizationEnabled. + // Build an enablement provider when the extension implements handleCustomizationEnablement. // This delegates disable/enable to the extension instead of VS Code's StorageService. let enablementProvider: ICustomizationEnablementProvider | undefined; if (hasSetEnabled) { @@ -789,8 +789,8 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA // No separate disabled set needed. return new ResourceSet(); }, - setEnabled: (uri: URI, type: PromptsType, enabled: boolean): void => { - proxy.$setCustomizationEnabled(providerHandle, uri.toJSON(), type, enabled); + setEnabled: (uri: URI, type: PromptsType, enabled: boolean, scope: 'global' | 'workspace'): void => { + proxy.$setCustomizationEnabled(providerHandle, uri.toJSON(), type, enabled, scope); }, }; } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 1e8f804e38c15..6ef6537fe65d8 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1765,9 +1765,9 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.onDidChangePlugins(listener, thisArgs, disposables); }, - registerChatSessionCustomizationProvider(chatSessionType: string, metadata: vscode.ChatSessionCustomizationProviderMetadata, provider: vscode.ChatSessionCustomizationProvider): vscode.Disposable { + registerChatSessionCustomizationProvider(chatSessionType: string, metadata: vscode.ChatSessionCustomizationProviderMetadata, provider: vscode.ChatSessionCustomizationProvider, enablementHandler?: vscode.ChatSessionCustomizationEnablementHandler): vscode.Disposable { checkProposedApiEnabled(extension, 'chatSessionCustomizationProvider'); - return extHostChatAgents2.registerChatSessionCustomizationProvider(extension, chatSessionType, metadata, provider); + return extHostChatAgents2.registerChatSessionCustomizationProvider(extension, chatSessionType, metadata, provider, enablementHandler); }, }; diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index fb5f627963ddc..fc54599fcd9ac 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1672,7 +1672,7 @@ export interface ExtHostChatAgentsShape2 { $detectChatParticipant(handle: number, request: Dto, context: { history: IChatAgentHistoryEntryDto[] }, options: { participants: IChatParticipantMetadata[]; location: ChatAgentLocation }, token: CancellationToken): Promise; $providePromptFiles(handle: number, type: PromptsType, context: IPromptFileContext, token: CancellationToken): Promise[] | undefined>; $provideChatSessionCustomizations(handle: number, token: CancellationToken): Promise; - $setCustomizationEnabled(handle: number, uri: UriComponents, type: string, enabled: boolean): Promise; + $setCustomizationEnabled(handle: number, uri: UriComponents, type: string, enabled: boolean, scope: 'global' | 'workspace'): Promise; $setRequestTools(requestId: string, tools: UserSelectedTools): void; $setYieldRequested(requestId: string, value: boolean): void; $acceptActiveChatSession(sessionResource: UriComponents | undefined): void; diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 5894a0cddedeb..db73225b15255 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -490,7 +490,11 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS 1: 'global', // ChatSessionCustomizationEnablementScope.Global 2: 'workspace', // ChatSessionCustomizationEnablementScope.Workspace }; - private readonly _customizationProviders = new Map(); + private static readonly _enablementScopeReverseMap: Record = { + 'global': 1, // ChatSessionCustomizationEnablementScope.Global + 'workspace': 2, // ChatSessionCustomizationEnablementScope.Workspace + }; + private readonly _customizationProviders = new Map(); private readonly _sessionDisposables: DisposableResourceMap = this._register(new DisposableResourceMap()); private readonly _completionDisposables: DisposableMap = this._register(new DisposableMap()); @@ -788,9 +792,9 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS return resources; } - registerChatSessionCustomizationProvider(extension: IExtensionDescription, chatSessionType: string, metadata: vscode.ChatSessionCustomizationProviderMetadata, provider: vscode.ChatSessionCustomizationProvider): vscode.Disposable { + registerChatSessionCustomizationProvider(extension: IExtensionDescription, chatSessionType: string, metadata: vscode.ChatSessionCustomizationProviderMetadata, provider: vscode.ChatSessionCustomizationProvider, enablementHandler?: vscode.ChatSessionCustomizationEnablementHandler): vscode.Disposable { const handle = ExtHostChatAgents2._customizationProviderIdPool++; - this._customizationProviders.set(handle, { extension, provider }); + this._customizationProviders.set(handle, { extension, provider, enablementHandler }); const metadataDto: IChatSessionCustomizationProviderMetadataDto = { label: metadata.label, @@ -798,7 +802,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS supportedTypes: metadata.supportedTypes?.map(t => typeConvert.ChatSessionCustomizationType.from(t)), }; - this._proxy.$registerChatSessionCustomizationProvider(handle, chatSessionType, metadataDto, extension.identifier, typeof provider.resolveCustomizationEnablement === 'function'); + this._proxy.$registerChatSessionCustomizationProvider(handle, chatSessionType, metadataDto, extension.identifier, !!enablementHandler); const disposables = new DisposableStore(); @@ -847,15 +851,16 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS } } - async $setCustomizationEnabled(handle: number, uri: UriComponents, type: string, enabled: boolean): Promise { + async $setCustomizationEnabled(handle: number, uri: UriComponents, type: string, enabled: boolean, scope: 'global' | 'workspace'): Promise { const providerData = this._customizationProviders.get(handle); - if (!providerData?.provider.resolveCustomizationEnablement) { + if (!providerData?.enablementHandler) { return; } - await providerData.provider.resolveCustomizationEnablement( + await providerData.enablementHandler.handleCustomizationEnablement( URI.revive(uri), typeConvert.ChatSessionCustomizationType.to(type), enabled, + ExtHostChatAgents2._enablementScopeReverseMap[scope] as vscode.ChatSessionCustomizationEnablementScope, CancellationToken.None, ); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index b75a21bd48405..6865f5cd68704 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -67,7 +67,7 @@ export interface IAICustomizationListItem { readonly status?: 'loading' | 'loaded' | 'degraded' | 'error'; /** Human-readable status detail (e.g. error message or warning). */ readonly statusMessage?: string; - /** Per-item enablement scope override. When absent, falls back to the harness-level enablementScope. */ + /** Per-item enablement scope override. Defaults to 'none' (not disableable) when absent. */ readonly enablementScope?: 'none' | 'global' | 'workspace'; /** Optional reference to the parent plugin item. When present, enable/disable actions target the plugin and the item's own enablementScope is ignored. */ readonly plugin?: ICustomizationItem; @@ -318,6 +318,7 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour readonly onDidChange: Event; constructor( + private readonly harnessId: string, private readonly itemProvider: ICustomizationItemProvider | undefined, private readonly syncProvider: ICustomizationSyncProvider | undefined, private readonly enablementProvider: ICustomizationEnablementProvider | undefined, @@ -387,9 +388,9 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour // Also add back disabled items that the provider didn't include at all. const disabledUris = this.enablementProvider ? this.enablementProvider.getDisabledPromptFiles(promptType) - : this.promptsService.getDisabledPromptFiles(promptType); + : this.promptsService.getDisabledPromptFiles(promptType, this.harnessId); const extensionDisabledUris = this.enablementProvider - ? this.promptsService.getDisabledPromptFiles(promptType) + ? this.promptsService.getDisabledPromptFiles(promptType, this.harnessId) : undefined; if (disabledUris.size > 0 || (extensionDisabledUris && extensionDisabledUris.size > 0)) { const existingUris = new ResourceSet(normalized.map(i => i.uri)); @@ -521,7 +522,7 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour const appended: IAICustomizationListItem[] = []; const disabledPromptFiles = this.enablementProvider ? this.enablementProvider.getDisabledPromptFiles(PromptsType.skill) - : this.promptsService.getDisabledPromptFiles(PromptsType.skill); + : this.promptsService.getDisabledPromptFiles(PromptsType.skill, this.harnessId); for (const p of builtinPaths) { const name = p.name ?? basename(p.uri); if (overriddenNames.has(name)) { @@ -568,7 +569,7 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour const disabledUris = this.enablementProvider ? this.enablementProvider.getDisabledPromptFiles(promptType) - : this.promptsService.getDisabledPromptFiles(promptType); + : this.promptsService.getDisabledPromptFiles(promptType, this.harnessId); const providerItems: ICustomizationItem[] = files .filter(file => file.storage === PromptsStorage.local || file.storage === PromptsStorage.user) .map(file => ({ diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index f65fd2ed0554c..fae85b8e3692c 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -440,7 +440,7 @@ class AICustomizationItemRenderer implements IListRenderer uri.toJSON()); + const suffix = namespace ? `.${namespace}.${type}` : `.${type}`; const key = scope === StorageScope.WORKSPACE - ? this.disabledPromptsWorkspaceStorageKeyPrefix + type - : this.disabledPromptsStorageKeyPrefix + type; + ? this.disabledPromptsWorkspaceStorageKeyPrefix.slice(0, -1) + suffix + : this.disabledPromptsStorageKeyPrefix.slice(0, -1) + suffix; this.storageService.store(key, JSON.stringify(disabled), scope, StorageTarget.USER); this._refreshCachesForType(type); } @@ -1132,11 +1134,12 @@ export class PromptsService extends Disposable implements IPromptsService { /** * Returns the profile-level disabled URIs for a given type (excludes workspace overrides). */ - public getDisabledPromptFilesForScope(type: PromptsType, scope: StorageScope): ResourceSet { + public getDisabledPromptFilesForScope(type: PromptsType, scope: StorageScope, namespace?: string): ResourceSet { const result = new ResourceSet(); + const suffix = namespace ? `.${namespace}.${type}` : `.${type}`; const key = scope === StorageScope.WORKSPACE - ? this.disabledPromptsWorkspaceStorageKeyPrefix + type - : this.disabledPromptsStorageKeyPrefix + type; + ? this.disabledPromptsWorkspaceStorageKeyPrefix.slice(0, -1) + suffix + : this.disabledPromptsStorageKeyPrefix.slice(0, -1) + suffix; this._readDisabledFromStorage(key, scope, result); return result; } diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts index 02cb8fe3c982d..f7c7941ab2273 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts @@ -59,9 +59,9 @@ export class MockPromptsService implements IPromptsService { listNestedAgentMDs(token: CancellationToken): Promise { throw new Error('Not implemented'); } listAgentInstructions(token: CancellationToken): Promise { throw new Error('Not implemented'); } getAgentFileURIFromModeFile(oldURI: URI): URI | undefined { throw new Error('Not implemented'); } - getDisabledPromptFiles(type: PromptsType): ResourceSet { throw new Error('Method not implemented.'); } - setDisabledPromptFiles(type: PromptsType, uris: ResourceSet): void { throw new Error('Method not implemented.'); } - getDisabledPromptFilesForScope(type: PromptsType, scope: import('../../../../../../../platform/storage/common/storage.js').StorageScope): ResourceSet { throw new Error('Method not implemented.'); } + getDisabledPromptFiles(type: PromptsType, namespace?: string): ResourceSet { throw new Error('Method not implemented.'); } + setDisabledPromptFiles(type: PromptsType, uris: ResourceSet, scope?: import('../../../../../../../platform/storage/common/storage.js').StorageScope, namespace?: string): void { throw new Error('Method not implemented.'); } + getDisabledPromptFilesForScope(type: PromptsType, scope: import('../../../../../../../platform/storage/common/storage.js').StorageScope, namespace?: string): ResourceSet { throw new Error('Method not implemented.'); } registerPromptFileProvider(extension: IExtensionDescription, type: PromptsType, provider: { providePromptFiles: (context: IPromptFileContext, token: CancellationToken) => Promise }): IDisposable { throw new Error('Method not implemented.'); } findAgentSkills(token: CancellationToken): Promise { throw new Error('Method not implemented.'); } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts index 1e7db4068992f..06aa3e1d6ff87 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts @@ -131,10 +131,9 @@ declare module 'vscode' { /** * Controls which disablement actions are available for this item. * - * When omitted, defaults to {@link ChatSessionCustomizationEnablementScope.Global} if - * {@link ChatSessionCustomizationProvider.resolveCustomizationEnablement} is implemented, - * or {@link ChatSessionCustomizationEnablementScope.Workspace} otherwise (built-in - * storage supports both scopes). + * Defaults to {@link ChatSessionCustomizationEnablementScope.None} when + * omitted — the item cannot be toggled unless the provider explicitly + * sets a scope. * * Ignored when {@link plugin} is set — plugin items always use global-scope * enablement targeting the plugin itself. @@ -148,7 +147,7 @@ declare module 'vscode' { * instead of the individual item, and the item's own * {@link enablementScope} is ignored. The plugin item is itself a * {@link ChatSessionCustomizationItem} so it can be passed directly to - * {@link ChatSessionCustomizationProvider.resolveCustomizationEnablement}. + * {@link ChatSessionCustomizationEnablementHandler.handleCustomizationEnablement}. */ readonly plugin?: ChatSessionCustomizationItem; } @@ -187,20 +186,36 @@ declare module 'vscode' { * @returns The list of customization items, or `undefined` if unavailable. */ provideChatSessionCustomizations(token: CancellationToken): ProviderResult; + } + /** + * A handler that persists enable/disable actions for chat customizations. + * + * When registered alongside a {@link ChatSessionCustomizationProvider}, + * the management UI delegates enable/disable actions to this handler. + * Without a handler, items reported by the provider cannot be toggled. + * + * @see {@link chat.registerChatSessionCustomizationProvider} + */ + export interface ChatSessionCustomizationEnablementHandler { /** * Called when the user enables or disables a customization in the - * management UI. The provider should persist the change and fire - * {@link onDidChange} so the UI re-queries the updated state. - * - * When this method is not implemented, the management UI falls back - * to built-in storage for disablement state. + * management UI. The handler should persist the change and fire + * {@link ChatSessionCustomizationProvider.onDidChange} so the UI + * re-queries the updated state. * * @param uri The URI of the customization item. * @param type The type of the customization. * @param enabled Whether the customization should be enabled (`true`) or disabled (`false`). - */ - resolveCustomizationEnablement?(uri: Uri, type: ChatSessionCustomizationType, enabled: boolean, token: CancellationToken): Thenable; + * @param scope The scope at which enablement should be changed (e.g. {@link ChatSessionCustomizationEnablementScope.Global} or {@link ChatSessionCustomizationEnablementScope.Workspace}). + */ + handleCustomizationEnablement( + uri: Uri, + type: ChatSessionCustomizationType, + enabled: boolean, + scope: ChatSessionCustomizationEnablementScope, + token: CancellationToken + ): Thenable; } // #endregion @@ -217,9 +232,15 @@ declare module 'vscode' { * @param chatSessionType The session type this provider is for (e.g. `'cli'`, `'claude'`). * @param metadata Metadata describing the provider's capabilities and UI presentation. * @param provider The customization provider implementation. + * @param enablementHandler Optional handler for enable/disable actions. When omitted, items reported by the provider cannot be toggled. * @returns A disposable that unregisters the provider when disposed. */ - export function registerChatSessionCustomizationProvider(chatSessionType: string, metadata: ChatSessionCustomizationProviderMetadata, provider: ChatSessionCustomizationProvider): Disposable; + export function registerChatSessionCustomizationProvider( + chatSessionType: string, + metadata: ChatSessionCustomizationProviderMetadata, + provider: ChatSessionCustomizationProvider, + enablementHandler?: ChatSessionCustomizationEnablementHandler + ): Disposable; } // #endregion From d7f153f5de02b9478807152beb1f3008d6feba32 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Thu, 23 Apr 2026 12:58:55 -0700 Subject: [PATCH 10/36] update --- .../api/browser/mainThreadChatAgents2.ts | 10 +++++----- .../workbench/api/common/extHost.protocol.ts | 2 +- .../api/common/extHostChatAgents2.ts | 4 ++-- .../aiCustomizationItemSource.ts | 6 +++--- .../aiCustomizationManagement.contribution.ts | 19 ++++++++----------- 5 files changed, 19 insertions(+), 22 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 2a8cbc7d65985..a02b09e8a0510 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -712,7 +712,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA } } - async $registerChatSessionCustomizationProvider(handle: number, chatSessionType: string, metadata: IChatSessionCustomizationProviderMetadataDto, extensionId: ExtensionIdentifier, hasSetEnabled: boolean): Promise { + async $registerChatSessionCustomizationProvider(handle: number, chatSessionType: string, metadata: IChatSessionCustomizationProviderMetadataDto, extensionId: ExtensionIdentifier, hasEnablementHandler: boolean): Promise { // In the sessions window, only the Copilot CLI harness is accepted via the // extension API. Other harnesses (e.g. Claude) are not shown in sessions. // AHP remote servers register directly via registerExternalHarness. @@ -737,7 +737,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA if (!items) { return undefined; } - const convertItem = (item: IChatSessionCustomizationItemDto): ICustomizationItem => ({ + const convertItem = (item: IChatSessionCustomizationItemDto, depth = 0): ICustomizationItem => ({ uri: URI.revive(item.uri), type: item.type, name: item.name, @@ -747,9 +747,9 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA badgeTooltip: item.badgeTooltip, enabled: item.enabled, enablementScope: item.enablementScope, - plugin: item.plugin ? convertItem(item.plugin) : undefined, + plugin: item.plugin && depth < 1 ? convertItem(item.plugin, depth + 1) : undefined, }); - return items.map(convertItem); + return items.map(i => convertItem(i)); }, }; @@ -779,7 +779,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA // Build an enablement provider when the extension implements handleCustomizationEnablement. // This delegates disable/enable to the extension instead of VS Code's StorageService. let enablementProvider: ICustomizationEnablementProvider | undefined; - if (hasSetEnabled) { + if (hasEnablementHandler) { const proxy = this._proxy; const providerHandle = handle; enablementProvider = { diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 1baf94b02a257..136cdfc19b2d8 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1588,7 +1588,7 @@ export interface MainThreadChatAgentsShape2 extends IChatAgentProgressShape, IDi $registerPromptFileProvider(handle: number, type: string, extension: ExtensionIdentifier): void; $unregisterPromptFileProvider(handle: number): void; $onDidChangePromptFiles(handle: number): void; - $registerChatSessionCustomizationProvider(handle: number, chatSessionType: string, metadata: IChatSessionCustomizationProviderMetadataDto, extension: ExtensionIdentifier, hasSetEnabled: boolean): void; + $registerChatSessionCustomizationProvider(handle: number, chatSessionType: string, metadata: IChatSessionCustomizationProviderMetadataDto, extension: ExtensionIdentifier, hasEnablementHandler: boolean): void; $unregisterChatSessionCustomizationProvider(handle: number): void; $onDidChangeCustomizations(handle: number): void; $registerAgentCompletionsProvider(handle: number, id: string, triggerCharacters: string[]): void; diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index db73225b15255..11e4afc709f07 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -832,7 +832,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS return undefined; } - const convertItem = (item: vscode.ChatSessionCustomizationItem): IChatSessionCustomizationItemDto => ({ + const convertItem = (item: vscode.ChatSessionCustomizationItem, depth = 0): IChatSessionCustomizationItemDto => ({ uri: item.uri, type: typeConvert.ChatSessionCustomizationType.from(item.type), name: item.name, @@ -842,7 +842,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS badgeTooltip: item.badgeTooltip, enabled: item.enabled, enablementScope: item.enablementScope !== undefined ? ExtHostChatAgents2._enablementScopeMap[item.enablementScope] : undefined, - plugin: item.plugin ? convertItem(item.plugin) : undefined, + plugin: item.plugin && depth < 1 ? convertItem(item.plugin, depth + 1) : undefined, }); return items.map(convertItem); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index 6865f5cd68704..44c602c3c1cb5 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -388,7 +388,7 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour // Also add back disabled items that the provider didn't include at all. const disabledUris = this.enablementProvider ? this.enablementProvider.getDisabledPromptFiles(promptType) - : this.promptsService.getDisabledPromptFiles(promptType, this.harnessId); + : this.promptsService.getDisabledPromptFiles(promptType); const extensionDisabledUris = this.enablementProvider ? this.promptsService.getDisabledPromptFiles(promptType, this.harnessId) : undefined; @@ -522,7 +522,7 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour const appended: IAICustomizationListItem[] = []; const disabledPromptFiles = this.enablementProvider ? this.enablementProvider.getDisabledPromptFiles(PromptsType.skill) - : this.promptsService.getDisabledPromptFiles(PromptsType.skill, this.harnessId); + : this.promptsService.getDisabledPromptFiles(PromptsType.skill); for (const p of builtinPaths) { const name = p.name ?? basename(p.uri); if (overriddenNames.has(name)) { @@ -569,7 +569,7 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour const disabledUris = this.enablementProvider ? this.enablementProvider.getDisabledPromptFiles(promptType) - : this.promptsService.getDisabledPromptFiles(promptType, this.harnessId); + : this.promptsService.getDisabledPromptFiles(promptType); const providerItems: ICustomizationItem[] = files .filter(file => file.storage === PromptsStorage.local || file.storage === PromptsStorage.user) .map(file => ({ diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index 5399572fdae17..e3c6ed026762f 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -792,10 +792,9 @@ registerAction2(class extends Action2 { // Workspace-local items are disabled at workspace scope; user-level items at profile scope const storage = extractStorage(context); const scope = storage === PromptsStorage.local ? StorageScope.WORKSPACE : StorageScope.PROFILE; - const namespace = harnessService.getActiveDescriptor().id; - const disabled = promptsService.getDisabledPromptFilesForScope(promptType, scope, namespace); + const disabled = promptsService.getDisabledPromptFilesForScope(promptType, scope); disabled.add(uri); - promptsService.setDisabledPromptFiles(promptType, disabled, scope, namespace); + promptsService.setDisabledPromptFiles(promptType, disabled, scope); } } }); @@ -822,10 +821,9 @@ registerAction2(class extends Action2 { if (enablementProvider) { enablementProvider.setEnabled(uri, promptType, false, 'workspace'); } else { - const namespace = harnessService.getActiveDescriptor().id; - const disabled = promptsService.getDisabledPromptFilesForScope(promptType, StorageScope.WORKSPACE, namespace); + const disabled = promptsService.getDisabledPromptFilesForScope(promptType, StorageScope.WORKSPACE); disabled.add(uri); - promptsService.setDisabledPromptFiles(promptType, disabled, StorageScope.WORKSPACE, namespace); + promptsService.setDisabledPromptFiles(promptType, disabled, StorageScope.WORKSPACE); } } }); @@ -864,18 +862,17 @@ registerAction2(class extends Action2 { enablementProvider.setEnabled(uri, promptType, true, 'global'); } else { // Remove from both scopes to fully re-enable - const namespace = harnessService.getActiveDescriptor().id; - const profileDisabled = promptsService.getDisabledPromptFilesForScope(promptType, StorageScope.PROFILE, namespace); + const profileDisabled = promptsService.getDisabledPromptFilesForScope(promptType, StorageScope.PROFILE); const wasInProfile = profileDisabled.delete(uri); - const workspaceDisabled = promptsService.getDisabledPromptFilesForScope(promptType, StorageScope.WORKSPACE, namespace); + const workspaceDisabled = promptsService.getDisabledPromptFilesForScope(promptType, StorageScope.WORKSPACE); const wasInWorkspace = workspaceDisabled.delete(uri); if (wasInProfile) { - promptsService.setDisabledPromptFiles(promptType, profileDisabled, StorageScope.PROFILE, namespace); + promptsService.setDisabledPromptFiles(promptType, profileDisabled, StorageScope.PROFILE); } if (wasInWorkspace) { - promptsService.setDisabledPromptFiles(promptType, workspaceDisabled, StorageScope.WORKSPACE, namespace); + promptsService.setDisabledPromptFiles(promptType, workspaceDisabled, StorageScope.WORKSPACE); } } } From 321667df5386de772fd4fe46ff648f80a1379ee5 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Thu, 23 Apr 2026 13:11:05 -0700 Subject: [PATCH 11/36] add support for local enablement --- .../aiCustomizationListWidget.ts | 12 +++-- .../customizationHarnessService.ts | 53 +++++++++++++++++-- .../common/customizationHarnessService.ts | 3 +- 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 16904ded7112c..2ab4dd9bb2b1c 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -439,8 +439,10 @@ class AICustomizationItemRenderer implements IListRenderer filter, + enablementProvider, }; } From 4d68ecfb2b77b517fc127ef44ae31d47a9577d53 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Thu, 23 Apr 2026 15:24:54 -0700 Subject: [PATCH 12/36] PR --- .../copilotcli/node/copilotCli.ts | 5 +- .../claudeCustomizationProvider.ts | 11 +- .../copilotCLICustomizationProvider.ts | 45 +- .../workbench/api/common/extHost.protocol.ts | 2 +- .../api/common/extHostChatAgents2.ts | 3 +- src/vs/workbench/api/common/extHostTypes.ts | 1 + .../aiCustomizationItemSource.ts | 137 +++- .../aiCustomizationListWidget.ts | 339 +++++++- .../aiCustomizationListWidgetUtils.ts | 14 + .../aiCustomizationManagement.contribution.ts | 60 +- .../aiCustomizationManagementEditor.ts | 23 + .../media/aiCustomizationManagement.css | 132 +++ ...promptsServiceCustomizationItemProvider.ts | 4 +- .../common/customizationHarnessService.ts | 2 +- .../aiCustomizationDisablement.test.ts | 755 ++++++++++++++++++ ...osed.chatSessionCustomizationProvider.d.ts | 13 + 16 files changed, 1448 insertions(+), 98 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts index 6898c09c7b387..7c1db3518eba0 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts @@ -278,6 +278,8 @@ export interface CLIAgentInfo { readonly agent: Readonly; /** File URI for prompt-file agents, synthetic `copilotcli:` URI for SDK-only agents. */ readonly sourceUri: URI; + /** Where the agent was loaded from (e.g. 'local', 'extension'). Undefined for SDK-only agents. */ + readonly source?: string; } export interface ICopilotCLIAgents { @@ -375,7 +377,7 @@ export class CopilotCLIAgents extends Disposable implements ICopilotCLIAgents { }); } - return this._agentsPromise.then(infos => infos.map(i => ({ agent: this.cloneAgent(i.agent), sourceUri: i.sourceUri }))); + return this._agentsPromise.then(infos => infos.map(i => ({ agent: this.cloneAgent(i.agent), sourceUri: i.sourceUri, source: i.source }))); } async getAgentsImpl(): Promise { @@ -442,6 +444,7 @@ export class CopilotCLIAgents extends Disposable implements ICopilotCLIAgents { ...(model ? { model } : {}), }, sourceUri: customAgent.uri, + source: customAgent.source, }; } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts index ee6b4c85feb92..a3f85544580d1 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts @@ -12,6 +12,7 @@ import { Emitter } from '../../../util/vs/base/common/event'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; import { basename, dirname } from '../../../util/vs/base/common/resources'; import { URI } from '../../../util/vs/base/common/uri'; +import type { Settings as ClaudeSettings } from '@anthropic-ai/claude-agent-sdk'; import { IClaudeRuntimeDataService } from '../claude/common/claudeRuntimeDataService'; import { ClaudeSessionUri } from '../claude/common/claudeSessionUri'; import { IPromptsService } from '../../../platform/promptFiles/common/promptsService'; @@ -59,16 +60,6 @@ interface HooksSettings { readonly hooks?: Partial>; } -/** - * Shape of `.claude/settings.json` fields relevant to enablement. - */ -interface ClaudeSettings { - readonly skillOverrides?: Record; - readonly claudeMdExcludes?: string[]; - readonly disableAllHooks?: boolean; - [key: string]: unknown; -} - export class ClaudeCustomizationProvider extends Disposable implements vscode.ChatSessionCustomizationProvider { private readonly _onDidChange = this._register(new Emitter()); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts index 69ca4e730b14c..c928a9579d3a0 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts @@ -17,8 +17,12 @@ import { Emitter } from '../../../util/vs/base/common/event'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; import { basename, dirname } from '../../../util/vs/base/common/resources'; import { URI } from '../../../util/vs/base/common/uri'; +import type { loadFeatureFlagsFromConfig } from '@github/copilot/sdk'; import { ICopilotCLIAgents, isEnabledForCopilotCLI } from '../copilotcli/node/copilotCli'; +// TODO: We should use an actual exported type from the Copilot SDK. This is currently not available. +type CopilotUserSettings = Parameters[0]; + export class CopilotCLICustomizationProvider extends Disposable implements vscode.ChatSessionCustomizationProvider { private readonly _onDidChange = this._register(new Emitter()); @@ -90,11 +94,14 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod */ private async getAgentItems(_token: vscode.CancellationToken): Promise { const agentInfos = await this.copilotCLIAgents.getAgents(); - return agentInfos.map(({ agent, sourceUri }) => ({ + return agentInfos.map(({ agent, sourceUri, source }) => ({ uri: sourceUri, type: vscode.ChatSessionCustomizationType.Agent, name: agent.displayName || agent.name, description: agent.description, + // Extension-sourced items are managed by the application (VS Code) + // rather than the CLI's settings.json. + ...(source === 'extension' ? { enablementScope: vscode.ChatSessionCustomizationEnablementScope.ManagedByApplication } : {}), })); } @@ -150,9 +157,15 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod } const name = instruction.name; - const pattern = instruction.pattern; const description = instruction.description; + // Extension-sourced items are managed by the application (VS Code) + // rather than the CLI's settings.json. + const enablementScope = instruction.source === 'extension' + ? vscode.ChatSessionCustomizationEnablementScope.ManagedByApplication + : undefined; + + const pattern = instruction.pattern; if (pattern !== undefined) { const badge = pattern === '**' ? l10n.t('always added') @@ -168,6 +181,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod groupKey: 'context-instructions', badge, badgeTooltip, + enablementScope, }); } else { items.push({ @@ -176,6 +190,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod name, description, groupKey: 'on-demand-instructions', + enablementScope, }); } } @@ -194,6 +209,16 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod return (await this.promptsService.getSkills(token)).filter(isEnabledForCopilotCLI).map(s => { const name = s.name; const folderName = basename(dirname(s.uri)) || basename(s.uri); + // Extension-sourced items are managed by the application (VS Code) + // rather than the CLI's settings.json. + if (s.source === 'extension') { + return { + uri: s.uri, + type: vscode.ChatSessionCustomizationType.Skill, + name, + enablementScope: vscode.ChatSessionCustomizationEnablementScope.ManagedByApplication, + }; + } return { uri: s.uri, type: vscode.ChatSessionCustomizationType.Skill, @@ -208,17 +233,12 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod * Each item is a hook configuration file (JSON). */ private async getHookItems(token: vscode.CancellationToken): Promise { - const settings = await this._readSettings(); - const disabledHooks = new Set( - Array.isArray(settings.disabledHooks) ? settings.disabledHooks as string[] : [], - ); return (await this.promptsService.getHooks(token)).filter(isEnabledForCopilotCLI).map(h => { const name = basename(h.uri).replace(/\.json$/i, ''); return { uri: h.uri, type: vscode.ChatSessionCustomizationType.Hook, name, - enabled: !disabledHooks.has(name), }; }); } @@ -257,7 +277,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod * Reads the user-level `~/.copilot/settings.json` as a JSON object. * Returns an empty object if the file doesn't exist or can't be parsed. */ - private async _readSettings(): Promise> { + private async _readSettings(): Promise { try { const bytes = await this.fileSystemService.readFile(this._settingsUri); const parsed = JSON.parse(new TextDecoder().decode(bytes)); @@ -270,7 +290,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod /** * Writes the user-level `~/.copilot/settings.json`. */ - private async _writeSettings(settings: Record): Promise { + private async _writeSettings(settings: CopilotUserSettings): Promise { const content = new TextEncoder().encode(JSON.stringify(settings, null, 4)); await this.fileSystemService.writeFile(this._settingsUri, content); } @@ -285,11 +305,6 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod const name = basename(dirname(URI.from(uri))) || basename(URI.from(uri)); return { settingsKey: 'disabledSkills', name }; } - if (type.id === vscode.ChatSessionCustomizationType.Hook?.id) { - // Hooks use the filename (without .json) as the key in disabledHooks - const name = basename(URI.from(uri)).replace(/\.json$/i, ''); - return { settingsKey: 'disabledHooks', name }; - } if (type.id === vscode.ChatSessionCustomizationType.Plugins?.id) { // Plugins use enabledPlugins map (Record) const name = basename(URI.from(uri)); @@ -321,7 +336,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod } settings[settingsKey] = Object.keys(map).length > 0 ? map : undefined; } else { - // disabledSkills / disabledHooks are string arrays + // disabledSkills are string arrays const currentList = Array.isArray(settings[settingsKey]) ? settings[settingsKey] as string[] : []; if (enabled) { settings[settingsKey] = currentList.filter(s => s !== name); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 136cdfc19b2d8..9e5976290bdd4 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1741,7 +1741,7 @@ export interface IChatSessionCustomizationItemDto { readonly badge?: string; readonly badgeTooltip?: string; readonly enabled?: boolean; - readonly enablementScope?: 'none' | 'global' | 'workspace'; + readonly enablementScope?: 'none' | 'global' | 'workspace' | 'application'; readonly plugin?: IChatSessionCustomizationItemDto; } export interface IChatParticipantMetadata { diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 11e4afc709f07..9d01456293528 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -485,10 +485,11 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS private readonly _promptFileProviders = new Map(); private static _customizationProviderIdPool = 0; - private static readonly _enablementScopeMap: Record = { + private static readonly _enablementScopeMap: Record = { 0: 'none', // ChatSessionCustomizationEnablementScope.None 1: 'global', // ChatSessionCustomizationEnablementScope.Global 2: 'workspace', // ChatSessionCustomizationEnablementScope.Workspace + 3: 'application', // ChatSessionCustomizationEnablementScope.ManagedByApplication }; private static readonly _enablementScopeReverseMap: Record = { 'global': 1, // ChatSessionCustomizationEnablementScope.Global diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 5d1c1ecf1e4f2..0394c4b9fca85 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3594,6 +3594,7 @@ export enum ChatSessionCustomizationEnablementScope { None = 0, Global = 1, Workspace = 2, + ManagedByApplication = 3, } export enum ChatDebugLogLevel { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index 44c602c3c1cb5..7683f45e7afe0 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -24,7 +24,7 @@ import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWo import { ICustomizationSyncProvider, ICustomizationItem, ICustomizationItemProvider, ICustomizationEnablementProvider } from '../../common/customizationHarnessService.js'; import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { parseHooksFromFile } from '../../common/promptSyntax/hookCompatibility.js'; -import { formatHookCommandLabel } from '../../common/promptSyntax/hookSchema.js'; +import { formatHookCommandLabel, getEffectiveCommandFieldKey } from '../../common/promptSyntax/hookSchema.js'; import { HOOK_METADATA } from '../../common/promptSyntax/hookTypes.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; @@ -68,17 +68,35 @@ export interface IAICustomizationListItem { /** Human-readable status detail (e.g. error message or warning). */ readonly statusMessage?: string; /** Per-item enablement scope override. Defaults to 'none' (not disableable) when absent. */ - readonly enablementScope?: 'none' | 'global' | 'workspace'; + readonly enablementScope?: 'none' | 'global' | 'workspace' | 'application'; /** Optional reference to the parent plugin item. When present, enable/disable actions target the plugin and the item's own enablementScope is ignored. */ readonly plugin?: ICustomizationItem; /** When true, this item can be selected for syncing to a remote harness. */ readonly syncable?: boolean; /** When true, this syncable item is currently selected for syncing. */ readonly synced?: boolean; + /** For hook file items: parsed child hooks within the file. */ + readonly hookChildren?: readonly IHookChildInfo[]; nameMatches?: IMatch[]; descriptionMatches?: IMatch[]; } +/** + * Describes a single hook within a hook file, used for nested rendering. + */ +export interface IHookChildInfo { + /** Lifecycle event label (e.g. "Session Start"). */ + readonly label: string; + /** Shell command description. */ + readonly description: string; + /** Original hook type ID as it appears in the JSON file. */ + readonly originalHookTypeId: string; + /** Index within the hook type array in the file. */ + readonly index: number; + /** JSON field key for the effective command (e.g. 'command', 'bash'). */ + readonly commandFieldKey: string; +} + /** * Browser-internal item source consumed by the list widget. * @@ -122,8 +140,9 @@ export function getFriendlyName(filename: string): string { } /** - * Expands hook file items into individual hook entries by parsing hook - * definitions from the file content. Falls back to the original item + * Expands hook file items into file-level entries with parsed child hooks. + * Each file becomes a single item with `hookChildren` describing the + * individual hooks within. Falls back to the original item (no children) * when parsing fails. */ export async function expandHookFileItems( @@ -131,8 +150,8 @@ export async function expandHookFileItems( workspaceService: IAICustomizationWorkspaceService, fileService: IFileService, pathService: IPathService, -): Promise { - const items: ICustomizationItem[] = []; +): Promise<(ICustomizationItem & { hookChildren?: IHookChildInfo[] })[]> { + const items: (ICustomizationItem & { hookChildren?: IHookChildInfo[] })[] = []; const activeRoot = workspaceService.getActiveProjectRoot(); const userHomeUri = await pathService.userHome(); const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; @@ -146,23 +165,28 @@ export async function expandHookFileItems( if (hooks.size > 0) { parsedHooks = true; + const children: IHookChildInfo[] = []; for (const [hookType, entry] of hooks) { const hookMeta = HOOK_METADATA[hookType]; for (let i = 0; i < entry.hooks.length; i++) { const hook = entry.hooks[i]; const cmdLabel = formatHookCommandLabel(hook, OS); const truncatedCmd = cmdLabel.length > 60 ? cmdLabel.substring(0, 57) + '...' : cmdLabel; - items.push({ - uri: item.uri, - type: PromptsType.hook, - name: hookMeta?.label ?? entry.originalId, + children.push({ + label: hookMeta?.label ?? entry.originalId, description: truncatedCmd || localize('hookUnset', "(unset)"), - enabled: item.enabled, - groupKey: item.groupKey, - storage: item.storage, + originalHookTypeId: entry.originalId, + index: i, + commandFieldKey: getEffectiveCommandFieldKey(hook, OS), }); } } + items.push({ + ...item, + type: PromptsType.hook, + name: item.name, + hookChildren: children, + }); } } catch { // Parse failed — fall through to show raw file. @@ -192,7 +216,7 @@ export class AICustomizationItemNormalizer { private readonly productService: IProductService, ) { } - normalizeItems(items: readonly ICustomizationItem[], promptType: PromptsType): IAICustomizationListItem[] { + normalizeItems(items: readonly (ICustomizationItem & { hookChildren?: IHookChildInfo[] })[], promptType: PromptsType): IAICustomizationListItem[] { const uriUseCounts = new ResourceMap(); return items .filter(item => item.type === promptType) @@ -200,7 +224,7 @@ export class AICustomizationItemNormalizer { .sort((a, b) => a.name.localeCompare(b.name)); } - normalizeItem(item: ICustomizationItem, promptType: PromptsType, uriUseCounts = new ResourceMap()): IAICustomizationListItem { + normalizeItem(item: ICustomizationItem & { hookChildren?: IHookChildInfo[] }, promptType: PromptsType, uriUseCounts = new ResourceMap()): IAICustomizationListItem { const { storage, groupKey, isBuiltin, extensionLabel } = this.resolveSource(item); const seenCount = uriUseCounts.get(item.uri) ?? 0; uriUseCounts.set(item.uri, seenCount + 1); @@ -230,6 +254,7 @@ export class AICustomizationItemNormalizer { statusMessage: item.statusMessage, enablementScope: item.enablementScope, plugin: item.plugin, + hookChildren: item.hookChildren, }; } @@ -327,6 +352,13 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour private readonly fileService: IFileService, private readonly pathService: IPathService, private readonly itemNormalizer: AICustomizationItemNormalizer, + /** + * When true, the harness has an externally-provided item provider + * (from an extension). promptsService disabled lookups will be + * namespaced by harness ID. When false (VS Code harness), the item + * provider was auto-assigned and no namespace is used. + */ + private readonly hasNativeItemProvider: boolean = !!itemProvider, ) { const promptServiceEvents = Event.any( this.promptsService.onDidChangeCustomAgents, @@ -361,7 +393,7 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour return []; } - let providerItems: readonly ICustomizationItem[]; + let providerItems: readonly (ICustomizationItem & { hookChildren?: IHookChildInfo[] })[]; if (promptType === PromptsType.hook) { const hookItems = allItems.filter(item => item.type === PromptsType.hook); // Plugin hooks are pre-expanded by plugin manifests — skip re-expansion. @@ -379,34 +411,56 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour providerItems = await this.addSkillDescriptionFallbacks(providerItems); } + // Track which items the provider explicitly marked with `storage` BEFORE + // normalization (which always infers a storage value from the URI). Items + // with explicit storage are "VS Code items" whose disablement is managed + // by promptsService; items without are "API items" whose disablement is + // managed by the enablementProvider. + const providerExplicitStorageUris = new ResourceSet( + providerItems.filter(i => i.storage !== undefined).map(i => i.uri), + ); + const normalized = this.itemNormalizer.normalizeItems(providerItems, promptType); - // Overlay disabled state from the harness's enablement provider, or - // fall back to promptsService (StorageService) when no provider is set. - // Extension items are always stored in promptsService regardless of the - // harness's enablement provider, so merge both sources when a provider is active. - // Also add back disabled items that the provider didn't include at all. - const disabledUris = this.enablementProvider + // Overlay disabled state from two sources: + // - API items (no explicit `storage` from provider): checked against + // enablementProvider's disabled set. The extension fully owns disablement. + // - VS Code items (explicit `storage` from provider): checked against + // promptsService. On external harnesses (with itemProvider) the namespace + // isolates per-harness state. On the VS Code harness (no itemProvider) no + // namespace is needed. + const apiDisabledUris = this.enablementProvider ? this.enablementProvider.getDisabledPromptFiles(promptType) - : this.promptsService.getDisabledPromptFiles(promptType); - const extensionDisabledUris = this.enablementProvider - ? this.promptsService.getDisabledPromptFiles(promptType, this.harnessId) : undefined; - if (disabledUris.size > 0 || (extensionDisabledUris && extensionDisabledUris.size > 0)) { + const vscodeDisabledUris = this.hasNativeItemProvider + ? this.promptsService.getDisabledPromptFiles(promptType, this.harnessId) + : this.promptsService.getDisabledPromptFiles(promptType); + const hasDisabled = (apiDisabledUris && apiDisabledUris.size > 0) || vscodeDisabledUris.size > 0; + if (hasDisabled) { const existingUris = new ResourceSet(normalized.map(i => i.uri)); for (let i = 0; i < normalized.length; i++) { if (normalized[i].disabled) { continue; } - const isExtension = normalized[i].storage === PromptsStorage.extension; - const isDisabled = isExtension - ? (extensionDisabledUris?.has(normalized[i].uri) ?? false) - : disabledUris.has(normalized[i].uri); + // Items are VS Code-managed when either: + // - The provider explicitly set `storage` on them, or + // - The provider set `enablementScope: 'application'`, signaling + // that VS Code should own disablement. + const isVSCodeItem = providerExplicitStorageUris.has(normalized[i].uri) + || normalized[i].enablementScope === 'application'; + const isDisabled = isVSCodeItem + ? vscodeDisabledUris.has(normalized[i].uri) + : (apiDisabledUris?.has(normalized[i].uri) ?? false); if (isDisabled) { normalized[i] = { ...normalized[i], disabled: true }; } } - const missing = await this.resolveMissingDisabledItems(promptType, disabledUris, existingUris); + // Ghost entries from all disabled sets for items not in the provider's results. + const allDisabledUris = new ResourceSet([ + ...(apiDisabledUris ?? []), + ...vscodeDisabledUris, + ]); + const missing = await this.resolveMissingDisabledItems(promptType, allDisabledUris, existingUris, vscodeDisabledUris); normalized.push(...missing); } @@ -421,11 +475,16 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour * External providers (e.g. extension-contributed harness providers) may * not include disabled items at all. This method queries discovery info * and file listings to reconstruct them so they appear in the UI. + * + * Ghost items need an `enablementScope` so the Enable button is visible. + * Items found in `vscodeDisabledUris` are VS Code-managed and get + * `enablementScope: 'workspace'`. API-managed items get `enablementScope: 'global'`. */ private async resolveMissingDisabledItems( promptType: PromptsType, disabledUris: ResourceSet, existingUris: ResourceSet, + vscodeDisabledUris: ResourceSet, ): Promise { const missingItems: ICustomizationItem[] = []; const resolvedUris = new ResourceSet(); @@ -445,6 +504,7 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour description: file.promptPath.description, storage: file.promptPath.storage, enabled: false, + enablementScope: vscodeDisabledUris.has(file.promptPath.uri) ? 'workspace' : 'global', }); } } @@ -461,6 +521,7 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour type: promptType, name, enabled: false, + enablementScope: vscodeDisabledUris.has(uri) ? 'workspace' : 'global', }); } } @@ -520,8 +581,10 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour uriUseCounts.set(item.uri, (uriUseCounts.get(item.uri) ?? 0) + 1); } const appended: IAICustomizationListItem[] = []; - const disabledPromptFiles = this.enablementProvider - ? this.enablementProvider.getDisabledPromptFiles(PromptsType.skill) + // Built-in skills are VS Code items — use namespaced promptsService disabled set + // only for external harnesses (with native item provider). VS Code harness uses no namespace. + const disabledPromptFiles = this.hasNativeItemProvider + ? this.promptsService.getDisabledPromptFiles(PromptsType.skill, this.harnessId) : this.promptsService.getDisabledPromptFiles(PromptsType.skill); for (const p of builtinPaths) { const name = p.name ?? basename(p.uri); @@ -540,6 +603,7 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour enabled: !disabledPromptFiles.has(p.uri), badge: uiTooltip ? uiIntegrationBadge : undefined, badgeTooltip: uiTooltip, + enablementScope: 'workspace', }; appended.push(this.itemNormalizer.normalizeItem(builtinItem, promptType, uriUseCounts)); } @@ -567,8 +631,10 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour return []; } - const disabledUris = this.enablementProvider - ? this.enablementProvider.getDisabledPromptFiles(promptType) + // Local syncable items are VS Code items — use namespaced promptsService disabled set + // only for external harnesses (with native item provider). VS Code harness uses no namespace. + const disabledUris = this.hasNativeItemProvider + ? this.promptsService.getDisabledPromptFiles(promptType, this.harnessId) : this.promptsService.getDisabledPromptFiles(promptType); const providerItems: ICustomizationItem[] = files .filter(file => file.storage === PromptsStorage.local || file.storage === PromptsStorage.user) @@ -578,6 +644,7 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour name: getFriendlyName(basename(file.uri)), groupKey: 'sync-local', enabled: !disabledUris.has(file.uri), + enablementScope: 'workspace' as const, })); return this.itemNormalizer.normalizeItems(providerItems, promptType) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 2ab4dd9bb2b1c..6c2334eb8c678 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -45,12 +45,12 @@ import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/ho import { IFileService } from '../../../../../platform/files/common/files.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; import { generateCustomizationDebugReport } from './aiCustomizationDebugPanel.js'; -import { getCustomizationSecondaryText } from './aiCustomizationListWidgetUtils.js'; +import { computeItemEnablementKeys, getCustomizationSecondaryText } from './aiCustomizationListWidgetUtils.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; -import { AICustomizationItemNormalizer, IAICustomizationItemSource, IAICustomizationListItem, ProviderCustomizationItemSource } from './aiCustomizationItemSource.js'; +import { AICustomizationItemNormalizer, IAICustomizationItemSource, IAICustomizationListItem, IHookChildInfo, ProviderCustomizationItemSource } from './aiCustomizationItemSource.js'; import { PromptsServiceCustomizationItemProvider } from './promptsServiceCustomizationItemProvider.js'; export { truncateToFirstLine } from './aiCustomizationListWidgetUtils.js'; @@ -100,7 +100,30 @@ interface IFileItemEntry { readonly item: IAICustomizationListItem; } -type IListEntry = IGroupHeaderEntry | IFileItemEntry; +/** + * Represents a collapsible hook file entry in the list. + * The top-level entry shows the file name and supports disable/enable. + */ +interface IHookFileEntry { + readonly type: 'hook-file'; + readonly item: IAICustomizationListItem; + collapsed: boolean; +} + +/** + * Represents an individual hook within a hook file. + * Clicking jumps to the specific hook in the editor. + */ +interface IHookChildEntry { + readonly type: 'hook-child'; + readonly parentItem: IAICustomizationListItem; + readonly child: IHookChildInfo; +} + +type IListEntry = IGroupHeaderEntry | IFileItemEntry | IHookFileEntry | IHookChildEntry; + +const HOOK_FILE_HEADER_HEIGHT = 36; +const HOOK_CHILD_HEIGHT = 28; /** * Delegate for the AI Customization list. @@ -110,11 +133,22 @@ class AICustomizationListDelegate implements IListVirtualDelegate { if (element.type === 'group-header') { return element.isFirst ? GROUP_HEADER_HEIGHT : GROUP_HEADER_HEIGHT_WITH_SEPARATOR; } + if (element.type === 'hook-file') { + return HOOK_FILE_HEADER_HEIGHT; + } + if (element.type === 'hook-child') { + return HOOK_CHILD_HEIGHT; + } return ITEM_HEIGHT; } getTemplateId(element: IListEntry): string { - return element.type === 'group-header' ? 'groupHeader' : 'aiCustomizationItem'; + switch (element.type) { + case 'group-header': return 'groupHeader'; + case 'hook-file': return 'hookFileHeader'; + case 'hook-child': return 'hookChild'; + default: return 'aiCustomizationItem'; + } } } @@ -205,6 +239,206 @@ class GroupHeaderRenderer implements IListRenderer { + readonly templateId = 'hookFileHeader'; + + constructor( + @IHoverService private readonly hoverService: IHoverService, + @ILabelService private readonly labelService: ILabelService, + @IMenuService private readonly menuService: IMenuService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, + ) { } + + renderTemplate(container: HTMLElement): IHookFileHeaderTemplateData { + const disposables = new DisposableStore(); + const elementDisposables = new DisposableStore(); + container.classList.add('ai-customization-hook-file-header'); + + const leftSection = DOM.append(container, $('.hook-file-left')); + const chevron = DOM.append(leftSection, $('.hook-file-chevron')); + const icon = DOM.append(leftSection, $('.hook-file-icon')); + const label = DOM.append(leftSection, $('.hook-file-label')); + const count = DOM.append(leftSection, $('.hook-file-count')); + + const actionsContainer = DOM.append(container, $('.item-right')); + const actionBar = disposables.add(new ActionBar(actionsContainer, { + actionViewItemProvider: createActionViewItem.bind(undefined, this.instantiationService), + })); + + return { container, chevron, icon, label, count, actionsContainer, actionBar, disposables, elementDisposables }; + } + + renderElement(element: IHookFileEntry, _index: number, templateData: IHookFileHeaderTemplateData): void { + templateData.elementDisposables.clear(); + const item = element.item; + + // Chevron + templateData.chevron.className = 'hook-file-chevron'; + templateData.chevron.classList.add(...ThemeIcon.asClassNameArray(element.collapsed ? Codicon.chevronRight : Codicon.chevronDown)); + + // Icon + templateData.icon.className = 'hook-file-icon'; + templateData.icon.classList.add(...ThemeIcon.asClassNameArray(item.disabled ? Codicon.eyeClosed : hookIcon)); + + // Label (filename) + templateData.label.textContent = item.displayName ?? formatDisplayName(item.name); + + // Count + const childCount = item.hookChildren?.length ?? 0; + templateData.count.textContent = childCount > 0 ? `${childCount}` : ''; + + // Disabled styling + templateData.container.classList.toggle('disabled', item.disabled); + + // Hover tooltip: file path + const isWorkspaceItem = item.storage === PromptsStorage.local; + templateData.elementDisposables.add(this.hoverService.setupDelayedHover(templateData.container, () => ({ + content: this.labelService.getUriLabel(item.uri, { relative: isWorkspaceItem }), + appearance: { + compact: true, + skipFadeInAnimation: true, + } + }))); + + // Action bar (enable/disable, delete, etc.) + const context: Record = { + uri: item.uri.toString(), + name: item.name, + promptType: item.promptType, + storage: item.storage, + pluginUri: item.pluginUri?.toString(), + itemId: item.id, + plugin: item.plugin ? { uri: item.plugin.uri.toString(), type: item.plugin.type, name: item.plugin.name } : undefined, + providerEnablementScope: item.enablementScope, + }; + + const descriptor = this.harnessService.getActiveDescriptor(); + const { enablementScope: itemEnablementScope, isDisableable } = computeItemEnablementKeys(item); + const overlayPairs: [string, string | boolean][] = [ + [AI_CUSTOMIZATION_ITEM_TYPE_KEY, item.promptType], + [AI_CUSTOMIZATION_ITEM_URI_KEY, item.uri.toString()], + [AI_CUSTOMIZATION_ITEM_DISABLED_KEY, item.disabled], + [AI_CUSTOMIZATION_SUPPORTS_TROUBLESHOOT_KEY, descriptor.supportsTroubleshoot ?? false], + [AI_CUSTOMIZATION_ENABLEMENT_SCOPE_KEY, itemEnablementScope], + [AI_CUSTOMIZATION_ITEM_DISABLEABLE_KEY, isDisableable], + [AI_CUSTOMIZATION_ITEM_HAS_PLUGIN_KEY, !!item.plugin], + ]; + if (item.storage) { + overlayPairs.push([AI_CUSTOMIZATION_ITEM_STORAGE_KEY, item.storage]); + } + if (item.pluginUri) { + overlayPairs.push([AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY, item.pluginUri.toString()]); + } + const overlay = this.contextKeyService.createOverlay(overlayPairs); + + const menu = templateData.elementDisposables.add( + this.menuService.createMenu(AICustomizationManagementItemMenuId, overlay) + ); + + const updateActions = () => { + const actions = menu.getActions({ arg: context, shouldForwardArgs: true }); + const { primary } = getContextMenuActions(actions, 'inline'); + templateData.actionBar.clear(); + templateData.actionBar.push(primary, { icon: true, label: false }); + }; + updateActions(); + templateData.elementDisposables.add(menu.onDidChange(updateActions)); + templateData.actionBar.context = context; + } + + disposeTemplate(templateData: IHookFileHeaderTemplateData): void { + templateData.elementDisposables.dispose(); + templateData.disposables.dispose(); + } +} + +interface IHookChildTemplateData { + readonly container: HTMLElement; + readonly icon: HTMLElement; + readonly label: HTMLElement; + readonly description: HTMLElement; + readonly disposables: DisposableStore; + readonly elementDisposables: DisposableStore; +} + +/** + * Renderer for individual hook entries within a hook file. + * Shows the lifecycle type label and command description. + */ +class HookChildRenderer implements IListRenderer { + readonly templateId = 'hookChild'; + + constructor( + @IHoverService private readonly hoverService: IHoverService, + ) { } + + renderTemplate(container: HTMLElement): IHookChildTemplateData { + const disposables = new DisposableStore(); + const elementDisposables = new DisposableStore(); + container.classList.add('ai-customization-hook-child'); + + const icon = DOM.append(container, $('.hook-child-icon')); + const label = DOM.append(container, $('.hook-child-label')); + const description = DOM.append(container, $('.hook-child-description')); + + return { container, icon, label, description, disposables, elementDisposables }; + } + + renderElement(element: IHookChildEntry, _index: number, templateData: IHookChildTemplateData): void { + templateData.elementDisposables.clear(); + const child = element.child; + + // Icon + templateData.icon.className = 'hook-child-icon'; + templateData.icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.zap)); + + // Label (lifecycle type) + templateData.label.textContent = child.label; + + // Description (command) + templateData.description.textContent = child.description; + + // Disabled styling inherited from parent + templateData.container.classList.toggle('disabled', element.parentItem.disabled); + + // Hover tooltip with full command + templateData.elementDisposables.add(this.hoverService.setupDelayedHover(templateData.container, () => ({ + content: `${child.label}: ${child.description}`, + appearance: { + compact: true, + skipFadeInAnimation: true, + } + }))); + } + + disposeTemplate(templateData: IHookChildTemplateData): void { + templateData.elementDisposables.dispose(); + templateData.disposables.dispose(); + } +} + +// #endregion + /** * Returns the icon for a given prompt type. */ @@ -435,15 +669,12 @@ class AICustomizationItemRenderer implements IListRenderer(); + private readonly collapsedHookFiles = new Set(); private readonly dropdownActionDisposables = this._register(new DisposableStore()); private _loadItemsSeq = 0; @@ -551,6 +783,9 @@ export class AICustomizationListWidget extends Disposable { private readonly _onDidSelectItem = this._register(new Emitter()); readonly onDidSelectItem: Event = this._onDidSelectItem.event; + private readonly _onDidSelectHookChild = this._register(new Emitter<{ item: IAICustomizationListItem; child: IHookChildInfo }>()); + readonly onDidSelectHookChild: Event<{ item: IAICustomizationListItem; child: IHookChildInfo }> = this._onDidSelectHookChild.event; + private readonly _onDidChangeItemCount = this._register(new Emitter()); readonly onDidChangeItemCount: Event = this._onDidChangeItemCount.event; @@ -701,16 +936,32 @@ export class AICustomizationListWidget extends Disposable { [ new GroupHeaderRenderer(this.hoverService), this.instantiationService.createInstance(AICustomizationItemRenderer), + this.instantiationService.createInstance(HookFileHeaderRenderer), + this.instantiationService.createInstance(HookChildRenderer), ], { identityProvider: { - getId: (entry: IListEntry) => entry.type === 'group-header' ? entry.id : entry.item.id, + getId: (entry: IListEntry) => { + switch (entry.type) { + case 'group-header': return entry.id; + case 'hook-file': return `hook-file:${entry.item.id}`; + case 'hook-child': return `hook-child:${entry.parentItem.id}#${entry.child.originalHookTypeId}[${entry.child.index}]`; + default: return entry.item.id; + } + }, }, accessibilityProvider: { getAriaLabel: (entry: IListEntry) => { if (entry.type === 'group-header') { return localize('groupAriaLabel', "{0}, {1} items, {2}", entry.label, entry.count, entry.collapsed ? localize('collapsed', "collapsed") : localize('expanded', "expanded")); } + if (entry.type === 'hook-file') { + const childCount = entry.item.hookChildren?.length ?? 0; + return localize('hookFileAriaLabel', "{0}, {1} hooks, {2}", entry.item.name, childCount, entry.collapsed ? localize('collapsed', "collapsed") : localize('expanded', "expanded")); + } + if (entry.type === 'hook-child') { + return localize('hookChildAriaLabel', "{0}, {1}", entry.child.label, entry.child.description); + } const nameAndDesc = entry.item.description ? localize('itemAriaLabel', "{0}, {1}", entry.item.name, entry.item.description) : entry.item.name; @@ -721,18 +972,29 @@ export class AICustomizationListWidget extends Disposable { getWidgetAriaLabel: () => localize('listAriaLabel', "Agent Customizations"), }, keyboardNavigationLabelProvider: { - getKeyboardNavigationLabel: (entry: IListEntry) => entry.type === 'group-header' ? entry.label : entry.item.name, + getKeyboardNavigationLabel: (entry: IListEntry) => { + switch (entry.type) { + case 'group-header': return entry.label; + case 'hook-file': return entry.item.name; + case 'hook-child': return entry.child.label; + default: return entry.item.name; + } + }, }, multipleSelectionSupport: false, openOnSingleClick: true, } )); - // Handle item selection (single click opens item, group header toggles) + // Handle item selection (single click opens item, group/hook-file header toggles) this._register(this.list.onDidOpen(e => { if (e.element) { if (e.element.type === 'group-header') { this.toggleGroup(e.element); + } else if (e.element.type === 'hook-file') { + this.toggleHookFile(e.element); + } else if (e.element.type === 'hook-child') { + this._onDidSelectHookChild.fire({ item: e.element.parentItem, child: e.element.child }); } else { this._onDidSelectItem.fire(e.element.item); } @@ -767,7 +1029,7 @@ export class AICustomizationListWidget extends Disposable { * Handles context menu for list items. */ private onContextMenu(e: IListContextMenuEvent): void { - if (!e.element || e.element.type !== 'file-item') { + if (!e.element || (e.element.type !== 'file-item' && e.element.type !== 'hook-file')) { return; } @@ -782,15 +1044,12 @@ export class AICustomizationListWidget extends Disposable { pluginUri: item.pluginUri?.toString(), itemId: item.id, plugin: item.plugin ? { uri: item.plugin.uri.toString(), type: item.plugin.type, name: item.plugin.name } : undefined, + providerEnablementScope: item.enablementScope, }; // Create scoped context key service with item-specific keys for when-clause filtering const descriptor = this.harnessService.getActiveDescriptor(); - // When plugin is set, enablement targets the plugin — always use global scope. - // When the harness has an enablement provider, items are disableable even without per-item scope. - const harnessDefault = descriptor.enablementProvider ? 'workspace' : 'none'; - const itemEnablementScope = item.plugin ? 'global' : (item.enablementScope ?? harnessDefault); - const isDisableable = !!item.plugin || itemEnablementScope !== 'none'; + const { enablementScope: itemEnablementScope, isDisableable } = computeItemEnablementKeys(item); const overlayPairs: [string, string | boolean][] = [ [AI_CUSTOMIZATION_ITEM_TYPE_KEY, item.promptType], [AI_CUSTOMIZATION_ITEM_URI_KEY, item.uri.toString()], @@ -1208,6 +1467,7 @@ export class AICustomizationListWidget extends Disposable { this.fileService, this.pathService, this.itemNormalizer, + !!descriptor.itemProvider, ); this.cachedItemSource = { descriptorId: descriptor.id, source }; return source; @@ -1234,7 +1494,12 @@ export class AICustomizationListWidget extends Disposable { const filenameMatches = matchesContiguousSubString(query, item.filename); const badgeMatches = item.badge ? matchesContiguousSubString(query, item.badge) : null; - if (nameMatches || descriptionMatches || filenameMatches || badgeMatches) { + // Also match against hook children labels/descriptions + const hookChildMatch = item.hookChildren?.some(child => + matchesContiguousSubString(query, child.label) || matchesContiguousSubString(query, child.description) + ) ?? false; + + if (nameMatches || descriptionMatches || filenameMatches || badgeMatches || hookChildMatch) { matched.push({ ...item, nameMatches: nameMatches || undefined, @@ -1249,6 +1514,7 @@ export class AICustomizationListWidget extends Disposable { /** * Builds grouped display entries from items assigned to groups. * Empty groups are omitted. Collapsed groups show only their header. + * For hooks, items with hookChildren are rendered as collapsible file headers. */ private buildGroupedEntries(groups: { groupKey: string; label: string; icon: ThemeIcon; description: string; items: IAICustomizationListItem[] }[]): void { // Sort items within each group @@ -1256,6 +1522,8 @@ export class AICustomizationListWidget extends Disposable { group.items.sort((a, b) => a.name.localeCompare(b.name)); } + const isHookSection = this.currentSection === AICustomizationManagementSection.Hooks; + this.displayEntries = []; let isFirstGroup = true; for (const group of groups) { @@ -1265,13 +1533,18 @@ export class AICustomizationListWidget extends Disposable { const collapsed = this.collapsedGroups.has(group.groupKey); + // For hooks, count the total number of individual hooks across all files + const itemCount = isHookSection + ? group.items.reduce((sum, item) => sum + (item.hookChildren?.length || 1), 0) + : group.items.length; + this.displayEntries.push({ type: 'group-header', id: `group-${group.groupKey}`, groupKey: group.groupKey, label: group.label, icon: group.icon, - count: group.items.length, + count: itemCount, isFirst: isFirstGroup, description: group.description, collapsed, @@ -1280,7 +1553,18 @@ export class AICustomizationListWidget extends Disposable { if (!collapsed) { for (const item of group.items) { - this.displayEntries.push({ type: 'file-item', item }); + if (isHookSection && item.hookChildren && item.hookChildren.length > 0) { + // Render as collapsible hook file with children + const hookFileCollapsed = this.collapsedHookFiles.has(item.id); + this.displayEntries.push({ type: 'hook-file', item, collapsed: hookFileCollapsed }); + if (!hookFileCollapsed) { + for (const child of item.hookChildren) { + this.displayEntries.push({ type: 'hook-child', parentItem: item, child }); + } + } + } else { + this.displayEntries.push({ type: 'file-item', item }); + } } } } @@ -1400,6 +1684,19 @@ export class AICustomizationListWidget extends Disposable { this.filterItems(); } + /** + * Toggles the collapsed state of a hook file. + */ + private toggleHookFile(entry: IHookFileEntry): void { + const key = entry.item.id; + if (this.collapsedHookFiles.has(key)) { + this.collapsedHookFiles.delete(key); + } else { + this.collapsedHookFiles.add(key); + } + this.filterItems(); + } + private updateEmptyState(): void { const hasItems = this.displayEntries.length > 0; if (!hasItems) { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidgetUtils.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidgetUtils.ts index 3872ef861ee35..2f4dc531216bd 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidgetUtils.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidgetUtils.ts @@ -47,3 +47,17 @@ export function extractExtensionIdFromPath(uriPath: string): string | undefined const versionMatch = folderName.match(/^(.+)-\d+\./); return versionMatch ? versionMatch[1] : undefined; } + +/** + * Computes enablement-related context keys for a customization list item. + * Used by the list widget renderer, context menu, and inline actions to + * determine Enable/Disable button visibility. + */ +export function computeItemEnablementKeys(item: { disabled: boolean; enablementScope?: string; plugin?: unknown }): { + readonly enablementScope: string; + readonly isDisableable: boolean; +} { + const enablementScope = item.plugin ? 'global' : (item.enablementScope ?? 'none'); + const isDisableable = !!item.plugin || enablementScope !== 'none'; + return { enablementScope, isDisableable }; +} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index e3c6ed026762f..290a84d525365 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -202,6 +202,22 @@ function extractPlugin(context: AICustomizationContext): { uri: URI; type: strin return { uri, type, name }; } +/** + * Returns true when the provider explicitly set an enablement scope on this item, + * meaning the provider's enablementHandler owns disablement for it. + * When false, VS Code should handle disablement via promptsService (with namespace). + * + * Items with `enablementScope: 'application'` are explicitly provider-reported but + * application-managed — the provider does NOT own their disablement. + */ +function hasProviderEnablement(context: AICustomizationContext): boolean { + if (URI.isUri(context) || typeof context === 'string') { + return false; + } + return context.providerEnablementScope !== undefined + && context.providerEnablementScope !== 'application'; +} + /** * Extracts the item name from context. */ @@ -785,16 +801,23 @@ registerAction2(class extends Action2 { return; } + // Provider-managed items: delegate to the harness's enablement provider. + // VS Code items on external harnesses: persist via promptsService with harness namespace. + // VS Code items on the VS Code harness: persist via enablementProvider (no namespace). const enablementProvider = harnessService.getActiveEnablementProvider(); - if (enablementProvider) { + const descriptor = harnessService.getActiveDescriptor(); + if (enablementProvider && hasProviderEnablement(context)) { + enablementProvider.setEnabled(uri, promptType, false, 'global'); + } else if (enablementProvider && !descriptor.itemProvider) { + // VS Code harness — delegate to its enablement provider (no namespace) enablementProvider.setEnabled(uri, promptType, false, 'global'); } else { - // Workspace-local items are disabled at workspace scope; user-level items at profile scope + const namespace = descriptor.id; const storage = extractStorage(context); const scope = storage === PromptsStorage.local ? StorageScope.WORKSPACE : StorageScope.PROFILE; - const disabled = promptsService.getDisabledPromptFilesForScope(promptType, scope); + const disabled = promptsService.getDisabledPromptFilesForScope(promptType, scope, namespace); disabled.add(uri); - promptsService.setDisabledPromptFiles(promptType, disabled, scope); + promptsService.setDisabledPromptFiles(promptType, disabled, scope, namespace); } } }); @@ -818,12 +841,17 @@ registerAction2(class extends Action2 { } const enablementProvider = harnessService.getActiveEnablementProvider(); - if (enablementProvider) { + const descriptor = harnessService.getActiveDescriptor(); + if (enablementProvider && hasProviderEnablement(context)) { + enablementProvider.setEnabled(uri, promptType, false, 'workspace'); + } else if (enablementProvider && !descriptor.itemProvider) { + // VS Code harness — delegate to its enablement provider (no namespace) enablementProvider.setEnabled(uri, promptType, false, 'workspace'); } else { - const disabled = promptsService.getDisabledPromptFilesForScope(promptType, StorageScope.WORKSPACE); + const namespace = descriptor.id; + const disabled = promptsService.getDisabledPromptFilesForScope(promptType, StorageScope.WORKSPACE, namespace); disabled.add(uri); - promptsService.setDisabledPromptFiles(promptType, disabled, StorageScope.WORKSPACE); + promptsService.setDisabledPromptFiles(promptType, disabled, StorageScope.WORKSPACE, namespace); } } }); @@ -857,22 +885,30 @@ registerAction2(class extends Action2 { return; } + // Provider-managed items: delegate to the harness's enablement provider. + // VS Code items on external harnesses: persist via promptsService with harness namespace. + // VS Code items on the VS Code harness: persist via enablementProvider (no namespace). const enablementProvider = harnessService.getActiveEnablementProvider(); - if (enablementProvider) { + const descriptor = harnessService.getActiveDescriptor(); + if (enablementProvider && hasProviderEnablement(context)) { + enablementProvider.setEnabled(uri, promptType, true, 'global'); + } else if (enablementProvider && !descriptor.itemProvider) { + // VS Code harness — delegate to its enablement provider (no namespace) enablementProvider.setEnabled(uri, promptType, true, 'global'); } else { + const namespace = descriptor.id; // Remove from both scopes to fully re-enable - const profileDisabled = promptsService.getDisabledPromptFilesForScope(promptType, StorageScope.PROFILE); + const profileDisabled = promptsService.getDisabledPromptFilesForScope(promptType, StorageScope.PROFILE, namespace); const wasInProfile = profileDisabled.delete(uri); - const workspaceDisabled = promptsService.getDisabledPromptFilesForScope(promptType, StorageScope.WORKSPACE); + const workspaceDisabled = promptsService.getDisabledPromptFilesForScope(promptType, StorageScope.WORKSPACE, namespace); const wasInWorkspace = workspaceDisabled.delete(uri); if (wasInProfile) { - promptsService.setDisabledPromptFiles(promptType, profileDisabled, StorageScope.PROFILE); + promptsService.setDisabledPromptFiles(promptType, profileDisabled, StorageScope.PROFILE, namespace); } if (wasInWorkspace) { - promptsService.setDisabledPromptFiles(promptType, workspaceDisabled, StorageScope.WORKSPACE); + promptsService.setDisabledPromptFiles(promptType, workspaceDisabled, StorageScope.WORKSPACE, namespace); } } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 2fa8262672846..d9c211f4df53e 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -59,6 +59,7 @@ import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/servi import { AGENT_MD_FILENAME } from '../../common/promptSyntax/config/promptFileLocations.js'; import { INewPromptOptions, NEW_PROMPT_COMMAND_ID, NEW_INSTRUCTIONS_COMMAND_ID, NEW_AGENT_COMMAND_ID, NEW_SKILL_COMMAND_ID } from '../promptSyntax/newPromptFileActions.js'; import { showConfigureHooksQuickPick } from '../promptSyntax/hookActions.js'; +import { findHookCommandSelection } from '../promptSyntax/hookUtils.js'; import { resolveWorkspaceTargetDirectory, resolveUserTargetDirectory } from './customizationCreatorService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; @@ -827,6 +828,28 @@ export class AICustomizationManagementEditor extends EditorPane { this.showEmbeddedEditor(item.uri, item.name, item.promptType, storage ?? BUILTIN_STORAGE, isWorkspaceFile, isReadOnly); })); + // Handle hook child selection (jump to specific hook in file) + this.editorDisposables.add(this.listWidget.onDidSelectHookChild(async ({ item, child }) => { + const storage = item.storage; + const isWorkspaceFile = storage === PromptsStorage.local; + const isReadOnly = !storage || storage === PromptsStorage.extension || storage === PromptsStorage.plugin || storage === BUILTIN_STORAGE; + await this.showEmbeddedEditor(item.uri, item.name, item.promptType, storage ?? BUILTIN_STORAGE, isWorkspaceFile, isReadOnly); + // Jump to the specific hook command in the file + if (this.embeddedEditor?.hasModel()) { + const content = this.embeddedEditor.getModel()!.getValue(); + const selection = findHookCommandSelection(content, child.originalHookTypeId, child.index, child.commandFieldKey); + if (selection) { + this.embeddedEditor.setSelection({ + startLineNumber: selection.startLineNumber, + startColumn: selection.startColumn, + endLineNumber: selection.endLineNumber ?? selection.startLineNumber, + endColumn: selection.endColumn ?? selection.startColumn, + }); + this.embeddedEditor.revealLineInCenter(selection.startLineNumber); + } + } + })); + // Handle create actions - AI-guided creation this.editorDisposables.add(this.listWidget.onDidRequestCreate(promptType => { this.createNewItemWithAI(promptType); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css index 7ece5014978d7..25551073153b2 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -1171,3 +1171,135 @@ } /* Welcome page styles live in the welcome page variant stylesheets. */ + +/* Hook file header styling */ +.ai-customization-hook-file-header { + display: flex; + align-items: center; + padding: 4px 8px 4px 24px; + cursor: pointer; + border-radius: 4px; + user-select: none; +} + +.ai-customization-hook-file-header:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.ai-customization-hook-file-header.disabled .hook-file-label, +.ai-customization-hook-file-header.disabled .hook-file-icon { + opacity: 0.5; +} + +.ai-customization-hook-file-header .hook-file-left { + display: flex; + align-items: center; + flex: 1; + overflow: hidden; + gap: 6px; + min-width: 0; +} + +.ai-customization-hook-file-header .hook-file-chevron { + flex-shrink: 0; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + opacity: 0.7; +} + +.ai-customization-hook-file-header .hook-file-icon { + flex-shrink: 0; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + opacity: 0.8; +} + +.ai-customization-hook-file-header .hook-file-label { + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 18px; +} + +.ai-customization-hook-file-header .hook-file-count { + flex-shrink: 0; + font-size: 10px; + font-weight: 500; + color: var(--vscode-descriptionForeground); + min-width: 14px; + text-align: center; + line-height: 16px; +} + +.ai-customization-hook-file-header .item-right { + display: flex; + align-items: center; + flex-shrink: 0; + margin-left: 8px; + gap: 4px; + opacity: 0; + transition: opacity 0.1s ease; +} + +.ai-customization-hook-file-header:hover .item-right, +.ai-customization-hook-file-header:focus-within .item-right { + opacity: 1; +} + +/* Hook child item styling */ +.ai-customization-hook-child { + display: flex; + align-items: center; + padding: 2px 8px 2px 56px; + cursor: pointer; + border-radius: 4px; + gap: 6px; +} + +.ai-customization-hook-child:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.ai-customization-hook-child.disabled .hook-child-label, +.ai-customization-hook-child.disabled .hook-child-description, +.ai-customization-hook-child.disabled .hook-child-icon { + opacity: 0.5; +} + +.ai-customization-hook-child .hook-child-icon { + flex-shrink: 0; + width: 14px; + height: 14px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + opacity: 0.6; +} + +.ai-customization-hook-child .hook-child-label { + flex-shrink: 0; + font-size: 12px; + font-weight: 500; + line-height: 16px; + color: var(--vscode-foreground); +} + +.ai-customization-hook-child .hook-child-description { + flex: 1; + font-size: 11px; + color: var(--vscode-descriptionForeground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 14px; +} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts index ef60b9050647f..9958c25c66426 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts @@ -288,7 +288,9 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt items = items.filter(item => matchesInstructionFileFilter(item.uri.path, instrFilter)); } - return items; + // All items from PromptsServiceCustomizationItemProvider are VS Code items + // and should be disableable at workspace scope. + return items.map(item => ({ ...item, enablementScope: 'workspace' as const })); } } diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index 23ebc0eae59bb..0641f29db0740 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -165,7 +165,7 @@ export interface ICustomizationItem { /** Whether this customization is currently enabled. */ readonly enabled?: boolean; /** Per-item enablement scope override. Defaults to 'none' (not disableable) when absent. */ - readonly enablementScope?: 'none' | 'global' | 'workspace'; + readonly enablementScope?: 'none' | 'global' | 'workspace' | 'application'; /** When set, items with the same groupKey are displayed under a shared collapsible header. */ readonly groupKey?: string; /** When set, shows a small inline badge next to the item name (e.g. an applyTo glob pattern). */ diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts new file mode 100644 index 0000000000000..482d970173c48 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts @@ -0,0 +1,755 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Event } from '../../../../../../base/common/event.js'; +import { ResourceSet } from '../../../../../../base/common/map.js'; +import { observableValue } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { StorageScope } from '../../../../../../platform/storage/common/storage.js'; +import { ILabelService } from '../../../../../../platform/label/common/label.js'; +import { IProductService } from '../../../../../../platform/product/common/productService.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { ProviderCustomizationItemSource, AICustomizationItemNormalizer } from '../../../browser/aiCustomization/aiCustomizationItemSource.js'; +import { computeItemEnablementKeys } from '../../../browser/aiCustomization/aiCustomizationListWidgetUtils.js'; +import { IAICustomizationWorkspaceService } from '../../../common/aiCustomizationWorkspaceService.js'; +import { ICustomizationEnablementProvider, ICustomizationItem, ICustomizationItemProvider } from '../../../common/customizationHarnessService.js'; +import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; +import { IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; +import { IFileService } from '../../../../../../platform/files/common/files.js'; +import { IPathService } from '../../../../../services/path/common/pathService.js'; + +suite('aiCustomizationDisablement', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const agentUri = URI.file('/workspace/.github/agents/my-agent.md'); + const skillUri = URI.file('/workspace/.github/skills/my-skill/SKILL.md'); + const instructionUri = URI.file('/workspace/.github/instructions/my-rule.instructions.md'); + + function makeDisabledKey(type: PromptsType, namespace?: string): string { + return namespace ? `${type}:${namespace}` : type; + } + + function createMockPromptsService(): IPromptsService { + const disabledSets = new Map(); + return { + onDidChangeCustomAgents: Event.None, + onDidChangeSlashCommands: Event.None, + onDidChangeSkills: Event.None, + onDidChangeHooks: Event.None, + onDidChangeInstructions: Event.None, + listPromptFiles: async () => [], + listPromptFilesForStorage: async () => [] as { uri: URI; name?: string; description?: string }[], + getCustomAgents: async () => [], + findAgentSkills: async () => [], + getHooks: async () => undefined, + getInstructionFiles: async () => [], + getDisabledPromptFiles: (type: PromptsType, namespace?: string): ResourceSet => { + return new ResourceSet([...(disabledSets.get(makeDisabledKey(type, namespace)) ?? [])]); + }, + getDisabledPromptFilesForScope: (type: PromptsType, _scope: StorageScope, namespace?: string): ResourceSet => { + return new ResourceSet([...(disabledSets.get(makeDisabledKey(type, namespace)) ?? [])]); + }, + setDisabledPromptFiles: (type: PromptsType, uris: ResourceSet, _scope?: StorageScope, namespace?: string): void => { + disabledSets.set(makeDisabledKey(type, namespace), new ResourceSet([...uris])); + }, + } as unknown as IPromptsService; + } + + function createMockEnablementProvider(): ICustomizationEnablementProvider { + const disabledSets = new Map(); + return { + onDidChange: Event.None, + getDisabledPromptFiles(type: PromptsType): ResourceSet { + return new ResourceSet([...(disabledSets.get(type) ?? [])]); + }, + setEnabled(uri: URI, type: PromptsType, enabled: boolean, _scope: string): void { + let set = disabledSets.get(type); + if (!set) { + set = new ResourceSet(); + disabledSets.set(type, set); + } + if (enabled) { + set.delete(uri); + } else { + set.add(uri); + } + }, + }; + } + + function createMockItemProvider(items: ICustomizationItem[]): ICustomizationItemProvider { + return { + onDidChange: Event.None, + provideChatSessionCustomizations: async () => items, + }; + } + + function createItemNormalizer(): AICustomizationItemNormalizer { + return new AICustomizationItemNormalizer( + { getWorkspace: () => ({ folders: [{ uri: URI.file('/workspace') }] }) } as unknown as IWorkspaceContextService, + { getActiveProjectRoot: () => URI.file('/workspace'), getSkillUIIntegrations: () => new Map(), isSessionsWindow: false } as unknown as IAICustomizationWorkspaceService, + { getUriLabel: (uri: URI, opts?: { relative?: boolean }) => opts?.relative ? uri.path.replace('/workspace/', '') : uri.path } as unknown as ILabelService, + { plugins: observableValue('test', []) } as unknown as IAgentPluginService, + { quality: 'insider' } as unknown as IProductService, + ); + } + + function createItemSource(opts: { + harnessId: string; + itemProvider?: ICustomizationItemProvider; + enablementProvider?: ICustomizationEnablementProvider; + promptsService?: IPromptsService; + /** Whether the harness has a natively-provided item provider (external harness). Defaults to true when enablementProvider is set. */ + hasNativeItemProvider?: boolean; + }): ProviderCustomizationItemSource { + const ps = opts.promptsService ?? createMockPromptsService(); + const hasNative = opts.hasNativeItemProvider ?? !!opts.enablementProvider; + return new ProviderCustomizationItemSource( + opts.harnessId, + opts.itemProvider, + undefined, + opts.enablementProvider, + ps, + { getActiveProjectRoot: () => URI.file('/workspace'), getSkillUIIntegrations: () => new Map(), isSessionsWindow: false } as unknown as IAICustomizationWorkspaceService, + { stat: async () => { throw new Error('not found'); } } as unknown as IFileService, + { userHome: async () => URI.file('/home/user') } as unknown as IPathService, + createItemNormalizer(), + hasNative, + ); + } + + suite('item enablementScope assignment', () => { + + test('API items with explicit enablementScope preserve it', async () => { + const source = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([{ + uri: agentUri, type: PromptsType.agent, name: 'API Agent', enablementScope: 'global', + }]), + enablementProvider: createMockEnablementProvider(), + }); + + const result = await source.fetchItems(PromptsType.agent); + assert.strictEqual(result[0].enablementScope, 'global'); + }); + + test('API items without enablementScope remain non-disableable', async () => { + const source = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([{ + uri: agentUri, type: PromptsType.agent, name: 'API Agent', + }]), + enablementProvider: createMockEnablementProvider(), + }); + + const result = await source.fetchItems(PromptsType.agent); + assert.strictEqual(result[0].enablementScope, undefined); + }); + + test('VS Code items with enablementScope: workspace preserve it', async () => { + const source = createItemSource({ + harnessId: 'vscode', + itemProvider: createMockItemProvider([{ + uri: agentUri, type: PromptsType.agent, name: 'Local Agent', + storage: PromptsStorage.local, enablementScope: 'workspace', + }]), + }); + + const result = await source.fetchItems(PromptsType.agent); + assert.strictEqual(result[0].enablementScope, 'workspace'); + }); + }); + + suite('disabled state overlay - API items', () => { + + test('disabled via enablementProvider shows as disabled', async () => { + const ep = createMockEnablementProvider(); + ep.setEnabled(agentUri, PromptsType.agent, false, 'global'); + + const source = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([{ + uri: agentUri, type: PromptsType.agent, name: 'Agent', enablementScope: 'workspace', + }]), + enablementProvider: ep, + }); + + const result = await source.fetchItems(PromptsType.agent); + assert.strictEqual(result[0].disabled, true); + }); + + test('not in enablementProvider disabled set shows as enabled', async () => { + const source = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([{ + uri: agentUri, type: PromptsType.agent, name: 'Agent', enablementScope: 'workspace', + }]), + enablementProvider: createMockEnablementProvider(), + }); + + const result = await source.fetchItems(PromptsType.agent); + assert.strictEqual(result[0].disabled, false); + }); + + test('NOT affected by promptsService disabled state', async () => { + const ps = createMockPromptsService(); + const disabled = new ResourceSet(); + disabled.add(agentUri); + ps.setDisabledPromptFiles(PromptsType.agent, disabled, StorageScope.PROFILE, 'cli'); + + const source = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([{ + uri: agentUri, type: PromptsType.agent, name: 'Agent', enablementScope: 'workspace', + }]), + enablementProvider: createMockEnablementProvider(), + promptsService: ps, + }); + + const result = await source.fetchItems(PromptsType.agent); + assert.strictEqual(result[0].disabled, false); + }); + }); + + suite('disabled state overlay - VS Code items in external harness', () => { + + test('disabled via namespaced promptsService shows as disabled', async () => { + const ps = createMockPromptsService(); + const disabled = new ResourceSet(); + disabled.add(skillUri); + ps.setDisabledPromptFiles(PromptsType.skill, disabled, StorageScope.PROFILE, 'cli'); + + const source = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([{ + uri: skillUri, type: PromptsType.skill, name: 'Skill', + storage: PromptsStorage.local, enablementScope: 'workspace', + }]), + enablementProvider: createMockEnablementProvider(), + promptsService: ps, + }); + + const result = await source.fetchItems(PromptsType.skill); + assert.strictEqual(result[0].disabled, true); + }); + + test('not in namespaced promptsService disabled set shows as enabled', async () => { + const source = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([{ + uri: skillUri, type: PromptsType.skill, name: 'Skill', + storage: PromptsStorage.local, enablementScope: 'workspace', + }]), + enablementProvider: createMockEnablementProvider(), + }); + + const result = await source.fetchItems(PromptsType.skill); + assert.strictEqual(result[0].disabled, false); + }); + + test('NOT affected by enablementProvider disabled state', async () => { + const ep = createMockEnablementProvider(); + ep.setEnabled(skillUri, PromptsType.skill, false, 'global'); + + const source = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([{ + uri: skillUri, type: PromptsType.skill, name: 'Skill', + storage: PromptsStorage.local, enablementScope: 'workspace', + }]), + enablementProvider: ep, + }); + + const result = await source.fetchItems(PromptsType.skill); + assert.strictEqual(result[0].disabled, false); + }); + }); + + suite('VS Code harness (no namespace)', () => { + + test('reads disabled state from promptsService without namespace', async () => { + const ps = createMockPromptsService(); + const disabled = new ResourceSet(); + disabled.add(agentUri); + ps.setDisabledPromptFiles(PromptsType.agent, disabled, StorageScope.PROFILE); + + const source = createItemSource({ + harnessId: 'vscode', + itemProvider: createMockItemProvider([{ + uri: agentUri, type: PromptsType.agent, name: 'Agent', + storage: PromptsStorage.local, enablementScope: 'workspace', + }]), + promptsService: ps, + }); + + const result = await source.fetchItems(PromptsType.agent); + assert.strictEqual(result[0].disabled, true); + }); + + test('namespaced disabled state does NOT affect VS Code harness', async () => { + const ps = createMockPromptsService(); + const disabled = new ResourceSet(); + disabled.add(agentUri); + ps.setDisabledPromptFiles(PromptsType.agent, disabled, StorageScope.PROFILE, 'cli'); + + const source = createItemSource({ + harnessId: 'vscode', + itemProvider: createMockItemProvider([{ + uri: agentUri, type: PromptsType.agent, name: 'Agent', + storage: PromptsStorage.local, enablementScope: 'workspace', + }]), + promptsService: ps, + }); + + const result = await source.fetchItems(PromptsType.agent); + assert.strictEqual(result[0].disabled, false); + }); + }); + + suite('namespace isolation between harnesses', () => { + + test('disabling on one harness does not affect another', async () => { + const ps = createMockPromptsService(); + const disabled = new ResourceSet(); + disabled.add(instructionUri); + ps.setDisabledPromptFiles(PromptsType.instructions, disabled, StorageScope.PROFILE, 'cli'); + + const items: ICustomizationItem[] = [{ + uri: instructionUri, type: PromptsType.instructions, name: 'Rule', + storage: PromptsStorage.local, enablementScope: 'workspace', + }]; + + const cliSource = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider(items), + enablementProvider: createMockEnablementProvider(), + promptsService: ps, + }); + assert.strictEqual((await cliSource.fetchItems(PromptsType.instructions))[0].disabled, true); + + const claudeSource = createItemSource({ + harnessId: 'claude', + itemProvider: createMockItemProvider(items), + enablementProvider: createMockEnablementProvider(), + promptsService: ps, + }); + assert.strictEqual((await claudeSource.fetchItems(PromptsType.instructions))[0].disabled, false); + }); + }); + + suite('mixed API and VS Code items', () => { + + test('API disabled, VS Code enabled', async () => { + const ep = createMockEnablementProvider(); + ep.setEnabled(agentUri, PromptsType.agent, false, 'global'); + + const source = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([ + { uri: agentUri, type: PromptsType.agent, name: 'API Agent', enablementScope: 'global' }, + { uri: skillUri, type: PromptsType.agent, name: 'VS Code Agent', storage: PromptsStorage.local, enablementScope: 'workspace' }, + ]), + enablementProvider: ep, + }); + + const result = await source.fetchItems(PromptsType.agent); + assert.deepStrictEqual( + result.map(i => ({ name: i.name, disabled: i.disabled })), + [ + { name: 'API Agent', disabled: true }, + { name: 'VS Code Agent', disabled: false }, + ], + ); + }); + + test('API enabled, VS Code disabled', async () => { + const ps = createMockPromptsService(); + const disabled = new ResourceSet(); + disabled.add(skillUri); + ps.setDisabledPromptFiles(PromptsType.agent, disabled, StorageScope.PROFILE, 'cli'); + + const source = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([ + { uri: agentUri, type: PromptsType.agent, name: 'API Agent', enablementScope: 'global' }, + { uri: skillUri, type: PromptsType.agent, name: 'VS Code Agent', storage: PromptsStorage.local, enablementScope: 'workspace' }, + ]), + enablementProvider: createMockEnablementProvider(), + promptsService: ps, + }); + + const result = await source.fetchItems(PromptsType.agent); + assert.deepStrictEqual( + result.map(i => ({ name: i.name, disabled: i.disabled })), + [ + { name: 'API Agent', disabled: false }, + { name: 'VS Code Agent', disabled: true }, + ], + ); + }); + }); + + suite('builtin skill merging', () => { + + test('builtin skills get enablementScope: workspace', async () => { + const builtinUri = URI.file('/app/builtins/fetch/SKILL.md'); + const ps = createMockPromptsService(); + (ps as { listPromptFilesForStorage: Function }).listPromptFilesForStorage = async () => [ + { uri: builtinUri, name: 'fetch', description: 'Fetch web pages' }, + ]; + + const source = createItemSource({ + harnessId: 'vscode', + itemProvider: createMockItemProvider([]), + promptsService: ps, + }); + + const result = await source.fetchItems(PromptsType.skill); + const builtin = result.find(i => i.name === 'fetch'); + assert.ok(builtin); + assert.strictEqual(builtin.enablementScope, 'workspace'); + }); + + test('disabled builtin skill shows as disabled', async () => { + const builtinUri = URI.file('/app/builtins/fetch/SKILL.md'); + const ps = createMockPromptsService(); + (ps as { listPromptFilesForStorage: Function }).listPromptFilesForStorage = async () => [ + { uri: builtinUri, name: 'fetch', description: 'Fetch web pages' }, + ]; + const disabled = new ResourceSet(); + disabled.add(builtinUri); + ps.setDisabledPromptFiles(PromptsType.skill, disabled, StorageScope.PROFILE); + + const source = createItemSource({ + harnessId: 'vscode', + itemProvider: createMockItemProvider([]), + promptsService: ps, + }); + + const result = await source.fetchItems(PromptsType.skill); + const builtin = result.find(i => i.name === 'fetch'); + assert.ok(builtin); + assert.strictEqual(builtin.disabled, true); + }); + }); + + suite('ghost entries for disabled items not in provider results', () => { + + test('VS Code harness: disabled agent ghost entry has enablementScope and shows Enable button', async () => { + // Simulate: local agent was disabled via enablementProvider → promptsService + // but the promptsServiceItemProvider no longer returns it (getCustomAgents filters it out). + const ps = createMockPromptsService(); + const disabled = new ResourceSet(); + disabled.add(agentUri); + ps.setDisabledPromptFiles(PromptsType.agent, disabled, StorageScope.PROFILE); + + // Provider returns NO items (disabled agent is filtered out) + const source = createItemSource({ + harnessId: 'vscode', + itemProvider: createMockItemProvider([]), + promptsService: ps, + }); + + const result = await source.fetchItems(PromptsType.agent); + assert.strictEqual(result.length, 1, 'ghost entry should be created'); + assert.strictEqual(result[0].disabled, true); + assert.strictEqual(result[0].enablementScope, 'workspace'); + + const keys = computeContextKeys(result[0]); + assert.strictEqual(keys.enableButtonVisible, true, 'Enable button should be visible for ghost entry'); + assert.strictEqual(keys.disableButtonVisible, false); + }); + + test('external harness: disabled API agent ghost entry has enablementScope: global', async () => { + const ep = createMockEnablementProvider(); + ep.setEnabled(agentUri, PromptsType.agent, false, 'global'); + + // Provider returns NO items (extension filtered out disabled agent) + const source = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([]), + enablementProvider: ep, + }); + + const result = await source.fetchItems(PromptsType.agent); + assert.strictEqual(result.length, 1, 'ghost entry should be created'); + assert.strictEqual(result[0].disabled, true); + assert.strictEqual(result[0].enablementScope, 'global'); + + const keys = computeContextKeys(result[0]); + assert.strictEqual(keys.enableButtonVisible, true, 'Enable button should be visible for ghost entry'); + }); + + test('external harness: disabled VS Code item ghost entry has enablementScope: workspace', async () => { + const ps = createMockPromptsService(); + const disabled = new ResourceSet(); + disabled.add(skillUri); + ps.setDisabledPromptFiles(PromptsType.skill, disabled, StorageScope.PROFILE, 'cli'); + + // Provider returns NO items + const source = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([]), + enablementProvider: createMockEnablementProvider(), + promptsService: ps, + }); + + const result = await source.fetchItems(PromptsType.skill); + assert.strictEqual(result.length, 1, 'ghost entry should be created'); + assert.strictEqual(result[0].disabled, true); + assert.strictEqual(result[0].enablementScope, 'workspace'); + + const keys = computeContextKeys(result[0]); + assert.strictEqual(keys.enableButtonVisible, true, 'Enable button should be visible for ghost entry'); + }); + }); + + suite('enablementScope: application (ManagedByApplication)', () => { + + test('application-scoped item is disableable', async () => { + const source = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([{ + uri: agentUri, type: PromptsType.agent, name: 'Extension Agent', + enablementScope: 'application', + }]), + enablementProvider: createMockEnablementProvider(), + }); + + const result = await source.fetchItems(PromptsType.agent); + assert.strictEqual(result[0].enablementScope, 'application'); + const keys = computeContextKeys(result[0]); + assert.strictEqual(keys.isDisableable, true); + assert.strictEqual(keys.disableButtonVisible, true); + }); + + test('application-scoped item uses vscodeDisabledUris (not enablementProvider)', async () => { + // Disable via promptsService (namespaced) — this is VS Code-managed disablement + const ps = createMockPromptsService(); + const disabled = new ResourceSet(); + disabled.add(agentUri); + ps.setDisabledPromptFiles(PromptsType.agent, disabled, StorageScope.PROFILE, 'cli'); + + // Enable in enablementProvider — should NOT matter for application-scoped items + const ep = createMockEnablementProvider(); + + const source = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([{ + uri: agentUri, type: PromptsType.agent, name: 'Extension Agent', + enablementScope: 'application', + }]), + enablementProvider: ep, + promptsService: ps, + }); + + const result = await source.fetchItems(PromptsType.agent); + assert.strictEqual(result[0].disabled, true, 'should be disabled via promptsService, not enablementProvider'); + }); + + test('application-scoped item NOT disabled by enablementProvider', async () => { + // Disable via enablementProvider — should NOT affect application-scoped items + const ep = createMockEnablementProvider(); + ep.setEnabled(agentUri, PromptsType.agent, false, 'global'); + + const source = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([{ + uri: agentUri, type: PromptsType.agent, name: 'Extension Agent', + enablementScope: 'application', + }]), + enablementProvider: ep, + }); + + const result = await source.fetchItems(PromptsType.agent); + assert.strictEqual(result[0].disabled, false, 'enablementProvider should not affect application-scoped items'); + }); + + test('disabled application-scoped item shows Enable button', async () => { + const ps = createMockPromptsService(); + const disabled = new ResourceSet(); + disabled.add(skillUri); + ps.setDisabledPromptFiles(PromptsType.skill, disabled, StorageScope.PROFILE, 'cli'); + + const source = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([{ + uri: skillUri, type: PromptsType.skill, name: 'Extension Skill', + enablementScope: 'application', + }]), + enablementProvider: createMockEnablementProvider(), + promptsService: ps, + }); + + const result = await source.fetchItems(PromptsType.skill); + const keys = computeContextKeys(result[0]); + assert.strictEqual(keys.disabled, true); + assert.strictEqual(keys.enableButtonVisible, true); + assert.strictEqual(keys.disableButtonVisible, false); + }); + }); + + suite('provider item with pre-set enabled:false', () => { + + test('shown as disabled regardless of disabled sets', async () => { + const source = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([{ + uri: agentUri, type: PromptsType.agent, name: 'Pre-Disabled', + enabled: false, enablementScope: 'global', + }]), + enablementProvider: createMockEnablementProvider(), + }); + + const result = await source.fetchItems(PromptsType.agent); + assert.strictEqual(result[0].disabled, true); + }); + }); + + /** + * Computes the full set of context keys that drive Enable/Disable button + * visibility in the list widget. Uses the shared production helper for + * enablementScope/isDisableable, then derives button visibility. + */ + function computeContextKeys(item: { disabled: boolean; enablementScope?: string; plugin?: unknown }) { + const { enablementScope, isDisableable } = computeItemEnablementKeys(item); + return { + disabled: item.disabled, + enablementScope, + isDisableable, + isPlugin: !!item.plugin, + enableButtonVisible: item.disabled && !item.plugin && isDisableable, + disableButtonVisible: !item.disabled && !item.plugin && isDisableable, + }; + } + + suite('Enable/Disable button visibility context keys', () => { + + test('VS Code harness: disabled local item shows Enable button', async () => { + const ps = createMockPromptsService(); + const disabled = new ResourceSet(); + disabled.add(agentUri); + ps.setDisabledPromptFiles(PromptsType.agent, disabled, StorageScope.PROFILE); + + const source = createItemSource({ + harnessId: 'vscode', + itemProvider: createMockItemProvider([{ + uri: agentUri, type: PromptsType.agent, name: 'My Agent', + storage: PromptsStorage.local, enablementScope: 'workspace', + }]), + promptsService: ps, + }); + + const result = await source.fetchItems(PromptsType.agent); + const keys = computeContextKeys(result[0]); + assert.deepStrictEqual(keys, { + disabled: true, + enablementScope: 'workspace', + isDisableable: true, + isPlugin: false, + enableButtonVisible: true, + disableButtonVisible: false, + }); + }); + + test('VS Code harness: enabled local item shows Disable button', async () => { + const source = createItemSource({ + harnessId: 'vscode', + itemProvider: createMockItemProvider([{ + uri: agentUri, type: PromptsType.agent, name: 'My Agent', + storage: PromptsStorage.local, enablementScope: 'workspace', + }]), + }); + + const result = await source.fetchItems(PromptsType.agent); + const keys = computeContextKeys(result[0]); + assert.deepStrictEqual(keys, { + disabled: false, + enablementScope: 'workspace', + isDisableable: true, + isPlugin: false, + enableButtonVisible: false, + disableButtonVisible: true, + }); + }); + + test('external harness: disabled API item shows Enable button', async () => { + const ep = createMockEnablementProvider(); + ep.setEnabled(agentUri, PromptsType.agent, false, 'global'); + + const source = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([{ + uri: agentUri, type: PromptsType.agent, name: 'CLI Agent', + enablementScope: 'global', + }]), + enablementProvider: ep, + }); + + const result = await source.fetchItems(PromptsType.agent); + const keys = computeContextKeys(result[0]); + assert.deepStrictEqual(keys, { + disabled: true, + enablementScope: 'global', + isDisableable: true, + isPlugin: false, + enableButtonVisible: true, + disableButtonVisible: false, + }); + }); + + test('external harness: disabled VS Code item shows Enable button', async () => { + const ps = createMockPromptsService(); + const disabled = new ResourceSet(); + disabled.add(skillUri); + ps.setDisabledPromptFiles(PromptsType.skill, disabled, StorageScope.PROFILE, 'cli'); + + const source = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([{ + uri: skillUri, type: PromptsType.skill, name: 'My Skill', + storage: PromptsStorage.local, enablementScope: 'workspace', + }]), + enablementProvider: createMockEnablementProvider(), + promptsService: ps, + }); + + const result = await source.fetchItems(PromptsType.skill); + const keys = computeContextKeys(result[0]); + assert.deepStrictEqual(keys, { + disabled: true, + enablementScope: 'workspace', + isDisableable: true, + isPlugin: false, + enableButtonVisible: true, + disableButtonVisible: false, + }); + }); + + test('item without enablementScope: no Enable or Disable button', async () => { + const source = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([{ + uri: agentUri, type: PromptsType.agent, name: 'No Scope Agent', + }]), + enablementProvider: createMockEnablementProvider(), + }); + + const result = await source.fetchItems(PromptsType.agent); + const keys = computeContextKeys(result[0]); + assert.deepStrictEqual(keys, { + disabled: false, + enablementScope: 'none', + isDisableable: false, + isPlugin: false, + enableButtonVisible: false, + disableButtonVisible: false, + }); + }); + }); +}); diff --git a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts index 06aa3e1d6ff87..e8deccab75fe5 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts @@ -51,6 +51,19 @@ declare module 'vscode' { Global = 1, /** Both "Disable" and "Disable (Workspace)" actions are shown, allowing per-workspace overrides. */ Workspace = 2, + /** + * The item is reported by the provider but its enablement is managed + * by the application rather than the provider's + * {@link ChatSessionCustomizationEnablementHandler}. Disable/enable + * actions are shown but their state is persisted internally. + * + * Use this for items the provider discovers from APIs + * (e.g. extension-contributed customizations) where the provider + * does not own the enablement lifecycle. Note that any enablement + * changes here would not be remembered outside of the current application, + * so it is less portable. + */ + ManagedByApplication = 3, } /** From 6b1b422562a50da15beee1febb9e50ae21d7bcca Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Thu, 23 Apr 2026 15:25:59 -0700 Subject: [PATCH 13/36] new tests --- .../aiCustomizationDisablement.test.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts index 482d970173c48..a1f3d914394f3 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts @@ -592,6 +592,56 @@ suite('aiCustomizationDisablement', () => { assert.strictEqual(keys.enableButtonVisible, true); assert.strictEqual(keys.disableButtonVisible, false); }); + + test('mixed: application-scoped disabled, API-scoped enabled', async () => { + const ps = createMockPromptsService(); + const disabled = new ResourceSet(); + disabled.add(agentUri); + ps.setDisabledPromptFiles(PromptsType.agent, disabled, StorageScope.PROFILE, 'cli'); + + const source = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([ + { uri: agentUri, type: PromptsType.agent, name: 'Extension Agent', enablementScope: 'application' }, + { uri: skillUri, type: PromptsType.agent, name: 'CLI Agent', enablementScope: 'global' }, + ]), + enablementProvider: createMockEnablementProvider(), + promptsService: ps, + }); + + const result = await source.fetchItems(PromptsType.agent); + assert.deepStrictEqual( + result.map(i => ({ name: i.name, disabled: i.disabled, enablementScope: i.enablementScope })), + [ + { name: 'CLI Agent', disabled: false, enablementScope: 'global' }, + { name: 'Extension Agent', disabled: true, enablementScope: 'application' }, + ], + ); + }); + + test('ghost entry for disabled application-scoped item not in provider results', async () => { + const ps = createMockPromptsService(); + const disabled = new ResourceSet(); + disabled.add(agentUri); + ps.setDisabledPromptFiles(PromptsType.agent, disabled, StorageScope.PROFILE, 'cli'); + + // Provider returns NO items — the disabled extension agent was filtered out + const source = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([]), + enablementProvider: createMockEnablementProvider(), + promptsService: ps, + }); + + const result = await source.fetchItems(PromptsType.agent); + assert.strictEqual(result.length, 1, 'ghost entry should be created'); + assert.strictEqual(result[0].disabled, true); + // Ghost entries from vscodeDisabledUris get enablementScope: 'workspace' + assert.strictEqual(result[0].enablementScope, 'workspace'); + + const keys = computeContextKeys(result[0]); + assert.strictEqual(keys.enableButtonVisible, true, 'Enable button should be visible'); + }); }); suite('provider item with pre-set enabled:false', () => { From f3e657a937f7d2917cd3950784839da268890f5a Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Thu, 23 Apr 2026 15:49:35 -0700 Subject: [PATCH 14/36] fixes --- .../copilotCLICustomizationProvider.ts | 73 +++++++------------ .../aiCustomizationItemSource.ts | 2 +- .../aiCustomizationDisablement.test.ts | 20 +++++ 3 files changed, 46 insertions(+), 49 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts index c928a9579d3a0..8af03fdf48815 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts @@ -224,6 +224,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod type: vscode.ChatSessionCustomizationType.Skill, name, enabled: !disabledSkills.has(folderName), + enablementScope: vscode.ChatSessionCustomizationEnablementScope.Global, }; }); } @@ -233,14 +234,11 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod * Each item is a hook configuration file (JSON). */ private async getHookItems(token: vscode.CancellationToken): Promise { - return (await this.promptsService.getHooks(token)).filter(isEnabledForCopilotCLI).map(h => { - const name = basename(h.uri).replace(/\.json$/i, ''); - return { - uri: h.uri, - type: vscode.ChatSessionCustomizationType.Hook, - name, - }; - }); + return (await this.promptsService.getHooks(token)).filter(isEnabledForCopilotCLI).map(h => ({ + uri: h.uri, + type: vscode.ChatSessionCustomizationType.Hook, + name: basename(h.uri).replace(/\.json$/i, ''), + })); } /** @@ -260,6 +258,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod type: vscode.ChatSessionCustomizationType.Plugins, name, enabled, + enablementScope: vscode.ChatSessionCustomizationEnablementScope.Global, }; }); } @@ -295,56 +294,34 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod await this.fileSystemService.writeFile(this._settingsUri, content); } - /** - * Resolves the CLI settings key and item name for a given customization type and URI. - * Returns `undefined` for types that don't support per-item disablement in the CLI. - */ - private _resolveDisablementKey(uri: vscode.Uri, type: vscode.ChatSessionCustomizationType): { settingsKey: string; name: string } | undefined { - if (type.id === vscode.ChatSessionCustomizationType.Skill.id) { - // Skills use the folder name as the key in disabledSkills - const name = basename(dirname(URI.from(uri))) || basename(URI.from(uri)); - return { settingsKey: 'disabledSkills', name }; - } - if (type.id === vscode.ChatSessionCustomizationType.Plugins?.id) { - // Plugins use enabledPlugins map (Record) - const name = basename(URI.from(uri)); - return { settingsKey: 'enabledPlugins', name }; - } - // Other types don't have per-item disablement in the CLI config - return undefined; - } - async handleCustomizationEnablement(uri: vscode.Uri, type: vscode.ChatSessionCustomizationType, enabled: boolean, _scope: vscode.ChatSessionCustomizationEnablementScope, _token: vscode.CancellationToken): Promise { - const resolved = this._resolveDisablementKey(uri, type); - if (!resolved) { - this.logService.warn(`[CopilotCLICustomizationProvider] Per-item enablement not supported for type: ${type.id}`); - return; - } - - const { settingsKey, name } = resolved; const settings = await this._readSettings(); + let name: string; - if (settingsKey === 'enabledPlugins') { - // enabledPlugins is a Record map - const map = (settings[settingsKey] && typeof settings[settingsKey] === 'object' && !Array.isArray(settings[settingsKey])) - ? { ...settings[settingsKey] as Record } + if (type.id === vscode.ChatSessionCustomizationType.Skill.id) { + // Skills use the folder name as the key in disabledSkills + name = basename(dirname(URI.from(uri))) || basename(URI.from(uri)); + const currentList = Array.isArray(settings.disabledSkills) ? settings.disabledSkills as string[] : []; + if (enabled) { + settings.disabledSkills = currentList.filter(s => s !== name); + } else if (!currentList.includes(name)) { + settings.disabledSkills = [...currentList, name]; + } + } else if (type.id === vscode.ChatSessionCustomizationType.Plugins?.id) { + // Plugins use enabledPlugins map (Record) + name = basename(URI.from(uri)); + const map = (settings.enabledPlugins && typeof settings.enabledPlugins === 'object' && !Array.isArray(settings.enabledPlugins)) + ? { ...settings.enabledPlugins as Record } : {}; if (enabled) { delete map[name]; } else { map[name] = false; } - settings[settingsKey] = Object.keys(map).length > 0 ? map : undefined; + settings.enabledPlugins = Object.keys(map).length > 0 ? map : undefined; } else { - // disabledSkills are string arrays - const currentList = Array.isArray(settings[settingsKey]) ? settings[settingsKey] as string[] : []; - if (enabled) { - settings[settingsKey] = currentList.filter(s => s !== name); - } else { - if (!currentList.includes(name)) { - settings[settingsKey] = [...currentList, name]; - } - } + this.logService.warn(`[CopilotCLICustomizationProvider] Per-item enablement not supported for type: ${type.id}`); + return; } await this._writeSettings(settings); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index 7683f45e7afe0..8a41bf9ea3b48 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -603,7 +603,7 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour enabled: !disabledPromptFiles.has(p.uri), badge: uiTooltip ? uiIntegrationBadge : undefined, badgeTooltip: uiTooltip, - enablementScope: 'workspace', + enablementScope: this.hasNativeItemProvider ? 'application' : 'workspace', }; appended.push(this.itemNormalizer.normalizeItem(builtinItem, promptType, uriUseCounts)); } diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts index a1f3d914394f3..605004ceb0dd8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts @@ -436,6 +436,26 @@ suite('aiCustomizationDisablement', () => { assert.ok(builtin); assert.strictEqual(builtin.disabled, true); }); + + test('external harness: builtin skills get enablementScope: application', async () => { + const builtinUri = URI.file('/app/builtins/fetch/SKILL.md'); + const ps = createMockPromptsService(); + (ps as { listPromptFilesForStorage: Function }).listPromptFilesForStorage = async () => [ + { uri: builtinUri, name: 'fetch', description: 'Fetch web pages' }, + ]; + + const source = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([]), + enablementProvider: createMockEnablementProvider(), + promptsService: ps, + }); + + const result = await source.fetchItems(PromptsType.skill); + const builtin = result.find(i => i.name === 'fetch'); + assert.ok(builtin); + assert.strictEqual(builtin.enablementScope, 'application'); + }); }); suite('ghost entries for disabled items not in provider results', () => { From 4335b51829d025af883c5ebf1121a87394220681 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Thu, 23 Apr 2026 16:27:14 -0700 Subject: [PATCH 15/36] updates --- .../claudeCustomizationProvider.ts | 127 +++++++++++++----- .../copilotCLICustomizationProvider.ts | 10 +- 2 files changed, 100 insertions(+), 37 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts index a3f85544580d1..77febafc188aa 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import picomatch from 'picomatch'; import * as vscode from 'vscode'; import { INativeEnvService } from '../../../platform/env/common/envService'; import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; @@ -131,7 +132,7 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch this.logService.debug(`[ClaudeCustomizationProvider] agents (${agentItems.length}): ${agentItems.map(a => a.name).join(', ') || '(none)'}${sdkAgents.length ? ' [sdk]' : ' [files-only, no session]'}`); // Instructions from hard-coded CLAUDE.md paths (checked for existence) - const settings = await this._readSettings(); + const settings = await this._readMergedSettings(); const instructionItems = await this.discoverInstructions(settings); items.push(...instructionItems); this.logService.debug(`[ClaudeCustomizationProvider] instructions (${instructionItems.length}): ${instructionItems.map(i => i.name).join(', ') || '(none)'}`); @@ -141,7 +142,7 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch const skillItems: vscode.ChatSessionCustomizationItem[] = []; for (const skill of await this.promptsService.getSkills(token)) { if (this.isClaudePath(skill.uri)) { - const folderName = basename(dirname(skill.uri)) || basename(skill.uri); + const folderName = basename(dirname(skill.uri)); const override = skillOverrides[folderName]; const item: vscode.ChatSessionCustomizationItem = { uri: skill.uri, @@ -187,12 +188,16 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch if (await this.fileExists(uri)) { const name = basename(uri).replace(/\.md$/i, ''); const excluded = excludes.some(pattern => this._matchesExclude(uri, pattern)); + // We can only toggle enablement for items excluded by the exact absolute + // path we write (our known pattern). Glob-based excludes from the user's + // settings are shown as disabled but cannot be toggled from the UI. + const excludedByKnownPattern = excluded && excludes.includes(uri.path); items.push({ uri, type: vscode.ChatSessionCustomizationType.Instructions, name, - enablementScope: excluded - ? vscode.ChatSessionCustomizationEnablementScope.Global + enablementScope: !excluded || excludedByKnownPattern + ? vscode.ChatSessionCustomizationEnablementScope.Workspace : vscode.ChatSessionCustomizationEnablementScope.None, enabled: !excluded, }); @@ -294,17 +299,29 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch /** * Path to the user-level claude settings file (`~/.claude/settings.json`). */ - private get _settingsUri(): URI { + private get _userSettingsUri(): URI { return URI.joinPath(this.envService.userHome, '.claude', 'settings.json'); } /** - * Reads the user-level `~/.claude/settings.json` as a typed object. + * Returns the workspace-local settings URI for the first workspace folder. + * Falls back to the user-level settings URI if no workspace folders exist. + */ + private get _workspaceSettingsUri(): URI { + const folders = this.workspaceService.getWorkspaceFolders(); + if (folders.length > 0) { + return URI.joinPath(folders[0], '.claude', 'settings.local.json'); + } + return this._userSettingsUri; + } + + /** + * Reads a single settings file as a typed object. * Returns an empty object if the file doesn't exist or can't be parsed. */ - private async _readSettings(): Promise { + private async _readSettingsFile(uri: URI): Promise { try { - const bytes = await this.fileSystemService.readFile(this._settingsUri); + const bytes = await this.fileSystemService.readFile(uri); const parsed = JSON.parse(new TextDecoder().decode(bytes)); return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {}; } catch { @@ -313,36 +330,74 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch } /** - * Writes the user-level `~/.claude/settings.json`. + * Reads and merges settings from all settings files (workspace + user-level). + * Workspace settings take precedence over user-level for object keys; + * array values (e.g. `claudeMdExcludes`) are concatenated. + */ + private async _readMergedSettings(): Promise { + const allSettings = await Promise.all( + this.getSettingsFilePaths().map(uri => this._readSettingsFile(uri)) + ); + + const merged: Record = {}; + for (const settings of allSettings) { + for (const [key, value] of Object.entries(settings)) { + if (value === undefined) { + continue; + } + const existing = merged[key]; + if (Array.isArray(existing) && Array.isArray(value)) { + merged[key] = [...existing, ...value]; + } else if (existing === undefined) { + merged[key] = value; + } + // First-writer-wins for non-array scalar/object values + } + } + + return merged as ClaudeSettings; + } + + /** + * Writes settings to the appropriate file based on scope. + * - `Workspace`: writes to `/.claude/settings.local.json` + * - `Global` (or other): writes to `~/.claude/settings.json` */ - private async _writeSettings(settings: ClaudeSettings): Promise { + private async _writeSettings(settings: ClaudeSettings, scope: vscode.ChatSessionCustomizationEnablementScope): Promise { + const targetUri = scope === vscode.ChatSessionCustomizationEnablementScope.Workspace + ? this._workspaceSettingsUri + : this._userSettingsUri; const content = new TextEncoder().encode(JSON.stringify(settings, null, 4)); - await this.fileSystemService.writeFile(this._settingsUri, content); + await this.fileSystemService.writeFile(targetUri, content); } /** * Checks whether a URI matches a claudeMdExcludes pattern. - * Patterns can be absolute paths or simple glob-like suffixes. + * Patterns are matched against absolute file paths using picomatch, + * consistent with how Claude Code evaluates them. */ private _matchesExclude(uri: URI, pattern: string): boolean { - const uriPath = uri.path; - // Absolute path match - if (pattern.startsWith('/') && !pattern.includes('*')) { - return uriPath === pattern; - } - // Simple suffix/glob match: strip leading **/ and check endsWith - const suffix = pattern.replace(/^\*\*\//, ''); - if (suffix !== pattern && !suffix.includes('*')) { - return uriPath.endsWith('/' + suffix) || uriPath === suffix; + return this._getExcludeMatcher(pattern)(uri.path); + } + + private readonly _excludeMatcherCache = new Map(); + + private _getExcludeMatcher(pattern: string): picomatch.Matcher { + let matcher = this._excludeMatcherCache.get(pattern); + if (!matcher) { + matcher = picomatch(pattern, { dot: true }); + this._excludeMatcherCache.set(pattern, matcher); } - // Exact match fallback - return uriPath === pattern; + return matcher; } // --- Enablement --- - async handleCustomizationEnablement(uri: vscode.Uri, type: vscode.ChatSessionCustomizationType, enabled: boolean, _scope: vscode.ChatSessionCustomizationEnablementScope, _token: vscode.CancellationToken): Promise { - const settings = { ...await this._readSettings() }; + async handleCustomizationEnablement(uri: vscode.Uri, type: vscode.ChatSessionCustomizationType, enabled: boolean, scope: vscode.ChatSessionCustomizationEnablementScope, _token: vscode.CancellationToken): Promise { + const settingsFileUri = scope === vscode.ChatSessionCustomizationEnablementScope.Workspace + ? this._workspaceSettingsUri + : this._userSettingsUri; + const settings = { ...await this._readSettingsFile(settingsFileUri) }; if (type.id === vscode.ChatSessionCustomizationType.Skill.id) { // skillOverrides: Record @@ -353,17 +408,17 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch } else { overrides[folderName] = 'off'; } - (settings as Record).skillOverrides = Object.keys(overrides).length > 0 ? overrides : undefined; + settings.skillOverrides = Object.keys(overrides).length > 0 ? overrides : undefined; } else if (type.id === vscode.ChatSessionCustomizationType.Instructions.id) { // claudeMdExcludes: string[] of absolute paths or glob patterns - const uriPath = URI.from(uri).path; + const targetUri = URI.from(uri); const currentExcludes = [...(settings.claudeMdExcludes ?? [])]; if (enabled) { - (settings as Record).claudeMdExcludes = currentExcludes.filter(p => !this._matchesExclude(URI.from(uri), p)); + settings.claudeMdExcludes = currentExcludes.filter(p => !this._matchesExclude(targetUri, p)); } else { - if (!currentExcludes.some(p => this._matchesExclude(URI.from(uri), p))) { - currentExcludes.push(uriPath); - (settings as Record).claudeMdExcludes = currentExcludes; + if (!currentExcludes.some(p => this._matchesExclude(targetUri, p))) { + currentExcludes.push(targetUri.path); + settings.claudeMdExcludes = currentExcludes; } } } else { @@ -371,9 +426,13 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch return; } - await this._writeSettings(settings); - this.logService.debug(`[ClaudeCustomizationProvider] ${enabled ? 'Enabled' : 'Disabled'} ${type.id} in ${this._settingsUri.toString()}`); - this._onDidChange.fire(); + try { + await this._writeSettings(settings, scope); + this.logService.debug(`[ClaudeCustomizationProvider] ${enabled ? 'Enabled' : 'Disabled'} ${type.id} in ${settingsFileUri.toString()}`); + this._onDidChange.fire(); + } catch (err) { + vscode.window.showErrorMessage(vscode.l10n.t('Failed to update Claude settings: {0}', err instanceof Error ? err.message : String(err))); + } } } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts index 8af03fdf48815..7409ce167b1fd 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts @@ -324,8 +324,12 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod return; } - await this._writeSettings(settings); - this.logService.debug(`[CopilotCLICustomizationProvider] ${enabled ? 'Enabled' : 'Disabled'} ${type.id} "${name}" in ${this._settingsUri.toString()}`); - this._onDidChange.fire(); + try { + await this._writeSettings(settings); + this.logService.debug(`[CopilotCLICustomizationProvider] ${enabled ? 'Enabled' : 'Disabled'} ${type.id} "${name}" in ${this._settingsUri.toString()}`); + this._onDidChange.fire(); + } catch (err) { + vscode.window.showErrorMessage(vscode.l10n.t('Failed to update Copilot settings: {0}', err instanceof Error ? err.message : String(err))); + } } } From 339f902a7a5d56a9a481a3039eb29f826159961f Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Thu, 23 Apr 2026 17:45:48 -0700 Subject: [PATCH 16/36] refactor --- .../claude/common/claudeSettingsService.ts | 63 +++++++ .../claude/node/claudeCodeAgent.ts | 12 +- .../claude/node/claudeSettingsService.ts | 145 ++++++++++++++++ .../chatSessions/vscode-node/chatSessions.ts | 3 + .../claudeCustomizationProvider.ts | 164 +++++------------- .../src/extension/test/node/services.ts | 3 + 6 files changed, 260 insertions(+), 130 deletions(-) create mode 100644 extensions/copilot/src/extension/chatSessions/claude/common/claudeSettingsService.ts create mode 100644 extensions/copilot/src/extension/chatSessions/claude/node/claudeSettingsService.ts diff --git a/extensions/copilot/src/extension/chatSessions/claude/common/claudeSettingsService.ts b/extensions/copilot/src/extension/chatSessions/claude/common/claudeSettingsService.ts new file mode 100644 index 0000000000000..76fea3bc53fad --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/claude/common/claudeSettingsService.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Settings as ClaudeSettings } from '@anthropic-ai/claude-agent-sdk'; +import { Event } from '../../../../util/vs/base/common/event'; +import { URI } from '../../../../util/vs/base/common/uri'; +import { createDecorator } from '../../../../util/vs/platform/instantiation/common/instantiation'; + +export const IClaudeSettingsService = createDecorator('claudeSettingsService'); + +export enum ClaudeSettingsLocationType { + // ~/.claude/settings.json + User = 'user', + // /.claude/settings.json + Workspace = 'workspace', + // /.claude/settings.local.json + WorkspaceLocal = 'workspaceLocal', +} + +export interface ClaudeSettingsFile { + type: ClaudeSettingsLocationType; + settings: ClaudeSettings; + uri: URI; +} + +export interface IClaudeSettingsService { + readonly _serviceBrand: undefined; + + /** + * Fires when any Claude settings file changes on disk. + */ + readonly onDidChange: Event; + + /** + * Returns the settings from all settings files as separate objects. + * Each is an empty object if the file doesn't exist or can't be parsed. + * Returns it in order of precedence (workspaceLocal > workspace > user). + */ + readAllSettings(): Promise>; + + /** + * Reads a single settings file as a typed object. + * Returns an empty object if the file doesn't exist or can't be parsed. + */ + readSettingsFile(uri: URI): Promise; + + /** + * Writes settings to the given location. + */ + writeSettingsFile(uri: URI, settings: ClaudeSettings): Promise; + + /** + * Returns known settings URIs. If location is provided, returns only the URIs for that location. + */ + getUris(location?: ClaudeSettingsLocationType): URI[]; + + /** + * Returns a single URI for a settings location given a URI that can be used to identify a workspace folder. + */ + getUri(location: ClaudeSettingsLocationType, uri: URI): URI; +} diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts index e2b82b4beeee0..9ba6190d8aa88 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts @@ -33,6 +33,7 @@ import { resolvePromptToContentBlocks } from './claudePromptResolver'; import { ClaudeSettingsChangeTracker } from './claudeSettingsChangeTracker'; import { ParsedClaudeModelId } from '../common/claudeModelId'; import { IClaudeSessionStateService } from '../common/claudeSessionStateService'; +import { IClaudeSettingsService } from '../common/claudeSettingsService'; // Manages Claude Code agent interactions and language model server lifecycle export class ClaudeAgentManager extends Disposable { @@ -218,6 +219,7 @@ export class ClaudeCodeSession extends Disposable { @IClaudePluginService private readonly claudePluginService: IClaudePluginService, @IOTelService private readonly _otelService: IOTelService, @IChatDebugFileLoggerService private readonly _debugFileLogger: IChatDebugFileLoggerService, + @IClaudeSettingsService private readonly settingsService: IClaudeSettingsService, ) { super(); this._currentModelId = initialModelId; @@ -263,15 +265,7 @@ export class ClaudeCodeSession extends Disposable { // Track settings/hooks files tracker.registerPathResolver(() => { - const paths: URI[] = []; - // User-level settings - paths.push(URI.joinPath(this.envService.userHome, '.claude', 'settings.json')); - // Project-level settings files - for (const folder of this.workspaceService.getWorkspaceFolders()) { - paths.push(URI.joinPath(folder, '.claude', 'settings.json')); - paths.push(URI.joinPath(folder, '.claude', 'settings.local.json')); - } - return paths; + return this.settingsService.getUris(); }); // Track agent files in agents directories diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeSettingsService.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeSettingsService.ts new file mode 100644 index 0000000000000..d60aa9c5aaf4a --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeSettingsService.ts @@ -0,0 +1,145 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Settings as ClaudeSettings } from '@anthropic-ai/claude-agent-sdk'; +import { INativeEnvService } from '../../../../platform/env/common/envService'; +import { IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService'; +import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService'; +import { Emitter } from '../../../../util/vs/base/common/event'; +import { Disposable } from '../../../../util/vs/base/common/lifecycle'; +import { URI } from '../../../../util/vs/base/common/uri'; +import { ClaudeSettingsFile, ClaudeSettingsLocationType, IClaudeSettingsService } from '../common/claudeSettingsService'; +import { extUriBiasedIgnorePathCase } from '../../../../util/vs/base/common/resources'; + +export class ClaudeSettingsService extends Disposable implements IClaudeSettingsService { + declare readonly _serviceBrand: undefined; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + + private _settingsCache: Readonly | undefined; + private _settingsUris: URI[] = []; + + constructor( + @IWorkspaceService private readonly workspaceService: IWorkspaceService, + @IFileSystemService private readonly fileSystemService: IFileSystemService, + @INativeEnvService private readonly envService: INativeEnvService, + ) { + super(); + + const onSettingsChanged = () => { + this._settingsCache = undefined; + this._onDidChange.fire(); + }; + + const setupWatchers = () => { + this._settingsUris = []; + for (const location of Object.values(ClaudeSettingsLocationType)) { + const uris = this.getUris(location); + this._settingsUris.push(...uris); + for (const uri of uris) { + const settingsWatcher = this._register(this.fileSystemService.createFileSystemWatcher(uri.fsPath)); + this._register(settingsWatcher.onDidChange(onSettingsChanged)); + this._register(settingsWatcher.onDidCreate(onSettingsChanged)); + this._register(settingsWatcher.onDidDelete(onSettingsChanged)); + } + } + }; + + this._register(this.workspaceService.onDidChangeWorkspaceFolders(() => { + setupWatchers(); + onSettingsChanged(); + })); + + setupWatchers(); + } + + private getUrisByLocation(location: ClaudeSettingsLocationType): URI[] { + switch (location) { + case ClaudeSettingsLocationType.User: + return [URI.joinPath(this.envService.userHome, '.claude', 'settings.json')]; + case ClaudeSettingsLocationType.Workspace: { + const folders = this.workspaceService.getWorkspaceFolders(); + const uris: URI[] = []; + for (const folder of folders) { + uris.push(URI.joinPath(folder, '.claude', 'settings.json')); + } + return uris; + } + case ClaudeSettingsLocationType.WorkspaceLocal: { + const folders = this.workspaceService.getWorkspaceFolders(); + const uris: URI[] = []; + for (const folder of folders) { + uris.push(URI.joinPath(folder, '.claude', 'settings.local.json')); + } + return uris; + } + } + } + + getUris(location?: ClaudeSettingsLocationType): URI[] { + if (location) { + return this.getUrisByLocation(location); + } else { + let uris: URI[] = []; + for (const loc of Object.values(ClaudeSettingsLocationType)) { + uris = uris.concat(this.getUrisByLocation(loc)); + } + return uris; + } + } + + getUri(location: ClaudeSettingsLocationType, uri: URI): URI { + const uris = this.getUris(location); + if (uris.length === 1) { + return uris[0]; + } + const workspaceFolders = this.workspaceService.getWorkspaceFolders(); + // Multiple workspace folders — find the one that matches the item's URI + for (const workspaceFolder of workspaceFolders) { + if (extUriBiasedIgnorePathCase.isEqualOrParent(uri, workspaceFolder)) { + const settingsUri = uris.find(u => extUriBiasedIgnorePathCase.isEqual(u, workspaceFolder)); + if (settingsUri) { + return settingsUri; + } + } + } + throw new Error(`Could not find a matching settings URI for ${uri.toString()}`); + } + + async readSettingsFile(uri: URI): Promise { + try { + const bytes = await this.fileSystemService.readFile(uri); + const parsed = JSON.parse(new TextDecoder().decode(bytes)); + return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {}; + } catch { + return {}; + } + } + + async readAllSettings(): Promise> { + if (this._settingsCache) { + return this._settingsCache; + } + + const settingsFiles = await Promise.all( + this._settingsUris.map(uri => this.readSettingsFile(uri)) + ); + + this._settingsCache = settingsFiles.map((settings, index) => ({ + type: Object.values(ClaudeSettingsLocationType)[index], + settings, + uri: this._settingsUris[index], + })); + + return this._settingsCache; + } + + async writeSettingsFile(uri: URI, settings: ClaudeSettings): Promise { + const content = new TextEncoder().encode(JSON.stringify(settings, null, 4)); + await this.fileSystemService.writeFile(uri, content); + // Cache will be invalidated by the file watcher + } +} diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts index 04dbe069809d1..3a5b06b0d8c23 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts @@ -24,6 +24,7 @@ import { GitBranchNameGenerator } from '../../prompt/node/gitBranch'; import { ChatSummarizerProvider } from '../../prompt/node/summarizer'; import { IToolsService } from '../../tools/common/toolsService'; import { IClaudeRuntimeDataService } from '../claude/common/claudeRuntimeDataService'; +import { IClaudeSettingsService } from '../claude/common/claudeSettingsService'; import { ClaudeSessionUri } from '../claude/common/claudeSessionUri'; import { ClaudeToolPermissionService, IClaudeToolPermissionService } from '../claude/common/claudeToolPermissionService'; import { ClaudeCodeFolderMruService } from '../claude/node/claudeCodeFolderMru'; @@ -31,6 +32,7 @@ import { ClaudeAgentManager } from '../claude/node/claudeCodeAgent'; import { ClaudeCodeModels, IClaudeCodeModels } from '../claude/node/claudeCodeModels'; import { ClaudeCodeSdkService, IClaudeCodeSdkService } from '../claude/node/claudeCodeSdkService'; import { ClaudeRuntimeDataService } from '../claude/node/claudeRuntimeDataService'; +import { ClaudeSettingsService } from '../claude/node/claudeSettingsService'; import { ClaudePluginService, IClaudePluginService } from '../claude/node/claudeSkills'; import { IClaudeSessionStateService } from '../claude/common/claudeSessionStateService'; import { ClaudeSessionStateService } from '../claude/node/claudeSessionStateService'; @@ -149,6 +151,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib [IFolderRepositoryManager, new SyncDescriptor(ClaudeFolderRepositoryManager)], [IChatFolderMruService, new SyncDescriptor(ClaudeCodeFolderMruService)], [IClaudeRuntimeDataService, new SyncDescriptor(ClaudeRuntimeDataService)], + [IClaudeSettingsService, new SyncDescriptor(ClaudeSettingsService)], [IClaudePluginService, new SyncDescriptor(ClaudePluginService)], )); const claudeAgentManager = this._register(claudeAgentInstaService.createInstance(ClaudeAgentManager)); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts index 77febafc188aa..708f1ebd80e60 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts @@ -13,8 +13,8 @@ import { Emitter } from '../../../util/vs/base/common/event'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; import { basename, dirname } from '../../../util/vs/base/common/resources'; import { URI } from '../../../util/vs/base/common/uri'; -import type { Settings as ClaudeSettings } from '@anthropic-ai/claude-agent-sdk'; import { IClaudeRuntimeDataService } from '../claude/common/claudeRuntimeDataService'; +import { ClaudeSettingsFile, ClaudeSettingsLocationType, IClaudeSettingsService } from '../claude/common/claudeSettingsService'; import { ClaudeSessionUri } from '../claude/common/claudeSessionUri'; import { IPromptsService } from '../../../platform/promptFiles/common/promptsService'; @@ -57,10 +57,6 @@ interface MatcherConfig { readonly hooks: HookConfig[]; } -interface HooksSettings { - readonly hooks?: Partial>; -} - export class ClaudeCustomizationProvider extends Disposable implements vscode.ChatSessionCustomizationProvider { private readonly _onDidChange = this._register(new Emitter()); @@ -82,6 +78,7 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch constructor( @IPromptsService private readonly promptsService: IPromptsService, @IClaudeRuntimeDataService private readonly runtimeDataService: IClaudeRuntimeDataService, + @IClaudeSettingsService private readonly claudeSettingsService: IClaudeSettingsService, @IWorkspaceService private readonly workspaceService: IWorkspaceService, @IFileSystemService private readonly fileSystemService: IFileSystemService, @INativeEnvService private readonly envService: INativeEnvService, @@ -90,6 +87,7 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch super(); this._register(this.runtimeDataService.onDidChange(() => this._onDidChange.fire())); + this._register(this.claudeSettingsService.onDidChange(() => this._onDidChange.fire())); this._register(this.promptsService.onDidChangeCustomAgents(() => this._onDidChange.fire())); this._register(this.promptsService.onDidChangeSkills(() => this._onDidChange.fire())); this._register(this.workspaceService.onDidChangeWorkspaceFolders(() => this._onDidChange.fire())); @@ -132,13 +130,21 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch this.logService.debug(`[ClaudeCustomizationProvider] agents (${agentItems.length}): ${agentItems.map(a => a.name).join(', ') || '(none)'}${sdkAgents.length ? ' [sdk]' : ' [files-only, no session]'}`); // Instructions from hard-coded CLAUDE.md paths (checked for existence) - const settings = await this._readMergedSettings(); - const instructionItems = await this.discoverInstructions(settings); + const settingsFiles = await this.claudeSettingsService.readAllSettings(); + + // Collect claudeMdExcludes from all files + const instructionItems = await this.discoverInstructions(settingsFiles); items.push(...instructionItems); this.logService.debug(`[ClaudeCustomizationProvider] instructions (${instructionItems.length}): ${instructionItems.map(i => i.name).join(', ') || '(none)'}`); // Skills from .claude/skills/ directories (user-defined SKILL.md files) - const skillOverrides = settings.skillOverrides ?? {}; + // Merge skillOverrides across files (first-writer-wins per skill name) + const skillOverrides: Record = {}; + for (const s of [...settingsFiles].reverse()) { + if (s.settings.skillOverrides) { + Object.assign(skillOverrides, s.settings.skillOverrides); + } + } const skillItems: vscode.ChatSessionCustomizationItem[] = []; for (const skill of await this.promptsService.getSkills(token)) { if (this.isClaudePath(skill.uri)) { @@ -157,7 +163,8 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch this.logService.debug(`[ClaudeCustomizationProvider] skills (${skillItems.length}): ${skillItems.map(s => s.name).join(', ') || '(none)'}`); // Hooks from .claude/settings.json files - const hookItems = await this.discoverHooks(settings); + const disableAllHooks = settingsFiles.some(s => s.settings.disableAllHooks === true); + const hookItems = await this.discoverHooks(disableAllHooks); items.push(...hookItems); this.logService.debug(`[ClaudeCustomizationProvider] hooks (${hookItems.length}): ${hookItems.map(h => h.name).join(', ') || '(none)'}`); @@ -165,10 +172,9 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch return items; } - private async discoverInstructions(settings: ClaudeSettings): Promise { + private async discoverInstructions(settingsFiles: Readonly): Promise { const items: vscode.ChatSessionCustomizationItem[] = []; const candidates: URI[] = []; - const excludes = settings.claudeMdExcludes ?? []; for (const folder of this.workspaceService.getWorkspaceFolders()) { for (const entry of WORKSPACE_INSTRUCTION_PATHS) { @@ -187,18 +193,17 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch for (const uri of candidates) { if (await this.fileExists(uri)) { const name = basename(uri).replace(/\.md$/i, ''); - const excluded = excludes.some(pattern => this._matchesExclude(uri, pattern)); - // We can only toggle enablement for items excluded by the exact absolute - // path we write (our known pattern). Glob-based excludes from the user's - // settings are shown as disabled but cannot be toggled from the UI. - const excludedByKnownPattern = excluded && excludes.includes(uri.path); + const excluded = settingsFiles.some(s => s.settings.claudeMdExcludes?.some(pattern => this._matchesExclude(uri, pattern))); + const localSettings = settingsFiles.find(s => s.type === ClaudeSettingsLocationType.WorkspaceLocal); + const excludedByLocal = localSettings?.settings.claudeMdExcludes?.some(pattern => this._matchesExclude(uri, pattern)); + const excludedByKnownPattern = excluded && settingsFiles.some(s => s.settings.claudeMdExcludes?.includes(uri.path)); items.push({ uri, type: vscode.ChatSessionCustomizationType.Instructions, name, - enablementScope: !excluded || excludedByKnownPattern - ? vscode.ChatSessionCustomizationEnablementScope.Workspace - : vscode.ChatSessionCustomizationEnablementScope.None, + enablementScope: !excludedByKnownPattern || excludedByLocal + ? vscode.ChatSessionCustomizationEnablementScope.None : + vscode.ChatSessionCustomizationEnablementScope.Workspace, enabled: !excluded, }); } @@ -216,30 +221,31 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch } } - private async discoverHooks(settings: ClaudeSettings): Promise { + private async discoverHooks(allHooksDisabled: boolean): Promise { const items: vscode.ChatSessionCustomizationItem[] = []; - const settingsPaths = this.getSettingsFilePaths(); - const allHooksDisabled = settings.disableAllHooks === true; + const allSettings = await this.claudeSettingsService.readAllSettings(); - for (const settingsUri of settingsPaths) { + for (const settingsFile of allSettings) { try { - const content = await this.fileSystemService.readFile(settingsUri); - const fileSettings: HooksSettings = JSON.parse(new TextDecoder().decode(content)); - if (!fileSettings.hooks) { + if (!settingsFile.settings.hooks) { continue; } for (const eventId of HOOK_EVENT_IDS) { - const matchers = fileSettings.hooks[eventId]; + const matchers = settingsFile.settings.hooks[eventId]; if (!matchers || matchers.length === 0) { continue; } for (const matcher of matchers) { for (const hook of matcher.hooks) { + if (hook.type !== 'command') { + this.logService.warn(`[ClaudeCustomizationProvider] Unsupported hook type: ${hook.type} in ${settingsFile.uri}`); + continue; + } const matcherLabel = matcher.matcher === '*' ? '' : ` (${matcher.matcher})`; items.push({ - uri: settingsUri, + uri: settingsFile.uri, type: vscode.ChatSessionCustomizationType.Hook, name: `${eventId}${matcherLabel}`, description: hook.command, @@ -257,18 +263,6 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch return items; } - private getSettingsFilePaths(): URI[] { - const paths: URI[] = []; - - for (const folder of this.workspaceService.getWorkspaceFolders()) { - paths.push(URI.joinPath(folder, '.claude', 'settings.json')); - paths.push(URI.joinPath(folder, '.claude', 'settings.local.json')); - } - - paths.push(URI.joinPath(this.envService.userHome, '.claude', 'settings.json')); - return paths; - } - private isClaudePath(uri: URI): boolean { const folders = this.workspaceService.getWorkspaceFolders(); for (const folder of folders) { @@ -296,81 +290,6 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch // --- Settings --- - /** - * Path to the user-level claude settings file (`~/.claude/settings.json`). - */ - private get _userSettingsUri(): URI { - return URI.joinPath(this.envService.userHome, '.claude', 'settings.json'); - } - - /** - * Returns the workspace-local settings URI for the first workspace folder. - * Falls back to the user-level settings URI if no workspace folders exist. - */ - private get _workspaceSettingsUri(): URI { - const folders = this.workspaceService.getWorkspaceFolders(); - if (folders.length > 0) { - return URI.joinPath(folders[0], '.claude', 'settings.local.json'); - } - return this._userSettingsUri; - } - - /** - * Reads a single settings file as a typed object. - * Returns an empty object if the file doesn't exist or can't be parsed. - */ - private async _readSettingsFile(uri: URI): Promise { - try { - const bytes = await this.fileSystemService.readFile(uri); - const parsed = JSON.parse(new TextDecoder().decode(bytes)); - return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {}; - } catch { - return {}; - } - } - - /** - * Reads and merges settings from all settings files (workspace + user-level). - * Workspace settings take precedence over user-level for object keys; - * array values (e.g. `claudeMdExcludes`) are concatenated. - */ - private async _readMergedSettings(): Promise { - const allSettings = await Promise.all( - this.getSettingsFilePaths().map(uri => this._readSettingsFile(uri)) - ); - - const merged: Record = {}; - for (const settings of allSettings) { - for (const [key, value] of Object.entries(settings)) { - if (value === undefined) { - continue; - } - const existing = merged[key]; - if (Array.isArray(existing) && Array.isArray(value)) { - merged[key] = [...existing, ...value]; - } else if (existing === undefined) { - merged[key] = value; - } - // First-writer-wins for non-array scalar/object values - } - } - - return merged as ClaudeSettings; - } - - /** - * Writes settings to the appropriate file based on scope. - * - `Workspace`: writes to `/.claude/settings.local.json` - * - `Global` (or other): writes to `~/.claude/settings.json` - */ - private async _writeSettings(settings: ClaudeSettings, scope: vscode.ChatSessionCustomizationEnablementScope): Promise { - const targetUri = scope === vscode.ChatSessionCustomizationEnablementScope.Workspace - ? this._workspaceSettingsUri - : this._userSettingsUri; - const content = new TextEncoder().encode(JSON.stringify(settings, null, 4)); - await this.fileSystemService.writeFile(targetUri, content); - } - /** * Checks whether a URI matches a claudeMdExcludes pattern. * Patterns are matched against absolute file paths using picomatch, @@ -394,10 +313,13 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch // --- Enablement --- async handleCustomizationEnablement(uri: vscode.Uri, type: vscode.ChatSessionCustomizationType, enabled: boolean, scope: vscode.ChatSessionCustomizationEnablementScope, _token: vscode.CancellationToken): Promise { - const settingsFileUri = scope === vscode.ChatSessionCustomizationEnablementScope.Workspace - ? this._workspaceSettingsUri - : this._userSettingsUri; - const settings = { ...await this._readSettingsFile(settingsFileUri) }; + // TODO: should we support writing to settings.local.json files? + const location = scope === vscode.ChatSessionCustomizationEnablementScope.Workspace + ? ClaudeSettingsLocationType.Workspace + : ClaudeSettingsLocationType.User; + + const settingsUri = this.claudeSettingsService.getUri(location, uri); + const settings = { ...await this.claudeSettingsService.readSettingsFile(settingsUri) }; if (type.id === vscode.ChatSessionCustomizationType.Skill.id) { // skillOverrides: Record @@ -427,8 +349,8 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch } try { - await this._writeSettings(settings, scope); - this.logService.debug(`[ClaudeCustomizationProvider] ${enabled ? 'Enabled' : 'Disabled'} ${type.id} in ${settingsFileUri.toString()}`); + await this.claudeSettingsService.writeSettingsFile(settingsUri, settings); + this.logService.debug(`[ClaudeCustomizationProvider] ${enabled ? 'Enabled' : 'Disabled'} ${type.id} in ${location}`); this._onDidChange.fire(); } catch (err) { vscode.window.showErrorMessage(vscode.l10n.t('Failed to update Claude settings: {0}', err instanceof Error ? err.message : String(err))); diff --git a/extensions/copilot/src/extension/test/node/services.ts b/extensions/copilot/src/extension/test/node/services.ts index e7afea1809f0d..38eda9cc1c660 100644 --- a/extensions/copilot/src/extension/test/node/services.ts +++ b/extensions/copilot/src/extension/test/node/services.ts @@ -54,10 +54,12 @@ import { SyncDescriptor } from '../../../util/vs/platform/instantiation/common/d import { ILanguageModelServer } from '../../agents/node/langModelServer'; import { MockLanguageModelServer } from '../../agents/node/test/mockLanguageModelServer'; import { IClaudeRuntimeDataService } from '../../chatSessions/claude/common/claudeRuntimeDataService'; +import { IClaudeSettingsService } from '../../chatSessions/claude/common/claudeSettingsService'; import { IClaudeToolPermissionService } from '../../chatSessions/claude/common/claudeToolPermissionService'; import { ClaudeCodeModels, IClaudeCodeModels } from '../../chatSessions/claude/node/claudeCodeModels'; import { IClaudeCodeSdkService } from '../../chatSessions/claude/node/claudeCodeSdkService'; import { ClaudeRuntimeDataService } from '../../chatSessions/claude/node/claudeRuntimeDataService'; +import { ClaudeSettingsService } from '../../chatSessions/claude/node/claudeSettingsService'; import { IClaudePluginService } from '../../chatSessions/claude/node/claudeSkills'; import { IClaudeSessionStateService } from '../../chatSessions/claude/common/claudeSessionStateService'; import { ClaudeSessionStateService } from '../../chatSessions/claude/node/claudeSessionStateService'; @@ -130,6 +132,7 @@ export function createExtensionUnitTestingServices(disposables: Pick Date: Thu, 23 Apr 2026 19:37:01 -0700 Subject: [PATCH 17/36] clean --- .../claudeCustomizationProvider.ts | 83 +++++++++++-------- 1 file changed, 47 insertions(+), 36 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts index 708f1ebd80e60..f7e4ba90009b8 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts @@ -47,16 +47,6 @@ const HOOK_EVENT_IDS = [ 'PreCompact', 'SessionStart', 'SessionEnd', 'Notification', ] as const; -interface HookConfig { - readonly type: string; - readonly command: string; -} - -interface MatcherConfig { - readonly matcher: string; - readonly hooks: HookConfig[]; -} - export class ClaudeCustomizationProvider extends Disposable implements vscode.ChatSessionCustomizationProvider { private readonly _onDidChange = this._register(new Emitter()); @@ -318,43 +308,64 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch ? ClaudeSettingsLocationType.Workspace : ClaudeSettingsLocationType.User; - const settingsUri = this.claudeSettingsService.getUri(location, uri); - const settings = { ...await this.claudeSettingsService.readSettingsFile(settingsUri) }; + const allSettingsFiles = await this.claudeSettingsService.readAllSettings(); + + const writeSettings = async (settingsUri: URI, settings: Parameters[1]): Promise => { + try { + await this.claudeSettingsService.writeSettingsFile(settingsUri, settings); + } catch (err) { + void vscode.window.showErrorMessage(vscode.l10n.t('Failed to update Claude settings: {0}', err instanceof Error ? err.message : String(err))); + } + }; if (type.id === vscode.ChatSessionCustomizationType.Skill.id) { - // skillOverrides: Record - const folderName = basename(dirname(URI.from(uri))) || basename(URI.from(uri)); - const overrides = { ...settings.skillOverrides ?? {} }; - if (enabled) { - delete overrides[folderName]; - } else { - overrides[folderName] = 'off'; + const skillName = basename(dirname(uri)); + const targetSettingsUri = !enabled ? this.claudeSettingsService.getUri(location, uri) : undefined; + + for (const file of allSettingsFiles) { + const isTarget = targetSettingsUri?.toString() === file.uri.toString(); + const skillOverrides = { ...file.settings.skillOverrides ?? {} }; + let shouldUpdateSettings = skillName in skillOverrides; + + delete skillOverrides[skillName]; + if (isTarget) { + skillOverrides[skillName] = 'off'; + shouldUpdateSettings = true; + } + + if (shouldUpdateSettings) { + const updated = { ...file.settings, skillOverrides: Object.keys(skillOverrides).length > 0 ? skillOverrides : undefined }; + await writeSettings(file.uri, updated); + } } - settings.skillOverrides = Object.keys(overrides).length > 0 ? overrides : undefined; } else if (type.id === vscode.ChatSessionCustomizationType.Instructions.id) { - // claudeMdExcludes: string[] of absolute paths or glob patterns - const targetUri = URI.from(uri); - const currentExcludes = [...(settings.claudeMdExcludes ?? [])]; - if (enabled) { - settings.claudeMdExcludes = currentExcludes.filter(p => !this._matchesExclude(targetUri, p)); - } else { - if (!currentExcludes.some(p => this._matchesExclude(targetUri, p))) { - currentExcludes.push(targetUri.path); - settings.claudeMdExcludes = currentExcludes; + const instructionsUri = uri; + const targetSettingsUri = !enabled ? this.claudeSettingsService.getUri(location, uri) : undefined; + + for (const file of allSettingsFiles) { + const isTarget = targetSettingsUri?.toString() === file.uri.toString(); + const filtered = (file.settings.claudeMdExcludes ?? []).filter(p => p !== instructionsUri.path); + let shouldUpdateSettings = filtered.length !== (file.settings.claudeMdExcludes ?? []).length; + + const newExcludes = [...filtered]; + if (isTarget && !newExcludes.includes(instructionsUri.path)) { + newExcludes.push(instructionsUri.path); + shouldUpdateSettings = true; + } + + if (shouldUpdateSettings) { + const updated = { ...file.settings, claudeMdExcludes: newExcludes.length > 0 ? newExcludes : undefined }; + await writeSettings(file.uri, updated); } } } else { this.logService.warn(`[ClaudeCustomizationProvider] Per-item enablement not supported for type: ${type.id}`); + void vscode.window.showErrorMessage(vscode.l10n.t('Toggling {0} customizations is not supported.', type.id)); return; } - try { - await this.claudeSettingsService.writeSettingsFile(settingsUri, settings); - this.logService.debug(`[ClaudeCustomizationProvider] ${enabled ? 'Enabled' : 'Disabled'} ${type.id} in ${location}`); - this._onDidChange.fire(); - } catch (err) { - vscode.window.showErrorMessage(vscode.l10n.t('Failed to update Claude settings: {0}', err instanceof Error ? err.message : String(err))); - } + this.logService.debug(`[ClaudeCustomizationProvider] ${enabled ? 'Enabled' : 'Disabled'} ${type.id} in ${location}`); + this._onDidChange.fire(); } } From ed5c585aeaf685406a12843b66a7673c070fe504 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Thu, 23 Apr 2026 20:11:35 -0700 Subject: [PATCH 18/36] clean --- .../claude/common/claudeSettingsService.ts | 4 +- .../node/test/claudeSettingsService.spec.ts | 312 ++++++++++++++++++ .../claudeCustomizationProvider.ts | 59 ++-- .../test/claudeCustomizationProvider.spec.ts | 293 +++++++++++++--- .../browser/aiCustomizationTreeViewViews.ts | 4 +- ...promptsServiceCustomizationItemProvider.ts | 8 +- .../plugins/workspacePluginSettingsService.ts | 1 - 7 files changed, 602 insertions(+), 79 deletions(-) create mode 100644 extensions/copilot/src/extension/chatSessions/claude/node/test/claudeSettingsService.spec.ts diff --git a/extensions/copilot/src/extension/chatSessions/claude/common/claudeSettingsService.ts b/extensions/copilot/src/extension/chatSessions/claude/common/claudeSettingsService.ts index 76fea3bc53fad..9ffc6b82b7777 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/common/claudeSettingsService.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/common/claudeSettingsService.ts @@ -57,7 +57,7 @@ export interface IClaudeSettingsService { getUris(location?: ClaudeSettingsLocationType): URI[]; /** - * Returns a single URI for a settings location given a URI that can be used to identify a workspace folder. + * Returns the settings URI for the given location and a URI that belongs to a workspace folder. */ - getUri(location: ClaudeSettingsLocationType, uri: URI): URI; + getUri(location: ClaudeSettingsLocationType, workspaceUri: URI): URI; } diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeSettingsService.spec.ts b/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeSettingsService.spec.ts new file mode 100644 index 0000000000000..9eccbd972b452 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeSettingsService.spec.ts @@ -0,0 +1,312 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { INativeEnvService } from '../../../../../platform/env/common/envService'; +import { IFileSystemService } from '../../../../../platform/filesystem/common/fileSystemService'; +import { IWorkspaceService } from '../../../../../platform/workspace/common/workspaceService'; +import { mock } from '../../../../../util/common/test/simpleMock'; +import { Emitter, Event } from '../../../../../util/vs/base/common/event'; +import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle'; +import { URI } from '../../../../../util/vs/base/common/uri'; +import { ClaudeSettingsLocationType } from '../../common/claudeSettingsService'; +import { ClaudeSettingsService } from '../claudeSettingsService'; +import type { FileSystemWatcher, RelativePattern } from 'vscode'; + +class MockWorkspaceService extends mock() { + private _folders: URI[] = []; + private readonly _onDidChange = new Emitter(); + override readonly onDidChangeWorkspaceFolders: Event = this._onDidChange.event; + setFolders(folders: URI[]) { this._folders = folders; } + override getWorkspaceFolders(): URI[] { return this._folders; } + dispose() { this._onDidChange.dispose(); } +} + +class MockFileSystemService extends mock() { + private readonly _files = new Map(); + readonly writtenFiles = new Map(); + + setFile(uri: URI, content: string) { + this._files.set(uri.toString(), new TextEncoder().encode(content)); + } + + override async readFile(uri: URI): Promise { + const content = this._files.get(uri.toString()); + if (!content) { + throw new Error(`File not found: ${uri.toString()}`); + } + return content; + } + + override async writeFile(uri: URI, content: Uint8Array): Promise { + this._files.set(uri.toString(), content); + this.writtenFiles.set(uri.toString(), content); + } + + override createFileSystemWatcher(_glob: string | RelativePattern): FileSystemWatcher { + return { + ignoreCreateEvents: false, + ignoreChangeEvents: false, + ignoreDeleteEvents: false, + onDidCreate: Event.None, + onDidChange: Event.None, + onDidDelete: Event.None, + dispose() { }, + }; + } +} + +class MockEnvService extends mock() { + override userHome = URI.file('/home/user'); +} + +describe('ClaudeSettingsService', () => { + let disposables: DisposableStore; + let mockWorkspaceService: MockWorkspaceService; + let mockFileSystemService: MockFileSystemService; + let service: ClaudeSettingsService; + + const workspaceFolder = URI.file('/workspace'); + + beforeEach(() => { + disposables = new DisposableStore(); + mockWorkspaceService = disposables.add(new MockWorkspaceService()); + mockWorkspaceService.setFolders([workspaceFolder]); + mockFileSystemService = new MockFileSystemService(); + service = disposables.add(new ClaudeSettingsService( + mockWorkspaceService, + mockFileSystemService, + new MockEnvService(), + )); + }); + + afterEach(() => { + disposables.dispose(); + }); + + describe('getUris', () => { + it('returns user settings URI', () => { + const uris = service.getUris(ClaudeSettingsLocationType.User); + expect(uris).toHaveLength(1); + expect(uris[0].path).toBe('/home/user/.claude/settings.json'); + }); + + it('returns workspace settings URI for each folder', () => { + const uris = service.getUris(ClaudeSettingsLocationType.Workspace); + expect(uris).toHaveLength(1); + expect(uris[0].path).toBe('/workspace/.claude/settings.json'); + }); + + it('returns workspace local settings URI for each folder', () => { + const uris = service.getUris(ClaudeSettingsLocationType.WorkspaceLocal); + expect(uris).toHaveLength(1); + expect(uris[0].path).toBe('/workspace/.claude/settings.local.json'); + }); + + it('returns all URIs when no location specified', () => { + const uris = service.getUris(); + expect(uris).toHaveLength(3); + }); + + it('returns URIs for multiple workspace folders', () => { + const folder2 = URI.file('/workspace2'); + mockWorkspaceService.setFolders([workspaceFolder, folder2]); + // Re-create service to pick up new folders + service.dispose(); + service = disposables.add(new ClaudeSettingsService( + mockWorkspaceService, + mockFileSystemService, + new MockEnvService(), + )); + + const workspaceUris = service.getUris(ClaudeSettingsLocationType.Workspace); + expect(workspaceUris).toHaveLength(2); + expect(workspaceUris[0].path).toBe('/workspace/.claude/settings.json'); + expect(workspaceUris[1].path).toBe('/workspace2/.claude/settings.json'); + }); + }); + + describe('readSettingsFile', () => { + it('returns parsed JSON from a settings file', async () => { + const uri = URI.file('/home/user/.claude/settings.json'); + mockFileSystemService.setFile(uri, JSON.stringify({ permissions: { allow: ['Read'] } })); + + const result = await service.readSettingsFile(uri); + expect(result).toEqual({ permissions: { allow: ['Read'] } }); + }); + + it('returns empty object when file does not exist', async () => { + const uri = URI.file('/home/user/.claude/settings.json'); + const result = await service.readSettingsFile(uri); + expect(result).toEqual({}); + }); + + it('returns empty object for invalid JSON', async () => { + const uri = URI.file('/home/user/.claude/settings.json'); + mockFileSystemService.setFile(uri, 'not valid json'); + + const result = await service.readSettingsFile(uri); + expect(result).toEqual({}); + }); + + it('returns empty object for JSON arrays', async () => { + const uri = URI.file('/home/user/.claude/settings.json'); + mockFileSystemService.setFile(uri, '[1, 2, 3]'); + + const result = await service.readSettingsFile(uri); + expect(result).toEqual({}); + }); + }); + + describe('readAllSettings', () => { + it('reads all settings files and returns them with metadata', async () => { + const userUri = URI.file('/home/user/.claude/settings.json'); + const wsUri = URI.file('/workspace/.claude/settings.json'); + const wsLocalUri = URI.file('/workspace/.claude/settings.local.json'); + + mockFileSystemService.setFile(userUri, JSON.stringify({ permissions: { allow: ['Read'] } })); + mockFileSystemService.setFile(wsUri, JSON.stringify({ permissions: { deny: ['Write'] } })); + mockFileSystemService.setFile(wsLocalUri, JSON.stringify({ env: { DEBUG: '1' } })); + + const results = await service.readAllSettings(); + expect(results).toHaveLength(3); + expect(results[0].settings).toEqual({ permissions: { allow: ['Read'] } }); + expect(results[1].settings).toEqual({ permissions: { deny: ['Write'] } }); + expect(results[2].settings).toEqual({ env: { DEBUG: '1' } }); + }); + + it('returns empty objects for missing files', async () => { + const results = await service.readAllSettings(); + expect(results).toHaveLength(3); + for (const result of results) { + expect(result.settings).toEqual({}); + } + }); + + it('caches results across calls', async () => { + const userUri = URI.file('/home/user/.claude/settings.json'); + mockFileSystemService.setFile(userUri, JSON.stringify({ cached: true })); + + const first = await service.readAllSettings(); + // Mutate the file — cache should return stale data + mockFileSystemService.setFile(userUri, JSON.stringify({ cached: false })); + const second = await service.readAllSettings(); + + expect(first).toBe(second); + }); + }); + + describe('getUri', () => { + it('returns User settings URI regardless of input URI', () => { + const uri = service.getUri(ClaudeSettingsLocationType.User, workspaceFolder); + expect(uri.path).toBe('/home/user/.claude/settings.json'); + }); + + it('returns Workspace settings URI for single folder', () => { + const itemUri = URI.file('/workspace/src/file.ts'); + const uri = service.getUri(ClaudeSettingsLocationType.Workspace, itemUri); + expect(uri.path).toBe('/workspace/.claude/settings.json'); + }); + + it('returns WorkspaceLocal settings URI for single folder', () => { + const itemUri = URI.file('/workspace/src/file.ts'); + const uri = service.getUri(ClaudeSettingsLocationType.WorkspaceLocal, itemUri); + expect(uri.path).toBe('/workspace/.claude/settings.local.json'); + }); + }); + + describe('writeSettingsFile', () => { + it('writes settings as formatted JSON', async () => { + const uri = URI.file('/home/user/.claude/settings.json'); + const settings = { permissions: { allow: ['Read', 'Write'] } }; + + await service.writeSettingsFile(uri, settings); + + const written = mockFileSystemService.writtenFiles.get(uri.toString()); + expect(written).toBeDefined(); + const parsed = JSON.parse(new TextDecoder().decode(written!)); + expect(parsed).toEqual(settings); + }); + + it('uses 4-space indentation', async () => { + const uri = URI.file('/home/user/.claude/settings.json'); + await service.writeSettingsFile(uri, { key: 'value' }); + + const written = new TextDecoder().decode(mockFileSystemService.writtenFiles.get(uri.toString())!); + expect(written).toBe(JSON.stringify({ key: 'value' }, null, 4)); + }); + }); + + describe('onDidChange', () => { + it('fires when a watched file changes', () => { + const changeEmitters: Emitter[] = []; + const fsService = new class extends MockFileSystemService { + override createFileSystemWatcher(): FileSystemWatcher { + const changeEmitter = new Emitter(); + changeEmitters.push(changeEmitter); + return { + ignoreCreateEvents: false, + ignoreChangeEvents: false, + ignoreDeleteEvents: false, + onDidCreate: Event.None, + onDidChange: changeEmitter.event, + onDidDelete: Event.None, + dispose() { }, + }; + } + }(); + + const svc = disposables.add(new ClaudeSettingsService( + mockWorkspaceService, + fsService, + new MockEnvService(), + )); + + let fired = false; + disposables.add(svc.onDidChange(() => { fired = true; })); + + // Fire one of the watcher change events + changeEmitters[0].fire(URI.file('/some/path')); + expect(fired).toBe(true); + }); + + it('invalidates cache when a file changes', async () => { + const changeEmitters: Emitter[] = []; + const fsService = new class extends MockFileSystemService { + override createFileSystemWatcher(): FileSystemWatcher { + const changeEmitter = new Emitter(); + changeEmitters.push(changeEmitter); + return { + ignoreCreateEvents: false, + ignoreChangeEvents: false, + ignoreDeleteEvents: false, + onDidCreate: Event.None, + onDidChange: changeEmitter.event, + onDidDelete: Event.None, + dispose() { }, + }; + } + }(); + + const svc = disposables.add(new ClaudeSettingsService( + mockWorkspaceService, + fsService, + new MockEnvService(), + )); + + const userUri = URI.file('/home/user/.claude/settings.json'); + fsService.setFile(userUri, JSON.stringify({ original: true })); + const first = await svc.readAllSettings(); + + // Update file and fire change + fsService.setFile(userUri, JSON.stringify({ updated: true })); + changeEmitters[0].fire(userUri); + + const second = await svc.readAllSettings(); + expect(first).not.toBe(second); + expect(second[0].settings).toEqual({ updated: true }); + }); + }); +}); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts index f7e4ba90009b8..f7011070249e6 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts @@ -17,6 +17,7 @@ import { IClaudeRuntimeDataService } from '../claude/common/claudeRuntimeDataSer import { ClaudeSettingsFile, ClaudeSettingsLocationType, IClaudeSettingsService } from '../claude/common/claudeSettingsService'; import { ClaudeSessionUri } from '../claude/common/claudeSessionUri'; import { IPromptsService } from '../../../platform/promptFiles/common/promptsService'; +import { HOOK_EVENTS } from '@anthropic-ai/claude-agent-sdk'; // TODO: Consider reporting Claude slash commands (from Query.supportedCommands()) when appropriate // TODO: Report MCP servers when ChatSessionCustomizationType.Mcp is available (use Query.mcpServerStatus()) @@ -37,16 +38,6 @@ const HOME_INSTRUCTION_PATHS = [ ['.claude', 'CLAUDE.md'] as const, ] as const; -/** - * Hook event IDs that Claude supports, matching the HookEvent types from - * the Claude Agent SDK. Used to discover hooks from .claude/settings.json. - */ -const HOOK_EVENT_IDS = [ - 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'PermissionRequest', - 'UserPromptSubmit', 'Stop', 'SubagentStart', 'SubagentStop', - 'PreCompact', 'SessionStart', 'SessionEnd', 'Notification', -] as const; - export class ClaudeCustomizationProvider extends Disposable implements vscode.ChatSessionCustomizationProvider { private readonly _onDidChange = this._register(new Emitter()); @@ -138,13 +129,14 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch const skillItems: vscode.ChatSessionCustomizationItem[] = []; for (const skill of await this.promptsService.getSkills(token)) { if (this.isClaudePath(skill.uri)) { - const folderName = basename(dirname(skill.uri)); - const override = skillOverrides[folderName]; + const skillName = basename(dirname(skill.uri)); + const override = skillOverrides[skillName]; const item: vscode.ChatSessionCustomizationItem = { uri: skill.uri, type: vscode.ChatSessionCustomizationType.Skill, name: skill.name, enabled: override !== 'off', + enablementScope: vscode.ChatSessionCustomizationEnablementScope.Workspace }; skillItems.push(item); } @@ -153,8 +145,7 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch this.logService.debug(`[ClaudeCustomizationProvider] skills (${skillItems.length}): ${skillItems.map(s => s.name).join(', ') || '(none)'}`); // Hooks from .claude/settings.json files - const disableAllHooks = settingsFiles.some(s => s.settings.disableAllHooks === true); - const hookItems = await this.discoverHooks(disableAllHooks); + const hookItems = await this.discoverHooks(settingsFiles); items.push(...hookItems); this.logService.debug(`[ClaudeCustomizationProvider] hooks (${hookItems.length}): ${hookItems.map(h => h.name).join(', ') || '(none)'}`); @@ -211,17 +202,16 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch } } - private async discoverHooks(allHooksDisabled: boolean): Promise { + private async discoverHooks(settingsFiles: Readonly): Promise { const items: vscode.ChatSessionCustomizationItem[] = []; - const allSettings = await this.claudeSettingsService.readAllSettings(); - for (const settingsFile of allSettings) { + for (const settingsFile of settingsFiles) { try { if (!settingsFile.settings.hooks) { continue; } - for (const eventId of HOOK_EVENT_IDS) { + for (const eventId of HOOK_EVENTS) { const matchers = settingsFile.settings.hooks[eventId]; if (!matchers || matchers.length === 0) { continue; @@ -229,18 +219,23 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch for (const matcher of matchers) { for (const hook of matcher.hooks) { - if (hook.type !== 'command') { - this.logService.warn(`[ClaudeCustomizationProvider] Unsupported hook type: ${hook.type} in ${settingsFile.uri}`); - continue; - } const matcherLabel = matcher.matcher === '*' ? '' : ` (${matcher.matcher})`; + let description: string | undefined; + switch (hook.type) { + case 'command': description = hook.command; break; + case 'prompt': description = hook.prompt; break; + case 'agent': description = hook.prompt; break; + case 'http': description = hook.url; break; + } items.push({ uri: settingsFile.uri, type: vscode.ChatSessionCustomizationType.Hook, name: `${eventId}${matcherLabel}`, - description: hook.command, - enabled: !allHooksDisabled, - // Individual hooks can't be toggled — only disableAllHooks + description, + enabled: settingsFile.settings.disableAllHooks !== true, + enablementScope: settingsFile.type === ClaudeSettingsLocationType.User + ? vscode.ChatSessionCustomizationEnablementScope.Global + : vscode.ChatSessionCustomizationEnablementScope.Workspace, }); } } @@ -358,6 +353,20 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch await writeSettings(file.uri, updated); } } + } else if (type.id === vscode.ChatSessionCustomizationType.Hook.id) { + // Hooks are toggled via the disableAllHooks flag in the settings file + // that contains them. Toggling any hook toggles all hooks in that file. + for (const file of allSettingsFiles) { + if (file.uri.toString() !== uri.toString()) { + continue; + } + const newValue = !enabled ? true : undefined; + const shouldUpdateSettings = file.settings.disableAllHooks !== newValue; + if (shouldUpdateSettings) { + const updated = { ...file.settings, disableAllHooks: newValue }; + await writeSettings(file.uri, updated); + } + } } else { this.logService.warn(`[ClaudeCustomizationProvider] Per-item enablement not supported for type: ${type.id}`); void vscode.window.showErrorMessage(vscode.l10n.t('Toggling {0} customizations is not supported.', type.id)); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts index 0a480c862e244..0b79da21a4323 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { AgentInfo } from '@anthropic-ai/claude-agent-sdk'; +import type { AgentInfo, Settings as ClaudeSettings } from '@anthropic-ai/claude-agent-sdk'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import * as vscode from 'vscode'; import { INativeEnvService } from '../../../../platform/env/common/envService'; @@ -15,6 +15,7 @@ import { Emitter, Event } from '../../../../util/vs/base/common/event'; import { DisposableStore } from '../../../../util/vs/base/common/lifecycle'; import { URI } from '../../../../util/vs/base/common/uri'; import { IClaudeRuntimeDataService } from '../../claude/common/claudeRuntimeDataService'; +import { ClaudeSettingsFile, ClaudeSettingsLocationType, IClaudeSettingsService } from '../../claude/common/claudeSettingsService'; import { ClaudeCustomizationProvider } from '../claudeCustomizationProvider'; import { MockPromptsService } from '../../../../platform/promptFiles/test/common/mockPromptsService'; @@ -32,9 +33,17 @@ class FakeChatSessionCustomizationType { static readonly Instructions = new FakeChatSessionCustomizationType('instructions'); static readonly Prompt = new FakeChatSessionCustomizationType('prompt'); static readonly Hook = new FakeChatSessionCustomizationType('hook'); + static readonly Plugins = new FakeChatSessionCustomizationType('plugins'); constructor(readonly id: string) { } } +const FakeChatSessionCustomizationEnablementScope = { + None: 0, + Global: 1, + Workspace: 2, + ManagedByApplication: 3, +} as const; + class MockRuntimeDataService extends mock() { private readonly _onDidChange = new Emitter(); override readonly onDidChange = this._onDidChange.event; @@ -75,6 +84,70 @@ class MockFileSystemService extends mock() { } } +class MockClaudeSettingsService extends mock() { + private readonly _onDidChange = new Emitter(); + override readonly onDidChange = this._onDidChange.event; + private readonly _files = new Map(); + private readonly _writtenFiles = new Map(); + private _settingsUris: URI[] = []; + + setSettingsUris(uris: URI[]) { this._settingsUris = uris; } + + setFile(uri: URI, settings: ClaudeSettings) { + this._files.set(uri.toString(), settings); + } + + getWrittenFile(uri: URI): ClaudeSettings | undefined { + return this._writtenFiles.get(uri.toString()); + } + + override getUris(location?: ClaudeSettingsLocationType): URI[] { + return this._settingsUris.filter(u => { + if (!location) { + return true; + } + if (location === ClaudeSettingsLocationType.User) { + return u.path.includes('/home/user/'); + } + if (location === ClaudeSettingsLocationType.WorkspaceLocal) { + return u.path.endsWith('.local.json'); + } + return u.path.includes('/workspace/') && !u.path.endsWith('.local.json'); + }); + } + + override getUri(location: ClaudeSettingsLocationType, _uri: URI): URI { + const uris = this.getUris(location); + return uris[0]; + } + + override async readSettingsFile(uri: URI): Promise { + return this._files.get(uri.toString()) ?? {}; + } + + override async readAllSettings(): Promise> { + return this._settingsUris.map(uri => { + let type: ClaudeSettingsLocationType; + if (uri.path.includes('/home/user/')) { + type = ClaudeSettingsLocationType.User; + } else if (uri.path.endsWith('.local.json')) { + type = ClaudeSettingsLocationType.WorkspaceLocal; + } else { + type = ClaudeSettingsLocationType.Workspace; + } + return { type, settings: this._files.get(uri.toString()) ?? {}, uri }; + }); + } + + override async writeSettingsFile(uri: URI, settings: ClaudeSettings): Promise { + this._files.set(uri.toString(), settings); + this._writtenFiles.set(uri.toString(), settings); + } + + fireChanged() { this._onDidChange.fire(); } + dispose() { this._onDidChange.dispose(); } +} + class MockEnvService extends mock() { override userHome = URI.file('/home/user'); } @@ -82,29 +155,36 @@ class MockEnvService extends mock() { class TestLogService extends mock() { override trace() { } override debug() { } + override warn() { } } describe('ClaudeCustomizationProvider', () => { let disposables: DisposableStore; let mockRuntimeDataService: MockRuntimeDataService; let mockPromptsService: MockPromptsService; + let mockClaudeSettingsService: MockClaudeSettingsService; let mockWorkspaceService: MockWorkspaceService; let mockFileSystemService: MockFileSystemService; let provider: ClaudeCustomizationProvider; let originalChatSessionCustomizationType: unknown; + let originalChatSessionCustomizationEnablementScope: unknown; beforeEach(() => { originalChatSessionCustomizationType = (vscode as Record).ChatSessionCustomizationType; + originalChatSessionCustomizationEnablementScope = (vscode as Record).ChatSessionCustomizationEnablementScope; (vscode as Record).ChatSessionCustomizationType = FakeChatSessionCustomizationType; + (vscode as Record).ChatSessionCustomizationEnablementScope = FakeChatSessionCustomizationEnablementScope; disposables = new DisposableStore(); mockRuntimeDataService = disposables.add(new MockRuntimeDataService()); mockPromptsService = disposables.add(new MockPromptsService()); + mockClaudeSettingsService = disposables.add(new MockClaudeSettingsService()); mockWorkspaceService = new MockWorkspaceService(); mockFileSystemService = new MockFileSystemService(); provider = disposables.add(new ClaudeCustomizationProvider( mockPromptsService, mockRuntimeDataService, + mockClaudeSettingsService, mockWorkspaceService, mockFileSystemService, new MockEnvService(), @@ -115,6 +195,7 @@ describe('ClaudeCustomizationProvider', () => { afterEach(() => { disposables.dispose(); (vscode as Record).ChatSessionCustomizationType = originalChatSessionCustomizationType; + (vscode as Record).ChatSessionCustomizationEnablementScope = originalChatSessionCustomizationEnablementScope; }); describe('metadata', () => { @@ -123,14 +204,15 @@ describe('ClaudeCustomizationProvider', () => { expect(ClaudeCustomizationProvider.metadata.iconId).toBe('claude'); }); - it('supports Agent, Skill, Instructions, and Hook types', () => { + it('supports Agent, Skill, Instructions, Hook, and Plugins types', () => { const supported = ClaudeCustomizationProvider.metadata.supportedTypes; expect(supported).toBeDefined(); - expect(supported).toHaveLength(4); + expect(supported).toHaveLength(5); expect(supported).toContain(FakeChatSessionCustomizationType.Agent); expect(supported).toContain(FakeChatSessionCustomizationType.Skill); expect(supported).toContain(FakeChatSessionCustomizationType.Instructions); expect(supported).toContain(FakeChatSessionCustomizationType.Hook); + expect(supported).toContain(FakeChatSessionCustomizationType.Plugins); }); it('only returns items whose type is in supportedTypes', async () => { @@ -321,10 +403,11 @@ describe('ClaudeCustomizationProvider', () => { mockRuntimeDataService.setAgents([{ name: 'Explore', description: 'Agent' }]); mockFileSystemService.setFile(URI.joinPath(URI.file('/workspace'), 'CLAUDE.md'), '# Instructions'); mockPromptsService.setSkills([mockSkill(URI.file('/workspace/.claude/skills/s/SKILL.md'), 's')]); - mockFileSystemService.setFile( - URI.joinPath(URI.file('/workspace'), '.claude', 'settings.json'), - JSON.stringify({ hooks: { SessionStart: [{ matcher: '*', hooks: [{ type: 'command', command: './init.sh' }] }] } }) - ); + const settingsUri = URI.joinPath(URI.file('/workspace'), '.claude', 'settings.json'); + mockClaudeSettingsService.setSettingsUris([settingsUri]); + mockClaudeSettingsService.setFile(settingsUri, { + hooks: { SessionStart: [{ matcher: '*', hooks: [{ type: 'command', command: './init.sh' }] }] } + }); const items = await provider.provideChatSessionCustomizations(undefined!); expect(items.filter(i => i.type === FakeChatSessionCustomizationType.Agent)).toHaveLength(1); @@ -335,17 +418,18 @@ describe('ClaudeCustomizationProvider', () => { }); describe('hook discovery', () => { - it('discovers hooks from workspace .claude/settings.json', async () => { + it('discovers hooks from workspace settings', async () => { const workspaceFolder = URI.file('/workspace'); mockWorkspaceService.setFolders([workspaceFolder]); const settingsUri = URI.joinPath(workspaceFolder, '.claude', 'settings.json'); - mockFileSystemService.setFile(settingsUri, JSON.stringify({ + mockClaudeSettingsService.setSettingsUris([settingsUri]); + mockClaudeSettingsService.setFile(settingsUri, { hooks: { PreToolUse: [ { matcher: 'Bash', hooks: [{ type: 'command', command: './scripts/pre-bash.sh' }] } ] } - })); + }); const items = await provider.provideChatSessionCustomizations(undefined!); const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook); @@ -358,16 +442,15 @@ describe('ClaudeCustomizationProvider', () => { it('uses wildcard label for * matcher', async () => { const workspaceFolder = URI.file('/workspace'); mockWorkspaceService.setFolders([workspaceFolder]); - mockFileSystemService.setFile( - URI.joinPath(workspaceFolder, '.claude', 'settings.json'), - JSON.stringify({ - hooks: { - SessionStart: [ - { matcher: '*', hooks: [{ type: 'command', command: './init.sh' }] } - ] - } - }) - ); + const settingsUri = URI.joinPath(workspaceFolder, '.claude', 'settings.json'); + mockClaudeSettingsService.setSettingsUris([settingsUri]); + mockClaudeSettingsService.setFile(settingsUri, { + hooks: { + SessionStart: [ + { matcher: '*', hooks: [{ type: 'command', command: './init.sh' }] } + ] + } + }); const items = await provider.provideChatSessionCustomizations(undefined!); const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook); @@ -375,15 +458,16 @@ describe('ClaudeCustomizationProvider', () => { expect(hookItems[0].name).toBe('SessionStart'); }); - it('discovers hooks from user home .claude/settings.json', async () => { + it('discovers hooks from user home settings', async () => { const userSettingsUri = URI.joinPath(URI.file('/home/user'), '.claude', 'settings.json'); - mockFileSystemService.setFile(userSettingsUri, JSON.stringify({ + mockClaudeSettingsService.setSettingsUris([userSettingsUri]); + mockClaudeSettingsService.setFile(userSettingsUri, { hooks: { PostToolUse: [ { matcher: 'Edit', hooks: [{ type: 'command', command: './lint.sh' }] } ] } - })); + }); const items = await provider.provideChatSessionCustomizations(undefined!); const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook); @@ -394,43 +478,154 @@ describe('ClaudeCustomizationProvider', () => { it('discovers multiple hooks across event types', async () => { const workspaceFolder = URI.file('/workspace'); mockWorkspaceService.setFolders([workspaceFolder]); - mockFileSystemService.setFile( - URI.joinPath(workspaceFolder, '.claude', 'settings.json'), - JSON.stringify({ - hooks: { - PreToolUse: [ - { matcher: 'Bash', hooks: [{ type: 'command', command: './a.sh' }] }, - { matcher: 'Edit', hooks: [{ type: 'command', command: './b.sh' }, { type: 'command', command: './c.sh' }] }, - ], - SessionStart: [ - { matcher: '*', hooks: [{ type: 'command', command: './init.sh' }] } - ] - } - }) - ); + const settingsUri = URI.joinPath(workspaceFolder, '.claude', 'settings.json'); + mockClaudeSettingsService.setSettingsUris([settingsUri]); + mockClaudeSettingsService.setFile(settingsUri, { + hooks: { + PreToolUse: [ + { matcher: 'Bash', hooks: [{ type: 'command', command: './a.sh' }] }, + { matcher: 'Edit', hooks: [{ type: 'command', command: './b.sh' }, { type: 'command', command: './c.sh' }] }, + ], + SessionStart: [ + { matcher: '*', hooks: [{ type: 'command', command: './init.sh' }] } + ] + } + }); const items = await provider.provideChatSessionCustomizations(undefined!); const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook); expect(hookItems).toHaveLength(4); }); - it('gracefully handles missing settings files', async () => { + it('reports hooks as enabled when disableAllHooks is not set', async () => { + const settingsUri = URI.joinPath(URI.file('/workspace'), '.claude', 'settings.json'); + mockWorkspaceService.setFolders([URI.file('/workspace')]); + mockClaudeSettingsService.setSettingsUris([settingsUri]); + mockClaudeSettingsService.setFile(settingsUri, { + hooks: { SessionStart: [{ matcher: '*', hooks: [{ type: 'command', command: './init.sh' }] }] } + }); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook); + expect(hookItems[0].enabled).toBe(true); + }); + + it('reports hooks as disabled when disableAllHooks is true', async () => { + const settingsUri = URI.joinPath(URI.file('/workspace'), '.claude', 'settings.json'); + mockWorkspaceService.setFolders([URI.file('/workspace')]); + mockClaudeSettingsService.setSettingsUris([settingsUri]); + mockClaudeSettingsService.setFile(settingsUri, { + disableAllHooks: true, + hooks: { SessionStart: [{ matcher: '*', hooks: [{ type: 'command', command: './init.sh' }] }] } + }); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook); + expect(hookItems[0].enabled).toBe(false); + }); + + it('gracefully handles no settings files', async () => { mockWorkspaceService.setFolders([URI.file('/workspace')]); const items = await provider.provideChatSessionCustomizations(undefined!); expect(items).toEqual([]); }); + }); + + describe('hook enablement', () => { + it('disables hooks by setting disableAllHooks to true', async () => { + const workspaceFolder = URI.file('/workspace'); + mockWorkspaceService.setFolders([workspaceFolder]); + const settingsUri = URI.joinPath(workspaceFolder, '.claude', 'settings.json'); + mockClaudeSettingsService.setSettingsUris([settingsUri]); + mockClaudeSettingsService.setFile(settingsUri, { + hooks: { PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: './check.sh' }] }] } + }); + + await provider.handleCustomizationEnablement( + settingsUri, FakeChatSessionCustomizationType.Hook as any, + false, 2 /* Workspace */, undefined!); + + const written = mockClaudeSettingsService.getWrittenFile(settingsUri); + expect(written).toBeDefined(); + expect(written!.disableAllHooks).toBe(true); + }); - it('gracefully handles invalid JSON in settings', async () => { + it('enables hooks by removing disableAllHooks', async () => { const workspaceFolder = URI.file('/workspace'); mockWorkspaceService.setFolders([workspaceFolder]); - mockFileSystemService.setFile( - URI.joinPath(workspaceFolder, '.claude', 'settings.json'), - 'not valid json {' - ); + const settingsUri = URI.joinPath(workspaceFolder, '.claude', 'settings.json'); + mockClaudeSettingsService.setSettingsUris([settingsUri]); + mockClaudeSettingsService.setFile(settingsUri, { + disableAllHooks: true, + hooks: { PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: './check.sh' }] }] } + }); + + await provider.handleCustomizationEnablement( + settingsUri, FakeChatSessionCustomizationType.Hook as any, + true, 2 /* Workspace */, undefined!); + + const written = mockClaudeSettingsService.getWrittenFile(settingsUri); + expect(written).toBeDefined(); + expect(written!.disableAllHooks).toBeUndefined(); + }); - const items = await provider.provideChatSessionCustomizations(undefined!); - expect(items).toEqual([]); + it('preserves other settings when toggling hooks', async () => { + const workspaceFolder = URI.file('/workspace'); + mockWorkspaceService.setFolders([workspaceFolder]); + const settingsUri = URI.joinPath(workspaceFolder, '.claude', 'settings.json'); + mockClaudeSettingsService.setSettingsUris([settingsUri]); + mockClaudeSettingsService.setFile(settingsUri, { + permissions: { allow: ['Read'] }, + hooks: { SessionStart: [{ matcher: '*', hooks: [{ type: 'command', command: './init.sh' }] }] } + }); + + await provider.handleCustomizationEnablement( + settingsUri, FakeChatSessionCustomizationType.Hook as any, + false, 2 /* Workspace */, undefined!); + + const written = mockClaudeSettingsService.getWrittenFile(settingsUri); + expect(written!.permissions).toEqual({ allow: ['Read'] }); + expect(written!.hooks).toBeDefined(); + }); + + it('only modifies the settings file matching the hook URI', async () => { + const userUri = URI.joinPath(URI.file('/home/user'), '.claude', 'settings.json'); + const wsUri = URI.joinPath(URI.file('/workspace'), '.claude', 'settings.json'); + mockWorkspaceService.setFolders([URI.file('/workspace')]); + mockClaudeSettingsService.setSettingsUris([userUri, wsUri]); + mockClaudeSettingsService.setFile(userUri, { + hooks: { SessionStart: [{ matcher: '*', hooks: [{ type: 'command', command: './user-init.sh' }] }] } + }); + mockClaudeSettingsService.setFile(wsUri, { + hooks: { PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: './ws-check.sh' }] }] } + }); + + // Disable hooks in the workspace file only + await provider.handleCustomizationEnablement( + wsUri, FakeChatSessionCustomizationType.Hook as any, + false, 2 /* Workspace */, undefined!); + + expect(mockClaudeSettingsService.getWrittenFile(wsUri)!.disableAllHooks).toBe(true); + expect(mockClaudeSettingsService.getWrittenFile(userUri)).toBeUndefined(); + }); + + it('fires onDidChange after toggling hooks', async () => { + const settingsUri = URI.joinPath(URI.file('/workspace'), '.claude', 'settings.json'); + mockWorkspaceService.setFolders([URI.file('/workspace')]); + mockClaudeSettingsService.setSettingsUris([settingsUri]); + mockClaudeSettingsService.setFile(settingsUri, { + hooks: { SessionStart: [{ matcher: '*', hooks: [{ type: 'command', command: './init.sh' }] }] } + }); + + let fired = false; + disposables.add(provider.onDidChange(() => { fired = true; })); + + await provider.handleCustomizationEnablement( + settingsUri, FakeChatSessionCustomizationType.Hook as any, + false, 2 /* Workspace */, undefined!); + + expect(fired).toBe(true); }); }); @@ -466,5 +661,13 @@ describe('ClaudeCustomizationProvider', () => { mockWorkspaceService.fireWorkspaceFoldersChanged(); expect(fired).toBe(true); }); + + it('fires when claude settings change', () => { + let fired = false; + disposables.add(provider.onDidChange(() => { fired = true; })); + + mockClaudeSettingsService.fireChanged(); + expect(fired).toBe(true); + }); }); }); diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts index fe31a079fb13d..fe5c91f5cc531 100644 --- a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts @@ -42,6 +42,7 @@ import { IEditorService } from '../../../../workbench/services/editor/common/edi import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { getSkillFolderName } from '../../../../workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.js'; //#region Context Keys @@ -545,8 +546,7 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource { seenUris.add(skill.uri.toString()); - // Use skill name from frontmatter, or fallback to parent folder name - const skillName = skill.name || basename(dirname(skill.uri)) || basename(skill.uri); + const skillName = getSkillFolderName(skill.uri); return { type: 'file' as const, id: skill.uri.toString(), diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts index 9958c25c66426..68dfabd81b385 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts @@ -7,7 +7,7 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Event } from '../../../../../base/common/event.js'; import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js'; import { OS } from '../../../../../base/common/platform.js'; -import { basename, dirname, isEqualOrParent } from '../../../../../base/common/resources.js'; +import { basename, isEqualOrParent } from '../../../../../base/common/resources.js'; import { localize } from '../../../../../nls.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; @@ -19,6 +19,7 @@ import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/servi import { ICustomizationItem, ICustomizationItemProvider, IHarnessDescriptor, matchesInstructionFileFilter, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js'; import { BUILTIN_STORAGE } from './aiCustomizationManagement.js'; import { getFriendlyName, isChatExtensionItem } from './aiCustomizationItemSource.js'; +import { getSkillFolderName } from '../../common/promptSyntax/config/promptFileLocations.js'; /** * Adapts the rich promptsService model to the same provider-shaped items @@ -88,9 +89,8 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt } const uiIntegrations = this.workspaceService.getSkillUIIntegrations(); for (const skill of skills || []) { - const skillName = skill.name || basename(dirname(skill.uri)) || basename(skill.uri); - const skillFolderName = basename(dirname(skill.uri)); - const uiTooltip = uiIntegrations.get(skillFolderName); + const skillName = getSkillFolderName(skill.uri); + const uiTooltip = uiIntegrations.get(skillName); items.push({ uri: skill.uri, type: promptType, diff --git a/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts b/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts index 00f58697a9722..052b93699ae29 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts @@ -47,7 +47,6 @@ export interface IWorkspacePluginSettingsService { * Keys are `"pluginName@marketplaceName"`, values indicate recommendation. */ readonly enabledPlugins: IObservable>; - } // --- Parsing helpers --------------------------------------------------------- From d73b4983a64f457abe76c40efb859f88cd3de79b Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Thu, 23 Apr 2026 20:23:59 -0700 Subject: [PATCH 19/36] clean --- .../claude/node/claudeSettingsService.ts | 11 +- .../node/test/claudeSettingsService.spec.ts | 21 +- .../claudeCustomizationProvider.ts | 11 +- .../test/claudeCustomizationProvider.spec.ts | 263 +++++++++++++++++- 4 files changed, 292 insertions(+), 14 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeSettingsService.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeSettingsService.ts index d60aa9c5aaf4a..fa48832ef174c 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeSettingsService.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeSettingsService.ts @@ -128,12 +128,21 @@ export class ClaudeSettingsService extends Disposable implements IClaudeSettings this._settingsUris.map(uri => this.readSettingsFile(uri)) ); - this._settingsCache = settingsFiles.map((settings, index) => ({ + const allFiles = settingsFiles.map((settings, index) => ({ type: Object.values(ClaudeSettingsLocationType)[index], settings, uri: this._settingsUris[index], })); + // Return in priority order: workspaceLocal > workspace > user + // Using Record ensures a compile error if a new ClaudeSettingsLocationType is added + const priority: Record = { + [ClaudeSettingsLocationType.WorkspaceLocal]: 0, + [ClaudeSettingsLocationType.Workspace]: 1, + [ClaudeSettingsLocationType.User]: 2, + }; + this._settingsCache = allFiles.sort((a, b) => priority[a.type] - priority[b.type]); + return this._settingsCache; } diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeSettingsService.spec.ts b/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeSettingsService.spec.ts index 9eccbd972b452..818a1a88b2f27 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeSettingsService.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeSettingsService.spec.ts @@ -172,9 +172,26 @@ describe('ClaudeSettingsService', () => { const results = await service.readAllSettings(); expect(results).toHaveLength(3); - expect(results[0].settings).toEqual({ permissions: { allow: ['Read'] } }); + expect(results[0].settings).toEqual({ env: { DEBUG: '1' } }); expect(results[1].settings).toEqual({ permissions: { deny: ['Write'] } }); - expect(results[2].settings).toEqual({ env: { DEBUG: '1' } }); + expect(results[2].settings).toEqual({ permissions: { allow: ['Read'] } }); + }); + + it('returns in priority order: workspaceLocal > workspace > user', async () => { + const userUri = URI.file('/home/user/.claude/settings.json'); + const wsUri = URI.file('/workspace/.claude/settings.json'); + const wsLocalUri = URI.file('/workspace/.claude/settings.local.json'); + + mockFileSystemService.setFile(userUri, JSON.stringify({ source: 'user' })); + mockFileSystemService.setFile(wsUri, JSON.stringify({ source: 'workspace' })); + mockFileSystemService.setFile(wsLocalUri, JSON.stringify({ source: 'workspaceLocal' })); + + const results = await service.readAllSettings(); + expect(results.map(r => r.type)).toEqual([ + ClaudeSettingsLocationType.WorkspaceLocal, + ClaudeSettingsLocationType.Workspace, + ClaudeSettingsLocationType.User, + ]); }); it('returns empty objects for missing files', async () => { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts index f7011070249e6..daea2e9c2809d 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts @@ -110,10 +110,9 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch const agentItems = items.filter(i => i.type === vscode.ChatSessionCustomizationType.Agent); this.logService.debug(`[ClaudeCustomizationProvider] agents (${agentItems.length}): ${agentItems.map(a => a.name).join(', ') || '(none)'}${sdkAgents.length ? ' [sdk]' : ' [files-only, no session]'}`); - // Instructions from hard-coded CLAUDE.md paths (checked for existence) const settingsFiles = await this.claudeSettingsService.readAllSettings(); - // Collect claudeMdExcludes from all files + // Instructions from hard-coded CLAUDE.md paths (checked for existence) const instructionItems = await this.discoverInstructions(settingsFiles); items.push(...instructionItems); this.logService.debug(`[ClaudeCustomizationProvider] instructions (${instructionItems.length}): ${instructionItems.map(i => i.name).join(', ') || '(none)'}`); @@ -175,14 +174,12 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch if (await this.fileExists(uri)) { const name = basename(uri).replace(/\.md$/i, ''); const excluded = settingsFiles.some(s => s.settings.claudeMdExcludes?.some(pattern => this._matchesExclude(uri, pattern))); - const localSettings = settingsFiles.find(s => s.type === ClaudeSettingsLocationType.WorkspaceLocal); - const excludedByLocal = localSettings?.settings.claudeMdExcludes?.some(pattern => this._matchesExclude(uri, pattern)); const excludedByKnownPattern = excluded && settingsFiles.some(s => s.settings.claudeMdExcludes?.includes(uri.path)); items.push({ uri, type: vscode.ChatSessionCustomizationType.Instructions, name, - enablementScope: !excludedByKnownPattern || excludedByLocal + enablementScope: !excludedByKnownPattern ? vscode.ChatSessionCustomizationEnablementScope.None : vscode.ChatSessionCustomizationEnablementScope.Workspace, enabled: !excluded, @@ -233,9 +230,7 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch name: `${eventId}${matcherLabel}`, description, enabled: settingsFile.settings.disableAllHooks !== true, - enablementScope: settingsFile.type === ClaudeSettingsLocationType.User - ? vscode.ChatSessionCustomizationEnablementScope.Global - : vscode.ChatSessionCustomizationEnablementScope.Workspace, + enablementScope: vscode.ChatSessionCustomizationEnablementScope.Workspace, }); } } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts index 0b79da21a4323..bc6dcdce76891 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts @@ -204,15 +204,14 @@ describe('ClaudeCustomizationProvider', () => { expect(ClaudeCustomizationProvider.metadata.iconId).toBe('claude'); }); - it('supports Agent, Skill, Instructions, Hook, and Plugins types', () => { + it('supports Agent, Skill, Instructions, and Hook types', () => { const supported = ClaudeCustomizationProvider.metadata.supportedTypes; expect(supported).toBeDefined(); - expect(supported).toHaveLength(5); + expect(supported).toHaveLength(4); expect(supported).toContain(FakeChatSessionCustomizationType.Agent); expect(supported).toContain(FakeChatSessionCustomizationType.Skill); expect(supported).toContain(FakeChatSessionCustomizationType.Instructions); expect(supported).toContain(FakeChatSessionCustomizationType.Hook); - expect(supported).toContain(FakeChatSessionCustomizationType.Plugins); }); it('only returns items whose type is in supportedTypes', async () => { @@ -395,6 +394,68 @@ describe('ClaudeCustomizationProvider', () => { const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill); expect(skillItems).toHaveLength(1); }); + + it('marks skill as disabled when skillOverrides has off', async () => { + mockWorkspaceService.setFolders([URI.file('/workspace')]); + const settingsUri = URI.joinPath(URI.file('/workspace'), '.claude', 'settings.json'); + mockClaudeSettingsService.setSettingsUris([settingsUri]); + mockClaudeSettingsService.setFile(settingsUri, { skillOverrides: { 'my-skill': 'off' } }); + mockPromptsService.setSkills([mockSkill(URI.file('/workspace/.claude/skills/my-skill/SKILL.md'), 'my-skill')]); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill); + expect(skillItems).toHaveLength(1); + expect(skillItems[0].enabled).toBe(false); + }); + + it('marks skill as enabled when skillOverrides has on or name-only', async () => { + mockWorkspaceService.setFolders([URI.file('/workspace')]); + const settingsUri = URI.joinPath(URI.file('/workspace'), '.claude', 'settings.json'); + mockClaudeSettingsService.setSettingsUris([settingsUri]); + mockClaudeSettingsService.setFile(settingsUri, { skillOverrides: { 'skill-a': 'on', 'skill-b': 'name-only' } }); + mockPromptsService.setSkills([ + mockSkill(URI.file('/workspace/.claude/skills/skill-a/SKILL.md'), 'skill-a'), + mockSkill(URI.file('/workspace/.claude/skills/skill-b/SKILL.md'), 'skill-b'), + ]); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill); + expect(skillItems).toHaveLength(2); + expect(skillItems[0].enabled).toBe(true); + expect(skillItems[1].enabled).toBe(true); + }); + + it('defaults skill to enabled when no override exists', async () => { + mockWorkspaceService.setFolders([URI.file('/workspace')]); + mockPromptsService.setSkills([mockSkill(URI.file('/workspace/.claude/skills/my-skill/SKILL.md'), 'my-skill')]); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill); + expect(skillItems[0].enabled).toBe(true); + }); + + it('uses higher-priority settings file for skillOverrides (first-writer-wins)', async () => { + mockWorkspaceService.setFolders([URI.file('/workspace')]); + const wsLocalUri = URI.joinPath(URI.file('/workspace'), '.claude', 'settings.local.json'); + const wsUri = URI.joinPath(URI.file('/workspace'), '.claude', 'settings.json'); + mockClaudeSettingsService.setSettingsUris([wsLocalUri, wsUri]); + mockClaudeSettingsService.setFile(wsLocalUri, { skillOverrides: { 'my-skill': 'off' } }); + mockClaudeSettingsService.setFile(wsUri, { skillOverrides: { 'my-skill': 'on' } }); + mockPromptsService.setSkills([mockSkill(URI.file('/workspace/.claude/skills/my-skill/SKILL.md'), 'my-skill')]); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill); + expect(skillItems[0].enabled).toBe(false); + }); + + it('sets enablementScope to Workspace for skills', async () => { + mockWorkspaceService.setFolders([URI.file('/workspace')]); + mockPromptsService.setSkills([mockSkill(URI.file('/workspace/.claude/skills/my-skill/SKILL.md'), 'my-skill')]); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill); + expect(skillItems[0].enablementScope).toBe(FakeChatSessionCustomizationEnablementScope.Workspace); + }); }); describe('combined items', () => { @@ -670,4 +731,200 @@ describe('ClaudeCustomizationProvider', () => { expect(fired).toBe(true); }); }); + + describe('skill enablement', () => { + it('disables a skill by writing skillOverrides off to workspace settings', async () => { + mockWorkspaceService.setFolders([URI.file('/workspace')]); + const wsUri = URI.joinPath(URI.file('/workspace'), '.claude', 'settings.json'); + mockClaudeSettingsService.setSettingsUris([wsUri]); + mockClaudeSettingsService.setFile(wsUri, {}); + + const skillUri = URI.file('/workspace/.claude/skills/my-skill/SKILL.md'); + await provider.handleCustomizationEnablement( + skillUri, FakeChatSessionCustomizationType.Skill as any, + false, FakeChatSessionCustomizationEnablementScope.Workspace, undefined!); + + const written = mockClaudeSettingsService.getWrittenFile(wsUri); + expect(written).toBeDefined(); + expect(written!.skillOverrides).toEqual({ 'my-skill': 'off' }); + }); + + it('enables a skill by removing its skillOverrides entry', async () => { + mockWorkspaceService.setFolders([URI.file('/workspace')]); + const wsUri = URI.joinPath(URI.file('/workspace'), '.claude', 'settings.json'); + mockClaudeSettingsService.setSettingsUris([wsUri]); + mockClaudeSettingsService.setFile(wsUri, { skillOverrides: { 'my-skill': 'off' } }); + + const skillUri = URI.file('/workspace/.claude/skills/my-skill/SKILL.md'); + await provider.handleCustomizationEnablement( + skillUri, FakeChatSessionCustomizationType.Skill as any, + true, FakeChatSessionCustomizationEnablementScope.Workspace, undefined!); + + const written = mockClaudeSettingsService.getWrittenFile(wsUri); + expect(written).toBeDefined(); + expect(written!.skillOverrides).toBeUndefined(); + }); + + it('preserves other skill overrides when toggling one', async () => { + mockWorkspaceService.setFolders([URI.file('/workspace')]); + const wsUri = URI.joinPath(URI.file('/workspace'), '.claude', 'settings.json'); + mockClaudeSettingsService.setSettingsUris([wsUri]); + mockClaudeSettingsService.setFile(wsUri, { skillOverrides: { 'my-skill': 'off', 'other-skill': 'off' } }); + + const skillUri = URI.file('/workspace/.claude/skills/my-skill/SKILL.md'); + await provider.handleCustomizationEnablement( + skillUri, FakeChatSessionCustomizationType.Skill as any, + true, FakeChatSessionCustomizationEnablementScope.Workspace, undefined!); + + const written = mockClaudeSettingsService.getWrittenFile(wsUri); + expect(written!.skillOverrides).toEqual({ 'other-skill': 'off' }); + }); + + it('fires onDidChange after toggling a skill', async () => { + mockWorkspaceService.setFolders([URI.file('/workspace')]); + const wsUri = URI.joinPath(URI.file('/workspace'), '.claude', 'settings.json'); + mockClaudeSettingsService.setSettingsUris([wsUri]); + mockClaudeSettingsService.setFile(wsUri, {}); + + let fired = false; + disposables.add(provider.onDidChange(() => { fired = true; })); + + const skillUri = URI.file('/workspace/.claude/skills/my-skill/SKILL.md'); + await provider.handleCustomizationEnablement( + skillUri, FakeChatSessionCustomizationType.Skill as any, + false, FakeChatSessionCustomizationEnablementScope.Workspace, undefined!); + + expect(fired).toBe(true); + }); + }); + + describe('instructions enablement', () => { + it('marks instruction as disabled when claudeMdExcludes matches', async () => { + mockWorkspaceService.setFolders([URI.file('/workspace')]); + const claudeMdUri = URI.joinPath(URI.file('/workspace'), 'CLAUDE.md'); + mockFileSystemService.setFile(claudeMdUri, '# Instructions'); + const settingsUri = URI.joinPath(URI.file('/workspace'), '.claude', 'settings.json'); + mockClaudeSettingsService.setSettingsUris([settingsUri]); + mockClaudeSettingsService.setFile(settingsUri, { claudeMdExcludes: ['/workspace/CLAUDE.md'] }); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const instructionItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions); + expect(instructionItems).toHaveLength(1); + expect(instructionItems[0].enabled).toBe(false); + }); + + it('marks instruction as enabled when not excluded', async () => { + mockWorkspaceService.setFolders([URI.file('/workspace')]); + const claudeMdUri = URI.joinPath(URI.file('/workspace'), 'CLAUDE.md'); + mockFileSystemService.setFile(claudeMdUri, '# Instructions'); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const instructionItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions); + expect(instructionItems[0].enabled).toBe(true); + }); + + it('sets enablementScope to Workspace when excluded by exact path', async () => { + mockWorkspaceService.setFolders([URI.file('/workspace')]); + const claudeMdUri = URI.joinPath(URI.file('/workspace'), 'CLAUDE.md'); + mockFileSystemService.setFile(claudeMdUri, '# Instructions'); + const settingsUri = URI.joinPath(URI.file('/workspace'), '.claude', 'settings.json'); + mockClaudeSettingsService.setSettingsUris([settingsUri]); + mockClaudeSettingsService.setFile(settingsUri, { claudeMdExcludes: ['/workspace/CLAUDE.md'] }); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const instructionItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions); + expect(instructionItems[0].enablementScope).toBe(FakeChatSessionCustomizationEnablementScope.Workspace); + }); + + it('sets enablementScope to None when excluded by glob pattern only', async () => { + mockWorkspaceService.setFolders([URI.file('/workspace')]); + const claudeMdUri = URI.joinPath(URI.file('/workspace'), 'CLAUDE.md'); + mockFileSystemService.setFile(claudeMdUri, '# Instructions'); + const settingsUri = URI.joinPath(URI.file('/workspace'), '.claude', 'settings.json'); + mockClaudeSettingsService.setSettingsUris([settingsUri]); + mockClaudeSettingsService.setFile(settingsUri, { claudeMdExcludes: ['**/CLAUDE.md'] }); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const instructionItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions); + expect(instructionItems[0].enabled).toBe(false); + expect(instructionItems[0].enablementScope).toBe(FakeChatSessionCustomizationEnablementScope.None); + }); + + it('disables an instruction by adding to claudeMdExcludes', async () => { + mockWorkspaceService.setFolders([URI.file('/workspace')]); + const wsUri = URI.joinPath(URI.file('/workspace'), '.claude', 'settings.json'); + mockClaudeSettingsService.setSettingsUris([wsUri]); + mockClaudeSettingsService.setFile(wsUri, {}); + + const claudeMdUri = URI.joinPath(URI.file('/workspace'), 'CLAUDE.md'); + await provider.handleCustomizationEnablement( + claudeMdUri, FakeChatSessionCustomizationType.Instructions as any, + false, FakeChatSessionCustomizationEnablementScope.Workspace, undefined!); + + const written = mockClaudeSettingsService.getWrittenFile(wsUri); + expect(written).toBeDefined(); + expect(written!.claudeMdExcludes).toContain('/workspace/CLAUDE.md'); + }); + + it('enables an instruction by removing from claudeMdExcludes', async () => { + mockWorkspaceService.setFolders([URI.file('/workspace')]); + const wsUri = URI.joinPath(URI.file('/workspace'), '.claude', 'settings.json'); + mockClaudeSettingsService.setSettingsUris([wsUri]); + mockClaudeSettingsService.setFile(wsUri, { claudeMdExcludes: ['/workspace/CLAUDE.md'] }); + + const claudeMdUri = URI.joinPath(URI.file('/workspace'), 'CLAUDE.md'); + await provider.handleCustomizationEnablement( + claudeMdUri, FakeChatSessionCustomizationType.Instructions as any, + true, FakeChatSessionCustomizationEnablementScope.Workspace, undefined!); + + const written = mockClaudeSettingsService.getWrittenFile(wsUri); + expect(written).toBeDefined(); + expect(written!.claudeMdExcludes).toBeUndefined(); + }); + + it('preserves other excludes when toggling one instruction', async () => { + mockWorkspaceService.setFolders([URI.file('/workspace')]); + const wsUri = URI.joinPath(URI.file('/workspace'), '.claude', 'settings.json'); + mockClaudeSettingsService.setSettingsUris([wsUri]); + mockClaudeSettingsService.setFile(wsUri, { + claudeMdExcludes: ['/workspace/CLAUDE.md', '/workspace/CLAUDE.local.md'] + }); + + const claudeMdUri = URI.joinPath(URI.file('/workspace'), 'CLAUDE.md'); + await provider.handleCustomizationEnablement( + claudeMdUri, FakeChatSessionCustomizationType.Instructions as any, + true, FakeChatSessionCustomizationEnablementScope.Workspace, undefined!); + + const written = mockClaudeSettingsService.getWrittenFile(wsUri); + expect(written!.claudeMdExcludes).toEqual(['/workspace/CLAUDE.local.md']); + }); + }); + + describe('hook descriptions', () => { + it('shows prompt text for prompt-type hooks', async () => { + const settingsUri = URI.joinPath(URI.file('/workspace'), '.claude', 'settings.json'); + mockWorkspaceService.setFolders([URI.file('/workspace')]); + mockClaudeSettingsService.setSettingsUris([settingsUri]); + mockClaudeSettingsService.setFile(settingsUri, { + hooks: { PostToolUse: [{ matcher: '*', hooks: [{ type: 'prompt', prompt: 'Review the output' }] }] } + }); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook); + expect(hookItems[0].description).toBe('Review the output'); + }); + + it('shows URL for http-type hooks', async () => { + const settingsUri = URI.joinPath(URI.file('/workspace'), '.claude', 'settings.json'); + mockWorkspaceService.setFolders([URI.file('/workspace')]); + mockClaudeSettingsService.setSettingsUris([settingsUri]); + mockClaudeSettingsService.setFile(settingsUri, { + hooks: { Stop: [{ matcher: '*', hooks: [{ type: 'http', url: 'https://example.com/hook' }] }] } + }); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook); + expect(hookItems[0].description).toBe('https://example.com/hook'); + }); + }); }); From 8a7077620a227d8ca0fb22d6f00c4bd427bdd346 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Thu, 23 Apr 2026 21:38:29 -0700 Subject: [PATCH 20/36] clean --- .../claude/common/claudeSettingsService.ts | 40 +-- .../claude/node/claudeSettingsService.ts | 166 ++-------- .../common/baseSessionSettingsService.ts | 153 +++++++++ .../common/sessionSettingsService.ts | 71 +++++ .../common/copilotCLISettingsService.ts | 23 ++ .../node/copilotCLISettingsService.ts | 35 ++ .../copilotcli/node/copilotCli.ts | 2 +- .../chatSessions/vscode-node/chatSessions.ts | 4 + .../claudeCustomizationProvider.ts | 51 ++- .../copilotCLICustomizationProvider.ts | 98 ++---- .../test/claudeCustomizationProvider.spec.ts | 54 ++++ .../copilotCLICustomizationProvider.spec.ts | 300 +++++++++++++++++- ...osed.chatSessionCustomizationProvider.d.ts | 13 - 13 files changed, 744 insertions(+), 266 deletions(-) create mode 100644 extensions/copilot/src/extension/chatSessions/common/baseSessionSettingsService.ts create mode 100644 extensions/copilot/src/extension/chatSessions/common/sessionSettingsService.ts create mode 100644 extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLISettingsService.ts create mode 100644 extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCLISettingsService.ts diff --git a/extensions/copilot/src/extension/chatSessions/claude/common/claudeSettingsService.ts b/extensions/copilot/src/extension/chatSessions/claude/common/claudeSettingsService.ts index 9ffc6b82b7777..f2862df2d95e2 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/common/claudeSettingsService.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/common/claudeSettingsService.ts @@ -4,9 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import type { Settings as ClaudeSettings } from '@anthropic-ai/claude-agent-sdk'; -import { Event } from '../../../../util/vs/base/common/event'; import { URI } from '../../../../util/vs/base/common/uri'; import { createDecorator } from '../../../../util/vs/platform/instantiation/common/instantiation'; +import { ISessionSettingsService, SessionSettingsFile } from '../../common/sessionSettingsService'; export const IClaudeSettingsService = createDecorator('claudeSettingsService'); @@ -19,43 +19,9 @@ export enum ClaudeSettingsLocationType { WorkspaceLocal = 'workspaceLocal', } -export interface ClaudeSettingsFile { - type: ClaudeSettingsLocationType; - settings: ClaudeSettings; - uri: URI; -} - -export interface IClaudeSettingsService { - readonly _serviceBrand: undefined; - - /** - * Fires when any Claude settings file changes on disk. - */ - readonly onDidChange: Event; - - /** - * Returns the settings from all settings files as separate objects. - * Each is an empty object if the file doesn't exist or can't be parsed. - * Returns it in order of precedence (workspaceLocal > workspace > user). - */ - readAllSettings(): Promise>; - - /** - * Reads a single settings file as a typed object. - * Returns an empty object if the file doesn't exist or can't be parsed. - */ - readSettingsFile(uri: URI): Promise; - - /** - * Writes settings to the given location. - */ - writeSettingsFile(uri: URI, settings: ClaudeSettings): Promise; - - /** - * Returns known settings URIs. If location is provided, returns only the URIs for that location. - */ - getUris(location?: ClaudeSettingsLocationType): URI[]; +export type ClaudeSettingsFile = SessionSettingsFile; +export interface IClaudeSettingsService extends ISessionSettingsService { /** * Returns the settings URI for the given location and a URI that belongs to a workspace folder. */ diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeSettingsService.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeSettingsService.ts index fa48832ef174c..8f999f1b7f3ae 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeSettingsService.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeSettingsService.ts @@ -7,148 +7,40 @@ import type { Settings as ClaudeSettings } from '@anthropic-ai/claude-agent-sdk' import { INativeEnvService } from '../../../../platform/env/common/envService'; import { IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService'; import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService'; -import { Emitter } from '../../../../util/vs/base/common/event'; -import { Disposable } from '../../../../util/vs/base/common/lifecycle'; import { URI } from '../../../../util/vs/base/common/uri'; -import { ClaudeSettingsFile, ClaudeSettingsLocationType, IClaudeSettingsService } from '../common/claudeSettingsService'; -import { extUriBiasedIgnorePathCase } from '../../../../util/vs/base/common/resources'; - -export class ClaudeSettingsService extends Disposable implements IClaudeSettingsService { - declare readonly _serviceBrand: undefined; - - private readonly _onDidChange = this._register(new Emitter()); - readonly onDidChange = this._onDidChange.event; - - private _settingsCache: Readonly | undefined; - private _settingsUris: URI[] = []; +import { ClaudeSettingsLocationType, IClaudeSettingsService } from '../common/claudeSettingsService'; +import { SessionSettingsLocationDescriptor } from '../../common/sessionSettingsService'; +import { SessionSettingsService } from '../../common/baseSessionSettingsService'; + +const CLAUDE_LOCATIONS: readonly SessionSettingsLocationDescriptor[] = [ + { + type: ClaudeSettingsLocationType.WorkspaceLocal, + priority: 0, + getUris: (workspaceFolders) => workspaceFolders.map(f => URI.joinPath(f, '.claude', 'settings.local.json')), + }, + { + type: ClaudeSettingsLocationType.Workspace, + priority: 1, + getUris: (workspaceFolders) => workspaceFolders.map(f => URI.joinPath(f, '.claude', 'settings.json')), + }, + { + type: ClaudeSettingsLocationType.User, + priority: 2, + getUris: (_workspaceFolders, userHome) => [URI.joinPath(userHome, '.claude', 'settings.json')], + }, +]; + +export class ClaudeSettingsService extends SessionSettingsService implements IClaudeSettingsService { constructor( - @IWorkspaceService private readonly workspaceService: IWorkspaceService, - @IFileSystemService private readonly fileSystemService: IFileSystemService, - @INativeEnvService private readonly envService: INativeEnvService, + @IWorkspaceService workspaceService: IWorkspaceService, + @IFileSystemService fileSystemService: IFileSystemService, + @INativeEnvService envService: INativeEnvService, ) { - super(); - - const onSettingsChanged = () => { - this._settingsCache = undefined; - this._onDidChange.fire(); - }; - - const setupWatchers = () => { - this._settingsUris = []; - for (const location of Object.values(ClaudeSettingsLocationType)) { - const uris = this.getUris(location); - this._settingsUris.push(...uris); - for (const uri of uris) { - const settingsWatcher = this._register(this.fileSystemService.createFileSystemWatcher(uri.fsPath)); - this._register(settingsWatcher.onDidChange(onSettingsChanged)); - this._register(settingsWatcher.onDidCreate(onSettingsChanged)); - this._register(settingsWatcher.onDidDelete(onSettingsChanged)); - } - } - }; - - this._register(this.workspaceService.onDidChangeWorkspaceFolders(() => { - setupWatchers(); - onSettingsChanged(); - })); - - setupWatchers(); - } - - private getUrisByLocation(location: ClaudeSettingsLocationType): URI[] { - switch (location) { - case ClaudeSettingsLocationType.User: - return [URI.joinPath(this.envService.userHome, '.claude', 'settings.json')]; - case ClaudeSettingsLocationType.Workspace: { - const folders = this.workspaceService.getWorkspaceFolders(); - const uris: URI[] = []; - for (const folder of folders) { - uris.push(URI.joinPath(folder, '.claude', 'settings.json')); - } - return uris; - } - case ClaudeSettingsLocationType.WorkspaceLocal: { - const folders = this.workspaceService.getWorkspaceFolders(); - const uris: URI[] = []; - for (const folder of folders) { - uris.push(URI.joinPath(folder, '.claude', 'settings.local.json')); - } - return uris; - } - } - } - - getUris(location?: ClaudeSettingsLocationType): URI[] { - if (location) { - return this.getUrisByLocation(location); - } else { - let uris: URI[] = []; - for (const loc of Object.values(ClaudeSettingsLocationType)) { - uris = uris.concat(this.getUrisByLocation(loc)); - } - return uris; - } - } - - getUri(location: ClaudeSettingsLocationType, uri: URI): URI { - const uris = this.getUris(location); - if (uris.length === 1) { - return uris[0]; - } - const workspaceFolders = this.workspaceService.getWorkspaceFolders(); - // Multiple workspace folders — find the one that matches the item's URI - for (const workspaceFolder of workspaceFolders) { - if (extUriBiasedIgnorePathCase.isEqualOrParent(uri, workspaceFolder)) { - const settingsUri = uris.find(u => extUriBiasedIgnorePathCase.isEqual(u, workspaceFolder)); - if (settingsUri) { - return settingsUri; - } - } - } - throw new Error(`Could not find a matching settings URI for ${uri.toString()}`); - } - - async readSettingsFile(uri: URI): Promise { - try { - const bytes = await this.fileSystemService.readFile(uri); - const parsed = JSON.parse(new TextDecoder().decode(bytes)); - return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {}; - } catch { - return {}; - } - } - - async readAllSettings(): Promise> { - if (this._settingsCache) { - return this._settingsCache; - } - - const settingsFiles = await Promise.all( - this._settingsUris.map(uri => this.readSettingsFile(uri)) - ); - - const allFiles = settingsFiles.map((settings, index) => ({ - type: Object.values(ClaudeSettingsLocationType)[index], - settings, - uri: this._settingsUris[index], - })); - - // Return in priority order: workspaceLocal > workspace > user - // Using Record ensures a compile error if a new ClaudeSettingsLocationType is added - const priority: Record = { - [ClaudeSettingsLocationType.WorkspaceLocal]: 0, - [ClaudeSettingsLocationType.Workspace]: 1, - [ClaudeSettingsLocationType.User]: 2, - }; - this._settingsCache = allFiles.sort((a, b) => priority[a.type] - priority[b.type]); - - return this._settingsCache; + super(CLAUDE_LOCATIONS, workspaceService, fileSystemService, envService); } - async writeSettingsFile(uri: URI, settings: ClaudeSettings): Promise { - const content = new TextEncoder().encode(JSON.stringify(settings, null, 4)); - await this.fileSystemService.writeFile(uri, content); - // Cache will be invalidated by the file watcher + protected getDefaultSettings(): ClaudeSettings { + return {}; } } diff --git a/extensions/copilot/src/extension/chatSessions/common/baseSessionSettingsService.ts b/extensions/copilot/src/extension/chatSessions/common/baseSessionSettingsService.ts new file mode 100644 index 0000000000000..b84d0560541fb --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/common/baseSessionSettingsService.ts @@ -0,0 +1,153 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { INativeEnvService } from '../../../platform/env/common/envService'; +import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; +import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; +import { Emitter } from '../../../util/vs/base/common/event'; +import { Disposable } from '../../../util/vs/base/common/lifecycle'; +import { extUriBiasedIgnorePathCase } from '../../../util/vs/base/common/resources'; +import { URI } from '../../../util/vs/base/common/uri'; +import { SessionSettingsFile, SessionSettingsLocationDescriptor } from './sessionSettingsService'; + +/** + * Base implementation for session settings services that read/write JSON settings files. + * Handles file watching, caching, and priority ordering. + * + * Subclasses must provide the location descriptors (which define where settings files live + * and their priority order). + */ +export abstract class SessionSettingsService extends Disposable { + declare readonly _serviceBrand: undefined; + + protected readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + + private _settingsCache: Readonly[]> | undefined; + private _settingsUris: URI[] = []; + + constructor( + private readonly _locations: readonly SessionSettingsLocationDescriptor[], + protected readonly workspaceService: IWorkspaceService, + protected readonly fileSystemService: IFileSystemService, + protected readonly envService: INativeEnvService, + ) { + super(); + + const onSettingsChanged = () => { + this._settingsCache = undefined; + this._onDidChange.fire(); + }; + + const setupWatchers = () => { + this._settingsUris = []; + for (const location of this._locations) { + const uris = location.getUris(this.workspaceService.getWorkspaceFolders(), this.envService.userHome); + this._settingsUris.push(...uris); + for (const uri of uris) { + const watcher = this._register(this.fileSystemService.createFileSystemWatcher(uri.fsPath)); + this._register(watcher.onDidChange(onSettingsChanged)); + this._register(watcher.onDidCreate(onSettingsChanged)); + this._register(watcher.onDidDelete(onSettingsChanged)); + } + } + }; + + this._register(this.workspaceService.onDidChangeWorkspaceFolders(() => { + setupWatchers(); + onSettingsChanged(); + })); + + setupWatchers(); + } + + private _getUrisByLocation(location: TLocationType): URI[] { + const descriptor = this._locations.find(l => l.type === location); + if (!descriptor) { + return []; + } + return descriptor.getUris(this.workspaceService.getWorkspaceFolders(), this.envService.userHome); + } + + getUris(location?: TLocationType): URI[] { + if (location) { + return this._getUrisByLocation(location); + } + return this._settingsUris; + } + + getUri(location: TLocationType, uri: URI): URI { + const uris = this.getUris(location); + if (uris.length === 1) { + return uris[0]; + } + const workspaceFolders = this.workspaceService.getWorkspaceFolders(); + for (const workspaceFolder of workspaceFolders) { + if (extUriBiasedIgnorePathCase.isEqualOrParent(uri, workspaceFolder)) { + const settingsUri = uris.find(u => extUriBiasedIgnorePathCase.isEqual(u, workspaceFolder)); + if (settingsUri) { + return settingsUri; + } + } + } + throw new Error(`Could not find a matching settings URI for ${uri.toString()}`); + } + + async readSettingsFile(uri: URI): Promise { + try { + const bytes = await this.fileSystemService.readFile(uri); + const parsed = JSON.parse(new TextDecoder().decode(bytes)); + return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : this.getDefaultSettings(); + } catch { + return this.getDefaultSettings(); + } + } + + async readAllSettings(): Promise[]>> { + if (this._settingsCache) { + return this._settingsCache; + } + + const settingsFiles = await Promise.all( + this._settingsUris.map(uri => this.readSettingsFile(uri)) + ); + + const allFiles: SessionSettingsFile[] = settingsFiles.map((settings, index) => ({ + type: this._getLocationType(this._settingsUris[index]), + settings, + uri: this._settingsUris[index], + })); + + // Sort by priority (lower number = higher precedence) + const priorityMap = new Map(this._locations.map(l => [l.type, l.priority])); + this._settingsCache = allFiles.sort((a, b) => (priorityMap.get(a.type) ?? 0) - (priorityMap.get(b.type) ?? 0)); + + return this._settingsCache; + } + + async writeSettingsFile(uri: URI, settings: TSettings): Promise { + const content = new TextEncoder().encode(JSON.stringify(settings, null, 4)); + await this.fileSystemService.writeFile(uri, content); + // Cache will be invalidated by the file watcher + } + + /** + * Returns the default empty settings object (e.g. `{}` cast to TSettings). + */ + protected abstract getDefaultSettings(): TSettings; + + /** + * Determines the location type for a given URI. + */ + private _getLocationType(uri: URI): TLocationType { + for (const location of this._locations) { + const uris = location.getUris(this.workspaceService.getWorkspaceFolders(), this.envService.userHome); + if (uris.some(u => extUriBiasedIgnorePathCase.isEqual(u, uri))) { + return location.type; + } + } + return this._locations[0].type; + } +} diff --git a/extensions/copilot/src/extension/chatSessions/common/sessionSettingsService.ts b/extensions/copilot/src/extension/chatSessions/common/sessionSettingsService.ts new file mode 100644 index 0000000000000..b57083eb75714 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/common/sessionSettingsService.ts @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../util/vs/base/common/event'; +import { URI } from '../../../util/vs/base/common/uri'; + +/** + * Describes a settings file with its location type, parsed settings, and URI. + */ +export interface SessionSettingsFile { + type: TLocationType; + settings: TSettings; + uri: URI; +} + +/** + * Describes a settings location: the enum value and how to derive URIs from workspace folders / user home. + */ +export interface SessionSettingsLocationDescriptor { + type: TLocationType; + /** + * Returns the URIs for this location given the workspace folders and user home. + */ + getUris(workspaceFolders: readonly URI[], userHome: URI): URI[]; + /** + * Sort priority — lower numbers come first (higher precedence). + */ + priority: number; +} + +/** + * Base interface for session settings services that read/write JSON settings files. + * Generic over the location enum and the settings shape. + */ +export interface ISessionSettingsService { + readonly _serviceBrand: undefined; + + /** + * Fires when any settings file changes on disk. + */ + readonly onDidChange: Event; + + /** + * Returns the settings from all settings files as separate objects, + * ordered by precedence (highest priority first). + */ + readAllSettings(): Promise[]>>; + + /** + * Reads a single settings file as a typed object. + * Returns a default empty object if the file doesn't exist or can't be parsed. + */ + readSettingsFile(uri: URI): Promise; + + /** + * Writes settings to the given URI. + */ + writeSettingsFile(uri: URI, settings: TSettings): Promise; + + /** + * Returns known settings URIs. If location is provided, returns only the URIs for that location. + */ + getUris(location?: TLocationType): URI[]; + + /** + * Returns the settings URI for the given location closest to the given workspace URI. + */ + getUri(location: TLocationType, workspaceUri: URI): URI; +} diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLISettingsService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLISettingsService.ts new file mode 100644 index 0000000000000..17024b3148da8 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLISettingsService.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { loadFeatureFlagsFromConfig } from '@github/copilot/sdk'; +import { createDecorator } from '../../../../util/vs/platform/instantiation/common/instantiation'; +import { ISessionSettingsService, SessionSettingsFile } from '../../common/sessionSettingsService'; + +// TODO: We should use an actual exported type from the Copilot SDK. This is currently not available. +export type CopilotCLISettings = Parameters[0]; + +export const ICopilotCLISettingsService = createDecorator('copilotCLISettingsService'); + +export enum CopilotCLISettingsLocationType { + // ~/.copilot/settings.json + User = 'user', +} + +export type CopilotCLISettingsFile = SessionSettingsFile; + +export interface ICopilotCLISettingsService extends ISessionSettingsService { +} diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCLISettingsService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCLISettingsService.ts new file mode 100644 index 0000000000000..5d95fe349ab64 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCLISettingsService.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { INativeEnvService } from '../../../../platform/env/common/envService'; +import { IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService'; +import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService'; +import { URI } from '../../../../util/vs/base/common/uri'; +import { SessionSettingsLocationDescriptor } from '../../common/sessionSettingsService'; +import { SessionSettingsService } from '../../common/baseSessionSettingsService'; +import { CopilotCLISettings, CopilotCLISettingsLocationType, ICopilotCLISettingsService } from '../common/copilotCLISettingsService'; + +const COPILOT_CLI_LOCATIONS: readonly SessionSettingsLocationDescriptor[] = [ + { + type: CopilotCLISettingsLocationType.User, + priority: 0, + getUris: (_workspaceFolders, userHome) => [URI.joinPath(userHome, '.copilot', 'settings.json')], + }, +]; + +export class CopilotCLISettingsService extends SessionSettingsService implements ICopilotCLISettingsService { + + constructor( + @IWorkspaceService workspaceService: IWorkspaceService, + @IFileSystemService fileSystemService: IFileSystemService, + @INativeEnvService envService: INativeEnvService, + ) { + super(COPILOT_CLI_LOCATIONS, workspaceService, fileSystemService, envService); + } + + protected getDefaultSettings(): CopilotCLISettings { + return {}; + } +} diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts index 7c1db3518eba0..853a0c59e66b5 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts @@ -279,7 +279,7 @@ export interface CLIAgentInfo { /** File URI for prompt-file agents, synthetic `copilotcli:` URI for SDK-only agents. */ readonly sourceUri: URI; /** Where the agent was loaded from (e.g. 'local', 'extension'). Undefined for SDK-only agents. */ - readonly source?: string; + readonly source?: vscode.ChatResourceSource; } export interface ICopilotCLIAgents { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts index 3a5b06b0d8c23..f30e95ce00ca8 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts @@ -53,6 +53,8 @@ import { CopilotCLIImageSupport, ICopilotCLIImageSupport } from '../copilotcli/n import { CopilotCLIPromptResolver } from '../copilotcli/node/copilotcliPromptResolver'; import { CopilotCLISessionService, ICopilotCLISessionService } from '../copilotcli/node/copilotcliSessionService'; import { CopilotCLISkills, ICopilotCLISkills } from '../copilotcli/node/copilotCLISkills'; +import { ICopilotCLISettingsService } from '../copilotcli/common/copilotCLISettingsService'; +import { CopilotCLISettingsService } from '../copilotcli/node/copilotCLISettingsService'; import { CopilotCLIMCPHandler, ICopilotCLIMCPHandler } from '../copilotcli/node/mcpHandler'; import { IUserQuestionHandler } from '../copilotcli/node/userInputHelpers'; import { CopilotCLIContrib, getServices } from '../copilotcli/vscode-node/contribution'; @@ -197,6 +199,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib [ISessionOptionGroupBuilder, new SyncDescriptor(SessionOptionGroupBuilder)], [ISessionRequestLifecycle, new SyncDescriptor(SessionRequestLifecycle)], [ICopilotCLIChatSessionInitializer, new SyncDescriptor(CopilotCLIChatSessionInitializer)], + [ICopilotCLISettingsService, new SyncDescriptor(CopilotCLISettingsService)], ...getServices() )); @@ -266,6 +269,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib [ICopilotCLISkills, new SyncDescriptor(CopilotCLISkills)], [IChatSessionMetadataStore, new SyncDescriptor(ChatSessionMetadataStore)], [IChatFolderMruService, new SyncDescriptor(CopilotCLIFolderMruService)], + [ICopilotCLISettingsService, new SyncDescriptor(CopilotCLISettingsService)], ...getServices() )); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts index daea2e9c2809d..124b35e33c137 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts @@ -173,13 +173,30 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch for (const uri of candidates) { if (await this.fileExists(uri)) { const name = basename(uri).replace(/\.md$/i, ''); - const excluded = settingsFiles.some(s => s.settings.claudeMdExcludes?.some(pattern => this._matchesExclude(uri, pattern))); - const excludedByKnownPattern = excluded && settingsFiles.some(s => s.settings.claudeMdExcludes?.includes(uri.path)); + + let excluded = false; + let excludedByUnknownPattern = false; + + for (const file of settingsFiles) { + if (!Array.isArray(file.settings.claudeMdExcludes)) { + continue; + } + for (const pattern of file.settings.claudeMdExcludes ?? []) { + if (typeof pattern !== 'string') { + continue; + } + if (this._matchesExclude(uri, pattern)) { + excluded = true; + excludedByUnknownPattern = excludedByUnknownPattern || (uri.path !== pattern); + } + } + } + items.push({ uri, type: vscode.ChatSessionCustomizationType.Instructions, name, - enablementScope: !excludedByKnownPattern + enablementScope: excludedByUnknownPattern ? vscode.ChatSessionCustomizationEnablementScope.None : vscode.ChatSessionCustomizationEnablementScope.Workspace, enabled: !excluded, @@ -202,20 +219,29 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch private async discoverHooks(settingsFiles: Readonly): Promise { const items: vscode.ChatSessionCustomizationItem[] = []; + let disableAllHooks = false; for (const settingsFile of settingsFiles) { try { - if (!settingsFile.settings.hooks) { + if (!settingsFile.settings.hooks || typeof settingsFile.settings.hooks !== 'object') { continue; } + // Higher priority settings files override lower priority ones + disableAllHooks = disableAllHooks || settingsFile.settings.disableAllHooks === true; + for (const eventId of HOOK_EVENTS) { const matchers = settingsFile.settings.hooks[eventId]; - if (!matchers || matchers.length === 0) { + if (!Array.isArray(matchers)) { continue; } - for (const matcher of matchers) { + if (!Array.isArray(matcher.hooks)) { + continue; + } for (const hook of matcher.hooks) { + if (typeof hook !== 'object') { + continue; + } const matcherLabel = matcher.matcher === '*' ? '' : ` (${matcher.matcher})`; let description: string | undefined; switch (hook.type) { @@ -229,8 +255,9 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch type: vscode.ChatSessionCustomizationType.Hook, name: `${eventId}${matcherLabel}`, description, - enabled: settingsFile.settings.disableAllHooks !== true, - enablementScope: vscode.ChatSessionCustomizationEnablementScope.Workspace, + enabled: !disableAllHooks, + // TODO: There isn't a great way to toggle enablement for individual hooks + enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, }); } } @@ -313,6 +340,10 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch const targetSettingsUri = !enabled ? this.claudeSettingsService.getUri(location, uri) : undefined; for (const file of allSettingsFiles) { + if (!file.settings.skillOverrides || typeof file.settings.skillOverrides !== 'object') { + continue; + } + const isTarget = targetSettingsUri?.toString() === file.uri.toString(); const skillOverrides = { ...file.settings.skillOverrides ?? {} }; let shouldUpdateSettings = skillName in skillOverrides; @@ -333,6 +364,10 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch const targetSettingsUri = !enabled ? this.claudeSettingsService.getUri(location, uri) : undefined; for (const file of allSettingsFiles) { + if (!file.settings.claudeMdExcludes || !Array.isArray(file.settings.claudeMdExcludes)) { + continue; + } + const isTarget = targetSettingsUri?.toString() === file.uri.toString(); const filtered = (file.settings.claudeMdExcludes ?? []).filter(p => p !== instructionsUri.path); let shouldUpdateSettings = filtered.length !== (file.settings.claudeMdExcludes ?? []).length; diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts index 7409ce167b1fd..e7a54f194f5e1 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts @@ -5,7 +5,6 @@ import * as l10n from '@vscode/l10n'; import * as vscode from 'vscode'; -import { INativeEnvService } from '../../../platform/env/common/envService'; import { ICustomInstructionsService } from '../../../platform/customInstructions/common/customInstructionsService'; import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; import { ILogService } from '../../../platform/log/common/logService'; @@ -17,11 +16,8 @@ import { Emitter } from '../../../util/vs/base/common/event'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; import { basename, dirname } from '../../../util/vs/base/common/resources'; import { URI } from '../../../util/vs/base/common/uri'; -import type { loadFeatureFlagsFromConfig } from '@github/copilot/sdk'; import { ICopilotCLIAgents, isEnabledForCopilotCLI } from '../copilotcli/node/copilotCli'; - -// TODO: We should use an actual exported type from the Copilot SDK. This is currently not available. -type CopilotUserSettings = Parameters[0]; +import { CopilotCLISettingsLocationType, ICopilotCLISettingsService } from '../copilotcli/common/copilotCLISettingsService'; export class CopilotCLICustomizationProvider extends Disposable implements vscode.ChatSessionCustomizationProvider { @@ -49,7 +45,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod @ILogService private readonly logService: ILogService, @IWorkspaceService private readonly workspaceService: IWorkspaceService, @IFileSystemService private readonly fileSystemService: IFileSystemService, - @INativeEnvService private readonly envService: INativeEnvService, + @ICopilotCLISettingsService private readonly copilotCLISettingsService: ICopilotCLISettingsService, ) { super(); @@ -59,6 +55,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod this._register(this.promptsService.onDidChangeHooks(() => this._onDidChange.fire())); this._register(this.promptsService.onDidChangePlugins(() => this._onDidChange.fire())); this._register(this.copilotCLIAgents.onDidChangeAgents(() => this._onDidChange.fire())); + this._register(this.copilotCLISettingsService.onDidChange(() => this._onDidChange.fire())); } async provideChatSessionCustomizations(token: vscode.CancellationToken): Promise { @@ -94,14 +91,12 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod */ private async getAgentItems(_token: vscode.CancellationToken): Promise { const agentInfos = await this.copilotCLIAgents.getAgents(); - return agentInfos.map(({ agent, sourceUri, source }) => ({ + return agentInfos.map(({ agent, sourceUri }) => ({ uri: sourceUri, type: vscode.ChatSessionCustomizationType.Agent, name: agent.displayName || agent.name, description: agent.description, - // Extension-sourced items are managed by the application (VS Code) - // rather than the CLI's settings.json. - ...(source === 'extension' ? { enablementScope: vscode.ChatSessionCustomizationEnablementScope.ManagedByApplication } : {}), + enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, })); } @@ -157,15 +152,9 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod } const name = instruction.name; + const pattern = instruction.pattern; const description = instruction.description; - // Extension-sourced items are managed by the application (VS Code) - // rather than the CLI's settings.json. - const enablementScope = instruction.source === 'extension' - ? vscode.ChatSessionCustomizationEnablementScope.ManagedByApplication - : undefined; - - const pattern = instruction.pattern; if (pattern !== undefined) { const badge = pattern === '**' ? l10n.t('always added') @@ -181,7 +170,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod groupKey: 'context-instructions', badge, badgeTooltip, - enablementScope, + enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, }); } else { items.push({ @@ -190,7 +179,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod name, description, groupKey: 'on-demand-instructions', - enablementScope, + enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, }); } } @@ -202,28 +191,17 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod * Collects all skill items from the prompt file service. */ private async getSkillItems(token: vscode.CancellationToken): Promise { - const settings = await this._readSettings(); - const disabledSkills = new Set( - Array.isArray(settings.disabledSkills) ? settings.disabledSkills as string[] : [], - ); + const settings = await this._readUserSettings(); + const disabledSkills = Array.isArray(settings.disabledSkills) ? settings.disabledSkills.filter(s => typeof s === 'string') : []; + const disabledSkillsSet = new Set(disabledSkills); return (await this.promptsService.getSkills(token)).filter(isEnabledForCopilotCLI).map(s => { const name = s.name; - const folderName = basename(dirname(s.uri)) || basename(s.uri); - // Extension-sourced items are managed by the application (VS Code) - // rather than the CLI's settings.json. - if (s.source === 'extension') { - return { - uri: s.uri, - type: vscode.ChatSessionCustomizationType.Skill, - name, - enablementScope: vscode.ChatSessionCustomizationEnablementScope.ManagedByApplication, - }; - } + const skillName = basename(dirname(s.uri)); return { uri: s.uri, type: vscode.ChatSessionCustomizationType.Skill, name, - enabled: !disabledSkills.has(folderName), + enabled: !disabledSkillsSet.has(skillName), enablementScope: vscode.ChatSessionCustomizationEnablementScope.Global, }; }); @@ -234,10 +212,15 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod * Each item is a hook configuration file (JSON). */ private async getHookItems(token: vscode.CancellationToken): Promise { + const settings = await this._readUserSettings(); return (await this.promptsService.getHooks(token)).filter(isEnabledForCopilotCLI).map(h => ({ uri: h.uri, type: vscode.ChatSessionCustomizationType.Hook, name: basename(h.uri).replace(/\.json$/i, ''), + // TODO: This is best-effort for now. Each hook file itself can disable all hooks with disableAllHooks. + enabled: settings.disableAllHooks === false, + // TODO: There isn't a great way to toggle enablement for individual hooks + enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, })); } @@ -245,19 +228,15 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod * Collects all plugin items from the prompt file service. */ private async getPluginItems(token: vscode.CancellationToken): Promise { - const settings = await this._readSettings(); - const enabledPlugins = (settings.enabledPlugins && typeof settings.enabledPlugins === 'object' && !Array.isArray(settings.enabledPlugins)) - ? settings.enabledPlugins as Record - : {}; + const settings = await this._readUserSettings(); + const enabledPlugins = typeof settings.enabledPlugins === 'object' ? settings.enabledPlugins : {}; return (await this.promptsService.getPlugins(token)).filter(isEnabledForCopilotCLI).map(p => { const name = basename(p.uri); - // A plugin is disabled if explicitly set to false in enabledPlugins - const enabled = enabledPlugins[name] !== false; return { uri: p.uri, type: vscode.ChatSessionCustomizationType.Plugins, name, - enabled, + enabled: enabledPlugins[name] !== false, enablementScope: vscode.ChatSessionCustomizationEnablementScope.Global, }; }); @@ -266,36 +245,22 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod // --- Enablement --- /** - * Path to the user-level copilot settings file (`~/.copilot/settings.json`). - */ - private get _settingsUri(): URI { - return URI.joinPath(this.envService.userHome, '.copilot', 'settings.json'); - } - - /** - * Reads the user-level `~/.copilot/settings.json` as a JSON object. - * Returns an empty object if the file doesn't exist or can't be parsed. + * Reads the user-level settings from the settings service. */ - private async _readSettings(): Promise { - try { - const bytes = await this.fileSystemService.readFile(this._settingsUri); - const parsed = JSON.parse(new TextDecoder().decode(bytes)); - return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {}; - } catch { - return {}; - } + private async _readUserSettings() { + const allSettings = await this.copilotCLISettingsService.readAllSettings(); + return allSettings[0]?.settings ?? {}; } /** - * Writes the user-level `~/.copilot/settings.json`. + * Returns the URI of the user-level settings file. */ - private async _writeSettings(settings: CopilotUserSettings): Promise { - const content = new TextEncoder().encode(JSON.stringify(settings, null, 4)); - await this.fileSystemService.writeFile(this._settingsUri, content); + private get _settingsUri(): URI { + return this.copilotCLISettingsService.getUris(CopilotCLISettingsLocationType.User)[0]; } async handleCustomizationEnablement(uri: vscode.Uri, type: vscode.ChatSessionCustomizationType, enabled: boolean, _scope: vscode.ChatSessionCustomizationEnablementScope, _token: vscode.CancellationToken): Promise { - const settings = await this._readSettings(); + const settings = await this._readUserSettings(); let name: string; if (type.id === vscode.ChatSessionCustomizationType.Skill.id) { @@ -321,15 +286,16 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod settings.enabledPlugins = Object.keys(map).length > 0 ? map : undefined; } else { this.logService.warn(`[CopilotCLICustomizationProvider] Per-item enablement not supported for type: ${type.id}`); + void vscode.window.showErrorMessage(vscode.l10n.t('Toggling {0} customizations is not supported.', type.id)); return; } try { - await this._writeSettings(settings); + await this.copilotCLISettingsService.writeSettingsFile(this._settingsUri, settings); this.logService.debug(`[CopilotCLICustomizationProvider] ${enabled ? 'Enabled' : 'Disabled'} ${type.id} "${name}" in ${this._settingsUri.toString()}`); this._onDidChange.fire(); } catch (err) { - vscode.window.showErrorMessage(vscode.l10n.t('Failed to update Copilot settings: {0}', err instanceof Error ? err.message : String(err))); + void vscode.window.showErrorMessage(vscode.l10n.t('Failed to update Copilot settings: {0}', err instanceof Error ? err.message : String(err))); } } } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts index bc6dcdce76891..8913df9fb392f 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts @@ -585,6 +585,60 @@ describe('ClaudeCustomizationProvider', () => { expect(hookItems[0].enabled).toBe(false); }); + it('disables hooks in lower-priority settings when higher-priority settings has disableAllHooks', async () => { + const workspaceFolder = URI.file('/workspace'); + mockWorkspaceService.setFolders([workspaceFolder]); + + const localSettingsUri = URI.joinPath(workspaceFolder, '.claude', 'settings.local.json'); + const wsSettingsUri = URI.joinPath(workspaceFolder, '.claude', 'settings.json'); + + mockClaudeSettingsService.setSettingsUris([localSettingsUri, wsSettingsUri]); + mockClaudeSettingsService.setFile(localSettingsUri, { + disableAllHooks: true, + hooks: { SessionStart: [{ matcher: '*', hooks: [{ type: 'command', command: './local-init.sh' }] }] }, + }); + mockClaudeSettingsService.setFile(wsSettingsUri, { + hooks: { PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: './check.sh' }] }] }, + }); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook); + expect(hookItems).toHaveLength(2); + // Local hook disabled by its own disableAllHooks + expect(hookItems[0].name).toBe('SessionStart'); + expect(hookItems[0].enabled).toBe(false); + // Workspace hook also disabled because higher-priority local had disableAllHooks + expect(hookItems[1].name).toBe('PreToolUse'); + expect(hookItems[1].enabled).toBe(false); + }); + + it('does not disable hooks in higher-priority settings when lower-priority has disableAllHooks', async () => { + const workspaceFolder = URI.file('/workspace'); + mockWorkspaceService.setFolders([workspaceFolder]); + + const localSettingsUri = URI.joinPath(workspaceFolder, '.claude', 'settings.local.json'); + const wsSettingsUri = URI.joinPath(workspaceFolder, '.claude', 'settings.json'); + + mockClaudeSettingsService.setSettingsUris([localSettingsUri, wsSettingsUri]); + mockClaudeSettingsService.setFile(localSettingsUri, { + hooks: { SessionStart: [{ matcher: '*', hooks: [{ type: 'command', command: './local-init.sh' }] }] }, + }); + mockClaudeSettingsService.setFile(wsSettingsUri, { + disableAllHooks: true, + hooks: { PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: './check.sh' }] }] }, + }); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook); + expect(hookItems).toHaveLength(2); + // Local (higher priority) hook stays enabled + expect(hookItems[0].name).toBe('SessionStart'); + expect(hookItems[0].enabled).toBe(true); + // Workspace hook disabled by its own disableAllHooks + expect(hookItems[1].name).toBe('PreToolUse'); + expect(hookItems[1].enabled).toBe(false); + }); + it('gracefully handles no settings files', async () => { mockWorkspaceService.setFolders([URI.file('/workspace')]); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLICustomizationProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLICustomizationProvider.spec.ts index 4ab67cc1905af..5811f72e615e8 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLICustomizationProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLICustomizationProvider.spec.ts @@ -6,6 +6,7 @@ import type { SweCustomAgent } from '@github/copilot/sdk'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import * as vscode from 'vscode'; +import { IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService'; import { ILogService } from '../../../../platform/log/common/logService'; import { MockCustomInstructionsService } from '../../../../platform/test/common/testCustomInstructionsService'; import { mock } from '../../../../util/common/test/simpleMock'; @@ -15,7 +16,8 @@ import { URI } from '../../../../util/vs/base/common/uri'; import { CLIAgentInfo, ICopilotCLIAgents } from '../../copilotcli/node/copilotCli'; import { CopilotCLICustomizationProvider } from '../copilotCLICustomizationProvider'; import { MockPromptsService } from '../../../../platform/promptFiles/test/common/mockPromptsService'; -import { INativeEnvService } from '../../../../platform/env/common/envService'; +import { CopilotCLISettings, CopilotCLISettingsLocationType, ICopilotCLISettingsService } from '../../copilotcli/common/copilotCLISettingsService'; +import { SessionSettingsFile } from '../../common/sessionSettingsService'; class FakeChatSessionCustomizationType { static readonly Agent = new FakeChatSessionCustomizationType('agent'); @@ -27,6 +29,50 @@ class FakeChatSessionCustomizationType { constructor(readonly id: string) { } } +const FakeChatSessionCustomizationEnablementScope = { + None: 0, + Global: 1, + Workspace: 2, + ManagedByApplication: 3, +} as const; + +class MockFileSystemService extends mock() { + private readonly _files = new Map(); + readonly writtenFiles = new Map(); + + setFile(uri: URI, content: string) { + this._files.set(uri.toString(), new TextEncoder().encode(content)); + } + + override async stat(uri: URI): Promise<{ type: number; ctime: number; mtime: number; size: number }> { + if (!this._files.has(uri.toString())) { + throw new Error(`File not found: ${uri.toString()}`); + } + return { type: 1, ctime: 0, mtime: 0, size: this._files.get(uri.toString())!.length }; + } + + override async readFile(uri: URI): Promise { + const content = this._files.get(uri.toString()); + if (!content) { + throw new Error(`File not found: ${uri.toString()}`); + } + return content; + } + + override async writeFile(uri: URI, content: Uint8Array): Promise { + this._files.set(uri.toString(), content); + this.writtenFiles.set(uri.toString(), content); + } + + getWrittenJson(uri: URI): Record | undefined { + const content = this.writtenFiles.get(uri.toString()); + if (!content) { + return undefined; + } + return JSON.parse(new TextDecoder().decode(content)); + } +} + function makeSweAgent(name: string, description = '', displayName?: string): Readonly { return { name, @@ -74,6 +120,49 @@ function makePlugin(uri: URI): vscode.ChatPlugin { return { uri }; } +class MockCopilotCLISettingsService extends mock() { + private readonly _onDidChange = new Emitter(); + override readonly onDidChange = this._onDidChange.event; + private _settings: CopilotCLISettings = {}; + private _writtenSettings: CopilotCLISettings | undefined; + private readonly _settingsUri: URI; + + constructor(userHome: URI) { + super(); + this._settingsUri = URI.joinPath(userHome, '.copilot', 'settings.json'); + } + + setSettings(settings: CopilotCLISettings) { this._settings = settings; } + getWrittenSettings(): CopilotCLISettings | undefined { return this._writtenSettings; } + + override getUris(location?: CopilotCLISettingsLocationType): URI[] { + if (!location || location === CopilotCLISettingsLocationType.User) { + return [this._settingsUri]; + } + return []; + } + + override getUri(_location: CopilotCLISettingsLocationType, _uri: URI): URI { + return this._settingsUri; + } + + override async readSettingsFile(_uri: URI): Promise { + return this._settings; + } + + override async readAllSettings(): Promise[]>> { + return [{ type: CopilotCLISettingsLocationType.User, settings: this._settings, uri: this._settingsUri }]; + } + + override async writeSettingsFile(_uri: URI, settings: CopilotCLISettings): Promise { + this._settings = settings; + this._writtenSettings = settings; + } + + fireDidChange() { this._onDidChange.fire(); } + dispose() { this._onDidChange.dispose(); } +} + class MockCopilotCLIAgents extends mock() { private readonly _onDidChangeAgents = new Emitter(); override readonly onDidChangeAgents = this._onDidChangeAgents.event; @@ -102,31 +191,41 @@ describe('CopilotCLICustomizationProvider', () => { let mockPromptsService: MockPromptsService; let mockCopilotCLIAgents: MockCopilotCLIAgents; let mockCustomInstructionsService: TestCustomInstructionsService; + let mockFileSystemService: MockFileSystemService; + let mockCopilotCLISettingsService: MockCopilotCLISettingsService; let provider: CopilotCLICustomizationProvider; + const userHome = URI.file('/home/testuser'); + let originalChatSessionCustomizationType: unknown; + let originalChatSessionCustomizationEnablementScope: unknown; beforeEach(() => { originalChatSessionCustomizationType = (vscode as Record).ChatSessionCustomizationType; + originalChatSessionCustomizationEnablementScope = (vscode as Record).ChatSessionCustomizationEnablementScope; (vscode as Record).ChatSessionCustomizationType = FakeChatSessionCustomizationType; + (vscode as Record).ChatSessionCustomizationEnablementScope = FakeChatSessionCustomizationEnablementScope; disposables = new DisposableStore(); mockPromptsService = disposables.add(new MockPromptsService()); mockCopilotCLIAgents = disposables.add(new MockCopilotCLIAgents()); mockCustomInstructionsService = new TestCustomInstructionsService(); + mockFileSystemService = new MockFileSystemService(); + mockCopilotCLISettingsService = disposables.add(new MockCopilotCLISettingsService(userHome)); provider = disposables.add(new CopilotCLICustomizationProvider( mockCopilotCLIAgents, mockCustomInstructionsService, mockPromptsService, new TestLogService(), { getWorkspaceFolders: () => [] } as any, - { stat: () => Promise.reject(new Error('not found')) } as any, - { userHome: URI.file('/home/testuser') } as unknown as INativeEnvService, + mockFileSystemService, + mockCopilotCLISettingsService, )); }); afterEach(() => { disposables.dispose(); (vscode as Record).ChatSessionCustomizationType = originalChatSessionCustomizationType; + (vscode as Record).ChatSessionCustomizationEnablementScope = originalChatSessionCustomizationEnablementScope; }); describe('metadata', () => { @@ -342,7 +441,7 @@ describe('CopilotCLICustomizationProvider', () => { ? Promise.resolve({ type: 1, ctime: 0, mtime: 0, size: 0 }) : Promise.reject(new Error('not found')), } as any, - { userHome: URI.file('/home/testuser') } as unknown as INativeEnvService, + mockCopilotCLISettingsService, )); mockPromptsService.setInstructions([]); @@ -490,5 +589,198 @@ describe('CopilotCLICustomizationProvider', () => { mockCopilotCLIAgents.fireAgentsChanged(); expect(fired).toBe(true); }); + + it('fires when settings change', () => { + let fired = false; + disposables.add(provider.onDidChange(() => { fired = true; })); + + mockCopilotCLISettingsService.fireDidChange(); + expect(fired).toBe(true); + }); + }); + + describe('skill enablement', () => { + it('marks skill as enabled by default when no settings file', async () => { + const uri = URI.file('/workspace/.github/skills/lint-check/SKILL.md'); + mockPromptsService.setSkills([makeSkill(uri, 'lint-check')]); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill); + expect(skillItems[0].enabled).toBe(true); + }); + + it('marks skill as disabled when in disabledSkills setting', async () => { + mockCopilotCLISettingsService.setSettings({ disabledSkills: ['lint-check'] }); + const uri = URI.file('/workspace/.github/skills/lint-check/SKILL.md'); + mockPromptsService.setSkills([makeSkill(uri, 'lint-check')]); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill); + expect(skillItems[0].enabled).toBe(false); + }); + + it('marks skill as enabled when not in disabledSkills', async () => { + mockCopilotCLISettingsService.setSettings({ disabledSkills: ['other-skill'] }); + const uri = URI.file('/workspace/.github/skills/lint-check/SKILL.md'); + mockPromptsService.setSkills([makeSkill(uri, 'lint-check')]); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill); + expect(skillItems[0].enabled).toBe(true); + }); + + it('sets enablementScope to Global for skills', async () => { + const uri = URI.file('/workspace/.github/skills/lint-check/SKILL.md'); + mockPromptsService.setSkills([makeSkill(uri, 'lint-check')]); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill); + expect(skillItems[0].enablementScope).toBe(FakeChatSessionCustomizationEnablementScope.Global); + }); + + it('disabling a skill adds it to disabledSkills in settings', async () => { + mockCopilotCLISettingsService.setSettings({}); + const skillUri = URI.file('/workspace/.github/skills/lint-check/SKILL.md'); + + await provider.handleCustomizationEnablement( + skillUri, FakeChatSessionCustomizationType.Skill as any, + false, FakeChatSessionCustomizationEnablementScope.Global, undefined!); + + const written = mockCopilotCLISettingsService.getWrittenSettings(); + expect(written).toBeDefined(); + expect(written!.disabledSkills).toEqual(['lint-check']); + }); + + it('enabling a skill removes it from disabledSkills', async () => { + mockCopilotCLISettingsService.setSettings({ disabledSkills: ['lint-check', 'other'] }); + const skillUri = URI.file('/workspace/.github/skills/lint-check/SKILL.md'); + + await provider.handleCustomizationEnablement( + skillUri, FakeChatSessionCustomizationType.Skill as any, + true, FakeChatSessionCustomizationEnablementScope.Global, undefined!); + + const written = mockCopilotCLISettingsService.getWrittenSettings(); + expect(written).toBeDefined(); + expect(written!.disabledSkills).toEqual(['other']); + }); + + it('does not duplicate when disabling an already disabled skill', async () => { + mockCopilotCLISettingsService.setSettings({ disabledSkills: ['lint-check'] }); + const skillUri = URI.file('/workspace/.github/skills/lint-check/SKILL.md'); + + await provider.handleCustomizationEnablement( + skillUri, FakeChatSessionCustomizationType.Skill as any, + false, FakeChatSessionCustomizationEnablementScope.Global, undefined!); + + const written = mockCopilotCLISettingsService.getWrittenSettings(); + expect(written).toBeDefined(); + expect(written!.disabledSkills).toEqual(['lint-check']); + }); + + it('fires onDidChange after toggling a skill', async () => { + mockCopilotCLISettingsService.setSettings({}); + let fired = false; + disposables.add(provider.onDidChange(() => { fired = true; })); + + const skillUri = URI.file('/workspace/.github/skills/lint-check/SKILL.md'); + await provider.handleCustomizationEnablement( + skillUri, FakeChatSessionCustomizationType.Skill as any, + false, FakeChatSessionCustomizationEnablementScope.Global, undefined!); + + expect(fired).toBe(true); + }); + }); + + describe('plugin enablement', () => { + it('marks plugin as enabled by default when no settings file', async () => { + const uri = URI.file('/workspace/.copilot/plugins/my-plugin'); + mockPromptsService.setPlugins([makePlugin(uri)]); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const pluginItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Plugins); + expect(pluginItems[0].enabled).toBe(true); + }); + + it('marks plugin as disabled when enabledPlugins is false', async () => { + mockCopilotCLISettingsService.setSettings({ enabledPlugins: { 'my-plugin': false } }); + const uri = URI.file('/workspace/.copilot/plugins/my-plugin'); + mockPromptsService.setPlugins([makePlugin(uri)]); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const pluginItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Plugins); + expect(pluginItems[0].enabled).toBe(false); + }); + + it('marks plugin as enabled when enabledPlugins is true', async () => { + mockCopilotCLISettingsService.setSettings({ enabledPlugins: { 'my-plugin': true } }); + const uri = URI.file('/workspace/.copilot/plugins/my-plugin'); + mockPromptsService.setPlugins([makePlugin(uri)]); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const pluginItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Plugins); + expect(pluginItems[0].enabled).toBe(true); + }); + + it('sets enablementScope to Global for plugins', async () => { + const uri = URI.file('/workspace/.copilot/plugins/my-plugin'); + mockPromptsService.setPlugins([makePlugin(uri)]); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const pluginItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Plugins); + expect(pluginItems[0].enablementScope).toBe(FakeChatSessionCustomizationEnablementScope.Global); + }); + + it('disabling a plugin sets enabledPlugins to false', async () => { + mockCopilotCLISettingsService.setSettings({}); + const pluginUri = URI.file('/workspace/.copilot/plugins/my-plugin'); + + await provider.handleCustomizationEnablement( + pluginUri, FakeChatSessionCustomizationType.Plugins as any, + false, FakeChatSessionCustomizationEnablementScope.Global, undefined!); + + const written = mockCopilotCLISettingsService.getWrittenSettings(); + expect(written).toBeDefined(); + expect((written!.enabledPlugins as Record)['my-plugin']).toBe(false); + }); + + it('enabling a plugin removes it from enabledPlugins', async () => { + mockCopilotCLISettingsService.setSettings({ enabledPlugins: { 'my-plugin': false } }); + const pluginUri = URI.file('/workspace/.copilot/plugins/my-plugin'); + + await provider.handleCustomizationEnablement( + pluginUri, FakeChatSessionCustomizationType.Plugins as any, + true, FakeChatSessionCustomizationEnablementScope.Global, undefined!); + + const written = mockCopilotCLISettingsService.getWrittenSettings(); + expect(written).toBeDefined(); + expect(written!.enabledPlugins).toBeUndefined(); + }); + + it('preserves other plugin entries when toggling one', async () => { + mockCopilotCLISettingsService.setSettings({ enabledPlugins: { 'my-plugin': false, 'other-plugin': false } }); + const pluginUri = URI.file('/workspace/.copilot/plugins/my-plugin'); + + await provider.handleCustomizationEnablement( + pluginUri, FakeChatSessionCustomizationType.Plugins as any, + true, FakeChatSessionCustomizationEnablementScope.Global, undefined!); + + const written = mockCopilotCLISettingsService.getWrittenSettings(); + expect(written).toBeDefined(); + expect((written!.enabledPlugins as Record)['other-plugin']).toBe(false); + expect((written!.enabledPlugins as Record)['my-plugin']).toBeUndefined(); + }); + + it('fires onDidChange after toggling a plugin', async () => { + mockCopilotCLISettingsService.setSettings({}); + let fired = false; + disposables.add(provider.onDidChange(() => { fired = true; })); + + const pluginUri = URI.file('/workspace/.copilot/plugins/my-plugin'); + await provider.handleCustomizationEnablement( + pluginUri, FakeChatSessionCustomizationType.Plugins as any, + false, FakeChatSessionCustomizationEnablementScope.Global, undefined!); + + expect(fired).toBe(true); + }); }); }); diff --git a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts index e8deccab75fe5..06aa3e1d6ff87 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts @@ -51,19 +51,6 @@ declare module 'vscode' { Global = 1, /** Both "Disable" and "Disable (Workspace)" actions are shown, allowing per-workspace overrides. */ Workspace = 2, - /** - * The item is reported by the provider but its enablement is managed - * by the application rather than the provider's - * {@link ChatSessionCustomizationEnablementHandler}. Disable/enable - * actions are shown but their state is persisted internally. - * - * Use this for items the provider discovers from APIs - * (e.g. extension-contributed customizations) where the provider - * does not own the enablement lifecycle. Note that any enablement - * changes here would not be remembered outside of the current application, - * so it is less portable. - */ - ManagedByApplication = 3, } /** From 37cbecc553123fef82635cf1b539b9454509eb59 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Thu, 23 Apr 2026 21:57:33 -0700 Subject: [PATCH 21/36] clean --- .../copilotcli/node/copilotCli.ts | 5 +- .../test/copilotCLISettingsService.spec.ts | 254 ++++++++++++++++++ .../api/browser/mainThreadChatAgents2.ts | 22 +- .../workbench/api/common/extHost.protocol.ts | 2 +- .../api/common/extHostChatAgents2.ts | 2 +- .../aiCustomizationItemSource.ts | 12 +- .../aiCustomizationListWidget.ts | 1 - .../aiCustomizationManagement.contribution.ts | 16 +- .../customizationHarnessService.ts | 22 +- .../common/customizationHarnessService.ts | 40 ++- .../aiCustomizationDisablement.test.ts | 93 +++---- 11 files changed, 338 insertions(+), 131 deletions(-) create mode 100644 extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCLISettingsService.spec.ts diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts index 853a0c59e66b5..6898c09c7b387 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts @@ -278,8 +278,6 @@ export interface CLIAgentInfo { readonly agent: Readonly; /** File URI for prompt-file agents, synthetic `copilotcli:` URI for SDK-only agents. */ readonly sourceUri: URI; - /** Where the agent was loaded from (e.g. 'local', 'extension'). Undefined for SDK-only agents. */ - readonly source?: vscode.ChatResourceSource; } export interface ICopilotCLIAgents { @@ -377,7 +375,7 @@ export class CopilotCLIAgents extends Disposable implements ICopilotCLIAgents { }); } - return this._agentsPromise.then(infos => infos.map(i => ({ agent: this.cloneAgent(i.agent), sourceUri: i.sourceUri, source: i.source }))); + return this._agentsPromise.then(infos => infos.map(i => ({ agent: this.cloneAgent(i.agent), sourceUri: i.sourceUri }))); } async getAgentsImpl(): Promise { @@ -444,7 +442,6 @@ export class CopilotCLIAgents extends Disposable implements ICopilotCLIAgents { ...(model ? { model } : {}), }, sourceUri: customAgent.uri, - source: customAgent.source, }; } diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCLISettingsService.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCLISettingsService.spec.ts new file mode 100644 index 0000000000000..ec866e2ee86d9 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCLISettingsService.spec.ts @@ -0,0 +1,254 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { INativeEnvService } from '../../../../../platform/env/common/envService'; +import { IFileSystemService } from '../../../../../platform/filesystem/common/fileSystemService'; +import { IWorkspaceService } from '../../../../../platform/workspace/common/workspaceService'; +import { mock } from '../../../../../util/common/test/simpleMock'; +import { Emitter, Event } from '../../../../../util/vs/base/common/event'; +import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle'; +import { URI } from '../../../../../util/vs/base/common/uri'; +import { CopilotCLISettingsLocationType } from '../../common/copilotCLISettingsService'; +import { CopilotCLISettingsService } from '../copilotCLISettingsService'; +import type { FileSystemWatcher, RelativePattern } from 'vscode'; + +class MockWorkspaceService extends mock() { + private _folders: URI[] = []; + private readonly _onDidChange = new Emitter(); + override readonly onDidChangeWorkspaceFolders: Event = this._onDidChange.event; + setFolders(folders: URI[]) { this._folders = folders; } + override getWorkspaceFolders(): URI[] { return this._folders; } + dispose() { this._onDidChange.dispose(); } +} + +class MockFileSystemService extends mock() { + private readonly _files = new Map(); + readonly writtenFiles = new Map(); + + setFile(uri: URI, content: string) { + this._files.set(uri.toString(), new TextEncoder().encode(content)); + } + + override async readFile(uri: URI): Promise { + const content = this._files.get(uri.toString()); + if (!content) { + throw new Error(`File not found: ${uri.toString()}`); + } + return content; + } + + override async writeFile(uri: URI, content: Uint8Array): Promise { + this._files.set(uri.toString(), content); + this.writtenFiles.set(uri.toString(), content); + } + + override createFileSystemWatcher(_glob: string | RelativePattern): FileSystemWatcher { + return { + ignoreCreateEvents: false, + ignoreChangeEvents: false, + ignoreDeleteEvents: false, + onDidCreate: Event.None, + onDidChange: Event.None, + onDidDelete: Event.None, + dispose() { }, + }; + } +} + +class MockEnvService extends mock() { + override userHome = URI.file('/home/user'); +} + +describe('CopilotCLISettingsService', () => { + let disposables: DisposableStore; + let mockWorkspaceService: MockWorkspaceService; + let mockFileSystemService: MockFileSystemService; + let service: CopilotCLISettingsService; + + const userHome = URI.file('/home/user'); + const settingsUri = URI.joinPath(userHome, '.copilot', 'settings.json'); + + beforeEach(() => { + disposables = new DisposableStore(); + mockWorkspaceService = disposables.add(new MockWorkspaceService()); + mockFileSystemService = new MockFileSystemService(); + service = disposables.add(new CopilotCLISettingsService( + mockWorkspaceService, + mockFileSystemService, + new MockEnvService(), + )); + }); + + afterEach(() => { + disposables.dispose(); + }); + + describe('getUris', () => { + it('returns user settings URI', () => { + const uris = service.getUris(CopilotCLISettingsLocationType.User); + expect(uris).toHaveLength(1); + expect(uris[0].path).toBe('/home/user/.copilot/settings.json'); + }); + + it('returns all URIs when no location specified', () => { + const uris = service.getUris(); + expect(uris).toHaveLength(1); + expect(uris[0].path).toBe('/home/user/.copilot/settings.json'); + }); + }); + + describe('getUri', () => { + it('returns user settings URI regardless of input URI', () => { + const uri = service.getUri(CopilotCLISettingsLocationType.User, URI.file('/workspace/src/file.ts')); + expect(uri.path).toBe('/home/user/.copilot/settings.json'); + }); + }); + + describe('readSettingsFile', () => { + it('returns parsed JSON from a settings file', async () => { + mockFileSystemService.setFile(settingsUri, JSON.stringify({ disabledSkills: ['my-skill'] })); + + const result = await service.readSettingsFile(settingsUri); + expect(result).toEqual({ disabledSkills: ['my-skill'] }); + }); + + it('returns empty object when file does not exist', async () => { + const result = await service.readSettingsFile(settingsUri); + expect(result).toEqual({}); + }); + + it('returns empty object for invalid JSON', async () => { + mockFileSystemService.setFile(settingsUri, 'not valid json'); + + const result = await service.readSettingsFile(settingsUri); + expect(result).toEqual({}); + }); + + it('returns empty object for JSON arrays', async () => { + mockFileSystemService.setFile(settingsUri, '[1, 2, 3]'); + + const result = await service.readSettingsFile(settingsUri); + expect(result).toEqual({}); + }); + }); + + describe('readAllSettings', () => { + it('reads settings file and returns it with User type', async () => { + mockFileSystemService.setFile(settingsUri, JSON.stringify({ disabledSkills: ['lint'] })); + + const results = await service.readAllSettings(); + expect(results).toHaveLength(1); + expect(results[0].type).toBe(CopilotCLISettingsLocationType.User); + expect(results[0].settings).toEqual({ disabledSkills: ['lint'] }); + expect(results[0].uri.path).toBe('/home/user/.copilot/settings.json'); + }); + + it('returns empty settings when file does not exist', async () => { + const results = await service.readAllSettings(); + expect(results).toHaveLength(1); + expect(results[0].settings).toEqual({}); + }); + + it('caches results across calls', async () => { + mockFileSystemService.setFile(settingsUri, JSON.stringify({ cached: true })); + + const first = await service.readAllSettings(); + mockFileSystemService.setFile(settingsUri, JSON.stringify({ cached: false })); + const second = await service.readAllSettings(); + + expect(first).toBe(second); + }); + }); + + describe('writeSettingsFile', () => { + it('writes settings as formatted JSON', async () => { + const settings = { disabledSkills: ['my-skill'], enabledPlugins: { 'my-plugin': false } }; + + await service.writeSettingsFile(settingsUri, settings); + + const written = mockFileSystemService.writtenFiles.get(settingsUri.toString()); + expect(written).toBeDefined(); + const parsed = JSON.parse(new TextDecoder().decode(written!)); + expect(parsed).toEqual(settings); + }); + + it('uses 4-space indentation', async () => { + await service.writeSettingsFile(settingsUri, { key: 'value' } as any); + + const written = new TextDecoder().decode(mockFileSystemService.writtenFiles.get(settingsUri.toString())!); + expect(written).toBe(JSON.stringify({ key: 'value' }, null, 4)); + }); + }); + + describe('onDidChange', () => { + it('fires when a watched file changes', () => { + const changeEmitters: Emitter[] = []; + const fsService = new class extends MockFileSystemService { + override createFileSystemWatcher(): FileSystemWatcher { + const changeEmitter = new Emitter(); + changeEmitters.push(changeEmitter); + return { + ignoreCreateEvents: false, + ignoreChangeEvents: false, + ignoreDeleteEvents: false, + onDidCreate: Event.None, + onDidChange: changeEmitter.event, + onDidDelete: Event.None, + dispose() { }, + }; + } + }(); + + const svc = disposables.add(new CopilotCLISettingsService( + mockWorkspaceService, + fsService, + new MockEnvService(), + )); + + let fired = false; + disposables.add(svc.onDidChange(() => { fired = true; })); + + expect(changeEmitters.length).toBeGreaterThan(0); + changeEmitters[0].fire(settingsUri); + expect(fired).toBe(true); + }); + + it('invalidates cache when a file changes', async () => { + const changeEmitters: Emitter[] = []; + const fsService = new class extends MockFileSystemService { + override createFileSystemWatcher(): FileSystemWatcher { + const changeEmitter = new Emitter(); + changeEmitters.push(changeEmitter); + return { + ignoreCreateEvents: false, + ignoreChangeEvents: false, + ignoreDeleteEvents: false, + onDidCreate: Event.None, + onDidChange: changeEmitter.event, + onDidDelete: Event.None, + dispose() { }, + }; + } + }(); + + const svc = disposables.add(new CopilotCLISettingsService( + mockWorkspaceService, + fsService, + new MockEnvService(), + )); + + fsService.setFile(settingsUri, JSON.stringify({ original: true })); + const first = await svc.readAllSettings(); + expect(first[0].settings).toEqual({ original: true }); + + fsService.setFile(settingsUri, JSON.stringify({ updated: true })); + changeEmitters[0].fire(settingsUri); + + const second = await svc.readAllSettings(); + expect(second[0].settings).toEqual({ updated: true }); + }); + }); +}); diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index a02b09e8a0510..71dc5eb278551 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -6,7 +6,6 @@ import { DeferredPromise } from '../../../base/common/async.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../base/common/event.js'; -import { ResourceSet } from '../../../base/common/map.js'; import { IMarkdownString } from '../../../base/common/htmlContent.js'; import { Disposable, DisposableMap, IDisposable } from '../../../base/common/lifecycle.js'; import { autorun } from '../../../base/common/observable.js'; @@ -47,7 +46,7 @@ import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; import { ExtHostChatAgentsShape2, ExtHostContext, IChatAgentInvokeResult, IChatSessionCustomizationItemDto, IChatSessionCustomizationProviderMetadataDto, IChatNotebookEditDto, IChatParticipantMetadata, IChatProgressDto, IChatSessionContextDto, ICustomAgentDto, IDynamicChatAgentProps, IExtensionChatAgentMetadata, IHookDto, IInstructionDto, IPluginDto, ISkillDto, ISlashCommandDto, MainContext, MainThreadChatAgentsShape2 } from '../common/extHost.protocol.js'; import { NotebookDto } from './mainThreadNotebookDto.js'; import { getChatSessionType, isUntitledChatSession } from '../../contrib/chat/common/model/chatUri.js'; -import { ICustomizationEnablementProvider, ICustomizationHarnessService, ICustomizationItem, ICustomizationItemProvider, IHarnessDescriptor } from '../../contrib/chat/common/customizationHarnessService.js'; +import { ICustomizationEnablementHandler, ICustomizationHarnessService, ICustomizationItem, ICustomizationItemProvider, IHarnessDescriptor } from '../../contrib/chat/common/customizationHarnessService.js'; import { AICustomizationManagementSection, BUILTIN_STORAGE } from '../../contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IAgentPlugin, IAgentPluginService } from '../../contrib/chat/common/plugins/agentPluginService.js'; import { IWorkbenchEnvironmentService } from '../../services/environment/common/environmentService.js'; @@ -778,22 +777,13 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA // Build an enablement provider when the extension implements handleCustomizationEnablement. // This delegates disable/enable to the extension instead of VS Code's StorageService. - let enablementProvider: ICustomizationEnablementProvider | undefined; + let enablementHandler: ICustomizationEnablementHandler | undefined; if (hasEnablementHandler) { const proxy = this._proxy; const providerHandle = handle; - enablementProvider = { - // The extension reports enabled state via the itemProvider — the - // enablement provider's onDidChange is driven by the item provider's - // onDidChange, so we reuse the same emitter. - onDidChange: emitter.event, - getDisabledPromptFiles: (): ResourceSet => { - // Extension reports disabled state on items directly via `enabled: false`. - // No separate disabled set needed. - return new ResourceSet(); - }, - setEnabled: (uri: URI, type: PromptsType, enabled: boolean, scope: 'global' | 'workspace'): void => { - proxy.$setCustomizationEnabled(providerHandle, uri.toJSON(), type, enabled, scope); + enablementHandler = { + handleCustomizationEnablement: (uri: URI, type: PromptsType, enabled: boolean, scope: 'global' | 'workspace'): void => { + proxy.$handleCustomizationEnablement(providerHandle, uri.toJSON(), type, enabled, scope); }, }; } @@ -809,7 +799,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.plugin, PromptsStorage.extension, BUILTIN_STORAGE], }), itemProvider, - enablementProvider, + enablementHandler, }; const registration = this._customizationHarnessService.registerExternalHarness(descriptor); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 9e5976290bdd4..3891651cfc631 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1672,7 +1672,7 @@ export interface ExtHostChatAgentsShape2 { $detectChatParticipant(handle: number, request: Dto, context: { history: IChatAgentHistoryEntryDto[] }, options: { participants: IChatParticipantMetadata[]; location: ChatAgentLocation }, token: CancellationToken): Promise; $providePromptFiles(handle: number, type: PromptsType, context: IPromptFileContext, token: CancellationToken): Promise[] | undefined>; $provideChatSessionCustomizations(handle: number, token: CancellationToken): Promise; - $setCustomizationEnabled(handle: number, uri: UriComponents, type: string, enabled: boolean, scope: 'global' | 'workspace'): Promise; + $handleCustomizationEnablement(handle: number, uri: UriComponents, type: string, enabled: boolean, scope: 'global' | 'workspace'): Promise; $setRequestTools(requestId: string, tools: UserSelectedTools): void; $setYieldRequested(requestId: string, value: boolean): void; $acceptActiveChatSession(sessionResource: UriComponents | undefined): void; diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 9d01456293528..ed88c56e77242 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -852,7 +852,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS } } - async $setCustomizationEnabled(handle: number, uri: UriComponents, type: string, enabled: boolean, scope: 'global' | 'workspace'): Promise { + async $handleCustomizationEnablement(handle: number, uri: UriComponents, type: string, enabled: boolean, scope: 'global' | 'workspace'): Promise { const providerData = this._customizationProviders.get(handle); if (!providerData?.enablementHandler) { return; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index 8a41bf9ea3b48..654d11115de35 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -21,7 +21,7 @@ import { IProductService } from '../../../../../platform/product/common/productS import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; -import { ICustomizationSyncProvider, ICustomizationItem, ICustomizationItemProvider, ICustomizationEnablementProvider } from '../../common/customizationHarnessService.js'; +import { ICustomizationSyncProvider, ICustomizationItem, ICustomizationItemProvider } from '../../common/customizationHarnessService.js'; import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { parseHooksFromFile } from '../../common/promptSyntax/hookCompatibility.js'; import { formatHookCommandLabel, getEffectiveCommandFieldKey } from '../../common/promptSyntax/hookSchema.js'; @@ -346,7 +346,6 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour private readonly harnessId: string, private readonly itemProvider: ICustomizationItemProvider | undefined, private readonly syncProvider: ICustomizationSyncProvider | undefined, - private readonly enablementProvider: ICustomizationEnablementProvider | undefined, private readonly promptsService: IPromptsService, private readonly workspaceService: IAICustomizationWorkspaceService, private readonly fileService: IFileService, @@ -371,7 +370,6 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour this.onDidChange = Event.any( this.itemProvider?.onDidChange ?? Event.None, this.syncProvider?.onDidChange ?? Event.None, - this.enablementProvider?.onDidChange ?? Event.None, promptServiceEvents, ); } @@ -429,13 +427,10 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour // promptsService. On external harnesses (with itemProvider) the namespace // isolates per-harness state. On the VS Code harness (no itemProvider) no // namespace is needed. - const apiDisabledUris = this.enablementProvider - ? this.enablementProvider.getDisabledPromptFiles(promptType) - : undefined; const vscodeDisabledUris = this.hasNativeItemProvider ? this.promptsService.getDisabledPromptFiles(promptType, this.harnessId) : this.promptsService.getDisabledPromptFiles(promptType); - const hasDisabled = (apiDisabledUris && apiDisabledUris.size > 0) || vscodeDisabledUris.size > 0; + const hasDisabled = vscodeDisabledUris.size > 0; if (hasDisabled) { const existingUris = new ResourceSet(normalized.map(i => i.uri)); for (let i = 0; i < normalized.length; i++) { @@ -450,14 +445,13 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour || normalized[i].enablementScope === 'application'; const isDisabled = isVSCodeItem ? vscodeDisabledUris.has(normalized[i].uri) - : (apiDisabledUris?.has(normalized[i].uri) ?? false); + : false; if (isDisabled) { normalized[i] = { ...normalized[i], disabled: true }; } } // Ghost entries from all disabled sets for items not in the provider's results. const allDisabledUris = new ResourceSet([ - ...(apiDisabledUris ?? []), ...vscodeDisabledUris, ]); const missing = await this.resolveMissingDisabledItems(promptType, allDisabledUris, existingUris, vscodeDisabledUris); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 6c2334eb8c678..c0473e219415a 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -1461,7 +1461,6 @@ export class AICustomizationListWidget extends Disposable { descriptor.id, itemProvider, descriptor.syncProvider, - descriptor.enablementProvider, this.promptsService, this.workspaceService, this.fileService, diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index 290a84d525365..90c39abc5ad94 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -796,7 +796,7 @@ registerAction2(class extends Action2 { if (plugin) { const enablementProvider = harnessService.getActiveEnablementProvider(); if (enablementProvider) { - enablementProvider.setEnabled(plugin.uri, plugin.type as PromptsType, false, 'global'); + enablementProvider.handleCustomizationEnablement(plugin.uri, plugin.type as PromptsType, false, 'global'); } return; } @@ -807,10 +807,10 @@ registerAction2(class extends Action2 { const enablementProvider = harnessService.getActiveEnablementProvider(); const descriptor = harnessService.getActiveDescriptor(); if (enablementProvider && hasProviderEnablement(context)) { - enablementProvider.setEnabled(uri, promptType, false, 'global'); + enablementProvider.handleCustomizationEnablement(uri, promptType, false, 'global'); } else if (enablementProvider && !descriptor.itemProvider) { // VS Code harness — delegate to its enablement provider (no namespace) - enablementProvider.setEnabled(uri, promptType, false, 'global'); + enablementProvider.handleCustomizationEnablement(uri, promptType, false, 'global'); } else { const namespace = descriptor.id; const storage = extractStorage(context); @@ -843,10 +843,10 @@ registerAction2(class extends Action2 { const enablementProvider = harnessService.getActiveEnablementProvider(); const descriptor = harnessService.getActiveDescriptor(); if (enablementProvider && hasProviderEnablement(context)) { - enablementProvider.setEnabled(uri, promptType, false, 'workspace'); + enablementProvider.handleCustomizationEnablement(uri, promptType, false, 'workspace'); } else if (enablementProvider && !descriptor.itemProvider) { // VS Code harness — delegate to its enablement provider (no namespace) - enablementProvider.setEnabled(uri, promptType, false, 'workspace'); + enablementProvider.handleCustomizationEnablement(uri, promptType, false, 'workspace'); } else { const namespace = descriptor.id; const disabled = promptsService.getDisabledPromptFilesForScope(promptType, StorageScope.WORKSPACE, namespace); @@ -880,7 +880,7 @@ registerAction2(class extends Action2 { if (plugin) { const enablementProvider = harnessService.getActiveEnablementProvider(); if (enablementProvider) { - enablementProvider.setEnabled(plugin.uri, plugin.type as PromptsType, true, 'global'); + enablementProvider.handleCustomizationEnablement(plugin.uri, plugin.type as PromptsType, true, 'global'); } return; } @@ -891,10 +891,10 @@ registerAction2(class extends Action2 { const enablementProvider = harnessService.getActiveEnablementProvider(); const descriptor = harnessService.getActiveDescriptor(); if (enablementProvider && hasProviderEnablement(context)) { - enablementProvider.setEnabled(uri, promptType, true, 'global'); + enablementProvider.handleCustomizationEnablement(uri, promptType, true, 'global'); } else if (enablementProvider && !descriptor.itemProvider) { // VS Code harness — delegate to its enablement provider (no namespace) - enablementProvider.setEnabled(uri, promptType, true, 'global'); + enablementProvider.handleCustomizationEnablement(uri, promptType, true, 'global'); } else { const namespace = descriptor.id; // Remove from both scopes to fully re-enable diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts index 24bd26ff462f7..e6caea77ed27e 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts @@ -3,15 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Event } from '../../../../../base/common/event.js'; -import { ResourceSet } from '../../../../../base/common/map.js'; import { URI } from '../../../../../base/common/uri.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; import { StorageScope } from '../../../../../platform/storage/common/storage.js'; import { CustomizationHarness, CustomizationHarnessServiceBase, - ICustomizationEnablementProvider, + ICustomizationEnablementHandler, ICustomizationHarnessService, createVSCodeHarnessDescriptor, } from '../../common/customizationHarnessService.js'; @@ -23,19 +21,9 @@ import { BUILTIN_STORAGE } from '../../common/aiCustomizationWorkspaceService.js * Enablement provider backed by promptsService (StorageService). * Used by the VS Code (Local) harness to manage disabled customizations. */ -function createPromptsServiceEnablementProvider(promptsService: IPromptsService): ICustomizationEnablementProvider { +function createPromptsServiceEnablementHandler(promptsService: IPromptsService): ICustomizationEnablementHandler { return { - onDidChange: Event.any( - promptsService.onDidChangeCustomAgents, - promptsService.onDidChangeSlashCommands, - promptsService.onDidChangeSkills, - promptsService.onDidChangeHooks, - promptsService.onDidChangeInstructions, - ), - getDisabledPromptFiles(type: PromptsType): ResourceSet { - return promptsService.getDisabledPromptFiles(type); - }, - setEnabled(uri: URI, type: PromptsType, enabled: boolean, scope: 'global' | 'workspace'): void { + handleCustomizationEnablement(uri: URI, type: PromptsType, enabled: boolean, scope: 'global' | 'workspace'): void { const storageScope = scope === 'workspace' ? StorageScope.WORKSPACE : StorageScope.PROFILE; const disabled = promptsService.getDisabledPromptFilesForScope(type, storageScope); if (enabled) { @@ -68,9 +56,9 @@ class CustomizationHarnessService extends CustomizationHarnessServiceBase { @IPromptsService promptsService: IPromptsService, ) { const localExtras = [PromptsStorage.extension, BUILTIN_STORAGE]; - const enablementProvider = createPromptsServiceEnablementProvider(promptsService); + const enablementHandler = createPromptsServiceEnablementHandler(promptsService); super( - [createVSCodeHarnessDescriptor(localExtras, enablementProvider)], + [createVSCodeHarnessDescriptor(localExtras, enablementHandler)], CustomizationHarness.VSCode, ); } diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index 0641f29db0740..ba560457831f0 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../base/common/codicons.js'; -import { ResourceSet } from '../../../../base/common/map.js'; import { IObservable, ISettableObservable, observableValue } from '../../../../base/common/observable.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; import { Event } from '../../../../base/common/event.js'; @@ -143,7 +142,7 @@ export interface IHarnessDescriptor { * When set, this harness manages its own enablement state. When absent, * the management UI falls back to promptsService (StorageService). */ - readonly enablementProvider?: ICustomizationEnablementProvider; + readonly enablementHandler?: ICustomizationEnablementHandler; } /** @@ -177,28 +176,19 @@ export interface ICustomizationItem { } /** - * Provider interface for per-harness enablement state. + * Handler interface for per-harness enablement state. * - * Each harness can supply its own enablement provider to control where + * Each harness can supply its own enablement handler to control where * disabled state is stored (e.g. StorageService for VS Code, settings.json - * for CLI). When a harness does not supply an enablement provider, the + * for CLI). When a harness does not supply an enablement handler, the * management UI falls back to the core promptsService storage. */ -export interface ICustomizationEnablementProvider { - /** Fires when any enablement state changes. */ - readonly onDidChange: Event; - - /** - * Returns the merged set of disabled URIs for a given prompt type. - * Used by the item source to overlay disabled state onto provider items. - */ - getDisabledPromptFiles(type: PromptsType): ResourceSet; - +export interface ICustomizationEnablementHandler { /** * Enables or disables a single customization item. - * The provider is expected to persist the change and fire {@link onDidChange}. + * The handler is expected to persist the change and fire {@link ICustomizationItemProvider.onDidChange}. */ - setEnabled(uri: URI, type: PromptsType, enabled: boolean, scope: 'global' | 'workspace'): void; + handleCustomizationEnablement(uri: URI, type: PromptsType, enabled: boolean, scope: 'global' | 'workspace'): void; } /** @@ -299,7 +289,7 @@ export interface ICustomizationHarnessService { * `undefined` when the harness has no custom enablement provider * (in which case the caller should fall back to promptsService). */ - getActiveEnablementProvider(): ICustomizationEnablementProvider | undefined; + getActiveEnablementProvider(): ICustomizationEnablementHandler | undefined; } /** @@ -411,7 +401,7 @@ function buildAllSources(extras: readonly string[]): readonly string[] { * Creates a "VS Code" harness descriptor that shows all storage sources * with no user-root restrictions. */ -export function createVSCodeHarnessDescriptor(extras: readonly string[], enablementProvider?: ICustomizationEnablementProvider): IHarnessDescriptor { +export function createVSCodeHarnessDescriptor(extras: readonly string[], enablementProvider?: ICustomizationEnablementHandler): IHarnessDescriptor { const filter: IStorageSourceFilter = { sources: buildAllSources(extras) }; return { id: CustomizationHarness.VSCode, @@ -424,7 +414,7 @@ export function createVSCodeHarnessDescriptor(extras: readonly string[], enablem }], ]), getStorageSourceFilter: () => filter, - enablementProvider, + enablementHandler: enablementProvider, }; } @@ -440,7 +430,7 @@ interface IRestrictedHarnessOptions { readonly sectionOverrides?: ReadonlyMap; readonly requiredAgentId?: string; readonly instructionFileFilter?: readonly string[]; - readonly enablementProvider?: ICustomizationEnablementProvider; + readonly enablementProvider?: ICustomizationEnablementHandler; } function createRestrictedHarnessDescriptor( @@ -464,7 +454,7 @@ function createRestrictedHarnessDescriptor( sectionOverrides: options?.sectionOverrides, requiredAgentId: options?.requiredAgentId, instructionFileFilter: options?.instructionFileFilter, - enablementProvider: options?.enablementProvider, + enablementHandler: options?.enablementProvider, getStorageSourceFilter(type: PromptsType): IStorageSourceFilter { if (type === PromptsType.hook) { return HOOKS_FILTER; @@ -480,7 +470,7 @@ function createRestrictedHarnessDescriptor( /** * Creates a "Copilot CLI" harness descriptor. */ -export function createCliHarnessDescriptor(cliUserRoots: readonly URI[], extras: readonly string[], enablementProvider?: ICustomizationEnablementProvider): IHarnessDescriptor { +export function createCliHarnessDescriptor(cliUserRoots: readonly URI[], extras: readonly string[], enablementProvider?: ICustomizationEnablementHandler): IHarnessDescriptor { return createRestrictedHarnessDescriptor( CustomizationHarness.CLI, localize('harness.cli', "Copilot CLI"), @@ -623,8 +613,8 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer return all.find(h => h.id === activeId) ?? all[0]; } - getActiveEnablementProvider(): ICustomizationEnablementProvider | undefined { - return this.getActiveDescriptor().enablementProvider; + getActiveEnablementProvider(): ICustomizationEnablementHandler | undefined { + return this.getActiveDescriptor().enablementHandler; } } diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts index 605004ceb0dd8..ead39aa9c11b4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts @@ -16,7 +16,7 @@ import { IWorkspaceContextService } from '../../../../../../platform/workspace/c import { ProviderCustomizationItemSource, AICustomizationItemNormalizer } from '../../../browser/aiCustomization/aiCustomizationItemSource.js'; import { computeItemEnablementKeys } from '../../../browser/aiCustomization/aiCustomizationListWidgetUtils.js'; import { IAICustomizationWorkspaceService } from '../../../common/aiCustomizationWorkspaceService.js'; -import { ICustomizationEnablementProvider, ICustomizationItem, ICustomizationItemProvider } from '../../../common/customizationHarnessService.js'; +import { ICustomizationEnablementHandler, ICustomizationItem, ICustomizationItemProvider } from '../../../common/customizationHarnessService.js'; import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; import { IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; @@ -60,14 +60,10 @@ suite('aiCustomizationDisablement', () => { } as unknown as IPromptsService; } - function createMockEnablementProvider(): ICustomizationEnablementProvider { + function createMockEnablementHandler(): ICustomizationEnablementHandler { const disabledSets = new Map(); return { - onDidChange: Event.None, - getDisabledPromptFiles(type: PromptsType): ResourceSet { - return new ResourceSet([...(disabledSets.get(type) ?? [])]); - }, - setEnabled(uri: URI, type: PromptsType, enabled: boolean, _scope: string): void { + handleCustomizationEnablement(uri: URI, type: PromptsType, enabled: boolean, _scope: string): void { let set = disabledSets.get(type); if (!set) { set = new ResourceSet(); @@ -102,18 +98,17 @@ suite('aiCustomizationDisablement', () => { function createItemSource(opts: { harnessId: string; itemProvider?: ICustomizationItemProvider; - enablementProvider?: ICustomizationEnablementProvider; + enablementHandler?: ICustomizationEnablementHandler; promptsService?: IPromptsService; - /** Whether the harness has a natively-provided item provider (external harness). Defaults to true when enablementProvider is set. */ + /** Whether the harness has a natively-provided item provider (external harness). Defaults to true when enablementHandler is set. */ hasNativeItemProvider?: boolean; }): ProviderCustomizationItemSource { const ps = opts.promptsService ?? createMockPromptsService(); - const hasNative = opts.hasNativeItemProvider ?? !!opts.enablementProvider; + const hasNative = opts.hasNativeItemProvider ?? !!opts.enablementHandler; return new ProviderCustomizationItemSource( opts.harnessId, opts.itemProvider, undefined, - opts.enablementProvider, ps, { getActiveProjectRoot: () => URI.file('/workspace'), getSkillUIIntegrations: () => new Map(), isSessionsWindow: false } as unknown as IAICustomizationWorkspaceService, { stat: async () => { throw new Error('not found'); } } as unknown as IFileService, @@ -131,7 +126,7 @@ suite('aiCustomizationDisablement', () => { itemProvider: createMockItemProvider([{ uri: agentUri, type: PromptsType.agent, name: 'API Agent', enablementScope: 'global', }]), - enablementProvider: createMockEnablementProvider(), + enablementHandler: createMockEnablementHandler(), }); const result = await source.fetchItems(PromptsType.agent); @@ -144,7 +139,7 @@ suite('aiCustomizationDisablement', () => { itemProvider: createMockItemProvider([{ uri: agentUri, type: PromptsType.agent, name: 'API Agent', }]), - enablementProvider: createMockEnablementProvider(), + enablementHandler: createMockEnablementHandler(), }); const result = await source.fetchItems(PromptsType.agent); @@ -168,15 +163,15 @@ suite('aiCustomizationDisablement', () => { suite('disabled state overlay - API items', () => { test('disabled via enablementProvider shows as disabled', async () => { - const ep = createMockEnablementProvider(); - ep.setEnabled(agentUri, PromptsType.agent, false, 'global'); + const eh = createMockEnablementHandler(); + eh.handleCustomizationEnablement(agentUri, PromptsType.agent, false, 'global'); const source = createItemSource({ harnessId: 'cli', itemProvider: createMockItemProvider([{ uri: agentUri, type: PromptsType.agent, name: 'Agent', enablementScope: 'workspace', }]), - enablementProvider: ep, + enablementHandler: eh, }); const result = await source.fetchItems(PromptsType.agent); @@ -189,7 +184,7 @@ suite('aiCustomizationDisablement', () => { itemProvider: createMockItemProvider([{ uri: agentUri, type: PromptsType.agent, name: 'Agent', enablementScope: 'workspace', }]), - enablementProvider: createMockEnablementProvider(), + enablementHandler: createMockEnablementHandler(), }); const result = await source.fetchItems(PromptsType.agent); @@ -207,7 +202,7 @@ suite('aiCustomizationDisablement', () => { itemProvider: createMockItemProvider([{ uri: agentUri, type: PromptsType.agent, name: 'Agent', enablementScope: 'workspace', }]), - enablementProvider: createMockEnablementProvider(), + enablementHandler: createMockEnablementHandler(), promptsService: ps, }); @@ -230,7 +225,7 @@ suite('aiCustomizationDisablement', () => { uri: skillUri, type: PromptsType.skill, name: 'Skill', storage: PromptsStorage.local, enablementScope: 'workspace', }]), - enablementProvider: createMockEnablementProvider(), + enablementHandler: createMockEnablementHandler(), promptsService: ps, }); @@ -245,7 +240,7 @@ suite('aiCustomizationDisablement', () => { uri: skillUri, type: PromptsType.skill, name: 'Skill', storage: PromptsStorage.local, enablementScope: 'workspace', }]), - enablementProvider: createMockEnablementProvider(), + enablementHandler: createMockEnablementHandler(), }); const result = await source.fetchItems(PromptsType.skill); @@ -253,8 +248,8 @@ suite('aiCustomizationDisablement', () => { }); test('NOT affected by enablementProvider disabled state', async () => { - const ep = createMockEnablementProvider(); - ep.setEnabled(skillUri, PromptsType.skill, false, 'global'); + const eh = createMockEnablementHandler(); + eh.handleCustomizationEnablement(skillUri, PromptsType.skill, false, 'global'); const source = createItemSource({ harnessId: 'cli', @@ -262,7 +257,7 @@ suite('aiCustomizationDisablement', () => { uri: skillUri, type: PromptsType.skill, name: 'Skill', storage: PromptsStorage.local, enablementScope: 'workspace', }]), - enablementProvider: ep, + enablementHandler: eh, }); const result = await source.fetchItems(PromptsType.skill); @@ -327,7 +322,7 @@ suite('aiCustomizationDisablement', () => { const cliSource = createItemSource({ harnessId: 'cli', itemProvider: createMockItemProvider(items), - enablementProvider: createMockEnablementProvider(), + enablementHandler: createMockEnablementHandler(), promptsService: ps, }); assert.strictEqual((await cliSource.fetchItems(PromptsType.instructions))[0].disabled, true); @@ -335,7 +330,7 @@ suite('aiCustomizationDisablement', () => { const claudeSource = createItemSource({ harnessId: 'claude', itemProvider: createMockItemProvider(items), - enablementProvider: createMockEnablementProvider(), + enablementHandler: createMockEnablementHandler(), promptsService: ps, }); assert.strictEqual((await claudeSource.fetchItems(PromptsType.instructions))[0].disabled, false); @@ -345,8 +340,8 @@ suite('aiCustomizationDisablement', () => { suite('mixed API and VS Code items', () => { test('API disabled, VS Code enabled', async () => { - const ep = createMockEnablementProvider(); - ep.setEnabled(agentUri, PromptsType.agent, false, 'global'); + const eh = createMockEnablementHandler(); + eh.handleCustomizationEnablement(agentUri, PromptsType.agent, false, 'global'); const source = createItemSource({ harnessId: 'cli', @@ -354,7 +349,7 @@ suite('aiCustomizationDisablement', () => { { uri: agentUri, type: PromptsType.agent, name: 'API Agent', enablementScope: 'global' }, { uri: skillUri, type: PromptsType.agent, name: 'VS Code Agent', storage: PromptsStorage.local, enablementScope: 'workspace' }, ]), - enablementProvider: ep, + enablementHandler: eh, }); const result = await source.fetchItems(PromptsType.agent); @@ -379,7 +374,7 @@ suite('aiCustomizationDisablement', () => { { uri: agentUri, type: PromptsType.agent, name: 'API Agent', enablementScope: 'global' }, { uri: skillUri, type: PromptsType.agent, name: 'VS Code Agent', storage: PromptsStorage.local, enablementScope: 'workspace' }, ]), - enablementProvider: createMockEnablementProvider(), + enablementHandler: createMockEnablementHandler(), promptsService: ps, }); @@ -447,7 +442,7 @@ suite('aiCustomizationDisablement', () => { const source = createItemSource({ harnessId: 'cli', itemProvider: createMockItemProvider([]), - enablementProvider: createMockEnablementProvider(), + enablementHandler: createMockEnablementHandler(), promptsService: ps, }); @@ -486,14 +481,14 @@ suite('aiCustomizationDisablement', () => { }); test('external harness: disabled API agent ghost entry has enablementScope: global', async () => { - const ep = createMockEnablementProvider(); - ep.setEnabled(agentUri, PromptsType.agent, false, 'global'); + const eh = createMockEnablementHandler(); + eh.handleCustomizationEnablement(agentUri, PromptsType.agent, false, 'global'); // Provider returns NO items (extension filtered out disabled agent) const source = createItemSource({ harnessId: 'cli', itemProvider: createMockItemProvider([]), - enablementProvider: ep, + enablementHandler: eh, }); const result = await source.fetchItems(PromptsType.agent); @@ -515,7 +510,7 @@ suite('aiCustomizationDisablement', () => { const source = createItemSource({ harnessId: 'cli', itemProvider: createMockItemProvider([]), - enablementProvider: createMockEnablementProvider(), + enablementHandler: createMockEnablementHandler(), promptsService: ps, }); @@ -538,7 +533,7 @@ suite('aiCustomizationDisablement', () => { uri: agentUri, type: PromptsType.agent, name: 'Extension Agent', enablementScope: 'application', }]), - enablementProvider: createMockEnablementProvider(), + enablementHandler: createMockEnablementHandler(), }); const result = await source.fetchItems(PromptsType.agent); @@ -556,7 +551,7 @@ suite('aiCustomizationDisablement', () => { ps.setDisabledPromptFiles(PromptsType.agent, disabled, StorageScope.PROFILE, 'cli'); // Enable in enablementProvider — should NOT matter for application-scoped items - const ep = createMockEnablementProvider(); + const eh = createMockEnablementHandler(); const source = createItemSource({ harnessId: 'cli', @@ -564,7 +559,7 @@ suite('aiCustomizationDisablement', () => { uri: agentUri, type: PromptsType.agent, name: 'Extension Agent', enablementScope: 'application', }]), - enablementProvider: ep, + enablementHandler: eh, promptsService: ps, }); @@ -574,8 +569,8 @@ suite('aiCustomizationDisablement', () => { test('application-scoped item NOT disabled by enablementProvider', async () => { // Disable via enablementProvider — should NOT affect application-scoped items - const ep = createMockEnablementProvider(); - ep.setEnabled(agentUri, PromptsType.agent, false, 'global'); + const eh = createMockEnablementHandler(); + eh.handleCustomizationEnablement(agentUri, PromptsType.agent, false, 'global'); const source = createItemSource({ harnessId: 'cli', @@ -583,7 +578,7 @@ suite('aiCustomizationDisablement', () => { uri: agentUri, type: PromptsType.agent, name: 'Extension Agent', enablementScope: 'application', }]), - enablementProvider: ep, + enablementHandler: eh, }); const result = await source.fetchItems(PromptsType.agent); @@ -602,7 +597,7 @@ suite('aiCustomizationDisablement', () => { uri: skillUri, type: PromptsType.skill, name: 'Extension Skill', enablementScope: 'application', }]), - enablementProvider: createMockEnablementProvider(), + enablementHandler: createMockEnablementHandler(), promptsService: ps, }); @@ -625,7 +620,7 @@ suite('aiCustomizationDisablement', () => { { uri: agentUri, type: PromptsType.agent, name: 'Extension Agent', enablementScope: 'application' }, { uri: skillUri, type: PromptsType.agent, name: 'CLI Agent', enablementScope: 'global' }, ]), - enablementProvider: createMockEnablementProvider(), + enablementHandler: createMockEnablementHandler(), promptsService: ps, }); @@ -649,7 +644,7 @@ suite('aiCustomizationDisablement', () => { const source = createItemSource({ harnessId: 'cli', itemProvider: createMockItemProvider([]), - enablementProvider: createMockEnablementProvider(), + enablementHandler: createMockEnablementHandler(), promptsService: ps, }); @@ -673,7 +668,7 @@ suite('aiCustomizationDisablement', () => { uri: agentUri, type: PromptsType.agent, name: 'Pre-Disabled', enabled: false, enablementScope: 'global', }]), - enablementProvider: createMockEnablementProvider(), + enablementHandler: createMockEnablementHandler(), }); const result = await source.fetchItems(PromptsType.agent); @@ -749,8 +744,8 @@ suite('aiCustomizationDisablement', () => { }); test('external harness: disabled API item shows Enable button', async () => { - const ep = createMockEnablementProvider(); - ep.setEnabled(agentUri, PromptsType.agent, false, 'global'); + const eh = createMockEnablementHandler(); + eh.handleCustomizationEnablement(agentUri, PromptsType.agent, false, 'global'); const source = createItemSource({ harnessId: 'cli', @@ -758,7 +753,7 @@ suite('aiCustomizationDisablement', () => { uri: agentUri, type: PromptsType.agent, name: 'CLI Agent', enablementScope: 'global', }]), - enablementProvider: ep, + enablementHandler: eh, }); const result = await source.fetchItems(PromptsType.agent); @@ -785,7 +780,7 @@ suite('aiCustomizationDisablement', () => { uri: skillUri, type: PromptsType.skill, name: 'My Skill', storage: PromptsStorage.local, enablementScope: 'workspace', }]), - enablementProvider: createMockEnablementProvider(), + enablementHandler: createMockEnablementHandler(), promptsService: ps, }); @@ -807,7 +802,7 @@ suite('aiCustomizationDisablement', () => { itemProvider: createMockItemProvider([{ uri: agentUri, type: PromptsType.agent, name: 'No Scope Agent', }]), - enablementProvider: createMockEnablementProvider(), + enablementHandler: createMockEnablementHandler(), }); const result = await source.fetchItems(PromptsType.agent); From dca714c5245ac0c8498a67fcdf18a57335e2e93d Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Thu, 23 Apr 2026 22:06:58 -0700 Subject: [PATCH 22/36] clean --- src/vs/workbench/api/common/extHostChatAgents2.ts | 3 +-- src/vs/workbench/api/common/extHostTypes.ts | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index ed88c56e77242..a2c9b4e42bddb 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -485,11 +485,10 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS private readonly _promptFileProviders = new Map(); private static _customizationProviderIdPool = 0; - private static readonly _enablementScopeMap: Record = { + private static readonly _enablementScopeMap: Record = { 0: 'none', // ChatSessionCustomizationEnablementScope.None 1: 'global', // ChatSessionCustomizationEnablementScope.Global 2: 'workspace', // ChatSessionCustomizationEnablementScope.Workspace - 3: 'application', // ChatSessionCustomizationEnablementScope.ManagedByApplication }; private static readonly _enablementScopeReverseMap: Record = { 'global': 1, // ChatSessionCustomizationEnablementScope.Global diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 0394c4b9fca85..5d1c1ecf1e4f2 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3594,7 +3594,6 @@ export enum ChatSessionCustomizationEnablementScope { None = 0, Global = 1, Workspace = 2, - ManagedByApplication = 3, } export enum ChatDebugLogLevel { From f1b78dd045a425f42538b2ed85667d01dec86bc6 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Thu, 23 Apr 2026 22:14:09 -0700 Subject: [PATCH 23/36] clean --- .../api/browser/mainThreadChatAgents2.ts | 4 +- .../workbench/api/common/extHost.protocol.ts | 2 +- .../api/common/extHostChatAgents2.ts | 4 +- .../aiCustomizationItemSource.ts | 8 +-- .../aiCustomizationListWidget.ts | 6 +- .../aiCustomizationManagement.contribution.ts | 61 ++++++++----------- .../common/customizationHarnessService.ts | 24 ++++---- .../agentHostChatContribution.test.ts | 2 +- .../agentHostClientTools.test.ts | 2 +- .../aiCustomizationDisablement.test.ts | 20 +++--- .../aiCustomizationListWidget.test.ts | 2 +- ...osed.chatSessionCustomizationProvider.d.ts | 10 ++- 12 files changed, 68 insertions(+), 77 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 71dc5eb278551..096feeb98e7b2 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -736,7 +736,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA if (!items) { return undefined; } - const convertItem = (item: IChatSessionCustomizationItemDto, depth = 0): ICustomizationItem => ({ + const convertItem = (item: IChatSessionCustomizationItemDto): ICustomizationItem => ({ uri: URI.revive(item.uri), type: item.type, name: item.name, @@ -746,7 +746,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA badgeTooltip: item.badgeTooltip, enabled: item.enabled, enablementScope: item.enablementScope, - plugin: item.plugin && depth < 1 ? convertItem(item.plugin, depth + 1) : undefined, + pluginUri: item.pluginUri ? URI.revive(item.pluginUri) : undefined, }); return items.map(i => convertItem(i)); }, diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 3891651cfc631..0601a36e2480a 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1742,7 +1742,7 @@ export interface IChatSessionCustomizationItemDto { readonly badgeTooltip?: string; readonly enabled?: boolean; readonly enablementScope?: 'none' | 'global' | 'workspace' | 'application'; - readonly plugin?: IChatSessionCustomizationItemDto; + readonly pluginUri?: UriComponents; } export interface IChatParticipantMetadata { participant: string; diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index a2c9b4e42bddb..024a7de4ec27a 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -832,7 +832,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS return undefined; } - const convertItem = (item: vscode.ChatSessionCustomizationItem, depth = 0): IChatSessionCustomizationItemDto => ({ + const convertItem = (item: vscode.ChatSessionCustomizationItem): IChatSessionCustomizationItemDto => ({ uri: item.uri, type: typeConvert.ChatSessionCustomizationType.from(item.type), name: item.name, @@ -842,7 +842,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS badgeTooltip: item.badgeTooltip, enabled: item.enabled, enablementScope: item.enablementScope !== undefined ? ExtHostChatAgents2._enablementScopeMap[item.enablementScope] : undefined, - plugin: item.plugin && depth < 1 ? convertItem(item.plugin, depth + 1) : undefined, + pluginUri: item.pluginUri, }); return items.map(convertItem); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index 654d11115de35..36faed1f0ee67 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -70,7 +70,7 @@ export interface IAICustomizationListItem { /** Per-item enablement scope override. Defaults to 'none' (not disableable) when absent. */ readonly enablementScope?: 'none' | 'global' | 'workspace' | 'application'; /** Optional reference to the parent plugin item. When present, enable/disable actions target the plugin and the item's own enablementScope is ignored. */ - readonly plugin?: ICustomizationItem; + readonly plugin?: URI; /** When true, this item can be selected for syncing to a remote harness. */ readonly syncable?: boolean; /** When true, this syncable item is currently selected for syncing. */ @@ -253,7 +253,7 @@ export class AICustomizationItemNormalizer { status: item.status, statusMessage: item.statusMessage, enablementScope: item.enablementScope, - plugin: item.plugin, + plugin: item.pluginUri, hookChildren: item.hookChildren, }; } @@ -413,7 +413,7 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour // normalization (which always infers a storage value from the URI). Items // with explicit storage are "VS Code items" whose disablement is managed // by promptsService; items without are "API items" whose disablement is - // managed by the enablementProvider. + // managed by the enablementHandler. const providerExplicitStorageUris = new ResourceSet( providerItems.filter(i => i.storage !== undefined).map(i => i.uri), ); @@ -422,7 +422,7 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour // Overlay disabled state from two sources: // - API items (no explicit `storage` from provider): checked against - // enablementProvider's disabled set. The extension fully owns disablement. + // enablementHandler's disabled set. The extension fully owns disablement. // - VS Code items (explicit `storage` from provider): checked against // promptsService. On external harnesses (with itemProvider) the namespace // isolates per-harness state. On the VS Code harness (no itemProvider) no diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index c0473e219415a..1398ab92042fa 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -328,7 +328,7 @@ class HookFileHeaderRenderer implements IListRenderer; - const uri = typeof plugin.uri === 'string' ? URI.parse(plugin.uri) : undefined; - const type = typeof plugin.type === 'string' ? plugin.type : undefined; - const name = typeof plugin.name === 'string' ? plugin.name : undefined; - if (!uri || !type || !name) { - return undefined; - } - return { uri, type, name }; + return URI.parse(raw); } /** @@ -794,23 +787,23 @@ registerAction2(class extends Action2 { // When this item has a parent plugin, disable the plugin instead const plugin = extractPlugin(context); if (plugin) { - const enablementProvider = harnessService.getActiveEnablementProvider(); - if (enablementProvider) { - enablementProvider.handleCustomizationEnablement(plugin.uri, plugin.type as PromptsType, false, 'global'); + const enablementHandler = harnessService.getActiveEnablementHandler(); + if (enablementHandler) { + enablementHandler.handleCustomizationEnablement(plugin, 'plugins' as PromptsType, false, 'global'); } return; } // Provider-managed items: delegate to the harness's enablement provider. // VS Code items on external harnesses: persist via promptsService with harness namespace. - // VS Code items on the VS Code harness: persist via enablementProvider (no namespace). - const enablementProvider = harnessService.getActiveEnablementProvider(); + // VS Code items on the VS Code harness: persist via enablementHandler (no namespace). + const enablementHandler = harnessService.getActiveEnablementHandler(); const descriptor = harnessService.getActiveDescriptor(); - if (enablementProvider && hasProviderEnablement(context)) { - enablementProvider.handleCustomizationEnablement(uri, promptType, false, 'global'); - } else if (enablementProvider && !descriptor.itemProvider) { + if (enablementHandler && hasProviderEnablement(context)) { + enablementHandler.handleCustomizationEnablement(uri, promptType, false, 'global'); + } else if (enablementHandler && !descriptor.itemProvider) { // VS Code harness — delegate to its enablement provider (no namespace) - enablementProvider.handleCustomizationEnablement(uri, promptType, false, 'global'); + enablementHandler.handleCustomizationEnablement(uri, promptType, false, 'global'); } else { const namespace = descriptor.id; const storage = extractStorage(context); @@ -840,13 +833,13 @@ registerAction2(class extends Action2 { return; } - const enablementProvider = harnessService.getActiveEnablementProvider(); + const enablementHandler = harnessService.getActiveEnablementHandler(); const descriptor = harnessService.getActiveDescriptor(); - if (enablementProvider && hasProviderEnablement(context)) { - enablementProvider.handleCustomizationEnablement(uri, promptType, false, 'workspace'); - } else if (enablementProvider && !descriptor.itemProvider) { + if (enablementHandler && hasProviderEnablement(context)) { + enablementHandler.handleCustomizationEnablement(uri, promptType, false, 'workspace'); + } else if (enablementHandler && !descriptor.itemProvider) { // VS Code harness — delegate to its enablement provider (no namespace) - enablementProvider.handleCustomizationEnablement(uri, promptType, false, 'workspace'); + enablementHandler.handleCustomizationEnablement(uri, promptType, false, 'workspace'); } else { const namespace = descriptor.id; const disabled = promptsService.getDisabledPromptFilesForScope(promptType, StorageScope.WORKSPACE, namespace); @@ -878,23 +871,23 @@ registerAction2(class extends Action2 { // When this item has a parent plugin, enable the plugin instead const plugin = extractPlugin(context); if (plugin) { - const enablementProvider = harnessService.getActiveEnablementProvider(); - if (enablementProvider) { - enablementProvider.handleCustomizationEnablement(plugin.uri, plugin.type as PromptsType, true, 'global'); + const enablementHandler = harnessService.getActiveEnablementHandler(); + if (enablementHandler) { + enablementHandler.handleCustomizationEnablement(plugin, 'plugins' as PromptsType, true, 'global'); } return; } // Provider-managed items: delegate to the harness's enablement provider. // VS Code items on external harnesses: persist via promptsService with harness namespace. - // VS Code items on the VS Code harness: persist via enablementProvider (no namespace). - const enablementProvider = harnessService.getActiveEnablementProvider(); + // VS Code items on the VS Code harness: persist via enablementHandler (no namespace). + const enablementHandler = harnessService.getActiveEnablementHandler(); const descriptor = harnessService.getActiveDescriptor(); - if (enablementProvider && hasProviderEnablement(context)) { - enablementProvider.handleCustomizationEnablement(uri, promptType, true, 'global'); - } else if (enablementProvider && !descriptor.itemProvider) { + if (enablementHandler && hasProviderEnablement(context)) { + enablementHandler.handleCustomizationEnablement(uri, promptType, true, 'global'); + } else if (enablementHandler && !descriptor.itemProvider) { // VS Code harness — delegate to its enablement provider (no namespace) - enablementProvider.handleCustomizationEnablement(uri, promptType, true, 'global'); + enablementHandler.handleCustomizationEnablement(uri, promptType, true, 'global'); } else { const namespace = descriptor.id; // Remove from both scopes to fully re-enable diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index ba560457831f0..e30572024b7d0 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -171,8 +171,8 @@ export interface ICustomizationItem { readonly badge?: string; /** Tooltip shown when hovering the badge. */ readonly badgeTooltip?: string; - /** Optional reference to the parent plugin. When present, enable/disable actions target the plugin and the item's own enablementScope is ignored. */ - readonly plugin?: ICustomizationItem; + /** Optional URI of the parent plugin. When present, enable/disable actions target the plugin and the item's own enablementScope is ignored. */ + readonly pluginUri?: URI; } /** @@ -285,11 +285,11 @@ export interface ICustomizationHarnessService { registerExternalHarness(descriptor: IHarnessDescriptor): IDisposable; /** - * Returns the enablement provider of the currently active harness, or - * `undefined` when the harness has no custom enablement provider + * Returns the enablement handler of the currently active harness, or + * `undefined` when the harness has no custom enablement handler * (in which case the caller should fall back to promptsService). */ - getActiveEnablementProvider(): ICustomizationEnablementHandler | undefined; + getActiveEnablementHandler(): ICustomizationEnablementHandler | undefined; } /** @@ -401,7 +401,7 @@ function buildAllSources(extras: readonly string[]): readonly string[] { * Creates a "VS Code" harness descriptor that shows all storage sources * with no user-root restrictions. */ -export function createVSCodeHarnessDescriptor(extras: readonly string[], enablementProvider?: ICustomizationEnablementHandler): IHarnessDescriptor { +export function createVSCodeHarnessDescriptor(extras: readonly string[], enablementHandler?: ICustomizationEnablementHandler): IHarnessDescriptor { const filter: IStorageSourceFilter = { sources: buildAllSources(extras) }; return { id: CustomizationHarness.VSCode, @@ -414,7 +414,7 @@ export function createVSCodeHarnessDescriptor(extras: readonly string[], enablem }], ]), getStorageSourceFilter: () => filter, - enablementHandler: enablementProvider, + enablementHandler, }; } @@ -430,7 +430,7 @@ interface IRestrictedHarnessOptions { readonly sectionOverrides?: ReadonlyMap; readonly requiredAgentId?: string; readonly instructionFileFilter?: readonly string[]; - readonly enablementProvider?: ICustomizationEnablementHandler; + readonly enablementHandler?: ICustomizationEnablementHandler; } function createRestrictedHarnessDescriptor( @@ -454,7 +454,7 @@ function createRestrictedHarnessDescriptor( sectionOverrides: options?.sectionOverrides, requiredAgentId: options?.requiredAgentId, instructionFileFilter: options?.instructionFileFilter, - enablementHandler: options?.enablementProvider, + enablementHandler: options?.enablementHandler, getStorageSourceFilter(type: PromptsType): IStorageSourceFilter { if (type === PromptsType.hook) { return HOOKS_FILTER; @@ -470,7 +470,7 @@ function createRestrictedHarnessDescriptor( /** * Creates a "Copilot CLI" harness descriptor. */ -export function createCliHarnessDescriptor(cliUserRoots: readonly URI[], extras: readonly string[], enablementProvider?: ICustomizationEnablementHandler): IHarnessDescriptor { +export function createCliHarnessDescriptor(cliUserRoots: readonly URI[], extras: readonly string[], enablementHandler?: ICustomizationEnablementHandler): IHarnessDescriptor { return createRestrictedHarnessDescriptor( CustomizationHarness.CLI, localize('harness.cli', "Copilot CLI"), @@ -486,7 +486,7 @@ export function createCliHarnessDescriptor(cliUserRoots: readonly URI[], extras: rootFileShortcuts: [AGENT_MD_FILENAME], }], ]), - enablementProvider, + enablementHandler, }, ); } @@ -613,7 +613,7 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer return all.find(h => h.id === activeId) ?? all[0]; } - getActiveEnablementProvider(): ICustomizationEnablementHandler | undefined { + getActiveEnablementHandler(): ICustomizationEnablementHandler | undefined { return this.getActiveDescriptor().enablementHandler; } } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 55bf6f088ac9b..e998c2d44bf0a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -359,7 +359,7 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv instantiationService.stub(IStorageService, disposables.add(new InMemoryStorageService())); instantiationService.stub(ICustomizationHarnessService, { registerExternalHarness: () => toDisposable(() => { }), - getActiveEnablementProvider: () => undefined, + getActiveEnablementHandler: () => undefined, }); instantiationService.stub(IAgentPluginService, { plugins: observableValue('plugins', []), diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts index 3c4d7e31791ef..4bee01d684b48 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts @@ -397,7 +397,7 @@ suite('AgentHostClientTools', () => { instantiationService.stub(IStorageService, disposables.add(new InMemoryStorageService())); instantiationService.stub(ICustomizationHarnessService, { registerExternalHarness: () => toDisposable(() => { }), - getActiveEnablementProvider: () => undefined, + getActiveEnablementHandler: () => undefined, }); instantiationService.stub(IAgentPluginService, { plugins: observableValue('plugins', []), diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts index ead39aa9c11b4..bf8e6b1068bad 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts @@ -162,7 +162,7 @@ suite('aiCustomizationDisablement', () => { suite('disabled state overlay - API items', () => { - test('disabled via enablementProvider shows as disabled', async () => { + test('disabled via enablementHandler shows as disabled', async () => { const eh = createMockEnablementHandler(); eh.handleCustomizationEnablement(agentUri, PromptsType.agent, false, 'global'); @@ -178,7 +178,7 @@ suite('aiCustomizationDisablement', () => { assert.strictEqual(result[0].disabled, true); }); - test('not in enablementProvider disabled set shows as enabled', async () => { + test('not in enablementHandler disabled set shows as enabled', async () => { const source = createItemSource({ harnessId: 'cli', itemProvider: createMockItemProvider([{ @@ -247,7 +247,7 @@ suite('aiCustomizationDisablement', () => { assert.strictEqual(result[0].disabled, false); }); - test('NOT affected by enablementProvider disabled state', async () => { + test('NOT affected by enablementHandler disabled state', async () => { const eh = createMockEnablementHandler(); eh.handleCustomizationEnablement(skillUri, PromptsType.skill, false, 'global'); @@ -456,7 +456,7 @@ suite('aiCustomizationDisablement', () => { suite('ghost entries for disabled items not in provider results', () => { test('VS Code harness: disabled agent ghost entry has enablementScope and shows Enable button', async () => { - // Simulate: local agent was disabled via enablementProvider → promptsService + // Simulate: local agent was disabled via enablementHandler → promptsService // but the promptsServiceItemProvider no longer returns it (getCustomAgents filters it out). const ps = createMockPromptsService(); const disabled = new ResourceSet(); @@ -543,14 +543,14 @@ suite('aiCustomizationDisablement', () => { assert.strictEqual(keys.disableButtonVisible, true); }); - test('application-scoped item uses vscodeDisabledUris (not enablementProvider)', async () => { + test('application-scoped item uses vscodeDisabledUris (not enablementHandler)', async () => { // Disable via promptsService (namespaced) — this is VS Code-managed disablement const ps = createMockPromptsService(); const disabled = new ResourceSet(); disabled.add(agentUri); ps.setDisabledPromptFiles(PromptsType.agent, disabled, StorageScope.PROFILE, 'cli'); - // Enable in enablementProvider — should NOT matter for application-scoped items + // Enable in enablementHandler — should NOT matter for application-scoped items const eh = createMockEnablementHandler(); const source = createItemSource({ @@ -564,11 +564,11 @@ suite('aiCustomizationDisablement', () => { }); const result = await source.fetchItems(PromptsType.agent); - assert.strictEqual(result[0].disabled, true, 'should be disabled via promptsService, not enablementProvider'); + assert.strictEqual(result[0].disabled, true, 'should be disabled via promptsService, not enablementHandler'); }); - test('application-scoped item NOT disabled by enablementProvider', async () => { - // Disable via enablementProvider — should NOT affect application-scoped items + test('application-scoped item NOT disabled by enablementHandler', async () => { + // Disable via enablementHandler — should NOT affect application-scoped items const eh = createMockEnablementHandler(); eh.handleCustomizationEnablement(agentUri, PromptsType.agent, false, 'global'); @@ -582,7 +582,7 @@ suite('aiCustomizationDisablement', () => { }); const result = await source.fetchItems(PromptsType.agent); - assert.strictEqual(result[0].disabled, false, 'enablementProvider should not affect application-scoped items'); + assert.strictEqual(result[0].disabled, false, 'enablementHandler should not affect application-scoped items'); }); test('disabled application-scoped item shows Enable button', async () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts index e9c3528ebe5dc..b6195b3aced79 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts @@ -199,7 +199,7 @@ suite('aiCustomizationListWidget', () => { getStorageSourceFilter: () => ({ sources: [] }), getActiveDescriptor: () => descriptor, registerExternalHarness: () => ({ dispose() { } }), - getActiveEnablementProvider: () => undefined, + getActiveEnablementHandler: () => undefined, }); instaService.stub(IAgentPluginService, { diff --git a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts index 06aa3e1d6ff87..f0f8b8645b394 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts @@ -135,21 +135,19 @@ declare module 'vscode' { * omitted — the item cannot be toggled unless the provider explicitly * sets a scope. * - * Ignored when {@link plugin} is set — plugin items always use global-scope + * Ignored when {@link pluginUri} is set — plugin items always use global-scope * enablement targeting the plugin itself. */ readonly enablementScope?: ChatSessionCustomizationEnablementScope; /** - * Optional reference to the parent plugin of this customization item. + * Optional URI of the parent plugin of this customization item. * * When set, all enable/disable actions for this item target the plugin * instead of the individual item, and the item's own - * {@link enablementScope} is ignored. The plugin item is itself a - * {@link ChatSessionCustomizationItem} so it can be passed directly to - * {@link ChatSessionCustomizationEnablementHandler.handleCustomizationEnablement}. + * {@link enablementScope} is ignored. */ - readonly plugin?: ChatSessionCustomizationItem; + readonly pluginUri?: Uri; } /** From 7e594c04a1b83627acfc8e3287114756e977b733 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Thu, 23 Apr 2026 22:15:02 -0700 Subject: [PATCH 24/36] clean --- src/vs/workbench/api/common/extHost.protocol.ts | 2 +- .../chat/browser/aiCustomization/aiCustomizationItemSource.ts | 2 +- .../contrib/chat/common/customizationHarnessService.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 0601a36e2480a..9023d319a1a19 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1741,7 +1741,7 @@ export interface IChatSessionCustomizationItemDto { readonly badge?: string; readonly badgeTooltip?: string; readonly enabled?: boolean; - readonly enablementScope?: 'none' | 'global' | 'workspace' | 'application'; + readonly enablementScope?: 'none' | 'global' | 'workspace'; readonly pluginUri?: UriComponents; } export interface IChatParticipantMetadata { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index 36faed1f0ee67..aa72fd51a91b9 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -68,7 +68,7 @@ export interface IAICustomizationListItem { /** Human-readable status detail (e.g. error message or warning). */ readonly statusMessage?: string; /** Per-item enablement scope override. Defaults to 'none' (not disableable) when absent. */ - readonly enablementScope?: 'none' | 'global' | 'workspace' | 'application'; + readonly enablementScope?: 'none' | 'global' | 'workspace'; /** Optional reference to the parent plugin item. When present, enable/disable actions target the plugin and the item's own enablementScope is ignored. */ readonly plugin?: URI; /** When true, this item can be selected for syncing to a remote harness. */ diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index e30572024b7d0..1cd7da8453136 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -164,7 +164,7 @@ export interface ICustomizationItem { /** Whether this customization is currently enabled. */ readonly enabled?: boolean; /** Per-item enablement scope override. Defaults to 'none' (not disableable) when absent. */ - readonly enablementScope?: 'none' | 'global' | 'workspace' | 'application'; + readonly enablementScope?: 'none' | 'global' | 'workspace'; /** When set, items with the same groupKey are displayed under a shared collapsible header. */ readonly groupKey?: string; /** When set, shows a small inline badge next to the item name (e.g. an applyTo glob pattern). */ From 325e1b8c7289158a1cd53b92159d0e323eb00329 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Thu, 23 Apr 2026 22:16:25 -0700 Subject: [PATCH 25/36] clean --- .../aiCustomizationItemSource.ts | 11 +- .../aiCustomizationDisablement.test.ts | 155 ------------------ 2 files changed, 4 insertions(+), 162 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index aa72fd51a91b9..3f9b01fe4c20a 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -437,12 +437,9 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour if (normalized[i].disabled) { continue; } - // Items are VS Code-managed when either: - // - The provider explicitly set `storage` on them, or - // - The provider set `enablementScope: 'application'`, signaling - // that VS Code should own disablement. - const isVSCodeItem = providerExplicitStorageUris.has(normalized[i].uri) - || normalized[i].enablementScope === 'application'; + // Items are VS Code-managed when: + // - The provider explicitly set `storage` on them + const isVSCodeItem = providerExplicitStorageUris.has(normalized[i].uri); const isDisabled = isVSCodeItem ? vscodeDisabledUris.has(normalized[i].uri) : false; @@ -597,7 +594,7 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour enabled: !disabledPromptFiles.has(p.uri), badge: uiTooltip ? uiIntegrationBadge : undefined, badgeTooltip: uiTooltip, - enablementScope: this.hasNativeItemProvider ? 'application' : 'workspace', + enablementScope: 'workspace', }; appended.push(this.itemNormalizer.normalizeItem(builtinItem, promptType, uriUseCounts)); } diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts index bf8e6b1068bad..9af971c48ed75 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts @@ -431,26 +431,6 @@ suite('aiCustomizationDisablement', () => { assert.ok(builtin); assert.strictEqual(builtin.disabled, true); }); - - test('external harness: builtin skills get enablementScope: application', async () => { - const builtinUri = URI.file('/app/builtins/fetch/SKILL.md'); - const ps = createMockPromptsService(); - (ps as { listPromptFilesForStorage: Function }).listPromptFilesForStorage = async () => [ - { uri: builtinUri, name: 'fetch', description: 'Fetch web pages' }, - ]; - - const source = createItemSource({ - harnessId: 'cli', - itemProvider: createMockItemProvider([]), - enablementHandler: createMockEnablementHandler(), - promptsService: ps, - }); - - const result = await source.fetchItems(PromptsType.skill); - const builtin = result.find(i => i.name === 'fetch'); - assert.ok(builtin); - assert.strictEqual(builtin.enablementScope, 'application'); - }); }); suite('ghost entries for disabled items not in provider results', () => { @@ -524,141 +504,6 @@ suite('aiCustomizationDisablement', () => { }); }); - suite('enablementScope: application (ManagedByApplication)', () => { - - test('application-scoped item is disableable', async () => { - const source = createItemSource({ - harnessId: 'cli', - itemProvider: createMockItemProvider([{ - uri: agentUri, type: PromptsType.agent, name: 'Extension Agent', - enablementScope: 'application', - }]), - enablementHandler: createMockEnablementHandler(), - }); - - const result = await source.fetchItems(PromptsType.agent); - assert.strictEqual(result[0].enablementScope, 'application'); - const keys = computeContextKeys(result[0]); - assert.strictEqual(keys.isDisableable, true); - assert.strictEqual(keys.disableButtonVisible, true); - }); - - test('application-scoped item uses vscodeDisabledUris (not enablementHandler)', async () => { - // Disable via promptsService (namespaced) — this is VS Code-managed disablement - const ps = createMockPromptsService(); - const disabled = new ResourceSet(); - disabled.add(agentUri); - ps.setDisabledPromptFiles(PromptsType.agent, disabled, StorageScope.PROFILE, 'cli'); - - // Enable in enablementHandler — should NOT matter for application-scoped items - const eh = createMockEnablementHandler(); - - const source = createItemSource({ - harnessId: 'cli', - itemProvider: createMockItemProvider([{ - uri: agentUri, type: PromptsType.agent, name: 'Extension Agent', - enablementScope: 'application', - }]), - enablementHandler: eh, - promptsService: ps, - }); - - const result = await source.fetchItems(PromptsType.agent); - assert.strictEqual(result[0].disabled, true, 'should be disabled via promptsService, not enablementHandler'); - }); - - test('application-scoped item NOT disabled by enablementHandler', async () => { - // Disable via enablementHandler — should NOT affect application-scoped items - const eh = createMockEnablementHandler(); - eh.handleCustomizationEnablement(agentUri, PromptsType.agent, false, 'global'); - - const source = createItemSource({ - harnessId: 'cli', - itemProvider: createMockItemProvider([{ - uri: agentUri, type: PromptsType.agent, name: 'Extension Agent', - enablementScope: 'application', - }]), - enablementHandler: eh, - }); - - const result = await source.fetchItems(PromptsType.agent); - assert.strictEqual(result[0].disabled, false, 'enablementHandler should not affect application-scoped items'); - }); - - test('disabled application-scoped item shows Enable button', async () => { - const ps = createMockPromptsService(); - const disabled = new ResourceSet(); - disabled.add(skillUri); - ps.setDisabledPromptFiles(PromptsType.skill, disabled, StorageScope.PROFILE, 'cli'); - - const source = createItemSource({ - harnessId: 'cli', - itemProvider: createMockItemProvider([{ - uri: skillUri, type: PromptsType.skill, name: 'Extension Skill', - enablementScope: 'application', - }]), - enablementHandler: createMockEnablementHandler(), - promptsService: ps, - }); - - const result = await source.fetchItems(PromptsType.skill); - const keys = computeContextKeys(result[0]); - assert.strictEqual(keys.disabled, true); - assert.strictEqual(keys.enableButtonVisible, true); - assert.strictEqual(keys.disableButtonVisible, false); - }); - - test('mixed: application-scoped disabled, API-scoped enabled', async () => { - const ps = createMockPromptsService(); - const disabled = new ResourceSet(); - disabled.add(agentUri); - ps.setDisabledPromptFiles(PromptsType.agent, disabled, StorageScope.PROFILE, 'cli'); - - const source = createItemSource({ - harnessId: 'cli', - itemProvider: createMockItemProvider([ - { uri: agentUri, type: PromptsType.agent, name: 'Extension Agent', enablementScope: 'application' }, - { uri: skillUri, type: PromptsType.agent, name: 'CLI Agent', enablementScope: 'global' }, - ]), - enablementHandler: createMockEnablementHandler(), - promptsService: ps, - }); - - const result = await source.fetchItems(PromptsType.agent); - assert.deepStrictEqual( - result.map(i => ({ name: i.name, disabled: i.disabled, enablementScope: i.enablementScope })), - [ - { name: 'CLI Agent', disabled: false, enablementScope: 'global' }, - { name: 'Extension Agent', disabled: true, enablementScope: 'application' }, - ], - ); - }); - - test('ghost entry for disabled application-scoped item not in provider results', async () => { - const ps = createMockPromptsService(); - const disabled = new ResourceSet(); - disabled.add(agentUri); - ps.setDisabledPromptFiles(PromptsType.agent, disabled, StorageScope.PROFILE, 'cli'); - - // Provider returns NO items — the disabled extension agent was filtered out - const source = createItemSource({ - harnessId: 'cli', - itemProvider: createMockItemProvider([]), - enablementHandler: createMockEnablementHandler(), - promptsService: ps, - }); - - const result = await source.fetchItems(PromptsType.agent); - assert.strictEqual(result.length, 1, 'ghost entry should be created'); - assert.strictEqual(result[0].disabled, true); - // Ghost entries from vscodeDisabledUris get enablementScope: 'workspace' - assert.strictEqual(result[0].enablementScope, 'workspace'); - - const keys = computeContextKeys(result[0]); - assert.strictEqual(keys.enableButtonVisible, true, 'Enable button should be visible'); - }); - }); - suite('provider item with pre-set enabled:false', () => { test('shown as disabled regardless of disabled sets', async () => { From a672ec8b3412d37d9b87b83300c0ffb00cf8a566 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Thu, 23 Apr 2026 22:16:50 -0700 Subject: [PATCH 26/36] clean --- .../chat/browser/aiCustomization/aiCustomizationItemSource.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index 3f9b01fe4c20a..81ae7ded831c3 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -420,9 +420,7 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour const normalized = this.itemNormalizer.normalizeItems(providerItems, promptType); - // Overlay disabled state from two sources: - // - API items (no explicit `storage` from provider): checked against - // enablementHandler's disabled set. The extension fully owns disablement. + // Overlay disabled state when: // - VS Code items (explicit `storage` from provider): checked against // promptsService. On external harnesses (with itemProvider) the namespace // isolates per-harness state. On the VS Code harness (no itemProvider) no From cee8a17c8d3553b1f05b265763a29f4a50ee9806 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Thu, 23 Apr 2026 22:58:49 -0700 Subject: [PATCH 27/36] clean --- .../aiCustomizationItemSource.ts | 83 +++++++++++++++++-- .../aiCustomizationListWidget.ts | 36 ++++++-- .../media/aiCustomizationManagement.css | 30 ++++++- ...promptsServiceCustomizationItemProvider.ts | 4 +- 4 files changed, 136 insertions(+), 17 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index 81ae7ded831c3..e16f637ce6bb0 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -139,6 +139,65 @@ export function getFriendlyName(filename: string): string { return name || filename; } +/** + * Derives a friendly display name for a hook settings file based on its path. + * + * Recognizes well-known tool directories (`.claude`, `.copilot`, `.vscode-insiders`, etc.) + * combined with well-known file stems (`settings`) and produces contextual names: + * - `~/.claude/settings.json` → "Claude User Settings" + * - `.claude/settings.json` → "Claude Settings" + * - `.claude/settings.local.json` → "Claude Settings (Local)" + * - `.github/hooks/hooks.json` → "hooks" + */ +export function getHookFileFriendlyName(uriPath: string, storage?: PromptsStorage): string { + const segments = uriPath.split('/'); + const filename = segments[segments.length - 1] || ''; + + // Strip the .json extension to get the base stem + const stem = filename.replace(/\.json$/i, '') || filename; + + // Check for .local suffix (e.g. settings.local.json → "settings") + const localMatch = stem.match(/^(.+)\.local$/i); + const coreStem = localMatch ? localMatch[1] : stem; + + // Only apply tool-directory naming for well-known file stems + const wellKnownStems = new Set(['settings']); + if (wellKnownStems.has(coreStem.toLowerCase())) { + // Look for a known tool directory in the path (e.g. .claude, .copilot) + const toolDirMap: Record = { + '.claude': 'Claude', + '.copilot': 'Copilot', + '.github': 'Copilot', + }; + + for (let i = segments.length - 2; i >= 0; i--) { + const toolLabel = toolDirMap[segments[i]]; + if (!toolLabel) { + continue; + } + + // Detect user-level vs workspace-level from storage + const isUserLevel = storage === PromptsStorage.user; + + const titleCased = coreStem + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, c => c.toUpperCase()); + + let name = isUserLevel + ? `${toolLabel} User ${titleCased}` + : `${toolLabel} ${titleCased}`; + + if (localMatch) { + name += ' (Local)'; + } + return name; + } + } + + // Default: just return the stem as-is + return stem; +} + /** * Expands hook file items into file-level entries with parsed child hooks. * Each file becomes a single item with `hookChildren` describing the @@ -483,9 +542,16 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour for (const file of discovery.files) { if (disabledUris.has(file.promptPath.uri) && !existingUris.has(file.promptPath.uri)) { resolvedUris.add(file.promptPath.uri); - const name = promptType === PromptsType.skill - ? (file.promptPath.name || basename(dirname(file.promptPath.uri)) || basename(file.promptPath.uri)) - : (file.promptPath.name || getFriendlyName(basename(file.promptPath.uri))); + let name: string; + if (file.promptPath.name) { + name = file.promptPath.name; + } else if (promptType === PromptsType.skill) { + name = basename(dirname(file.promptPath.uri)) || basename(file.promptPath.uri); + } else if (promptType === PromptsType.hook) { + name = getHookFileFriendlyName(file.promptPath.uri.path, file.promptPath.storage); + } else { + name = getFriendlyName(basename(file.promptPath.uri)); + } missingItems.push({ uri: file.promptPath.uri, type: promptType, @@ -502,9 +568,14 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour // Fallback for disabled URIs not found in discovery info for (const uri of disabledUris) { if (!existingUris.has(uri) && !resolvedUris.has(uri)) { - const name = promptType === PromptsType.skill - ? (basename(dirname(uri)) || basename(uri)) - : getFriendlyName(basename(uri)); + let name: string; + if (promptType === PromptsType.skill) { + name = basename(dirname(uri)) || basename(uri); + } else if (promptType === PromptsType.hook) { + name = getHookFileFriendlyName(uri.path); + } else { + name = getFriendlyName(basename(uri)); + } missingItems.push({ uri, type: promptType, diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 1398ab92042fa..80d3ccec070fb 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -122,7 +122,7 @@ interface IHookChildEntry { type IListEntry = IGroupHeaderEntry | IFileItemEntry | IHookFileEntry | IHookChildEntry; -const HOOK_FILE_HEADER_HEIGHT = 36; +const HOOK_FILE_HEADER_HEIGHT = 44; const HOOK_CHILD_HEIGHT = 28; /** @@ -246,6 +246,7 @@ interface IHookFileHeaderTemplateData { readonly chevron: HTMLElement; readonly icon: HTMLElement; readonly label: HTMLElement; + readonly description: HTMLElement; readonly count: HTMLElement; readonly actionsContainer: HTMLElement; readonly actionBar: ActionBar; @@ -277,15 +278,18 @@ class HookFileHeaderRenderer implements IListRenderer 0 ? `${childCount}` : ''; + // Secondary text (file path) + const secondaryText = getCustomizationSecondaryText(item.description, item.filename, item.promptType); + if (secondaryText) { + templateData.description.textContent = secondaryText; + templateData.description.style.display = ''; + } else { + templateData.description.textContent = ''; + templateData.description.style.display = 'none'; + } + // Disabled styling templateData.container.classList.toggle('disabled', item.disabled); @@ -583,7 +597,7 @@ class AICustomizationItemRenderer implements IListRenderer sum + (item.hookChildren?.length || 1), 0) + ? group.items.reduce((sum, item) => sum + (item.hookChildren?.length ?? 0), 0) : group.items.length; + if (isHookSection && itemCount === 0) { + continue; + } + this.displayEntries.push({ type: 'group-header', id: `group-${group.groupKey}`, @@ -1561,6 +1580,9 @@ export class AICustomizationListWidget extends Disposable { this.displayEntries.push({ type: 'hook-child', parentItem: item, child }); } } + } else if (isHookSection) { + // Hide hook files that don't contribute any hooks + continue; } else { this.displayEntries.push({ type: 'file-item', item }); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css index 25551073153b2..7ba619ba52b93 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -435,7 +435,8 @@ .ai-customization-list-item.disabled .item-name, .ai-customization-list-item.disabled .item-description, -.ai-customization-list-item.disabled .item-type-icon { +.ai-customization-list-item.disabled .item-type-icon, +.ai-customization-list-item.disabled .item-badge { opacity: 0.5; } @@ -1187,7 +1188,8 @@ } .ai-customization-hook-file-header.disabled .hook-file-label, -.ai-customization-hook-file-header.disabled .hook-file-icon { +.ai-customization-hook-file-header.disabled .hook-file-icon, +.ai-customization-hook-file-header.disabled .hook-file-description { opacity: 0.5; } @@ -1200,6 +1202,30 @@ min-width: 0; } +.ai-customization-hook-file-header .hook-file-text { + display: flex; + flex-direction: column; + overflow: hidden; + flex: 1; + min-width: 0; +} + +.ai-customization-hook-file-header .hook-file-name-row { + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; +} + +.ai-customization-hook-file-header .hook-file-description { + font-size: 11px; + color: var(--vscode-descriptionForeground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 14px; +} + .ai-customization-hook-file-header .hook-file-chevron { flex-shrink: 0; width: 16px; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts index 68dfabd81b385..ff8631cc0388e 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts @@ -18,7 +18,7 @@ import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { ICustomizationItem, ICustomizationItemProvider, IHarnessDescriptor, matchesInstructionFileFilter, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js'; import { BUILTIN_STORAGE } from './aiCustomizationManagement.js'; -import { getFriendlyName, isChatExtensionItem } from './aiCustomizationItemSource.js'; +import { getFriendlyName, getHookFileFriendlyName, isChatExtensionItem } from './aiCustomizationItemSource.js'; import { getSkillFolderName } from '../../common/promptSyntax/config/promptFileLocations.js'; /** @@ -138,7 +138,7 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt items.push({ uri: f.uri, type: promptType, - name: f.name || getFriendlyName(basename(f.uri)), + name: f.name || getHookFileFriendlyName(f.uri.path, f.storage), storage: f.storage, }); } From 1a8c4a87c67042e425b3890cfd2ca9527d363524 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Fri, 24 Apr 2026 07:53:51 -0700 Subject: [PATCH 28/36] updates --- .../copilotcli/node/copilotCli.ts | 3 + .../copilotCLICustomizationProvider.ts | 153 +++++++++++++--- .../copilotCLICustomizationProvider.spec.ts | 103 +++++++++-- .../contrib/chat/browser/promptsService.ts | 8 +- .../api/browser/mainThreadChatAgents2.ts | 4 +- .../aiCustomizationItemSource.ts | 20 +-- .../aiCustomizationListWidget.ts | 1 - .../aiCustomizationManagement.contribution.ts | 73 +------- .../promptSyntax/service/promptsService.ts | 17 +- .../service/promptsServiceImpl.ts | 42 ++--- .../aiCustomizationDisablement.test.ts | 170 ++++-------------- .../service/mockPromptsService.ts | 6 +- 12 files changed, 305 insertions(+), 295 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts index 6898c09c7b387..cc3ebab7cdda8 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts @@ -278,6 +278,8 @@ export interface CLIAgentInfo { readonly agent: Readonly; /** File URI for prompt-file agents, synthetic `copilotcli:` URI for SDK-only agents. */ readonly sourceUri: URI; + /** The contributing extension identifier, when the agent came from a VS Code extension. */ + readonly extensionId?: string; } export interface ICopilotCLIAgents { @@ -442,6 +444,7 @@ export class CopilotCLIAgents extends Disposable implements ICopilotCLIAgents { ...(model ? { model } : {}), }, sourceUri: customAgent.uri, + extensionId: customAgent.extensionId, }; } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts index e7a54f194f5e1..d55b84b246fc6 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts @@ -19,11 +19,38 @@ import { URI } from '../../../util/vs/base/common/uri'; import { ICopilotCLIAgents, isEnabledForCopilotCLI } from '../copilotcli/node/copilotCli'; import { CopilotCLISettingsLocationType, ICopilotCLISettingsService } from '../copilotcli/common/copilotCLISettingsService'; +/** + * Settings keys for tracking VS Code extension-contributed customizations + * that have been disabled in the CLI harness. + */ +const enum VSCodeDisabledSettingsKey { + Agents = 'vscodeDisabledAgents', + Instructions = 'vscodeDisabledInstructions', + Skills = 'vscodeDisabledSkills', +} + +/** + * Internal item type that extends the API item with a flag indicating + * whether the customization is owned by a VS Code extension. + */ +interface CLICustomizationItem extends vscode.ChatSessionCustomizationItem { + readonly vscodeOwned?: boolean; + source?: string; +} + export class CopilotCLICustomizationProvider extends Disposable implements vscode.ChatSessionCustomizationProvider { private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange = this._onDidChange.event; + /** + * Tracks URIs of VS Code extension-contributed customizations seen + * during the last {@link provideChatSessionCustomizations} call. + * Used by {@link handleCustomizationEnablement} to route disablement + * to the correct settings key. + */ + private _vscodeOwnedUris = new Set(); + static get metadata(): vscode.ChatSessionCustomizationProviderMetadata { return { label: 'Copilot CLI', @@ -65,7 +92,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod this.getSkillItems(token), this.getHookItems(token), this.getPluginItems(token), - ].map(p => p.catch(err => { + ].map(p => p.catch((err): CLICustomizationItem[] => { if (isCancellationError(err) || token.isCancellationRequested) { throw err; } @@ -80,23 +107,38 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod this.logService.debug(`[CopilotCLICustomizationProvider] plugins (${plugins.length}): ${plugins.map(p => p.name).join(', ') || '(none)'}`); - const items = [...agents, ...instructions, ...skills, ...hooks, ...plugins]; - this.logService.debug(`[CopilotCLICustomizationProvider] total: ${items.length} items`); - return items; + const allItems = [...agents, ...instructions, ...skills, ...hooks, ...plugins]; + + // Rebuild the vscode-owned URI set from the freshly fetched items. + this._vscodeOwnedUris = new Set( + allItems.filter(i => i.vscodeOwned).map(i => i.uri.toString()), + ); + + this.logService.debug(`[CopilotCLICustomizationProvider] total: ${allItems.length} items (${this._vscodeOwnedUris.size} vscode-owned)`); + return allItems; } /** * Builds agent items from ICopilotCLIAgents, which already merges SDK * and prompt-file agents with source URIs. */ - private async getAgentItems(_token: vscode.CancellationToken): Promise { + private async getAgentItems(_token: vscode.CancellationToken): Promise { const agentInfos = await this.copilotCLIAgents.getAgents(); - return agentInfos.map(({ agent, sourceUri }) => ({ + const settings = await this._readUserSettings(); + const disabledSet = this._getDisabledUriSet(settings, VSCodeDisabledSettingsKey.Agents); + return agentInfos.map(({ agent, sourceUri, extensionId }) => ({ uri: sourceUri, type: vscode.ChatSessionCustomizationType.Agent, name: agent.displayName || agent.name, description: agent.description, - enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, + vscodeOwned: !!extensionId, + // Only extension-contributed agents (owned by VS Code) support CLI-side disablement + ...(extensionId ? { + enabled: !disabledSet.has(sourceUri.toString()), + enablementScope: vscode.ChatSessionCustomizationEnablementScope.Global, + } : { + enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, + }), })); } @@ -108,7 +150,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod * - context-instructions: files with an applyTo pattern (badge = pattern) * - on-demand-instructions: files without an applyTo pattern */ - private async getInstructionItems(token: CancellationToken): Promise { + private async getInstructionItems(token: CancellationToken): Promise { // Collect agent instruction URIs from customInstructionsService // (copilot-instructions.md) plus workspace-root AGENTS.md and CLAUDE.md const agentInstructionUriList = await this.customInstructionsService.getAgentInstructions(); @@ -125,12 +167,15 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod } } - const items: vscode.ChatSessionCustomizationItem[] = []; + const items: CLICustomizationItem[] = []; const seenUris = new Set(); + const settings = await this._readUserSettings(); + const disabledSet = this._getDisabledUriSet(settings, VSCodeDisabledSettingsKey.Instructions); // Emit agent instruction files (AGENTS.md, CLAUDE.md, copilot-instructions.md) // that come from customInstructionsService but may not appear in // promptsService.getInstructions(). + // These are filesystem-discovered — not disableable from CLI. for (const uri of agentInstructionUriList) { seenUris.add(uri.toString()); items.push({ @@ -154,6 +199,14 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod const name = instruction.name; const pattern = instruction.pattern; const description = instruction.description; + // Only extension-contributed instructions support CLI-side disablement + const hasEnablement = !!instruction.extensionId; + const enablementProps = hasEnablement ? { + enabled: !disabledSet.has(uri.toString()), + enablementScope: vscode.ChatSessionCustomizationEnablementScope.Global, + } : { + enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, + }; if (pattern !== undefined) { const badge = pattern === '**' @@ -170,7 +223,8 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod groupKey: 'context-instructions', badge, badgeTooltip, - enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, + vscodeOwned: hasEnablement, + ...enablementProps, }); } else { items.push({ @@ -179,7 +233,8 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod name, description, groupKey: 'on-demand-instructions', - enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, + vscodeOwned: hasEnablement, + ...enablementProps, }); } } @@ -190,19 +245,33 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod /** * Collects all skill items from the prompt file service. */ - private async getSkillItems(token: vscode.CancellationToken): Promise { + private async getSkillItems(token: vscode.CancellationToken): Promise { const settings = await this._readUserSettings(); const disabledSkills = Array.isArray(settings.disabledSkills) ? settings.disabledSkills.filter(s => typeof s === 'string') : []; const disabledSkillsSet = new Set(disabledSkills); + const vscodeDisabledSkills = this._getDisabledUriSet(settings, VSCodeDisabledSettingsKey.Skills); return (await this.promptsService.getSkills(token)).filter(isEnabledForCopilotCLI).map(s => { const name = s.name; const skillName = basename(dirname(s.uri)); + // Only extension-contributed skills support CLI-side disablement + if (s.extensionId) { + return { + uri: s.uri, + type: vscode.ChatSessionCustomizationType.Skill, + name, + vscodeOwned: true, + enabled: !vscodeDisabledSkills.has(s.uri.toString()), + enablementScope: vscode.ChatSessionCustomizationEnablementScope.Global, + source: s.source + }; + } return { uri: s.uri, type: vscode.ChatSessionCustomizationType.Skill, name, enabled: !disabledSkillsSet.has(skillName), enablementScope: vscode.ChatSessionCustomizationEnablementScope.Global, + source: s.source }; }); } @@ -211,15 +280,11 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod * Collects all hook items from the prompt file service. * Each item is a hook configuration file (JSON). */ - private async getHookItems(token: vscode.CancellationToken): Promise { - const settings = await this._readUserSettings(); + private async getHookItems(token: vscode.CancellationToken): Promise { return (await this.promptsService.getHooks(token)).filter(isEnabledForCopilotCLI).map(h => ({ uri: h.uri, type: vscode.ChatSessionCustomizationType.Hook, name: basename(h.uri).replace(/\.json$/i, ''), - // TODO: This is best-effort for now. Each hook file itself can disable all hooks with disableAllHooks. - enabled: settings.disableAllHooks === false, - // TODO: There isn't a great way to toggle enablement for individual hooks enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, })); } @@ -227,7 +292,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod /** * Collects all plugin items from the prompt file service. */ - private async getPluginItems(token: vscode.CancellationToken): Promise { + private async getPluginItems(token: vscode.CancellationToken): Promise { const settings = await this._readUserSettings(); const enabledPlugins = typeof settings.enabledPlugins === 'object' ? settings.enabledPlugins : {}; return (await this.promptsService.getPlugins(token)).filter(isEnabledForCopilotCLI).map(p => { @@ -264,17 +329,23 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod let name: string; if (type.id === vscode.ChatSessionCustomizationType.Skill.id) { - // Skills use the folder name as the key in disabledSkills - name = basename(dirname(URI.from(uri))) || basename(URI.from(uri)); - const currentList = Array.isArray(settings.disabledSkills) ? settings.disabledSkills as string[] : []; - if (enabled) { - settings.disabledSkills = currentList.filter(s => s !== name); - } else if (!currentList.includes(name)) { - settings.disabledSkills = [...currentList, name]; + if (this._vscodeOwnedUris.has(uri.toString())) { + // VS Code extension-contributed skill — use vscode disabled key + name = basename(dirname(uri)); + this._toggleDisabledUri(settings, VSCodeDisabledSettingsKey.Skills, uri.toString(), enabled); + } else { + // Filesystem-discovered skill — use disabledSkills folder-name list + name = basename(dirname(uri)); + const currentList = Array.isArray(settings.disabledSkills) ? settings.disabledSkills as string[] : []; + if (enabled) { + settings.disabledSkills = currentList.filter(s => s !== name); + } else if (!currentList.includes(name)) { + settings.disabledSkills = [...currentList, name]; + } } } else if (type.id === vscode.ChatSessionCustomizationType.Plugins?.id) { // Plugins use enabledPlugins map (Record) - name = basename(URI.from(uri)); + name = basename(uri); const map = (settings.enabledPlugins && typeof settings.enabledPlugins === 'object' && !Array.isArray(settings.enabledPlugins)) ? { ...settings.enabledPlugins as Record } : {}; @@ -284,6 +355,12 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod map[name] = false; } settings.enabledPlugins = Object.keys(map).length > 0 ? map : undefined; + } else if (type.id === vscode.ChatSessionCustomizationType.Instructions.id && this._vscodeOwnedUris.has(uri.toString())) { + name = basename(uri); + this._toggleDisabledUri(settings, VSCodeDisabledSettingsKey.Instructions, uri.toString(), enabled); + } else if (type.id === vscode.ChatSessionCustomizationType.Agent.id && this._vscodeOwnedUris.has(uri.toString())) { + name = basename(uri); + this._toggleDisabledUri(settings, VSCodeDisabledSettingsKey.Agents, uri.toString(), enabled); } else { this.logService.warn(`[CopilotCLICustomizationProvider] Per-item enablement not supported for type: ${type.id}`); void vscode.window.showErrorMessage(vscode.l10n.t('Toggling {0} customizations is not supported.', type.id)); @@ -298,4 +375,28 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod void vscode.window.showErrorMessage(vscode.l10n.t('Failed to update Copilot settings: {0}', err instanceof Error ? err.message : String(err))); } } + + /** + * Toggles a URI string in a disabled list within the settings object. + */ + private _toggleDisabledUri(settings: Record, key: VSCodeDisabledSettingsKey, uriString: string, enabled: boolean): void { + const currentList = Array.isArray(settings[key]) ? (settings[key] as string[]).filter(s => typeof s === 'string') : []; + if (enabled) { + settings[key] = currentList.filter(s => s !== uriString); + } else if (!currentList.includes(uriString)) { + settings[key] = [...currentList, uriString]; + } + // Clean up empty arrays + if (Array.isArray(settings[key]) && (settings[key] as string[]).length === 0) { + delete settings[key]; + } + } + + /** + * Reads a disabled URI set from the settings object for the given key. + */ + private _getDisabledUriSet(settings: Record, key: VSCodeDisabledSettingsKey): Set { + const list = Array.isArray(settings[key]) ? (settings[key] as string[]).filter(s => typeof s === 'string') : []; + return new Set(list); + } } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLICustomizationProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLICustomizationProvider.spec.ts index 5811f72e615e8..57711b81f56e9 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLICustomizationProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLICustomizationProvider.spec.ts @@ -101,13 +101,22 @@ function makeFileAgentInfo(name: string, fileUri: URI, description = ''): CLIAge } /** Creates a ChatInstruction stub with the required name and source fields. */ -function makeInstruction(uri: URI, name: string, pattern: string | undefined, description?: string): vscode.ChatInstruction { - return { uri, name, pattern, source: 'local', description }; +function makeInstruction(uri: URI, name: string, pattern: string | undefined, description?: string, extensionId?: string): vscode.ChatInstruction { + return { uri, name, pattern, source: extensionId ? 'extension' : 'local', description, extensionId }; } /** Creates a ChatSkill stub, deriving the name from the parent directory for SKILL.md files. */ -function makeSkill(uri: URI, name: string): vscode.ChatSkill { - return { uri, name: name, source: 'local' }; +function makeSkill(uri: URI, name: string, extensionId?: string): vscode.ChatSkill { + return { uri, name: name, source: extensionId ? 'extension' : 'local', extensionId }; +} + +/** Creates a CLIAgentInfo with a file: URI and extensionId (extension-contributed agent). */ +function makeExtensionAgentInfo(name: string, fileUri: URI, extensionId: string, description = ''): CLIAgentInfo { + return { + agent: makeSweAgent(name, description), + sourceUri: fileUri, + extensionId, + }; } /** Creates a ChatHook stub. */ @@ -600,44 +609,54 @@ describe('CopilotCLICustomizationProvider', () => { }); describe('skill enablement', () => { - it('marks skill as enabled by default when no settings file', async () => { + it('marks extension-contributed skill as enabled by default when no settings file', async () => { const uri = URI.file('/workspace/.github/skills/lint-check/SKILL.md'); - mockPromptsService.setSkills([makeSkill(uri, 'lint-check')]); + mockPromptsService.setSkills([makeSkill(uri, 'lint-check', 'my-ext.lint')]); const items = await provider.provideChatSessionCustomizations(undefined!); const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill); expect(skillItems[0].enabled).toBe(true); }); - it('marks skill as disabled when in disabledSkills setting', async () => { + it('marks extension-contributed skill as disabled when in disabledSkills setting', async () => { mockCopilotCLISettingsService.setSettings({ disabledSkills: ['lint-check'] }); const uri = URI.file('/workspace/.github/skills/lint-check/SKILL.md'); - mockPromptsService.setSkills([makeSkill(uri, 'lint-check')]); + mockPromptsService.setSkills([makeSkill(uri, 'lint-check', 'my-ext.lint')]); const items = await provider.provideChatSessionCustomizations(undefined!); const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill); expect(skillItems[0].enabled).toBe(false); }); - it('marks skill as enabled when not in disabledSkills', async () => { + it('marks extension-contributed skill as enabled when not in disabledSkills', async () => { mockCopilotCLISettingsService.setSettings({ disabledSkills: ['other-skill'] }); const uri = URI.file('/workspace/.github/skills/lint-check/SKILL.md'); - mockPromptsService.setSkills([makeSkill(uri, 'lint-check')]); + mockPromptsService.setSkills([makeSkill(uri, 'lint-check', 'my-ext.lint')]); const items = await provider.provideChatSessionCustomizations(undefined!); const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill); expect(skillItems[0].enabled).toBe(true); }); - it('sets enablementScope to Global for skills', async () => { + it('sets enablementScope to Global for extension-contributed skills', async () => { const uri = URI.file('/workspace/.github/skills/lint-check/SKILL.md'); - mockPromptsService.setSkills([makeSkill(uri, 'lint-check')]); + mockPromptsService.setSkills([makeSkill(uri, 'lint-check', 'my-ext.lint')]); const items = await provider.provideChatSessionCustomizations(undefined!); const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill); expect(skillItems[0].enablementScope).toBe(FakeChatSessionCustomizationEnablementScope.Global); }); + it('sets enablementScope to None for filesystem-discovered skills (no extensionId)', async () => { + const uri = URI.file('/workspace/.github/skills/lint-check/SKILL.md'); + mockPromptsService.setSkills([makeSkill(uri, 'lint-check')]); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill); + expect(skillItems[0].enablementScope).toBe(FakeChatSessionCustomizationEnablementScope.None); + expect(skillItems[0].enabled).toBeUndefined(); + }); + it('disabling a skill adds it to disabledSkills in settings', async () => { mockCopilotCLISettingsService.setSettings({}); const skillUri = URI.file('/workspace/.github/skills/lint-check/SKILL.md'); @@ -783,4 +802,64 @@ describe('CopilotCLICustomizationProvider', () => { expect(fired).toBe(true); }); }); + + describe('extensionId gating', () => { + it('extension-contributed agents get enablementScope: Global', async () => { + const fileUri = URI.file('/workspace/.github/my-agent.agent.md'); + mockCopilotCLIAgents.setAgents([makeExtensionAgentInfo('my-agent', fileUri, 'ext.agents')]); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const agentItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Agent); + expect(agentItems[0].enablementScope).toBe(FakeChatSessionCustomizationEnablementScope.Global); + expect(agentItems[0].enabled).toBe(true); + }); + + it('non-extension agents get enablementScope: None', async () => { + mockCopilotCLIAgents.setAgents([makeAgentInfo('explore', 'Fast code exploration')]); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const agentItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Agent); + expect(agentItems[0].enablementScope).toBe(FakeChatSessionCustomizationEnablementScope.None); + expect(agentItems[0].enabled).toBeUndefined(); + }); + + it('extension-contributed instructions get enablementScope: Global', async () => { + const uri = URI.file('/workspace/.github/style.instructions.md'); + mockPromptsService.setInstructions([makeInstruction(uri, 'style', undefined, undefined, 'ext.instructions')]); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions); + expect(instrItems[0].enablementScope).toBe(FakeChatSessionCustomizationEnablementScope.Global); + expect(instrItems[0].enabled).toBe(true); + }); + + it('filesystem-discovered instructions get enablementScope: None', async () => { + const uri = URI.file('/workspace/.github/style.instructions.md'); + mockPromptsService.setInstructions([makeInstruction(uri, 'style', undefined)]); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions); + expect(instrItems[0].enablementScope).toBe(FakeChatSessionCustomizationEnablementScope.None); + expect(instrItems[0].enabled).toBeUndefined(); + }); + + it('agent instructions (AGENTS.md, etc.) are not disableable', async () => { + const agentsUri = URI.file('/workspace/AGENTS.md'); + mockCustomInstructionsService.setAgentInstructionUris([agentsUri]); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions); + expect(instrItems[0].enablementScope).toBeUndefined(); + expect(instrItems[0].enabled).toBeUndefined(); + }); + + it('hooks are not disableable (no extensionId available)', async () => { + mockPromptsService.setHooks([makeHook(URI.file('/workspace/.copilot/hooks/pre-commit.json'))]); + + const items = await provider.provideChatSessionCustomizations(undefined!); + const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook); + expect(hookItems[0].enablementScope).toBe(FakeChatSessionCustomizationEnablementScope.None); + expect(hookItems[0].enabled).toBeUndefined(); + }); + }); }); diff --git a/src/vs/sessions/contrib/chat/browser/promptsService.ts b/src/vs/sessions/contrib/chat/browser/promptsService.ts index 89c92e6a6163e..b50c17f43dc17 100644 --- a/src/vs/sessions/contrib/chat/browser/promptsService.ts +++ b/src/vs/sessions/contrib/chat/browser/promptsService.ts @@ -92,8 +92,8 @@ export class AgenticPromptsService extends PromptsService { })); } - public override async findAgentSkills(token: CancellationToken): Promise { - const baseResult = await super.findAgentSkills(token); + public override async findAgentSkills(token: CancellationToken, options?: { includeDisabled?: boolean }): Promise { + const baseResult = await super.findAgentSkills(token, options); if (baseResult === undefined) { return undefined; } @@ -108,8 +108,8 @@ export class AgenticPromptsService extends PromptsService { .filter(s => s.storage === PromptsStorage.local || s.storage === PromptsStorage.user) .map(s => s.name) ); - const disabledSkills = this.getDisabledPromptFiles(PromptsType.skill); - const nonOverridden = builtinSkills.filter(s => !existingNames.has(s.name) && !disabledSkills.has(s.uri)); + const disabledSkills = options?.includeDisabled ? undefined : this.getDisabledPromptFiles(PromptsType.skill); + const nonOverridden = builtinSkills.filter(s => !existingNames.has(s.name) && !disabledSkills?.has(s.uri)); if (nonOverridden.length === 0) { return baseResult; } diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 096feeb98e7b2..6389355c9d5f3 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -281,7 +281,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA } async $provideCustomAgents(token: CancellationToken): Promise { - const customAgents = await this._promptsService.getCustomAgents(token); + const customAgents = await this._promptsService.getCustomAgents(token, { includeDisabled: true }); return customAgents.map(agent => this._toCustomAgentDto(agent)); } @@ -291,7 +291,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA } async $provideSkills(token: CancellationToken): Promise { - const skills = await this._promptsService.findAgentSkills(token) ?? []; + const skills = await this._promptsService.findAgentSkills(token, { includeDisabled: true }) ?? []; return skills.map(skill => this._toSkillDto(skill)); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index e16f637ce6bb0..834e817c01f97 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -402,7 +402,6 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour readonly onDidChange: Event; constructor( - private readonly harnessId: string, private readonly itemProvider: ICustomizationItemProvider | undefined, private readonly syncProvider: ICustomizationSyncProvider | undefined, private readonly promptsService: IPromptsService, @@ -481,11 +480,10 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour // Overlay disabled state when: // - VS Code items (explicit `storage` from provider): checked against - // promptsService. On external harnesses (with itemProvider) the namespace - // isolates per-harness state. On the VS Code harness (no itemProvider) no - // namespace is needed. + // promptsService. External harnesses handle disablement via the provider's + // `enabled` field, so the overlay only applies to the VS Code harness. const vscodeDisabledUris = this.hasNativeItemProvider - ? this.promptsService.getDisabledPromptFiles(promptType, this.harnessId) + ? new ResourceSet() // External harness — provider reports disabled state directly : this.promptsService.getDisabledPromptFiles(promptType); const hasDisabled = vscodeDisabledUris.size > 0; if (hasDisabled) { @@ -641,10 +639,10 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour uriUseCounts.set(item.uri, (uriUseCounts.get(item.uri) ?? 0) + 1); } const appended: IAICustomizationListItem[] = []; - // Built-in skills are VS Code items — use namespaced promptsService disabled set - // only for external harnesses (with native item provider). VS Code harness uses no namespace. + // Built-in skills are VS Code items — use promptsService disabled set + // only for the VS Code harness. External harnesses handle disablement via the provider. const disabledPromptFiles = this.hasNativeItemProvider - ? this.promptsService.getDisabledPromptFiles(PromptsType.skill, this.harnessId) + ? new ResourceSet() // External harness — provider reports disabled state directly : this.promptsService.getDisabledPromptFiles(PromptsType.skill); for (const p of builtinPaths) { const name = p.name ?? basename(p.uri); @@ -691,10 +689,10 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour return []; } - // Local syncable items are VS Code items — use namespaced promptsService disabled set - // only for external harnesses (with native item provider). VS Code harness uses no namespace. + // Local syncable items are VS Code items — use promptsService disabled set + // only for the VS Code harness. External harnesses handle disablement via the provider. const disabledUris = this.hasNativeItemProvider - ? this.promptsService.getDisabledPromptFiles(promptType, this.harnessId) + ? new ResourceSet() // External harness — provider reports disabled state directly : this.promptsService.getDisabledPromptFiles(promptType); const providerItems: ICustomizationItem[] = files .filter(file => file.storage === PromptsStorage.local || file.storage === PromptsStorage.user) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 80d3ccec070fb..461f8ba3b4f69 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -1472,7 +1472,6 @@ export class AICustomizationListWidget extends Disposable { } const itemProvider = descriptor.itemProvider ?? (descriptor.syncProvider ? undefined : this.promptsServiceItemProvider); const source = new ProviderCustomizationItemSource( - descriptor.id, itemProvider, descriptor.syncProvider, this.promptsService, diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index f0a446bc2d838..012e6e17a1453 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -23,7 +23,6 @@ import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.j import { FileSystemProviderCapabilities, IFileService } from '../../../../../platform/files/common/files.js'; import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; -import { StorageScope } from '../../../../../platform/storage/common/storage.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../../browser/editor.js'; @@ -39,7 +38,7 @@ import { getChatSessionType } from '../../common/model/chatUri.js'; import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { ContributionEnablementState, isContributionDisabled, isContributionEnabled } from '../../common/enablement.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; -import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { IChatWidgetService } from '../chat.js'; import { AgentPluginItemKind } from '../agentPluginEditor/agentPluginItems.js'; @@ -195,22 +194,6 @@ function extractPlugin(context: AICustomizationContext): URI | undefined { return URI.parse(raw); } -/** - * Returns true when the provider explicitly set an enablement scope on this item, - * meaning the provider's enablementHandler owns disablement for it. - * When false, VS Code should handle disablement via promptsService (with namespace). - * - * Items with `enablementScope: 'application'` are explicitly provider-reported but - * application-managed — the provider does NOT own their disablement. - */ -function hasProviderEnablement(context: AICustomizationContext): boolean { - if (URI.isUri(context) || typeof context === 'string') { - return false; - } - return context.providerEnablementScope !== undefined - && context.providerEnablementScope !== 'application'; -} - /** * Extracts the item name from context. */ @@ -776,7 +759,6 @@ registerAction2(class extends Action2 { }); } async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise { - const promptsService = accessor.get(IPromptsService); const harnessService = accessor.get(ICustomizationHarnessService); const uri = extractURI(context); const promptType = extractPromptType(context); @@ -795,22 +777,10 @@ registerAction2(class extends Action2 { } // Provider-managed items: delegate to the harness's enablement provider. - // VS Code items on external harnesses: persist via promptsService with harness namespace. - // VS Code items on the VS Code harness: persist via enablementHandler (no namespace). + // All harnesses now own their disablement through enablementHandler. const enablementHandler = harnessService.getActiveEnablementHandler(); - const descriptor = harnessService.getActiveDescriptor(); - if (enablementHandler && hasProviderEnablement(context)) { - enablementHandler.handleCustomizationEnablement(uri, promptType, false, 'global'); - } else if (enablementHandler && !descriptor.itemProvider) { - // VS Code harness — delegate to its enablement provider (no namespace) + if (enablementHandler) { enablementHandler.handleCustomizationEnablement(uri, promptType, false, 'global'); - } else { - const namespace = descriptor.id; - const storage = extractStorage(context); - const scope = storage === PromptsStorage.local ? StorageScope.WORKSPACE : StorageScope.PROFILE; - const disabled = promptsService.getDisabledPromptFilesForScope(promptType, scope, namespace); - disabled.add(uri); - promptsService.setDisabledPromptFiles(promptType, disabled, scope, namespace); } } }); @@ -825,7 +795,6 @@ registerAction2(class extends Action2 { }); } async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise { - const promptsService = accessor.get(IPromptsService); const harnessService = accessor.get(ICustomizationHarnessService); const uri = extractURI(context); const promptType = extractPromptType(context); @@ -834,17 +803,8 @@ registerAction2(class extends Action2 { } const enablementHandler = harnessService.getActiveEnablementHandler(); - const descriptor = harnessService.getActiveDescriptor(); - if (enablementHandler && hasProviderEnablement(context)) { + if (enablementHandler) { enablementHandler.handleCustomizationEnablement(uri, promptType, false, 'workspace'); - } else if (enablementHandler && !descriptor.itemProvider) { - // VS Code harness — delegate to its enablement provider (no namespace) - enablementHandler.handleCustomizationEnablement(uri, promptType, false, 'workspace'); - } else { - const namespace = descriptor.id; - const disabled = promptsService.getDisabledPromptFilesForScope(promptType, StorageScope.WORKSPACE, namespace); - disabled.add(uri); - promptsService.setDisabledPromptFiles(promptType, disabled, StorageScope.WORKSPACE, namespace); } } }); @@ -860,7 +820,6 @@ registerAction2(class extends Action2 { }); } async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise { - const promptsService = accessor.get(IPromptsService); const harnessService = accessor.get(ICustomizationHarnessService); const uri = extractURI(context); const promptType = extractPromptType(context); @@ -879,30 +838,10 @@ registerAction2(class extends Action2 { } // Provider-managed items: delegate to the harness's enablement provider. - // VS Code items on external harnesses: persist via promptsService with harness namespace. - // VS Code items on the VS Code harness: persist via enablementHandler (no namespace). + // All harnesses now own their disablement through enablementHandler. const enablementHandler = harnessService.getActiveEnablementHandler(); - const descriptor = harnessService.getActiveDescriptor(); - if (enablementHandler && hasProviderEnablement(context)) { - enablementHandler.handleCustomizationEnablement(uri, promptType, true, 'global'); - } else if (enablementHandler && !descriptor.itemProvider) { - // VS Code harness — delegate to its enablement provider (no namespace) + if (enablementHandler) { enablementHandler.handleCustomizationEnablement(uri, promptType, true, 'global'); - } else { - const namespace = descriptor.id; - // Remove from both scopes to fully re-enable - const profileDisabled = promptsService.getDisabledPromptFilesForScope(promptType, StorageScope.PROFILE, namespace); - const wasInProfile = profileDisabled.delete(uri); - - const workspaceDisabled = promptsService.getDisabledPromptFilesForScope(promptType, StorageScope.WORKSPACE, namespace); - const wasInWorkspace = workspaceDisabled.delete(uri); - - if (wasInProfile) { - promptsService.setDisabledPromptFiles(promptType, profileDisabled, StorageScope.PROFILE, namespace); - } - if (wasInWorkspace) { - promptsService.setDisabledPromptFiles(promptType, workspaceDisabled, StorageScope.WORKSPACE, namespace); - } } } }); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index 1b85389b86ee4..4a302cb6c96e9 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -625,9 +625,10 @@ export interface IPromptsService extends IDisposable { readonly onDidChangeInstructions: Event; /** - * Finds all available custom agents + * Finds all available custom agents. + * @param options.includeDisabled If true, includes disabled agents in the result. */ - getCustomAgents(token: CancellationToken): Promise; + getCustomAgents(token: CancellationToken, options?: { includeDisabled?: boolean }): Promise; /** * Parses the provided URI @@ -663,22 +664,19 @@ export interface IPromptsService extends IDisposable { /** * Returns the list of disabled prompt file URIs for a given type. By default no prompt files are disabled. - * @param namespace Optional harness namespace to isolate disabled state per harness (e.g. `'copilotcli'`, `'claude'`). */ - getDisabledPromptFiles(type: PromptsType, namespace?: string): ResourceSet; + getDisabledPromptFiles(type: PromptsType): ResourceSet; /** * Persists the set of disabled prompt file URIs for the given type. * @param scope Storage scope — defaults to profile. Use WORKSPACE to disable for the current workspace only. - * @param namespace Optional harness namespace to isolate disabled state per harness. */ - setDisabledPromptFiles(type: PromptsType, uris: ResourceSet, scope?: StorageScope, namespace?: string): void; + setDisabledPromptFiles(type: PromptsType, uris: ResourceSet, scope?: StorageScope): void; /** * Returns the disabled prompt file URIs for a specific scope. - * @param namespace Optional harness namespace to isolate disabled state per harness. */ - getDisabledPromptFilesForScope(type: PromptsType, scope: StorageScope, namespace?: string): ResourceSet; + getDisabledPromptFilesForScope(type: PromptsType, scope: StorageScope): ResourceSet; /** * Registers a prompt file provider that can provide prompt files for repositories. @@ -694,8 +692,9 @@ export interface IPromptsService extends IDisposable { /** * Gets list of agent skills files. + * @param options.includeDisabled If true, includes disabled skills in the result. */ - findAgentSkills(token: CancellationToken): Promise; + findAgentSkills(token: CancellationToken, options?: { includeDisabled?: boolean }): Promise; /** * Event that is triggered when the list of skills changes. diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 05d17018696a4..ca9578111c597 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -757,19 +757,18 @@ export class PromptsService extends Disposable implements IPromptsService { return this.cachedInstructions.onDidChangePromise; } - public async getCustomAgents(token: CancellationToken): Promise { + public async getCustomAgents(token: CancellationToken, options?: { includeDisabled?: boolean }): Promise { const discoveryInfo = await this.cachedCustomAgents.get(token); - const result = this.agentsFromDiscoveryInfo(discoveryInfo); - return result; + return this.agentsFromDiscoveryInfo(discoveryInfo, options?.includeDisabled); } /** * Derives ICustomAgent[] from cached discovery info. */ - private agentsFromDiscoveryInfo(discoveryInfo: IAgentDiscoveryInfo): readonly ICustomAgent[] { + private agentsFromDiscoveryInfo(discoveryInfo: IAgentDiscoveryInfo, includeDisabled?: boolean): readonly ICustomAgent[] { const result: ICustomAgent[] = []; for (const file of discoveryInfo.files) { - if (file.status === 'loaded' && file.agent) { + if (file.agent && (includeDisabled || file.status === 'loaded')) { result.push(file.agent); } } @@ -790,18 +789,7 @@ export class PromptsService extends Disposable implements IPromptsService { const files = await Promise.all(allAgentFiles.map(async (promptPath): Promise => { const uri = promptPath.uri; - - if (disabledAgents.has(uri)) { - // Still parse the header so we have name/description for the UI - try { - const ast = await this.parseNew(uri, token); - const name = ast.header?.name; - const description = ast.header?.description; - return { status: 'skipped', skipReason: 'disabled', promptPath: this.withPromptPathMetadata(promptPath, name ?? promptPath.name, description ?? promptPath.description) }; - } catch { - return { status: 'skipped', skipReason: 'disabled', promptPath }; - } - } + const isDisabled = disabledAgents.has(uri); try { const ast = await this.parseNew(uri, token); @@ -846,7 +834,7 @@ export class PromptsService extends Disposable implements IPromptsService { : undefined; if (!ast.header) { const agent: ICustomAgent = { uri, name, agentInstructions, source, target, visibility: { userInvocable: true, agentInvocable: true }, sessionTypes: promptPath.sessionTypes, ...(when !== undefined ? { when } : undefined) }; - return { status: 'loaded', promptPath: this.withPromptPathMetadata(promptPath, name, description), agent }; + return { status: isDisabled ? 'skipped' : 'loaded', ...(isDisabled ? { skipReason: 'disabled' as const } : undefined), promptPath: this.withPromptPathMetadata(promptPath, name, description), agent }; } const visibility = { userInvocable: ast.header.userInvocable !== false, @@ -872,7 +860,7 @@ export class PromptsService extends Disposable implements IPromptsService { } const agent: ICustomAgent = { uri, name, description, model, tools, handOffs, argumentHint, target, visibility, agents, hooks, agentInstructions, source, sessionTypes: promptPath.sessionTypes, ...(when !== undefined ? { when } : undefined) }; - return { status: 'loaded', promptPath: this.withPromptPathMetadata(promptPath, name, description), agent }; + return { status: isDisabled ? 'skipped' : 'loaded', ...(isDisabled ? { skipReason: 'disabled' as const } : undefined), promptPath: this.withPromptPathMetadata(promptPath, name, description), agent }; } catch (e) { const error = e instanceof Error ? e : new Error(String(e)); if (error instanceof FileOperationError && error.fileOperationResult === FileOperationResult.FILE_NOT_FOUND) { @@ -1093,9 +1081,9 @@ export class PromptsService extends Disposable implements IPromptsService { private readonly disabledPromptsStorageKeyPrefix = 'chat.disabledPromptFiles.'; private readonly disabledPromptsWorkspaceStorageKeyPrefix = 'chat.disabledPromptFiles.workspace.'; - public getDisabledPromptFiles(type: PromptsType, namespace?: string): ResourceSet { + public getDisabledPromptFiles(type: PromptsType): ResourceSet { const result = new ResourceSet(); - const suffix = namespace ? `.${namespace}.${type}` : `.${type}`; + const suffix = `.${type}`; // Read profile-level disabled URIs this._readDisabledFromStorage(this.disabledPromptsStorageKeyPrefix.slice(0, -1) + suffix, StorageScope.PROFILE, result); // Read workspace-level disabled URIs @@ -1121,9 +1109,9 @@ export class PromptsService extends Disposable implements IPromptsService { } } - public setDisabledPromptFiles(type: PromptsType, uris: ResourceSet, scope: StorageScope = StorageScope.PROFILE, namespace?: string): void { + public setDisabledPromptFiles(type: PromptsType, uris: ResourceSet, scope: StorageScope = StorageScope.PROFILE): void { const disabled = Array.from(uris).map(uri => uri.toJSON()); - const suffix = namespace ? `.${namespace}.${type}` : `.${type}`; + const suffix = `.${type}`; const key = scope === StorageScope.WORKSPACE ? this.disabledPromptsWorkspaceStorageKeyPrefix.slice(0, -1) + suffix : this.disabledPromptsStorageKeyPrefix.slice(0, -1) + suffix; @@ -1134,9 +1122,9 @@ export class PromptsService extends Disposable implements IPromptsService { /** * Returns the profile-level disabled URIs for a given type (excludes workspace overrides). */ - public getDisabledPromptFilesForScope(type: PromptsType, scope: StorageScope, namespace?: string): ResourceSet { + public getDisabledPromptFilesForScope(type: PromptsType, scope: StorageScope): ResourceSet { const result = new ResourceSet(); - const suffix = namespace ? `.${namespace}.${type}` : `.${type}`; + const suffix = `.${type}`; const key = scope === StorageScope.WORKSPACE ? this.disabledPromptsWorkspaceStorageKeyPrefix.slice(0, -1) + suffix : this.disabledPromptsStorageKeyPrefix.slice(0, -1) + suffix; @@ -1243,14 +1231,14 @@ export class PromptsService extends Disposable implements IPromptsService { return this.cachedHooks.onDidChangePromise; } - public async findAgentSkills(token: CancellationToken): Promise { + public async findAgentSkills(token: CancellationToken, options?: { includeDisabled?: boolean }): Promise { const useAgentSkills = this.configurationService.getValue(PromptsConfig.USE_AGENT_SKILLS); if (!useAgentSkills) { return undefined; } const discoveryInfo = await this.cachedSkills.get(token); - const disabledSkills = this.getDisabledPromptFiles(PromptsType.skill); + const disabledSkills = options?.includeDisabled ? undefined : this.getDisabledPromptFiles(PromptsType.skill); const result = this.skillsFromDiscoveryInfo(discoveryInfo, disabledSkills); return result; } diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts index 9af971c48ed75..ac0f7823702de 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts @@ -30,10 +30,6 @@ suite('aiCustomizationDisablement', () => { const skillUri = URI.file('/workspace/.github/skills/my-skill/SKILL.md'); const instructionUri = URI.file('/workspace/.github/instructions/my-rule.instructions.md'); - function makeDisabledKey(type: PromptsType, namespace?: string): string { - return namespace ? `${type}:${namespace}` : type; - } - function createMockPromptsService(): IPromptsService { const disabledSets = new Map(); return { @@ -48,14 +44,14 @@ suite('aiCustomizationDisablement', () => { findAgentSkills: async () => [], getHooks: async () => undefined, getInstructionFiles: async () => [], - getDisabledPromptFiles: (type: PromptsType, namespace?: string): ResourceSet => { - return new ResourceSet([...(disabledSets.get(makeDisabledKey(type, namespace)) ?? [])]); + getDisabledPromptFiles: (type: PromptsType): ResourceSet => { + return new ResourceSet([...(disabledSets.get(type) ?? [])]); }, - getDisabledPromptFilesForScope: (type: PromptsType, _scope: StorageScope, namespace?: string): ResourceSet => { - return new ResourceSet([...(disabledSets.get(makeDisabledKey(type, namespace)) ?? [])]); + getDisabledPromptFilesForScope: (type: PromptsType, _scope: StorageScope): ResourceSet => { + return new ResourceSet([...(disabledSets.get(type) ?? [])]); }, - setDisabledPromptFiles: (type: PromptsType, uris: ResourceSet, _scope?: StorageScope, namespace?: string): void => { - disabledSets.set(makeDisabledKey(type, namespace), new ResourceSet([...uris])); + setDisabledPromptFiles: (type: PromptsType, uris: ResourceSet, _scope?: StorageScope): void => { + disabledSets.set(type, new ResourceSet([...uris])); }, } as unknown as IPromptsService; } @@ -106,7 +102,6 @@ suite('aiCustomizationDisablement', () => { const ps = opts.promptsService ?? createMockPromptsService(); const hasNative = opts.hasNativeItemProvider ?? !!opts.enablementHandler; return new ProviderCustomizationItemSource( - opts.harnessId, opts.itemProvider, undefined, ps, @@ -162,16 +157,14 @@ suite('aiCustomizationDisablement', () => { suite('disabled state overlay - API items', () => { - test('disabled via enablementHandler shows as disabled', async () => { - const eh = createMockEnablementHandler(); - eh.handleCustomizationEnablement(agentUri, PromptsType.agent, false, 'global'); - + test('provider item with enabled:false shows as disabled', async () => { const source = createItemSource({ harnessId: 'cli', itemProvider: createMockItemProvider([{ uri: agentUri, type: PromptsType.agent, name: 'Agent', enablementScope: 'workspace', + enabled: false, }]), - enablementHandler: eh, + enablementHandler: createMockEnablementHandler(), }); const result = await source.fetchItems(PromptsType.agent); @@ -191,11 +184,11 @@ suite('aiCustomizationDisablement', () => { assert.strictEqual(result[0].disabled, false); }); - test('NOT affected by promptsService disabled state', async () => { + test('NOT affected by promptsService disabled state for external harness', async () => { const ps = createMockPromptsService(); const disabled = new ResourceSet(); disabled.add(agentUri); - ps.setDisabledPromptFiles(PromptsType.agent, disabled, StorageScope.PROFILE, 'cli'); + ps.setDisabledPromptFiles(PromptsType.agent, disabled, StorageScope.PROFILE); const source = createItemSource({ harnessId: 'cli', @@ -206,39 +199,36 @@ suite('aiCustomizationDisablement', () => { promptsService: ps, }); + // External harness ignores promptsService disabled state — uses provider's enabled field const result = await source.fetchItems(PromptsType.agent); assert.strictEqual(result[0].disabled, false); }); }); - suite('disabled state overlay - VS Code items in external harness', () => { - - test('disabled via namespaced promptsService shows as disabled', async () => { - const ps = createMockPromptsService(); - const disabled = new ResourceSet(); - disabled.add(skillUri); - ps.setDisabledPromptFiles(PromptsType.skill, disabled, StorageScope.PROFILE, 'cli'); + suite('disabled state overlay - external harness provider items', () => { + test('provider item with enabled:false shows as disabled', async () => { const source = createItemSource({ harnessId: 'cli', itemProvider: createMockItemProvider([{ uri: skillUri, type: PromptsType.skill, name: 'Skill', storage: PromptsStorage.local, enablementScope: 'workspace', + enabled: false, }]), enablementHandler: createMockEnablementHandler(), - promptsService: ps, }); const result = await source.fetchItems(PromptsType.skill); assert.strictEqual(result[0].disabled, true); }); - test('not in namespaced promptsService disabled set shows as enabled', async () => { + test('provider item with enabled:true shows as enabled', async () => { const source = createItemSource({ harnessId: 'cli', itemProvider: createMockItemProvider([{ uri: skillUri, type: PromptsType.skill, name: 'Skill', storage: PromptsStorage.local, enablementScope: 'workspace', + enabled: true, }]), enablementHandler: createMockEnablementHandler(), }); @@ -247,17 +237,14 @@ suite('aiCustomizationDisablement', () => { assert.strictEqual(result[0].disabled, false); }); - test('NOT affected by enablementHandler disabled state', async () => { - const eh = createMockEnablementHandler(); - eh.handleCustomizationEnablement(skillUri, PromptsType.skill, false, 'global'); - + test('provider item without enabled field shows as enabled', async () => { const source = createItemSource({ harnessId: 'cli', itemProvider: createMockItemProvider([{ uri: skillUri, type: PromptsType.skill, name: 'Skill', storage: PromptsStorage.local, enablementScope: 'workspace', }]), - enablementHandler: eh, + enablementHandler: createMockEnablementHandler(), }); const result = await source.fetchItems(PromptsType.skill); @@ -265,9 +252,9 @@ suite('aiCustomizationDisablement', () => { }); }); - suite('VS Code harness (no namespace)', () => { + suite('VS Code harness', () => { - test('reads disabled state from promptsService without namespace', async () => { + test('reads disabled state from promptsService', async () => { const ps = createMockPromptsService(); const disabled = new ResourceSet(); disabled.add(agentUri); @@ -285,71 +272,43 @@ suite('aiCustomizationDisablement', () => { const result = await source.fetchItems(PromptsType.agent); assert.strictEqual(result[0].disabled, true); }); - - test('namespaced disabled state does NOT affect VS Code harness', async () => { - const ps = createMockPromptsService(); - const disabled = new ResourceSet(); - disabled.add(agentUri); - ps.setDisabledPromptFiles(PromptsType.agent, disabled, StorageScope.PROFILE, 'cli'); - - const source = createItemSource({ - harnessId: 'vscode', - itemProvider: createMockItemProvider([{ - uri: agentUri, type: PromptsType.agent, name: 'Agent', - storage: PromptsStorage.local, enablementScope: 'workspace', - }]), - promptsService: ps, - }); - - const result = await source.fetchItems(PromptsType.agent); - assert.strictEqual(result[0].disabled, false); - }); }); - suite('namespace isolation between harnesses', () => { - - test('disabling on one harness does not affect another', async () => { - const ps = createMockPromptsService(); - const disabled = new ResourceSet(); - disabled.add(instructionUri); - ps.setDisabledPromptFiles(PromptsType.instructions, disabled, StorageScope.PROFILE, 'cli'); + suite('harness isolation', () => { + test('external harness disablement via provider enabled:false does not affect VS Code harness', async () => { const items: ICustomizationItem[] = [{ uri: instructionUri, type: PromptsType.instructions, name: 'Rule', storage: PromptsStorage.local, enablementScope: 'workspace', }]; + // External harness reports item as disabled via enabled:false const cliSource = createItemSource({ harnessId: 'cli', - itemProvider: createMockItemProvider(items), + itemProvider: createMockItemProvider([{ ...items[0], enabled: false }]), enablementHandler: createMockEnablementHandler(), - promptsService: ps, }); assert.strictEqual((await cliSource.fetchItems(PromptsType.instructions))[0].disabled, true); - const claudeSource = createItemSource({ - harnessId: 'claude', + // VS Code harness is not affected — it reads from promptsService + const vscodeSource = createItemSource({ + harnessId: 'vscode', itemProvider: createMockItemProvider(items), - enablementHandler: createMockEnablementHandler(), - promptsService: ps, }); - assert.strictEqual((await claudeSource.fetchItems(PromptsType.instructions))[0].disabled, false); + assert.strictEqual((await vscodeSource.fetchItems(PromptsType.instructions))[0].disabled, false); }); }); suite('mixed API and VS Code items', () => { test('API disabled, VS Code enabled', async () => { - const eh = createMockEnablementHandler(); - eh.handleCustomizationEnablement(agentUri, PromptsType.agent, false, 'global'); - const source = createItemSource({ harnessId: 'cli', itemProvider: createMockItemProvider([ - { uri: agentUri, type: PromptsType.agent, name: 'API Agent', enablementScope: 'global' }, + { uri: agentUri, type: PromptsType.agent, name: 'API Agent', enablementScope: 'global', enabled: false }, { uri: skillUri, type: PromptsType.agent, name: 'VS Code Agent', storage: PromptsStorage.local, enablementScope: 'workspace' }, ]), - enablementHandler: eh, + enablementHandler: createMockEnablementHandler(), }); const result = await source.fetchItems(PromptsType.agent); @@ -362,20 +321,14 @@ suite('aiCustomizationDisablement', () => { ); }); - test('API enabled, VS Code disabled', async () => { - const ps = createMockPromptsService(); - const disabled = new ResourceSet(); - disabled.add(skillUri); - ps.setDisabledPromptFiles(PromptsType.agent, disabled, StorageScope.PROFILE, 'cli'); - + test('API enabled, VS Code disabled via provider', async () => { const source = createItemSource({ harnessId: 'cli', itemProvider: createMockItemProvider([ { uri: agentUri, type: PromptsType.agent, name: 'API Agent', enablementScope: 'global' }, - { uri: skillUri, type: PromptsType.agent, name: 'VS Code Agent', storage: PromptsStorage.local, enablementScope: 'workspace' }, + { uri: skillUri, type: PromptsType.agent, name: 'VS Code Agent', storage: PromptsStorage.local, enablementScope: 'workspace', enabled: false }, ]), enablementHandler: createMockEnablementHandler(), - promptsService: ps, }); const result = await source.fetchItems(PromptsType.agent); @@ -460,48 +413,6 @@ suite('aiCustomizationDisablement', () => { assert.strictEqual(keys.disableButtonVisible, false); }); - test('external harness: disabled API agent ghost entry has enablementScope: global', async () => { - const eh = createMockEnablementHandler(); - eh.handleCustomizationEnablement(agentUri, PromptsType.agent, false, 'global'); - - // Provider returns NO items (extension filtered out disabled agent) - const source = createItemSource({ - harnessId: 'cli', - itemProvider: createMockItemProvider([]), - enablementHandler: eh, - }); - - const result = await source.fetchItems(PromptsType.agent); - assert.strictEqual(result.length, 1, 'ghost entry should be created'); - assert.strictEqual(result[0].disabled, true); - assert.strictEqual(result[0].enablementScope, 'global'); - - const keys = computeContextKeys(result[0]); - assert.strictEqual(keys.enableButtonVisible, true, 'Enable button should be visible for ghost entry'); - }); - - test('external harness: disabled VS Code item ghost entry has enablementScope: workspace', async () => { - const ps = createMockPromptsService(); - const disabled = new ResourceSet(); - disabled.add(skillUri); - ps.setDisabledPromptFiles(PromptsType.skill, disabled, StorageScope.PROFILE, 'cli'); - - // Provider returns NO items - const source = createItemSource({ - harnessId: 'cli', - itemProvider: createMockItemProvider([]), - enablementHandler: createMockEnablementHandler(), - promptsService: ps, - }); - - const result = await source.fetchItems(PromptsType.skill); - assert.strictEqual(result.length, 1, 'ghost entry should be created'); - assert.strictEqual(result[0].disabled, true); - assert.strictEqual(result[0].enablementScope, 'workspace'); - - const keys = computeContextKeys(result[0]); - assert.strictEqual(keys.enableButtonVisible, true, 'Enable button should be visible for ghost entry'); - }); }); suite('provider item with pre-set enabled:false', () => { @@ -589,16 +500,14 @@ suite('aiCustomizationDisablement', () => { }); test('external harness: disabled API item shows Enable button', async () => { - const eh = createMockEnablementHandler(); - eh.handleCustomizationEnablement(agentUri, PromptsType.agent, false, 'global'); - const source = createItemSource({ harnessId: 'cli', itemProvider: createMockItemProvider([{ uri: agentUri, type: PromptsType.agent, name: 'CLI Agent', enablementScope: 'global', + enabled: false, }]), - enablementHandler: eh, + enablementHandler: createMockEnablementHandler(), }); const result = await source.fetchItems(PromptsType.agent); @@ -613,20 +522,15 @@ suite('aiCustomizationDisablement', () => { }); }); - test('external harness: disabled VS Code item shows Enable button', async () => { - const ps = createMockPromptsService(); - const disabled = new ResourceSet(); - disabled.add(skillUri); - ps.setDisabledPromptFiles(PromptsType.skill, disabled, StorageScope.PROFILE, 'cli'); - + test('external harness: disabled item via provider enabled:false shows Enable button', async () => { const source = createItemSource({ harnessId: 'cli', itemProvider: createMockItemProvider([{ uri: skillUri, type: PromptsType.skill, name: 'My Skill', storage: PromptsStorage.local, enablementScope: 'workspace', + enabled: false, }]), enablementHandler: createMockEnablementHandler(), - promptsService: ps, }); const result = await source.fetchItems(PromptsType.skill); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts index 19ff782da41dc..50256e50f973a 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts @@ -59,9 +59,9 @@ export class MockPromptsService implements IPromptsService { listNestedAgentMDs(token: CancellationToken): Promise { throw new Error('Not implemented'); } listAgentInstructions(token: CancellationToken): Promise { throw new Error('Not implemented'); } getAgentFileURIFromModeFile(oldURI: URI): URI | undefined { throw new Error('Not implemented'); } - getDisabledPromptFiles(type: PromptsType, namespace?: string): ResourceSet { throw new Error('Method not implemented.'); } - setDisabledPromptFiles(type: PromptsType, uris: ResourceSet, scope?: import('../../../../../../../platform/storage/common/storage.js').StorageScope, namespace?: string): void { throw new Error('Method not implemented.'); } - getDisabledPromptFilesForScope(type: PromptsType, scope: import('../../../../../../../platform/storage/common/storage.js').StorageScope, namespace?: string): ResourceSet { throw new Error('Method not implemented.'); } + getDisabledPromptFiles(type: PromptsType): ResourceSet { throw new Error('Method not implemented.'); } + setDisabledPromptFiles(type: PromptsType, uris: ResourceSet, scope?: import('../../../../../../../platform/storage/common/storage.js').StorageScope): void { throw new Error('Method not implemented.'); } + getDisabledPromptFilesForScope(type: PromptsType, scope: import('../../../../../../../platform/storage/common/storage.js').StorageScope): ResourceSet { throw new Error('Method not implemented.'); } registerPromptFileProvider(extension: IExtensionDescription, type: PromptsType, provider: { providePromptFiles: (context: IPromptFileContext, token: CancellationToken) => Promise }): IDisposable { throw new Error('Method not implemented.'); } findAgentSkills(token: CancellationToken): Promise { throw new Error('Method not implemented.'); } // eslint-disable-next-line @typescript-eslint/no-explicit-any From 82a04f976e960538fbd05ba9929cb94b908966c4 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Fri, 24 Apr 2026 08:15:46 -0700 Subject: [PATCH 29/36] clean --- .../copilotcli/node/copilotCli.ts | 2 +- .../claudeCustomizationProvider.ts | 222 ++++++++++++++---- 2 files changed, 178 insertions(+), 46 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts index cc3ebab7cdda8..4b9c1ca964d8f 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts @@ -377,7 +377,7 @@ export class CopilotCLIAgents extends Disposable implements ICopilotCLIAgents { }); } - return this._agentsPromise.then(infos => infos.map(i => ({ agent: this.cloneAgent(i.agent), sourceUri: i.sourceUri }))); + return this._agentsPromise.then(infos => infos.map(i => ({ agent: this.cloneAgent(i.agent), sourceUri: i.sourceUri, extensionId: i.extensionId }))); } async getAgentsImpl(): Promise { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts index 124b35e33c137..047108d1f7ccf 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts @@ -22,6 +22,24 @@ import { HOOK_EVENTS } from '@anthropic-ai/claude-agent-sdk'; // TODO: Consider reporting Claude slash commands (from Query.supportedCommands()) when appropriate // TODO: Report MCP servers when ChatSessionCustomizationType.Mcp is available (use Query.mcpServerStatus()) +/** + * Settings keys for tracking VS Code extension-contributed customizations + * that have been disabled in the Claude harness. + */ +const enum VSCodeDisabledSettingsKey { + Agents = 'vscodeDisabledAgents', + Instructions = 'vscodeDisabledInstructions', + Skills = 'vscodeDisabledSkills', +} + +/** + * Internal item type that extends the API item with a flag indicating + * whether the customization is owned by a VS Code extension. + */ +interface ClaudeCustomizationItem extends vscode.ChatSessionCustomizationItem { + readonly vscodeOwned?: boolean; +} + /** * Hard-coded CLAUDE.md instruction file names that Claude recognizes. * Per workspace folder: CLAUDE.md, CLAUDE.local.md, .claude/CLAUDE.md, .claude/CLAUDE.local.md @@ -43,6 +61,14 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange = this._onDidChange.event; + /** + * Tracks URIs of VS Code extension-contributed customizations seen + * during the last {@link provideChatSessionCustomizations} call. + * Used by {@link handleCustomizationEnablement} to route disablement + * to the correct settings key. + */ + private _vscodeOwnedUris = new Set(); + static get metadata(): vscode.ChatSessionCustomizationProviderMetadata { return { label: 'Claude', @@ -75,7 +101,9 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch } async provideChatSessionCustomizations(token: vscode.CancellationToken): Promise { - const items: vscode.ChatSessionCustomizationItem[] = []; + const items: ClaudeCustomizationItem[] = []; + const settingsFiles = await this.claudeSettingsService.readAllSettings(); + const allSettings = settingsFiles; // Agents: hybrid approach — file-based .claude/ agents merged with SDK-provided agents. // File-based agents are available immediately; SDK agents appear once a session starts. @@ -107,11 +135,28 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch } } + // Extension-contributed agents (owned by VS Code) — disableable from Claude harness + const vscodeDisabledAgents = this._getDisabledUriSet(allSettings, VSCodeDisabledSettingsKey.Agents); + for (const agent of await this.promptsService.getCustomAgents(token)) { + if (agent.extensionId && !sdkAgentNames.has(agent.name.toLowerCase())) { + const uriStr = agent.uri.toString(); + if (!items.some(i => i.uri.toString() === uriStr)) { + items.push({ + uri: agent.uri, + type: vscode.ChatSessionCustomizationType.Agent, + name: agent.name, + description: agent.description, + vscodeOwned: true, + enabled: !vscodeDisabledAgents.has(uriStr), + enablementScope: vscode.ChatSessionCustomizationEnablementScope.Global, + }); + } + } + } + const agentItems = items.filter(i => i.type === vscode.ChatSessionCustomizationType.Agent); this.logService.debug(`[ClaudeCustomizationProvider] agents (${agentItems.length}): ${agentItems.map(a => a.name).join(', ') || '(none)'}${sdkAgents.length ? ' [sdk]' : ' [files-only, no session]'}`); - const settingsFiles = await this.claudeSettingsService.readAllSettings(); - // Instructions from hard-coded CLAUDE.md paths (checked for existence) const instructionItems = await this.discoverInstructions(settingsFiles); items.push(...instructionItems); @@ -125,12 +170,12 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch Object.assign(skillOverrides, s.settings.skillOverrides); } } - const skillItems: vscode.ChatSessionCustomizationItem[] = []; + const skillItems: ClaudeCustomizationItem[] = []; for (const skill of await this.promptsService.getSkills(token)) { if (this.isClaudePath(skill.uri)) { const skillName = basename(dirname(skill.uri)); const override = skillOverrides[skillName]; - const item: vscode.ChatSessionCustomizationItem = { + const item: ClaudeCustomizationItem = { uri: skill.uri, type: vscode.ChatSessionCustomizationType.Skill, name: skill.name, @@ -138,6 +183,17 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch enablementScope: vscode.ChatSessionCustomizationEnablementScope.Workspace }; skillItems.push(item); + } else if (skill.extensionId) { + // Extension-contributed skills (owned by VS Code) + const vscodeDisabledSkills = this._getDisabledUriSet(allSettings, VSCodeDisabledSettingsKey.Skills); + skillItems.push({ + uri: skill.uri, + type: vscode.ChatSessionCustomizationType.Skill, + name: skill.name, + vscodeOwned: true, + enabled: !vscodeDisabledSkills.has(skill.uri.toString()), + enablementScope: vscode.ChatSessionCustomizationEnablementScope.Global, + }); } } items.push(...skillItems); @@ -149,11 +205,17 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch this.logService.debug(`[ClaudeCustomizationProvider] hooks (${hookItems.length}): ${hookItems.map(h => h.name).join(', ') || '(none)'}`); this.logService.debug(`[ClaudeCustomizationProvider] total: ${items.length} items`); + + // Rebuild the vscode-owned URI set from the freshly fetched items. + this._vscodeOwnedUris = new Set( + items.filter(i => i.vscodeOwned).map(i => i.uri.toString()), + ); + return items; } - private async discoverInstructions(settingsFiles: Readonly): Promise { - const items: vscode.ChatSessionCustomizationItem[] = []; + private async discoverInstructions(settingsFiles: Readonly): Promise { + const items: ClaudeCustomizationItem[] = []; const candidates: URI[] = []; for (const folder of this.workspaceService.getWorkspaceFolders()) { @@ -216,8 +278,8 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch } } - private async discoverHooks(settingsFiles: Readonly): Promise { - const items: vscode.ChatSessionCustomizationItem[] = []; + private async discoverHooks(settingsFiles: Readonly): Promise { + const items: ClaudeCustomizationItem[] = []; let disableAllHooks = false; for (const settingsFile of settingsFiles) { @@ -336,53 +398,68 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch }; if (type.id === vscode.ChatSessionCustomizationType.Skill.id) { - const skillName = basename(dirname(uri)); - const targetSettingsUri = !enabled ? this.claudeSettingsService.getUri(location, uri) : undefined; - - for (const file of allSettingsFiles) { - if (!file.settings.skillOverrides || typeof file.settings.skillOverrides !== 'object') { - continue; - } + if (this._vscodeOwnedUris.has(uri.toString())) { + // VS Code extension-contributed skill + await this._toggleVscodeDisabledUri(allSettingsFiles, VSCodeDisabledSettingsKey.Skills, uri.toString(), enabled, writeSettings); + } else { + // Claude-native skill — use skillOverrides + const skillName = basename(dirname(uri)); + const targetSettingsUri = !enabled ? this.claudeSettingsService.getUri(location, uri) : undefined; + + for (const file of allSettingsFiles) { + if (!file.settings.skillOverrides || typeof file.settings.skillOverrides !== 'object') { + continue; + } - const isTarget = targetSettingsUri?.toString() === file.uri.toString(); - const skillOverrides = { ...file.settings.skillOverrides ?? {} }; - let shouldUpdateSettings = skillName in skillOverrides; + const isTarget = targetSettingsUri?.toString() === file.uri.toString(); + const skillOverrides = { ...file.settings.skillOverrides ?? {} }; + let shouldUpdateSettings = skillName in skillOverrides; - delete skillOverrides[skillName]; - if (isTarget) { - skillOverrides[skillName] = 'off'; - shouldUpdateSettings = true; - } + delete skillOverrides[skillName]; + if (isTarget) { + skillOverrides[skillName] = 'off'; + shouldUpdateSettings = true; + } - if (shouldUpdateSettings) { - const updated = { ...file.settings, skillOverrides: Object.keys(skillOverrides).length > 0 ? skillOverrides : undefined }; - await writeSettings(file.uri, updated); + if (shouldUpdateSettings) { + const updated = { ...file.settings, skillOverrides: Object.keys(skillOverrides).length > 0 ? skillOverrides : undefined }; + await writeSettings(file.uri, updated); + } } } } else if (type.id === vscode.ChatSessionCustomizationType.Instructions.id) { - const instructionsUri = uri; - const targetSettingsUri = !enabled ? this.claudeSettingsService.getUri(location, uri) : undefined; - - for (const file of allSettingsFiles) { - if (!file.settings.claudeMdExcludes || !Array.isArray(file.settings.claudeMdExcludes)) { - continue; - } + if (this._vscodeOwnedUris.has(uri.toString())) { + // VS Code extension-contributed instruction + await this._toggleVscodeDisabledUri(allSettingsFiles, VSCodeDisabledSettingsKey.Instructions, uri.toString(), enabled, writeSettings); + } else { + // Claude-native instruction — use claudeMdExcludes + const instructionsUri = uri; + const targetSettingsUri = !enabled ? this.claudeSettingsService.getUri(location, uri) : undefined; + + for (const file of allSettingsFiles) { + if (!file.settings.claudeMdExcludes || !Array.isArray(file.settings.claudeMdExcludes)) { + continue; + } - const isTarget = targetSettingsUri?.toString() === file.uri.toString(); - const filtered = (file.settings.claudeMdExcludes ?? []).filter(p => p !== instructionsUri.path); - let shouldUpdateSettings = filtered.length !== (file.settings.claudeMdExcludes ?? []).length; + const isTarget = targetSettingsUri?.toString() === file.uri.toString(); + const filtered = (file.settings.claudeMdExcludes ?? []).filter(p => p !== instructionsUri.path); + let shouldUpdateSettings = filtered.length !== (file.settings.claudeMdExcludes ?? []).length; - const newExcludes = [...filtered]; - if (isTarget && !newExcludes.includes(instructionsUri.path)) { - newExcludes.push(instructionsUri.path); - shouldUpdateSettings = true; - } + const newExcludes = [...filtered]; + if (isTarget && !newExcludes.includes(instructionsUri.path)) { + newExcludes.push(instructionsUri.path); + shouldUpdateSettings = true; + } - if (shouldUpdateSettings) { - const updated = { ...file.settings, claudeMdExcludes: newExcludes.length > 0 ? newExcludes : undefined }; - await writeSettings(file.uri, updated); + if (shouldUpdateSettings) { + const updated = { ...file.settings, claudeMdExcludes: newExcludes.length > 0 ? newExcludes : undefined }; + await writeSettings(file.uri, updated); + } } } + } else if (type.id === vscode.ChatSessionCustomizationType.Agent.id && this._vscodeOwnedUris.has(uri.toString())) { + // VS Code extension-contributed agent + await this._toggleVscodeDisabledUri(allSettingsFiles, VSCodeDisabledSettingsKey.Agents, uri.toString(), enabled, writeSettings); } else if (type.id === vscode.ChatSessionCustomizationType.Hook.id) { // Hooks are toggled via the disableAllHooks flag in the settings file // that contains them. Toggling any hook toggles all hooks in that file. @@ -406,6 +483,61 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch this.logService.debug(`[ClaudeCustomizationProvider] ${enabled ? 'Enabled' : 'Disabled'} ${type.id} in ${location}`); this._onDidChange.fire(); } + + // --- VS Code extension-owned enablement helpers --- + + /** + * Reads a disabled URI set from all settings files for the given key. + */ + private _getDisabledUriSet(settingsFiles: Readonly, key: VSCodeDisabledSettingsKey): Set { + const result = new Set(); + for (const file of settingsFiles) { + const list = (file.settings as Record)[key]; + if (Array.isArray(list)) { + for (const item of list) { + if (typeof item === 'string') { + result.add(item); + } + } + } + } + return result; + } + + /** + * Toggles a URI string in a disabled list within a settings file. + */ + private async _toggleVscodeDisabledUri( + settingsFiles: Readonly, + key: VSCodeDisabledSettingsKey, + uriString: string, + enabled: boolean, + writeSettings: (uri: URI, settings: Parameters[1]) => Promise, + ): Promise { + if (enabled) { + // Remove from all settings files that contain it + for (const file of settingsFiles) { + const settings = file.settings as Record; + const list = settings[key]; + if (Array.isArray(list) && list.includes(uriString)) { + const filtered = list.filter(s => s !== uriString); + const updated = { ...file.settings, [key]: filtered.length > 0 ? filtered : undefined }; + await writeSettings(file.uri, updated); + } + } + } else { + // Add to the first (highest priority) settings file + const targetFile = settingsFiles[0]; + if (targetFile) { + const settings = targetFile.settings as Record; + const currentList = Array.isArray(settings[key]) ? (settings[key] as string[]).filter(s => typeof s === 'string') : []; + if (!currentList.includes(uriString)) { + const updated = { ...targetFile.settings, [key]: [...currentList, uriString] }; + await writeSettings(targetFile.uri, updated); + } + } + } + } } export function isEnabledForClaudeCode(customization: { sessionTypes?: readonly string[] }): boolean { From 4fb09ca57b3ad26614fe9da957047896e838dfa8 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Fri, 24 Apr 2026 08:34:44 -0700 Subject: [PATCH 30/36] fixes --- .../chatSessions/common/baseSessionSettingsService.ts | 4 +++- .../chatSessions/vscode-node/claudeCustomizationProvider.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/common/baseSessionSettingsService.ts b/extensions/copilot/src/extension/chatSessions/common/baseSessionSettingsService.ts index b84d0560541fb..71e67f9a460d8 100644 --- a/extensions/copilot/src/extension/chatSessions/common/baseSessionSettingsService.ts +++ b/extensions/copilot/src/extension/chatSessions/common/baseSessionSettingsService.ts @@ -130,7 +130,9 @@ export abstract class SessionSettingsService { const content = new TextEncoder().encode(JSON.stringify(settings, null, 4)); await this.fileSystemService.writeFile(uri, content); - // Cache will be invalidated by the file watcher + // Eagerly invalidate so that subsequent reads (before the file + // watcher fires) return fresh data. + this._settingsCache = undefined; } /** diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts index 047108d1f7ccf..b95f2c09e28d5 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts @@ -407,7 +407,9 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch const targetSettingsUri = !enabled ? this.claudeSettingsService.getUri(location, uri) : undefined; for (const file of allSettingsFiles) { - if (!file.settings.skillOverrides || typeof file.settings.skillOverrides !== 'object') { + if (file.settings.skillOverrides && typeof file.settings.skillOverrides !== 'object') { + // skip malformed skillOverrides + this.logService.warn(`[ClaudeCustomizationProvider] Skipping malformed skillOverrides in ${file.uri.toString()}`); continue; } From 71e762cbeb446c5941ea2f4ea87e5a751337952a Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Fri, 24 Apr 2026 09:57:51 -0700 Subject: [PATCH 31/36] PR --- .../claudeCustomizationProvider.ts | 19 ------------ .../aiCustomizationItemSource.ts | 30 +++++++++++++++++++ .../service/promptsServiceImpl.ts | 9 ++++++ 3 files changed, 39 insertions(+), 19 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts index b95f2c09e28d5..68a6ca7a4f8d7 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts @@ -135,25 +135,6 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch } } - // Extension-contributed agents (owned by VS Code) — disableable from Claude harness - const vscodeDisabledAgents = this._getDisabledUriSet(allSettings, VSCodeDisabledSettingsKey.Agents); - for (const agent of await this.promptsService.getCustomAgents(token)) { - if (agent.extensionId && !sdkAgentNames.has(agent.name.toLowerCase())) { - const uriStr = agent.uri.toString(); - if (!items.some(i => i.uri.toString() === uriStr)) { - items.push({ - uri: agent.uri, - type: vscode.ChatSessionCustomizationType.Agent, - name: agent.name, - description: agent.description, - vscodeOwned: true, - enabled: !vscodeDisabledAgents.has(uriStr), - enablementScope: vscode.ChatSessionCustomizationEnablementScope.Global, - }); - } - } - } - const agentItems = items.filter(i => i.type === vscode.ChatSessionCustomizationType.Agent); this.logService.debug(`[ClaudeCustomizationProvider] agents (${agentItems.length}): ${agentItems.map(a => a.name).join(', ') || '(none)'}${sdkAgents.length ? ' [sdk]' : ' [files-only, no session]'}`); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index a73deb14fbbd9..5e397526bb4bc 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -468,6 +468,36 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour } const normalized = this.itemNormalizer.normalizeItems(providerItems, promptType); + + // Overlay disabled state for VS Code harness items. + // External harnesses report disabled state via the provider's `enabled` field. + if (!this.hasNativeItemProvider) { + const disabledUris = this.promptsService.getDisabledPromptFiles(promptType); + if (disabledUris.size > 0) { + const existingUris = new ResourceSet(normalized.map(i => i.uri)); + for (let i = 0; i < normalized.length; i++) { + if (!normalized[i].disabled && disabledUris.has(normalized[i].uri)) { + normalized[i] = { ...normalized[i], disabled: true }; + } + } + // Ghost entries for disabled items not in the provider's results + for (const disabledUri of disabledUris) { + if (!existingUris.has(disabledUri)) { + const name = basename(disabledUri); + normalized.push({ + id: disabledUri.toString(), + uri: disabledUri, + name, + filename: name, + promptType, + disabled: true, + enablementScope: 'workspace', + }); + } + } + } + } + if (promptType === PromptsType.skill) { return this.mergeBuiltinSkills(normalized, promptType); } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 1ee2f8aa8561e..92ce71c41b19a 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -780,6 +780,7 @@ export class PromptsService extends Disposable implements IPromptsService { const allAgentFiles = await this.listPromptFiles(PromptsType.agent, token); const useChatHooks = this.configurationService.getValue(PromptsConfig.USE_CHAT_HOOKS); const isWorkspaceTrusted = this.workspaceTrustService.isWorkspaceTrusted(); + const disabledAgents = this.getDisabledPromptFiles(PromptsType.agent); // Get user home for tilde expansion in hook cwd paths const userHomeUri = await this.pathService.userHome(); @@ -810,6 +811,14 @@ export class PromptsService extends Disposable implements IPromptsService { source: IAgentSource.fromPromptPath(promptPath) }; const agent = CustomAgent.fromParsedPromptFile(ast, extra); + + // Disabled agents are fully parsed but marked as skipped so + // agentsFromDiscoveryInfo can filter them out (or include + // them when includeDisabled is set). + if (disabledAgents.has(uri)) { + return { status: 'skipped', skipReason: 'disabled', promptPath: this.withPromptPathMetadata(promptPath, agent.name, agent.description), agent }; + } + return { status: 'loaded', promptPath: this.withPromptPathMetadata(promptPath, agent.name, agent.description), agent }; } catch (e) { const error = e instanceof Error ? e : new Error(String(e)); From 0a322fb51b2fa0de24ccd0b72e34a5f5bd274f38 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Fri, 24 Apr 2026 12:01:02 -0700 Subject: [PATCH 32/36] don't write to json --- .../common/extensionDisablementStore.ts | 92 ++++++++++++++++ .../copilotCLICustomizationProvider.ts | 98 +++++++---------- .../copilotCLICustomizationProvider.spec.ts | 29 ++++- .../claudeCustomizationProvider.ts | 104 ++++-------------- .../test/claudeCustomizationProvider.spec.ts | 20 ++++ 5 files changed, 198 insertions(+), 145 deletions(-) create mode 100644 extensions/copilot/src/extension/chatSessions/common/extensionDisablementStore.ts diff --git a/extensions/copilot/src/extension/chatSessions/common/extensionDisablementStore.ts b/extensions/copilot/src/extension/chatSessions/common/extensionDisablementStore.ts new file mode 100644 index 0000000000000..6ffb142ffea41 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/common/extensionDisablementStore.ts @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as vscode from 'vscode'; +import { URI } from '../../../util/vs/base/common/uri'; + +/** + * Stores disabled customization URIs for a harness using VS Code's + * extension context Memento storage (`globalState` / `workspaceState`). + * + * This mirrors the core `promptsServiceImpl` disablement storage but + * lives in the extension host, giving each external harness (Copilot CLI, + * Claude) its own independent disabled set. + * + * Storage keys follow the pattern: `.disabled.` for + * workspace scope and `.disabled.global.` for profile scope. + */ +export class ExtensionDisablementStore { + + constructor( + private readonly prefix: string, + private readonly globalState: vscode.Memento, + private readonly workspaceState: vscode.Memento, + ) { } + + /** + * Returns true if the given URI is disabled for the given type + * (checking both workspace and global scopes). + */ + isDisabled(uri: URI, type: string): boolean { + return this.getDisabledUris(type).has(uri.toString()); + } + + /** + * Returns all disabled URIs for the given type, merging workspace + * and global scopes. + */ + getDisabledUris(type: string): Set { + const result = new Set(); + for (const uriStr of this._readList(this._workspaceKey(type), this.workspaceState)) { + result.add(uriStr); + } + for (const uriStr of this._readList(this._globalKey(type), this.globalState)) { + result.add(uriStr); + } + return result; + } + + /** + * Enables or disables a URI for the given type and scope. + * When enabling, the URI is removed from both scopes. + */ + async setDisabled(uri: URI, type: string, disabled: boolean, scope: 'global' | 'workspace'): Promise { + const uriStr = uri.toString(); + if (disabled) { + const key = scope === 'workspace' ? this._workspaceKey(type) : this._globalKey(type); + const memento = scope === 'workspace' ? this.workspaceState : this.globalState; + const list = this._readList(key, memento); + if (!list.includes(uriStr)) { + await memento.update(key, [...list, uriStr]); + } + } else { + // Remove from both scopes when enabling + await this._removeFromScope(uriStr, type, this.workspaceState, this._workspaceKey(type)); + await this._removeFromScope(uriStr, type, this.globalState, this._globalKey(type)); + } + } + + private _readList(key: string, memento: vscode.Memento): string[] { + const value = memento.get(key); + return Array.isArray(value) ? value.filter(s => typeof s === 'string') : []; + } + + private async _removeFromScope(uriStr: string, _type: string, memento: vscode.Memento, key: string): Promise { + const list = this._readList(key, memento); + const index = list.indexOf(uriStr); + if (index >= 0) { + const filtered = list.filter(s => s !== uriStr); + await memento.update(key, filtered.length > 0 ? filtered : undefined); + } + } + + private _workspaceKey(type: string): string { + return `${this.prefix}.disabled.${type}`; + } + + private _globalKey(type: string): string { + return `${this.prefix}.disabled.global.${type}`; + } +} diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts index 7d4cf8ff51561..dff06af1c676f 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts @@ -18,16 +18,8 @@ import { basename, dirname } from '../../../../util/vs/base/common/resources'; import { URI } from '../../../../util/vs/base/common/uri'; import { ICopilotCLIAgents, isEnabledForCopilotCLI } from '../../copilotcli/node/copilotCli'; import { CopilotCLISettingsLocationType, ICopilotCLISettingsService } from '../common/copilotCLISettingsService'; - -/** - * Settings keys for tracking VS Code extension-contributed customizations - * that have been disabled in the CLI harness. - */ -const enum VSCodeDisabledSettingsKey { - Agents = 'vscodeDisabledAgents', - Instructions = 'vscodeDisabledInstructions', - Skills = 'vscodeDisabledSkills', -} +import { ExtensionDisablementStore } from '../../common/extensionDisablementStore'; +import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext'; /** * Internal item type that extends the API item with a flag indicating @@ -42,13 +34,8 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange = this._onDidChange.event; - /** - * Tracks URIs of VS Code extension-contributed customizations seen - * during the last {@link provideChatSessionCustomizations} call. - * Used by {@link handleCustomizationEnablement} to route disablement - * to the correct settings key. - */ - private _vscodeOwnedUris = new Set(); + private readonly _disablementStore: ExtensionDisablementStore; + private _lastVscodeOwnedUris = new Set(); static get metadata(): vscode.ChatSessionCustomizationProviderMetadata { return { @@ -72,9 +59,12 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod @IWorkspaceService private readonly workspaceService: IWorkspaceService, @IFileSystemService private readonly fileSystemService: IFileSystemService, @ICopilotCLISettingsService private readonly copilotCLISettingsService: ICopilotCLISettingsService, + @IVSCodeExtensionContext extensionContext: IVSCodeExtensionContext, ) { super(); + this._disablementStore = new ExtensionDisablementStore('copilotcli', extensionContext.globalState, extensionContext.workspaceState); + this._register(this.promptsService.onDidChangeCustomAgents(() => this._onDidChange.fire())); this._register(this.promptsService.onDidChangeInstructions(() => this._onDidChange.fire())); this._register(this.promptsService.onDidChangeSkills(() => this._onDidChange.fire())); @@ -108,12 +98,12 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod const allItems = [...agents, ...instructions, ...skills, ...hooks, ...plugins]; - // Rebuild the vscode-owned URI set from the freshly fetched items. - this._vscodeOwnedUris = new Set( + // Track vscode-owned URIs for routing enablement handlers + this._lastVscodeOwnedUris = new Set( allItems.filter(i => i.vscodeOwned).map(i => i.uri.toString()), ); - this.logService.debug(`[CopilotCLICustomizationProvider] total: ${allItems.length} items (${this._vscodeOwnedUris.size} vscode-owned)`); + this.logService.debug(`[CopilotCLICustomizationProvider] total: ${allItems.length} items`); return allItems; } @@ -123,8 +113,6 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod */ private async getAgentItems(_token: vscode.CancellationToken): Promise { const agentInfos = await this.copilotCLIAgents.getAgents(); - const settings = await this._readUserSettings(); - const disabledSet = this._getDisabledUriSet(settings, VSCodeDisabledSettingsKey.Agents); return agentInfos.map(({ agent, sourceUri, extensionId }) => ({ uri: sourceUri, type: vscode.ChatSessionCustomizationType.Agent, @@ -133,7 +121,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod vscodeOwned: !!extensionId, // Only extension-contributed agents (owned by VS Code) support CLI-side disablement ...(extensionId ? { - enabled: !disabledSet.has(sourceUri.toString()), + enabled: !this._disablementStore.isDisabled(sourceUri, 'agent'), enablementScope: vscode.ChatSessionCustomizationEnablementScope.Global, } : { enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, @@ -168,8 +156,6 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod const items: CLICustomizationItem[] = []; const seenUris = new Set(); - const settings = await this._readUserSettings(); - const disabledSet = this._getDisabledUriSet(settings, VSCodeDisabledSettingsKey.Instructions); // Emit agent instruction files (AGENTS.md, CLAUDE.md, copilot-instructions.md) // that come from customInstructionsService but may not appear in @@ -201,7 +187,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod // Only extension-contributed instructions support CLI-side disablement const hasEnablement = !!instruction.extensionId; const enablementProps = hasEnablement ? { - enabled: !disabledSet.has(uri.toString()), + enabled: !this._disablementStore.isDisabled(uri, 'instructions'), enablementScope: vscode.ChatSessionCustomizationEnablementScope.Global, } : { enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, @@ -245,13 +231,8 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod * Collects all skill items from the prompt file service. */ private async getSkillItems(token: vscode.CancellationToken): Promise { - const settings = await this._readUserSettings(); - const disabledSkills = Array.isArray(settings.disabledSkills) ? settings.disabledSkills.filter(s => typeof s === 'string') : []; - const disabledSkillsSet = new Set(disabledSkills); - const vscodeDisabledSkills = this._getDisabledUriSet(settings, VSCodeDisabledSettingsKey.Skills); return (await this.promptsService.getSkills(token)).filter(isEnabledForCopilotCLI).map(s => { const name = s.name; - const skillName = basename(dirname(s.uri)); // Only extension-contributed skills support CLI-side disablement if (s.extensionId) { return { @@ -259,7 +240,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod type: vscode.ChatSessionCustomizationType.Skill, name, vscodeOwned: true, - enabled: !vscodeDisabledSkills.has(s.uri.toString()), + enabled: !this._disablementStore.isDisabled(s.uri, 'skill'), enablementScope: vscode.ChatSessionCustomizationEnablementScope.Global, extensionId: s.extensionId, pluginUri: s.pluginUri, @@ -269,8 +250,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod uri: s.uri, type: vscode.ChatSessionCustomizationType.Skill, name, - enabled: !disabledSkillsSet.has(skillName), - enablementScope: vscode.ChatSessionCustomizationEnablementScope.Global, + enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, extensionId: s.extensionId, pluginUri: s.pluginUri, }; @@ -330,10 +310,12 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod let name: string; if (type.id === vscode.ChatSessionCustomizationType.Skill.id) { - if (this._vscodeOwnedUris.has(uri.toString())) { - // VS Code extension-contributed skill — use vscode disabled key + if (this._isVscodeOwned(uri, type)) { + // VS Code extension-contributed skill name = basename(dirname(uri)); - this._toggleDisabledUri(settings, VSCodeDisabledSettingsKey.Skills, uri.toString(), enabled); + await this._disablementStore.setDisabled(URI.from(uri), 'skill', !enabled, 'global'); + this._onDidChange.fire(); + return; } else { // Filesystem-discovered skill — use disabledSkills folder-name list name = basename(dirname(uri)); @@ -356,12 +338,16 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod map[name] = false; } settings.enabledPlugins = Object.keys(map).length > 0 ? map : undefined; - } else if (type.id === vscode.ChatSessionCustomizationType.Instructions.id && this._vscodeOwnedUris.has(uri.toString())) { + } else if (type.id === vscode.ChatSessionCustomizationType.Instructions.id && this._isVscodeOwned(uri, type)) { name = basename(uri); - this._toggleDisabledUri(settings, VSCodeDisabledSettingsKey.Instructions, uri.toString(), enabled); - } else if (type.id === vscode.ChatSessionCustomizationType.Agent.id && this._vscodeOwnedUris.has(uri.toString())) { + await this._disablementStore.setDisabled(URI.from(uri), 'instructions', !enabled, 'global'); + this._onDidChange.fire(); + return; + } else if (type.id === vscode.ChatSessionCustomizationType.Agent.id && this._isVscodeOwned(uri, type)) { name = basename(uri); - this._toggleDisabledUri(settings, VSCodeDisabledSettingsKey.Agents, uri.toString(), enabled); + await this._disablementStore.setDisabled(URI.from(uri), 'agent', !enabled, 'global'); + this._onDidChange.fire(); + return; } else { this.logService.warn(`[CopilotCLICustomizationProvider] Per-item enablement not supported for type: ${type.id}`); void vscode.window.showErrorMessage(vscode.l10n.t('Toggling {0} customizations is not supported.', type.id)); @@ -378,26 +364,16 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod } /** - * Toggles a URI string in a disabled list within the settings object. - */ - private _toggleDisabledUri(settings: Record, key: VSCodeDisabledSettingsKey, uriString: string, enabled: boolean): void { - const currentList = Array.isArray(settings[key]) ? (settings[key] as string[]).filter(s => typeof s === 'string') : []; - if (enabled) { - settings[key] = currentList.filter(s => s !== uriString); - } else if (!currentList.includes(uriString)) { - settings[key] = [...currentList, uriString]; - } - // Clean up empty arrays - if (Array.isArray(settings[key]) && (settings[key] as string[]).length === 0) { - delete settings[key]; - } - } - - /** - * Reads a disabled URI set from the settings object for the given key. + * Checks whether a customization item is owned by a VS Code extension + * by looking for its URI in the last fetched items with `extensionId`. */ - private _getDisabledUriSet(settings: Record, key: VSCodeDisabledSettingsKey): Set { - const list = Array.isArray(settings[key]) ? (settings[key] as string[]).filter(s => typeof s === 'string') : []; - return new Set(list); + private _isVscodeOwned(uri: vscode.Uri, _type: vscode.ChatSessionCustomizationType): boolean { + // Items with extensionId are tracked during provideChatSessionCustomizations + // via the vscodeOwned flag. Since the disablement store is keyed by URI, + // any URI in the store is vscode-owned by definition. + return this._disablementStore.isDisabled(URI.from(uri), 'agent') + || this._disablementStore.isDisabled(URI.from(uri), 'skill') + || this._disablementStore.isDisabled(URI.from(uri), 'instructions') + || this._lastVscodeOwnedUris.has(uri.toString()); } } diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLICustomizationProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLICustomizationProvider.spec.ts index 729f9c7b4edf9..cffe83cb8299c 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLICustomizationProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLICustomizationProvider.spec.ts @@ -8,6 +8,25 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import * as vscode from 'vscode'; import { ILogService } from '../../../../../platform/log/common/logService'; import { MockCustomInstructionsService } from '../../../../../platform/test/common/testCustomInstructionsService'; + +class MockMemento implements vscode.Memento { + private readonly _store = new Map(); + keys(): readonly string[] { return [...this._store.keys()]; } + get(key: string): T | undefined; + get(key: string, defaultValue: T): T; + get(key: string, defaultValue?: T): T | undefined { + const v = this._store.get(key); + return v !== undefined ? v as T : defaultValue; + } + update(key: string, value: unknown): Thenable { + if (value === undefined) { + this._store.delete(key); + } else { + this._store.set(key, value); + } + return Promise.resolve(); + } +} import { mock } from '../../../../../util/common/test/simpleMock'; import { Emitter } from '../../../../../util/vs/base/common/event'; import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle'; @@ -202,6 +221,8 @@ describe('CopilotCLICustomizationProvider', () => { let mockCustomInstructionsService: TestCustomInstructionsService; let mockFileSystemService: MockFileSystemService; let mockCopilotCLISettingsService: MockCopilotCLISettingsService; + let mockGlobalState: MockMemento; + let mockWorkspaceState: MockMemento; let provider: CopilotCLICustomizationProvider; const userHome = URI.file('/home/testuser'); @@ -220,6 +241,8 @@ describe('CopilotCLICustomizationProvider', () => { mockCustomInstructionsService = new TestCustomInstructionsService(); mockFileSystemService = new MockFileSystemService(); mockCopilotCLISettingsService = disposables.add(new MockCopilotCLISettingsService(userHome)); + mockGlobalState = new MockMemento(); + mockWorkspaceState = new MockMemento(); provider = disposables.add(new CopilotCLICustomizationProvider( mockCopilotCLIAgents, mockCustomInstructionsService, @@ -228,6 +251,7 @@ describe('CopilotCLICustomizationProvider', () => { { getWorkspaceFolders: () => [] } as any, mockFileSystemService, mockCopilotCLISettingsService, + { globalState: mockGlobalState, workspaceState: mockWorkspaceState } as any, )); }); @@ -451,6 +475,7 @@ describe('CopilotCLICustomizationProvider', () => { : Promise.reject(new Error('not found')), } as any, mockCopilotCLISettingsService, + { globalState: new MockMemento(), workspaceState: new MockMemento() } as any, )); mockPromptsService.setInstructions([]); @@ -618,9 +643,9 @@ describe('CopilotCLICustomizationProvider', () => { expect(skillItems[0].enabled).toBe(true); }); - it('marks extension-contributed skill as disabled when in disabledSkills setting', async () => { - mockCopilotCLISettingsService.setSettings({ disabledSkills: ['lint-check'] }); + it('marks extension-contributed skill as disabled when disabled in store', async () => { const uri = URI.file('/workspace/.github/skills/lint-check/SKILL.md'); + await mockGlobalState.update('copilotcli.disabled.global.skill', [uri.toString()]); mockPromptsService.setSkills([makeSkill(uri, 'lint-check', 'my-ext.lint')]); const items = await provider.provideChatSessionCustomizations(undefined!); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts index 68a6ca7a4f8d7..2ea831df0972a 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts @@ -17,21 +17,13 @@ import { IClaudeRuntimeDataService } from '../claude/common/claudeRuntimeDataSer import { ClaudeSettingsFile, ClaudeSettingsLocationType, IClaudeSettingsService } from '../claude/common/claudeSettingsService'; import { ClaudeSessionUri } from '../claude/common/claudeSessionUri'; import { IPromptsService } from '../../../platform/promptFiles/common/promptsService'; +import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; import { HOOK_EVENTS } from '@anthropic-ai/claude-agent-sdk'; +import { ExtensionDisablementStore } from '../common/extensionDisablementStore'; // TODO: Consider reporting Claude slash commands (from Query.supportedCommands()) when appropriate // TODO: Report MCP servers when ChatSessionCustomizationType.Mcp is available (use Query.mcpServerStatus()) -/** - * Settings keys for tracking VS Code extension-contributed customizations - * that have been disabled in the Claude harness. - */ -const enum VSCodeDisabledSettingsKey { - Agents = 'vscodeDisabledAgents', - Instructions = 'vscodeDisabledInstructions', - Skills = 'vscodeDisabledSkills', -} - /** * Internal item type that extends the API item with a flag indicating * whether the customization is owned by a VS Code extension. @@ -61,13 +53,8 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange = this._onDidChange.event; - /** - * Tracks URIs of VS Code extension-contributed customizations seen - * during the last {@link provideChatSessionCustomizations} call. - * Used by {@link handleCustomizationEnablement} to route disablement - * to the correct settings key. - */ - private _vscodeOwnedUris = new Set(); + private readonly _disablementStore: ExtensionDisablementStore; + private _lastVscodeOwnedUris = new Set(); static get metadata(): vscode.ChatSessionCustomizationProviderMetadata { return { @@ -90,9 +77,12 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch @IFileSystemService private readonly fileSystemService: IFileSystemService, @INativeEnvService private readonly envService: INativeEnvService, @ILogService private readonly logService: ILogService, + @IVSCodeExtensionContext extensionContext: IVSCodeExtensionContext, ) { super(); + this._disablementStore = new ExtensionDisablementStore('claude', extensionContext.globalState, extensionContext.workspaceState); + this._register(this.runtimeDataService.onDidChange(() => this._onDidChange.fire())); this._register(this.claudeSettingsService.onDidChange(() => this._onDidChange.fire())); this._register(this.promptsService.onDidChangeCustomAgents(() => this._onDidChange.fire())); @@ -103,7 +93,6 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch async provideChatSessionCustomizations(token: vscode.CancellationToken): Promise { const items: ClaudeCustomizationItem[] = []; const settingsFiles = await this.claudeSettingsService.readAllSettings(); - const allSettings = settingsFiles; // Agents: hybrid approach — file-based .claude/ agents merged with SDK-provided agents. // File-based agents are available immediately; SDK agents appear once a session starts. @@ -166,13 +155,12 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch skillItems.push(item); } else if (skill.extensionId) { // Extension-contributed skills (owned by VS Code) - const vscodeDisabledSkills = this._getDisabledUriSet(allSettings, VSCodeDisabledSettingsKey.Skills); skillItems.push({ uri: skill.uri, type: vscode.ChatSessionCustomizationType.Skill, name: skill.name, vscodeOwned: true, - enabled: !vscodeDisabledSkills.has(skill.uri.toString()), + enabled: !this._disablementStore.isDisabled(skill.uri, 'skill'), enablementScope: vscode.ChatSessionCustomizationEnablementScope.Global, }); } @@ -187,8 +175,8 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch this.logService.debug(`[ClaudeCustomizationProvider] total: ${items.length} items`); - // Rebuild the vscode-owned URI set from the freshly fetched items. - this._vscodeOwnedUris = new Set( + // Track vscode-owned URIs for routing enablement handlers + this._lastVscodeOwnedUris = new Set( items.filter(i => i.vscodeOwned).map(i => i.uri.toString()), ); @@ -379,9 +367,11 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch }; if (type.id === vscode.ChatSessionCustomizationType.Skill.id) { - if (this._vscodeOwnedUris.has(uri.toString())) { + if (this._lastVscodeOwnedUris.has(uri.toString())) { // VS Code extension-contributed skill - await this._toggleVscodeDisabledUri(allSettingsFiles, VSCodeDisabledSettingsKey.Skills, uri.toString(), enabled, writeSettings); + await this._disablementStore.setDisabled(URI.from(uri), 'skill', !enabled, 'global'); + this._onDidChange.fire(); + return; } else { // Claude-native skill — use skillOverrides const skillName = basename(dirname(uri)); @@ -411,9 +401,11 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch } } } else if (type.id === vscode.ChatSessionCustomizationType.Instructions.id) { - if (this._vscodeOwnedUris.has(uri.toString())) { + if (this._lastVscodeOwnedUris.has(uri.toString())) { // VS Code extension-contributed instruction - await this._toggleVscodeDisabledUri(allSettingsFiles, VSCodeDisabledSettingsKey.Instructions, uri.toString(), enabled, writeSettings); + await this._disablementStore.setDisabled(URI.from(uri), 'instructions', !enabled, 'global'); + this._onDidChange.fire(); + return; } else { // Claude-native instruction — use claudeMdExcludes const instructionsUri = uri; @@ -440,9 +432,11 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch } } } - } else if (type.id === vscode.ChatSessionCustomizationType.Agent.id && this._vscodeOwnedUris.has(uri.toString())) { + } else if (type.id === vscode.ChatSessionCustomizationType.Agent.id && this._lastVscodeOwnedUris.has(uri.toString())) { // VS Code extension-contributed agent - await this._toggleVscodeDisabledUri(allSettingsFiles, VSCodeDisabledSettingsKey.Agents, uri.toString(), enabled, writeSettings); + await this._disablementStore.setDisabled(URI.from(uri), 'agent', !enabled, 'global'); + this._onDidChange.fire(); + return; } else if (type.id === vscode.ChatSessionCustomizationType.Hook.id) { // Hooks are toggled via the disableAllHooks flag in the settings file // that contains them. Toggling any hook toggles all hooks in that file. @@ -467,60 +461,6 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch this._onDidChange.fire(); } - // --- VS Code extension-owned enablement helpers --- - - /** - * Reads a disabled URI set from all settings files for the given key. - */ - private _getDisabledUriSet(settingsFiles: Readonly, key: VSCodeDisabledSettingsKey): Set { - const result = new Set(); - for (const file of settingsFiles) { - const list = (file.settings as Record)[key]; - if (Array.isArray(list)) { - for (const item of list) { - if (typeof item === 'string') { - result.add(item); - } - } - } - } - return result; - } - - /** - * Toggles a URI string in a disabled list within a settings file. - */ - private async _toggleVscodeDisabledUri( - settingsFiles: Readonly, - key: VSCodeDisabledSettingsKey, - uriString: string, - enabled: boolean, - writeSettings: (uri: URI, settings: Parameters[1]) => Promise, - ): Promise { - if (enabled) { - // Remove from all settings files that contain it - for (const file of settingsFiles) { - const settings = file.settings as Record; - const list = settings[key]; - if (Array.isArray(list) && list.includes(uriString)) { - const filtered = list.filter(s => s !== uriString); - const updated = { ...file.settings, [key]: filtered.length > 0 ? filtered : undefined }; - await writeSettings(file.uri, updated); - } - } - } else { - // Add to the first (highest priority) settings file - const targetFile = settingsFiles[0]; - if (targetFile) { - const settings = targetFile.settings as Record; - const currentList = Array.isArray(settings[key]) ? (settings[key] as string[]).filter(s => typeof s === 'string') : []; - if (!currentList.includes(uriString)) { - const updated = { ...targetFile.settings, [key]: [...currentList, uriString] }; - await writeSettings(targetFile.uri, updated); - } - } - } - } } export function isEnabledForClaudeCode(customization: { sessionTypes?: readonly string[] }): boolean { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts index 8913df9fb392f..e4d33b982a624 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts @@ -19,6 +19,25 @@ import { ClaudeSettingsFile, ClaudeSettingsLocationType, IClaudeSettingsService import { ClaudeCustomizationProvider } from '../claudeCustomizationProvider'; import { MockPromptsService } from '../../../../platform/promptFiles/test/common/mockPromptsService'; +class MockMemento implements vscode.Memento { + private readonly _store = new Map(); + keys(): readonly string[] { return [...this._store.keys()]; } + get(key: string): T | undefined; + get(key: string, defaultValue: T): T; + get(key: string, defaultValue?: T): T | undefined { + const v = this._store.get(key); + return v !== undefined ? v as T : defaultValue; + } + update(key: string, value: unknown): Thenable { + if (value === undefined) { + this._store.delete(key); + } else { + this._store.set(key, value); + } + return Promise.resolve(); + } +} + function mockAgent(uri: URI, name: string): vscode.ChatCustomAgent { return { uri, name, source: 'local', userInvocable: true, disableModelInvocation: false } as vscode.ChatCustomAgent; } @@ -189,6 +208,7 @@ describe('ClaudeCustomizationProvider', () => { mockFileSystemService, new MockEnvService(), new TestLogService(), + { globalState: new MockMemento(), workspaceState: new MockMemento() } as any, )); }); From 1ab30b16c241bd0f5f0d449a1155c6ceaf16fbc1 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Fri, 24 Apr 2026 12:43:21 -0700 Subject: [PATCH 33/36] fixes --- .../node/test/claudeSettingsService.spec.ts | 3 ++- .../vscode-node/claudeCustomizationProvider.ts | 9 +++++++-- .../filesystem/node/test/mockFileSystemService.ts | 13 ++++++++++++- .../aiCustomization/aiCustomizationListWidget.ts | 15 +++++++++++++-- 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeSettingsService.spec.ts b/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeSettingsService.spec.ts index 818a1a88b2f27..a97cacea2e774 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeSettingsService.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeSettingsService.spec.ts @@ -323,7 +323,8 @@ describe('ClaudeSettingsService', () => { const second = await svc.readAllSettings(); expect(first).not.toBe(second); - expect(second[0].settings).toEqual({ updated: true }); + const userSettings = second.find(f => f.uri.toString() === userUri.toString()); + expect(userSettings?.settings).toEqual({ updated: true }); }); }); }); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts index 2ea831df0972a..673e29e34f81b 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts @@ -412,11 +412,16 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch const targetSettingsUri = !enabled ? this.claudeSettingsService.getUri(location, uri) : undefined; for (const file of allSettingsFiles) { + const isTarget = targetSettingsUri?.toString() === file.uri.toString(); + if (!file.settings.claudeMdExcludes || !Array.isArray(file.settings.claudeMdExcludes)) { + // File has no claudeMdExcludes — only write if this is the target for disabling + if (isTarget) { + const updated = { ...file.settings, claudeMdExcludes: [instructionsUri.path] }; + await writeSettings(file.uri, updated); + } continue; } - - const isTarget = targetSettingsUri?.toString() === file.uri.toString(); const filtered = (file.settings.claudeMdExcludes ?? []).filter(p => p !== instructionsUri.path); let shouldUpdateSettings = filtered.length !== (file.settings.claudeMdExcludes ?? []).length; diff --git a/extensions/copilot/src/platform/filesystem/node/test/mockFileSystemService.ts b/extensions/copilot/src/platform/filesystem/node/test/mockFileSystemService.ts index fe0858d8e5876..cddf87fb9c24f 100644 --- a/extensions/copilot/src/platform/filesystem/node/test/mockFileSystemService.ts +++ b/extensions/copilot/src/platform/filesystem/node/test/mockFileSystemService.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type { FileStat, FileSystemWatcher } from 'vscode'; +import { Emitter, Event } from '../../../../util/vs/base/common/event'; import { basename, dirname } from '../../../../util/vs/base/common/resources'; import { URI } from '../../../../util/vs/base/common/uri'; import { IFileSystemService } from '../../common/fileSystemService'; @@ -84,7 +85,17 @@ export class MockFileSystemService implements IFileSystemService { // Required interface methods isWritableFileSystem(): boolean | undefined { return true; } - createFileSystemWatcher(): FileSystemWatcher { throw new Error('not implemented'); } + createFileSystemWatcher(): FileSystemWatcher { + return { + ignoreCreateEvents: false, + ignoreChangeEvents: false, + ignoreDeleteEvents: false, + onDidCreate: new Emitter().event, + onDidChange: new Emitter().event, + onDidDelete: new Emitter().event, + dispose() { }, + } satisfies FileSystemWatcher as FileSystemWatcher; + } async createDirectory(uri: URI): Promise { const uriString = uri.toString(); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index bd1fdd1550086..b089250e67d98 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -302,7 +302,7 @@ class HookFileHeaderRenderer implements IListRenderer { const items = await this.fetchItemsForSection(section); + return this.computeEffectiveItemCount(items, section); + } + + /** + * Computes the effective item count for a section. + * For hooks, counts individual hooks (hookChildren) and excludes files with none. + */ + private computeEffectiveItemCount(items: readonly IAICustomizationListItem[], section: AICustomizationManagementSection): number { + if (section === AICustomizationManagementSection.Hooks) { + return items.reduce((sum, item) => sum + (item.hookChildren?.length ?? 0), 0); + } return items.length; } From 7d9e67c9cb40a6f8399cce4f4dca45fb3bba26e1 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Fri, 24 Apr 2026 12:57:39 -0700 Subject: [PATCH 34/36] CI --- .../src/platform/filesystem/node/test/mockFileSystemService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/copilot/src/platform/filesystem/node/test/mockFileSystemService.ts b/extensions/copilot/src/platform/filesystem/node/test/mockFileSystemService.ts index cddf87fb9c24f..1aa9363cd1116 100644 --- a/extensions/copilot/src/platform/filesystem/node/test/mockFileSystemService.ts +++ b/extensions/copilot/src/platform/filesystem/node/test/mockFileSystemService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type { FileStat, FileSystemWatcher } from 'vscode'; -import { Emitter, Event } from '../../../../util/vs/base/common/event'; +import { Emitter } from '../../../../util/vs/base/common/event'; import { basename, dirname } from '../../../../util/vs/base/common/resources'; import { URI } from '../../../../util/vs/base/common/uri'; import { IFileSystemService } from '../../common/fileSystemService'; From f0eb46394d512b24fc64e24c651f43b539985d4e Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Tue, 28 Apr 2026 09:27:39 -0700 Subject: [PATCH 35/36] API update --- .../copilotCLICustomizationProvider.ts | 49 ++++++++------- .../copilotCLICustomizationProvider.spec.ts | 50 +++++++-------- .../chatSessions/vscode-node/chatSessions.ts | 6 +- .../claudeCustomizationProvider.ts | 41 +++++++----- .../test/claudeCustomizationProvider.spec.ts | 54 ++++++++-------- .../common/extensionsApiProposals.ts | 1 + .../remoteAgentHostCustomizationHarness.ts | 2 - .../test/browser/customizationCounts.test.ts | 2 +- .../api/browser/mainThreadChatAgents2.ts | 21 ++----- .../workbench/api/common/extHost.api.impl.ts | 4 +- .../workbench/api/common/extHost.protocol.ts | 6 +- .../api/common/extHostChatAgents2.ts | 33 +++------- .../aiCustomizationItemSource.ts | 14 ++--- .../aiCustomizationListWidget.ts | 15 +++-- .../aiCustomizationListWidgetUtils.ts | 6 +- .../aiCustomizationManagement.contribution.ts | 34 ++++++++++ ...promptsServiceCustomizationItemProvider.ts | 10 +-- .../common/customizationHarnessService.ts | 8 +-- .../aiCustomizationDisablement.test.ts | 21 ------- .../customizationHarnessService.test.ts | 14 ++--- ...osed.chatSessionCustomizationProvider.d.ts | 62 ++++++------------- 21 files changed, 212 insertions(+), 241 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts index dff06af1c676f..20dbd0f1cf3d4 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts @@ -37,6 +37,8 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod private readonly _disablementStore: ExtensionDisablementStore; private _lastVscodeOwnedUris = new Set(); + static readonly enablementCommandId = 'copilot.copilotcli.handleCustomizationEnablement'; + static get metadata(): vscode.ChatSessionCustomizationProviderMetadata { return { label: 'Copilot CLI', @@ -72,6 +74,9 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod this._register(this.promptsService.onDidChangePlugins(() => this._onDidChange.fire())); this._register(this.copilotCLIAgents.onDidChangeAgents(() => this._onDidChange.fire())); this._register(this.copilotCLISettingsService.onDidChange(() => this._onDidChange.fire())); + + this._register(vscode.commands.registerCommand(CopilotCLICustomizationProvider.enablementCommandId, + (uri: vscode.Uri, type: string, enabled: boolean, scope: string) => this.handleCustomizationEnablement(uri, type, enabled, scope))); } async provideChatSessionCustomizations(token: vscode.CancellationToken): Promise { @@ -122,9 +127,10 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod // Only extension-contributed agents (owned by VS Code) support CLI-side disablement ...(extensionId ? { enabled: !this._disablementStore.isDisabled(sourceUri, 'agent'), - enablementScope: vscode.ChatSessionCustomizationEnablementScope.Global, + enablementScopeHint: vscode.ChatSessionCustomizationEnablementScope.Global, + enablementCommand: CopilotCLICustomizationProvider.enablementCommandId, } : { - enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, + enablementScopeHint: vscode.ChatSessionCustomizationEnablementScope.None, }), })); } @@ -188,9 +194,10 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod const hasEnablement = !!instruction.extensionId; const enablementProps = hasEnablement ? { enabled: !this._disablementStore.isDisabled(uri, 'instructions'), - enablementScope: vscode.ChatSessionCustomizationEnablementScope.Global, + enablementScopeHint: vscode.ChatSessionCustomizationEnablementScope.Global, + enablementCommand: CopilotCLICustomizationProvider.enablementCommandId, } : { - enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, + enablementScopeHint: vscode.ChatSessionCustomizationEnablementScope.None, }; if (pattern !== undefined) { @@ -241,18 +248,15 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod name, vscodeOwned: true, enabled: !this._disablementStore.isDisabled(s.uri, 'skill'), - enablementScope: vscode.ChatSessionCustomizationEnablementScope.Global, - extensionId: s.extensionId, - pluginUri: s.pluginUri, + enablementScopeHint: vscode.ChatSessionCustomizationEnablementScope.Global, + enablementCommand: CopilotCLICustomizationProvider.enablementCommandId, }; } return { uri: s.uri, type: vscode.ChatSessionCustomizationType.Skill, name, - enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, - extensionId: s.extensionId, - pluginUri: s.pluginUri, + enablementScopeHint: vscode.ChatSessionCustomizationEnablementScope.None, }; }); } @@ -266,7 +270,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod uri: h.uri, type: vscode.ChatSessionCustomizationType.Hook, name: basename(h.uri).replace(/\.json$/i, ''), - enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, + enablementScopeHint: vscode.ChatSessionCustomizationEnablementScope.None, })); } @@ -283,7 +287,8 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod type: vscode.ChatSessionCustomizationType.Plugins, name, enabled: enabledPlugins[name] !== false, - enablementScope: vscode.ChatSessionCustomizationEnablementScope.Global, + enablementScopeHint: vscode.ChatSessionCustomizationEnablementScope.Global, + enablementCommand: CopilotCLICustomizationProvider.enablementCommandId, }; }); } @@ -305,12 +310,12 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod return this.copilotCLISettingsService.getUris(CopilotCLISettingsLocationType.User)[0]; } - async handleCustomizationEnablement(uri: vscode.Uri, type: vscode.ChatSessionCustomizationType, enabled: boolean, _scope: vscode.ChatSessionCustomizationEnablementScope, _token: vscode.CancellationToken): Promise { + async handleCustomizationEnablement(uri: vscode.Uri, typeId: string, enabled: boolean, _scope: string): Promise { const settings = await this._readUserSettings(); let name: string; - if (type.id === vscode.ChatSessionCustomizationType.Skill.id) { - if (this._isVscodeOwned(uri, type)) { + if (typeId === vscode.ChatSessionCustomizationType.Skill.id) { + if (this._isVscodeOwned(uri)) { // VS Code extension-contributed skill name = basename(dirname(uri)); await this._disablementStore.setDisabled(URI.from(uri), 'skill', !enabled, 'global'); @@ -326,7 +331,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod settings.disabledSkills = [...currentList, name]; } } - } else if (type.id === vscode.ChatSessionCustomizationType.Plugins?.id) { + } else if (typeId === vscode.ChatSessionCustomizationType.Plugins?.id) { // Plugins use enabledPlugins map (Record) name = basename(uri); const map = (settings.enabledPlugins && typeof settings.enabledPlugins === 'object' && !Array.isArray(settings.enabledPlugins)) @@ -338,25 +343,25 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod map[name] = false; } settings.enabledPlugins = Object.keys(map).length > 0 ? map : undefined; - } else if (type.id === vscode.ChatSessionCustomizationType.Instructions.id && this._isVscodeOwned(uri, type)) { + } else if (typeId === vscode.ChatSessionCustomizationType.Instructions.id && this._isVscodeOwned(uri)) { name = basename(uri); await this._disablementStore.setDisabled(URI.from(uri), 'instructions', !enabled, 'global'); this._onDidChange.fire(); return; - } else if (type.id === vscode.ChatSessionCustomizationType.Agent.id && this._isVscodeOwned(uri, type)) { + } else if (typeId === vscode.ChatSessionCustomizationType.Agent.id && this._isVscodeOwned(uri)) { name = basename(uri); await this._disablementStore.setDisabled(URI.from(uri), 'agent', !enabled, 'global'); this._onDidChange.fire(); return; } else { - this.logService.warn(`[CopilotCLICustomizationProvider] Per-item enablement not supported for type: ${type.id}`); - void vscode.window.showErrorMessage(vscode.l10n.t('Toggling {0} customizations is not supported.', type.id)); + this.logService.warn(`[CopilotCLICustomizationProvider] Per-item enablement not supported for type: ${typeId}`); + void vscode.window.showErrorMessage(vscode.l10n.t('Toggling {0} customizations is not supported.', typeId)); return; } try { await this.copilotCLISettingsService.writeSettingsFile(this._settingsUri, settings); - this.logService.debug(`[CopilotCLICustomizationProvider] ${enabled ? 'Enabled' : 'Disabled'} ${type.id} "${name}" in ${this._settingsUri.toString()}`); + this.logService.debug(`[CopilotCLICustomizationProvider] ${enabled ? 'Enabled' : 'Disabled'} ${typeId} "${name}" in ${this._settingsUri.toString()}`); this._onDidChange.fire(); } catch (err) { void vscode.window.showErrorMessage(vscode.l10n.t('Failed to update Copilot settings: {0}', err instanceof Error ? err.message : String(err))); @@ -367,7 +372,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod * Checks whether a customization item is owned by a VS Code extension * by looking for its URI in the last fetched items with `extensionId`. */ - private _isVscodeOwned(uri: vscode.Uri, _type: vscode.ChatSessionCustomizationType): boolean { + private _isVscodeOwned(uri: vscode.Uri): boolean { // Items with extensionId are tracked during provideChatSessionCustomizations // via the vscodeOwned flag. Since the disablement store is keyed by URI, // any URI in the store is vscode-owned by definition. diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLICustomizationProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLICustomizationProvider.spec.ts index cffe83cb8299c..f4a14a682bf6c 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLICustomizationProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLICustomizationProvider.spec.ts @@ -669,7 +669,7 @@ describe('CopilotCLICustomizationProvider', () => { const items = await provider.provideChatSessionCustomizations(undefined!); const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill); - expect(skillItems[0].enablementScope).toBe(FakeChatSessionCustomizationEnablementScope.Global); + expect(skillItems[0].enablementScopeHint).toBe(FakeChatSessionCustomizationEnablementScope.Global); }); it('sets enablementScope to None for filesystem-discovered skills (no extensionId)', async () => { @@ -678,7 +678,7 @@ describe('CopilotCLICustomizationProvider', () => { const items = await provider.provideChatSessionCustomizations(undefined!); const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill); - expect(skillItems[0].enablementScope).toBe(FakeChatSessionCustomizationEnablementScope.None); + expect(skillItems[0].enablementScopeHint).toBe(FakeChatSessionCustomizationEnablementScope.None); expect(skillItems[0].enabled).toBeUndefined(); }); @@ -687,8 +687,8 @@ describe('CopilotCLICustomizationProvider', () => { const skillUri = URI.file('/workspace/.github/skills/lint-check/SKILL.md'); await provider.handleCustomizationEnablement( - skillUri, FakeChatSessionCustomizationType.Skill as any, - false, FakeChatSessionCustomizationEnablementScope.Global, undefined!); + skillUri, FakeChatSessionCustomizationType.Skill.id, + false, 'global'); const written = mockCopilotCLISettingsService.getWrittenSettings(); expect(written).toBeDefined(); @@ -700,8 +700,8 @@ describe('CopilotCLICustomizationProvider', () => { const skillUri = URI.file('/workspace/.github/skills/lint-check/SKILL.md'); await provider.handleCustomizationEnablement( - skillUri, FakeChatSessionCustomizationType.Skill as any, - true, FakeChatSessionCustomizationEnablementScope.Global, undefined!); + skillUri, FakeChatSessionCustomizationType.Skill.id, + true, 'global'); const written = mockCopilotCLISettingsService.getWrittenSettings(); expect(written).toBeDefined(); @@ -713,8 +713,8 @@ describe('CopilotCLICustomizationProvider', () => { const skillUri = URI.file('/workspace/.github/skills/lint-check/SKILL.md'); await provider.handleCustomizationEnablement( - skillUri, FakeChatSessionCustomizationType.Skill as any, - false, FakeChatSessionCustomizationEnablementScope.Global, undefined!); + skillUri, FakeChatSessionCustomizationType.Skill.id, + false, 'global'); const written = mockCopilotCLISettingsService.getWrittenSettings(); expect(written).toBeDefined(); @@ -728,8 +728,8 @@ describe('CopilotCLICustomizationProvider', () => { const skillUri = URI.file('/workspace/.github/skills/lint-check/SKILL.md'); await provider.handleCustomizationEnablement( - skillUri, FakeChatSessionCustomizationType.Skill as any, - false, FakeChatSessionCustomizationEnablementScope.Global, undefined!); + skillUri, FakeChatSessionCustomizationType.Skill.id, + false, 'global'); expect(fired).toBe(true); }); @@ -771,7 +771,7 @@ describe('CopilotCLICustomizationProvider', () => { const items = await provider.provideChatSessionCustomizations(undefined!); const pluginItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Plugins); - expect(pluginItems[0].enablementScope).toBe(FakeChatSessionCustomizationEnablementScope.Global); + expect(pluginItems[0].enablementScopeHint).toBe(FakeChatSessionCustomizationEnablementScope.Global); }); it('disabling a plugin sets enabledPlugins to false', async () => { @@ -779,8 +779,8 @@ describe('CopilotCLICustomizationProvider', () => { const pluginUri = URI.file('/workspace/.copilot/plugins/my-plugin'); await provider.handleCustomizationEnablement( - pluginUri, FakeChatSessionCustomizationType.Plugins as any, - false, FakeChatSessionCustomizationEnablementScope.Global, undefined!); + pluginUri, FakeChatSessionCustomizationType.Plugins.id, + false, 'global'); const written = mockCopilotCLISettingsService.getWrittenSettings(); expect(written).toBeDefined(); @@ -792,8 +792,8 @@ describe('CopilotCLICustomizationProvider', () => { const pluginUri = URI.file('/workspace/.copilot/plugins/my-plugin'); await provider.handleCustomizationEnablement( - pluginUri, FakeChatSessionCustomizationType.Plugins as any, - true, FakeChatSessionCustomizationEnablementScope.Global, undefined!); + pluginUri, FakeChatSessionCustomizationType.Plugins.id, + true, 'global'); const written = mockCopilotCLISettingsService.getWrittenSettings(); expect(written).toBeDefined(); @@ -805,8 +805,8 @@ describe('CopilotCLICustomizationProvider', () => { const pluginUri = URI.file('/workspace/.copilot/plugins/my-plugin'); await provider.handleCustomizationEnablement( - pluginUri, FakeChatSessionCustomizationType.Plugins as any, - true, FakeChatSessionCustomizationEnablementScope.Global, undefined!); + pluginUri, FakeChatSessionCustomizationType.Plugins.id, + true, 'global'); const written = mockCopilotCLISettingsService.getWrittenSettings(); expect(written).toBeDefined(); @@ -821,8 +821,8 @@ describe('CopilotCLICustomizationProvider', () => { const pluginUri = URI.file('/workspace/.copilot/plugins/my-plugin'); await provider.handleCustomizationEnablement( - pluginUri, FakeChatSessionCustomizationType.Plugins as any, - false, FakeChatSessionCustomizationEnablementScope.Global, undefined!); + pluginUri, FakeChatSessionCustomizationType.Plugins.id, + false, 'global'); expect(fired).toBe(true); }); @@ -835,7 +835,7 @@ describe('CopilotCLICustomizationProvider', () => { const items = await provider.provideChatSessionCustomizations(undefined!); const agentItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Agent); - expect(agentItems[0].enablementScope).toBe(FakeChatSessionCustomizationEnablementScope.Global); + expect(agentItems[0].enablementScopeHint).toBe(FakeChatSessionCustomizationEnablementScope.Global); expect(agentItems[0].enabled).toBe(true); }); @@ -844,7 +844,7 @@ describe('CopilotCLICustomizationProvider', () => { const items = await provider.provideChatSessionCustomizations(undefined!); const agentItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Agent); - expect(agentItems[0].enablementScope).toBe(FakeChatSessionCustomizationEnablementScope.None); + expect(agentItems[0].enablementScopeHint).toBe(FakeChatSessionCustomizationEnablementScope.None); expect(agentItems[0].enabled).toBeUndefined(); }); @@ -854,7 +854,7 @@ describe('CopilotCLICustomizationProvider', () => { const items = await provider.provideChatSessionCustomizations(undefined!); const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions); - expect(instrItems[0].enablementScope).toBe(FakeChatSessionCustomizationEnablementScope.Global); + expect(instrItems[0].enablementScopeHint).toBe(FakeChatSessionCustomizationEnablementScope.Global); expect(instrItems[0].enabled).toBe(true); }); @@ -864,7 +864,7 @@ describe('CopilotCLICustomizationProvider', () => { const items = await provider.provideChatSessionCustomizations(undefined!); const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions); - expect(instrItems[0].enablementScope).toBe(FakeChatSessionCustomizationEnablementScope.None); + expect(instrItems[0].enablementScopeHint).toBe(FakeChatSessionCustomizationEnablementScope.None); expect(instrItems[0].enabled).toBeUndefined(); }); @@ -874,7 +874,7 @@ describe('CopilotCLICustomizationProvider', () => { const items = await provider.provideChatSessionCustomizations(undefined!); const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions); - expect(instrItems[0].enablementScope).toBeUndefined(); + expect(instrItems[0].enablementScopeHint).toBeUndefined(); expect(instrItems[0].enabled).toBeUndefined(); }); @@ -883,7 +883,7 @@ describe('CopilotCLICustomizationProvider', () => { const items = await provider.provideChatSessionCustomizations(undefined!); const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook); - expect(hookItems[0].enablementScope).toBe(FakeChatSessionCustomizationEnablementScope.None); + expect(hookItems[0].enablementScopeHint).toBe(FakeChatSessionCustomizationEnablementScope.None); expect(hookItems[0].enabled).toBeUndefined(); }); }); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts index 26a241f30038f..f8f7bb35d2d4e 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts @@ -164,7 +164,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib chatParticipant.iconPath = new vscode.ThemeIcon('claude'); this._register(vscode.chat.registerChatSessionContentProvider(ClaudeSessionUri.scheme, chatSessionContentProvider, chatParticipant)); const claudeCustomizationProvider = this._register(claudeAgentInstaService.createInstance(ClaudeCustomizationProvider)); - this._register(vscode.chat.registerChatSessionCustomizationProvider(ClaudeSessionUri.scheme, ClaudeCustomizationProvider.metadata, claudeCustomizationProvider, claudeCustomizationProvider)); + this._register(vscode.chat.registerChatSessionCustomizationProvider(ClaudeSessionUri.scheme, ClaudeCustomizationProvider.metadata, claudeCustomizationProvider)); // #endregion @@ -238,7 +238,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib const copilotcliParticipant = vscode.chat.createChatParticipant(this.copilotcliSessionType, copilotcliChatSessionParticipant.createHandler()); this._register(vscode.chat.registerChatSessionContentProvider(this.copilotcliSessionType, copilotcliChatSessionContentProvider, copilotcliParticipant)); const copilotcliCustomizationProvider = this._register(copilotcliAgentInstaService.createInstance(CopilotCLICustomizationProvider)); - this._register(vscode.chat.registerChatSessionCustomizationProvider(this.copilotcliSessionType, CopilotCLICustomizationProvider.metadata, copilotcliCustomizationProvider, copilotcliCustomizationProvider)); + this._register(vscode.chat.registerChatSessionCustomizationProvider(this.copilotcliSessionType, CopilotCLICustomizationProvider.metadata, copilotcliCustomizationProvider)); this._register(registerCLIChatCommands(copilotCLISessionService, copilotCLIWorktreeManagerService, gitService, copilotCLIWorkspaceFolderSessions, copilotcliChatSessionContentProvider, folderRepositoryManager, copilotCLIFolderMruService, nativeEnvService, fileSystemService, sessionTracker, terminalIntegration, logService)); // #endregion @@ -339,7 +339,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib const copilotcliParticipant = vscode.chat.createChatParticipant(this.copilotcliSessionType, copilotcliChatSessionParticipant.createHandler()); this._register(vscode.chat.registerChatSessionContentProvider(this.copilotcliSessionType, copilotcliChatSessionContentProvider, copilotcliParticipant)); const copilotcliCustomizationProvider = this._register(copilotcliAgentInstaService.createInstance(CopilotCLICustomizationProvider)); - this._register(vscode.chat.registerChatSessionCustomizationProvider(this.copilotcliSessionType, CopilotCLICustomizationProvider.metadata, copilotcliCustomizationProvider, copilotcliCustomizationProvider)); + this._register(vscode.chat.registerChatSessionCustomizationProvider(this.copilotcliSessionType, CopilotCLICustomizationProvider.metadata, copilotcliCustomizationProvider)); this._register(registerCLIChatCommandsV1(copilotcliSessionItemProvider, copilotCLISessionService, copilotCLIWorktreeManagerService, gitService, gitExtensionService, toolsService, copilotCLIWorkspaceFolderSessions, copilotcliChatSessionContentProvider, folderRepositoryManager, copilotFolderMruService, nativeEnvService, fileSystemService, logService)); // #endregion diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts index 673e29e34f81b..8fb04a9f38410 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts @@ -56,6 +56,8 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch private readonly _disablementStore: ExtensionDisablementStore; private _lastVscodeOwnedUris = new Set(); + static readonly enablementCommandId = 'copilot.claude.handleCustomizationEnablement'; + static get metadata(): vscode.ChatSessionCustomizationProviderMetadata { return { label: 'Claude', @@ -88,6 +90,9 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch this._register(this.promptsService.onDidChangeCustomAgents(() => this._onDidChange.fire())); this._register(this.promptsService.onDidChangeSkills(() => this._onDidChange.fire())); this._register(this.workspaceService.onDidChangeWorkspaceFolders(() => this._onDidChange.fire())); + + this._register(vscode.commands.registerCommand(ClaudeCustomizationProvider.enablementCommandId, + (uri: vscode.Uri, type: string, enabled: boolean, scope: string) => this.handleCustomizationEnablement(uri, type, enabled, scope))); } async provideChatSessionCustomizations(token: vscode.CancellationToken): Promise { @@ -150,7 +155,8 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch type: vscode.ChatSessionCustomizationType.Skill, name: skill.name, enabled: override !== 'off', - enablementScope: vscode.ChatSessionCustomizationEnablementScope.Workspace + enablementScopeHint: vscode.ChatSessionCustomizationEnablementScope.Workspace, + enablementCommand: ClaudeCustomizationProvider.enablementCommandId, }; skillItems.push(item); } else if (skill.extensionId) { @@ -161,7 +167,8 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch name: skill.name, vscodeOwned: true, enabled: !this._disablementStore.isDisabled(skill.uri, 'skill'), - enablementScope: vscode.ChatSessionCustomizationEnablementScope.Global, + enablementScopeHint: vscode.ChatSessionCustomizationEnablementScope.Global, + enablementCommand: ClaudeCustomizationProvider.enablementCommandId, }); } } @@ -223,14 +230,18 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch } } + const itemEnablementScope = excludedByUnknownPattern + ? vscode.ChatSessionCustomizationEnablementScope.None + : vscode.ChatSessionCustomizationEnablementScope.Workspace; items.push({ uri, type: vscode.ChatSessionCustomizationType.Instructions, name, - enablementScope: excludedByUnknownPattern - ? vscode.ChatSessionCustomizationEnablementScope.None : - vscode.ChatSessionCustomizationEnablementScope.Workspace, + enablementScopeHint: itemEnablementScope, enabled: !excluded, + enablementCommand: itemEnablementScope !== vscode.ChatSessionCustomizationEnablementScope.None + ? ClaudeCustomizationProvider.enablementCommandId + : undefined, }); } } @@ -288,7 +299,7 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch description, enabled: !disableAllHooks, // TODO: There isn't a great way to toggle enablement for individual hooks - enablementScope: vscode.ChatSessionCustomizationEnablementScope.None, + enablementScopeHint: vscode.ChatSessionCustomizationEnablementScope.None, }); } } @@ -350,9 +361,9 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch // --- Enablement --- - async handleCustomizationEnablement(uri: vscode.Uri, type: vscode.ChatSessionCustomizationType, enabled: boolean, scope: vscode.ChatSessionCustomizationEnablementScope, _token: vscode.CancellationToken): Promise { + async handleCustomizationEnablement(uri: vscode.Uri, typeId: string, enabled: boolean, scope: string): Promise { // TODO: should we support writing to settings.local.json files? - const location = scope === vscode.ChatSessionCustomizationEnablementScope.Workspace + const location = scope === 'workspace' ? ClaudeSettingsLocationType.Workspace : ClaudeSettingsLocationType.User; @@ -366,7 +377,7 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch } }; - if (type.id === vscode.ChatSessionCustomizationType.Skill.id) { + if (typeId === vscode.ChatSessionCustomizationType.Skill.id) { if (this._lastVscodeOwnedUris.has(uri.toString())) { // VS Code extension-contributed skill await this._disablementStore.setDisabled(URI.from(uri), 'skill', !enabled, 'global'); @@ -400,7 +411,7 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch } } } - } else if (type.id === vscode.ChatSessionCustomizationType.Instructions.id) { + } else if (typeId === vscode.ChatSessionCustomizationType.Instructions.id) { if (this._lastVscodeOwnedUris.has(uri.toString())) { // VS Code extension-contributed instruction await this._disablementStore.setDisabled(URI.from(uri), 'instructions', !enabled, 'global'); @@ -437,12 +448,12 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch } } } - } else if (type.id === vscode.ChatSessionCustomizationType.Agent.id && this._lastVscodeOwnedUris.has(uri.toString())) { + } else if (typeId === vscode.ChatSessionCustomizationType.Agent.id && this._lastVscodeOwnedUris.has(uri.toString())) { // VS Code extension-contributed agent await this._disablementStore.setDisabled(URI.from(uri), 'agent', !enabled, 'global'); this._onDidChange.fire(); return; - } else if (type.id === vscode.ChatSessionCustomizationType.Hook.id) { + } else if (typeId === vscode.ChatSessionCustomizationType.Hook.id) { // Hooks are toggled via the disableAllHooks flag in the settings file // that contains them. Toggling any hook toggles all hooks in that file. for (const file of allSettingsFiles) { @@ -457,12 +468,12 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch } } } else { - this.logService.warn(`[ClaudeCustomizationProvider] Per-item enablement not supported for type: ${type.id}`); - void vscode.window.showErrorMessage(vscode.l10n.t('Toggling {0} customizations is not supported.', type.id)); + this.logService.warn(`[ClaudeCustomizationProvider] Per-item enablement not supported for type: ${typeId}`); + void vscode.window.showErrorMessage(vscode.l10n.t('Toggling {0} customizations is not supported.', typeId)); return; } - this.logService.debug(`[ClaudeCustomizationProvider] ${enabled ? 'Enabled' : 'Disabled'} ${type.id} in ${location}`); + this.logService.debug(`[ClaudeCustomizationProvider] ${enabled ? 'Enabled' : 'Disabled'} ${typeId} in ${location}`); this._onDidChange.fire(); } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts index e4d33b982a624..0d0aa631d70e3 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts @@ -474,7 +474,7 @@ describe('ClaudeCustomizationProvider', () => { const items = await provider.provideChatSessionCustomizations(undefined!); const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill); - expect(skillItems[0].enablementScope).toBe(FakeChatSessionCustomizationEnablementScope.Workspace); + expect(skillItems[0].enablementScopeHint).toBe(FakeChatSessionCustomizationEnablementScope.Workspace); }); }); @@ -678,8 +678,8 @@ describe('ClaudeCustomizationProvider', () => { }); await provider.handleCustomizationEnablement( - settingsUri, FakeChatSessionCustomizationType.Hook as any, - false, 2 /* Workspace */, undefined!); + settingsUri, FakeChatSessionCustomizationType.Hook.id, + false, 'workspace'); const written = mockClaudeSettingsService.getWrittenFile(settingsUri); expect(written).toBeDefined(); @@ -697,8 +697,8 @@ describe('ClaudeCustomizationProvider', () => { }); await provider.handleCustomizationEnablement( - settingsUri, FakeChatSessionCustomizationType.Hook as any, - true, 2 /* Workspace */, undefined!); + settingsUri, FakeChatSessionCustomizationType.Hook.id, + true, 'workspace'); const written = mockClaudeSettingsService.getWrittenFile(settingsUri); expect(written).toBeDefined(); @@ -716,8 +716,8 @@ describe('ClaudeCustomizationProvider', () => { }); await provider.handleCustomizationEnablement( - settingsUri, FakeChatSessionCustomizationType.Hook as any, - false, 2 /* Workspace */, undefined!); + settingsUri, FakeChatSessionCustomizationType.Hook.id, + false, 'workspace'); const written = mockClaudeSettingsService.getWrittenFile(settingsUri); expect(written!.permissions).toEqual({ allow: ['Read'] }); @@ -738,8 +738,8 @@ describe('ClaudeCustomizationProvider', () => { // Disable hooks in the workspace file only await provider.handleCustomizationEnablement( - wsUri, FakeChatSessionCustomizationType.Hook as any, - false, 2 /* Workspace */, undefined!); + wsUri, FakeChatSessionCustomizationType.Hook.id, + false, 'workspace'); expect(mockClaudeSettingsService.getWrittenFile(wsUri)!.disableAllHooks).toBe(true); expect(mockClaudeSettingsService.getWrittenFile(userUri)).toBeUndefined(); @@ -757,8 +757,8 @@ describe('ClaudeCustomizationProvider', () => { disposables.add(provider.onDidChange(() => { fired = true; })); await provider.handleCustomizationEnablement( - settingsUri, FakeChatSessionCustomizationType.Hook as any, - false, 2 /* Workspace */, undefined!); + settingsUri, FakeChatSessionCustomizationType.Hook.id, + false, 'workspace'); expect(fired).toBe(true); }); @@ -815,8 +815,8 @@ describe('ClaudeCustomizationProvider', () => { const skillUri = URI.file('/workspace/.claude/skills/my-skill/SKILL.md'); await provider.handleCustomizationEnablement( - skillUri, FakeChatSessionCustomizationType.Skill as any, - false, FakeChatSessionCustomizationEnablementScope.Workspace, undefined!); + skillUri, FakeChatSessionCustomizationType.Skill.id, + false, 'workspace'); const written = mockClaudeSettingsService.getWrittenFile(wsUri); expect(written).toBeDefined(); @@ -831,8 +831,8 @@ describe('ClaudeCustomizationProvider', () => { const skillUri = URI.file('/workspace/.claude/skills/my-skill/SKILL.md'); await provider.handleCustomizationEnablement( - skillUri, FakeChatSessionCustomizationType.Skill as any, - true, FakeChatSessionCustomizationEnablementScope.Workspace, undefined!); + skillUri, FakeChatSessionCustomizationType.Skill.id, + true, 'workspace'); const written = mockClaudeSettingsService.getWrittenFile(wsUri); expect(written).toBeDefined(); @@ -847,8 +847,8 @@ describe('ClaudeCustomizationProvider', () => { const skillUri = URI.file('/workspace/.claude/skills/my-skill/SKILL.md'); await provider.handleCustomizationEnablement( - skillUri, FakeChatSessionCustomizationType.Skill as any, - true, FakeChatSessionCustomizationEnablementScope.Workspace, undefined!); + skillUri, FakeChatSessionCustomizationType.Skill.id, + true, 'workspace'); const written = mockClaudeSettingsService.getWrittenFile(wsUri); expect(written!.skillOverrides).toEqual({ 'other-skill': 'off' }); @@ -865,8 +865,8 @@ describe('ClaudeCustomizationProvider', () => { const skillUri = URI.file('/workspace/.claude/skills/my-skill/SKILL.md'); await provider.handleCustomizationEnablement( - skillUri, FakeChatSessionCustomizationType.Skill as any, - false, FakeChatSessionCustomizationEnablementScope.Workspace, undefined!); + skillUri, FakeChatSessionCustomizationType.Skill.id, + false, 'workspace'); expect(fired).toBe(true); }); @@ -907,7 +907,7 @@ describe('ClaudeCustomizationProvider', () => { const items = await provider.provideChatSessionCustomizations(undefined!); const instructionItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions); - expect(instructionItems[0].enablementScope).toBe(FakeChatSessionCustomizationEnablementScope.Workspace); + expect(instructionItems[0].enablementScopeHint).toBe(FakeChatSessionCustomizationEnablementScope.Workspace); }); it('sets enablementScope to None when excluded by glob pattern only', async () => { @@ -921,7 +921,7 @@ describe('ClaudeCustomizationProvider', () => { const items = await provider.provideChatSessionCustomizations(undefined!); const instructionItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions); expect(instructionItems[0].enabled).toBe(false); - expect(instructionItems[0].enablementScope).toBe(FakeChatSessionCustomizationEnablementScope.None); + expect(instructionItems[0].enablementScopeHint).toBe(FakeChatSessionCustomizationEnablementScope.None); }); it('disables an instruction by adding to claudeMdExcludes', async () => { @@ -932,8 +932,8 @@ describe('ClaudeCustomizationProvider', () => { const claudeMdUri = URI.joinPath(URI.file('/workspace'), 'CLAUDE.md'); await provider.handleCustomizationEnablement( - claudeMdUri, FakeChatSessionCustomizationType.Instructions as any, - false, FakeChatSessionCustomizationEnablementScope.Workspace, undefined!); + claudeMdUri, FakeChatSessionCustomizationType.Instructions.id, + false, 'workspace'); const written = mockClaudeSettingsService.getWrittenFile(wsUri); expect(written).toBeDefined(); @@ -948,8 +948,8 @@ describe('ClaudeCustomizationProvider', () => { const claudeMdUri = URI.joinPath(URI.file('/workspace'), 'CLAUDE.md'); await provider.handleCustomizationEnablement( - claudeMdUri, FakeChatSessionCustomizationType.Instructions as any, - true, FakeChatSessionCustomizationEnablementScope.Workspace, undefined!); + claudeMdUri, FakeChatSessionCustomizationType.Instructions.id, + true, 'workspace'); const written = mockClaudeSettingsService.getWrittenFile(wsUri); expect(written).toBeDefined(); @@ -966,8 +966,8 @@ describe('ClaudeCustomizationProvider', () => { const claudeMdUri = URI.joinPath(URI.file('/workspace'), 'CLAUDE.md'); await provider.handleCustomizationEnablement( - claudeMdUri, FakeChatSessionCustomizationType.Instructions as any, - true, FakeChatSessionCustomizationEnablementScope.Workspace, undefined!); + claudeMdUri, FakeChatSessionCustomizationType.Instructions.id, + true, 'workspace'); const written = mockClaudeSettingsService.getWrittenFile(wsUri); expect(written!.claudeMdExcludes).toEqual(['/workspace/CLAUDE.local.md']); diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index f9402b1ef47c3..3c587471fd181 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -81,6 +81,7 @@ const _allApiProposals = { }, chatSessionCustomizationProvider: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts', + version: 1 }, chatSessionsProvider: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts', diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts index a6ef3df422be7..ed3927f49c35b 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts @@ -91,7 +91,6 @@ export class RemoteAgentCustomizationItemProvider extends Disposable implements statusMessage: sc.statusMessage, enabled: sc.enabled, extensionId: undefined, - pluginUri: undefined })); } @@ -102,7 +101,6 @@ export class RemoteAgentCustomizationItemProvider extends Disposable implements name: ref.displayName, description: ref.description, extensionId: undefined, - pluginUri: undefined })); } } diff --git a/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts b/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts index 3bb2f7738ec92..26d9ebcc3ee24 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts @@ -751,7 +751,7 @@ suite('customizationCounts', () => { } function makeItem(type: string, name: string): ICustomizationItem { - return { uri: URI.file(`/mock/${name}`), type, name, extensionId: undefined, pluginUri: undefined }; + return { uri: URI.file(`/mock/${name}`), type, name, extensionId: undefined }; } test('uses itemProvider counts when provided', async () => { diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 59d1398d5fae3..d9a4ed87c97d0 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -46,7 +46,7 @@ import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; import { ExtHostChatAgentsShape2, ExtHostContext, IChatAgentInvokeResult, IChatSessionCustomizationItemDto, IChatSessionCustomizationProviderMetadataDto, IChatNotebookEditDto, IChatParticipantMetadata, IChatProgressDto, IChatSessionContextDto, ICustomAgentDto, IDynamicChatAgentProps, IExtensionChatAgentMetadata, IHookDto, IInstructionDto, IPluginDto, ISkillDto, ISlashCommandDto, MainContext, MainThreadChatAgentsShape2 } from '../common/extHost.protocol.js'; import { NotebookDto } from './mainThreadNotebookDto.js'; import { getChatSessionType, isUntitledChatSession } from '../../contrib/chat/common/model/chatUri.js'; -import { ICustomizationEnablementHandler, ICustomizationHarnessService, ICustomizationItem, ICustomizationItemProvider, IHarnessDescriptor } from '../../contrib/chat/common/customizationHarnessService.js'; +import { ICustomizationHarnessService, ICustomizationItem, ICustomizationItemProvider, IHarnessDescriptor } from '../../contrib/chat/common/customizationHarnessService.js'; import { AICustomizationManagementSection, BUILTIN_STORAGE } from '../../contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IAgentPlugin, IAgentPluginService } from '../../contrib/chat/common/plugins/agentPluginService.js'; import { IWorkbenchEnvironmentService } from '../../services/environment/common/environmentService.js'; @@ -713,7 +713,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA } } - async $registerChatSessionCustomizationProvider(handle: number, chatSessionType: string, metadata: IChatSessionCustomizationProviderMetadataDto, extensionId: ExtensionIdentifier, hasEnablementHandler: boolean): Promise { + async $registerChatSessionCustomizationProvider(handle: number, chatSessionType: string, metadata: IChatSessionCustomizationProviderMetadataDto, extensionId: ExtensionIdentifier): Promise { // In the sessions window, only accept harnesses for session types that // have a registered content provider (i.e., can actually run sessions). // AHP remote servers register directly via registerExternalHarness. @@ -748,7 +748,8 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA badgeTooltip: item.badgeTooltip, enabled: item.enabled, enablementScope: item.enablementScope, - pluginUri: item.pluginUri ? URI.revive(item.pluginUri) : undefined, + enablementCommand: item.enablementCommand, + enablementMessage: item.enablementMessage, extensionId: undefined, }); return items.map(i => convertItem(i)); @@ -778,19 +779,6 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA hiddenSections = Object.values(typeToSection).filter(section => !supportedSections.has(section)); } - // Build an enablement provider when the extension implements handleCustomizationEnablement. - // This delegates disable/enable to the extension instead of VS Code's StorageService. - let enablementHandler: ICustomizationEnablementHandler | undefined; - if (hasEnablementHandler) { - const proxy = this._proxy; - const providerHandle = handle; - enablementHandler = { - handleCustomizationEnablement: (uri: URI, type: PromptsType, enabled: boolean, scope: 'global' | 'workspace'): void => { - proxy.$handleCustomizationEnablement(providerHandle, uri.toJSON(), type, enabled, scope); - }, - }; - } - const descriptor: IHarnessDescriptor = { id: chatSessionType, label: metadata.label, @@ -802,7 +790,6 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.plugin, PromptsStorage.extension, BUILTIN_STORAGE], }), itemProvider, - enablementHandler, }; const registration = this._customizationHarnessService.registerExternalHarness(descriptor); diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 6ef6537fe65d8..1e8f804e38c15 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1765,9 +1765,9 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.onDidChangePlugins(listener, thisArgs, disposables); }, - registerChatSessionCustomizationProvider(chatSessionType: string, metadata: vscode.ChatSessionCustomizationProviderMetadata, provider: vscode.ChatSessionCustomizationProvider, enablementHandler?: vscode.ChatSessionCustomizationEnablementHandler): vscode.Disposable { + registerChatSessionCustomizationProvider(chatSessionType: string, metadata: vscode.ChatSessionCustomizationProviderMetadata, provider: vscode.ChatSessionCustomizationProvider): vscode.Disposable { checkProposedApiEnabled(extension, 'chatSessionCustomizationProvider'); - return extHostChatAgents2.registerChatSessionCustomizationProvider(extension, chatSessionType, metadata, provider, enablementHandler); + return extHostChatAgents2.registerChatSessionCustomizationProvider(extension, chatSessionType, metadata, provider); }, }; diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 70c3d29f4c3c9..d75ca55478a64 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1588,7 +1588,7 @@ export interface MainThreadChatAgentsShape2 extends IChatAgentProgressShape, IDi $registerPromptFileProvider(handle: number, type: string, extension: ExtensionIdentifier): void; $unregisterPromptFileProvider(handle: number): void; $onDidChangePromptFiles(handle: number): void; - $registerChatSessionCustomizationProvider(handle: number, chatSessionType: string, metadata: IChatSessionCustomizationProviderMetadataDto, extension: ExtensionIdentifier, hasEnablementHandler: boolean): void; + $registerChatSessionCustomizationProvider(handle: number, chatSessionType: string, metadata: IChatSessionCustomizationProviderMetadataDto, extension: ExtensionIdentifier): void; $unregisterChatSessionCustomizationProvider(handle: number): void; $onDidChangeCustomizations(handle: number): void; $registerAgentCompletionsProvider(handle: number, id: string, triggerCharacters: string[]): void; @@ -1672,7 +1672,6 @@ export interface ExtHostChatAgentsShape2 { $detectChatParticipant(handle: number, request: Dto, context: { history: IChatAgentHistoryEntryDto[] }, options: { participants: IChatParticipantMetadata[]; location: ChatAgentLocation }, token: CancellationToken): Promise; $providePromptFiles(handle: number, type: PromptsType, context: IPromptFileContext, token: CancellationToken): Promise[] | undefined>; $provideChatSessionCustomizations(handle: number, token: CancellationToken): Promise; - $handleCustomizationEnablement(handle: number, uri: UriComponents, type: string, enabled: boolean, scope: 'global' | 'workspace'): Promise; $setRequestTools(requestId: string, tools: UserSelectedTools): void; $setYieldRequested(requestId: string, value: boolean): void; $acceptActiveChatSession(sessionResource: UriComponents | undefined): void; @@ -1743,7 +1742,8 @@ export interface IChatSessionCustomizationItemDto { readonly badgeTooltip?: string; readonly enabled?: boolean; readonly enablementScope?: 'none' | 'global' | 'workspace'; - readonly pluginUri?: UriComponents; + readonly enablementCommand?: string; + readonly enablementMessage?: string; } export interface IChatParticipantMetadata { participant: string; diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index eb2a78cbf2d54..8d8f5bda38882 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -490,11 +490,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS 1: 'global', // ChatSessionCustomizationEnablementScope.Global 2: 'workspace', // ChatSessionCustomizationEnablementScope.Workspace }; - private static readonly _enablementScopeReverseMap: Record = { - 'global': 1, // ChatSessionCustomizationEnablementScope.Global - 'workspace': 2, // ChatSessionCustomizationEnablementScope.Workspace - }; - private readonly _customizationProviders = new Map(); + private readonly _customizationProviders = new Map(); private readonly _sessionDisposables: DisposableResourceMap = this._register(new DisposableResourceMap()); private readonly _completionDisposables: DisposableMap = this._register(new DisposableMap()); @@ -792,9 +788,9 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS return resources; } - registerChatSessionCustomizationProvider(extension: IExtensionDescription, chatSessionType: string, metadata: vscode.ChatSessionCustomizationProviderMetadata, provider: vscode.ChatSessionCustomizationProvider, enablementHandler?: vscode.ChatSessionCustomizationEnablementHandler): vscode.Disposable { + registerChatSessionCustomizationProvider(extension: IExtensionDescription, chatSessionType: string, metadata: vscode.ChatSessionCustomizationProviderMetadata, provider: vscode.ChatSessionCustomizationProvider): vscode.Disposable { const handle = ExtHostChatAgents2._customizationProviderIdPool++; - this._customizationProviders.set(handle, { extension, provider, enablementHandler }); + this._customizationProviders.set(handle, { extension, provider }); const metadataDto: IChatSessionCustomizationProviderMetadataDto = { label: metadata.label, @@ -802,7 +798,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS supportedTypes: metadata.supportedTypes?.map(t => typeConvert.ChatSessionCustomizationType.from(t)), }; - this._proxy.$registerChatSessionCustomizationProvider(handle, chatSessionType, metadataDto, extension.identifier, !!enablementHandler); + this._proxy.$registerChatSessionCustomizationProvider(handle, chatSessionType, metadataDto, extension.identifier); const disposables = new DisposableStore(); @@ -841,8 +837,11 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS badge: item.badge, badgeTooltip: item.badgeTooltip, enabled: item.enabled, - enablementScope: item.enablementScope !== undefined ? ExtHostChatAgents2._enablementScopeMap[item.enablementScope] : undefined, - pluginUri: item.pluginUri, + enablementScope: item.enablementScopeHint !== undefined + ? ExtHostChatAgents2._enablementScopeMap[item.enablementScopeHint] + : item.enablementCommand ? 'global' : undefined, + enablementCommand: item.enablementCommand, + enablementMessage: item.enablementDisabledReason, } satisfies IChatSessionCustomizationItemDto); return items.map(convertItem); @@ -851,20 +850,6 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS } } - async $handleCustomizationEnablement(handle: number, uri: UriComponents, type: string, enabled: boolean, scope: 'global' | 'workspace'): Promise { - const providerData = this._customizationProviders.get(handle); - if (!providerData?.enablementHandler) { - return; - } - await providerData.enablementHandler.handleCustomizationEnablement( - URI.revive(uri), - typeConvert.ChatSessionCustomizationType.to(type), - enabled, - ExtHostChatAgents2._enablementScopeReverseMap[scope] as vscode.ChatSessionCustomizationEnablementScope, - CancellationToken.None, - ); - } - async $detectChatParticipant(handle: number, requestDto: Dto, context: { history: IChatAgentHistoryEntryDto[] }, options: { location: ChatAgentLocation; participants?: vscode.ChatParticipantMetadata[] }, token: CancellationToken): Promise { const detector = this._participantDetectionProviders.get(handle); if (!detector) { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index 5e397526bb4bc..b5103435ff838 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -52,8 +52,7 @@ export interface IAICustomizationListItem { /** URI of the parent plugin, when this item comes from an installed plugin. */ readonly pluginUri?: URI; /** When set, overrides the formatted name for display. */ - readonly displayName?: string; - /** When set, shows a small inline badge next to the item name. */ + readonly displayName?: string; /** When set, shows a small inline badge next to the item name. */ readonly badge?: string; /** Tooltip shown when hovering the badge. */ readonly badgeTooltip?: string; @@ -69,8 +68,10 @@ export interface IAICustomizationListItem { readonly statusMessage?: string; /** Per-item enablement scope override. Defaults to 'none' (not disableable) when absent. */ readonly enablementScope?: 'none' | 'global' | 'workspace'; - /** Optional reference to the parent plugin item. When present, enable/disable actions target the plugin and the item's own enablementScope is ignored. */ - readonly plugin?: URI; + /** Command ID to execute when the user toggles this item's enablement. */ + readonly enablementCommand?: string; + /** Human-readable message explaining why this item cannot be toggled. */ + readonly enablementMessage?: string; /** When true, this item can be selected for syncing to a remote harness. */ readonly syncable?: boolean; /** When true, this syncable item is currently selected for syncing. */ @@ -312,7 +313,8 @@ export class AICustomizationItemNormalizer { status: item.status, statusMessage: item.statusMessage, enablementScope: item.enablementScope, - plugin: item.pluginUri, + enablementCommand: item.enablementCommand, + enablementMessage: item.enablementMessage, hookChildren: item.hookChildren, }; } @@ -580,7 +582,6 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour badgeTooltip: uiTooltip, enablementScope: 'workspace', extensionId: undefined, - pluginUri: undefined }; appended.push(this.itemNormalizer.normalizeItem(builtinItem, promptType, uriUseCounts)); } @@ -623,7 +624,6 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour enabled: !disabledUris.has(file.uri), enablementScope: 'workspace' as const, extensionId: undefined, - pluginUri: undefined })); return this.itemNormalizer.normalizeItems(providerItems, promptType) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index b089250e67d98..17cc803981703 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -342,8 +342,9 @@ class HookFileHeaderRenderer implements IListRenderer { if (item.storage === PromptsStorage.extension && item.extensionId) { return { storage: PromptsStorage.extension, extensionId: new ExtensionIdentifier(item.extensionId) }; - } else if (item.storage === PromptsStorage.plugin && item.pluginUri) { - return { storage: PromptsStorage.plugin, pluginUri: item.pluginUri }; } else if (item.storage === PromptsStorage.user) { return { storage: PromptsStorage.user }; } diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts index affce6f863d2c..032831af72e27 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts @@ -121,7 +121,6 @@ suite('aiCustomizationDisablement', () => { itemProvider: createMockItemProvider([{ uri: agentUri, type: PromptsType.agent, name: 'API Agent', enablementScope: 'global', extensionId: undefined, - pluginUri: undefined }]), enablementHandler: createMockEnablementHandler(), }); @@ -136,7 +135,6 @@ suite('aiCustomizationDisablement', () => { itemProvider: createMockItemProvider([{ uri: agentUri, type: PromptsType.agent, name: 'API Agent', extensionId: undefined, - pluginUri: undefined }]), enablementHandler: createMockEnablementHandler(), }); @@ -152,7 +150,6 @@ suite('aiCustomizationDisablement', () => { uri: agentUri, type: PromptsType.agent, name: 'Local Agent', storage: PromptsStorage.local, enablementScope: 'workspace', extensionId: undefined, - pluginUri: undefined }]), }); @@ -170,7 +167,6 @@ suite('aiCustomizationDisablement', () => { uri: agentUri, type: PromptsType.agent, name: 'Agent', enablementScope: 'workspace', enabled: false, extensionId: undefined, - pluginUri: undefined }]), enablementHandler: createMockEnablementHandler(), }); @@ -185,7 +181,6 @@ suite('aiCustomizationDisablement', () => { itemProvider: createMockItemProvider([{ uri: agentUri, type: PromptsType.agent, name: 'Agent', enablementScope: 'workspace', extensionId: undefined, - pluginUri: undefined }]), enablementHandler: createMockEnablementHandler(), }); @@ -205,7 +200,6 @@ suite('aiCustomizationDisablement', () => { itemProvider: createMockItemProvider([{ uri: agentUri, type: PromptsType.agent, name: 'Agent', enablementScope: 'workspace', extensionId: undefined, - pluginUri: undefined }]), enablementHandler: createMockEnablementHandler(), promptsService: ps, @@ -227,7 +221,6 @@ suite('aiCustomizationDisablement', () => { storage: PromptsStorage.local, enablementScope: 'workspace', enabled: false, extensionId: undefined, - pluginUri: undefined }]), enablementHandler: createMockEnablementHandler(), }); @@ -244,7 +237,6 @@ suite('aiCustomizationDisablement', () => { storage: PromptsStorage.local, enablementScope: 'workspace', enabled: true, extensionId: undefined, - pluginUri: undefined }]), enablementHandler: createMockEnablementHandler(), }); @@ -260,7 +252,6 @@ suite('aiCustomizationDisablement', () => { uri: skillUri, type: PromptsType.skill, name: 'Skill', storage: PromptsStorage.local, enablementScope: 'workspace', extensionId: undefined, - pluginUri: undefined }]), enablementHandler: createMockEnablementHandler(), }); @@ -284,7 +275,6 @@ suite('aiCustomizationDisablement', () => { uri: agentUri, type: PromptsType.agent, name: 'Agent', storage: PromptsStorage.local, enablementScope: 'workspace', extensionId: undefined, - pluginUri: undefined }]), promptsService: ps, }); @@ -301,7 +291,6 @@ suite('aiCustomizationDisablement', () => { uri: instructionUri, type: PromptsType.instructions, name: 'Rule', storage: PromptsStorage.local, enablementScope: 'workspace', extensionId: undefined, - pluginUri: undefined }]; // External harness reports item as disabled via enabled:false @@ -330,12 +319,10 @@ suite('aiCustomizationDisablement', () => { { uri: agentUri, type: PromptsType.agent, name: 'API Agent', enablementScope: 'global', enabled: false, extensionId: undefined, - pluginUri: undefined }, { uri: skillUri, type: PromptsType.agent, name: 'VS Code Agent', storage: PromptsStorage.local, enablementScope: 'workspace', extensionId: undefined, - pluginUri: undefined }, ]), enablementHandler: createMockEnablementHandler(), @@ -358,12 +345,10 @@ suite('aiCustomizationDisablement', () => { { uri: agentUri, type: PromptsType.agent, name: 'API Agent', enablementScope: 'global', extensionId: undefined, - pluginUri: undefined }, { uri: skillUri, type: PromptsType.agent, name: 'VS Code Agent', storage: PromptsStorage.local, enablementScope: 'workspace', enabled: false, extensionId: undefined, - pluginUri: undefined }, ]), enablementHandler: createMockEnablementHandler(), @@ -462,7 +447,6 @@ suite('aiCustomizationDisablement', () => { uri: agentUri, type: PromptsType.agent, name: 'Pre-Disabled', enabled: false, enablementScope: 'global', extensionId: undefined, - pluginUri: undefined }]), enablementHandler: createMockEnablementHandler(), }); @@ -503,7 +487,6 @@ suite('aiCustomizationDisablement', () => { uri: agentUri, type: PromptsType.agent, name: 'My Agent', storage: PromptsStorage.local, enablementScope: 'workspace', extensionId: undefined, - pluginUri: undefined }]), promptsService: ps, }); @@ -527,7 +510,6 @@ suite('aiCustomizationDisablement', () => { uri: agentUri, type: PromptsType.agent, name: 'My Agent', storage: PromptsStorage.local, enablementScope: 'workspace', extensionId: undefined, - pluginUri: undefined }]), }); @@ -551,7 +533,6 @@ suite('aiCustomizationDisablement', () => { enablementScope: 'global', enabled: false, extensionId: undefined, - pluginUri: undefined }]), enablementHandler: createMockEnablementHandler(), }); @@ -576,7 +557,6 @@ suite('aiCustomizationDisablement', () => { storage: PromptsStorage.local, enablementScope: 'workspace', enabled: false, extensionId: undefined, - pluginUri: undefined }]), enablementHandler: createMockEnablementHandler(), }); @@ -599,7 +579,6 @@ suite('aiCustomizationDisablement', () => { itemProvider: createMockItemProvider([{ uri: agentUri, type: PromptsType.agent, name: 'No Scope Agent', extensionId: undefined, - pluginUri: undefined }]), enablementHandler: createMockEnablementHandler(), }); diff --git a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts index d0a384e9aec7e..d108274ef914c 100644 --- a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts @@ -201,7 +201,7 @@ suite('CustomizationHarnessService', () => { const emitter = new Emitter(); store.add(emitter); const testItems = [ - { uri: URI.parse('file:///workspace/.claude/SKILL.md'), type: 'skill', name: 'Test Skill', description: 'A test skill', extensionId: undefined, pluginUri: undefined }, + { uri: URI.parse('file:///workspace/.claude/SKILL.md'), type: 'skill', name: 'Test Skill', description: 'A test skill', extensionId: undefined }, ]; const itemProvider: ICustomizationItemProvider = { @@ -372,10 +372,10 @@ suite('CustomizationHarnessService', () => { itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async () => [ - { uri: URI.parse('file:///workspace/.test/prompts/fix.prompt.md'), type: PromptsType.prompt, name: 'fix', description: 'Fix something', extensionId: undefined, pluginUri: undefined }, - { uri: URI.parse('file:///workspace/.test/skills/lint/SKILL.md'), type: PromptsType.skill, name: 'lint', description: 'Lint skill', extensionId: undefined, pluginUri: undefined }, - { uri: URI.parse('file:///workspace/.test/instructions/rule.instructions.md'), type: PromptsType.instructions, name: 'rule', description: 'Ignore me', extensionId: undefined, pluginUri: undefined }, - { uri: URI.parse('file:///workspace/.test/skills/disabled/SKILL.md'), type: PromptsType.skill, name: 'disabled', enabled: false, extensionId: undefined, pluginUri: undefined }, + { uri: URI.parse('file:///workspace/.test/prompts/fix.prompt.md'), type: PromptsType.prompt, name: 'fix', description: 'Fix something', extensionId: undefined }, + { uri: URI.parse('file:///workspace/.test/skills/lint/SKILL.md'), type: PromptsType.skill, name: 'lint', description: 'Lint skill', extensionId: undefined }, + { uri: URI.parse('file:///workspace/.test/instructions/rule.instructions.md'), type: PromptsType.instructions, name: 'rule', description: 'Ignore me', extensionId: undefined }, + { uri: URI.parse('file:///workspace/.test/skills/disabled/SKILL.md'), type: PromptsType.skill, name: 'disabled', enabled: false, extensionId: undefined }, ], }, }); @@ -461,8 +461,8 @@ suite('CustomizationHarnessService', () => { itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async () => [ - { uri: URI.parse('file:///workspace/.test/agents/selected.agent.md'), type: PromptsType.agent, name: 'selected', extensionId: undefined, pluginUri: undefined }, - { uri: URI.parse('file:///workspace/.test/agents/disabled.agent.md'), type: PromptsType.agent, name: 'disabled', enabled: false, extensionId: undefined, pluginUri: undefined }, + { uri: URI.parse('file:///workspace/.test/agents/selected.agent.md'), type: PromptsType.agent, name: 'selected', extensionId: undefined }, + { uri: URI.parse('file:///workspace/.test/agents/disabled.agent.md'), type: PromptsType.agent, name: 'disabled', enabled: false, extensionId: undefined }, ], }, }], testSessionType, promptsService); diff --git a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts index f0f8b8645b394..14e22585a7576 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts @@ -130,24 +130,32 @@ declare module 'vscode' { /** * Controls which disablement actions are available for this item. + * Only takes effect when {@link enablementCommand} is set — without + * a command, no toggle actions are shown regardless of this value. * - * Defaults to {@link ChatSessionCustomizationEnablementScope.None} when - * omitted — the item cannot be toggled unless the provider explicitly - * sets a scope. + * Defaults to {@link ChatSessionCustomizationEnablementScope.Global} when + * {@link enablementCommand} is set. + */ + readonly enablementScopeHint?: ChatSessionCustomizationEnablementScope; + + /** + * Optional command executed when the user sets this item's enablement. * - * Ignored when {@link pluginUri} is set — plugin items always use global-scope - * enablement targeting the plugin itself. + * The handler should persist the change and fire + * {@link ChatSessionCustomizationProvider.onDidChange} so the UI + * re-queries the updated state. */ - readonly enablementScope?: ChatSessionCustomizationEnablementScope; + readonly enablementCommand?: string; /** - * Optional URI of the parent plugin of this customization item. + * Optional human-readable reason why this item cannot be toggled. * - * When set, all enable/disable actions for this item target the plugin - * instead of the individual item, and the item's own - * {@link enablementScope} is ignored. + * Shown in the UI when neither {@link enablementCommand} nor a built-in + * toggle is available. For example, a plugin-contributed item + * might set this to explain that disabling requires uninstalling + * the plugin. */ - readonly pluginUri?: Uri; + readonly enablementDisabledReason?: string; } /** @@ -186,36 +194,6 @@ declare module 'vscode' { provideChatSessionCustomizations(token: CancellationToken): ProviderResult; } - /** - * A handler that persists enable/disable actions for chat customizations. - * - * When registered alongside a {@link ChatSessionCustomizationProvider}, - * the management UI delegates enable/disable actions to this handler. - * Without a handler, items reported by the provider cannot be toggled. - * - * @see {@link chat.registerChatSessionCustomizationProvider} - */ - export interface ChatSessionCustomizationEnablementHandler { - /** - * Called when the user enables or disables a customization in the - * management UI. The handler should persist the change and fire - * {@link ChatSessionCustomizationProvider.onDidChange} so the UI - * re-queries the updated state. - * - * @param uri The URI of the customization item. - * @param type The type of the customization. - * @param enabled Whether the customization should be enabled (`true`) or disabled (`false`). - * @param scope The scope at which enablement should be changed (e.g. {@link ChatSessionCustomizationEnablementScope.Global} or {@link ChatSessionCustomizationEnablementScope.Workspace}). - */ - handleCustomizationEnablement( - uri: Uri, - type: ChatSessionCustomizationType, - enabled: boolean, - scope: ChatSessionCustomizationEnablementScope, - token: CancellationToken - ): Thenable; - } - // #endregion // #region Registration @@ -230,14 +208,12 @@ declare module 'vscode' { * @param chatSessionType The session type this provider is for (e.g. `'cli'`, `'claude'`). * @param metadata Metadata describing the provider's capabilities and UI presentation. * @param provider The customization provider implementation. - * @param enablementHandler Optional handler for enable/disable actions. When omitted, items reported by the provider cannot be toggled. * @returns A disposable that unregisters the provider when disposed. */ export function registerChatSessionCustomizationProvider( chatSessionType: string, metadata: ChatSessionCustomizationProviderMetadata, provider: ChatSessionCustomizationProvider, - enablementHandler?: ChatSessionCustomizationEnablementHandler ): Disposable; } From 0c08bc599b66f4b403ae364e85f3cf1df6881214 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Tue, 28 Apr 2026 14:30:56 -0700 Subject: [PATCH 36/36] PR --- .../copilotCLICustomizationProvider.ts | 8 +++--- .../claudeCustomizationProvider.ts | 8 +++--- .../api/common/extHostChatAgents2.ts | 4 +-- ...osed.chatSessionCustomizationProvider.d.ts | 27 ++++++++++--------- 4 files changed, 24 insertions(+), 23 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts index 20dbd0f1cf3d4..9e1a430b682fb 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts @@ -126,7 +126,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod vscodeOwned: !!extensionId, // Only extension-contributed agents (owned by VS Code) support CLI-side disablement ...(extensionId ? { - enabled: !this._disablementStore.isDisabled(sourceUri, 'agent'), + disabled: this._disablementStore.isDisabled(sourceUri, 'agent') ? { reason: l10n.t('Disabled') } : undefined, enablementScopeHint: vscode.ChatSessionCustomizationEnablementScope.Global, enablementCommand: CopilotCLICustomizationProvider.enablementCommandId, } : { @@ -193,7 +193,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod // Only extension-contributed instructions support CLI-side disablement const hasEnablement = !!instruction.extensionId; const enablementProps = hasEnablement ? { - enabled: !this._disablementStore.isDisabled(uri, 'instructions'), + disabled: this._disablementStore.isDisabled(uri, 'instructions') ? { reason: l10n.t('Disabled') } : undefined, enablementScopeHint: vscode.ChatSessionCustomizationEnablementScope.Global, enablementCommand: CopilotCLICustomizationProvider.enablementCommandId, } : { @@ -247,7 +247,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod type: vscode.ChatSessionCustomizationType.Skill, name, vscodeOwned: true, - enabled: !this._disablementStore.isDisabled(s.uri, 'skill'), + disabled: this._disablementStore.isDisabled(s.uri, 'skill') ? { reason: l10n.t('Disabled') } : undefined, enablementScopeHint: vscode.ChatSessionCustomizationEnablementScope.Global, enablementCommand: CopilotCLICustomizationProvider.enablementCommandId, }; @@ -286,7 +286,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod uri: p.uri, type: vscode.ChatSessionCustomizationType.Plugins, name, - enabled: enabledPlugins[name] !== false, + disabled: enabledPlugins[name] === false ? { reason: l10n.t('Disabled') } : undefined, enablementScopeHint: vscode.ChatSessionCustomizationEnablementScope.Global, enablementCommand: CopilotCLICustomizationProvider.enablementCommandId, }; diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts index 8fb04a9f38410..1a1f58cfbbd3e 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts @@ -154,7 +154,7 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch uri: skill.uri, type: vscode.ChatSessionCustomizationType.Skill, name: skill.name, - enabled: override !== 'off', + disabled: override === 'off' ? { reason: vscode.l10n.t('Disabled via skill overrides') } : undefined, enablementScopeHint: vscode.ChatSessionCustomizationEnablementScope.Workspace, enablementCommand: ClaudeCustomizationProvider.enablementCommandId, }; @@ -166,7 +166,7 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch type: vscode.ChatSessionCustomizationType.Skill, name: skill.name, vscodeOwned: true, - enabled: !this._disablementStore.isDisabled(skill.uri, 'skill'), + disabled: this._disablementStore.isDisabled(skill.uri, 'skill') ? { reason: vscode.l10n.t('Disabled') } : undefined, enablementScopeHint: vscode.ChatSessionCustomizationEnablementScope.Global, enablementCommand: ClaudeCustomizationProvider.enablementCommandId, }); @@ -238,7 +238,7 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch type: vscode.ChatSessionCustomizationType.Instructions, name, enablementScopeHint: itemEnablementScope, - enabled: !excluded, + disabled: excluded ? { reason: vscode.l10n.t('Excluded via settings') } : undefined, enablementCommand: itemEnablementScope !== vscode.ChatSessionCustomizationEnablementScope.None ? ClaudeCustomizationProvider.enablementCommandId : undefined, @@ -297,7 +297,7 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch type: vscode.ChatSessionCustomizationType.Hook, name: `${eventId}${matcherLabel}`, description, - enabled: !disableAllHooks, + disabled: disableAllHooks ? { reason: vscode.l10n.t('All hooks disabled') } : undefined, // TODO: There isn't a great way to toggle enablement for individual hooks enablementScopeHint: vscode.ChatSessionCustomizationEnablementScope.None, }); diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 8d8f5bda38882..53c14dacdf45f 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -836,12 +836,12 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS groupKey: item.groupKey, badge: item.badge, badgeTooltip: item.badgeTooltip, - enabled: item.enabled, + enabled: item.disabled ? false : undefined, enablementScope: item.enablementScopeHint !== undefined ? ExtHostChatAgents2._enablementScopeMap[item.enablementScopeHint] : item.enablementCommand ? 'global' : undefined, enablementCommand: item.enablementCommand, - enablementMessage: item.enablementDisabledReason, + enablementMessage: item.disabled?.reason, } satisfies IChatSessionCustomizationItemDto); return items.map(convertItem); diff --git a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts index 14e22585a7576..8f56112de3db3 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts @@ -123,10 +123,21 @@ declare module 'vscode' { readonly badgeTooltip?: string; /** - * Whether this customization is currently enabled. - * Defaults to `true` when omitted. + * Marks that this customization is currently disabled. + * + * When set, the item appears grayed-out in the management UI with + * the {@link ChatSessionCustomizationItemDisabled.reason reason} + * shown on hover. + * + * When omitted, the item is considered enabled. */ - readonly enabled?: boolean; + readonly disabled?: { + /** + * Human-readable description of why this customization is + * currently disabled. Displayed in the management UI. + */ + readonly reason: string; + }; /** * Controls which disablement actions are available for this item. @@ -146,16 +157,6 @@ declare module 'vscode' { * re-queries the updated state. */ readonly enablementCommand?: string; - - /** - * Optional human-readable reason why this item cannot be toggled. - * - * Shown in the UI when neither {@link enablementCommand} nor a built-in - * toggle is available. For example, a plugin-contributed item - * might set this to explain that disabling requires uninstalling - * the plugin. - */ - readonly enablementDisabledReason?: string; } /**