Path: blob/main/extensions/copilot/src/extension/log/vscode-node/requestLogTree.ts
13399 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 { IHTMLRouter } from '@vscode/prompt-tsx';6import { createServer } from 'http';7import { AddressInfo } from 'net';8import * as os from 'os';9import * as path from 'path';10import * as tar from 'tar';11import * as vscode from 'vscode';12import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';13import { outputChannel } from '../../../platform/log/vscode/outputChannelLogTarget';14import { CapturingToken } from '../../../platform/requestLogger/common/capturingToken';15import { ChatRequestScheme, ILoggedElementInfo, ILoggedRequestInfo, ILoggedToolCall, IRequestLogger, LoggedInfo, LoggedInfoKind, LoggedRequestKind, resolveMarkdownIcon } from '../../../platform/requestLogger/common/requestLogger';16import { filterMap } from '../../../util/common/arrays';17import { assert, assertNever } from '../../../util/vs/base/common/assert';18import { Disposable, toDisposable } from '../../../util/vs/base/common/lifecycle';19import { LRUCache } from '../../../util/vs/base/common/map';20import { isDefined } from '../../../util/vs/base/common/types';21import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';22import { IExtensionContribution } from '../../common/contributions';23import { assembleChatLogExport, createExportedPrompt, ExportedPrompt, serializeChatLogExport } from '../node/chatLogExport';2425const showHtmlCommand = 'vscode.copilot.chat.showRequestHtmlItem';26const exportLogItemCommand = 'github.copilot.chat.debug.exportLogItem';27const exportPromptArchiveCommand = 'github.copilot.chat.debug.exportPromptArchive';2829/**30* Serialize MCP server definitions to a JSON-safe format.31* Excludes sensitive headers like Authorization.32*/33function serializeMcpServers(servers: readonly vscode.McpServerDefinition[]): object[] {34return servers.map(server => {35if (server instanceof vscode.McpStdioServerDefinition) {36return {37type: 'stdio',38label: server.label,39command: server.command,40args: server.args,41cwd: server.cwd?.toString(),42version: server.version43};44} else {45return {46type: 'http',47label: server.label,48uri: server.uri.with({ authority: '[authority]', query: '', fragment: '' }).toString(),49version: server.version50};51}52});53}54const exportPromptLogsAsJsonCommand = 'github.copilot.chat.debug.exportPromptLogsAsJson';55const exportAllPromptLogsAsJsonCommand = 'github.copilot.chat.debug.exportAllPromptLogsAsJson';56const saveCurrentMarkdownCommand = 'github.copilot.chat.debug.saveCurrentMarkdown';57const showRawRequestBodyCommand = 'github.copilot.chat.debug.showRawRequestBody';5859export class RequestLogTree extends Disposable implements IExtensionContribution {60readonly id = 'requestLogTree';61private readonly chatRequestProvider: ChatRequestProvider;6263constructor(64@IInstantiationService instantiationService: IInstantiationService,65@IRequestLogger requestLogger: IRequestLogger,66) {67super();68this.chatRequestProvider = this._register(instantiationService.createInstance(ChatRequestProvider));69this._register(vscode.window.registerTreeDataProvider('copilot-chat', this.chatRequestProvider));7071let server: RequestServer | undefined;7273const getExportableLogEntries = (treeItem: ChatPromptItem): LoggedInfo[] => {74if (!treeItem || !treeItem.children) {75return [];76}7778const logEntries = treeItem.children.map(child => {79if (child instanceof ChatRequestItem || child instanceof ToolCallItem || child instanceof ChatElementItem) {80return child.info;81}82return undefined; // Skip non-loggable items83}).filter(isDefined);8485return logEntries;86};8788// Helper method to process log entries for a single prompt using shared export function89const preparePromptLogsAsJson = async (treeItem: ChatPromptItem): Promise<ExportedPrompt | undefined> => {90const logEntries = getExportableLogEntries(treeItem);9192if (logEntries.length === 0) {93return;94}9596return createExportedPrompt(treeItem.token.label, logEntries, {97promptId: treeItem.id,98});99};100101this._register(vscode.commands.registerCommand(showHtmlCommand, async (elementId: string) => {102if (!server) {103server = this._register(new RequestServer());104}105106const req = requestLogger.getRequests().find(r => r.kind === LoggedInfoKind.Element && r.id === elementId);107if (!req) {108return;109}110111const address = await server.addRouter(req as ILoggedElementInfo);112await vscode.commands.executeCommand('simpleBrowser.show', address);113}));114115this._register(vscode.commands.registerCommand(exportLogItemCommand, async (treeItem: TreeItem) => {116if (!treeItem || !treeItem.id) {117return;118}119120let logEntry: LoggedInfo;121122if (treeItem instanceof ChatPromptItem) {123// ChatPromptItem doesn't represent a single log entry124vscode.window.showWarningMessage('Cannot export chat prompt item. Please select a specific request, tool call, or element.');125return;126} else if (treeItem instanceof ChatRequestItem || treeItem instanceof ToolCallItem || treeItem instanceof ChatElementItem) {127logEntry = treeItem.info;128} else {129vscode.window.showErrorMessage('Unable to determine log entry ID for this item.');130return;131}132133// Check if this entry type supports markdown export134if (logEntry.kind === LoggedInfoKind.Element) {135vscode.window.showWarningMessage('Element entries cannot be exported as markdown. They contain HTML content that can be viewed in the browser.');136return;137}138139// Generate a default filename based on the entry type and id140let defaultFilename: string;141switch (logEntry.kind) {142case LoggedInfoKind.Request: {143const requestEntry = logEntry as ILoggedRequestInfo;144const debugName = requestEntry.entry.debugName.replace(/\W/g, '_');145defaultFilename = `${debugName}_${logEntry.id}.copilotmd`;146break;147}148case LoggedInfoKind.ToolCall: {149const toolEntry = logEntry as ILoggedToolCall;150const toolName = toolEntry.name.replace(/\W/g, '_');151defaultFilename = `tool_${toolName}_${logEntry.id}.copilotmd`;152break;153}154}155156if (!defaultFilename) {157return;158}159160// Show save dialog161const saveUri = await vscode.window.showSaveDialog({162defaultUri: vscode.Uri.file(path.join(os.homedir(), defaultFilename)),163filters: {164'Copilot Markdown': ['copilotmd'],165'Markdown': ['md'],166'All Files': ['*']167},168title: 'Export Log Entry'169});170171if (!saveUri) {172return; // User cancelled173}174175try {176// Get the content using the virtual document URI177const virtualUri = vscode.Uri.parse(ChatRequestScheme.buildUri({ kind: 'request', id: logEntry.id }));178const document = await vscode.workspace.openTextDocument(virtualUri);179const content = document.getText();180181// Write to the selected file182await vscode.workspace.fs.writeFile(saveUri, Buffer.from(content, 'utf8'));183184// Show success message with option to open the file185const openAction = 'Open File';186const result = await vscode.window.showInformationMessage(187`Successfully exported to ${saveUri.fsPath}`,188openAction189);190191if (result === openAction) {192await vscode.commands.executeCommand('vscode.open', saveUri);193}194} catch (error) {195vscode.window.showErrorMessage(`Failed to export log entry: ${error}`);196}197}));198199// Save the currently opened chat log (ccreq:*.copilotmd) to a file200this._register(vscode.commands.registerCommand(saveCurrentMarkdownCommand, async (...args: any[]) => {201// Accept resource from menu invocation (editor/title passes the resource)202let resource: vscode.Uri | undefined;203const first = args?.[0];204if (first instanceof vscode.Uri) {205resource = first;206} else if (first && typeof first === 'object') {207// Some menu invocations pass { resource: Uri }208const candidate = (first as { resource?: vscode.Uri }).resource;209if (candidate instanceof vscode.Uri) {210resource = candidate;211}212}213214// Fallback to the active editor's document215resource ??= vscode.window.activeTextEditor?.document.uri;216if (!resource) {217vscode.window.showWarningMessage('No document is active to save.');218return;219}220221if (resource.scheme !== ChatRequestScheme.chatRequestScheme) {222vscode.window.showWarningMessage('This command only works for Copilot request documents.');223return;224}225226// Determine a default filename from the virtual URI227const parseResult = ChatRequestScheme.parseUri(resource.toString());228const defaultBase = parseResult && parseResult.data.kind === 'request' ? parseResult.data.id : 'latestrequest';229const defaultFilename = `${defaultBase}.md`;230231const saveUri = await vscode.window.showSaveDialog({232defaultUri: vscode.Uri.file(path.join(os.homedir(), defaultFilename)),233filters: {234'Markdown': ['md'],235'Copilot Markdown': ['copilotmd'],236'All Files': ['*']237},238title: 'Save Markdown As'239});240241if (!saveUri) {242return; // User cancelled243}244245try {246// Read the text from the virtual document URI explicitly247const doc = await vscode.workspace.openTextDocument(resource);248await vscode.workspace.fs.writeFile(saveUri, Buffer.from(doc.getText(), 'utf8'));249250const openAction = 'Open File';251const result = await vscode.window.showInformationMessage(252`Successfully saved to ${saveUri.fsPath}`,253openAction254);255256if (result === openAction) {257await vscode.commands.executeCommand('vscode.open', saveUri);258}259} catch (error) {260vscode.window.showErrorMessage(`Failed to save markdown: ${error}`);261}262}));263264this._register(vscode.commands.registerCommand(exportPromptArchiveCommand, async (treeItem: ChatPromptItem) => {265const logEntries = getExportableLogEntries(treeItem);266267if (logEntries.length === 0) {268vscode.window.showInformationMessage('No exportable entries found in this prompt.');269return;270}271272// Generate a default filename based on the prompt273const promptText = treeItem.token.label.replace(/\W/g, '_').substring(0, 50);274const defaultFilename = `${promptText}_exports.tar.gz`;275276// Show save dialog277const saveUri = await vscode.window.showSaveDialog({278defaultUri: vscode.Uri.file(path.join(os.homedir(), defaultFilename)),279filters: {280'Tar Archive': ['tar.gz', 'tgz'],281'All Files': ['*']282},283title: 'Export Prompt Archive'284});285286if (!saveUri) {287return; // User cancelled288}289290try {291// Create temporary directory for files292const tempDir = path.join(os.tmpdir(), `vscode-copilot-export-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`);293await vscode.workspace.fs.createDirectory(vscode.Uri.file(tempDir));294295const filesToArchive: string[] = [];296297// Export each child to a temporary file298for (const logEntry of logEntries) {299// Generate filename for this entry300let filename: string;301switch (logEntry.kind) {302case LoggedInfoKind.Request: {303const requestEntry = logEntry as ILoggedRequestInfo;304const debugName = requestEntry.entry.debugName.replace(/\W/g, '_');305filename = `${debugName}_${logEntry.id}.copilotmd`;306break;307}308case LoggedInfoKind.ToolCall: {309const toolEntry = logEntry as ILoggedToolCall;310const toolName = toolEntry.name.replace(/\W/g, '_');311filename = `tool_${toolName}_${logEntry.id}.copilotmd`;312break;313}314default:315continue;316}317318// Get the content and write to temporary file319const virtualUri = vscode.Uri.parse(ChatRequestScheme.buildUri({ kind: 'request', id: logEntry.id }));320const document = await vscode.workspace.openTextDocument(virtualUri);321const content = document.getText();322323const tempFilePath = path.join(tempDir, filename);324await vscode.workspace.fs.writeFile(vscode.Uri.file(tempFilePath), Buffer.from(content, 'utf8'));325filesToArchive.push(tempFilePath);326}327328if (filesToArchive.length > 0) {329// Create tar.gz archive330await tar.create(331{332gzip: true,333file: saveUri.fsPath,334cwd: tempDir335},336filesToArchive.map(f => path.basename(f))337);338339// Clean up temporary files340for (const filePath of filesToArchive) {341await vscode.workspace.fs.delete(vscode.Uri.file(filePath));342}343await vscode.workspace.fs.delete(vscode.Uri.file(tempDir));344345// Show success message with option to reveal the file346const revealAction = 'Reveal in Explorer';347const result = await vscode.window.showInformationMessage(348`Successfully exported ${filesToArchive.length} entries to ${saveUri.fsPath}`,349revealAction350);351352if (result === revealAction) {353await vscode.commands.executeCommand('revealFileInOS', saveUri);354}355} else {356vscode.window.showWarningMessage('No valid entries could be exported.');357}358} catch (error) {359vscode.window.showErrorMessage(`Failed to export prompt archive: ${error}`);360}361}));362363this._register(vscode.commands.registerCommand(exportPromptLogsAsJsonCommand, async (treeItem: ChatPromptItem) => {364const promptObject = await preparePromptLogsAsJson(treeItem);365if (!promptObject) {366vscode.window.showWarningMessage('No exportable entries found for this prompt.');367return;368}369370// Generate a default filename based on the prompt371const promptText = treeItem.token.label.replace(/\W/g, '_').substring(0, 50);372const defaultFilename = `${promptText}_logs.json`;373374// Show save dialog375const saveUri = await vscode.window.showSaveDialog({376defaultUri: vscode.Uri.file(path.join(os.homedir(), defaultFilename)),377filters: {378'JSON': ['json'],379'All Files': ['*']380},381title: 'Export Prompt Logs as JSON'382});383384if (!saveUri) {385return; // User cancelled386}387388try {389// Convert to JSON390const finalContent = JSON.stringify(promptObject, null, 2);391392// Write to the selected file393await vscode.workspace.fs.writeFile(saveUri, Buffer.from(finalContent, 'utf8'));394395// Show success message with option to reveal the file396const revealAction = 'Reveal in Explorer';397const openAction = 'Open File';398const result = await vscode.window.showInformationMessage(399`Successfully exported prompt with ${promptObject.logCount} log entries to ${saveUri.fsPath}`,400revealAction,401openAction402);403404if (result === revealAction) {405await vscode.commands.executeCommand('revealFileInOS', saveUri);406} else if (result === openAction) {407await vscode.commands.executeCommand('vscode.open', saveUri);408}409} catch (error) {410vscode.window.showErrorMessage(`Failed to export prompt logs as JSON: ${error}`);411}412}));413414this._register(vscode.commands.registerCommand(exportAllPromptLogsAsJsonCommand, async (savePath?: string) => {415// Build the tree structure to get all chat prompt items416const allTreeItems = await this.chatRequestProvider.getChildren();417418if (!allTreeItems || allTreeItems.length === 0) {419vscode.window.showInformationMessage('No chat prompts found to export.');420return;421}422423// Filter to only include ChatPromptItem entries424const exportableItems = allTreeItems.filter(item =>425item instanceof ChatPromptItem426);427428if (exportableItems.length === 0) {429vscode.window.showInformationMessage('No chat prompts found to export.');430return;431}432433let saveUri: vscode.Uri;434435if (savePath && typeof savePath === 'string') {436// Use provided path437saveUri = vscode.Uri.file(savePath);438} else {439// Generate a default filename based on current timestamp440const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);441const defaultFilename = `copilot_all_prompts_${timestamp}.json`;442443// Show save dialog444const dialogResult = await vscode.window.showSaveDialog({445defaultUri: vscode.Uri.file(path.join(os.homedir(), defaultFilename)),446filters: {447'JSON': ['json'],448'All Files': ['*']449},450title: 'Export All Prompt Logs as JSON'451});452453if (!dialogResult) {454return; // User cancelled455}456saveUri = dialogResult;457}458459try {460const allPromptsContent: ExportedPrompt[] = [];461462for (const exportableItem of exportableItems) {463if (exportableItem instanceof ChatPromptItem) {464const promptObject = await preparePromptLogsAsJson(exportableItem);465if (promptObject) {466allPromptsContent.push(promptObject);467}468}469}470471// Use shared export assembly function472const exportData = assembleChatLogExport(473allPromptsContent,474serializeMcpServers(vscode.lm.mcpServerDefinitions ?? [])475);476const finalContent = serializeChatLogExport(exportData);477478// Write to the selected file479await vscode.workspace.fs.writeFile(saveUri, Buffer.from(finalContent, 'utf8'));480481// Show success message with option to reveal the file (only for user-initiated calls)482if (!savePath) {483const revealAction = 'Reveal in Explorer';484const openAction = 'Open File';485const result = await vscode.window.showInformationMessage(486`Successfully exported ${exportData.totalPrompts} prompts with ${exportData.totalLogEntries} log entries to ${saveUri.fsPath}`,487revealAction,488openAction489);490491if (result === revealAction) {492await vscode.commands.executeCommand('revealFileInOS', saveUri);493} else if (result === openAction) {494await vscode.commands.executeCommand('vscode.open', saveUri);495}496}497} catch (error) {498vscode.window.showErrorMessage(`Failed to export all prompt logs as JSON: ${error}`);499}500}));501502this._register(vscode.commands.registerCommand(showRawRequestBodyCommand, async (arg?: ChatPromptItem) => {503const requestId = arg?.id;504if (!requestId) {505return;506}507508await vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(ChatRequestScheme.buildUri({ kind: 'request', id: requestId }, 'rawrequest')));509}));510511this._register(vscode.commands.registerCommand('github.copilot.debug.showOutputChannel', async () => {512outputChannel.show();513}));514}515}516517518/**519* Servers that shows logged request html for the simple browser. Doing this520* is annoying, but the markdown renderer is limited and doesn't show full HTML,521* and the simple browser extension can't render internal or `file://` URIs.522*523* Note that we don't need secret tokens or anything at this point because the524* server is read-only and does not advertise any CORS headers.525*/526class RequestServer extends Disposable {527public port: Promise<number>;528private routers = new LRUCache<string, IHTMLRouter>(10);529530constructor() {531super();532533const server = createServer((req, res) => {534for (const [key, router] of this.routers) {535if (router.route(req, res)) {536this.routers.get(key); // LRU touch537return;538}539}540541res.statusCode = 404;542res.end('Not Found');543});544545this.port = new Promise<number>((resolve, reject) => {546server.listen(0, '127.0.0.1', () => resolve((server.address() as AddressInfo).port)).on('error', reject);547});548549this._register(toDisposable(() => server.close()));550}551552async addRouter(info: ILoggedElementInfo) {553const prev = this.routers.get(info.id);554if (prev) {555return prev.address;556}557558const port = await this.port;559const router = info.trace.serveRouter(`http://127.0.0.1:${port}`);560this.routers.set(info.id, router);561return router.address;562}563}564565type TreeItem = ChatPromptItem | ChatRequestItem | ChatElementItem | ToolCallItem;566567class ChatRequestProvider extends Disposable implements vscode.TreeDataProvider<TreeItem> {568private readonly filters: LogTreeFilters;569570constructor(571@IRequestLogger private readonly requestLogger: IRequestLogger,572@IInstantiationService instantiationService: IInstantiationService,573) {574super();575this.filters = this._register(instantiationService.createInstance(LogTreeFilters));576this._register(new LogTreeFilterCommands(this.filters));577this._register(this.requestLogger.onDidChangeRequests(() => this._onDidChangeTreeData.fire()));578this._register(this.filters.onDidChangeFilters(() => this._onDidChangeTreeData.fire()));579}580581private readonly _onDidChangeTreeData = new vscode.EventEmitter<TreeItem | undefined | void>();582onDidChangeTreeData = this._onDidChangeTreeData.event;583584getTreeItem(element: TreeItem): vscode.TreeItem | Thenable<vscode.TreeItem> {585return element;586}587588getChildren(element?: TreeItem | undefined): vscode.ProviderResult<TreeItem[]> {589if (element instanceof ChatPromptItem) {590return element.children;591} else if (element) {592return [];593} else {594const result: (ChatPromptItem | TreeChildItem)[] = [];595const tokenToPrompt = new Map<CapturingToken, ChatPromptItem>();596597for (const currReq of this.requestLogger.getRequests()) {598if (!currReq.token) {599// Skip non-main hidden entries (e.g. skipped/cancelled live NES requests)600if (currReq.kind === LoggedInfoKind.Request &&601currReq.entry.type === LoggedRequestKind.MarkdownContentRequest &&602currReq.entry.isVisible && !currReq.entry.isVisible()) {603continue;604}605606result.push(this.logToTreeItem(currReq));607continue;608}609610let prompt = tokenToPrompt.get(currReq.token);611if (!prompt) {612prompt = ChatPromptItem.create(currReq, currReq.token);613tokenToPrompt.set(currReq.token, prompt);614result.push(prompt);615}616617// If this entry is the main entry for the group (a MarkdownContentRequest618// whose debugName matches the token label), associate it directly with the619// parent ChatPromptItem — don't add it as a child. The entry stays in the620// request logger for virtual document serving; only tree nesting changes.621// Always wire the main entry so the parent node is clickable and shows the622// current icon (e.g. loading, lightbulb, skipped, circleSlash, etc.).623if (currReq.kind === LoggedInfoKind.Request &&624currReq.entry.type === LoggedRequestKind.MarkdownContentRequest &&625currReq.entry.debugName === currReq.token.label) {626prompt.setMainEntry(currReq);627continue;628}629630// Skip non-main hidden entries631if (currReq.kind === LoggedInfoKind.Request &&632currReq.entry.type === LoggedRequestKind.MarkdownContentRequest &&633currReq.entry.isVisible && !currReq.entry.isVisible()) {634continue;635}636637const currReqTreeItem = this.logToTreeItem(currReq);638const alreadyIncluded = prompt.children.find(existingChild => existingChild.id === currReqTreeItem.id);639if (!alreadyIncluded) {640prompt.children.push(currReqTreeItem);641}642}643644return filterMap(result, r => {645if (!this.filters.itemIncluded(r)) {646return undefined;647}648649if (r instanceof ChatPromptItem) {650return r.withFilteredChildren(child => this.filters.itemIncluded(child));651}652653return r;654});655}656}657658private logToTreeItem(r: LoggedInfo): TreeChildItem {659switch (r.kind) {660case LoggedInfoKind.Request:661return new ChatRequestItem(r);662case LoggedInfoKind.Element:663return new ChatElementItem(r);664case LoggedInfoKind.ToolCall:665return new ToolCallItem(r);666default:667assertNever(r);668}669}670}671672type TreeChildItem = ChatRequestItem | ChatElementItem | ToolCallItem;673674class ChatPromptItem extends vscode.TreeItem {675private static readonly ids = new WeakMap<LoggedInfo, ChatPromptItem>();676override readonly contextValue = 'chatprompt';677public children: TreeChildItem[] = [];678public override id: string | undefined;679680public static create(info: LoggedInfo, request: CapturingToken) {681const existing = ChatPromptItem.ids.get(info);682if (existing) {683return existing;684}685686const item = new ChatPromptItem(request);687item.id = info.id + '-prompt';688ChatPromptItem.ids.set(info, item);689return item;690}691692protected constructor(public readonly token: CapturingToken) {693super(token.label, vscode.TreeItemCollapsibleState.Expanded);694if (token.icon) {695this.iconPath = new vscode.ThemeIcon(token.icon);696}697}698699/**700* The main entry associated with this parent node. Stored so that701* `withFilteredChildren` can re-resolve the icon freshly from the entry702* rather than copying a potentially stale `iconPath` snapshot.703*/704private _mainEntryRef: ILoggedRequestInfo | undefined;705706/**707* Associate a main entry directly with this parent item.708* The main entry's icon and click command are shown on the parent node.709* The entry is NOT added as a child — it stays in the request logger710* for virtual document serving only.711*/712public setMainEntry(info: ILoggedRequestInfo): void {713if (info.entry.type !== LoggedRequestKind.MarkdownContentRequest) {714return;715}716this._mainEntryRef = info;717const resolvedIcon = resolveMarkdownIcon(info.entry);718this.iconPath = resolvedIcon !== undefined ? new vscode.ThemeIcon(resolvedIcon.id) : undefined;719this.command = {720command: 'vscode.open',721title: '',722arguments: [vscode.Uri.parse(ChatRequestScheme.buildUri({ kind: 'request', id: info.id }))]723};724}725726public withFilteredChildren(filter: (child: TreeChildItem) => boolean): ChatPromptItem {727const item = new ChatPromptItem(this.token);728item.children = this.children.filter(filter);729item.id = this.id;730if (this._mainEntryRef) {731item.setMainEntry(this._mainEntryRef);732} else {733item.iconPath = this.iconPath;734item.command = this.command;735}736item.collapsibleState = item.children.length > 0737? vscode.TreeItemCollapsibleState.Expanded738: vscode.TreeItemCollapsibleState.None;739return item;740}741742}743744class ToolCallItem extends vscode.TreeItem {745public override id: string;746override readonly contextValue = 'toolcall';747constructor(748readonly info: ILoggedToolCall749) {750// todo@connor4312: we should have flags from the renderer whether it dropped any messages and indicate that here751super(info.name, vscode.TreeItemCollapsibleState.None);752this.id = `${info.id}_${info.time}`;753this.description = info.args === undefined ? '' : typeof info.args === 'string' ? info.args : JSON.stringify(info.args);754this.command = {755command: 'vscode.open',756title: '',757arguments: [vscode.Uri.parse(ChatRequestScheme.buildUri({ kind: 'request', id: info.id }))]758};759this.iconPath = new vscode.ThemeIcon('tools');760}761}762763class ChatElementItem extends vscode.TreeItem {764public override readonly id?: string;765766constructor(767readonly info: ILoggedElementInfo768) {769// todo@connor4312: we should have flags from the renderer whether it dropped any messages and indicate that here770super(`<${info.name}/>`, vscode.TreeItemCollapsibleState.None);771this.id = info.id;772this.description = `${info.tokens} tokens`;773this.command = { command: showHtmlCommand, title: '', arguments: [info.id] };774this.iconPath = new vscode.ThemeIcon('code');775}776}777778class ChatRequestItem extends vscode.TreeItem {779public override id: string;780override readonly contextValue = 'request';781constructor(782readonly info: ILoggedRequestInfo783) {784super(info.entry.debugName, vscode.TreeItemCollapsibleState.None);785this.id = info.id;786787if (info.entry.type === LoggedRequestKind.MarkdownContentRequest) {788const resolvedIcon = resolveMarkdownIcon(info.entry);789this.iconPath = resolvedIcon === undefined ? undefined : new vscode.ThemeIcon(resolvedIcon.id);790const startTimeStr = new Date(info.entry.startTimeMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });791this.description = startTimeStr;792} else {793const durationMs = info.entry.endTime.getTime() - info.entry.startTime.getTime();794const timeStr = `${durationMs.toLocaleString('en-US')}ms`;795const startTimeStr = info.entry.startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });796const tokensStr = info.entry.type === LoggedRequestKind.ChatMLSuccess && info.entry.usage ? `${info.entry.usage.prompt_tokens.toLocaleString('en-US')}tks` : '';797const tokensStrPart = tokensStr ? `[${tokensStr}] ` : '';798this.description = `${tokensStrPart}[${timeStr}] [${startTimeStr}]`;799800this.iconPath = info.entry.type === LoggedRequestKind.ChatMLSuccess ? undefined : new vscode.ThemeIcon('error');801this.tooltip = `${info.entry.type === LoggedRequestKind.ChatMLCancelation ? 'cancelled' : info.entry.result.type}802${info.entry.chatEndpoint.model}803${timeStr}804${startTimeStr}`;805if (tokensStr) {806this.tooltip += `\n\t${tokensStr}`;807}808}809this.command = {810command: 'vscode.open',811title: '',812arguments: [vscode.Uri.parse(ChatRequestScheme.buildUri({ kind: 'request', id: info.id }))]813};814this.iconPath ??= new vscode.ThemeIcon('copilot');815}816}817818class LogTreeFilters extends Disposable {819private _elementsShown = true;820private _toolsShown = true;821private _nesRequestsShown = true;822private _ghostRequestsShown = true;823824private readonly _onDidChangeFilters = new vscode.EventEmitter<void>();825readonly onDidChangeFilters = this._onDidChangeFilters.event;826827constructor(828@IVSCodeExtensionContext private readonly vscodeExtensionContext: IVSCodeExtensionContext,829) {830super();831832this.setElementsShown(!vscodeExtensionContext.workspaceState.get(this.getStorageKey('elements')));833this.setToolsShown(!vscodeExtensionContext.workspaceState.get(this.getStorageKey('tools')));834this.setNesRequestsShown(!vscodeExtensionContext.workspaceState.get(this.getStorageKey('nesRequests')));835this.setGhostRequestsShown(!vscodeExtensionContext.workspaceState.get(this.getStorageKey('ghostRequests')));836}837838private getStorageKey(name: string): string {839return `github.copilot.chat.debug.${name}Hidden`;840}841842setElementsShown(value: boolean) {843this._elementsShown = value;844this.setShown('elements', this._elementsShown);845}846847setToolsShown(value: boolean) {848this._toolsShown = value;849this.setShown('tools', this._toolsShown);850}851852setNesRequestsShown(value: boolean) {853this._nesRequestsShown = value;854this.setShown('nesRequests', this._nesRequestsShown);855}856857setGhostRequestsShown(value: boolean) {858this._ghostRequestsShown = value;859this.setShown('ghostRequests', this._ghostRequestsShown);860}861862itemIncluded(item: TreeItem): boolean {863if (item instanceof ChatPromptItem) {864if (this.isNesRequest(item)) {865return this._nesRequestsShown;866}867if (this.isGhostRequest(item)) {868return this._ghostRequestsShown;869}870return true; // Always show chat prompt items871} else if (item instanceof ChatElementItem) {872return this._elementsShown;873} else if (item instanceof ToolCallItem) {874return this._toolsShown;875} else if (item instanceof ChatRequestItem) {876// Check if this is a NES request877if (this.isNesRequest(item)) {878return this._nesRequestsShown;879}880// Check if this is a Ghost request881if (this.isGhostRequest(item)) {882return this._ghostRequestsShown;883}884}885886return true;887}888889private isGhostRequest(item: ChatPromptItem | ChatRequestItem): boolean {890let debugName: string;891if (item instanceof ChatPromptItem) {892assert(typeof item.label === 'string', 'ChatPromptItem label must be a string');893debugName = item.label.toLowerCase();894} else {895debugName = item.info.entry.debugName.toLowerCase();896}897return debugName === 'ghost' || debugName.startsWith('ghost |');898}899900private isNesRequest(item: ChatPromptItem | ChatRequestItem): boolean {901let debugName: string;902if (item instanceof ChatPromptItem) {903assert(typeof item.label === 'string', 'ChatPromptItem label must be a string');904debugName = item.label.toLowerCase();905} else {906debugName = item.info.entry.debugName.toLowerCase();907}908return debugName.startsWith('nes |') || debugName === 'xtabprovider' || debugName.startsWith('nes.');909}910911private setShown(name: string, value: boolean): void {912vscode.commands.executeCommand('setContext', `github.copilot.chat.debug.${name}Hidden`, !value);913this.vscodeExtensionContext.workspaceState.update(this.getStorageKey(name), !value);914this._onDidChangeFilters.fire();915}916}917918class LogTreeFilterCommands extends Disposable {919constructor(filters: LogTreeFilters) {920super();921922this._register(vscode.commands.registerCommand('github.copilot.chat.debug.showElements', () => filters.setElementsShown(true)));923this._register(vscode.commands.registerCommand('github.copilot.chat.debug.hideElements', () => filters.setElementsShown(false)));924this._register(vscode.commands.registerCommand('github.copilot.chat.debug.showTools', () => filters.setToolsShown(true)));925this._register(vscode.commands.registerCommand('github.copilot.chat.debug.hideTools', () => filters.setToolsShown(false)));926this._register(vscode.commands.registerCommand('github.copilot.chat.debug.showNesRequests', () => filters.setNesRequestsShown(true)));927this._register(vscode.commands.registerCommand('github.copilot.chat.debug.hideNesRequests', () => filters.setNesRequestsShown(false)));928this._register(vscode.commands.registerCommand('github.copilot.chat.debug.showGhostRequests', () => filters.setGhostRequestsShown(true)));929this._register(vscode.commands.registerCommand('github.copilot.chat.debug.hideGhostRequests', () => filters.setGhostRequestsShown(false)));930}931}932933934