Path: blob/main/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts
5255 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 { Schemas } from '../../../../../base/common/network.js';7import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js';8import { localize2 } from '../../../../../nls.js';9import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js';10import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js';11import { ICommandService } from '../../../../../platform/commands/common/commands.js';12import { IPromptsService, PromptsStorage, IPromptFileDiscoveryResult, PromptFileSkipReason, AgentFileType } from '../../common/promptSyntax/service/promptsService.js';13import { PromptsConfig } from '../../common/promptSyntax/config/config.js';14import { PromptsType } from '../../common/promptSyntax/promptTypes.js';15import { basename, dirname, relativePath } from '../../../../../base/common/resources.js';16import { IFileService } from '../../../../../platform/files/common/files.js';17import { URI } from '../../../../../base/common/uri.js';18import * as nls from '../../../../../nls.js';19import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';20import { COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, IResolvedPromptSourceFolder } from '../../common/promptSyntax/config/promptFileLocations.js';21import { IUntitledTextEditorService } from '../../../../services/untitled/common/untitledTextEditorService.js';22import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from './chatActions.js';23import { ChatViewId } from '../chat.js';24import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';25import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../platform/workspace/common/workspace.js';26import { IPathService } from '../../../../services/path/common/pathService.js';27import { parseAllHookFiles, IParsedHook } from '../promptSyntax/hookUtils.js';28import { ILabelService } from '../../../../../platform/label/common/label.js';29import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js';30import { OS } from '../../../../../base/common/platform.js';3132/**33* URL encodes path segments for use in markdown links.34* Encodes each segment individually to preserve path separators.35*/36function encodePathForMarkdown(path: string): string {37return path.split('/').map(segment => encodeURIComponent(segment)).join('/');38}3940/**41* Converts a URI to a relative path string for markdown links.42* Tries to make the path relative to a workspace folder if possible.43* The returned path is URL encoded for use in markdown link targets.44*/45function getRelativePath(uri: URI, workspaceFolders: readonly IWorkspaceFolder[]): string {46// On desktop, vscode-userdata scheme maps 1:1 to file scheme paths via FileUserDataProvider.47// Convert to file scheme so relativePath() can compute paths correctly.48// On web, vscode-userdata uses IndexedDB so this conversion has no effect (different schemes won't match workspace folders).49const normalizedUri = uri.scheme === Schemas.vscodeUserData ? uri.with({ scheme: Schemas.file }) : uri;5051for (const folder of workspaceFolders) {52const relative = relativePath(folder.uri, normalizedUri);53if (relative) {54return encodePathForMarkdown(relative);55}56}57// Fall back to fsPath if not under any workspace folder58// Use forward slashes for consistency in markdown links59return encodePathForMarkdown(normalizedUri.fsPath.replace(/\\/g, '/'));60}6162// Tree prefixes63// allow-any-unicode-next-line64const TREE_BRANCH = '├─';65// allow-any-unicode-next-line66const TREE_END = '└─';67// allow-any-unicode-next-line68const ICON_ERROR = '❌';69// allow-any-unicode-next-line70const ICON_WARN = '⚠️';71// allow-any-unicode-next-line72const ICON_MANUAL = '🔧';73// allow-any-unicode-next-line74const ICON_HIDDEN = '👁️🗨️';7576/**77* Information about a file that was loaded or skipped.78*/79export interface IFileStatusInfo {80uri: URI;81status: 'loaded' | 'skipped' | 'overwritten';82reason?: string;83name?: string;84storage: PromptsStorage;85/** For overwritten files, the name of the file that took precedence */86overwrittenBy?: string;87/** Extension ID if this file comes from an extension */88extensionId?: string;89/** If true, hidden from / menu (user-invokable: false) */90userInvokable?: boolean;91/** If true, won't be auto-loaded by agent (disable-model-invocation: true) */92disableModelInvocation?: boolean;93}9495/**96* Path information with scan order.97*/98export interface IPathInfo {99uri: URI;100exists: boolean;101storage: PromptsStorage;102/** 1-based scan order (lower = higher priority) */103scanOrder: number;104/** Original path string for display (e.g., '~/.copilot/agents' or '.github/agents') */105displayPath: string;106/** Whether this is a default folder (vs custom configured) */107isDefault: boolean;108}109110/**111* Status information for a specific type of prompt files.112*/113export interface ITypeStatusInfo {114type: PromptsType;115paths: IPathInfo[];116files: IFileStatusInfo[];117enabled: boolean;118/** For hooks only: parsed hooks grouped by lifecycle */119parsedHooks?: IParsedHook[];120}121122/**123* Registers the Diagnostics action for the chat context menu.124*/125export function registerChatCustomizationDiagnosticsAction() {126registerAction2(class DiagnosticsAction extends Action2 {127constructor() {128super({129id: 'workbench.action.chat.diagnostics',130title: localize2('chat.diagnostics.label', "Diagnostics"),131f1: false,132category: CHAT_CATEGORY,133menu: [{134id: MenuId.ChatContext,135group: 'z_clear',136order: -1137}, {138id: CHAT_CONFIG_MENU_ID,139when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId)),140order: 14,141group: '3_configure'142}, {143id: MenuId.ChatWelcomeContext,144group: '2_settings',145order: 0,146when: ChatContextKeys.inChatEditor.negate()147}]148});149}150151async run(accessor: ServicesAccessor): Promise<void> {152const promptsService = accessor.get(IPromptsService);153const configurationService = accessor.get(IConfigurationService);154const fileService = accessor.get(IFileService);155const untitledTextEditorService = accessor.get(IUntitledTextEditorService);156const commandService = accessor.get(ICommandService);157const workspaceContextService = accessor.get(IWorkspaceContextService);158const labelService = accessor.get(ILabelService);159const remoteAgentService = accessor.get(IRemoteAgentService);160161const token = CancellationToken.None;162const workspaceFolders = workspaceContextService.getWorkspace().folders;163const pathService = accessor.get(IPathService);164165// Collect status for each type166const statusInfos: ITypeStatusInfo[] = [];167168// 1. Custom Agents169const agentsStatus = await collectAgentsStatus(promptsService, fileService, token);170statusInfos.push(agentsStatus);171172// 2. Instructions173const instructionsStatus = await collectInstructionsStatus(promptsService, fileService, token);174statusInfos.push(instructionsStatus);175176// 3. Prompt Files177const promptsStatus = await collectPromptsStatus(promptsService, fileService, token);178statusInfos.push(promptsStatus);179180// 4. Skills181const skillsStatus = await collectSkillsStatus(promptsService, configurationService, fileService, token);182statusInfos.push(skillsStatus);183184// 5. Hooks185const hooksStatus = await collectHooksStatus(promptsService, fileService, labelService, pathService, workspaceContextService, remoteAgentService, token);186statusInfos.push(hooksStatus);187188// 6. Special files (AGENTS.md, copilot-instructions.md)189const specialFilesStatus = await collectSpecialFilesStatus(promptsService, configurationService, token);190191// Generate the markdown output192const output = formatStatusOutput(statusInfos, specialFilesStatus, workspaceFolders);193194// Create an untitled markdown document with the content195const untitledModel = untitledTextEditorService.create({196initialValue: output,197languageId: 'markdown'198});199200// Open the markdown file in edit mode201await commandService.executeCommand('vscode.open', untitledModel.resource);202}203});204}205206/**207* Collects status for custom agents.208*/209async function collectAgentsStatus(210promptsService: IPromptsService,211fileService: IFileService,212token: CancellationToken213): Promise<ITypeStatusInfo> {214const type = PromptsType.agent;215const enabled = true; // Agents are always enabled216217// Get resolved source folders using the shared path resolution logic218const resolvedFolders = await promptsService.getResolvedSourceFolders(type);219const paths = await convertResolvedFoldersToPathInfo(resolvedFolders, fileService);220221// Get discovery info from the service (handles all duplicate detection and error tracking)222const discoveryInfo = await promptsService.getPromptDiscoveryInfo(type, token);223const files = discoveryInfo.files.map(convertDiscoveryResultToFileStatus);224225return { type, paths, files, enabled };226}227228/**229* Collects status for instructions files.230*/231async function collectInstructionsStatus(232promptsService: IPromptsService,233fileService: IFileService,234token: CancellationToken235): Promise<ITypeStatusInfo> {236const type = PromptsType.instructions;237const enabled = true;238239// Get resolved source folders using the shared path resolution logic240const resolvedFolders = await promptsService.getResolvedSourceFolders(type);241const paths = await convertResolvedFoldersToPathInfo(resolvedFolders, fileService);242243// Get discovery info from the service244// Filter out copilot-instructions.md files as they are handled separately in the special files section245const discoveryInfo = await promptsService.getPromptDiscoveryInfo(type, token);246const files = discoveryInfo.files247.filter(f => basename(f.uri) !== COPILOT_CUSTOM_INSTRUCTIONS_FILENAME)248.map(convertDiscoveryResultToFileStatus);249250return { type, paths, files, enabled };251}252253/**254* Collects status for prompt files.255*/256async function collectPromptsStatus(257promptsService: IPromptsService,258fileService: IFileService,259token: CancellationToken260): Promise<ITypeStatusInfo> {261const type = PromptsType.prompt;262const enabled = true;263264// Get resolved source folders using the shared path resolution logic265const resolvedFolders = await promptsService.getResolvedSourceFolders(type);266const paths = await convertResolvedFoldersToPathInfo(resolvedFolders, fileService);267268// Get discovery info from the service269const discoveryInfo = await promptsService.getPromptDiscoveryInfo(type, token);270const files = discoveryInfo.files.map(convertDiscoveryResultToFileStatus);271272return { type, paths, files, enabled };273}274275/**276* Collects status for skill files.277*/278async function collectSkillsStatus(279promptsService: IPromptsService,280configurationService: IConfigurationService,281fileService: IFileService,282token: CancellationToken283): Promise<ITypeStatusInfo> {284const type = PromptsType.skill;285const enabled = configurationService.getValue<boolean>(PromptsConfig.USE_AGENT_SKILLS) ?? false;286287// Get resolved source folders using the shared path resolution logic288const resolvedFolders = await promptsService.getResolvedSourceFolders(type);289const paths = await convertResolvedFoldersToPathInfo(resolvedFolders, fileService);290291// Get discovery info from the service (handles all duplicate detection and error tracking)292const discoveryInfo = await promptsService.getPromptDiscoveryInfo(type, token);293const files = discoveryInfo.files.map(convertDiscoveryResultToFileStatus);294295return { type, paths, files, enabled };296}297298export interface ISpecialFilesStatus {299agentsMd: { enabled: boolean; files: URI[] };300copilotInstructions: { enabled: boolean; files: URI[] };301claudeMd: { enabled: boolean; files: URI[] };302}303304/**305* Collects status for hook files.306*/307async function collectHooksStatus(308promptsService: IPromptsService,309fileService: IFileService,310labelService: ILabelService,311pathService: IPathService,312workspaceContextService: IWorkspaceContextService,313remoteAgentService: IRemoteAgentService,314token: CancellationToken315): Promise<ITypeStatusInfo> {316const type = PromptsType.hook;317const enabled = true; // Hooks are always enabled318319// Get resolved source folders using the shared path resolution logic320const resolvedFolders = await promptsService.getResolvedSourceFolders(type);321const paths = await convertResolvedFoldersToPathInfo(resolvedFolders, fileService);322323// Get discovery info from the service (handles all duplicate detection and error tracking)324const discoveryInfo = await promptsService.getPromptDiscoveryInfo(type, token);325const files = discoveryInfo.files.map(convertDiscoveryResultToFileStatus);326327// Parse hook files to extract individual hooks grouped by lifecycle328const parsedHooks = await parseHookFiles(promptsService, fileService, labelService, pathService, workspaceContextService, remoteAgentService, token);329330return { type, paths, files, enabled, parsedHooks };331}332333/**334* Parses all hook files and extracts individual hooks.335*/336async function parseHookFiles(337promptsService: IPromptsService,338fileService: IFileService,339labelService: ILabelService,340pathService: IPathService,341workspaceContextService: IWorkspaceContextService,342remoteAgentService: IRemoteAgentService,343token: CancellationToken344): Promise<IParsedHook[]> {345// Get workspace root and user home for path resolution346const workspaceFolder = workspaceContextService.getWorkspace().folders[0];347const workspaceRootUri = workspaceFolder?.uri;348const userHomeUri = await pathService.userHome();349const userHome = userHomeUri.fsPath ?? userHomeUri.path;350351// Get the remote OS (or fall back to local OS)352const remoteEnv = await remoteAgentService.getEnvironment();353const targetOS = remoteEnv?.os ?? OS;354355// Use the shared helper356return parseAllHookFiles(promptsService, fileService, labelService, workspaceRootUri, userHome, targetOS, token);357}358359/**360* Collects status for special files like AGENTS.md and copilot-instructions.md.361*/362async function collectSpecialFilesStatus(363promptsService: IPromptsService,364configurationService: IConfigurationService,365token: CancellationToken366): Promise<ISpecialFilesStatus> {367const useAgentMd = configurationService.getValue<boolean>(PromptsConfig.USE_AGENT_MD) ?? false;368const useClaudeMd = configurationService.getValue<boolean>(PromptsConfig.USE_CLAUDE_MD) ?? false;369const useCopilotInstructions = configurationService.getValue<boolean>(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES) ?? false;370371const allFiles = await promptsService.listAgentInstructions(token);372373return {374agentsMd: {375enabled: useAgentMd,376files: allFiles.filter(f => f.type === AgentFileType.agentsMd).map(f => f.uri)377},378claudeMd: {379enabled: useClaudeMd,380files: allFiles.filter(f => f.type === AgentFileType.claudeMd).map(f => f.uri)381},382copilotInstructions: {383enabled: useCopilotInstructions,384files: allFiles.filter(f => f.type === AgentFileType.copilotInstructionsMd).map(f => f.uri)385}386};387}388389/**390* Checks if a directory exists.391*/392async function checkDirectoryExists(fileService: IFileService, uri: URI): Promise<boolean> {393try {394const stat = await fileService.stat(uri);395return stat.isDirectory;396} catch {397return false;398}399}400401/**402* Converts resolved source folders to path info with existence checks.403* This uses the shared path resolution logic from the prompts service.404*/405async function convertResolvedFoldersToPathInfo(406resolvedFolders: readonly IResolvedPromptSourceFolder[],407fileService: IFileService408): Promise<IPathInfo[]> {409const paths: IPathInfo[] = [];410let scanOrder = 1;411412for (const folder of resolvedFolders) {413const exists = await checkDirectoryExists(fileService, folder.uri);414paths.push({415uri: folder.uri,416exists,417storage: folder.storage,418scanOrder: scanOrder++,419displayPath: folder.displayPath ?? folder.uri.path,420isDefault: folder.isDefault ?? false421});422}423424return paths;425}426427/**428* Converts skip reason enum to user-friendly message.429*/430function getSkipReasonMessage(skipReason: PromptFileSkipReason | undefined, errorMessage: string | undefined): string {431switch (skipReason) {432case 'missing-name':433return nls.localize('status.missingName', 'Missing name attribute');434case 'missing-description':435return nls.localize('status.skillMissingDescription', 'Missing description attribute');436case 'name-mismatch':437return errorMessage ?? nls.localize('status.skillNameMismatch2', 'Name does not match folder');438case 'duplicate-name':439return nls.localize('status.overwrittenByHigherPriority', 'Overwritten by higher priority file');440case 'parse-error':441return errorMessage ?? nls.localize('status.parseError', 'Parse error');442case 'disabled':443return nls.localize('status.typeDisabled', 'Disabled');444default:445return errorMessage ?? nls.localize('status.unknownError', 'Unknown error');446}447}448449/**450* Converts IPromptFileDiscoveryResult to IFileStatusInfo for display.451*/452function convertDiscoveryResultToFileStatus(result: IPromptFileDiscoveryResult): IFileStatusInfo {453if (result.status === 'loaded') {454return {455uri: result.uri,456status: 'loaded',457name: result.name,458storage: result.storage,459extensionId: result.extensionId,460userInvokable: result.userInvokable,461disableModelInvocation: result.disableModelInvocation462};463}464465// Handle skipped files466if (result.skipReason === 'duplicate-name' && result.duplicateOf) {467// This is an overwritten file468return {469uri: result.uri,470status: 'overwritten',471name: result.name,472storage: result.storage,473overwrittenBy: result.name,474extensionId: result.extensionId475};476}477478// Regular skip479return {480uri: result.uri,481status: 'skipped',482name: result.name,483reason: getSkipReasonMessage(result.skipReason, result.errorMessage),484storage: result.storage,485extensionId: result.extensionId486};487}488489/**490* Formats the status output as a compact markdown string with tree structure.491* Files are grouped under their parent paths.492* Special files (AGENTS.md, copilot-instructions.md) are merged into their respective sections.493*/494export function formatStatusOutput(495statusInfos: ITypeStatusInfo[],496specialFiles: ISpecialFilesStatus,497workspaceFolders: readonly IWorkspaceFolder[]498): string {499const lines: string[] = [];500501lines.push(`## ${nls.localize('status.title', 'Chat Customization Diagnostics')}`);502lines.push(`*${nls.localize('status.sensitiveWarning', 'WARNING: This file may contain sensitive information.')}*`);503lines.push('');504505for (const info of statusInfos) {506const typeName = getTypeName(info.type);507508// Special handling for disabled skills509if (info.type === PromptsType.skill && !info.enabled) {510lines.push(`**${typeName}**`);511lines.push(`*${nls.localize('status.skillsDisabled', 'Skills are disabled. Enable them by setting `chat.useAgentSkills` to `true` in your settings.')}*`);512lines.push('');513continue;514}515516const enabledStatus = info.enabled517? ''518: ` *(${nls.localize('status.disabled', 'disabled')})*`;519520// Count loaded and skipped files (overwritten counts as skipped)521let loadedCount = info.files.filter(f => f.status === 'loaded').length;522const skippedCount = info.files.filter(f => f.status === 'skipped' || f.status === 'overwritten').length;523// Include special files in the loaded count for instructions524if (info.type === PromptsType.instructions) {525if (specialFiles.agentsMd.enabled) {526loadedCount += specialFiles.agentsMd.files.length;527}528if (specialFiles.copilotInstructions.enabled) {529loadedCount += specialFiles.copilotInstructions.files.length;530}531if (specialFiles.claudeMd.enabled) {532loadedCount += specialFiles.claudeMd.files.length;533}534}535536lines.push(`**${typeName}**${enabledStatus}<br>`);537538// Show stats line - use "skills" for skills type, "hooks" for hooks type, "files" for others539const statsParts: string[] = [];540if (info.type === PromptsType.hook) {541// For hooks, show both file count and individual hook count542if (loadedCount > 0) {543statsParts.push(loadedCount === 1544? nls.localize('status.fileLoaded', '1 file loaded')545: nls.localize('status.filesLoaded', '{0} files loaded', loadedCount));546}547if (info.parsedHooks && info.parsedHooks.length > 0) {548const hookCount = info.parsedHooks.length;549statsParts.push(hookCount === 1550? nls.localize('status.hookLoaded', '1 hook loaded')551: nls.localize('status.hooksLoaded', '{0} hooks loaded', hookCount));552}553} else if (loadedCount > 0) {554if (info.type === PromptsType.skill) {555statsParts.push(loadedCount === 1556? nls.localize('status.skillLoaded', '1 skill loaded')557: nls.localize('status.skillsLoaded', '{0} skills loaded', loadedCount));558} else {559statsParts.push(loadedCount === 1560? nls.localize('status.fileLoaded', '1 file loaded')561: nls.localize('status.filesLoaded', '{0} files loaded', loadedCount));562}563}564if (skippedCount > 0) {565statsParts.push(nls.localize('status.skippedCount', '{0} skipped', skippedCount));566}567if (statsParts.length > 0) {568lines.push(`*${statsParts.join(', ')}*`);569}570lines.push('');571572const allPaths = info.paths;573const allFiles = info.files;574575// Group files by their parent path576const filesByPath = new Map<string, IFileStatusInfo[]>();577const unmatchedFiles: IFileStatusInfo[] = [];578579for (const file of allFiles) {580let matched = false;581for (const path of allPaths) {582if (isFileUnderPath(file.uri, path.uri)) {583const key = path.uri.toString();584if (!filesByPath.has(key)) {585filesByPath.set(key, []);586}587filesByPath.get(key)!.push(file);588matched = true;589break;590}591}592if (!matched) {593unmatchedFiles.push(file);594}595}596597// Render each path with its files as a tree598// Skip for hooks since we show files with their hooks below599let hasContent = false;600if (info.type !== PromptsType.hook) {601for (const path of allPaths) {602const pathFiles = filesByPath.get(path.uri.toString()) || [];603604if (path.exists) {605lines.push(`${path.displayPath}<br>`);606} else if (path.isDefault) {607// Default folders that don't exist - no error icon608lines.push(`${path.displayPath}<br>`);609} else {610// Custom folders that don't exist - show error611lines.push(`${ICON_ERROR} ${path.displayPath} - *${nls.localize('status.folderNotFound', 'Folder does not exist')}*<br>`);612}613614if (path.exists && pathFiles.length > 0) {615for (let i = 0; i < pathFiles.length; i++) {616const file = pathFiles[i];617// Show the file ID: skill name for skills, basename for others618let fileName: string;619if (info.type === PromptsType.skill) {620fileName = file.name || `${basename(dirname(file.uri))}`;621} else {622fileName = basename(file.uri);623}624const isLast = i === pathFiles.length - 1;625const prefix = isLast ? TREE_END : TREE_BRANCH;626const filePath = getRelativePath(file.uri, workspaceFolders);627if (file.status === 'loaded') {628const flags = getSkillFlags(file, info.type);629lines.push(`${prefix} [\`${fileName}\`](${filePath})${flags}<br>`);630} else if (file.status === 'overwritten') {631lines.push(`${prefix} ${ICON_WARN} [\`${fileName}\`](${filePath}) - *${nls.localize('status.overwrittenByHigherPriority', 'Overwritten by higher priority file')}*<br>`);632} else {633lines.push(`${prefix} ${ICON_ERROR} [\`${fileName}\`](${filePath}) - *${file.reason}*<br>`);634}635}636}637hasContent = true;638}639}640641// Render unmatched files (e.g., from extensions) - group by extension ID642// Skip for hooks since we show files with their hooks below643if (info.type !== PromptsType.hook && unmatchedFiles.length > 0) {644// Group files by extension ID645const filesByExtension = new Map<string, IFileStatusInfo[]>();646for (const file of unmatchedFiles) {647const extId = file.extensionId || 'unknown';648if (!filesByExtension.has(extId)) {649filesByExtension.set(extId, []);650}651filesByExtension.get(extId)!.push(file);652}653654// Render each extension group655for (const [extId, extFiles] of filesByExtension) {656lines.push(`${nls.localize('status.extension', 'Extension')}: ${extId}<br>`);657for (let i = 0; i < extFiles.length; i++) {658const file = extFiles[i];659// Show the file ID: skill name for skills, basename for others660let fileName: string;661if (info.type === PromptsType.skill) {662fileName = file.name || `${basename(dirname(file.uri))}`;663} else {664fileName = basename(file.uri);665}666const isLast = i === extFiles.length - 1;667const prefix = isLast ? TREE_END : TREE_BRANCH;668const filePath = getRelativePath(file.uri, workspaceFolders);669if (file.status === 'loaded') {670const flags = getSkillFlags(file, info.type);671lines.push(`${prefix} [\`${fileName}\`](${filePath})${flags}<br>`);672} else if (file.status === 'overwritten') {673lines.push(`${prefix} ${ICON_WARN} [\`${fileName}\`](${filePath}) - *${nls.localize('status.overwrittenByHigherPriority', 'Overwritten by higher priority file')}*<br>`);674} else {675lines.push(`${prefix} ${ICON_ERROR} [\`${fileName}\`](${filePath}) - *${file.reason}*<br>`);676}677}678}679hasContent = true;680}681682// Add special files for instructions (AGENTS.md and copilot-instructions.md)683if (info.type === PromptsType.instructions) {684// AGENTS.md685if (specialFiles.agentsMd.enabled && specialFiles.agentsMd.files.length > 0) {686lines.push(`AGENTS.md<br>`);687for (let i = 0; i < specialFiles.agentsMd.files.length; i++) {688const file = specialFiles.agentsMd.files[i];689const fileName = basename(file);690const isLast = i === specialFiles.agentsMd.files.length - 1;691const prefix = isLast ? TREE_END : TREE_BRANCH;692const filePath = getRelativePath(file, workspaceFolders);693lines.push(`${prefix} [\`${fileName}\`](${filePath})<br>`);694}695hasContent = true;696} else if (!specialFiles.agentsMd.enabled) {697lines.push(`AGENTS.md -<br>`);698hasContent = true;699}700701// copilot-instructions.md702if (specialFiles.copilotInstructions.enabled && specialFiles.copilotInstructions.files.length > 0) {703lines.push(`${COPILOT_CUSTOM_INSTRUCTIONS_FILENAME}<br>`);704for (let i = 0; i < specialFiles.copilotInstructions.files.length; i++) {705const file = specialFiles.copilotInstructions.files[i];706const fileName = basename(file);707const isLast = i === specialFiles.copilotInstructions.files.length - 1;708const prefix = isLast ? TREE_END : TREE_BRANCH;709const filePath = getRelativePath(file, workspaceFolders);710lines.push(`${prefix} [\`${fileName}\`](${filePath})<br>`);711}712hasContent = true;713} else if (!specialFiles.copilotInstructions.enabled) {714lines.push(`${COPILOT_CUSTOM_INSTRUCTIONS_FILENAME} -<br>`);715hasContent = true;716}717}718719// Special handling for hooks - display grouped by file, then by lifecycle720if (info.type === PromptsType.hook && info.parsedHooks && info.parsedHooks.length > 0) {721// Group hooks first by file, then by lifecycle within each file722const hooksByFile = new Map<string, IParsedHook[]>();723for (const hook of info.parsedHooks) {724const fileKey = hook.fileUri.toString();725const existing = hooksByFile.get(fileKey) ?? [];726existing.push(hook);727hooksByFile.set(fileKey, existing);728}729730// Display hooks grouped by file731const fileUris = Array.from(hooksByFile.keys());732for (let fileIdx = 0; fileIdx < fileUris.length; fileIdx++) {733const fileKey = fileUris[fileIdx];734const fileHooks = hooksByFile.get(fileKey)!;735const firstHook = fileHooks[0];736const filePath = getRelativePath(firstHook.fileUri, workspaceFolders);737738// File as clickable link739lines.push(`[${firstHook.filePath}](${filePath})<br>`);740741// Flatten hooks with their lifecycle label742for (let i = 0; i < fileHooks.length; i++) {743const hook = fileHooks[i];744const isLast = i === fileHooks.length - 1;745const prefix = isLast ? TREE_END : TREE_BRANCH;746lines.push(`${prefix} ${hook.hookTypeLabel}: \`${hook.commandLabel}\`<br>`);747}748}749hasContent = true;750}751752if (!hasContent && info.enabled) {753lines.push(`*${nls.localize('status.noFilesLoaded', 'No files loaded')}*`);754}755lines.push('');756}757758return lines.join('\n');759}760761/**762* Gets flag annotations for skills based on their visibility settings.763* Returns an empty string for non-skill types or skills with default settings.764*/765function getSkillFlags(file: IFileStatusInfo, type: PromptsType): string {766if (type !== PromptsType.skill) {767return '';768}769770const flags: string[] = [];771772// disableModelInvocation: true means agent won't auto-load, only manual /name trigger773if (file.disableModelInvocation) {774flags.push(`${ICON_MANUAL} *${nls.localize('status.skill.manualOnly', 'manual only')}*`);775}776777// userInvokable: false means hidden from / menu778if (file.userInvokable === false) {779flags.push(`${ICON_HIDDEN} *${nls.localize('status.skill.hiddenFromMenu', 'hidden from menu')}*`);780}781782if (flags.length === 0) {783return '';784}785786return ` - ${flags.join(', ')}`;787}788789/**790* Checks if a file URI is under a given path URI.791*/792function isFileUnderPath(fileUri: URI, pathUri: URI): boolean {793const filePath = fileUri.toString();794const folderPath = pathUri.toString();795return filePath.startsWith(folderPath + '/') || filePath.startsWith(folderPath + '\\');796}797798/**799* Gets a human-readable name for a prompt type.800*/801function getTypeName(type: PromptsType): string {802switch (type) {803case PromptsType.agent:804return nls.localize('status.type.agents', 'Custom Agents');805case PromptsType.instructions:806return nls.localize('status.type.instructions', 'Instructions');807case PromptsType.prompt:808return nls.localize('status.type.prompts', 'Prompt Files');809case PromptsType.skill:810return nls.localize('status.type.skills', 'Skills');811case PromptsType.hook:812return nls.localize('status.type.hooks', 'Hooks');813default:814return type;815}816}817818819