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 ───────────────────────────────────────────────────
415
416/// Information about a detected tool operation in message text.
417/// Keep: used internally for structured tool rendering; may be exposed in future export formats.
418#[derive(Debug, Clone)]
419#[allow(dead_code)]
420enum ToolOp {
421    Bash {
422        command: String,
423        output: String,
424        exit_code: Option<i32>,
425    },
426    FileRead {
427        path: String,
428        content: String,
429    },
430    FileWrite {
431        path: String,
432        content: String,
433    },
434    FileEdit {
435        path: String,
436        old_text: String,
437        new_text: String,
438    },
439    Search {
440        query: String,
441        results: Vec<String>,
442    },
443}
444
445/// Detect and render tool operations embedded in assistant message content.
446fn render_tool_blocks(content: &str, include_tool_calls: bool) -> Option<String> {
447    if !include_tool_calls {
448        return None;
449    }
450
451    let mut html = String::new();
452    let mut found = false;
453    let mut lines = content.lines().peekable();
454
455    while let Some(line) = lines.next() {
456        // Detect bash tool call: 🔧 followed by bash command in code block
457        if (line.starts_with("🔧 Running bash") || line.starts_with("🔧 bash"))
458            && lines.peek().is_some_and(|l| l.starts_with("```"))
459        {
460            found = true;
461            // Consume the ``` line
462            let _code_fence = lines.next();
463            let mut cmd = String::new();
464            let mut output_lines = Vec::new();
465            let mut in_output = false;
466
467            // Read command (single line typically)
468            if let Some(cmd_line) = lines.next() {
469                cmd.push_str(cmd_line);
470            }
471
472            // Look for output section
473            for line in lines.by_ref() {
474                if line.starts_with("```") {
475                    // End of code block
476                    break;
477                }
478                if line.starts_with("📤") || line.starts_with("result:") {
479                    in_output = true;
480                    continue;
481                }
482                if in_output {
483                    output_lines.push(line.to_string());
484                } else {
485                    // Still in command or before result marker
486                    cmd.push('\n');
487                    cmd.push_str(line);
488                }
489            }
490
491            html.push_str(&render_bash_tool(&cmd, &output_lines.join("\n")));
492            continue;
493        }
494
495        // Detect file read: lines with "📄" or "read" + path + code block
496        if (line.starts_with("📄 Reading") || line.starts_with("📄 read"))
497            && lines.peek().is_some_and(|l| l.starts_with("```"))
498        {
499            found = true;
500            let path = extract_path_from_line(line);
501            let _fence = lines.next(); // ```
502            let _lang = "";
503            let mut content_buf = String::new();
504            for line in lines.by_ref() {
505                if line.starts_with("```") {
506                    break;
507                }
508                content_buf.push_str(line);
509                content_buf.push('\n');
510            }
511            html.push_str(&render_file_read_tool(&path, &content_buf));
512            continue;
513        }
514
515        // Detect file write: "📝 Writing" or "📝 write" + path + code block
516        if (line.starts_with("📝 Writing") || line.starts_with("📝 write"))
517            && lines.peek().is_some_and(|l| l.starts_with("```"))
518        {
519            found = true;
520            let path = extract_path_from_line(line);
521            let _fence = lines.next();
522            let mut content_buf = String::new();
523            for line in lines.by_ref() {
524                if line.starts_with("```") {
525                    break;
526                }
527                content_buf.push_str(line);
528                content_buf.push('\n');
529            }
530            html.push_str(&render_file_write_tool(&path, &content_buf));
531            continue;
532        }
533
534        // Detect file edit: "✏️ Editing" or "✏️ edit" + path
535        if line.starts_with("✏️ Editing") || line.starts_with("✏️ edit") {
536            found = true;
537            let path = extract_path_from_line(line);
538            let mut old_text = String::new();
539            let mut new_text = String::new();
540
541            // Consume lines looking for old/new sections
542            while let Some(next) = lines.peek() {
543                if next.starts_with("🔧")
544                    || next.starts_with("📄")
545                    || next.starts_with("📝")
546                    || next.starts_with("✏️")
547                    || next.starts_with("📤")
548                {
549                    break;
550                }
551                let Some(l) = lines.next() else {
552                    break;
553                };
554                if l.contains("old:") || l.contains("Old text:") {
555                    // Collect old text
556                    while let Some(next) = lines.peek() {
557                        if next.contains("new:") || next.contains("New text:") {
558                            break;
559                        }
560                        let Some(ol) = lines.next() else {
561                            break;
562                        };
563                        if ol.starts_with("```") {
564                            continue;
565                        }
566                        old_text.push_str(ol);
567                        old_text.push('\n');
568                    }
569                } else if l.contains("new:") || l.contains("New text:") {
570                    while let Some(next) = lines.peek() {
571                        if next.starts_with("🔧")
572                            || next.starts_with("📄")
573                            || next.starts_with("📝")
574                            || next.starts_with("✏️")
575                            || next.starts_with("📤")
576                        {
577                            break;
578                        }
579                        let Some(nl) = lines.next() else {
580                            break;
581                        };
582                        if nl.starts_with("```") {
583                            continue;
584                        }
585                        new_text.push_str(nl);
586                        new_text.push('\n');
587                    }
588                }
589            }
590            html.push_str(&render_file_edit_tool(&path, &old_text, &new_text));
591            continue;
592        }
593
594        // Detect search: "🔍" or "grep"/"find"
595        if line.starts_with("🔍 Searching")
596            || line.starts_with("🔍 grep")
597            || line.starts_with("🔍 find")
598        {
599            found = true;
600            let query = line
601                .trim_start_matches(|c: char| !c.is_alphanumeric())
602                .trim()
603                .to_string();
604            let mut results = Vec::new();
605
606            while let Some(next) = lines.peek() {
607                if next.starts_with("🔧")
608                    || next.starts_with("📄")
609                    || next.starts_with("📝")
610                    || next.starts_with("✏️")
611                    || next.starts_with("📤")
612                    || next.trim().is_empty()
613                {
614                    break;
615                }
616                if let Some(r) = lines.next() {
617                    results.push(r.to_string());
618                }
619            }
620            html.push_str(&render_search_tool(&query, &results));
621            continue;
622        }
623    }
624
625    if found { Some(html) } else { None }
626}
627
628/// Extract a file path from a tool operation header line.
629fn extract_path_from_line(line: &str) -> String {
630    // Try to extract path after common patterns
631    let line = line.trim();
632    for prefix in &[
633        "📄 Reading ",
634        "📄 reading ",
635        "📄 Read ",
636        "📄 read ",
637        "📝 Writing ",
638        "📝 writing ",
639        "📝 Write ",
640        "📝 write ",
641        "✏️ Editing ",
642        "✏️ editing ",
643        "✏️ Edit ",
644        "✏️ edit ",
645    ] {
646        if let Some(rest) = line.strip_prefix(prefix) {
647            return rest.trim().trim_end_matches(':').to_string();
648        }
649    }
650    line.to_string()
651}
652
653/// Render a bash tool call and its output as styled HTML.
654fn render_bash_tool(command: &str, output: &str) -> String {
655    let mut html = String::new();
656    html.push_str("<div class=\"tool-block tool-bash\">\n");
657    html.push_str("<div class=\"tool-label\">⌨ Bash</div>\n");
658    html.push_str("<pre class=\"tool-command\"><code>");
659    html.push_str(&html_escape(command.trim()));
660    html.push_str("</code></pre>\n");
661    if !output.trim().is_empty() {
662        html.push_str("<details class=\"tool-output-details\">\n");
663        html.push_str("<summary>Output</summary>\n");
664        html.push_str("<pre class=\"tool-output\"><code>");
665        html.push_str(&html_escape(output.trim()));
666        html.push_str("</code></pre>\n");
667        html.push_str("</details>\n");
668    }
669    html.push_str("</div>\n");
670    html
671}
672
673/// Render a file read operation as styled HTML.
674fn render_file_read_tool(path: &str, content: &str) -> String {
675    let mut html = String::new();
676    html.push_str("<div class=\"tool-block tool-file-read\">\n");
677    html.push_str("<div class=\"tool-label\">📄 Read: ");
678    html.push_str(&html_escape(path));
679    html.push_str("</div>\n");
680    html.push_str("<details class=\"tool-output-details\" open>\n");
681    html.push_str("<summary>Content</summary>\n");
682    html.push_str("<pre class=\"tool-output\"><code>");
683    html.push_str(&html_escape(content.trim()));
684    html.push_str("</code></pre>\n");
685    html.push_str("</details>\n");
686    html.push_str("</div>\n");
687    html
688}
689
690/// Render a file write operation as styled HTML.
691fn render_file_write_tool(path: &str, content: &str) -> String {
692    let mut html = String::new();
693    html.push_str("<div class=\"tool-block tool-file-write\">\n");
694    html.push_str("<div class=\"tool-label\">📝 Write: ");
695    html.push_str(&html_escape(path));
696    html.push_str("</div>\n");
697    html.push_str("<details class=\"tool-output-details\">\n");
698    html.push_str("<summary>Content</summary>\n");
699    html.push_str("<pre class=\"tool-output\"><code>");
700    html.push_str(&html_escape(content.trim()));
701    html.push_str("</code></pre>\n");
702    html.push_str("</details>\n");
703    html.push_str("</div>\n");
704    html
705}
706
707/// Render a file edit operation as styled HTML.
708fn render_file_edit_tool(path: &str, old_text: &str, new_text: &str) -> String {
709    let mut html = String::new();
710    html.push_str("<div class=\"tool-block tool-file-edit\">\n");
711    html.push_str("<div class=\"tool-label\">✏️ Edit: ");
712    html.push_str(&html_escape(path));
713    html.push_str("</div>\n");
714
715    if !old_text.trim().is_empty() {
716        html.push_str("<div class=\"edit-section edit-old\">\n");
717        html.push_str("<div class=\"edit-label\">− Removed</div>\n");
718        html.push_str("<pre class=\"tool-output\"><code>");
719        html.push_str(&html_escape(old_text.trim()));
720        html.push_str("</code></pre>\n");
721        html.push_str("</div>\n");
722    }
723
724    if !new_text.trim().is_empty() {
725        html.push_str("<div class=\"edit-section edit-new\">\n");
726        html.push_str("<div class=\"edit-label\">+ Added</div>\n");
727        html.push_str("<pre class=\"tool-output\"><code>");
728        html.push_str(&html_escape(new_text.trim()));
729        html.push_str("</code></pre>\n");
730        html.push_str("</div>\n");
731    }
732
733    html.push_str("</div>\n");
734    html
735}
736
737/// Render search results as styled HTML.
738fn render_search_tool(query: &str, results: &[String]) -> String {
739    let mut html = String::new();
740    html.push_str("<div class=\"tool-block tool-search\">\n");
741    html.push_str("<div class=\"tool-label\">🔍 Search: ");
742    html.push_str(&html_escape(query));
743    html.push_str("</div>\n");
744
745    if results.is_empty() {
746        html.push_str("<div class=\"tool-no-results\">No results found</div>\n");
747    } else {
748        html.push_str("<div class=\"search-results\">\n");
749        for result in results {
750            // Render with ANSI support (grep output often has colors)
751            let rendered = ansi_to_html(result);
752            html.push_str("<div class=\"search-result-line\">");
753            html.push_str(&rendered);
754            html.push_str("</div>\n");
755        }
756        html.push_str("</div>\n");
757    }
758
759    html.push_str("</div>\n");
760    html
761}
762
763// ── Core export functions ────────────────────────────────────────────
764
765/// Render a flat list of session entries into a self-contained HTML string.
766///
767/// `tree` is optional – when provided, a sidebar with session-tree navigation
768/// is included.
769#[allow(dead_code)]
770pub fn export_html(
771    entries: &[SessionEntry],
772    meta: &ExportMeta,
773    session_meta: Option<&SessionMeta>,
774    tree: Option<&TreeNode>,
775) -> Result<String> {
776    export_html_with_options(
777        entries,
778        meta,
779        session_meta,
780        tree,
781        &HtmlExportOptions::default(),
782    )
783}
784
785/// Render a flat list of session entries into a self-contained HTML string
786/// with fine-grained control over the output via [`HtmlExportOptions`].
787pub fn export_html_with_options(
788    entries: &[SessionEntry],
789    meta: &ExportMeta,
790    session_meta: Option<&SessionMeta>,
791    tree: Option<&TreeNode>,
792    options: &HtmlExportOptions,
793) -> Result<String> {
794    let mut html = String::with_capacity(64 * 1024);
795
796    // ── Head ──────────────────────────────────────────────────────
797    html.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n");
798    html.push_str("<meta charset=\"utf-8\">\n");
799    html.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
800
801    let title = options
802        .title
803        .as_deref()
804        .or(session_meta.and_then(|m| m.name.as_deref()))
805        .unwrap_or("oxi session export");
806    writeln!(html, "<title>{}</title>", html_escape(title))?;
807
808    // highlight.js CDN for syntax highlighting
809    html.push_str(
810        "<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css\" id=\"hljs-dark\">\n",
811    );
812    html.push_str(
813        "<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css\" id=\"hljs-light\" disabled>\n",
814    );
815    html.push_str(
816        "<script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>\n",
817    );
818
819    // Embedded CSS
820    html.push_str("<style>\n");
821    html.push_str(CSS);
822    html.push_str("\n</style>\n");
823    html.push_str("</head>\n");
824
825    // ── Body ──────────────────────────────────────────────────────
826    let theme_class = if options.dark_theme { "dark" } else { "light" };
827    writeln!(html, "<body class=\"{}\">", theme_class)?;
828
829    // Theme toggle button
830    html.push_str(
831        "<button id=\"theme-toggle\" onclick=\"toggleTheme()\" title=\"Toggle light/dark theme\">",
832    );
833    html.push_str("🌓</button>\n");
834
835    // Optional tree sidebar
836    if let Some(node) = tree {
837        html.push_str("<nav class=\"tree-nav\">\n<h3>Session Tree</h3>\n");
838        render_tree_node(&mut html, node, 0)?;
839        html.push_str("</nav>\n");
840    }
841
842    // Main content
843    html.push_str("<main class=\"content\">\n");
844
845    // Metadata header
846    render_meta_header(&mut html, meta, session_meta)?;
847
848    // Messages
849    for entry in entries {
850        render_entry(&mut html, entry, options)?;
851    }
852
853    html.push_str("</main>\n");
854
855    // Embedded JS
856    html.push_str("<script>\n");
857    html.push_str(JS);
858    html.push_str("\n</script>\n");
859
860    html.push_str("</body>\n</html>\n");
861    Ok(html)
862}
863
864/// Convenience function: export session entries to an HTML string using the
865/// given [`HtmlExportOptions`].
866pub fn export_to_html(
867    entries: &[SessionEntry],
868    meta: &ExportMeta,
869    options: &HtmlExportOptions,
870) -> Result<String> {
871    export_html_with_options(entries, meta, None, None, options)
872}
873
874// ── Internal rendering helpers ───────────────────────────────────────
875
876fn render_meta_header(
877    html: &mut String,
878    meta: &ExportMeta,
879    session_meta: Option<&SessionMeta>,
880) -> Result<()> {
881    html.push_str("<header class=\"meta-header\">\n");
882    html.push_str("<h1>oxi Session Export</h1>\n");
883    html.push_str("<table class=\"meta-table\">\n");
884
885    let exported_dt = DateTime::from_timestamp_millis(meta.exported_at)
886        .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
887        .unwrap_or_else(|| "unknown".to_string());
888    render_meta_row(html, "Exported", &exported_dt)?;
889
890    if let Some(model) = &meta.model {
891        render_meta_row(html, "Model", model)?;
892    }
893    if let Some(provider) = &meta.provider {
894        render_meta_row(html, "Provider", provider)?;
895    }
896    if let Some(sm) = session_meta {
897        let created_dt = DateTime::from_timestamp_millis(sm.created_at)
898            .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
899            .unwrap_or_else(|| "unknown".to_string());
900        render_meta_row(html, "Session ID", &sm.id.to_string())?;
901        render_meta_row(html, "Created", &created_dt)?;
902        if let Some(name) = &sm.name {
903            render_meta_row(html, "Name", name)?;
904        }
905    }
906    if let Some(t) = meta.total_user_tokens {
907        render_meta_row(html, "User Tokens", &t.to_string())?;
908    }
909    if let Some(t) = meta.total_assistant_tokens {
910        render_meta_row(html, "Assistant Tokens", &t.to_string())?;
911    }
912
913    html.push_str("</table>\n</header>\n");
914    Ok(())
915}
916
917fn render_meta_row(html: &mut String, label: &str, value: &str) -> Result<()> {
918    writeln!(
919        html,
920        "<tr><td class=\"meta-label\">{}</td><td class=\"meta-value\">{}</td></tr>",
921        html_escape(label),
922        html_escape(value)
923    )?;
924    Ok(())
925}
926
927fn render_entry(
928    html: &mut String,
929    entry: &SessionEntry,
930    options: &HtmlExportOptions,
931) -> Result<()> {
932    let ts = DateTime::from_timestamp_millis(entry.timestamp)
933        .map(|dt| dt.format("%H:%M:%S").to_string())
934        .unwrap_or_default();
935
936    match &entry.message {
937        AgentMessage::User { content } => {
938            html.push_str("<div class=\"msg msg-user\">\n");
939            html.push_str("<div class=\"msg-header\"><span class=\"msg-role\">You</span>");
940            write!(html, "<span class=\"msg-time\">{}</span>", html_escape(&ts))?;
941            html.push_str("</div>\n");
942            html.push_str("<div class=\"msg-body\">");
943            let content_str: String = match content {
944                crate::store::session::ContentValue::String(s) => s.clone(),
945                crate::store::session::ContentValue::Blocks(blocks) => {
946                    let mut text = String::new();
947                    for block in blocks {
948                        if let crate::store::session::ContentBlock::Text { text: t } = block {
949                            text.push_str(t);
950                            text.push('\n');
951                        }
952                    }
953                    text.trim().to_string()
954                }
955            };
956            html.push_str(&render_markdown_with_options(&content_str, options));
957            html.push_str("</div>\n</div>\n");
958        }
959        AgentMessage::Assistant { content, .. } => {
960            html.push_str("<div class=\"msg msg-assistant\">\n");
961            html.push_str("<div class=\"msg-header\"><span class=\"msg-role\">Assistant</span>");
962            write!(html, "<span class=\"msg-time\">{}</span>", html_escape(&ts))?;
963            html.push_str("</div>\n");
964            html.push_str("<div class=\"msg-body\">");
965
966            // Extract text content for markdown rendering
967            let mut text_content = String::new();
968            for block in content {
969                if let crate::store::session::AssistantContentBlock::Text { text } = block {
970                    text_content.push_str(text);
971                    text_content.push('\n');
972                }
973            }
974
975            let text_str = text_content.trim().to_string();
976
977            // Try tool rendering first for assistant messages
978            if let Some(tool_html) = render_tool_blocks(&text_str, options.include_tool_calls) {
979                html.push_str(&tool_html);
980                // Also render the non-tool content via markdown
981                html.push_str(&render_markdown_with_options(&text_str, options));
982            } else {
983                html.push_str(&render_markdown_with_options(&text_str, options));
984            }
985
986            html.push_str("</div>\n</div>\n");
987        }
988        AgentMessage::System { content } => {
989            html.push_str("<div class=\"msg msg-system\">\n");
990            html.push_str("<div class=\"msg-header\"><span class=\"msg-role\">System</span>");
991            write!(html, "<span class=\"msg-time\">{}</span>", html_escape(&ts))?;
992            html.push_str("</div>\n");
993            html.push_str("<div class=\"msg-body\">");
994            let content_str: String = match content {
995                crate::store::session::ContentValue::String(s) => s.clone(),
996                crate::store::session::ContentValue::Blocks(blocks) => {
997                    let mut text = String::new();
998                    for block in blocks {
999                        if let crate::store::session::ContentBlock::Text { text: t } = block {
1000                            text.push_str(t);
1001                            text.push('\n');
1002                        }
1003                    }
1004                    text.trim().to_string()
1005                }
1006            };
1007            html.push_str(&render_markdown_with_options(&content_str, options));
1008            html.push_str("</div>\n</div>\n");
1009        }
1010        // Handle other message types (render them as system for simplicity)
1011        _ => {
1012            let content = entry.content();
1013            if !content.is_empty() {
1014                html.push_str("<div class=\"msg msg-system\">\n");
1015                html.push_str("<div class=\"msg-header\"><span class=\"msg-role\">System</span>");
1016                write!(html, "<span class=\"msg-time\">{}</span>", html_escape(&ts))?;
1017                html.push_str("</div>\n");
1018                html.push_str("<div class=\"msg-body\">");
1019                html.push_str(&render_markdown_with_options(&content, options));
1020                html.push_str("</div>\n</div>\n");
1021            }
1022        }
1023    }
1024    Ok(())
1025}
1026
1027/// Recursively render a session-tree sidebar node.
1028fn render_tree_node(html: &mut String, node: &TreeNode, depth: usize) -> Result<()> {
1029    let indent = "&nbsp;".repeat(depth * 4);
1030    let current = if node.is_current { " tree-current" } else { "" };
1031    let fallback = node.session_id.to_string();
1032    let short_id = &fallback[..8.min(fallback.len())];
1033    let name = node.name.as_deref().unwrap_or(short_id);
1034    writeln!(
1035        html,
1036        "<div class=\"tree-node{}\">{}<a href=\"#\">{}</a></div>",
1037        current,
1038        indent,
1039        html_escape(name)
1040    )?;
1041    for child in &node.children {
1042        render_tree_node(html, child, depth + 1)?;
1043    }
1044    Ok(())
1045}
1046
1047// ── Minimal markdown → HTML renderer ─────────────────────────────────
1048//
1049// Handles: code blocks (```), inline code (`), bold (**), italic (*),
1050// headers (#), unordered lists (- ), links, and paragraphs.
1051// This is intentionally lightweight to avoid pulling in a heavy crate.
1052
1053/// Convenience wrapper — renders markdown to HTML with default options.
1054/// Keep: public convenience API for callers that don't need custom options.
1055#[allow(dead_code)]
1056fn render_markdown(input: &str) -> String {
1057    render_markdown_with_options(input, &HtmlExportOptions::default())
1058}
1059
1060/// Core markdown-to-HTML renderer (lightweight, no external crate).
1061/// Keep: available for future HTML export pipelines and testable in isolation.
1062#[allow(dead_code)]
1063fn render_markdown_with_options(input: &str, options: &HtmlExportOptions) -> String {
1064    let mut out = String::with_capacity(input.len() * 2);
1065    let mut in_code_block = false;
1066    let mut code_lang = String::new();
1067    let mut code_buf = String::new();
1068    let mut in_thinking = false;
1069    let mut think_buf = String::new();
1070    let mut lines = input.lines().peekable();
1071
1072    while let Some(line) = lines.next() {
1073        // ── Fenced code blocks ────────────────────────────────────
1074        if line.starts_with("```") {
1075            if in_code_block {
1076                // close
1077                out.push_str("<pre><code class=\"language-");
1078                out.push_str(&html_escape(&code_lang));
1079                out.push_str("\">");
1080                out.push_str(&html_escape(&code_buf));
1081                out.push_str("</code></pre>\n");
1082                code_buf.clear();
1083                code_lang.clear();
1084                in_code_block = false;
1085            } else {
1086                in_code_block = true;
1087                code_lang = line.trim_start_matches('`').trim().to_string();
1088            }
1089            continue;
1090        }
1091        if in_code_block {
1092            code_buf.push_str(line);
1093            code_buf.push('\n');
1094            continue;
1095        }
1096
1097        // ── Thinking blocks ───────────────────────────────────────
1098        if line.trim() == "<think/>" {
1099            // skip empty self-closing
1100            continue;
1101        }
1102        if line.trim().starts_with("<think") || line.trim() == "<thinking>" {
1103            if !options.include_thinking {
1104                // Skip thinking blocks if not included
1105                for l in lines.by_ref() {
1106                    if l.trim() == "</think" || l.trim() == "</thinking>" {
1107                        break;
1108                    }
1109                }
1110                continue;
1111            }
1112            in_thinking = true;
1113            continue;
1114        }
1115        if in_thinking && (line.trim() == "</think" || line.trim() == "</thinking>") {
1116            // emit collapsible
1117            out.push_str("<details class=\"thinking-block\"><summary>💭 Thinking</summary><div class=\"think-content\">");
1118            out.push_str(&render_inline(&think_buf));
1119            out.push_str("</div></details>\n");
1120            think_buf.clear();
1121            in_thinking = false;
1122            continue;
1123        }
1124        if in_thinking {
1125            think_buf.push_str(line);
1126            think_buf.push('\n');
1127            continue;
1128        }
1129
1130        // ── Tool calls (emoji-prefixed) ───────────────────────────
1131        if options.include_tool_calls {
1132            if line.starts_with("🔧 ") || line.starts_with("tool:") {
1133                out.push_str("<div class=\"tool-call\">");
1134                out.push_str(&render_inline(line));
1135                out.push_str("</div>\n");
1136                continue;
1137            }
1138            if line.starts_with("📤 ") || line.starts_with("result:") {
1139                out.push_str("<div class=\"tool-result\">");
1140                out.push_str(&render_inline(line));
1141                out.push_str("</div>\n");
1142                continue;
1143            }
1144        } else {
1145            // Skip tool call lines
1146            if line.starts_with("🔧 ")
1147                || line.starts_with("tool:")
1148                || line.starts_with("📤 ")
1149                || line.starts_with("result:")
1150            {
1151                continue;
1152            }
1153        }
1154
1155        // ── Headings ──────────────────────────────────────────────
1156        if let Some(rest) = line.strip_prefix("### ") {
1157            out.push_str("<h3>");
1158            out.push_str(&render_inline(rest));
1159            out.push_str("</h3>\n");
1160            continue;
1161        }
1162        if let Some(rest) = line.strip_prefix("## ") {
1163            out.push_str("<h2>");
1164            out.push_str(&render_inline(rest));
1165            out.push_str("</h2>\n");
1166            continue;
1167        }
1168        if let Some(rest) = line.strip_prefix("# ") {
1169            out.push_str("<h1>");
1170            out.push_str(&render_inline(rest));
1171            out.push_str("</h1>\n");
1172            continue;
1173        }
1174
1175        // ── Unordered list items ──────────────────────────────────
1176        if line.starts_with("- ") || line.starts_with("* ") {
1177            out.push_str("<li>");
1178            out.push_str(&render_inline(&line[2..]));
1179            out.push_str("</li>\n");
1180            continue;
1181        }
1182
1183        // ── Empty line = paragraph break ──────────────────────────
1184        if line.trim().is_empty() {
1185            out.push_str("<br>\n");
1186            continue;
1187        }
1188
1189        // ── Regular paragraph ─────────────────────────────────────
1190        out.push_str("<p>");
1191        out.push_str(&render_inline(line));
1192        out.push_str("</p>\n");
1193    }
1194
1195    // Close any still-open code block
1196    if in_code_block {
1197        out.push_str("<pre><code>");
1198        out.push_str(&html_escape(&code_buf));
1199        out.push_str("</code></pre>\n");
1200    }
1201
1202    out
1203}
1204
1205/// Inline formatting: bold, italic, inline code, links.
1206fn render_inline(input: &str) -> String {
1207    let mut out = String::with_capacity(input.len() * 2);
1208    let mut chars = input.char_indices().peekable();
1209    let bytes = input.as_bytes();
1210
1211    while let Some((i, ch)) = chars.next() {
1212        match ch {
1213            '`' => {
1214                // inline code
1215                let start = i + 1;
1216                let end = bytes[start..]
1217                    .iter()
1218                    .position(|&b| b == b'`')
1219                    .map(|pos| start + pos)
1220                    .unwrap_or(input.len());
1221                let code = &input[start..end];
1222                out.push_str("<code>");
1223                out.push_str(&html_escape(code));
1224                out.push_str("</code>");
1225                // skip past closing backtick
1226                if end < input.len() {
1227                    for _ in input[i..=end].chars() {
1228                        chars.next();
1229                    }
1230                }
1231            }
1232            '*' => {
1233                // Look ahead for bold (**)
1234                if bytes.get(i + 1) == Some(&b'*') {
1235                    let rest = &input[i + 2..];
1236                    if let Some(end_pos) = rest.find("**") {
1237                        out.push_str("<strong>");
1238                        out.push_str(&render_inline(&rest[..end_pos]));
1239                        out.push_str("</strong>");
1240                        // skip past closing **
1241                        for _ in input[i..=i + 2 + end_pos + 1].chars() {
1242                            chars.next();
1243                        }
1244                        continue;
1245                    }
1246                }
1247                // Italic (*)
1248                let rest = &input[i + 1..];
1249                if let Some(end_pos) = rest.find('*') {
1250                    out.push_str("<em>");
1251                    out.push_str(&render_inline(&rest[..end_pos]));
1252                    out.push_str("</em>");
1253                    for _ in input[i..=i + 1 + end_pos].chars() {
1254                        chars.next();
1255                    }
1256                    continue;
1257                }
1258                out.push('*');
1259            }
1260            '[' => {
1261                // Markdown link [text](url)
1262                let rest = &input[i..];
1263                if let Some(link_end) = rest.find(')')
1264                    && let Some(mid) = rest.find("](")
1265                {
1266                    let text = &rest[1..mid];
1267                    let url = &rest[mid + 2..link_end];
1268                    out.push_str("<a href=\"");
1269                    out.push_str(&html_escape(url));
1270                    out.push_str("\">");
1271                    out.push_str(&html_escape(text));
1272                    out.push_str("</a>");
1273                    // skip entire link
1274                    for _ in rest[..=link_end].chars() {
1275                        chars.next();
1276                    }
1277                    continue;
1278                }
1279                out.push('[');
1280            }
1281            '<' => {
1282                // escape HTML
1283                out.push_str("&lt;");
1284            }
1285            '>' => {
1286                out.push_str("&gt;");
1287            }
1288            '&' => {
1289                out.push_str("&amp;");
1290            }
1291            _ => {
1292                out.push(ch);
1293            }
1294        }
1295    }
1296    out
1297}
1298
1299fn html_escape(input: &str) -> String {
1300    let mut s = String::with_capacity(input.len());
1301    for ch in input.chars() {
1302        match ch {
1303            '<' => s.push_str("&lt;"),
1304            '>' => s.push_str("&gt;"),
1305            '&' => s.push_str("&amp;"),
1306            '"' => s.push_str("&quot;"),
1307            '\'' => s.push_str("&#39;"),
1308            _ => s.push(ch),
1309        }
1310    }
1311    s
1312}
1313
1314// ── Embedded CSS ─────────────────────────────────────────────────────
1315
1316const CSS: &str = r#"
1317/* ── Reset & base ──────────────────────────────────────────────── */
1318*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
1319
1320body {
1321  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
1322  line-height: 1.6;
1323  padding: 1rem;
1324  display: flex;
1325  min-height: 100vh;
1326}
1327
1328/* ── Dark theme (default) ─────────────────────────────────────── */
1329body.dark {
1330  background: #1a1b26;
1331  color: #c0caf5;
1332}
1333
1334/* ── Light theme ──────────────────────────────────────────────── */
1335body.light {
1336  background: #f8f9fc;
1337  color: #1a1b26;
1338}
1339
1340/* ── Theme toggle button ──────────────────────────────────────── */
1341#theme-toggle {
1342  position: fixed;
1343  top: 1rem;
1344  right: 1rem;
1345  z-index: 100;
1346  background: rgba(255,255,255,0.1);
1347  border: 1px solid rgba(255,255,255,0.2);
1348  border-radius: 8px;
1349  padding: 0.4rem 0.7rem;
1350  cursor: pointer;
1351  font-size: 1.2rem;
1352}
1353body.light #theme-toggle {
1354  background: rgba(0,0,0,0.05);
1355  border-color: rgba(0,0,0,0.15);
1356}
1357
1358/* ── Tree sidebar ──────────────────────────────────────────────── */
1359.tree-nav {
1360  width: 220px;
1361  min-width: 220px;
1362  padding: 1rem;
1363  margin-right: 1rem;
1364  border-right: 1px solid rgba(255,255,255,0.1);
1365  font-size: 0.85rem;
1366  overflow-y: auto;
1367}
1368body.light .tree-nav { border-color: rgba(0,0,0,0.12); }
1369.tree-nav h3 { margin-bottom: 0.5rem; font-size: 0.95rem; }
1370.tree-node { padding: 0.2rem 0; }
1371.tree-node a { text-decoration: none; color: inherit; opacity: 0.7; }
1372.tree-node a:hover { opacity: 1; }
1373.tree-current a { font-weight: bold; opacity: 1; }
1374body.dark .tree-current a { color: #7aa2f7; }
1375body.light .tree-current a { color: #1d4ed8; }
1376
1377/* ── Main content ──────────────────────────────────────────────── */
1378.content {
1379  flex: 1;
1380  max-width: 900px;
1381  margin: 0 auto;
1382}
1383
1384/* ── Metadata header ───────────────────────────────────────────── */
1385.meta-header { margin-bottom: 1.5rem; }
1386.meta-header h1 { font-size: 1.4rem; margin-bottom: 0.5rem; }
1387.meta-table { border-collapse: collapse; font-size: 0.9rem; }
1388.meta-table td { padding: 0.15rem 0.75rem 0.15rem 0; }
1389.meta-label { color: #7982a9; font-weight: 600; }
1390body.light .meta-label { color: #6b7280; }
1391
1392/* ── Message bubbles ───────────────────────────────────────────── */
1393.msg {
1394  border-radius: 10px;
1395  padding: 0.75rem 1rem;
1396  margin-bottom: 0.75rem;
1397  max-width: 100%;
1398  word-wrap: break-word;
1399  overflow-wrap: break-word;
1400}
1401
1402.msg-header {
1403  display: flex;
1404  justify-content: space-between;
1405  align-items: center;
1406  margin-bottom: 0.35rem;
1407  font-size: 0.82rem;
1408}
1409
1410.msg-role { font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
1411.msg-time { opacity: 0.5; font-size: 0.78rem; }
1412
1413.msg-body p { margin: 0.25rem 0; }
1414.msg-body h1, .msg-body h2, .msg-body h3 { margin: 0.6rem 0 0.25rem; }
1415.msg-body li { margin-left: 1.2rem; }
1416
1417/* ── User message ──────────────────────────────────────────────── */
1418body.dark .msg-user  { background: #24283b; border-left: 4px solid #7aa2f7; }
1419body.light .msg-user { background: #eef2ff; border-left: 4px solid #6366f1; }
1420.msg-user .msg-role { color: #7aa2f7; }
1421body.light .msg-user .msg-role { color: #4f46e5; }
1422
1423/* ── Assistant message ─────────────────────────────────────────── */
1424body.dark .msg-assistant  { background: #1f2335; border-left: 4px solid #9ece6a; }
1425body.light .msg-assistant { background: #f0fdf4; border-left: 4px solid #22c55e; }
1426.msg-assistant .msg-role { color: #9ece6a; }
1427body.light .msg-assistant .msg-role { color: #16a34a; }
1428
1429/* ── System message ────────────────────────────────────────────── */
1430body.dark .msg-system  { background: #292e42; border-left: 4px solid #ff9e64; }
1431body.light .msg-system { background: #fffbeb; border-left: 4px solid #f59e0b; }
1432.msg-system .msg-role { color: #ff9e64; }
1433body.light .msg-system .msg-role { color: #d97706; }
1434
1435/* ── Code blocks ───────────────────────────────────────────────── */
1436pre {
1437  background: #13141c;
1438  border-radius: 6px;
1439  padding: 0.75rem 1rem;
1440  overflow-x: auto;
1441  margin: 0.5rem 0;
1442  font-size: 0.88rem;
1443}
1444body.light pre { background: #f1f5f9; }
1445pre code { font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", monospace; }
1446
1447code {
1448  background: rgba(255,255,255,0.07);
1449  padding: 0.1rem 0.3rem;
1450  border-radius: 3px;
1451  font-family: "JetBrains Mono", "Fira Code", monospace;
1452  font-size: 0.88em;
1453}
1454body.light code { background: rgba(0,0,0,0.06); }
1455
1456/* ── Thinking block (collapsible) ──────────────────────────────── */
1457.thinking-block {
1458  border: 1px dashed rgba(255,255,255,0.15);
1459  border-radius: 6px;
1460  padding: 0.5rem 0.75rem;
1461  margin: 0.4rem 0;
1462  font-size: 0.88rem;
1463}
1464body.light .thinking-block { border-color: rgba(0,0,0,0.15); }
1465.thinking-block summary {
1466  cursor: pointer;
1467  color: #bb9af7;
1468  font-weight: 600;
1469  user-select: none;
1470}
1471body.light .thinking-block summary { color: #7c3aed; }
1472.think-content {
1473  margin-top: 0.4rem;
1474  padding-top: 0.4rem;
1475  border-top: 1px dashed rgba(255,255,255,0.1);
1476  opacity: 0.8;
1477}
1478body.light .think-content { border-color: rgba(0,0,0,0.08); }
1479
1480/* ── Tool call / result ────────────────────────────────────────── */
1481.tool-call, .tool-result {
1482  border-radius: 5px;
1483  padding: 0.4rem 0.75rem;
1484  margin: 0.3rem 0;
1485  font-size: 0.88rem;
1486  font-family: monospace;
1487}
1488body.dark .tool-call  { background: #2d1f3d; border-left: 3px solid #bb9af7; }
1489body.dark .tool-result { background: #1a2d2d; border-left: 3px solid #73daca; }
1490body.light .tool-call  { background: #faf5ff; border-left: 3px solid #a78bfa; }
1491body.light .tool-result { background: #f0fdfa; border-left: 3px solid #14b8a6; }
1492
1493/* ── Tool blocks (structured) ──────────────────────────────────── */
1494.tool-block {
1495  border-radius: 8px;
1496  margin: 0.5rem 0;
1497  overflow: hidden;
1498}
1499.tool-label {
1500  padding: 0.35rem 0.75rem;
1501  font-weight: 600;
1502  font-size: 0.85rem;
1503  font-family: monospace;
1504}
1505
1506/* Bash tool */
1507body.dark .tool-bash { border: 1px solid #3b2d5d; }
1508body.light .tool-bash { border: 1px solid #e0d4f5; }
1509body.dark .tool-bash .tool-label { background: #2d1f3d; color: #bb9af7; }
1510body.light .tool-bash .tool-label { background: #faf5ff; color: #7c3aed; }
1511.tool-bash .tool-command {
1512  background: #0d1117;
1513  border-radius: 4px;
1514  margin: 0.35rem 0.5rem;
1515  padding: 0.5rem 0.75rem;
1516  font-size: 0.85rem;
1517}
1518body.light .tool-bash .tool-command { background: #f6f8fa; }
1519
1520/* File read tool */
1521body.dark .tool-file-read { border: 1px solid #1e3a5f; }
1522body.light .tool-file-read { border: 1px solid #d0e0f0; }
1523body.dark .tool-file-read .tool-label { background: #1a2d44; color: #7aa2f7; }
1524body.light .tool-file-read .tool-label { background: #eff6ff; color: #2563eb; }
1525
1526/* File write tool */
1527body.dark .tool-file-write { border: 1px solid #2d4a2d; }
1528body.light .tool-file-write { border: 1px solid #d0f0d0; }
1529body.dark .tool-file-write .tool-label { background: #1a2d1a; color: #9ece6a; }
1530body.light .tool-file-write .tool-label { background: #f0fdf4; color: #16a34a; }
1531
1532/* File edit tool */
1533body.dark .tool-file-edit { border: 1px solid #4a3a1a; }
1534body.light .tool-file-edit { border: 1px solid #f0e0c0; }
1535body.dark .tool-file-edit .tool-label { background: #2d2a1a; color: #e0af68; }
1536body.light .tool-file-edit .tool-label { background: #fffbeb; color: #d97706; }
1537.edit-section { margin: 0.35rem 0.5rem; }
1538.edit-old { border-left: 3px solid #f7768e; }
1539.edit-new { border-left: 3px solid #9ece6a; }
1540body.light .edit-old { border-left-color: #ef4444; }
1541body.light .edit-new { border-left-color: #22c55e; }
1542.edit-label {
1543  font-size: 0.8rem;
1544  font-weight: 600;
1545  padding: 0.15rem 0.5rem;
1546}
1547.edit-old .edit-label { color: #f7768e; }
1548.edit-new .edit-label { color: #9ece6a; }
1549body.light .edit-old .edit-label { color: #ef4444; }
1550body.light .edit-new .edit-label { color: #22c55e; }
1551
1552/* Search tool */
1553body.dark .tool-search { border: 1px solid #1a3a3a; }
1554body.light .tool-search { border: 1px solid #d0f0f0; }
1555body.dark .tool-search .tool-label { background: #1a2d2d; color: #73daca; }
1556body.light .tool-search .tool-label { background: #f0fdfa; color: #14b8a6; }
1557.search-results {
1558  margin: 0.35rem 0.5rem;
1559  font-family: monospace;
1560  font-size: 0.85rem;
1561}
1562.search-result-line {
1563  padding: 0.15rem 0.5rem;
1564  border-bottom: 1px solid rgba(255,255,255,0.05);
1565}
1566body.light .search-result-line { border-bottom-color: rgba(0,0,0,0.05); }
1567.search-result-line:last-child { border-bottom: none; }
1568.tool-no-results {
1569  padding: 0.5rem 0.75rem;
1570  opacity: 0.6;
1571  font-style: italic;
1572}
1573
1574/* Tool output details (collapsible) */
1575.tool-output-details {
1576  margin: 0.35rem 0.5rem;
1577}
1578.tool-output-details summary {
1579  cursor: pointer;
1580  font-size: 0.82rem;
1581  color: #7982a9;
1582  padding: 0.2rem 0;
1583  user-select: none;
1584}
1585body.light .tool-output-details summary { color: #6b7280; }
1586.tool-output-details summary:hover { color: inherit; }
1587.tool-output {
1588  background: #0d1117;
1589  border-radius: 4px;
1590  padding: 0.5rem 0.75rem;
1591  font-size: 0.85rem;
1592  max-height: 400px;
1593  overflow: auto;
1594}
1595body.light .tool-output { background: #f6f8fa; }
1596
1597/* ── ANSI lines ────────────────────────────────────────────────── */
1598.ansi-line {
1599  font-family: "JetBrains Mono", "Fira Code", monospace;
1600  font-size: 0.85rem;
1601  line-height: 1.5;
1602  white-space: pre;
1603}
1604
1605/* ── Links ─────────────────────────────────────────────────────── */
1606a { color: #7aa2f7; text-decoration: underline; }
1607body.light a { color: #2563eb; }
1608"#;
1609
1610// ── Embedded JS ──────────────────────────────────────────────────────
1611
1612const JS: &str = r#"
1613function toggleTheme() {
1614  const body = document.body;
1615  const isDark = body.classList.contains('dark');
1616  body.classList.toggle('dark', !isDark);
1617  body.classList.toggle('light', isDark);
1618
1619  // Swap highlight.js stylesheet
1620  const darkSheet = document.getElementById('hljs-dark');
1621  const lightSheet = document.getElementById('hljs-light');
1622  if (darkSheet && lightSheet) {
1623    darkSheet.disabled = isDark;
1624    lightSheet.disabled = !isDark;
1625  }
1626}
1627
1628// Apply syntax highlighting
1629document.addEventListener('DOMContentLoaded', () => {
1630  document.querySelectorAll('pre code').forEach((block) => {
1631    hljs.highlightElement(block);
1632  });
1633});
1634"#;
1635
1636// ══════════════════════════════════════════════════════════════════════
1637// Tests
1638// ══════════════════════════════════════════════════════════════════════
1639
1640#[cfg(test)]
1641mod tests {
1642    use super::*;
1643    use crate::store::session::{AgentMessage, AssistantContentBlock};
1644
1645    fn make_entry(msg: AgentMessage) -> SessionEntry {
1646        SessionEntry {
1647            id: Uuid::new_v4().to_string(),
1648            parent_id: None,
1649            message: msg,
1650            timestamp: 1_700_000_000_000,
1651        }
1652    }
1653
1654    // ── HTML export tests ────────────────────────────────────────
1655
1656    #[test]
1657    fn export_produces_valid_html_structure() {
1658        let entries = vec![
1659            make_entry(AgentMessage::User {
1660                content: "Hello".into(),
1661            }),
1662            make_entry(AgentMessage::Assistant {
1663                content: vec![AssistantContentBlock::Text {
1664                    text: "Hi there!".into(),
1665                }],
1666                provider: None,
1667                model_id: None,
1668                usage: None,
1669                stop_reason: None,
1670            }),
1671        ];
1672        let meta = ExportMeta::default();
1673        let html = export_html(&entries, &meta, None, None).unwrap();
1674
1675        assert!(html.starts_with("<!DOCTYPE html>"));
1676        assert!(html.contains("<html"));
1677        assert!(html.contains("</html>"));
1678        assert!(html.contains("<head>"));
1679        assert!(html.contains("</head>"));
1680        assert!(html.contains("<body"));
1681        assert!(html.contains("</body>"));
1682        assert!(html.contains("msg-user"));
1683        assert!(html.contains("msg-assistant"));
1684        assert!(html.contains("You"));
1685        assert!(html.contains("Assistant"));
1686        assert!(html.contains("Hello"));
1687        assert!(html.contains("Hi there!"));
1688    }
1689
1690    #[test]
1691    fn export_renders_thinking_block_collapsible() {
1692        let entries = vec![make_entry(AgentMessage::Assistant {
1693            content: vec![AssistantContentBlock::Text {
1694                text: "<think\nLet me reason step by step.\n</think\n\nThe answer is 42.".into(),
1695            }],
1696            provider: None,
1697            model_id: None,
1698            usage: None,
1699            stop_reason: None,
1700        })];
1701        let meta = ExportMeta::default();
1702        let html = export_html(&entries, &meta, None, None).unwrap();
1703
1704        assert!(html.contains("<details class=\"thinking-block\">"));
1705        assert!(html.contains("<summary>💭 Thinking</summary>"));
1706        assert!(html.contains("Let me reason step by step."));
1707        assert!(html.contains("The answer is 42."));
1708    }
1709
1710    #[test]
1711    fn export_includes_metadata_header() {
1712        let entries = vec![];
1713        let meta = ExportMeta {
1714            model: Some("claude-sonnet-4".into()),
1715            provider: Some("anthropic".into()),
1716            exported_at: 1_700_000_000_000,
1717            total_user_tokens: Some(120),
1718            total_assistant_tokens: Some(350),
1719        };
1720        let html = export_html(&entries, &meta, None, None).unwrap();
1721
1722        assert!(html.contains("claude-sonnet-4"));
1723        assert!(html.contains("anthropic"));
1724        assert!(html.contains("120"));
1725        assert!(html.contains("350"));
1726        assert!(html.contains("User Tokens"));
1727        assert!(html.contains("Assistant Tokens"));
1728    }
1729
1730    #[test]
1731    fn export_renders_code_block_with_language_class() {
1732        let entries =
1733            vec![make_entry(AgentMessage::Assistant {
1734                content: vec![AssistantContentBlock::Text { text:
1735                "Here is some code:\n```rust\nfn main() {\n    println!(\"hi\");\n}\n```\nDone."
1736                    .into() }],
1737                provider: None,
1738                model_id: None,
1739                usage: None,
1740                stop_reason: None,
1741            })];
1742        let meta = ExportMeta::default();
1743        let html = export_html(&entries, &meta, None, None).unwrap();
1744
1745        assert!(html.contains("language-rust"));
1746        assert!(html.contains("fn main()"));
1747        assert!(html.contains("println!"));
1748    }
1749
1750    #[test]
1751    fn export_renders_tool_calls_and_results() {
1752        let entries = vec![make_entry(AgentMessage::Assistant {
1753            content: vec![AssistantContentBlock::Text {
1754                text: "🔧 Running bash\n```\nls -la\n```\n📤 result:\nfile1.txt\nfile2.txt".into(),
1755            }],
1756            provider: None,
1757            model_id: None,
1758            usage: None,
1759            stop_reason: None,
1760        })];
1761        let meta = ExportMeta::default();
1762        let html = export_html(&entries, &meta, None, None).unwrap();
1763
1764        assert!(html.contains("tool-call"));
1765        assert!(html.contains("tool-result"));
1766    }
1767
1768    #[test]
1769    fn export_renders_session_tree_navigation() {
1770        let tree = TreeNode {
1771            session_id: Uuid::new_v4(),
1772            name: Some("root session".into()),
1773            is_current: false,
1774            children: vec![TreeNode {
1775                session_id: Uuid::new_v4(),
1776                name: Some("branch-1".into()),
1777                is_current: true,
1778                children: vec![],
1779            }],
1780        };
1781        let meta = ExportMeta::default();
1782        let html = export_html(&[], &meta, None, Some(&tree)).unwrap();
1783
1784        assert!(html.contains("tree-nav"));
1785        assert!(html.contains("tree-current"));
1786        assert!(html.contains("root session"));
1787        assert!(html.contains("branch-1"));
1788    }
1789
1790    #[test]
1791    fn export_dark_theme_default_with_toggle() {
1792        let meta = ExportMeta::default();
1793        let html = export_html(&[], &meta, None, None).unwrap();
1794
1795        assert!(html.contains("class=\"dark\""));
1796        assert!(html.contains("toggleTheme"));
1797        assert!(html.contains("theme-toggle"));
1798    }
1799
1800    // ── HtmlExportOptions tests ──────────────────────────────────
1801
1802    #[test]
1803    fn export_options_light_theme() {
1804        let options = HtmlExportOptions {
1805            dark_theme: false,
1806            ..Default::default()
1807        };
1808        let meta = ExportMeta::default();
1809        let html = export_html_with_options(&[], &meta, None, None, &options).unwrap();
1810        assert!(html.contains("class=\"light\""));
1811    }
1812
1813    #[test]
1814    fn export_options_custom_title() {
1815        let options = HtmlExportOptions {
1816            title: Some("My Session".into()),
1817            ..Default::default()
1818        };
1819        let meta = ExportMeta::default();
1820        let html = export_html_with_options(&[], &meta, None, None, &options).unwrap();
1821        assert!(html.contains("<title>My Session</title>"));
1822    }
1823
1824    #[test]
1825    fn export_options_skip_thinking() {
1826        let entries = vec![make_entry(AgentMessage::Assistant {
1827            content: vec![AssistantContentBlock::Text {
1828                text: "<thinking>\nSecret thoughts\n</thinking>\n\nVisible answer.".into(),
1829            }],
1830            provider: None,
1831            model_id: None,
1832            usage: None,
1833            stop_reason: None,
1834        })];
1835        let options = HtmlExportOptions {
1836            include_thinking: false,
1837            ..Default::default()
1838        };
1839        let meta = ExportMeta::default();
1840        let html = export_html_with_options(&entries, &meta, None, None, &options).unwrap();
1841        // Check that thinking content is not rendered (specific element check)
1842        assert!(!html.contains("<details class=\"thinking-block\">"));
1843        assert!(!html.contains("Secret thoughts"));
1844        assert!(html.contains("Visible answer"));
1845    }
1846
1847    #[test]
1848    fn export_options_skip_tool_calls() {
1849        let entries = vec![make_entry(AgentMessage::Assistant {
1850            content: vec![AssistantContentBlock::Text {
1851                text: "Here is my response with tool calls that should be hidden.".into(),
1852            }],
1853            provider: None,
1854            model_id: None,
1855            usage: None,
1856            stop_reason: None,
1857        })];
1858        let options = HtmlExportOptions {
1859            include_tool_calls: false,
1860            ..Default::default()
1861        };
1862        let meta = ExportMeta::default();
1863        let html = export_html_with_options(&entries, &meta, None, None, &options).unwrap();
1864        // With include_tool_calls: false, lines starting with 🔧 or 📤 are skipped
1865        // The response content is still rendered
1866        assert!(html.contains("Here is my response"));
1867    }
1868
1869    #[test]
1870    fn export_to_html_convenience() {
1871        let entries = vec![make_entry(AgentMessage::User {
1872            content: "Hello".into(),
1873        })];
1874        let meta = ExportMeta::default();
1875        let options = HtmlExportOptions::default();
1876        let html = export_to_html(&entries, &meta, &options).unwrap();
1877        assert!(html.contains("Hello"));
1878    }
1879
1880    // ── ANSI-to-HTML tests ───────────────────────────────────────
1881
1882    #[test]
1883    fn ansi_to_html_plain_text_unchanged() {
1884        assert_eq!(ansi_to_html("Hello world"), "Hello world");
1885    }
1886
1887    #[test]
1888    fn ansi_to_html_escapes_html_chars() {
1889        assert_eq!(
1890            ansi_to_html("<script>alert('xss')</script>"),
1891            "&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;"
1892        );
1893    }
1894
1895    #[test]
1896    fn ansi_to_html_standard_foreground_colors() {
1897        // Red foreground: ESC[31m
1898        let input = "\x1b[31mError\x1b[0m";
1899        let result = ansi_to_html(input);
1900        assert!(result.contains("color:#800000"));
1901        assert!(result.contains("Error"));
1902        assert!(result.contains("</span>"));
1903    }
1904
1905    #[test]
1906    fn ansi_to_html_bright_foreground_colors() {
1907        // Bright red: ESC[91m
1908        let input = "\x1b[91mWarning\x1b[0m";
1909        let result = ansi_to_html(input);
1910        assert!(result.contains("color:#ff0000"));
1911        assert!(result.contains("Warning"));
1912    }
1913
1914    #[test]
1915    fn ansi_to_html_standard_background_colors() {
1916        // Blue background: ESC[44m
1917        let input = "\x1b[44mBlue bg\x1b[0m";
1918        let result = ansi_to_html(input);
1919        assert!(result.contains("background-color:#000080"));
1920        assert!(result.contains("Blue bg"));
1921    }
1922
1923    #[test]
1924    fn ansi_to_html_bright_background_colors() {
1925        // Bright yellow background: ESC[103m
1926        let input = "\x1b[103mBright yellow\x1b[0m";
1927        let result = ansi_to_html(input);
1928        assert!(result.contains("background-color:#ffff00"));
1929        assert!(result.contains("Bright yellow"));
1930    }
1931
1932    #[test]
1933    fn ansi_to_html_bold() {
1934        let input = "\x1b[1mBold text\x1b[0m";
1935        let result = ansi_to_html(input);
1936        assert!(result.contains("font-weight:bold"));
1937        assert!(result.contains("Bold text"));
1938    }
1939
1940    #[test]
1941    fn ansi_to_html_italic() {
1942        let input = "\x1b[3mItalic text\x1b[0m";
1943        let result = ansi_to_html(input);
1944        assert!(result.contains("font-style:italic"));
1945        assert!(result.contains("Italic text"));
1946    }
1947
1948    #[test]
1949    fn ansi_to_html_underline() {
1950        let input = "\x1b[4mUnderlined\x1b[0m";
1951        let result = ansi_to_html(input);
1952        assert!(result.contains("text-decoration:underline"));
1953        assert!(result.contains("Underlined"));
1954    }
1955
1956    #[test]
1957    fn ansi_to_html_strikethrough() {
1958        let input = "\x1b[9mStruck\x1b[0m";
1959        let result = ansi_to_html(input);
1960        assert!(result.contains("text-decoration:line-through"));
1961        assert!(result.contains("Struck"));
1962    }
1963
1964    #[test]
1965    fn ansi_to_html_dim() {
1966        let input = "\x1b[2mDimmed\x1b[0m";
1967        let result = ansi_to_html(input);
1968        assert!(result.contains("opacity:0.6"));
1969        assert!(result.contains("Dimmed"));
1970    }
1971
1972    #[test]
1973    fn ansi_to_html_256_color() {
1974        // 256-color: ESC[38;5;196m (bright red from color cube)
1975        let input = "\x1b[38;5;196mCustom color\x1b[0m";
1976        let result = ansi_to_html(input);
1977        assert!(result.contains("color:#"));
1978        assert!(result.contains("Custom color"));
1979    }
1980
1981    #[test]
1982    fn ansi_to_html_true_color_rgb() {
1983        // True color: ESC[38;2;255;128;0m (orange)
1984        let input = "\x1b[38;2;255;128;0mOrange\x1b[0m";
1985        let result = ansi_to_html(input);
1986        assert!(result.contains("color:rgb(255,128,0)"));
1987        assert!(result.contains("Orange"));
1988    }
1989
1990    #[test]
1991    fn ansi_to_html_background_true_color() {
1992        let input = "\x1b[48;2;0;128;255mBlue bg\x1b[0m";
1993        let result = ansi_to_html(input);
1994        assert!(result.contains("background-color:rgb(0,128,255)"));
1995        assert!(result.contains("Blue bg"));
1996    }
1997
1998    #[test]
1999    fn ansi_to_html_reset_clears_styles() {
2000        let input = "\x1b[1;31mBold Red\x1b[0m Normal";
2001        let result = ansi_to_html(input);
2002        assert!(result.contains("font-weight:bold"));
2003        assert!(result.contains("color:#800000"));
2004        assert!(result.contains(" Normal"));
2005    }
2006
2007    #[test]
2008    fn ansi_to_html_multiple_styles_combined() {
2009        // Bold + Red + Underline
2010        let input = "\x1b[1;4;31mBold Red Underlined\x1b[0m";
2011        let result = ansi_to_html(input);
2012        assert!(result.contains("font-weight:bold"));
2013        assert!(result.contains("text-decoration:underline"));
2014        assert!(result.contains("color:#800000"));
2015        assert!(result.contains("Bold Red Underlined"));
2016    }
2017
2018    #[test]
2019    fn ansi_to_html_no_escapes_returns_plain() {
2020        assert_eq!(ansi_to_html("No escapes here"), "No escapes here");
2021    }
2022
2023    #[test]
2024    fn ansi_to_html_256_color_standard_range() {
2025        // Color index 2 = green
2026        let input = "\x1b[38;5;2mGreen\x1b[0m";
2027        let result = ansi_to_html(input);
2028        assert!(result.contains("color:#008000"));
2029    }
2030
2031    #[test]
2032    fn ansi_to_html_256_color_grayscale() {
2033        // Grayscale index 232 = very dark gray
2034        let input = "\x1b[38;5;232mDark gray\x1b[0m";
2035        let result = ansi_to_html(input);
2036        assert!(result.contains("color:#"));
2037        assert!(result.contains("Dark gray"));
2038    }
2039
2040    #[test]
2041    fn ansi_to_html_background_256() {
2042        let input = "\x1b[48;5;4mBlue bg\x1b[0m";
2043        let result = ansi_to_html(input);
2044        assert!(result.contains("background-color:#000080"));
2045    }
2046
2047    #[test]
2048    fn ansi_lines_to_html_wraps_lines() {
2049        let lines = vec!["Line 1", "Line 2", ""];
2050        let result = ansi_lines_to_html(&lines);
2051        assert!(result.contains("<div class=\"ansi-line\">Line 1</div>"));
2052        assert!(result.contains("<div class=\"ansi-line\">Line 2</div>"));
2053        assert!(result.contains("&nbsp;</div>"));
2054    }
2055
2056    // ── Color conversion tests ───────────────────────────────────
2057
2058    #[test]
2059    fn color_256_standard_colors() {
2060        assert_eq!(color_256_to_hex(0), "#000000");
2061        assert_eq!(color_256_to_hex(7), "#c0c0c0");
2062        assert_eq!(color_256_to_hex(15), "#ffffff");
2063    }
2064
2065    #[test]
2066    fn color_256_cube_colors() {
2067        // Index 16 = first cube color (black)
2068        assert_eq!(color_256_to_hex(16), "#000000");
2069        // Index 231 = last cube color (white)
2070        assert_eq!(color_256_to_hex(231), "#ffffff");
2071    }
2072
2073    #[test]
2074    fn color_256_grayscale() {
2075        // Index 232 = darkest gray
2076        assert_eq!(color_256_to_hex(232), "#080808");
2077        // Index 255 = lightest gray
2078        assert_eq!(color_256_to_hex(255), "#eeeeee");
2079    }
2080
2081    // ── Markdown tests ───────────────────────────────────────────
2082
2083    #[test]
2084    fn markdown_renders_bold_and_italic() {
2085        let result = render_markdown("This is **bold** and *italic* text.");
2086        assert!(result.contains("<strong>bold</strong>"));
2087        assert!(result.contains("<em>italic</em>"));
2088    }
2089
2090    #[test]
2091    fn markdown_renders_inline_code() {
2092        let result = render_markdown("Use `cargo build` to compile.");
2093        assert!(result.contains("<code>cargo build</code>"));
2094    }
2095
2096    #[test]
2097    fn markdown_renders_links() {
2098        let result = render_markdown("See [docs](https://example.com) for info.");
2099        assert!(result.contains("<a href=\"https://example.com\">docs</a>"));
2100    }
2101
2102    #[test]
2103    fn html_escape_prevents_xss() {
2104        let escaped = html_escape("<script>alert('xss')</script>");
2105        assert!(!escaped.contains('<'));
2106        assert!(escaped.contains("&lt;script"));
2107    }
2108
2109    // ── Tool rendering tests ─────────────────────────────────────
2110
2111    #[test]
2112    fn bash_tool_renders_command_and_output() {
2113        let html = render_bash_tool("ls -la", "file1.txt\nfile2.txt");
2114        assert!(html.contains("tool-bash"));
2115        assert!(html.contains("ls -la"));
2116        assert!(html.contains("file1.txt"));
2117        assert!(html.contains("tool-output-details"));
2118    }
2119
2120    #[test]
2121    fn file_read_tool_renders_path_and_content() {
2122        let html = render_file_read_tool("/path/to/file.rs", "fn main() {}");
2123        assert!(html.contains("tool-file-read"));
2124        assert!(html.contains("/path/to/file.rs"));
2125        assert!(html.contains("fn main()"));
2126    }
2127
2128    #[test]
2129    fn file_write_tool_renders_path_and_content() {
2130        let html = render_file_write_tool("/path/to/output.txt", "Hello world");
2131        assert!(html.contains("tool-file-write"));
2132        assert!(html.contains("/path/to/output.txt"));
2133        assert!(html.contains("Hello world"));
2134    }
2135
2136    #[test]
2137    fn file_edit_tool_renders_diff() {
2138        let html = render_file_edit_tool("/src/main.rs", "old code", "new code");
2139        assert!(html.contains("tool-file-edit"));
2140        assert!(html.contains("/src/main.rs"));
2141        assert!(html.contains("edit-old"));
2142        assert!(html.contains("edit-new"));
2143        assert!(html.contains("old code"));
2144        assert!(html.contains("new code"));
2145    }
2146
2147    #[test]
2148    fn search_tool_renders_results() {
2149        let results = vec![
2150            "src/main.rs:10:found match".to_string(),
2151            "src/lib.rs:42:another match".to_string(),
2152        ];
2153        let html = render_search_tool("TODO", &results);
2154        assert!(html.contains("tool-search"));
2155        assert!(html.contains("TODO"));
2156        assert!(html.contains("found match"));
2157        assert!(html.contains("another match"));
2158    }
2159
2160    #[test]
2161    fn search_tool_shows_no_results() {
2162        let html = render_search_tool("missing", &[]);
2163        assert!(html.contains("No results found"));
2164    }
2165}