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