mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 11:02:17 +00:00
feat: Improve HTML export with markdown rendering and theme support
- Render markdown server-side using marked (tables, headings, code blocks, etc.) - Honor user's chosen theme (light/dark) with appropriate color defaults - Add image rendering for user messages - Style code blocks with TUI-like ```language markers
This commit is contained in:
parent
e904b11e7b
commit
531bb91337
4 changed files with 489 additions and 88 deletions
3
package-lock.json
generated
3
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -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<string, string | number>;
|
||||
colors: Record<string, string | number>;
|
||||
}
|
||||
|
||||
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<string, string | number>,
|
||||
defaultValue: string,
|
||||
visited = new Set<string>(),
|
||||
): 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 (
|
||||
'<div class="code-block-wrapper">' +
|
||||
`<div class="code-block-header">\`\`\`${language}</div>` +
|
||||
`<pre><code>${escaped}</code></pre>` +
|
||||
'<div class="code-block-footer">```</div>' +
|
||||
"</div>"
|
||||
);
|
||||
};
|
||||
|
||||
// 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<string, unknown>,
|
||||
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 += `<span class="line-numbers" style="color: ${COLORS.yellow}">:${startLine}${endLine ? `-${endLine}` : ""}</span>`;
|
||||
pathHtml += `<span class="line-numbers">:${startLine}${endLine ? `-${endLine}` : ""}</span>`;
|
||||
}
|
||||
|
||||
html = `<div class="tool-header"><span class="tool-name">read</span> <span class="tool-path">${pathHtml}</span></div>`;
|
||||
|
|
@ -436,7 +688,7 @@ function formatToolExecution(
|
|||
return { html, bgColor };
|
||||
}
|
||||
|
||||
function formatMessage(message: Message, toolResultsMap: Map<string, ToolResultMessage>): string {
|
||||
function formatMessage(message: Message, toolResultsMap: Map<string, ToolResultMessage>, colors: ThemeColors): string {
|
||||
let html = "";
|
||||
const timestamp = (message as { timestamp?: number }).timestamp;
|
||||
const timestampHtml = timestamp ? `<div class="message-timestamp">${formatTimestamp(timestamp)}</div>` : "";
|
||||
|
|
@ -445,9 +697,8 @@ function formatMessage(message: Message, toolResultsMap: Map<string, ToolResultM
|
|||
if (isBashExecutionMessage(message)) {
|
||||
const bashMsg = message as unknown as BashExecutionMessage;
|
||||
const isError = bashMsg.cancelled || (bashMsg.exitCode !== 0 && bashMsg.exitCode !== null);
|
||||
const bgColor = isError ? COLORS.userBashErrorBg : COLORS.userBashBg;
|
||||
|
||||
html += `<div class="tool-execution" style="background-color: ${bgColor}">`;
|
||||
html += `<div class="tool-execution user-bash${isError ? " user-bash-error" : ""}">`;
|
||||
html += timestampHtml;
|
||||
html += `<div class="tool-command">$ ${escapeHtml(bashMsg.command)}</div>`;
|
||||
|
||||
|
|
@ -457,13 +708,13 @@ function formatMessage(message: Message, toolResultsMap: Map<string, ToolResultM
|
|||
}
|
||||
|
||||
if (bashMsg.cancelled) {
|
||||
html += `<div class="bash-status" style="color: ${COLORS.yellow}">(cancelled)</div>`;
|
||||
html += `<div class="bash-status warning">(cancelled)</div>`;
|
||||
} else if (bashMsg.exitCode !== 0 && bashMsg.exitCode !== null) {
|
||||
html += `<div class="bash-status" style="color: ${COLORS.red}">(exit ${bashMsg.exitCode})</div>`;
|
||||
html += `<div class="bash-status error">(exit ${bashMsg.exitCode})</div>`;
|
||||
}
|
||||
|
||||
if (bashMsg.truncated && bashMsg.fullOutputPath) {
|
||||
html += `<div class="bash-truncation" style="color: ${COLORS.yellow}">Output truncated. Full output: ${escapeHtml(bashMsg.fullOutputPath)}</div>`;
|
||||
html += `<div class="bash-truncation warning">Output truncated. Full output: ${escapeHtml(bashMsg.fullOutputPath)}</div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
|
|
@ -473,24 +724,45 @@ function formatMessage(message: Message, toolResultsMap: Map<string, ToolResultM
|
|||
if (message.role === "user") {
|
||||
const userMsg = message as UserMessage;
|
||||
let textContent = "";
|
||||
const images: ImageContent[] = [];
|
||||
|
||||
if (typeof userMsg.content === "string") {
|
||||
textContent = userMsg.content;
|
||||
} else {
|
||||
const textBlocks = userMsg.content.filter((c) => 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 += `<div class="user-message">${timestampHtml}${escapeHtml(textContent).replace(/\n/g, "<br>")}</div>`;
|
||||
html += `<div class="user-message">${timestampHtml}`;
|
||||
|
||||
// Render images first
|
||||
if (images.length > 0) {
|
||||
html += `<div class="message-images">`;
|
||||
for (const img of images) {
|
||||
html += `<img src="data:${img.mimeType};base64,${img.data}" alt="User uploaded image" class="message-image" />`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// Render text as markdown (server-side)
|
||||
if (textContent.trim()) {
|
||||
html += `<div class="markdown-content">${renderMarkdown(textContent)}</div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
} else if (message.role === "assistant") {
|
||||
const assistantMsg = message as AssistantMessage;
|
||||
html += timestampHtml ? `<div class="assistant-message">${timestampHtml}` : "";
|
||||
|
||||
for (const content of assistantMsg.content) {
|
||||
if (content.type === "text" && content.text.trim()) {
|
||||
html += `<div class="assistant-text">${escapeHtml(content.text.trim()).replace(/\n/g, "<br>")}</div>`;
|
||||
// Render markdown server-side
|
||||
html += `<div class="assistant-text markdown-content">${renderMarkdown(content.text)}</div>`;
|
||||
} else if (content.type === "thinking" && content.thinking.trim()) {
|
||||
html += `<div class="thinking-text">${escapeHtml(content.thinking.trim()).replace(/\n/g, "<br>")}</div>`;
|
||||
}
|
||||
|
|
@ -503,6 +775,7 @@ function formatMessage(message: Message, toolResultsMap: Map<string, ToolResultM
|
|||
content.name,
|
||||
content.arguments as Record<string, unknown>,
|
||||
toolResult,
|
||||
colors,
|
||||
);
|
||||
html += `<div class="tool-execution" style="background-color: ${bgColor}">${toolHtml}</div>`;
|
||||
}
|
||||
|
|
@ -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 `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
|
@ -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; } }
|
||||
</style>
|
||||
</head>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue