@mariozechner/pi-coding-agent 0.12.3 → 0.12.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/README.md +38 -1
  3. package/dist/changelog.d.ts +1 -1
  4. package/dist/changelog.d.ts.map +1 -1
  5. package/dist/changelog.js +1 -1
  6. package/dist/changelog.js.map +1 -1
  7. package/dist/config.d.ts +48 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +109 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/export-html.d.ts.map +1 -1
  12. package/dist/export-html.js +1 -4
  13. package/dist/export-html.js.map +1 -1
  14. package/dist/main.d.ts.map +1 -1
  15. package/dist/main.js +23 -27
  16. package/dist/main.js.map +1 -1
  17. package/dist/model-config.d.ts.map +1 -1
  18. package/dist/model-config.js +3 -4
  19. package/dist/model-config.js.map +1 -1
  20. package/dist/oauth/storage.d.ts.map +1 -1
  21. package/dist/oauth/storage.js +4 -12
  22. package/dist/oauth/storage.js.map +1 -1
  23. package/dist/session-manager.d.ts +2 -2
  24. package/dist/session-manager.d.ts.map +1 -1
  25. package/dist/session-manager.js +2 -2
  26. package/dist/session-manager.js.map +1 -1
  27. package/dist/settings-manager.d.ts.map +1 -1
  28. package/dist/settings-manager.js +2 -2
  29. package/dist/settings-manager.js.map +1 -1
  30. package/dist/slash-commands.d.ts +2 -2
  31. package/dist/slash-commands.d.ts.map +1 -1
  32. package/dist/slash-commands.js +7 -8
  33. package/dist/slash-commands.js.map +1 -1
  34. package/dist/theme/theme.d.ts.map +1 -1
  35. package/dist/theme/theme.js +11 -15
  36. package/dist/theme/theme.js.map +1 -1
  37. package/dist/tools-manager.d.ts.map +1 -1
  38. package/dist/tools-manager.js +3 -2
  39. package/dist/tools-manager.js.map +1 -1
  40. package/dist/tui/model-selector.d.ts.map +1 -1
  41. package/dist/tui/model-selector.js +1 -1
  42. package/dist/tui/model-selector.js.map +1 -1
  43. package/dist/tui/tool-execution.d.ts.map +1 -1
  44. package/dist/tui/tool-execution.js +3 -2
  45. package/dist/tui/tool-execution.js.map +1 -1
  46. package/dist/tui/tui-renderer.d.ts +1 -0
  47. package/dist/tui/tui-renderer.d.ts.map +1 -1
  48. package/dist/tui/tui-renderer.js +40 -6
  49. package/dist/tui/tui-renderer.js.map +1 -1
  50. package/package.json +8 -4
  51. package/dist/paths.d.ts +0 -32
  52. package/dist/paths.d.ts.map +0 -1
  53. package/dist/paths.js +0 -60
  54. package/dist/paths.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"export-html.d.ts","sourceRoot":"","sources":["../src/export-html.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAM9D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAmU3D;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,cAAc,EAAE,cAAc,EAAE,KAAK,EAAE,UAAU,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,CAkkBlH;AAgqBD;;;GAGG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,CAgC7E","sourcesContent":["import type { AgentState } from \"@mariozechner/pi-agent-core\";\nimport type { AssistantMessage, Message, ToolResultMessage, UserMessage } from \"@mariozechner/pi-ai\";\nimport { existsSync, readFileSync, writeFileSync } from \"fs\";\nimport { homedir } from \"os\";\nimport { basename } from \"path\";\nimport { getPackageJsonPath } from \"./paths.js\";\nimport type { SessionManager } from \"./session-manager.js\";\n\n// Get version from package.json\nconst packageJson = JSON.parse(readFileSync(getPackageJsonPath(), \"utf-8\"));\nconst VERSION = packageJson.version;\n\n/**\n * TUI Color scheme (matching exact RGB values from TUI components)\n */\nconst COLORS = {\n\t// Backgrounds\n\tuserMessageBg: \"rgb(52, 53, 65)\", // Dark slate\n\ttoolPendingBg: \"rgb(40, 40, 50)\", // Dark blue-gray\n\ttoolSuccessBg: \"rgb(40, 50, 40)\", // Dark green\n\ttoolErrorBg: \"rgb(60, 40, 40)\", // Dark red\n\tbodyBg: \"rgb(24, 24, 30)\", // Very dark background\n\tcontainerBg: \"rgb(30, 30, 36)\", // Slightly lighter container\n\n\t// Text colors (matching chalk colors)\n\ttext: \"rgb(229, 229, 231)\", // Light gray (close to white)\n\ttextDim: \"rgb(161, 161, 170)\", // Dimmed gray\n\tcyan: \"rgb(103, 232, 249)\", // Cyan for paths\n\tgreen: \"rgb(34, 197, 94)\", // Green for success\n\tred: \"rgb(239, 68, 68)\", // Red for errors\n\tyellow: \"rgb(234, 179, 8)\", // Yellow for warnings\n\titalic: \"rgb(161, 161, 170)\", // Gray italic for thinking\n};\n\n/**\n * Escape HTML special characters\n */\nfunction escapeHtml(text: string): string {\n\treturn text\n\t\t.replace(/&/g, \"&amp;\")\n\t\t.replace(/</g, \"&lt;\")\n\t\t.replace(/>/g, \"&gt;\")\n\t\t.replace(/\"/g, \"&quot;\")\n\t\t.replace(/'/g, \"&#039;\");\n}\n\n/**\n * Shorten path with tilde notation\n */\nfunction shortenPath(path: string): string {\n\tconst home = homedir();\n\tif (path.startsWith(home)) {\n\t\treturn \"~\" + path.slice(home.length);\n\t}\n\treturn path;\n}\n\n/**\n * Replace tabs with 3 spaces\n */\nfunction replaceTabs(text: string): string {\n\treturn text.replace(/\\t/g, \" \");\n}\n\n/**\n * Format tool execution matching TUI ToolExecutionComponent\n */\nfunction formatToolExecution(\n\ttoolName: string,\n\targs: any,\n\tresult?: ToolResultMessage,\n): { html: string; bgColor: string } {\n\tlet html = \"\";\n\tconst isError = result?.isError || false;\n\tconst bgColor = result ? (isError ? COLORS.toolErrorBg : COLORS.toolSuccessBg) : COLORS.toolPendingBg;\n\n\t// Get text output from result\n\tconst getTextOutput = (): string => {\n\t\tif (!result) return \"\";\n\t\tconst textBlocks = result.content.filter((c) => c.type === \"text\");\n\t\treturn textBlocks.map((c: any) => c.text).join(\"\\n\");\n\t};\n\n\t// Format based on tool type (matching TUI logic exactly)\n\tif (toolName === \"bash\") {\n\t\tconst command = args?.command || \"\";\n\t\thtml = `<div class=\"tool-command\">$ ${escapeHtml(command || \"...\")}</div>`;\n\n\t\tif (result) {\n\t\t\tconst output = getTextOutput().trim();\n\t\t\tif (output) {\n\t\t\t\tconst lines = output.split(\"\\n\");\n\t\t\t\tconst maxLines = 5;\n\t\t\t\tconst displayLines = lines.slice(0, maxLines);\n\t\t\t\tconst remaining = lines.length - maxLines;\n\n\t\t\t\tif (remaining > 0) {\n\t\t\t\t\t// Truncated output - make it expandable\n\t\t\t\t\thtml += '<div class=\"tool-output expandable\" onclick=\"this.classList.toggle(\\'expanded\\')\">';\n\t\t\t\t\thtml += '<div class=\"output-preview\">';\n\t\t\t\t\tfor (const line of displayLines) {\n\t\t\t\t\t\thtml += `<div>${escapeHtml(line)}</div>`;\n\t\t\t\t\t}\n\t\t\t\t\thtml += `<div class=\"expand-hint\">... (${remaining} more lines) - click to expand</div>`;\n\t\t\t\t\thtml += \"</div>\";\n\t\t\t\t\thtml += '<div class=\"output-full\">';\n\t\t\t\t\tfor (const line of lines) {\n\t\t\t\t\t\thtml += `<div>${escapeHtml(line)}</div>`;\n\t\t\t\t\t}\n\t\t\t\t\thtml += \"</div>\";\n\t\t\t\t\thtml += \"</div>\";\n\t\t\t\t} else {\n\t\t\t\t\t// Short output - show all\n\t\t\t\t\thtml += '<div class=\"tool-output\">';\n\t\t\t\t\tfor (const line of displayLines) {\n\t\t\t\t\t\thtml += `<div>${escapeHtml(line)}</div>`;\n\t\t\t\t\t}\n\t\t\t\t\thtml += \"</div>\";\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t} else if (toolName === \"read\") {\n\t\tconst path = shortenPath(args?.file_path || args?.path || \"\");\n\t\thtml = `<div class=\"tool-header\"><span class=\"tool-name\">read</span> <span class=\"tool-path\">${escapeHtml(path || \"...\")}</span></div>`;\n\n\t\tif (result) {\n\t\t\tconst output = getTextOutput();\n\t\t\tconst lines = output.split(\"\\n\");\n\t\t\tconst maxLines = 10;\n\t\t\tconst displayLines = lines.slice(0, maxLines);\n\t\t\tconst remaining = lines.length - maxLines;\n\n\t\t\tif (remaining > 0) {\n\t\t\t\t// Truncated output - make it expandable\n\t\t\t\thtml += '<div class=\"tool-output expandable\" onclick=\"this.classList.toggle(\\'expanded\\')\">';\n\t\t\t\thtml += '<div class=\"output-preview\">';\n\t\t\t\tfor (const line of displayLines) {\n\t\t\t\t\thtml += `<div>${escapeHtml(replaceTabs(line))}</div>`;\n\t\t\t\t}\n\t\t\t\thtml += `<div class=\"expand-hint\">... (${remaining} more lines) - click to expand</div>`;\n\t\t\t\thtml += \"</div>\";\n\t\t\t\thtml += '<div class=\"output-full\">';\n\t\t\t\tfor (const line of lines) {\n\t\t\t\t\thtml += `<div>${escapeHtml(replaceTabs(line))}</div>`;\n\t\t\t\t}\n\t\t\t\thtml += \"</div>\";\n\t\t\t\thtml += \"</div>\";\n\t\t\t} else {\n\t\t\t\t// Short output - show all\n\t\t\t\thtml += '<div class=\"tool-output\">';\n\t\t\t\tfor (const line of displayLines) {\n\t\t\t\t\thtml += `<div>${escapeHtml(replaceTabs(line))}</div>`;\n\t\t\t\t}\n\t\t\t\thtml += \"</div>\";\n\t\t\t}\n\t\t}\n\t} else if (toolName === \"write\") {\n\t\tconst path = shortenPath(args?.file_path || args?.path || \"\");\n\t\tconst fileContent = args?.content || \"\";\n\t\tconst lines = fileContent ? fileContent.split(\"\\n\") : [];\n\t\tconst totalLines = lines.length;\n\n\t\thtml = `<div class=\"tool-header\"><span class=\"tool-name\">write</span> <span class=\"tool-path\">${escapeHtml(path || \"...\")}</span>`;\n\t\tif (totalLines > 10) {\n\t\t\thtml += ` <span class=\"line-count\">(${totalLines} lines)</span>`;\n\t\t}\n\t\thtml += \"</div>\";\n\n\t\tif (fileContent) {\n\t\t\tconst maxLines = 10;\n\t\t\tconst displayLines = lines.slice(0, maxLines);\n\t\t\tconst remaining = lines.length - maxLines;\n\n\t\t\tif (remaining > 0) {\n\t\t\t\t// Truncated output - make it expandable\n\t\t\t\thtml += '<div class=\"tool-output expandable\" onclick=\"this.classList.toggle(\\'expanded\\')\">';\n\t\t\t\thtml += '<div class=\"output-preview\">';\n\t\t\t\tfor (const line of displayLines) {\n\t\t\t\t\thtml += `<div>${escapeHtml(replaceTabs(line))}</div>`;\n\t\t\t\t}\n\t\t\t\thtml += `<div class=\"expand-hint\">... (${remaining} more lines) - click to expand</div>`;\n\t\t\t\thtml += \"</div>\";\n\t\t\t\thtml += '<div class=\"output-full\">';\n\t\t\t\tfor (const line of lines) {\n\t\t\t\t\thtml += `<div>${escapeHtml(replaceTabs(line))}</div>`;\n\t\t\t\t}\n\t\t\t\thtml += \"</div>\";\n\t\t\t\thtml += \"</div>\";\n\t\t\t} else {\n\t\t\t\t// Short output - show all\n\t\t\t\thtml += '<div class=\"tool-output\">';\n\t\t\t\tfor (const line of displayLines) {\n\t\t\t\t\thtml += `<div>${escapeHtml(replaceTabs(line))}</div>`;\n\t\t\t\t}\n\t\t\t\thtml += \"</div>\";\n\t\t\t}\n\t\t}\n\n\t\tif (result) {\n\t\t\tconst output = getTextOutput().trim();\n\t\t\tif (output) {\n\t\t\t\thtml += `<div class=\"tool-output\"><div>${escapeHtml(output)}</div></div>`;\n\t\t\t}\n\t\t}\n\t} else if (toolName === \"edit\") {\n\t\tconst path = shortenPath(args?.file_path || args?.path || \"\");\n\t\thtml = `<div class=\"tool-header\"><span class=\"tool-name\">edit</span> <span class=\"tool-path\">${escapeHtml(path || \"...\")}</span></div>`;\n\n\t\t// Show diff if available from result.details.diff\n\t\tif (result?.details?.diff) {\n\t\t\tconst diffLines = result.details.diff.split(\"\\n\");\n\t\t\thtml += '<div class=\"tool-diff\">';\n\t\t\tfor (const line of diffLines) {\n\t\t\t\tif (line.startsWith(\"+\")) {\n\t\t\t\t\thtml += `<div class=\"diff-line-new\">${escapeHtml(line)}</div>`;\n\t\t\t\t} else if (line.startsWith(\"-\")) {\n\t\t\t\t\thtml += `<div class=\"diff-line-old\">${escapeHtml(line)}</div>`;\n\t\t\t\t} else {\n\t\t\t\t\thtml += `<div class=\"diff-line-context\">${escapeHtml(line)}</div>`;\n\t\t\t\t}\n\t\t\t}\n\t\t\thtml += \"</div>\";\n\t\t}\n\n\t\tif (result) {\n\t\t\tconst output = getTextOutput().trim();\n\t\t\tif (output) {\n\t\t\t\thtml += `<div class=\"tool-output\"><div>${escapeHtml(output)}</div></div>`;\n\t\t\t}\n\t\t}\n\t} else {\n\t\t// Generic tool\n\t\thtml = `<div class=\"tool-header\"><span class=\"tool-name\">${escapeHtml(toolName)}</span></div>`;\n\t\thtml += `<div class=\"tool-output\"><pre>${escapeHtml(JSON.stringify(args, null, 2))}</pre></div>`;\n\n\t\tif (result) {\n\t\t\tconst output = getTextOutput();\n\t\t\tif (output) {\n\t\t\t\thtml += `<div class=\"tool-output\"><div>${escapeHtml(output)}</div></div>`;\n\t\t\t}\n\t\t}\n\t}\n\n\treturn { html, bgColor };\n}\n\n/**\n * Format timestamp for display\n */\nfunction formatTimestamp(timestamp: number | string | undefined): string {\n\tif (!timestamp) return \"\";\n\tconst date = new Date(typeof timestamp === \"string\" ? timestamp : timestamp);\n\treturn date.toLocaleTimeString(undefined, { hour: \"2-digit\", minute: \"2-digit\", second: \"2-digit\" });\n}\n\n/**\n * Format model change event\n */\nfunction formatModelChange(event: any): string {\n\tconst timestamp = formatTimestamp(event.timestamp);\n\tconst timestampHtml = timestamp ? `<div class=\"message-timestamp\">${timestamp}</div>` : \"\";\n\tconst modelInfo = `${event.provider}/${event.modelId}`;\n\treturn `<div class=\"model-change\">${timestampHtml}<div class=\"model-change-text\">Switched to model: <span class=\"model-name\">${escapeHtml(modelInfo)}</span></div></div>`;\n}\n\n/**\n * Format a message as HTML (matching TUI component styling)\n */\nfunction formatMessage(message: Message, toolResultsMap: Map<string, ToolResultMessage>): string {\n\tlet html = \"\";\n\tconst timestamp = (message as any).timestamp;\n\tconst timestampHtml = timestamp ? `<div class=\"message-timestamp\">${formatTimestamp(timestamp)}</div>` : \"\";\n\n\tif (message.role === \"user\") {\n\t\tconst userMsg = message as UserMessage;\n\t\tlet textContent = \"\";\n\n\t\tif (typeof userMsg.content === \"string\") {\n\t\t\ttextContent = userMsg.content;\n\t\t} else {\n\t\t\tconst textBlocks = userMsg.content.filter((c) => c.type === \"text\");\n\t\t\ttextContent = textBlocks.map((c: any) => c.text).join(\"\");\n\t\t}\n\n\t\tif (textContent.trim()) {\n\t\t\thtml += `<div class=\"user-message\">${timestampHtml}${escapeHtml(textContent).replace(/\\n/g, \"<br>\")}</div>`;\n\t\t}\n\t} else if (message.role === \"assistant\") {\n\t\tconst assistantMsg = message as AssistantMessage;\n\t\thtml += timestampHtml ? `<div class=\"assistant-message\">${timestampHtml}` : \"\";\n\n\t\t// Render text and thinking content\n\t\tfor (const content of assistantMsg.content) {\n\t\t\tif (content.type === \"text\" && content.text.trim()) {\n\t\t\t\thtml += `<div class=\"assistant-text\">${escapeHtml(content.text.trim()).replace(/\\n/g, \"<br>\")}</div>`;\n\t\t\t} else if (content.type === \"thinking\" && content.thinking.trim()) {\n\t\t\t\thtml += `<div class=\"thinking-text\">${escapeHtml(content.thinking.trim()).replace(/\\n/g, \"<br>\")}</div>`;\n\t\t\t}\n\t\t}\n\n\t\t// Render tool calls with their results\n\t\tfor (const content of assistantMsg.content) {\n\t\t\tif (content.type === \"toolCall\") {\n\t\t\t\tconst toolResult = toolResultsMap.get(content.id);\n\t\t\t\tconst { html: toolHtml, bgColor } = formatToolExecution(content.name, content.arguments, toolResult);\n\t\t\t\thtml += `<div class=\"tool-execution\" style=\"background-color: ${bgColor}\">${toolHtml}</div>`;\n\t\t\t}\n\t\t}\n\n\t\t// Show error/abort status if no tool calls\n\t\tconst hasToolCalls = assistantMsg.content.some((c) => c.type === \"toolCall\");\n\t\tif (!hasToolCalls) {\n\t\t\tif (assistantMsg.stopReason === \"aborted\") {\n\t\t\t\thtml += '<div class=\"error-text\">Aborted</div>';\n\t\t\t} else if (assistantMsg.stopReason === \"error\") {\n\t\t\t\tconst errorMsg = assistantMsg.errorMessage || \"Unknown error\";\n\t\t\t\thtml += `<div class=\"error-text\">Error: ${escapeHtml(errorMsg)}</div>`;\n\t\t\t}\n\t\t}\n\n\t\t// Close the assistant message wrapper if we opened one\n\t\tif (timestampHtml) {\n\t\t\thtml += \"</div>\";\n\t\t}\n\t}\n\n\treturn html;\n}\n\n/**\n * Export session to a self-contained HTML file matching TUI visual style\n */\nexport function exportSessionToHtml(sessionManager: SessionManager, state: AgentState, outputPath?: string): string {\n\tconst sessionFile = sessionManager.getSessionFile();\n\tconst timestamp = new Date().toISOString();\n\n\t// Use pi-session- prefix + session filename + .html if no output path provided\n\tif (!outputPath) {\n\t\tconst sessionBasename = basename(sessionFile, \".jsonl\");\n\t\toutputPath = `pi-session-${sessionBasename}.html`;\n\t}\n\n\t// Read and parse session data\n\tconst sessionContent = readFileSync(sessionFile, \"utf8\");\n\tconst lines = sessionContent.trim().split(\"\\n\");\n\n\tlet sessionHeader: any = null;\n\tconst messages: Message[] = [];\n\tconst toolResultsMap = new Map<string, ToolResultMessage>();\n\tconst sessionEvents: any[] = []; // Track all events including model changes\n\tconst modelsUsed = new Set<string>(); // Track unique models used\n\n\t// Cumulative token and cost stats\n\tconst tokenStats = {\n\t\tinput: 0,\n\t\toutput: 0,\n\t\tcacheRead: 0,\n\t\tcacheWrite: 0,\n\t};\n\tconst costStats = {\n\t\tinput: 0,\n\t\toutput: 0,\n\t\tcacheRead: 0,\n\t\tcacheWrite: 0,\n\t};\n\n\tfor (const line of lines) {\n\t\ttry {\n\t\t\tconst entry = JSON.parse(line);\n\t\t\tif (entry.type === \"session\") {\n\t\t\t\tsessionHeader = entry;\n\t\t\t\t// Track initial model from session header\n\t\t\t\tif (entry.modelId) {\n\t\t\t\t\tconst modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : entry.modelId;\n\t\t\t\t\tmodelsUsed.add(modelInfo);\n\t\t\t\t}\n\t\t\t} else if (entry.type === \"message\") {\n\t\t\t\tmessages.push(entry.message);\n\t\t\t\tsessionEvents.push(entry);\n\t\t\t\t// Build map of tool call ID to result\n\t\t\t\tif (entry.message.role === \"toolResult\") {\n\t\t\t\t\ttoolResultsMap.set(entry.message.toolCallId, entry.message);\n\t\t\t\t}\n\t\t\t\t// Accumulate token and cost stats from assistant messages\n\t\t\t\tif (entry.message.role === \"assistant\" && entry.message.usage) {\n\t\t\t\t\tconst usage = entry.message.usage;\n\t\t\t\t\ttokenStats.input += usage.input || 0;\n\t\t\t\t\ttokenStats.output += usage.output || 0;\n\t\t\t\t\ttokenStats.cacheRead += usage.cacheRead || 0;\n\t\t\t\t\ttokenStats.cacheWrite += usage.cacheWrite || 0;\n\n\t\t\t\t\tif (usage.cost) {\n\t\t\t\t\t\tcostStats.input += usage.cost.input || 0;\n\t\t\t\t\t\tcostStats.output += usage.cost.output || 0;\n\t\t\t\t\t\tcostStats.cacheRead += usage.cost.cacheRead || 0;\n\t\t\t\t\t\tcostStats.cacheWrite += usage.cost.cacheWrite || 0;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (entry.type === \"model_change\") {\n\t\t\t\tsessionEvents.push(entry);\n\t\t\t\t// Track model from model change event\n\t\t\t\tif (entry.modelId) {\n\t\t\t\t\tconst modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : entry.modelId;\n\t\t\t\t\tmodelsUsed.add(modelInfo);\n\t\t\t\t}\n\t\t\t}\n\t\t} catch {\n\t\t\t// Skip malformed lines\n\t\t}\n\t}\n\n\t// Calculate message stats (matching session command)\n\tconst userMessages = messages.filter((m) => m.role === \"user\").length;\n\tconst assistantMessages = messages.filter((m) => m.role === \"assistant\").length;\n\tconst toolResultMessages = messages.filter((m) => m.role === \"toolResult\").length;\n\tconst totalMessages = messages.length;\n\n\t// Count tool calls from assistant messages\n\tlet toolCallsCount = 0;\n\tfor (const message of messages) {\n\t\tif (message.role === \"assistant\") {\n\t\t\tconst assistantMsg = message as AssistantMessage;\n\t\t\ttoolCallsCount += assistantMsg.content.filter((c) => c.type === \"toolCall\").length;\n\t\t}\n\t}\n\n\t// Get last assistant message for context percentage calculation (skip aborted messages)\n\tconst lastAssistantMessage = messages\n\t\t.slice()\n\t\t.reverse()\n\t\t.find((m) => m.role === \"assistant\" && (m as AssistantMessage).stopReason !== \"aborted\") as\n\t\t| AssistantMessage\n\t\t| undefined;\n\n\t// Calculate context percentage from last message (input + output + cacheRead + cacheWrite)\n\tconst contextTokens = lastAssistantMessage\n\t\t? lastAssistantMessage.usage.input +\n\t\t\tlastAssistantMessage.usage.output +\n\t\t\tlastAssistantMessage.usage.cacheRead +\n\t\t\tlastAssistantMessage.usage.cacheWrite\n\t\t: 0;\n\n\t// Get the model info from the last assistant message\n\tconst lastModel = lastAssistantMessage?.model || state.model?.id || \"unknown\";\n\tconst lastProvider = lastAssistantMessage?.provider || \"\";\n\tconst lastModelInfo = lastProvider ? `${lastProvider}/${lastModel}` : lastModel;\n\n\tconst contextWindow = state.model?.contextWindow || 0;\n\tconst contextPercent = contextWindow > 0 ? ((contextTokens / contextWindow) * 100).toFixed(1) : \"0.0\";\n\n\t// Generate messages HTML (including model changes in chronological order)\n\tlet messagesHtml = \"\";\n\tfor (const event of sessionEvents) {\n\t\tif (event.type === \"message\" && event.message.role !== \"toolResult\") {\n\t\t\t// Skip toolResult messages as they're rendered with their tool calls\n\t\t\tmessagesHtml += formatMessage(event.message, toolResultsMap);\n\t\t} else if (event.type === \"model_change\") {\n\t\t\tmessagesHtml += formatModelChange(event);\n\t\t}\n\t}\n\n\t// Generate HTML (matching TUI aesthetic)\n\tconst html = `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Session Export - ${basename(sessionFile)}</title>\n <style>\n * {\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n }\n\n body {\n font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;\n font-size: 12px;\n line-height: 1.6;\n color: ${COLORS.text};\n background: ${COLORS.bodyBg};\n padding: 24px;\n }\n\n .container {\n max-width: 700px;\n margin: 0 auto;\n }\n\n .header {\n margin-bottom: 24px;\n padding: 16px;\n background: ${COLORS.containerBg};\n border-radius: 4px;\n }\n\n .header h1 {\n font-size: 14px;\n font-weight: bold;\n margin-bottom: 12px;\n color: ${COLORS.cyan};\n }\n\n .header-info {\n display: flex;\n flex-direction: column;\n gap: 3px;\n font-size: 11px;\n }\n\n .info-item {\n color: ${COLORS.textDim};\n display: flex;\n align-items: baseline;\n }\n\n .info-label {\n font-weight: 600;\n margin-right: 8px;\n min-width: 100px;\n }\n\n .info-value {\n color: ${COLORS.text};\n flex: 1;\n }\n\n .info-value.cost {\n font-family: 'SF Mono', monospace;\n }\n\n .messages {\n display: flex;\n flex-direction: column;\n gap: 16px;\n }\n\n /* Message timestamp */\n .message-timestamp {\n font-size: 10px;\n color: ${COLORS.textDim};\n margin-bottom: 4px;\n opacity: 0.8;\n }\n\n /* User message - matching TUI UserMessageComponent */\n .user-message {\n background: ${COLORS.userMessageBg};\n padding: 12px 16px;\n border-radius: 4px;\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n }\n\n /* Assistant message wrapper */\n .assistant-message {\n padding: 0;\n }\n\n /* Assistant text - matching TUI AssistantMessageComponent */\n .assistant-text {\n padding: 12px 16px;\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n }\n\n /* Thinking text - gray italic */\n .thinking-text {\n padding: 12px 16px;\n color: ${COLORS.italic};\n font-style: italic;\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n }\n\n /* Model change */\n .model-change {\n padding: 8px 16px;\n background: rgb(40, 40, 50);\n border-radius: 4px;\n }\n\n .model-change-text {\n color: ${COLORS.textDim};\n font-size: 11px;\n }\n\n .model-name {\n color: ${COLORS.cyan};\n font-weight: bold;\n }\n\n /* Tool execution - matching TUI ToolExecutionComponent */\n .tool-execution {\n padding: 12px 16px;\n border-radius: 4px;\n margin-top: 8px;\n }\n\n .tool-header {\n font-weight: bold;\n }\n\n .tool-name {\n font-weight: bold;\n }\n\n .tool-path {\n color: ${COLORS.cyan};\n word-break: break-all;\n }\n\n .line-count {\n color: ${COLORS.textDim};\n }\n\n .tool-command {\n font-weight: bold;\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n }\n\n .tool-output {\n margin-top: 12px;\n color: ${COLORS.textDim};\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n font-family: inherit;\n overflow-x: auto;\n }\n\n .tool-output > div {\n line-height: 1.4;\n }\n\n .tool-output pre {\n margin: 0;\n font-family: inherit;\n color: inherit;\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n }\n\n /* Expandable tool output */\n .tool-output.expandable {\n cursor: pointer;\n }\n\n .tool-output.expandable:hover {\n opacity: 0.9;\n }\n\n .tool-output.expandable .output-full {\n display: none;\n }\n\n .tool-output.expandable.expanded .output-preview {\n display: none;\n }\n\n .tool-output.expandable.expanded .output-full {\n display: block;\n }\n\n .expand-hint {\n color: ${COLORS.cyan};\n font-style: italic;\n margin-top: 4px;\n }\n\n /* System prompt section */\n .system-prompt {\n background: rgb(60, 55, 40);\n padding: 12px 16px;\n border-radius: 4px;\n margin-bottom: 16px;\n }\n\n .system-prompt-header {\n font-weight: bold;\n color: ${COLORS.yellow};\n margin-bottom: 8px;\n }\n\n .system-prompt-content {\n color: ${COLORS.textDim};\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n font-size: 11px;\n }\n\n .tools-list {\n background: rgb(60, 55, 40);\n padding: 12px 16px;\n border-radius: 4px;\n margin-bottom: 16px;\n }\n\n .tools-header {\n font-weight: bold;\n color: ${COLORS.yellow};\n margin-bottom: 8px;\n }\n\n .tools-content {\n color: ${COLORS.textDim};\n font-size: 11px;\n }\n\n .tool-item {\n margin: 4px 0;\n }\n\n .tool-item-name {\n font-weight: bold;\n color: ${COLORS.text};\n }\n\n /* Diff styling */\n .tool-diff {\n margin-top: 12px;\n font-size: 11px;\n font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;\n overflow-x: auto;\n max-width: 100%;\n }\n\n .diff-line-old {\n color: ${COLORS.red};\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n }\n\n .diff-line-new {\n color: ${COLORS.green};\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n }\n\n .diff-line-context {\n color: ${COLORS.textDim};\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n }\n\n /* Error text */\n .error-text {\n color: ${COLORS.red};\n padding: 12px 16px;\n }\n\n .footer {\n margin-top: 48px;\n padding: 20px;\n text-align: center;\n color: ${COLORS.textDim};\n font-size: 10px;\n }\n\n @media print {\n body {\n background: white;\n color: black;\n }\n .tool-execution {\n border: 1px solid #ddd;\n }\n }\n </style>\n</head>\n<body>\n <div class=\"container\">\n <div class=\"header\">\n <h1>pi v${VERSION}</h1>\n <div class=\"header-info\">\n <div class=\"info-item\">\n <span class=\"info-label\">Session:</span>\n <span class=\"info-value\">${escapeHtml(sessionHeader?.id || \"unknown\")}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Date:</span>\n <span class=\"info-value\">${sessionHeader?.timestamp ? new Date(sessionHeader.timestamp).toLocaleString() : timestamp}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Models:</span>\n <span class=\"info-value\">${\n\t\t\t\t\t\t\t\tArray.from(modelsUsed)\n\t\t\t\t\t\t\t\t\t.map((m) => escapeHtml(m))\n\t\t\t\t\t\t\t\t\t.join(\", \") || escapeHtml(sessionHeader?.model || state.model.id)\n\t\t\t\t\t\t\t}</span>\n </div>\n </div>\n </div>\n\n <div class=\"header\">\n <h1>Messages</h1>\n <div class=\"header-info\">\n <div class=\"info-item\">\n <span class=\"info-label\">User:</span>\n <span class=\"info-value\">${userMessages}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Assistant:</span>\n <span class=\"info-value\">${assistantMessages}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Tool Calls:</span>\n <span class=\"info-value\">${toolCallsCount}</span>\n </div>\n </div>\n </div>\n\n <div class=\"header\">\n <h1>Tokens & Cost</h1>\n <div class=\"header-info\">\n <div class=\"info-item\">\n <span class=\"info-label\">Input:</span>\n <span class=\"info-value\">${tokenStats.input.toLocaleString()} tokens</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Output:</span>\n <span class=\"info-value\">${tokenStats.output.toLocaleString()} tokens</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Cache Read:</span>\n <span class=\"info-value\">${tokenStats.cacheRead.toLocaleString()} tokens</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Cache Write:</span>\n <span class=\"info-value\">${tokenStats.cacheWrite.toLocaleString()} tokens</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Total:</span>\n <span class=\"info-value\">${(tokenStats.input + tokenStats.output + tokenStats.cacheRead + tokenStats.cacheWrite).toLocaleString()} tokens</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Input Cost:</span>\n <span class=\"info-value cost\">$${costStats.input.toFixed(4)}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Output Cost:</span>\n <span class=\"info-value cost\">$${costStats.output.toFixed(4)}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Cache Read Cost:</span>\n <span class=\"info-value cost\">$${costStats.cacheRead.toFixed(4)}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Cache Write Cost:</span>\n <span class=\"info-value cost\">$${costStats.cacheWrite.toFixed(4)}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Total Cost:</span>\n <span class=\"info-value cost\"><strong>$${(costStats.input + costStats.output + costStats.cacheRead + costStats.cacheWrite).toFixed(4)}</strong></span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Context Usage:</span>\n <span class=\"info-value\">${contextTokens.toLocaleString()} / ${contextWindow.toLocaleString()} tokens (${contextPercent}%) - ${escapeHtml(lastModelInfo)}</span>\n </div>\n </div>\n </div>\n\n <div class=\"system-prompt\">\n <div class=\"system-prompt-header\">System Prompt</div>\n <div class=\"system-prompt-content\">${escapeHtml(sessionHeader?.systemPrompt || state.systemPrompt)}</div>\n </div>\n\n <div class=\"tools-list\">\n <div class=\"tools-header\">Available Tools</div>\n <div class=\"tools-content\">\n ${state.tools\n\t\t\t\t\t\t\t.map(\n\t\t\t\t\t\t\t\t(tool) =>\n\t\t\t\t\t\t\t\t\t`<div class=\"tool-item\"><span class=\"tool-item-name\">${escapeHtml(tool.name)}</span> - ${escapeHtml(tool.description)}</div>`,\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t.join(\"\")}\n </div>\n </div>\n\n <div class=\"messages\">\n ${messagesHtml}\n </div>\n\n <div class=\"footer\">\n Generated by pi coding-agent on ${new Date().toLocaleString()}\n </div>\n </div>\n</body>\n</html>`;\n\n\t// Write HTML file\n\twriteFileSync(outputPath, html, \"utf8\");\n\n\treturn outputPath;\n}\n\n/**\n * Parsed session data structure for HTML generation\n */\ninterface ParsedSessionData {\n\tsessionId: string;\n\ttimestamp: string;\n\tcwd?: string;\n\tsystemPrompt?: string;\n\tmodelsUsed: Set<string>;\n\tmessages: Message[];\n\ttoolResultsMap: Map<string, ToolResultMessage>;\n\tsessionEvents: any[];\n\ttokenStats: { input: number; output: number; cacheRead: number; cacheWrite: number };\n\tcostStats: { input: number; output: number; cacheRead: number; cacheWrite: number };\n\ttools?: { name: string; description: string }[];\n\tisStreamingFormat?: boolean;\n}\n\n/**\n * Parse session manager format (type: \"session\", \"message\", \"model_change\")\n */\nfunction parseSessionManagerFormat(lines: string[]): ParsedSessionData {\n\tconst data: ParsedSessionData = {\n\t\tsessionId: \"unknown\",\n\t\ttimestamp: new Date().toISOString(),\n\t\tmodelsUsed: new Set(),\n\t\tmessages: [],\n\t\ttoolResultsMap: new Map(),\n\t\tsessionEvents: [],\n\t\ttokenStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\t\tcostStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\t};\n\n\tfor (const line of lines) {\n\t\ttry {\n\t\t\tconst entry = JSON.parse(line);\n\t\t\tif (entry.type === \"session\") {\n\t\t\t\tdata.sessionId = entry.id || \"unknown\";\n\t\t\t\tdata.timestamp = entry.timestamp || data.timestamp;\n\t\t\t\tdata.cwd = entry.cwd;\n\t\t\t\tdata.systemPrompt = entry.systemPrompt;\n\t\t\t\tif (entry.modelId) {\n\t\t\t\t\tconst modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : entry.modelId;\n\t\t\t\t\tdata.modelsUsed.add(modelInfo);\n\t\t\t\t}\n\t\t\t} else if (entry.type === \"message\") {\n\t\t\t\tdata.messages.push(entry.message);\n\t\t\t\tdata.sessionEvents.push(entry);\n\t\t\t\tif (entry.message.role === \"toolResult\") {\n\t\t\t\t\tdata.toolResultsMap.set(entry.message.toolCallId, entry.message);\n\t\t\t\t}\n\t\t\t\tif (entry.message.role === \"assistant\" && entry.message.usage) {\n\t\t\t\t\tconst usage = entry.message.usage;\n\t\t\t\t\tdata.tokenStats.input += usage.input || 0;\n\t\t\t\t\tdata.tokenStats.output += usage.output || 0;\n\t\t\t\t\tdata.tokenStats.cacheRead += usage.cacheRead || 0;\n\t\t\t\t\tdata.tokenStats.cacheWrite += usage.cacheWrite || 0;\n\t\t\t\t\tif (usage.cost) {\n\t\t\t\t\t\tdata.costStats.input += usage.cost.input || 0;\n\t\t\t\t\t\tdata.costStats.output += usage.cost.output || 0;\n\t\t\t\t\t\tdata.costStats.cacheRead += usage.cost.cacheRead || 0;\n\t\t\t\t\t\tdata.costStats.cacheWrite += usage.cost.cacheWrite || 0;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (entry.type === \"model_change\") {\n\t\t\t\tdata.sessionEvents.push(entry);\n\t\t\t\tif (entry.modelId) {\n\t\t\t\t\tconst modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : entry.modelId;\n\t\t\t\t\tdata.modelsUsed.add(modelInfo);\n\t\t\t\t}\n\t\t\t}\n\t\t} catch {\n\t\t\t// Skip malformed lines\n\t\t}\n\t}\n\n\treturn data;\n}\n\n/**\n * Parse streaming event format (type: \"agent_start\", \"message_start\", \"message_end\", etc.)\n */\nfunction parseStreamingEventFormat(lines: string[]): ParsedSessionData {\n\tconst data: ParsedSessionData = {\n\t\tsessionId: \"unknown\",\n\t\ttimestamp: new Date().toISOString(),\n\t\tmodelsUsed: new Set(),\n\t\tmessages: [],\n\t\ttoolResultsMap: new Map(),\n\t\tsessionEvents: [],\n\t\ttokenStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\t\tcostStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\t\tisStreamingFormat: true,\n\t};\n\n\tlet timestampSet = false;\n\n\t// Track messages by collecting message_end events (which have the final state)\n\tfor (const line of lines) {\n\t\ttry {\n\t\t\tconst entry = JSON.parse(line);\n\n\t\t\tif (entry.type === \"message_end\" && entry.message) {\n\t\t\t\tconst msg = entry.message;\n\t\t\t\tdata.messages.push(msg);\n\t\t\t\tdata.sessionEvents.push({ type: \"message\", message: msg, timestamp: msg.timestamp });\n\n\t\t\t\t// Build tool results map\n\t\t\t\tif (msg.role === \"toolResult\") {\n\t\t\t\t\tdata.toolResultsMap.set(msg.toolCallId, msg);\n\t\t\t\t}\n\n\t\t\t\t// Track models and accumulate stats from assistant messages\n\t\t\t\tif (msg.role === \"assistant\") {\n\t\t\t\t\tif (msg.model) {\n\t\t\t\t\t\tconst modelInfo = msg.provider ? `${msg.provider}/${msg.model}` : msg.model;\n\t\t\t\t\t\tdata.modelsUsed.add(modelInfo);\n\t\t\t\t\t}\n\t\t\t\t\tif (msg.usage) {\n\t\t\t\t\t\tdata.tokenStats.input += msg.usage.input || 0;\n\t\t\t\t\t\tdata.tokenStats.output += msg.usage.output || 0;\n\t\t\t\t\t\tdata.tokenStats.cacheRead += msg.usage.cacheRead || 0;\n\t\t\t\t\t\tdata.tokenStats.cacheWrite += msg.usage.cacheWrite || 0;\n\t\t\t\t\t\tif (msg.usage.cost) {\n\t\t\t\t\t\t\tdata.costStats.input += msg.usage.cost.input || 0;\n\t\t\t\t\t\t\tdata.costStats.output += msg.usage.cost.output || 0;\n\t\t\t\t\t\t\tdata.costStats.cacheRead += msg.usage.cost.cacheRead || 0;\n\t\t\t\t\t\t\tdata.costStats.cacheWrite += msg.usage.cost.cacheWrite || 0;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Use first message timestamp as session timestamp\n\t\t\t\tif (!timestampSet && msg.timestamp) {\n\t\t\t\t\tdata.timestamp = new Date(msg.timestamp).toISOString();\n\t\t\t\t\ttimestampSet = true;\n\t\t\t\t}\n\t\t\t}\n\t\t} catch {\n\t\t\t// Skip malformed lines\n\t\t}\n\t}\n\n\t// Generate a session ID from the timestamp\n\tdata.sessionId = `stream-${data.timestamp.replace(/[:.]/g, \"-\")}`;\n\n\treturn data;\n}\n\n/**\n * Detect the format of a session file by examining the first valid JSON line\n */\nfunction detectFormat(lines: string[]): \"session-manager\" | \"streaming-events\" | \"unknown\" {\n\tfor (const line of lines) {\n\t\ttry {\n\t\t\tconst entry = JSON.parse(line);\n\t\t\tif (entry.type === \"session\") return \"session-manager\";\n\t\t\tif (entry.type === \"agent_start\" || entry.type === \"message_start\" || entry.type === \"turn_start\") {\n\t\t\t\treturn \"streaming-events\";\n\t\t\t}\n\t\t} catch {\n\t\t\t// Skip malformed lines\n\t\t}\n\t}\n\treturn \"unknown\";\n}\n\n/**\n * Generate HTML from parsed session data\n */\nfunction generateHtml(data: ParsedSessionData, inputFilename: string): string {\n\t// Calculate message stats\n\tconst userMessages = data.messages.filter((m) => m.role === \"user\").length;\n\tconst assistantMessages = data.messages.filter((m) => m.role === \"assistant\").length;\n\n\t// Count tool calls from assistant messages\n\tlet toolCallsCount = 0;\n\tfor (const message of data.messages) {\n\t\tif (message.role === \"assistant\") {\n\t\t\tconst assistantMsg = message as AssistantMessage;\n\t\t\ttoolCallsCount += assistantMsg.content.filter((c) => c.type === \"toolCall\").length;\n\t\t}\n\t}\n\n\t// Get last assistant message for context info\n\tconst lastAssistantMessage = data.messages\n\t\t.slice()\n\t\t.reverse()\n\t\t.find((m) => m.role === \"assistant\" && (m as AssistantMessage).stopReason !== \"aborted\") as\n\t\t| AssistantMessage\n\t\t| undefined;\n\n\tconst contextTokens = lastAssistantMessage\n\t\t? lastAssistantMessage.usage.input +\n\t\t\tlastAssistantMessage.usage.output +\n\t\t\tlastAssistantMessage.usage.cacheRead +\n\t\t\tlastAssistantMessage.usage.cacheWrite\n\t\t: 0;\n\n\tconst lastModel = lastAssistantMessage?.model || \"unknown\";\n\tconst lastProvider = lastAssistantMessage?.provider || \"\";\n\tconst lastModelInfo = lastProvider ? `${lastProvider}/${lastModel}` : lastModel;\n\n\t// Generate messages HTML\n\tlet messagesHtml = \"\";\n\tfor (const event of data.sessionEvents) {\n\t\tif (event.type === \"message\" && event.message.role !== \"toolResult\") {\n\t\t\tmessagesHtml += formatMessage(event.message, data.toolResultsMap);\n\t\t} else if (event.type === \"model_change\") {\n\t\t\tmessagesHtml += formatModelChange(event);\n\t\t}\n\t}\n\n\t// Tools section (only if tools info available)\n\tconst toolsHtml = data.tools\n\t\t? `\n <div class=\"tools-list\">\n <div class=\"tools-header\">Available Tools</div>\n <div class=\"tools-content\">\n ${data.tools.map((tool) => `<div class=\"tool-item\"><span class=\"tool-item-name\">${escapeHtml(tool.name)}</span> - ${escapeHtml(tool.description)}</div>`).join(\"\")}\n </div>\n </div>`\n\t\t: \"\";\n\n\t// System prompt section (only if available)\n\tconst systemPromptHtml = data.systemPrompt\n\t\t? `\n <div class=\"system-prompt\">\n <div class=\"system-prompt-header\">System Prompt</div>\n <div class=\"system-prompt-content\">${escapeHtml(data.systemPrompt)}</div>\n </div>`\n\t\t: \"\";\n\n\treturn `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Session Export - ${escapeHtml(inputFilename)}</title>\n <style>\n * {\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n }\n\n body {\n font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;\n font-size: 12px;\n line-height: 1.6;\n color: ${COLORS.text};\n background: ${COLORS.bodyBg};\n padding: 24px;\n }\n\n .container {\n max-width: 700px;\n margin: 0 auto;\n }\n\n .header {\n margin-bottom: 24px;\n padding: 16px;\n background: ${COLORS.containerBg};\n border-radius: 4px;\n }\n\n .header h1 {\n font-size: 14px;\n font-weight: bold;\n margin-bottom: 12px;\n color: ${COLORS.cyan};\n }\n\n .header-info {\n display: flex;\n flex-direction: column;\n gap: 3px;\n font-size: 11px;\n }\n\n .info-item {\n color: ${COLORS.textDim};\n display: flex;\n align-items: baseline;\n }\n\n .info-label {\n font-weight: 600;\n margin-right: 8px;\n min-width: 100px;\n }\n\n .info-value {\n color: ${COLORS.text};\n flex: 1;\n }\n\n .info-value.cost {\n font-family: 'SF Mono', monospace;\n }\n\n .messages {\n display: flex;\n flex-direction: column;\n gap: 16px;\n }\n\n .message-timestamp {\n font-size: 10px;\n color: ${COLORS.textDim};\n margin-bottom: 4px;\n opacity: 0.8;\n }\n\n .user-message {\n background: ${COLORS.userMessageBg};\n padding: 12px 16px;\n border-radius: 4px;\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n }\n\n .assistant-message {\n padding: 0;\n }\n\n .assistant-text {\n padding: 12px 16px;\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n }\n\n .thinking-text {\n padding: 12px 16px;\n color: ${COLORS.italic};\n font-style: italic;\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n }\n\n .model-change {\n padding: 8px 16px;\n background: rgb(40, 40, 50);\n border-radius: 4px;\n }\n\n .model-change-text {\n color: ${COLORS.textDim};\n font-size: 11px;\n }\n\n .model-name {\n color: ${COLORS.cyan};\n font-weight: bold;\n }\n\n .tool-execution {\n padding: 12px 16px;\n border-radius: 4px;\n margin-top: 8px;\n }\n\n .tool-header {\n font-weight: bold;\n }\n\n .tool-name {\n font-weight: bold;\n }\n\n .tool-path {\n color: ${COLORS.cyan};\n word-break: break-all;\n }\n\n .line-count {\n color: ${COLORS.textDim};\n }\n\n .tool-command {\n font-weight: bold;\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n }\n\n .tool-output {\n margin-top: 12px;\n color: ${COLORS.textDim};\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n font-family: inherit;\n overflow-x: auto;\n }\n\n .tool-output > div {\n line-height: 1.4;\n }\n\n .tool-output pre {\n margin: 0;\n font-family: inherit;\n color: inherit;\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n }\n\n .tool-output.expandable {\n cursor: pointer;\n }\n\n .tool-output.expandable:hover {\n opacity: 0.9;\n }\n\n .tool-output.expandable .output-full {\n display: none;\n }\n\n .tool-output.expandable.expanded .output-preview {\n display: none;\n }\n\n .tool-output.expandable.expanded .output-full {\n display: block;\n }\n\n .expand-hint {\n color: ${COLORS.cyan};\n font-style: italic;\n margin-top: 4px;\n }\n\n .system-prompt {\n background: rgb(60, 55, 40);\n padding: 12px 16px;\n border-radius: 4px;\n margin-bottom: 16px;\n }\n\n .system-prompt-header {\n font-weight: bold;\n color: ${COLORS.yellow};\n margin-bottom: 8px;\n }\n\n .system-prompt-content {\n color: ${COLORS.textDim};\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n font-size: 11px;\n }\n\n .tools-list {\n background: rgb(60, 55, 40);\n padding: 12px 16px;\n border-radius: 4px;\n margin-bottom: 16px;\n }\n\n .tools-header {\n font-weight: bold;\n color: ${COLORS.yellow};\n margin-bottom: 8px;\n }\n\n .tools-content {\n color: ${COLORS.textDim};\n font-size: 11px;\n }\n\n .tool-item {\n margin: 4px 0;\n }\n\n .tool-item-name {\n font-weight: bold;\n color: ${COLORS.text};\n }\n\n .tool-diff {\n margin-top: 12px;\n font-size: 11px;\n font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;\n overflow-x: auto;\n max-width: 100%;\n }\n\n .diff-line-old {\n color: ${COLORS.red};\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n }\n\n .diff-line-new {\n color: ${COLORS.green};\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n }\n\n .diff-line-context {\n color: ${COLORS.textDim};\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n }\n\n .error-text {\n color: ${COLORS.red};\n padding: 12px 16px;\n }\n\n .footer {\n margin-top: 48px;\n padding: 20px;\n text-align: center;\n color: ${COLORS.textDim};\n font-size: 10px;\n }\n\n .streaming-notice {\n background: rgb(50, 45, 35);\n padding: 12px 16px;\n border-radius: 4px;\n margin-bottom: 16px;\n color: ${COLORS.textDim};\n font-size: 11px;\n }\n\n @media print {\n body {\n background: white;\n color: black;\n }\n .tool-execution {\n border: 1px solid #ddd;\n }\n }\n </style>\n</head>\n<body>\n <div class=\"container\">\n <div class=\"header\">\n <h1>pi v${VERSION}</h1>\n <div class=\"header-info\">\n <div class=\"info-item\">\n <span class=\"info-label\">Session:</span>\n <span class=\"info-value\">${escapeHtml(data.sessionId)}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Date:</span>\n <span class=\"info-value\">${new Date(data.timestamp).toLocaleString()}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Models:</span>\n <span class=\"info-value\">${\n\t\t\t\t\t\t\t\tArray.from(data.modelsUsed)\n\t\t\t\t\t\t\t\t\t.map((m) => escapeHtml(m))\n\t\t\t\t\t\t\t\t\t.join(\", \") || \"unknown\"\n\t\t\t\t\t\t\t}</span>\n </div>\n </div>\n </div>\n\n <div class=\"header\">\n <h1>Messages</h1>\n <div class=\"header-info\">\n <div class=\"info-item\">\n <span class=\"info-label\">User:</span>\n <span class=\"info-value\">${userMessages}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Assistant:</span>\n <span class=\"info-value\">${assistantMessages}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Tool Calls:</span>\n <span class=\"info-value\">${toolCallsCount}</span>\n </div>\n </div>\n </div>\n\n <div class=\"header\">\n <h1>Tokens & Cost</h1>\n <div class=\"header-info\">\n <div class=\"info-item\">\n <span class=\"info-label\">Input:</span>\n <span class=\"info-value\">${data.tokenStats.input.toLocaleString()} tokens</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Output:</span>\n <span class=\"info-value\">${data.tokenStats.output.toLocaleString()} tokens</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Cache Read:</span>\n <span class=\"info-value\">${data.tokenStats.cacheRead.toLocaleString()} tokens</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Cache Write:</span>\n <span class=\"info-value\">${data.tokenStats.cacheWrite.toLocaleString()} tokens</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Total:</span>\n <span class=\"info-value\">${(data.tokenStats.input + data.tokenStats.output + data.tokenStats.cacheRead + data.tokenStats.cacheWrite).toLocaleString()} tokens</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Input Cost:</span>\n <span class=\"info-value cost\">$${data.costStats.input.toFixed(4)}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Output Cost:</span>\n <span class=\"info-value cost\">$${data.costStats.output.toFixed(4)}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Cache Read Cost:</span>\n <span class=\"info-value cost\">$${data.costStats.cacheRead.toFixed(4)}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Cache Write Cost:</span>\n <span class=\"info-value cost\">$${data.costStats.cacheWrite.toFixed(4)}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Total Cost:</span>\n <span class=\"info-value cost\"><strong>$${(data.costStats.input + data.costStats.output + data.costStats.cacheRead + data.costStats.cacheWrite).toFixed(4)}</strong></span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Context Usage:</span>\n <span class=\"info-value\">${contextTokens.toLocaleString()} tokens (last turn) - ${escapeHtml(lastModelInfo)}</span>\n </div>\n </div>\n </div>\n\n ${systemPromptHtml}\n ${toolsHtml}\n\n ${\n\t\t\t\tdata.isStreamingFormat\n\t\t\t\t\t? `<div class=\"streaming-notice\">\n <em>Note: This session was reconstructed from raw agent event logs, which do not contain system prompt or tool definitions.</em>\n </div>`\n\t\t\t\t\t: \"\"\n\t\t\t}\n\n <div class=\"messages\">\n ${messagesHtml}\n </div>\n\n <div class=\"footer\">\n Generated by pi coding-agent on ${new Date().toLocaleString()}\n </div>\n </div>\n</body>\n</html>`;\n}\n\n/**\n * Export a session file to HTML (standalone, without AgentState or SessionManager)\n * Auto-detects format: session manager format or streaming event format\n */\nexport function exportFromFile(inputPath: string, outputPath?: string): string {\n\tif (!existsSync(inputPath)) {\n\t\tthrow new Error(`File not found: ${inputPath}`);\n\t}\n\n\tconst content = readFileSync(inputPath, \"utf8\");\n\tconst lines = content\n\t\t.trim()\n\t\t.split(\"\\n\")\n\t\t.filter((l) => l.trim());\n\n\tif (lines.length === 0) {\n\t\tthrow new Error(`Empty file: ${inputPath}`);\n\t}\n\n\tconst format = detectFormat(lines);\n\tif (format === \"unknown\") {\n\t\tthrow new Error(`Unknown session file format: ${inputPath}`);\n\t}\n\n\tconst data = format === \"session-manager\" ? parseSessionManagerFormat(lines) : parseStreamingEventFormat(lines);\n\n\t// Generate output path if not provided\n\tif (!outputPath) {\n\t\tconst inputBasename = basename(inputPath, \".jsonl\");\n\t\toutputPath = `pi-session-${inputBasename}.html`;\n\t}\n\n\tconst html = generateHtml(data, basename(inputPath));\n\twriteFileSync(outputPath, html, \"utf8\");\n\n\treturn outputPath;\n}\n"]}
