diff --git a/packages/cli/src/ui/utils/export/collect.ts b/packages/cli/src/ui/utils/export/collect.ts index c4de5ee75..cbad97abb 100644 --- a/packages/cli/src/ui/utils/export/collect.ts +++ b/packages/cli/src/ui/utils/export/collect.ts @@ -27,11 +27,111 @@ interface FileOperationStats { uniqueFiles: Set; } +/** + * Tool call arguments index for matching tool_result records. + */ +interface ToolCallArgsIndex { + byId: Map>; + byName: Map>>; +} + +/** + * Extracts tool name from a ChatRecord's function response. + */ +function extractToolNameFromRecord(record: ChatRecord): string | undefined { + if (!record.message?.parts) { + return undefined; + } + + for (const part of record.message.parts) { + if ('functionResponse' in part && part.functionResponse?.name) { + return part.functionResponse.name; + } + } + + return undefined; +} + +/** + * Extracts call ID from a ChatRecord's function response. + */ +function extractFunctionResponseId(record: ChatRecord): string | undefined { + if (!record.message?.parts) { + return undefined; + } + + for (const part of record.message.parts) { + if ('functionResponse' in part && part.functionResponse?.id) { + return part.functionResponse.id; + } + } + + return undefined; +} + +/** + * Normalizes function call args into a plain object. + */ +function normalizeFunctionCallArgs( + args: unknown, +): Record | undefined { + if (args && typeof args === 'object') { + return args as Record; + } + if (typeof args === 'string') { + try { + const parsed = JSON.parse(args) as unknown; + if (parsed && typeof parsed === 'object') { + return parsed as Record; + } + } catch { + // Ignore parse errors and treat as unavailable args + } + } + return undefined; +} + +/** + * Builds an index of assistant tool calls for later tool_result arg resolution. + */ +function buildToolCallArgsIndex(records: ChatRecord[]): ToolCallArgsIndex { + const byId = new Map>(); + const byName = new Map>>(); + + for (const record of records) { + if (record.type !== 'assistant' || !record.message?.parts) continue; + + for (const part of record.message.parts) { + if (!('functionCall' in part) || !part.functionCall?.name) continue; + + const normalizedArgs = normalizeFunctionCallArgs(part.functionCall.args); + if (!normalizedArgs) continue; + + const toolName = part.functionCall.name; + const callId = + typeof part.functionCall.id === 'string' ? part.functionCall.id : null; + + if (callId) { + byId.set(callId, normalizedArgs); + } + + const queue = byName.get(toolName) ?? []; + queue.push(normalizedArgs); + byName.set(toolName, queue); + } + } + + return { byId, byName }; +} + /** * Calculate file operation statistics from ChatRecords. * Uses toolCallResult from tool_result records for accurate statistics. */ function calculateFileStats(records: ChatRecord[]): FileOperationStats { + const argsIndex = buildToolCallArgsIndex(records); + const byNameCursor = new Map(); + const stats: FileOperationStats = { filesRead: 0, filesWritten: 0, @@ -43,8 +143,35 @@ function calculateFileStats(records: ChatRecord[]): FileOperationStats { for (const record of records) { if (record.type !== 'tool_result' || !record.toolCallResult) continue; + const toolName = extractToolNameFromRecord(record); + const callId = + record.toolCallResult.callId ?? extractFunctionResponseId(record); + const argsFromId = + callId && argsIndex.byId.has(callId) + ? argsIndex.byId.get(callId) + : undefined; + let args = argsFromId; + if (!args && toolName) { + const queue = argsIndex.byName.get(toolName); + if (queue && queue.length > 0) { + const cursor = byNameCursor.get(toolName) ?? 0; + args = queue[cursor]; + byNameCursor.set(toolName, cursor + 1); + } + } const { resultDisplay } = record.toolCallResult; + // Handle read_file operations + if ( + toolName === 'read_file' && + (args?.['absolute_path'] || args?.['file_path']) + ) { + const filePath = String(args['absolute_path'] ?? args['file_path']); + stats.filesRead++; + stats.uniqueFiles.add(filePath); + continue; + } + // Track file locations from resultDisplay if ( resultDisplay && @@ -53,20 +180,27 @@ function calculateFileStats(records: ChatRecord[]): FileOperationStats { ) { const display = resultDisplay as { fileName: string; + fileDiff?: string; originalContent?: string | null; newContent?: string; diffStat?: { model_added_lines?: number; model_removed_lines?: number }; }; - // Track unique files - if (typeof display.fileName === 'string') { - stats.uniqueFiles.add(display.fileName); - } - // Determine operation type based on content fields const hasOriginalContent = 'originalContent' in display; const hasNewContent = 'newContent' in display; + // For write/edit operations, use full path from args if available + let filePath: string; + if (typeof display.fileName === 'string') { + // Prefer args.file_path for full path, fallback to fileName (which may be basename) + filePath = + (args?.['file_path'] as string) || + (args?.['absolute_path'] as string) || + display.fileName; + stats.uniqueFiles.add(filePath); + } + if (hasOriginalContent || hasNewContent) { // This is a write/edit operation stats.filesWritten++; @@ -92,9 +226,6 @@ function calculateFileStats(records: ChatRecord[]): FileOperationStats { stats.linesAdded += newLines; stats.linesRemoved += oldLines; } - } else { - // This is likely a read operation (no content changes) - stats.filesRead++; } } } @@ -102,9 +233,47 @@ function calculateFileStats(records: ChatRecord[]): FileOperationStats { return stats; } +/** + * Extracts token usage from TaskResultDisplay executionSummary. + */ +function extractTaskToolTokens(record: ChatRecord): number { + if (record.type !== 'tool_result' || !record.toolCallResult?.resultDisplay) { + return 0; + } + + const { resultDisplay } = record.toolCallResult; + if ( + typeof resultDisplay === 'object' && + 'type' in resultDisplay && + resultDisplay.type === 'task_execution' && + 'executionSummary' in resultDisplay + ) { + const summary = resultDisplay.executionSummary as { + totalTokens?: number; + inputTokens?: number; + outputTokens?: number; + thoughtTokens?: number; + cachedTokens?: number; + }; + // Use totalTokens if available, otherwise sum individual token counts + if (typeof summary.totalTokens === 'number') { + return summary.totalTokens; + } + // Fallback: sum available token counts + return ( + (summary.inputTokens ?? 0) + + (summary.outputTokens ?? 0) + + (summary.thoughtTokens ?? 0) + + (summary.cachedTokens ?? 0) + ); + } + + return 0; +} + /** * Calculate token statistics from ChatRecords. - * Aggregates usageMetadata from assistant records to get total token usage. + * Aggregates usageMetadata from assistant records and TaskTool executionSummary to get total token usage. */ function calculateTokenStats( records: ChatRecord[], @@ -123,6 +292,12 @@ function calculateTokenStats( lastTotalTokens = record.usageMetadata.totalTokenCount; } } + + // Include TaskTool token usage from executionSummary + const taskTokens = extractTaskToolTokens(record); + if (taskTokens > 0) { + totalTokens += taskTokens; + } } // Use last totalTokenCount for context usage calculation diff --git a/packages/cli/src/ui/utils/export/formatters/markdown.ts b/packages/cli/src/ui/utils/export/formatters/markdown.ts index 00250dd16..9267f8bd3 100644 --- a/packages/cli/src/ui/utils/export/formatters/markdown.ts +++ b/packages/cli/src/ui/utils/export/formatters/markdown.ts @@ -17,11 +17,9 @@ export function toMarkdown(sessionData: ExportSessionData): string { lines.push('# Chat Session Export\n'); lines.push(`- **Session ID**: \`${sanitizeText(sessionData.sessionId)}\``); lines.push(`- **Start Time**: ${sanitizeText(sessionData.startTime)}`); - - // Add exportTime if available - if (metadata?.exportTime) { - lines.push(`- **Exported**: ${sanitizeText(metadata.exportTime)}`); - } + lines.push( + `- **Exported**: ${sanitizeText(metadata?.exportTime ?? new Date().toISOString())}`, + ); // Add requestId if available if (metadata?.requestId) { diff --git a/packages/web-templates/src/export-html/src/components/MetadataSidebar.tsx b/packages/web-templates/src/export-html/src/components/MetadataSidebar.tsx index 7593f6d0e..17f6c4264 100644 --- a/packages/web-templates/src/export-html/src/components/MetadataSidebar.tsx +++ b/packages/web-templates/src/export-html/src/components/MetadataSidebar.tsx @@ -41,12 +41,13 @@ export const MetadataSidebar = ({ metadata }: MetadataSidebarProps) => {

Statistics

- {metadata.contextUsagePercent !== undefined && ( - - )} + {metadata.contextUsagePercent !== undefined && + metadata.contextWindowSize !== undefined && ( + + )} {metadata.totalTokens !== undefined && ( { try { const date = new Date(startTime); + const startTimestamp = date.getTime(); + if (Number.isNaN(startTimestamp)) { + return '-'; + } const now = new Date(); - const diffMs = now.getTime() - date.getTime(); + const diffMs = Math.max(0, now.getTime() - startTimestamp); const diffSeconds = Math.floor(diffMs / 1000); const diffMinutes = Math.floor(diffSeconds / 60); const diffHours = Math.floor(diffMinutes / 60); @@ -122,9 +126,10 @@ export const formatPath = (path: string, maxLength: number = 40) => { /** * Format token limit for display (e.g., 128k, 200k, 1m) + * Returns undefined if tokens is not provided. */ -export const formatTokenLimit = (tokens?: number): string => { - if (tokens === undefined || tokens === null) return '128k'; +export const formatTokenLimit = (tokens?: number): string | undefined => { + if (tokens === undefined || tokens === null) return undefined; if (tokens >= 1000000) { return `${(tokens / 1000000).toFixed(tokens % 1000000 === 0 ? 0 : 1)}m`; }