import { existsSync, readFileSync, writeFileSync } from "fs"; import { basename, join } from "path"; import { APP_NAME, getExportTemplateDir } from "../../config.js"; import { getResolvedThemeColors, getThemeExportColors, } from "../../modes/interactive/theme/theme.js"; import { SessionManager } from "../session-manager.js"; /** Parse a color string to RGB values. Supports hex (#RRGGBB) and rgb(r,g,b) formats. */ function parseColor(color) { const hexMatch = color.match( /^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/, ); if (hexMatch) { return { r: Number.parseInt(hexMatch[1], 16), g: Number.parseInt(hexMatch[2], 16), b: Number.parseInt(hexMatch[3], 16), }; } const rgbMatch = color.match( /^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/, ); if (rgbMatch) { return { r: Number.parseInt(rgbMatch[1], 10), g: Number.parseInt(rgbMatch[2], 10), b: Number.parseInt(rgbMatch[3], 10), }; } return undefined; } /** Calculate relative luminance of a color (0-1, higher = lighter). */ function getLuminance(r, g, b) { const toLinear = (c) => { const s = c / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; }; return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b); } /** Adjust color brightness. Factor > 1 lightens, < 1 darkens. */ function adjustBrightness(color, factor) { const parsed = parseColor(color); if (!parsed) return color; const adjust = (c) => Math.min(255, Math.max(0, Math.round(c * factor))); return `rgb(${adjust(parsed.r)}, ${adjust(parsed.g)}, ${adjust(parsed.b)})`; } /** Derive export background colors from a base color (e.g., userMessageBg). */ function deriveExportColors(baseColor) { const parsed = parseColor(baseColor); if (!parsed) { return { pageBg: "rgb(24, 24, 30)", cardBg: "rgb(30, 30, 36)", infoBg: "rgb(60, 55, 40)", }; } const luminance = getLuminance(parsed.r, parsed.g, parsed.b); const isLight = luminance > 0.5; if (isLight) { return { pageBg: adjustBrightness(baseColor, 0.96), cardBg: baseColor, infoBg: `rgb(${Math.min(255, parsed.r + 10)}, ${Math.min(255, parsed.g + 5)}, ${Math.max(0, parsed.b - 20)})`, }; } return { pageBg: adjustBrightness(baseColor, 0.7), cardBg: adjustBrightness(baseColor, 0.85), infoBg: `rgb(${Math.min(255, parsed.r + 20)}, ${Math.min(255, parsed.g + 15)}, ${parsed.b})`, }; } /** * Generate CSS custom property declarations from theme colors. */ function generateThemeVars(themeName) { const colors = getResolvedThemeColors(themeName); const lines = []; for (const [key, value] of Object.entries(colors)) { lines.push(`--${key}: ${value};`); } // Use explicit theme export colors if available, otherwise derive from userMessageBg const themeExport = getThemeExportColors(themeName); const userMessageBg = colors.userMessageBg || "#343541"; const derivedColors = deriveExportColors(userMessageBg); lines.push(`--exportPageBg: ${themeExport.pageBg ?? derivedColors.pageBg};`); lines.push(`--exportCardBg: ${themeExport.cardBg ?? derivedColors.cardBg};`); lines.push(`--exportInfoBg: ${themeExport.infoBg ?? derivedColors.infoBg};`); return lines.join("\n "); } /** * Core HTML generation logic shared by both export functions. */ function generateHtml(sessionData, themeName) { const templateDir = getExportTemplateDir(); const template = readFileSync(join(templateDir, "template.html"), "utf-8"); const templateCss = readFileSync(join(templateDir, "template.css"), "utf-8"); const templateJs = readFileSync(join(templateDir, "template.js"), "utf-8"); const markedJs = readFileSync( join(templateDir, "vendor", "marked.min.js"), "utf-8", ); const hljsJs = readFileSync( join(templateDir, "vendor", "highlight.min.js"), "utf-8", ); const themeVars = generateThemeVars(themeName); const colors = getResolvedThemeColors(themeName); const exportColors = deriveExportColors(colors.userMessageBg || "#343541"); const bodyBg = exportColors.pageBg; const containerBg = exportColors.cardBg; const infoBg = exportColors.infoBg; // Base64 encode session data to avoid escaping issues const sessionDataBase64 = Buffer.from(JSON.stringify(sessionData)).toString( "base64", ); // Build the CSS with theme variables injected const css = templateCss .replace("{{THEME_VARS}}", themeVars) .replace("{{BODY_BG}}", bodyBg) .replace("{{CONTAINER_BG}}", containerBg) .replace("{{INFO_BG}}", infoBg); return template .replace("{{CSS}}", css) .replace("{{JS}}", templateJs) .replace("{{SESSION_DATA}}", sessionDataBase64) .replace("{{MARKED_JS}}", markedJs) .replace("{{HIGHLIGHT_JS}}", hljsJs); } /** Built-in tool names that have custom rendering in template.js */ const BUILTIN_TOOLS = new Set([ "bash", "read", "write", "edit", "ls", "find", "grep", ]); /** * Pre-render custom tools to HTML using their TUI renderers. */ function preRenderCustomTools(entries, toolRenderer) { const renderedTools = {}; for (const entry of entries) { if (entry.type !== "message") continue; const msg = entry.message; // Find tool calls in assistant messages if (msg.role === "assistant" && Array.isArray(msg.content)) { for (const block of msg.content) { if (block.type === "toolCall" && !BUILTIN_TOOLS.has(block.name)) { const callHtml = toolRenderer.renderCall(block.name, block.arguments); if (callHtml) { renderedTools[block.id] = { callHtml }; } } } } // Find tool results if (msg.role === "toolResult" && msg.toolCallId) { const toolName = msg.toolName || ""; // Only render if we have a pre-rendered call OR it's not a built-in tool const existing = renderedTools[msg.toolCallId]; if (existing || !BUILTIN_TOOLS.has(toolName)) { const resultHtml = toolRenderer.renderResult( toolName, msg.content, msg.details, msg.isError || false, ); if (resultHtml) { renderedTools[msg.toolCallId] = { ...existing, resultHtml, }; } } } } return renderedTools; } /** * Export session to HTML using SessionManager and AgentState. * Used by TUI's /export command. */ export async function exportSessionToHtml(sm, state, options) { const opts = typeof options === "string" ? { outputPath: options } : options || {}; const sessionFile = sm.getSessionFile(); if (!sessionFile) { throw new Error("Cannot export in-memory session to HTML"); } if (!existsSync(sessionFile)) { throw new Error("Nothing to export yet - start a conversation first"); } const entries = sm.getEntries(); // Pre-render custom tools if a tool renderer is provided let renderedTools; if (opts.toolRenderer) { renderedTools = preRenderCustomTools(entries, opts.toolRenderer); // Only include if we actually rendered something if (Object.keys(renderedTools).length === 0) { renderedTools = undefined; } } const sessionData = { header: sm.getHeader(), entries, leafId: sm.getLeafId(), systemPrompt: state?.systemPrompt, tools: state?.tools?.map((t) => ({ name: t.name, description: t.description, parameters: t.parameters, })), renderedTools, }; const html = generateHtml(sessionData, opts.themeName); let outputPath = opts.outputPath; if (!outputPath) { const sessionBasename = basename(sessionFile, ".jsonl"); outputPath = `${APP_NAME}-session-${sessionBasename}.html`; } writeFileSync(outputPath, html, "utf8"); return outputPath; } /** * Export session file to HTML (standalone, without AgentState). * Used by CLI for exporting arbitrary session files. */ export async function exportFromFile(inputPath, options) { const opts = typeof options === "string" ? { outputPath: options } : options || {}; if (!existsSync(inputPath)) { throw new Error(`File not found: ${inputPath}`); } const sm = SessionManager.open(inputPath); const sessionData = { header: sm.getHeader(), entries: sm.getEntries(), leafId: sm.getLeafId(), systemPrompt: undefined, tools: undefined, }; const html = generateHtml(sessionData, opts.themeName); let outputPath = opts.outputPath; if (!outputPath) { const inputBasename = basename(inputPath, ".jsonl"); outputPath = `${APP_NAME}-session-${inputBasename}.html`; } writeFileSync(outputPath, html, "utf8"); return outputPath; } //# sourceMappingURL=index.js.map