1use 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#[derive(Debug, Clone)]
27pub struct HtmlExportOptions {
28 pub include_thinking: bool,
30 pub include_tool_calls: bool,
32 pub dark_theme: bool,
34 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#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ExportMeta {
52 pub model: Option<String>,
54 pub provider: Option<String>,
56 pub exported_at: i64,
58 pub total_user_tokens: Option<u64>,
60 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#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct TreeNode {
79 pub session_id: Uuid,
81 pub name: Option<String>,
83 pub is_current: bool,
85 pub children: Vec<TreeNode>,
87}
88
89const ANSI_COLORS: [&str; 16] = [
93 "#000000", "#800000", "#008000", "#808000", "#000080", "#800080", "#008080", "#c0c0c0", "#808080", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff", ];
110
111fn color_256_to_hex(index: u8) -> String {
113 let idx = index as usize;
114
115 if idx < 16 {
117 return ANSI_COLORS[idx].to_string();
118 }
119
120 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 let gray = 8 + (idx - 232) * 10;
143 format!("#{gray:02x}{gray:02x}{gray:02x}", gray = gray as u8)
144}
145
146#[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
211fn 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 30..=37 => {
250 style.fg = Some(ANSI_COLORS[(code - 30) as usize].to_string());
251 }
252 38 if i + 1 < params.len() => {
254 match params[i + 1] {
255 5
256 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 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 style.fg = None;
276 }
277 40..=47 => {
279 style.bg = Some(ANSI_COLORS[(code - 40) as usize].to_string());
280 }
281 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 style.bg = None;
299 }
300 90..=97 => {
302 style.fg = Some(ANSI_COLORS[(code - 90 + 8) as usize].to_string());
303 }
304 100..=107 => {
306 style.bg = Some(ANSI_COLORS[(code - 100 + 8) as usize].to_string());
307 }
308 _ => {
309 }
311 }
312 i += 1;
313 }
314}
315
316pub 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 if bytes[pos] == 0x1b && pos + 1 < len && bytes[pos + 1] == b'[' {
338 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 pos += 1;
347 continue;
348 }
349
350 if pos > last_end {
352 result.push_str(&html_escape(&text[last_end..pos]));
353 }
354
355 if in_span {
357 result.push_str("</span>");
358 in_span = false;
359 }
360
361 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(¶ms, &mut style);
374
375 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; pos = seq_end + 1;
385 } else {
386 pos += 1;
387 }
388 }
389
390 if last_end < len {
392 result.push_str(&html_escape(&text[last_end..]));
393 }
394
395 if in_span {
397 result.push_str("</span>");
398 }
399
400 result
401}
402
403#[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\"> </div>".to_string()
413 } else {
414 format!("<div class=\"ansi-line\">{rendered}</div>")
415 }
416 })
417 .collect()
418}
419
420#[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
451fn 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 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 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 if let Some(cmd_line) = lines.next() {
475 cmd.push_str(cmd_line);
476 }
477
478 for line in lines.by_ref() {
480 if line.starts_with("```") {
481 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 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 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(); 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 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 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 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 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 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
638fn extract_path_from_line(line: &str) -> String {
640 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
663fn 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
683fn 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
700fn 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
717fn 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
747fn 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 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#[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
795pub 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 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 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 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 let theme_class = if options.dark_theme { "dark" } else { "light" };
837 writeln!(html, "<body class=\"{}\">", theme_class)?;
838
839 html.push_str(
841 "<button id=\"theme-toggle\" onclick=\"toggleTheme()\" title=\"Toggle light/dark theme\">",
842 );
843 html.push_str("🌓</button>\n");
844
845 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 html.push_str("<main class=\"content\">\n");
854
855 render_meta_header(&mut html, meta, session_meta)?;
857
858 for entry in entries {
860 render_entry(&mut html, entry, options)?;
861 }
862
863 html.push_str("</main>\n");
864
865 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
874pub 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
884fn 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 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 if let Some(tool_html) = render_tool_blocks(&text_str, options.include_tool_calls) {
989 html.push_str(&tool_html);
990 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 _ => {
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
1037fn render_tree_node(html: &mut String, node: &TreeNode, depth: usize) -> Result<()> {
1039 let indent = " ".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#[allow(dead_code)]
1066fn render_markdown(input: &str) -> String {
1067 render_markdown_with_options(input, &HtmlExportOptions::default())
1068}
1069
1070#[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 if line.starts_with("```") {
1085 if in_code_block {
1086 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 if line.trim() == "<think/>" {
1109 continue;
1111 }
1112 if line.trim().starts_with("<think") || line.trim() == "<thinking>" {
1113 if !options.include_thinking {
1114 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 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 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 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 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 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 if line.trim().is_empty() {
1195 out.push_str("<br>\n");
1196 continue;
1197 }
1198
1199 out.push_str("<p>");
1201 out.push_str(&render_inline(line));
1202 out.push_str("</p>\n");
1203 }
1204
1205 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
1215fn 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 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 if end < input.len() {
1237 for _ in input[i..=end].chars() {
1238 chars.next();
1239 }
1240 }
1241 }
1242 '*' => {
1243 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 for _ in input[i..=i + 2 + end_pos + 1].chars() {
1252 chars.next();
1253 }
1254 continue;
1255 }
1256 }
1257 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 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 for _ in rest[..=link_end].chars() {
1284 chars.next();
1285 }
1286 continue;
1287 }
1288 }
1289 out.push('[');
1290 }
1291 '<' => {
1292 out.push_str("<");
1294 }
1295 '>' => {
1296 out.push_str(">");
1297 }
1298 '&' => {
1299 out.push_str("&");
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("<"),
1314 '>' => s.push_str(">"),
1315 '&' => s.push_str("&"),
1316 '"' => s.push_str("""),
1317 '\'' => s.push_str("'"),
1318 _ => s.push(ch),
1319 }
1320 }
1321 s
1322}
1323
1324const 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
1620const 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#[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 #[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 #[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 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 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 #[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 "<script>alert('xss')</script>"
1902 );
1903 }
1904
1905 #[test]
1906 fn ansi_to_html_standard_foreground_colors() {
1907 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 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 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 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 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 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 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 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 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(" </div>"));
2064 }
2065
2066 #[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 assert_eq!(color_256_to_hex(16), "#000000");
2079 assert_eq!(color_256_to_hex(231), "#ffffff");
2081 }
2082
2083 #[test]
2084 fn color_256_grayscale() {
2085 assert_eq!(color_256_to_hex(232), "#080808");
2087 assert_eq!(color_256_to_hex(255), "#eeeeee");
2089 }
2090
2091 #[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("<script"));
2117 }
2118
2119 #[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}