import type { AgentMessage, AgentState } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, ImageContent, Message, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai"; import { existsSync, readFileSync, writeFileSync } from "fs"; import hljs from "highlight.js"; import { marked } from "marked"; import { homedir } from "os"; import * as path from "path"; import { basename } from "path"; import { APP_NAME, getCustomThemesDir, getThemesDir, VERSION } from "../config.js"; import type { SessionManager } from "./session-manager.js"; // ============================================================================ // Types // ============================================================================ interface MessageEvent { type: "message"; message: Message; timestamp?: number; } interface ModelChangeEvent { type: "model_change"; provider: string; modelId: string; timestamp?: number; } interface CompactionEvent { type: "compaction"; timestamp: string; summary: string; tokensBefore: number; } type SessionEvent = MessageEvent | ModelChangeEvent | CompactionEvent; interface ParsedSessionData { sessionId: string; timestamp: string; systemPrompt?: string; modelsUsed: Set; messages: Message[]; toolResultsMap: Map; sessionEvents: SessionEvent[]; tokenStats: { input: number; output: number; cacheRead: number; cacheWrite: number }; costStats: { input: number; output: number; cacheRead: number; cacheWrite: number }; tools?: { name: string; description: string }[]; contextWindow?: number; isStreamingFormat?: boolean; } // ============================================================================ // Theme Types and Loading // ============================================================================ interface ThemeJson { name: string; vars?: Record; colors: Record; } interface ThemeColors { // Core UI accent: string; border: string; borderAccent: string; success: string; error: string; warning: string; muted: string; dim: string; text: string; // Backgrounds userMessageBg: string; userMessageText: string; toolPendingBg: string; toolSuccessBg: string; toolErrorBg: string; toolOutput: string; // Markdown mdHeading: string; mdLink: string; mdLinkUrl: string; mdCode: string; mdCodeBlock: string; mdCodeBlockBorder: string; mdQuote: string; mdQuoteBorder: string; mdHr: string; mdListBullet: string; // Diffs toolDiffAdded: string; toolDiffRemoved: string; toolDiffContext: string; // Syntax highlighting syntaxComment: string; syntaxKeyword: string; syntaxFunction: string; syntaxVariable: string; syntaxString: string; syntaxNumber: string; syntaxType: string; syntaxOperator: string; syntaxPunctuation: string; } /** Resolve a theme color value, following variable references until we get a final value. */ function resolveColorValue( value: string | number, vars: Record, defaultValue: string, visited = new Set(), ): string { if (value === "") return defaultValue; if (typeof value !== "string") return defaultValue; if (visited.has(value)) return defaultValue; if (!(value in vars)) return value; // Return as-is (hex colors work in CSS) visited.add(value); return resolveColorValue(vars[value], vars, defaultValue, visited); } /** Load theme JSON from built-in or custom themes directory. */ function loadThemeJson(name: string): ThemeJson | undefined { // Try built-in themes first const themesDir = getThemesDir(); const builtinPath = path.join(themesDir, `${name}.json`); if (existsSync(builtinPath)) { try { return JSON.parse(readFileSync(builtinPath, "utf-8")) as ThemeJson; } catch { return undefined; } } // Try custom themes const customThemesDir = getCustomThemesDir(); const customPath = path.join(customThemesDir, `${name}.json`); if (existsSync(customPath)) { try { return JSON.parse(readFileSync(customPath, "utf-8")) as ThemeJson; } catch { return undefined; } } return undefined; } /** Build complete theme colors object, resolving theme JSON values against defaults. */ function getThemeColors(themeName?: string): ThemeColors { const isLight = isLightTheme(themeName); // Default colors based on theme type const defaultColors: ThemeColors = isLight ? { // Light theme defaults accent: "rgb(95, 135, 135)", border: "rgb(95, 135, 175)", borderAccent: "rgb(95, 135, 135)", success: "rgb(135, 175, 135)", error: "rgb(175, 95, 95)", warning: "rgb(215, 175, 95)", muted: "rgb(108, 108, 108)", dim: "rgb(138, 138, 138)", text: "rgb(0, 0, 0)", userMessageBg: "rgb(232, 232, 232)", userMessageText: "rgb(0, 0, 0)", toolPendingBg: "rgb(232, 232, 240)", toolSuccessBg: "rgb(232, 240, 232)", toolErrorBg: "rgb(240, 232, 232)", toolOutput: "rgb(108, 108, 108)", mdHeading: "rgb(215, 175, 95)", mdLink: "rgb(95, 135, 175)", mdLinkUrl: "rgb(138, 138, 138)", mdCode: "rgb(95, 135, 135)", mdCodeBlock: "rgb(135, 175, 135)", mdCodeBlockBorder: "rgb(108, 108, 108)", mdQuote: "rgb(108, 108, 108)", mdQuoteBorder: "rgb(108, 108, 108)", mdHr: "rgb(108, 108, 108)", mdListBullet: "rgb(135, 175, 135)", toolDiffAdded: "rgb(135, 175, 135)", toolDiffRemoved: "rgb(175, 95, 95)", toolDiffContext: "rgb(108, 108, 108)", syntaxComment: "rgb(0, 128, 0)", syntaxKeyword: "rgb(0, 0, 255)", syntaxFunction: "rgb(121, 94, 38)", syntaxVariable: "rgb(0, 16, 128)", syntaxString: "rgb(163, 21, 21)", syntaxNumber: "rgb(9, 134, 88)", syntaxType: "rgb(38, 127, 153)", syntaxOperator: "rgb(0, 0, 0)", syntaxPunctuation: "rgb(0, 0, 0)", } : { // Dark theme defaults accent: "rgb(138, 190, 183)", border: "rgb(95, 135, 255)", borderAccent: "rgb(0, 215, 255)", success: "rgb(181, 189, 104)", error: "rgb(204, 102, 102)", warning: "rgb(255, 255, 0)", muted: "rgb(128, 128, 128)", dim: "rgb(102, 102, 102)", text: "rgb(229, 229, 231)", userMessageBg: "rgb(52, 53, 65)", userMessageText: "rgb(229, 229, 231)", toolPendingBg: "rgb(40, 40, 50)", toolSuccessBg: "rgb(40, 50, 40)", toolErrorBg: "rgb(60, 40, 40)", toolOutput: "rgb(128, 128, 128)", mdHeading: "rgb(240, 198, 116)", mdLink: "rgb(129, 162, 190)", mdLinkUrl: "rgb(102, 102, 102)", mdCode: "rgb(138, 190, 183)", mdCodeBlock: "rgb(181, 189, 104)", mdCodeBlockBorder: "rgb(128, 128, 128)", mdQuote: "rgb(128, 128, 128)", mdQuoteBorder: "rgb(128, 128, 128)", mdHr: "rgb(128, 128, 128)", mdListBullet: "rgb(138, 190, 183)", toolDiffAdded: "rgb(181, 189, 104)", toolDiffRemoved: "rgb(204, 102, 102)", toolDiffContext: "rgb(128, 128, 128)", syntaxComment: "rgb(106, 153, 85)", syntaxKeyword: "rgb(86, 156, 214)", syntaxFunction: "rgb(220, 220, 170)", syntaxVariable: "rgb(156, 220, 254)", syntaxString: "rgb(206, 145, 120)", syntaxNumber: "rgb(181, 206, 168)", syntaxType: "rgb(78, 201, 176)", syntaxOperator: "rgb(212, 212, 212)", syntaxPunctuation: "rgb(212, 212, 212)", }; if (!themeName) return defaultColors; const themeJson = loadThemeJson(themeName); if (!themeJson) return defaultColors; const vars = themeJson.vars || {}; const colors = themeJson.colors; const resolve = (key: keyof ThemeColors): string => { const value = colors[key]; if (value === undefined) return defaultColors[key]; return resolveColorValue(value, vars, defaultColors[key]); }; return { accent: resolve("accent"), border: resolve("border"), borderAccent: resolve("borderAccent"), success: resolve("success"), error: resolve("error"), warning: resolve("warning"), muted: resolve("muted"), dim: resolve("dim"), text: resolve("text"), userMessageBg: resolve("userMessageBg"), userMessageText: resolve("userMessageText"), toolPendingBg: resolve("toolPendingBg"), toolSuccessBg: resolve("toolSuccessBg"), toolErrorBg: resolve("toolErrorBg"), toolOutput: resolve("toolOutput"), mdHeading: resolve("mdHeading"), mdLink: resolve("mdLink"), mdLinkUrl: resolve("mdLinkUrl"), mdCode: resolve("mdCode"), mdCodeBlock: resolve("mdCodeBlock"), mdCodeBlockBorder: resolve("mdCodeBlockBorder"), mdQuote: resolve("mdQuote"), mdQuoteBorder: resolve("mdQuoteBorder"), mdHr: resolve("mdHr"), mdListBullet: resolve("mdListBullet"), toolDiffAdded: resolve("toolDiffAdded"), toolDiffRemoved: resolve("toolDiffRemoved"), toolDiffContext: resolve("toolDiffContext"), syntaxComment: resolve("syntaxComment"), syntaxKeyword: resolve("syntaxKeyword"), syntaxFunction: resolve("syntaxFunction"), syntaxVariable: resolve("syntaxVariable"), syntaxString: resolve("syntaxString"), syntaxNumber: resolve("syntaxNumber"), syntaxType: resolve("syntaxType"), syntaxOperator: resolve("syntaxOperator"), syntaxPunctuation: resolve("syntaxPunctuation"), }; } /** Check if theme is a light theme (currently only matches "light" exactly). */ function isLightTheme(themeName?: string): boolean { return themeName === "light"; } // ============================================================================ // Utility functions // ============================================================================ function escapeHtml(text: string): string { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function shortenPath(path: string): string { const home = homedir(); return path.startsWith(home) ? `~${path.slice(home.length)}` : path; } function replaceTabs(text: string): string { return text.replace(/\t/g, " "); } function formatTimestamp(timestamp: number | string | undefined): string { if (!timestamp) return ""; const date = new Date(typeof timestamp === "string" ? timestamp : timestamp); return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" }); } /** Highlight code using highlight.js. Returns HTML with syntax highlighting spans. */ function highlightCode(code: string, lang?: string): string { if (!lang) { return escapeHtml(code); } try { // Check if language is supported if (hljs.getLanguage(lang)) { return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value; } // Try common aliases const aliases: Record = { ts: "typescript", js: "javascript", py: "python", rb: "ruby", sh: "bash", yml: "yaml", md: "markdown", }; const aliasedLang = aliases[lang]; if (aliasedLang && hljs.getLanguage(aliasedLang)) { return hljs.highlight(code, { language: aliasedLang, ignoreIllegals: true }).value; } } catch { // Fall through to escaped output } return escapeHtml(code); } /** Get language from file path extension. */ function getLanguageFromPath(filePath: string): string | undefined { const ext = filePath.split(".").pop()?.toLowerCase(); if (!ext) return undefined; const extToLang: Record = { ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript", mjs: "javascript", cjs: "javascript", py: "python", rb: "ruby", rs: "rust", go: "go", java: "java", kt: "kotlin", swift: "swift", c: "c", h: "c", cpp: "cpp", cc: "cpp", cxx: "cpp", hpp: "cpp", cs: "csharp", php: "php", sh: "bash", bash: "bash", zsh: "bash", fish: "bash", ps1: "powershell", sql: "sql", html: "html", htm: "html", xml: "xml", css: "css", scss: "scss", sass: "scss", less: "less", json: "json", yaml: "yaml", yml: "yaml", toml: "toml", ini: "ini", md: "markdown", markdown: "markdown", dockerfile: "dockerfile", makefile: "makefile", cmake: "cmake", lua: "lua", r: "r", scala: "scala", clj: "clojure", cljs: "clojure", ex: "elixir", exs: "elixir", erl: "erlang", hrl: "erlang", hs: "haskell", ml: "ocaml", mli: "ocaml", fs: "fsharp", fsx: "fsharp", vue: "vue", svelte: "xml", tf: "hcl", hcl: "hcl", proto: "protobuf", graphql: "graphql", gql: "graphql", }; return extToLang[ext]; } /** Render markdown to HTML server-side with TUI-style code block formatting and syntax highlighting. */ function renderMarkdown(text: string): string { // Custom renderer for code blocks to match TUI style const renderer = new marked.Renderer(); renderer.code = ({ text: code, lang }: { text: string; lang?: string }) => { const language = lang || ""; const highlighted = highlightCode(code, lang); return ( '
' + `
\`\`\`${language}
` + `
${highlighted}
` + '' + "
" ); }; // Configure marked for safe rendering marked.setOptions({ breaks: true, gfm: true, }); // Parse markdown (marked escapes HTML by default in newer versions) return marked.parse(text, { renderer }) as string; } function formatExpandableOutput(lines: string[], maxLines: number, lang?: string): string { const displayLines = lines.slice(0, maxLines); const remaining = lines.length - maxLines; // If language is provided, highlight the entire code block if (lang) { const code = lines.join("\n"); const highlighted = highlightCode(code, lang); if (remaining > 0) { // For expandable, we need preview and full versions const previewCode = displayLines.join("\n"); const previewHighlighted = highlightCode(previewCode, lang); let out = '`; return out; } return `
${highlighted}
`; } // No language - plain text output if (remaining > 0) { let out = '"; return out; } let out = '
'; for (const line of displayLines) { out += `
${escapeHtml(replaceTabs(line))}
`; } out += "
"; return out; } // ============================================================================ // Parsing functions // ============================================================================ function parseSessionManagerFormat(lines: string[]): ParsedSessionData { const data: ParsedSessionData = { sessionId: "unknown", timestamp: new Date().toISOString(), modelsUsed: new Set(), messages: [], toolResultsMap: new Map(), sessionEvents: [], tokenStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, costStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, }; for (const line of lines) { let entry: { type: string; [key: string]: unknown }; try { entry = JSON.parse(line) as { type: string; [key: string]: unknown }; } catch { continue; } switch (entry.type) { case "session": data.sessionId = (entry.id as string) || "unknown"; data.timestamp = (entry.timestamp as string) || data.timestamp; data.systemPrompt = entry.systemPrompt as string | undefined; if (entry.modelId) { const modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : (entry.modelId as string); data.modelsUsed.add(modelInfo); } break; case "message": { const message = entry.message as Message; data.messages.push(message); data.sessionEvents.push({ type: "message", message, timestamp: entry.timestamp as number | undefined, }); if (message.role === "toolResult") { const toolResult = message as ToolResultMessage; data.toolResultsMap.set(toolResult.toolCallId, toolResult); } else if (message.role === "assistant") { const assistantMsg = message as AssistantMessage; if (assistantMsg.usage) { data.tokenStats.input += assistantMsg.usage.input || 0; data.tokenStats.output += assistantMsg.usage.output || 0; data.tokenStats.cacheRead += assistantMsg.usage.cacheRead || 0; data.tokenStats.cacheWrite += assistantMsg.usage.cacheWrite || 0; if (assistantMsg.usage.cost) { data.costStats.input += assistantMsg.usage.cost.input || 0; data.costStats.output += assistantMsg.usage.cost.output || 0; data.costStats.cacheRead += assistantMsg.usage.cost.cacheRead || 0; data.costStats.cacheWrite += assistantMsg.usage.cost.cacheWrite || 0; } } } break; } case "model_change": data.sessionEvents.push({ type: "model_change", provider: entry.provider as string, modelId: entry.modelId as string, timestamp: entry.timestamp as number | undefined, }); if (entry.modelId) { const modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : (entry.modelId as string); data.modelsUsed.add(modelInfo); } break; case "compaction": data.sessionEvents.push({ type: "compaction", timestamp: entry.timestamp as string, summary: entry.summary as string, tokensBefore: entry.tokensBefore as number, }); break; } } return data; } function parseStreamingEventFormat(lines: string[]): ParsedSessionData { const data: ParsedSessionData = { sessionId: "unknown", timestamp: new Date().toISOString(), modelsUsed: new Set(), messages: [], toolResultsMap: new Map(), sessionEvents: [], tokenStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, costStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, isStreamingFormat: true, }; let timestampSet = false; for (const line of lines) { let entry: { type: string; message?: Message }; try { entry = JSON.parse(line) as { type: string; message?: Message }; } catch { continue; } if (entry.type === "message_end" && entry.message) { const msg = entry.message; data.messages.push(msg); data.sessionEvents.push({ type: "message", message: msg, timestamp: (msg as { timestamp?: number }).timestamp, }); if (msg.role === "toolResult") { const toolResult = msg as ToolResultMessage; data.toolResultsMap.set(toolResult.toolCallId, toolResult); } else if (msg.role === "assistant") { const assistantMsg = msg as AssistantMessage; if (assistantMsg.model) { const modelInfo = assistantMsg.provider ? `${assistantMsg.provider}/${assistantMsg.model}` : assistantMsg.model; data.modelsUsed.add(modelInfo); } if (assistantMsg.usage) { data.tokenStats.input += assistantMsg.usage.input || 0; data.tokenStats.output += assistantMsg.usage.output || 0; data.tokenStats.cacheRead += assistantMsg.usage.cacheRead || 0; data.tokenStats.cacheWrite += assistantMsg.usage.cacheWrite || 0; if (assistantMsg.usage.cost) { data.costStats.input += assistantMsg.usage.cost.input || 0; data.costStats.output += assistantMsg.usage.cost.output || 0; data.costStats.cacheRead += assistantMsg.usage.cost.cacheRead || 0; data.costStats.cacheWrite += assistantMsg.usage.cost.cacheWrite || 0; } } } if (!timestampSet && (msg as { timestamp?: number }).timestamp) { data.timestamp = new Date((msg as { timestamp: number }).timestamp).toISOString(); timestampSet = true; } } } data.sessionId = `stream-${data.timestamp.replace(/[:.]/g, "-")}`; return data; } function detectFormat(lines: string[]): "session-manager" | "streaming-events" | "unknown" { for (const line of lines) { try { const entry = JSON.parse(line) as { type: string }; if (entry.type === "session") return "session-manager"; if (entry.type === "agent_start" || entry.type === "message_start" || entry.type === "turn_start") { return "streaming-events"; } } catch {} } return "unknown"; } function parseSessionFile(content: string): ParsedSessionData { const lines = content .trim() .split("\n") .filter((l) => l.trim()); if (lines.length === 0) { throw new Error("Empty session file"); } const format = detectFormat(lines); if (format === "unknown") { throw new Error("Unknown session file format"); } return format === "session-manager" ? parseSessionManagerFormat(lines) : parseStreamingEventFormat(lines); } // ============================================================================ // HTML formatting functions // ============================================================================ function formatToolExecution( toolName: string, args: Record, result: ToolResultMessage | undefined, colors: ThemeColors, ): { html: string; bgColor: string } { let html = ""; const isError = result?.isError || false; const bgColor = result ? (isError ? colors.toolErrorBg : colors.toolSuccessBg) : colors.toolPendingBg; const getTextOutput = (): string => { if (!result) return ""; const textBlocks = result.content.filter((c) => c.type === "text"); return textBlocks.map((c) => (c as { type: "text"; text: string }).text).join("\n"); }; switch (toolName) { case "bash": { const command = (args?.command as string) || ""; html = `
$ ${escapeHtml(command || "...")}
`; if (result) { const output = getTextOutput().trim(); if (output) { html += formatExpandableOutput(output.split("\n"), 5); } } break; } case "read": { const filePath = (args?.file_path as string) || (args?.path as string) || ""; const shortenedPath = shortenPath(filePath); const offset = args?.offset as number | undefined; const limit = args?.limit as number | undefined; const lang = getLanguageFromPath(filePath); // Build path display with offset/limit suffix let pathHtml = escapeHtml(shortenedPath || "..."); if (offset !== undefined || limit !== undefined) { const startLine = offset ?? 1; const endLine = limit !== undefined ? startLine + limit - 1 : ""; pathHtml += `:${startLine}${endLine ? `-${endLine}` : ""}`; } html = `
read ${pathHtml}
`; if (result) { const output = getTextOutput(); if (output) { html += formatExpandableOutput(output.split("\n"), 10, lang); } } break; } case "write": { const filePath = (args?.file_path as string) || (args?.path as string) || ""; const shortenedPath = shortenPath(filePath); const fileContent = (args?.content as string) || ""; const lines = fileContent ? fileContent.split("\n") : []; const lang = getLanguageFromPath(filePath); html = `
write ${escapeHtml(shortenedPath || "...")}`; if (lines.length > 10) { html += ` (${lines.length} lines)`; } html += "
"; if (fileContent) { html += formatExpandableOutput(lines, 10, lang); } if (result) { const output = getTextOutput().trim(); if (output) { html += `
${escapeHtml(output)}
`; } } break; } case "edit": { const path = shortenPath((args?.file_path as string) || (args?.path as string) || ""); html = `
edit ${escapeHtml(path || "...")}
`; if (result?.details?.diff) { const diffLines = result.details.diff.split("\n"); html += '
'; for (const line of diffLines) { if (line.startsWith("+")) { html += `
${escapeHtml(line)}
`; } else if (line.startsWith("-")) { html += `
${escapeHtml(line)}
`; } else { html += `
${escapeHtml(line)}
`; } } html += "
"; } if (result) { const output = getTextOutput().trim(); if (output) { html += `
${escapeHtml(output)}
`; } } break; } default: { html = `
${escapeHtml(toolName)}
`; html += `
${escapeHtml(JSON.stringify(args, null, 2))}
`; if (result) { const output = getTextOutput(); if (output) { html += `
${escapeHtml(output)}
`; } } } } return { html, bgColor }; } function formatMessage( message: AgentMessage, toolResultsMap: Map, colors: ThemeColors, ): string { let html = ""; const timestamp = (message as { timestamp?: number }).timestamp; const timestampHtml = timestamp ? `
${formatTimestamp(timestamp)}
` : ""; switch (message.role) { case "bashExecution": { const isError = message.cancelled || (message.exitCode !== 0 && message.exitCode !== null && message.exitCode !== undefined); html += `
`; html += timestampHtml; html += `
$ ${escapeHtml(message.command)}
`; if (message.output) { const lines = message.output.split("\n"); html += formatExpandableOutput(lines, 10); } if (message.cancelled) { html += `
(cancelled)
`; } else if (message.exitCode !== 0 && message.exitCode !== null && message.exitCode !== undefined) { html += `
(exit ${message.exitCode})
`; } if (message.truncated && message.fullOutputPath) { html += `
Output truncated. Full output: ${escapeHtml(message.fullOutputPath)}
`; } html += `
`; break; } case "user": { const userMsg = message as UserMessage; let textContent = ""; const images: ImageContent[] = []; if (typeof userMsg.content === "string") { textContent = userMsg.content; } else { for (const block of userMsg.content) { if (block.type === "text") { textContent += block.text; } else if (block.type === "image") { images.push(block as ImageContent); } } } html += `
${timestampHtml}`; // Render images first if (images.length > 0) { html += `
`; for (const img of images) { html += `User uploaded image`; } html += `
`; } // Render text as markdown (server-side) if (textContent.trim()) { html += `
${renderMarkdown(textContent)}
`; } html += `
`; break; } case "assistant": { html += timestampHtml ? `
${timestampHtml}` : ""; for (const content of message.content) { if (content.type === "text" && content.text.trim()) { // Render markdown server-side html += `
${renderMarkdown(content.text)}
`; } else if (content.type === "thinking" && content.thinking.trim()) { html += `
${escapeHtml(content.thinking.trim()).replace(/\n/g, "
")}
`; } } for (const content of message.content) { if (content.type === "toolCall") { const toolResult = toolResultsMap.get(content.id); const { html: toolHtml, bgColor } = formatToolExecution( content.name, content.arguments as Record, toolResult, colors, ); html += `
${toolHtml}
`; } } const hasToolCalls = message.content.some((c) => c.type === "toolCall"); if (!hasToolCalls) { if (message.stopReason === "aborted") { html += '
Aborted
'; } else if (message.stopReason === "error") { html += `
Error: ${escapeHtml(message.errorMessage || "Unknown error")}
`; } } if (timestampHtml) { html += "
"; } break; } case "toolResult": // Tool results are rendered inline with tool calls break; case "hookMessage": // Hook messages with display:true shown as info boxes if (message.display) { const content = typeof message.content === "string" ? message.content : JSON.stringify(message.content); html += `
${timestampHtml}
[${escapeHtml(message.customType)}]
${renderMarkdown(content)}
`; } break; case "compactionSummary": // Rendered separately via formatCompaction break; case "branchSummary": // Rendered as compaction-like summary html += `
Branch Summary
${escapeHtml(message.summary).replace(/\n/g, "
")}
`; break; default: { // Exhaustive check const _exhaustive: never = message; } } return html; } function formatModelChange(event: ModelChangeEvent): string { const timestamp = formatTimestamp(event.timestamp); const timestampHtml = timestamp ? `
${timestamp}
` : ""; const modelInfo = `${event.provider}/${event.modelId}`; return `
${timestampHtml}
Switched to model: ${escapeHtml(modelInfo)}
`; } function formatCompaction(event: CompactionEvent): string { const timestamp = formatTimestamp(event.timestamp); const timestampHtml = timestamp ? `
${timestamp}
` : ""; const summaryHtml = escapeHtml(event.summary).replace(/\n/g, "
"); return `
${timestampHtml}
Context compacted from ${event.tokensBefore.toLocaleString()} tokens (click to expand summary)
Summary sent to model
${summaryHtml}
`; } // ============================================================================ // HTML generation // ============================================================================ function generateHtml(data: ParsedSessionData, filename: string, colors: ThemeColors, isLight: boolean): string { const userMessages = data.messages.filter((m) => m.role === "user").length; const assistantMessages = data.messages.filter((m) => m.role === "assistant").length; let toolCallsCount = 0; for (const message of data.messages) { if (message.role === "assistant") { toolCallsCount += (message as AssistantMessage).content.filter((c) => c.type === "toolCall").length; } } const lastAssistantMessage = data.messages .slice() .reverse() .find((m) => m.role === "assistant" && (m as AssistantMessage).stopReason !== "aborted") as | AssistantMessage | undefined; const contextTokens = lastAssistantMessage ? lastAssistantMessage.usage.input + lastAssistantMessage.usage.output + lastAssistantMessage.usage.cacheRead + lastAssistantMessage.usage.cacheWrite : 0; const lastModel = lastAssistantMessage?.model || "unknown"; const lastProvider = lastAssistantMessage?.provider || ""; const lastModelInfo = lastProvider ? `${lastProvider}/${lastModel}` : lastModel; const contextWindow = data.contextWindow || 0; const contextPercent = contextWindow > 0 ? ((contextTokens / contextWindow) * 100).toFixed(1) : undefined; let messagesHtml = ""; for (const event of data.sessionEvents) { switch (event.type) { case "message": if (event.message.role !== "toolResult") { messagesHtml += formatMessage(event.message, data.toolResultsMap, colors); } break; case "model_change": messagesHtml += formatModelChange(event); break; case "compaction": messagesHtml += formatCompaction(event); break; } } const systemPromptHtml = data.systemPrompt ? `
System Prompt
${escapeHtml(data.systemPrompt)}
` : ""; const toolsHtml = data.tools ? `
Available Tools
${data.tools.map((tool) => `
${escapeHtml(tool.name)} - ${escapeHtml(tool.description)}
`).join("")}
` : ""; const streamingNotice = data.isStreamingFormat ? `
Note: This session was reconstructed from raw agent event logs, which do not contain system prompt or tool definitions.
` : ""; const contextUsageText = contextPercent ? `${contextTokens.toLocaleString()} / ${contextWindow.toLocaleString()} tokens (${contextPercent}%) - ${escapeHtml(lastModelInfo)}` : `${contextTokens.toLocaleString()} tokens (last turn) - ${escapeHtml(lastModelInfo)}`; // Compute body background based on theme const bodyBg = isLight ? "rgb(248, 248, 248)" : "rgb(24, 24, 30)"; const containerBg = isLight ? "rgb(255, 255, 255)" : "rgb(30, 30, 36)"; const compactionBg = isLight ? "rgb(255, 248, 220)" : "rgb(60, 55, 35)"; const systemPromptBg = isLight ? "rgb(255, 250, 230)" : "rgb(60, 55, 40)"; const streamingNoticeBg = isLight ? "rgb(250, 245, 235)" : "rgb(50, 45, 35)"; const modelChangeBg = isLight ? "rgb(240, 240, 250)" : "rgb(40, 40, 50)"; const userBashBg = isLight ? "rgb(255, 250, 240)" : "rgb(50, 48, 35)"; const userBashErrorBg = isLight ? "rgb(255, 245, 235)" : "rgb(60, 45, 35)"; return ` Session Export - ${escapeHtml(filename)}

${APP_NAME} v${VERSION}

Session:${escapeHtml(data.sessionId)}
Date:${new Date(data.timestamp).toLocaleString()}
Models:${ Array.from(data.modelsUsed) .map((m) => escapeHtml(m)) .join(", ") || "unknown" }

Messages

User:${userMessages}
Assistant:${assistantMessages}
Tool Calls:${toolCallsCount}

Tokens & Cost

Input:${data.tokenStats.input.toLocaleString()} tokens
Output:${data.tokenStats.output.toLocaleString()} tokens
Cache Read:${data.tokenStats.cacheRead.toLocaleString()} tokens
Cache Write:${data.tokenStats.cacheWrite.toLocaleString()} tokens
Total:${(data.tokenStats.input + data.tokenStats.output + data.tokenStats.cacheRead + data.tokenStats.cacheWrite).toLocaleString()} tokens
Input Cost:$${data.costStats.input.toFixed(4)}
Output Cost:$${data.costStats.output.toFixed(4)}
Cache Read Cost:$${data.costStats.cacheRead.toFixed(4)}
Cache Write Cost:$${data.costStats.cacheWrite.toFixed(4)}
Total Cost:$${(data.costStats.input + data.costStats.output + data.costStats.cacheRead + data.costStats.cacheWrite).toFixed(4)}
Context Usage:${contextUsageText}
${systemPromptHtml} ${toolsHtml} ${streamingNotice}
${messagesHtml}
`; } // ============================================================================ // Public API // ============================================================================ export interface ExportOptions { outputPath?: string; themeName?: string; } /** * Export session to HTML using SessionManager and AgentState. * Used by TUI's /export command. * @param sessionManager The session manager * @param state The agent state * @param options Export options including output path and theme name */ export function exportSessionToHtml( sessionManager: SessionManager, state: AgentState, options?: ExportOptions | string, ): string { // Handle backwards compatibility: options can be just the output path string const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {}; const sessionFile = sessionManager.getSessionFile(); if (!sessionFile) { throw new Error("Cannot export in-memory session to HTML"); } const content = readFileSync(sessionFile, "utf8"); const data = parseSessionFile(content); // Enrich with data from AgentState (tools, context window) data.tools = state.tools.map((t: { name: string; description: string }) => ({ name: t.name, description: t.description, })); data.contextWindow = state.model?.contextWindow; if (!data.systemPrompt) { data.systemPrompt = state.systemPrompt; } let outputPath = opts.outputPath; if (!outputPath) { const sessionBasename = basename(sessionFile, ".jsonl"); outputPath = `${APP_NAME}-session-${sessionBasename}.html`; } const colors = getThemeColors(opts.themeName); const isLight = isLightTheme(opts.themeName); const html = generateHtml(data, basename(sessionFile), colors, isLight); writeFileSync(outputPath, html, "utf8"); return outputPath; } /** * Export session file to HTML (standalone, without AgentState). * Auto-detects format: session manager format or streaming event format. * Used by CLI for exporting arbitrary session files. * @param inputPath Path to the session file * @param options Export options including output path and theme name */ export function exportFromFile(inputPath: string, options?: ExportOptions | string): string { // Handle backwards compatibility: options can be just the output path string const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {}; if (!existsSync(inputPath)) { throw new Error(`File not found: ${inputPath}`); } const content = readFileSync(inputPath, "utf8"); const data = parseSessionFile(content); let outputPath = opts.outputPath; if (!outputPath) { const inputBasename = basename(inputPath, ".jsonl"); outputPath = `${APP_NAME}-session-${inputBasename}.html`; } const colors = getThemeColors(opts.themeName); const isLight = isLightTheme(opts.themeName); const html = generateHtml(data, basename(inputPath), colors, isLight); writeFileSync(outputPath, html, "utf8"); return outputPath; }