@mariozechner/pi-coding-agent 0.10.2 → 0.11.1

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 (41) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +52 -2
  3. package/dist/export-html.d.ts +5 -0
  4. package/dist/export-html.d.ts.map +1 -1
  5. package/dist/export-html.js +662 -1
  6. package/dist/export-html.js.map +1 -1
  7. package/dist/main.d.ts.map +1 -1
  8. package/dist/main.js +116 -19
  9. package/dist/main.js.map +1 -1
  10. package/dist/tools/find.d.ts +9 -0
  11. package/dist/tools/find.d.ts.map +1 -0
  12. package/dist/tools/find.js +143 -0
  13. package/dist/tools/find.js.map +1 -0
  14. package/dist/tools/grep.d.ts +13 -0
  15. package/dist/tools/grep.d.ts.map +1 -0
  16. package/dist/tools/grep.js +207 -0
  17. package/dist/tools/grep.js.map +1 -0
  18. package/dist/tools/index.d.ts +42 -0
  19. package/dist/tools/index.d.ts.map +1 -1
  20. package/dist/tools/index.js +17 -0
  21. package/dist/tools/index.js.map +1 -1
  22. package/dist/tools/ls.d.ts +8 -0
  23. package/dist/tools/ls.d.ts.map +1 -0
  24. package/dist/tools/ls.js +100 -0
  25. package/dist/tools/ls.js.map +1 -0
  26. package/dist/tools-manager.d.ts +3 -0
  27. package/dist/tools-manager.d.ts.map +1 -0
  28. package/dist/tools-manager.js +186 -0
  29. package/dist/tools-manager.js.map +1 -0
  30. package/dist/tui/footer.d.ts +12 -0
  31. package/dist/tui/footer.d.ts.map +1 -1
  32. package/dist/tui/footer.js +42 -1
  33. package/dist/tui/footer.js.map +1 -1
  34. package/dist/tui/tool-execution.d.ts.map +1 -1
  35. package/dist/tui/tool-execution.js +77 -0
  36. package/dist/tui/tool-execution.js.map +1 -1
  37. package/dist/tui/tui-renderer.d.ts +1 -1
  38. package/dist/tui/tui-renderer.d.ts.map +1 -1
  39. package/dist/tui/tui-renderer.js +8 -2
  40. package/dist/tui/tui-renderer.js.map +1 -1
  41. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -1,6 +1,25 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
