Path: blob/main/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts
13406 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { CancellationToken } from '../../../../../base/common/cancellation.js';6import { Event } from '../../../../../base/common/event.js';7import { IMatch } from '../../../../../base/common/filters.js';8import { parse as parseJSONC } from '../../../../../base/common/json.js';9import { ResourceMap } from '../../../../../base/common/map.js';10import { Schemas } from '../../../../../base/common/network.js';11import { OS } from '../../../../../base/common/platform.js';12import { basename, dirname, isEqualOrParent } from '../../../../../base/common/resources.js';13import { ThemeIcon } from '../../../../../base/common/themables.js';14import { URI } from '../../../../../base/common/uri.js';15import { localize } from '../../../../../nls.js';16import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js';17import { IFileService } from '../../../../../platform/files/common/files.js';18import { ILabelService } from '../../../../../platform/label/common/label.js';19import { IProductService } from '../../../../../platform/product/common/productService.js';20import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js';21import { IPathService } from '../../../../services/path/common/pathService.js';22import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js';23import { ICustomizationSyncProvider, ICustomizationItem, ICustomizationItemProvider } from '../../common/customizationHarnessService.js';24import { IAgentPluginService } from '../../common/plugins/agentPluginService.js';25import { parseHooksFromFile } from '../../common/promptSyntax/hookCompatibility.js';26import { formatHookCommandLabel } from '../../common/promptSyntax/hookSchema.js';27import { HOOK_METADATA } from '../../common/promptSyntax/hookTypes.js';28import { PromptsType } from '../../common/promptSyntax/promptTypes.js';29import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js';30import { storageToIcon } from './aiCustomizationIcons.js';31import { BUILTIN_STORAGE } from './aiCustomizationManagement.js';32import { extractExtensionIdFromPath } from './aiCustomizationListWidgetUtils.js';3334// #region Interfaces3536/**37* Represents an AI customization item in the list widget.38*/39export interface IAICustomizationListItem {40readonly id: string;41readonly uri: URI;42readonly name: string;43readonly filename: string;44readonly description?: string;45/** Storage origin. Set by core when items come from promptsService; omitted for external provider items. */46readonly storage?: PromptsStorage;47readonly promptType: PromptsType;48readonly disabled: boolean;49/** When set, overrides `storage` for display grouping purposes. */50readonly groupKey?: string;51/** URI of the parent plugin, when this item comes from an installed plugin. */52readonly pluginUri?: URI;53/** When set, overrides the formatted name for display. */54readonly displayName?: string;55/** When set, shows a small inline badge next to the item name. */56readonly badge?: string;57/** Tooltip shown when hovering the badge. */58readonly badgeTooltip?: string;59/** When set, overrides the default prompt-type icon. */60readonly typeIcon?: ThemeIcon;61/** True when item comes from the default chat extension (grouped under Built-in). */62readonly isBuiltin?: boolean;63/** Display name of the contributing extension (for non-built-in extension items). */64readonly extensionId?: string;65/** Server-reported loading/sync status for remote customizations. */66readonly status?: 'loading' | 'loaded' | 'degraded' | 'error';67/** Human-readable status detail (e.g. error message or warning). */68readonly statusMessage?: string;69/** When true, this item can be selected for syncing to a remote harness. */70readonly syncable?: boolean;71/** When true, this syncable item is currently selected for syncing. */72readonly synced?: boolean;73nameMatches?: IMatch[];74descriptionMatches?: IMatch[];75}7677/**78* Browser-internal item source consumed by the list widget.79*80* Item sources fetch provider-shaped customization rows, normalize them into81* the browser-only list item shape, and add view-only overlays such as sync state.82*/83export interface IAICustomizationItemSource {84readonly onDidChange: Event<void>;85fetchItems(promptType: PromptsType): Promise<IAICustomizationListItem[]>;86}8788// #endregion8990// #region Utilities9192/**93* Returns true if the given extension identifier matches the default94* chat extension (e.g. GitHub Copilot Chat). Used to group items from95* the chat extension under "Built-in" instead of "Extensions".96*/97export function isChatExtensionItem(extensionId: ExtensionIdentifier, productService: IProductService): boolean {98const chatExtensionId = productService.defaultChatAgent?.chatExtensionId;99return !!chatExtensionId && ExtensionIdentifier.equals(extensionId, chatExtensionId);100}101102/**103* Derives a friendly name from a filename by removing extension suffixes.104*/105export function getFriendlyName(filename: string): string {106let name = filename107.replace(/\.instructions\.md$/i, '')108.replace(/\.prompt\.md$/i, '')109.replace(/\.agent\.md$/i, '')110.replace(/\.md$/i, '');111112name = name113.replace(/[-_]/g, ' ')114.replace(/\b\w/g, c => c.toUpperCase());115116return name || filename;117}118119/**120* Expands hook file items into individual hook entries by parsing hook121* definitions from the file content. Falls back to the original item122* when parsing fails.123*/124export async function expandHookFileItems(125hookFileItems: readonly ICustomizationItem[],126workspaceService: IAICustomizationWorkspaceService,127fileService: IFileService,128pathService: IPathService,129): Promise<ICustomizationItem[]> {130const items: ICustomizationItem[] = [];131const activeRoot = workspaceService.getActiveProjectRoot();132const userHomeUri = await pathService.userHome();133const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path;134135for (const item of hookFileItems) {136let parsedHooks = false;137try {138const content = await fileService.readFile(item.uri);139const json = parseJSONC(content.value.toString());140const { hooks } = parseHooksFromFile(item.uri, json, activeRoot, userHome);141142if (hooks.size > 0) {143parsedHooks = true;144for (const [hookType, entry] of hooks) {145const hookMeta = HOOK_METADATA[hookType];146for (let i = 0; i < entry.hooks.length; i++) {147const hook = entry.hooks[i];148const cmdLabel = formatHookCommandLabel(hook, OS);149const truncatedCmd = cmdLabel.length > 60 ? cmdLabel.substring(0, 57) + '...' : cmdLabel;150items.push({151uri: item.uri,152type: PromptsType.hook,153name: hookMeta?.label ?? entry.originalId,154description: truncatedCmd || localize('hookUnset', "(unset)"),155enabled: item.enabled,156groupKey: item.groupKey,157storage: item.storage,158extensionId: item.extensionId,159pluginUri: item.pluginUri,160userInvocable: item.userInvocable,161});162}163}164}165} catch {166// Parse failed — fall through to show raw file.167}168169if (!parsedHooks) {170items.push(item);171}172}173174return items;175}176177// #endregion178179// #region Normalizer180181/**182* Converts provider-shaped customization rows into the rich list model used by the management UI.183*/184export class AICustomizationItemNormalizer {185constructor(186private readonly workspaceContextService: IWorkspaceContextService,187private readonly workspaceService: IAICustomizationWorkspaceService,188private readonly labelService: ILabelService,189private readonly agentPluginService: IAgentPluginService,190private readonly productService: IProductService,191) { }192193normalizeItems(items: readonly ICustomizationItem[], promptType: PromptsType): IAICustomizationListItem[] {194const uriUseCounts = new ResourceMap<number>();195return items196.filter(item => item.type === promptType)197.map(item => this.normalizeItem(item, promptType, uriUseCounts))198.sort((a, b) => a.name.localeCompare(b.name));199}200201normalizeItem(item: ICustomizationItem, promptType: PromptsType, uriUseCounts = new ResourceMap<number>()): IAICustomizationListItem {202const { storage, groupKey, isBuiltin, extensionId, pluginUri } = this.inferStorageAndGroup(item);203const seenCount = uriUseCounts.get(item.uri) ?? 0;204uriUseCounts.set(item.uri, seenCount + 1);205const duplicateSuffix = seenCount === 0 ? '' : `#${seenCount}`;206const isWorkspaceItem = storage === PromptsStorage.local;207208return {209id: `${item.uri.toString()}${duplicateSuffix}`,210uri: item.uri,211name: item.name,212filename: item.uri.scheme === Schemas.file213? this.labelService.getUriLabel(item.uri, { relative: isWorkspaceItem })214: basename(item.uri),215description: item.description,216storage,217promptType,218disabled: item.enabled === false,219groupKey,220pluginUri,221displayName: item.name,222badge: item.badge,223badgeTooltip: item.badgeTooltip,224typeIcon: promptType === PromptsType.instructions && storage ? storageToIcon(storage) : undefined,225isBuiltin,226extensionId,227status: item.status,228statusMessage: item.statusMessage,229};230}231232private inferStorageAndGroup(item: ICustomizationItem): { storage: PromptsStorage; groupKey?: string; isBuiltin?: boolean; extensionId?: string; pluginUri?: URI } {233const groupKey = item.groupKey;234const isBuiltin = groupKey === BUILTIN_STORAGE;235236if (item.extensionId) {237const extensionIdentifier = new ExtensionIdentifier(item.extensionId);238if (isChatExtensionItem(extensionIdentifier, this.productService)) {239return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true, extensionId: item.extensionId };240}241return { storage: PromptsStorage.extension, extensionId: item.extensionId, groupKey, isBuiltin };242}243if (item.pluginUri) {244return { storage: PromptsStorage.plugin, pluginUri: item.pluginUri, groupKey, isBuiltin };245}246247const uri = item.uri;248249const activeProjectRoot = this.workspaceService.getActiveProjectRoot();250if (activeProjectRoot && isEqualOrParent(uri, activeProjectRoot)) {251return { storage: PromptsStorage.local, groupKey, isBuiltin };252}253254for (const folder of this.workspaceContextService.getWorkspace().folders) {255if (isEqualOrParent(uri, folder.uri)) {256return { storage: PromptsStorage.local, groupKey, isBuiltin };257}258}259260for (const plugin of this.agentPluginService.plugins.get()) {261if (isEqualOrParent(uri, plugin.uri)) {262return { storage: PromptsStorage.plugin, pluginUri: plugin.uri, groupKey, isBuiltin };263}264}265266const extensionId = extractExtensionIdFromPath(uri.path);267if (extensionId) {268const extensionIdentifier = new ExtensionIdentifier(extensionId);269if (isChatExtensionItem(extensionIdentifier, this.productService)) {270return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true, extensionId };271}272return { storage: PromptsStorage.extension, extensionId, groupKey, isBuiltin };273}274return { storage: PromptsStorage.user };275}276277}278279// #endregion280281// #region Item Source282283/**284* Unified item source that fetches items from a provider (extension-contributed285* or the promptsService adapter), normalizes them into list items, and optionally286* blends in local syncable items when a sync provider is present.287*/288export class ProviderCustomizationItemSource implements IAICustomizationItemSource {289290readonly onDidChange: Event<void>;291292constructor(293private readonly itemProvider: ICustomizationItemProvider | undefined,294private readonly syncProvider: ICustomizationSyncProvider | undefined,295private readonly promptsService: IPromptsService,296private readonly workspaceService: IAICustomizationWorkspaceService,297private readonly fileService: IFileService,298private readonly pathService: IPathService,299private readonly itemNormalizer: AICustomizationItemNormalizer,300) {301const onDidChangeSyncableCustomizations = this.syncProvider302? Event.any(303this.promptsService.onDidChangeCustomAgents,304this.promptsService.onDidChangeSlashCommands,305this.promptsService.onDidChangeSkills,306this.promptsService.onDidChangeHooks,307this.promptsService.onDidChangeInstructions,308)309: Event.None;310311this.onDidChange = Event.any(312this.itemProvider?.onDidChange ?? Event.None,313this.syncProvider?.onDidChange ?? Event.None,314onDidChangeSyncableCustomizations,315);316}317318async fetchItems(promptType: PromptsType): Promise<IAICustomizationListItem[]> {319const remoteItems = this.itemProvider320? await this.fetchItemsFromProvider(this.itemProvider, promptType)321: [];322if (!this.syncProvider) {323return remoteItems;324}325const localItems = await this.fetchLocalSyncableItems(promptType, this.syncProvider);326return [...remoteItems, ...localItems];327}328329private async fetchItemsFromProvider(provider: ICustomizationItemProvider, promptType: PromptsType): Promise<IAICustomizationListItem[]> {330const allItems = await provider.provideChatSessionCustomizations(CancellationToken.None);331if (!allItems) {332return [];333}334335let providerItems: readonly ICustomizationItem[];336if (promptType === PromptsType.hook) {337const hookItems = allItems.filter(item => item.type === PromptsType.hook);338// Plugin hooks are pre-expanded by plugin manifests — skip re-expansion.339const toExpand = hookItems.filter(item => item.storage !== PromptsStorage.plugin);340const preExpanded = hookItems.filter(item => item.storage === PromptsStorage.plugin);341const expanded = await expandHookFileItems(342toExpand, this.workspaceService, this.fileService, this.pathService,343);344providerItems = [...expanded, ...preExpanded];345} else {346providerItems = allItems.filter(item => item.type === promptType);347}348349if (promptType === PromptsType.skill) {350providerItems = await this.addSkillDescriptionFallbacks(providerItems);351}352353const normalized = this.itemNormalizer.normalizeItems(providerItems, promptType);354if (promptType === PromptsType.skill) {355return this.mergeBuiltinSkills(normalized, promptType);356}357return normalized;358}359360/**361* Merges built-in skills (bundled with the app under `vs/sessions/skills/`)362* into the provider's items. The provider may re-discover the bundled363* copies when scanning disk — those duplicates are dropped (deduped by364* URI) and replaced with the authoritative built-in entry tagged365* `groupKey: BUILTIN_STORAGE` so the UI renders them in the "Built-in"366* group. User-authored overrides (different URI, same name) are preserved.367*368* A workbench that uses the base `PromptsService` will throw on369* `BUILTIN_STORAGE` — we catch and return the items unchanged in that case.370*/371private async mergeBuiltinSkills(items: readonly IAICustomizationListItem[], promptType: PromptsType): Promise<IAICustomizationListItem[]> {372let builtinPaths: readonly { uri: URI; name?: string; description?: string }[] = [];373try {374builtinPaths = await this.promptsService.listPromptFilesForStorage(PromptsType.skill, BUILTIN_STORAGE as unknown as PromptsStorage, CancellationToken.None);375} catch {376return [...items];377}378if (builtinPaths.length === 0) {379return [...items];380}381382const builtinUris = new ResourceMap<typeof builtinPaths[number]>();383for (const p of builtinPaths) {384builtinUris.set(p.uri, p);385}386387// Drop provider items that are the same URI as a built-in (the provider388// re-discovered the bundled copy by scanning disk).389const deduped = items.filter(item => !builtinUris.has(item.uri));390391const uiIntegrations = this.workspaceService.getSkillUIIntegrations();392const uiIntegrationBadge = localize('uiIntegrationBadge', "UI Integration");393394// Collect names of user/workspace skills so we can hide the built-in395// copy once the user has added an override at either level.396const overriddenNames = new Set<string>();397for (const item of deduped) {398if (item.storage === PromptsStorage.local || item.storage === PromptsStorage.user) {399if (item.name) {400overriddenNames.add(item.name);401}402}403}404405// Append authoritative built-in entries (excluding any that have been406// overridden by a workspace or user copy with the same name).407const uriUseCounts = new ResourceMap<number>();408for (const item of deduped) {409uriUseCounts.set(item.uri, (uriUseCounts.get(item.uri) ?? 0) + 1);410}411const appended: IAICustomizationListItem[] = [];412const disabledPromptFiles = this.promptsService.getDisabledPromptFiles(PromptsType.skill);413for (const p of builtinPaths) {414const name = p.name ?? basename(p.uri);415if (overriddenNames.has(name)) {416continue;417}418const folderName = basename(dirname(p.uri));419const uiTooltip = uiIntegrations.get(folderName);420const builtinItem: ICustomizationItem = {421uri: p.uri,422type: PromptsType.skill,423name,424description: p.description,425storage: BUILTIN_STORAGE as unknown as PromptsStorage,426groupKey: BUILTIN_STORAGE,427enabled: !disabledPromptFiles.has(p.uri),428badge: uiTooltip ? uiIntegrationBadge : undefined,429badgeTooltip: uiTooltip,430extensionId: undefined,431pluginUri: undefined,432userInvocable: true,433};434appended.push(this.itemNormalizer.normalizeItem(builtinItem, promptType, uriUseCounts));435}436437return [...deduped, ...appended];438}439440private async addSkillDescriptionFallbacks(items: readonly ICustomizationItem[]): Promise<readonly ICustomizationItem[]> {441const descriptionsByUri = new Map<string, string>();442const skills = await this.promptsService.findAgentSkills(CancellationToken.None);443for (const skill of skills ?? []) {444if (skill.description) {445descriptionsByUri.set(skill.uri.toString(), skill.description);446}447}448449return items.map(item => item.description450? item451: { ...item, description: descriptionsByUri.get(item.uri.toString()) });452}453454private async fetchLocalSyncableItems(promptType: PromptsType, syncProvider: ICustomizationSyncProvider): Promise<IAICustomizationListItem[]> {455const files = await this.promptsService.listPromptFiles(promptType, CancellationToken.None);456if (!files.length) {457return [];458}459460const providerItems: ICustomizationItem[] = files461.filter(file => file.storage === PromptsStorage.local || file.storage === PromptsStorage.user)462.map(file => ({463uri: file.uri,464type: promptType,465name: getFriendlyName(basename(file.uri)),466groupKey: 'sync-local',467enabled: true,468extensionId: file.extension?.id,469pluginUri: file.pluginUri,470userInvocable: undefined471}));472473return this.itemNormalizer.normalizeItems(providerItems, promptType)474.map(item => ({475...item,476id: `sync-${item.id}`,477syncable: true,478synced: !syncProvider.isDisabled(item.uri),479}));480}481}482483// #endregion484485486