Skip to main content

oxi/storage/
export.rs

1//! Export conversation sessions to standalone HTML files.
2//!
3//! Produces a self-contained HTML document with embedded CSS and JS that renders
4//! a conversation session with:
5//! - Color-coded user / assistant / system messages
6//! - Markdown → HTML rendering (basic, no external deps)
7//! - ANSI escape code → HTML span conversion (full 256-color + true-color support)
8//! - Tool calls and results in styled blocks (bash, file ops, search)
9//! - Collapsible thinking blocks
10//! - Metadata header (model, provider, date, token counts)
11//! - Dark theme (default) with light-theme toggle
12//! - Syntax highlighting for code blocks (via highlight.js CDN)
13//! - Session tree navigation for branched sessions
14
15use anyhow::Result;
16use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18use std::fmt::Write as FmtWrite;
19use uuid::Uuid;
20
21use crate::store::session::{AgentMessage, SessionEntry, SessionMeta};
22
23// ── Public types ─────────────────────────────────────────────────────
24
25/// Options for HTML export.
26#[derive(Debug, Clone)]
27pub struct HtmlExportOptions {
28    /// Whether to include thinking blocks in the output.
29    pub include_thinking: bool,
30    /// Whether to include tool call/result blocks in the output.
31    pub include_tool_calls: bool,
32    /// Whether to use dark theme (default: true).
33    pub dark_theme: bool,
34    /// Optional custom title for the HTML document.
35    pub title: Option<String>,
36}
37
38impl Default for HtmlExportOptions {
39    fn default() -> Self {
40        Self {
41            include_thinking: true,
42            include_tool_calls: true,
43            dark_theme: true,
44            title: None,
45        }
46    }
47}
48
49/// Metadata attached to an export (optional but encouraged).
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ExportMeta {
52    /// pub.
53    pub model: Option<String>,
54    /// pub.
55    pub provider: Option<String>,
56    /// pub.
57    pub exported_at: i64,
58    /// pub.
59    pub total_user_tokens: Option<u64>,
60    /// pub.
61    pub total_assistant_tokens: Option<u64>,
62}
63
64impl Default for ExportMeta {
65    fn default() -> Self {
66        Self {
67            model: None,
68            provider: None,
69            exported_at: Utc::now().timestamp_millis(),
70            total_user_tokens: None,
71            total_assistant_tokens: None,
72        }
73    }
74}
75
76/// A single rendered node in the session tree.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct TreeNode {
79    /// pub.
80    pub session_id: Uuid,
81    /// pub.
82    pub name: Option<String>,
83    /// pub.
84    pub is_current: bool,
85    /// pub.
86    pub children: Vec<TreeNode>,
87}
88
89// ── ANSI-to-HTML conversion ──────────────────────────────────────────
90
91/// Standard ANSI color palette (0–15).
92const ANSI_COLORS: [&str; 16] = [
93    "#000000", // 0: black
94    "#800000", // 1: red
95    "#008000", // 2: green
96    "#808000", // 3: yellow
97    "#000080", // 4: blue
98    "#800080", // 5: magenta
99    "#008080", // 6: cyan
100    "#c0c0c0", // 7: white
101    "#808080", // 8: bright black
102    "#ff0000", // 9: bright red
103    "#00ff00", // 10: bright green
104    "#ffff00", // 11: bright yellow
105    "#0000ff", // 12: bright blue
106    "#ff00ff", // 13: bright magenta
107    "#00ffff", // 14: bright cyan
108    "#ffffff", // 15: bright white
109];
110
111/// Convert a 256-color index to a hex color string.
112fn color_256_to_hex(index: u8) -> String {
113    let idx = index as usize;
114
115    // Standard colors (0–15)
116    if idx < 16 {
117        return ANSI_COLORS[idx].to_string();
118    }
119
120    // Color cube (16–231): 6×6×6 = 216 colors
121    if idx < 232 {
122        let cube = idx - 16;
123        let r = cube / 36;
124        let g = (cube % 36) / 6;
125        let b = cube % 6;
126        let component = |n: usize| -> u8 { if n == 0 { 0 } else { (55 + n * 40) as u8 } };
127        return format!(
128            "#{:02x}{:02x}{:02x}",
129            component(r),
130            component(g),
131            component(b)
132        );
133    }
134
135    // Grayscale (232–255): 24 shades
136    let gray = 8 + (idx - 232) * 10;
137    format!("#{gray:02x}{gray:02x}{gray:02x}", gray = gray as u8)
138}
139
140/// Tracked text style state during ANSI SGR processing.
141#[derive(Clone, Default)]
142struct TextStyle {
143    fg: Option<String>,
144    bg: Option<String>,
145    bold: bool,
146    dim: bool,
147    italic: bool,
148    underline: bool,
149    strikethrough: bool,
150}
151
152impl TextStyle {
153    fn to_inline_css(&self) -> String {
154        let mut parts = Vec::new();
155        if let Some(ref fg) = self.fg {
156            parts.push(format!("color:{fg}"));
157        }
158        if let Some(ref bg) = self.bg {
159            parts.push(format!("background-color:{bg}"));
160        }
161        if self.bold {
162            parts.push("font-weight:bold".to_string());
163        }
164        if self.dim {
165            parts.push("opacity:0.6".to_string());
166        }
167        if self.italic {
168            parts.push("font-style:italic".to_string());
169        }
170        if self.underline {
171            parts.push("text-decoration:underline".to_string());
172        }
173        if self.strikethrough {
174            let deco = if self.underline {
175                "text-decoration:underline line-through"
176            } else {
177                "text-decoration:line-through"
178            };
179            parts.push(deco.to_string());
180        }
181        parts.join(";")
182    }
183
184    fn has_style(&self) -> bool {
185        self.fg.is_some()
186            || self.bg.is_some()
187            || self.bold
188            || self.dim
189            || self.italic
190            || self.underline
191            || self.strikethrough
192    }
193
194    fn reset(&mut self) {
195        self.fg = None;
196        self.bg = None;
197        self.bold = false;
198        self.dim = false;
199        self.italic = false;
200        self.underline = false;
201        self.strikethrough = false;
202    }
203}
204
205/// Apply ANSI SGR (Select Graphic Rendition) parameters to a style.
206fn apply_sgr_codes(params: &[u16], style: &mut TextStyle) {
207    let mut i = 0;
208    while i < params.len() {
209        let code = params[i];
210        match code {
211            0 => {
212                style.reset();
213            }
214            1 => {
215                style.bold = true;
216            }
217            2 => {
218                style.dim = true;
219            }
220            3 => {
221                style.italic = true;
222            }
223            4 => {
224                style.underline = true;
225            }
226            9 => {
227                style.strikethrough = true;
228            }
229            22 => {
230                style.bold = false;
231                style.dim = false;
232            }
233            23 => {
234                style.italic = false;
235            }
236            24 => {
237                style.underline = false;
238            }
239            29 => {
240                style.strikethrough = false;
241            }
242            // Standard foreground colors (30–37)
243            30..=37 => {
244                style.fg = Some(ANSI_COLORS[(code - 30) as usize].to_string());
245            }
246            // Extended foreground color (38;5;N or 38;2;R;G;B)
247            38 if i + 1 < params.len() => {
248                match params[i + 1] {
249                        5
250                            // 256-color: 38;5;N
251                            if i + 2 < params.len() => {
252                                style.fg = Some(color_256_to_hex(params[i + 2] as u8));
253                                i += 2;
254                            }
255                        2
256                            // RGB: 38;2;R;G;B
257                            if i + 4 < params.len() => {
258                                let r = params[i + 2];
259                                let g = params[i + 3];
260                                let b = params[i + 4];
261                                style.fg = Some(format!("rgb({r},{g},{b})"));
262                                i += 4;
263                            }
264                        _ => {}
265                    }
266            }
267            39 => {
268                // Default foreground
269                style.fg = None;
270            }
271            // Standard background colors (40–47)
272            40..=47 => {
273                style.bg = Some(ANSI_COLORS[(code - 40) as usize].to_string());
274            }
275            // Extended background color (48;5;N or 48;2;R;G;B)
276            48 if i + 1 < params.len() => match params[i + 1] {
277                5 if i + 2 < params.len() => {
278                    style.bg = Some(color_256_to_hex(params[i + 2] as u8));
279                    i += 2;
280                }
281                2 if i + 4 < params.len() => {
282                    let r = params[i + 2];
283                    let g = params[i + 3];
284                    let b = params[i + 4];
285                    style.bg = Some(format!("rgb({r},{g},{b})"));
286                    i += 4;
287                }
288                _ => {}
289            },
290            49 => {
291                // Default background
292                style.bg = None;
293            }
294            // Bright foreground colors (90–97)
295            90..=97 => {
296                style.fg = Some(ANSI_COLORS[(code - 90 + 8) as usize].to_string());
297            }
298            // Bright background colors (100–107)
299            100..=107 => {
300                style.bg = Some(ANSI_COLORS[(code - 100 + 8) as usize].to_string());
301            }
302            _ => {
303                // Ignore unrecognized SGR codes
304            }
305        }
306        i += 1;
307    }
308}
309
310/// Convert ANSI-escaped text to HTML with inline styles.
311///
312/// Supports:
313/// - Standard foreground colors (30–37) and bright variants (90–97)
314/// - Standard background colors (40–47) and bright variants (100–107)
315/// - 256-color palette (38;5;N and 48;5;N)
316/// - RGB true color (38;2;R;G;B and 48;2;R;G;B)
317/// - Text styles: bold (1), dim (2), italic (3), underline (4), strikethrough (9)
318/// - Reset (0)
319pub fn ansi_to_html(text: &str) -> String {
320    let mut style = TextStyle::default();
321    let mut result = String::with_capacity(text.len() * 2);
322    let mut last_end = 0;
323    let mut in_span = false;
324
325    let bytes = text.as_bytes();
326    let len = bytes.len();
327    let mut pos = 0;
328
329    while pos < len {
330        // Look for ESC[ ... m
331        if bytes[pos] == 0x1b && pos + 1 < len && bytes[pos + 1] == b'[' {
332            // Find 'm' terminator
333            let seq_start = pos + 2;
334            let mut seq_end = seq_start;
335            while seq_end < len && bytes[seq_end] != b'm' {
336                seq_end += 1;
337            }
338            if seq_end >= len {
339                // No closing 'm', not a valid sequence
340                pos += 1;
341                continue;
342            }
343
344            // Emit text before this sequence
345            if pos > last_end {
346                result.push_str(&html_escape(&text[last_end..pos]));
347            }
348
349            // Close existing span
350            if in_span {
351                result.push_str("</span>");
352                in_span = false;
353            }
354
355            // Parse parameters
356            let param_str = &text[seq_start..seq_end];
357            let params: Vec<u16> = if param_str.is_empty() {
358                vec![0]
359            } else {
360                param_str
361                    .split(';')
362                    .map(|p| p.parse::<u16>().unwrap_or(0))
363                    .collect()
364            };
365
366            // Apply SGR codes
367            apply_sgr_codes(&params, &mut style);
368
369            // Open new span if styled
370            if style.has_style() {
371                result.push_str("<span style=\"");
372                result.push_str(&style.to_inline_css());
373                result.push_str("\">");
374                in_span = true;
375            }
376
377            last_end = seq_end + 1; // skip past 'm'
378            pos = seq_end + 1;
379        } else {
380            pos += 1;
381        }
382    }
383
384    // Emit remaining text
385    if last_end < len {
386        result.push_str(&html_escape(&text[last_end..]));
387    }
388
389    // Close any open span
390    if in_span {
391        result.push_str("</span>");
392    }
393
394    result
395}
396
397/// Convert an array of ANSI-escaped lines to HTML.
398/// Each line is wrapped in a `<div class="ansi-line">` element.
399#[allow(dead_code)]
400pub fn ansi_lines_to_html(lines: &[&str]) -> String {
401    lines
402        .iter()
403        .map(|line| {
404            let rendered = ansi_to_html(line);
405            if rendered.is_empty() {
406                "<div class=\"ansi-line\">&nbsp;</div>".to_string()
407            } else {
408                format!("<div class=\"ansi-line\">{rendered}</div>")
409            }
410        })
411        .collect()
412}
413
414// ── Tool rendering (structured) ──────────────────────────────────────
415
416/// Extract text from a `ContentValue`, joining `Text` blocks (mirrors
417/// User/System handling). Non-text blocks (images) are skipped.
418fn extract_text(content: &crate::store::session::ContentValue) -> String {
419    use crate::store::session::{ContentBlock, ContentValue};
420    match content {
421        ContentValue::String(s) => s.clone(),
422        ContentValue::Blocks(blocks) => {
423            let mut text = String::new();
424            for block in blocks {
425                if let ContentBlock::Text { text: t } = block {
426                    text.push_str(t);
427                    text.push('\n');
428                }
429            }
430            text.trim().to_string()
431        }
432    }
433}
434
435/// Render an `AssistantContentBlock::ToolCall` as a `.tool-call` block.
436/// Dispatches on tool name for per-tool formatting; unknown tools fall back
437/// to a generic label + JSON arguments.
438fn render_tool_call_block(name: &str, arguments: &serde_json::Value) -> String {
439    match name {
440        "bash" => render_bash_call(arguments),
441        "read" => render_read_call(arguments),
442        "write" => render_write_call(arguments),
443        "edit" | "edit_diff" => render_edit_call(arguments),
444        "grep" => render_search_call("G", "pattern", arguments),
445        "find" => render_search_call("F", "name", arguments),
446        "ls" => render_search_call("D", "path", arguments),
447        _ => render_generic_tool_call(name, arguments),
448    }
449}
450
451/// Render a `bash` tool call as a `.tool-call.tool-bash` block.
452fn render_bash_call(arguments: &serde_json::Value) -> String {
453    let command = arguments
454        .get("command")
455        .and_then(|v| v.as_str())
456        .unwrap_or("");
457    let mut html = String::new();
458    html.push_str("<div class=\"tool-call tool-bash\">\n");
459    html.push_str("<div class=\"tool-label\">⌨ Bash</div>\n");
460    html.push_str("<pre class=\"tool-command\"><code>$ ");
461    html.push_str(&html_escape(command.trim()));
462    html.push_str("</code></pre>\n");
463    html.push_str("</div>\n");
464    html
465}
466
467/// Render a `read` tool call.
468fn render_read_call(arguments: &serde_json::Value) -> String {
469    let path = arguments
470        .get("path")
471        .and_then(|v| v.as_str())
472        .unwrap_or("?");
473    let mut html = String::new();
474    html.push_str("<div class=\"tool-call\">\n");
475    html.push_str("<div class=\"tool-label\">📄 Read: ");
476    html.push_str(&html_escape(path));
477    html.push_str("</div>\n");
478    html.push_str("</div>\n");
479    html
480}
481
482/// Render a `write` tool call.
483fn render_write_call(arguments: &serde_json::Value) -> String {
484    let path = arguments
485        .get("path")
486        .and_then(|v| v.as_str())
487        .unwrap_or("?");
488    let mut html = String::new();
489    html.push_str("<div class=\"tool-call\">\n");
490    html.push_str("<div class=\"tool-label\">📝 Write: ");
491    html.push_str(&html_escape(path));
492    html.push_str("</div>\n");
493    html.push_str("</div>\n");
494    html
495}
496
497/// Render an `edit` / `edit_diff` tool call.
498fn render_edit_call(arguments: &serde_json::Value) -> String {
499    let path = arguments
500        .get("path")
501        .and_then(|v| v.as_str())
502        .unwrap_or("?");
503    let mut html = String::new();
504    html.push_str("<div class=\"tool-call\">\n");
505    html.push_str("<div class=\"tool-label\">✏️ Edit: ");
506    html.push_str(&html_escape(path));
507    html.push_str("</div>\n");
508    html.push_str("</div>\n");
509    html
510}
511
512/// Render a `grep` / `find` / `ls` tool call.
513/// `tag` is the bracket label ([G]/[F]/[D]), `key` is the argument key
514/// holding the user's query (pattern for grep, name for find, path for ls).
515fn render_search_call(tag: &str, key: &str, arguments: &serde_json::Value) -> String {
516    let query = arguments.get(key).and_then(|v| v.as_str()).unwrap_or("");
517    let mut html = String::new();
518    html.push_str("<div class=\"tool-call\">\n");
519    let _ = write!(html, "<div class=\"tool-label\">[{}] ", tag);
520    html.push_str(&html_escape(query));
521    html.push_str("</div>\n");
522    html.push_str("</div>\n");
523    html
524}
525
526/// Generic fallback for unknown tools — label + raw JSON arguments.
527fn render_generic_tool_call(name: &str, arguments: &serde_json::Value) -> String {
528    let mut html = String::new();
529    html.push_str("<div class=\"tool-call\">\n");
530    html.push_str("<div class=\"tool-label\">");
531    html.push_str(&html_escape(name));
532    html.push_str("</div>\n");
533    let args_str =
534        serde_json::to_string_pretty(arguments).unwrap_or_else(|_| arguments.to_string());
535    html.push_str("<pre><code>");
536    html.push_str(&html_escape(&args_str));
537    html.push_str("</code></pre>\n");
538    html.push_str("</div>\n");
539    html
540}
541
542/// Render an `AgentMessage::ToolResult` as a bare `.tool-result` block.
543/// Renders outside any `msg-*` wrapper — a tool result is a continuation of
544/// the tool-call flow, not an independent conversational message.
545fn render_tool_result_block(
546    content: &crate::store::session::ContentValue,
547    tool_call_id: &str,
548) -> String {
549    let text = extract_text(content);
550    let mut html = String::new();
551    html.push_str("<div class=\"tool-result\" data-tool-call-id=\"");
552    html.push_str(&html_escape(tool_call_id));
553    html.push_str("\">\n");
554    if !text.trim().is_empty() {
555        html.push_str("<pre><code>");
556        html.push_str(&html_escape(text.trim()));
557        html.push_str("</code></pre>\n");
558    }
559    html.push_str("</div>\n");
560    html
561}
562
563// ── Core export functions ────────────────────────────────────────────
564
565/// Render a flat list of session entries into a self-contained HTML string.
566///
567/// `tree` is optional – when provided, a sidebar with session-tree navigation
568/// is included.
569#[allow(dead_code)]
570pub fn export_html(
571    entries: &[SessionEntry],
572    meta: &ExportMeta,
573    session_meta: Option<&SessionMeta>,
574    tree: Option<&TreeNode>,
575) -> Result<String> {
576    export_html_with_options(
577        entries,
578        meta,
579        session_meta,
580        tree,
581        &HtmlExportOptions::default(),
582    )
583}
584
585/// Render a flat list of session entries into a self-contained HTML string
586/// with fine-grained control over the output via [`HtmlExportOptions`].
587pub fn export_html_with_options(
588    entries: &[SessionEntry],
589    meta: &ExportMeta,
590    session_meta: Option<&SessionMeta>,
591    tree: Option<&TreeNode>,
592    options: &HtmlExportOptions,
593) -> Result<String> {
594    let mut html = String::with_capacity(64 * 1024);
595
596    // ── Head ──────────────────────────────────────────────────────
597    html.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n");
598    html.push_str("<meta charset=\"utf-8\">\n");
599    html.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
600
601    let title = options
602        .title
603        .as_deref()
604        .or(session_meta.and_then(|m| m.name.as_deref()))
605        .unwrap_or("oxi session export");
606    writeln!(html, "<title>{}</title>", html_escape(title))?;
607
608    // highlight.js CDN for syntax highlighting
609    html.push_str(
610        "<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css\" id=\"hljs-dark\">\n",
611    );
612    html.push_str(
613        "<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css\" id=\"hljs-light\" disabled>\n",
614    );
615    html.push_str(
616        "<script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>\n",
617    );
618
619    // Embedded CSS
620    html.push_str("<style>\n");
621    html.push_str(CSS);
622    html.push_str("\n</style>\n");
623    html.push_str("</head>\n");
624
625    // ── Body ──────────────────────────────────────────────────────
626    let theme_class = if options.dark_theme { "dark" } else { "light" };
627    writeln!(html, "<body class=\"{}\">", theme_class)?;
628
629    // Theme toggle button
630    html.push_str(
631        "<button id=\"theme-toggle\" onclick=\"toggleTheme()\" title=\"Toggle light/dark theme\">",
632    );
633    html.push_str("🌓</button>\n");
634
635    // Optional tree sidebar
636    if let Some(node) = tree {
637        html.push_str("<nav class=\"tree-nav\">\n<h3>Session Tree</h3>\n");
638        render_tree_node(&mut html, node, 0)?;
639        html.push_str("</nav>\n");
640    }
641
642    // Main content
643    html.push_str("<main class=\"content\">\n");
644
645    // Metadata header
646    render_meta_header(&mut html, meta, session_meta)?;
647
648    // Messages
649    for entry in entries {
650        render_entry(&mut html, entry, options)?;
651    }
652
653    html.push_str("</main>\n");
654
655    // Embedded JS
656    html.push_str("<script>\n");
657    html.push_str(JS);
658    html.push_str("\n</script>\n");
659
660    html.push_str("</body>\n</html>\n");
661    Ok(html)
662}
663
664/// Convenience function: export session entries to an HTML string using the
665/// given [`HtmlExportOptions`].
666pub fn export_to_html(
667    entries: &[SessionEntry],
668    meta: &ExportMeta,
669    options: &HtmlExportOptions,
670) -> Result<String> {
671    export_html_with_options(entries, meta, None, None, options)
672}
673
674// ── Internal rendering helpers ───────────────────────────────────────
675
676fn render_meta_header(
677    html: &mut String,
678    meta: &ExportMeta,
679    session_meta: Option<&SessionMeta>,
680) -> Result<()> {
681    html.push_str("<header class=\"meta-header\">\n");
682    html.push_str("<h1>oxi Session Export</h1>\n");
683    html.push_str("<table class=\"meta-table\">\n");
684
685    let exported_dt = DateTime::from_timestamp_millis(meta.exported_at)
686        .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
687        .unwrap_or_else(|| "unknown".to_string());
688    render_meta_row(html, "Exported", &exported_dt)?;
689
690    if let Some(model) = &meta.model {
691        render_meta_row(html, "Model", model)?;
692    }
693    if let Some(provider) = &meta.provider {
694        render_meta_row(html, "Provider", provider)?;
695    }
696    if let Some(sm) = session_meta {
697        let created_dt = DateTime::from_timestamp_millis(sm.created_at)
698            .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
699            .unwrap_or_else(|| "unknown".to_string());
700        render_meta_row(html, "Session ID", &sm.id.to_string())?;
701        render_meta_row(html, "Created", &created_dt)?;
702        if let Some(name) = &sm.name {
703            render_meta_row(html, "Name", name)?;
704        }
705    }
706    if let Some(t) = meta.total_user_tokens {
707        render_meta_row(html, "User Tokens", &t.to_string())?;
708    }
709    if let Some(t) = meta.total_assistant_tokens {
710        render_meta_row(html, "Assistant Tokens", &t.to_string())?;
711    }
712
713    html.push_str("</table>\n</header>\n");
714    Ok(())
715}
716
717fn render_meta_row(html: &mut String, label: &str, value: &str) -> Result<()> {
718    writeln!(
719        html,
720        "<tr><td class=\"meta-label\">{}</td><td class=\"meta-value\">{}</td></tr>",
721        html_escape(label),
722        html_escape(value)
723    )?;
724    Ok(())
725}
726
727fn render_entry(
728    html: &mut String,
729    entry: &SessionEntry,
730    options: &HtmlExportOptions,
731) -> Result<()> {
732    let ts = DateTime::from_timestamp_millis(entry.timestamp)
733        .map(|dt| dt.format("%H:%M:%S").to_string())
734        .unwrap_or_default();
735
736    match &entry.message {
737        AgentMessage::User { content } => {
738            html.push_str("<div class=\"msg msg-user\">\n");
739            html.push_str("<div class=\"msg-header\"><span class=\"msg-role\">You</span>");
740            write!(html, "<span class=\"msg-time\">{}</span>", html_escape(&ts))?;
741            html.push_str("</div>\n");
742            html.push_str("<div class=\"msg-body\">");
743            let content_str = extract_text(content);
744            html.push_str(&render_markdown_with_options(&content_str, options));
745            html.push_str("</div>\n</div>\n");
746        }
747        AgentMessage::Assistant { content, .. } => {
748            html.push_str("<div class=\"msg msg-assistant\">\n");
749            html.push_str("<div class=\"msg-header\"><span class=\"msg-role\">Assistant</span>");
750            write!(html, "<span class=\"msg-time\">{}</span>", html_escape(&ts))?;
751            html.push_str("</div>\n");
752            html.push_str("<div class=\"msg-body\">");
753
754            // Iterate blocks in order, rendering each by type
755            for block in content {
756                match block {
757                    crate::store::session::AssistantContentBlock::Text { text } => {
758                        html.push_str(&render_markdown_with_options(text, options));
759                    }
760                    crate::store::session::AssistantContentBlock::ToolCall {
761                        name,
762                        arguments,
763                        ..
764                    } => {
765                        if options.include_tool_calls {
766                            html.push_str(&render_tool_call_block(name, arguments));
767                        }
768                    }
769                    crate::store::session::AssistantContentBlock::Thinking { thinking }
770                        if options.include_thinking =>
771                    {
772                        html.push_str(
773                            "<details class=\"thinking-block\"><summary>💭 Thinking</summary><div class=\"think-content\">",
774                        );
775                        html.push_str(&render_markdown_with_options(thinking, options));
776                        html.push_str("</div></details>\n");
777                    }
778                    // ImageResult / ToolPlan / Refusal — skip (out of scope)
779                    _ => {}
780                }
781            }
782
783            html.push_str("</div>\n</div>\n");
784        }
785        AgentMessage::System { content } => {
786            html.push_str("<div class=\"msg msg-system\">\n");
787            html.push_str("<div class=\"msg-header\"><span class=\"msg-role\">System</span>");
788            write!(html, "<span class=\"msg-time\">{}</span>", html_escape(&ts))?;
789            html.push_str("</div>\n");
790            html.push_str("<div class=\"msg-body\">");
791            let content_str = extract_text(content);
792            html.push_str(&render_markdown_with_options(&content_str, options));
793            html.push_str("</div>\n</div>\n");
794        }
795        AgentMessage::ToolResult {
796            content,
797            tool_call_id,
798        } => {
799            if options.include_tool_calls {
800                html.push_str(&render_tool_result_block(content, tool_call_id));
801            }
802        }
803        // Handle remaining message types (BashExecution, Custom, etc.)
804        _ => {
805            let content = entry.content();
806            if !content.is_empty() {
807                html.push_str("<div class=\"msg msg-system\">\n");
808                html.push_str("<div class=\"msg-header\"><span class=\"msg-role\">System</span>");
809                write!(html, "<span class=\"msg-time\">{}</span>", html_escape(&ts))?;
810                html.push_str("</div>\n");
811                html.push_str("<div class=\"msg-body\">");
812                html.push_str(&render_markdown_with_options(&content, options));
813                html.push_str("</div>\n</div>\n");
814            }
815        }
816    }
817    Ok(())
818}
819
820/// Recursively render a session-tree sidebar node.
821fn render_tree_node(html: &mut String, node: &TreeNode, depth: usize) -> Result<()> {
822    let indent = "&nbsp;".repeat(depth * 4);
823    let current = if node.is_current { " tree-current" } else { "" };
824    let fallback = node.session_id.to_string();
825    let short_id = &fallback[..8.min(fallback.len())];
826    let name = node.name.as_deref().unwrap_or(short_id);
827    writeln!(
828        html,
829        "<div class=\"tree-node{}\">{}<a href=\"#\">{}</a></div>",
830        current,
831        indent,
832        html_escape(name)
833    )?;
834    for child in &node.children {
835        render_tree_node(html, child, depth + 1)?;
836    }
837    Ok(())
838}
839
840// ── Minimal markdown → HTML renderer ─────────────────────────────────
841//
842// Handles: code blocks (```), inline code (`), bold (**), italic (*),
843// headers (#), unordered lists (- ), links, and paragraphs.
844// This is intentionally lightweight to avoid pulling in a heavy crate.
845
846/// Convenience wrapper — renders markdown to HTML with default options.
847/// Keep: public convenience API for callers that don't need custom options.
848#[allow(dead_code)]
849fn render_markdown(input: &str) -> String {
850    render_markdown_with_options(input, &HtmlExportOptions::default())
851}
852
853/// Core markdown-to-HTML renderer (lightweight, no external crate).
854/// Keep: available for future HTML export pipelines and testable in isolation.
855fn render_markdown_with_options(input: &str, options: &HtmlExportOptions) -> String {
856    let mut out = String::with_capacity(input.len() * 2);
857    let mut in_code_block = false;
858    let mut code_lang = String::new();
859    let mut code_buf = String::new();
860    let mut in_thinking = false;
861    let mut think_buf = String::new();
862    let mut lines = input.lines().peekable();
863
864    while let Some(line) = lines.next() {
865        // ── Fenced code blocks ────────────────────────────────────
866        if line.starts_with("```") {
867            if in_code_block {
868                // close
869                out.push_str("<pre><code class=\"language-");
870                out.push_str(&html_escape(&code_lang));
871                out.push_str("\">");
872                out.push_str(&html_escape(&code_buf));
873                out.push_str("</code></pre>\n");
874                code_buf.clear();
875                code_lang.clear();
876                in_code_block = false;
877            } else {
878                in_code_block = true;
879                code_lang = line.trim_start_matches('`').trim().to_string();
880            }
881            continue;
882        }
883        if in_code_block {
884            code_buf.push_str(line);
885            code_buf.push('\n');
886            continue;
887        }
888
889        // ── Thinking blocks ───────────────────────────────────────
890        if line.trim() == "<think/>" {
891            // skip empty self-closing
892            continue;
893        }
894        if line.trim().starts_with("<think") || line.trim() == "<thinking>" {
895            if !options.include_thinking {
896                // Skip thinking blocks if not included
897                for l in lines.by_ref() {
898                    if l.trim() == "</think" || l.trim() == "</thinking>" {
899                        break;
900                    }
901                }
902                continue;
903            }
904            in_thinking = true;
905            continue;
906        }
907        if in_thinking && (line.trim() == "</think" || line.trim() == "</thinking>") {
908            // emit collapsible
909            out.push_str("<details class=\"thinking-block\"><summary>💭 Thinking</summary><div class=\"think-content\">");
910            out.push_str(&render_inline(&think_buf));
911            out.push_str("</div></details>\n");
912            think_buf.clear();
913            in_thinking = false;
914            continue;
915        }
916        if in_thinking {
917            think_buf.push_str(line);
918            think_buf.push('\n');
919            continue;
920        }
921
922        // ── Headings ──────────────────────────────────────────────
923        if let Some(rest) = line.strip_prefix("### ") {
924            out.push_str("<h3>");
925            out.push_str(&render_inline(rest));
926            out.push_str("</h3>\n");
927            continue;
928        }
929        if let Some(rest) = line.strip_prefix("## ") {
930            out.push_str("<h2>");
931            out.push_str(&render_inline(rest));
932            out.push_str("</h2>\n");
933            continue;
934        }
935        if let Some(rest) = line.strip_prefix("# ") {
936            out.push_str("<h1>");
937            out.push_str(&render_inline(rest));
938            out.push_str("</h1>\n");
939            continue;
940        }
941
942        // ── Unordered list items ──────────────────────────────────
943        if line.starts_with("- ") || line.starts_with("* ") {
944            out.push_str("<li>");
945            out.push_str(&render_inline(&line[2..]));
946            out.push_str("</li>\n");
947            continue;
948        }
949
950        // ── Empty line = paragraph break ──────────────────────────
951        if line.trim().is_empty() {
952            out.push_str("<br>\n");
953            continue;
954        }
955
956        // ── Regular paragraph ─────────────────────────────────────
957        out.push_str("<p>");
958        out.push_str(&render_inline(line));
959        out.push_str("</p>\n");
960    }
961
962    // Close any still-open code block
963    if in_code_block {
964        out.push_str("<pre><code>");
965        out.push_str(&html_escape(&code_buf));
966        out.push_str("</code></pre>\n");
967    }
968
969    out
970}
971
972/// Inline formatting: bold, italic, inline code, links.
973fn render_inline(input: &str) -> String {
974    let mut out = String::with_capacity(input.len() * 2);
975    let mut chars = input.char_indices().peekable();
976    let bytes = input.as_bytes();
977
978    while let Some((i, ch)) = chars.next() {
979        match ch {
980            '`' => {
981                // inline code
982                let start = i + 1;
983                let end = bytes[start..]
984                    .iter()
985                    .position(|&b| b == b'`')
986                    .map(|pos| start + pos)
987                    .unwrap_or(input.len());
988                let code = &input[start..end];
989                out.push_str("<code>");
990                out.push_str(&html_escape(code));
991                out.push_str("</code>");
992                // skip past closing backtick
993                if end < input.len() {
994                    for _ in input[i..=end].chars() {
995                        chars.next();
996                    }
997                }
998            }
999            '*' => {
1000                // Look ahead for bold (**)
1001                if bytes.get(i + 1) == Some(&b'*') {
1002                    let rest = &input[i + 2..];
1003                    if let Some(end_pos) = rest.find("**") {
1004                        out.push_str("<strong>");
1005                        out.push_str(&render_inline(&rest[..end_pos]));
1006                        out.push_str("</strong>");
1007                        // skip past closing **
1008                        for _ in input[i..=i + 2 + end_pos + 1].chars() {
1009                            chars.next();
1010                        }
1011                        continue;
1012                    }
1013                }
1014                // Italic (*)
1015                let rest = &input[i + 1..];
1016                if let Some(end_pos) = rest.find('*') {
1017                    out.push_str("<em>");
1018                    out.push_str(&render_inline(&rest[..end_pos]));
1019                    out.push_str("</em>");
1020                    for _ in input[i..=i + 1 + end_pos].chars() {
1021                        chars.next();
1022                    }
1023                    continue;
1024                }
1025                out.push('*');
1026            }
1027            '[' => {
1028                // Markdown link [text](url)
1029                let rest = &input[i..];
1030                if let Some(link_end) = rest.find(')')
1031                    && let Some(mid) = rest.find("](")
1032                {
1033                    let text = &rest[1..mid];
1034                    let url = &rest[mid + 2..link_end];
1035                    out.push_str("<a href=\"");
1036                    out.push_str(&html_escape(url));
1037                    out.push_str("\">");
1038                    out.push_str(&html_escape(text));
1039                    out.push_str("</a>");
1040                    // skip entire link
1041                    for _ in rest[..=link_end].chars() {
1042                        chars.next();
1043                    }
1044                    continue;
1045                }
1046                out.push('[');
1047            }
1048            '<' => {
1049                // escape HTML
1050                out.push_str("&lt;");
1051            }
1052            '>' => {
1053                out.push_str("&gt;");
1054            }
1055            '&' => {
1056                out.push_str("&amp;");
1057            }
1058            _ => {
1059                out.push(ch);
1060            }
1061        }
1062    }
1063    out
1064}
1065
1066fn html_escape(input: &str) -> String {
1067    let mut s = String::with_capacity(input.len());
1068    for ch in input.chars() {
1069        match ch {
1070            '<' => s.push_str("&lt;"),
1071            '>' => s.push_str("&gt;"),
1072            '&' => s.push_str("&amp;"),
1073            '"' => s.push_str("&quot;"),
1074            '\'' => s.push_str("&#39;"),
1075            _ => s.push(ch),
1076        }
1077    }
1078    s
1079}
1080
1081// ── Embedded CSS ─────────────────────────────────────────────────────
1082
1083const CSS: &str = r#"
1084/* ── Reset & base ──────────────────────────────────────────────── */
1085*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
1086
1087body {
1088  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
1089  line-height: 1.6;
1090  padding: 1rem;
1091  display: flex;
1092  min-height: 100vh;
1093}
1094
1095/* ── Dark theme (default) ─────────────────────────────────────── */
1096body.dark {
1097  background: #1a1b26;
1098  color: #c0caf5;
1099}
1100
1101/* ── Light theme ──────────────────────────────────────────────── */
1102body.light {
1103  background: #f8f9fc;
1104  color: #1a1b26;
1105}
1106
1107/* ── Theme toggle button ──────────────────────────────────────── */
1108#theme-toggle {
1109  position: fixed;
1110  top: 1rem;
1111  right: 1rem;
1112  z-index: 100;
1113  background: rgba(255,255,255,0.1);
1114  border: 1px solid rgba(255,255,255,0.2);
1115  border-radius: 8px;
1116  padding: 0.4rem 0.7rem;
1117  cursor: pointer;
1118  font-size: 1.2rem;
1119}
1120body.light #theme-toggle {
1121  background: rgba(0,0,0,0.05);
1122  border-color: rgba(0,0,0,0.15);
1123}
1124
1125/* ── Tree sidebar ──────────────────────────────────────────────── */
1126.tree-nav {
1127  width: 220px;
1128  min-width: 220px;
1129  padding: 1rem;
1130  margin-right: 1rem;
1131  border-right: 1px solid rgba(255,255,255,0.1);
1132  font-size: 0.85rem;
1133  overflow-y: auto;
1134}
1135body.light .tree-nav { border-color: rgba(0,0,0,0.12); }
1136.tree-nav h3 { margin-bottom: 0.5rem; font-size: 0.95rem; }
1137.tree-node { padding: 0.2rem 0; }
1138.tree-node a { text-decoration: none; color: inherit; opacity: 0.7; }
1139.tree-node a:hover { opacity: 1; }
1140.tree-current a { font-weight: bold; opacity: 1; }
1141body.dark .tree-current a { color: #7aa2f7; }
1142body.light .tree-current a { color: #1d4ed8; }
1143
1144/* ── Main content ──────────────────────────────────────────────── */
1145.content {
1146  flex: 1;
1147  max-width: 900px;
1148  margin: 0 auto;
1149}
1150
1151/* ── Metadata header ───────────────────────────────────────────── */
1152.meta-header { margin-bottom: 1.5rem; }
1153.meta-header h1 { font-size: 1.4rem; margin-bottom: 0.5rem; }
1154.meta-table { border-collapse: collapse; font-size: 0.9rem; }
1155.meta-table td { padding: 0.15rem 0.75rem 0.15rem 0; }
1156.meta-label { color: #7982a9; font-weight: 600; }
1157body.light .meta-label { color: #6b7280; }
1158
1159/* ── Message bubbles ───────────────────────────────────────────── */
1160.msg {
1161  border-radius: 10px;
1162  padding: 0.75rem 1rem;
1163  margin-bottom: 0.75rem;
1164  max-width: 100%;
1165  word-wrap: break-word;
1166  overflow-wrap: break-word;
1167}
1168
1169.msg-header {
1170  display: flex;
1171  justify-content: space-between;
1172  align-items: center;
1173  margin-bottom: 0.35rem;
1174  font-size: 0.82rem;
1175}
1176
1177.msg-role { font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
1178.msg-time { opacity: 0.5; font-size: 0.78rem; }
1179
1180.msg-body p { margin: 0.25rem 0; }
1181.msg-body h1, .msg-body h2, .msg-body h3 { margin: 0.6rem 0 0.25rem; }
1182.msg-body li { margin-left: 1.2rem; }
1183
1184/* ── User message ──────────────────────────────────────────────── */
1185body.dark .msg-user  { background: #24283b; border-left: 4px solid #7aa2f7; }
1186body.light .msg-user { background: #eef2ff; border-left: 4px solid #6366f1; }
1187.msg-user .msg-role { color: #7aa2f7; }
1188body.light .msg-user .msg-role { color: #4f46e5; }
1189
1190/* ── Assistant message ─────────────────────────────────────────── */
1191body.dark .msg-assistant  { background: #1f2335; border-left: 4px solid #9ece6a; }
1192body.light .msg-assistant { background: #f0fdf4; border-left: 4px solid #22c55e; }
1193.msg-assistant .msg-role { color: #9ece6a; }
1194body.light .msg-assistant .msg-role { color: #16a34a; }
1195
1196/* ── System message ────────────────────────────────────────────── */
1197body.dark .msg-system  { background: #292e42; border-left: 4px solid #ff9e64; }
1198body.light .msg-system { background: #fffbeb; border-left: 4px solid #f59e0b; }
1199.msg-system .msg-role { color: #ff9e64; }
1200body.light .msg-system .msg-role { color: #d97706; }
1201
1202/* ── Code blocks ───────────────────────────────────────────────── */
1203pre {
1204  background: #13141c;
1205  border-radius: 6px;
1206  padding: 0.75rem 1rem;
1207  overflow-x: auto;
1208  margin: 0.5rem 0;
1209  font-size: 0.88rem;
1210}
1211body.light pre { background: #f1f5f9; }
1212pre code { font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", monospace; }
1213
1214code {
1215  background: rgba(255,255,255,0.07);
1216  padding: 0.1rem 0.3rem;
1217  border-radius: 3px;
1218  font-family: "JetBrains Mono", "Fira Code", monospace;
1219  font-size: 0.88em;
1220}
1221body.light code { background: rgba(0,0,0,0.06); }
1222
1223/* ── Thinking block (collapsible) ──────────────────────────────── */
1224.thinking-block {
1225  border: 1px dashed rgba(255,255,255,0.15);
1226  border-radius: 6px;
1227  padding: 0.5rem 0.75rem;
1228  margin: 0.4rem 0;
1229  font-size: 0.88rem;
1230}
1231body.light .thinking-block { border-color: rgba(0,0,0,0.15); }
1232.thinking-block summary {
1233  cursor: pointer;
1234  color: #bb9af7;
1235  font-weight: 600;
1236  user-select: none;
1237}
1238body.light .thinking-block summary { color: #7c3aed; }
1239.think-content {
1240  margin-top: 0.4rem;
1241  padding-top: 0.4rem;
1242  border-top: 1px dashed rgba(255,255,255,0.1);
1243  opacity: 0.8;
1244}
1245body.light .think-content { border-color: rgba(0,0,0,0.08); }
1246
1247/* ── Tool call / result ────────────────────────────────────────── */
1248.tool-call, .tool-result {
1249  border-radius: 5px;
1250  padding: 0.4rem 0.75rem;
1251  margin: 0.3rem 0;
1252  font-size: 0.88rem;
1253  font-family: monospace;
1254}
1255body.dark .tool-call  { background: #2d1f3d; border-left: 3px solid #bb9af7; }
1256body.dark .tool-result { background: #1a2d2d; border-left: 3px solid #73daca; }
1257body.light .tool-call  { background: #faf5ff; border-left: 3px solid #a78bfa; }
1258body.light .tool-result { background: #f0fdfa; border-left: 3px solid #14b8a6; }
1259
1260/* ── Tool call / result styles ───────────────────────────────── */
1261.tool-label {
1262  padding: 0.35rem 0.75rem;
1263  font-weight: 600;
1264  font-size: 0.85rem;
1265  font-family: monospace;
1266}
1267
1268/* Bash tool */
1269body.dark .tool-bash { border: 1px solid #3b2d5d; }
1270body.light .tool-bash { border: 1px solid #e0d4f5; }
1271body.dark .tool-bash .tool-label { background: #2d1f3d; color: #bb9af7; }
1272body.light .tool-bash .tool-label { background: #faf5ff; color: #7c3aed; }
1273.tool-bash .tool-command {
1274  background: #0d1117;
1275  border-radius: 4px;
1276  margin: 0.35rem 0.5rem;
1277  padding: 0.5rem 0.75rem;
1278  font-size: 0.85rem;
1279}
1280body.light .tool-bash .tool-command { background: #f6f8fa; }
1281
1282/* ── ANSI lines ────────────────────────────────────────────────── */
1283.ansi-line {
1284  font-family: "JetBrains Mono", "Fira Code", monospace;
1285  font-size: 0.85rem;
1286  line-height: 1.5;
1287  white-space: pre;
1288}
1289
1290/* ── Links ─────────────────────────────────────────────────────── */
1291a { color: #7aa2f7; text-decoration: underline; }
1292body.light a { color: #2563eb; }
1293"#;
1294
1295// ── Embedded JS ──────────────────────────────────────────────────────
1296
1297const JS: &str = r#"
1298function toggleTheme() {
1299  const body = document.body;
1300  const isDark = body.classList.contains('dark');
1301  body.classList.toggle('dark', !isDark);
1302  body.classList.toggle('light', isDark);
1303
1304  // Swap highlight.js stylesheet
1305  const darkSheet = document.getElementById('hljs-dark');
1306  const lightSheet = document.getElementById('hljs-light');
1307  if (darkSheet && lightSheet) {
1308    darkSheet.disabled = isDark;
1309    lightSheet.disabled = !isDark;
1310  }
1311}
1312
1313// Apply syntax highlighting
1314document.addEventListener('DOMContentLoaded', () => {
1315  document.querySelectorAll('pre code').forEach((block) => {
1316    hljs.highlightElement(block);
1317  });
1318});
1319"#;
1320
1321// ══════════════════════════════════════════════════════════════════════
1322// Tests
1323// ══════════════════════════════════════════════════════════════════════
1324
1325#[cfg(test)]
1326mod tests {
1327    use super::*;
1328    use crate::store::session::{AgentMessage, AssistantContentBlock, ContentValue};
1329
1330    fn make_entry(msg: AgentMessage) -> SessionEntry {
1331        SessionEntry {
1332            id: Uuid::new_v4().to_string(),
1333            parent_id: None,
1334            message: msg,
1335            timestamp: 1_700_000_000_000,
1336        }
1337    }
1338
1339    // ── HTML export tests ────────────────────────────────────────
1340
1341    #[test]
1342    fn export_produces_valid_html_structure() {
1343        let entries = vec![
1344            make_entry(AgentMessage::User {
1345                content: "Hello".into(),
1346            }),
1347            make_entry(AgentMessage::Assistant {
1348                content: vec![AssistantContentBlock::Text {
1349                    text: "Hi there!".into(),
1350                }],
1351                provider: None,
1352                model_id: None,
1353                usage: None,
1354                stop_reason: None,
1355            }),
1356        ];
1357        let meta = ExportMeta::default();
1358        let html = export_html(&entries, &meta, None, None).unwrap();
1359
1360        assert!(html.starts_with("<!DOCTYPE html>"));
1361        assert!(html.contains("<html"));
1362        assert!(html.contains("</html>"));
1363        assert!(html.contains("<head>"));
1364        assert!(html.contains("</head>"));
1365        assert!(html.contains("<body"));
1366        assert!(html.contains("</body>"));
1367        assert!(html.contains("msg-user"));
1368        assert!(html.contains("msg-assistant"));
1369        assert!(html.contains("You"));
1370        assert!(html.contains("Assistant"));
1371        assert!(html.contains("Hello"));
1372        assert!(html.contains("Hi there!"));
1373    }
1374
1375    #[test]
1376    fn export_renders_thinking_block_collapsible() {
1377        let entries = vec![make_entry(AgentMessage::Assistant {
1378            content: vec![AssistantContentBlock::Text {
1379                text: "<think\nLet me reason step by step.\n</think\n\nThe answer is 42.".into(),
1380            }],
1381            provider: None,
1382            model_id: None,
1383            usage: None,
1384            stop_reason: None,
1385        })];
1386        let meta = ExportMeta::default();
1387        let html = export_html(&entries, &meta, None, None).unwrap();
1388
1389        assert!(html.contains("<details class=\"thinking-block\">"));
1390        assert!(html.contains("<summary>💭 Thinking</summary>"));
1391        assert!(html.contains("Let me reason step by step."));
1392        assert!(html.contains("The answer is 42."));
1393    }
1394
1395    #[test]
1396    fn export_includes_metadata_header() {
1397        let entries = vec![];
1398        let meta = ExportMeta {
1399            model: Some("claude-sonnet-4".into()),
1400            provider: Some("anthropic".into()),
1401            exported_at: 1_700_000_000_000,
1402            total_user_tokens: Some(120),
1403            total_assistant_tokens: Some(350),
1404        };
1405        let html = export_html(&entries, &meta, None, None).unwrap();
1406
1407        assert!(html.contains("claude-sonnet-4"));
1408        assert!(html.contains("anthropic"));
1409        assert!(html.contains("120"));
1410        assert!(html.contains("350"));
1411        assert!(html.contains("User Tokens"));
1412        assert!(html.contains("Assistant Tokens"));
1413    }
1414
1415    #[test]
1416    fn export_renders_code_block_with_language_class() {
1417        let entries =
1418            vec![make_entry(AgentMessage::Assistant {
1419                content: vec![AssistantContentBlock::Text { text:
1420                "Here is some code:\n```rust\nfn main() {\n    println!(\"hi\");\n}\n```\nDone."
1421                    .into() }],
1422                provider: None,
1423                model_id: None,
1424                usage: None,
1425                stop_reason: None,
1426            })];
1427        let meta = ExportMeta::default();
1428        let html = export_html(&entries, &meta, None, None).unwrap();
1429
1430        assert!(html.contains("language-rust"));
1431        assert!(html.contains("fn main()"));
1432        assert!(html.contains("println!"));
1433    }
1434
1435    #[test]
1436    fn export_renders_session_tree_navigation() {
1437        let tree = TreeNode {
1438            session_id: Uuid::new_v4(),
1439            name: Some("root session".into()),
1440            is_current: false,
1441            children: vec![TreeNode {
1442                session_id: Uuid::new_v4(),
1443                name: Some("branch-1".into()),
1444                is_current: true,
1445                children: vec![],
1446            }],
1447        };
1448        let meta = ExportMeta::default();
1449        let html = export_html(&[], &meta, None, Some(&tree)).unwrap();
1450
1451        assert!(html.contains("tree-nav"));
1452        assert!(html.contains("tree-current"));
1453        assert!(html.contains("root session"));
1454        assert!(html.contains("branch-1"));
1455    }
1456
1457    #[test]
1458    fn export_dark_theme_default_with_toggle() {
1459        let meta = ExportMeta::default();
1460        let html = export_html(&[], &meta, None, None).unwrap();
1461
1462        assert!(html.contains("class=\"dark\""));
1463        assert!(html.contains("toggleTheme"));
1464        assert!(html.contains("theme-toggle"));
1465    }
1466
1467    // ── HtmlExportOptions tests ──────────────────────────────────
1468
1469    #[test]
1470    fn export_options_light_theme() {
1471        let options = HtmlExportOptions {
1472            dark_theme: false,
1473            ..Default::default()
1474        };
1475        let meta = ExportMeta::default();
1476        let html = export_html_with_options(&[], &meta, None, None, &options).unwrap();
1477        assert!(html.contains("class=\"light\""));
1478    }
1479
1480    #[test]
1481    fn export_options_custom_title() {
1482        let options = HtmlExportOptions {
1483            title: Some("My Session".into()),
1484            ..Default::default()
1485        };
1486        let meta = ExportMeta::default();
1487        let html = export_html_with_options(&[], &meta, None, None, &options).unwrap();
1488        assert!(html.contains("<title>My Session</title>"));
1489    }
1490
1491    #[test]
1492    fn export_options_skip_thinking() {
1493        let entries = vec![make_entry(AgentMessage::Assistant {
1494            content: vec![AssistantContentBlock::Text {
1495                text: "<thinking>\nSecret thoughts\n</thinking>\n\nVisible answer.".into(),
1496            }],
1497            provider: None,
1498            model_id: None,
1499            usage: None,
1500            stop_reason: None,
1501        })];
1502        let options = HtmlExportOptions {
1503            include_thinking: false,
1504            ..Default::default()
1505        };
1506        let meta = ExportMeta::default();
1507        let html = export_html_with_options(&entries, &meta, None, None, &options).unwrap();
1508        // Check that thinking content is not rendered (specific element check)
1509        assert!(!html.contains("<details class=\"thinking-block\">"));
1510        assert!(!html.contains("Secret thoughts"));
1511        assert!(html.contains("Visible answer"));
1512    }
1513
1514    #[test]
1515    fn export_options_skip_tool_calls() {
1516        let entries = vec![
1517            make_entry(AgentMessage::Assistant {
1518                content: vec![
1519                    AssistantContentBlock::Text {
1520                        text: "Let me run a command.".into(),
1521                    },
1522                    AssistantContentBlock::ToolCall {
1523                        id: "tc-1".into(),
1524                        name: "bash".into(),
1525                        arguments: serde_json::json!({"command": "ls -la"}),
1526                    },
1527                ],
1528                provider: None,
1529                model_id: None,
1530                usage: None,
1531                stop_reason: None,
1532            }),
1533            make_entry(AgentMessage::ToolResult {
1534                content: "file1.txt\nfile2.txt".into(),
1535                tool_call_id: "tc-1".to_string(),
1536            }),
1537        ];
1538        let options = HtmlExportOptions {
1539            include_tool_calls: false,
1540            ..Default::default()
1541        };
1542        let meta = ExportMeta::default();
1543        let html = export_html_with_options(&entries, &meta, None, None, &options).unwrap();
1544        // Text still renders
1545        assert!(html.contains("Let me run a command."));
1546        // Tool call and result are hidden
1547        assert!(!html.contains("<div class=\"tool-call"));
1548        assert!(!html.contains("<div class=\"tool-result"));
1549        assert!(!html.contains("ls -la"));
1550    }
1551
1552    #[test]
1553    fn export_to_html_convenience() {
1554        let entries = vec![make_entry(AgentMessage::User {
1555            content: "Hello".into(),
1556        })];
1557        let meta = ExportMeta::default();
1558        let options = HtmlExportOptions::default();
1559        let html = export_to_html(&entries, &meta, &options).unwrap();
1560        assert!(html.contains("Hello"));
1561    }
1562
1563    // ── ANSI-to-HTML tests ───────────────────────────────────────
1564
1565    #[test]
1566    fn ansi_to_html_plain_text_unchanged() {
1567        assert_eq!(ansi_to_html("Hello world"), "Hello world");
1568    }
1569
1570    #[test]
1571    fn ansi_to_html_escapes_html_chars() {
1572        assert_eq!(
1573            ansi_to_html("<script>alert('xss')</script>"),
1574            "&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;"
1575        );
1576    }
1577
1578    #[test]
1579    fn ansi_to_html_standard_foreground_colors() {
1580        // Red foreground: ESC[31m
1581        let input = "\x1b[31mError\x1b[0m";
1582        let result = ansi_to_html(input);
1583        assert!(result.contains("color:#800000"));
1584        assert!(result.contains("Error"));
1585        assert!(result.contains("</span>"));
1586    }
1587
1588    #[test]
1589    fn ansi_to_html_bright_foreground_colors() {
1590        // Bright red: ESC[91m
1591        let input = "\x1b[91mWarning\x1b[0m";
1592        let result = ansi_to_html(input);
1593        assert!(result.contains("color:#ff0000"));
1594        assert!(result.contains("Warning"));
1595    }
1596
1597    #[test]
1598    fn ansi_to_html_standard_background_colors() {
1599        // Blue background: ESC[44m
1600        let input = "\x1b[44mBlue bg\x1b[0m";
1601        let result = ansi_to_html(input);
1602        assert!(result.contains("background-color:#000080"));
1603        assert!(result.contains("Blue bg"));
1604    }
1605
1606    #[test]
1607    fn ansi_to_html_bright_background_colors() {
1608        // Bright yellow background: ESC[103m
1609        let input = "\x1b[103mBright yellow\x1b[0m";
1610        let result = ansi_to_html(input);
1611        assert!(result.contains("background-color:#ffff00"));
1612        assert!(result.contains("Bright yellow"));
1613    }
1614
1615    #[test]
1616    fn ansi_to_html_bold() {
1617        let input = "\x1b[1mBold text\x1b[0m";
1618        let result = ansi_to_html(input);
1619        assert!(result.contains("font-weight:bold"));
1620        assert!(result.contains("Bold text"));
1621    }
1622
1623    #[test]
1624    fn ansi_to_html_italic() {
1625        let input = "\x1b[3mItalic text\x1b[0m";
1626        let result = ansi_to_html(input);
1627        assert!(result.contains("font-style:italic"));
1628        assert!(result.contains("Italic text"));
1629    }
1630
1631    #[test]
1632    fn ansi_to_html_underline() {
1633        let input = "\x1b[4mUnderlined\x1b[0m";
1634        let result = ansi_to_html(input);
1635        assert!(result.contains("text-decoration:underline"));
1636        assert!(result.contains("Underlined"));
1637    }
1638
1639    #[test]
1640    fn ansi_to_html_strikethrough() {
1641        let input = "\x1b[9mStruck\x1b[0m";
1642        let result = ansi_to_html(input);
1643        assert!(result.contains("text-decoration:line-through"));
1644        assert!(result.contains("Struck"));
1645    }
1646
1647    #[test]
1648    fn ansi_to_html_dim() {
1649        let input = "\x1b[2mDimmed\x1b[0m";
1650        let result = ansi_to_html(input);
1651        assert!(result.contains("opacity:0.6"));
1652        assert!(result.contains("Dimmed"));
1653    }
1654
1655    #[test]
1656    fn ansi_to_html_256_color() {
1657        // 256-color: ESC[38;5;196m (bright red from color cube)
1658        let input = "\x1b[38;5;196mCustom color\x1b[0m";
1659        let result = ansi_to_html(input);
1660        assert!(result.contains("color:#"));
1661        assert!(result.contains("Custom color"));
1662    }
1663
1664    #[test]
1665    fn ansi_to_html_true_color_rgb() {
1666        // True color: ESC[38;2;255;128;0m (orange)
1667        let input = "\x1b[38;2;255;128;0mOrange\x1b[0m";
1668        let result = ansi_to_html(input);
1669        assert!(result.contains("color:rgb(255,128,0)"));
1670        assert!(result.contains("Orange"));
1671    }
1672
1673    #[test]
1674    fn ansi_to_html_background_true_color() {
1675        let input = "\x1b[48;2;0;128;255mBlue bg\x1b[0m";
1676        let result = ansi_to_html(input);
1677        assert!(result.contains("background-color:rgb(0,128,255)"));
1678        assert!(result.contains("Blue bg"));
1679    }
1680
1681    #[test]
1682    fn ansi_to_html_reset_clears_styles() {
1683        let input = "\x1b[1;31mBold Red\x1b[0m Normal";
1684        let result = ansi_to_html(input);
1685        assert!(result.contains("font-weight:bold"));
1686        assert!(result.contains("color:#800000"));
1687        assert!(result.contains(" Normal"));
1688    }
1689
1690    #[test]
1691    fn ansi_to_html_multiple_styles_combined() {
1692        // Bold + Red + Underline
1693        let input = "\x1b[1;4;31mBold Red Underlined\x1b[0m";
1694        let result = ansi_to_html(input);
1695        assert!(result.contains("font-weight:bold"));
1696        assert!(result.contains("text-decoration:underline"));
1697        assert!(result.contains("color:#800000"));
1698        assert!(result.contains("Bold Red Underlined"));
1699    }
1700
1701    #[test]
1702    fn ansi_to_html_no_escapes_returns_plain() {
1703        assert_eq!(ansi_to_html("No escapes here"), "No escapes here");
1704    }
1705
1706    #[test]
1707    fn ansi_to_html_256_color_standard_range() {
1708        // Color index 2 = green
1709        let input = "\x1b[38;5;2mGreen\x1b[0m";
1710        let result = ansi_to_html(input);
1711        assert!(result.contains("color:#008000"));
1712    }
1713
1714    #[test]
1715    fn ansi_to_html_256_color_grayscale() {
1716        // Grayscale index 232 = very dark gray
1717        let input = "\x1b[38;5;232mDark gray\x1b[0m";
1718        let result = ansi_to_html(input);
1719        assert!(result.contains("color:#"));
1720        assert!(result.contains("Dark gray"));
1721    }
1722
1723    #[test]
1724    fn ansi_to_html_background_256() {
1725        let input = "\x1b[48;5;4mBlue bg\x1b[0m";
1726        let result = ansi_to_html(input);
1727        assert!(result.contains("background-color:#000080"));
1728    }
1729
1730    #[test]
1731    fn ansi_lines_to_html_wraps_lines() {
1732        let lines = vec!["Line 1", "Line 2", ""];
1733        let result = ansi_lines_to_html(&lines);
1734        assert!(result.contains("<div class=\"ansi-line\">Line 1</div>"));
1735        assert!(result.contains("<div class=\"ansi-line\">Line 2</div>"));
1736        assert!(result.contains("&nbsp;</div>"));
1737    }
1738
1739    // ── Color conversion tests ───────────────────────────────────
1740
1741    #[test]
1742    fn color_256_standard_colors() {
1743        assert_eq!(color_256_to_hex(0), "#000000");
1744        assert_eq!(color_256_to_hex(7), "#c0c0c0");
1745        assert_eq!(color_256_to_hex(15), "#ffffff");
1746    }
1747
1748    #[test]
1749    fn color_256_cube_colors() {
1750        // Index 16 = first cube color (black)
1751        assert_eq!(color_256_to_hex(16), "#000000");
1752        // Index 231 = last cube color (white)
1753        assert_eq!(color_256_to_hex(231), "#ffffff");
1754    }
1755
1756    #[test]
1757    fn color_256_grayscale() {
1758        // Index 232 = darkest gray
1759        assert_eq!(color_256_to_hex(232), "#080808");
1760        // Index 255 = lightest gray
1761        assert_eq!(color_256_to_hex(255), "#eeeeee");
1762    }
1763
1764    // ── Markdown tests ───────────────────────────────────────────
1765
1766    #[test]
1767    fn markdown_renders_bold_and_italic() {
1768        let result = render_markdown("This is **bold** and *italic* text.");
1769        assert!(result.contains("<strong>bold</strong>"));
1770        assert!(result.contains("<em>italic</em>"));
1771    }
1772
1773    #[test]
1774    fn markdown_renders_inline_code() {
1775        let result = render_markdown("Use `cargo build` to compile.");
1776        assert!(result.contains("<code>cargo build</code>"));
1777    }
1778
1779    #[test]
1780    fn markdown_renders_links() {
1781        let result = render_markdown("See [docs](https://example.com) for info.");
1782        assert!(result.contains("<a href=\"https://example.com\">docs</a>"));
1783    }
1784
1785    #[test]
1786    fn html_escape_prevents_xss() {
1787        let escaped = html_escape("<script>alert('xss')</script>");
1788        assert!(!escaped.contains('<'));
1789        assert!(escaped.contains("&lt;script"));
1790    }
1791
1792    // ── Tool rendering tests (structural input) ─────────────────
1793
1794    #[test]
1795    fn tool_call_block_renders_bash() {
1796        let args = serde_json::json!({"command": "ls -la"});
1797        let html = render_tool_call_block("bash", &args);
1798        assert!(html.contains("tool-call"));
1799        assert!(html.contains("tool-bash"));
1800        assert!(html.contains("ls -la"));
1801    }
1802
1803    #[test]
1804    fn tool_call_block_renders_read() {
1805        let args = serde_json::json!({"path": "/src/main.rs"});
1806        let html = render_tool_call_block("read", &args);
1807        assert!(html.contains("tool-call"));
1808        assert!(html.contains("/src/main.rs"));
1809    }
1810
1811    #[test]
1812    fn tool_call_block_renders_write() {
1813        let args = serde_json::json!({"path": "/out.txt"});
1814        let html = render_tool_call_block("write", &args);
1815        assert!(html.contains("tool-call"));
1816        assert!(html.contains("/out.txt"));
1817    }
1818
1819    #[test]
1820    fn tool_call_block_renders_edit() {
1821        let args = serde_json::json!({"path": "/src/lib.rs"});
1822        let html = render_tool_call_block("edit", &args);
1823        assert!(html.contains("tool-call"));
1824        assert!(html.contains("/src/lib.rs"));
1825    }
1826
1827    #[test]
1828    fn tool_call_block_renders_grep() {
1829        let args = serde_json::json!({"pattern": "TODO"});
1830        let html = render_tool_call_block("grep", &args);
1831        assert!(html.contains("tool-call"));
1832        assert!(html.contains("[G]"));
1833        assert!(html.contains("TODO"));
1834    }
1835
1836    #[test]
1837    fn tool_call_block_renders_find() {
1838        let args = serde_json::json!({"path": ".", "name": "*.rs"});
1839        let html = render_tool_call_block("find", &args);
1840        assert!(html.contains("tool-call"));
1841        assert!(html.contains("[F]"));
1842        // Should show the glob pattern (name), not the directory (path)
1843        assert!(html.contains("*.rs"));
1844        assert!(!html.contains("tool-label\">[F] .</div>")); // directory "." not shown
1845    }
1846
1847    #[test]
1848    fn tool_call_block_unknown_tool_falls_back() {
1849        let args = serde_json::json!({"key": "value"});
1850        let html = render_tool_call_block("frobnicate", &args);
1851        assert!(html.contains("tool-call"));
1852        assert!(html.contains("frobnicate"));
1853        assert!(html.contains("&quot;key&quot;"));
1854    }
1855
1856    #[test]
1857    fn tool_result_block_renders_content_and_id() {
1858        let content = ContentValue::String("command output".into());
1859        let html = render_tool_result_block(&content, "tc-42");
1860        assert!(html.contains("tool-result"));
1861        assert!(html.contains("data-tool-call-id=\"tc-42\""));
1862        assert!(html.contains("command output"));
1863    }
1864
1865    #[test]
1866    fn tool_result_block_no_msg_wrapper() {
1867        let content = ContentValue::String("output".into());
1868        let html = render_tool_result_block(&content, "tc-1");
1869        // Must NOT contain a msg-* wrapper
1870        assert!(!html.contains("msg msg-"));
1871    }
1872
1873    #[test]
1874    fn export_assistant_tool_call_renders_in_order() {
1875        let entries = vec![make_entry(AgentMessage::Assistant {
1876            content: vec![
1877                AssistantContentBlock::Text {
1878                    text: "alpha-marker".into(),
1879                },
1880                AssistantContentBlock::ToolCall {
1881                    id: "tc-1".into(),
1882                    name: "bash".into(),
1883                    arguments: serde_json::json!({"command": "echo hi"}),
1884                },
1885                AssistantContentBlock::Text {
1886                    text: "omega-marker".into(),
1887                },
1888            ],
1889            provider: None,
1890            model_id: None,
1891            usage: None,
1892            stop_reason: None,
1893        })];
1894        let meta = ExportMeta::default();
1895        let html = export_html(&entries, &meta, None, None).unwrap();
1896        let pos_before = html.find("alpha-marker").unwrap();
1897        let pos_tool = html.find("<div class=\"tool-call").unwrap();
1898        let pos_after = html.find("omega-marker").unwrap();
1899        assert!(pos_before < pos_tool);
1900        assert!(pos_tool < pos_after);
1901    }
1902
1903    #[test]
1904    fn export_tool_result_entry_renders() {
1905        let entries = vec![
1906            make_entry(AgentMessage::Assistant {
1907                content: vec![AssistantContentBlock::ToolCall {
1908                    id: "tc-1".into(),
1909                    name: "bash".into(),
1910                    arguments: serde_json::json!({"command": "ls"}),
1911                }],
1912                provider: None,
1913                model_id: None,
1914                usage: None,
1915                stop_reason: None,
1916            }),
1917            make_entry(AgentMessage::ToolResult {
1918                content: "output here".into(),
1919                tool_call_id: "tc-1".to_string(),
1920            }),
1921        ];
1922        let meta = ExportMeta::default();
1923        let html = export_html(&entries, &meta, None, None).unwrap();
1924        assert!(html.contains("<div class=\"tool-result"));
1925        assert!(html.contains("data-tool-call-id=\"tc-1\""));
1926        assert!(html.contains("output here"));
1927    }
1928}