Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/log/vscode-node/requestLogTree.ts
13399 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 { IHTMLRouter } from '@vscode/prompt-tsx';
7
import { createServer } from 'http';
8
import { AddressInfo } from 'net';
9
import * as os from 'os';
10
import * as path from 'path';
11
import * as tar from 'tar';
12
import * as vscode from 'vscode';
13
import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';
14
import { outputChannel } from '../../../platform/log/vscode/outputChannelLogTarget';
15
import { CapturingToken } from '../../../platform/requestLogger/common/capturingToken';
16
import { ChatRequestScheme, ILoggedElementInfo, ILoggedRequestInfo, ILoggedToolCall, IRequestLogger, LoggedInfo, LoggedInfoKind, LoggedRequestKind, resolveMarkdownIcon } from '../../../platform/requestLogger/common/requestLogger';
17
import { filterMap } from '../../../util/common/arrays';
18
import { assert, assertNever } from '../../../util/vs/base/common/assert';
19
import { Disposable, toDisposable } from '../../../util/vs/base/common/lifecycle';
20
import { LRUCache } from '../../../util/vs/base/common/map';
21
import { isDefined } from '../../../util/vs/base/common/types';
22
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
23
import { IExtensionContribution } from '../../common/contributions';
24
import { assembleChatLogExport, createExportedPrompt, ExportedPrompt, serializeChatLogExport } from '../node/chatLogExport';
25
26
const showHtmlCommand = 'vscode.copilot.chat.showRequestHtmlItem';
27
const exportLogItemCommand = 'github.copilot.chat.debug.exportLogItem';
28
const exportPromptArchiveCommand = 'github.copilot.chat.debug.exportPromptArchive';
29
30
/**
31
* Serialize MCP server definitions to a JSON-safe format.
32
* Excludes sensitive headers like Authorization.
33
*/
34
function serializeMcpServers(servers: readonly vscode.McpServerDefinition[]): object[] {
35
return servers.map(server => {
36
if (server instanceof vscode.McpStdioServerDefinition) {
37
return {
38
type: 'stdio',
39
label: server.label,
40
command: server.command,
41
args: server.args,
42
cwd: server.cwd?.toString(),
43
version: server.version
44
};
45
} else {
46
return {
47
type: 'http',
48
label: server.label,
49
uri: server.uri.with({ authority: '[authority]', query: '', fragment: '' }).toString(),
50
version: server.version
51
};
52
}
53
});
54
}
55
const exportPromptLogsAsJsonCommand = 'github.copilot.chat.debug.exportPromptLogsAsJson';
56
const exportAllPromptLogsAsJsonCommand = 'github.copilot.chat.debug.exportAllPromptLogsAsJson';
57
const saveCurrentMarkdownCommand = 'github.copilot.chat.debug.saveCurrentMarkdown';
58
const showRawRequestBodyCommand = 'github.copilot.chat.debug.showRawRequestBody';
59
60
export class RequestLogTree extends Disposable implements IExtensionContribution {
61
readonly id = 'requestLogTree';
62
private readonly chatRequestProvider: ChatRequestProvider;
63
64
constructor(
65
@IInstantiationService instantiationService: IInstantiationService,
66
@IRequestLogger requestLogger: IRequestLogger,
67
) {
68
super();
69
this.chatRequestProvider = this._register(instantiationService.createInstance(ChatRequestProvider));
70
this._register(vscode.window.registerTreeDataProvider('copilot-chat', this.chatRequestProvider));
71
72
let server: RequestServer | undefined;
73
74
const getExportableLogEntries = (treeItem: ChatPromptItem): LoggedInfo[] => {
75
if (!treeItem || !treeItem.children) {
76
return [];
77
}
78
79
const logEntries = treeItem.children.map(child => {
80
if (child instanceof ChatRequestItem || child instanceof ToolCallItem || child instanceof ChatElementItem) {
81
return child.info;
82
}
83
return undefined; // Skip non-loggable items
84
}).filter(isDefined);
85
86
return logEntries;
87
};
88
89
// Helper method to process log entries for a single prompt using shared export function
90
const preparePromptLogsAsJson = async (treeItem: ChatPromptItem): Promise<ExportedPrompt | undefined> => {
91
const logEntries = getExportableLogEntries(treeItem);
92
93
if (logEntries.length === 0) {
94
return;
95
}
96
97
return createExportedPrompt(treeItem.token.label, logEntries, {
98
promptId: treeItem.id,
99
});
100
};
101
102
this._register(vscode.commands.registerCommand(showHtmlCommand, async (elementId: string) => {
103
if (!server) {
104
server = this._register(new RequestServer());
105
}
106
107
const req = requestLogger.getRequests().find(r => r.kind === LoggedInfoKind.Element && r.id === elementId);
108
if (!req) {
109
return;
110
}
111
112
const address = await server.addRouter(req as ILoggedElementInfo);
113
await vscode.commands.executeCommand('simpleBrowser.show', address);
114
}));
115
116
this._register(vscode.commands.registerCommand(exportLogItemCommand, async (treeItem: TreeItem) => {
117
if (!treeItem || !treeItem.id) {
118
return;
119
}
120
121
let logEntry: LoggedInfo;
122
123
if (treeItem instanceof ChatPromptItem) {
124
// ChatPromptItem doesn't represent a single log entry
125
vscode.window.showWarningMessage('Cannot export chat prompt item. Please select a specific request, tool call, or element.');
126
return;
127
} else if (treeItem instanceof ChatRequestItem || treeItem instanceof ToolCallItem || treeItem instanceof ChatElementItem) {
128
logEntry = treeItem.info;
129
} else {
130
vscode.window.showErrorMessage('Unable to determine log entry ID for this item.');
131
return;
132
}
133
134
// Check if this entry type supports markdown export
135
if (logEntry.kind === LoggedInfoKind.Element) {
136
vscode.window.showWarningMessage('Element entries cannot be exported as markdown. They contain HTML content that can be viewed in the browser.');
137
return;
138
}
139
140
// Generate a default filename based on the entry type and id
141
let defaultFilename: string;
142
switch (logEntry.kind) {
143
case LoggedInfoKind.Request: {
144
const requestEntry = logEntry as ILoggedRequestInfo;
145
const debugName = requestEntry.entry.debugName.replace(/\W/g, '_');
146
defaultFilename = `${debugName}_${logEntry.id}.copilotmd`;
147
break;
148
}
149
case LoggedInfoKind.ToolCall: {
150
const toolEntry = logEntry as ILoggedToolCall;
151
const toolName = toolEntry.name.replace(/\W/g, '_');
152
defaultFilename = `tool_${toolName}_${logEntry.id}.copilotmd`;
153
break;
154
}
155
}
156
157
if (!defaultFilename) {
158
return;
159
}
160
161
// Show save dialog
162
const saveUri = await vscode.window.showSaveDialog({
163
defaultUri: vscode.Uri.file(path.join(os.homedir(), defaultFilename)),
164
filters: {
165
'Copilot Markdown': ['copilotmd'],
166
'Markdown': ['md'],
167
'All Files': ['*']
168
},
169
title: 'Export Log Entry'
170
});
171
172
if (!saveUri) {
173
return; // User cancelled
174
}
175
176
try {
177
// Get the content using the virtual document URI
178
const virtualUri = vscode.Uri.parse(ChatRequestScheme.buildUri({ kind: 'request', id: logEntry.id }));
179
const document = await vscode.workspace.openTextDocument(virtualUri);
180
const content = document.getText();
181
182
// Write to the selected file
183
await vscode.workspace.fs.writeFile(saveUri, Buffer.from(content, 'utf8'));
184
185
// Show success message with option to open the file
186
const openAction = 'Open File';
187
const result = await vscode.window.showInformationMessage(
188
`Successfully exported to ${saveUri.fsPath}`,
189
openAction
190
);
191
192
if (result === openAction) {
193
await vscode.commands.executeCommand('vscode.open', saveUri);
194
}
195
} catch (error) {
196
vscode.window.showErrorMessage(`Failed to export log entry: ${error}`);
197
}
198
}));
199
200
// Save the currently opened chat log (ccreq:*.copilotmd) to a file
201
this._register(vscode.commands.registerCommand(saveCurrentMarkdownCommand, async (...args: any[]) => {
202
// Accept resource from menu invocation (editor/title passes the resource)
203
let resource: vscode.Uri | undefined;
204
const first = args?.[0];
205
if (first instanceof vscode.Uri) {
206
resource = first;
207
} else if (first && typeof first === 'object') {
208
// Some menu invocations pass { resource: Uri }
209
const candidate = (first as { resource?: vscode.Uri }).resource;
210
if (candidate instanceof vscode.Uri) {
211
resource = candidate;
212
}
213
}
214
215
// Fallback to the active editor's document
216
resource ??= vscode.window.activeTextEditor?.document.uri;
217
if (!resource) {
218
vscode.window.showWarningMessage('No document is active to save.');
219
return;
220
}
221
222
if (resource.scheme !== ChatRequestScheme.chatRequestScheme) {
223
vscode.window.showWarningMessage('This command only works for Copilot request documents.');
224
return;
225
}
226
227
// Determine a default filename from the virtual URI
228
const parseResult = ChatRequestScheme.parseUri(resource.toString());
229
const defaultBase = parseResult && parseResult.data.kind === 'request' ? parseResult.data.id : 'latestrequest';
230
const defaultFilename = `${defaultBase}.md`;
231
232
const saveUri = await vscode.window.showSaveDialog({
233
defaultUri: vscode.Uri.file(path.join(os.homedir(), defaultFilename)),
234
filters: {
235
'Markdown': ['md'],
236
'Copilot Markdown': ['copilotmd'],
237
'All Files': ['*']
238
},
239
title: 'Save Markdown As'
240
});
241
242
if (!saveUri) {
243
return; // User cancelled
244
}
245
246
try {
247
// Read the text from the virtual document URI explicitly
248
const doc = await vscode.workspace.openTextDocument(resource);
249
await vscode.workspace.fs.writeFile(saveUri, Buffer.from(doc.getText(), 'utf8'));
250
251
const openAction = 'Open File';
252
const result = await vscode.window.showInformationMessage(
253
`Successfully saved to ${saveUri.fsPath}`,
254
openAction
255
);
256
257
if (result === openAction) {
258
await vscode.commands.executeCommand('vscode.open', saveUri);
259
}
260
} catch (error) {
261
vscode.window.showErrorMessage(`Failed to save markdown: ${error}`);
262
}
263
}));
264
265
this._register(vscode.commands.registerCommand(exportPromptArchiveCommand, async (treeItem: ChatPromptItem) => {
266
const logEntries = getExportableLogEntries(treeItem);
267
268
if (logEntries.length === 0) {
269
vscode.window.showInformationMessage('No exportable entries found in this prompt.');
270
return;
271
}
272
273
// Generate a default filename based on the prompt
274
const promptText = treeItem.token.label.replace(/\W/g, '_').substring(0, 50);
275
const defaultFilename = `${promptText}_exports.tar.gz`;
276
277
// Show save dialog
278
const saveUri = await vscode.window.showSaveDialog({
279
defaultUri: vscode.Uri.file(path.join(os.homedir(), defaultFilename)),
280
filters: {
281
'Tar Archive': ['tar.gz', 'tgz'],
282
'All Files': ['*']
283
},
284
title: 'Export Prompt Archive'
285
});
286
287
if (!saveUri) {
288
return; // User cancelled
289
}
290
291
try {
292
// Create temporary directory for files
293
const tempDir = path.join(os.tmpdir(), `vscode-copilot-export-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`);
294
await vscode.workspace.fs.createDirectory(vscode.Uri.file(tempDir));
295
296
const filesToArchive: string[] = [];
297
298
// Export each child to a temporary file
299
for (const logEntry of logEntries) {
300
// Generate filename for this entry
301
let filename: string;
302
switch (logEntry.kind) {
303
case LoggedInfoKind.Request: {
304
const requestEntry = logEntry as ILoggedRequestInfo;
305
const debugName = requestEntry.entry.debugName.replace(/\W/g, '_');
306
filename = `${debugName}_${logEntry.id}.copilotmd`;
307
break;
308
}
309
case LoggedInfoKind.ToolCall: {
310
const toolEntry = logEntry as ILoggedToolCall;
311
const toolName = toolEntry.name.replace(/\W/g, '_');
312
filename = `tool_${toolName}_${logEntry.id}.copilotmd`;
313
break;
314
}
315
default:
316
continue;
317
}
318
319
// Get the content and write to temporary file
320
const virtualUri = vscode.Uri.parse(ChatRequestScheme.buildUri({ kind: 'request', id: logEntry.id }));
321
const document = await vscode.workspace.openTextDocument(virtualUri);
322
const content = document.getText();
323
324
const tempFilePath = path.join(tempDir, filename);
325
await vscode.workspace.fs.writeFile(vscode.Uri.file(tempFilePath), Buffer.from(content, 'utf8'));
326
filesToArchive.push(tempFilePath);
327
}
328
329
if (filesToArchive.length > 0) {
330
// Create tar.gz archive
331
await tar.create(
332
{
333
gzip: true,
334
file: saveUri.fsPath,
335
cwd: tempDir
336
},
337
filesToArchive.map(f => path.basename(f))
338
);
339
340
// Clean up temporary files
341
for (const filePath of filesToArchive) {
342
await vscode.workspace.fs.delete(vscode.Uri.file(filePath));
343
}
344
await vscode.workspace.fs.delete(vscode.Uri.file(tempDir));
345
346
// Show success message with option to reveal the file
347
const revealAction = 'Reveal in Explorer';
348
const result = await vscode.window.showInformationMessage(
349
`Successfully exported ${filesToArchive.length} entries to ${saveUri.fsPath}`,
350
revealAction
351
);
352
353
if (result === revealAction) {
354
await vscode.commands.executeCommand('revealFileInOS', saveUri);
355
}
356
} else {
357
vscode.window.showWarningMessage('No valid entries could be exported.');
358
}
359
} catch (error) {
360
vscode.window.showErrorMessage(`Failed to export prompt archive: ${error}`);
361
}
362
}));
363
364
this._register(vscode.commands.registerCommand(exportPromptLogsAsJsonCommand, async (treeItem: ChatPromptItem) => {
365
const promptObject = await preparePromptLogsAsJson(treeItem);
366
if (!promptObject) {
367
vscode.window.showWarningMessage('No exportable entries found for this prompt.');
368
return;
369
}
370
371
// Generate a default filename based on the prompt
372
const promptText = treeItem.token.label.replace(/\W/g, '_').substring(0, 50);
373
const defaultFilename = `${promptText}_logs.json`;
374
375
// Show save dialog
376
const saveUri = await vscode.window.showSaveDialog({
377
defaultUri: vscode.Uri.file(path.join(os.homedir(), defaultFilename)),
378
filters: {
379
'JSON': ['json'],
380
'All Files': ['*']
381
},
382
title: 'Export Prompt Logs as JSON'
383
});
384
385
if (!saveUri) {
386
return; // User cancelled
387
}
388
389
try {
390
// Convert to JSON
391
const finalContent = JSON.stringify(promptObject, null, 2);
392
393
// Write to the selected file
394
await vscode.workspace.fs.writeFile(saveUri, Buffer.from(finalContent, 'utf8'));
395
396
// Show success message with option to reveal the file
397
const revealAction = 'Reveal in Explorer';
398
const openAction = 'Open File';
399
const result = await vscode.window.showInformationMessage(
400
`Successfully exported prompt with ${promptObject.logCount} log entries to ${saveUri.fsPath}`,
401
revealAction,
402
openAction
403
);
404
405
if (result === revealAction) {
406
await vscode.commands.executeCommand('revealFileInOS', saveUri);
407
} else if (result === openAction) {
408
await vscode.commands.executeCommand('vscode.open', saveUri);
409
}
410
} catch (error) {
411
vscode.window.showErrorMessage(`Failed to export prompt logs as JSON: ${error}`);
412
}
413
}));
414
415
this._register(vscode.commands.registerCommand(exportAllPromptLogsAsJsonCommand, async (savePath?: string) => {
416
// Build the tree structure to get all chat prompt items
417
const allTreeItems = await this.chatRequestProvider.getChildren();
418
419
if (!allTreeItems || allTreeItems.length === 0) {
420
vscode.window.showInformationMessage('No chat prompts found to export.');
421
return;
422
}
423
424
// Filter to only include ChatPromptItem entries
425
const exportableItems = allTreeItems.filter(item =>
426
item instanceof ChatPromptItem
427
);
428
429
if (exportableItems.length === 0) {
430
vscode.window.showInformationMessage('No chat prompts found to export.');
431
return;
432
}
433
434
let saveUri: vscode.Uri;
435
436
if (savePath && typeof savePath === 'string') {
437
// Use provided path
438
saveUri = vscode.Uri.file(savePath);
439
} else {
440
// Generate a default filename based on current timestamp
441
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
442
const defaultFilename = `copilot_all_prompts_${timestamp}.json`;
443
444
// Show save dialog
445
const dialogResult = await vscode.window.showSaveDialog({
446
defaultUri: vscode.Uri.file(path.join(os.homedir(), defaultFilename)),
447
filters: {
448
'JSON': ['json'],
449
'All Files': ['*']
450
},
451
title: 'Export All Prompt Logs as JSON'
452
});
453
454
if (!dialogResult) {
455
return; // User cancelled
456
}
457
saveUri = dialogResult;
458
}
459
460
try {
461
const allPromptsContent: ExportedPrompt[] = [];
462
463
for (const exportableItem of exportableItems) {
464
if (exportableItem instanceof ChatPromptItem) {
465
const promptObject = await preparePromptLogsAsJson(exportableItem);
466
if (promptObject) {
467
allPromptsContent.push(promptObject);
468
}
469
}
470
}
471
472
// Use shared export assembly function
473
const exportData = assembleChatLogExport(
474
allPromptsContent,
475
serializeMcpServers(vscode.lm.mcpServerDefinitions ?? [])
476
);
477
const finalContent = serializeChatLogExport(exportData);
478
479
// Write to the selected file
480
await vscode.workspace.fs.writeFile(saveUri, Buffer.from(finalContent, 'utf8'));
481
482
// Show success message with option to reveal the file (only for user-initiated calls)
483
if (!savePath) {
484
const revealAction = 'Reveal in Explorer';
485
const openAction = 'Open File';
486
const result = await vscode.window.showInformationMessage(
487
`Successfully exported ${exportData.totalPrompts} prompts with ${exportData.totalLogEntries} log entries to ${saveUri.fsPath}`,
488
revealAction,
489
openAction
490
);
491
492
if (result === revealAction) {
493
await vscode.commands.executeCommand('revealFileInOS', saveUri);
494
} else if (result === openAction) {
495
await vscode.commands.executeCommand('vscode.open', saveUri);
496
}
497
}
498
} catch (error) {
499
vscode.window.showErrorMessage(`Failed to export all prompt logs as JSON: ${error}`);
500
}
501
}));
502
503
this._register(vscode.commands.registerCommand(showRawRequestBodyCommand, async (arg?: ChatPromptItem) => {
504
const requestId = arg?.id;
505
if (!requestId) {
506
return;
507
}
508
509
await vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(ChatRequestScheme.buildUri({ kind: 'request', id: requestId }, 'rawrequest')));
510
}));
511
512
this._register(vscode.commands.registerCommand('github.copilot.debug.showOutputChannel', async () => {
513
outputChannel.show();
514
}));
515
}
516
}
517
518
519
/**
520
* Servers that shows logged request html for the simple browser. Doing this
521
* is annoying, but the markdown renderer is limited and doesn't show full HTML,
522
* and the simple browser extension can't render internal or `file://` URIs.
523
*
524
* Note that we don't need secret tokens or anything at this point because the
525
* server is read-only and does not advertise any CORS headers.
526
*/
527
class RequestServer extends Disposable {
528
public port: Promise<number>;
529
private routers = new LRUCache<string, IHTMLRouter>(10);
530
531
constructor() {
532
super();
533
534
const server = createServer((req, res) => {
535
for (const [key, router] of this.routers) {
536
if (router.route(req, res)) {
537
this.routers.get(key); // LRU touch
538
return;
539
}
540
}
541
542
res.statusCode = 404;
543
res.end('Not Found');
544
});
545
546
this.port = new Promise<number>((resolve, reject) => {
547
server.listen(0, '127.0.0.1', () => resolve((server.address() as AddressInfo).port)).on('error', reject);
548
});
549
550
this._register(toDisposable(() => server.close()));
551
}
552
553
async addRouter(info: ILoggedElementInfo) {
554
const prev = this.routers.get(info.id);
555
if (prev) {
556
return prev.address;
557
}
558
559
const port = await this.port;
560
const router = info.trace.serveRouter(`http://127.0.0.1:${port}`);
561
this.routers.set(info.id, router);
562
return router.address;
563
}
564
}
565
566
type TreeItem = ChatPromptItem | ChatRequestItem | ChatElementItem | ToolCallItem;
567
568
class ChatRequestProvider extends Disposable implements vscode.TreeDataProvider<TreeItem> {
569
private readonly filters: LogTreeFilters;
570
571
constructor(
572
@IRequestLogger private readonly requestLogger: IRequestLogger,
573
@IInstantiationService instantiationService: IInstantiationService,
574
) {
575
super();
576
this.filters = this._register(instantiationService.createInstance(LogTreeFilters));
577
this._register(new LogTreeFilterCommands(this.filters));
578
this._register(this.requestLogger.onDidChangeRequests(() => this._onDidChangeTreeData.fire()));
579
this._register(this.filters.onDidChangeFilters(() => this._onDidChangeTreeData.fire()));
580
}
581
582
private readonly _onDidChangeTreeData = new vscode.EventEmitter<TreeItem | undefined | void>();
583
onDidChangeTreeData = this._onDidChangeTreeData.event;
584
585
getTreeItem(element: TreeItem): vscode.TreeItem | Thenable<vscode.TreeItem> {
586
return element;
587
}
588
589
getChildren(element?: TreeItem | undefined): vscode.ProviderResult<TreeItem[]> {
590
if (element instanceof ChatPromptItem) {
591
return element.children;
592
} else if (element) {
593
return [];
594
} else {
595
const result: (ChatPromptItem | TreeChildItem)[] = [];
596
const tokenToPrompt = new Map<CapturingToken, ChatPromptItem>();
597
598
for (const currReq of this.requestLogger.getRequests()) {
599
if (!currReq.token) {
600
// Skip non-main hidden entries (e.g. skipped/cancelled live NES requests)
601
if (currReq.kind === LoggedInfoKind.Request &&
602
currReq.entry.type === LoggedRequestKind.MarkdownContentRequest &&
603
currReq.entry.isVisible && !currReq.entry.isVisible()) {
604
continue;
605
}
606
607
result.push(this.logToTreeItem(currReq));
608
continue;
609
}
610
611
let prompt = tokenToPrompt.get(currReq.token);
612
if (!prompt) {
613
prompt = ChatPromptItem.create(currReq, currReq.token);
614
tokenToPrompt.set(currReq.token, prompt);
615
result.push(prompt);
616
}
617
618
// If this entry is the main entry for the group (a MarkdownContentRequest
619
// whose debugName matches the token label), associate it directly with the
620
// parent ChatPromptItem — don't add it as a child. The entry stays in the
621
// request logger for virtual document serving; only tree nesting changes.
622
// Always wire the main entry so the parent node is clickable and shows the
623
// current icon (e.g. loading, lightbulb, skipped, circleSlash, etc.).
624
if (currReq.kind === LoggedInfoKind.Request &&
625
currReq.entry.type === LoggedRequestKind.MarkdownContentRequest &&
626
currReq.entry.debugName === currReq.token.label) {
627
prompt.setMainEntry(currReq);
628
continue;
629
}
630
631
// Skip non-main hidden entries
632
if (currReq.kind === LoggedInfoKind.Request &&
633
currReq.entry.type === LoggedRequestKind.MarkdownContentRequest &&
634
currReq.entry.isVisible && !currReq.entry.isVisible()) {
635
continue;
636
}
637
638
const currReqTreeItem = this.logToTreeItem(currReq);
639
const alreadyIncluded = prompt.children.find(existingChild => existingChild.id === currReqTreeItem.id);
640
if (!alreadyIncluded) {
641
prompt.children.push(currReqTreeItem);
642
}
643
}
644
645
return filterMap(result, r => {
646
if (!this.filters.itemIncluded(r)) {
647
return undefined;
648
}
649
650
if (r instanceof ChatPromptItem) {
651
return r.withFilteredChildren(child => this.filters.itemIncluded(child));
652
}
653
654
return r;
655
});
656
}
657
}
658
659
private logToTreeItem(r: LoggedInfo): TreeChildItem {
660
switch (r.kind) {
661
case LoggedInfoKind.Request:
662
return new ChatRequestItem(r);
663
case LoggedInfoKind.Element:
664
return new ChatElementItem(r);
665
case LoggedInfoKind.ToolCall:
666
return new ToolCallItem(r);
667
default:
668
assertNever(r);
669
}
670
}
671
}
672
673
type TreeChildItem = ChatRequestItem | ChatElementItem | ToolCallItem;
674
675
class ChatPromptItem extends vscode.TreeItem {
676
private static readonly ids = new WeakMap<LoggedInfo, ChatPromptItem>();
677
override readonly contextValue = 'chatprompt';
678
public children: TreeChildItem[] = [];
679
public override id: string | undefined;
680
681
public static create(info: LoggedInfo, request: CapturingToken) {
682
const existing = ChatPromptItem.ids.get(info);
683
if (existing) {
684
return existing;
685
}
686
687
const item = new ChatPromptItem(request);
688
item.id = info.id + '-prompt';
689
ChatPromptItem.ids.set(info, item);
690
return item;
691
}
692
693
protected constructor(public readonly token: CapturingToken) {
694
super(token.label, vscode.TreeItemCollapsibleState.Expanded);
695
if (token.icon) {
696
this.iconPath = new vscode.ThemeIcon(token.icon);
697
}
698
}
699
700
/**
701
* The main entry associated with this parent node. Stored so that
702
* `withFilteredChildren` can re-resolve the icon freshly from the entry
703
* rather than copying a potentially stale `iconPath` snapshot.
704
*/
705
private _mainEntryRef: ILoggedRequestInfo | undefined;
706
707
/**
708
* Associate a main entry directly with this parent item.
709
* The main entry's icon and click command are shown on the parent node.
710
* The entry is NOT added as a child — it stays in the request logger
711
* for virtual document serving only.
712
*/
713
public setMainEntry(info: ILoggedRequestInfo): void {
714
if (info.entry.type !== LoggedRequestKind.MarkdownContentRequest) {
715
return;
716
}
717
this._mainEntryRef = info;
718
const resolvedIcon = resolveMarkdownIcon(info.entry);
719
this.iconPath = resolvedIcon !== undefined ? new vscode.ThemeIcon(resolvedIcon.id) : undefined;
720
this.command = {
721
command: 'vscode.open',
722
title: '',
723
arguments: [vscode.Uri.parse(ChatRequestScheme.buildUri({ kind: 'request', id: info.id }))]
724
};
725
}
726
727
public withFilteredChildren(filter: (child: TreeChildItem) => boolean): ChatPromptItem {
728
const item = new ChatPromptItem(this.token);
729
item.children = this.children.filter(filter);
730
item.id = this.id;
731
if (this._mainEntryRef) {
732
item.setMainEntry(this._mainEntryRef);
733
} else {
734
item.iconPath = this.iconPath;
735
item.command = this.command;
736
}
737
item.collapsibleState = item.children.length > 0
738
? vscode.TreeItemCollapsibleState.Expanded
739
: vscode.TreeItemCollapsibleState.None;
740
return item;
741
}
742
743
}
744
745
class ToolCallItem extends vscode.TreeItem {
746
public override id: string;
747
override readonly contextValue = 'toolcall';
748
constructor(
749
readonly info: ILoggedToolCall
750
) {
751
// todo@connor4312: we should have flags from the renderer whether it dropped any messages and indicate that here
752
super(info.name, vscode.TreeItemCollapsibleState.None);
753
this.id = `${info.id}_${info.time}`;
754
this.description = info.args === undefined ? '' : typeof info.args === 'string' ? info.args : JSON.stringify(info.args);
755
this.command = {
756
command: 'vscode.open',
757
title: '',
758
arguments: [vscode.Uri.parse(ChatRequestScheme.buildUri({ kind: 'request', id: info.id }))]
759
};
760
this.iconPath = new vscode.ThemeIcon('tools');
761
}
762
}
763
764
class ChatElementItem extends vscode.TreeItem {
765
public override readonly id?: string;
766
767
constructor(
768
readonly info: ILoggedElementInfo
769
) {
770
// todo@connor4312: we should have flags from the renderer whether it dropped any messages and indicate that here
771
super(`<${info.name}/>`, vscode.TreeItemCollapsibleState.None);
772
this.id = info.id;
773
this.description = `${info.tokens} tokens`;
774
this.command = { command: showHtmlCommand, title: '', arguments: [info.id] };
775
this.iconPath = new vscode.ThemeIcon('code');
776
}
777
}
778
779
class ChatRequestItem extends vscode.TreeItem {
780
public override id: string;
781
override readonly contextValue = 'request';
782
constructor(
783
readonly info: ILoggedRequestInfo
784
) {
785
super(info.entry.debugName, vscode.TreeItemCollapsibleState.None);
786
this.id = info.id;
787
788
if (info.entry.type === LoggedRequestKind.MarkdownContentRequest) {
789
const resolvedIcon = resolveMarkdownIcon(info.entry);
790
this.iconPath = resolvedIcon === undefined ? undefined : new vscode.ThemeIcon(resolvedIcon.id);
791
const startTimeStr = new Date(info.entry.startTimeMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
792
this.description = startTimeStr;
793
} else {
794
const durationMs = info.entry.endTime.getTime() - info.entry.startTime.getTime();
795
const timeStr = `${durationMs.toLocaleString('en-US')}ms`;
796
const startTimeStr = info.entry.startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
797
const tokensStr = info.entry.type === LoggedRequestKind.ChatMLSuccess && info.entry.usage ? `${info.entry.usage.prompt_tokens.toLocaleString('en-US')}tks` : '';
798
const tokensStrPart = tokensStr ? `[${tokensStr}] ` : '';
799
this.description = `${tokensStrPart}[${timeStr}] [${startTimeStr}]`;
800
801
this.iconPath = info.entry.type === LoggedRequestKind.ChatMLSuccess ? undefined : new vscode.ThemeIcon('error');
802
this.tooltip = `${info.entry.type === LoggedRequestKind.ChatMLCancelation ? 'cancelled' : info.entry.result.type}
803
${info.entry.chatEndpoint.model}
804
${timeStr}
805
${startTimeStr}`;
806
if (tokensStr) {
807
this.tooltip += `\n\t${tokensStr}`;
808
}
809
}
810
this.command = {
811
command: 'vscode.open',
812
title: '',
813
arguments: [vscode.Uri.parse(ChatRequestScheme.buildUri({ kind: 'request', id: info.id }))]
814
};
815
this.iconPath ??= new vscode.ThemeIcon('copilot');
816
}
817
}
818
819
class LogTreeFilters extends Disposable {
820
private _elementsShown = true;
821
private _toolsShown = true;
822
private _nesRequestsShown = true;
823
private _ghostRequestsShown = true;
824
825
private readonly _onDidChangeFilters = new vscode.EventEmitter<void>();
826
readonly onDidChangeFilters = this._onDidChangeFilters.event;
827
828
constructor(
829
@IVSCodeExtensionContext private readonly vscodeExtensionContext: IVSCodeExtensionContext,
830
) {
831
super();
832
833
this.setElementsShown(!vscodeExtensionContext.workspaceState.get(this.getStorageKey('elements')));
834
this.setToolsShown(!vscodeExtensionContext.workspaceState.get(this.getStorageKey('tools')));
835
this.setNesRequestsShown(!vscodeExtensionContext.workspaceState.get(this.getStorageKey('nesRequests')));
836
this.setGhostRequestsShown(!vscodeExtensionContext.workspaceState.get(this.getStorageKey('ghostRequests')));
837
}
838
839
private getStorageKey(name: string): string {
840
return `github.copilot.chat.debug.${name}Hidden`;
841
}
842
843
setElementsShown(value: boolean) {
844
this._elementsShown = value;
845
this.setShown('elements', this._elementsShown);
846
}
847
848
setToolsShown(value: boolean) {
849
this._toolsShown = value;
850
this.setShown('tools', this._toolsShown);
851
}
852
853
setNesRequestsShown(value: boolean) {
854
this._nesRequestsShown = value;
855
this.setShown('nesRequests', this._nesRequestsShown);
856
}
857
858
setGhostRequestsShown(value: boolean) {
859
this._ghostRequestsShown = value;
860
this.setShown('ghostRequests', this._ghostRequestsShown);
861
}
862
863
itemIncluded(item: TreeItem): boolean {
864
if (item instanceof ChatPromptItem) {
865
if (this.isNesRequest(item)) {
866
return this._nesRequestsShown;
867
}
868
if (this.isGhostRequest(item)) {
869
return this._ghostRequestsShown;
870
}
871
return true; // Always show chat prompt items
872
} else if (item instanceof ChatElementItem) {
873
return this._elementsShown;
874
} else if (item instanceof ToolCallItem) {
875
return this._toolsShown;
876
} else if (item instanceof ChatRequestItem) {
877
// Check if this is a NES request
878
if (this.isNesRequest(item)) {
879
return this._nesRequestsShown;
880
}
881
// Check if this is a Ghost request
882
if (this.isGhostRequest(item)) {
883
return this._ghostRequestsShown;
884
}
885
}
886
887
return true;
888
}
889
890
private isGhostRequest(item: ChatPromptItem | ChatRequestItem): boolean {
891
let debugName: string;
892
if (item instanceof ChatPromptItem) {
893
assert(typeof item.label === 'string', 'ChatPromptItem label must be a string');
894
debugName = item.label.toLowerCase();
895
} else {
896
debugName = item.info.entry.debugName.toLowerCase();
897
}
898
return debugName === 'ghost' || debugName.startsWith('ghost |');
899
}
900
901
private isNesRequest(item: ChatPromptItem | ChatRequestItem): boolean {
902
let debugName: string;
903
if (item instanceof ChatPromptItem) {
904
assert(typeof item.label === 'string', 'ChatPromptItem label must be a string');
905
debugName = item.label.toLowerCase();
906
} else {
907
debugName = item.info.entry.debugName.toLowerCase();
908
}
909
return debugName.startsWith('nes |') || debugName === 'xtabprovider' || debugName.startsWith('nes.');
910
}
911
912
private setShown(name: string, value: boolean): void {
913
vscode.commands.executeCommand('setContext', `github.copilot.chat.debug.${name}Hidden`, !value);
914
this.vscodeExtensionContext.workspaceState.update(this.getStorageKey(name), !value);
915
this._onDidChangeFilters.fire();
916
}
917
}
918
919
class LogTreeFilterCommands extends Disposable {
920
constructor(filters: LogTreeFilters) {
921
super();
922
923
this._register(vscode.commands.registerCommand('github.copilot.chat.debug.showElements', () => filters.setElementsShown(true)));
924
this._register(vscode.commands.registerCommand('github.copilot.chat.debug.hideElements', () => filters.setElementsShown(false)));
925
this._register(vscode.commands.registerCommand('github.copilot.chat.debug.showTools', () => filters.setToolsShown(true)));
926
this._register(vscode.commands.registerCommand('github.copilot.chat.debug.hideTools', () => filters.setToolsShown(false)));
927
this._register(vscode.commands.registerCommand('github.copilot.chat.debug.showNesRequests', () => filters.setNesRequestsShown(true)));
928
this._register(vscode.commands.registerCommand('github.copilot.chat.debug.hideNesRequests', () => filters.setNesRequestsShown(false)));
929
this._register(vscode.commands.registerCommand('github.copilot.chat.debug.showGhostRequests', () => filters.setGhostRequestsShown(true)));
930
this._register(vscode.commands.registerCommand('github.copilot.chat.debug.hideGhostRequests', () => filters.setGhostRequestsShown(false)));
931
}
932
}
933
934