diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index bf46c8c1f66..b4a19a06821 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -28,7 +28,7 @@ src/vs/workbench/contrib/chat/browser/aiCustomization/ └── aiCustomizationManagement.css src/vs/workbench/contrib/chat/common/ -├── aiCustomizationWorkspaceService.ts # IAICustomizationWorkspaceService + IStorageSourceFilter +├── aiCustomizationWorkspaceService.ts # IAICustomizationWorkspaceService + IStorageSourceFilter + BUILTIN_STORAGE └── customizationHarnessService.ts # ICustomizationHarnessService + ISectionOverride + helpers ``` @@ -76,7 +76,7 @@ Storage answers "where did this come from?"; harness answers "who consumes it?". The service is defined in `common/customizationHarnessService.ts` which also provides: - **`CustomizationHarnessServiceBase`** — reusable base class handling active-harness state, the observable list, and `getStorageSourceFilter` dispatch. - **`ISectionOverride`** — per-section UI customization: `commandId` (command invocation), `rootFile` + `label` (root-file creation), `typeLabel` (custom type name), `fileExtension` (override default), `rootFileShortcuts` (dropdown shortcuts). -- **Factory functions** — `createVSCodeHarnessDescriptor`, `createCliHarnessDescriptor`, `createClaudeHarnessDescriptor`. The VS Code harness receives `[PromptsStorage.extension]` as extras; CLI and Claude in core receive `[]` (no extension source). Sessions CLI receives `[BUILTIN_STORAGE]`. +- **Factory functions** — `createVSCodeHarnessDescriptor`, `createCliHarnessDescriptor`, `createClaudeHarnessDescriptor`. The VS Code harness receives `[PromptsStorage.extension, BUILTIN_STORAGE]` as extras; CLI and Claude in core receive `[]` (no extension source). Sessions CLI receives `[BUILTIN_STORAGE]`. - **Well-known root helpers** — `getCliUserRoots(userHome)` and `getClaudeUserRoots(userHome)` centralize the `~/.copilot`, `~/.claude`, `~/.agents` path knowledge. - **Filter helpers** — `matchesWorkspaceSubpath()` for segment-safe subpath matching; `matchesInstructionFileFilter()` for filename/path-prefix pattern matching. @@ -128,7 +128,7 @@ The shared `applyStorageSourceFilter()` helper applies this filter to any `{uri, **Core VS Code filter behavior:** -Local harness: all types use `[local, user, extension, plugin]` with no user root filter. +Local harness: all types use `[local, user, extension, plugin, builtin]` with no user root filter. Items from the default chat extension (`productService.defaultChatAgent.chatExtensionId`) are grouped under "Built-in" via `groupKey` override in the list widget. CLI harness (core): @@ -152,6 +152,21 @@ Claude additionally applies: - `workspaceSubpaths: ['.claude']` (instruction files matching `instructionFileFilter` are exempt) - `sectionOverrides`: Hooks → `copilot.claude.hooks` command; Instructions → "Add CLAUDE.md" primary, "Rule" type label, `.md` file extension +### Built-in Extension Grouping (Core VS Code) + +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. + +`BUILTIN_STORAGE` is defined in `aiCustomizationWorkspaceService.ts` (common layer) and re-exported by both `aiCustomizationManagement.ts` (browser) and `builtinPromptsStorage.ts` (sessions) for backward compatibility. + ### AgenticPromptsService (Sessions) Sessions overrides `PromptsService` via `AgenticPromptsService` (in `promptsService.ts`): diff --git a/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts b/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts index e7a79bda11c..a4eb5afd410 100644 --- a/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts +++ b/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts @@ -4,19 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../../base/common/uri.js'; -import { PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { AICustomizationPromptsStorage } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; -/** - * Extended storage type for AI Customization that includes built-in prompts - * shipped with the application, alongside the core `PromptsStorage` values. - */ -export type AICustomizationPromptsStorage = PromptsStorage | 'builtin'; - -/** - * Storage type discriminator for built-in prompts shipped with the application. - */ -export const BUILTIN_STORAGE: AICustomizationPromptsStorage = 'builtin'; +// Re-export from common for backward compatibility +export type { AICustomizationPromptsStorage } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +export { BUILTIN_STORAGE } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; /** * Prompt path for built-in prompts bundled with the Sessions app. diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index f0ddbf2cb49..2c8ac552b61 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -56,6 +56,8 @@ import { ICustomizationHarnessService, matchesWorkspaceSubpath, matchesInstructi import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { getCleanPromptName, isInClaudeRulesFolder } from '../../common/promptSyntax/config/promptFileLocations.js'; import { evaluateApplyToPattern } from '../../common/promptSyntax/computeAutomaticInstructions.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; export { truncateToFirstSentence } from './aiCustomizationListWidgetUtils.js'; @@ -514,6 +516,7 @@ export class AICustomizationListWidget extends Disposable { @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, @IAgentPluginService private readonly agentPluginService: IAgentPluginService, @ICommandService private readonly commandService: ICommandService, + @IProductService private readonly productService: IProductService, ) { super(); this.element = $('.ai-customization-list-widget'); @@ -1020,6 +1023,61 @@ 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); + } + + /** + * Resolves the display group key for an extension-storage item. + * Items from the default chat extension are re-grouped under "Built-in"; + * all other extension items keep their original storage as group key. + * + * Returns `undefined` when no override is needed (the item will fall back + * to its `storage` value for grouping). + * + * This is the single point where extension → group mapping is decided, + * making it easy to add dynamic filter layers in the future. + */ + private resolveExtensionGroupKey(extensionId: ExtensionIdentifier | undefined): string | undefined { + if (extensionId && this.isChatExtensionItem(extensionId)) { + return BUILTIN_STORAGE; + } + return undefined; + } + + /** + * 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[], extensionIdByUri: ReadonlyMap): void { + for (const item of items) { + if (item.groupKey !== undefined) { + continue; // respect explicit groupKey from upstream (e.g. instruction categories) + } + if (item.storage !== PromptsStorage.extension) { + continue; + } + const extId = extensionIdByUri.get(item.uri.toString()); + const override = this.resolveExtensionGroupKey(extId); + if (override) { + // IAICustomizationListItem.groupKey is readonly for consumers but + // we own the items array here, so the mutation is safe. + (item as { groupKey?: string }).groupKey = override; + } + } + } + /** * Fetches and filters items for a given section. * Shared between `loadItems` (active section) and `computeItemCountForSection` (any section). @@ -1028,6 +1086,7 @@ export class AICustomizationListWidget extends Disposable { const promptType = sectionToPromptType(section); const items: IAICustomizationListItem[] = []; const disabledUris = this.promptsService.getDisabledPromptFiles(promptType); + const extensionIdByUri = new Map(); if (promptType === PromptsType.agent) { @@ -1046,10 +1105,21 @@ export class AICustomizationListWidget extends Disposable { pluginUri: agent.source.storage === PromptsStorage.plugin ? agent.source.pluginUri : undefined, disabled: disabledUris.has(agent.uri), }); + // Track extension ID for built-in grouping + if (agent.source.storage === PromptsStorage.extension) { + extensionIdByUri.set(agent.uri.toString(), 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) { + extensionIdByUri.set(file.uri.toString(), file.extension.identifier); + } + } const seenUris = new ResourceSet(); for (const skill of skills || []) { const filename = basename(skill.uri); @@ -1069,7 +1139,6 @@ export class AICustomizationListWidget extends Disposable { } // Also include disabled skills from the raw file list if (disabledUris.size > 0) { - const allSkillFiles = await this.promptsService.listPromptFiles(PromptsType.skill, CancellationToken.None); for (const file of allSkillFiles) { if (!seenUris.has(file.uri) && disabledUris.has(file.uri)) { const filename = basename(file.uri); @@ -1106,6 +1175,9 @@ export class AICustomizationListWidget extends Disposable { pluginUri: command.promptPath.storage === PromptsStorage.plugin ? command.promptPath.pluginUri : undefined, disabled: disabledUris.has(command.promptPath.uri), }); + if (command.promptPath.extension) { + extensionIdByUri.set(command.promptPath.uri.toString(), command.promptPath.extension.identifier); + } } } else if (promptType === PromptsType.hook) { // Try to parse individual hooks from each file; fall back to showing the file itself @@ -1212,6 +1284,11 @@ export class AICustomizationListWidget extends Disposable { } else { // For instructions, group by category: agent instructions, context instructions, on-demand instructions const promptFiles = await this.promptsService.listPromptFiles(promptType, CancellationToken.None); + for (const file of promptFiles) { + if (file.extension) { + extensionIdByUri.set(file.uri.toString(), file.extension.identifier); + } + } const agentInstructionFiles = await this.promptsService.listAgentInstructions(CancellationToken.None, undefined); const agentInstructionUris = new ResourceSet(agentInstructionFiles.map(f => f.uri)); @@ -1296,6 +1373,12 @@ export class AICustomizationListWidget extends Disposable { } } + // 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. + this.applyBuiltinGroupKeys(items, extensionIdByUri); + // Apply storage source filter (removes items not in visible sources or excluded user roots) const filter = this.workspaceService.getStorageSourceFilter(promptType); const filteredItems = applyStorageSourceFilter(items, filter); @@ -1406,8 +1489,8 @@ export class AICustomizationListWidget extends Disposable { : [ { 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.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, description: localize('extensionGroupDescription', "Read-only customizations provided by installed extensions."), 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 => visibleSources.has(g.groupKey as PromptsStorage) || g.groupKey === 'agents'); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts index d997ccb513b..d6439837a8e 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts @@ -5,23 +5,13 @@ import { RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; import { AICustomizationManagementSection } from '../../common/aiCustomizationWorkspaceService.js'; -import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { localize } from '../../../../../nls.js'; import { MenuId } from '../../../../../platform/actions/common/actions.js'; // Re-export for convenience — consumers import from this file export { AICustomizationManagementSection } from '../../common/aiCustomizationWorkspaceService.js'; - -/** - * Extended storage type for AI Customization that includes built-in prompts - * shipped with the application, alongside the core `PromptsStorage` values. - */ -export type AICustomizationPromptsStorage = PromptsStorage | 'builtin'; - -/** - * Storage type discriminator for built-in prompts shipped with the application. - */ -export const BUILTIN_STORAGE: AICustomizationPromptsStorage = 'builtin'; +export type { AICustomizationPromptsStorage } from '../../common/aiCustomizationWorkspaceService.js'; +export { BUILTIN_STORAGE } from '../../common/aiCustomizationWorkspaceService.js'; /** * Editor pane ID for the AI Customizations Management Editor. diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts index f87b510db74..3f03253741d 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts @@ -17,6 +17,7 @@ import { getClaudeUserRoots, } from '../../common/customizationHarnessService.js'; import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { BUILTIN_STORAGE } from '../../common/aiCustomizationWorkspaceService.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; import { IChatAgentService } from '../../common/participants/chatAgents.js'; @@ -31,9 +32,10 @@ class CustomizationHarnessService extends CustomizationHarnessServiceBase { @IChatAgentService chatAgentService: IChatAgentService, ) { const userHome = pathService.userHome({ preferLocal: true }); - // Only the Local harness includes extension-contributed customizations. + // The Local harness includes extension-contributed and built-in customizations. + // Built-in items come from the default chat extension (productService.defaultChatAgent). // CLI and Claude harnesses don't consume extension contributions. - const localExtras = [PromptsStorage.extension]; + const localExtras = [PromptsStorage.extension, BUILTIN_STORAGE]; const restrictedExtras: readonly string[] = []; const allHarnesses: readonly IHarnessDescriptor[] = [ createVSCodeHarnessDescriptor(localExtras), diff --git a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts index 2b6c01066fe..9b2aadb806a 100644 --- a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts +++ b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts @@ -13,6 +13,17 @@ import { IChatPromptSlashCommand, PromptsStorage } from './promptSyntax/service/ export const IAICustomizationWorkspaceService = createDecorator('aiCustomizationWorkspaceService'); +/** + * Extended storage type for AI Customization that includes built-in prompts + * shipped with the application, alongside the core `PromptsStorage` values. + */ +export type AICustomizationPromptsStorage = PromptsStorage | 'builtin'; + +/** + * Storage type discriminator for built-in customizations shipped with the application. + */ +export const BUILTIN_STORAGE: AICustomizationPromptsStorage = 'builtin'; + /** * Possible section IDs for the AI Customization Management Editor sidebar. */ diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/applyStorageSourceFilter.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/applyStorageSourceFilter.test.ts index 95988b49868..55f7cb956f7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/applyStorageSourceFilter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/applyStorageSourceFilter.test.ts @@ -7,9 +7,9 @@ import assert from 'assert'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; -import { applyStorageSourceFilter, IStorageSourceFilter } from '../../../common/aiCustomizationWorkspaceService.js'; +import { applyStorageSourceFilter, BUILTIN_STORAGE, IStorageSourceFilter } from '../../../common/aiCustomizationWorkspaceService.js'; -function item(path: string, storage: PromptsStorage): { uri: URI; storage: PromptsStorage } { +function item(path: string, storage: PromptsStorage | string): { uri: URI; storage: string } { return { uri: URI.file(path), storage }; } @@ -218,6 +218,36 @@ suite('applyStorageSourceFilter', () => { }; assert.strictEqual(applyStorageSourceFilter(items, filter).length, 4); }); + + test('core-like filter with builtin: extension items pass when both extension and builtin are in sources', () => { + // Items from the chat extension have storage=extension but groupKey=builtin. + // The filter operates on storage, so extension items pass through regardless of groupKey. + const items = [ + item('/w/a.md', PromptsStorage.local), + item('/e/builtin-agent.md', PromptsStorage.extension), + item('/e/third-party.md', PromptsStorage.extension), + item('/b/sessions-builtin.md', BUILTIN_STORAGE), + ]; + const filter: IStorageSourceFilter = { + sources: [PromptsStorage.local, PromptsStorage.extension, BUILTIN_STORAGE], + }; + const result = applyStorageSourceFilter(items, filter); + assert.strictEqual(result.length, 4); + }); + + test('builtin source is respected independently', () => { + const items = [ + item('/e/from-extension.md', PromptsStorage.extension), + item('/b/from-sessions.md', BUILTIN_STORAGE), + ]; + // Only builtin in sources — extension items excluded + const filter: IStorageSourceFilter = { + sources: [BUILTIN_STORAGE], + }; + const result = applyStorageSourceFilter(items, filter); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].storage, BUILTIN_STORAGE); + }); }); suite('type safety', () => {