import type { AgentState } from "@mariozechner/pi-agent"; import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai"; import { readFileSync, writeFileSync } from "fs"; import { homedir } from "os"; import { basename, dirname, join } from "path"; import { fileURLToPath } from "url"; import type { SessionManager } from "./session-manager.js"; // Get version from package.json const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const packageJson = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8")); const VERSION = packageJson.version; /** * TUI Color scheme (matching exact RGB values from TUI components) */ const COLORS = { // Backgrounds userMessageBg: "rgb(52, 53, 65)", // Dark slate toolPendingBg: "rgb(40, 40, 50)", // Dark blue-gray toolSuccessBg: "rgb(40, 50, 40)", // Dark green toolErrorBg: "rgb(60, 40, 40)", // Dark red bodyBg: "rgb(24, 24, 30)", // Very dark background containerBg: "rgb(30, 30, 36)", // Slightly lighter container // Text colors (matching chalk colors) text: "rgb(229, 229, 231)", // Light gray (close to white) textDim: "rgb(161, 161, 170)", // Dimmed gray cyan: "rgb(103, 232, 249)", // Cyan for paths green: "rgb(34, 197, 94)", // Green for success red: "rgb(239, 68, 68)", // Red for errors yellow: "rgb(234, 179, 8)", // Yellow for warnings italic: "rgb(161, 161, 170)", // Gray italic for thinking }; /** * Escape HTML special characters */ function escapeHtml(text: string): string { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } /** * Shorten path with tilde notation */ function shortenPath(path: string): string { const home = homedir(); if (path.startsWith(home)) { return "~" + path.slice(home.length); } return path; } /** * Replace tabs with 3 spaces */ function replaceTabs(text: string): string { return text.replace(/\t/g, " "); } /** * Format tool execution matching TUI ToolExecutionComponent */ function formatToolExecution( toolName: string, args: any, result?: ToolResultMessage, ): { html: string; bgColor: string } { let html = ""; const isError = result?.isError || false; const bgColor = result ? (isError ? COLORS.toolErrorBg : COLORS.toolSuccessBg) : COLORS.toolPendingBg; // Get text output from result const getTextOutput = (): string => { if (!result) return ""; const textBlocks = result.content.filter((c) => c.type === "text"); return textBlocks.map((c: any) => c.text).join("\n"); }; // Format based on tool type (matching TUI logic exactly) if (toolName === "bash") { const command = args?.command || ""; html = `
$ ${escapeHtml(command || "...")}
`; if (result) { const output = getTextOutput().trim(); if (output) { const lines = output.split("\n"); const maxLines = 5; const displayLines = lines.slice(0, maxLines); const remaining = lines.length - maxLines; if (remaining > 0) { // Truncated output - make it expandable html += '"; } else { // Short output - show all html += '
'; for (const line of displayLines) { html += `
${escapeHtml(line)}
`; } html += "
"; } } } } else if (toolName === "read") { const path = shortenPath(args?.file_path || args?.path || ""); html = `
read ${escapeHtml(path || "...")}
`; if (result) { const output = getTextOutput(); const lines = output.split("\n"); const maxLines = 10; const displayLines = lines.slice(0, maxLines); const remaining = lines.length - maxLines; if (remaining > 0) { // Truncated output - make it expandable html += '"; } else { // Short output - show all html += '
'; for (const line of displayLines) { html += `
${escapeHtml(replaceTabs(line))}
`; } html += "
"; } } } else if (toolName === "write") { const path = shortenPath(args?.file_path || args?.path || ""); const fileContent = args?.content || ""; const lines = fileContent ? fileContent.split("\n") : []; const totalLines = lines.length; html = `
write ${escapeHtml(path || "...")}`; if (totalLines > 10) { html += ` (${totalLines} lines)`; } html += "
"; if (fileContent) { const maxLines = 10; const displayLines = lines.slice(0, maxLines); const remaining = lines.length - maxLines; if (remaining > 0) { // Truncated output - make it expandable html += '"; } else { // Short output - show all html += '
'; for (const line of displayLines) { html += `
${escapeHtml(replaceTabs(line))}
`; } html += "
"; } } if (result) { const output = getTextOutput().trim(); if (output) { html += `
${escapeHtml(output)}
`; } } } else if (toolName === "edit") { const path = shortenPath(args?.file_path || args?.path || ""); html = `
edit ${escapeHtml(path || "...")}
`; // Show diff if available from result.details.diff 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)}
`; } } } else { // Generic tool html = `
${escapeHtml(toolName)}
`; html += `
${escapeHtml(JSON.stringify(args, null, 2))}
`; if (result) { const output = getTextOutput(); if (output) { html += `
${escapeHtml(output)}
`; } } } return { html, bgColor }; } /** * Format a message as HTML (matching TUI component styling) */ function formatMessage(message: Message, toolResultsMap: Map): string { let html = ""; if (message.role === "user") { const userMsg = message as UserMessage; let textContent = ""; if (typeof userMsg.content === "string") { textContent = userMsg.content; } else { const textBlocks = userMsg.content.filter((c) => c.type === "text"); textContent = textBlocks.map((c: any) => c.text).join(""); } if (textContent.trim()) { html += `
${escapeHtml(textContent).replace(/\n/g, "
")}
`; } } else if (message.role === "assistant") { const assistantMsg = message as AssistantMessage; // Render text and thinking content for (const content of assistantMsg.content) { if (content.type === "text" && content.text.trim()) { html += `
${escapeHtml(content.text.trim()).replace(/\n/g, "
")}
`; } else if (content.type === "thinking" && content.thinking.trim()) { html += `
${escapeHtml(content.thinking.trim()).replace(/\n/g, "
")}
`; } } // Render tool calls with their results for (const content of assistantMsg.content) { if (content.type === "toolCall") { const toolResult = toolResultsMap.get(content.id); const { html: toolHtml, bgColor } = formatToolExecution(content.name, content.arguments, toolResult); html += `
${toolHtml}
`; } } // Show error/abort status if no tool calls const hasToolCalls = assistantMsg.content.some((c) => c.type === "toolCall"); if (!hasToolCalls) { if (assistantMsg.stopReason === "aborted") { html += '
Aborted
'; } else if (assistantMsg.stopReason === "error") { const errorMsg = assistantMsg.errorMessage || "Unknown error"; html += `
Error: ${escapeHtml(errorMsg)}
`; } } } return html; } /** * Export session to a self-contained HTML file matching TUI visual style */ export function exportSessionToHtml(sessionManager: SessionManager, state: AgentState, outputPath?: string): string { const sessionFile = sessionManager.getSessionFile(); const timestamp = new Date().toISOString(); // Use session filename + .html if no output path provided if (!outputPath) { const sessionBasename = basename(sessionFile, ".jsonl"); outputPath = `${sessionBasename}.html`; } // Read and parse session data const sessionContent = readFileSync(sessionFile, "utf8"); const lines = sessionContent.trim().split("\n"); let sessionHeader: any = null; const messages: Message[] = []; const toolResultsMap = new Map(); for (const line of lines) { try { const entry = JSON.parse(line); if (entry.type === "session") { sessionHeader = entry; } else if (entry.type === "message") { messages.push(entry.message); // Build map of tool call ID to result if (entry.message.role === "toolResult") { toolResultsMap.set(entry.message.toolCallId, entry.message); } } } catch { // Skip malformed lines } } // Generate messages HTML let messagesHtml = ""; for (const message of messages) { if (message.role !== "toolResult") { // Skip toolResult messages as they're rendered with their tool calls messagesHtml += formatMessage(message, toolResultsMap); } } // Generate HTML (matching TUI aesthetic) const html = ` Session Export - ${basename(sessionFile)}

pi v${VERSION}

Session: ${escapeHtml(sessionHeader?.id || "unknown")}
Date: ${sessionHeader?.timestamp ? new Date(sessionHeader.timestamp).toLocaleString() : timestamp}
Model: ${escapeHtml(sessionHeader?.model || state.model.id)}
Messages: ${messages.filter((m) => m.role !== "toolResult").length}
Directory: ${escapeHtml(shortenPath(sessionHeader?.cwd || process.cwd()))}
Thinking: ${escapeHtml(sessionHeader?.thinkingLevel || state.thinkingLevel)}
System Prompt
${escapeHtml(sessionHeader?.systemPrompt || state.systemPrompt)}
Available Tools
${state.tools .map( (tool) => `
${escapeHtml(tool.name)} - ${escapeHtml(tool.description)}
`, ) .join("")}
${messagesHtml}
`; // Write HTML file writeFileSync(outputPath, html, "utf8"); return outputPath; }