From a63f21d38cddc93c7fd3bf1ee237ceea90f1beb5 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:43:28 -0700 Subject: [PATCH 1/3] chat: extract AI customization item sources --- src/vs/sessions/AI_CUSTOMIZATIONS.md | 8 + .../aiCustomizationItemNormalizer.ts | 155 ++++ .../aiCustomizationItemSourceUtils.ts | 23 + .../aiCustomizationListItem.ts | 63 ++ .../aiCustomizationListWidget.ts | 828 +----------------- ...promptsServiceCustomizationItemProvider.ts | 385 ++++++++ .../providerCustomizationItemSource.ts | 179 ++++ 7 files changed, 859 insertions(+), 782 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemNormalizer.ts create mode 100644 src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSourceUtils.ts create mode 100644 src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListItem.ts create mode 100644 src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts create mode 100644 src/vs/workbench/contrib/chat/browser/aiCustomization/providerCustomizationItemSource.ts diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index 4aceb3ea3c0eb..02aaedc297b12 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -15,6 +15,10 @@ src/vs/workbench/contrib/chat/browser/aiCustomization/ ├── aiCustomizationManagementEditor.ts # SplitView list/editor ├── aiCustomizationManagementEditorInput.ts # Singleton input ├── aiCustomizationListWidget.ts # Search + grouped list + harness toggle +├── aiCustomizationListItem.ts # Browser-only list item model +├── promptsServiceCustomizationItemProvider.ts # promptsService -> provider-shaped item adapter +├── providerCustomizationItemSource.ts # Active provider/sync -> normalized list item source +├── aiCustomizationItemNormalizer.ts # provider-shaped item -> browser list item normalizer ├── aiCustomizationListWidgetUtils.ts # List item helpers (truncation, etc.) ├── aiCustomizationDebugPanel.ts # Debug diagnostics panel ├── aiCustomizationWorkspaceService.ts # Core VS Code workspace service impl @@ -168,6 +172,10 @@ The underlying `storage` remains `PromptsStorage.extension` — the grouping is `BUILTIN_STORAGE` is defined in `aiCustomizationWorkspaceService.ts` (common layer) and re-exported by both `aiCustomizationManagement.ts` (browser) and `builtinPromptsStorage.ts` (sessions) for backward compatibility. +### Management Editor Item Pipeline + +The management editor list widget renders one browser-only list item model, but customization discovery converges earlier on the internal provider-shaped item contract (`IExternalCustomizationItem`). Extension-contributed providers already reach that shape through the extension-host/main-thread adapter. The Local/static harness uses `PromptsServiceCustomizationItemProvider`, a thin adapter that reads `IPromptsService`, expands local-only concepts (parsed hooks, instruction buckets, UI integration badges), applies the active harness's file/storage filters, and returns the same provider-shaped rows. `ProviderCustomizationItemSource` then normalizes provider rows via `AICustomizationItemNormalizer` and adds view-only sync overlays when the active harness has an `ICustomizationSyncProvider`. + ### AgenticPromptsService (Sessions) Sessions overrides `PromptsService` via `AgenticPromptsService` (in `promptsService.ts`): diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemNormalizer.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemNormalizer.ts new file mode 100644 index 0000000000000..60b1fdcab4859 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemNormalizer.ts @@ -0,0 +1,155 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ResourceMap } from '../../../../../base/common/map.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { basename, isEqualOrParent } from '../../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; +import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { IExternalCustomizationItem } from '../../common/customizationHarnessService.js'; +import { BUILTIN_STORAGE } from './aiCustomizationManagement.js'; +import { extensionIcon, instructionsIcon, pluginIcon, userIcon, workspaceIcon } from './aiCustomizationIcons.js'; +import { IAICustomizationListItem } from './aiCustomizationListItem.js'; +import { extractExtensionIdFromPath } from './aiCustomizationListWidgetUtils.js'; + +/** + * Returns the icon for a given storage type. + */ +export function storageToIcon(storage: PromptsStorage): ThemeIcon { + switch (storage) { + case PromptsStorage.local: return workspaceIcon; + case PromptsStorage.user: return userIcon; + case PromptsStorage.extension: return extensionIcon; + case PromptsStorage.plugin: return pluginIcon; + default: return instructionsIcon; + } +} + +/** + * Converts provider-shaped customization rows into the rich list model used by the management UI. + */ +export class AICustomizationItemNormalizer { + constructor( + private readonly workspaceContextService: IWorkspaceContextService, + private readonly workspaceService: IAICustomizationWorkspaceService, + private readonly labelService: ILabelService, + private readonly agentPluginService: IAgentPluginService, + private readonly productService: IProductService, + ) { } + + normalizeItems(items: readonly IExternalCustomizationItem[], promptType: PromptsType): IAICustomizationListItem[] { + const uriUseCounts = new ResourceMap(); + return items + .filter(item => item.type === promptType) + .map(item => this.normalizeItem(item, promptType, uriUseCounts)) + .sort((a, b) => a.name.localeCompare(b.name)); + } + + normalizeItem(item: IExternalCustomizationItem, promptType: PromptsType, uriUseCounts = new ResourceMap()): IAICustomizationListItem { + const { storage, groupKey, isBuiltin, extensionLabel } = this.resolveSource(item); + const seenCount = uriUseCounts.get(item.uri) ?? 0; + uriUseCounts.set(item.uri, seenCount + 1); + const duplicateSuffix = seenCount === 0 ? '' : `#${seenCount}`; + const isWorkspaceItem = storage === PromptsStorage.local; + + return { + id: `${item.uri.toString()}${duplicateSuffix}`, + uri: item.uri, + name: item.name, + filename: item.uri.scheme === Schemas.file + ? this.labelService.getUriLabel(item.uri, { relative: isWorkspaceItem }) + : basename(item.uri), + description: item.description, + storage, + promptType, + disabled: item.enabled === false, + groupKey, + pluginUri: storage === PromptsStorage.plugin ? this.findPluginUri(item.uri) : undefined, + displayName: item.name, + badge: item.badge, + badgeTooltip: item.badgeTooltip, + typeIcon: promptType === PromptsType.instructions && storage ? storageToIcon(storage) : undefined, + isBuiltin, + extensionLabel, + status: item.status, + statusMessage: item.statusMessage, + }; + } + + private resolveSource(item: IExternalCustomizationItem): { storage?: PromptsStorage; groupKey?: string; isBuiltin?: boolean; extensionLabel?: string } { + const inferred = this.inferStorageAndGroup(item.uri); + if (!item.groupKey) { + return inferred; + } + + switch (item.groupKey) { + case PromptsStorage.local: + case PromptsStorage.user: + case PromptsStorage.extension: + case PromptsStorage.plugin: + return { ...inferred, storage: item.groupKey }; + case BUILTIN_STORAGE: + return { ...inferred, storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true }; + default: + return { ...inferred, groupKey: item.groupKey }; + } + } + + private inferStorageAndGroup(uri: URI): { storage?: PromptsStorage; groupKey?: string; isBuiltin?: boolean; extensionLabel?: string } { + if (uri.scheme !== Schemas.file) { + return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true }; + } + + const activeProjectRoot = this.workspaceService.getActiveProjectRoot(); + if (activeProjectRoot && isEqualOrParent(uri, activeProjectRoot)) { + return { storage: PromptsStorage.local }; + } + + for (const folder of this.workspaceContextService.getWorkspace().folders) { + if (isEqualOrParent(uri, folder.uri)) { + return { storage: PromptsStorage.local }; + } + } + + for (const plugin of this.agentPluginService.plugins.get()) { + if (isEqualOrParent(uri, plugin.uri)) { + return { storage: PromptsStorage.plugin }; + } + } + + const extensionId = extractExtensionIdFromPath(uri.path); + if (extensionId) { + const extensionIdentifier = new ExtensionIdentifier(extensionId); + if (this.isChatExtensionItem(extensionIdentifier)) { + return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true }; + } + return { storage: PromptsStorage.extension, extensionLabel: extensionIdentifier.value }; + } + + return { storage: PromptsStorage.user }; + } + + private isChatExtensionItem(extensionId: ExtensionIdentifier): boolean { + const chatExtensionId = this.productService.defaultChatAgent?.chatExtensionId; + return !!chatExtensionId && ExtensionIdentifier.equals(extensionId, chatExtensionId); + } + + private findPluginUri(itemUri: URI): URI | undefined { + for (const plugin of this.agentPluginService.plugins.get()) { + if (isEqualOrParent(itemUri, plugin.uri)) { + return plugin.uri; + } + } + return undefined; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSourceUtils.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSourceUtils.ts new file mode 100644 index 0000000000000..c292e16879229 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSourceUtils.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Derives a friendly name from a filename by removing extension suffixes. + */ +export function getFriendlyName(filename: string): string { + // Remove common prompt file extensions like .instructions.md, .prompt.md, etc. + let name = filename + .replace(/\.instructions\.md$/i, '') + .replace(/\.prompt\.md$/i, '') + .replace(/\.agent\.md$/i, '') + .replace(/\.md$/i, ''); + + // Convert kebab-case or snake_case to Title Case + name = name + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, c => c.toUpperCase()); + + return name || filename; +} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListItem.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListItem.ts new file mode 100644 index 0000000000000..b2d45f1fcc41e --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListItem.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../../base/common/event.js'; +import { IMatch } from '../../../../../base/common/filters.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; + +/** + * Represents an AI customization item in the list. + */ +export interface IAICustomizationListItem { + readonly id: string; + readonly uri: URI; + readonly name: string; + readonly filename: string; + readonly description?: string; + /** 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. */ + readonly groupKey?: string; + /** URI of the parent plugin, when this item comes from an installed plugin. */ + readonly pluginUri?: URI; + /** When set, overrides the formatted name for display. */ + readonly displayName?: string; + /** When set, shows a small inline badge next to the item name. */ + readonly badge?: string; + /** Tooltip shown when hovering the badge. */ + readonly badgeTooltip?: string; + /** When set, overrides the default prompt-type icon. */ + readonly typeIcon?: ThemeIcon; + /** True when item comes from the default chat extension (grouped under Built-in). */ + readonly isBuiltin?: boolean; + /** Display name of the contributing extension (for non-built-in extension items). */ + readonly extensionLabel?: string; + /** Server-reported loading/sync status for remote customizations. */ + readonly status?: 'loading' | 'loaded' | 'degraded' | 'error'; + /** Human-readable status detail (e.g. error message or warning). */ + readonly statusMessage?: string; + /** When true, this item can be selected for syncing to a remote harness. */ + readonly syncable?: boolean; + /** When true, this syncable item is currently selected for syncing. */ + readonly synced?: boolean; + nameMatches?: IMatch[]; + descriptionMatches?: IMatch[]; +} + +/** + * Browser-internal item source consumed by the list widget. + * + * Item sources fetch provider-shaped customization rows, normalize them into + * this browser-only list item shape, and add view-only overlays such as sync state. + */ +export interface IAICustomizationItemSource { + readonly onDidChange: Event; + fetchItems(promptType: PromptsType): Promise; +} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index cdbbda2a20522..201c247575bfc 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -10,12 +10,10 @@ import { Checkbox } from '../../../../../base/browser/ui/toggle/toggle.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { onUnexpectedError } from '../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; -import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { autorun } from '../../../../../base/common/observable.js'; -import { basename, dirname, isEqual, isEqualOrParent } from '../../../../../base/common/resources.js'; +import { isEqual } from '../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; -import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { localize } from '../../../../../nls.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -39,7 +37,7 @@ import { IContextKeyService } from '../../../../../platform/contextkey/common/co import { createActionViewItem, getContextMenuActions } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; -import { IAICustomizationWorkspaceService, applyStorageSourceFilter } from '../../common/aiCustomizationWorkspaceService.js'; +import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; import { Action, Separator } from '../../../../../base/common/actions.js'; import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; @@ -47,18 +45,15 @@ import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/ho import { IFileService } from '../../../../../platform/files/common/files.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; import { generateCustomizationDebugReport } from './aiCustomizationDebugPanel.js'; -import { extractExtensionIdFromPath, getCustomizationSecondaryText } from './aiCustomizationListWidgetUtils.js'; -import { parseHooksFromFile } from '../../common/promptSyntax/hookCompatibility.js'; -import { formatHookCommandLabel } from '../../common/promptSyntax/hookSchema.js'; -import { HookType, HOOK_METADATA } from '../../common/promptSyntax/hookTypes.js'; -import { parse as parseJSONC } from '../../../../../base/common/json.js'; -import { Schemas } from '../../../../../base/common/network.js'; -import { OS } from '../../../../../base/common/platform.js'; +import { getCustomizationSecondaryText } from './aiCustomizationListWidgetUtils.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; -import { ICustomizationHarnessService, IExternalCustomizationItem, IExternalCustomizationItemProvider, matchesWorkspaceSubpath, matchesInstructionFileFilter, ICustomizationSyncProvider } from '../../common/customizationHarnessService.js'; -import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; +import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { AICustomizationItemNormalizer } from './aiCustomizationItemNormalizer.js'; +import { IAICustomizationItemSource, IAICustomizationListItem } from './aiCustomizationListItem.js'; +import { ProviderCustomizationItemSource } from './providerCustomizationItemSource.js'; +import { PromptsServiceCustomizationItemProvider } from './promptsServiceCustomizationItemProvider.js'; export { truncateToFirstLine } from './aiCustomizationListWidgetUtils.js'; @@ -84,47 +79,6 @@ const ITEM_HEIGHT = 44; const GROUP_HEADER_HEIGHT = 36; const GROUP_HEADER_HEIGHT_WITH_SEPARATOR = 40; -/** - * Represents an AI customization item in the list. - */ -export interface IAICustomizationListItem { - readonly id: string; - readonly uri: URI; - readonly name: string; - readonly filename: string; - readonly description?: string; - /** 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. */ - readonly groupKey?: string; - /** URI of the parent plugin, when this item comes from an installed plugin. */ - readonly pluginUri?: URI; - /** When set, overrides the formatted name for display. */ - readonly displayName?: string; - /** When set, shows a small inline badge next to the item name. */ - readonly badge?: string; - /** Tooltip shown when hovering the badge. */ - readonly badgeTooltip?: string; - /** When set, overrides the default prompt-type icon. */ - readonly typeIcon?: ThemeIcon; - /** True when item comes from the default chat extension (grouped under Built-in). */ - readonly isBuiltin?: boolean; - /** Display name of the contributing extension (for non-built-in extension items). */ - readonly extensionLabel?: string; - /** Server-reported loading/sync status for remote customizations. */ - readonly status?: 'loading' | 'loaded' | 'degraded' | 'error'; - /** Human-readable status detail (e.g. error message or warning). */ - readonly statusMessage?: string; - /** When true, this item can be selected for syncing to a remote harness. */ - readonly syncable?: boolean; - /** When true, this syncable item is currently selected for syncing. */ - readonly synced?: boolean; - nameMatches?: IMatch[]; - descriptionMatches?: IMatch[]; -} - /** * Represents a collapsible group header in the list. */ @@ -267,19 +221,6 @@ function promptTypeToIcon(type: PromptsType): ThemeIcon { } } -/** - * Returns the icon for a given storage type. - */ -function storageToIcon(storage: PromptsStorage): ThemeIcon { - switch (storage) { - case PromptsStorage.local: return workspaceIcon; - case PromptsStorage.user: return userIcon; - case PromptsStorage.extension: return extensionIcon; - case PromptsStorage.plugin: return pluginIcon; - default: return instructionsIcon; - } -} - /** * Formats a name for display by stripping a trailing .md extension. * Names from frontmatter headers are shown as-is to stay consistent @@ -590,6 +531,8 @@ export class AICustomizationListWidget extends Disposable { private _loadItemsSeq = 0; private readonly delayedFilter = new Delayer(200); + private readonly itemNormalizer: AICustomizationItemNormalizer; + private readonly promptsServiceItemProvider: PromptsServiceCustomizationItemProvider; private readonly _onDidSelectItem = this._register(new Emitter()); readonly onDidSelectItem: Event = this._onDidSelectItem.event; @@ -625,6 +568,21 @@ export class AICustomizationListWidget extends Disposable { @IProductService private readonly productService: IProductService, ) { super(); + this.itemNormalizer = new AICustomizationItemNormalizer( + this.workspaceContextService, + this.workspaceService, + this.labelService, + this.agentPluginService, + this.productService, + ); + this.promptsServiceItemProvider = new PromptsServiceCustomizationItemProvider( + () => this.harnessService.getActiveDescriptor(), + this.promptsService, + this.workspaceService, + this.fileService, + this.pathService, + this.productService, + ); this.element = $('.ai-customization-list-widget'); this.create(); @@ -648,27 +606,17 @@ export class AICustomizationListWidget extends Disposable { this.refresh(); })); - // Subscribe to the active provider's onDidChange event. + // Subscribe to the active item source's onDidChange event. // Read both activeHarness and availableHarnesses so that the // subscription is re-established when a new provider harness // registers (availableHarnesses changes) even if activeHarness // was already set to the harness id from persisted state. - const providerChangeDisposable = this._register(new MutableDisposable()); - const syncChangeDisposable = this._register(new MutableDisposable()); + const itemSourceChangeDisposable = this._register(new MutableDisposable()); this._register(autorun(reader => { this.harnessService.activeHarness.read(reader); this.harnessService.availableHarnesses.read(reader); const activeDescriptor = this.harnessService.getActiveDescriptor(); - if (activeDescriptor.itemProvider) { - providerChangeDisposable.value = activeDescriptor.itemProvider.onDidChange(() => this.refresh()); - } else { - providerChangeDisposable.clear(); - } - if (activeDescriptor.syncProvider) { - syncChangeDisposable.value = activeDescriptor.syncProvider.onDidChange(() => this.refresh()); - } else { - syncChangeDisposable.clear(); - } + itemSourceChangeDisposable.value = this.getItemSource(activeDescriptor).onDidChange(() => this.refresh()); })); } @@ -781,12 +729,6 @@ export class AICustomizationListWidget extends Disposable { // Handle context menu this._register(this.list.onContextMenu(e => this.onContextMenu(e))); - // Subscribe to prompt service changes - this._register(this.promptsService.onDidChangeCustomAgents(() => this.refresh())); - this._register(this.promptsService.onDidChangeSlashCommands(() => this.refresh())); - this._register(this.promptsService.onDidChangeSkills(() => this.refresh())); - this._register(this.promptsService.onDidChangeInstructions(() => this.refresh())); - // Refresh on file deletions so the list updates after inline delete actions this._register(this.fileService.onDidFilesChange(e => { if (e.gotDeleted()) { @@ -1200,653 +1142,29 @@ export class AICustomizationListWidget extends Disposable { return items.length; } - /** - * Returns true if the given extension identifier matches the default - * chat extension (e.g. GitHub Copilot Chat). Used to group items from - * the chat extension under "Built-in" instead of "Extensions", similar - * to how MCP categorizes built-in servers. - */ - private isChatExtensionItem(extensionId: ExtensionIdentifier): boolean { - const chatExtensionId = this.productService.defaultChatAgent?.chatExtensionId; - return !!chatExtensionId && ExtensionIdentifier.equals(extensionId, chatExtensionId); - } - - /** - * Post-processes items to assign groupKey overrides for extension-sourced - * items. Applies the built-in grouping consistently across all item types. - * - * Items that already have an explicit groupKey (e.g. instruction categories, - * agent hooks) are left untouched — groupKey overrides are only applied to - * items whose current groupKey is `undefined`. - */ - private applyBuiltinGroupKeys(items: IAICustomizationListItem[], extensionInfoByUri: ResourceMap<{ id: ExtensionIdentifier; displayName?: string }>): IAICustomizationListItem[] { - return items.map(item => { - if (item.storage !== PromptsStorage.extension) { - return item; - } - const extInfo = extensionInfoByUri.get(item.uri); - if (!extInfo) { - return item; - } - const isBuiltin = this.isChatExtensionItem(extInfo.id); - if (isBuiltin) { - return { - ...item, - isBuiltin: true, - groupKey: item.groupKey ?? BUILTIN_STORAGE, - }; - } - return { - ...item, - extensionLabel: extInfo.displayName || extInfo.id.value, - }; - }); - } - /** * Fetches and filters items for a given section. - * Delegates to the provider path or core path based on the active harness. + * Delegates to the item source selected by the active harness. */ private async fetchItemsForSection(section: AICustomizationManagementSection): Promise { const promptType = sectionToPromptType(section); - const activeDescriptor = this.harnessService.getActiveDescriptor(); - - if (activeDescriptor.itemProvider && promptType) { - return this.fetchProviderItemsForSection(activeDescriptor, promptType); - } - - return this.fetchCoreItemsForSection(promptType); + return this.getItemSource(this.harnessService.getActiveDescriptor()).fetchItems(promptType); } /** - * Fetches items from an external customization provider. - * When a syncProvider is present, blends remote items with local sync items. + * Returns the rich, browser-internal item source for a harness descriptor. */ - private async fetchProviderItemsForSection(descriptor: ReturnType, promptType: PromptsType): Promise { - const remoteItems = await this.fetchItemsFromProvider(descriptor.itemProvider!, promptType); - if (!descriptor.syncProvider) { - return remoteItems; - } - const localItems = await this.fetchLocalSyncableItems(promptType, descriptor.syncProvider); - return [...remoteItems, ...localItems]; - } - - /** - * Fetches items from the core promptsService with full filtering pipeline. - * This is the legacy path used when no external provider is active. - * TODO: Remove when provider API is the sole code path. - */ - private async fetchCoreItemsForSection(promptType: PromptsType): Promise { - const items: IAICustomizationListItem[] = []; - const disabledUris = this.promptsService.getDisabledPromptFiles(promptType); - const extensionInfoByUri = new ResourceMap<{ id: ExtensionIdentifier; displayName?: string }>(); - - - if (promptType === PromptsType.agent) { - // Use getCustomAgents which has parsed name/description from frontmatter - const agents = await this.promptsService.getCustomAgents(CancellationToken.None); - // Build extension display name lookup from raw file list - const allAgentFiles = await this.promptsService.listPromptFiles(PromptsType.agent, CancellationToken.None); - for (const file of allAgentFiles) { - if (file.extension) { - extensionInfoByUri.set(file.uri, { id: file.extension.identifier, displayName: file.extension.displayName }); - } - } - for (const agent of agents) { - const filename = basename(agent.uri); - items.push({ - id: agent.uri.toString(), - uri: agent.uri, - name: agent.name, - filename, - description: agent.description, - storage: agent.source.storage, - promptType, - pluginUri: agent.source.storage === PromptsStorage.plugin ? agent.source.pluginUri : undefined, - disabled: disabledUris.has(agent.uri), - }); - // Track extension ID for built-in grouping (if not already set from file list) - if (agent.source.storage === PromptsStorage.extension && !extensionInfoByUri.has(agent.uri)) { - extensionInfoByUri.set(agent.uri, { id: agent.source.extensionId }); - } - } - } else if (promptType === PromptsType.skill) { - // Use findAgentSkills for enabled skills (has parsed name/description from frontmatter) - const skills = await this.promptsService.findAgentSkills(CancellationToken.None); - // Build extension ID lookup from raw file list (like MCP builds collectionSources) - const allSkillFiles = await this.promptsService.listPromptFiles(PromptsType.skill, CancellationToken.None); - for (const file of allSkillFiles) { - if (file.extension) { - extensionInfoByUri.set(file.uri, { id: file.extension.identifier, displayName: file.extension.displayName }); - } - } - const uiIntegrations = this.workspaceService.getSkillUIIntegrations(); - const seenUris = new ResourceSet(); - for (const skill of skills || []) { - const filename = basename(skill.uri); - const skillName = skill.name || basename(dirname(skill.uri)) || filename; - seenUris.add(skill.uri); - const skillFolderName = basename(dirname(skill.uri)); - const uiTooltip = uiIntegrations.get(skillFolderName); - items.push({ - id: skill.uri.toString(), - uri: skill.uri, - name: skillName, - filename, - description: skill.description, - storage: skill.storage, - promptType, - pluginUri: skill.storage === PromptsStorage.plugin ? this.findPluginUri(skill.uri) : undefined, - disabled: false, - badge: uiTooltip ? localize('uiIntegrationBadge', "UI Integration") : undefined, - badgeTooltip: uiTooltip, - }); - } - // Also include disabled skills from the raw file list - if (disabledUris.size > 0) { - for (const file of allSkillFiles) { - if (!seenUris.has(file.uri) && disabledUris.has(file.uri)) { - const filename = basename(file.uri); - const disabledName = file.name || basename(dirname(file.uri)) || filename; - const disabledFolderName = basename(dirname(file.uri)); - const uiTooltip = uiIntegrations.get(disabledFolderName); - items.push({ - id: file.uri.toString(), - uri: file.uri, - name: disabledName, - filename, - description: file.description, - storage: file.storage, - promptType, - disabled: true, - badge: uiTooltip ? localize('uiIntegrationBadge', "UI Integration") : undefined, - badgeTooltip: uiTooltip, - }); - } - } - } - } else if (promptType === PromptsType.prompt) { - // Use getPromptSlashCommands which has parsed name/description from frontmatter - // Filter out skills since they have their own section - const commands = await this.promptsService.getPromptSlashCommands(CancellationToken.None); - for (const command of commands) { - if (command.type === PromptsType.skill) { - continue; - } - const filename = basename(command.uri); - items.push({ - id: command.uri.toString(), - uri: command.uri, - name: command.name, - filename, - description: command.description, - storage: command.storage, - promptType, - pluginUri: command.storage === PromptsStorage.plugin ? command.pluginUri : undefined, - disabled: disabledUris.has(command.uri), - }); - if (command.extension) { - extensionInfoByUri.set(command.uri, { id: command.extension.identifier, displayName: command.extension.displayName }); - } - } - } else if (promptType === PromptsType.hook) { - // Try to parse individual hooks from each file; fall back to showing the file itself - const hookFiles = await this.promptsService.listPromptFiles(PromptsType.hook, CancellationToken.None); - const activeRoot = this.workspaceService.getActiveProjectRoot(); - const userHomeUri = await this.pathService.userHome(); - const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; - - for (const hookFile of hookFiles) { - // Plugins parse their own hooks and emit them individually because they can - // be embedded with interpolations in the plugin manifests; don't re-parse them - if (hookFile.storage === PromptsStorage.plugin) { - const filename = basename(hookFile.uri); - items.push({ - id: hookFile.uri.toString() + ':' + hookFile.name, - uri: hookFile.uri, - name: hookFile.name || this.getFriendlyName(filename), - filename, - storage: hookFile.storage, - promptType, - pluginUri: hookFile.pluginUri, - disabled: disabledUris.has(hookFile.uri), - }); - continue; - } - - let parsedHooks = false; - try { - const content = await this.fileService.readFile(hookFile.uri); - const json = parseJSONC(content.value.toString()); - const { hooks } = parseHooksFromFile(hookFile.uri, json, activeRoot, userHome); - - if (hooks.size > 0) { - parsedHooks = true; - for (const [hookType, entry] of hooks) { - const hookMeta = HOOK_METADATA[hookType]; - for (let i = 0; i < entry.hooks.length; i++) { - const hook = entry.hooks[i]; - const cmdLabel = formatHookCommandLabel(hook, OS); - const truncatedCmd = cmdLabel.length > 60 ? cmdLabel.substring(0, 57) + '...' : cmdLabel; - items.push({ - id: `${hookFile.uri.toString()}#${entry.originalId}[${i}]`, - uri: hookFile.uri, - name: hookMeta?.label ?? entry.originalId, - filename: basename(hookFile.uri), - description: truncatedCmd || localize('hookUnset', "(unset)"), - storage: hookFile.storage, - promptType, - disabled: disabledUris.has(hookFile.uri), - }); - } - } - } - } catch { - // Parse failed — fall through to show raw file - } - - if (!parsedHooks) { - const filename = basename(hookFile.uri); - items.push({ - id: hookFile.uri.toString(), - uri: hookFile.uri, - name: hookFile.name || this.getFriendlyName(filename), - filename, - storage: hookFile.storage, - promptType, - disabled: disabledUris.has(hookFile.uri), - }); - } - } - - // Also include hooks defined in agent frontmatter (not in sessions window) - // TODO: add this back when Copilot CLI supports this - const agents = !this.workspaceService.isSessionsWindow ? await this.promptsService.getCustomAgents(CancellationToken.None) : []; - for (const agent of agents) { - if (!agent.hooks) { - continue; - } - for (const hookType of Object.values(HookType)) { - const hookCommands = agent.hooks[hookType]; - if (!hookCommands || hookCommands.length === 0) { - continue; - } - const hookMeta = HOOK_METADATA[hookType]; - for (let i = 0; i < hookCommands.length; i++) { - const hook = hookCommands[i]; - const cmdLabel = formatHookCommandLabel(hook, OS); - const truncatedCmd = cmdLabel.length > 60 ? cmdLabel.substring(0, 57) + '...' : cmdLabel; - items.push({ - id: `${agent.uri.toString()}#hook:${hookType}[${i}]`, - uri: agent.uri, - name: hookMeta?.label ?? hookType, - filename: basename(agent.uri), - description: `${agent.name}: ${truncatedCmd || localize('hookUnset', "(unset)")}`, - storage: agent.source.storage, - groupKey: 'agents', - promptType, - pluginUri: agent.source.storage === PromptsStorage.plugin ? agent.source.pluginUri : undefined, - disabled: disabledUris.has(agent.uri), - }); - } - } - } - } else { - // For instructions, group by category: agent instructions, context instructions, on-demand instructions - const instructionFiles = await this.promptsService.getInstructionFiles(CancellationToken.None); - for (const file of instructionFiles) { - if (file.extension) { - extensionInfoByUri.set(file.uri, { id: file.extension.identifier, displayName: file.extension.displayName }); - } - } - const agentInstructionFiles = await this.promptsService.listAgentInstructions(CancellationToken.None, undefined); - const agentInstructionUris = new ResourceSet(agentInstructionFiles.map(f => f.uri)); - - // Add agent instruction items - const workspaceFolderUris = this.workspaceContextService.getWorkspace().folders.map(f => f.uri); - const activeRoot = this.workspaceService.getActiveProjectRoot(); - if (activeRoot) { - workspaceFolderUris.push(activeRoot); - } - for (const file of agentInstructionFiles) { - const storage = PromptsStorage.local; - const filename = basename(file.uri); - items.push({ - id: file.uri.toString(), - uri: file.uri, - name: filename, - filename: this.labelService.getUriLabel(file.uri, { relative: true }), - displayName: filename, - storage, - promptType, - typeIcon: storageToIcon(storage), - groupKey: 'agent-instructions', - disabled: disabledUris.has(file.uri), - }); - } - - // Parse prompt files to separate into context vs on-demand - - for (const { uri, pattern, name, description, storage, pluginUri } of instructionFiles) { - if (agentInstructionUris.has(uri)) { - continue; // already added as agent instruction - } - - const friendlyName = this.getFriendlyName(name); - - if (pattern !== undefined) { - // Context instruction - const badge = pattern === '**' - ? localize('alwaysAdded', "always added") - : pattern; - const badgeTooltip = pattern === '**' - ? localize('alwaysAddedTooltip', "This instruction is automatically included in every interaction.") - : localize('onContextTooltip', "This instruction is automatically included when files matching '{0}' are in context.", pattern); - items.push({ - id: uri.toString(), - uri, - name: friendlyName, - filename: this.labelService.getUriLabel(uri, { relative: true }), - displayName: friendlyName, - badge, - badgeTooltip, - description, - storage, - promptType, - typeIcon: storageToIcon(storage), - groupKey: 'context-instructions', - pluginUri, - disabled: disabledUris.has(uri), - }); - } else { - // On-demand instruction - items.push({ - id: uri.toString(), - uri, - name: friendlyName, - filename: basename(uri), - displayName: friendlyName, - description, - storage, - promptType, - typeIcon: storageToIcon(storage), - groupKey: 'on-demand-instructions', - pluginUri, - disabled: disabledUris.has(uri), - }); - } - } - } - - // Assign built-in groupKeys — items from the default chat extension - // are re-grouped under "Built-in" instead of "Extensions". - // This is a single-pass transformation applied after all items are - // collected, keeping the item-building code free of grouping logic. - const groupedItems = this.applyBuiltinGroupKeys(items, extensionInfoByUri); - - // Apply storage source filter (removes items not in visible sources or excluded user roots) - const filter = this.workspaceService.getStorageSourceFilter(promptType); - 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); - - // Apply workspace subpath filter — when the active harness specifies - // workspaceSubpaths, hide workspace-local items that aren't under one - // of the recognized sub-paths (e.g. Claude only shows .claude/ items). - // Exception: instruction files matched by the harness's instructionFileFilter - // are exempt (e.g. CLAUDE.md at workspace root is a Claude-native file - // even though it's not under .claude/). - const descriptor = this.harnessService.getActiveDescriptor(); - const subpaths = descriptor.workspaceSubpaths; - const instrFilter = descriptor.instructionFileFilter; - if (subpaths) { - const projectRoot = this.workspaceService.getActiveProjectRoot(); - for (let i = items.length - 1; i >= 0; i--) { - const item = items[i]; - if (item.storage === PromptsStorage.local && projectRoot && isEqualOrParent(item.uri, projectRoot)) { - if (!matchesWorkspaceSubpath(item.uri.path, subpaths)) { - // Keep instruction files that match the harness's native patterns - if (instrFilter && promptType === PromptsType.instructions && matchesInstructionFileFilter(item.uri.path, instrFilter)) { - continue; - } - // Keep agent instruction files (AGENTS.md, CLAUDE.md, copilot-instructions.md) - // — these live at the workspace root by design and should not be - // filtered out by workspace subpath restrictions. - if (item.groupKey === 'agent-instructions') { - continue; - } - items.splice(i, 1); - } - } - } - } - - // Apply instruction file filter — when the active harness specifies - // instructionFileFilter, hide instruction files that don't match the - // recognized patterns (e.g. Claude doesn't support *.instructions.md). - if (instrFilter && promptType === PromptsType.instructions) { - for (let i = items.length - 1; i >= 0; i--) { - if (!matchesInstructionFileFilter(items[i].uri.path, instrFilter)) { - items.splice(i, 1); - } - } - } - - // Sort items by name - items.sort((a, b) => a.name.localeCompare(b.name)); - - return items; - } - - /** - * Fetches items from an external customization provider, converting - * the provider's items into the list widget format. - */ - private async fetchItemsFromProvider(provider: IExternalCustomizationItemProvider, promptType: PromptsType): Promise { - const allItems = await provider.provideChatSessionCustomizations(CancellationToken.None); - if (!allItems) { - return []; - } - - const workspaceFolders = this.workspaceContextService.getWorkspace().folders; - - // Build a URI→description lookup from promptsService for items the provider - // doesn't supply descriptions for (e.g. skills and instructions from ChatResource). - const descriptionsByUri = new ResourceMap(); - if (promptType === PromptsType.skill) { - const skills = await this.promptsService.findAgentSkills(CancellationToken.None); - for (const s of skills ?? []) { - if (s.description) { - descriptionsByUri.set(s.uri, s.description); - } - } - } - - // Hooks: expand file-level items into individual hook entries (matching core path display) - if (promptType === PromptsType.hook) { - return this._expandProviderHookItems(allItems, workspaceFolders); - } - - return allItems - .filter(item => item.type === promptType) - .map((item: IExternalCustomizationItem) => { - const { storage, groupKey } = item.groupKey - ? { storage: undefined, groupKey: item.groupKey } - : this._inferStorageAndGroup(item.uri, workspaceFolders); - return { - id: item.uri.toString(), - uri: item.uri, - name: item.name, - filename: item.uri.scheme === Schemas.file - ? this.labelService.getUriLabel(item.uri, { relative: true }) - : basename(item.uri), - description: item.description ?? descriptionsByUri.get(item.uri), - promptType, - disabled: item.enabled === false, - status: item.status, - statusMessage: item.statusMessage, - groupKey, - badge: item.badge, - badgeTooltip: item.badgeTooltip, - storage, - }; - }) - .sort((a, b) => a.name.localeCompare(b.name)); - } - - /** - * Expands provider hook items (file-level) into individual hook entries - * with hook type labels and command descriptions, matching the core path display. - */ - private async _expandProviderHookItems(allItems: readonly IExternalCustomizationItem[], workspaceFolders: readonly { uri: URI }[]): Promise { - const hookFileItems = allItems.filter(item => item.type === PromptsType.hook); - const items: IAICustomizationListItem[] = []; - const activeRoot = this.workspaceService.getActiveProjectRoot(); - const userHomeUri = await this.pathService.userHome(); - const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; - - for (const item of hookFileItems) { - const { storage } = item.groupKey - ? { storage: undefined } - : this._inferStorageAndGroup(item.uri, workspaceFolders); - - let parsedHooks = false; - try { - const content = await this.fileService.readFile(item.uri); - const json = parseJSONC(content.value.toString()); - const { hooks } = parseHooksFromFile(item.uri, json, activeRoot, userHome); - - if (hooks.size > 0) { - parsedHooks = true; - for (const [hookType, entry] of hooks) { - const hookMeta = HOOK_METADATA[hookType]; - for (let i = 0; i < entry.hooks.length; i++) { - const hook = entry.hooks[i]; - const cmdLabel = formatHookCommandLabel(hook, OS); - const truncatedCmd = cmdLabel.length > 60 ? cmdLabel.substring(0, 57) + '...' : cmdLabel; - items.push({ - id: `${item.uri.toString()}#${entry.originalId}[${i}]`, - uri: item.uri, - name: hookMeta?.label ?? entry.originalId, - filename: basename(item.uri), - description: truncatedCmd || localize('hookUnset', "(unset)"), - storage, - promptType: PromptsType.hook, - disabled: item.enabled === false, - }); - } - } - } - } catch { - // Parse failed — fall through to show raw file - } - - if (!parsedHooks) { - items.push({ - id: item.uri.toString(), - uri: item.uri, - name: item.name, - filename: basename(item.uri), - description: item.description, - storage, - promptType: PromptsType.hook, - disabled: item.enabled === false, - }); - } - } - - return items; - } - - /** - * Infers storage and groupKey from a URI for auto-grouping. - * - * - `file:` URIs under a workspace folder → storage `local` (Workspace group) - * - `file:` URIs elsewhere (e.g. `~/.copilot/`) → storage `user` (User group) - * - Non-file schemes (synthetic URIs, vscode-userdata:, etc.) → groupKey `builtin` (Built-in group) - */ - private _inferStorageAndGroup(uri: URI, workspaceFolders: readonly { uri: URI }[]): { storage?: PromptsStorage; groupKey?: string } { - // Non-file schemes are synthetic/built-in (includes vscode-userdata: for extension-contributed items) - if (uri.scheme !== Schemas.file) { - return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE }; - } - - // file: URI under a workspace folder = workspace (local) - for (const folder of workspaceFolders) { - if (isEqualOrParent(uri, folder.uri)) { - return { storage: PromptsStorage.local }; - } - } - - // file: URI under an installed plugin = plugin - for (const plugin of this.agentPluginService.plugins.get()) { - if (isEqualOrParent(uri, plugin.uri)) { - return { storage: PromptsStorage.plugin }; - } - } - - // file: URI inside an extension install directory = extension or built-in. - // At this point we've already checked workspace folders and plugins, so - // a path containing /extensions/-/ is an extension directory. - const extensionId = extractExtensionIdFromPath(uri.path); - if (extensionId) { - if (this.isChatExtensionItem(new ExtensionIdentifier(extensionId))) { - return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE }; - } - return { storage: PromptsStorage.extension }; - } - - // file: URI elsewhere = user directory - return { storage: PromptsStorage.user }; - } - - /** - * Fetches local customization items and marks them as syncable, using - * the sync provider to determine their current selection state. - * These items appear alongside remote items with sync checkboxes. - */ - private async fetchLocalSyncableItems(promptType: PromptsType, syncProvider: ICustomizationSyncProvider): Promise { - const files = await this.promptsService.listPromptFiles(promptType, CancellationToken.None); - if (!files.length) { - return []; - } - - return files - .filter(f => f.storage === PromptsStorage.local || f.storage === PromptsStorage.user) - .map(f => ({ - id: `sync-${f.uri.toString()}`, - uri: f.uri, - name: this.getFriendlyName(basename(f.uri)), - filename: basename(f.uri), - promptType, - disabled: false, - storage: f.storage, - groupKey: 'sync-local', - syncable: true, - synced: syncProvider.isSelected(f.uri), - })) - .sort((a, b) => a.name.localeCompare(b.name)); - } - - /** - * Derives a friendly name from a filename by removing extension suffixes. - */ - private getFriendlyName(filename: string): string { - // Remove common prompt file extensions like .instructions.md, .prompt.md, etc. - let name = filename - .replace(/\.instructions\.md$/i, '') - .replace(/\.prompt\.md$/i, '') - .replace(/\.agent\.md$/i, '') - .replace(/\.md$/i, ''); - - // Convert kebab-case or snake_case to Title Case - name = name - .replace(/[-_]/g, ' ') - .replace(/\b\w/g, c => c.toUpperCase()); - - return name || filename; + private getItemSource(descriptor: ReturnType): IAICustomizationItemSource { + const itemProvider = descriptor.itemProvider ?? (descriptor.syncProvider ? undefined : this.promptsServiceItemProvider); + return new ProviderCustomizationItemSource( + itemProvider, + descriptor.syncProvider, + this.promptsService, + this.workspaceService, + this.fileService, + this.pathService, + this.itemNormalizer, + ); } /** @@ -1931,11 +1249,11 @@ export class AICustomizationListWidget extends Disposable { } /** - * Filters and groups items from an external provider. + * Groups normalized list items for display. * When a syncProvider is present, shows remote items + local sync items. - * Otherwise, groups items by inferred storage/groupKey. + * Otherwise, groups items by normalized storage/groupKey. */ - private filterItemsForProvider(matchedItems: IAICustomizationListItem[]): void { + private groupMatchedItems(matchedItems: IAICustomizationListItem[]): void { const activeDescriptor = this.harnessService.getActiveDescriptor(); if (activeDescriptor.syncProvider) { @@ -2014,54 +1332,12 @@ export class AICustomizationListWidget extends Disposable { this.commitDisplayEntries(); } - /** - * Filters and groups items from the core promptsService (static harness path). - * Instructions use semantic categories; other sections use storage-based groups. - */ - private filterItemsForCore(matchedItems: IAICustomizationListItem[]): void { - 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[] }[] = - this.currentSection === AICustomizationManagementSection.Instructions - ? [ - { groupKey: 'agent-instructions', label: localize('agentInstructionsGroup', "Agent Instructions"), icon: instructionsIcon, description: localize('agentInstructionsGroupDescription', "Instruction files automatically loaded for all agent interactions (e.g. AGENTS.md, CLAUDE.md, copilot-instructions.md)."), items: [] }, - { groupKey: 'context-instructions', label: localize('contextInstructionsGroup', "Included Based on Context"), icon: instructionsIcon, description: localize('contextInstructionsGroupDescription', "Instructions automatically loaded when matching files are part of the context."), items: [] }, - { groupKey: 'on-demand-instructions', label: localize('onDemandInstructionsGroup', "Loaded on Demand"), icon: instructionsIcon, description: localize('onDemandInstructionsGroupDescription', "Instructions loaded only when explicitly referenced."), items: [] }, - ] - : [ - { groupKey: PromptsStorage.local, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "Customizations stored as files in your project folder and shared with your team via version control."), items: [] }, - { groupKey: PromptsStorage.user, label: localize('userGroup', "User"), icon: userIcon, description: localize('userGroupDescription', "Customizations stored locally on your machine in a central location. Private to you and available across all projects."), items: [] }, - { groupKey: PromptsStorage.plugin, label: localize('pluginGroup', "Plugins"), icon: pluginIcon, description: localize('pluginGroupDescription', "Read-only customizations provided by installed plugins."), items: [] }, - { groupKey: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, description: localize('extensionGroupDescription', "Read-only customizations provided by installed extensions."), items: [] }, - { groupKey: BUILTIN_STORAGE, label: localize('builtinGroup', "Built-in"), icon: builtinIcon, description: localize('builtinGroupDescription', "Built-in customizations shipped with the application."), items: [] }, - { groupKey: 'agents', label: localize('agentsGroup', "Agents"), icon: agentIcon, description: localize('agentsGroupDescription', "Hooks defined in agent files."), items: [] }, - ].filter(g => g.groupKey === BUILTIN_STORAGE || g.groupKey === 'agents' || visibleSources.has(g.groupKey as PromptsStorage)); - - for (const item of matchedItems) { - const key = item.groupKey ?? item.storage ?? PromptsStorage.local; - const group = groups.find(g => g.groupKey === key); - if (group) { - group.items.push(item); - } - } - - this.buildGroupedEntries(groups); - this.commitDisplayEntries(); - } - /** * Filters items based on the current search query and builds grouped display entries. */ private filterItems(): number { const matchedItems = this.applySearchFilter(this.allItems); - const activeDescriptor = this.harnessService.getActiveDescriptor(); - - if (activeDescriptor.itemProvider) { - this.filterItemsForProvider(matchedItems); - } else { - this.filterItemsForCore(matchedItems); - } + this.groupMatchedItems(matchedItems); return matchedItems.length; } @@ -2121,18 +1397,6 @@ export class AICustomizationListWidget extends Disposable { } } - /** - * Finds the plugin URI for an item URI by checking the known plugins. - */ - private findPluginUri(itemUri: URI): URI | undefined { - for (const plugin of this.agentPluginService.plugins.get()) { - if (isEqualOrParent(itemUri, plugin.uri)) { - return plugin.uri; - } - } - return undefined; - } - private getEmptyStateInfo(): { title: string; description: string } { switch (this.currentSection) { case AICustomizationManagementSection.Agents: diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts new file mode 100644 index 0000000000000..978ed304562a3 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts @@ -0,0 +1,385 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Event } from '../../../../../base/common/event.js'; +import { parse as parseJSONC } from '../../../../../base/common/json.js'; +import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { OS } from '../../../../../base/common/platform.js'; +import { basename, dirname, isEqualOrParent } from '../../../../../base/common/resources.js'; +import { localize } from '../../../../../nls.js'; +import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IPathService } from '../../../../services/path/common/pathService.js'; +import { IAICustomizationWorkspaceService, applyStorageSourceFilter } from '../../common/aiCustomizationWorkspaceService.js'; +import { HookType, HOOK_METADATA } from '../../common/promptSyntax/hookTypes.js'; +import { parseHooksFromFile } from '../../common/promptSyntax/hookCompatibility.js'; +import { formatHookCommandLabel } from '../../common/promptSyntax/hookSchema.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { IExternalCustomizationItem, IExternalCustomizationItemProvider, IHarnessDescriptor, matchesInstructionFileFilter, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js'; +import { BUILTIN_STORAGE } from './aiCustomizationManagement.js'; +import { getFriendlyName } from './aiCustomizationItemSourceUtils.js'; + +interface IPromptsServiceCustomizationItem extends IExternalCustomizationItem { + readonly storage?: PromptsStorage; +} + +/** + * Adapts the rich promptsService model to the same provider-shaped items + * contributed by external customization providers. + */ +export class PromptsServiceCustomizationItemProvider implements IExternalCustomizationItemProvider { + + readonly onDidChange: Event; + + constructor( + private readonly getActiveDescriptor: () => IHarnessDescriptor, + private readonly promptsService: IPromptsService, + private readonly workspaceService: IAICustomizationWorkspaceService, + private readonly fileService: IFileService, + private readonly pathService: IPathService, + private readonly productService: IProductService, + ) { + this.onDidChange = Event.any( + this.promptsService.onDidChangeCustomAgents, + this.promptsService.onDidChangeSlashCommands, + this.promptsService.onDidChangeSkills, + this.promptsService.onDidChangeHooks, + this.promptsService.onDidChangeInstructions, + ); + } + + async provideChatSessionCustomizations(token: CancellationToken): Promise { + const itemSets = await Promise.all([ + this.provideCustomizations(PromptsType.agent, token), + this.provideCustomizations(PromptsType.skill, token), + this.provideCustomizations(PromptsType.instructions, token), + this.provideCustomizations(PromptsType.hook, token), + this.provideCustomizations(PromptsType.prompt, token), + ]); + return itemSets.flat(); + } + + async provideCustomizations(promptType: PromptsType, token: CancellationToken = CancellationToken.None): Promise { + const items: IPromptsServiceCustomizationItem[] = []; + const disabledUris = this.promptsService.getDisabledPromptFiles(promptType); + const extensionInfoByUri = new ResourceMap<{ id: ExtensionIdentifier; displayName?: string }>(); + + if (promptType === PromptsType.agent) { + const agents = await this.promptsService.getCustomAgents(token); + const allAgentFiles = await this.promptsService.listPromptFiles(PromptsType.agent, token); + for (const file of allAgentFiles) { + if (file.extension) { + extensionInfoByUri.set(file.uri, { id: file.extension.identifier, displayName: file.extension.displayName }); + } + } + for (const agent of agents) { + items.push({ + uri: agent.uri, + type: promptType, + name: agent.name, + description: agent.description, + storage: agent.source.storage, + enabled: !disabledUris.has(agent.uri), + }); + if (agent.source.storage === PromptsStorage.extension && !extensionInfoByUri.has(agent.uri)) { + extensionInfoByUri.set(agent.uri, { id: agent.source.extensionId }); + } + } + } else if (promptType === PromptsType.skill) { + const skills = await this.promptsService.findAgentSkills(token); + const allSkillFiles = await this.promptsService.listPromptFiles(PromptsType.skill, token); + for (const file of allSkillFiles) { + if (file.extension) { + extensionInfoByUri.set(file.uri, { id: file.extension.identifier, displayName: file.extension.displayName }); + } + } + const uiIntegrations = this.workspaceService.getSkillUIIntegrations(); + const seenUris = new ResourceSet(); + for (const skill of skills || []) { + const skillName = skill.name || basename(dirname(skill.uri)) || basename(skill.uri); + seenUris.add(skill.uri); + const skillFolderName = basename(dirname(skill.uri)); + const uiTooltip = uiIntegrations.get(skillFolderName); + items.push({ + uri: skill.uri, + type: promptType, + name: skillName, + description: skill.description, + storage: skill.storage, + enabled: true, + badge: uiTooltip ? localize('uiIntegrationBadge', "UI Integration") : undefined, + badgeTooltip: uiTooltip, + }); + } + if (disabledUris.size > 0) { + for (const file of allSkillFiles) { + if (!seenUris.has(file.uri) && disabledUris.has(file.uri)) { + const disabledName = file.name || basename(dirname(file.uri)) || basename(file.uri); + const disabledFolderName = basename(dirname(file.uri)); + const uiTooltip = uiIntegrations.get(disabledFolderName); + items.push({ + uri: file.uri, + type: promptType, + name: disabledName, + description: file.description, + storage: file.storage, + enabled: false, + badge: uiTooltip ? localize('uiIntegrationBadge', "UI Integration") : undefined, + badgeTooltip: uiTooltip, + }); + } + } + } + } else if (promptType === PromptsType.prompt) { + const commands = await this.promptsService.getPromptSlashCommands(token); + for (const command of commands) { + if (command.type === PromptsType.skill) { + continue; + } + items.push({ + uri: command.uri, + type: promptType, + name: command.name, + description: command.description, + storage: command.storage, + enabled: !disabledUris.has(command.uri), + }); + if (command.extension) { + extensionInfoByUri.set(command.uri, { id: command.extension.identifier, displayName: command.extension.displayName }); + } + } + } else if (promptType === PromptsType.hook) { + await this.fetchPromptServiceHooks(items, disabledUris, promptType); + } else { + await this.fetchPromptServiceInstructions(items, extensionInfoByUri, disabledUris, promptType); + } + + return this.toProviderItems(this.applyLocalFilters(this.applyBuiltinGroupKeys(items, extensionInfoByUri), promptType)); + } + + private async fetchPromptServiceHooks(items: IPromptsServiceCustomizationItem[], disabledUris: ResourceSet, promptType: PromptsType): Promise { + const hookFiles = await this.promptsService.listPromptFiles(PromptsType.hook, CancellationToken.None); + const activeRoot = this.workspaceService.getActiveProjectRoot(); + const userHomeUri = await this.pathService.userHome(); + const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; + + for (const hookFile of hookFiles) { + if (hookFile.storage === PromptsStorage.plugin) { + items.push({ + uri: hookFile.uri, + type: promptType, + name: hookFile.name || getFriendlyName(basename(hookFile.uri)), + storage: hookFile.storage, + enabled: !disabledUris.has(hookFile.uri), + }); + continue; + } + + let parsedHooks = false; + try { + const content = await this.fileService.readFile(hookFile.uri); + const json = parseJSONC(content.value.toString()); + const { hooks } = parseHooksFromFile(hookFile.uri, json, activeRoot, userHome); + + if (hooks.size > 0) { + parsedHooks = true; + for (const [hookType, entry] of hooks) { + const hookMeta = HOOK_METADATA[hookType]; + for (let i = 0; i < entry.hooks.length; i++) { + const hook = entry.hooks[i]; + const cmdLabel = formatHookCommandLabel(hook, OS); + const truncatedCmd = cmdLabel.length > 60 ? cmdLabel.substring(0, 57) + '...' : cmdLabel; + items.push({ + uri: hookFile.uri, + type: promptType, + name: hookMeta?.label ?? entry.originalId, + description: truncatedCmd || localize('hookUnset', "(unset)"), + storage: hookFile.storage, + enabled: !disabledUris.has(hookFile.uri), + }); + } + } + } + } catch { + // Parse failed - fall through to show raw file. + } + + if (!parsedHooks) { + items.push({ + uri: hookFile.uri, + type: promptType, + name: hookFile.name || getFriendlyName(basename(hookFile.uri)), + storage: hookFile.storage, + enabled: !disabledUris.has(hookFile.uri), + }); + } + } + + const agents = !this.workspaceService.isSessionsWindow ? await this.promptsService.getCustomAgents(CancellationToken.None) : []; + for (const agent of agents) { + if (!agent.hooks) { + continue; + } + for (const hookType of Object.values(HookType)) { + const hookCommands = agent.hooks[hookType]; + if (!hookCommands || hookCommands.length === 0) { + continue; + } + const hookMeta = HOOK_METADATA[hookType]; + for (let i = 0; i < hookCommands.length; i++) { + const hook = hookCommands[i]; + const cmdLabel = formatHookCommandLabel(hook, OS); + const truncatedCmd = cmdLabel.length > 60 ? cmdLabel.substring(0, 57) + '...' : cmdLabel; + items.push({ + uri: agent.uri, + type: promptType, + name: hookMeta?.label ?? hookType, + description: `${agent.name}: ${truncatedCmd || localize('hookUnset', "(unset)")}`, + storage: agent.source.storage, + groupKey: 'agents', + enabled: !disabledUris.has(agent.uri), + }); + } + } + } + } + + private async fetchPromptServiceInstructions(items: IPromptsServiceCustomizationItem[], extensionInfoByUri: ResourceMap<{ id: ExtensionIdentifier; displayName?: string }>, disabledUris: ResourceSet, promptType: PromptsType): Promise { + const instructionFiles = await this.promptsService.getInstructionFiles(CancellationToken.None); + for (const file of instructionFiles) { + if (file.extension) { + extensionInfoByUri.set(file.uri, { id: file.extension.identifier, displayName: file.extension.displayName }); + } + } + const agentInstructionFiles = await this.promptsService.listAgentInstructions(CancellationToken.None, undefined); + const agentInstructionUris = new ResourceSet(agentInstructionFiles.map(f => f.uri)); + + for (const file of agentInstructionFiles) { + const storage = PromptsStorage.local; + const filename = basename(file.uri); + items.push({ + uri: file.uri, + type: promptType, + name: filename, + storage, + groupKey: 'agent-instructions', + enabled: !disabledUris.has(file.uri), + }); + } + + for (const { uri, pattern, name, description, storage } of instructionFiles) { + if (agentInstructionUris.has(uri)) { + continue; + } + + const friendlyName = getFriendlyName(name); + + if (pattern !== undefined) { + const badge = pattern === '**' + ? localize('alwaysAdded', "always added") + : pattern; + const badgeTooltip = pattern === '**' + ? localize('alwaysAddedTooltip', "This instruction is automatically included in every interaction.") + : localize('onContextTooltip', "This instruction is automatically included when files matching '{0}' are in context.", pattern); + items.push({ + uri, + type: promptType, + name: friendlyName, + badge, + badgeTooltip, + description, + storage, + groupKey: 'context-instructions', + enabled: !disabledUris.has(uri), + }); + } else { + items.push({ + uri, + type: promptType, + name: friendlyName, + description, + storage, + groupKey: 'on-demand-instructions', + enabled: !disabledUris.has(uri), + }); + } + } + } + + private applyBuiltinGroupKeys(items: IPromptsServiceCustomizationItem[], extensionInfoByUri: ResourceMap<{ id: ExtensionIdentifier; displayName?: string }>): IPromptsServiceCustomizationItem[] { + return items.map(item => { + if (item.storage !== PromptsStorage.extension) { + return item; + } + const extInfo = extensionInfoByUri.get(item.uri); + if (!extInfo) { + return item; + } + const isBuiltin = this.isChatExtensionItem(extInfo.id); + if (isBuiltin) { + return { + ...item, + groupKey: item.groupKey ?? BUILTIN_STORAGE, + }; + } + return item; + }); + } + + private applyLocalFilters(groupedItems: IPromptsServiceCustomizationItem[], promptType: PromptsType): IPromptsServiceCustomizationItem[] { + const filter = this.workspaceService.getStorageSourceFilter(promptType); + const withStorage = groupedItems.filter((item): item is IPromptsServiceCustomizationItem & { readonly storage: PromptsStorage } => item.storage !== undefined); + const withoutStorage = groupedItems.filter(item => item.storage === undefined); + const items = [...applyStorageSourceFilter(withStorage, filter), ...withoutStorage]; + + const descriptor = this.getActiveDescriptor(); + const subpaths = descriptor.workspaceSubpaths; + const instrFilter = descriptor.instructionFileFilter; + if (subpaths) { + const projectRoot = this.workspaceService.getActiveProjectRoot(); + for (let i = items.length - 1; i >= 0; i--) { + const item = items[i]; + if (item.storage === PromptsStorage.local && projectRoot && isEqualOrParent(item.uri, projectRoot)) { + if (!matchesWorkspaceSubpath(item.uri.path, subpaths)) { + if (instrFilter && promptType === PromptsType.instructions && matchesInstructionFileFilter(item.uri.path, instrFilter)) { + continue; + } + if (item.groupKey === 'agent-instructions') { + continue; + } + items.splice(i, 1); + } + } + } + } + + if (instrFilter && promptType === PromptsType.instructions) { + for (let i = items.length - 1; i >= 0; i--) { + if (!matchesInstructionFileFilter(items[i].uri.path, instrFilter)) { + items.splice(i, 1); + } + } + } + + return items; + } + + private toProviderItems(items: readonly IPromptsServiceCustomizationItem[]): IExternalCustomizationItem[] { + return items.map(({ storage, ...item }) => ({ + ...item, + groupKey: item.groupKey ?? storage, + })); + } + + private isChatExtensionItem(extensionId: ExtensionIdentifier): boolean { + const chatExtensionId = this.productService.defaultChatAgent?.chatExtensionId; + return !!chatExtensionId && ExtensionIdentifier.equals(extensionId, chatExtensionId); + } + +} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/providerCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/providerCustomizationItemSource.ts new file mode 100644 index 0000000000000..7c83965996fd4 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/providerCustomizationItemSource.ts @@ -0,0 +1,179 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Event } from '../../../../../base/common/event.js'; +import { parse as parseJSONC } from '../../../../../base/common/json.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { OS } from '../../../../../base/common/platform.js'; +import { basename } from '../../../../../base/common/resources.js'; +import { localize } from '../../../../../nls.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { IPathService } from '../../../../services/path/common/pathService.js'; +import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; +import { HOOK_METADATA } from '../../common/promptSyntax/hookTypes.js'; +import { parseHooksFromFile } from '../../common/promptSyntax/hookCompatibility.js'; +import { formatHookCommandLabel } from '../../common/promptSyntax/hookSchema.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { ICustomizationSyncProvider, IExternalCustomizationItem, IExternalCustomizationItemProvider } from '../../common/customizationHarnessService.js'; +import { AICustomizationItemNormalizer } from './aiCustomizationItemNormalizer.js'; +import { IAICustomizationItemSource, IAICustomizationListItem } from './aiCustomizationListItem.js'; +import { getFriendlyName } from './aiCustomizationItemSourceUtils.js'; + +interface ITypeScopedCustomizationItemProvider extends IExternalCustomizationItemProvider { + provideCustomizations(promptType: PromptsType, token: CancellationToken): Promise; +} + +function isTypeScopedCustomizationItemProvider(provider: IExternalCustomizationItemProvider): provider is ITypeScopedCustomizationItemProvider { + return typeof (provider as Partial).provideCustomizations === 'function'; +} + +export class ProviderCustomizationItemSource implements IAICustomizationItemSource { + + readonly onDidChange: Event; + + constructor( + private readonly itemProvider: IExternalCustomizationItemProvider | undefined, + private readonly syncProvider: ICustomizationSyncProvider | undefined, + private readonly promptsService: IPromptsService, + private readonly workspaceService: IAICustomizationWorkspaceService, + private readonly fileService: IFileService, + private readonly pathService: IPathService, + private readonly itemNormalizer: AICustomizationItemNormalizer, + ) { + const onDidChangeSyncableCustomizations = this.syncProvider + ? Event.any( + this.promptsService.onDidChangeCustomAgents, + this.promptsService.onDidChangeSlashCommands, + this.promptsService.onDidChangeSkills, + this.promptsService.onDidChangeHooks, + this.promptsService.onDidChangeInstructions, + ) + : Event.None; + + this.onDidChange = Event.any( + this.itemProvider?.onDidChange ?? Event.None, + this.syncProvider?.onDidChange ?? Event.None, + onDidChangeSyncableCustomizations, + ); + } + + async fetchItems(promptType: PromptsType): Promise { + const remoteItems = this.itemProvider + ? await this.fetchItemsFromProvider(this.itemProvider, promptType) + : []; + if (!this.syncProvider) { + return remoteItems; + } + const localItems = await this.fetchLocalSyncableItems(promptType, this.syncProvider); + return [...remoteItems, ...localItems]; + } + + private async fetchItemsFromProvider(provider: IExternalCustomizationItemProvider, promptType: PromptsType): Promise { + let providerItems: readonly IExternalCustomizationItem[]; + if (isTypeScopedCustomizationItemProvider(provider)) { + providerItems = await provider.provideCustomizations(promptType, CancellationToken.None) ?? []; + } else { + const allItems = await provider.provideChatSessionCustomizations(CancellationToken.None); + if (!allItems) { + return []; + } + providerItems = promptType === PromptsType.hook + ? await this.expandProviderHookItems(allItems) + : allItems.filter(item => item.type === promptType); + } + + if (promptType === PromptsType.skill) { + providerItems = await this.addSkillDescriptionFallbacks(providerItems); + } + + return this.itemNormalizer.normalizeItems(providerItems, promptType); + } + + private async addSkillDescriptionFallbacks(items: readonly IExternalCustomizationItem[]): Promise { + const descriptionsByUri = new Map(); + const skills = await this.promptsService.findAgentSkills(CancellationToken.None); + for (const skill of skills ?? []) { + if (skill.description) { + descriptionsByUri.set(skill.uri.toString(), skill.description); + } + } + + return items.map(item => item.description + ? item + : { ...item, description: descriptionsByUri.get(item.uri.toString()) }); + } + + private async expandProviderHookItems(allItems: readonly IExternalCustomizationItem[]): Promise { + const hookFileItems = allItems.filter(item => item.type === PromptsType.hook); + const items: IExternalCustomizationItem[] = []; + const activeRoot = this.workspaceService.getActiveProjectRoot(); + const userHomeUri = await this.pathService.userHome(); + const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; + + for (const item of hookFileItems) { + let parsedHooks = false; + try { + const content = await this.fileService.readFile(item.uri); + const json = parseJSONC(content.value.toString()); + const { hooks } = parseHooksFromFile(item.uri, json, activeRoot, userHome); + + if (hooks.size > 0) { + parsedHooks = true; + for (const [hookType, entry] of hooks) { + const hookMeta = HOOK_METADATA[hookType]; + for (let i = 0; i < entry.hooks.length; i++) { + const hook = entry.hooks[i]; + const cmdLabel = formatHookCommandLabel(hook, OS); + const truncatedCmd = cmdLabel.length > 60 ? cmdLabel.substring(0, 57) + '...' : cmdLabel; + items.push({ + uri: item.uri, + type: PromptsType.hook, + name: hookMeta?.label ?? entry.originalId, + description: truncatedCmd || localize('hookUnset', "(unset)"), + enabled: item.enabled, + groupKey: item.groupKey, + }); + } + } + } + } catch { + // Parse failed - fall through to show raw file. + } + + if (!parsedHooks) { + items.push(item); + } + } + + return items; + } + + private async fetchLocalSyncableItems(promptType: PromptsType, syncProvider: ICustomizationSyncProvider): Promise { + const files = await this.promptsService.listPromptFiles(promptType, CancellationToken.None); + if (!files.length) { + return []; + } + + const providerItems: IExternalCustomizationItem[] = files + .filter(file => file.storage === PromptsStorage.local || file.storage === PromptsStorage.user) + .map(file => ({ + uri: file.uri, + type: promptType, + name: getFriendlyName(basename(file.uri)), + groupKey: 'sync-local', + enabled: true, + })); + + return this.itemNormalizer.normalizeItems(providerItems, promptType) + .map(item => ({ + ...item, + id: `sync-${item.id}`, + syncable: true, + synced: syncProvider.isSelected(item.uri), + })); + } +} From 26e4ae0ed8df4043b998aa8a940d9dae22f27f06 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:31:29 -0700 Subject: [PATCH 2/3] chat: clean up AI customization item source extraction Addresses code quality issues from council review: - Extract shared isChatExtensionItem() to aiCustomizationItemSourceUtils - Move storageToIcon() to aiCustomizationIcons (pure function, no class dep) - Extract shared expandHookFileItems() utility, deduplicating hook file parsing from PromptsServiceCustomizationItemProvider and ProviderCustomizationItemSource - Replace fragile backward-index splice loops in applyLocalFilters with idiomatic .filter() chains - Cache ProviderCustomizationItemSource per active harness descriptor to avoid redundant event composition on every call Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../aiCustomization/aiCustomizationIcons.ts | 15 +++ .../aiCustomizationItemNormalizer.ts | 26 +--- .../aiCustomizationItemSourceUtils.ts | 79 ++++++++++++ .../aiCustomizationListWidget.ts | 11 +- ...promptsServiceCustomizationItemProvider.ts | 115 +++++++----------- .../providerCustomizationItemSource.ts | 59 +-------- 6 files changed, 155 insertions(+), 150 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts index 2eb829a3e0364..a3d71e823171c 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts @@ -4,8 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; import { localize } from '../../../../../nls.js'; import { registerIcon } from '../../../../../platform/theme/common/iconRegistry.js'; +import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; /** * Icon for the AI Customization view container (sidebar). @@ -76,3 +78,16 @@ export const builtinIcon = registerIcon('ai-customization-builtin', Codicon.star * Icon for MCP servers. */ export const mcpServerIcon = registerIcon('ai-customization-mcp-server', Codicon.server, localize('aiCustomizationMcpServerIcon', "Icon for MCP servers.")); + +/** + * Returns the icon for a given storage type. + */ +export function storageToIcon(storage: PromptsStorage): ThemeIcon { + switch (storage) { + case PromptsStorage.local: return workspaceIcon; + case PromptsStorage.user: return userIcon; + case PromptsStorage.extension: return extensionIcon; + case PromptsStorage.plugin: return pluginIcon; + default: return instructionsIcon; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemNormalizer.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemNormalizer.ts index 60b1fdcab4859..f2490540bf4a2 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemNormalizer.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemNormalizer.ts @@ -6,7 +6,6 @@ import { ResourceMap } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; import { basename, isEqualOrParent } from '../../../../../base/common/resources.js'; -import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; @@ -18,22 +17,10 @@ import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { IExternalCustomizationItem } from '../../common/customizationHarnessService.js'; import { BUILTIN_STORAGE } from './aiCustomizationManagement.js'; -import { extensionIcon, instructionsIcon, pluginIcon, userIcon, workspaceIcon } from './aiCustomizationIcons.js'; +import { storageToIcon } from './aiCustomizationIcons.js'; import { IAICustomizationListItem } from './aiCustomizationListItem.js'; import { extractExtensionIdFromPath } from './aiCustomizationListWidgetUtils.js'; - -/** - * Returns the icon for a given storage type. - */ -export function storageToIcon(storage: PromptsStorage): ThemeIcon { - switch (storage) { - case PromptsStorage.local: return workspaceIcon; - case PromptsStorage.user: return userIcon; - case PromptsStorage.extension: return extensionIcon; - case PromptsStorage.plugin: return pluginIcon; - default: return instructionsIcon; - } -} +import { isChatExtensionItem } from './aiCustomizationItemSourceUtils.js'; /** * Converts provider-shaped customization rows into the rich list model used by the management UI. @@ -97,7 +84,7 @@ export class AICustomizationItemNormalizer { case PromptsStorage.user: case PromptsStorage.extension: case PromptsStorage.plugin: - return { ...inferred, storage: item.groupKey }; + return { storage: item.groupKey, extensionLabel: inferred.extensionLabel }; case BUILTIN_STORAGE: return { ...inferred, storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true }; default: @@ -130,7 +117,7 @@ export class AICustomizationItemNormalizer { const extensionId = extractExtensionIdFromPath(uri.path); if (extensionId) { const extensionIdentifier = new ExtensionIdentifier(extensionId); - if (this.isChatExtensionItem(extensionIdentifier)) { + if (isChatExtensionItem(extensionIdentifier, this.productService)) { return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true }; } return { storage: PromptsStorage.extension, extensionLabel: extensionIdentifier.value }; @@ -139,11 +126,6 @@ export class AICustomizationItemNormalizer { return { storage: PromptsStorage.user }; } - private isChatExtensionItem(extensionId: ExtensionIdentifier): boolean { - const chatExtensionId = this.productService.defaultChatAgent?.chatExtensionId; - return !!chatExtensionId && ExtensionIdentifier.equals(extensionId, chatExtensionId); - } - private findPluginUri(itemUri: URI): URI | undefined { for (const plugin of this.agentPluginService.plugins.get()) { if (isEqualOrParent(itemUri, plugin.uri)) { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSourceUtils.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSourceUtils.ts index c292e16879229..dcae39bc96dce 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSourceUtils.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSourceUtils.ts @@ -3,6 +3,31 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { localize } from '../../../../../nls.js'; +import { parse as parseJSONC } from '../../../../../base/common/json.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { OS } from '../../../../../base/common/platform.js'; +import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IPathService } from '../../../../services/path/common/pathService.js'; +import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; +import { HOOK_METADATA } from '../../common/promptSyntax/hookTypes.js'; +import { parseHooksFromFile } from '../../common/promptSyntax/hookCompatibility.js'; +import { formatHookCommandLabel } from '../../common/promptSyntax/hookSchema.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { IExternalCustomizationItem } from '../../common/customizationHarnessService.js'; + +/** + * Returns true if the given extension identifier matches the default + * chat extension (e.g. GitHub Copilot Chat). Used to group items from + * the chat extension under "Built-in" instead of "Extensions". + */ +export function isChatExtensionItem(extensionId: ExtensionIdentifier, productService: IProductService): boolean { + const chatExtensionId = productService.defaultChatAgent?.chatExtensionId; + return !!chatExtensionId && ExtensionIdentifier.equals(extensionId, chatExtensionId); +} + /** * Derives a friendly name from a filename by removing extension suffixes. */ @@ -21,3 +46,57 @@ export function getFriendlyName(filename: string): string { return name || filename; } + +/** + * Expands hook file items into individual hook entries by parsing hook + * definitions from the file content. Falls back to the original item + * when parsing fails. + */ +export async function expandHookFileItems( + hookFileItems: readonly IExternalCustomizationItem[], + workspaceService: IAICustomizationWorkspaceService, + fileService: IFileService, + pathService: IPathService, +): Promise { + const items: IExternalCustomizationItem[] = []; + const activeRoot = workspaceService.getActiveProjectRoot(); + const userHomeUri = await pathService.userHome(); + const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; + + for (const item of hookFileItems) { + let parsedHooks = false; + try { + const content = await fileService.readFile(item.uri); + const json = parseJSONC(content.value.toString()); + const { hooks } = parseHooksFromFile(item.uri, json, activeRoot, userHome); + + if (hooks.size > 0) { + parsedHooks = true; + for (const [hookType, entry] of hooks) { + const hookMeta = HOOK_METADATA[hookType]; + for (let i = 0; i < entry.hooks.length; i++) { + const hook = entry.hooks[i]; + const cmdLabel = formatHookCommandLabel(hook, OS); + const truncatedCmd = cmdLabel.length > 60 ? cmdLabel.substring(0, 57) + '...' : cmdLabel; + items.push({ + uri: item.uri, + type: PromptsType.hook, + name: hookMeta?.label ?? entry.originalId, + description: truncatedCmd || localize('hookUnset', "(unset)"), + enabled: item.enabled, + groupKey: item.groupKey, + }); + } + } + } + } catch { + // Parse failed — fall through to show raw file. + } + + if (!parsedHooks) { + items.push(item); + } + } + + return items; +} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 201c247575bfc..333e53c72ad03 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -533,6 +533,7 @@ export class AICustomizationListWidget extends Disposable { private readonly delayedFilter = new Delayer(200); private readonly itemNormalizer: AICustomizationItemNormalizer; private readonly promptsServiceItemProvider: PromptsServiceCustomizationItemProvider; + private cachedItemSource: { descriptorId: string; source: IAICustomizationItemSource } | undefined; private readonly _onDidSelectItem = this._register(new Emitter()); readonly onDidSelectItem: Event = this._onDidSelectItem.event; @@ -615,6 +616,7 @@ export class AICustomizationListWidget extends Disposable { this._register(autorun(reader => { this.harnessService.activeHarness.read(reader); this.harnessService.availableHarnesses.read(reader); + this.cachedItemSource = undefined; const activeDescriptor = this.harnessService.getActiveDescriptor(); itemSourceChangeDisposable.value = this.getItemSource(activeDescriptor).onDidChange(() => this.refresh()); })); @@ -1153,10 +1155,15 @@ export class AICustomizationListWidget extends Disposable { /** * Returns the rich, browser-internal item source for a harness descriptor. + * The source is cached per descriptor id and reused across fetch and + * subscription calls to avoid redundant event composition. */ private getItemSource(descriptor: ReturnType): IAICustomizationItemSource { + if (this.cachedItemSource && this.cachedItemSource.descriptorId === descriptor.id) { + return this.cachedItemSource.source; + } const itemProvider = descriptor.itemProvider ?? (descriptor.syncProvider ? undefined : this.promptsServiceItemProvider); - return new ProviderCustomizationItemSource( + const source = new ProviderCustomizationItemSource( itemProvider, descriptor.syncProvider, this.promptsService, @@ -1165,6 +1172,8 @@ export class AICustomizationListWidget extends Disposable { this.pathService, this.itemNormalizer, ); + this.cachedItemSource = { descriptorId: descriptor.id, source }; + return source; } /** diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts index 978ed304562a3..a6aca698bb4bf 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts @@ -5,9 +5,7 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Event } from '../../../../../base/common/event.js'; -import { parse as parseJSONC } from '../../../../../base/common/json.js'; import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js'; -import { Schemas } from '../../../../../base/common/network.js'; import { OS } from '../../../../../base/common/platform.js'; import { basename, dirname, isEqualOrParent } from '../../../../../base/common/resources.js'; import { localize } from '../../../../../nls.js'; @@ -17,13 +15,12 @@ import { IProductService } from '../../../../../platform/product/common/productS import { IPathService } from '../../../../services/path/common/pathService.js'; import { IAICustomizationWorkspaceService, applyStorageSourceFilter } from '../../common/aiCustomizationWorkspaceService.js'; import { HookType, HOOK_METADATA } from '../../common/promptSyntax/hookTypes.js'; -import { parseHooksFromFile } from '../../common/promptSyntax/hookCompatibility.js'; import { formatHookCommandLabel } from '../../common/promptSyntax/hookSchema.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { IExternalCustomizationItem, IExternalCustomizationItemProvider, IHarnessDescriptor, matchesInstructionFileFilter, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js'; import { BUILTIN_STORAGE } from './aiCustomizationManagement.js'; -import { getFriendlyName } from './aiCustomizationItemSourceUtils.js'; +import { expandHookFileItems, getFriendlyName, isChatExtensionItem } from './aiCustomizationItemSourceUtils.js'; interface IPromptsServiceCustomizationItem extends IExternalCustomizationItem { readonly storage?: PromptsStorage; @@ -165,52 +162,29 @@ export class PromptsServiceCustomizationItemProvider implements IExternalCustomi private async fetchPromptServiceHooks(items: IPromptsServiceCustomizationItem[], disabledUris: ResourceSet, promptType: PromptsType): Promise { const hookFiles = await this.promptsService.listPromptFiles(PromptsType.hook, CancellationToken.None); - const activeRoot = this.workspaceService.getActiveProjectRoot(); - const userHomeUri = await this.pathService.userHome(); - const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; - for (const hookFile of hookFiles) { - if (hookFile.storage === PromptsStorage.plugin) { - items.push({ - uri: hookFile.uri, - type: promptType, - name: hookFile.name || getFriendlyName(basename(hookFile.uri)), - storage: hookFile.storage, - enabled: !disabledUris.has(hookFile.uri), - }); - continue; - } - - let parsedHooks = false; - try { - const content = await this.fileService.readFile(hookFile.uri); - const json = parseJSONC(content.value.toString()); - const { hooks } = parseHooksFromFile(hookFile.uri, json, activeRoot, userHome); + // Convert hook files to provider-shaped items for shared expansion. + // Plugin hooks are pre-expanded by plugin manifests and kept as-is. + const hookFileItems: IExternalCustomizationItem[] = hookFiles + .filter(f => f.storage !== PromptsStorage.plugin) + .map(f => ({ + uri: f.uri, + type: promptType, + name: f.name || getFriendlyName(basename(f.uri)), + enabled: !disabledUris.has(f.uri), + })); - if (hooks.size > 0) { - parsedHooks = true; - for (const [hookType, entry] of hooks) { - const hookMeta = HOOK_METADATA[hookType]; - for (let i = 0; i < entry.hooks.length; i++) { - const hook = entry.hooks[i]; - const cmdLabel = formatHookCommandLabel(hook, OS); - const truncatedCmd = cmdLabel.length > 60 ? cmdLabel.substring(0, 57) + '...' : cmdLabel; - items.push({ - uri: hookFile.uri, - type: promptType, - name: hookMeta?.label ?? entry.originalId, - description: truncatedCmd || localize('hookUnset', "(unset)"), - storage: hookFile.storage, - enabled: !disabledUris.has(hookFile.uri), - }); - } - } - } - } catch { - // Parse failed - fall through to show raw file. - } + const expanded = await expandHookFileItems( + hookFileItems, this.workspaceService, this.fileService, this.pathService, + ); + const storageByUri = new Map(hookFiles.map(f => [f.uri.toString(), f.storage])); + for (const item of expanded) { + items.push({ ...item, storage: storageByUri.get(item.uri.toString()) }); + } - if (!parsedHooks) { + // Plugin hooks are pre-expanded; add them directly. + for (const hookFile of hookFiles) { + if (hookFile.storage === PromptsStorage.plugin) { items.push({ uri: hookFile.uri, type: promptType, @@ -221,6 +195,7 @@ export class PromptsServiceCustomizationItemProvider implements IExternalCustomi } } + // Agent-embedded hooks (not in sessions window). const agents = !this.workspaceService.isSessionsWindow ? await this.promptsService.getCustomAgents(CancellationToken.None) : []; for (const agent of agents) { if (!agent.hooks) { @@ -321,8 +296,7 @@ export class PromptsServiceCustomizationItemProvider implements IExternalCustomi if (!extInfo) { return item; } - const isBuiltin = this.isChatExtensionItem(extInfo.id); - if (isBuiltin) { + if (isChatExtensionItem(extInfo.id, this.productService)) { return { ...item, groupKey: item.groupKey ?? BUILTIN_STORAGE, @@ -336,35 +310,35 @@ export class PromptsServiceCustomizationItemProvider implements IExternalCustomi const filter = this.workspaceService.getStorageSourceFilter(promptType); const withStorage = groupedItems.filter((item): item is IPromptsServiceCustomizationItem & { readonly storage: PromptsStorage } => item.storage !== undefined); const withoutStorage = groupedItems.filter(item => item.storage === undefined); - const items = [...applyStorageSourceFilter(withStorage, filter), ...withoutStorage]; + let items = [...applyStorageSourceFilter(withStorage, filter), ...withoutStorage]; const descriptor = this.getActiveDescriptor(); const subpaths = descriptor.workspaceSubpaths; const instrFilter = descriptor.instructionFileFilter; + if (subpaths) { const projectRoot = this.workspaceService.getActiveProjectRoot(); - for (let i = items.length - 1; i >= 0; i--) { - const item = items[i]; - if (item.storage === PromptsStorage.local && projectRoot && isEqualOrParent(item.uri, projectRoot)) { - if (!matchesWorkspaceSubpath(item.uri.path, subpaths)) { - if (instrFilter && promptType === PromptsType.instructions && matchesInstructionFileFilter(item.uri.path, instrFilter)) { - continue; - } - if (item.groupKey === 'agent-instructions') { - continue; - } - items.splice(i, 1); - } + items = items.filter(item => { + if (item.storage !== PromptsStorage.local || !projectRoot || !isEqualOrParent(item.uri, projectRoot)) { + return true; } - } + if (matchesWorkspaceSubpath(item.uri.path, subpaths)) { + return true; + } + // Keep instruction files matching the harness's native patterns + if (instrFilter && promptType === PromptsType.instructions && matchesInstructionFileFilter(item.uri.path, instrFilter)) { + return true; + } + // Keep agent instruction files (AGENTS.md, CLAUDE.md, copilot-instructions.md) + if (item.groupKey === 'agent-instructions') { + return true; + } + return false; + }); } if (instrFilter && promptType === PromptsType.instructions) { - for (let i = items.length - 1; i >= 0; i--) { - if (!matchesInstructionFileFilter(items[i].uri.path, instrFilter)) { - items.splice(i, 1); - } - } + items = items.filter(item => matchesInstructionFileFilter(item.uri.path, instrFilter)); } return items; @@ -377,9 +351,4 @@ export class PromptsServiceCustomizationItemProvider implements IExternalCustomi })); } - private isChatExtensionItem(extensionId: ExtensionIdentifier): boolean { - const chatExtensionId = this.productService.defaultChatAgent?.chatExtensionId; - return !!chatExtensionId && ExtensionIdentifier.equals(extensionId, chatExtensionId); - } - } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/providerCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/providerCustomizationItemSource.ts index 7c83965996fd4..c3657987be27b 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/providerCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/providerCustomizationItemSource.ts @@ -5,23 +5,16 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Event } from '../../../../../base/common/event.js'; -import { parse as parseJSONC } from '../../../../../base/common/json.js'; -import { Schemas } from '../../../../../base/common/network.js'; -import { OS } from '../../../../../base/common/platform.js'; import { basename } from '../../../../../base/common/resources.js'; -import { localize } from '../../../../../nls.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; -import { HOOK_METADATA } from '../../common/promptSyntax/hookTypes.js'; -import { parseHooksFromFile } from '../../common/promptSyntax/hookCompatibility.js'; -import { formatHookCommandLabel } from '../../common/promptSyntax/hookSchema.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { ICustomizationSyncProvider, IExternalCustomizationItem, IExternalCustomizationItemProvider } from '../../common/customizationHarnessService.js'; import { AICustomizationItemNormalizer } from './aiCustomizationItemNormalizer.js'; import { IAICustomizationItemSource, IAICustomizationListItem } from './aiCustomizationListItem.js'; -import { getFriendlyName } from './aiCustomizationItemSourceUtils.js'; +import { expandHookFileItems, getFriendlyName } from './aiCustomizationItemSourceUtils.js'; interface ITypeScopedCustomizationItemProvider extends IExternalCustomizationItemProvider { provideCustomizations(promptType: PromptsType, token: CancellationToken): Promise; @@ -82,7 +75,10 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour return []; } providerItems = promptType === PromptsType.hook - ? await this.expandProviderHookItems(allItems) + ? await expandHookFileItems( + allItems.filter(item => item.type === PromptsType.hook), + this.workspaceService, this.fileService, this.pathService, + ) : allItems.filter(item => item.type === promptType); } @@ -107,51 +103,6 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour : { ...item, description: descriptionsByUri.get(item.uri.toString()) }); } - private async expandProviderHookItems(allItems: readonly IExternalCustomizationItem[]): Promise { - const hookFileItems = allItems.filter(item => item.type === PromptsType.hook); - const items: IExternalCustomizationItem[] = []; - const activeRoot = this.workspaceService.getActiveProjectRoot(); - const userHomeUri = await this.pathService.userHome(); - const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; - - for (const item of hookFileItems) { - let parsedHooks = false; - try { - const content = await this.fileService.readFile(item.uri); - const json = parseJSONC(content.value.toString()); - const { hooks } = parseHooksFromFile(item.uri, json, activeRoot, userHome); - - if (hooks.size > 0) { - parsedHooks = true; - for (const [hookType, entry] of hooks) { - const hookMeta = HOOK_METADATA[hookType]; - for (let i = 0; i < entry.hooks.length; i++) { - const hook = entry.hooks[i]; - const cmdLabel = formatHookCommandLabel(hook, OS); - const truncatedCmd = cmdLabel.length > 60 ? cmdLabel.substring(0, 57) + '...' : cmdLabel; - items.push({ - uri: item.uri, - type: PromptsType.hook, - name: hookMeta?.label ?? entry.originalId, - description: truncatedCmd || localize('hookUnset', "(unset)"), - enabled: item.enabled, - groupKey: item.groupKey, - }); - } - } - } - } catch { - // Parse failed - fall through to show raw file. - } - - if (!parsedHooks) { - items.push(item); - } - } - - return items; - } - private async fetchLocalSyncableItems(promptType: PromptsType, syncProvider: ICustomizationSyncProvider): Promise { const files = await this.promptsService.listPromptFiles(promptType, CancellationToken.None); if (!files.length) { From 7c4bc0120b4226e41d8e4cadcb8d9263cbe32cf8 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:59:57 -0700 Subject: [PATCH 3/3] chat: consolidate item source files into single module Merge aiCustomizationListItem, aiCustomizationItemSourceUtils, aiCustomizationItemNormalizer, and providerCustomizationItemSource into a single aiCustomizationItemSource.ts (~414 lines). These four files form a tight linear dependency chain with one external consumer (the list widget). Consolidating matches the codebase convention (cf. testing explorerProjections/index.ts) and reduces the aiCustomization directory from 22 to 19 files. The promptsServiceCustomizationItemProvider remains separate as a distinct adapter that bridges the core promptsService into the provider-shaped pipeline for harnesses without an external provider. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/AI_CUSTOMIZATIONS.md | 62 +-- .../remoteAgentHostCustomizationHarness.ts | 12 +- .../api/browser/mainThreadChatAgents2.ts | 6 +- .../aiCustomizationDebugPanel.ts | 4 +- .../aiCustomizationItemNormalizer.ts | 137 ------ .../aiCustomizationItemSource.ts | 402 ++++++++++++++++++ .../aiCustomizationItemSourceUtils.ts | 102 ----- .../aiCustomizationListItem.ts | 63 --- .../aiCustomizationListWidget.ts | 4 +- ...promptsServiceCustomizationItemProvider.ts | 37 +- .../providerCustomizationItemSource.ts | 130 ------ .../common/customizationHarnessService.ts | 10 +- .../customizationHarnessService.test.ts | 4 +- 13 files changed, 471 insertions(+), 502 deletions(-) delete mode 100644 src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemNormalizer.ts create mode 100644 src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts delete mode 100644 src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSourceUtils.ts delete mode 100644 src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListItem.ts delete mode 100644 src/vs/workbench/contrib/chat/browser/aiCustomization/providerCustomizationItemSource.ts diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index 02aaedc297b12..5dc82ac562e2a 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -15,10 +15,8 @@ src/vs/workbench/contrib/chat/browser/aiCustomization/ ├── aiCustomizationManagementEditor.ts # SplitView list/editor ├── aiCustomizationManagementEditorInput.ts # Singleton input ├── aiCustomizationListWidget.ts # Search + grouped list + harness toggle -├── aiCustomizationListItem.ts # Browser-only list item model -├── promptsServiceCustomizationItemProvider.ts # promptsService -> provider-shaped item adapter -├── providerCustomizationItemSource.ts # Active provider/sync -> normalized list item source -├── aiCustomizationItemNormalizer.ts # provider-shaped item -> browser list item normalizer +├── aiCustomizationItemSource.ts # Item pipeline: ICustomizationItem → IAICustomizationListItem view model +├── promptsServiceCustomizationItemProvider.ts # Adapts IPromptsService → ICustomizationItemProvider ├── aiCustomizationListWidgetUtils.ts # List item helpers (truncation, etc.) ├── aiCustomizationDebugPanel.ts # Debug diagnostics panel ├── aiCustomizationWorkspaceService.ts # Core VS Code workspace service impl @@ -33,7 +31,7 @@ src/vs/workbench/contrib/chat/browser/aiCustomization/ src/vs/workbench/contrib/chat/common/ ├── aiCustomizationWorkspaceService.ts # IAICustomizationWorkspaceService + IStorageSourceFilter + BUILTIN_STORAGE -└── customizationHarnessService.ts # ICustomizationHarnessService + ISectionOverride + helpers +└── customizationHarnessService.ts # ICustomizationHarnessService + ICustomizationItem + ICustomizationItemProvider + helpers ``` The tree view and overview live in `vs/sessions` (agent sessions window only): @@ -102,6 +100,8 @@ Key properties on the harness descriptor: | Property | Purpose | |----------|--------| +| `itemProvider` | `ICustomizationItemProvider` supplying items; when absent, falls back to `PromptsServiceCustomizationItemProvider` | +| `syncProvider` | `ICustomizationSyncProvider` enabling local→remote sync checkboxes | | `hiddenSections` | Sidebar sections to hide (e.g. Claude: `[Prompts, Plugins]`) | | `workspaceSubpaths` | Restrict file creation/display to directories (e.g. Claude: `['.claude']`) | | `hideGenerateButton` | Replace "Generate X" sparkle button with "New X" | @@ -161,20 +161,38 @@ Claude additionally applies: In core VS Code, customization items contributed by the default chat extension (`productService.defaultChatAgent.chatExtensionId`, typically `GitHub.copilot-chat`) are grouped under the "Built-in" header in the management editor list widget, separate from third-party "Extensions". -This follows the same pattern as the MCP list widget, which determines grouping at the UI layer by inspecting collection sources. The list widget uses `IProductService` to identify the chat extension and sets `groupKey: BUILTIN_STORAGE` on matching items: - -- **Agents**: checks `agent.source.extensionId` against the chat extension ID -- **Skills**: builds a URI→ExtensionIdentifier lookup from `listPromptFiles(PromptsType.skill)`, then checks each skill's URI -- **Prompts**: checks `command.promptPath.extension?.identifier` -- **Instructions/Hooks**: checks `item.extension?.identifier` via `IPromptPath` - -The underlying `storage` remains `PromptsStorage.extension` — the grouping is a UI-level override via `groupKey` that keeps `applyStorageSourceFilter` working with existing storage types while visually distinguishing chat-extension items from third-party extension items. +`PromptsServiceCustomizationItemProvider` handles this via `applyBuiltinGroupKeys()`: it builds a URI→extension-ID lookup from prompt file metadata, then sets `groupKey: BUILTIN_STORAGE` on items whose extension matches the chat extension ID (checked via the shared `isChatExtensionItem()` utility). The underlying `storage` remains `PromptsStorage.extension` — the grouping is a `groupKey` override that keeps `applyStorageSourceFilter` working while visually distinguishing chat-extension items from third-party extension items. `BUILTIN_STORAGE` is defined in `aiCustomizationWorkspaceService.ts` (common layer) and re-exported by both `aiCustomizationManagement.ts` (browser) and `builtinPromptsStorage.ts` (sessions) for backward compatibility. ### Management Editor Item Pipeline -The management editor list widget renders one browser-only list item model, but customization discovery converges earlier on the internal provider-shaped item contract (`IExternalCustomizationItem`). Extension-contributed providers already reach that shape through the extension-host/main-thread adapter. The Local/static harness uses `PromptsServiceCustomizationItemProvider`, a thin adapter that reads `IPromptsService`, expands local-only concepts (parsed hooks, instruction buckets, UI integration badges), applies the active harness's file/storage filters, and returns the same provider-shaped rows. `ProviderCustomizationItemSource` then normalizes provider rows via `AICustomizationItemNormalizer` and adds view-only sync overlays when the active harness has an `ICustomizationSyncProvider`. +All customization sources — `IPromptsService`, extension-contributed providers, and AHP remote servers — produce items conforming to the same `ICustomizationItem` contract (defined in `customizationHarnessService.ts`). This contract carries `uri`, `type`, `name`, `description`, optional `storage`, `groupKey`, `badge`, and status fields. + +``` +promptsService ──→ PromptsServiceCustomizationItemProvider ──→ ICustomizationItem[] + │ +Extension Provider ───────────────────────────────────────→ ICustomizationItem[] + │ +AHP Remote Server ────────────────────────────────────────→ ICustomizationItem[] + │ + ▼ + CustomizationItemSource (aiCustomizationItemSource.ts) + ├── normalizes → IAICustomizationListItem[] + ├── expands hooks from file content + └── blends sync overlays when syncProvider present + │ + ▼ + List Widget renders +``` + +**Key files:** + +- **`aiCustomizationItemSource.ts`** — The browser-side pipeline: `IAICustomizationListItem` (view model), `IAICustomizationItemSource` (data contract), `AICustomizationItemNormalizer` (maps `ICustomizationItem` → view model, inferring storage/grouping from URIs when the provider doesn't supply them), `ProviderCustomizationItemSource` (orchestrates provider + sync + normalizer), and shared utilities (`expandHookFileItems`, `getFriendlyName`, `isChatExtensionItem`). + +- **`promptsServiceCustomizationItemProvider.ts`** — Adapts `IPromptsService` to `ICustomizationItemProvider`. Reads agents, skills, instructions, hooks, and prompts from the core service, expands instruction categories and hook entries, applies harness-specific filters (storage sources, workspace subpaths, instruction file patterns), and returns `ICustomizationItem[]` with `storage` set from the authoritative promptsService metadata. Used as the default item provider for harnesses that don't supply their own. + +- **`customizationHarnessService.ts`** (common layer) — Defines `ICustomizationItem`, `ICustomizationItemProvider`, `ICustomizationSyncProvider`, and `IHarnessDescriptor`. A harness descriptor optionally carries an `itemProvider`; when absent, the widget falls back to `PromptsServiceCustomizationItemProvider`. ### AgenticPromptsService (Sessions) @@ -202,15 +220,7 @@ Skills that are directly invoked by UI elements (toolbar buttons, menu items) ar ### Count Consistency -`customizationCounts.ts` uses the **same data sources** as the list widget's `loadItems()`: - -| Type | Data Source | Notes | -|------|-------------|-------| -| Agents | `getCustomAgents()` | Parsed agents, not raw files | -| Skills | `findAgentSkills()` | Parsed skills with frontmatter | -| Prompts | `getPromptSlashCommands()` | Filters out skill-type commands | -| Instructions | `listPromptFiles()` + `listAgentInstructions()` | Includes AGENTS.md, CLAUDE.md etc. | -| Hooks | `listPromptFiles()` | Individual hooks parsed via `parseHooksFromFile()` | +`customizationCounts.ts` uses the **same data sources** as the list widget. Both go through the active harness's `ICustomizationItemProvider` (or the `PromptsServiceCustomizationItemProvider` fallback), ensuring counts match what the list displays. ### Item Badges @@ -218,10 +228,10 @@ Skills that are directly invoked by UI elements (toolbar buttons, menu items) ar ### Debug Panel -Toggle via Command Palette: "Toggle Customizations Debug Panel". Shows a 4-stage pipeline view: +Toggle via Command Palette: "Toggle Customizations Debug Panel". Shows a diagnostic view of the item pipeline: -1. **Raw PromptsService data** — per-storage file lists + type-specific extras -2. **After applyStorageSourceFilter** — what was removed and why +1. **Provider data** — items returned by the active `ICustomizationItemProvider` +2. **After filtering** — what was removed by storage source and workspace subpath filters 3. **Widget state** — allItems vs displayEntries with group counts 4. **Source/resolved folders** — creation targets and discovery order diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts index f651084001751..10bed14b9f989 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts @@ -12,7 +12,7 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { AICustomizationManagementSection, type IStorageSourceFilter } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; -import { type IHarnessDescriptor, type IExternalCustomizationItem, type IExternalCustomizationItemProvider } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; +import { type IHarnessDescriptor, type ICustomizationItem, type ICustomizationItemProvider } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import type { IAgentConnection } from '../../../../platform/agentHost/common/agentService.js'; import { ActionType } from '../../../../platform/agentHost/common/state/sessionActions.js'; import { type IAgentInfo, type ICustomizationRef, type ISessionCustomization, CustomizationStatus } from '../../../../platform/agentHost/common/state/sessionState.js'; @@ -23,7 +23,7 @@ export { AgentCustomizationSyncProvider as RemoteAgentSyncProvider } from '../.. /** * Maps a {@link CustomizationStatus} enum value to the string literal - * expected by {@link IExternalCustomizationItem.status}. + * expected by {@link ICustomizationItem.status}. */ function toStatusString(status: CustomizationStatus | undefined): 'loading' | 'loaded' | 'degraded' | 'error' | undefined { switch (status) { @@ -37,14 +37,14 @@ function toStatusString(status: CustomizationStatus | undefined): 'loading' | 'l /** * Provider that exposes a remote agent's customizations as - * {@link IExternalCustomizationItem} entries for the list widget. + * {@link ICustomizationItem} entries for the list widget. * * Baseline items come from {@link IAgentInfo.customizations} (available * without an active session). When a session is active, the provider * overlays {@link ISessionCustomization} data, which includes loading * status and enabled state. */ -export class RemoteAgentCustomizationItemProvider extends Disposable implements IExternalCustomizationItemProvider { +export class RemoteAgentCustomizationItemProvider extends Disposable implements ICustomizationItemProvider { private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange: Event = this._onDidChange.event; @@ -79,7 +79,7 @@ export class RemoteAgentCustomizationItemProvider extends Disposable implements this._onDidChange.fire(); } - async provideChatSessionCustomizations(_token: CancellationToken): Promise { + async provideChatSessionCustomizations(_token: CancellationToken): Promise { // When a session is active, prefer session-level data (includes status) if (this._sessionCustomizations) { return this._sessionCustomizations.map(sc => ({ @@ -108,7 +108,7 @@ export class RemoteAgentCustomizationItemProvider extends Disposable implements * the agent host protocol. * * The descriptor exposes the agent's server-provided customizations through - * an {@link IExternalCustomizationItemProvider} and allows the user to + * an {@link ICustomizationItemProvider} and allows the user to * select local customizations for syncing via an {@link ICustomizationSyncProvider}. */ export function createRemoteAgentHarnessDescriptor( diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index a2f6359afeca7..95656a46876d1 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -46,7 +46,7 @@ import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; import { ExtHostChatAgentsShape2, ExtHostContext, IChatAgentInvokeResult, IChatSessionCustomizationItemDto, IChatSessionCustomizationProviderMetadataDto, IChatNotebookEditDto, IChatParticipantMetadata, IChatProgressDto, IChatSessionContextDto, ICustomAgentDto, IDynamicChatAgentProps, IExtensionChatAgentMetadata, IHookDto, IInstructionDto, IPluginDto, ISkillDto, ISlashCommandDto, MainContext, MainThreadChatAgentsShape2 } from '../common/extHost.protocol.js'; import { NotebookDto } from './mainThreadNotebookDto.js'; import { isUntitledChatSession } from '../../contrib/chat/common/model/chatUri.js'; -import { ICustomizationHarnessService, IExternalCustomizationItem, IExternalCustomizationItemProvider, IHarnessDescriptor } from '../../contrib/chat/common/customizationHarnessService.js'; +import { ICustomizationHarnessService, ICustomizationItem, ICustomizationItemProvider, IHarnessDescriptor } from '../../contrib/chat/common/customizationHarnessService.js'; import { AICustomizationManagementSection, BUILTIN_STORAGE } from '../../contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IAgentPluginService } from '../../contrib/chat/common/plugins/agentPluginService.js'; import { IWorkbenchEnvironmentService } from '../../services/environment/common/environmentService.js'; @@ -762,14 +762,14 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA this._customizationProviderEmitters.set(handle, emitter); // Build the item provider that calls back to the ExtHost - const itemProvider: IExternalCustomizationItemProvider = { + const itemProvider: ICustomizationItemProvider = { onDidChange: emitter.event, provideChatSessionCustomizations: async (token) => { const items = await this._proxy.$provideChatSessionCustomizations(handle, token); if (!items) { return undefined; } - return items.map((item: IChatSessionCustomizationItemDto): IExternalCustomizationItem => ({ + return items.map((item: IChatSessionCustomizationItemDto): ICustomizationItem => ({ uri: URI.revive(item.uri), type: item.type, name: item.name, diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts index d255da3cfcd90..cab4800610779 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts @@ -9,7 +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, IHarnessDescriptor } from '../../common/customizationHarnessService.js'; +import { ICustomizationItemProvider, IHarnessDescriptor } from '../../common/customizationHarnessService.js'; /** * Maps section ID to prompt type. Duplicated from aiCustomizationListWidget @@ -100,7 +100,7 @@ export async function generateCustomizationDebugReport( return lines.join('\n'); } -async function appendExternalProviderData(lines: string[], provider: IExternalCustomizationItemProvider, promptType: PromptsType): Promise { +async function appendExternalProviderData(lines: string[], provider: ICustomizationItemProvider, promptType: PromptsType): Promise { lines.push('--- External Provider Data ---'); const allItems = await provider.provideChatSessionCustomizations(CancellationToken.None); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemNormalizer.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemNormalizer.ts deleted file mode 100644 index f2490540bf4a2..0000000000000 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemNormalizer.ts +++ /dev/null @@ -1,137 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { ResourceMap } from '../../../../../base/common/map.js'; -import { Schemas } from '../../../../../base/common/network.js'; -import { basename, isEqualOrParent } from '../../../../../base/common/resources.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; -import { ILabelService } from '../../../../../platform/label/common/label.js'; -import { IProductService } from '../../../../../platform/product/common/productService.js'; -import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; -import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; -import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; -import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; -import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; -import { IExternalCustomizationItem } from '../../common/customizationHarnessService.js'; -import { BUILTIN_STORAGE } from './aiCustomizationManagement.js'; -import { storageToIcon } from './aiCustomizationIcons.js'; -import { IAICustomizationListItem } from './aiCustomizationListItem.js'; -import { extractExtensionIdFromPath } from './aiCustomizationListWidgetUtils.js'; -import { isChatExtensionItem } from './aiCustomizationItemSourceUtils.js'; - -/** - * Converts provider-shaped customization rows into the rich list model used by the management UI. - */ -export class AICustomizationItemNormalizer { - constructor( - private readonly workspaceContextService: IWorkspaceContextService, - private readonly workspaceService: IAICustomizationWorkspaceService, - private readonly labelService: ILabelService, - private readonly agentPluginService: IAgentPluginService, - private readonly productService: IProductService, - ) { } - - normalizeItems(items: readonly IExternalCustomizationItem[], promptType: PromptsType): IAICustomizationListItem[] { - const uriUseCounts = new ResourceMap(); - return items - .filter(item => item.type === promptType) - .map(item => this.normalizeItem(item, promptType, uriUseCounts)) - .sort((a, b) => a.name.localeCompare(b.name)); - } - - normalizeItem(item: IExternalCustomizationItem, promptType: PromptsType, uriUseCounts = new ResourceMap()): IAICustomizationListItem { - const { storage, groupKey, isBuiltin, extensionLabel } = this.resolveSource(item); - const seenCount = uriUseCounts.get(item.uri) ?? 0; - uriUseCounts.set(item.uri, seenCount + 1); - const duplicateSuffix = seenCount === 0 ? '' : `#${seenCount}`; - const isWorkspaceItem = storage === PromptsStorage.local; - - return { - id: `${item.uri.toString()}${duplicateSuffix}`, - uri: item.uri, - name: item.name, - filename: item.uri.scheme === Schemas.file - ? this.labelService.getUriLabel(item.uri, { relative: isWorkspaceItem }) - : basename(item.uri), - description: item.description, - storage, - promptType, - disabled: item.enabled === false, - groupKey, - pluginUri: storage === PromptsStorage.plugin ? this.findPluginUri(item.uri) : undefined, - displayName: item.name, - badge: item.badge, - badgeTooltip: item.badgeTooltip, - typeIcon: promptType === PromptsType.instructions && storage ? storageToIcon(storage) : undefined, - isBuiltin, - extensionLabel, - status: item.status, - statusMessage: item.statusMessage, - }; - } - - private resolveSource(item: IExternalCustomizationItem): { storage?: PromptsStorage; groupKey?: string; isBuiltin?: boolean; extensionLabel?: string } { - const inferred = this.inferStorageAndGroup(item.uri); - if (!item.groupKey) { - return inferred; - } - - switch (item.groupKey) { - case PromptsStorage.local: - case PromptsStorage.user: - case PromptsStorage.extension: - case PromptsStorage.plugin: - return { storage: item.groupKey, extensionLabel: inferred.extensionLabel }; - case BUILTIN_STORAGE: - return { ...inferred, storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true }; - default: - return { ...inferred, groupKey: item.groupKey }; - } - } - - private inferStorageAndGroup(uri: URI): { storage?: PromptsStorage; groupKey?: string; isBuiltin?: boolean; extensionLabel?: string } { - if (uri.scheme !== Schemas.file) { - return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true }; - } - - const activeProjectRoot = this.workspaceService.getActiveProjectRoot(); - if (activeProjectRoot && isEqualOrParent(uri, activeProjectRoot)) { - return { storage: PromptsStorage.local }; - } - - for (const folder of this.workspaceContextService.getWorkspace().folders) { - if (isEqualOrParent(uri, folder.uri)) { - return { storage: PromptsStorage.local }; - } - } - - for (const plugin of this.agentPluginService.plugins.get()) { - if (isEqualOrParent(uri, plugin.uri)) { - return { storage: PromptsStorage.plugin }; - } - } - - const extensionId = extractExtensionIdFromPath(uri.path); - if (extensionId) { - const extensionIdentifier = new ExtensionIdentifier(extensionId); - if (isChatExtensionItem(extensionIdentifier, this.productService)) { - return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true }; - } - return { storage: PromptsStorage.extension, extensionLabel: extensionIdentifier.value }; - } - - return { storage: PromptsStorage.user }; - } - - private findPluginUri(itemUri: URI): URI | undefined { - for (const plugin of this.agentPluginService.plugins.get()) { - if (isEqualOrParent(itemUri, plugin.uri)) { - return plugin.uri; - } - } - return undefined; - } -} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts new file mode 100644 index 0000000000000..9035727f7d69e --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -0,0 +1,402 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Event } from '../../../../../base/common/event.js'; +import { IMatch } from '../../../../../base/common/filters.js'; +import { parse as parseJSONC } from '../../../../../base/common/json.js'; +import { ResourceMap } from '../../../../../base/common/map.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { OS } from '../../../../../base/common/platform.js'; +import { basename, isEqualOrParent } from '../../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IPathService } from '../../../../services/path/common/pathService.js'; +import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; +import { ICustomizationSyncProvider, ICustomizationItem, ICustomizationItemProvider } from '../../common/customizationHarnessService.js'; +import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; +import { parseHooksFromFile } from '../../common/promptSyntax/hookCompatibility.js'; +import { formatHookCommandLabel } from '../../common/promptSyntax/hookSchema.js'; +import { HOOK_METADATA } from '../../common/promptSyntax/hookTypes.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { storageToIcon } from './aiCustomizationIcons.js'; +import { BUILTIN_STORAGE } from './aiCustomizationManagement.js'; +import { extractExtensionIdFromPath } from './aiCustomizationListWidgetUtils.js'; + +// #region Interfaces + +/** + * Represents an AI customization item in the list widget. + */ +export interface IAICustomizationListItem { + readonly id: string; + readonly uri: URI; + readonly name: string; + readonly filename: string; + readonly description?: string; + /** 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. */ + readonly groupKey?: string; + /** URI of the parent plugin, when this item comes from an installed plugin. */ + readonly pluginUri?: URI; + /** When set, overrides the formatted name for display. */ + readonly displayName?: string; + /** When set, shows a small inline badge next to the item name. */ + readonly badge?: string; + /** Tooltip shown when hovering the badge. */ + readonly badgeTooltip?: string; + /** When set, overrides the default prompt-type icon. */ + readonly typeIcon?: ThemeIcon; + /** True when item comes from the default chat extension (grouped under Built-in). */ + readonly isBuiltin?: boolean; + /** Display name of the contributing extension (for non-built-in extension items). */ + readonly extensionLabel?: string; + /** Server-reported loading/sync status for remote customizations. */ + readonly status?: 'loading' | 'loaded' | 'degraded' | 'error'; + /** Human-readable status detail (e.g. error message or warning). */ + readonly statusMessage?: string; + /** When true, this item can be selected for syncing to a remote harness. */ + readonly syncable?: boolean; + /** When true, this syncable item is currently selected for syncing. */ + readonly synced?: boolean; + nameMatches?: IMatch[]; + descriptionMatches?: IMatch[]; +} + +/** + * Browser-internal item source consumed by the list widget. + * + * Item sources fetch provider-shaped customization rows, normalize them into + * the browser-only list item shape, and add view-only overlays such as sync state. + */ +export interface IAICustomizationItemSource { + readonly onDidChange: Event; + fetchItems(promptType: PromptsType): Promise; +} + +// #endregion + +// #region Utilities + +/** + * Returns true if the given extension identifier matches the default + * chat extension (e.g. GitHub Copilot Chat). Used to group items from + * the chat extension under "Built-in" instead of "Extensions". + */ +export function isChatExtensionItem(extensionId: ExtensionIdentifier, productService: IProductService): boolean { + const chatExtensionId = productService.defaultChatAgent?.chatExtensionId; + return !!chatExtensionId && ExtensionIdentifier.equals(extensionId, chatExtensionId); +} + +/** + * Derives a friendly name from a filename by removing extension suffixes. + */ +export function getFriendlyName(filename: string): string { + let name = filename + .replace(/\.instructions\.md$/i, '') + .replace(/\.prompt\.md$/i, '') + .replace(/\.agent\.md$/i, '') + .replace(/\.md$/i, ''); + + name = name + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, c => c.toUpperCase()); + + return name || filename; +} + +/** + * Expands hook file items into individual hook entries by parsing hook + * definitions from the file content. Falls back to the original item + * when parsing fails. + */ +export async function expandHookFileItems( + hookFileItems: readonly ICustomizationItem[], + workspaceService: IAICustomizationWorkspaceService, + fileService: IFileService, + pathService: IPathService, +): Promise { + const items: ICustomizationItem[] = []; + const activeRoot = workspaceService.getActiveProjectRoot(); + const userHomeUri = await pathService.userHome(); + const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; + + for (const item of hookFileItems) { + let parsedHooks = false; + try { + const content = await fileService.readFile(item.uri); + const json = parseJSONC(content.value.toString()); + const { hooks } = parseHooksFromFile(item.uri, json, activeRoot, userHome); + + if (hooks.size > 0) { + parsedHooks = true; + for (const [hookType, entry] of hooks) { + const hookMeta = HOOK_METADATA[hookType]; + for (let i = 0; i < entry.hooks.length; i++) { + const hook = entry.hooks[i]; + const cmdLabel = formatHookCommandLabel(hook, OS); + const truncatedCmd = cmdLabel.length > 60 ? cmdLabel.substring(0, 57) + '...' : cmdLabel; + items.push({ + uri: item.uri, + type: PromptsType.hook, + name: hookMeta?.label ?? entry.originalId, + description: truncatedCmd || localize('hookUnset', "(unset)"), + enabled: item.enabled, + groupKey: item.groupKey, + }); + } + } + } + } catch { + // Parse failed — fall through to show raw file. + } + + if (!parsedHooks) { + items.push(item); + } + } + + return items; +} + +// #endregion + +// #region Normalizer + +/** + * Converts provider-shaped customization rows into the rich list model used by the management UI. + */ +export class AICustomizationItemNormalizer { + constructor( + private readonly workspaceContextService: IWorkspaceContextService, + private readonly workspaceService: IAICustomizationWorkspaceService, + private readonly labelService: ILabelService, + private readonly agentPluginService: IAgentPluginService, + private readonly productService: IProductService, + ) { } + + normalizeItems(items: readonly ICustomizationItem[], promptType: PromptsType): IAICustomizationListItem[] { + const uriUseCounts = new ResourceMap(); + return items + .filter(item => item.type === promptType) + .map(item => this.normalizeItem(item, promptType, uriUseCounts)) + .sort((a, b) => a.name.localeCompare(b.name)); + } + + normalizeItem(item: ICustomizationItem, promptType: PromptsType, uriUseCounts = new ResourceMap()): IAICustomizationListItem { + const { storage, groupKey, isBuiltin, extensionLabel } = this.resolveSource(item); + const seenCount = uriUseCounts.get(item.uri) ?? 0; + uriUseCounts.set(item.uri, seenCount + 1); + const duplicateSuffix = seenCount === 0 ? '' : `#${seenCount}`; + const isWorkspaceItem = storage === PromptsStorage.local; + + return { + id: `${item.uri.toString()}${duplicateSuffix}`, + uri: item.uri, + name: item.name, + filename: item.uri.scheme === Schemas.file + ? this.labelService.getUriLabel(item.uri, { relative: isWorkspaceItem }) + : basename(item.uri), + description: item.description, + storage, + promptType, + disabled: item.enabled === false, + groupKey, + pluginUri: storage === PromptsStorage.plugin ? this.findPluginUri(item.uri) : undefined, + displayName: item.name, + badge: item.badge, + badgeTooltip: item.badgeTooltip, + typeIcon: promptType === PromptsType.instructions && storage ? storageToIcon(storage) : undefined, + isBuiltin, + extensionLabel, + status: item.status, + statusMessage: item.statusMessage, + }; + } + + private resolveSource(item: ICustomizationItem): { storage?: PromptsStorage; groupKey?: string; isBuiltin?: boolean; extensionLabel?: string } { + const inferred = this.inferStorageAndGroup(item.uri); + + // Use provider-supplied storage when available; otherwise fall back to URI inference. + const storage = item.storage ?? inferred.storage; + const extensionLabel = inferred.extensionLabel; + + if (!item.groupKey) { + return { ...inferred, storage }; + } + + switch (item.groupKey) { + case BUILTIN_STORAGE: + return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true, extensionLabel }; + default: + return { storage, groupKey: item.groupKey, extensionLabel }; + } + } + + private inferStorageAndGroup(uri: URI): { storage?: PromptsStorage; groupKey?: string; isBuiltin?: boolean; extensionLabel?: string } { + if (uri.scheme !== Schemas.file) { + return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true }; + } + + const activeProjectRoot = this.workspaceService.getActiveProjectRoot(); + if (activeProjectRoot && isEqualOrParent(uri, activeProjectRoot)) { + return { storage: PromptsStorage.local }; + } + + for (const folder of this.workspaceContextService.getWorkspace().folders) { + if (isEqualOrParent(uri, folder.uri)) { + return { storage: PromptsStorage.local }; + } + } + + for (const plugin of this.agentPluginService.plugins.get()) { + if (isEqualOrParent(uri, plugin.uri)) { + return { storage: PromptsStorage.plugin }; + } + } + + const extensionId = extractExtensionIdFromPath(uri.path); + if (extensionId) { + const extensionIdentifier = new ExtensionIdentifier(extensionId); + if (isChatExtensionItem(extensionIdentifier, this.productService)) { + return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true }; + } + return { storage: PromptsStorage.extension, extensionLabel: extensionIdentifier.value }; + } + + return { storage: PromptsStorage.user }; + } + + private findPluginUri(itemUri: URI): URI | undefined { + for (const plugin of this.agentPluginService.plugins.get()) { + if (isEqualOrParent(itemUri, plugin.uri)) { + return plugin.uri; + } + } + return undefined; + } +} + +// #endregion + +// #region Item Source + +/** + * Unified item source that fetches items from a provider (extension-contributed + * or the promptsService adapter), normalizes them into list items, and optionally + * blends in local syncable items when a sync provider is present. + */ +export class ProviderCustomizationItemSource implements IAICustomizationItemSource { + + readonly onDidChange: Event; + + constructor( + private readonly itemProvider: ICustomizationItemProvider | undefined, + private readonly syncProvider: ICustomizationSyncProvider | undefined, + private readonly promptsService: IPromptsService, + private readonly workspaceService: IAICustomizationWorkspaceService, + private readonly fileService: IFileService, + private readonly pathService: IPathService, + private readonly itemNormalizer: AICustomizationItemNormalizer, + ) { + const onDidChangeSyncableCustomizations = this.syncProvider + ? Event.any( + this.promptsService.onDidChangeCustomAgents, + this.promptsService.onDidChangeSlashCommands, + this.promptsService.onDidChangeSkills, + this.promptsService.onDidChangeHooks, + this.promptsService.onDidChangeInstructions, + ) + : Event.None; + + this.onDidChange = Event.any( + this.itemProvider?.onDidChange ?? Event.None, + this.syncProvider?.onDidChange ?? Event.None, + onDidChangeSyncableCustomizations, + ); + } + + async fetchItems(promptType: PromptsType): Promise { + const remoteItems = this.itemProvider + ? await this.fetchItemsFromProvider(this.itemProvider, promptType) + : []; + if (!this.syncProvider) { + return remoteItems; + } + const localItems = await this.fetchLocalSyncableItems(promptType, this.syncProvider); + return [...remoteItems, ...localItems]; + } + + private async fetchItemsFromProvider(provider: ICustomizationItemProvider, promptType: PromptsType): Promise { + const allItems = await provider.provideChatSessionCustomizations(CancellationToken.None); + if (!allItems) { + return []; + } + + let providerItems: readonly ICustomizationItem[] = promptType === PromptsType.hook + ? await expandHookFileItems( + allItems.filter(item => item.type === PromptsType.hook), + this.workspaceService, this.fileService, this.pathService, + ) + : allItems.filter(item => item.type === promptType); + + if (promptType === PromptsType.skill) { + providerItems = await this.addSkillDescriptionFallbacks(providerItems); + } + + return this.itemNormalizer.normalizeItems(providerItems, promptType); + } + + private async addSkillDescriptionFallbacks(items: readonly ICustomizationItem[]): Promise { + const descriptionsByUri = new Map(); + const skills = await this.promptsService.findAgentSkills(CancellationToken.None); + for (const skill of skills ?? []) { + if (skill.description) { + descriptionsByUri.set(skill.uri.toString(), skill.description); + } + } + + return items.map(item => item.description + ? item + : { ...item, description: descriptionsByUri.get(item.uri.toString()) }); + } + + private async fetchLocalSyncableItems(promptType: PromptsType, syncProvider: ICustomizationSyncProvider): Promise { + const files = await this.promptsService.listPromptFiles(promptType, CancellationToken.None); + if (!files.length) { + return []; + } + + const providerItems: ICustomizationItem[] = files + .filter(file => file.storage === PromptsStorage.local || file.storage === PromptsStorage.user) + .map(file => ({ + uri: file.uri, + type: promptType, + name: getFriendlyName(basename(file.uri)), + groupKey: 'sync-local', + enabled: true, + })); + + return this.itemNormalizer.normalizeItems(providerItems, promptType) + .map(item => ({ + ...item, + id: `sync-${item.id}`, + syncable: true, + synced: syncProvider.isSelected(item.uri), + })); + } +} + +// #endregion diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSourceUtils.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSourceUtils.ts deleted file mode 100644 index dcae39bc96dce..0000000000000 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSourceUtils.ts +++ /dev/null @@ -1,102 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { localize } from '../../../../../nls.js'; -import { parse as parseJSONC } from '../../../../../base/common/json.js'; -import { Schemas } from '../../../../../base/common/network.js'; -import { OS } from '../../../../../base/common/platform.js'; -import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; -import { IFileService } from '../../../../../platform/files/common/files.js'; -import { IProductService } from '../../../../../platform/product/common/productService.js'; -import { IPathService } from '../../../../services/path/common/pathService.js'; -import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; -import { HOOK_METADATA } from '../../common/promptSyntax/hookTypes.js'; -import { parseHooksFromFile } from '../../common/promptSyntax/hookCompatibility.js'; -import { formatHookCommandLabel } from '../../common/promptSyntax/hookSchema.js'; -import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; -import { IExternalCustomizationItem } from '../../common/customizationHarnessService.js'; - -/** - * Returns true if the given extension identifier matches the default - * chat extension (e.g. GitHub Copilot Chat). Used to group items from - * the chat extension under "Built-in" instead of "Extensions". - */ -export function isChatExtensionItem(extensionId: ExtensionIdentifier, productService: IProductService): boolean { - const chatExtensionId = productService.defaultChatAgent?.chatExtensionId; - return !!chatExtensionId && ExtensionIdentifier.equals(extensionId, chatExtensionId); -} - -/** - * Derives a friendly name from a filename by removing extension suffixes. - */ -export function getFriendlyName(filename: string): string { - // Remove common prompt file extensions like .instructions.md, .prompt.md, etc. - let name = filename - .replace(/\.instructions\.md$/i, '') - .replace(/\.prompt\.md$/i, '') - .replace(/\.agent\.md$/i, '') - .replace(/\.md$/i, ''); - - // Convert kebab-case or snake_case to Title Case - name = name - .replace(/[-_]/g, ' ') - .replace(/\b\w/g, c => c.toUpperCase()); - - return name || filename; -} - -/** - * Expands hook file items into individual hook entries by parsing hook - * definitions from the file content. Falls back to the original item - * when parsing fails. - */ -export async function expandHookFileItems( - hookFileItems: readonly IExternalCustomizationItem[], - workspaceService: IAICustomizationWorkspaceService, - fileService: IFileService, - pathService: IPathService, -): Promise { - const items: IExternalCustomizationItem[] = []; - const activeRoot = workspaceService.getActiveProjectRoot(); - const userHomeUri = await pathService.userHome(); - const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; - - for (const item of hookFileItems) { - let parsedHooks = false; - try { - const content = await fileService.readFile(item.uri); - const json = parseJSONC(content.value.toString()); - const { hooks } = parseHooksFromFile(item.uri, json, activeRoot, userHome); - - if (hooks.size > 0) { - parsedHooks = true; - for (const [hookType, entry] of hooks) { - const hookMeta = HOOK_METADATA[hookType]; - for (let i = 0; i < entry.hooks.length; i++) { - const hook = entry.hooks[i]; - const cmdLabel = formatHookCommandLabel(hook, OS); - const truncatedCmd = cmdLabel.length > 60 ? cmdLabel.substring(0, 57) + '...' : cmdLabel; - items.push({ - uri: item.uri, - type: PromptsType.hook, - name: hookMeta?.label ?? entry.originalId, - description: truncatedCmd || localize('hookUnset', "(unset)"), - enabled: item.enabled, - groupKey: item.groupKey, - }); - } - } - } - } catch { - // Parse failed — fall through to show raw file. - } - - if (!parsedHooks) { - items.push(item); - } - } - - return items; -} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListItem.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListItem.ts deleted file mode 100644 index b2d45f1fcc41e..0000000000000 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListItem.ts +++ /dev/null @@ -1,63 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Event } from '../../../../../base/common/event.js'; -import { IMatch } from '../../../../../base/common/filters.js'; -import { ThemeIcon } from '../../../../../base/common/themables.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; -import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; - -/** - * Represents an AI customization item in the list. - */ -export interface IAICustomizationListItem { - readonly id: string; - readonly uri: URI; - readonly name: string; - readonly filename: string; - readonly description?: string; - /** 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. */ - readonly groupKey?: string; - /** URI of the parent plugin, when this item comes from an installed plugin. */ - readonly pluginUri?: URI; - /** When set, overrides the formatted name for display. */ - readonly displayName?: string; - /** When set, shows a small inline badge next to the item name. */ - readonly badge?: string; - /** Tooltip shown when hovering the badge. */ - readonly badgeTooltip?: string; - /** When set, overrides the default prompt-type icon. */ - readonly typeIcon?: ThemeIcon; - /** True when item comes from the default chat extension (grouped under Built-in). */ - readonly isBuiltin?: boolean; - /** Display name of the contributing extension (for non-built-in extension items). */ - readonly extensionLabel?: string; - /** Server-reported loading/sync status for remote customizations. */ - readonly status?: 'loading' | 'loaded' | 'degraded' | 'error'; - /** Human-readable status detail (e.g. error message or warning). */ - readonly statusMessage?: string; - /** When true, this item can be selected for syncing to a remote harness. */ - readonly syncable?: boolean; - /** When true, this syncable item is currently selected for syncing. */ - readonly synced?: boolean; - nameMatches?: IMatch[]; - descriptionMatches?: IMatch[]; -} - -/** - * Browser-internal item source consumed by the list widget. - * - * Item sources fetch provider-shaped customization rows, normalize them into - * this browser-only list item shape, and add view-only overlays such as sync state. - */ -export interface IAICustomizationItemSource { - readonly onDidChange: Event; - fetchItems(promptType: PromptsType): Promise; -} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 333e53c72ad03..21a1063961ed1 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -50,9 +50,7 @@ import { ITelemetryService } from '../../../../../platform/telemetry/common/tele import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; -import { AICustomizationItemNormalizer } from './aiCustomizationItemNormalizer.js'; -import { IAICustomizationItemSource, IAICustomizationListItem } from './aiCustomizationListItem.js'; -import { ProviderCustomizationItemSource } from './providerCustomizationItemSource.js'; +import { AICustomizationItemNormalizer, IAICustomizationItemSource, IAICustomizationListItem, ProviderCustomizationItemSource } from './aiCustomizationItemSource.js'; import { PromptsServiceCustomizationItemProvider } from './promptsServiceCustomizationItemProvider.js'; export { truncateToFirstLine } from './aiCustomizationListWidgetUtils.js'; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts index a6aca698bb4bf..2ee50a453a1d6 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts @@ -18,19 +18,15 @@ import { HookType, HOOK_METADATA } from '../../common/promptSyntax/hookTypes.js' import { formatHookCommandLabel } from '../../common/promptSyntax/hookSchema.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; -import { IExternalCustomizationItem, IExternalCustomizationItemProvider, IHarnessDescriptor, matchesInstructionFileFilter, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js'; +import { ICustomizationItem, ICustomizationItemProvider, IHarnessDescriptor, matchesInstructionFileFilter, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js'; import { BUILTIN_STORAGE } from './aiCustomizationManagement.js'; -import { expandHookFileItems, getFriendlyName, isChatExtensionItem } from './aiCustomizationItemSourceUtils.js'; - -interface IPromptsServiceCustomizationItem extends IExternalCustomizationItem { - readonly storage?: PromptsStorage; -} +import { expandHookFileItems, getFriendlyName, isChatExtensionItem } from './aiCustomizationItemSource.js'; /** * Adapts the rich promptsService model to the same provider-shaped items * contributed by external customization providers. */ -export class PromptsServiceCustomizationItemProvider implements IExternalCustomizationItemProvider { +export class PromptsServiceCustomizationItemProvider implements ICustomizationItemProvider { readonly onDidChange: Event; @@ -51,7 +47,7 @@ export class PromptsServiceCustomizationItemProvider implements IExternalCustomi ); } - async provideChatSessionCustomizations(token: CancellationToken): Promise { + async provideChatSessionCustomizations(token: CancellationToken): Promise { const itemSets = await Promise.all([ this.provideCustomizations(PromptsType.agent, token), this.provideCustomizations(PromptsType.skill, token), @@ -62,8 +58,8 @@ export class PromptsServiceCustomizationItemProvider implements IExternalCustomi return itemSets.flat(); } - async provideCustomizations(promptType: PromptsType, token: CancellationToken = CancellationToken.None): Promise { - const items: IPromptsServiceCustomizationItem[] = []; + private async provideCustomizations(promptType: PromptsType, token: CancellationToken = CancellationToken.None): Promise { + const items: ICustomizationItem[] = []; const disabledUris = this.promptsService.getDisabledPromptFiles(promptType); const extensionInfoByUri = new ResourceMap<{ id: ExtensionIdentifier; displayName?: string }>(); @@ -157,15 +153,15 @@ export class PromptsServiceCustomizationItemProvider implements IExternalCustomi await this.fetchPromptServiceInstructions(items, extensionInfoByUri, disabledUris, promptType); } - return this.toProviderItems(this.applyLocalFilters(this.applyBuiltinGroupKeys(items, extensionInfoByUri), promptType)); + return this.applyLocalFilters(this.applyBuiltinGroupKeys(items, extensionInfoByUri), promptType); } - private async fetchPromptServiceHooks(items: IPromptsServiceCustomizationItem[], disabledUris: ResourceSet, promptType: PromptsType): Promise { + private async fetchPromptServiceHooks(items: ICustomizationItem[], disabledUris: ResourceSet, promptType: PromptsType): Promise { const hookFiles = await this.promptsService.listPromptFiles(PromptsType.hook, CancellationToken.None); // Convert hook files to provider-shaped items for shared expansion. // Plugin hooks are pre-expanded by plugin manifests and kept as-is. - const hookFileItems: IExternalCustomizationItem[] = hookFiles + const hookFileItems: ICustomizationItem[] = hookFiles .filter(f => f.storage !== PromptsStorage.plugin) .map(f => ({ uri: f.uri, @@ -225,7 +221,7 @@ export class PromptsServiceCustomizationItemProvider implements IExternalCustomi } } - private async fetchPromptServiceInstructions(items: IPromptsServiceCustomizationItem[], extensionInfoByUri: ResourceMap<{ id: ExtensionIdentifier; displayName?: string }>, disabledUris: ResourceSet, promptType: PromptsType): Promise { + private async fetchPromptServiceInstructions(items: ICustomizationItem[], extensionInfoByUri: ResourceMap<{ id: ExtensionIdentifier; displayName?: string }>, disabledUris: ResourceSet, promptType: PromptsType): Promise { const instructionFiles = await this.promptsService.getInstructionFiles(CancellationToken.None); for (const file of instructionFiles) { if (file.extension) { @@ -287,7 +283,7 @@ export class PromptsServiceCustomizationItemProvider implements IExternalCustomi } } - private applyBuiltinGroupKeys(items: IPromptsServiceCustomizationItem[], extensionInfoByUri: ResourceMap<{ id: ExtensionIdentifier; displayName?: string }>): IPromptsServiceCustomizationItem[] { + private applyBuiltinGroupKeys(items: ICustomizationItem[], extensionInfoByUri: ResourceMap<{ id: ExtensionIdentifier; displayName?: string }>): ICustomizationItem[] { return items.map(item => { if (item.storage !== PromptsStorage.extension) { return item; @@ -306,9 +302,9 @@ export class PromptsServiceCustomizationItemProvider implements IExternalCustomi }); } - private applyLocalFilters(groupedItems: IPromptsServiceCustomizationItem[], promptType: PromptsType): IPromptsServiceCustomizationItem[] { + private applyLocalFilters(groupedItems: ICustomizationItem[], promptType: PromptsType): ICustomizationItem[] { const filter = this.workspaceService.getStorageSourceFilter(promptType); - const withStorage = groupedItems.filter((item): item is IPromptsServiceCustomizationItem & { readonly storage: PromptsStorage } => item.storage !== undefined); + const withStorage = groupedItems.filter((item): item is ICustomizationItem & { readonly storage: PromptsStorage } => item.storage !== undefined); const withoutStorage = groupedItems.filter(item => item.storage === undefined); let items = [...applyStorageSourceFilter(withStorage, filter), ...withoutStorage]; @@ -344,11 +340,4 @@ export class PromptsServiceCustomizationItemProvider implements IExternalCustomi return items; } - private toProviderItems(items: readonly IPromptsServiceCustomizationItem[]): IExternalCustomizationItem[] { - return items.map(({ storage, ...item }) => ({ - ...item, - groupKey: item.groupKey ?? storage, - })); - } - } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/providerCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/providerCustomizationItemSource.ts deleted file mode 100644 index c3657987be27b..0000000000000 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/providerCustomizationItemSource.ts +++ /dev/null @@ -1,130 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { CancellationToken } from '../../../../../base/common/cancellation.js'; -import { Event } from '../../../../../base/common/event.js'; -import { basename } from '../../../../../base/common/resources.js'; -import { IFileService } from '../../../../../platform/files/common/files.js'; -import { IPathService } from '../../../../services/path/common/pathService.js'; -import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; -import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; -import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; -import { ICustomizationSyncProvider, IExternalCustomizationItem, IExternalCustomizationItemProvider } from '../../common/customizationHarnessService.js'; -import { AICustomizationItemNormalizer } from './aiCustomizationItemNormalizer.js'; -import { IAICustomizationItemSource, IAICustomizationListItem } from './aiCustomizationListItem.js'; -import { expandHookFileItems, getFriendlyName } from './aiCustomizationItemSourceUtils.js'; - -interface ITypeScopedCustomizationItemProvider extends IExternalCustomizationItemProvider { - provideCustomizations(promptType: PromptsType, token: CancellationToken): Promise; -} - -function isTypeScopedCustomizationItemProvider(provider: IExternalCustomizationItemProvider): provider is ITypeScopedCustomizationItemProvider { - return typeof (provider as Partial).provideCustomizations === 'function'; -} - -export class ProviderCustomizationItemSource implements IAICustomizationItemSource { - - readonly onDidChange: Event; - - constructor( - private readonly itemProvider: IExternalCustomizationItemProvider | undefined, - private readonly syncProvider: ICustomizationSyncProvider | undefined, - private readonly promptsService: IPromptsService, - private readonly workspaceService: IAICustomizationWorkspaceService, - private readonly fileService: IFileService, - private readonly pathService: IPathService, - private readonly itemNormalizer: AICustomizationItemNormalizer, - ) { - const onDidChangeSyncableCustomizations = this.syncProvider - ? Event.any( - this.promptsService.onDidChangeCustomAgents, - this.promptsService.onDidChangeSlashCommands, - this.promptsService.onDidChangeSkills, - this.promptsService.onDidChangeHooks, - this.promptsService.onDidChangeInstructions, - ) - : Event.None; - - this.onDidChange = Event.any( - this.itemProvider?.onDidChange ?? Event.None, - this.syncProvider?.onDidChange ?? Event.None, - onDidChangeSyncableCustomizations, - ); - } - - async fetchItems(promptType: PromptsType): Promise { - const remoteItems = this.itemProvider - ? await this.fetchItemsFromProvider(this.itemProvider, promptType) - : []; - if (!this.syncProvider) { - return remoteItems; - } - const localItems = await this.fetchLocalSyncableItems(promptType, this.syncProvider); - return [...remoteItems, ...localItems]; - } - - private async fetchItemsFromProvider(provider: IExternalCustomizationItemProvider, promptType: PromptsType): Promise { - let providerItems: readonly IExternalCustomizationItem[]; - if (isTypeScopedCustomizationItemProvider(provider)) { - providerItems = await provider.provideCustomizations(promptType, CancellationToken.None) ?? []; - } else { - const allItems = await provider.provideChatSessionCustomizations(CancellationToken.None); - if (!allItems) { - return []; - } - providerItems = promptType === PromptsType.hook - ? await expandHookFileItems( - allItems.filter(item => item.type === PromptsType.hook), - this.workspaceService, this.fileService, this.pathService, - ) - : allItems.filter(item => item.type === promptType); - } - - if (promptType === PromptsType.skill) { - providerItems = await this.addSkillDescriptionFallbacks(providerItems); - } - - return this.itemNormalizer.normalizeItems(providerItems, promptType); - } - - private async addSkillDescriptionFallbacks(items: readonly IExternalCustomizationItem[]): Promise { - const descriptionsByUri = new Map(); - const skills = await this.promptsService.findAgentSkills(CancellationToken.None); - for (const skill of skills ?? []) { - if (skill.description) { - descriptionsByUri.set(skill.uri.toString(), skill.description); - } - } - - return items.map(item => item.description - ? item - : { ...item, description: descriptionsByUri.get(item.uri.toString()) }); - } - - private async fetchLocalSyncableItems(promptType: PromptsType, syncProvider: ICustomizationSyncProvider): Promise { - const files = await this.promptsService.listPromptFiles(promptType, CancellationToken.None); - if (!files.length) { - return []; - } - - const providerItems: IExternalCustomizationItem[] = files - .filter(file => file.storage === PromptsStorage.local || file.storage === PromptsStorage.user) - .map(file => ({ - uri: file.uri, - type: promptType, - name: getFriendlyName(basename(file.uri)), - groupKey: 'sync-local', - enabled: true, - })); - - return this.itemNormalizer.normalizeItems(providerItems, promptType) - .map(item => ({ - ...item, - id: `sync-${item.id}`, - syncable: true, - synced: syncProvider.isSelected(item.uri), - })); - } -} diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index 7f1ae9aae444b..d4c407a3e846f 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -125,7 +125,7 @@ export interface IHarnessDescriptor { * that can supply customization items directly (bypassing promptsService * discovery and filtering). */ - readonly itemProvider?: IExternalCustomizationItemProvider; + readonly itemProvider?: ICustomizationItemProvider; /** * When set, this harness supports syncing local customizations to a * remote target. The UI shows local items with sync checkboxes when @@ -137,11 +137,13 @@ export interface IHarnessDescriptor { /** * Represents a customization item provided by an external extension. */ -export interface IExternalCustomizationItem { +export interface ICustomizationItem { readonly uri: URI; readonly type: string; readonly name: string; readonly description?: string; + /** Storage origin (local, user, extension, plugin). Set by providers that know the source. */ + readonly storage?: PromptsStorage; /** Server-reported loading status for this customization. */ readonly status?: 'loading' | 'loaded' | 'degraded' | 'error'; /** Human-readable status detail (e.g. error message or warning). */ @@ -160,7 +162,7 @@ export interface IExternalCustomizationItem { * Provider interface for extension-contributed harnesses that supply * customization items directly from their SDK. */ -export interface IExternalCustomizationItemProvider { +export interface ICustomizationItemProvider { /** * Event that fires when the provider's customizations change. */ @@ -168,7 +170,7 @@ export interface IExternalCustomizationItemProvider { /** * Provide the customization items this harness supports. */ - provideChatSessionCustomizations(token: CancellationToken): Promise; + provideChatSessionCustomizations(token: CancellationToken): Promise; } /** diff --git a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts index f9016d832647b..71a39402d31d6 100644 --- a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts @@ -8,7 +8,7 @@ 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 { CustomizationHarness, CustomizationHarnessServiceBase, createVSCodeHarnessDescriptor, ICustomizationItemProvider, 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'; @@ -147,7 +147,7 @@ suite('CustomizationHarnessService', () => { { uri: URI.parse('file:///workspace/.claude/SKILL.md'), type: 'skill', name: 'Test Skill', description: 'A test skill' }, ]; - const itemProvider: IExternalCustomizationItemProvider = { + const itemProvider: ICustomizationItemProvider = { onDidChange: emitter.event, provideChatSessionCustomizations: async () => testItems, };