Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts
5255 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { CancellationToken } from '../../../../../base/common/cancellation.js';
7
import { Schemas } from '../../../../../base/common/network.js';
8
import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js';
9
import { localize2 } from '../../../../../nls.js';
10
import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js';
11
import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js';
12
import { ICommandService } from '../../../../../platform/commands/common/commands.js';
13
import { IPromptsService, PromptsStorage, IPromptFileDiscoveryResult, PromptFileSkipReason, AgentFileType } from '../../common/promptSyntax/service/promptsService.js';
14
import { PromptsConfig } from '../../common/promptSyntax/config/config.js';
15
import { PromptsType } from '../../common/promptSyntax/promptTypes.js';
16
import { basename, dirname, relativePath } from '../../../../../base/common/resources.js';
17
import { IFileService } from '../../../../../platform/files/common/files.js';
18
import { URI } from '../../../../../base/common/uri.js';
19
import * as nls from '../../../../../nls.js';
20
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
21
import { COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, IResolvedPromptSourceFolder } from '../../common/promptSyntax/config/promptFileLocations.js';
22
import { IUntitledTextEditorService } from '../../../../services/untitled/common/untitledTextEditorService.js';
23
import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from './chatActions.js';
24
import { ChatViewId } from '../chat.js';
25
import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';
26
import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../platform/workspace/common/workspace.js';
27
import { IPathService } from '../../../../services/path/common/pathService.js';
28
import { parseAllHookFiles, IParsedHook } from '../promptSyntax/hookUtils.js';
29
import { ILabelService } from '../../../../../platform/label/common/label.js';
30
import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js';
31
import { OS } from '../../../../../base/common/platform.js';
32
33
/**
34
* URL encodes path segments for use in markdown links.
35
* Encodes each segment individually to preserve path separators.
36
*/
37
function encodePathForMarkdown(path: string): string {
38
return path.split('/').map(segment => encodeURIComponent(segment)).join('/');
39
}
40
41
/**
42
* Converts a URI to a relative path string for markdown links.
43
* Tries to make the path relative to a workspace folder if possible.
44
* The returned path is URL encoded for use in markdown link targets.
45
*/
46
function getRelativePath(uri: URI, workspaceFolders: readonly IWorkspaceFolder[]): string {
47
// On desktop, vscode-userdata scheme maps 1:1 to file scheme paths via FileUserDataProvider.
48
// Convert to file scheme so relativePath() can compute paths correctly.
49
// On web, vscode-userdata uses IndexedDB so this conversion has no effect (different schemes won't match workspace folders).
50
const normalizedUri = uri.scheme === Schemas.vscodeUserData ? uri.with({ scheme: Schemas.file }) : uri;
51
52
for (const folder of workspaceFolders) {
53
const relative = relativePath(folder.uri, normalizedUri);
54
if (relative) {
55
return encodePathForMarkdown(relative);
56
}
57
}
58
// Fall back to fsPath if not under any workspace folder
59
// Use forward slashes for consistency in markdown links
60
return encodePathForMarkdown(normalizedUri.fsPath.replace(/\\/g, '/'));
61
}
62
63
// Tree prefixes
64
// allow-any-unicode-next-line
65
const TREE_BRANCH = '├─';
66
// allow-any-unicode-next-line
67
const TREE_END = '└─';
68
// allow-any-unicode-next-line
69
const ICON_ERROR = '❌';
70
// allow-any-unicode-next-line
71
const ICON_WARN = '⚠️';
72
// allow-any-unicode-next-line
73
const ICON_MANUAL = '🔧';
74
// allow-any-unicode-next-line
75
const ICON_HIDDEN = '👁️‍🗨️';
76
77
/**
78
* Information about a file that was loaded or skipped.
79
*/
80
export interface IFileStatusInfo {
81
uri: URI;
82
status: 'loaded' | 'skipped' | 'overwritten';
83
reason?: string;
84
name?: string;
85
storage: PromptsStorage;
86
/** For overwritten files, the name of the file that took precedence */
87
overwrittenBy?: string;
88
/** Extension ID if this file comes from an extension */
89
extensionId?: string;
90
/** If true, hidden from / menu (user-invokable: false) */
91
userInvokable?: boolean;
92
/** If true, won't be auto-loaded by agent (disable-model-invocation: true) */
93
disableModelInvocation?: boolean;
94
}
95
96
/**
97
* Path information with scan order.
98
*/
99
export interface IPathInfo {
100
uri: URI;
101
exists: boolean;
102
storage: PromptsStorage;
103
/** 1-based scan order (lower = higher priority) */
104
scanOrder: number;
105
/** Original path string for display (e.g., '~/.copilot/agents' or '.github/agents') */
106
displayPath: string;
107
/** Whether this is a default folder (vs custom configured) */
108
isDefault: boolean;
109
}
110
111
/**
112
* Status information for a specific type of prompt files.
113
*/
114
export interface ITypeStatusInfo {
115
type: PromptsType;
116
paths: IPathInfo[];
117
files: IFileStatusInfo[];
118
enabled: boolean;
119
/** For hooks only: parsed hooks grouped by lifecycle */
120
parsedHooks?: IParsedHook[];
121
}
122
123
/**
124
* Registers the Diagnostics action for the chat context menu.
125
*/
126
export function registerChatCustomizationDiagnosticsAction() {
127
registerAction2(class DiagnosticsAction extends Action2 {
128
constructor() {
129
super({
130
id: 'workbench.action.chat.diagnostics',
131
title: localize2('chat.diagnostics.label', "Diagnostics"),
132
f1: false,
133
category: CHAT_CATEGORY,
134
menu: [{
135
id: MenuId.ChatContext,
136
group: 'z_clear',
137
order: -1
138
}, {
139
id: CHAT_CONFIG_MENU_ID,
140
when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId)),
141
order: 14,
142
group: '3_configure'
143
}, {
144
id: MenuId.ChatWelcomeContext,
145
group: '2_settings',
146
order: 0,
147
when: ChatContextKeys.inChatEditor.negate()
148
}]
149
});
150
}
151
152
async run(accessor: ServicesAccessor): Promise<void> {
153
const promptsService = accessor.get(IPromptsService);
154
const configurationService = accessor.get(IConfigurationService);
155
const fileService = accessor.get(IFileService);
156
const untitledTextEditorService = accessor.get(IUntitledTextEditorService);
157
const commandService = accessor.get(ICommandService);
158
const workspaceContextService = accessor.get(IWorkspaceContextService);
159
const labelService = accessor.get(ILabelService);
160
const remoteAgentService = accessor.get(IRemoteAgentService);
161
162
const token = CancellationToken.None;
163
const workspaceFolders = workspaceContextService.getWorkspace().folders;
164
const pathService = accessor.get(IPathService);
165
166
// Collect status for each type
167
const statusInfos: ITypeStatusInfo[] = [];
168
169
// 1. Custom Agents
170
const agentsStatus = await collectAgentsStatus(promptsService, fileService, token);
171
statusInfos.push(agentsStatus);
172
173
// 2. Instructions
174
const instructionsStatus = await collectInstructionsStatus(promptsService, fileService, token);
175
statusInfos.push(instructionsStatus);
176
177
// 3. Prompt Files
178
const promptsStatus = await collectPromptsStatus(promptsService, fileService, token);
179
statusInfos.push(promptsStatus);
180
181
// 4. Skills
182
const skillsStatus = await collectSkillsStatus(promptsService, configurationService, fileService, token);
183
statusInfos.push(skillsStatus);
184
185
// 5. Hooks
186
const hooksStatus = await collectHooksStatus(promptsService, fileService, labelService, pathService, workspaceContextService, remoteAgentService, token);
187
statusInfos.push(hooksStatus);
188
189
// 6. Special files (AGENTS.md, copilot-instructions.md)
190
const specialFilesStatus = await collectSpecialFilesStatus(promptsService, configurationService, token);
191
192
// Generate the markdown output
193
const output = formatStatusOutput(statusInfos, specialFilesStatus, workspaceFolders);
194
195
// Create an untitled markdown document with the content
196
const untitledModel = untitledTextEditorService.create({
197
initialValue: output,
198
languageId: 'markdown'
199
});
200
201
// Open the markdown file in edit mode
202
await commandService.executeCommand('vscode.open', untitledModel.resource);
203
}
204
});
205
}
206
207
/**
208
* Collects status for custom agents.
209
*/
210
async function collectAgentsStatus(
211
promptsService: IPromptsService,
212
fileService: IFileService,
213
token: CancellationToken
214
): Promise<ITypeStatusInfo> {
215
const type = PromptsType.agent;
216
const enabled = true; // Agents are always enabled
217
218
// Get resolved source folders using the shared path resolution logic
219
const resolvedFolders = await promptsService.getResolvedSourceFolders(type);
220
const paths = await convertResolvedFoldersToPathInfo(resolvedFolders, fileService);
221
222
// Get discovery info from the service (handles all duplicate detection and error tracking)
223
const discoveryInfo = await promptsService.getPromptDiscoveryInfo(type, token);
224
const files = discoveryInfo.files.map(convertDiscoveryResultToFileStatus);
225
226
return { type, paths, files, enabled };
227
}
228
229
/**
230
* Collects status for instructions files.
231
*/
232
async function collectInstructionsStatus(
233
promptsService: IPromptsService,
234
fileService: IFileService,
235
token: CancellationToken
236
): Promise<ITypeStatusInfo> {
237
const type = PromptsType.instructions;
238
const enabled = true;
239
240
// Get resolved source folders using the shared path resolution logic
241
const resolvedFolders = await promptsService.getResolvedSourceFolders(type);
242
const paths = await convertResolvedFoldersToPathInfo(resolvedFolders, fileService);
243
244
// Get discovery info from the service
245
// Filter out copilot-instructions.md files as they are handled separately in the special files section
246
const discoveryInfo = await promptsService.getPromptDiscoveryInfo(type, token);
247
const files = discoveryInfo.files
248
.filter(f => basename(f.uri) !== COPILOT_CUSTOM_INSTRUCTIONS_FILENAME)
249
.map(convertDiscoveryResultToFileStatus);
250
251
return { type, paths, files, enabled };
252
}
253
254
/**
255
* Collects status for prompt files.
256
*/
257
async function collectPromptsStatus(
258
promptsService: IPromptsService,
259
fileService: IFileService,
260
token: CancellationToken
261
): Promise<ITypeStatusInfo> {
262
const type = PromptsType.prompt;
263
const enabled = true;
264
265
// Get resolved source folders using the shared path resolution logic
266
const resolvedFolders = await promptsService.getResolvedSourceFolders(type);
267
const paths = await convertResolvedFoldersToPathInfo(resolvedFolders, fileService);
268
269
// Get discovery info from the service
270
const discoveryInfo = await promptsService.getPromptDiscoveryInfo(type, token);
271
const files = discoveryInfo.files.map(convertDiscoveryResultToFileStatus);
272
273
return { type, paths, files, enabled };
274
}
275
276
/**
277
* Collects status for skill files.
278
*/
279
async function collectSkillsStatus(
280
promptsService: IPromptsService,
281
configurationService: IConfigurationService,
282
fileService: IFileService,
283
token: CancellationToken
284
): Promise<ITypeStatusInfo> {
285
const type = PromptsType.skill;
286
const enabled = configurationService.getValue<boolean>(PromptsConfig.USE_AGENT_SKILLS) ?? false;
287
288
// Get resolved source folders using the shared path resolution logic
289
const resolvedFolders = await promptsService.getResolvedSourceFolders(type);
290
const paths = await convertResolvedFoldersToPathInfo(resolvedFolders, fileService);
291
292
// Get discovery info from the service (handles all duplicate detection and error tracking)
293
const discoveryInfo = await promptsService.getPromptDiscoveryInfo(type, token);
294
const files = discoveryInfo.files.map(convertDiscoveryResultToFileStatus);
295
296
return { type, paths, files, enabled };
297
}
298
299
export interface ISpecialFilesStatus {
300
agentsMd: { enabled: boolean; files: URI[] };
301
copilotInstructions: { enabled: boolean; files: URI[] };
302
claudeMd: { enabled: boolean; files: URI[] };
303
}
304
305
/**
306
* Collects status for hook files.
307
*/
308
async function collectHooksStatus(
309
promptsService: IPromptsService,
310
fileService: IFileService,
311
labelService: ILabelService,
312
pathService: IPathService,
313
workspaceContextService: IWorkspaceContextService,
314
remoteAgentService: IRemoteAgentService,
315
token: CancellationToken
316
): Promise<ITypeStatusInfo> {
317
const type = PromptsType.hook;
318
const enabled = true; // Hooks are always enabled
319
320
// Get resolved source folders using the shared path resolution logic
321
const resolvedFolders = await promptsService.getResolvedSourceFolders(type);
322
const paths = await convertResolvedFoldersToPathInfo(resolvedFolders, fileService);
323
324
// Get discovery info from the service (handles all duplicate detection and error tracking)
325
const discoveryInfo = await promptsService.getPromptDiscoveryInfo(type, token);
326
const files = discoveryInfo.files.map(convertDiscoveryResultToFileStatus);
327
328
// Parse hook files to extract individual hooks grouped by lifecycle
329
const parsedHooks = await parseHookFiles(promptsService, fileService, labelService, pathService, workspaceContextService, remoteAgentService, token);
330
331
return { type, paths, files, enabled, parsedHooks };
332
}
333
334
/**
335
* Parses all hook files and extracts individual hooks.
336
*/
337
async function parseHookFiles(
338
promptsService: IPromptsService,
339
fileService: IFileService,
340
labelService: ILabelService,
341
pathService: IPathService,
342
workspaceContextService: IWorkspaceContextService,
343
remoteAgentService: IRemoteAgentService,
344
token: CancellationToken
345
): Promise<IParsedHook[]> {
346
// Get workspace root and user home for path resolution
347
const workspaceFolder = workspaceContextService.getWorkspace().folders[0];
348
const workspaceRootUri = workspaceFolder?.uri;
349
const userHomeUri = await pathService.userHome();
350
const userHome = userHomeUri.fsPath ?? userHomeUri.path;
351
352
// Get the remote OS (or fall back to local OS)
353
const remoteEnv = await remoteAgentService.getEnvironment();
354
const targetOS = remoteEnv?.os ?? OS;
355
356
// Use the shared helper
357
return parseAllHookFiles(promptsService, fileService, labelService, workspaceRootUri, userHome, targetOS, token);
358
}
359
360
/**
361
* Collects status for special files like AGENTS.md and copilot-instructions.md.
362
*/
363
async function collectSpecialFilesStatus(
364
promptsService: IPromptsService,
365
configurationService: IConfigurationService,
366
token: CancellationToken
367
): Promise<ISpecialFilesStatus> {
368
const useAgentMd = configurationService.getValue<boolean>(PromptsConfig.USE_AGENT_MD) ?? false;
369
const useClaudeMd = configurationService.getValue<boolean>(PromptsConfig.USE_CLAUDE_MD) ?? false;
370
const useCopilotInstructions = configurationService.getValue<boolean>(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES) ?? false;
371
372
const allFiles = await promptsService.listAgentInstructions(token);
373
374
return {
375
agentsMd: {
376
enabled: useAgentMd,
377
files: allFiles.filter(f => f.type === AgentFileType.agentsMd).map(f => f.uri)
378
},
379
claudeMd: {
380
enabled: useClaudeMd,
381
files: allFiles.filter(f => f.type === AgentFileType.claudeMd).map(f => f.uri)
382
},
383
copilotInstructions: {
384
enabled: useCopilotInstructions,
385
files: allFiles.filter(f => f.type === AgentFileType.copilotInstructionsMd).map(f => f.uri)
386
}
387
};
388
}
389
390
/**
391
* Checks if a directory exists.
392
*/
393
async function checkDirectoryExists(fileService: IFileService, uri: URI): Promise<boolean> {
394
try {
395
const stat = await fileService.stat(uri);
396
return stat.isDirectory;
397
} catch {
398
return false;
399
}
400
}
401
402
/**
403
* Converts resolved source folders to path info with existence checks.
404
* This uses the shared path resolution logic from the prompts service.
405
*/
406
async function convertResolvedFoldersToPathInfo(
407
resolvedFolders: readonly IResolvedPromptSourceFolder[],
408
fileService: IFileService
409
): Promise<IPathInfo[]> {
410
const paths: IPathInfo[] = [];
411
let scanOrder = 1;
412
413
for (const folder of resolvedFolders) {
414
const exists = await checkDirectoryExists(fileService, folder.uri);
415
paths.push({
416
uri: folder.uri,
417
exists,
418
storage: folder.storage,
419
scanOrder: scanOrder++,
420
displayPath: folder.displayPath ?? folder.uri.path,
421
isDefault: folder.isDefault ?? false
422
});
423
}
424
425
return paths;
426
}
427
428
/**
429
* Converts skip reason enum to user-friendly message.
430
*/
431
function getSkipReasonMessage(skipReason: PromptFileSkipReason | undefined, errorMessage: string | undefined): string {
432
switch (skipReason) {
433
case 'missing-name':
434
return nls.localize('status.missingName', 'Missing name attribute');
435
case 'missing-description':
436
return nls.localize('status.skillMissingDescription', 'Missing description attribute');
437
case 'name-mismatch':
438
return errorMessage ?? nls.localize('status.skillNameMismatch2', 'Name does not match folder');
439
case 'duplicate-name':
440
return nls.localize('status.overwrittenByHigherPriority', 'Overwritten by higher priority file');
441
case 'parse-error':
442
return errorMessage ?? nls.localize('status.parseError', 'Parse error');
443
case 'disabled':
444
return nls.localize('status.typeDisabled', 'Disabled');
445
default:
446
return errorMessage ?? nls.localize('status.unknownError', 'Unknown error');
447
}
448
}
449
450
/**
451
* Converts IPromptFileDiscoveryResult to IFileStatusInfo for display.
452
*/
453
function convertDiscoveryResultToFileStatus(result: IPromptFileDiscoveryResult): IFileStatusInfo {
454
if (result.status === 'loaded') {
455
return {
456
uri: result.uri,
457
status: 'loaded',
458
name: result.name,
459
storage: result.storage,
460
extensionId: result.extensionId,
461
userInvokable: result.userInvokable,
462
disableModelInvocation: result.disableModelInvocation
463
};
464
}
465
466
// Handle skipped files
467
if (result.skipReason === 'duplicate-name' && result.duplicateOf) {
468
// This is an overwritten file
469
return {
470
uri: result.uri,
471
status: 'overwritten',
472
name: result.name,
473
storage: result.storage,
474
overwrittenBy: result.name,
475
extensionId: result.extensionId
476
};
477
}
478
479
// Regular skip
480
return {
481
uri: result.uri,
482
status: 'skipped',
483
name: result.name,
484
reason: getSkipReasonMessage(result.skipReason, result.errorMessage),
485
storage: result.storage,
486
extensionId: result.extensionId
487
};
488
}
489
490
/**
491
* Formats the status output as a compact markdown string with tree structure.
492
* Files are grouped under their parent paths.
493
* Special files (AGENTS.md, copilot-instructions.md) are merged into their respective sections.
494
*/
495
export function formatStatusOutput(
496
statusInfos: ITypeStatusInfo[],
497
specialFiles: ISpecialFilesStatus,
498
workspaceFolders: readonly IWorkspaceFolder[]
499
): string {
500
const lines: string[] = [];
501
502
lines.push(`## ${nls.localize('status.title', 'Chat Customization Diagnostics')}`);
503
lines.push(`*${nls.localize('status.sensitiveWarning', 'WARNING: This file may contain sensitive information.')}*`);
504
lines.push('');
505
506
for (const info of statusInfos) {
507
const typeName = getTypeName(info.type);
508
509
// Special handling for disabled skills
510
if (info.type === PromptsType.skill && !info.enabled) {
511
lines.push(`**${typeName}**`);
512
lines.push(`*${nls.localize('status.skillsDisabled', 'Skills are disabled. Enable them by setting `chat.useAgentSkills` to `true` in your settings.')}*`);
513
lines.push('');
514
continue;
515
}
516
517
const enabledStatus = info.enabled
518
? ''
519
: ` *(${nls.localize('status.disabled', 'disabled')})*`;
520
521
// Count loaded and skipped files (overwritten counts as skipped)
522
let loadedCount = info.files.filter(f => f.status === 'loaded').length;
523
const skippedCount = info.files.filter(f => f.status === 'skipped' || f.status === 'overwritten').length;
524
// Include special files in the loaded count for instructions
525
if (info.type === PromptsType.instructions) {
526
if (specialFiles.agentsMd.enabled) {
527
loadedCount += specialFiles.agentsMd.files.length;
528
}
529
if (specialFiles.copilotInstructions.enabled) {
530
loadedCount += specialFiles.copilotInstructions.files.length;
531
}
532
if (specialFiles.claudeMd.enabled) {
533
loadedCount += specialFiles.claudeMd.files.length;
534
}
535
}
536
537
lines.push(`**${typeName}**${enabledStatus}<br>`);
538
539
// Show stats line - use "skills" for skills type, "hooks" for hooks type, "files" for others
540
const statsParts: string[] = [];
541
if (info.type === PromptsType.hook) {
542
// For hooks, show both file count and individual hook count
543
if (loadedCount > 0) {
544
statsParts.push(loadedCount === 1
545
? nls.localize('status.fileLoaded', '1 file loaded')
546
: nls.localize('status.filesLoaded', '{0} files loaded', loadedCount));
547
}
548
if (info.parsedHooks && info.parsedHooks.length > 0) {
549
const hookCount = info.parsedHooks.length;
550
statsParts.push(hookCount === 1
551
? nls.localize('status.hookLoaded', '1 hook loaded')
552
: nls.localize('status.hooksLoaded', '{0} hooks loaded', hookCount));
553
}
554
} else if (loadedCount > 0) {
555
if (info.type === PromptsType.skill) {
556
statsParts.push(loadedCount === 1
557
? nls.localize('status.skillLoaded', '1 skill loaded')
558
: nls.localize('status.skillsLoaded', '{0} skills loaded', loadedCount));
559
} else {
560
statsParts.push(loadedCount === 1
561
? nls.localize('status.fileLoaded', '1 file loaded')
562
: nls.localize('status.filesLoaded', '{0} files loaded', loadedCount));
563
}
564
}
565
if (skippedCount > 0) {
566
statsParts.push(nls.localize('status.skippedCount', '{0} skipped', skippedCount));
567
}
568
if (statsParts.length > 0) {
569
lines.push(`*${statsParts.join(', ')}*`);
570
}
571
lines.push('');
572
573
const allPaths = info.paths;
574
const allFiles = info.files;
575
576
// Group files by their parent path
577
const filesByPath = new Map<string, IFileStatusInfo[]>();
578
const unmatchedFiles: IFileStatusInfo[] = [];
579
580
for (const file of allFiles) {
581
let matched = false;
582
for (const path of allPaths) {
583
if (isFileUnderPath(file.uri, path.uri)) {
584
const key = path.uri.toString();
585
if (!filesByPath.has(key)) {
586
filesByPath.set(key, []);
587
}
588
filesByPath.get(key)!.push(file);
589
matched = true;
590
break;
591
}
592
}
593
if (!matched) {
594
unmatchedFiles.push(file);
595
}
596
}
597
598
// Render each path with its files as a tree
599
// Skip for hooks since we show files with their hooks below
600
let hasContent = false;
601
if (info.type !== PromptsType.hook) {
602
for (const path of allPaths) {
603
const pathFiles = filesByPath.get(path.uri.toString()) || [];
604
605
if (path.exists) {
606
lines.push(`${path.displayPath}<br>`);
607
} else if (path.isDefault) {
608
// Default folders that don't exist - no error icon
609
lines.push(`${path.displayPath}<br>`);
610
} else {
611
// Custom folders that don't exist - show error
612
lines.push(`${ICON_ERROR} ${path.displayPath} - *${nls.localize('status.folderNotFound', 'Folder does not exist')}*<br>`);
613
}
614
615
if (path.exists && pathFiles.length > 0) {
616
for (let i = 0; i < pathFiles.length; i++) {
617
const file = pathFiles[i];
618
// Show the file ID: skill name for skills, basename for others
619
let fileName: string;
620
if (info.type === PromptsType.skill) {
621
fileName = file.name || `${basename(dirname(file.uri))}`;
622
} else {
623
fileName = basename(file.uri);
624
}
625
const isLast = i === pathFiles.length - 1;
626
const prefix = isLast ? TREE_END : TREE_BRANCH;
627
const filePath = getRelativePath(file.uri, workspaceFolders);
628
if (file.status === 'loaded') {
629
const flags = getSkillFlags(file, info.type);
630
lines.push(`${prefix} [\`${fileName}\`](${filePath})${flags}<br>`);
631
} else if (file.status === 'overwritten') {
632
lines.push(`${prefix} ${ICON_WARN} [\`${fileName}\`](${filePath}) - *${nls.localize('status.overwrittenByHigherPriority', 'Overwritten by higher priority file')}*<br>`);
633
} else {
634
lines.push(`${prefix} ${ICON_ERROR} [\`${fileName}\`](${filePath}) - *${file.reason}*<br>`);
635
}
636
}
637
}
638
hasContent = true;
639
}
640
}
641
642
// Render unmatched files (e.g., from extensions) - group by extension ID
643
// Skip for hooks since we show files with their hooks below
644
if (info.type !== PromptsType.hook && unmatchedFiles.length > 0) {
645
// Group files by extension ID
646
const filesByExtension = new Map<string, IFileStatusInfo[]>();
647
for (const file of unmatchedFiles) {
648
const extId = file.extensionId || 'unknown';
649
if (!filesByExtension.has(extId)) {
650
filesByExtension.set(extId, []);
651
}
652
filesByExtension.get(extId)!.push(file);
653
}
654
655
// Render each extension group
656
for (const [extId, extFiles] of filesByExtension) {
657
lines.push(`${nls.localize('status.extension', 'Extension')}: ${extId}<br>`);
658
for (let i = 0; i < extFiles.length; i++) {
659
const file = extFiles[i];
660
// Show the file ID: skill name for skills, basename for others
661
let fileName: string;
662
if (info.type === PromptsType.skill) {
663
fileName = file.name || `${basename(dirname(file.uri))}`;
664
} else {
665
fileName = basename(file.uri);
666
}
667
const isLast = i === extFiles.length - 1;
668
const prefix = isLast ? TREE_END : TREE_BRANCH;
669
const filePath = getRelativePath(file.uri, workspaceFolders);
670
if (file.status === 'loaded') {
671
const flags = getSkillFlags(file, info.type);
672
lines.push(`${prefix} [\`${fileName}\`](${filePath})${flags}<br>`);
673
} else if (file.status === 'overwritten') {
674
lines.push(`${prefix} ${ICON_WARN} [\`${fileName}\`](${filePath}) - *${nls.localize('status.overwrittenByHigherPriority', 'Overwritten by higher priority file')}*<br>`);
675
} else {
676
lines.push(`${prefix} ${ICON_ERROR} [\`${fileName}\`](${filePath}) - *${file.reason}*<br>`);
677
}
678
}
679
}
680
hasContent = true;
681
}
682
683
// Add special files for instructions (AGENTS.md and copilot-instructions.md)
684
if (info.type === PromptsType.instructions) {
685
// AGENTS.md
686
if (specialFiles.agentsMd.enabled && specialFiles.agentsMd.files.length > 0) {
687
lines.push(`AGENTS.md<br>`);
688
for (let i = 0; i < specialFiles.agentsMd.files.length; i++) {
689
const file = specialFiles.agentsMd.files[i];
690
const fileName = basename(file);
691
const isLast = i === specialFiles.agentsMd.files.length - 1;
692
const prefix = isLast ? TREE_END : TREE_BRANCH;
693
const filePath = getRelativePath(file, workspaceFolders);
694
lines.push(`${prefix} [\`${fileName}\`](${filePath})<br>`);
695
}
696
hasContent = true;
697
} else if (!specialFiles.agentsMd.enabled) {
698
lines.push(`AGENTS.md -<br>`);
699
hasContent = true;
700
}
701
702
// copilot-instructions.md
703
if (specialFiles.copilotInstructions.enabled && specialFiles.copilotInstructions.files.length > 0) {
704
lines.push(`${COPILOT_CUSTOM_INSTRUCTIONS_FILENAME}<br>`);
705
for (let i = 0; i < specialFiles.copilotInstructions.files.length; i++) {
706
const file = specialFiles.copilotInstructions.files[i];
707
const fileName = basename(file);
708
const isLast = i === specialFiles.copilotInstructions.files.length - 1;
709
const prefix = isLast ? TREE_END : TREE_BRANCH;
710
const filePath = getRelativePath(file, workspaceFolders);
711
lines.push(`${prefix} [\`${fileName}\`](${filePath})<br>`);
712
}
713
hasContent = true;
714
} else if (!specialFiles.copilotInstructions.enabled) {
715
lines.push(`${COPILOT_CUSTOM_INSTRUCTIONS_FILENAME} -<br>`);
716
hasContent = true;
717
}
718
}
719
720
// Special handling for hooks - display grouped by file, then by lifecycle
721
if (info.type === PromptsType.hook && info.parsedHooks && info.parsedHooks.length > 0) {
722
// Group hooks first by file, then by lifecycle within each file
723
const hooksByFile = new Map<string, IParsedHook[]>();
724
for (const hook of info.parsedHooks) {
725
const fileKey = hook.fileUri.toString();
726
const existing = hooksByFile.get(fileKey) ?? [];
727
existing.push(hook);
728
hooksByFile.set(fileKey, existing);
729
}
730
731
// Display hooks grouped by file
732
const fileUris = Array.from(hooksByFile.keys());
733
for (let fileIdx = 0; fileIdx < fileUris.length; fileIdx++) {
734
const fileKey = fileUris[fileIdx];
735
const fileHooks = hooksByFile.get(fileKey)!;
736
const firstHook = fileHooks[0];
737
const filePath = getRelativePath(firstHook.fileUri, workspaceFolders);
738
739
// File as clickable link
740
lines.push(`[${firstHook.filePath}](${filePath})<br>`);
741
742
// Flatten hooks with their lifecycle label
743
for (let i = 0; i < fileHooks.length; i++) {
744
const hook = fileHooks[i];
745
const isLast = i === fileHooks.length - 1;
746
const prefix = isLast ? TREE_END : TREE_BRANCH;
747
lines.push(`${prefix} ${hook.hookTypeLabel}: \`${hook.commandLabel}\`<br>`);
748
}
749
}
750
hasContent = true;
751
}
752
753
if (!hasContent && info.enabled) {
754
lines.push(`*${nls.localize('status.noFilesLoaded', 'No files loaded')}*`);
755
}
756
lines.push('');
757
}
758
759
return lines.join('\n');
760
}
761
762
/**
763
* Gets flag annotations for skills based on their visibility settings.
764
* Returns an empty string for non-skill types or skills with default settings.
765
*/
766
function getSkillFlags(file: IFileStatusInfo, type: PromptsType): string {
767
if (type !== PromptsType.skill) {
768
return '';
769
}
770
771
const flags: string[] = [];
772
773
// disableModelInvocation: true means agent won't auto-load, only manual /name trigger
774
if (file.disableModelInvocation) {
775
flags.push(`${ICON_MANUAL} *${nls.localize('status.skill.manualOnly', 'manual only')}*`);
776
}
777
778
// userInvokable: false means hidden from / menu
779
if (file.userInvokable === false) {
780
flags.push(`${ICON_HIDDEN} *${nls.localize('status.skill.hiddenFromMenu', 'hidden from menu')}*`);
781
}
782
783
if (flags.length === 0) {
784
return '';
785
}
786
787
return ` - ${flags.join(', ')}`;
788
}
789
790
/**
791
* Checks if a file URI is under a given path URI.
792
*/
793
function isFileUnderPath(fileUri: URI, pathUri: URI): boolean {
794
const filePath = fileUri.toString();
795
const folderPath = pathUri.toString();
796
return filePath.startsWith(folderPath + '/') || filePath.startsWith(folderPath + '\\');
797
}
798
799
/**
800
* Gets a human-readable name for a prompt type.
801
*/
802
function getTypeName(type: PromptsType): string {
803
switch (type) {
804
case PromptsType.agent:
805
return nls.localize('status.type.agents', 'Custom Agents');
806
case PromptsType.instructions:
807
return nls.localize('status.type.instructions', 'Instructions');
808
case PromptsType.prompt:
809
return nls.localize('status.type.prompts', 'Prompt Files');
810
case PromptsType.skill:
811
return nls.localize('status.type.skills', 'Skills');
812
case PromptsType.hook:
813
return nls.localize('status.type.hooks', 'Hooks');
814
default:
815
return type;
816
}
817
}
818
819