From eb17107e9ed37517b3650bce9fb5457deba44adf Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Mon, 9 Feb 2026 22:29:11 +0100 Subject: [PATCH 1/4] Researching actual token usage --- src/extension.ts | 31 +++- src/webview/logviewer/main.ts | 215 +++++++++++++++++++++++- src/webview/logviewer/styles.css | 271 +++++++++++++++++++++++++++++++ 3 files changed, 513 insertions(+), 4 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index d69e7008..f0c79805 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -189,6 +189,21 @@ interface SessionFileDetails { repository?: string; // Git remote origin URL for the session's workspace } +// Prompt token detail from actual LLM usage data +interface PromptTokenDetail { + category: string; + label: string; + percentageOfPrompt: number; +} + +// Actual usage data from the LLM API (when available in JSONL) +interface ActualUsage { + completionTokens: number; + promptTokens: number; + promptTokenDetails?: PromptTokenDetail[]; + details?: string; // e.g. "Claude Opus 4.5 • 3x" +} + // Chat turn information for log viewer interface ChatTurn { turnNumber: number; @@ -202,6 +217,7 @@ interface ChatTurn { mcpTools: { server: string; tool: string }[]; inputTokensEstimate: number; outputTokensEstimate: number; + actualUsage?: ActualUsage; } // Full session log data for the log viewer @@ -3030,6 +3046,18 @@ class CopilotTokenTracker implements vscode.Disposable { // Extract response data const { responseText, toolCalls, mcpTools } = this.extractResponseData(request.response || []); + // Extract actual usage data from request.result if available + let actualUsage: ActualUsage | undefined; + if (request.result?.usage) { + const u = request.result.usage; + actualUsage = { + completionTokens: typeof u.completionTokens === 'number' ? u.completionTokens : 0, + promptTokens: typeof u.promptTokens === 'number' ? u.promptTokens : 0, + promptTokenDetails: Array.isArray(u.promptTokenDetails) ? u.promptTokenDetails : undefined, + details: typeof request.result.details === 'string' ? request.result.details : undefined + }; + } + const turn: ChatTurn = { turnNumber: i + 1, timestamp: request.timestamp ? new Date(request.timestamp).toISOString() : null, @@ -3041,7 +3069,8 @@ class CopilotTokenTracker implements vscode.Disposable { contextReferences: contextRefs, mcpTools, inputTokensEstimate: this.estimateTokensFromText(userMessage, requestModel), - outputTokensEstimate: this.estimateTokensFromText(responseText, requestModel) + outputTokensEstimate: this.estimateTokensFromText(responseText, requestModel), + actualUsage }; turns.push(turn); diff --git a/src/webview/logviewer/main.ts b/src/webview/logviewer/main.ts index f74e247f..d1d69ad9 100644 --- a/src/webview/logviewer/main.ts +++ b/src/webview/logviewer/main.ts @@ -3,6 +3,19 @@ import { ContextReferenceUsage, getTotalContextRefs, getImplicitContextRefs, get // CSS imported as text via esbuild import styles from './styles.css'; +type PromptTokenDetail = { + category: string; + label: string; + percentageOfPrompt: number; +}; + +type ActualUsage = { + completionTokens: number; + promptTokens: number; + promptTokenDetails?: PromptTokenDetail[]; + details?: string; +}; + type ChatTurn = { turnNumber: number; timestamp: string | null; @@ -15,6 +28,7 @@ type ChatTurn = { mcpTools: { server: string; tool: string }[]; inputTokensEstimate: number; outputTokensEstimate: number; + actualUsage?: ActualUsage; }; type ToolCallUsage = { total: number; byTool: { [key: string]: number } }; @@ -241,6 +255,121 @@ function renderTurnCard(turn: ChatTurn): string { const hasToolCalls = turn.toolCalls.length > 0; const hasMcpTools = turn.mcpTools.length > 0; const totalRefs = getTotalContextRefs(turn.contextReferences); + const hasActualUsage = !!turn.actualUsage; + + // Build actual usage section + let actualUsageHtml = ''; + if (hasActualUsage && turn.actualUsage) { + const au = turn.actualUsage; + const actualTotal = au.promptTokens + au.completionTokens; + const estimatedTotal = totalTokens; + const deltaTotal = actualTotal - estimatedTotal; + const deltaInput = au.promptTokens - turn.inputTokensEstimate; + const deltaOutput = au.completionTokens - turn.outputTokensEstimate; + const deltaSign = (n: number) => n > 0 ? '+' : ''; + const deltaClass = (n: number) => n > 0 ? 'delta-over' : n < 0 ? 'delta-under' : 'delta-zero'; + + // Build prompt breakdown rows + let promptBreakdownHtml = ''; + if (au.promptTokenDetails && au.promptTokenDetails.length > 0) { + const breakdownRows = au.promptTokenDetails.map(detail => { + const deducedTokens = Math.round(au.promptTokens * detail.percentageOfPrompt / 100); + const barWidth = Math.min(detail.percentageOfPrompt, 100); + const categoryClass = detail.category === 'System' ? 'category-system' : 'category-user'; + return ` + ${escapeHtml(detail.category)} + ${escapeHtml(detail.label)} + ${detail.percentageOfPrompt}% + ${deducedTokens.toLocaleString()} +
+ `; + }).join(''); + + // Calculate system vs user totals + const systemPct = au.promptTokenDetails.filter(d => d.category === 'System').reduce((s, d) => s + d.percentageOfPrompt, 0); + const userPct = au.promptTokenDetails.filter(d => d.category !== 'System').reduce((s, d) => s + d.percentageOfPrompt, 0); + const systemTokens = Math.round(au.promptTokens * systemPct / 100); + const userTokens = Math.round(au.promptTokens * userPct / 100); + + promptBreakdownHtml = ` +
+
+ System: ${systemPct}% (~${systemTokens.toLocaleString()} tokens) + User Context: ${userPct}% (~${userTokens.toLocaleString()} tokens) +
+ + + + + + + + + + + + ${breakdownRows} + +
CategoryLabel%~TokensDistribution
+
+ `; + } + + actualUsageHtml = ` +
+
+ + + 📊 ACTUAL LLM USAGE + + ↑${au.promptTokens.toLocaleString()} + ↓${au.completionTokens.toLocaleString()} + Σ${actualTotal.toLocaleString()} + delta: ${deltaSign(deltaTotal)}${deltaTotal.toLocaleString()} + ${au.details ? `${escapeHtml(au.details)}` : ''} + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MetricEstimatedActualDeltaRatio
↑ Prompt / Input${turn.inputTokensEstimate.toLocaleString()}${au.promptTokens.toLocaleString()}${deltaSign(deltaInput)}${deltaInput.toLocaleString()}${turn.inputTokensEstimate > 0 ? (au.promptTokens / turn.inputTokensEstimate).toFixed(1) + 'x' : 'N/A'}
↓ Completion / Output${turn.outputTokensEstimate.toLocaleString()}${au.completionTokens.toLocaleString()}${deltaSign(deltaOutput)}${deltaOutput.toLocaleString()}${turn.outputTokensEstimate > 0 ? (au.completionTokens / turn.outputTokensEstimate).toFixed(1) + 'x' : 'N/A'}
Σ Total${estimatedTotal.toLocaleString()}${actualTotal.toLocaleString()}${deltaSign(deltaTotal)}${deltaTotal.toLocaleString()}${estimatedTotal > 0 ? (actualTotal / estimatedTotal).toFixed(1) + 'x' : 'N/A'}
+ ${promptBreakdownHtml} +
+
+
+ `; + } // Build context file badges for header const contextFileBadges: string[] = []; @@ -355,7 +484,7 @@ function renderTurnCard(turn: ChatTurn): string { #${turn.turnNumber} ${getModeIcon(turn.mode)} ${turn.mode} ${turn.model ? `🎯 ${escapeHtml(turn.model)}` : ''} - 📊 ${totalTokens.toLocaleString()} tokens (↑${turn.inputTokensEstimate} ↓${turn.outputTokensEstimate}) + 📊 ${totalTokens.toLocaleString()} est.${hasActualUsage ? ` | ${(turn.actualUsage!.promptTokens + turn.actualUsage!.completionTokens).toLocaleString()} actual` : ''} ${contextHeaderHtml}
${formatDate(turn.timestamp)}
@@ -364,6 +493,7 @@ function renderTurnCard(turn: ChatTurn): string { ${toolCallsHtml} ${mcpToolsHtml} ${contextRefsHtml} + ${actualUsageHtml}
@@ -399,6 +529,30 @@ function renderLayout(data: SessionLogData): void { const usageContextImplicit = getImplicitContextRefs(usageContextRefs); const usageContextExplicit = getExplicitContextRefs(usageContextRefs); + // Calculate actual usage totals across all turns + const turnsWithActual = data.turns.filter(t => t.actualUsage); + const hasAnyActualUsage = turnsWithActual.length > 0; + const actualPromptTotal = turnsWithActual.reduce((s, t) => s + (t.actualUsage?.promptTokens || 0), 0); + const actualCompletionTotal = turnsWithActual.reduce((s, t) => s + (t.actualUsage?.completionTokens || 0), 0); + const actualTotal = actualPromptTotal + actualCompletionTotal; + + // Aggregate prompt breakdown across all turns + const aggregatedBreakdown: { [key: string]: { category: string; label: string; totalTokens: number; totalPct: number; count: number } } = {}; + for (const turn of turnsWithActual) { + if (turn.actualUsage?.promptTokenDetails) { + for (const detail of turn.actualUsage.promptTokenDetails) { + const key = `${detail.category}|${detail.label}`; + if (!aggregatedBreakdown[key]) { + aggregatedBreakdown[key] = { category: detail.category, label: detail.label, totalTokens: 0, totalPct: 0, count: 0 }; + } + const deducedTokens = Math.round((turn.actualUsage?.promptTokens || 0) * detail.percentageOfPrompt / 100); + aggregatedBreakdown[key].totalTokens += deducedTokens; + aggregatedBreakdown[key].totalPct += detail.percentageOfPrompt; + aggregatedBreakdown[key].count++; + } + } + } + const formatTopList = (entries: { key: string; value: number }[], mapper?: (k: string) => string) => { if (!entries.length) { return 'None'; } return entries.map(e => `
${escapeHtml(mapper ? mapper(e.key) : e.key)}: ${e.value}
`).join(''); @@ -437,10 +591,17 @@ function renderLayout(data: SessionLogData): void {
Total chat turns in this session
-
📊 Total Tokens
+
📊 Estimated Tokens
${totalTokens.toLocaleString()}
-
Input + Output tokens across all turns
+
Input + Output estimated from text
+
+ ${hasAnyActualUsage ? ` +
+
📊 Actual Tokens
+
${actualTotal.toLocaleString()}
+
↑${actualPromptTotal.toLocaleString()} prompt, ↓${actualCompletionTotal.toLocaleString()} completion
+ ` : ''}
🔧 Tool Calls
${usageToolTotal}
@@ -490,6 +651,54 @@ function renderLayout(data: SessionLogData): void {
+ ${hasAnyActualUsage ? (() => { + const breakdownEntries = Object.values(aggregatedBreakdown).sort((a, b) => b.totalTokens - a.totalTokens); + const avgPct = (entry: { totalPct: number; count: number }) => Math.round(entry.totalPct / entry.count); + const breakdownRows = breakdownEntries.map(entry => { + const pct = avgPct(entry); + const categoryClass = entry.category === 'System' ? 'category-system' : 'category-user'; + return ` + ${escapeHtml(entry.category)} + ${escapeHtml(entry.label)} + ${pct}% + ${entry.totalTokens.toLocaleString()} +
+ `; + }).join(''); + + const systemTokens = breakdownEntries.filter(e => e.category === 'System').reduce((s, e) => s + e.totalTokens, 0); + const userTokens = breakdownEntries.filter(e => e.category !== 'System').reduce((s, e) => s + e.totalTokens, 0); + const estimateRatio = totalTokens > 0 ? (actualTotal / totalTokens).toFixed(1) : 'N/A'; + + return ` +
+
📊 Session Actual LLM Usage (${turnsWithActual.length}/${data.turns.length} turns with data)
+
+
+ + + + + + + +
MetricEstimatedActualRatio
↑ Prompt${data.turns.reduce((s,t) => s + t.inputTokensEstimate, 0).toLocaleString()}${actualPromptTotal.toLocaleString()}${data.turns.reduce((s,t) => s + t.inputTokensEstimate, 0) > 0 ? (actualPromptTotal / data.turns.reduce((s,t) => s + t.inputTokensEstimate, 0)).toFixed(1) + 'x' : 'N/A'}
↓ Completion${data.turns.reduce((s,t) => s + t.outputTokensEstimate, 0).toLocaleString()}${actualCompletionTotal.toLocaleString()}${data.turns.reduce((s,t) => s + t.outputTokensEstimate, 0) > 0 ? (actualCompletionTotal / data.turns.reduce((s,t) => s + t.outputTokensEstimate, 0)).toFixed(1) + 'x' : 'N/A'}
Σ Total${totalTokens.toLocaleString()}${actualTotal.toLocaleString()}${estimateRatio}x
+
+
+
+ System: ~${systemTokens.toLocaleString()} tokens + User Context: ~${userTokens.toLocaleString()} tokens +
+ + + ${breakdownRows} +
CategoryLabelAvg %Total ~TokensDistribution
+
+
+
+ `; + })() : ''} +
📝 Chat Turns (${data.turns.length}) diff --git a/src/webview/logviewer/styles.css b/src/webview/logviewer/styles.css index 912903ff..5a0c58ca 100644 --- a/src/webview/logviewer/styles.css +++ b/src/webview/logviewer/styles.css @@ -744,3 +744,274 @@ details[open] > summary .collapse-arrow { font-size: 11px; color: #666; } + +/* Actual LLM Usage - per turn */ +.turn-actual-usage { + margin: 0 16px 14px; + background: linear-gradient(135deg, #1a2530 0%, #1a2028 100%); + border: 1px solid #2a5a6a; + border-radius: 8px; + padding: 12px 14px; + box-shadow: 0 2px 8px rgb(0, 0, 0, 0.2); +} + +.actual-usage-details { + cursor: pointer; + margin: 0; + padding: 0; +} + +.actual-usage-summary { + list-style: none; + cursor: pointer; + user-select: none; + display: flex; + align-items: center; + gap: 10px; + padding: 2px 0; + padding-inline-start: 0; + margin: 0; +} + +.actual-usage-summary::-webkit-details-marker { + display: none; +} + +.actual-usage-summary::marker { + display: none; +} + +.actual-usage-summary:hover { + color: #fff; +} + +.actual-usage-header-inline { + font-size: 13px; + font-weight: 700; + color: #fff; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.actual-usage-summary-text { + font-size: 12px; + font-weight: 600; + color: #38bdf8; + flex: 1; + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.usage-badge { + background: rgb(56, 189, 248, 0.1); + border: 1px solid rgb(56, 189, 248, 0.3); + padding: 2px 8px; + border-radius: 4px; + white-space: nowrap; +} + +.usage-badge.usage-total { + background: rgb(56, 189, 248, 0.2); + border-color: rgb(56, 189, 248, 0.5); + font-weight: 700; +} + +.usage-badge.usage-model-info { + background: rgb(168, 85, 247, 0.15); + border-color: rgb(168, 85, 247, 0.4); + color: #c084fc; +} + +.usage-badge.delta-over { + background: rgb(239, 68, 68, 0.15); + border-color: rgb(239, 68, 68, 0.4); + color: #f87171; +} + +.usage-badge.delta-under { + background: rgb(34, 197, 94, 0.15); + border-color: rgb(34, 197, 94, 0.4); + color: #4ade80; +} + +.usage-badge.delta-zero { + background: rgb(148, 163, 184, 0.1); + border-color: rgb(148, 163, 184, 0.3); + color: #94a3b8; +} + +.actual-usage-content { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid rgb(255, 255, 255, 0.1); +} + +.usage-comparison-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + margin-bottom: 16px; +} + +.usage-comparison-table th, +.usage-comparison-table td { + padding: 8px 12px; + text-align: left; + border-bottom: 1px solid #334155; +} + +.usage-comparison-table th { + color: #94a3b8; + font-weight: 600; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + background: #1e293b; +} + +.usage-comparison-table .count-cell { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.usage-total-row { + border-top: 2px solid #475569; +} + +.usage-total-row td { + padding-top: 10px; +} + +.delta-over { + color: #f87171; +} + +.delta-under { + color: #4ade80; +} + +.delta-zero { + color: #94a3b8; +} + +/* Prompt breakdown */ +.prompt-breakdown { + margin-top: 4px; +} + +.breakdown-summary { + display: flex; + gap: 20px; + margin-bottom: 12px; + font-size: 13px; + font-weight: 600; +} + +.category-system { + color: #f97316; + background: rgb(249, 115, 22, 0.1); + border: 1px solid rgb(249, 115, 22, 0.3); + padding: 3px 10px; + border-radius: 4px; +} + +.category-user { + color: #38bdf8; + background: rgb(56, 189, 248, 0.1); + border: 1px solid rgb(56, 189, 248, 0.3); + padding: 3px 10px; + border-radius: 4px; +} + +.prompt-breakdown-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.prompt-breakdown-table th, +.prompt-breakdown-table td { + padding: 8px 12px; + text-align: left; + border-bottom: 1px solid #334155; +} + +.prompt-breakdown-table th { + color: #94a3b8; + font-weight: 600; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + background: #1e293b; +} + +.prompt-breakdown-table .count-cell { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.bar-cell { + width: 100%; + height: 14px; + background: #1e293b; + border-radius: 4px; + overflow: hidden; + min-width: 80px; +} + +.bar-fill { + height: 100%; + border-radius: 4px; + transition: width 0.3s ease; +} + +.category-system-bar { + background: linear-gradient(90deg, #f97316, #fb923c); +} + +.category-user-bar { + background: linear-gradient(90deg, #0ea5e9, #38bdf8); +} + +/* Actual usage summary card highlight */ +.actual-usage-card { + border-color: #2a5a6a; + background: linear-gradient(135deg, #1a2530 0%, #1a2028 100%); +} + +.actual-usage-card .summary-value { + color: #38bdf8; +} + +/* Session-level actual usage panel */ +.session-actual-usage { + background: linear-gradient(135deg, #1a2530 0%, #1a2028 100%); + border: 1px solid #2a5a6a; + border-radius: 12px; + padding: 20px 24px; + margin-bottom: 24px; + box-shadow: 0 4px 12px rgb(0, 0, 0, 0.3), 0 1px 3px rgb(0, 0, 0, 0.2); +} + +.session-usage-header { + font-size: 16px; + font-weight: 700; + color: #fff; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 2px solid #2a5a6a; +} + +.session-usage-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; +} + +@media (max-width: 800px) { + .session-usage-grid { + grid-template-columns: 1fr; + } +} From 5fe20bbeca78ff14dac9d0bfe4f91093175dc747 Mon Sep 17 00:00:00 2001 From: rajbos <6085745+rajbos@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:48:58 +0000 Subject: [PATCH 2/4] chore: sync toolNames.json with vscode-copilot-chat This commit automatically updates src/toolNames.json with new tool identifiers from microsoft/vscode-copilot-chat repository. Source: microsoft/vscode-copilot-chat File: src/extension/tools/common/toolNames.ts --- src/toolNames.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/toolNames.json b/src/toolNames.json index 5fc84675..71249b24 100644 --- a/src/toolNames.json +++ b/src/toolNames.json @@ -64,6 +64,7 @@ ,"copilot_insertEdit": "Insert Edit" ,"copilot_installExtension": "Install Extension" ,"copilot_memory": "Memory" + ,"copilot_openIntegratedBrowser": "Open Integrated Browser" ,"copilot_openSimpleBrowser": "Open Simple Browser" ,"copilot_readNotebookCellOutput": "Read Notebook Cell Output" ,"copilot_readProjectStructure": "Read Project Structure" @@ -96,6 +97,7 @@ ,"list_dir": "List Dir" ,"memory": "Memory" ,"multi_replace_string_in_file": "Multi Replace String In File" + ,"open_integrated_browser": "Open Integrated Browser" ,"open_simple_browser": "Open Simple Browser" ,"read_file": "Read File" ,"read_notebook_cell_output": "Read Notebook Cell Output" @@ -112,6 +114,7 @@ ,"test_failure": "Test Failure" ,"test_search": "Test Search" ,"tool_replay": "Tool Replay" + ,"vscode_askQuestions": "VSCode Ask Questions" ,"vscode_get_confirmation": "VSCode Get Confirmation" ,"vscode_get_terminal_confirmation": "VSCode Get Terminal Confirmation" } From 8a551eb4d14cc55a469307bcc7321d8259cb8305 Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Sun, 22 Feb 2026 21:02:02 +0100 Subject: [PATCH 3/4] Fix compilation errors --- src/extension.ts | 167 ++++++++++++++++------------------ src/webview/logviewer/main.ts | 10 +- 2 files changed, 79 insertions(+), 98 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 7136bccf..f57007d5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -311,11 +311,8 @@ interface ChatTurn { mcpTools: { server: string; tool: string }[]; inputTokensEstimate: number; outputTokensEstimate: number; -<<<<<<< actual-tokens - actualUsage?: ActualUsage; -======= thinkingTokensEstimate: number; ->>>>>>> main + actualUsage?: ActualUsage; } // Full session log data for the log viewer @@ -4782,8 +4779,7 @@ class CopilotTokenTracker implements vscode.Disposable { 'gpt-4'; // Extract response data -<<<<<<< actual-tokens - const { responseText, toolCalls, mcpTools } = this.extractResponseData(request.response || []); + const { responseText, thinkingText, toolCalls, mcpTools } = this.extractResponseData(request.response || []); // Extract actual usage data from request.result if available let actualUsage: ActualUsage | undefined; @@ -4796,103 +4792,94 @@ class CopilotTokenTracker implements vscode.Disposable { details: typeof request.result.details === 'string' ? request.result.details : undefined }; } -======= - const { responseText, thinkingText, toolCalls, mcpTools } = this.extractResponseData(request.response || []); ->>>>>>> main + const turn: ChatTurn = { + turnNumber: i + 1, + timestamp: request.timestamp ? new Date(request.timestamp).toISOString() : null, + mode: sessionMode, + userMessage, + assistantResponse: responseText, + model: requestModel, + toolCalls, + contextReferences: contextRefs, + mcpTools, + inputTokensEstimate: this.estimateTokensFromText(userMessage, requestModel), + outputTokensEstimate: this.estimateTokensFromText(responseText, requestModel), + thinkingTokensEstimate: this.estimateTokensFromText(thinkingText, requestModel), + actualUsage + }; + + turns.push(turn); + } + } else { + // Non-delta JSONL (Copilot CLI format) + let turnNumber = 0; + + for (const line of lines) { + try { + const event = JSON.parse(line); + + // Handle Copilot CLI format (type: 'user.message') + if (event.type === 'user.message' && event.data?.content) { + turnNumber++; + const contextRefs = this.createEmptyContextRefs(); + const userMessage = event.data.content; + this.analyzeContextReferences(userMessage, contextRefs); const turn: ChatTurn = { - turnNumber: i + 1, - timestamp: request.timestamp ? new Date(request.timestamp).toISOString() : null, - mode: sessionMode, + turnNumber, + timestamp: event.timestamp ? new Date(event.timestamp).toISOString() : null, + mode: 'agent', // CLI is typically agent mode userMessage, - assistantResponse: responseText, - model: requestModel, - toolCalls, + assistantResponse: '', + model: event.model || 'gpt-4o', + toolCalls: [], contextReferences: contextRefs, - mcpTools, - inputTokensEstimate: this.estimateTokensFromText(userMessage, requestModel), - outputTokensEstimate: this.estimateTokensFromText(responseText, requestModel), -<<<<<<< actual-tokens - actualUsage -======= - thinkingTokensEstimate: this.estimateTokensFromText(thinkingText, requestModel) ->>>>>>> main + mcpTools: [], + inputTokensEstimate: this.estimateTokensFromText(userMessage, event.model || 'gpt-4o'), + outputTokensEstimate: 0, + thinkingTokensEstimate: 0 }; - turns.push(turn); } - } else { - // Non-delta JSONL (Copilot CLI format) - let turnNumber = 0; - for (const line of lines) { - try { - const event = JSON.parse(line); - - // Handle Copilot CLI format (type: 'user.message') - if (event.type === 'user.message' && event.data?.content) { - turnNumber++; - const contextRefs = this.createEmptyContextRefs(); - const userMessage = event.data.content; - this.analyzeContextReferences(userMessage, contextRefs); - const turn: ChatTurn = { - turnNumber, - timestamp: event.timestamp ? new Date(event.timestamp).toISOString() : null, - mode: 'agent', // CLI is typically agent mode - userMessage, - assistantResponse: '', - model: event.model || 'gpt-4o', - toolCalls: [], - contextReferences: contextRefs, - mcpTools: [], - inputTokensEstimate: this.estimateTokensFromText(userMessage, event.model || 'gpt-4o'), - outputTokensEstimate: 0, - thinkingTokensEstimate: 0 - }; - turns.push(turn); - } - - // Handle CLI assistant response - if (event.type === 'assistant.message' && event.data?.content && turns.length > 0) { - const lastTurn = turns[turns.length - 1]; - lastTurn.assistantResponse += event.data.content; - lastTurn.outputTokensEstimate = this.estimateTokensFromText(lastTurn.assistantResponse, lastTurn.model || 'gpt-4o'); - } - - // Handle CLI tool calls - if ((event.type === 'tool.call' || event.type === 'tool.result') && turns.length > 0) { - const lastTurn = turns[turns.length - 1]; - const toolName = event.data?.toolName || event.toolName || 'unknown'; + // Handle CLI assistant response + if (event.type === 'assistant.message' && event.data?.content && turns.length > 0) { + const lastTurn = turns[turns.length - 1]; + lastTurn.assistantResponse += event.data.content; + lastTurn.outputTokensEstimate = this.estimateTokensFromText(lastTurn.assistantResponse, lastTurn.model || 'gpt-4o'); + } - // Check if this is an MCP tool by name pattern - if (this.isMcpTool(toolName)) { - const serverName = this.extractMcpServerName(toolName); - lastTurn.mcpTools.push({ server: serverName, tool: toolName }); - } else { - // Add to regular tool calls - lastTurn.toolCalls.push({ - toolName, - arguments: event.type === 'tool.call' ? JSON.stringify(event.data?.arguments || {}) : undefined, - result: event.type === 'tool.result' ? event.data?.output : undefined - }); - } - } + // Handle CLI tool calls + if ((event.type === 'tool.call' || event.type === 'tool.result') && turns.length > 0) { + const lastTurn = turns[turns.length - 1]; + const toolName = event.data?.toolName || event.toolName || 'unknown'; - // Handle explicit MCP tool calls from CLI - if ((event.type === 'mcp.tool.call' || event.data?.mcpServer) && turns.length > 0) { - const lastTurn = turns[turns.length - 1]; - const serverName = event.data?.mcpServer || 'unknown'; - const toolName = event.data?.toolName || event.toolName || 'unknown'; - lastTurn.mcpTools.push({ server: serverName, tool: toolName }); - } - } catch { - // Skip malformed lines + // Check if this is an MCP tool by name pattern + if (this.isMcpTool(toolName)) { + const serverName = this.extractMcpServerName(toolName); + lastTurn.mcpTools.push({ server: serverName, tool: toolName }); + } else { + // Add to regular tool calls + lastTurn.toolCalls.push({ + toolName, + arguments: event.type === 'tool.call' ? JSON.stringify(event.data?.arguments || {}) : undefined, + result: event.type === 'tool.result' ? event.data?.output : undefined + }); } } - } - } else { - // Handle regular .json files + // Handle explicit MCP tool calls from CLI + if ((event.type === 'mcp.tool.call' || event.data?.mcpServer) && turns.length > 0) { + const lastTurn = turns[turns.length - 1]; + const serverName = event.data?.mcpServer || 'unknown'; + const toolName = event.data?.toolName || event.toolName || 'unknown'; + lastTurn.mcpTools.push({ server: serverName, tool: toolName }); + } + } catch { + // Skip malformed lines + } + } + } const sessionContent = JSON.parse(fileContent); let sessionMode: 'ask' | 'edit' | 'agent' | 'plan' | 'customAgent' = 'ask'; diff --git a/src/webview/logviewer/main.ts b/src/webview/logviewer/main.ts index 5174a972..cbe584c5 100644 --- a/src/webview/logviewer/main.ts +++ b/src/webview/logviewer/main.ts @@ -29,11 +29,8 @@ type ChatTurn = { mcpTools: { server: string; tool: string }[]; inputTokensEstimate: number; outputTokensEstimate: number; -<<<<<<< actual-tokens - actualUsage?: ActualUsage; -======= thinkingTokensEstimate: number; ->>>>>>> main + actualUsage?: ActualUsage; }; type ToolCallUsage = { total: number; byTool: { [key: string]: number } }; @@ -264,7 +261,7 @@ function renderTurnCard(turn: ChatTurn): string { const hasToolCalls = turn.toolCalls.length > 0; const hasMcpTools = turn.mcpTools.length > 0; const totalRefs = getTotalContextRefs(turn.contextReferences); -<<<<<<< actual-tokens + const hasThinking = turn.thinkingTokensEstimate > 0; const hasActualUsage = !!turn.actualUsage; // Build actual usage section @@ -380,9 +377,6 @@ function renderTurnCard(turn: ChatTurn): string {
`; } -======= - const hasThinking = turn.thinkingTokensEstimate > 0; ->>>>>>> main // Build context file badges for header const contextFileBadges: string[] = []; From 90d90456ec0514355532e8c693c82342fd0e7c33 Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Sun, 22 Feb 2026 23:05:53 +0100 Subject: [PATCH 4/4] feat: add actual and estimated token tracking to usage stats --- src/extension.ts | 228 +++++++++++++++++++++++------------- src/webview/details/main.ts | 6 +- 2 files changed, 153 insertions(+), 81 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index f57007d5..bd40fe40 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -51,6 +51,8 @@ interface RepositoryUsage { interface PeriodStats { tokens: number; thinkingTokens: number; + estimatedTokens: number; // Text-based estimate (user messages + responses only) + actualTokens: number; // Actual LLM API-reported tokens (0 when unavailable) sessions: number; avgInteractionsPerSession: number; avgTokensPerSession: number; @@ -93,6 +95,7 @@ interface SessionFileCache { repository?: string; // Git remote origin URL for the session's workspace workspaceFolderPath?: string; // Full local path to the workspace folder (optional) thinkingTokens?: number; // Estimated thinking/reasoning tokens + actualTokens?: number; // Actual token count from LLM API usage data (when available) } // Local copy of customization file entry type (mirrors webview/shared/contextRefUtils.ts) @@ -345,7 +348,7 @@ interface WorkspaceCustomizationSummary { class CopilotTokenTracker implements vscode.Disposable { // Cache version - increment this when making changes that require cache invalidation - private static readonly CACHE_VERSION = 20; // Fix OpenCode token counting (use cumulative totals) + private static readonly CACHE_VERSION = 22; // Force cache rebuild for actualTokens extraction fix // Maximum length for displaying workspace IDs in diagnostics/customization matrix private static readonly WORKSPACE_ID_DISPLAY_LENGTH = 8; @@ -1489,10 +1492,10 @@ class CopilotTokenTracker implements vscode.Disposable { // Calculate last 30 days boundary const last30DaysStart = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); - const todayStats = { tokens: 0, thinkingTokens: 0, sessions: 0, interactions: 0, modelUsage: {} as ModelUsage, editorUsage: {} as EditorUsage }; - const monthStats = { tokens: 0, thinkingTokens: 0, sessions: 0, interactions: 0, modelUsage: {} as ModelUsage, editorUsage: {} as EditorUsage }; - const lastMonthStats = { tokens: 0, thinkingTokens: 0, sessions: 0, interactions: 0, modelUsage: {} as ModelUsage, editorUsage: {} as EditorUsage }; - const last30DaysStats = { tokens: 0, thinkingTokens: 0, sessions: 0, interactions: 0, modelUsage: {} as ModelUsage, editorUsage: {} as EditorUsage }; + const todayStats = { tokens: 0, thinkingTokens: 0, estimatedTokens: 0, actualTokens: 0, sessions: 0, interactions: 0, modelUsage: {} as ModelUsage, editorUsage: {} as EditorUsage }; + const monthStats = { tokens: 0, thinkingTokens: 0, estimatedTokens: 0, actualTokens: 0, sessions: 0, interactions: 0, modelUsage: {} as ModelUsage, editorUsage: {} as EditorUsage }; + const lastMonthStats = { tokens: 0, thinkingTokens: 0, estimatedTokens: 0, actualTokens: 0, sessions: 0, interactions: 0, modelUsage: {} as ModelUsage, editorUsage: {} as EditorUsage }; + const last30DaysStats = { tokens: 0, thinkingTokens: 0, estimatedTokens: 0, actualTokens: 0, sessions: 0, interactions: 0, modelUsage: {} as ModelUsage, editorUsage: {} as EditorUsage }; try { // Clean expired cache entries @@ -1553,7 +1556,9 @@ class CopilotTokenTracker implements vscode.Disposable { } // Extract remaining data from the cached session - const tokens = sessionData.tokens; + const estimatedTokens = sessionData.tokens; // Text-based estimate (user content only) + const actualTokens = sessionData.actualTokens || 0; // Actual LLM API tokens (when available) + const tokens = actualTokens > 0 ? actualTokens : estimatedTokens; // Best available const modelUsage = sessionData.modelUsage; const editorType = this.getEditorTypeFromPath(sessionFile); @@ -1573,6 +1578,8 @@ class CopilotTokenTracker implements vscode.Disposable { // Check if activity is within last 30 days if (lastActivity >= last30DaysStart) { last30DaysStats.tokens += tokens; + last30DaysStats.estimatedTokens += estimatedTokens; + last30DaysStats.actualTokens += actualTokens; last30DaysStats.thinkingTokens += (sessionData.thinkingTokens || 0); last30DaysStats.sessions += 1; last30DaysStats.interactions += interactions; @@ -1596,6 +1603,8 @@ class CopilotTokenTracker implements vscode.Disposable { if (lastActivity >= monthStart) { monthStats.tokens += tokens; + monthStats.estimatedTokens += estimatedTokens; + monthStats.actualTokens += actualTokens; monthStats.thinkingTokens += (sessionData.thinkingTokens || 0); monthStats.sessions += 1; monthStats.interactions += interactions; @@ -1618,6 +1627,8 @@ class CopilotTokenTracker implements vscode.Disposable { if (lastActivity >= todayStart) { todayStats.tokens += tokens; + todayStats.estimatedTokens += estimatedTokens; + todayStats.actualTokens += actualTokens; todayStats.thinkingTokens += (sessionData.thinkingTokens || 0); todayStats.sessions += 1; todayStats.interactions += interactions; @@ -1642,6 +1653,8 @@ class CopilotTokenTracker implements vscode.Disposable { else if (lastActivity >= lastMonthStart && lastActivity <= lastMonthEnd) { // Session is from last month - only track lastMonth stats lastMonthStats.tokens += tokens; + lastMonthStats.estimatedTokens += estimatedTokens; + lastMonthStats.actualTokens += actualTokens; lastMonthStats.thinkingTokens += (sessionData.thinkingTokens || 0); lastMonthStats.sessions += 1; lastMonthStats.interactions += interactions; @@ -1700,6 +1713,8 @@ class CopilotTokenTracker implements vscode.Disposable { today: { tokens: todayStats.tokens, thinkingTokens: todayStats.thinkingTokens, + estimatedTokens: todayStats.estimatedTokens, + actualTokens: todayStats.actualTokens, sessions: todayStats.sessions, avgInteractionsPerSession: todayStats.sessions > 0 ? Math.round(todayStats.interactions / todayStats.sessions) : 0, avgTokensPerSession: todayStats.sessions > 0 ? Math.round(todayStats.tokens / todayStats.sessions) : 0, @@ -1713,6 +1728,8 @@ class CopilotTokenTracker implements vscode.Disposable { month: { tokens: monthStats.tokens, thinkingTokens: monthStats.thinkingTokens, + estimatedTokens: monthStats.estimatedTokens, + actualTokens: monthStats.actualTokens, sessions: monthStats.sessions, avgInteractionsPerSession: monthStats.sessions > 0 ? Math.round(monthStats.interactions / monthStats.sessions) : 0, avgTokensPerSession: monthStats.sessions > 0 ? Math.round(monthStats.tokens / monthStats.sessions) : 0, @@ -1726,6 +1743,8 @@ class CopilotTokenTracker implements vscode.Disposable { lastMonth: { tokens: lastMonthStats.tokens, thinkingTokens: lastMonthStats.thinkingTokens, + estimatedTokens: lastMonthStats.estimatedTokens, + actualTokens: lastMonthStats.actualTokens, sessions: lastMonthStats.sessions, avgInteractionsPerSession: lastMonthStats.sessions > 0 ? Math.round(lastMonthStats.interactions / lastMonthStats.sessions) : 0, avgTokensPerSession: lastMonthStats.sessions > 0 ? Math.round(lastMonthStats.tokens / lastMonthStats.sessions) : 0, @@ -1739,6 +1758,8 @@ class CopilotTokenTracker implements vscode.Disposable { last30Days: { tokens: last30DaysStats.tokens, thinkingTokens: last30DaysStats.thinkingTokens, + estimatedTokens: last30DaysStats.estimatedTokens, + actualTokens: last30DaysStats.actualTokens, sessions: last30DaysStats.sessions, avgInteractionsPerSession: last30DaysStats.sessions > 0 ? Math.round(last30DaysStats.interactions / last30DaysStats.sessions) : 0, avgTokensPerSession: last30DaysStats.sessions > 0 ? Math.round(last30DaysStats.tokens / last30DaysStats.sessions) : 0, @@ -2519,11 +2540,21 @@ class CopilotTokenTracker implements vscode.Disposable { // Default model for CLI sessions - they may not specify the model per event let defaultModel = 'gpt-4o'; + // For delta-based formats, reconstruct state to extract actual usage + let sessionState: any = {}; + let isDeltaBased = false; + for (const line of lines) { if (!line.trim()) { continue; } try { const event = JSON.parse(line); + // Detect and reconstruct delta-based format + if (typeof event.kind === 'number') { + isDeltaBased = true; + sessionState = this.applyDelta(sessionState, event); + } + // Handle VS Code incremental format - extract model from session header (kind: 0) // The schema has v.selectedModel.identifier or v.selectedModel.metadata.id if (event.kind === 0) { @@ -2551,70 +2582,63 @@ class CopilotTokenTracker implements vscode.Disposable { modelUsage[model] = { inputTokens: 0, outputTokens: 0 }; } - // Handle Copilot CLI format - if (event.type === 'user.message' && event.data?.content) { - modelUsage[model].inputTokens += this.estimateTokensFromText(event.data.content, model); - } else if (event.type === 'assistant.message' && event.data?.content) { - modelUsage[model].outputTokens += this.estimateTokensFromText(event.data.content, model); - } else if (event.type === 'tool.result' && event.data?.output) { - // Tool outputs are typically input context - modelUsage[model].inputTokens += this.estimateTokensFromText(event.data.output, model); + // For non-delta formats, estimate from event text (CLI format) + if (!isDeltaBased) { + // Handle Copilot CLI format + if (event.type === 'user.message' && event.data?.content) { + modelUsage[model].inputTokens += this.estimateTokensFromText(event.data.content, model); + } else if (event.type === 'assistant.message' && event.data?.content) { + modelUsage[model].outputTokens += this.estimateTokensFromText(event.data.content, model); + } else if (event.type === 'tool.result' && event.data?.output) { + // Tool outputs are typically input context + modelUsage[model].inputTokens += this.estimateTokensFromText(event.data.output, model); + } } + } catch (e) { + // Skip malformed lines + } + } - // Handle VS Code incremental format (kind: 2 with requests) - if (event.kind === 2 && event.k?.[0] === 'requests' && Array.isArray(event.v)) { - for (const request of event.v) { - // Extract request-level modelId if available - let requestModel = model; - if (request.modelId) { - requestModel = request.modelId.replace(/^copilot\//, ''); - } else if (request.result?.metadata?.modelId) { - requestModel = request.result.metadata.modelId.replace(/^copilot\//, ''); - } else if (request.result?.details) { - // Parse model from details string like "Claude Opus 4.5 • 3x" - requestModel = this.getModelFromRequest(request); - } + // For delta-based formats, extract actual usage from reconstructed state + if (isDeltaBased && sessionState.requests && Array.isArray(sessionState.requests)) { + for (const request of sessionState.requests) { + if (!request || !request.requestId) { continue; } - if (!modelUsage[requestModel]) { - modelUsage[requestModel] = { inputTokens: 0, outputTokens: 0 }; - } + // Extract request-level modelId + let requestModel = defaultModel; + if (request.modelId) { + requestModel = request.modelId.replace(/^copilot\//, ''); + } else if (request.result?.metadata?.modelId) { + requestModel = request.result.metadata.modelId.replace(/^copilot\//, ''); + } else if (request.result?.details) { + requestModel = this.getModelFromRequest(request); + } - if (request.message?.text) { - modelUsage[requestModel].inputTokens += this.estimateTokensFromText(request.message.text, requestModel); - } - // Also process message.parts if available - if (request.message?.parts && Array.isArray(request.message.parts)) { - for (const part of request.message.parts) { - if (part.text && part.text !== request.message?.text) { - modelUsage[requestModel].inputTokens += this.estimateTokensFromText(part.text, requestModel); - } - } - } - // Process response items if present in the request - if (request.response && Array.isArray(request.response)) { - for (const responseItem of request.response) { - if (responseItem.value) { - modelUsage[requestModel].outputTokens += this.estimateTokensFromText(responseItem.value, requestModel); - } - } - } - } + if (!modelUsage[requestModel]) { + modelUsage[requestModel] = { inputTokens: 0, outputTokens: 0 }; } - // Handle VS Code incremental format - response content (kind: 2 with response) - if (event.kind === 2 && event.k?.includes('response') && Array.isArray(event.v)) { - for (const responseItem of event.v) { - if (responseItem.value) { - modelUsage[model].outputTokens += this.estimateTokensFromText(responseItem.value, model); - } else if (responseItem.kind === 'markdownContent' && responseItem.content?.value) { - modelUsage[model].outputTokens += this.estimateTokensFromText(responseItem.content.value, model); + // Use actual usage if available, otherwise estimate from text + if (request.result?.usage) { + const u = request.result.usage; + modelUsage[requestModel].inputTokens += typeof u.promptTokens === 'number' ? u.promptTokens : 0; + modelUsage[requestModel].outputTokens += typeof u.completionTokens === 'number' ? u.completionTokens : 0; + } else { + // Fallback to text-based estimation + if (request.message?.text) { + modelUsage[requestModel].inputTokens += this.estimateTokensFromText(request.message.text, requestModel); + } + if (request.response && Array.isArray(request.response)) { + for (const responseItem of request.response) { + if (responseItem.value) { + modelUsage[requestModel].outputTokens += this.estimateTokensFromText(responseItem.value, requestModel); + } } } } - } catch (e) { - // Skip malformed lines } } + return modelUsage; } @@ -2631,22 +2655,30 @@ class CopilotTokenTracker implements vscode.Disposable { modelUsage[model] = { inputTokens: 0, outputTokens: 0 }; } - // Estimate tokens from user message (input) - if (request.message && request.message.parts) { - for (const part of request.message.parts) { - if (part.text) { - const tokens = this.estimateTokensFromText(part.text, model); - modelUsage[model].inputTokens += tokens; + // Use actual usage if available, otherwise estimate from text + if (request.result?.usage) { + const u = request.result.usage; + modelUsage[model].inputTokens += typeof u.promptTokens === 'number' ? u.promptTokens : 0; + modelUsage[model].outputTokens += typeof u.completionTokens === 'number' ? u.completionTokens : 0; + } else { + // Fallback to text-based estimation + // Estimate tokens from user message (input) + if (request.message && request.message.parts) { + for (const part of request.message.parts) { + if (part.text) { + const tokens = this.estimateTokensFromText(part.text, model); + modelUsage[model].inputTokens += tokens; + } } } - } - // Estimate tokens from assistant response (output) - if (request.response && Array.isArray(request.response)) { - for (const responseItem of request.response) { - if (responseItem.value) { - const tokens = this.estimateTokensFromText(responseItem.value, model); - modelUsage[model].outputTokens += tokens; + // Estimate tokens from assistant response (output) + if (request.response && Array.isArray(request.response)) { + for (const responseItem of request.response) { + if (responseItem.value) { + const tokens = this.estimateTokensFromText(responseItem.value, model); + modelUsage[model].outputTokens += tokens; + } } } } @@ -4080,7 +4112,8 @@ class CopilotTokenTracker implements vscode.Disposable { title: sessionMeta.title, firstInteraction: sessionMeta.firstInteraction, lastInteraction: sessionMeta.lastInteraction, - thinkingTokens: tokenResult.thinkingTokens + thinkingTokens: tokenResult.thinkingTokens, + actualTokens: tokenResult.actualTokens }; this.setCachedSessionData(sessionFilePath, sessionData, fileSize); @@ -5451,18 +5484,19 @@ class CopilotTokenTracker implements vscode.Disposable { return nonSessionFilePatterns.some(pattern => lowerFilename.includes(pattern)); } - private async estimateTokensFromSession(sessionFilePath: string): Promise<{ tokens: number; thinkingTokens: number }> { + private async estimateTokensFromSession(sessionFilePath: string): Promise<{ tokens: number; thinkingTokens: number; actualTokens: number }> { try { // Handle OpenCode sessions - they have actual token counts in message files if (this.isOpenCodeSessionFile(sessionFilePath)) { - return this.getTokensFromOpenCodeSession(sessionFilePath); + const result = await this.getTokensFromOpenCodeSession(sessionFilePath); + return { ...result, actualTokens: result.tokens }; // OpenCode has actual counts } const fileContent = await fs.promises.readFile(sessionFilePath, 'utf8'); // Check if this is a UUID-only file (new Copilot CLI format) if (this.isUuidPointerFile(fileContent)) { - return { tokens: 0, thinkingTokens: 0 }; + return { tokens: 0, thinkingTokens: 0, actualTokens: 0 }; } // Handle .jsonl files OR .json files with JSONL content (each line is a separate JSON object) @@ -5476,6 +5510,7 @@ class CopilotTokenTracker implements vscode.Disposable { let totalInputTokens = 0; let totalOutputTokens = 0; let totalThinkingTokens = 0; + let totalActualTokens = 0; if (sessionContent.requests && Array.isArray(sessionContent.requests)) { for (const request of sessionContent.requests) { @@ -5501,13 +5536,21 @@ class CopilotTokenTracker implements vscode.Disposable { } } } + + // Extract actual token counts from LLM API usage data + if (request.result?.usage) { + const u = request.result.usage; + const prompt = typeof u.promptTokens === 'number' ? u.promptTokens : 0; + const completion = typeof u.completionTokens === 'number' ? u.completionTokens : 0; + totalActualTokens += prompt + completion; + } } } - return { tokens: totalInputTokens + totalOutputTokens + totalThinkingTokens, thinkingTokens: totalThinkingTokens }; + return { tokens: totalInputTokens + totalOutputTokens + totalThinkingTokens, thinkingTokens: totalThinkingTokens, actualTokens: totalActualTokens }; } catch (error) { this.warn(`Error parsing session file ${sessionFilePath}: ${error}`); - return { tokens: 0, thinkingTokens: 0 }; + return { tokens: 0, thinkingTokens: 0, actualTokens: 0 }; } } @@ -5515,17 +5558,29 @@ class CopilotTokenTracker implements vscode.Disposable { * Estimate tokens from a JSONL session file (used by Copilot CLI/Agent mode and VS Code incremental format) * Each line is a separate JSON object representing an event in the session */ - private estimateTokensFromJsonlSession(fileContent: string): { tokens: number; thinkingTokens: number } { + private estimateTokensFromJsonlSession(fileContent: string): { tokens: number; thinkingTokens: number; actualTokens: number } { let totalTokens = 0; let totalThinkingTokens = 0; const lines = fileContent.trim().split('\n'); + // For delta-based formats, reconstruct full state to reliably extract actual usage. + // Usage data can arrive at many different delta path levels, so line-by-line matching + // is fragile. Reconstructing the state (like the logviewer does) is the reliable approach. + let sessionState: any = {}; + let isDeltaBased = false; + for (const line of lines) { if (!line.trim()) { continue; } try { const event = JSON.parse(line); + // Detect and reconstruct delta-based format in parallel with estimation + if (typeof event.kind === 'number') { + isDeltaBased = true; + sessionState = this.applyDelta(sessionState, event); + } + // Handle Copilot CLI event types if (event.type === 'user.message' && event.data?.content) { totalTokens += this.estimateTokensFromText(event.data.content); @@ -5566,7 +5621,20 @@ class CopilotTokenTracker implements vscode.Disposable { } } - return { tokens: totalTokens + totalThinkingTokens, thinkingTokens: totalThinkingTokens }; + // Extract actual tokens from the reconstructed state (handles all delta path patterns) + let totalActualTokens = 0; + if (isDeltaBased && sessionState.requests && Array.isArray(sessionState.requests)) { + for (const request of sessionState.requests) { + if (request?.result?.usage) { + const u = request.result.usage; + const prompt = typeof u.promptTokens === 'number' ? u.promptTokens : 0; + const completion = typeof u.completionTokens === 'number' ? u.completionTokens : 0; + totalActualTokens += prompt + completion; + } + } + } + + return { tokens: totalTokens + totalThinkingTokens, thinkingTokens: totalThinkingTokens, actualTokens: totalActualTokens }; } /** diff --git a/src/webview/details/main.ts b/src/webview/details/main.ts index 1162da64..224ed62e 100644 --- a/src/webview/details/main.ts +++ b/src/webview/details/main.ts @@ -16,6 +16,8 @@ type EditorUsage = Record; type PeriodStats = { tokens: number; thinkingTokens: number; + estimatedTokens: number; + actualTokens: number; sessions: number; avgInteractionsPerSession: number; avgTokensPerSession: number; @@ -192,7 +194,9 @@ function buildMetricsSection( const tbody = document.createElement('tbody'); const rows: Array<{ label: string; icon: string; color?: string; today: string; last30Days: string; lastMonth: string; projected: string }> = [ - { label: 'Tokens', icon: '🟣', color: '#c37bff', today: formatNumber(stats.today.tokens), last30Days: formatNumber(stats.last30Days.tokens), lastMonth: formatNumber(stats.lastMonth.tokens), projected: formatNumber(projections.projectedTokens) }, + { label: 'Tokens (total)', icon: '🟣', color: '#c37bff', today: (stats.today.actualTokens || 0) > 0 ? formatNumber(stats.today.tokens) : '—', last30Days: (stats.last30Days.actualTokens || 0) > 0 ? formatNumber(stats.last30Days.tokens) : '—', lastMonth: (stats.lastMonth.actualTokens || 0) > 0 ? formatNumber(stats.lastMonth.tokens) : '—', projected: formatNumber(projections.projectedTokens) }, + { label: 'Tokens (user estimated)', icon: '📝', color: '#b39ddb', today: formatNumber(stats.today.estimatedTokens), last30Days: formatNumber(stats.last30Days.estimatedTokens), lastMonth: formatNumber(stats.lastMonth.estimatedTokens), projected: '—' }, + { label: 'Service overhead %', icon: '☁️', color: '#90a4ae', today: (stats.today.actualTokens || 0) > 0 ? formatPercent(((stats.today.tokens - stats.today.estimatedTokens) / stats.today.tokens) * 100) : '—', last30Days: (stats.last30Days.actualTokens || 0) > 0 ? formatPercent(((stats.last30Days.tokens - stats.last30Days.estimatedTokens) / stats.last30Days.tokens) * 100) : '—', lastMonth: (stats.lastMonth.actualTokens || 0) > 0 ? formatPercent(((stats.lastMonth.tokens - stats.lastMonth.estimatedTokens) / stats.lastMonth.tokens) * 100) : '—', projected: '—' }, { label: 'Thinking tokens', icon: '🧠', color: '#a78bfa', today: formatNumber(stats.today.thinkingTokens || 0), last30Days: formatNumber(stats.last30Days.thinkingTokens || 0), lastMonth: formatNumber(stats.lastMonth.thinkingTokens || 0), projected: '—' }, { label: 'Estimated cost', icon: '🪙', color: '#ffd166', today: formatCost(stats.today.estimatedCost), last30Days: formatCost(stats.last30Days.estimatedCost), lastMonth: formatCost(stats.lastMonth.estimatedCost), projected: formatCost(projections.projectedCost) }, { label: 'Sessions', icon: '📅', color: '#66aaff', today: formatNumber(stats.today.sessions), last30Days: formatNumber(stats.last30Days.sessions), lastMonth: formatNumber(stats.lastMonth.sessions), projected: formatNumber(projections.projectedSessions) },