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