diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 5ecf5cf10fa..5cbe6159c65 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -79,8 +79,8 @@ const _allApiProposals = { chatReferenceDiagnostic: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatReferenceDiagnostic.d.ts', }, - chatSessionCustomizations: { - proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatSessionCustomizations.d.ts', + chatSessionCustomizationProvider: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts', }, chatSessionsProvider: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts', diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 8ac37889384..ad86d3eb212 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -13,6 +13,7 @@ import { Schemas } from '../../../base/common/network.js'; import { escapeRegExpCharacters } from '../../../base/common/strings.js'; import { ThemeIcon } from '../../../base/common/themables.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; +import { Codicon } from '../../../base/common/codicons.js'; import { Position } from '../../../editor/common/core/position.js'; import { Range } from '../../../editor/common/core/range.js'; import { getWordAtText } from '../../../editor/common/core/wordHelper.js'; @@ -27,7 +28,7 @@ import { IChatWidget, IChatWidgetService } from '../../contrib/chat/browser/chat import { AgentSessionProviders, getAgentSessionProvider } from '../../contrib/chat/browser/agentSessions/agentSessions.js'; import { AddDynamicVariableAction, IAddDynamicVariableContext } from '../../contrib/chat/browser/attachments/chatDynamicVariables.js'; import { IChatAgentHistoryEntry, IChatAgentImplementation, IChatAgentRequest, IChatAgentService } from '../../contrib/chat/common/participants/chatAgents.js'; -import { IPromptFileContext, IPromptsService } from '../../contrib/chat/common/promptSyntax/service/promptsService.js'; +import { IPromptFileContext, IPromptsService, PromptsStorage } from '../../contrib/chat/common/promptSyntax/service/promptsService.js'; import { isValidPromptType } from '../../contrib/chat/common/promptSyntax/promptTypes.js'; import { IChatModel } from '../../contrib/chat/common/model/chatModel.js'; import { ChatRequestAgentPart } from '../../contrib/chat/common/requestParser/chatParserTypes.js'; @@ -40,9 +41,12 @@ import { ILanguageModelToolsService } from '../../contrib/chat/common/tools/lang import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; import { IExtensionService } from '../../services/extensions/common/extensions.js'; import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; -import { ExtHostChatAgentsShape2, ExtHostContext, IChatNotebookEditDto, IChatParticipantMetadata, IChatProgressDto, IChatSessionContextDto, ICustomAgentDto, IDynamicChatAgentProps, IExtensionChatAgentMetadata, IInstructionDto, ISkillDto, MainContext, MainThreadChatAgentsShape2 } from '../common/extHost.protocol.js'; +import { ExtHostChatAgentsShape2, ExtHostContext, IChatSessionCustomizationItemDto, IChatSessionCustomizationProviderMetadataDto, IChatNotebookEditDto, IChatParticipantMetadata, IChatProgressDto, IChatSessionContextDto, ICustomAgentDto, IDynamicChatAgentProps, IExtensionChatAgentMetadata, IInstructionDto, ISkillDto, MainContext, MainThreadChatAgentsShape2 } from '../common/extHost.protocol.js'; import { NotebookDto } from './mainThreadNotebookDto.js'; import { isUntitledChatSession } from '../../contrib/chat/common/model/chatUri.js'; +import { ICustomizationHarnessService, IExternalCustomizationItem, IExternalCustomizationItemProvider, IHarnessDescriptor } from '../../contrib/chat/common/customizationHarnessService.js'; +import { AICustomizationManagementSection } from '../../contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; interface AgentData { dispose: () => void; @@ -102,6 +106,9 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA private readonly _promptFileProviderEmitters = this._register(new DisposableMap>()); private readonly _promptFileContentRegistrations = this._register(new DisposableMap>()); + private readonly _customizationProviders = this._register(new DisposableMap()); + private readonly _customizationProviderEmitters = this._register(new DisposableMap>()); + private readonly _pendingProgress = new Map void; chatSession: IChatModel | undefined }>(); private readonly _proxy: ExtHostChatAgentsShape2; @@ -122,10 +129,22 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, @IPromptsService private readonly _promptsService: IPromptsService, @ILanguageModelToolsService private readonly _languageModelToolsService: ILanguageModelToolsService, + @ICustomizationHarnessService private readonly _customizationHarnessService: ICustomizationHarnessService, + @IConfigurationService private readonly _configurationService: IConfigurationService, ) { super(); this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostChatAgents2); + // When the provider API kill-switch is toggled off, dispose all registered providers + this._register(this._configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('chat.customizations.providerApi.enabled')) { + if (!this._configurationService.getValue('chat.customizations.providerApi.enabled')) { + this._customizationProviders.clearAndDisposeAll(); + this._customizationProviderEmitters.clearAndDisposeAll(); + } + } + })); + this._register(this._chatService.onDidDisposeSession(e => { for (const resource of e.sessionResource) { this._proxy.$releaseSession(resource); @@ -584,6 +603,80 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA emitter.fire(); } } + + async $registerChatSessionCustomizationProvider(handle: number, chatSessionType: string, metadata: IChatSessionCustomizationProviderMetadataDto, extensionId: ExtensionIdentifier): Promise { + if (!this._configurationService.getValue('chat.customizations.providerApi.enabled')) { + this._logService.trace(`[MainThreadChatAgents2] Customization provider API is disabled, ignoring registration from ${extensionId.value}`); + return; + } + + const extension = await this._extensionService.getExtension(extensionId.value); + if (!extension) { + this._logService.error(`[MainThreadChatAgents2] Could not find extension for customization provider: ${extensionId.value}`); + return; + } + + const emitter = new Emitter(); + this._customizationProviderEmitters.set(handle, emitter); + + // Build the item provider that calls back to the ExtHost + const itemProvider: IExternalCustomizationItemProvider = { + onDidChange: emitter.event, + provideChatSessionCustomizations: async (token) => { + const items = await this._proxy.$provideChatSessionCustomizations(handle, token); + if (!items) { + return undefined; + } + return items.map((item: IChatSessionCustomizationItemDto): IExternalCustomizationItem => ({ + uri: URI.revive(item.uri), + type: item.type, + name: item.name, + description: item.description, + })); + }, + }; + + // Convert metadata to a harness descriptor + const hiddenSections = metadata.unsupportedTypes?.map(type => { + switch (type) { + case 'agent': return AICustomizationManagementSection.Agents; + case 'skill': return AICustomizationManagementSection.Skills; + case 'instructions': return AICustomizationManagementSection.Instructions; + case 'prompt': return AICustomizationManagementSection.Prompts; + case 'hook': return AICustomizationManagementSection.Hooks; + default: return type; + } + }); + + const descriptor: IHarnessDescriptor = { + id: chatSessionType, + label: metadata.label, + icon: metadata.iconId ? ThemeIcon.fromId(metadata.iconId) : ThemeIcon.fromId(Codicon.extensions.id), + hiddenSections, + workspaceSubpaths: metadata.workspaceSubpaths ? [...metadata.workspaceSubpaths] : undefined, + getStorageSourceFilter: () => ({ + // Extension-provided harnesses manage their own items via the provider, + // so we show all sources for storage-filter-based flows. + sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.plugin, PromptsStorage.extension], + }), + itemProvider, + }; + + const registration = this._customizationHarnessService.registerExternalHarness(descriptor); + this._customizationProviders.set(handle, registration); + } + + $unregisterChatSessionCustomizationProvider(handle: number): void { + this._customizationProviders.deleteAndDispose(handle); + this._customizationProviderEmitters.deleteAndDispose(handle); + } + + $onDidChangeCustomizations(handle: number): void { + const emitter = this._customizationProviderEmitters.get(handle); + if (emitter) { + emitter.fire(); + } + } } diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index b273fe293f0..8f4f9f234e4 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -5,7 +5,6 @@ import { raceCancellationError } from '../../../base/common/async.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; -import { Codicon } from '../../../base/common/codicons.js'; import { Emitter } from '../../../base/common/event.js'; import { IMarkdownString, MarkdownString } from '../../../base/common/htmlContent.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; @@ -13,11 +12,9 @@ import { ResourceMap } from '../../../base/common/map.js'; import { revive } from '../../../base/common/marshalling.js'; import { autorun, IObservable, observableValue } from '../../../base/common/observable.js'; import { isEqual } from '../../../base/common/resources.js'; -import { ThemeIcon } from '../../../base/common/themables.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { IDialogService } from '../../../platform/dialogs/common/dialogs.js'; -import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../platform/log/common/log.js'; import { hasValidDiff, IAgentSession } from '../../contrib/chat/browser/agentSessions/agentSessionsModel.js'; @@ -29,14 +26,12 @@ import { IChatRequestVariableEntry } from '../../contrib/chat/common/attachments import { awaitStatsForSession } from '../../contrib/chat/common/chat.js'; import { getInProgressSessionDescription } from '../../contrib/chat/browser/chatSessions/chatSessionDescription.js'; import { IChatContentInlineReference, IChatProgress, IChatService, ResponseModelState } from '../../contrib/chat/common/chatService/chatService.js'; -import { ChatSessionOptionsMap, ChatSessionStatus, IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionCustomizationsProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionProviderOptionItem, IChatSessionRequestHistoryItem, IChatSessionsService, ReadonlyChatSessionOptionsMap } from '../../contrib/chat/common/chatSessionsService.js'; -import { ChatAgentLocation, ChatConfiguration } from '../../contrib/chat/common/constants.js'; +import { ChatSessionOptionsMap, ChatSessionStatus, IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionProviderOptionItem, IChatSessionRequestHistoryItem, IChatSessionsService, ReadonlyChatSessionOptionsMap } from '../../contrib/chat/common/chatSessionsService.js'; +import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { IChatModel } from '../../contrib/chat/common/model/chatModel.js'; import { isUntitledChatSession } from '../../contrib/chat/common/model/chatUri.js'; import { IChatAgentRequest } from '../../contrib/chat/common/participants/chatAgents.js'; import { IChatDebugService } from '../../contrib/chat/common/chatDebugService.js'; -import { ICustomizationHarnessService, IHarnessDescriptor } from '../../contrib/chat/common/customizationHarnessService.js'; -import { PromptsStorage } from '../../contrib/chat/common/promptSyntax/service/promptsService.js'; import { IChatArtifactsService } from '../../contrib/chat/common/tools/chatArtifactsService.js'; import { IChatTodoListService } from '../../contrib/chat/common/tools/chatTodoListService.js'; import { IEditorGroupsService } from '../../services/editor/common/editorGroupsService.js'; @@ -443,7 +438,6 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat readonly controller: MainThreadChatSessionItemController; }>()); private readonly _contentProvidersRegistrations = this._register(new DisposableMap()); - private readonly _customizationsProviderRegistrations = new Map; dispose: () => void }>(); private readonly _sessionTypeToHandle = new Map(); private readonly _activeSessions = new ResourceMap(); @@ -465,8 +459,6 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat @IEditorGroupsService private readonly editorGroupService: IEditorGroupsService, @ILogService private readonly _logService: ILogService, @IInstantiationService private readonly _instantiationService: IInstantiationService, - @ICustomizationHarnessService private readonly _harnessService: ICustomizationHarnessService, - @IConfigurationService private readonly _configurationService: IConfigurationService, ) { super(); @@ -847,68 +839,6 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat }).catch(err => this._logService.error('Error fetching chat session options', err)); } - $registerChatSessionCustomizationsProvider(handle: number, chatSessionType: string): void { - // Kill switch: when disabled, ignore all extension customizations providers - if (!this._configurationService.getValue(ChatConfiguration.CustomizationsProviderApi)) { - return; - } - - const disposables = new DisposableStore(); - const emitter = disposables.add(new Emitter()); - - const provider: IChatSessionCustomizationsProvider = { - onDidChangeCustomizations: emitter.event, - provideCustomizations: (token) => { - return this._proxy.$provideChatSessionCustomizations(handle, token).then(groups => { - if (!groups) { return undefined; } - return groups.map(g => ({ - ...g, - items: g.items.map(item => ({ - ...item, - uri: URI.revive(item.uri), - })), - })); - }); - }, - }; - - disposables.add(this._chatSessionsService.registerCustomizationsProvider(chatSessionType, provider)); - - // Register as a harness so it appears in the harness picker - const contribution = this._chatSessionsService.getChatSessionContribution(chatSessionType); - if (contribution) { - const icon = ThemeIcon.isThemeIcon(contribution.icon) - ? contribution.icon - : ThemeIcon.fromId(Codicon.copilot.id); - const filter = { sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension, PromptsStorage.plugin] }; - const harnessDescriptor: IHarnessDescriptor = { - id: chatSessionType, - label: contribution.displayName ?? chatSessionType, - icon, - hideGenerateButton: true, - getStorageSourceFilter: () => filter, - }; - disposables.add(this._harnessService.registerContributedHarness(harnessDescriptor)); - } - - this._customizationsProviderRegistrations.set(handle, { chatSessionType, emitter, dispose: () => disposables.dispose() }); - } - - $unregisterChatSessionCustomizationsProvider(handle: number): void { - const reg = this._customizationsProviderRegistrations.get(handle); - if (reg) { - reg.dispose(); - this._customizationsProviderRegistrations.delete(handle); - } - } - - $onDidChangeChatSessionCustomizations(handle: number): void { - const reg = this._customizationsProviderRegistrations.get(handle); - if (reg) { - reg.emitter.fire(); - } - } - override dispose(): void { for (const session of this._activeSessions.values()) { session.dispose(); @@ -920,11 +850,6 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat } this._sessionDisposables.clear(); - for (const reg of this._customizationsProviderRegistrations.values()) { - reg.dispose(); - } - this._customizationsProviderRegistrations.clear(); - super.dispose(); } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index f751b3135ab..a2c054bf399 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1664,10 +1664,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatSessionsProvider'); return extHostChatSessions.createChatSessionItemController(extension, chatSessionType, refreshHandler); }, - registerChatSessionCustomizationsProvider(chatSessionType: string, provider: vscode.ChatSessionCustomizationsProvider): vscode.Disposable { - checkProposedApiEnabled(extension, 'chatSessionsProvider'); - return extHostChatSessions.registerChatSessionCustomizationsProvider(extension, chatSessionType, provider); - }, registerChatSessionContentProvider(scheme: string, provider: vscode.ChatSessionContentProvider, chatParticipant: vscode.ChatParticipant, capabilities?: vscode.ChatSessionCapabilities) { checkProposedApiEnabled(extension, 'chatSessionsProvider'); return extHostChatSessions.registerChatSessionContentProvider(extension, scheme, chatParticipant, provider, capabilities); @@ -1741,6 +1737,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.onDidChangeSkills(listener, thisArgs, disposables); }, + registerChatSessionCustomizationProvider(chatSessionType: string, metadata: vscode.ChatSessionCustomizationProviderMetadata, provider: vscode.ChatSessionCustomizationProvider): vscode.Disposable { + checkProposedApiEnabled(extension, 'chatSessionCustomizationProvider'); + return extHostChatAgents2.registerChatSessionCustomizationProvider(extension, chatSessionType, metadata, provider); + }, }; // namespace: lm @@ -2130,7 +2130,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I ChatLocation: extHostTypes.ChatLocation, ChatSessionStatus: extHostTypes.ChatSessionStatus, ChatSessionCustomizationType: extHostTypes.ChatSessionCustomizationType, - ChatSessionCustomizationStorageLocation: extHostTypes.ChatSessionCustomizationStorageLocation, 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 200fed89d93..97241cabb67 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1587,6 +1587,9 @@ export interface MainThreadChatAgentsShape2 extends IChatAgentProgressShape, IDi $registerPromptFileProvider(handle: number, type: string, extension: ExtensionIdentifier): void; $unregisterPromptFileProvider(handle: number): void; $onDidChangePromptFiles(handle: number): void; + $registerChatSessionCustomizationProvider(handle: number, chatSessionType: string, metadata: IChatSessionCustomizationProviderMetadataDto, extension: ExtensionIdentifier): void; + $unregisterChatSessionCustomizationProvider(handle: number): void; + $onDidChangeCustomizations(handle: number): void; $registerAgentCompletionsProvider(handle: number, id: string, triggerCharacters: string[]): void; $unregisterAgentCompletionsProvider(handle: number, id: string): void; $updateAgent(handle: number, metadataUpdate: IExtensionChatAgentMetadata): void; @@ -1654,6 +1657,7 @@ export interface ExtHostChatAgentsShape2 { $releaseSession(sessionResource: UriComponents): void; $detectChatParticipant(handle: number, request: Dto, context: { history: IChatAgentHistoryEntryDto[] }, options: { participants: IChatParticipantMetadata[]; location: ChatAgentLocation }, token: CancellationToken): Promise; $providePromptFiles(handle: number, type: PromptsType, context: IPromptFileContext, token: CancellationToken): Promise[] | undefined>; + $provideChatSessionCustomizations(handle: number, token: CancellationToken): Promise; $setRequestTools(requestId: string, tools: UserSelectedTools): void; $setYieldRequested(requestId: string, value: boolean): void; $acceptActiveChatSession(sessionResource: UriComponents | undefined): void; @@ -1673,6 +1677,20 @@ export interface IInstructionDto { export interface ISkillDto { uri: UriComponents; } + +export interface IChatSessionCustomizationProviderMetadataDto { + readonly label: string; + readonly iconId?: string; + readonly unsupportedTypes?: readonly string[]; + readonly workspaceSubpaths?: readonly string[]; +} + +export interface IChatSessionCustomizationItemDto { + readonly uri: UriComponents; + readonly type: string; + readonly name: string; + readonly description?: string; +} export interface IChatParticipantMetadata { participant: string; command?: string; @@ -3628,21 +3646,6 @@ export interface IChatSessionItemsChange { readonly removed: readonly UriComponents[]; } -export interface IChatSessionCustomizationItemDto { - readonly label: string; - readonly description?: string; - readonly uri: UriComponents; - readonly storageLocation: number; - readonly icon?: ThemeIcon; -} - -export interface IChatSessionCustomizationItemGroupDto { - readonly id: string; - readonly items: IChatSessionCustomizationItemDto[]; - readonly commands?: ICommandDto[]; - readonly itemCommands?: ICommandDto[]; -} - export interface MainThreadChatSessionsShape extends IDisposable { $registerChatSessionItemController(controllerHandle: number, chatSessionType: string): void; $unregisterChatSessionItemController(controllerHandle: number): void; @@ -3654,10 +3657,6 @@ export interface MainThreadChatSessionsShape extends IDisposable { $onDidChangeChatSessionOptions(handle: number, sessionResource: UriComponents, updates: Record): void; $onDidChangeChatSessionProviderOptions(handle: number): void; - $registerChatSessionCustomizationsProvider(handle: number, chatSessionType: string): void; - $unregisterChatSessionCustomizationsProvider(handle: number): void; - $onDidChangeChatSessionCustomizations(handle: number): void; - $handleProgressChunk(handle: number, sessionResource: UriComponents, requestId: string, chunks: (IChatProgressDto | [IChatProgressDto, number])[]): Promise; $handleAnchorResolve(handle: number, sessionResource: UriComponents, requestId: string, requestHandle: string, anchor: Dto): void; $handleProgressComplete(handle: number, sessionResource: UriComponents, requestId: string): void; @@ -3676,7 +3675,6 @@ export interface ExtHostChatSessionsShape { $invokeOptionGroupSearch(providerHandle: number, optionGroupId: string, query: string, token: CancellationToken): Promise; $provideHandleOptionsChange(providerHandle: number, sessionResource: UriComponents, updates: Record, token: CancellationToken): Promise; $forkChatSession(providerHandle: number, sessionResource: UriComponents, request: IChatSessionRequestHistoryItemDto | undefined, token: CancellationToken): Promise>; - $provideChatSessionCustomizations(handle: number, token: CancellationToken): Promise; } export interface GitRefQueryDto { diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index f67a043941f..a8ca253cb44 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -27,7 +27,7 @@ import { LocalChatSessionUri } from '../../contrib/chat/common/model/chatUri.js' import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { checkProposedApiEnabled, isProposedApiEnabled } from '../../services/extensions/common/extensions.js'; import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; -import { ExtHostChatAgentsShape2, IChatAgentCompletionItem, IChatAgentHistoryEntryDto, IChatAgentProgressShape, IChatProgressDto, IChatSessionContextDto, ICustomAgentDto, IExtensionChatAgentMetadata, IInstructionDto, IMainContext, ISkillDto, MainContext, MainThreadChatAgentsShape2 } from './extHost.protocol.js'; +import { ExtHostChatAgentsShape2, IChatAgentCompletionItem, IChatAgentHistoryEntryDto, IChatAgentProgressShape, IChatSessionCustomizationItemDto, IChatSessionCustomizationProviderMetadataDto, IChatProgressDto, IChatSessionContextDto, ICustomAgentDto, IExtensionChatAgentMetadata, IInstructionDto, IMainContext, ISkillDto, MainContext, MainThreadChatAgentsShape2 } from './extHost.protocol.js'; import { CommandsConverter, ExtHostCommands } from './extHostCommands.js'; import { ExtHostDiagnostics } from './extHostDiagnostics.js'; import { ExtHostDocuments } from './extHostDocuments.js'; @@ -474,6 +474,9 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS private static _contributionsProviderIdPool = 0; private readonly _promptFileProviders = new Map(); + private static _customizationProviderIdPool = 0; + private readonly _customizationProviders = new Map(); + private readonly _sessionDisposables: DisposableResourceMap = this._register(new DisposableResourceMap()); private readonly _completionDisposables: DisposableMap = this._register(new DisposableMap()); @@ -661,6 +664,58 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS return resources; } + registerChatSessionCustomizationProvider(extension: IExtensionDescription, chatSessionType: string, metadata: vscode.ChatSessionCustomizationProviderMetadata, provider: vscode.ChatSessionCustomizationProvider): vscode.Disposable { + const handle = ExtHostChatAgents2._customizationProviderIdPool++; + this._customizationProviders.set(handle, { extension, provider }); + + const metadataDto: IChatSessionCustomizationProviderMetadataDto = { + label: metadata.label, + iconId: metadata.iconId, + unsupportedTypes: metadata.unsupportedTypes?.map(t => typeConvert.ChatSessionCustomizationType.from(t)), + workspaceSubpaths: metadata.workspaceSubpaths ? [...metadata.workspaceSubpaths] : undefined, + }; + + this._proxy.$registerChatSessionCustomizationProvider(handle, chatSessionType, metadataDto, extension.identifier); + + const disposables = new DisposableStore(); + + if (provider.onDidChange) { + disposables.add(provider.onDidChange(() => { + this._proxy.$onDidChangeCustomizations(handle); + })); + } + + disposables.add(toDisposable(() => { + this._customizationProviders.delete(handle); + this._proxy.$unregisterChatSessionCustomizationProvider(handle); + })); + + return disposables; + } + + async $provideChatSessionCustomizations(handle: number, token: CancellationToken): Promise { + const providerData = this._customizationProviders.get(handle); + if (!providerData) { + return undefined; + } + + try { + const items = await providerData.provider.provideChatSessionCustomizations(token); + if (!items) { + return undefined; + } + + return items.map(item => ({ + uri: item.uri, + type: typeConvert.ChatSessionCustomizationType.from(item.type), + name: item.name, + description: item.description, + })); + } catch (err) { + return undefined; + } + } + async $detectChatParticipant(handle: number, requestDto: Dto, context: { history: IChatAgentHistoryEntryDto[] }, options: { location: ChatAgentLocation; participants?: vscode.ChatParticipantMetadata[] }, token: CancellationToken): Promise { const detector = this._participantDetectionProviders.get(handle); if (!detector) { diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 4106b566013..a640e9b5ce7 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -22,7 +22,7 @@ import { IChatSessionProviderOptionItem } from '../../contrib/chat/common/chatSe import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { IChatAgentRequest, IChatAgentResult } from '../../contrib/chat/common/participants/chatAgents.js'; import { Proxied } from '../../services/extensions/common/proxyIdentifier.js'; -import { ChatSessionContentContextDto, IChatSessionDto, ExtHostChatSessionsShape, IChatAgentProgressShape, IChatSessionRequestHistoryItemDto, IChatSessionProviderOptions, MainContext, MainThreadChatSessionsShape, IChatNewSessionRequestDto, IChatSessionCustomizationItemGroupDto } from './extHost.protocol.js'; +import { ChatSessionContentContextDto, IChatSessionDto, ExtHostChatSessionsShape, IChatAgentProgressShape, IChatSessionRequestHistoryItemDto, IChatSessionProviderOptions, MainContext, MainThreadChatSessionsShape, IChatNewSessionRequestDto } from './extHost.protocol.js'; import { ChatAgentResponseStream } from './extHostChatAgents2.js'; import { CommandsConverter, ExtHostCommands } from './extHostCommands.js'; import { ExtHostLanguageModels } from './extHostLanguageModels.js'; @@ -331,9 +331,6 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio */ private readonly _providerOptionGroups = new Map(); - private _customizationsHandlePool = 0; - private readonly _customizationsProviders = new Map(); - constructor( private readonly commands: ExtHostCommands, private readonly _languageModels: ExtHostLanguageModels, @@ -880,43 +877,4 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio controllerData.onDidChangeChatSessionItemStateEmitter.fire(item); } - registerChatSessionCustomizationsProvider(extension: IExtensionDescription, chatSessionType: string, provider: vscode.ChatSessionCustomizationsProvider): vscode.Disposable { - const handle = this._customizationsHandlePool++; - const disposables = new DisposableStore(); - - this._customizationsProviders.set(handle, { provider, chatSessionType, disposable: disposables }); - - if (provider.onDidChangeCustomizations) { - disposables.add(provider.onDidChangeCustomizations(() => { - this._proxy.$onDidChangeChatSessionCustomizations(handle); - })); - } - - this._proxy.$registerChatSessionCustomizationsProvider(handle, chatSessionType); - - disposables.add(toDisposable(() => { - this._customizationsProviders.delete(handle); - this._proxy.$unregisterChatSessionCustomizationsProvider(handle); - })); - - return disposables; - } - - async $provideChatSessionCustomizations(handle: number, token: CancellationToken): Promise { - const entry = this._customizationsProviders.get(handle); - if (!entry) { - return undefined; - } - try { - const result = await entry.provider.provideCustomizations(token); - if (!result) { - return undefined; - } - return result.map(g => typeConvert.ChatSessionCustomizations.fromGroup(g)); - } catch (err) { - this._logService.error(`[ExtHostChatSessions] provideCustomizations failed:`, err); - return undefined; - } - } - } diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 5b90e57544b..2c76f571e93 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -3512,6 +3512,12 @@ export namespace ChatLocation { } } +export namespace ChatSessionCustomizationType { + export function from(type: types.ChatSessionCustomizationType): string { + return type.id; + } +} + export namespace ChatPromptReference { export function to(variable: IChatRequestVariableEntry, diagnostics: readonly [vscode.Uri, readonly vscode.Diagnostic[]][], logService: ILogService): vscode.ChatPromptReference | undefined { let value: vscode.ChatPromptReference['value'] = variable.value; @@ -4208,33 +4214,3 @@ export namespace ChatSessionItem { }; } } - -export namespace ChatSessionCustomizations { - function commandFrom(cmd: vscode.Command): extHostProtocol.ICommandDto { - return { - id: cmd.command, - title: cmd.title, - tooltip: cmd.tooltip, - arguments: cmd.arguments, - }; - } - - export function fromItem(item: vscode.ChatSessionCustomizationItem): extHostProtocol.IChatSessionCustomizationItemDto { - return { - label: item.label, - description: item.description, - uri: item.uri, - storageLocation: item.storageLocation, - icon: item.icon ? { id: item.icon.id, color: item.icon.color } : undefined, - }; - } - - export function fromGroup(group: vscode.ChatSessionCustomizationItemGroup): extHostProtocol.IChatSessionCustomizationItemGroupDto { - return { - id: group.id, - items: group.items.map(fromItem), - commands: group.commands?.map(commandFrom), - itemCommands: group.itemCommands?.map(commandFrom), - }; - } -} diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 035939abe0c..5eb7131d801 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3568,21 +3568,14 @@ export enum ChatSessionStatus { NeedsInput = 3 } -export enum ChatSessionCustomizationType { - Agents = 'agents', - Skills = 'skills', - AgentInstructions = 'agentInstructions', - ContextInstructions = 'contextInstructions', - OnDemandInstructions = 'onDemandInstructions', - Prompts = 'prompts', -} +export class ChatSessionCustomizationType { + static readonly Agent = new ChatSessionCustomizationType('agent'); + static readonly Skill = new ChatSessionCustomizationType('skill'); + static readonly Instructions = new ChatSessionCustomizationType('instructions'); + static readonly Prompt = new ChatSessionCustomizationType('prompt'); + static readonly Hook = new ChatSessionCustomizationType('hook'); -export enum ChatSessionCustomizationStorageLocation { - Workspace = 1, - User = 2, - Extension = 3, - Plugin = 4, - BuiltIn = 5, + constructor(public readonly id: string) { } } export enum ChatDebugLogLevel { diff --git a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts index d29e9f1715a..5e9504d111f 100644 --- a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts @@ -74,7 +74,6 @@ suite('ObservableChatSession', function () { $onDidChangeChatSessionItemState: sinon.stub(), $newChatSessionItem: sinon.stub().resolves(undefined), $forkChatSession: sinon.stub().resolves(undefined), - $provideChatSessionCustomizations: sinon.stub().resolves(undefined), }; }); @@ -525,7 +524,6 @@ suite('MainThreadChatSessions', function () { $onDidChangeChatSessionItemState: sinon.stub(), $newChatSessionItem: sinon.stub().resolves(undefined), $forkChatSession: sinon.stub().resolves(undefined), - $provideChatSessionCustomizations: sinon.stub().resolves(undefined), }; const extHostContext = new class implements IExtHostContext { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts index e4ed34928ff..a3a08cdb446 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts @@ -9,6 +9,7 @@ import { IPromptsService, PromptsStorage, IPromptPath } from '../../common/promp import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IAICustomizationWorkspaceService, applyStorageSourceFilter, IStorageSourceFilter } from '../../common/aiCustomizationWorkspaceService.js'; import { AICustomizationManagementSection } from './aiCustomizationManagement.js'; +import { IExternalCustomizationItemProvider } from '../../common/customizationHarnessService.js'; /** * Maps section ID to prompt type. Duplicated from aiCustomizationListWidget @@ -34,7 +35,7 @@ function sectionToPromptType(section: AICustomizationManagementSection): Prompts * Snapshot of the list widget's internal state, passed in to avoid coupling. */ export interface IDebugWidgetState { - readonly allItems: readonly { readonly storage: PromptsStorage }[]; + readonly allItems: readonly { readonly storage?: PromptsStorage }[]; readonly displayEntries: readonly { type: string; label?: string; count?: number; collapsed?: boolean }[]; } @@ -47,6 +48,7 @@ export async function generateCustomizationDebugReport( promptsService: IPromptsService, workspaceService: IAICustomizationWorkspaceService, widgetState: IDebugWidgetState, + externalProvider?: IExternalCustomizationItemProvider, ): Promise { const promptType = sectionToPromptType(section); const filter = workspaceService.getStorageSourceFilter(promptType); @@ -67,14 +69,55 @@ export async function generateCustomizationDebugReport( } lines.push(''); - await appendRawServiceData(lines, promptsService, promptType); - await appendFilteredData(lines, promptsService, promptType, filter); + if (externalProvider) { + await appendExternalProviderData(lines, externalProvider, promptType); + } else { + await appendRawServiceData(lines, promptsService, promptType); + await appendFilteredData(lines, promptsService, promptType, filter); + } appendWidgetState(lines, widgetState); - await appendSourceFolders(lines, promptsService, promptType); + if (!externalProvider) { + await appendSourceFolders(lines, promptsService, promptType); + } return lines.join('\n'); } +async function appendExternalProviderData(lines: string[], provider: IExternalCustomizationItemProvider, promptType: PromptsType): Promise { + lines.push('--- External Provider Data ---'); + + const allItems = await provider.provideChatSessionCustomizations(CancellationToken.None); + if (!allItems) { + lines.push(' Provider returned undefined'); + lines.push(''); + return; + } + + lines.push(` Total items from provider: ${allItems.length}`); + + // Group by type for summary + const byType = new Map(); + for (const item of allItems) { + const existing = byType.get(item.type) ?? []; + existing.push(item); + byType.set(item.type, existing); + } + for (const [type, items] of byType) { + lines.push(` ${type}: ${items.length} items`); + for (const item of items) { + lines.push(` ${item.name} — ${item.uri.fsPath ?? item.uri.toString()}`); + if (item.description) { + lines.push(` desc: ${item.description}`); + } + } + } + + // Show items matching the current section + const sectionItems = allItems.filter(i => i.type === promptType); + lines.push(` Items matching current section (${promptType}): ${sectionItems.length}`); + lines.push(''); +} + async function appendRawServiceData(lines: string[], promptsService: IPromptsService, promptType: PromptsType): Promise { lines.push('--- Stage 1: Raw PromptsService Data ---'); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index f6e6da00b6a..9f7c9b7a9ee 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -6,7 +6,7 @@ import './media/aiCustomizationManagement.css'; import * as DOM from '../../../../../base/browser/dom.js'; import { ActionBar } from '../../../../../base/browser/ui/actionbar/actionbar.js'; -import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { autorun } from '../../../../../base/common/observable.js'; @@ -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, AICustomizationManagementSection, BUILTIN_STORAGE, AI_CUSTOMIZATION_ITEM_DISABLED_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 } from './aiCustomizationManagement.js'; import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { InputBox } from '../../../../../base/browser/ui/inputbox/inputBox.js'; import { defaultButtonStyles, defaultInputBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; @@ -32,7 +32,7 @@ import { HighlightedLabel } from '../../../../../base/browser/ui/highlightedlabe import { matchesContiguousSubString, IMatch } from '../../../../../base/common/filters.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { Button, ButtonWithDropdown } from '../../../../../base/browser/ui/button/button.js'; -import { IMenuService } from '../../../../../platform/actions/common/actions.js'; +import { IMenuService, MenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { createActionViewItem, getContextMenuActions } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; @@ -53,13 +53,12 @@ import { parse as parseJSONC } from '../../../../../base/common/json.js'; import { Schemas } from '../../../../../base/common/network.js'; import { OS } from '../../../../../base/common/platform.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; -import { ICustomizationHarnessService, matchesWorkspaceSubpath, matchesInstructionFileFilter } from '../../common/customizationHarnessService.js'; -import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { getCleanPromptName, isInClaudeRulesFolder } from '../../common/promptSyntax/config/promptFileLocations.js'; +import { ICustomizationHarnessService, IExternalCustomizationItem, IExternalCustomizationItemProvider, matchesWorkspaceSubpath, matchesInstructionFileFilter } from '../../common/customizationHarnessService.js'; import { evaluateApplyToPattern } from '../../common/promptSyntax/computeAutomaticInstructions.js'; -import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { isInClaudeRulesFolder, getCleanPromptName } from '../../common/promptSyntax/config/promptFileLocations.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; -import { IChatSessionsService, IChatSessionCustomizationItem, IChatSessionCustomizationItemGroup } from '../../common/chatSessionsService.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; export { truncateToFirstLine } from './aiCustomizationListWidgetUtils.js'; @@ -94,7 +93,8 @@ export interface IAICustomizationListItem { readonly name: string; readonly filename: string; readonly description?: string; - readonly storage: PromptsStorage; + /** Storage origin. Set by core when items come from promptsService; omitted for external provider items. */ + readonly storage?: PromptsStorage; readonly promptType: PromptsType; readonly disabled: boolean; /** When set, overrides `storage` for display grouping purposes. */ @@ -419,7 +419,7 @@ class AICustomizationItemRenderer implements IListRenderer = { uri: element.uri.toString(), name: element.name, promptType: element.promptType, @@ -430,10 +430,12 @@ class AICustomizationItemRenderer implements IListRenderer(); - private _currentGroupCommands: IChatSessionCustomizationItemGroup['commands']; - private _currentGroupItemCommands: IChatSessionCustomizationItemGroup['itemCommands']; private readonly dropdownActionDisposables = this._register(new DisposableStore()); private readonly delayedFilter = new Delayer(200); @@ -642,7 +556,6 @@ export class AICustomizationListWidget extends Disposable { @IAgentPluginService private readonly agentPluginService: IAgentPluginService, @ICommandService private readonly commandService: ICommandService, @IProductService private readonly productService: IProductService, - @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, ) { super(); this.element = $('.ai-customization-list-widget'); @@ -662,6 +575,24 @@ export class AICustomizationListWidget extends Disposable { this.refresh(); })); + // Refresh when available harnesses change (external provider registered/unregistered) + this._register(autorun(reader => { + this.harnessService.availableHarnesses.read(reader); + this.refresh(); + })); + + // Subscribe to the active provider's onDidChange event + const providerChangeDisposable = this._register(new MutableDisposable()); + this._register(autorun(reader => { + this.harnessService.activeHarness.read(reader); + const activeDescriptor = this.harnessService.getActiveDescriptor(); + if (activeDescriptor.itemProvider) { + providerChangeDisposable.value = activeDescriptor.itemProvider.onDidChange(() => this.refresh()); + } else { + providerChangeDisposable.clear(); + } + })); + } private create(): void { @@ -776,7 +707,6 @@ export class AICustomizationListWidget extends Disposable { this._register(this.promptsService.onDidChangeCustomAgents(() => this.refresh())); this._register(this.promptsService.onDidChangeSlashCommands(() => this.refresh())); this._register(this.promptsService.onDidChangeSkills(() => this.refresh())); - this._register(this.chatSessionsService.onDidChangeCustomizations(() => this.refresh())); // Refresh on file deletions so the list updates after inline delete actions this._register(this.fileService.onDidFilesChange(e => { @@ -810,7 +740,7 @@ export class AICustomizationListWidget extends Disposable { const item = e.element.item; // Create context for the menu actions - const context = { + const context: Record = { uri: item.uri.toString(), name: item.name, promptType: item.promptType, @@ -821,10 +751,12 @@ export class AICustomizationListWidget extends Disposable { // Create scoped context key service with item-specific keys for when-clause filtering const overlayPairs: [string, string | boolean][] = [ [AI_CUSTOMIZATION_ITEM_TYPE_KEY, item.promptType], - [AI_CUSTOMIZATION_ITEM_STORAGE_KEY, item.storage], [AI_CUSTOMIZATION_ITEM_URI_KEY, item.uri.toString()], [AI_CUSTOMIZATION_ITEM_DISABLED_KEY, item.disabled], ]; + 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()]); } @@ -857,25 +789,9 @@ export class AICustomizationListWidget extends Disposable { }), ]; - // Add extension-provided item commands for non-built-in harnesses - const activeHarness = this.harnessService.activeHarness.get(); - const hasProvider = this.chatSessionsService.hasCustomizationsProvider(activeHarness); - const extensionItemActions = (hasProvider && this._currentGroupItemCommands?.length) - ? [ - new Separator(), - ...this._currentGroupItemCommands.map(cmd => new Action( - cmd.id, - cmd.title, - undefined, - true, - async () => { this.commandService.executeCommand(cmd.id, item.uri.toString()); }, - )), - ] - : []; - this.contextMenuService.showContextMenu({ getAnchor: () => e.anchor, - getActions: () => [...secondary, ...extensionItemActions, ...copyActions], + getActions: () => [...secondary, ...copyActions], }); } @@ -971,17 +887,6 @@ export class AICustomizationListWidget extends Disposable { const override = descriptor.sectionOverrides?.get(this.currentSection); const hasWorkspace = this.hasActiveWorkspace(); - // Extension-contributed harness: use commands from the provider group - const activeHarness = this.harnessService.activeHarness.get(); - const hasProvider = this.chatSessionsService.hasCustomizationsProvider(activeHarness); - if (hasProvider && this._currentGroupCommands?.length) { - return this._currentGroupCommands.map(cmd => ({ - label: `$(${Codicon.add.id}) ${cmd.title}`, - enabled: true, - run: () => { this.commandService.executeCommand(cmd.id, ...(cmd.arguments ?? [])); }, - })); - } - // Full command override (e.g. Claude hooks) — single action, no dropdown if (override?.commandId) { return [{ @@ -1092,6 +997,34 @@ export class AICustomizationListWidget extends Disposable { } } + // Check for menu-contributed create actions from extensions. + // Extensions contribute to AICustomizationManagementCreateMenuId with + // when-clauses targeting aiCustomizationManagementHarness and + // aiCustomizationManagementSection context keys. + // When a harness contributes create actions, they REPLACE the built-in ones. + const menuActions = this.menuService.getMenuActions( + AICustomizationManagementCreateMenuId, + this.contextKeyService, + { shouldForwardArgs: true }, + ); + const extensionCreateActions: ICreateAction[] = []; + for (const [, group] of menuActions) { + for (const menuItem of group) { + if (menuItem instanceof MenuItemAction) { + const icon = ThemeIcon.isThemeIcon(menuItem.item.icon) ? menuItem.item.icon.id : Codicon.add.id; + extensionCreateActions.push({ + label: `$(${icon}) ${typeof menuItem.item.title === 'string' ? menuItem.item.title : menuItem.item.title.value}`, + enabled: menuItem.enabled, + run: () => { menuItem.run(); }, + }); + } + } + } + + if (extensionCreateActions.length > 0) { + return extensionCreateActions; + } + return actions; } @@ -1157,28 +1090,12 @@ export class AICustomizationListWidget extends Disposable { */ private async loadItems(): Promise { const section = this.currentSection; - this._currentGroupCommands = undefined; - this._currentGroupItemCommands = undefined; const items = await this.fetchItemsForSection(section); if (this.currentSection !== section) { return; // section changed while loading } - // Fetch commands for extension-contributed harnesses separately to avoid - // race conditions when _fetchItemsFromProvider is also called for count - // computation on other sections. - const activeHarness = this.harnessService.activeHarness.get(); - const hasProvider = this.chatSessionsService.hasCustomizationsProvider(activeHarness); - if (hasProvider) { - const groups = await this.chatSessionsService.getCustomizations(activeHarness, CancellationToken.None); - if (groups) { - const matchingIds = sectionToCustomizationGroupIds(section); - this._currentGroupCommands = groups.filter(g => matchingIds.includes(g.id)).flatMap(g => g.commands ?? []); - this._currentGroupItemCommands = groups.filter(g => matchingIds.includes(g.id)).flatMap(g => g.itemCommands ?? []); - } - } - this.allItems = items; this.filterItems(); this._onDidChangeItemCount.fire(items.length); @@ -1241,15 +1158,15 @@ export class AICustomizationListWidget extends Disposable { * Shared between `loadItems` (active section) and `computeItemCountForSection` (any section). */ private async fetchItemsForSection(section: AICustomizationManagementSection): Promise { - // Extension-contributed harnesses always use the provider path - const activeHarness = this.harnessService.activeHarness.get(); - const hasProvider = this.chatSessionsService.hasCustomizationsProvider(activeHarness); + const promptType = sectionToPromptType(section); - if (hasProvider) { - return this._fetchItemsFromProvider(section); + // When the active harness has an external item provider, delegate to it + // instead of querying promptsService and applying filters. + const activeDescriptor = this.harnessService.getActiveDescriptor(); + if (activeDescriptor.itemProvider && promptType) { + return this.fetchItemsFromProvider(activeDescriptor.itemProvider, promptType); } - const promptType = sectionToPromptType(section); const items: IAICustomizationListItem[] = []; const disabledUris = this.promptsService.getDisabledPromptFiles(promptType); const extensionInfoByUri = new Map(); @@ -1569,7 +1486,9 @@ export class AICustomizationListWidget extends Disposable { // Apply storage source filter (removes items not in visible sources or excluded user roots) const filter = this.workspaceService.getStorageSourceFilter(promptType); - const filteredItems = applyStorageSourceFilter(groupedItems, filter); + const withStorage = groupedItems.filter((item): item is IAICustomizationListItem & { readonly storage: PromptsStorage } => item.storage !== undefined); + const withoutStorage = groupedItems.filter(item => item.storage === undefined); + const filteredItems = [...applyStorageSourceFilter(withStorage, filter), ...withoutStorage]; items.length = 0; items.push(...filteredItems); @@ -1616,33 +1535,27 @@ export class AICustomizationListWidget extends Disposable { } /** - * Fetches items from the registered {@link IChatSessionCustomizationsProvider} - * instead of the built-in {@link IPromptsService} discovery. + * Fetches items from an external customization provider, converting + * the provider's items into the list widget format. */ - private async _fetchItemsFromProvider(section: AICustomizationManagementSection): Promise { - const promptType = sectionToPromptType(section); - - // Use the active harness ID as the session type - const activeHarness = this.harnessService.activeHarness.get(); - const groups = await this.chatSessionsService.getCustomizations(activeHarness, CancellationToken.None); - - if (!groups) { + private async fetchItemsFromProvider(provider: IExternalCustomizationItemProvider, promptType: PromptsType): Promise { + const allItems = await provider.provideChatSessionCustomizations(CancellationToken.None); + if (!allItems) { return []; } - // Filter groups to only those matching the current section - const matchingGroupIds = sectionToCustomizationGroupIds(section); - const filteredGroups = groups.filter(g => matchingGroupIds.includes(g.id)); - - const items: IAICustomizationListItem[] = []; - for (const group of filteredGroups) { - for (const item of group.items) { - items.push(mapProviderItemToListItem(item, group.id, promptType)); - } - } - - items.sort((a, b) => a.name.localeCompare(b.name)); - return items; + return allItems + .filter(item => item.type === promptType) + .map((item: IExternalCustomizationItem) => ({ + id: item.uri.toString(), + uri: item.uri, + name: item.name, + filename: basename(item.uri), + description: item.description, + promptType, + disabled: false, + })) + .sort((a, b) => a.name.localeCompare(b.name)); } /** @@ -1695,7 +1608,19 @@ export class AICustomizationListWidget extends Disposable { } } - // Group items — instructions use category-based grouping; other sections use storage-based + // When items come from an external provider, skip storage-based grouping + // and render a flat list. The provider controls the full item set, so + // Workspace/User/Extension categories don't apply. + const activeDescriptor = this.harnessService.getActiveDescriptor(); + if (activeDescriptor.itemProvider) { + matchedItems.sort((a, b) => a.name.localeCompare(b.name)); + this.displayEntries = matchedItems.map(item => ({ type: 'file-item' as const, item })); + this.list.splice(0, this.list.length, this.displayEntries); + this.updateEmptyState(); + return matchedItems.length; + } + + // Group items by storage const promptType = sectionToPromptType(this.currentSection); const visibleSources = new Set(this.workspaceService.getStorageSourceFilter(promptType).sources); const groups: { groupKey: string; label: string; icon: ThemeIcon; description: string; items: IAICustomizationListItem[] }[] = @@ -1715,7 +1640,7 @@ export class AICustomizationListWidget extends Disposable { ].filter(g => visibleSources.has(g.groupKey as PromptsStorage) || g.groupKey === 'agents'); for (const item of matchedItems) { - const key = item.groupKey ?? item.storage; + const key = item.groupKey ?? item.storage ?? PromptsStorage.local; const group = groups.find(g => g.groupKey === key); if (group) { group.items.push(item); @@ -1934,11 +1859,13 @@ export class AICustomizationListWidget extends Disposable { * Generates a debug report for the current section. */ async generateDebugReport(): Promise { + const activeDescriptor = this.harnessService.getActiveDescriptor(); return generateCustomizationDebugReport( this.currentSection, this.promptsService, this.workspaceService, { allItems: this.allItems, displayEntries: this.displayEntries }, + activeDescriptor.itemProvider, ); } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts index d6439837a8e..f36decf30f0 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts @@ -52,6 +52,16 @@ export const CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_SECTION = new RawContextKey( + 'aiCustomizationManagementHarness', + '', + localize('aiCustomizationManagementHarness', "The active harness (session type) in the Chat Customizations editor") +); + /** * Menu ID for the AI Customization Management Editor title bar actions. */ @@ -62,6 +72,13 @@ export const AICustomizationManagementTitleMenuId = MenuId.for('AICustomizationM */ export const AICustomizationManagementItemMenuId = MenuId.for('AICustomizationManagementEditorItem'); +/** + * Menu ID for the AI Customization Management Editor create/add button. + * Extensions can contribute commands here to add create actions to the section's add button dropdown. + * Use the `aiCustomizationManagementSection` context key to target a specific section. + */ +export const AICustomizationManagementCreateMenuId = MenuId.for('AICustomizationManagementCreate'); + /** * Context key for the item prompt type (e.g. 'prompt', 'agent') used in when-clause filtering. */ diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index df45fc2b9b5..d5f83373be3 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -46,6 +46,7 @@ import { BUILTIN_STORAGE, CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_EDITOR, CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_SECTION, + CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_HARNESS, SIDEBAR_DEFAULT_WIDTH, SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH, @@ -311,9 +312,11 @@ export class AICustomizationManagementEditor extends EditorPane { private harnessDropdownButton: HTMLElement | undefined; private harnessDropdownIcon: HTMLElement | undefined; private harnessDropdownLabel: HTMLElement | undefined; + private sidebarContent: HTMLElement | undefined; private readonly inEditorContextKey: IContextKey; private readonly sectionContextKey: IContextKey; + private readonly harnessContextKey: IContextKey; constructor( group: IEditorGroup, @@ -342,6 +345,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.inEditorContextKey = CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_EDITOR.bindTo(contextKeyService); this.sectionContextKey = CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_SECTION.bindTo(contextKeyService); + this.harnessContextKey = CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_HARNESS.bindTo(contextKeyService); // Track workspace changes for embedded editor this._register(autorun(reader => { @@ -513,7 +517,7 @@ export class AICustomizationManagementEditor extends EditorPane { } private createSidebar(): void { - const sidebarContent = DOM.append(this.sidebarContainer, $('.sidebar-content')); + const sidebarContent = this.sidebarContent = DOM.append(this.sidebarContainer, $('.sidebar-content')); // Harness dropdown (shown when multiple harnesses available) this.createHarnessDropdown(sidebarContent); @@ -565,7 +569,9 @@ export class AICustomizationManagementEditor extends EditorPane { return; // setActiveHarness will trigger another autorun cycle } + this.harnessContextKey.set(activeId); this.rebuildVisibleSections(); + this.ensureHarnessDropdown(); this.updateHarnessDropdown(); this.refreshAllPromptsSectionCounts(); })); @@ -613,6 +619,27 @@ export class AICustomizationManagementEditor extends EditorPane { })); } + /** + * Lazily creates the harness dropdown if it doesn't exist but + * multiple harnesses are now available, or hides it if only one + * harness remains (e.g. after an extension-contributed harness is + * unregistered). + */ + private ensureHarnessDropdown(): void { + const harnesses = this.harnessService.availableHarnesses.get(); + const shouldShow = this.isHarnessSelectorEnabled && harnesses.length > 1; + + if (shouldShow && !this.harnessDropdownContainer && this.sidebarContent) { + this.createHarnessDropdown(this.sidebarContent); + } else if (!shouldShow && this.harnessDropdownContainer) { + this.harnessDropdownContainer.remove(); + this.harnessDropdownContainer = undefined; + this.harnessDropdownButton = undefined; + this.harnessDropdownIcon = undefined; + this.harnessDropdownLabel = undefined; + } + } + private updateHarnessDropdown(): void { if (!this.harnessDropdownContainer || !this.harnessDropdownIcon || !this.harnessDropdownLabel) { return; @@ -726,11 +753,12 @@ export class AICustomizationManagementEditor extends EditorPane { this.telemetryService.publicLog2('chatCustomizationEditor.itemSelected', { section: this.selectedSection, promptType: item.promptType, - storage: item.storage, + storage: item.storage ?? 'external', }); - const isWorkspaceFile = item.storage === PromptsStorage.local; - const isReadOnly = item.storage === PromptsStorage.extension || item.storage === PromptsStorage.plugin || item.storage === BUILTIN_STORAGE; - this.showEmbeddedEditor(item.uri, item.name, item.promptType, item.storage, isWorkspaceFile, isReadOnly); + const storage = item.storage; + const isWorkspaceFile = storage === PromptsStorage.local; + const isReadOnly = !storage || storage === PromptsStorage.extension || storage === PromptsStorage.plugin || storage === BUILTIN_STORAGE; + this.showEmbeddedEditor(item.uri, item.name, item.promptType, storage ?? BUILTIN_STORAGE, isWorkspaceFile, isReadOnly); })); // Handle create actions - AI-guided creation diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts index 3f03253741d..417546fdca2 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { derived, observableFromEvent } from '../../../../../base/common/observable.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; import { CustomizationHarness, @@ -19,17 +18,14 @@ import { import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { BUILTIN_STORAGE } from '../../common/aiCustomizationWorkspaceService.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; -import { IChatAgentService } from '../../common/participants/chatAgents.js'; /** * Core implementation of the customization harness service. * Exposes VS Code, CLI, and Claude harnesses for filtering customizations. - * CLI and Claude harnesses are only shown when their respective agents are registered. */ class CustomizationHarnessService extends CustomizationHarnessServiceBase { constructor( @IPathService pathService: IPathService, - @IChatAgentService chatAgentService: IChatAgentService, ) { const userHome = pathService.userHome({ preferLocal: true }); // The Local harness includes extension-contributed and built-in customizations. @@ -43,26 +39,9 @@ class CustomizationHarnessService extends CustomizationHarnessServiceBase { createClaudeHarnessDescriptor(getClaudeUserRoots(userHome), restrictedExtras), ]; - // Track agent registration changes as an observable. - // Return the agent count so the value changes on each event - // (observableFromEvent uses strictEquals to decide whether to notify). - const agentCount = observableFromEvent(chatAgentService.onDidChangeAgents, () => chatAgentService.getAgents().length); - - // Derive available harnesses from agent registration state - const available = derived(reader => { - agentCount.read(reader); - return allHarnesses.filter(h => { - if (!h.requiredAgentId) { - return true; - } - return !!chatAgentService.getAgent(h.requiredAgentId); - }); - }); - super( allHarnesses, CustomizationHarness.VSCode, - available, ); } } diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index 8ab06b5762f..9c5a935bc78 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -4,8 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../base/common/codicons.js'; -import { IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { constObservable, derived, IObservable, ISettableObservable, observableValue } from '../../../../base/common/observable.js'; +import { IObservable, ISettableObservable, observableValue } from '../../../../base/common/observable.js'; +import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { Event } from '../../../../base/common/event.js'; import { joinPath } from '../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; @@ -13,8 +14,9 @@ import { localize } from '../../../../nls.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { AICustomizationManagementSection, IStorageSourceFilter } from './aiCustomizationWorkspaceService.js'; import { PromptsType } from './promptSyntax/promptTypes.js'; -import { PromptsStorage } from './promptSyntax/service/promptsService.js'; import { AGENT_MD_FILENAME } from './promptSyntax/config/promptFileLocations.js'; +import { PromptsStorage } from './promptSyntax/service/promptsService.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; export const ICustomizationHarnessService = createDecorator('customizationHarnessService'); @@ -119,6 +121,37 @@ export interface IHarnessDescriptor { * items of the given type when this harness is active. */ getStorageSourceFilter(type: PromptsType): IStorageSourceFilter; + /** + * When set, this harness is backed by an extension-contributed provider + * that can supply customization items directly (bypassing promptsService + * discovery and filtering). + */ + readonly itemProvider?: IExternalCustomizationItemProvider; +} + +/** + * Represents a customization item provided by an external extension. + */ +export interface IExternalCustomizationItem { + readonly uri: URI; + readonly type: string; + readonly name: string; + readonly description?: string; +} + +/** + * Provider interface for extension-contributed harnesses that supply + * customization items directly from their SDK. + */ +export interface IExternalCustomizationItemProvider { + /** + * Event that fires when the provider's customizations change. + */ + readonly onDidChange: Event; + /** + * Provide the customization items this harness supports. + */ + provideChatSessionCustomizations(token: CancellationToken): Promise; } /** @@ -149,12 +182,6 @@ export interface ICustomizationHarnessService { */ setActiveHarness(id: string): void; - /** - * Registers a harness descriptor contributed by an extension's - * customizations provider. Returns a disposable that removes the harness. - */ - registerContributedHarness(descriptor: IHarnessDescriptor): IDisposable; - /** * Convenience: returns the storage source filter for the active harness * and the given customization type. @@ -165,6 +192,13 @@ export interface ICustomizationHarnessService { * Returns the descriptor of the currently active harness. */ getActiveDescriptor(): IHarnessDescriptor; + + /** + * Registers an external harness contributed by an extension. + * The harness appears in the UI toggle alongside static harnesses. + * Returns a disposable that removes the harness when disposed. + */ + registerExternalHarness(descriptor: IHarnessDescriptor): IDisposable; } // #region Shared filter constants @@ -381,53 +415,69 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer private readonly _activeHarness: ISettableObservable; readonly activeHarness: IObservable; + + private readonly _staticHarnesses: readonly IHarnessDescriptor[]; + private readonly _externalHarnesses: IHarnessDescriptor[] = []; + private readonly _availableHarnesses: ISettableObservable; readonly availableHarnesses: IObservable; - private readonly _allHarnesses: readonly IHarnessDescriptor[]; - private readonly _contributedHarnesses = observableValue(this, []); - constructor( - harnesses: readonly IHarnessDescriptor[], + staticHarnesses: readonly IHarnessDescriptor[], defaultHarness: string, - builtInAvailable?: IObservable, ) { - this._allHarnesses = harnesses; + this._staticHarnesses = staticHarnesses; this._activeHarness = observableValue(this, defaultHarness); this.activeHarness = this._activeHarness; + this._availableHarnesses = observableValue(this, [...this._staticHarnesses]); + this.availableHarnesses = this._availableHarnesses; + } - const builtIn = builtInAvailable ?? constObservable(harnesses); - this.availableHarnesses = derived(this, reader => { - const built = builtIn.read(reader); - const contributed = this._contributedHarnesses.read(reader); - return [...built, ...contributed]; - }); + private _getAllHarnesses(): readonly IHarnessDescriptor[] { + return [...this._staticHarnesses, ...this._externalHarnesses]; + } + + private _refreshAvailableHarnesses(): void { + this._availableHarnesses.set(this._getAllHarnesses(), undefined); + } + + registerExternalHarness(descriptor: IHarnessDescriptor): IDisposable { + this._externalHarnesses.push(descriptor); + this._refreshAvailableHarnesses(); + return { + dispose: () => { + const idx = this._externalHarnesses.indexOf(descriptor); + if (idx >= 0) { + this._externalHarnesses.splice(idx, 1); + this._refreshAvailableHarnesses(); + // If the removed harness was active, fall back to the first available + if (this._activeHarness.get() === descriptor.id) { + const all = this._getAllHarnesses(); + if (all.length > 0) { + this._activeHarness.set(all[0].id, undefined); + } + } + } + } + }; } setActiveHarness(id: string): void { - const available = this.availableHarnesses.get(); - if (available.some(h => h.id === id)) { + if (this._getAllHarnesses().some(h => h.id === id)) { this._activeHarness.set(id, undefined); } } - registerContributedHarness(descriptor: IHarnessDescriptor): IDisposable { - const current = this._contributedHarnesses.get(); - this._contributedHarnesses.set([...current, descriptor], undefined); - return toDisposable(() => { - const updated = this._contributedHarnesses.get().filter(h => h !== descriptor); - this._contributedHarnesses.set(updated, undefined); - }); - } - getStorageSourceFilter(type: PromptsType): IStorageSourceFilter { - const descriptor = this.getActiveDescriptor(); - return descriptor.getStorageSourceFilter(type); + const activeId = this._activeHarness.get(); + const all = this._getAllHarnesses(); + const descriptor = all.find(h => h.id === activeId); + return descriptor?.getStorageSourceFilter(type) ?? all[0].getStorageSourceFilter(type); } getActiveDescriptor(): IHarnessDescriptor { const activeId = this._activeHarness.get(); - const available = this.availableHarnesses.get(); - return available.find(h => h.id === activeId) ?? available[0] ?? this._allHarnesses[0]; + const all = this._getAllHarnesses(); + return all.find(h => h.id === activeId) ?? all[0]; } } diff --git a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts new file mode 100644 index 00000000000..e18d4cf2750 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts @@ -0,0 +1,217 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Emitter } from '../../../../../base/common/event.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { CustomizationHarness, CustomizationHarnessServiceBase, createVSCodeHarnessDescriptor, IExternalCustomizationItemProvider, IHarnessDescriptor, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; + +suite('CustomizationHarnessService', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function createService(...harnesses: IHarnessDescriptor[]): CustomizationHarnessServiceBase { + if (harnesses.length === 0) { + harnesses = [createVSCodeHarnessDescriptor([PromptsStorage.extension])]; + } + return new CustomizationHarnessServiceBase(harnesses, harnesses[0].id); + } + + suite('registerExternalHarness', () => { + test('adds harness to available list', () => { + const service = createService(); + assert.strictEqual(service.availableHarnesses.get().length, 1); + + const emitter = new Emitter(); + store.add(emitter); + const externalDescriptor: IHarnessDescriptor = { + id: 'test-ext', + label: 'Test Extension', + icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + itemProvider: { + onDidChange: emitter.event, + provideChatSessionCustomizations: async () => [], + }, + }; + + const reg = service.registerExternalHarness(externalDescriptor); + store.add(reg); + + assert.strictEqual(service.availableHarnesses.get().length, 2); + assert.strictEqual(service.availableHarnesses.get()[1].id, 'test-ext'); + }); + + test('removes harness on dispose', () => { + const service = createService(); + const emitter = new Emitter(); + store.add(emitter); + const externalDescriptor: IHarnessDescriptor = { + id: 'test-ext', + label: 'Test Extension', + icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + itemProvider: { + onDidChange: emitter.event, + provideChatSessionCustomizations: async () => [], + }, + }; + + const reg = service.registerExternalHarness(externalDescriptor); + assert.strictEqual(service.availableHarnesses.get().length, 2); + + reg.dispose(); + assert.strictEqual(service.availableHarnesses.get().length, 1); + }); + + test('falls back to first harness when active external harness is removed', () => { + const service = createService(); + const emitter = new Emitter(); + store.add(emitter); + const externalDescriptor: IHarnessDescriptor = { + id: 'test-ext', + label: 'Test Extension', + icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + itemProvider: { + onDidChange: emitter.event, + provideChatSessionCustomizations: async () => [], + }, + }; + + const reg = service.registerExternalHarness(externalDescriptor); + service.setActiveHarness('test-ext'); + assert.strictEqual(service.activeHarness.get(), 'test-ext'); + + reg.dispose(); + assert.strictEqual(service.activeHarness.get(), CustomizationHarness.VSCode); + }); + + test('allows switching to external harness', () => { + const service = createService(); + const emitter = new Emitter(); + store.add(emitter); + const externalDescriptor: IHarnessDescriptor = { + id: 'test-ext', + label: 'Test Extension', + icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + itemProvider: { + onDidChange: emitter.event, + provideChatSessionCustomizations: async () => [], + }, + }; + + store.add(service.registerExternalHarness(externalDescriptor)); + service.setActiveHarness('test-ext'); + assert.strictEqual(service.activeHarness.get(), 'test-ext'); + + const activeDescriptor = service.getActiveDescriptor(); + assert.strictEqual(activeDescriptor.id, 'test-ext'); + assert.strictEqual(activeDescriptor.label, 'Test Extension'); + assert.ok(activeDescriptor.itemProvider); + }); + + test('external harness provides storage filter', () => { + const service = createService(); + const emitter = new Emitter(); + store.add(emitter); + const customFilter = { sources: [PromptsStorage.local, PromptsStorage.user] }; + const externalDescriptor: IHarnessDescriptor = { + id: 'test-ext', + label: 'Test Extension', + icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => customFilter, + itemProvider: { + onDidChange: emitter.event, + provideChatSessionCustomizations: async () => [], + }, + }; + + store.add(service.registerExternalHarness(externalDescriptor)); + service.setActiveHarness('test-ext'); + assert.deepStrictEqual(service.getStorageSourceFilter(PromptsType.agent), customFilter); + }); + + test('external harness item provider returns items', async () => { + const service = createService(); + 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' }, + ]; + + const itemProvider: IExternalCustomizationItemProvider = { + onDidChange: emitter.event, + provideChatSessionCustomizations: async () => testItems, + }; + + const externalDescriptor: IHarnessDescriptor = { + id: 'test-ext', + label: 'Test Extension', + icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + itemProvider, + }; + + store.add(service.registerExternalHarness(externalDescriptor)); + service.setActiveHarness('test-ext'); + + const items = await service.getActiveDescriptor().itemProvider!.provideChatSessionCustomizations(CancellationToken.None); + assert.strictEqual(items?.length, 1); + assert.strictEqual(items![0].name, 'Test Skill'); + assert.strictEqual(items![0].type, 'skill'); + }); + + test('external harness with hidden sections and workspace subpaths', () => { + const service = createService(); + const emitter = new Emitter(); + store.add(emitter); + const externalDescriptor: IHarnessDescriptor = { + id: 'test-ext', + label: 'Test Extension', + icon: ThemeIcon.fromId('extensions'), + hiddenSections: ['agents', 'prompts'], + workspaceSubpaths: ['.test-ext'], + getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + itemProvider: { + onDidChange: emitter.event, + provideChatSessionCustomizations: async () => [], + }, + }; + + store.add(service.registerExternalHarness(externalDescriptor)); + service.setActiveHarness('test-ext'); + + const descriptor = service.getActiveDescriptor(); + assert.deepStrictEqual(descriptor.hiddenSections, ['agents', 'prompts']); + assert.deepStrictEqual(descriptor.workspaceSubpaths, ['.test-ext']); + }); + }); + + suite('matchesWorkspaceSubpath', () => { + test('matches segment boundary', () => { + assert.ok(matchesWorkspaceSubpath('/workspace/.claude/skills/SKILL.md', ['.claude'])); + assert.ok(matchesWorkspaceSubpath('/workspace/.github/instructions.md', ['.github'])); + }); + + test('does not match partial segment', () => { + assert.ok(!matchesWorkspaceSubpath('/workspace/not.claude/file.md', ['.claude'])); + }); + + test('matches path ending with subpath', () => { + assert.ok(matchesWorkspaceSubpath('/workspace/.claude', ['.claude'])); + }); + + test('matches any of multiple subpaths', () => { + assert.ok(matchesWorkspaceSubpath('/workspace/.copilot/file.md', ['.github', '.copilot'])); + assert.ok(matchesWorkspaceSubpath('/workspace/.github/file.md', ['.github', '.copilot'])); + }); + }); +}); diff --git a/src/vs/workbench/services/actions/common/menusExtensionPoint.ts b/src/vs/workbench/services/actions/common/menusExtensionPoint.ts index 1a80b0b1e3e..2a2643375a2 100644 --- a/src/vs/workbench/services/actions/common/menusExtensionPoint.ts +++ b/src/vs/workbench/services/actions/common/menusExtensionPoint.ts @@ -524,6 +524,13 @@ const apiMenus: IAPIMenu[] = [ supportsSubmenus: false, proposed: 'chatSessionsProvider', }, + { + key: 'chat/customizations/create', + id: MenuId.for('AICustomizationManagementCreate'), + description: localize('menus.chatCustomizationsCreate', "The create button in the Chat Customizations management editor."), + supportsSubmenus: false, + proposed: 'chatSessionCustomizationProvider', + }, { key: 'chat/editor/inlineGutter', id: MenuId.ChatEditorInlineMenu, diff --git a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts index bafcf3d70ba..53835fc84b5 100644 --- a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts @@ -104,7 +104,7 @@ function createMockHarnessService(): ICustomizationHarnessService { override readonly availableHarnesses = observableValue('harnesses', [descriptor]); override getStorageSourceFilter() { return defaultFilter; } override getActiveDescriptor() { return descriptor; } - override registerContributedHarness() { return { dispose() { } }; } + override registerExternalHarness() { return { dispose() { } }; } }(); } diff --git a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts index cd2ee3fc79a..2b889a18d50 100644 --- a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts @@ -176,7 +176,7 @@ function createMockHarnessService(activeHarness: CustomizationHarness, descripto return descriptors.find(h => h.id === active.get()) ?? descriptors[0]; } override setActiveHarness(id: string) { active.set(id, undefined); } - override registerContributedHarness() { return { dispose() { } }; } + override registerExternalHarness() { return { dispose() { } }; } }(); } @@ -653,7 +653,7 @@ async function renderMcpBrowseMode(ctx: ComponentFixtureContext): Promise reg.defineInstance(ICustomizationHarnessService, new class extends mock() { override readonly activeHarness = observableValue('activeHarness', CustomizationHarness.VSCode); override getActiveDescriptor() { return createVSCodeHarnessDescriptor([PromptsStorage.extension, BUILTIN_STORAGE]); } - override registerContributedHarness() { return { dispose() { } }; } + override registerExternalHarness() { return { dispose() { } }; } }()); }, }); diff --git a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts new file mode 100644 index 00000000000..ac7425f8dd8 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.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. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + // #region Customization Provider Types + + /** + * Identifies the kind of customization an item represents. + * + * Use the built-in static instances (e.g. {@link ChatSessionCustomizationType.Agent}) + * for well-known customization types, or create a new instance with a custom + * string id for extension-defined types. + */ + export class ChatSessionCustomizationType { + /** Agent customization (`.agent.md` files). */ + static readonly Agent: ChatSessionCustomizationType; + /** Skill customization (`SKILL.md` files). */ + static readonly Skill: ChatSessionCustomizationType; + /** Instruction customization (`.instructions.md` files). */ + static readonly Instructions: ChatSessionCustomizationType; + /** Prompt customization (`.prompt.md` files). */ + static readonly Prompt: ChatSessionCustomizationType; + /** Hook customization (event-driven automation). */ + static readonly Hook: ChatSessionCustomizationType; + + /** + * The string identifier for this customization type. + */ + readonly id: string; + + /** + * Create a new customization type. + * + * @param id A unique string identifier for this type (e.g. `'agent'`, `'skill'`). + */ + constructor(id: string); + } + + /** + * Metadata describing a customization provider and its capabilities. + * This drives UI presentation (label, icon) and filtering (unsupported types, + * workspace sub-paths). + */ + export interface ChatSessionCustomizationProviderMetadata { + /** + * Display label for this provider (e.g. "Copilot CLI", "Claude Code"). + */ + readonly label: string; + + /** + * Optional codicon ID for this provider's icon in the UI. + */ + readonly iconId?: string; + + /** + * Customization types that this provider does **not** support. + * The corresponding sections will be hidden in the management UI + * when this provider is active. + */ + readonly unsupportedTypes?: readonly ChatSessionCustomizationType[]; + + /** + * Workspace sub-paths that this provider recognizes for customization files. + * When set, only workspace files under these paths are shown in the UI. + * For example, `['.claude']` for Claude or `['.github', '.copilot']` for CLI. + * When `undefined`, all workspace paths are shown. + */ + readonly workspaceSubpaths?: readonly string[]; + } + + /** + * Represents a single customization item reported by a provider. + */ + export interface ChatSessionCustomizationItem { + /** + * URI to the customization file (e.g. an `.agent.md`, `SKILL.md`, or `.instructions.md` file). + */ + readonly uri: Uri; + + /** + * The type of customization this item represents. + */ + readonly type: ChatSessionCustomizationType; + + /** + * Display name for this customization. + */ + readonly name: string; + + /** + * Optional description of this customization. + */ + readonly description?: string; + } + + /** + * A provider that reports which chat customizations are available. + * + * Chat customizations are configuration artifacts — agents, skills, + * instructions, prompts, and hooks — that augment LLM behavior during + * a chat session. Extensions that manage their own customization files + * (e.g. from an SDK's config directory) register a provider so the + * management UI can discover and display them. + * + * ### Lifecycle + * + * 1. Register via {@link chat.registerChatSessionCustomizationProvider}. + * 2. The UI calls {@link provideChatSessionCustomizations} once and caches + * the result. + * 3. When the underlying files change, fire {@link onDidChange} to + * trigger a fresh call to {@link provideChatSessionCustomizations}. + */ + export interface ChatSessionCustomizationProvider { + /** + * An optional event that fires when the provider's customizations change. + * The UI caches the result of {@link provideChatSessionCustomizations} and will + * only re-query the provider when this event fires. + */ + readonly onDidChange?: Event; + + /** + * Provide the customization items this provider supports. + * + * The result is cached by the UI until {@link onDidChange} fires. + * + * @param token A cancellation token. + * @returns The list of customization items, or `undefined` if unavailable. + */ + provideChatSessionCustomizations(token: CancellationToken): ProviderResult; + } + + // #endregion + + // #region Registration + + export namespace chat { + /** + * Register a customization provider that reports what customizations + * a harness or runtime supports. The provider's metadata drives UI + * presentation and filtering, while {@link ChatSessionCustomizationProvider.provideChatSessionCustomizations} + * supplies the actual items. + * + * @param chatSessionType The session type this provider is for (e.g. `'cli'`, `'claude'`). + * @param metadata Metadata describing the provider's capabilities and UI presentation. + * @param provider The customization provider implementation. + * @returns A disposable that unregisters the provider when disposed. + */ + export function registerChatSessionCustomizationProvider(chatSessionType: string, metadata: ChatSessionCustomizationProviderMetadata, provider: ChatSessionCustomizationProvider): Disposable; + } + + // #endregion +} diff --git a/src/vscode-dts/vscode.proposed.chatSessionCustomizations.d.ts b/src/vscode-dts/vscode.proposed.chatSessionCustomizations.d.ts deleted file mode 100644 index 11275ed107f..00000000000 --- a/src/vscode-dts/vscode.proposed.chatSessionCustomizations.d.ts +++ /dev/null @@ -1,145 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// Types used by the chatSessionsProvider proposal for chat customizations. - -declare module 'vscode' { - - /** - * Well-known customization type identifiers. - * - * Extensions may use these as the {@link ChatSessionCustomizationItemGroup.id id} - * of a {@link ChatSessionCustomizationItemGroup} to place items into standard - * sections in the management UI. - * - * TODO: How granular should we be? Consider removing the sub-instruction - * types (ContextInstructions, OnDemandInstructions) and collapsing to a - * single 'instructions' type. - */ - export enum ChatSessionCustomizationType { - Agents = 'agents', - Skills = 'skills', - AgentInstructions = 'agentInstructions', - ContextInstructions = 'contextInstructions', - OnDemandInstructions = 'onDemandInstructions', - Prompts = 'prompts', - } - - /** - * Where a customization item originates from. - * - * Controls default behaviour in the management UI (grouping, delete-ability). - * - * TODO: Should this be inferred by core itself depending on the URI - * scheme/path rather than declared by the extension? - */ - export enum ChatSessionCustomizationStorageLocation { - /** From the current workspace (`.github/` folder, workspace root, etc.) */ - Workspace = 1, - /** From user-level configuration (`~/.copilot/`, `~/.config/`, etc.) */ - User = 2, - /** From an extension's contribution */ - Extension = 3, - /** From an installed plugin */ - Plugin = 4, - /** Built into the session provider itself */ - BuiltIn = 5, - } - - /** - * A single customization item such as an agent, skill, instruction, or prompt. - */ - export interface ChatSessionCustomizationItem { - /** - * Display label for the item. - */ - readonly label: string; - - /** - * Optional description shown as secondary text or tooltip. - */ - readonly description?: string; - - /** - * URI pointing to the underlying resource - * (`.agent.md`, `.instructions.md`, `SKILL.md`, etc.). - * Also serves as the unique identity for this item. - */ - readonly uri: Uri; - - /** - * Where this item comes from. The management UI uses this to - * group items under "Workspace", "User", "Extensions", etc. - */ - readonly storageLocation: ChatSessionCustomizationStorageLocation; - - /** - * Optional icon for the item. Overrides the default icon derived - * from the customization type. - */ - readonly icon?: ThemeIcon; - } - - /** - * A named group of customization items of a single type. - * - * Use a well-known {@link ChatSessionCustomizationType} as the - * {@link id} to place items into a standard management UI section. - */ - export interface ChatSessionCustomizationItemGroup { - /** - * Identifier for this group. Use a value from - * {@link ChatSessionCustomizationType} to map to a built-in section, - * or a custom string for extension-defined sections. - */ - readonly id: string; - - /** - * The items in this group. - */ - readonly items: ChatSessionCustomizationItem[]; - - /** - * Commands shown in the toolbar / "New" dropdown for this group. - * - * @example A "New Agent" command that opens a scaffold wizard. - */ - readonly commands?: Command[]; - - /** - * Commands shown in the context menu for individual items. - * Each command receives the item's {@link ChatSessionCustomizationItem.uri uri} - * as its first argument. - * - * @example A "Run Prompt" command, a "Disable Skill" command. - */ - readonly itemCommands?: Command[]; - } - - /** - * Provides customization items for a chat session type. - * - * Registered via {@link chat.registerChatSessionCustomizationsProvider}. - * The provider is called when the management UI needs to display - * customizations, and re-called whenever - * {@link onDidChangeCustomizations} fires. - */ - export interface ChatSessionCustomizationsProvider { - /** - * Fired when customization items have changed and the UI should - * re-fetch them. - */ - readonly onDidChangeCustomizations: Event; - - /** - * Provide the current customization groups. - * - * @param token A cancellation token. - * @returns An array of customization groups, or a thenable that resolves to one. - */ - provideCustomizations(token: CancellationToken): ProviderResult; - - } -} diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index 8131b4c90ed..96adb309671 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -57,18 +57,6 @@ declare module 'vscode' { * @returns A new controller instance that can be used to manage chat session items for the given chat session type. */ export function createChatSessionItemController(chatSessionType: string, refreshHandler: ChatSessionItemControllerRefreshHandler): ChatSessionItemController; - - /** - * Registers a {@link ChatSessionCustomizationsProvider customizations provider} for a chat session type. - * - * The provider supplies customization items (agents, skills, instructions, prompts) - * that appear in the Customizations management UI for the given session type. - * - * @param chatSessionType The chat session type to provide customizations for. - * @param provider The customizations provider. - * @returns A disposable that unregisters the provider when disposed. - */ - export function registerChatSessionCustomizationsProvider(chatSessionType: string, provider: ChatSessionCustomizationsProvider): Disposable; } /**