Path: blob/main/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts
13406 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { localize } from '../../../../../nls.js';6import { IChatDebugEvent } from '../../common/chatDebugService.js';78// ---- Data model ----910export interface FlowNode {11readonly id: string;12readonly kind: IChatDebugEvent['kind'];13/** For `generic` nodes: the event category (e.g. `'discovery'`). Used to narrow filtering. */14readonly category?: string;15readonly label: string;16readonly sublabel?: string;17readonly description?: string;18readonly tooltip?: string;19readonly isError?: boolean;20readonly created: number;21readonly children: FlowNode[];22/** Present on merged discovery nodes: the individual nodes that were merged. */23readonly mergedNodes?: FlowNode[];24}2526export interface FlowFilterOptions {27readonly isKindVisible: (kind: string, category?: string) => boolean;28readonly textFilter: string;29}3031export interface LayoutNode {32readonly id: string;33readonly kind: IChatDebugEvent['kind'];34readonly label: string;35readonly sublabel?: string;36readonly tooltip?: string;37readonly isError?: boolean;38readonly x: number;39readonly y: number;40readonly width: number;41readonly height: number;42/** Number of individual nodes merged into this one (for discovery merging). */43readonly mergedCount?: number;44/** Whether the merged node is currently expanded (individual nodes shown to the right). */45readonly isMergedExpanded?: boolean;46}4748export interface LayoutEdge {49readonly fromId?: string;50readonly toId?: string;51readonly fromX: number;52readonly fromY: number;53readonly toX: number;54readonly toY: number;55}5657export interface SubgraphRect {58readonly label: string;59readonly x: number;60readonly y: number;61readonly width: number;62readonly height: number;63readonly depth: number;64readonly nodeId: string;65readonly collapsedChildCount?: number;66}6768export interface FlowLayout {69readonly nodes: LayoutNode[];70readonly edges: LayoutEdge[];71readonly subgraphs: SubgraphRect[];72readonly width: number;73readonly height: number;74}7576export interface FlowChartRenderResult {77readonly svg: SVGElement;78/** Map from node/subgraph ID to its focusable SVG element. */79readonly focusableElements: Map<string, SVGElement>;80/** Adjacency lists derived from graph edges: successors and predecessors per node ID. */81readonly adjacency: Map<string, { next: string[]; prev: string[] }>;82/** Map from node/subgraph ID to its layout position. */83readonly positions: Map<string, { x: number; y: number }>;84}8586// ---- Build flow graph from debug events ----8788/**89* Truncates a string to a max length, appending an ellipsis if trimmed.90*/91function truncateLabel(text: string, maxLength: number): string {92if (text.length <= maxLength) {93return text;94}95return text.substring(0, maxLength - 1) + '\u2026';96}9798export function buildFlowGraph(events: readonly IChatDebugEvent[]): FlowNode[] {99// Before filtering, extract description metadata from subagent events100// that will be filtered out, so we can enrich the surviving sibling events.101const subagentToolNames = ['runSubagent', 'search_subagent'];102103/**104* Check whether a name matches a known subagent tool name.105* Handles exact matches and names with suffixes (e.g.106* "runSubagent-default", "runSubagent (agent)", "runSubagent: desc").107* Callers must strip any emoji prefix before calling.108*/109function isSubagentName(name: string): boolean {110for (const toolName of subagentToolNames) {111if (name === toolName) {112return true;113}114if (name.startsWith(toolName)) {115const nextChar = name[toolName.length];116if (nextChar === '-' || nextChar === ' ' || nextChar === '(' || nextChar === ':') {117return true;118}119}120}121return false;122}123124/** Strip the leading tool emoji prefix if present. */125const emojiPrefixRe = /^\u{1F6E0}\uFE0F?\s*/u;126function stripToolEmoji(name: string): string {127return name.replace(emojiPrefixRe, '');128}129130// The extension may emit two subagentInvocation events per subagent:131// 1. "started" marker (agentName = descriptive name, status = running) — survives filtering132// 2. completion event (agentName = "runSubagent" / "runSubagent-*", status = completed) — filtered out133// The completion event carries the real description. When multiple subagents134// run under the same parent, they share a parentEventId, so we match them135// by order: the N-th started marker gets the N-th completion's description.136const completionDescsByParent = new Map<string, string[]>();137const startedCountByParent = new Map<string, number>();138for (const e of events) {139if (e.kind === 'subagentInvocation' && isSubagentName(e.agentName) && e.description && e.parentEventId) {140let descs = completionDescsByParent.get(e.parentEventId);141if (!descs) {142descs = [];143completionDescsByParent.set(e.parentEventId, descs);144}145descs.push(e.description);146}147}148149function getSubagentDescription(event: IChatDebugEvent): string | undefined {150if (event.kind !== 'subagentInvocation' || !event.parentEventId) {151return undefined;152}153const descs = completionDescsByParent.get(event.parentEventId);154if (!descs || descs.length === 0) {155return event.description && event.description !== event.agentName ? event.description : undefined;156}157const idx = startedCountByParent.get(event.parentEventId) ?? 0;158startedCountByParent.set(event.parentEventId, idx + 1);159return descs[idx] ?? descs[0];160}161162// Filter out subagent invocation completion duplicates (events whose163// agentName matches a known tool name). Subagent tool calls are kept164// in the tree for correct parent-child linkage; they are collapsed165// into their subagent child in a post-processing step.166const filtered = events.filter(e => {167if (e.kind === 'subagentInvocation' && isSubagentName(e.agentName)) {168return false;169}170return true;171});172173const idToEvent = new Map<string, IChatDebugEvent>();174const idToChildren = new Map<string, IChatDebugEvent[]>();175const roots: IChatDebugEvent[] = [];176177for (const event of filtered) {178if (event.id) {179idToEvent.set(event.id, event);180}181}182183for (const event of filtered) {184if (event.parentEventId && idToEvent.has(event.parentEventId)) {185let children = idToChildren.get(event.parentEventId);186if (!children) {187children = [];188idToChildren.set(event.parentEventId, children);189}190children.push(event);191} else {192roots.push(event);193}194}195196function toFlowNode(event: IChatDebugEvent): FlowNode {197const children = event.id ? idToChildren.get(event.id) : undefined;198199// Remap generic events with well-known names to their proper kind200// so they get correct styling and sublabel treatment.201const effectiveKind = getEffectiveKind(event);202203// For subagent invocations, enrich with description from the204// filtered-out completion sibling, or fall back to the event's own field.205let label = getEventLabel(event, effectiveKind);206const sublabel = getEventSublabel(event, effectiveKind);207let tooltip = getEventTooltip(event);208let description: string | undefined;209if (effectiveKind === 'subagentInvocation') {210description = getSubagentDescription(event);211// Strip any existing "Subagent:" prefix from the description to212// avoid double-prefixing (e.g. "Subagent: Subagent: name").213const cleanDesc = description?.replace(/^Subagent:\s*/i, '');214// Show "Subagent: <description>" as the label so users can identify215// these nodes and see what task they perform.216label = cleanDesc217? localize('subagentWithDesc', "Subagent: {0}", truncateLabel(cleanDesc, 30))218: localize('subagentLabel', "Subagent");219if (description) {220// Ensure description appears in tooltip if not already present221if (tooltip && !tooltip.includes(description)) {222const lines = tooltip.split('\n');223lines.splice(1, 0, description);224tooltip = lines.join('\n');225}226}227}228229return {230id: event.id ?? `event-${events.indexOf(event)}`,231kind: effectiveKind,232category: event.kind === 'generic' ? event.category : undefined,233label,234sublabel,235description,236tooltip,237isError: isErrorEvent(event),238created: event.created.getTime(),239children: children?.map(toFlowNode) ?? [],240};241}242243const rawNodes = roots.map(toFlowNode);244245// Post-process: collapse subagent tool call nodes into their246// subagent child, and flatten child_session_ref placeholder nodes.247// This preserves the correct parent-child hierarchy that would248// otherwise break when filtering events before tree construction.249return collapseSubagentToolCalls(rawNodes);250251function collapseSubagentToolCalls(nodeList: FlowNode[]): FlowNode[] {252let changed = false;253const result: FlowNode[] = [];254for (const node of nodeList) {255if (node.kind === 'toolCall' && isSubagentName(stripToolEmoji(node.label))) {256changed = true;257// Flatten any child_session_ref intermediaries first so258// the subagentInvocation becomes a direct child.259const flatChildren = flattenChildSessionRefs(node.children);260const subagentChildren = flatChildren.filter(c => c.kind === 'subagentInvocation');261if (subagentChildren.length > 0) {262const otherChildren = flatChildren.filter(c => c.kind !== 'subagentInvocation');263// Each subagent child gets its own children; non-subagent264// siblings (which are rare) are added to the first subagent.265for (let i = 0; i < subagentChildren.length; i++) {266const extra = i === 0 ? otherChildren : [];267result.push({268...subagentChildren[i],269children: collapseSubagentToolCalls(270[...subagentChildren[i].children, ...extra]271),272});273}274} else {275// No subagent child — promote children directly276result.push(...collapseSubagentToolCalls(flatChildren));277}278} else {279const newChildren = collapseSubagentToolCalls(node.children);280if (newChildren !== node.children) {281changed = true;282result.push({ ...node, children: newChildren });283} else {284result.push(node);285}286}287}288return changed ? result : nodeList;289}290291function flattenChildSessionRefs(nodeList: FlowNode[]): FlowNode[] {292if (!nodeList.some(n => n.kind === 'generic' && n.category === 'subagent')) {293return nodeList; // fast path: nothing to flatten294}295const result: FlowNode[] = [];296for (const node of nodeList) {297if (node.kind === 'generic' && node.category === 'subagent') {298// child_session_ref placeholder — find the subagentInvocation299// and move all siblings into it as children.300const subagentChild = node.children.find(c => c.kind === 'subagentInvocation');301if (subagentChild) {302const siblings = node.children.filter(c => c !== subagentChild);303result.push({304...subagentChild,305children: [...subagentChild.children, ...siblings],306});307} else {308// No subagent child — promote all children309result.push(...node.children);310}311} else {312result.push(node);313}314}315return result;316}317}318319// ---- Flow node filtering ----320321/**322* Filters a flow node tree by kind visibility and text search.323* Returns a new tree — the input is not mutated.324*325* Kind filtering: nodes whose kind is not visible are removed.326* For `subagentInvocation` nodes, the entire subgraph is removed.327* For other kinds, the node is removed and its children are re-parented.328*329* Text filtering: only nodes whose label, sublabel, or tooltip match the330* search term are kept, along with all their ancestors (path to root).331* If a subagent label matches, its entire subgraph is kept.332*/333export function filterFlowNodes(nodes: FlowNode[], options: FlowFilterOptions): FlowNode[] {334let result = filterByKind(nodes, options.isKindVisible);335if (options.textFilter) {336result = filterByText(result, options.textFilter);337}338return result;339}340341function filterByKind(nodes: FlowNode[], isKindVisible: (kind: string, category?: string) => boolean): FlowNode[] {342const result: FlowNode[] = [];343let changed = false;344for (const node of nodes) {345if (!isKindVisible(node.kind, node.category)) {346changed = true;347// For subagents, drop the entire subgraph348if (node.kind === 'subagentInvocation') {349continue;350}351// For other kinds, re-parent children up352result.push(...filterByKind(node.children, isKindVisible));353continue;354}355const filteredChildren = filterByKind(node.children, isKindVisible);356if (filteredChildren !== node.children) {357changed = true;358result.push({ ...node, children: filteredChildren });359} else {360result.push(node);361}362}363return changed ? result : nodes;364}365366367function nodeMatchesText(node: FlowNode, text: string): boolean {368return node.label.toLowerCase().includes(text) ||369(node.sublabel?.toLowerCase().includes(text) ?? false) ||370(node.tooltip?.toLowerCase().includes(text) ?? false);371}372373function filterByText(nodes: FlowNode[], text: string): FlowNode[] {374const result: FlowNode[] = [];375for (const node of nodes) {376if (nodeMatchesText(node, text)) {377// Node matches — keep it with all descendants378result.push(node);379continue;380}381// Check if any descendant matches382const filteredChildren = filterByText(node.children, text);383if (filteredChildren.length > 0) {384// Keep this node as an ancestor of matching descendants385result.push({ ...node, children: filteredChildren });386}387}388return result;389}390391// ---- Node slicing (pagination) ----392393export interface FlowSliceResult {394readonly nodes: FlowNode[];395readonly totalCount: number;396readonly shownCount: number;397}398399/**400* Counts the total number of nodes in a tree (each node + all descendants).401*/402function countNodes(nodes: readonly FlowNode[]): number {403let count = 0;404for (const node of nodes) {405count += 1 + countNodes(node.children);406}407return count;408}409410/**411* Slices a flow node tree to at most `maxCount` nodes (pre-order DFS).412*413* When a subagent's children would exceed the remaining budget, the414* children list is truncated. Returns the sliced tree along with total415* and shown node counts for the "Show More" UI.416*/417export function sliceFlowNodes(nodes: readonly FlowNode[], maxCount: number): FlowSliceResult {418const totalCount = countNodes(nodes);419if (totalCount <= maxCount) {420return { nodes: nodes as FlowNode[], totalCount, shownCount: totalCount };421}422423let remaining = maxCount;424425function sliceTree(nodeList: readonly FlowNode[]): FlowNode[] {426const result: FlowNode[] = [];427for (const node of nodeList) {428if (remaining <= 0) {429break;430}431remaining--; // count this node432if (node.children.length === 0 || remaining <= 0) {433result.push(node.children.length === 0 ? node : { ...node, children: [] });434} else {435const slicedChildren = sliceTree(node.children);436result.push(slicedChildren !== node.children ? { ...node, children: slicedChildren } : node);437}438}439return result;440}441442const sliced = sliceTree(nodes);443const shownCount = maxCount - remaining;444return { nodes: sliced, totalCount, shownCount };445}446447// ---- Discovery node merging ----448449function isDiscoveryNode(node: FlowNode): boolean {450return node.kind === 'generic' && node.category === 'discovery';451}452453/**454* Merges consecutive prompt-discovery nodes (generic events with455* `category === 'discovery'`) into a single summary node.456*457* The merged node always stays in the graph and carries the individual458* nodes in `mergedNodes`. Expansion (showing the individual nodes to the459* right) is handled at the layout level.460*461* Operates recursively on children.462*/463export function mergeDiscoveryNodes(464nodes: readonly FlowNode[],465): FlowNode[] {466const result: FlowNode[] = [];467468let i = 0;469while (i < nodes.length) {470const node = nodes[i];471472// Non-discovery node: recurse into children and pass through.473if (!isDiscoveryNode(node)) {474const mergedChildren = mergeDiscoveryNodes(node.children);475result.push(mergedChildren !== node.children ? { ...node, children: mergedChildren } : node);476i++;477continue;478}479480// Accumulate a run of consecutive discovery nodes.481const run: FlowNode[] = [node];482let j = i + 1;483while (j < nodes.length && isDiscoveryNode(nodes[j])) {484run.push(nodes[j]);485j++;486}487488if (run.length < 2) {489// Single discovery node — nothing to merge.490result.push(node);491i = j;492continue;493}494495// Build a stable id from the first node so the expand state persists.496const mergedId = `merged-discovery:${run[0].id}`;497498// Build a merged summary node.499const labels = run.map(n => n.label);500const uniqueLabels = [...new Set(labels)];501const summaryLabel = uniqueLabels.length <= 2502? uniqueLabels.join(', ')503: localize('discoveryMergedLabel', "{0} +{1} more", uniqueLabels[0], run.length - 1);504505result.push({506id: mergedId,507kind: 'generic',508category: 'discovery',509label: summaryLabel,510sublabel: localize('discoveryStepsCount', "{0} discovery steps", run.length),511tooltip: run.map(n => n.label + (n.sublabel ? `: ${n.sublabel}` : '')).join('\n'),512created: run[0].created,513children: [],514mergedNodes: run,515});516i = j;517}518519return result;520}521522// ---- Tool call node merging ----523524function isToolCallNode(node: FlowNode): boolean {525return node.kind === 'toolCall';526}527528/**529* Returns the tool name from a tool-call node's label.530* Tool call labels are set to `event.toolName` (possibly with a leading531* emoji prefix stripped), so the label itself is the canonical tool name.532*/533function getToolName(node: FlowNode): string {534return node.label;535}536537/**538* Merges consecutive tool-call nodes that invoke the same tool into a539* single summary node.540*541* This mirrors `mergeDiscoveryNodes`: the merged node carries the542* individual nodes in `mergedNodes` and expansion is handled at the543* layout level.544*545* Operates recursively on children.546*/547export function mergeToolCallNodes(548nodes: readonly FlowNode[],549): FlowNode[] {550const result: FlowNode[] = [];551552let i = 0;553while (i < nodes.length) {554const node = nodes[i];555556// Non-tool-call node: recurse into children and pass through.557if (!isToolCallNode(node)) {558const mergedChildren = mergeToolCallNodes(node.children);559result.push(mergedChildren !== node.children ? { ...node, children: mergedChildren } : node);560i++;561continue;562}563564// Accumulate a run of consecutive tool-call nodes with the same tool name.565const toolName = getToolName(node);566const run: FlowNode[] = [node];567let j = i + 1;568while (j < nodes.length && isToolCallNode(nodes[j]) && getToolName(nodes[j]) === toolName) {569run.push(nodes[j]);570j++;571}572573if (run.length < 2) {574// Single tool call — recurse into children, nothing to merge.575const mergedChildren = mergeToolCallNodes(node.children);576result.push(mergedChildren !== node.children ? { ...node, children: mergedChildren } : node);577i = j;578continue;579}580581// Build a stable id from the first node so the expand state persists.582const mergedId = `merged-toolCall:${run[0].id}`;583584result.push({585id: mergedId,586kind: 'toolCall',587label: toolName,588sublabel: localize('toolCallsCount', "{0} calls", run.length),589tooltip: run.map(n => n.label + (n.sublabel ? `: ${n.sublabel}` : '')).join('\n'),590created: run[0].created,591children: [],592mergedNodes: run,593});594i = j;595}596597return result;598}599600// ---- Event helpers ----601602/**603* Remaps generic events with well-known names (e.g. "User message",604* "Agent response") to their proper typed kind so they receive605* correct colors, labels, and sublabel treatment in the flow chart.606*/607function getEffectiveKind(event: IChatDebugEvent): IChatDebugEvent['kind'] {608if (event.kind === 'generic') {609const name = event.name.toLowerCase().replace(/[\s_-]+/g, '');610if (name === 'usermessage' || name === 'userprompt' || name === 'user' || name.startsWith('usermessage')) {611return 'userMessage';612}613if (name === 'response' || name.startsWith('agentresponse') || name.startsWith('assistantresponse') || name.startsWith('modelresponse')) {614return 'agentResponse';615}616const cat = event.category?.toLowerCase();617if (cat === 'user' || cat === 'usermessage') {618return 'userMessage';619}620if (cat === 'response' || cat === 'agentresponse') {621return 'agentResponse';622}623}624return event.kind;625}626627function getEventLabel(event: IChatDebugEvent, effectiveKind?: IChatDebugEvent['kind']): string {628const kind = effectiveKind ?? event.kind;629switch (kind) {630case 'userMessage':631return localize('userLabel', "User Message");632case 'modelTurn':633return event.kind === 'modelTurn' ? (event.model ?? localize('modelTurnLabel', "Model Turn")) : localize('modelTurnLabel', "Model Turn");634case 'toolCall':635return event.kind === 'toolCall' ? event.toolName : event.kind === 'generic' ? event.name : localize('toolCallLabel', "Tool Call");636case 'subagentInvocation':637return event.kind === 'subagentInvocation' ? event.agentName : localize('subagentFallback', "Subagent");638case 'agentResponse':639return localize('agentResponseLabel', "Agent Response");640case 'generic':641return event.kind === 'generic' ? event.name : localize('genericLabel', "Event");642}643}644645function getEventSublabel(event: IChatDebugEvent, effectiveKind?: IChatDebugEvent['kind']): string | undefined {646const kind = effectiveKind ?? event.kind;647switch (kind) {648case 'modelTurn': {649const parts: string[] = [];650if (event.kind === 'modelTurn' && event.requestName) {651parts.push(event.requestName);652}653if (event.kind === 'modelTurn' && event.totalTokens) {654parts.push(localize('tokenCount', "{0} tokens", event.totalTokens));655}656if (event.kind === 'modelTurn' && event.durationInMillis) {657parts.push(formatDuration(event.durationInMillis));658}659return parts.length > 0 ? parts.join(' \u00b7 ') : undefined;660}661case 'toolCall': {662const parts: string[] = [];663if (event.kind === 'toolCall' && event.result) {664parts.push(event.result);665}666if (event.kind === 'toolCall' && event.durationInMillis) {667parts.push(formatDuration(event.durationInMillis));668}669return parts.length > 0 ? parts.join(' \u00b7 ') : undefined;670}671case 'subagentInvocation': {672const parts: string[] = [];673if (event.kind === 'subagentInvocation' && event.status) {674parts.push(event.status);675}676if (event.kind === 'subagentInvocation' && event.durationInMillis) {677parts.push(formatDuration(event.durationInMillis));678}679return parts.length > 0 ? parts.join(' \u00b7 ') : undefined;680}681case 'userMessage':682case 'agentResponse': {683// Use the message summary as the sublabel. For remapped generic684// events, use the details property.685let text: string | undefined;686if (event.kind === 'userMessage' || event.kind === 'agentResponse') {687text = event.message;688} else if (event.kind === 'generic') {689text = event.details;690}691if (!text) {692return undefined;693}694// Find the first meaningful line, skipping trivial lines like695// lone brackets/braces that appear when the message is JSON.696const lines = text.split('\n');697let firstLine = '';698for (const line of lines) {699const trimmed = line.trim();700if (trimmed && trimmed.length > 2) {701firstLine = trimmed;702break;703}704}705if (!firstLine) {706// Fall back to the full text collapsed to a single line707firstLine = text.replace(/\s+/g, ' ').trim();708}709if (!firstLine) {710return undefined;711}712return firstLine.length > 60 ? firstLine.substring(0, 57) + '...' : firstLine;713}714default:715return undefined;716}717}718719function formatDuration(ms: number): string {720if (ms < 1000) {721return `${ms}ms`;722}723return `${(ms / 1000).toFixed(1)}s`;724}725726function isErrorEvent(event: IChatDebugEvent): boolean {727return (event.kind === 'toolCall' && event.result === 'error') ||728(event.kind === 'generic' && event.level === 3 /* ChatDebugLogLevel.Error */) ||729(event.kind === 'subagentInvocation' && event.status === 'failed');730}731732const TOOLTIP_MAX_LENGTH = 500;733734function getEventTooltip(event: IChatDebugEvent): string | undefined {735switch (event.kind) {736case 'userMessage': {737const msg = event.message.trim();738if (msg.length > TOOLTIP_MAX_LENGTH) {739return msg.substring(0, TOOLTIP_MAX_LENGTH) + '\u2026';740}741return msg || undefined;742}743case 'toolCall': {744const parts: string[] = [event.toolName];745if (event.input) {746const input = event.input.trim();747parts.push(localize('tooltipInput', "Input: {0}", input.length > TOOLTIP_MAX_LENGTH ? input.substring(0, TOOLTIP_MAX_LENGTH) + '\u2026' : input));748}749if (event.output) {750const output = event.output.trim();751parts.push(localize('tooltipOutput', "Output: {0}", output.length > TOOLTIP_MAX_LENGTH ? output.substring(0, TOOLTIP_MAX_LENGTH) + '\u2026' : output));752}753if (event.result) {754parts.push(localize('tooltipResult', "Result: {0}", event.result));755}756return parts.join('\n');757}758case 'subagentInvocation': {759const parts: string[] = [event.agentName];760if (event.description) {761parts.push(event.description);762}763if (event.status) {764parts.push(localize('tooltipStatus', "Status: {0}", event.status));765}766if (event.toolCallCount !== undefined) {767parts.push(localize('tooltipToolCalls', "Tool calls: {0}", event.toolCallCount));768}769if (event.modelTurnCount !== undefined) {770parts.push(localize('tooltipModelTurns', "Model turns: {0}", event.modelTurnCount));771}772return parts.join('\n');773}774case 'generic': {775if (event.details) {776const details = event.details.trim();777return details.length > TOOLTIP_MAX_LENGTH ? details.substring(0, TOOLTIP_MAX_LENGTH) + '\u2026' : details;778}779return undefined;780}781case 'modelTurn': {782const parts: string[] = [];783if (event.model) {784parts.push(event.model);785}786if (event.totalTokens) {787parts.push(localize('tooltipTokens', "Tokens: {0}", event.totalTokens));788}789if (event.inputTokens) {790parts.push(localize('tooltipInputTokens', "Input tokens: {0}", event.inputTokens));791}792if (event.outputTokens) {793parts.push(localize('tooltipOutputTokens', "Output tokens: {0}", event.outputTokens));794}795if (event.cachedTokens !== undefined) {796parts.push(localize('tooltipCachedTokens', "Cached tokens: {0}", event.cachedTokens));797}798if (event.durationInMillis) {799parts.push(localize('tooltipDuration', "Duration: {0}", formatDuration(event.durationInMillis)));800}801return parts.length > 0 ? parts.join('\n') : undefined;802}803default:804return undefined;805}806}807808809