diff --git a/package-lock.json b/package-lock.json index c568fbd8..f2b84f92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6539,7 +6539,8 @@ "diff": "^8.0.2", "file-type": "^21.1.1", "glob": "^11.0.3", - "jiti": "^2.6.1" + "jiti": "^2.6.1", + "marked": "^15.0.12" }, "bin": { "pi": "dist/cli.js" diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index fd170112..847c2074 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -47,7 +47,8 @@ "diff": "^8.0.2", "file-type": "^21.1.1", "glob": "^11.0.3", - "jiti": "^2.6.1" + "jiti": "^2.6.1", + "marked": "^15.0.12" }, "devDependencies": { "@types/diff": "^7.0.2", diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 6cde7bae..f55435f1 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1483,7 +1483,8 @@ export class AgentSession { * @returns Path to exported file */ exportToHtml(outputPath?: string): string { - return exportSessionToHtml(this.sessionManager, this.state, outputPath); + const themeName = this.settingsManager.getTheme(); + return exportSessionToHtml(this.sessionManager, this.state, { outputPath, themeName }); } // ========================================================================= diff --git a/packages/coding-agent/src/core/export-html.ts b/packages/coding-agent/src/core/export-html.ts index deceddd6..ea48edc2 100644 --- a/packages/coding-agent/src/core/export-html.ts +++ b/packages/coding-agent/src/core/export-html.ts @@ -1,9 +1,11 @@ import type { AgentState } from "@mariozechner/pi-agent-core"; -import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai"; +import type { AssistantMessage, ImageContent, Message, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai"; import { existsSync, readFileSync, writeFileSync } from "fs"; +import { marked } from "marked"; import { homedir } from "os"; +import * as path from "path"; import { basename } from "path"; -import { APP_NAME, VERSION } from "../config.js"; +import { APP_NAME, getCustomThemesDir, getThemesDir, VERSION } from "../config.js"; import { type BashExecutionMessage, isBashExecutionMessage } from "./messages.js"; import type { SessionManager } from "./session-manager.js"; @@ -49,25 +51,248 @@ interface ParsedSessionData { } // ============================================================================ -// Color scheme (matching TUI) +// Theme Types and Loading // ============================================================================ -const COLORS = { - userMessageBg: "rgb(52, 53, 65)", - toolPendingBg: "rgb(40, 40, 50)", - toolSuccessBg: "rgb(40, 50, 40)", - toolErrorBg: "rgb(60, 40, 40)", - userBashBg: "rgb(50, 48, 35)", // Faint yellow/brown for user-executed bash - userBashErrorBg: "rgb(60, 45, 35)", // Slightly more orange for errors - bodyBg: "rgb(24, 24, 30)", - containerBg: "rgb(30, 30, 36)", - text: "rgb(229, 229, 231)", - textDim: "rgb(161, 161, 170)", - cyan: "rgb(103, 232, 249)", - green: "rgb(34, 197, 94)", - red: "rgb(239, 68, 68)", - yellow: "rgb(234, 179, 8)", -}; +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 | null { + // 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 null; + } + } + + // 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 null; + } + } + + return null; +} + +/** 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 @@ -97,6 +322,32 @@ function formatTimestamp(timestamp: number | string | undefined): string { return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" }); } +/** Render markdown to HTML server-side with TUI-style code block formatting. */ +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 escaped = escapeHtml(code); + return ( + '
' + + `
\`\`\`${language}
` + + `
${escaped}
` + + '' + + "
" + ); + }; + + // 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): string { const displayLines = lines.slice(0, maxLines); const remaining = lines.length - maxLines; @@ -323,11 +574,12 @@ function parseSessionFile(content: string): ParsedSessionData { function formatToolExecution( toolName: string, args: Record, - result?: ToolResultMessage, + 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 bgColor = result ? (isError ? colors.toolErrorBg : colors.toolSuccessBg) : colors.toolPendingBg; const getTextOutput = (): string => { if (!result) return ""; @@ -349,16 +601,16 @@ function formatToolExecution( } case "read": { - const path = shortenPath((args?.file_path as string) || (args?.path as string) || ""); + const filePath = shortenPath((args?.file_path as string) || (args?.path as string) || ""); const offset = args?.offset as number | undefined; const limit = args?.limit as number | undefined; - // Build path display with offset/limit suffix (in yellow color if offset/limit used) - let pathHtml = escapeHtml(path || "..."); + // Build path display with offset/limit suffix + let pathHtml = escapeHtml(filePath || "..."); if (offset !== undefined || limit !== undefined) { const startLine = offset ?? 1; const endLine = limit !== undefined ? startLine + limit - 1 : ""; - pathHtml += `:${startLine}${endLine ? `-${endLine}` : ""}`; + pathHtml += `:${startLine}${endLine ? `-${endLine}` : ""}`; } html = `
read ${pathHtml}
`; @@ -436,7 +688,7 @@ function formatToolExecution( return { html, bgColor }; } -function formatMessage(message: Message, toolResultsMap: Map): string { +function formatMessage(message: Message, toolResultsMap: Map, colors: ThemeColors): string { let html = ""; const timestamp = (message as { timestamp?: number }).timestamp; const timestampHtml = timestamp ? `
${formatTimestamp(timestamp)}
` : ""; @@ -445,9 +697,8 @@ function formatMessage(message: Message, toolResultsMap: Map`; + html += `
`; html += timestampHtml; html += `
$ ${escapeHtml(bashMsg.command)}
`; @@ -457,13 +708,13 @@ function formatMessage(message: Message, toolResultsMap: Map(cancelled)
`; + html += `
(cancelled)
`; } else if (bashMsg.exitCode !== 0 && bashMsg.exitCode !== null) { - html += `
(exit ${bashMsg.exitCode})
`; + html += `
(exit ${bashMsg.exitCode})
`; } if (bashMsg.truncated && bashMsg.fullOutputPath) { - html += `
Output truncated. Full output: ${escapeHtml(bashMsg.fullOutputPath)}
`; + html += `
Output truncated. Full output: ${escapeHtml(bashMsg.fullOutputPath)}
`; } html += ``; @@ -473,24 +724,45 @@ function formatMessage(message: Message, toolResultsMap: Map c.type === "text"); - textContent = textBlocks.map((c) => (c as { type: "text"; text: string }).text).join(""); + for (const block of userMsg.content) { + if (block.type === "text") { + textContent += block.text; + } else if (block.type === "image") { + images.push(block as ImageContent); + } + } } - if (textContent.trim()) { - html += `
${timestampHtml}${escapeHtml(textContent).replace(/\n/g, "
")}
`; + 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 += `
`; } else if (message.role === "assistant") { const assistantMsg = message as AssistantMessage; html += timestampHtml ? `
${timestampHtml}` : ""; for (const content of assistantMsg.content) { if (content.type === "text" && content.text.trim()) { - html += `
${escapeHtml(content.text.trim()).replace(/\n/g, "
")}
`; + // Render markdown server-side + html += `
${renderMarkdown(content.text)}
`; } else if (content.type === "thinking" && content.thinking.trim()) { html += `
${escapeHtml(content.thinking.trim()).replace(/\n/g, "
")}
`; } @@ -503,6 +775,7 @@ function formatMessage(message: Message, toolResultsMap: Map, toolResult, + colors, ); html += `
${toolHtml}
`; } @@ -559,7 +832,7 @@ function formatCompaction(event: CompactionEvent): string { // HTML generation // ============================================================================ -function generateHtml(data: ParsedSessionData, filename: string): string { +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; @@ -596,7 +869,7 @@ function generateHtml(data: ParsedSessionData, filename: string): string { switch (event.type) { case "message": if (event.message.role !== "toolResult") { - messagesHtml += formatMessage(event.message, data.toolResultsMap); + messagesHtml += formatMessage(event.message, data.toolResultsMap, colors); } break; case "model_change": @@ -634,6 +907,16 @@ function generateHtml(data: ParsedSessionData, filename: string): string { ? `${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 ` @@ -646,72 +929,68 @@ function generateHtml(data: ParsedSessionData, filename: string): string { font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace; font-size: 12px; line-height: 1.6; - color: ${COLORS.text}; - background: ${COLORS.bodyBg}; + color: ${colors.text}; + background: ${bodyBg}; padding: 24px; } .container { max-width: 700px; margin: 0 auto; } .header { margin-bottom: 24px; padding: 16px; - background: ${COLORS.containerBg}; + background: ${containerBg}; border-radius: 4px; } .header h1 { font-size: 14px; font-weight: bold; margin-bottom: 12px; - color: ${COLORS.cyan}; + color: ${colors.borderAccent}; } .header-info { display: flex; flex-direction: column; gap: 3px; font-size: 11px; } - .info-item { color: ${COLORS.textDim}; display: flex; align-items: baseline; } + .info-item { color: ${colors.dim}; display: flex; align-items: baseline; } .info-label { font-weight: 600; margin-right: 8px; min-width: 100px; } - .info-value { color: ${COLORS.text}; flex: 1; } + .info-value { color: ${colors.text}; flex: 1; } .info-value.cost { font-family: 'SF Mono', monospace; } .messages { display: flex; flex-direction: column; gap: 16px; } - .message-timestamp { font-size: 10px; color: ${COLORS.textDim}; margin-bottom: 4px; opacity: 0.8; } + .message-timestamp { font-size: 10px; color: ${colors.dim}; margin-bottom: 4px; opacity: 0.8; } .user-message { - background: ${COLORS.userMessageBg}; + background: ${colors.userMessageBg}; + color: ${colors.userMessageText}; padding: 12px 16px; border-radius: 4px; - white-space: pre-wrap; - word-wrap: break-word; - overflow-wrap: break-word; - word-break: break-word; } .assistant-message { padding: 0; } .assistant-text, .thinking-text { padding: 12px 16px; - white-space: pre-wrap; - word-wrap: break-word; - overflow-wrap: break-word; - word-break: break-word; } - .thinking-text { color: ${COLORS.textDim}; font-style: italic; } - .model-change { padding: 8px 16px; background: rgb(40, 40, 50); border-radius: 4px; } - .model-change-text { color: ${COLORS.textDim}; font-size: 11px; } - .model-name { color: ${COLORS.cyan}; font-weight: bold; } - .compaction-container { background: rgb(60, 55, 35); border-radius: 4px; overflow: hidden; } + .thinking-text { color: ${colors.dim}; font-style: italic; white-space: pre-wrap; } + .model-change { padding: 8px 16px; background: ${modelChangeBg}; border-radius: 4px; } + .model-change-text { color: ${colors.dim}; font-size: 11px; } + .model-name { color: ${colors.borderAccent}; font-weight: bold; } + .compaction-container { background: ${compactionBg}; border-radius: 4px; overflow: hidden; } .compaction-header { padding: 12px 16px; cursor: pointer; } - .compaction-header:hover { background: rgba(255, 255, 255, 0.05); } + .compaction-header:hover { background: rgba(${isLight ? "0, 0, 0" : "255, 255, 255"}, 0.05); } .compaction-header-row { display: flex; align-items: center; gap: 8px; } - .compaction-toggle { color: ${COLORS.cyan}; font-size: 10px; transition: transform 0.2s; } + .compaction-toggle { color: ${colors.borderAccent}; font-size: 10px; transition: transform 0.2s; } .compaction-container.expanded .compaction-toggle { transform: rotate(90deg); } - .compaction-title { color: ${COLORS.text}; font-weight: bold; } - .compaction-hint { color: ${COLORS.textDim}; font-size: 11px; } + .compaction-title { color: ${colors.text}; font-weight: bold; } + .compaction-hint { color: ${colors.dim}; font-size: 11px; } .compaction-content { display: none; padding: 0 16px 16px 16px; } .compaction-container.expanded .compaction-content { display: block; } - .compaction-summary { background: rgba(0, 0, 0, 0.2); border-radius: 4px; padding: 12px; } - .compaction-summary-header { font-weight: bold; color: ${COLORS.cyan}; margin-bottom: 8px; font-size: 11px; } - .compaction-summary-content { color: ${COLORS.text}; white-space: pre-wrap; word-wrap: break-word; } + .compaction-summary { background: rgba(0, 0, 0, 0.1); border-radius: 4px; padding: 12px; } + .compaction-summary-header { font-weight: bold; color: ${colors.borderAccent}; margin-bottom: 8px; font-size: 11px; } + .compaction-summary-content { color: ${colors.text}; white-space: pre-wrap; word-wrap: break-word; } .tool-execution { padding: 12px 16px; border-radius: 4px; margin-top: 8px; } + .tool-execution.user-bash { background: ${userBashBg}; } + .tool-execution.user-bash-error { background: ${userBashErrorBg}; } .tool-header, .tool-name { font-weight: bold; } - .tool-path { color: ${COLORS.cyan}; word-break: break-all; } - .line-count { color: ${COLORS.textDim}; } + .tool-path { color: ${colors.borderAccent}; word-break: break-all; } + .line-numbers { color: ${colors.warning}; } + .line-count { color: ${colors.dim}; } .tool-command { font-weight: bold; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; } .tool-output { margin-top: 12px; - color: ${COLORS.textDim}; + color: ${colors.toolOutput}; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; @@ -726,19 +1005,109 @@ function generateHtml(data: ParsedSessionData, filename: string): string { .tool-output.expandable .output-full { display: none; } .tool-output.expandable.expanded .output-preview { display: none; } .tool-output.expandable.expanded .output-full { display: block; } - .expand-hint { color: ${COLORS.cyan}; font-style: italic; margin-top: 4px; } - .system-prompt, .tools-list { background: rgb(60, 55, 40); padding: 12px 16px; border-radius: 4px; margin-bottom: 16px; } - .system-prompt-header, .tools-header { font-weight: bold; color: ${COLORS.yellow}; margin-bottom: 8px; } - .system-prompt-content, .tools-content { color: ${COLORS.textDim}; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; font-size: 11px; } + .expand-hint { color: ${colors.borderAccent}; font-style: italic; margin-top: 4px; } + .system-prompt, .tools-list { background: ${systemPromptBg}; padding: 12px 16px; border-radius: 4px; margin-bottom: 16px; } + .system-prompt-header, .tools-header { font-weight: bold; color: ${colors.warning}; margin-bottom: 8px; } + .system-prompt-content, .tools-content { color: ${colors.dim}; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; font-size: 11px; } .tool-item { margin: 4px 0; } - .tool-item-name { font-weight: bold; color: ${COLORS.text}; } + .tool-item-name { font-weight: bold; color: ${colors.text}; } .tool-diff { margin-top: 12px; font-size: 11px; font-family: inherit; overflow-x: auto; max-width: 100%; } - .diff-line-old { color: ${COLORS.red}; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; } - .diff-line-new { color: ${COLORS.green}; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; } - .diff-line-context { color: ${COLORS.textDim}; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; } - .error-text { color: ${COLORS.red}; padding: 12px 16px; } - .footer { margin-top: 48px; padding: 20px; text-align: center; color: ${COLORS.textDim}; font-size: 10px; } - .streaming-notice { background: rgb(50, 45, 35); padding: 12px 16px; border-radius: 4px; margin-bottom: 16px; color: ${COLORS.textDim}; font-size: 11px; } + .diff-line-old { color: ${colors.toolDiffRemoved}; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; } + .diff-line-new { color: ${colors.toolDiffAdded}; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; } + .diff-line-context { color: ${colors.toolDiffContext}; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; } + .error-text { color: ${colors.error}; padding: 12px 16px; } + .bash-status.warning { color: ${colors.warning}; } + .bash-status.error { color: ${colors.error}; } + .bash-truncation.warning { color: ${colors.warning}; } + .footer { margin-top: 48px; padding: 20px; text-align: center; color: ${colors.dim}; font-size: 10px; } + .streaming-notice { background: ${streamingNoticeBg}; padding: 12px 16px; border-radius: 4px; margin-bottom: 16px; color: ${colors.dim}; font-size: 11px; } + + /* Image styles */ + .message-images { margin-bottom: 12px; } + .message-image { max-width: 100%; max-height: 400px; border-radius: 4px; margin: 4px 0; } + + /* Markdown styles */ + .markdown-content h1, .markdown-content h2, .markdown-content h3, + .markdown-content h4, .markdown-content h5, .markdown-content h6 { + color: ${colors.mdHeading}; + margin: 1em 0 0.5em 0; + font-weight: bold; + } + .markdown-content h1 { font-size: 1.4em; text-decoration: underline; } + .markdown-content h2 { font-size: 1.2em; } + .markdown-content h3 { font-size: 1.1em; } + .markdown-content p { margin: 0.5em 0; } + .markdown-content a { color: ${colors.mdLink}; text-decoration: underline; } + .markdown-content a:hover { opacity: 0.8; } + .markdown-content code { + background: rgba(${isLight ? "0, 0, 0" : "255, 255, 255"}, 0.1); + color: ${colors.mdCode}; + padding: 2px 6px; + border-radius: 3px; + font-family: inherit; + } + .markdown-content pre { + background: transparent; + border: none; + border-radius: 0; + padding: 0; + margin: 0.5em 0; + overflow-x: auto; + } + .markdown-content pre code { + display: block; + background: none; + color: ${colors.mdCodeBlock}; + padding: 8px 12px; + } + .code-block-wrapper { + margin: 0.5em 0; + } + .code-block-header { + color: ${colors.mdCodeBlockBorder}; + font-size: 11px; + } + .code-block-footer { + color: ${colors.mdCodeBlockBorder}; + font-size: 11px; + } + .markdown-content blockquote { + border-left: 3px solid ${colors.mdQuoteBorder}; + padding-left: 12px; + margin: 0.5em 0; + color: ${colors.mdQuote}; + font-style: italic; + } + .markdown-content ul, .markdown-content ol { + margin: 0.5em 0; + padding-left: 24px; + } + .markdown-content li { margin: 0.25em 0; } + .markdown-content li::marker { color: ${colors.mdListBullet}; } + .markdown-content hr { + border: none; + border-top: 1px solid ${colors.mdHr}; + margin: 1em 0; + } + .markdown-content table { + border-collapse: collapse; + margin: 0.5em 0; + width: 100%; + } + .markdown-content th, .markdown-content td { + border: 1px solid ${colors.mdCodeBlockBorder}; + padding: 6px 10px; + text-align: left; + } + .markdown-content th { + background: rgba(${isLight ? "0, 0, 0" : "255, 255, 255"}, 0.05); + font-weight: bold; + } + .markdown-content img { + max-width: 100%; + border-radius: 4px; + } + @media print { body { background: white; color: black; } .tool-execution { border: 1px solid #ddd; } } @@ -803,28 +1172,49 @@ function generateHtml(data: ParsedSessionData, filename: string): string { // 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, outputPath?: string): string { +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(); const content = readFileSync(sessionFile, "utf8"); const data = parseSessionFile(content); // Enrich with data from AgentState (tools, context window) - data.tools = state.tools.map((t) => ({ name: t.name, description: t.description })); + 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 html = generateHtml(data, basename(sessionFile)); + const colors = getThemeColors(opts.themeName); + const isLight = isLightTheme(opts.themeName); + const html = generateHtml(data, basename(sessionFile), colors, isLight); writeFileSync(outputPath, html, "utf8"); return outputPath; } @@ -833,8 +1223,13 @@ export function exportSessionToHtml(sessionManager: SessionManager, state: Agent * 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, outputPath?: string): string { +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}`); } @@ -842,12 +1237,15 @@ export function exportFromFile(inputPath: string, outputPath?: string): string { 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 html = generateHtml(data, basename(inputPath)); + const colors = getThemeColors(opts.themeName); + const isLight = isLightTheme(opts.themeName); + const html = generateHtml(data, basename(inputPath), colors, isLight); writeFileSync(outputPath, html, "utf8"); return outputPath; }