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..f2862df2d95e2 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/claude/common/claudeSettingsService.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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'); + +export enum ClaudeSettingsLocationType { + // ~/.claude/settings.json + User = 'user', + // /.claude/settings.json + Workspace = 'workspace', + // /.claude/settings.local.json + WorkspaceLocal = 'workspaceLocal', +} + +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. + */ + getUri(location: ClaudeSettingsLocationType, workspaceUri: 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..8f999f1b7f3ae --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeSettingsService.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../util/vs/base/common/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 workspaceService: IWorkspaceService, + @IFileSystemService fileSystemService: IFileSystemService, + @INativeEnvService envService: INativeEnvService, + ) { + super(CLAUDE_LOCATIONS, workspaceService, fileSystemService, envService); + } + + protected getDefaultSettings(): ClaudeSettings { + return {}; + } +} 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..a97cacea2e774 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeSettingsService.spec.ts @@ -0,0 +1,330 @@ +/*--------------------------------------------------------------------------------------------- + * 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({ env: { DEBUG: '1' } }); + expect(results[1].settings).toEqual({ permissions: { deny: ['Write'] } }); + 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 () => { + 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); + const userSettings = second.find(f => f.uri.toString() === userUri.toString()); + expect(userSettings?.settings).toEqual({ updated: true }); + }); + }); +}); 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..71e67f9a460d8 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/common/baseSessionSettingsService.ts @@ -0,0 +1,155 @@ +/*--------------------------------------------------------------------------------------------- + * 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); + // Eagerly invalidate so that subsequent reads (before the file + // watcher fires) return fresh data. + this._settingsCache = undefined; + } + + /** + * 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/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/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 1805638b146a6..bf8ccdcd113dc 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts @@ -278,7 +278,9 @@ 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; + /** The contributing plugin URI, when the agent came from a plugin. */ readonly pluginUri?: URI; } @@ -377,7 +379,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 { @@ -444,6 +446,7 @@ export class CopilotCLIAgents extends Disposable implements ICopilotCLIAgents { ...(model ? { model } : {}), }, sourceUri: customAgent.uri, + extensionId: customAgent.extensionId, }; } 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/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts index 95ff49d8534e5..9e1a430b682fb 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts @@ -14,15 +14,31 @@ 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'; +import { CopilotCLISettingsLocationType, ICopilotCLISettingsService } from '../common/copilotCLISettingsService'; +import { ExtensionDisablementStore } from '../../common/extensionDisablementStore'; +import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext'; + +/** + * 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; +} export class CopilotCLICustomizationProvider extends Disposable implements vscode.ChatSessionCustomizationProvider { private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange = this._onDidChange.event; + private readonly _disablementStore: ExtensionDisablementStore; + private _lastVscodeOwnedUris = new Set(); + + static readonly enablementCommandId = 'copilot.copilotcli.handleCustomizationEnablement'; + static get metadata(): vscode.ChatSessionCustomizationProviderMetadata { return { label: 'Copilot CLI', @@ -44,15 +60,23 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod @ILogService private readonly logService: ILogService, @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())); 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())); + + 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 { @@ -62,7 +86,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; } @@ -77,22 +101,37 @@ 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]; + + // 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`); + 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 }) => ({ + return agentInfos.map(({ agent, sourceUri, extensionId }) => ({ uri: sourceUri, type: vscode.ChatSessionCustomizationType.Agent, name: agent.displayName || agent.name, description: agent.description, + vscodeOwned: !!extensionId, + // Only extension-contributed agents (owned by VS Code) support CLI-side disablement + ...(extensionId ? { + disabled: this._disablementStore.isDisabled(sourceUri, 'agent') ? { reason: l10n.t('Disabled') } : undefined, + enablementScopeHint: vscode.ChatSessionCustomizationEnablementScope.Global, + enablementCommand: CopilotCLICustomizationProvider.enablementCommandId, + } : { + enablementScopeHint: vscode.ChatSessionCustomizationEnablementScope.None, + }), })); } @@ -104,7 +143,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(); @@ -121,12 +160,13 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod } } - const items: vscode.ChatSessionCustomizationItem[] = []; + const items: CLICustomizationItem[] = []; const seenUris = new Set(); // 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({ @@ -150,6 +190,15 @@ 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 ? { + disabled: this._disablementStore.isDisabled(uri, 'instructions') ? { reason: l10n.t('Disabled') } : undefined, + enablementScopeHint: vscode.ChatSessionCustomizationEnablementScope.Global, + enablementCommand: CopilotCLICustomizationProvider.enablementCommandId, + } : { + enablementScopeHint: vscode.ChatSessionCustomizationEnablementScope.None, + }; if (pattern !== undefined) { const badge = pattern === '**' @@ -166,6 +215,8 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod groupKey: 'context-instructions', badge, badgeTooltip, + vscodeOwned: hasEnablement, + ...enablementProps, }); } else { items.push({ @@ -174,6 +225,8 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod name, description, groupKey: 'on-demand-instructions', + vscodeOwned: hasEnablement, + ...enablementProps, }); } } @@ -184,36 +237,148 @@ 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, - extensionId: s.extensionId, - pluginUri: s.pluginUri, - })); + private async getSkillItems(token: vscode.CancellationToken): Promise { + return (await this.promptsService.getSkills(token)).filter(isEnabledForCopilotCLI).map(s => { + const name = s.name; + // Only extension-contributed skills support CLI-side disablement + if (s.extensionId) { + return { + uri: s.uri, + type: vscode.ChatSessionCustomizationType.Skill, + name, + vscodeOwned: true, + disabled: this._disablementStore.isDisabled(s.uri, 'skill') ? { reason: l10n.t('Disabled') } : undefined, + enablementScopeHint: vscode.ChatSessionCustomizationEnablementScope.Global, + enablementCommand: CopilotCLICustomizationProvider.enablementCommandId, + }; + } + return { + uri: s.uri, + type: vscode.ChatSessionCustomizationType.Skill, + name, + enablementScopeHint: vscode.ChatSessionCustomizationEnablementScope.None, + }; + }); } /** * Collects all hook items from the prompt file service. * Each item is a hook configuration file (JSON). */ - private async getHookItems(token: vscode.CancellationToken): Promise { + 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, ''), + enablementScopeHint: vscode.ChatSessionCustomizationEnablementScope.None, })); } /** * 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), - })); + 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 => { + const name = basename(p.uri); + return { + uri: p.uri, + type: vscode.ChatSessionCustomizationType.Plugins, + name, + disabled: enabledPlugins[name] === false ? { reason: l10n.t('Disabled') } : undefined, + enablementScopeHint: vscode.ChatSessionCustomizationEnablementScope.Global, + enablementCommand: CopilotCLICustomizationProvider.enablementCommandId, + }; + }); + } + + // --- Enablement --- + + /** + * Reads the user-level settings from the settings service. + */ + private async _readUserSettings() { + const allSettings = await this.copilotCLISettingsService.readAllSettings(); + return allSettings[0]?.settings ?? {}; + } + + /** + * Returns the URI of the user-level settings file. + */ + private get _settingsUri(): URI { + return this.copilotCLISettingsService.getUris(CopilotCLISettingsLocationType.User)[0]; + } + + async handleCustomizationEnablement(uri: vscode.Uri, typeId: string, enabled: boolean, _scope: string): Promise { + const settings = await this._readUserSettings(); + let name: string; + + 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'); + this._onDidChange.fire(); + return; + } 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 (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)) + ? { ...settings.enabledPlugins as Record } + : {}; + if (enabled) { + delete map[name]; + } else { + map[name] = false; + } + settings.enabledPlugins = Object.keys(map).length > 0 ? map : undefined; + } 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 (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: ${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'} ${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))); + } + } + + /** + * 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): 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 a9f947bdfde35..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 @@ -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'; @@ -15,6 +34,9 @@ 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 { IFileSystemService } from '../../../../../platform/filesystem/common/fileSystemService'; +import { SessionSettingsFile } from '../../../common/sessionSettingsService'; +import { ICopilotCLISettingsService, CopilotCLISettings, CopilotCLISettingsLocationType } from '../../common/copilotCLISettingsService'; class FakeChatSessionCustomizationType { static readonly Agent = new FakeChatSessionCustomizationType('agent'); @@ -26,6 +48,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, @@ -54,13 +120,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. */ @@ -73,6 +148,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; @@ -101,30 +219,46 @@ describe('CopilotCLICustomizationProvider', () => { let mockPromptsService: MockPromptsService; let mockCopilotCLIAgents: MockCopilotCLIAgents; let mockCustomInstructionsService: TestCustomInstructionsService; + let mockFileSystemService: MockFileSystemService; + let mockCopilotCLISettingsService: MockCopilotCLISettingsService; + let mockGlobalState: MockMemento; + let mockWorkspaceState: MockMemento; 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)); + mockGlobalState = new MockMemento(); + mockWorkspaceState = new MockMemento(); provider = disposables.add(new CopilotCLICustomizationProvider( mockCopilotCLIAgents, mockCustomInstructionsService, mockPromptsService, new TestLogService(), { getWorkspaceFolders: () => [] } as any, - { stat: () => Promise.reject(new Error('not found')) } as any, + mockFileSystemService, + mockCopilotCLISettingsService, + { globalState: mockGlobalState, workspaceState: mockWorkspaceState } as any, )); }); afterEach(() => { disposables.dispose(); (vscode as Record).ChatSessionCustomizationType = originalChatSessionCustomizationType; + (vscode as Record).ChatSessionCustomizationEnablementScope = originalChatSessionCustomizationEnablementScope; }); describe('metadata', () => { @@ -340,6 +474,8 @@ describe('CopilotCLICustomizationProvider', () => { ? Promise.resolve({ type: 1, ctime: 0, mtime: 0, size: 0 }) : Promise.reject(new Error('not found')), } as any, + mockCopilotCLISettingsService, + { globalState: new MockMemento(), workspaceState: new MockMemento() } as any, )); mockPromptsService.setInstructions([]); @@ -487,5 +623,268 @@ 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 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', '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 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!); + const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill); + expect(skillItems[0].enabled).toBe(false); + }); + + 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', '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 extension-contributed skills', async () => { + const uri = URI.file('/workspace/.github/skills/lint-check/SKILL.md'); + 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].enablementScopeHint).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].enablementScopeHint).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'); + + await provider.handleCustomizationEnablement( + skillUri, FakeChatSessionCustomizationType.Skill.id, + false, 'global'); + + 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.id, + true, 'global'); + + 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.id, + false, 'global'); + + 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.id, + false, 'global'); + + 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].enablementScopeHint).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.id, + false, 'global'); + + 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.id, + true, 'global'); + + 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.id, + true, 'global'); + + 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.id, + false, 'global'); + + 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].enablementScopeHint).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].enablementScopeHint).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].enablementScopeHint).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].enablementScopeHint).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].enablementScopeHint).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].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 c610cc0eddd96..f8f7bb35d2d4e 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'; @@ -51,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'; @@ -149,6 +153,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)); @@ -194,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() )); @@ -263,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 89f766fd1a620..1a1f58cfbbd3e 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'; @@ -10,15 +11,27 @@ 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 { 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()) +/** + * 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 @@ -35,35 +48,16 @@ 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; - -interface HookConfig { - readonly type: string; - readonly command: string; -} - -interface MatcherConfig { - readonly matcher: string; - readonly hooks: HookConfig[]; -} - -interface HooksSettings { - readonly hooks?: Partial>; -} - export class ClaudeCustomizationProvider extends Disposable implements vscode.ChatSessionCustomizationProvider { private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange = this._onDidChange.event; + private readonly _disablementStore: ExtensionDisablementStore; + private _lastVscodeOwnedUris = new Set(); + + static readonly enablementCommandId = 'copilot.claude.handleCustomizationEnablement'; + static get metadata(): vscode.ChatSessionCustomizationProviderMetadata { return { label: 'Claude', @@ -80,21 +74,30 @@ 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, @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())); 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 { - const items: vscode.ChatSessionCustomizationItem[] = []; + const items: ClaudeCustomizationItem[] = []; + const settingsFiles = await this.claudeSettingsService.readAllSettings(); // 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. @@ -130,36 +133,65 @@ 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 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 skillItems: vscode.ChatSessionCustomizationItem[] = []; + // 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: ClaudeCustomizationItem[] = []; for (const skill of await this.promptsService.getSkills(token)) { if (this.isClaudePath(skill.uri)) { - const item: vscode.ChatSessionCustomizationItem = { + const skillName = basename(dirname(skill.uri)); + const override = skillOverrides[skillName]; + const item: ClaudeCustomizationItem = { uri: skill.uri, type: vscode.ChatSessionCustomizationType.Skill, name: skill.name, + disabled: override === 'off' ? { reason: vscode.l10n.t('Disabled via skill overrides') } : undefined, + enablementScopeHint: vscode.ChatSessionCustomizationEnablementScope.Workspace, + enablementCommand: ClaudeCustomizationProvider.enablementCommandId, }; skillItems.push(item); + } else if (skill.extensionId) { + // Extension-contributed skills (owned by VS Code) + skillItems.push({ + uri: skill.uri, + type: vscode.ChatSessionCustomizationType.Skill, + name: skill.name, + vscodeOwned: true, + disabled: this._disablementStore.isDisabled(skill.uri, 'skill') ? { reason: vscode.l10n.t('Disabled') } : undefined, + enablementScopeHint: vscode.ChatSessionCustomizationEnablementScope.Global, + enablementCommand: ClaudeCustomizationProvider.enablementCommandId, + }); } } items.push(...skillItems); 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(settingsFiles); items.push(...hookItems); this.logService.debug(`[ClaudeCustomizationProvider] hooks (${hookItems.length}): ${hookItems.map(h => h.name).join(', ') || '(none)'}`); this.logService.debug(`[ClaudeCustomizationProvider] total: ${items.length} items`); + + // Track vscode-owned URIs for routing enablement handlers + this._lastVscodeOwnedUris = new Set( + items.filter(i => i.vscodeOwned).map(i => i.uri.toString()), + ); + return items; } - private async discoverInstructions(): Promise { - const items: vscode.ChatSessionCustomizationItem[] = []; + private async discoverInstructions(settingsFiles: Readonly): Promise { + const items: ClaudeCustomizationItem[] = []; const candidates: URI[] = []; for (const folder of this.workspaceService.getWorkspaceFolders()) { @@ -179,10 +211,37 @@ 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, ''); + + 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); + } + } + } + + const itemEnablementScope = excludedByUnknownPattern + ? vscode.ChatSessionCustomizationEnablementScope.None + : vscode.ChatSessionCustomizationEnablementScope.Workspace; items.push({ uri, type: vscode.ChatSessionCustomizationType.Instructions, name, + enablementScopeHint: itemEnablementScope, + disabled: excluded ? { reason: vscode.l10n.t('Excluded via settings') } : undefined, + enablementCommand: itemEnablementScope !== vscode.ChatSessionCustomizationEnablementScope.None + ? ClaudeCustomizationProvider.enablementCommandId + : undefined, }); } } @@ -199,32 +258,48 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch } } - private async discoverHooks(): Promise { - const items: vscode.ChatSessionCustomizationItem[] = []; - const settingsPaths = this.getSettingsFilePaths(); + private async discoverHooks(settingsFiles: Readonly): Promise { + const items: ClaudeCustomizationItem[] = []; - for (const settingsUri of settingsPaths) { + let disableAllHooks = false; + for (const settingsFile of settingsFiles) { try { - const content = await this.fileSystemService.readFile(settingsUri); - const settings: HooksSettings = JSON.parse(new TextDecoder().decode(content)); - if (!settings.hooks) { + if (!settingsFile.settings.hooks || typeof settingsFile.settings.hooks !== 'object') { continue; } - for (const eventId of HOOK_EVENT_IDS) { - const matchers = settings.hooks[eventId]; - if (!matchers || matchers.length === 0) { + // 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 (!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) { + 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: settingsUri, + uri: settingsFile.uri, type: vscode.ChatSessionCustomizationType.Hook, name: `${eventId}${matcherLabel}`, - description: hook.command, + description, + 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, }); } } @@ -237,18 +312,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) { @@ -273,6 +336,147 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch return false; } + + // --- Settings --- + + /** + * Checks whether a URI matches a claudeMdExcludes pattern. + * Patterns are matched against absolute file paths using picomatch, + * consistent with how Claude Code evaluates them. + */ + private _matchesExclude(uri: URI, pattern: string): boolean { + 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); + } + return matcher; + } + + // --- Enablement --- + + 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 === 'workspace' + ? ClaudeSettingsLocationType.Workspace + : ClaudeSettingsLocationType.User; + + 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 (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'); + this._onDidChange.fire(); + return; + } 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') { + // skip malformed skillOverrides + this.logService.warn(`[ClaudeCustomizationProvider] Skipping malformed skillOverrides in ${file.uri.toString()}`); + continue; + } + + 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); + } + } + } + } 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'); + this._onDidChange.fire(); + return; + } else { + // Claude-native instruction — use claudeMdExcludes + const instructionsUri = uri; + 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 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 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 (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) { + 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: ${typeId}`); + void vscode.window.showErrorMessage(vscode.l10n.t('Toggling {0} customizations is not supported.', typeId)); + return; + } + + this.logService.debug(`[ClaudeCustomizationProvider] ${enabled ? 'Enabled' : 'Disabled'} ${typeId} in ${location}`); + this._onDidChange.fire(); + } + } 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 0a480c862e244..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 @@ -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,9 +15,29 @@ 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'; +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; } @@ -32,9 +52,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 +103,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,39 +174,48 @@ 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(), new TestLogService(), + { globalState: new MockMemento(), workspaceState: new MockMemento() } as any, )); }); afterEach(() => { disposables.dispose(); (vscode as Record).ChatSessionCustomizationType = originalChatSessionCustomizationType; + (vscode as Record).ChatSessionCustomizationEnablementScope = originalChatSessionCustomizationEnablementScope; }); describe('metadata', () => { @@ -313,6 +414,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].enablementScopeHint).toBe(FakeChatSessionCustomizationEnablementScope.Workspace); + }); }); describe('combined items', () => { @@ -321,10 +484,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 +499,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 +523,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 +539,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,46 +559,211 @@ 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!); - expect(items).toEqual([]); + const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook); + expect(hookItems[0].enabled).toBe(true); }); - it('gracefully handles invalid JSON in settings', async () => { + 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('disables hooks in lower-priority settings when higher-priority settings has disableAllHooks', async () => { const workspaceFolder = URI.file('/workspace'); mockWorkspaceService.setFolders([workspaceFolder]); - mockFileSystemService.setFile( - URI.joinPath(workspaceFolder, '.claude', 'settings.json'), - 'not valid json {' - ); + + 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')]); 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.id, + false, 'workspace'); + + const written = mockClaudeSettingsService.getWrittenFile(settingsUri); + expect(written).toBeDefined(); + expect(written!.disableAllHooks).toBe(true); + }); + + it('enables hooks by removing disableAllHooks', async () => { + const workspaceFolder = URI.file('/workspace'); + mockWorkspaceService.setFolders([workspaceFolder]); + 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.id, + true, 'workspace'); + + const written = mockClaudeSettingsService.getWrittenFile(settingsUri); + expect(written).toBeDefined(); + expect(written!.disableAllHooks).toBeUndefined(); + }); + + 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.id, + false, 'workspace'); + + 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.id, + false, 'workspace'); + + 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.id, + false, 'workspace'); + + expect(fired).toBe(true); + }); + }); + describe('onDidChange', () => { it('fires when runtime data changes', () => { let fired = false; @@ -466,5 +796,209 @@ 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); + }); + }); + + 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.id, + false, 'workspace'); + + 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.id, + true, 'workspace'); + + 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.id, + true, 'workspace'); + + 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.id, + false, 'workspace'); + + 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].enablementScopeHint).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].enablementScopeHint).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.id, + false, 'workspace'); + + 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.id, + true, 'workspace'); + + 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.id, + true, 'workspace'); + + 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'); + }); }); }); 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().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/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/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/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/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 7f7211ae2af4b..d9a4ed87c97d0 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)); } @@ -738,7 +738,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, @@ -746,9 +746,13 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA groupKey: item.groupKey, badge: item.badge, badgeTooltip: item.badgeTooltip, + enabled: item.enabled, + enablementScope: item.enablementScope, + enablementCommand: item.enablementCommand, + enablementMessage: item.enablementMessage, extensionId: undefined, - pluginUri: undefined - })); + }); + return items.map(i => convertItem(i)); }, }; 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 2ba3793dc283a..d75ca55478a64 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1740,6 +1740,10 @@ export interface IChatSessionCustomizationItemDto { readonly badge?: string; readonly badgeTooltip?: string; + readonly enabled?: boolean; + readonly enablementScope?: 'none' | 'global' | 'workspace'; + 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 0b58d688ac7e4..53c14dacdf45f 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()); @@ -823,15 +828,23 @@ 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, description: item.description, groupKey: item.groupKey, badge: item.badge, - badgeTooltip: item.badgeTooltip - } satisfies IChatSessionCustomizationItemDto)); + badgeTooltip: item.badgeTooltip, + enabled: item.disabled ? false : undefined, + enablementScope: item.enablementScopeHint !== undefined + ? ExtHostChatAgents2._enablementScopeMap[item.enablementScopeHint] + : item.enablementCommand ? 'global' : undefined, + enablementCommand: item.enablementCommand, + enablementMessage: item.disabled?.reason, + } satisfies IChatSessionCustomizationItemDto); + + return items.map(convertItem); } catch (err) { return undefined; } 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/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 2d643c9065853..b5103435ff838 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'; @@ -24,7 +24,7 @@ import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWo import { ICustomizationSyncProvider, ICustomizationItem, ICustomizationItemProvider } 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'; @@ -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; @@ -67,14 +66,38 @@ 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. Defaults to 'none' (not disableable) when absent. */ + readonly enablementScope?: 'none' | 'global' | 'workspace'; + /** 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. */ 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. * @@ -118,8 +141,68 @@ 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 + * 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 + * individual hooks within. Falls back to the original item (no children) * when parsing fails. */ export async function expandHookFileItems( @@ -127,8 +210,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; @@ -142,25 +225,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, - extensionId: item.extensionId, - pluginUri: item.pluginUri + 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. @@ -190,7 +276,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) @@ -198,7 +284,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); @@ -226,6 +312,10 @@ export class AICustomizationItemNormalizer { extensionLabel, status: item.status, statusMessage: item.statusMessage, + enablementScope: item.enablementScope, + enablementCommand: item.enablementCommand, + enablementMessage: item.enablementMessage, + hookChildren: item.hookChildren, }; } @@ -321,21 +411,26 @@ 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 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, ); } @@ -356,7 +451,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. @@ -375,6 +470,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); } @@ -433,7 +558,11 @@ 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); + // 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 + ? 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); if (overriddenNames.has(name)) { @@ -451,8 +580,8 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour enabled: !disabledPromptFiles.has(p.uri), badge: uiTooltip ? uiIntegrationBadge : undefined, badgeTooltip: uiTooltip, + enablementScope: 'workspace', extensionId: undefined, - pluginUri: undefined }; appended.push(this.itemNormalizer.normalizeItem(builtinItem, promptType, uriUseCounts)); } @@ -480,6 +609,11 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour return []; } + // 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 + ? 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) .map(file => ({ @@ -487,9 +621,9 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour type: promptType, name: getFriendlyName(basename(file.uri)), groupKey: 'sync-local', - enabled: true, + 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 8f70afe369c7e..17cc803981703 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, 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'; @@ -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 = 44; +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,221 @@ 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 textContainer = DOM.append(leftSection, $('.hook-file-text')); + const nameRow = DOM.append(textContainer, $('.hook-file-name-row')); + const label = DOM.append(nameRow, $('.hook-file-label')); + const count = DOM.append(nameRow, $('.hook-file-count')); + const description = DOM.append(textContainer, $('.hook-file-description')); + + 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, description, 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 : Codicon.file)); + + // Label (filename) + templateData.label.textContent = item.displayName ?? formatDisplayName(item.name); + + // Count + const childCount = item.hookChildren?.length ?? 0; + templateData.count.textContent = childCount > 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); + + // 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.pluginUri?.toString(), + providerEnablementScope: item.enablementScope, + enablementCommand: item.enablementCommand, + }; + + 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.pluginUri], + ]; + 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. */ @@ -305,9 +554,13 @@ class AICustomizationItemRenderer implements IListRenderer { @@ -430,14 +683,22 @@ class AICustomizationItemRenderer implements IListRenderer(); + private readonly collapsedHookFiles = new Set(); private _layoutDeferred = false; private readonly dropdownActionDisposables = this._register(new DisposableStore()); private _loadItemsSeq = 0; @@ -538,6 +800,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; @@ -688,16 +953,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; @@ -708,18 +989,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); } @@ -754,7 +1046,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; } @@ -768,14 +1060,22 @@ export class AICustomizationListWidget extends Disposable { storage: item.storage, pluginUri: item.pluginUri?.toString(), itemId: item.id, + plugin: item.pluginUri?.toString(), + providerEnablementScope: item.enablementScope, + enablementCommand: item.enablementCommand, }; // Create scoped context key service with item-specific keys for when-clause filtering + 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, this.harnessService.getActiveDescriptor().supportsTroubleshoot ?? false], + [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.pluginUri], ]; if (item.storage) { overlayPairs.push([AI_CUSTOMIZATION_ITEM_STORAGE_KEY, item.storage]); @@ -1144,7 +1444,7 @@ export class AICustomizationListWidget extends Disposable { this.allItems = items; this.filterItems(); - this._onDidChangeItemCount.fire(items.length); + this._onDidChangeItemCount.fire(this.computeEffectiveItemCount(items, section)); } /** @@ -1153,6 +1453,17 @@ export class AICustomizationListWidget extends Disposable { */ async computeItemCountForSection(section: AICustomizationManagementSection): Promise { 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; } @@ -1183,6 +1494,7 @@ export class AICustomizationListWidget extends Disposable { this.fileService, this.pathService, this.itemNormalizer, + !!descriptor.itemProvider, ); this.cachedItemSource = { descriptorId: descriptor.id, source }; return source; @@ -1209,7 +1521,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, @@ -1224,6 +1541,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 @@ -1231,6 +1549,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) { @@ -1240,13 +1560,23 @@ export class AICustomizationListWidget extends Disposable { const collapsed = this.collapsedGroups.has(group.groupKey); + // For hooks, count the total number of individual hooks across all files, + // excluding files that don't contribute any hooks. + const itemCount = isHookSection + ? 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}`, groupKey: group.groupKey, label: group.label, icon: group.icon, - count: group.items.length, + count: itemCount, isFirst: isFirstGroup, description: group.description, collapsed, @@ -1255,7 +1585,21 @@ 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 if (isHookSection) { + // Hide hook files that don't contribute any hooks + continue; + } else { + this.displayEntries.push({ type: 'file-item', item }); + } } } } @@ -1375,6 +1719,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..176b582674301 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; pluginUri?: unknown }): { + readonly enablementScope: string; + readonly isDisableable: boolean; +} { + const enablementScope = item.pluginUri ? 'global' : (item.enablementScope ?? 'none'); + const isDisableable = !!item.pluginUri || 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 2f602be4ac8c8..6a76294d0ba88 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -35,8 +35,9 @@ import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWo import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; 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'; @@ -48,6 +49,8 @@ import { AI_CUSTOMIZATION_MANAGEMENT_EDITOR_ID, AI_CUSTOMIZATION_MANAGEMENT_EDITOR_INPUT_ID, AI_CUSTOMIZATION_SUPPORTS_TROUBLESHOOT_KEY, + AI_CUSTOMIZATION_ENABLEMENT_SCOPE_KEY, + AI_CUSTOMIZATION_ITEM_DISABLEABLE_KEY, AICustomizationManagementCommands, AICustomizationManagementItemMenuId, AICustomizationManagementSection, @@ -176,6 +179,30 @@ function extractPluginUri(context: AICustomizationContext): URI | undefined { return URI.isUri(raw) ? raw : typeof raw === 'string' ? URI.parse(raw) : undefined; } +/** + * Extracts the serialized plugin URI from context, if present. + */ +function extractPlugin(context: AICustomizationContext): URI | undefined { + if (URI.isUri(context) || typeof context === 'string') { + return undefined; + } + const raw = context.plugin; + if (!raw || typeof raw !== 'string') { + return undefined; + } + return URI.parse(raw); +} + +/** + * Extracts the enablement command ID from context, if present. + */ +function extractEnablementCommand(context: AICustomizationContext): string | undefined { + if (URI.isUri(context) || typeof context === 'string') { + return undefined; + } + return typeof context.enablementCommand === 'string' ? context.enablementCommand : undefined; +} + /** * Extracts the item name from context. */ @@ -496,7 +523,10 @@ MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { command: { id: TROUBLESHOOT_AI_CUSTOMIZATION_ID, title: localize('troubleshootInline', "Troubleshoot"), icon: Codicon.bug }, group: 'inline', order: 2, - when: ContextKeyExpr.equals(AI_CUSTOMIZATION_SUPPORTS_TROUBLESHOOT_KEY, true), + when: ContextKeyExpr.and( + ContextKeyExpr.equals(AI_CUSTOMIZATION_SUPPORTS_TROUBLESHOOT_KEY, true), + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, false), + ), }); // Context menu items (shown on right-click) @@ -510,14 +540,20 @@ MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { command: { id: RUN_PROMPT_MGMT_ID, title: localize('runPrompt', "Run Prompt"), icon: Codicon.play }, group: '2_run', order: 1, - when: ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_TYPE_KEY, PromptsType.prompt), + when: ContextKeyExpr.and( + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_TYPE_KEY, PromptsType.prompt), + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, false), + ), }); MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { command: { id: TROUBLESHOOT_AI_CUSTOMIZATION_ID, title: localize('troubleshootItem', "Troubleshoot") }, group: '2_run', order: 2, - when: ContextKeyExpr.equals(AI_CUSTOMIZATION_SUPPORTS_TROUBLESHOOT_KEY, true), + when: ContextKeyExpr.and( + ContextKeyExpr.equals(AI_CUSTOMIZATION_SUPPORTS_TROUBLESHOOT_KEY, true), + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, false), + ), }); MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { @@ -629,6 +665,98 @@ MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { when: WHEN_ITEM_IS_PLUGIN, }); +// Disable plugin action (for plugin sub-items — disables the entire parent plugin) +const DISABLE_PLUGIN_AI_CUSTOMIZATION_ID = 'aiCustomizationManagement.disablePlugin'; +registerAction2(class extends Action2 { + constructor() { + super({ + id: DISABLE_PLUGIN_AI_CUSTOMIZATION_ID, + title: localize2('disablePlugin', "Disable Plugin"), + icon: Codicon.eyeClosed, + }); + } + 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 || 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 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: ContextKeyExpr.and( + WHEN_ITEM_IS_PLUGIN, + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, false), + ), +}); + +// 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: 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 const DISABLE_AI_CUSTOMIZATION_MGMT_ITEM_ID = 'aiCustomizationManagement.disableItem'; registerAction2(class extends Action2 { @@ -640,16 +768,69 @@ 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; + } + + // When this item has a parent plugin, disable the plugin instead + const plugin = extractPlugin(context); + if (plugin) { + const enablementHandler = harnessService.getActiveEnablementHandler(); + if (enablementHandler) { + enablementHandler.handleCustomizationEnablement(plugin, 'plugins' as PromptsType, false, 'global'); + } + return; + } + + // Extension-provided items with a command: execute the command + const command = extractEnablementCommand(context); + if (command) { + const commandService = accessor.get(ICommandService); + await commandService.executeCommand(command, uri, promptType, false, 'global'); + return; + } + + // Provider-managed items: delegate to the harness's enablement provider. + // All harnesses now own their disablement through enablementHandler. + const enablementHandler = harnessService.getActiveEnablementHandler(); + if (enablementHandler) { + enablementHandler.handleCustomizationEnablement(uri, promptType, false, 'global'); + } + } +}); + +// 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 harnessService = accessor.get(ICustomizationHarnessService); const uri = extractURI(context); const promptType = extractPromptType(context); if (!promptType) { return; } - const disabled = promptsService.getDisabledPromptFiles(promptType); - disabled.add(uri); - promptsService.setDisabledPromptFiles(promptType, disabled); + // Extension-provided items with a command: execute the command + const command = extractEnablementCommand(context); + if (command) { + const commandService = accessor.get(ICommandService); + await commandService.executeCommand(command, uri, promptType, false, 'workspace'); + return; + } + + const enablementHandler = harnessService.getActiveEnablementHandler(); + if (enablementHandler) { + enablementHandler.handleCustomizationEnablement(uri, promptType, false, 'workspace'); + } } }); @@ -664,64 +845,112 @@ 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.getDisabledPromptFiles(promptType); - disabled.delete(uri); - promptsService.setDisabledPromptFiles(promptType, disabled); + // When this item has a parent plugin, enable the plugin instead + const plugin = extractPlugin(context); + if (plugin) { + const enablementHandler = harnessService.getActiveEnablementHandler(); + if (enablementHandler) { + enablementHandler.handleCustomizationEnablement(plugin, 'plugins' as PromptsType, true, 'global'); + } + return; + } + + // Extension-provided items with a command: execute the command + const command = extractEnablementCommand(context); + if (command) { + const commandService = accessor.get(ICommandService); + await commandService.executeCommand(command, uri, promptType, true, 'global'); + return; + } + + // Provider-managed items: delegate to the harness's enablement provider. + // All harnesses now own their disablement through enablementHandler. + const enablementHandler = harnessService.getActiveEnablementHandler(); + if (enablementHandler) { + enablementHandler.handleCustomizationEnablement(uri, promptType, true, 'global'); + } } }); -// 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); + +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', 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, + WHEN_ITEM_DISABLEABLE, + ), +}); + +// 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', + order: 2, + 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), + ), + ContextKeyExpr.notEquals('workbenchState', 'empty'), ), }); -// Context menu: Enable (shown when builtin 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', 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, + WHEN_ITEM_DISABLEABLE, ), }); -// Inline hover: Disable (shown when builtin 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', 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, + WHEN_ITEM_DISABLEABLE, ), }); -// Inline hover: Enable (shown when builtin 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', 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, + 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..9e70069b1ad56 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts @@ -111,6 +111,22 @@ 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'; + +/** + * 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/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 877bc8e99c5d7..6bb535aeddfe2 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'; @@ -828,6 +829,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/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts index 519a41f869a18..7c59883e2136f 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts @@ -3,16 +3,48 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +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 { CustomizationHarnessServiceBase, + ICustomizationEnablementHandler, ICustomizationHarnessService, createVSCodeHarnessDescriptor, } from '../../common/customizationHarnessService.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { BUILTIN_STORAGE } from '../../common/aiCustomizationWorkspaceService.js'; import { SessionType } from '../../common/chatSessionsService.js'; +/** + * Enablement provider backed by promptsService (StorageService). + * Used by the VS Code (Local) harness to manage disabled customizations. + */ +function createPromptsServiceEnablementHandler(promptsService: IPromptsService): ICustomizationEnablementHandler { + return { + 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) { + disabled.delete(uri); + } else { + disabled.add(uri); + } + promptsService.setDisabledPromptFiles(type, disabled, storageScope); + + // When enabling, also remove from the other scope to fully re-enable + if (enabled) { + const otherScope = scope === 'workspace' ? StorageScope.PROFILE : StorageScope.WORKSPACE; + const otherDisabled = promptsService.getDisabledPromptFilesForScope(type, otherScope); + if (otherDisabled.delete(uri)) { + promptsService.setDisabledPromptFiles(type, otherDisabled, otherScope); + } + } + }, + }; +} + /** * Core implementation of the customization harness service. * @@ -21,11 +53,12 @@ import { SessionType } from '../../common/chatSessionsService.js'; */ class CustomizationHarnessService extends CustomizationHarnessServiceBase { constructor( - @IPromptsService promptsService: IPromptsService + @IPromptsService promptsService: IPromptsService, ) { const localExtras = [PromptsStorage.extension, BUILTIN_STORAGE]; + const enablementHandler = createPromptsServiceEnablementHandler(promptsService); super( - [createVSCodeHarnessDescriptor(localExtras)], + [createVSCodeHarnessDescriptor(localExtras, enablementHandler)], SessionType.Local, promptsService, ); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index a97d31543aaa8..2d18d30644769 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -216,7 +216,16 @@ class McpServerItemRenderer implements IListRenderer { const items: ICustomizationItem[] = []; - const disabledUris = this.promptsService.getDisabledPromptFiles(promptType); const extensionInfoByUri = new ResourceMap<{ id: ExtensionIdentifier; displayName?: string }>(); if (promptType === PromptsType.agent) { @@ -74,9 +74,7 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt name: agent.name, description: agent.description, storage: agent.source.storage, - enabled: !disabledUris.has(agent.uri), extensionId: agent.source.storage === PromptsStorage.extension ? agent.source.extensionId.value : undefined, - pluginUri: agent.source.storage === PromptsStorage.plugin ? agent.source.pluginUri : undefined }); if (agent.source.storage === PromptsStorage.extension && !extensionInfoByUri.has(agent.uri)) { extensionInfoByUri.set(agent.uri, { id: agent.source.extensionId }); @@ -91,46 +89,20 @@ 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); + const skillName = getSkillFolderName(skill.uri); + const uiTooltip = uiIntegrations.get(skillName); items.push({ uri: skill.uri, type: promptType, name: skillName, description: skill.description, storage: skill.storage, - enabled: true, badge: uiTooltip ? localize('uiIntegrationBadge', "UI Integration") : undefined, badgeTooltip: uiTooltip, extensionId: skill.extension?.identifier.value, - pluginUri: skill.pluginUri }); } - 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 uiTooltip = uiIntegrations.get(disabledFolderName); - items.push({ - uri: file.uri, - type: promptType, - name: disabledName, - description: file.description, - storage: file.storage, - enabled: false, - badge: uiTooltip ? localize('uiIntegrationBadge', "UI Integration") : undefined, - badgeTooltip: uiTooltip, - extensionId: file.extension?.identifier.value, - pluginUri: file.pluginUri - }); - } - } - } } else if (promptType === PromptsType.prompt) { const commands = await this.promptsService.getPromptSlashCommands(token); for (const command of commands) { @@ -143,24 +115,22 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt name: command.name, description: command.description, storage: command.storage, - enabled: !disabledUris.has(command.uri), extensionId: command.extension?.identifier.value, - pluginUri: command.pluginUri }); if (command.extension) { extensionInfoByUri.set(command.uri, { id: command.extension.identifier, displayName: command.extension.displayName }); } } } else if (promptType === PromptsType.hook) { - await this.fetchPromptServiceHooks(items, disabledUris, promptType); + await this.fetchPromptServiceHooks(items, promptType); } else { - await this.fetchPromptServiceInstructions(items, extensionInfoByUri, disabledUris, promptType); + await this.fetchPromptServiceInstructions(items, extensionInfoByUri, promptType); } 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 @@ -171,11 +141,9 @@ 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, - enabled: !disabledUris.has(f.uri), extensionId: f.extension?.identifier.value, - pluginUri: f.pluginUri }); } @@ -202,16 +170,14 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt description: `${agent.name}: ${truncatedCmd || localize('hookUnset', "(unset)")}`, storage: agent.source.storage, groupKey: 'agents', - enabled: !disabledUris.has(agent.uri), extensionId: agent.source.storage === PromptsStorage.extension ? agent.source.extensionId.value : undefined, - pluginUri: agent.source.storage === PromptsStorage.plugin ? agent.source.pluginUri : undefined }); } } } } - 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) { @@ -230,13 +196,11 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt name: filename, storage, groupKey: 'agent-instructions', - enabled: !disabledUris.has(file.uri), extensionId: undefined, - pluginUri: undefined }); } - for (const { uri, pattern, name, description, storage, extension, pluginUri } of instructionFiles) { + for (const { uri, pattern, name, description, storage, extension } of instructionFiles) { if (agentInstructionUris.has(uri)) { continue; } @@ -259,9 +223,7 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt description, storage, groupKey: 'context-instructions', - enabled: !disabledUris.has(uri), extensionId: extension?.identifier.value, - pluginUri }); } else { items.push({ @@ -271,9 +233,7 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt description, storage, groupKey: 'on-demand-instructions', - enabled: !disabledUris.has(uri), extensionId: extension?.identifier.value, - pluginUri }); } } @@ -336,7 +296,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/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 9fe7079c924a7..7c6a72b525003 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'; @@ -1122,7 +1122,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/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index 83b2a474a7b57..c194020d6bace 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -134,6 +134,11 @@ 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 enablementHandler?: ICustomizationEnablementHandler; } /** @@ -150,14 +155,18 @@ export interface ICustomizationItem { readonly extensionLabel?: string; /** The extension identifier that contributed this customization, if any. */ readonly extensionId: string | undefined; - /** The URI of the plugin that contributed this customization, if any. */ - readonly pluginUri: URI | undefined; /** Server-reported loading status for this customization. */ readonly status?: 'loading' | 'loaded' | 'degraded' | 'error'; /** Human-readable status detail (e.g. error message or warning). */ readonly statusMessage?: string; /** 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'; + /** 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 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). */ @@ -166,6 +175,22 @@ export interface ICustomizationItem { readonly badgeTooltip?: string; } +/** + * Handler interface for per-harness enablement state. + * + * 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 handler, the + * management UI falls back to the core promptsService storage. + */ +export interface ICustomizationEnablementHandler { + /** + * Enables or disables a single customization item. + * The handler is expected to persist the change and fire {@link ICustomizationItemProvider.onDidChange}. + */ + handleCustomizationEnablement(uri: URI, type: PromptsType, enabled: boolean, scope: 'global' | 'workspace'): void; +} + /** * Provider interface for extension-contributed harnesses that supply * customization items directly from their SDK. @@ -265,6 +290,12 @@ export interface ICustomizationHarnessService { */ registerExternalHarness(descriptor: IHarnessDescriptor): IDisposable; + /** + * 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). + */ + getActiveEnablementHandler(): ICustomizationEnablementHandler | undefined; /** * Fires when one of the provided slash commands changes. @@ -370,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[]): IHarnessDescriptor { +export function createVSCodeHarnessDescriptor(extras: readonly string[], enablementHandler?: ICustomizationEnablementHandler): IHarnessDescriptor { const filter: IStorageSourceFilter = { sources: buildAllSources(extras) }; return { id: SessionType.Local, @@ -383,6 +414,7 @@ export function createVSCodeHarnessDescriptor(extras: readonly string[]): IHarne }], ]), getStorageSourceFilter: () => filter, + enablementHandler, }; } @@ -398,6 +430,7 @@ interface IRestrictedHarnessOptions { readonly sectionOverrides?: ReadonlyMap; readonly requiredAgentId?: string; readonly instructionFileFilter?: readonly string[]; + readonly enablementHandler?: ICustomizationEnablementHandler; } function createRestrictedHarnessDescriptor( @@ -421,6 +454,7 @@ function createRestrictedHarnessDescriptor( sectionOverrides: options?.sectionOverrides, requiredAgentId: options?.requiredAgentId, instructionFileFilter: options?.instructionFileFilter, + enablementHandler: options?.enablementHandler, getStorageSourceFilter(type: PromptsType): IStorageSourceFilter { if (type === PromptsType.hook) { return HOOKS_FILTER; @@ -436,7 +470,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[], enablementHandler?: ICustomizationEnablementHandler): IHarnessDescriptor { return createRestrictedHarnessDescriptor( SessionType.CopilotCLI, localize('harness.cli', "Copilot CLI"), @@ -452,6 +486,7 @@ export function createCliHarnessDescriptor(cliUserRoots: readonly URI[], extras: rootFileShortcuts: [AGENT_MD_FILENAME], }], ]), + enablementHandler, }, ); } @@ -626,6 +661,10 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer return all.find(h => h.id === activeId) ?? all[0]; } + getActiveEnablementHandler(): ICustomizationEnablementHandler | undefined { + return this.getActiveDescriptor().enablementHandler; + } + async getSlashCommands(sessionType: string, token: CancellationToken): Promise { const harness = this.findHarnessById(sessionType); if (!harness || !harness.itemProvider) { @@ -670,8 +709,6 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer const getSource = (item: ICustomizationItem): IAgentSource => { 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/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index 54d983b14c0aa..f83114624d7e4 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) { @@ -379,7 +390,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 17fd0c835e7eb..076641835ba19 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'; @@ -623,9 +624,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 @@ -666,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. @@ -683,8 +691,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 74de7de710220..92ce71c41b19a 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -626,10 +626,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 slashCommandFiles = [ ...promptFiles, - ...skills.filter(s => !disabledSkills.has(s.uri)), + ...skills, ]; const parseResults = await Promise.all(slashCommandFiles.map(async promptPath => { @@ -667,11 +666,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); } @@ -750,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); } } @@ -772,9 +778,9 @@ export class PromptsService extends Disposable implements IPromptsService { private async computeAgentDiscoveryInfo(token: CancellationToken): Promise { const stopWatch = StopWatch.create(true); const allAgentFiles = await this.listPromptFiles(PromptsType.agent, token); - const disabledAgents = this.getDisabledPromptFiles(PromptsType.agent); 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(); @@ -784,10 +790,6 @@ 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)) { - return { status: 'skipped', skipReason: 'disabled', promptPath }; - } - try { const ast = await this.parseNew(uri, token); @@ -800,6 +802,7 @@ export class PromptsService extends Disposable implements IPromptsService { const target = getTarget(PromptsType.agent, ast.header ?? promptPath.uri); hooks = parseSubagentHooksFromYaml(hooksRaw, workspaceRootUri, userHome, target); } + const extra = { sessionTypes: promptPath.sessionTypes, hooks, @@ -808,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)); @@ -1027,12 +1038,20 @@ 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(); + const suffix = `.${type}`; + // Read profile-level disabled URIs + this._readDisabledFromStorage(this.disabledPromptsStorageKeyPrefix.slice(0, -1) + suffix, StorageScope.PROFILE, result); + // Read workspace-level disabled URIs + this._readDisabledFromStorage(this.disabledPromptsWorkspaceStorageKeyPrefix.slice(0, -1) + suffix, 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)) { @@ -1047,17 +1066,50 @@ 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); - if (type === PromptsType.agent) { - this.cachedCustomAgents.refresh(); - } else if (type === PromptsType.skill) { - this.cachedSkills.refresh(); - this.cachedSlashCommands.refresh(); + const suffix = `.${type}`; + const key = scope === StorageScope.WORKSPACE + ? 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); + } + + /** + * 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 suffix = `.${type}`; + const key = scope === StorageScope.WORKSPACE + ? this.disabledPromptsWorkspaceStorageKeyPrefix.slice(0, -1) + suffix + : this.disabledPromptsStorageKeyPrefix.slice(0, -1) + suffix; + this._readDisabledFromStorage(key, scope, result); + return result; + } + + private _refreshCachesForType(type: PromptsType): void { + 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; } } @@ -1138,24 +1190,25 @@ 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 result = this.skillsFromDiscoveryInfo(discoveryInfo); + const disabledSkills = options?.includeDisabled ? undefined : 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 @@ -1357,7 +1410,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/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 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 8d9e3d78cb8b5..79f243db0cabe 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,6 +359,7 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv instantiationService.stub(IStorageService, disposables.add(new InMemoryStorageService())); instantiationService.stub(ICustomizationHarnessService, { registerExternalHarness: () => toDisposable(() => { }), + 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 4bedaad86a720..5fdacdb1ca338 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(() => { }), + 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 new file mode 100644 index 0000000000000..032831af72e27 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDisablement.test.ts @@ -0,0 +1,598 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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'; +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 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): ResourceSet => { + return new ResourceSet([...(disabledSets.get(type) ?? [])]); + }, + getDisabledPromptFilesForScope: (type: PromptsType, _scope: StorageScope): ResourceSet => { + return new ResourceSet([...(disabledSets.get(type) ?? [])]); + }, + setDisabledPromptFiles: (type: PromptsType, uris: ResourceSet, _scope?: StorageScope): void => { + disabledSets.set(type, new ResourceSet([...uris])); + }, + } as unknown as IPromptsService; + } + + function createMockEnablementHandler(): ICustomizationEnablementHandler { + const disabledSets = new Map(); + return { + handleCustomizationEnablement(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; + enablementHandler?: ICustomizationEnablementHandler; + promptsService?: IPromptsService; + /** 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.enablementHandler; + return new ProviderCustomizationItemSource( + opts.itemProvider, + undefined, + 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', + extensionId: undefined, + }]), + enablementHandler: createMockEnablementHandler(), + }); + + 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', + extensionId: undefined, + }]), + enablementHandler: createMockEnablementHandler(), + }); + + 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', + extensionId: undefined, + }]), + }); + + const result = await source.fetchItems(PromptsType.agent); + assert.strictEqual(result[0].enablementScope, 'workspace'); + }); + }); + + suite('disabled state overlay - API items', () => { + + 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, + extensionId: undefined, + }]), + enablementHandler: createMockEnablementHandler(), + }); + + const result = await source.fetchItems(PromptsType.agent); + assert.strictEqual(result[0].disabled, true); + }); + + test('not in enablementHandler disabled set shows as enabled', async () => { + const source = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([{ + uri: agentUri, type: PromptsType.agent, name: 'Agent', enablementScope: 'workspace', + extensionId: undefined, + }]), + enablementHandler: createMockEnablementHandler(), + }); + + const result = await source.fetchItems(PromptsType.agent); + assert.strictEqual(result[0].disabled, false); + }); + + 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); + + const source = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([{ + uri: agentUri, type: PromptsType.agent, name: 'Agent', enablementScope: 'workspace', + extensionId: undefined, + }]), + enablementHandler: createMockEnablementHandler(), + 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 - 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, + extensionId: undefined, + }]), + enablementHandler: createMockEnablementHandler(), + }); + + const result = await source.fetchItems(PromptsType.skill); + assert.strictEqual(result[0].disabled, true); + }); + + 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, + extensionId: undefined, + }]), + enablementHandler: createMockEnablementHandler(), + }); + + const result = await source.fetchItems(PromptsType.skill); + assert.strictEqual(result[0].disabled, false); + }); + + 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', + extensionId: undefined, + }]), + enablementHandler: createMockEnablementHandler(), + }); + + const result = await source.fetchItems(PromptsType.skill); + assert.strictEqual(result[0].disabled, false); + }); + }); + + suite('VS Code harness', () => { + + test('reads disabled state from promptsService', 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', + extensionId: undefined, + }]), + promptsService: ps, + }); + + const result = await source.fetchItems(PromptsType.agent); + assert.strictEqual(result[0].disabled, true); + }); + }); + + 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', + extensionId: undefined, + }]; + + // External harness reports item as disabled via enabled:false + const cliSource = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([{ ...items[0], enabled: false }]), + enablementHandler: createMockEnablementHandler(), + }); + assert.strictEqual((await cliSource.fetchItems(PromptsType.instructions))[0].disabled, true); + + // VS Code harness is not affected — it reads from promptsService + const vscodeSource = createItemSource({ + harnessId: 'vscode', + itemProvider: createMockItemProvider(items), + }); + 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 source = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([ + { + uri: agentUri, type: PromptsType.agent, name: 'API Agent', enablementScope: 'global', enabled: false, + extensionId: undefined, + }, + { + uri: skillUri, type: PromptsType.agent, name: 'VS Code Agent', storage: PromptsStorage.local, enablementScope: 'workspace', + extensionId: undefined, + }, + ]), + enablementHandler: createMockEnablementHandler(), + }); + + 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 via provider', async () => { + const source = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([ + { + uri: agentUri, type: PromptsType.agent, name: 'API Agent', enablementScope: 'global', + extensionId: undefined, + }, + { + uri: skillUri, type: PromptsType.agent, name: 'VS Code Agent', storage: PromptsStorage.local, enablementScope: 'workspace', enabled: false, + extensionId: undefined, + }, + ]), + enablementHandler: createMockEnablementHandler(), + }); + + 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 enablementHandler → 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); + }); + + }); + + 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', + extensionId: undefined, + }]), + enablementHandler: createMockEnablementHandler(), + }); + + 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', + extensionId: undefined, + }]), + 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', + extensionId: undefined, + }]), + }); + + 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 source = createItemSource({ + harnessId: 'cli', + itemProvider: createMockItemProvider([{ + uri: agentUri, type: PromptsType.agent, name: 'CLI Agent', + enablementScope: 'global', + enabled: false, + extensionId: undefined, + }]), + enablementHandler: createMockEnablementHandler(), + }); + + 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 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, + extensionId: undefined, + }]), + enablementHandler: createMockEnablementHandler(), + }); + + 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', + extensionId: undefined, + }]), + enablementHandler: createMockEnablementHandler(), + }); + + 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/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts index fb38770250231..3bb7bdf440ee2 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 @@ -200,6 +200,7 @@ suite('aiCustomizationListWidget', () => { getActiveDescriptor: () => descriptor, findHarnessById: (id) => id === descriptor.id ? descriptor : undefined, registerExternalHarness: () => ({ dispose() { } }), + getActiveEnablementHandler: () => undefined, }); instaService.stub(IAgentPluginService, { 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/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 47828f1ad080e..84a4a366dca56 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,7 +59,8 @@ export class MockPromptsService implements IPromptsService { 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.'); } + 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 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 fc58f19982daf..9a96bff6edfe6 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'; @@ -4452,6 +4452,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 d668b9522f483..5c714611b491c 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); diff --git a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts index 33027458d2c7c..8f56112de3db3 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, @@ -109,6 +121,42 @@ declare module 'vscode' { * Optional tooltip text shown when hovering over the badge. */ readonly badgeTooltip?: string; + + /** + * 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 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. + * Only takes effect when {@link enablementCommand} is set — without + * a command, no toggle actions are shown regardless of this value. + * + * Defaults to {@link ChatSessionCustomizationEnablementScope.Global} when + * {@link enablementCommand} is set. + */ + readonly enablementScopeHint?: ChatSessionCustomizationEnablementScope; + + /** + * Optional command executed when the user sets this item's enablement. + * + * The handler should persist the change and fire + * {@link ChatSessionCustomizationProvider.onDidChange} so the UI + * re-queries the updated state. + */ + readonly enablementCommand?: string; } /** @@ -163,7 +211,11 @@ declare module 'vscode' { * @param provider The customization provider implementation. * @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, + ): Disposable; } // #endregion