diff --git a/packages/coding-agent/src/export-html.ts b/packages/coding-agent/src/export-html.ts index 1afd159e..86578bc8 100644 --- a/packages/coding-agent/src/export-html.ts +++ b/packages/coding-agent/src/export-html.ts @@ -1,8 +1,32 @@ import type { AgentState } from "@mariozechner/pi-agent"; -import type { Message } from "@mariozechner/pi-ai"; +import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai"; import { readFileSync, writeFileSync } from "fs"; +import { homedir } from "os"; +import { basename } from "path"; import type { SessionManager } from "./session-manager.js"; +/** + * 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 */ @@ -16,142 +40,251 @@ function escapeHtml(text: string): string { } /** - * Convert ANSI color codes to HTML spans + * Shorten path with tilde notation */ -function ansiToHtml(text: string): string { - // Simple ANSI color code to HTML conversion - // This is a basic implementation - could be enhanced with a library - const ansiColors: Record = { - "30": "#000000", // black - "31": "#cd3131", // red - "32": "#0dbc79", // green - "33": "#e5e510", // yellow - "34": "#2472c8", // blue - "35": "#bc3fbc", // magenta - "36": "#11a8cd", // cyan - "37": "#e5e5e5", // white - "90": "#666666", // bright black (gray) - "91": "#f14c4c", // bright red - "92": "#23d18b", // bright green - "93": "#f5f543", // bright yellow - "94": "#3b8eea", // bright blue - "95": "#d670d6", // bright magenta - "96": "#29b8db", // bright cyan - "97": "#ffffff", // bright white +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, " "); +} + +/** + * Generate unified diff matching TUI style + */ +function generateDiff(oldStr: string, newStr: string): string { + const oldLines = oldStr.split("\n"); + const newLines = newStr.split("\n"); + + const maxLineNum = Math.max(oldLines.length, newLines.length); + const lineNumWidth = String(maxLineNum).length; + + let html = `
- old:
`; + for (let i = 0; i < oldLines.length; i++) { + const lineNum = String(i + 1).padStart(lineNumWidth, " "); + html += `
- ${escapeHtml(lineNum)} ${escapeHtml(oldLines[i])}
`; + } + + html += `
`; + + html += `
+ new:
`; + for (let i = 0; i < newLines.length; i++) { + const lineNum = String(i + 1).padStart(lineNumWidth, " "); + html += `
+ ${escapeHtml(lineNum)} ${escapeHtml(newLines[i])}
`; + } + + return html; +} + +/** + * 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"); }; - let html = escapeHtml(text); + // Format based on tool type (matching TUI logic exactly) + if (toolName === "bash") { + const command = args?.command || ""; + html = `
$ ${escapeHtml(command || "...")}
`; - // Replace ANSI codes with HTML spans - html = html.replace(/\x1b\[([0-9;]+)m/g, (_match, codes) => { - const codeList = codes.split(";"); - if (codeList.includes("0")) { - return ""; // Reset - } - for (const code of codeList) { - if (ansiColors[code]) { - return ``; - } - if (code === "1") { - return ''; - } - if (code === "2") { - return ''; + 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; + + html += '
'; + for (const line of displayLines) { + html += `
${escapeHtml(line)}
`; + } + if (remaining > 0) { + html += `
... (${remaining} more lines)
`; + } + html += "
"; } } - return ""; - }); + } else if (toolName === "read") { + const path = shortenPath(args?.file_path || args?.path || ""); + html = `
read ${escapeHtml(path || "...")}
`; - return html; -} + if (result) { + const output = getTextOutput(); + const lines = output.split("\n"); + const maxLines = 10; + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; -/** - * Format a message as HTML - */ -function formatMessage(message: Message): string { - const role = message.role; - const roleClass = - role === "user" ? "user-message" : role === "toolResult" ? "tool-result-message" : "assistant-message"; - const roleLabel = role === "user" ? "User" : role === "assistant" ? "Assistant" : "Tool Result"; + html += '
'; + for (const line of displayLines) { + html += `
${escapeHtml(replaceTabs(line))}
`; + } + if (remaining > 0) { + html += `
... (${remaining} more lines)
`; + } + 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; - let html = `
`; - html += `
${roleLabel}
`; - html += `
`; + html = `
write ${escapeHtml(path || "...")}`; + if (totalLines > 10) { + html += ` (${totalLines} lines)`; + } + html += "
"; - // Handle ToolResultMessage separately - if (role === "toolResult") { - const isError = message.isError; - html += `
`; - html += `
${isError ? "❌" : "✅"} ${escapeHtml(message.toolName)}
`; + if (fileContent) { + const maxLines = 10; + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; - for (const content of message.content) { - if (content.type === "text") { - html += `
${ansiToHtml(content.text)}
`; - } else if (content.type === "image") { - const imageData = content.data; - const mimeType = content.mimeType || "image/png"; - html += `Tool result image`; + html += '
'; + for (const line of displayLines) { + html += `
${escapeHtml(replaceTabs(line))}
`; + } + if (remaining > 0) { + html += `
... (${remaining} more lines)
`; + } + 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 || "...")}
`; + + if (args?.old_string && args?.new_string) { + html += '
' + generateDiff(args.old_string, args.new_string) + "
"; + } + + if (result) { + const output = getTextOutput().trim(); + if (output) { + html += `
${escapeHtml(output)}
`; } } - html += `
`; - } - // Handle string content (for user messages) - else if (typeof message.content === "string") { - const text = escapeHtml(message.content); - html += `
${text.replace(/\n/g, "
")}
`; } else { - // Handle array content - for (const content of message.content) { - if (typeof content === "string") { - // Handle legacy string content - const text = escapeHtml(content); - html += `
${text.replace(/\n/g, "
")}
`; - } else if (content.type === "text") { - // Format text with markdown-like rendering - const text = escapeHtml(content.text); - html += `
${text.replace(/\n/g, "
")}
`; - } else if (content.type === "thinking") { - html += `
`; - html += `Thinking...`; - html += `
${escapeHtml(content.thinking).replace(/\n/g, "
")}
`; - html += `
`; - } else if (content.type === "toolCall") { - html += `
`; - html += `
🔧 ${escapeHtml(content.name)}
`; - html += `
${escapeHtml(JSON.stringify(content.arguments, null, 2))}
`; - html += `
`; - } else if (content.type === "image") { - const imageData = content.data; - const mimeType = content.mimeType || "image/png"; - html += `User image`; + // 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)}
`; } } } - html += `
`; return html; } /** - * Export session to a self-contained HTML file + * 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(); - // Generate output filename if not provided + // Use session filename + .html if no output path provided if (!outputPath) { - const dateStr = new Date().toISOString().replace(/[:.]/g, "-").split("T")[0]; - outputPath = `coding-session-${dateStr}.html`; + const sessionBasename = basename(sessionFile, ".jsonl"); + outputPath = `${sessionBasename}.html`; } - // Read session data + // Read and parse session data const sessionContent = readFileSync(sessionFile, "utf8"); const lines = sessionContent.trim().split("\n"); - // Parse session metadata let sessionHeader: any = null; const messages: Message[] = []; + const toolResultsMap = new Map(); for (const line of lines) { try { @@ -160,19 +293,32 @@ export function exportSessionToHtml(sessionManager: SessionManager, state: Agent 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 HTML + // 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 = ` - Coding Session Export - ${timestamp} + Session Export - ${basename(sessionFile)} @@ -410,41 +512,41 @@ export function exportSessionToHtml(sessionManager: SessionManager, state: Agent
-

Coding Session Export

+

pi coding-agent session

-
Session ID
-
${sessionHeader?.id || "unknown"}
+ Session: + ${escapeHtml(sessionHeader?.id || "unknown")}
-
Date
-
${sessionHeader?.timestamp ? new Date(sessionHeader.timestamp).toLocaleString() : timestamp}
+ Date: + ${sessionHeader?.timestamp ? new Date(sessionHeader.timestamp).toLocaleString() : timestamp}
-
Model
-
${escapeHtml(sessionHeader?.model || state.model.id)}
+ Model: + ${escapeHtml(sessionHeader?.model || state.model.id)}
-
Messages
-
${messages.length}
+ Messages: + ${messages.filter((m) => m.role !== "toolResult").length}
-
Working Directory
-
${escapeHtml(sessionHeader?.cwd || process.cwd())}
+ Directory: + ${escapeHtml(shortenPath(sessionHeader?.cwd || process.cwd()))}
-
Thinking Level
-
${escapeHtml(sessionHeader?.thinkingLevel || state.thinkingLevel)}
+ Thinking: + ${escapeHtml(sessionHeader?.thinkingLevel || state.thinkingLevel)}
- ${messages.map((msg) => formatMessage(msg)).join("\n")} + ${messagesHtml}