1
+ {"version":3,"file":"export-html.d.ts","sourceRoot":"","sources":["../src/export-html.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAM9D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AA+T3D;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,cAAc,EAAE,cAAc,EAAE,KAAK,EAAE,UAAU,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,CAkkBlH;AAgqBD;;;GAGG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,CAgC7E","sourcesContent":["import type { AgentState } from \"@mariozechner/pi-agent-core\";\nimport type { AssistantMessage, Message, ToolResultMessage, UserMessage } from \"@mariozechner/pi-ai\";\nimport { existsSync, readFileSync, writeFileSync } from \"fs\";\nimport { homedir } from \"os\";\nimport { basename } from \"path\";\nimport { VERSION } from \"./config.js\";\nimport type { SessionManager } from \"./session-manager.js\";\n\n/**\n * TUI Color scheme (matching exact RGB values from TUI components)\n */\nconst COLORS = {\n\t// Backgrounds\n\tuserMessageBg: \"rgb(52, 53, 65)\", // Dark slate\n\ttoolPendingBg: \"rgb(40, 40, 50)\", // Dark blue-gray\n\ttoolSuccessBg: \"rgb(40, 50, 40)\", // Dark green\n\ttoolErrorBg: \"rgb(60, 40, 40)\", // Dark red\n\tbodyBg: \"rgb(24, 24, 30)\", // Very dark background\n\tcontainerBg: \"rgb(30, 30, 36)\", // Slightly lighter container\n\n\t// Text colors (matching chalk colors)\n\ttext: \"rgb(229, 229, 231)\", // Light gray (close to white)\n\ttextDim: \"rgb(161, 161, 170)\", // Dimmed gray\n\tcyan: \"rgb(103, 232, 249)\", // Cyan for paths\n\tgreen: \"rgb(34, 197, 94)\", // Green for success\n\tred: \"rgb(239, 68, 68)\", // Red for errors\n\tyellow: \"rgb(234, 179, 8)\", // Yellow for warnings\n\titalic: \"rgb(161, 161, 170)\", // Gray italic for thinking\n};\n\n/**\n * Escape HTML special characters\n */\nfunction escapeHtml(text: string): string {\n\treturn text\n\t\t.replace(/&/g, \"&amp;\")\n\t\t.replace(/</g, \"&lt;\")\n\t\t.replace(/>/g, \"&gt;\")\n\t\t.replace(/\"/g, \"&quot;\")\n\t\t.replace(/'/g, \"&#039;\");\n}\n\n/**\n * Shorten path with tilde notation\n */\nfunction shortenPath(path: string): string {\n\tconst home = homedir();\n\tif (path.startsWith(home)) {\n\t\treturn \"~\" + path.slice(home.length);\n\t}\n\treturn path;\n}\n\n/**\n * Replace tabs with 3 spaces\n */\nfunction replaceTabs(text: string): string {\n\treturn text.replace(/\\t/g, \" \");\n}\n\n/**\n * Format tool execution matching TUI ToolExecutionComponent\n */\nfunction formatToolExecution(\n\ttoolName: string,\n\targs: any,\n\tresult?: ToolResultMessage,\n): { html: string; bgColor: string } {\n\tlet html = \"\";\n\tconst isError = result?.isError || false;\n\tconst bgColor = result ? (isError ? COLORS.toolErrorBg : COLORS.toolSuccessBg) : COLORS.toolPendingBg;\n\n\t// Get text output from result\n\tconst getTextOutput = (): string => {\n\t\tif (!result) return \"\";\n\t\tconst textBlocks = result.content.filter((c) => c.type === \"text\");\n\t\treturn textBlocks.map((c: any) => c.text).join(\"\\n\");\n\t};\n\n\t// Format based on tool type (matching TUI logic exactly)\n\tif (toolName === \"bash\") {\n\t\tconst command = args?.command || \"\";\n\t\thtml = `<div class=\"tool-command\">$ ${escapeHtml(command || \"...\")}</div>`;\n\n\t\tif (result) {\n\t\t\tconst output = getTextOutput().trim();\n\t\t\tif (output) {\n\t\t\t\tconst lines = output.split(\"\\n\");\n\t\t\t\tconst maxLines = 5;\n\t\t\t\tconst displayLines = lines.slice(0, maxLines);\n\t\t\t\tconst remaining = lines.length - maxLines;\n\n\t\t\t\tif (remaining > 0) {\n\t\t\t\t\t// Truncated output - make it expandable\n\t\t\t\t\thtml += '<div class=\"tool-output expandable\" onclick=\"this.classList.toggle(\\'expanded\\')\">';\n\t\t\t\t\thtml += '<div class=\"output-preview\">';\n\t\t\t\t\tfor (const line of displayLines) {\n\t\t\t\t\t\thtml += `<div>${escapeHtml(line)}</div>`;\n\t\t\t\t\t}\n\t\t\t\t\thtml += `<div class=\"expand-hint\">... (${remaining} more lines) - click to expand</div>`;\n\t\t\t\t\thtml += \"</div>\";\n\t\t\t\t\thtml += '<div class=\"output-full\">';\n\t\t\t\t\tfor (const line of lines) {\n\t\t\t\t\t\thtml += `<div>${escapeHtml(line)}</div>`;\n\t\t\t\t\t}\n\t\t\t\t\thtml += \"</div>\";\n\t\t\t\t\thtml += \"</div>\";\n\t\t\t\t} else {\n\t\t\t\t\t// Short output - show all\n\t\t\t\t\thtml += '<div class=\"tool-output\">';\n\t\t\t\t\tfor (const line of displayLines) {\n\t\t\t\t\t\thtml += `<div>${escapeHtml(line)}</div>`;\n\t\t\t\t\t}\n\t\t\t\t\thtml += \"</div>\";\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t} else if (toolName === \"read\") {\n\t\tconst path = shortenPath(args?.file_path || args?.path || \"\");\n\t\thtml = `<div class=\"tool-header\"><span class=\"tool-name\">read</span> <span class=\"tool-path\">${escapeHtml(path || \"...\")}</span></div>`;\n\n\t\tif (result) {\n\t\t\tconst output = getTextOutput();\n\t\t\tconst lines = output.split(\"\\n\");\n\t\t\tconst maxLines = 10;\n\t\t\tconst displayLines = lines.slice(0, maxLines);\n\t\t\tconst remaining = lines.length - maxLines;\n\n\t\t\tif (remaining > 0) {\n\t\t\t\t// Truncated output - make it expandable\n\t\t\t\thtml += '<div class=\"tool-output expandable\" onclick=\"this.classList.toggle(\\'expanded\\')\">';\n\t\t\t\thtml += '<div class=\"output-preview\">';\n\t\t\t\tfor (const line of displayLines) {\n\t\t\t\t\thtml += `<div>${escapeHtml(replaceTabs(line))}</div>`;\n\t\t\t\t}\n\t\t\t\thtml += `<div class=\"expand-hint\">... (${remaining} more lines) - click to expand</div>`;\n\t\t\t\thtml += \"</div>\";\n\t\t\t\thtml += '<div class=\"output-full\">';\n\t\t\t\tfor (const line of lines) {\n\t\t\t\t\thtml += `<div>${escapeHtml(replaceTabs(line))}</div>`;\n\t\t\t\t}\n\t\t\t\thtml += \"</div>\";\n\t\t\t\thtml += \"</div>\";\n\t\t\t} else {\n\t\t\t\t// Short output - show all\n\t\t\t\thtml += '<div class=\"tool-output\">';\n\t\t\t\tfor (const line of displayLines) {\n\t\t\t\t\thtml += `<div>${escapeHtml(replaceTabs(line))}</div>`;\n\t\t\t\t}\n\t\t\t\thtml += \"</div>\";\n\t\t\t}\n\t\t}\n\t} else if (toolName === \"write\") {\n\t\tconst path = shortenPath(args?.file_path || args?.path || \"\");\n\t\tconst fileContent = args?.content || \"\";\n\t\tconst lines = fileContent ? fileContent.split(\"\\n\") : [];\n\t\tconst totalLines = lines.length;\n\n\t\thtml = `<div class=\"tool-header\"><span class=\"tool-name\">write</span> <span class=\"tool-path\">${escapeHtml(path || \"...\")}</span>`;\n\t\tif (totalLines > 10) {\n\t\t\thtml += ` <span class=\"line-count\">(${totalLines} lines)</span>`;\n\t\t}\n\t\thtml += \"</div>\";\n\n\t\tif (fileContent) {\n\t\t\tconst maxLines = 10;\n\t\t\tconst displayLines = lines.slice(0, maxLines);\n\t\t\tconst remaining = lines.length - maxLines;\n\n\t\t\tif (remaining > 0) {\n\t\t\t\t// Truncated output - make it expandable\n\t\t\t\thtml += '<div class=\"tool-output expandable\" onclick=\"this.classList.toggle(\\'expanded\\')\">';\n\t\t\t\thtml += '<div class=\"output-preview\">';\n\t\t\t\tfor (const line of displayLines) {\n\t\t\t\t\thtml += `<div>${escapeHtml(replaceTabs(line))}</div>`;\n\t\t\t\t}\n\t\t\t\thtml += `<div class=\"expand-hint\">... (${remaining} more lines) - click to expand</div>`;\n\t\t\t\thtml += \"</div>\";\n\t\t\t\thtml += '<div class=\"output-full\">';\n\t\t\t\tfor (const line of lines) {\n\t\t\t\t\thtml += `<div>${escapeHtml(replaceTabs(line))}</div>`;\n\t\t\t\t}\n\t\t\t\thtml += \"</div>\";\n\t\t\t\thtml += \"</div>\";\n\t\t\t} else {\n\t\t\t\t// Short output - show all\n\t\t\t\thtml += '<div class=\"tool-output\">';\n\t\t\t\tfor (const line of displayLines) {\n\t\t\t\t\thtml += `<div>${escapeHtml(replaceTabs(line))}</div>`;\n\t\t\t\t}\n\t\t\t\thtml += \"</div>\";\n\t\t\t}\n\t\t}\n\n\t\tif (result) {\n\t\t\tconst output = getTextOutput().trim();\n\t\t\tif (output) {\n\t\t\t\thtml += `<div class=\"tool-output\"><div>${escapeHtml(output)}</div></div>`;\n\t\t\t}\n\t\t}\n\t} else if (toolName === \"edit\") {\n\t\tconst path = shortenPath(args?.file_path || args?.path || \"\");\n\t\thtml = `<div class=\"tool-header\"><span class=\"tool-name\">edit</span> <span class=\"tool-path\">${escapeHtml(path || \"...\")}</span></div>`;\n\n\t\t// Show diff if available from result.details.diff\n\t\tif (result?.details?.diff) {\n\t\t\tconst diffLines = result.details.diff.split(\"\\n\");\n\t\t\thtml += '<div class=\"tool-diff\">';\n\t\t\tfor (const line of diffLines) {\n\t\t\t\tif (line.startsWith(\"+\")) {\n\t\t\t\t\thtml += `<div class=\"diff-line-new\">${escapeHtml(line)}</div>`;\n\t\t\t\t} else if (line.startsWith(\"-\")) {\n\t\t\t\t\thtml += `<div class=\"diff-line-old\">${escapeHtml(line)}</div>`;\n\t\t\t\t} else {\n\t\t\t\t\thtml += `<div class=\"diff-line-context\">${escapeHtml(line)}</div>`;\n\t\t\t\t}\n\t\t\t}\n\t\t\thtml += \"</div>\";\n\t\t}\n\n\t\tif (result) {\n\t\t\tconst output = getTextOutput().trim();\n\t\t\tif (output) {\n\t\t\t\thtml += `<div class=\"tool-output\"><div>${escapeHtml(output)}</div></div>`;\n\t\t\t}\n\t\t}\n\t} else {\n\t\t// Generic tool\n\t\thtml = `<div class=\"tool-header\"><span class=\"tool-name\">${escapeHtml(toolName)}</span></div>`;\n\t\thtml += `<div class=\"tool-output\"><pre>${escapeHtml(JSON.stringify(args, null, 2))}</pre></div>`;\n\n\t\tif (result) {\n\t\t\tconst output = getTextOutput();\n\t\t\tif (output) {\n\t\t\t\thtml += `<div class=\"tool-output\"><div>${escapeHtml(output)}</div></div>`;\n\t\t\t}\n\t\t}\n\t}\n\n\treturn { html, bgColor };\n}\n\n/**\n * Format timestamp for display\n */\nfunction formatTimestamp(timestamp: number | string | undefined): string {\n\tif (!timestamp) return \"\";\n\tconst date = new Date(typeof timestamp === \"string\" ? timestamp : timestamp);\n\treturn date.toLocaleTimeString(undefined, { hour: \"2-digit\", minute: \"2-digit\", second: \"2-digit\" });\n}\n\n/**\n * Format model change event\n */\nfunction formatModelChange(event: any): string {\n\tconst timestamp = formatTimestamp(event.timestamp);\n\tconst timestampHtml = timestamp ? `<div class=\"message-timestamp\">${timestamp}</div>` : \"\";\n\tconst modelInfo = `${event.provider}/${event.modelId}`;\n\treturn `<div class=\"model-change\">${timestampHtml}<div class=\"model-change-text\">Switched to model: <span class=\"model-name\">${escapeHtml(modelInfo)}</span></div></div>`;\n}\n\n/**\n * Format a message as HTML (matching TUI component styling)\n */\nfunction formatMessage(message: Message, toolResultsMap: Map<string, ToolResultMessage>): string {\n\tlet html = \"\";\n\tconst timestamp = (message as any).timestamp;\n\tconst timestampHtml = timestamp ? `<div class=\"message-timestamp\">${formatTimestamp(timestamp)}</div>` : \"\";\n\n\tif (message.role === \"user\") {\n\t\tconst userMsg = message as UserMessage;\n\t\tlet textContent = \"\";\n\n\t\tif (typeof userMsg.content === \"string\") {\n\t\t\ttextContent = userMsg.content;\n\t\t} else {\n\t\t\tconst textBlocks = userMsg.content.filter((c) => c.type === \"text\");\n\t\t\ttextContent = textBlocks.map((c: any) => c.text).join(\"\");\n\t\t}\n\n\t\tif (textContent.trim()) {\n\t\t\thtml += `<div class=\"user-message\">${timestampHtml}${escapeHtml(textContent).replace(/\\n/g, \"<br>\")}</div>`;\n\t\t}\n\t} else if (message.role === \"assistant\") {\n\t\tconst assistantMsg = message as AssistantMessage;\n\t\thtml += timestampHtml ? `<div class=\"assistant-message\">${timestampHtml}` : \"\";\n\n\t\t// Render text and thinking content\n\t\tfor (const content of assistantMsg.content) {\n\t\t\tif (content.type === \"text\" && content.text.trim()) {\n\t\t\t\thtml += `<div class=\"assistant-text\">${escapeHtml(content.text.trim()).replace(/\\n/g, \"<br>\")}</div>`;\n\t\t\t} else if (content.type === \"thinking\" && content.thinking.trim()) {\n\t\t\t\thtml += `<div class=\"thinking-text\">${escapeHtml(content.thinking.trim()).replace(/\\n/g, \"<br>\")}</div>`;\n\t\t\t}\n\t\t}\n\n\t\t// Render tool calls with their results\n\t\tfor (const content of assistantMsg.content) {\n\t\t\tif (content.type === \"toolCall\") {\n\t\t\t\tconst toolResult = toolResultsMap.get(content.id);\n\t\t\t\tconst { html: toolHtml, bgColor } = formatToolExecution(content.name, content.arguments, toolResult);\n\t\t\t\thtml += `<div class=\"tool-execution\" style=\"background-color: ${bgColor}\">${toolHtml}</div>`;\n\t\t\t}\n\t\t}\n\n\t\t// Show error/abort status if no tool calls\n\t\tconst hasToolCalls = assistantMsg.content.some((c) => c.type === \"toolCall\");\n\t\tif (!hasToolCalls) {\n\t\t\tif (assistantMsg.stopReason === \"aborted\") {\n\t\t\t\thtml += '<div class=\"error-text\">Aborted</div>';\n\t\t\t} else if (assistantMsg.stopReason === \"error\") {\n\t\t\t\tconst errorMsg = assistantMsg.errorMessage || \"Unknown error\";\n\t\t\t\thtml += `<div class=\"error-text\">Error: ${escapeHtml(errorMsg)}</div>`;\n\t\t\t}\n\t\t}\n\n\t\t// Close the assistant message wrapper if we opened one\n\t\tif (timestampHtml) {\n\t\t\thtml += \"</div>\";\n\t\t}\n\t}\n\n\treturn html;\n}\n\n/**\n * Export session to a self-contained HTML file matching TUI visual style\n */\nexport function exportSessionToHtml(sessionManager: SessionManager, state: AgentState, outputPath?: string): string {\n\tconst sessionFile = sessionManager.getSessionFile();\n\tconst timestamp = new Date().toISOString();\n\n\t// Use pi-session- prefix + session filename + .html if no output path provided\n\tif (!outputPath) {\n\t\tconst sessionBasename = basename(sessionFile, \".jsonl\");\n\t\toutputPath = `pi-session-${sessionBasename}.html`;\n\t}\n\n\t// Read and parse session data\n\tconst sessionContent = readFileSync(sessionFile, \"utf8\");\n\tconst lines = sessionContent.trim().split(\"\\n\");\n\n\tlet sessionHeader: any = null;\n\tconst messages: Message[] = [];\n\tconst toolResultsMap = new Map<string, ToolResultMessage>();\n\tconst sessionEvents: any[] = []; // Track all events including model changes\n\tconst modelsUsed = new Set<string>(); // Track unique models used\n\n\t// Cumulative token and cost stats\n\tconst tokenStats = {\n\t\tinput: 0,\n\t\toutput: 0,\n\t\tcacheRead: 0,\n\t\tcacheWrite: 0,\n\t};\n\tconst costStats = {\n\t\tinput: 0,\n\t\toutput: 0,\n\t\tcacheRead: 0,\n\t\tcacheWrite: 0,\n\t};\n\n\tfor (const line of lines) {\n\t\ttry {\n\t\t\tconst entry = JSON.parse(line);\n\t\t\tif (entry.type === \"session\") {\n\t\t\t\tsessionHeader = entry;\n\t\t\t\t// Track initial model from session header\n\t\t\t\tif (entry.modelId) {\n\t\t\t\t\tconst modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : entry.modelId;\n\t\t\t\t\tmodelsUsed.add(modelInfo);\n\t\t\t\t}\n\t\t\t} else if (entry.type === \"message\") {\n\t\t\t\tmessages.push(entry.message);\n\t\t\t\tsessionEvents.push(entry);\n\t\t\t\t// Build map of tool call ID to result\n\t\t\t\tif (entry.message.role === \"toolResult\") {\n\t\t\t\t\ttoolResultsMap.set(entry.message.toolCallId, entry.message);\n\t\t\t\t}\n\t\t\t\t// Accumulate token and cost stats from assistant messages\n\t\t\t\tif (entry.message.role === \"assistant\" && entry.message.usage) {\n\t\t\t\t\tconst usage = entry.message.usage;\n\t\t\t\t\ttokenStats.input += usage.input || 0;\n\t\t\t\t\ttokenStats.output += usage.output || 0;\n\t\t\t\t\ttokenStats.cacheRead += usage.cacheRead || 0;\n\t\t\t\t\ttokenStats.cacheWrite += usage.cacheWrite || 0;\n\n\t\t\t\t\tif (usage.cost) {\n\t\t\t\t\t\tcostStats.input += usage.cost.input || 0;\n\t\t\t\t\t\tcostStats.output += usage.cost.output || 0;\n\t\t\t\t\t\tcostStats.cacheRead += usage.cost.cacheRead || 0;\n\t\t\t\t\t\tcostStats.cacheWrite += usage.cost.cacheWrite || 0;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (entry.type === \"model_change\") {\n\t\t\t\tsessionEvents.push(entry);\n\t\t\t\t// Track model from model change event\n\t\t\t\tif (entry.modelId) {\n\t\t\t\t\tconst modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : entry.modelId;\n\t\t\t\t\tmodelsUsed.add(modelInfo);\n\t\t\t\t}\n\t\t\t}\n\t\t} catch {\n\t\t\t// Skip malformed lines\n\t\t}\n\t}\n\n\t// Calculate message stats (matching session command)\n\tconst userMessages = messages.filter((m) => m.role === \"user\").length;\n\tconst assistantMessages = messages.filter((m) => m.role === \"assistant\").length;\n\tconst toolResultMessages = messages.filter((m) => m.role === \"toolResult\").length;\n\tconst totalMessages = messages.length;\n\n\t// Count tool calls from assistant messages\n\tlet toolCallsCount = 0;\n\tfor (const message of messages) {\n\t\tif (message.role === \"assistant\") {\n\t\t\tconst assistantMsg = message as AssistantMessage;\n\t\t\ttoolCallsCount += assistantMsg.content.filter((c) => c.type === \"toolCall\").length;\n\t\t}\n\t}\n\n\t// Get last assistant message for context percentage calculation (skip aborted messages)\n\tconst lastAssistantMessage = messages\n\t\t.slice()\n\t\t.reverse()\n\t\t.find((m) => m.role === \"assistant\" && (m as AssistantMessage).stopReason !== \"aborted\") as\n\t\t| AssistantMessage\n\t\t| undefined;\n\n\t// Calculate context percentage from last message (input + output + cacheRead + cacheWrite)\n\tconst contextTokens = lastAssistantMessage\n\t\t? lastAssistantMessage.usage.input +\n\t\t\tlastAssistantMessage.usage.output +\n\t\t\tlastAssistantMessage.usage.cacheRead +\n\t\t\tlastAssistantMessage.usage.cacheWrite\n\t\t: 0;\n\n\t// Get the model info from the last assistant message\n\tconst lastModel = lastAssistantMessage?.model || state.model?.id || \"unknown\";\n\tconst lastProvider = lastAssistantMessage?.provider || \"\";\n\tconst lastModelInfo = lastProvider ? `${lastProvider}/${lastModel}` : lastModel;\n\n\tconst contextWindow = state.model?.contextWindow || 0;\n\tconst contextPercent = contextWindow > 0 ? ((contextTokens / contextWindow) * 100).toFixed(1) : \"0.0\";\n\n\t// Generate messages HTML (including model changes in chronological order)\n\tlet messagesHtml = \"\";\n\tfor (const event of sessionEvents) {\n\t\tif (event.type === \"message\" && event.message.role !== \"toolResult\") {\n\t\t\t// Skip toolResult messages as they're rendered with their tool calls\n\t\t\tmessagesHtml += formatMessage(event.message, toolResultsMap);\n\t\t} else if (event.type === \"model_change\") {\n\t\t\tmessagesHtml += formatModelChange(event);\n\t\t}\n\t}\n\n\t// Generate HTML (matching TUI aesthetic)\n\tconst html = `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Session Export - ${basename(sessionFile)}</title>\n <style>\n * {\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n }\n\n body {\n font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;\n font-size: 12px;\n line-height: 1.6;\n color: ${COLORS.text};\n background: ${COLORS.bodyBg};\n padding: 24px;\n }\n\n .container {\n max-width: 700px;\n margin: 0 auto;\n }\n\n .header {\n margin-bottom: 24px;\n padding: 16px;\n background: ${COLORS.containerBg};\n border-radius: 4px;\n }\n\n .header h1 {\n font-size: 14px;\n font-weight: bold;\n margin-bottom: 12px;\n color: ${COLORS.cyan};\n }\n\n .header-info {\n display: flex;\n flex-direction: column;\n gap: 3px;\n font-size: 11px;\n }\n\n .info-item {\n color: ${COLORS.textDim};\n display: flex;\n align-items: baseline;\n }\n\n .info-label {\n font-weight: 600;\n margin-right: 8px;\n min-width: 100px;\n }\n\n .info-value {\n color: ${COLORS.text};\n flex: 1;\n }\n\n .info-value.cost {\n font-family: 'SF Mono', monospace;\n }\n\n .messages {\n display: flex;\n flex-direction: column;\n gap: 16px;\n }\n\n /* Message timestamp */\n .message-timestamp {\n font-size: 10px;\n color: ${COLORS.textDim};\n margin-bottom: 4px;\n opacity: 0.8;\n }\n\n /* User message - matching TUI UserMessageComponent */\n .user-message {\n background: ${COLORS.userMessageBg};\n padding: 12px 16px;\n border-radius: 4px;\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n }\n\n /* Assistant message wrapper */\n .assistant-message {\n padding: 0;\n }\n\n /* Assistant text - matching TUI AssistantMessageComponent */\n .assistant-text {\n padding: 12px 16px;\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n }\n\n /* Thinking text - gray italic */\n .thinking-text {\n padding: 12px 16px;\n color: ${COLORS.italic};\n font-style: italic;\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n }\n\n /* Model change */\n .model-change {\n padding: 8px 16px;\n background: rgb(40, 40, 50);\n border-radius: 4px;\n }\n\n .model-change-text {\n color: ${COLORS.textDim};\n font-size: 11px;\n }\n\n .model-name {\n color: ${COLORS.cyan};\n font-weight: bold;\n }\n\n /* Tool execution - matching TUI ToolExecutionComponent */\n .tool-execution {\n padding: 12px 16px;\n border-radius: 4px;\n margin-top: 8px;\n }\n\n .tool-header {\n font-weight: bold;\n }\n\n .tool-name {\n font-weight: bold;\n }\n\n .tool-path {\n color: ${COLORS.cyan};\n word-break: break-all;\n }\n\n .line-count {\n color: ${COLORS.textDim};\n }\n\n .tool-command {\n font-weight: bold;\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n }\n\n .tool-output {\n margin-top: 12px;\n color: ${COLORS.textDim};\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n font-family: inherit;\n overflow-x: auto;\n }\n\n .tool-output > div {\n line-height: 1.4;\n }\n\n .tool-output pre {\n margin: 0;\n font-family: inherit;\n color: inherit;\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n }\n\n /* Expandable tool output */\n .tool-output.expandable {\n cursor: pointer;\n }\n\n .tool-output.expandable:hover {\n opacity: 0.9;\n }\n\n .tool-output.expandable .output-full {\n display: none;\n }\n\n .tool-output.expandable.expanded .output-preview {\n display: none;\n }\n\n .tool-output.expandable.expanded .output-full {\n display: block;\n }\n\n .expand-hint {\n color: ${COLORS.cyan};\n font-style: italic;\n margin-top: 4px;\n }\n\n /* System prompt section */\n .system-prompt {\n background: rgb(60, 55, 40);\n padding: 12px 16px;\n border-radius: 4px;\n margin-bottom: 16px;\n }\n\n .system-prompt-header {\n font-weight: bold;\n color: ${COLORS.yellow};\n margin-bottom: 8px;\n }\n\n .system-prompt-content {\n color: ${COLORS.textDim};\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n font-size: 11px;\n }\n\n .tools-list {\n background: rgb(60, 55, 40);\n padding: 12px 16px;\n border-radius: 4px;\n margin-bottom: 16px;\n }\n\n .tools-header {\n font-weight: bold;\n color: ${COLORS.yellow};\n margin-bottom: 8px;\n }\n\n .tools-content {\n color: ${COLORS.textDim};\n font-size: 11px;\n }\n\n .tool-item {\n margin: 4px 0;\n }\n\n .tool-item-name {\n font-weight: bold;\n color: ${COLORS.text};\n }\n\n /* Diff styling */\n .tool-diff {\n margin-top: 12px;\n font-size: 11px;\n font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;\n overflow-x: auto;\n max-width: 100%;\n }\n\n .diff-line-old {\n color: ${COLORS.red};\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n }\n\n .diff-line-new {\n color: ${COLORS.green};\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n }\n\n .diff-line-context {\n color: ${COLORS.textDim};\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n }\n\n /* Error text */\n .error-text {\n color: ${COLORS.red};\n padding: 12px 16px;\n }\n\n .footer {\n margin-top: 48px;\n padding: 20px;\n text-align: center;\n color: ${COLORS.textDim};\n font-size: 10px;\n }\n\n @media print {\n body {\n background: white;\n color: black;\n }\n .tool-execution {\n border: 1px solid #ddd;\n }\n }\n </style>\n</head>\n<body>\n <div class=\"container\">\n <div class=\"header\">\n <h1>pi v${VERSION}</h1>\n <div class=\"header-info\">\n <div class=\"info-item\">\n <span class=\"info-label\">Session:</span>\n <span class=\"info-value\">${escapeHtml(sessionHeader?.id || \"unknown\")}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Date:</span>\n <span class=\"info-value\">${sessionHeader?.timestamp ? new Date(sessionHeader.timestamp).toLocaleString() : timestamp}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Models:</span>\n <span class=\"info-value\">${\n\t\t\t\t\t\t\t\tArray.from(modelsUsed)\n\t\t\t\t\t\t\t\t\t.map((m) => escapeHtml(m))\n\t\t\t\t\t\t\t\t\t.join(\", \") || escapeHtml(sessionHeader?.model || state.model.id)\n\t\t\t\t\t\t\t}</span>\n </div>\n </div>\n </div>\n\n <div class=\"header\">\n <h1>Messages</h1>\n <div class=\"header-info\">\n <div class=\"info-item\">\n <span class=\"info-label\">User:</span>\n <span class=\"info-value\">${userMessages}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Assistant:</span>\n <span class=\"info-value\">${assistantMessages}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Tool Calls:</span>\n <span class=\"info-value\">${toolCallsCount}</span>\n </div>\n </div>\n </div>\n\n <div class=\"header\">\n <h1>Tokens & Cost</h1>\n <div class=\"header-info\">\n <div class=\"info-item\">\n <span class=\"info-label\">Input:</span>\n <span class=\"info-value\">${tokenStats.input.toLocaleString()} tokens</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Output:</span>\n <span class=\"info-value\">${tokenStats.output.toLocaleString()} tokens</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Cache Read:</span>\n <span class=\"info-value\">${tokenStats.cacheRead.toLocaleString()} tokens</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Cache Write:</span>\n <span class=\"info-value\">${tokenStats.cacheWrite.toLocaleString()} tokens</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Total:</span>\n <span class=\"info-value\">${(tokenStats.input + tokenStats.output + tokenStats.cacheRead + tokenStats.cacheWrite).toLocaleString()} tokens</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Input Cost:</span>\n <span class=\"info-value cost\">$${costStats.input.toFixed(4)}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Output Cost:</span>\n <span class=\"info-value cost\">$${costStats.output.toFixed(4)}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Cache Read Cost:</span>\n <span class=\"info-value cost\">$${costStats.cacheRead.toFixed(4)}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Cache Write Cost:</span>\n <span class=\"info-value cost\">$${costStats.cacheWrite.toFixed(4)}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Total Cost:</span>\n <span class=\"info-value cost\"><strong>$${(costStats.input + costStats.output + costStats.cacheRead + costStats.cacheWrite).toFixed(4)}</strong></span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Context Usage:</span>\n <span class=\"info-value\">${contextTokens.toLocaleString()} / ${contextWindow.toLocaleString()} tokens (${contextPercent}%) - ${escapeHtml(lastModelInfo)}</span>\n </div>\n </div>\n </div>\n\n <div class=\"system-prompt\">\n <div class=\"system-prompt-header\">System Prompt</div>\n <div class=\"system-prompt-content\">${escapeHtml(sessionHeader?.systemPrompt || state.systemPrompt)}</div>\n </div>\n\n <div class=\"tools-list\">\n <div class=\"tools-header\">Available Tools</div>\n <div class=\"tools-content\">\n ${state.tools\n\t\t\t\t\t\t\t.map(\n\t\t\t\t\t\t\t\t(tool) =>\n\t\t\t\t\t\t\t\t\t`<div class=\"tool-item\"><span class=\"tool-item-name\">${escapeHtml(tool.name)}</span> - ${escapeHtml(tool.description)}</div>`,\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t.join(\"\")}\n </div>\n </div>\n\n <div class=\"messages\">\n ${messagesHtml}\n </div>\n\n <div class=\"footer\">\n Generated by pi coding-agent on ${new Date().toLocaleString()}\n </div>\n </div>\n</body>\n</html>`;\n\n\t// Write HTML file\n\twriteFileSync(outputPath, html, \"utf8\");\n\n\treturn outputPath;\n}\n\n/**\n * Parsed session data structure for HTML generation\n */\ninterface ParsedSessionData {\n\tsessionId: string;\n\ttimestamp: string;\n\tcwd?: string;\n\tsystemPrompt?: string;\n\tmodelsUsed: Set<string>;\n\tmessages: Message[];\n\ttoolResultsMap: Map<string, ToolResultMessage>;\n\tsessionEvents: any[];\n\ttokenStats: { input: number; output: number; cacheRead: number; cacheWrite: number };\n\tcostStats: { input: number; output: number; cacheRead: number; cacheWrite: number };\n\ttools?: { name: string; description: string }[];\n\tisStreamingFormat?: boolean;\n}\n\n/**\n * Parse session manager format (type: \"session\", \"message\", \"model_change\")\n */\nfunction parseSessionManagerFormat(lines: string[]): ParsedSessionData {\n\tconst data: ParsedSessionData = {\n\t\tsessionId: \"unknown\",\n\t\ttimestamp: new Date().toISOString(),\n\t\tmodelsUsed: new Set(),\n\t\tmessages: [],\n\t\ttoolResultsMap: new Map(),\n\t\tsessionEvents: [],\n\t\ttokenStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\t\tcostStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\t};\n\n\tfor (const line of lines) {\n\t\ttry {\n\t\t\tconst entry = JSON.parse(line);\n\t\t\tif (entry.type === \"session\") {\n\t\t\t\tdata.sessionId = entry.id || \"unknown\";\n\t\t\t\tdata.timestamp = entry.timestamp || data.timestamp;\n\t\t\t\tdata.cwd = entry.cwd;\n\t\t\t\tdata.systemPrompt = entry.systemPrompt;\n\t\t\t\tif (entry.modelId) {\n\t\t\t\t\tconst modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : entry.modelId;\n\t\t\t\t\tdata.modelsUsed.add(modelInfo);\n\t\t\t\t}\n\t\t\t} else if (entry.type === \"message\") {\n\t\t\t\tdata.messages.push(entry.message);\n\t\t\t\tdata.sessionEvents.push(entry);\n\t\t\t\tif (entry.message.role === \"toolResult\") {\n\t\t\t\t\tdata.toolResultsMap.set(entry.message.toolCallId, entry.message);\n\t\t\t\t}\n\t\t\t\tif (entry.message.role === \"assistant\" && entry.message.usage) {\n\t\t\t\t\tconst usage = entry.message.usage;\n\t\t\t\t\tdata.tokenStats.input += usage.input || 0;\n\t\t\t\t\tdata.tokenStats.output += usage.output || 0;\n\t\t\t\t\tdata.tokenStats.cacheRead += usage.cacheRead || 0;\n\t\t\t\t\tdata.tokenStats.cacheWrite += usage.cacheWrite || 0;\n\t\t\t\t\tif (usage.cost) {\n\t\t\t\t\t\tdata.costStats.input += usage.cost.input || 0;\n\t\t\t\t\t\tdata.costStats.output += usage.cost.output || 0;\n\t\t\t\t\t\tdata.costStats.cacheRead += usage.cost.cacheRead || 0;\n\t\t\t\t\t\tdata.costStats.cacheWrite += usage.cost.cacheWrite || 0;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (entry.type === \"model_change\") {\n\t\t\t\tdata.sessionEvents.push(entry);\n\t\t\t\tif (entry.modelId) {\n\t\t\t\t\tconst modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : entry.modelId;\n\t\t\t\t\tdata.modelsUsed.add(modelInfo);\n\t\t\t\t}\n\t\t\t}\n\t\t} catch {\n\t\t\t// Skip malformed lines\n\t\t}\n\t}\n\n\treturn data;\n}\n\n/**\n * Parse streaming event format (type: \"agent_start\", \"message_start\", \"message_end\", etc.)\n */\nfunction parseStreamingEventFormat(lines: string[]): ParsedSessionData {\n\tconst data: ParsedSessionData = {\n\t\tsessionId: \"unknown\",\n\t\ttimestamp: new Date().toISOString(),\n\t\tmodelsUsed: new Set(),\n\t\tmessages: [],\n\t\ttoolResultsMap: new Map(),\n\t\tsessionEvents: [],\n\t\ttokenStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\t\tcostStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\t\tisStreamingFormat: true,\n\t};\n\n\tlet timestampSet = false;\n\n\t// Track messages by collecting message_end events (which have the final state)\n\tfor (const line of lines) {\n\t\ttry {\n\t\t\tconst entry = JSON.parse(line);\n\n\t\t\tif (entry.type === \"message_end\" && entry.message) {\n\t\t\t\tconst msg = entry.message;\n\t\t\t\tdata.messages.push(msg);\n\t\t\t\tdata.sessionEvents.push({ type: \"message\", message: msg, timestamp: msg.timestamp });\n\n\t\t\t\t// Build tool results map\n\t\t\t\tif (msg.role === \"toolResult\") {\n\t\t\t\t\tdata.toolResultsMap.set(msg.toolCallId, msg);\n\t\t\t\t}\n\n\t\t\t\t// Track models and accumulate stats from assistant messages\n\t\t\t\tif (msg.role === \"assistant\") {\n\t\t\t\t\tif (msg.model) {\n\t\t\t\t\t\tconst modelInfo = msg.provider ? `${msg.provider}/${msg.model}` : msg.model;\n\t\t\t\t\t\tdata.modelsUsed.add(modelInfo);\n\t\t\t\t\t}\n\t\t\t\t\tif (msg.usage) {\n\t\t\t\t\t\tdata.tokenStats.input += msg.usage.input || 0;\n\t\t\t\t\t\tdata.tokenStats.output += msg.usage.output || 0;\n\t\t\t\t\t\tdata.tokenStats.cacheRead += msg.usage.cacheRead || 0;\n\t\t\t\t\t\tdata.tokenStats.cacheWrite += msg.usage.cacheWrite || 0;\n\t\t\t\t\t\tif (msg.usage.cost) {\n\t\t\t\t\t\t\tdata.costStats.input += msg.usage.cost.input || 0;\n\t\t\t\t\t\t\tdata.costStats.output += msg.usage.cost.output || 0;\n\t\t\t\t\t\t\tdata.costStats.cacheRead += msg.usage.cost.cacheRead || 0;\n\t\t\t\t\t\t\tdata.costStats.cacheWrite += msg.usage.cost.cacheWrite || 0;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Use first message timestamp as session timestamp\n\t\t\t\tif (!timestampSet && msg.timestamp) {\n\t\t\t\t\tdata.timestamp = new Date(msg.timestamp).toISOString();\n\t\t\t\t\ttimestampSet = true;\n\t\t\t\t}\n\t\t\t}\n\t\t} catch {\n\t\t\t// Skip malformed lines\n\t\t}\n\t}\n\n\t// Generate a session ID from the timestamp\n\tdata.sessionId = `stream-${data.timestamp.replace(/[:.]/g, \"-\")}`;\n\n\treturn data;\n}\n\n/**\n * Detect the format of a session file by examining the first valid JSON line\n */\nfunction detectFormat(lines: string[]): \"session-manager\" | \"streaming-events\" | \"unknown\" {\n\tfor (const line of lines) {\n\t\ttry {\n\t\t\tconst entry = JSON.parse(line);\n\t\t\tif (entry.type === \"session\") return \"session-manager\";\n\t\t\tif (entry.type === \"agent_start\" || entry.type === \"message_start\" || entry.type === \"turn_start\") {\n\t\t\t\treturn \"streaming-events\";\n\t\t\t}\n\t\t} catch {\n\t\t\t// Skip malformed lines\n\t\t}\n\t}\n\treturn \"unknown\";\n}\n\n/**\n * Generate HTML from parsed session data\n */\nfunction generateHtml(data: ParsedSessionData, inputFilename: string): string {\n\t// Calculate message stats\n\tconst userMessages = data.messages.filter((m) => m.role === \"user\").length;\n\tconst assistantMessages = data.messages.filter((m) => m.role === \"assistant\").length;\n\n\t// Count tool calls from assistant messages\n\tlet toolCallsCount = 0;\n\tfor (const message of data.messages) {\n\t\tif (message.role === \"assistant\") {\n\t\t\tconst assistantMsg = message as AssistantMessage;\n\t\t\ttoolCallsCount += assistantMsg.content.filter((c) => c.type === \"toolCall\").length;\n\t\t}\n\t}\n\n\t// Get last assistant message for context info\n\tconst lastAssistantMessage = data.messages\n\t\t.slice()\n\t\t.reverse()\n\t\t.find((m) => m.role === \"assistant\" && (m as AssistantMessage).stopReason !== \"aborted\") as\n\t\t| AssistantMessage\n\t\t| undefined;\n\n\tconst contextTokens = lastAssistantMessage\n\t\t? lastAssistantMessage.usage.input +\n\t\t\tlastAssistantMessage.usage.output +\n\t\t\tlastAssistantMessage.usage.cacheRead +\n\t\t\tlastAssistantMessage.usage.cacheWrite\n\t\t: 0;\n\n\tconst lastModel = lastAssistantMessage?.model || \"unknown\";\n\tconst lastProvider = lastAssistantMessage?.provider || \"\";\n\tconst lastModelInfo = lastProvider ? `${lastProvider}/${lastModel}` : lastModel;\n\n\t// Generate messages HTML\n\tlet messagesHtml = \"\";\n\tfor (const event of data.sessionEvents) {\n\t\tif (event.type === \"message\" && event.message.role !== \"toolResult\") {\n\t\t\tmessagesHtml += formatMessage(event.message, data.toolResultsMap);\n\t\t} else if (event.type === \"model_change\") {\n\t\t\tmessagesHtml += formatModelChange(event);\n\t\t}\n\t}\n\n\t// Tools section (only if tools info available)\n\tconst toolsHtml = data.tools\n\t\t? `\n <div class=\"tools-list\">\n <div class=\"tools-header\">Available Tools</div>\n <div class=\"tools-content\">\n ${data.tools.map((tool) => `<div class=\"tool-item\"><span class=\"tool-item-name\">${escapeHtml(tool.name)}</span> - ${escapeHtml(tool.description)}</div>`).join(\"\")}\n </div>\n </div>`\n\t\t: \"\";\n\n\t// System prompt section (only if available)\n\tconst systemPromptHtml = data.systemPrompt\n\t\t? `\n <div class=\"system-prompt\">\n <div class=\"system-prompt-header\">System Prompt</div>\n <div class=\"system-prompt-content\">${escapeHtml(data.systemPrompt)}</div>\n </div>`\n\t\t: \"\";\n\n\treturn `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Session Export - ${escapeHtml(inputFilename)}</title>\n <style>\n * {\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n }\n\n body {\n font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;\n font-size: 12px;\n line-height: 1.6;\n color: ${COLORS.text};\n background: ${COLORS.bodyBg};\n padding: 24px;\n }\n\n .container {\n max-width: 700px;\n margin: 0 auto;\n }\n\n .header {\n margin-bottom: 24px;\n padding: 16px;\n background: ${COLORS.containerBg};\n border-radius: 4px;\n }\n\n .header h1 {\n font-size: 14px;\n font-weight: bold;\n margin-bottom: 12px;\n color: ${COLORS.cyan};\n }\n\n .header-info {\n display: flex;\n flex-direction: column;\n gap: 3px;\n font-size: 11px;\n }\n\n .info-item {\n color: ${COLORS.textDim};\n display: flex;\n align-items: baseline;\n }\n\n .info-label {\n font-weight: 600;\n margin-right: 8px;\n min-width: 100px;\n }\n\n .info-value {\n color: ${COLORS.text};\n flex: 1;\n }\n\n .info-value.cost {\n font-family: 'SF Mono', monospace;\n }\n\n .messages {\n display: flex;\n flex-direction: column;\n gap: 16px;\n }\n\n .message-timestamp {\n font-size: 10px;\n color: ${COLORS.textDim};\n margin-bottom: 4px;\n opacity: 0.8;\n }\n\n .user-message {\n background: ${COLORS.userMessageBg};\n padding: 12px 16px;\n border-radius: 4px;\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n }\n\n .assistant-message {\n padding: 0;\n }\n\n .assistant-text {\n padding: 12px 16px;\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n }\n\n .thinking-text {\n padding: 12px 16px;\n color: ${COLORS.italic};\n font-style: italic;\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n }\n\n .model-change {\n padding: 8px 16px;\n background: rgb(40, 40, 50);\n border-radius: 4px;\n }\n\n .model-change-text {\n color: ${COLORS.textDim};\n font-size: 11px;\n }\n\n .model-name {\n color: ${COLORS.cyan};\n font-weight: bold;\n }\n\n .tool-execution {\n padding: 12px 16px;\n border-radius: 4px;\n margin-top: 8px;\n }\n\n .tool-header {\n font-weight: bold;\n }\n\n .tool-name {\n font-weight: bold;\n }\n\n .tool-path {\n color: ${COLORS.cyan};\n word-break: break-all;\n }\n\n .line-count {\n color: ${COLORS.textDim};\n }\n\n .tool-command {\n font-weight: bold;\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n }\n\n .tool-output {\n margin-top: 12px;\n color: ${COLORS.textDim};\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n font-family: inherit;\n overflow-x: auto;\n }\n\n .tool-output > div {\n line-height: 1.4;\n }\n\n .tool-output pre {\n margin: 0;\n font-family: inherit;\n color: inherit;\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n }\n\n .tool-output.expandable {\n cursor: pointer;\n }\n\n .tool-output.expandable:hover {\n opacity: 0.9;\n }\n\n .tool-output.expandable .output-full {\n display: none;\n }\n\n .tool-output.expandable.expanded .output-preview {\n display: none;\n }\n\n .tool-output.expandable.expanded .output-full {\n display: block;\n }\n\n .expand-hint {\n color: ${COLORS.cyan};\n font-style: italic;\n margin-top: 4px;\n }\n\n .system-prompt {\n background: rgb(60, 55, 40);\n padding: 12px 16px;\n border-radius: 4px;\n margin-bottom: 16px;\n }\n\n .system-prompt-header {\n font-weight: bold;\n color: ${COLORS.yellow};\n margin-bottom: 8px;\n }\n\n .system-prompt-content {\n color: ${COLORS.textDim};\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n word-break: break-word;\n font-size: 11px;\n }\n\n .tools-list {\n background: rgb(60, 55, 40);\n padding: 12px 16px;\n border-radius: 4px;\n margin-bottom: 16px;\n }\n\n .tools-header {\n font-weight: bold;\n color: ${COLORS.yellow};\n margin-bottom: 8px;\n }\n\n .tools-content {\n color: ${COLORS.textDim};\n font-size: 11px;\n }\n\n .tool-item {\n margin: 4px 0;\n }\n\n .tool-item-name {\n font-weight: bold;\n color: ${COLORS.text};\n }\n\n .tool-diff {\n margin-top: 12px;\n font-size: 11px;\n font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;\n overflow-x: auto;\n max-width: 100%;\n }\n\n .diff-line-old {\n color: ${COLORS.red};\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n }\n\n .diff-line-new {\n color: ${COLORS.green};\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n }\n\n .diff-line-context {\n color: ${COLORS.textDim};\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n }\n\n .error-text {\n color: ${COLORS.red};\n padding: 12px 16px;\n }\n\n .footer {\n margin-top: 48px;\n padding: 20px;\n text-align: center;\n color: ${COLORS.textDim};\n font-size: 10px;\n }\n\n .streaming-notice {\n background: rgb(50, 45, 35);\n padding: 12px 16px;\n border-radius: 4px;\n margin-bottom: 16px;\n color: ${COLORS.textDim};\n font-size: 11px;\n }\n\n @media print {\n body {\n background: white;\n color: black;\n }\n .tool-execution {\n border: 1px solid #ddd;\n }\n }\n </style>\n</head>\n<body>\n <div class=\"container\">\n <div class=\"header\">\n <h1>pi v${VERSION}</h1>\n <div class=\"header-info\">\n <div class=\"info-item\">\n <span class=\"info-label\">Session:</span>\n <span class=\"info-value\">${escapeHtml(data.sessionId)}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Date:</span>\n <span class=\"info-value\">${new Date(data.timestamp).toLocaleString()}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Models:</span>\n <span class=\"info-value\">${\n\t\t\t\t\t\t\t\tArray.from(data.modelsUsed)\n\t\t\t\t\t\t\t\t\t.map((m) => escapeHtml(m))\n\t\t\t\t\t\t\t\t\t.join(\", \") || \"unknown\"\n\t\t\t\t\t\t\t}</span>\n </div>\n </div>\n </div>\n\n <div class=\"header\">\n <h1>Messages</h1>\n <div class=\"header-info\">\n <div class=\"info-item\">\n <span class=\"info-label\">User:</span>\n <span class=\"info-value\">${userMessages}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Assistant:</span>\n <span class=\"info-value\">${assistantMessages}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Tool Calls:</span>\n <span class=\"info-value\">${toolCallsCount}</span>\n </div>\n </div>\n </div>\n\n <div class=\"header\">\n <h1>Tokens & Cost</h1>\n <div class=\"header-info\">\n <div class=\"info-item\">\n <span class=\"info-label\">Input:</span>\n <span class=\"info-value\">${data.tokenStats.input.toLocaleString()} tokens</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Output:</span>\n <span class=\"info-value\">${data.tokenStats.output.toLocaleString()} tokens</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Cache Read:</span>\n <span class=\"info-value\">${data.tokenStats.cacheRead.toLocaleString()} tokens</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Cache Write:</span>\n <span class=\"info-value\">${data.tokenStats.cacheWrite.toLocaleString()} tokens</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Total:</span>\n <span class=\"info-value\">${(data.tokenStats.input + data.tokenStats.output + data.tokenStats.cacheRead + data.tokenStats.cacheWrite).toLocaleString()} tokens</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Input Cost:</span>\n <span class=\"info-value cost\">$${data.costStats.input.toFixed(4)}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Output Cost:</span>\n <span class=\"info-value cost\">$${data.costStats.output.toFixed(4)}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Cache Read Cost:</span>\n <span class=\"info-value cost\">$${data.costStats.cacheRead.toFixed(4)}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Cache Write Cost:</span>\n <span class=\"info-value cost\">$${data.costStats.cacheWrite.toFixed(4)}</span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Total Cost:</span>\n <span class=\"info-value cost\"><strong>$${(data.costStats.input + data.costStats.output + data.costStats.cacheRead + data.costStats.cacheWrite).toFixed(4)}</strong></span>\n </div>\n <div class=\"info-item\">\n <span class=\"info-label\">Context Usage:</span>\n <span class=\"info-value\">${contextTokens.toLocaleString()} tokens (last turn) - ${escapeHtml(lastModelInfo)}</span>\n </div>\n </div>\n </div>\n\n ${systemPromptHtml}\n ${toolsHtml}\n\n ${\n\t\t\t\tdata.isStreamingFormat\n\t\t\t\t\t? `<div class=\"streaming-notice\">\n <em>Note: This session was reconstructed from raw agent event logs, which do not contain system prompt or tool definitions.</em>\n </div>`\n\t\t\t\t\t: \"\"\n\t\t\t}\n\n <div class=\"messages\">\n ${messagesHtml}\n </div>\n\n <div class=\"footer\">\n Generated by pi coding-agent on ${new Date().toLocaleString()}\n </div>\n </div>\n</body>\n</html>`;\n}\n\n/**\n * Export a session file to HTML (standalone, without AgentState or SessionManager)\n * Auto-detects format: session manager format or streaming event format\n */\nexport function exportFromFile(inputPath: string, outputPath?: string): string {\n\tif (!existsSync(inputPath)) {\n\t\tthrow new Error(`File not found: ${inputPath}`);\n\t}\n\n\tconst content = readFileSync(inputPath, \"utf8\");\n\tconst lines = content\n\t\t.trim()\n\t\t.split(\"\\n\")\n\t\t.filter((l) => l.trim());\n\n\tif (lines.length === 0) {\n\t\tthrow new Error(`Empty file: ${inputPath}`);\n\t}\n\n\tconst format = detectFormat(lines);\n\tif (format === \"unknown\") {\n\t\tthrow new Error(`Unknown session file format: ${inputPath}`);\n\t}\n\n\tconst data = format === \"session-manager\" ? parseSessionManagerFormat(lines) : parseStreamingEventFormat(lines);\n\n\t// Generate output path if not provided\n\tif (!outputPath) {\n\t\tconst inputBasename = basename(inputPath, \".jsonl\");\n\t\toutputPath = `pi-session-${inputBasename}.html`;\n\t}\n\n\tconst html = generateHtml(data, basename(inputPath));\n\twriteFileSync(outputPath, html, \"utf8\");\n\n\treturn outputPath;\n}\n"]}
@@ -1,10 +1,7 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "fs";
2
2
  import { homedir } from "os";
3
3
  import { basename } from "path";
4
- import { getPackageJsonPath } from "./paths.js";
5
- // Get version from package.json
6
- const packageJson = JSON.parse(readFileSync(getPackageJsonPath(), "utf-8"));
7
- const VERSION = packageJson.version;
4
+ import { VERSION } from "./config.js";
8
5
  /**
9
6
  * TUI Color scheme (matching exact RGB values from TUI components)
10
7
  */