3
+ ## [0.11.1] - 2025-11-29
4
+
5
+ ### Added
6
+
7
+ - **`--export` CLI Flag**: Export session files to self-contained HTML files from the command line. Auto-detects format (session manager format or streaming event format). Usage: `pi --export session.jsonl` or `pi --export session.jsonl output.html`. Note: Streaming event logs (from `--mode json`) don't contain system prompt or tool definitions, so those sections are omitted with a notice in the HTML. ([#80](https://github.com/badlogic/pi-mono/issues/80))
8
+
9
+ - **Git Branch File Watcher**: Footer now auto-updates when the git branch changes externally (e.g., running `git checkout` in another terminal). Watches `.git/HEAD` for changes and refreshes the branch display automatically. ([#79](https://github.com/badlogic/pi-mono/pull/79) by [@fightbulc](https://github.com/fightbulc))
10
+
11
+ - **Read-Only Exploration Tools**: Added `grep`, `find`, and `ls` tools for safe code exploration without modification risk. These tools are available via the new `--tools` flag.
12
+ - `grep`: Uses `ripgrep` (auto-downloaded) for fast regex searching. Respects `.gitignore` (including nested), supports glob filtering, context lines, and hidden files.
13
+ - `find`: Uses `fd` (auto-downloaded) for fast file finding. Respects `.gitignore`, supports glob patterns, and hidden files.
14
+ - `ls`: Lists directory contents with proper sorting and indicators.
15
+ - **`--tools` Flag**: New CLI flag to specify available tools (e.g., `--tools read,grep,find,ls` for read-only mode). Default behavior remains unchanged (`read,bash,edit,write`).
16
+ - **Dynamic System Prompt**: The system prompt now adapts to the selected tools, showing relevant guidelines and warnings (e.g., "READ-ONLY mode" when write tools are disabled).
17
+
18
+ ### Fixed
19
+
20
+ - **Prompt Restoration on API Key Error**: When submitting a message fails due to missing API key, the prompt is now restored to the editor instead of being lost. ([#77](https://github.com/badlogic/pi-mono/issues/77))
21
+ - **File `@` Autocomplete Performance**: Fixed severe UI jank when using `@` for file attachment in large repositories. The file picker now uses `fd` (a fast file finder) instead of synchronous directory walking with minimatch. On a 55k file repo, search time dropped from ~900ms to ~10ms per keystroke. If `fd` is not installed, it will be automatically downloaded to `~/.pi/agent/tools/` on first use. ([#69](https://github.com/badlogic/pi-mono/issues/69))
22
+ - **File Selector Styling**: Selected items in file autocomplete (`@` and Tab) now use consistent accent color for the entire line instead of mixed colors.
4
23
 
5
24
  ## [0.10.2] - 2025-11-27
6
25
 
package/README.md CHANGED
@@ -757,6 +757,22 @@ Examples:
757
757
  - `--models sonnet:high,haiku:low` - Sonnet with high thinking, Haiku with low thinking
758
758
  - `--models sonnet,haiku` - Partial match for any model containing "sonnet" or "haiku"
759
759
 
760
+ **--tools <tools>**
761
+ Comma-separated list of tools to enable. By default, pi uses `read,bash,edit,write`. This flag allows restricting or changing the available tools.
762
+
763
+ Available tools:
764
+ - `read` - Read file contents
765
+ - `bash` - Execute bash commands
766
+ - `edit` - Make surgical edits to files
767
+ - `write` - Create or overwrite files
768
+ - `grep` - Search file contents for patterns (read-only, off by default)
769
+ - `find` - Find files by glob pattern (read-only, off by default)
770
+ - `ls` - List directory contents (read-only, off by default)
771
+
772
+ Examples:
773
+ - `--tools read,grep,find,ls` - Read-only mode for code review/exploration
774
+ - `--tools read,bash` - Only allow reading and bash commands
775
+
760
776
  **--thinking <level>**
761
777
  Set thinking level for reasoning-capable models. Valid values: `off`, `minimal`, `low`, `medium`, `high`. Takes highest priority over all other thinking level sources (saved settings, `--models` pattern levels, session restore).
762
778
 
@@ -764,6 +780,15 @@ Examples:
764
780
  - `--thinking high` - Start with high thinking level
765
781
  - `--thinking off` - Disable thinking even if saved setting was different
766
782
 
783
+ **--export <file>**
784
+ Export a session file to a self-contained HTML file and exit. Auto-detects format (session manager format or streaming event format). Optionally provide an output filename as the second argument.
785
+
786
+ **Note:** When exporting streaming event logs (e.g., `pi-output.jsonl` from `--mode json`), the system prompt and tool definitions are not available since they are not recorded in the event stream. The exported HTML will include a notice about this.
787
+
788
+ Examples:
789
+ - `--export session.jsonl` - Export to `pi-session-session.html`
790
+ - `--export session.jsonl output.html` - Export to custom filename
791
+
767
792
  **--help, -h**
768
793
  Show help message
769
794
 
@@ -810,13 +835,25 @@ pi --models sonnet:high,haiku:low
810
835
 
811
836
  # Start with specific thinking level
812
837
  pi --thinking high "Solve this complex algorithm problem"
838
+
839
+ # Read-only mode (no file modifications possible)
840
+ pi --tools read,grep,find,ls -p "Review the architecture in src/"
841
+
842
+ # Oracle-style subagent (bash for git/gh, no file modifications)
843
+ pi --tools read,bash,grep,find,ls \
844
+ --no-session \
845
+ -p "Use bash only for read-only operations. Read issue #74 with gh, then review the implementation"
846
+
847
+ # Export a session file to HTML
848
+ pi --export ~/.pi/agent/sessions/--myproject--/session.jsonl
849
+ pi --export session.jsonl my-export.html
813
850
  ```
814
851
 
815
852
  ## Tools
816
853
 
817
- ### Built-in Tools
854
+ ### Default Tools
818
855
 
819
- The agent has access to four core tools for working with your codebase:
856
+ By default, the agent has access to four core tools:
820
857
 
821
858
  **read**
822
859
  Read file contents. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, defaults to first 2000 lines. Use offset/limit parameters for large files. Lines longer than 2000 characters are truncated.
@@ -830,6 +867,19 @@ Edit a file by replacing exact text. The oldText must match exactly (including w
830
867
  **bash**
831
868
  Execute a bash command in the current working directory. Returns stdout and stderr. Optionally accepts a `timeout` parameter (in seconds) - no default timeout.
832
869
 
870
+ ### Read-Only Exploration Tools
871
+
872
+ These tools are available via `--tools` flag for read-only code exploration:
873
+
874
+ **grep**
875
+ Search file contents for a pattern (regex or literal). Returns matching lines with file paths and line numbers. Respects `.gitignore`. Parameters: `pattern` (required), `path`, `glob`, `ignoreCase`, `literal`, `context`, `limit`.
876
+
877
+ **find**
878
+ Search for files by glob pattern (e.g., `**/*.ts`). Returns matching file paths relative to the search directory. Respects `.gitignore`. Parameters: `pattern` (required), `path`, `limit`.
879
+
880
+ **ls**
881
+ List directory contents. Returns entries sorted alphabetically with `/` suffix for directories. Includes dotfiles. Parameters: `path`, `limit`.
882
+
833
883
  ### MCP & Adding Your Own Tools
834
884
 
835
885
  **pi does and will not support MCP.** Instead, it relies on the four built-in tools above and assumes the agent can invoke pre-existing CLI tools or write them on the fly as needed.
@@ -4,4 +4,9 @@ import type { SessionManager } from "./session-manager.js";
4
4
  * Export session to a self-contained HTML file matching TUI visual style
5
5
  */
6
6
  export declare function exportSessionToHtml(sessionManager: SessionManager, state: AgentState, outputPath?: string): string;
7
+ /**
8
+ * Export a session file to HTML (standalone, without AgentState or SessionManager)
9
+ * Auto-detects format: session manager format or streaming event format
10
+ */
11
+ export declare function exportFromFile(inputPath: string, outputPath?: string): string;
7
12
  //# sourceMappingURL=export-html.d.ts.map
@@ -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;AAqU3D;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,cAAc,EAAE,cAAc,EAAE,KAAK,EAAE,UAAU,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,CAkkBlH","sourcesContent":["import type { AgentState } from \"@mariozechner/pi-agent-core\";\nimport type { AssistantMessage, Message, ToolResultMessage, UserMessage } from \"@mariozechner/pi-ai\";\nimport { readFileSync, writeFileSync } from \"fs\";\nimport { homedir } from \"os\";\nimport { basename, dirname, join } from \"path\";\nimport { fileURLToPath } from \"url\";\nimport type { SessionManager } from \"./session-manager.js\";\n\n// Get version from package.json\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = dirname(__filename);\nconst packageJson = JSON.parse(readFileSync(join(__dirname, \"../package.json\"), \"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"]}
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;AAqU3D;;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, dirname, join } from \"path\";\nimport { fileURLToPath } from \"url\";\nimport type { SessionManager } from \"./session-manager.js\";\n\n// Get version from package.json\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = dirname(__filename);\nconst packageJson = JSON.parse(readFileSync(join(__dirname, \"../package.json\"), \"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"]}