@mariozechner/pi-coding-agent 0.27.4 → 0.27.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,27 +1,192 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "fs";
2
+ import hljs from "highlight.js";
3
+ import { marked } from "marked";
2
4
  import { homedir } from "os";
5
+ import * as path from "path";
3
6
  import { basename } from "path";
4
- import { APP_NAME, VERSION } from "../config.js";
7
+ import { APP_NAME, getCustomThemesDir, getThemesDir, VERSION } from "../config.js";
5
8
  import { isBashExecutionMessage } from "./messages.js";
6
- // ============================================================================
7
- // Color scheme (matching TUI)
8
- // ============================================================================
9
- const COLORS = {
10
- userMessageBg: "rgb(52, 53, 65)",
11
- toolPendingBg: "rgb(40, 40, 50)",
12
- toolSuccessBg: "rgb(40, 50, 40)",
13
- toolErrorBg: "rgb(60, 40, 40)",
14
- userBashBg: "rgb(50, 48, 35)", // Faint yellow/brown for user-executed bash
15
- userBashErrorBg: "rgb(60, 45, 35)", // Slightly more orange for errors
16
- bodyBg: "rgb(24, 24, 30)",
17
- containerBg: "rgb(30, 30, 36)",
18
- text: "rgb(229, 229, 231)",
19
- textDim: "rgb(161, 161, 170)",
20
- cyan: "rgb(103, 232, 249)",
21
- green: "rgb(34, 197, 94)",
22
- red: "rgb(239, 68, 68)",
23
- yellow: "rgb(234, 179, 8)",
24
- };
9
+ /** Resolve a theme color value, following variable references until we get a final value. */
10
+ function resolveColorValue(value, vars, defaultValue, visited = new Set()) {
11
+ if (value === "")
12
+ return defaultValue;
13
+ if (typeof value !== "string")
14
+ return defaultValue;
15
+ if (visited.has(value))
16
+ return defaultValue;
17
+ if (!(value in vars))
18
+ return value; // Return as-is (hex colors work in CSS)
19
+ visited.add(value);
20
+ return resolveColorValue(vars[value], vars, defaultValue, visited);
21
+ }
22
+ /** Load theme JSON from built-in or custom themes directory. */
23
+ function loadThemeJson(name) {
24
+ // Try built-in themes first
25
+ const themesDir = getThemesDir();
26
+ const builtinPath = path.join(themesDir, `${name}.json`);
27
+ if (existsSync(builtinPath)) {
28
+ try {
29
+ return JSON.parse(readFileSync(builtinPath, "utf-8"));
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ // Try custom themes
36
+ const customThemesDir = getCustomThemesDir();
37
+ const customPath = path.join(customThemesDir, `${name}.json`);
38
+ if (existsSync(customPath)) {
39
+ try {
40
+ return JSON.parse(readFileSync(customPath, "utf-8"));
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ return null;
47
+ }
48
+ /** Build complete theme colors object, resolving theme JSON values against defaults. */
49
+ function getThemeColors(themeName) {
50
+ const isLight = isLightTheme(themeName);
51
+ // Default colors based on theme type
52
+ const defaultColors = isLight
53
+ ? {
54
+ // Light theme defaults
55
+ accent: "rgb(95, 135, 135)",
56
+ border: "rgb(95, 135, 175)",
57
+ borderAccent: "rgb(95, 135, 135)",
58
+ success: "rgb(135, 175, 135)",
59
+ error: "rgb(175, 95, 95)",
60
+ warning: "rgb(215, 175, 95)",
61
+ muted: "rgb(108, 108, 108)",
62
+ dim: "rgb(138, 138, 138)",
63
+ text: "rgb(0, 0, 0)",
64
+ userMessageBg: "rgb(232, 232, 232)",
65
+ userMessageText: "rgb(0, 0, 0)",
66
+ toolPendingBg: "rgb(232, 232, 240)",
67
+ toolSuccessBg: "rgb(232, 240, 232)",
68
+ toolErrorBg: "rgb(240, 232, 232)",
69
+ toolOutput: "rgb(108, 108, 108)",
70
+ mdHeading: "rgb(215, 175, 95)",
71
+ mdLink: "rgb(95, 135, 175)",
72
+ mdLinkUrl: "rgb(138, 138, 138)",
73
+ mdCode: "rgb(95, 135, 135)",
74
+ mdCodeBlock: "rgb(135, 175, 135)",
75
+ mdCodeBlockBorder: "rgb(108, 108, 108)",
76
+ mdQuote: "rgb(108, 108, 108)",
77
+ mdQuoteBorder: "rgb(108, 108, 108)",
78
+ mdHr: "rgb(108, 108, 108)",
79
+ mdListBullet: "rgb(135, 175, 135)",
80
+ toolDiffAdded: "rgb(135, 175, 135)",
81
+ toolDiffRemoved: "rgb(175, 95, 95)",
82
+ toolDiffContext: "rgb(108, 108, 108)",
83
+ syntaxComment: "rgb(0, 128, 0)",
84
+ syntaxKeyword: "rgb(0, 0, 255)",
85
+ syntaxFunction: "rgb(121, 94, 38)",
86
+ syntaxVariable: "rgb(0, 16, 128)",
87
+ syntaxString: "rgb(163, 21, 21)",
88
+ syntaxNumber: "rgb(9, 134, 88)",
89
+ syntaxType: "rgb(38, 127, 153)",
90
+ syntaxOperator: "rgb(0, 0, 0)",
91
+ syntaxPunctuation: "rgb(0, 0, 0)",
92
+ }
93
+ : {
94
+ // Dark theme defaults
95
+ accent: "rgb(138, 190, 183)",
96
+ border: "rgb(95, 135, 255)",
97
+ borderAccent: "rgb(0, 215, 255)",
98
+ success: "rgb(181, 189, 104)",
99
+ error: "rgb(204, 102, 102)",
100
+ warning: "rgb(255, 255, 0)",
101
+ muted: "rgb(128, 128, 128)",
102
+ dim: "rgb(102, 102, 102)",
103
+ text: "rgb(229, 229, 231)",
104
+ userMessageBg: "rgb(52, 53, 65)",
105
+ userMessageText: "rgb(229, 229, 231)",
106
+ toolPendingBg: "rgb(40, 40, 50)",
107
+ toolSuccessBg: "rgb(40, 50, 40)",
108
+ toolErrorBg: "rgb(60, 40, 40)",
109
+ toolOutput: "rgb(128, 128, 128)",
110
+ mdHeading: "rgb(240, 198, 116)",
111
+ mdLink: "rgb(129, 162, 190)",
112
+ mdLinkUrl: "rgb(102, 102, 102)",
113
+ mdCode: "rgb(138, 190, 183)",
114
+ mdCodeBlock: "rgb(181, 189, 104)",
115
+ mdCodeBlockBorder: "rgb(128, 128, 128)",
116
+ mdQuote: "rgb(128, 128, 128)",
117
+ mdQuoteBorder: "rgb(128, 128, 128)",
118
+ mdHr: "rgb(128, 128, 128)",
119
+ mdListBullet: "rgb(138, 190, 183)",
120
+ toolDiffAdded: "rgb(181, 189, 104)",
121
+ toolDiffRemoved: "rgb(204, 102, 102)",
122
+ toolDiffContext: "rgb(128, 128, 128)",
123
+ syntaxComment: "rgb(106, 153, 85)",
124
+ syntaxKeyword: "rgb(86, 156, 214)",
125
+ syntaxFunction: "rgb(220, 220, 170)",
126
+ syntaxVariable: "rgb(156, 220, 254)",
127
+ syntaxString: "rgb(206, 145, 120)",
128
+ syntaxNumber: "rgb(181, 206, 168)",
129
+ syntaxType: "rgb(78, 201, 176)",
130
+ syntaxOperator: "rgb(212, 212, 212)",
131
+ syntaxPunctuation: "rgb(212, 212, 212)",
132
+ };
133
+ if (!themeName)
134
+ return defaultColors;
135
+ const themeJson = loadThemeJson(themeName);
136
+ if (!themeJson)
137
+ return defaultColors;
138
+ const vars = themeJson.vars || {};
139
+ const colors = themeJson.colors;
140
+ const resolve = (key) => {
141
+ const value = colors[key];
142
+ if (value === undefined)
143
+ return defaultColors[key];
144
+ return resolveColorValue(value, vars, defaultColors[key]);
145
+ };
146
+ return {
147
+ accent: resolve("accent"),
148
+ border: resolve("border"),
149
+ borderAccent: resolve("borderAccent"),
150
+ success: resolve("success"),
151
+ error: resolve("error"),
152
+ warning: resolve("warning"),
153
+ muted: resolve("muted"),
154
+ dim: resolve("dim"),
155
+ text: resolve("text"),
156
+ userMessageBg: resolve("userMessageBg"),
157
+ userMessageText: resolve("userMessageText"),
158
+ toolPendingBg: resolve("toolPendingBg"),
159
+ toolSuccessBg: resolve("toolSuccessBg"),
160
+ toolErrorBg: resolve("toolErrorBg"),
161
+ toolOutput: resolve("toolOutput"),
162
+ mdHeading: resolve("mdHeading"),
163
+ mdLink: resolve("mdLink"),
164
+ mdLinkUrl: resolve("mdLinkUrl"),
165
+ mdCode: resolve("mdCode"),
166
+ mdCodeBlock: resolve("mdCodeBlock"),
167
+ mdCodeBlockBorder: resolve("mdCodeBlockBorder"),
168
+ mdQuote: resolve("mdQuote"),
169
+ mdQuoteBorder: resolve("mdQuoteBorder"),
170
+ mdHr: resolve("mdHr"),
171
+ mdListBullet: resolve("mdListBullet"),
172
+ toolDiffAdded: resolve("toolDiffAdded"),
173
+ toolDiffRemoved: resolve("toolDiffRemoved"),
174
+ toolDiffContext: resolve("toolDiffContext"),
175
+ syntaxComment: resolve("syntaxComment"),
176
+ syntaxKeyword: resolve("syntaxKeyword"),
177
+ syntaxFunction: resolve("syntaxFunction"),
178
+ syntaxVariable: resolve("syntaxVariable"),
179
+ syntaxString: resolve("syntaxString"),
180
+ syntaxNumber: resolve("syntaxNumber"),
181
+ syntaxType: resolve("syntaxType"),
182
+ syntaxOperator: resolve("syntaxOperator"),
183
+ syntaxPunctuation: resolve("syntaxPunctuation"),
184
+ };
185
+ }
186
+ /** Check if theme is a light theme (currently only matches "light" exactly). */
187
+ function isLightTheme(themeName) {
188
+ return themeName === "light";
189
+ }
25
190
  // ============================================================================
26
191
  // Utility functions
27
192
  // ============================================================================
@@ -46,9 +211,152 @@ function formatTimestamp(timestamp) {
46
211
  const date = new Date(typeof timestamp === "string" ? timestamp : timestamp);
47
212
  return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
48
213
  }
49
- function formatExpandableOutput(lines, maxLines) {
214
+ /** Highlight code using highlight.js. Returns HTML with syntax highlighting spans. */
215
+ function highlightCode(code, lang) {
216
+ if (!lang) {
217
+ return escapeHtml(code);
218
+ }
219
+ try {
220
+ // Check if language is supported
221
+ if (hljs.getLanguage(lang)) {
222
+ return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value;
223
+ }
224
+ // Try common aliases
225
+ const aliases = {
226
+ ts: "typescript",
227
+ js: "javascript",
228
+ py: "python",
229
+ rb: "ruby",
230
+ sh: "bash",
231
+ yml: "yaml",
232
+ md: "markdown",
233
+ };
234
+ const aliasedLang = aliases[lang];
235
+ if (aliasedLang && hljs.getLanguage(aliasedLang)) {
236
+ return hljs.highlight(code, { language: aliasedLang, ignoreIllegals: true }).value;
237
+ }
238
+ }
239
+ catch {
240
+ // Fall through to escaped output
241
+ }
242
+ return escapeHtml(code);
243
+ }
244
+ /** Get language from file path extension. */
245
+ function getLanguageFromPath(filePath) {
246
+ const ext = filePath.split(".").pop()?.toLowerCase();
247
+ if (!ext)
248
+ return undefined;
249
+ const extToLang = {
250
+ ts: "typescript",
251
+ tsx: "typescript",
252
+ js: "javascript",
253
+ jsx: "javascript",
254
+ mjs: "javascript",
255
+ cjs: "javascript",
256
+ py: "python",
257
+ rb: "ruby",
258
+ rs: "rust",
259
+ go: "go",
260
+ java: "java",
261
+ kt: "kotlin",
262
+ swift: "swift",
263
+ c: "c",
264
+ h: "c",
265
+ cpp: "cpp",
266
+ cc: "cpp",
267
+ cxx: "cpp",
268
+ hpp: "cpp",
269
+ cs: "csharp",
270
+ php: "php",
271
+ sh: "bash",
272
+ bash: "bash",
273
+ zsh: "bash",
274
+ fish: "bash",
275
+ ps1: "powershell",
276
+ sql: "sql",
277
+ html: "html",
278
+ htm: "html",
279
+ xml: "xml",
280
+ css: "css",
281
+ scss: "scss",
282
+ sass: "scss",
283
+ less: "less",
284
+ json: "json",
285
+ yaml: "yaml",
286
+ yml: "yaml",
287
+ toml: "toml",
288
+ ini: "ini",
289
+ md: "markdown",
290
+ markdown: "markdown",
291
+ dockerfile: "dockerfile",
292
+ makefile: "makefile",
293
+ cmake: "cmake",
294
+ lua: "lua",
295
+ r: "r",
296
+ scala: "scala",
297
+ clj: "clojure",
298
+ cljs: "clojure",
299
+ ex: "elixir",
300
+ exs: "elixir",
301
+ erl: "erlang",
302
+ hrl: "erlang",
303
+ hs: "haskell",
304
+ ml: "ocaml",
305
+ mli: "ocaml",
306
+ fs: "fsharp",
307
+ fsx: "fsharp",
308
+ vue: "vue",
309
+ svelte: "xml",
310
+ tf: "hcl",
311
+ hcl: "hcl",
312
+ proto: "protobuf",
313
+ graphql: "graphql",
314
+ gql: "graphql",
315
+ };
316
+ return extToLang[ext];
317
+ }
318
+ /** Render markdown to HTML server-side with TUI-style code block formatting and syntax highlighting. */
319
+ function renderMarkdown(text) {
320
+ // Custom renderer for code blocks to match TUI style
321
+ const renderer = new marked.Renderer();
322
+ renderer.code = ({ text: code, lang }) => {
323
+ const language = lang || "";
324
+ const highlighted = highlightCode(code, lang);
325
+ return ('<div class="code-block-wrapper">' +
326
+ `<div class="code-block-header">\`\`\`${language}</div>` +
327
+ `<pre><code class="hljs">${highlighted}</code></pre>` +
328
+ '<div class="code-block-footer">```</div>' +
329
+ "</div>");
330
+ };
331
+ // Configure marked for safe rendering
332
+ marked.setOptions({
333
+ breaks: true,
334
+ gfm: true,
335
+ });
336
+ // Parse markdown (marked escapes HTML by default in newer versions)
337
+ return marked.parse(text, { renderer });
338
+ }
339
+ function formatExpandableOutput(lines, maxLines, lang) {
50
340
  const displayLines = lines.slice(0, maxLines);
51
341
  const remaining = lines.length - maxLines;
342
+ // If language is provided, highlight the entire code block
343
+ if (lang) {
344
+ const code = lines.join("\n");
345
+ const highlighted = highlightCode(code, lang);
346
+ if (remaining > 0) {
347
+ // For expandable, we need preview and full versions
348
+ const previewCode = displayLines.join("\n");
349
+ const previewHighlighted = highlightCode(previewCode, lang);
350
+ let out = '<div class="tool-output expandable" onclick="this.classList.toggle(\'expanded\')">';
351
+ out += `<div class="output-preview"><pre><code class="hljs">${previewHighlighted}</code></pre>`;
352
+ out += `<div class="expand-hint">... (${remaining} more lines) - click to expand</div>`;
353
+ out += "</div>";
354
+ out += `<div class="output-full"><pre><code class="hljs">${highlighted}</code></pre></div></div>`;
355
+ return out;
356
+ }
357
+ return `<div class="tool-output"><pre><code class="hljs">${highlighted}</code></pre></div>`;
358
+ }
359
+ // No language - plain text output
52
360
  if (remaining > 0) {
53
361
  let out = '<div class="tool-output expandable" onclick="this.classList.toggle(\'expanded\')">';
54
362
  out += '<div class="output-preview">';
@@ -250,10 +558,10 @@ function parseSessionFile(content) {
250
558
  // ============================================================================
251
559
  // HTML formatting functions
252
560
  // ============================================================================
253
- function formatToolExecution(toolName, args, result) {
561
+ function formatToolExecution(toolName, args, result, colors) {
254
562
  let html = "";
255
563
  const isError = result?.isError || false;
256
- const bgColor = result ? (isError ? COLORS.toolErrorBg : COLORS.toolSuccessBg) : COLORS.toolPendingBg;
564
+ const bgColor = result ? (isError ? colors.toolErrorBg : colors.toolSuccessBg) : colors.toolPendingBg;
257
565
  const getTextOutput = () => {
258
566
  if (!result)
259
567
  return "";
@@ -273,36 +581,40 @@ function formatToolExecution(toolName, args, result) {
273
581
  break;
274
582
  }
275
583
  case "read": {
276
- const path = shortenPath(args?.file_path || args?.path || "");
584
+ const filePath = args?.file_path || args?.path || "";
585
+ const shortenedPath = shortenPath(filePath);
277
586
  const offset = args?.offset;
278
587
  const limit = args?.limit;
279
- // Build path display with offset/limit suffix (in yellow color if offset/limit used)
280
- let pathHtml = escapeHtml(path || "...");
588
+ const lang = getLanguageFromPath(filePath);
589
+ // Build path display with offset/limit suffix
590
+ let pathHtml = escapeHtml(shortenedPath || "...");
281
591
  if (offset !== undefined || limit !== undefined) {
282
592
  const startLine = offset ?? 1;
283
593
  const endLine = limit !== undefined ? startLine + limit - 1 : "";
284
- pathHtml += `<span class="line-numbers" style="color: ${COLORS.yellow}">:${startLine}${endLine ? `-${endLine}` : ""}</span>`;
594
+ pathHtml += `<span class="line-numbers">:${startLine}${endLine ? `-${endLine}` : ""}</span>`;
285
595
  }
286
596
  html = `<div class="tool-header"><span class="tool-name">read</span> <span class="tool-path">${pathHtml}</span></div>`;
287
597
  if (result) {
288
598
  const output = getTextOutput();
289
599
  if (output) {
290
- html += formatExpandableOutput(output.split("\n"), 10);
600
+ html += formatExpandableOutput(output.split("\n"), 10, lang);
291
601
  }
292
602
  }
293
603
  break;
294
604
  }
295
605
  case "write": {
296
- const path = shortenPath(args?.file_path || args?.path || "");
606
+ const filePath = args?.file_path || args?.path || "";
607
+ const shortenedPath = shortenPath(filePath);
297
608
  const fileContent = args?.content || "";
298
609
  const lines = fileContent ? fileContent.split("\n") : [];
299
- html = `<div class="tool-header"><span class="tool-name">write</span> <span class="tool-path">${escapeHtml(path || "...")}</span>`;
610
+ const lang = getLanguageFromPath(filePath);
611
+ html = `<div class="tool-header"><span class="tool-name">write</span> <span class="tool-path">${escapeHtml(shortenedPath || "...")}</span>`;
300
612
  if (lines.length > 10) {
301
613
  html += ` <span class="line-count">(${lines.length} lines)</span>`;
302
614
  }
303
615
  html += "</div>";
304
616
  if (fileContent) {
305
- html += formatExpandableOutput(lines, 10);
617
+ html += formatExpandableOutput(lines, 10, lang);
306
618
  }
307
619
  if (result) {
308
620
  const output = getTextOutput().trim();
@@ -352,7 +664,7 @@ function formatToolExecution(toolName, args, result) {
352
664
  }
353
665
  return { html, bgColor };
354
666
  }
355
- function formatMessage(message, toolResultsMap) {
667
+ function formatMessage(message, toolResultsMap, colors) {
356
668
  let html = "";
357
669
  const timestamp = message.timestamp;
358
670
  const timestampHtml = timestamp ? `<div class="message-timestamp">${formatTimestamp(timestamp)}</div>` : "";
@@ -360,8 +672,7 @@ function formatMessage(message, toolResultsMap) {
360
672
  if (isBashExecutionMessage(message)) {
361
673
  const bashMsg = message;
362
674
  const isError = bashMsg.cancelled || (bashMsg.exitCode !== 0 && bashMsg.exitCode !== null);
363
- const bgColor = isError ? COLORS.userBashErrorBg : COLORS.userBashBg;
364
- html += `<div class="tool-execution" style="background-color: ${bgColor}">`;
675
+ html += `<div class="tool-execution user-bash${isError ? " user-bash-error" : ""}">`;
365
676
  html += timestampHtml;
366
677
  html += `<div class="tool-command">$ ${escapeHtml(bashMsg.command)}</div>`;
367
678
  if (bashMsg.output) {
@@ -369,13 +680,13 @@ function formatMessage(message, toolResultsMap) {
369
680
  html += formatExpandableOutput(lines, 10);
370
681
  }
371
682
  if (bashMsg.cancelled) {
372
- html += `<div class="bash-status" style="color: ${COLORS.yellow}">(cancelled)</div>`;
683
+ html += `<div class="bash-status warning">(cancelled)</div>`;
373
684
  }
374
685
  else if (bashMsg.exitCode !== 0 && bashMsg.exitCode !== null) {
375
- html += `<div class="bash-status" style="color: ${COLORS.red}">(exit ${bashMsg.exitCode})</div>`;
686
+ html += `<div class="bash-status error">(exit ${bashMsg.exitCode})</div>`;
376
687
  }
377
688
  if (bashMsg.truncated && bashMsg.fullOutputPath) {
378
- html += `<div class="bash-truncation" style="color: ${COLORS.yellow}">Output truncated. Full output: ${escapeHtml(bashMsg.fullOutputPath)}</div>`;
689
+ html += `<div class="bash-truncation warning">Output truncated. Full output: ${escapeHtml(bashMsg.fullOutputPath)}</div>`;
379
690
  }
380
691
  html += `</div>`;
381
692
  return html;
@@ -383,23 +694,42 @@ function formatMessage(message, toolResultsMap) {
383
694
  if (message.role === "user") {
384
695
  const userMsg = message;
385
696
  let textContent = "";
697
+ const images = [];
386
698
  if (typeof userMsg.content === "string") {
387
699
  textContent = userMsg.content;
388
700
  }
389
701
  else {
390
- const textBlocks = userMsg.content.filter((c) => c.type === "text");
391
- textContent = textBlocks.map((c) => c.text).join("");
702
+ for (const block of userMsg.content) {
703
+ if (block.type === "text") {
704
+ textContent += block.text;
705
+ }
706
+ else if (block.type === "image") {
707
+ images.push(block);
708
+ }
709
+ }
392
710
  }
711
+ html += `<div class="user-message">${timestampHtml}`;
712
+ // Render images first
713
+ if (images.length > 0) {
714
+ html += `<div class="message-images">`;
715
+ for (const img of images) {
716
+ html += `<img src="data:${img.mimeType};base64,${img.data}" alt="User uploaded image" class="message-image" />`;
717
+ }
718
+ html += `</div>`;
719
+ }
720
+ // Render text as markdown (server-side)
393
721
  if (textContent.trim()) {
394
- html += `<div class="user-message">${timestampHtml}${escapeHtml(textContent).replace(/\n/g, "<br>")}</div>`;
722
+ html += `<div class="markdown-content">${renderMarkdown(textContent)}</div>`;
395
723
  }
724
+ html += `</div>`;
396
725
  }
397
726
  else if (message.role === "assistant") {
398
727
  const assistantMsg = message;
399
728
  html += timestampHtml ? `<div class="assistant-message">${timestampHtml}` : "";
400
729
  for (const content of assistantMsg.content) {
401
730
  if (content.type === "text" && content.text.trim()) {
402
- html += `<div class="assistant-text">${escapeHtml(content.text.trim()).replace(/\n/g, "<br>")}</div>`;
731
+ // Render markdown server-side
732
+ html += `<div class="assistant-text markdown-content">${renderMarkdown(content.text)}</div>`;
403
733
  }
404
734
  else if (content.type === "thinking" && content.thinking.trim()) {
405
735
  html += `<div class="thinking-text">${escapeHtml(content.thinking.trim()).replace(/\n/g, "<br>")}</div>`;
@@ -408,7 +738,7 @@ function formatMessage(message, toolResultsMap) {
408
738
  for (const content of assistantMsg.content) {
409
739
  if (content.type === "toolCall") {
410
740
  const toolResult = toolResultsMap.get(content.id);
411
- const { html: toolHtml, bgColor } = formatToolExecution(content.name, content.arguments, toolResult);
741
+ const { html: toolHtml, bgColor } = formatToolExecution(content.name, content.arguments, toolResult, colors);
412
742
  html += `<div class="tool-execution" style="background-color: ${bgColor}">${toolHtml}</div>`;
413
743
  }
414
744
  }
@@ -457,7 +787,7 @@ function formatCompaction(event) {
457
787
  // ============================================================================
458
788
  // HTML generation
459
789
  // ============================================================================
460
- function generateHtml(data, filename) {
790
+ function generateHtml(data, filename, colors, isLight) {
461
791
  const userMessages = data.messages.filter((m) => m.role === "user").length;
462
792
  const assistantMessages = data.messages.filter((m) => m.role === "assistant").length;
463
793
  let toolCallsCount = 0;
@@ -486,7 +816,7 @@ function generateHtml(data, filename) {
486
816
  switch (event.type) {
487
817
  case "message":
488
818
  if (event.message.role !== "toolResult") {
489
- messagesHtml += formatMessage(event.message, data.toolResultsMap);
819
+ messagesHtml += formatMessage(event.message, data.toolResultsMap, colors);
490
820
  }
491
821
  break;
492
822
  case "model_change":
@@ -519,6 +849,15 @@ function generateHtml(data, filename) {
519
849
  const contextUsageText = contextPercent
520
850
  ? `${contextTokens.toLocaleString()} / ${contextWindow.toLocaleString()} tokens (${contextPercent}%) - ${escapeHtml(lastModelInfo)}`
521
851
  : `${contextTokens.toLocaleString()} tokens (last turn) - ${escapeHtml(lastModelInfo)}`;
852
+ // Compute body background based on theme
853
+ const bodyBg = isLight ? "rgb(248, 248, 248)" : "rgb(24, 24, 30)";
854
+ const containerBg = isLight ? "rgb(255, 255, 255)" : "rgb(30, 30, 36)";
855
+ const compactionBg = isLight ? "rgb(255, 248, 220)" : "rgb(60, 55, 35)";
856
+ const systemPromptBg = isLight ? "rgb(255, 250, 230)" : "rgb(60, 55, 40)";
857
+ const streamingNoticeBg = isLight ? "rgb(250, 245, 235)" : "rgb(50, 45, 35)";
858
+ const modelChangeBg = isLight ? "rgb(240, 240, 250)" : "rgb(40, 40, 50)";
859
+ const userBashBg = isLight ? "rgb(255, 250, 240)" : "rgb(50, 48, 35)";
860
+ const userBashErrorBg = isLight ? "rgb(255, 245, 235)" : "rgb(60, 45, 35)";
522
861
  return `<!DOCTYPE html>
523
862
  <html lang="en">
524
863
  <head>
@@ -531,72 +870,68 @@ function generateHtml(data, filename) {
531
870
  font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
532
871
  font-size: 12px;
533
872
  line-height: 1.6;
534
- color: ${COLORS.text};
535
- background: ${COLORS.bodyBg};
873
+ color: ${colors.text};
874
+ background: ${bodyBg};
536
875
  padding: 24px;
537
876
  }
538
877
  .container { max-width: 700px; margin: 0 auto; }
539
878
  .header {
540
879
  margin-bottom: 24px;
541
880
  padding: 16px;
542
- background: ${COLORS.containerBg};
881
+ background: ${containerBg};
543
882
  border-radius: 4px;
544
883
  }
545
884
  .header h1 {
546
885
  font-size: 14px;
547
886
  font-weight: bold;
548
887
  margin-bottom: 12px;
549
- color: ${COLORS.cyan};
888
+ color: ${colors.borderAccent};
550
889
  }
551
890
  .header-info { display: flex; flex-direction: column; gap: 3px; font-size: 11px; }
552
- .info-item { color: ${COLORS.textDim}; display: flex; align-items: baseline; }
891
+ .info-item { color: ${colors.dim}; display: flex; align-items: baseline; }
553
892
  .info-label { font-weight: 600; margin-right: 8px; min-width: 100px; }
554
- .info-value { color: ${COLORS.text}; flex: 1; }
893
+ .info-value { color: ${colors.text}; flex: 1; }
555
894
  .info-value.cost { font-family: 'SF Mono', monospace; }
556
895
  .messages { display: flex; flex-direction: column; gap: 16px; }
557
- .message-timestamp { font-size: 10px; color: ${COLORS.textDim}; margin-bottom: 4px; opacity: 0.8; }
896
+ .message-timestamp { font-size: 10px; color: ${colors.dim}; margin-bottom: 4px; opacity: 0.8; }
558
897
  .user-message {
559
- background: ${COLORS.userMessageBg};
898
+ background: ${colors.userMessageBg};
899
+ color: ${colors.userMessageText};
560
900
  padding: 12px 16px;
561
901
  border-radius: 4px;
562
- white-space: pre-wrap;
563
- word-wrap: break-word;
564
- overflow-wrap: break-word;
565
- word-break: break-word;
566
902
  }
567
903
  .assistant-message { padding: 0; }
568
904
  .assistant-text, .thinking-text {
569
905
  padding: 12px 16px;
570
- white-space: pre-wrap;
571
- word-wrap: break-word;
572
- overflow-wrap: break-word;
573
- word-break: break-word;
574
906
  }
575
- .thinking-text { color: ${COLORS.textDim}; font-style: italic; }
576
- .model-change { padding: 8px 16px; background: rgb(40, 40, 50); border-radius: 4px; }
577
- .model-change-text { color: ${COLORS.textDim}; font-size: 11px; }
578
- .model-name { color: ${COLORS.cyan}; font-weight: bold; }
579
- .compaction-container { background: rgb(60, 55, 35); border-radius: 4px; overflow: hidden; }
907
+ .thinking-text { color: ${colors.dim}; font-style: italic; white-space: pre-wrap; }
908
+ .model-change { padding: 8px 16px; background: ${modelChangeBg}; border-radius: 4px; }
909
+ .model-change-text { color: ${colors.dim}; font-size: 11px; }
910
+ .model-name { color: ${colors.borderAccent}; font-weight: bold; }
911
+ .compaction-container { background: ${compactionBg}; border-radius: 4px; overflow: hidden; }
580
912
  .compaction-header { padding: 12px 16px; cursor: pointer; }
581
- .compaction-header:hover { background: rgba(255, 255, 255, 0.05); }
913
+ .compaction-header:hover { background: rgba(${isLight ? "0, 0, 0" : "255, 255, 255"}, 0.05); }
582
914
  .compaction-header-row { display: flex; align-items: center; gap: 8px; }
583
- .compaction-toggle { color: ${COLORS.cyan}; font-size: 10px; transition: transform 0.2s; }
915
+ .compaction-toggle { color: ${colors.borderAccent}; font-size: 10px; transition: transform 0.2s; }
584
916
  .compaction-container.expanded .compaction-toggle { transform: rotate(90deg); }
585
- .compaction-title { color: ${COLORS.text}; font-weight: bold; }
586
- .compaction-hint { color: ${COLORS.textDim}; font-size: 11px; }
917
+ .compaction-title { color: ${colors.text}; font-weight: bold; }
918
+ .compaction-hint { color: ${colors.dim}; font-size: 11px; }
587
919
  .compaction-content { display: none; padding: 0 16px 16px 16px; }
588
920
  .compaction-container.expanded .compaction-content { display: block; }
589
- .compaction-summary { background: rgba(0, 0, 0, 0.2); border-radius: 4px; padding: 12px; }
590
- .compaction-summary-header { font-weight: bold; color: ${COLORS.cyan}; margin-bottom: 8px; font-size: 11px; }
591
- .compaction-summary-content { color: ${COLORS.text}; white-space: pre-wrap; word-wrap: break-word; }
921
+ .compaction-summary { background: rgba(0, 0, 0, 0.1); border-radius: 4px; padding: 12px; }
922
+ .compaction-summary-header { font-weight: bold; color: ${colors.borderAccent}; margin-bottom: 8px; font-size: 11px; }
923
+ .compaction-summary-content { color: ${colors.text}; white-space: pre-wrap; word-wrap: break-word; }
592
924
  .tool-execution { padding: 12px 16px; border-radius: 4px; margin-top: 8px; }
925
+ .tool-execution.user-bash { background: ${userBashBg}; }
926
+ .tool-execution.user-bash-error { background: ${userBashErrorBg}; }
593
927
  .tool-header, .tool-name { font-weight: bold; }
594
- .tool-path { color: ${COLORS.cyan}; word-break: break-all; }
595
- .line-count { color: ${COLORS.textDim}; }
928
+ .tool-path { color: ${colors.borderAccent}; word-break: break-all; }
929
+ .line-numbers { color: ${colors.warning}; }
930
+ .line-count { color: ${colors.dim}; }
596
931
  .tool-command { font-weight: bold; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
597
932
  .tool-output {
598
933
  margin-top: 12px;
599
- color: ${COLORS.textDim};
934
+ color: ${colors.toolOutput};
600
935
  white-space: pre-wrap;
601
936
  word-wrap: break-word;
602
937
  overflow-wrap: break-word;
@@ -611,19 +946,126 @@ function generateHtml(data, filename) {
611
946
  .tool-output.expandable .output-full { display: none; }
612
947
  .tool-output.expandable.expanded .output-preview { display: none; }
613
948
  .tool-output.expandable.expanded .output-full { display: block; }
614
- .expand-hint { color: ${COLORS.cyan}; font-style: italic; margin-top: 4px; }
615
- .system-prompt, .tools-list { background: rgb(60, 55, 40); padding: 12px 16px; border-radius: 4px; margin-bottom: 16px; }
616
- .system-prompt-header, .tools-header { font-weight: bold; color: ${COLORS.yellow}; margin-bottom: 8px; }
617
- .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; }
949
+ .expand-hint { color: ${colors.borderAccent}; font-style: italic; margin-top: 4px; }
950
+ .system-prompt, .tools-list { background: ${systemPromptBg}; padding: 12px 16px; border-radius: 4px; margin-bottom: 16px; }
951
+ .system-prompt-header, .tools-header { font-weight: bold; color: ${colors.warning}; margin-bottom: 8px; }
952
+ .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; }
618
953
  .tool-item { margin: 4px 0; }
619
- .tool-item-name { font-weight: bold; color: ${COLORS.text}; }
954
+ .tool-item-name { font-weight: bold; color: ${colors.text}; }
620
955
  .tool-diff { margin-top: 12px; font-size: 11px; font-family: inherit; overflow-x: auto; max-width: 100%; }
621
- .diff-line-old { color: ${COLORS.red}; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; }
622
- .diff-line-new { color: ${COLORS.green}; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; }
623
- .diff-line-context { color: ${COLORS.textDim}; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; }
624
- .error-text { color: ${COLORS.red}; padding: 12px 16px; }
625
- .footer { margin-top: 48px; padding: 20px; text-align: center; color: ${COLORS.textDim}; font-size: 10px; }
626
- .streaming-notice { background: rgb(50, 45, 35); padding: 12px 16px; border-radius: 4px; margin-bottom: 16px; color: ${COLORS.textDim}; font-size: 11px; }
956
+ .diff-line-old { color: ${colors.toolDiffRemoved}; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; }
957
+ .diff-line-new { color: ${colors.toolDiffAdded}; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; }
958
+ .diff-line-context { color: ${colors.toolDiffContext}; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; }
959
+ .error-text { color: ${colors.error}; padding: 12px 16px; }
960
+ .bash-status.warning { color: ${colors.warning}; }
961
+ .bash-status.error { color: ${colors.error}; }
962
+ .bash-truncation.warning { color: ${colors.warning}; }
963
+ .footer { margin-top: 48px; padding: 20px; text-align: center; color: ${colors.dim}; font-size: 10px; }
964
+ .streaming-notice { background: ${streamingNoticeBg}; padding: 12px 16px; border-radius: 4px; margin-bottom: 16px; color: ${colors.dim}; font-size: 11px; }
965
+
966
+ /* Image styles */
967
+ .message-images { margin-bottom: 12px; }
968
+ .message-image { max-width: 100%; max-height: 400px; border-radius: 4px; margin: 4px 0; }
969
+
970
+ /* Markdown styles */
971
+ .markdown-content h1, .markdown-content h2, .markdown-content h3,
972
+ .markdown-content h4, .markdown-content h5, .markdown-content h6 {
973
+ color: ${colors.mdHeading};
974
+ margin: 1em 0 0.5em 0;
975
+ font-weight: bold;
976
+ }
977
+ .markdown-content h1 { font-size: 1.4em; text-decoration: underline; }
978
+ .markdown-content h2 { font-size: 1.2em; }
979
+ .markdown-content h3 { font-size: 1.1em; }
980
+ .markdown-content p { margin: 0.5em 0; }
981
+ .markdown-content a { color: ${colors.mdLink}; text-decoration: underline; }
982
+ .markdown-content a:hover { opacity: 0.8; }
983
+ .markdown-content code {
984
+ background: rgba(${isLight ? "0, 0, 0" : "255, 255, 255"}, 0.1);
985
+ color: ${colors.mdCode};
986
+ padding: 2px 6px;
987
+ border-radius: 3px;
988
+ font-family: inherit;
989
+ }
990
+ .markdown-content pre {
991
+ background: transparent;
992
+ border: none;
993
+ border-radius: 0;
994
+ padding: 0;
995
+ margin: 0.5em 0;
996
+ overflow-x: auto;
997
+ }
998
+ .markdown-content pre code {
999
+ display: block;
1000
+ background: none;
1001
+ color: ${colors.mdCodeBlock};
1002
+ padding: 8px 12px;
1003
+ }
1004
+ .code-block-wrapper {
1005
+ margin: 0.5em 0;
1006
+ }
1007
+ .code-block-header {
1008
+ color: ${colors.mdCodeBlockBorder};
1009
+ font-size: 11px;
1010
+ }
1011
+ .code-block-footer {
1012
+ color: ${colors.mdCodeBlockBorder};
1013
+ font-size: 11px;
1014
+ }
1015
+ .markdown-content blockquote {
1016
+ border-left: 3px solid ${colors.mdQuoteBorder};
1017
+ padding-left: 12px;
1018
+ margin: 0.5em 0;
1019
+ color: ${colors.mdQuote};
1020
+ font-style: italic;
1021
+ }
1022
+ .markdown-content ul, .markdown-content ol {
1023
+ margin: 0.5em 0;
1024
+ padding-left: 24px;
1025
+ }
1026
+ .markdown-content li { margin: 0.25em 0; }
1027
+ .markdown-content li::marker { color: ${colors.mdListBullet}; }
1028
+ .markdown-content hr {
1029
+ border: none;
1030
+ border-top: 1px solid ${colors.mdHr};
1031
+ margin: 1em 0;
1032
+ }
1033
+ .markdown-content table {
1034
+ border-collapse: collapse;
1035
+ margin: 0.5em 0;
1036
+ width: 100%;
1037
+ }
1038
+ .markdown-content th, .markdown-content td {
1039
+ border: 1px solid ${colors.mdCodeBlockBorder};
1040
+ padding: 6px 10px;
1041
+ text-align: left;
1042
+ }
1043
+ .markdown-content th {
1044
+ background: rgba(${isLight ? "0, 0, 0" : "255, 255, 255"}, 0.05);
1045
+ font-weight: bold;
1046
+ }
1047
+ .markdown-content img {
1048
+ max-width: 100%;
1049
+ border-radius: 4px;
1050
+ }
1051
+
1052
+ /* Syntax highlighting (highlight.js) */
1053
+ .hljs { background: transparent; }
1054
+ .hljs-comment, .hljs-quote { color: ${colors.syntaxComment}; }
1055
+ .hljs-keyword, .hljs-selector-tag, .hljs-addition { color: ${colors.syntaxKeyword}; }
1056
+ .hljs-number, .hljs-literal, .hljs-symbol, .hljs-bullet { color: ${colors.syntaxNumber}; }
1057
+ .hljs-string, .hljs-doctag, .hljs-regexp { color: ${colors.syntaxString}; }
1058
+ .hljs-title, .hljs-section, .hljs-name, .hljs-selector-id, .hljs-selector-class { color: ${colors.syntaxFunction}; }
1059
+ .hljs-type, .hljs-class, .hljs-built_in { color: ${colors.syntaxType}; }
1060
+ .hljs-attr, .hljs-variable, .hljs-template-variable, .hljs-params { color: ${colors.syntaxVariable}; }
1061
+ .hljs-attribute { color: ${colors.syntaxVariable}; }
1062
+ .hljs-meta { color: ${colors.syntaxKeyword}; }
1063
+ .hljs-formula { background: rgba(${isLight ? "0, 0, 0" : "255, 255, 255"}, 0.05); }
1064
+ .hljs-deletion { color: ${colors.toolDiffRemoved}; }
1065
+ .hljs-emphasis { font-style: italic; }
1066
+ .hljs-strong { font-weight: bold; }
1067
+ .hljs-link { color: ${colors.mdLink}; text-decoration: underline; }
1068
+
627
1069
  @media print { body { background: white; color: black; } .tool-execution { border: 1px solid #ddd; } }
628
1070
  </style>
629
1071
  </head>
@@ -681,28 +1123,36 @@ function generateHtml(data, filename) {
681
1123
  </body>
682
1124
  </html>`;
683
1125
  }
684
- // ============================================================================
685
- // Public API
686
- // ============================================================================
687
1126
  /**
688
1127
  * Export session to HTML using SessionManager and AgentState.
689
1128
  * Used by TUI's /export command.
1129
+ * @param sessionManager The session manager
1130
+ * @param state The agent state
1131
+ * @param options Export options including output path and theme name
690
1132
  */
691
- export function exportSessionToHtml(sessionManager, state, outputPath) {
1133
+ export function exportSessionToHtml(sessionManager, state, options) {
1134
+ // Handle backwards compatibility: options can be just the output path string
1135
+ const opts = typeof options === "string" ? { outputPath: options } : options || {};
692
1136
  const sessionFile = sessionManager.getSessionFile();
693
1137
  const content = readFileSync(sessionFile, "utf8");
694
1138
  const data = parseSessionFile(content);
695
1139
  // Enrich with data from AgentState (tools, context window)
696
- data.tools = state.tools.map((t) => ({ name: t.name, description: t.description }));
1140
+ data.tools = state.tools.map((t) => ({
1141
+ name: t.name,
1142
+ description: t.description,
1143
+ }));
697
1144
  data.contextWindow = state.model?.contextWindow;
698
1145
  if (!data.systemPrompt) {
699
1146
  data.systemPrompt = state.systemPrompt;
700
1147
  }
1148
+ let outputPath = opts.outputPath;
701
1149
  if (!outputPath) {
702
1150
  const sessionBasename = basename(sessionFile, ".jsonl");
703
1151
  outputPath = `${APP_NAME}-session-${sessionBasename}.html`;
704
1152
  }
705
- const html = generateHtml(data, basename(sessionFile));
1153
+ const colors = getThemeColors(opts.themeName);
1154
+ const isLight = isLightTheme(opts.themeName);
1155
+ const html = generateHtml(data, basename(sessionFile), colors, isLight);
706
1156
  writeFileSync(outputPath, html, "utf8");
707
1157
  return outputPath;
708
1158
  }
@@ -710,18 +1160,25 @@ export function exportSessionToHtml(sessionManager, state, outputPath) {
710
1160
  * Export session file to HTML (standalone, without AgentState).
711
1161
  * Auto-detects format: session manager format or streaming event format.
712
1162
  * Used by CLI for exporting arbitrary session files.
1163
+ * @param inputPath Path to the session file
1164
+ * @param options Export options including output path and theme name
713
1165
  */
714
- export function exportFromFile(inputPath, outputPath) {
1166
+ export function exportFromFile(inputPath, options) {
1167
+ // Handle backwards compatibility: options can be just the output path string
1168
+ const opts = typeof options === "string" ? { outputPath: options } : options || {};
715
1169
  if (!existsSync(inputPath)) {
716
1170
  throw new Error(`File not found: ${inputPath}`);
717
1171
  }
718
1172
  const content = readFileSync(inputPath, "utf8");
719
1173
  const data = parseSessionFile(content);
1174
+ let outputPath = opts.outputPath;
720
1175
  if (!outputPath) {
721
1176
  const inputBasename = basename(inputPath, ".jsonl");
722
1177
  outputPath = `${APP_NAME}-session-${inputBasename}.html`;
723
1178
  }
724
- const html = generateHtml(data, basename(inputPath));
1179
+ const colors = getThemeColors(opts.themeName);
1180
+ const isLight = isLightTheme(opts.themeName);
1181
+ const html = generateHtml(data, basename(inputPath), colors, isLight);
725
1182
  writeFileSync(outputPath, html, "utf8");
726
1183
  return outputPath;
727
1184
  }