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
414fn extract_text(content: &crate::store::session::ContentValue) -> String {
419 use crate::store::session::{ContentBlock, ContentValue};
420 match content {
421 ContentValue::String(s) => s.clone(),
422 ContentValue::Blocks(blocks) => {
423 let mut text = String::new();
424 for block in blocks {
425 if let ContentBlock::Text { text: t } = block {
426 text.push_str(t);
427 text.push('\n');
428 }
429 }
430 text.trim().to_string()
431 }
432 }
433}
434
435fn render_tool_call_block(name: &str, arguments: &serde_json::Value) -> String {
439 match name {
440 "bash" => render_bash_call(arguments),
441 "read" => render_read_call(arguments),
442 "write" => render_write_call(arguments),
443 "edit" | "edit_diff" => render_edit_call(arguments),
444 "grep" => render_search_call("G", "pattern", arguments),
445 "find" => render_search_call("F", "name", arguments),
446 "ls" => render_search_call("D", "path", arguments),
447 _ => render_generic_tool_call(name, arguments),
448 }
449}
450
451fn render_bash_call(arguments: &serde_json::Value) -> String {
453 let command = arguments
454 .get("command")
455 .and_then(|v| v.as_str())
456 .unwrap_or("");
457 let mut html = String::new();
458 html.push_str("<div class=\"tool-call tool-bash\">\n");
459 html.push_str("<div class=\"tool-label\">⌨ Bash</div>\n");
460 html.push_str("<pre class=\"tool-command\"><code>$ ");
461 html.push_str(&html_escape(command.trim()));
462 html.push_str("</code></pre>\n");
463 html.push_str("</div>\n");
464 html
465}
466
467fn render_read_call(arguments: &serde_json::Value) -> String {
469 let path = arguments
470 .get("path")
471 .and_then(|v| v.as_str())
472 .unwrap_or("?");
473 let mut html = String::new();
474 html.push_str("<div class=\"tool-call\">\n");
475 html.push_str("<div class=\"tool-label\">📄 Read: ");
476 html.push_str(&html_escape(path));
477 html.push_str("</div>\n");
478 html.push_str("</div>\n");
479 html
480}
481
482fn render_write_call(arguments: &serde_json::Value) -> String {
484 let path = arguments
485 .get("path")
486 .and_then(|v| v.as_str())
487 .unwrap_or("?");
488 let mut html = String::new();
489 html.push_str("<div class=\"tool-call\">\n");
490 html.push_str("<div class=\"tool-label\">📝 Write: ");
491 html.push_str(&html_escape(path));
492 html.push_str("</div>\n");
493 html.push_str("</div>\n");
494 html
495}
496
497fn render_edit_call(arguments: &serde_json::Value) -> String {
499 let path = arguments
500 .get("path")
501 .and_then(|v| v.as_str())
502 .unwrap_or("?");
503 let mut html = String::new();
504 html.push_str("<div class=\"tool-call\">\n");
505 html.push_str("<div class=\"tool-label\">✏️ Edit: ");
506 html.push_str(&html_escape(path));
507 html.push_str("</div>\n");
508 html.push_str("</div>\n");
509 html
510}
511
512fn render_search_call(tag: &str, key: &str, arguments: &serde_json::Value) -> String {
516 let query = arguments.get(key).and_then(|v| v.as_str()).unwrap_or("");
517 let mut html = String::new();
518 html.push_str("<div class=\"tool-call\">\n");
519 let _ = write!(html, "<div class=\"tool-label\">[{}] ", tag);
520 html.push_str(&html_escape(query));
521 html.push_str("</div>\n");
522 html.push_str("</div>\n");
523 html
524}
525
526fn render_generic_tool_call(name: &str, arguments: &serde_json::Value) -> String {
528 let mut html = String::new();
529 html.push_str("<div class=\"tool-call\">\n");
530 html.push_str("<div class=\"tool-label\">");
531 html.push_str(&html_escape(name));
532 html.push_str("</div>\n");
533 let args_str =
534 serde_json::to_string_pretty(arguments).unwrap_or_else(|_| arguments.to_string());
535 html.push_str("<pre><code>");
536 html.push_str(&html_escape(&args_str));
537 html.push_str("</code></pre>\n");
538 html.push_str("</div>\n");
539 html
540}
541
542fn render_tool_result_block(
546 content: &crate::store::session::ContentValue,
547 tool_call_id: &str,
548) -> String {
549 let text = extract_text(content);
550 let mut html = String::new();
551 html.push_str("<div class=\"tool-result\" data-tool-call-id=\"");
552 html.push_str(&html_escape(tool_call_id));
553 html.push_str("\">\n");
554 if !text.trim().is_empty() {
555 html.push_str("<pre><code>");
556 html.push_str(&html_escape(text.trim()));
557 html.push_str("</code></pre>\n");
558 }
559 html.push_str("</div>\n");
560 html
561}
562
563#[allow(dead_code)]
570pub fn export_html(
571 entries: &[SessionEntry],
572 meta: &ExportMeta,
573 session_meta: Option<&SessionMeta>,
574 tree: Option<&TreeNode>,
575) -> Result<String> {
576 export_html_with_options(
577 entries,
578 meta,
579 session_meta,
580 tree,
581 &HtmlExportOptions::default(),
582 )
583}
584
585pub fn export_html_with_options(
588 entries: &[SessionEntry],
589 meta: &ExportMeta,
590 session_meta: Option<&SessionMeta>,
591 tree: Option<&TreeNode>,
592 options: &HtmlExportOptions,
593) -> Result<String> {
594 let mut html = String::with_capacity(64 * 1024);
595
596 html.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n");
598 html.push_str("<meta charset=\"utf-8\">\n");
599 html.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
600
601 let title = options
602 .title
603 .as_deref()
604 .or(session_meta.and_then(|m| m.name.as_deref()))
605 .unwrap_or("oxi session export");
606 writeln!(html, "<title>{}</title>", html_escape(title))?;
607
608 html.push_str(
610 "<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css\" id=\"hljs-dark\">\n",
611 );
612 html.push_str(
613 "<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css\" id=\"hljs-light\" disabled>\n",
614 );
615 html.push_str(
616 "<script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>\n",
617 );
618
619 html.push_str("<style>\n");
621 html.push_str(CSS);
622 html.push_str("\n</style>\n");
623 html.push_str("</head>\n");
624
625 let theme_class = if options.dark_theme { "dark" } else { "light" };
627 writeln!(html, "<body class=\"{}\">", theme_class)?;
628
629 html.push_str(
631 "<button id=\"theme-toggle\" onclick=\"toggleTheme()\" title=\"Toggle light/dark theme\">",
632 );
633 html.push_str("🌓</button>\n");
634
635 if let Some(node) = tree {
637 html.push_str("<nav class=\"tree-nav\">\n<h3>Session Tree</h3>\n");
638 render_tree_node(&mut html, node, 0)?;
639 html.push_str("</nav>\n");
640 }
641
642 html.push_str("<main class=\"content\">\n");
644
645 render_meta_header(&mut html, meta, session_meta)?;
647
648 for entry in entries {
650 render_entry(&mut html, entry, options)?;
651 }
652
653 html.push_str("</main>\n");
654
655 html.push_str("<script>\n");
657 html.push_str(JS);
658 html.push_str("\n</script>\n");
659
660 html.push_str("</body>\n</html>\n");
661 Ok(html)
662}
663
664pub fn export_to_html(
667 entries: &[SessionEntry],
668 meta: &ExportMeta,
669 options: &HtmlExportOptions,
670) -> Result<String> {
671 export_html_with_options(entries, meta, None, None, options)
672}
673
674fn render_meta_header(
677 html: &mut String,
678 meta: &ExportMeta,
679 session_meta: Option<&SessionMeta>,
680) -> Result<()> {
681 html.push_str("<header class=\"meta-header\">\n");
682 html.push_str("<h1>oxi Session Export</h1>\n");
683 html.push_str("<table class=\"meta-table\">\n");
684
685 let exported_dt = DateTime::from_timestamp_millis(meta.exported_at)
686 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
687 .unwrap_or_else(|| "unknown".to_string());
688 render_meta_row(html, "Exported", &exported_dt)?;
689
690 if let Some(model) = &meta.model {
691 render_meta_row(html, "Model", model)?;
692 }
693 if let Some(provider) = &meta.provider {
694 render_meta_row(html, "Provider", provider)?;
695 }
696 if let Some(sm) = session_meta {
697 let created_dt = DateTime::from_timestamp_millis(sm.created_at)
698 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
699 .unwrap_or_else(|| "unknown".to_string());
700 render_meta_row(html, "Session ID", &sm.id.to_string())?;
701 render_meta_row(html, "Created", &created_dt)?;
702 if let Some(name) = &sm.name {
703 render_meta_row(html, "Name", name)?;
704 }
705 }
706 if let Some(t) = meta.total_user_tokens {
707 render_meta_row(html, "User Tokens", &t.to_string())?;
708 }
709 if let Some(t) = meta.total_assistant_tokens {
710 render_meta_row(html, "Assistant Tokens", &t.to_string())?;
711 }
712
713 html.push_str("</table>\n</header>\n");
714 Ok(())
715}
716
717fn render_meta_row(html: &mut String, label: &str, value: &str) -> Result<()> {
718 writeln!(
719 html,
720 "<tr><td class=\"meta-label\">{}</td><td class=\"meta-value\">{}</td></tr>",
721 html_escape(label),
722 html_escape(value)
723 )?;
724 Ok(())
725}
726
727fn render_entry(
728 html: &mut String,
729 entry: &SessionEntry,
730 options: &HtmlExportOptions,
731) -> Result<()> {
732 let ts = DateTime::from_timestamp_millis(entry.timestamp)
733 .map(|dt| dt.format("%H:%M:%S").to_string())
734 .unwrap_or_default();
735
736 match &entry.message {
737 AgentMessage::User { content } => {
738 html.push_str("<div class=\"msg msg-user\">\n");
739 html.push_str("<div class=\"msg-header\"><span class=\"msg-role\">You</span>");
740 write!(html, "<span class=\"msg-time\">{}</span>", html_escape(&ts))?;
741 html.push_str("</div>\n");
742 html.push_str("<div class=\"msg-body\">");
743 let content_str = extract_text(content);
744 html.push_str(&render_markdown_with_options(&content_str, options));
745 html.push_str("</div>\n</div>\n");
746 }
747 AgentMessage::Assistant { content, .. } => {
748 html.push_str("<div class=\"msg msg-assistant\">\n");
749 html.push_str("<div class=\"msg-header\"><span class=\"msg-role\">Assistant</span>");
750 write!(html, "<span class=\"msg-time\">{}</span>", html_escape(&ts))?;
751 html.push_str("</div>\n");
752 html.push_str("<div class=\"msg-body\">");
753
754 for block in content {
756 match block {
757 crate::store::session::AssistantContentBlock::Text { text } => {
758 html.push_str(&render_markdown_with_options(text, options));
759 }
760 crate::store::session::AssistantContentBlock::ToolCall {
761 name,
762 arguments,
763 ..
764 } => {
765 if options.include_tool_calls {
766 html.push_str(&render_tool_call_block(name, arguments));
767 }
768 }
769 crate::store::session::AssistantContentBlock::Thinking { thinking }
770 if options.include_thinking =>
771 {
772 html.push_str(
773 "<details class=\"thinking-block\"><summary>💭 Thinking</summary><div class=\"think-content\">",
774 );
775 html.push_str(&render_markdown_with_options(thinking, options));
776 html.push_str("</div></details>\n");
777 }
778 _ => {}
780 }
781 }
782
783 html.push_str("</div>\n</div>\n");
784 }
785 AgentMessage::System { content } => {
786 html.push_str("<div class=\"msg msg-system\">\n");
787 html.push_str("<div class=\"msg-header\"><span class=\"msg-role\">System</span>");
788 write!(html, "<span class=\"msg-time\">{}</span>", html_escape(&ts))?;
789 html.push_str("</div>\n");
790 html.push_str("<div class=\"msg-body\">");
791 let content_str = extract_text(content);
792 html.push_str(&render_markdown_with_options(&content_str, options));
793 html.push_str("</div>\n</div>\n");
794 }
795 AgentMessage::ToolResult {
796 content,
797 tool_call_id,
798 } => {
799 if options.include_tool_calls {
800 html.push_str(&render_tool_result_block(content, tool_call_id));
801 }
802 }
803 _ => {
805 let content = entry.content();
806 if !content.is_empty() {
807 html.push_str("<div class=\"msg msg-system\">\n");
808 html.push_str("<div class=\"msg-header\"><span class=\"msg-role\">System</span>");
809 write!(html, "<span class=\"msg-time\">{}</span>", html_escape(&ts))?;
810 html.push_str("</div>\n");
811 html.push_str("<div class=\"msg-body\">");
812 html.push_str(&render_markdown_with_options(&content, options));
813 html.push_str("</div>\n</div>\n");
814 }
815 }
816 }
817 Ok(())
818}
819
820fn render_tree_node(html: &mut String, node: &TreeNode, depth: usize) -> Result<()> {
822 let indent = " ".repeat(depth * 4);
823 let current = if node.is_current { " tree-current" } else { "" };
824 let fallback = node.session_id.to_string();
825 let short_id = &fallback[..8.min(fallback.len())];
826 let name = node.name.as_deref().unwrap_or(short_id);
827 writeln!(
828 html,
829 "<div class=\"tree-node{}\">{}<a href=\"#\">{}</a></div>",
830 current,
831 indent,
832 html_escape(name)
833 )?;
834 for child in &node.children {
835 render_tree_node(html, child, depth + 1)?;
836 }
837 Ok(())
838}
839
840#[allow(dead_code)]
849fn render_markdown(input: &str) -> String {
850 render_markdown_with_options(input, &HtmlExportOptions::default())
851}
852
853fn render_markdown_with_options(input: &str, options: &HtmlExportOptions) -> String {
856 let mut out = String::with_capacity(input.len() * 2);
857 let mut in_code_block = false;
858 let mut code_lang = String::new();
859 let mut code_buf = String::new();
860 let mut in_thinking = false;
861 let mut think_buf = String::new();
862 let mut lines = input.lines().peekable();
863
864 while let Some(line) = lines.next() {
865 if line.starts_with("```") {
867 if in_code_block {
868 out.push_str("<pre><code class=\"language-");
870 out.push_str(&html_escape(&code_lang));
871 out.push_str("\">");
872 out.push_str(&html_escape(&code_buf));
873 out.push_str("</code></pre>\n");
874 code_buf.clear();
875 code_lang.clear();
876 in_code_block = false;
877 } else {
878 in_code_block = true;
879 code_lang = line.trim_start_matches('`').trim().to_string();
880 }
881 continue;
882 }
883 if in_code_block {
884 code_buf.push_str(line);
885 code_buf.push('\n');
886 continue;
887 }
888
889 if line.trim() == "<think/>" {
891 continue;
893 }
894 if line.trim().starts_with("<think") || line.trim() == "<thinking>" {
895 if !options.include_thinking {
896 for l in lines.by_ref() {
898 if l.trim() == "</think" || l.trim() == "</thinking>" {
899 break;
900 }
901 }
902 continue;
903 }
904 in_thinking = true;
905 continue;
906 }
907 if in_thinking && (line.trim() == "</think" || line.trim() == "</thinking>") {
908 out.push_str("<details class=\"thinking-block\"><summary>💭 Thinking</summary><div class=\"think-content\">");
910 out.push_str(&render_inline(&think_buf));
911 out.push_str("</div></details>\n");
912 think_buf.clear();
913 in_thinking = false;
914 continue;
915 }
916 if in_thinking {
917 think_buf.push_str(line);
918 think_buf.push('\n');
919 continue;
920 }
921
922 if let Some(rest) = line.strip_prefix("### ") {
924 out.push_str("<h3>");
925 out.push_str(&render_inline(rest));
926 out.push_str("</h3>\n");
927 continue;
928 }
929 if let Some(rest) = line.strip_prefix("## ") {
930 out.push_str("<h2>");
931 out.push_str(&render_inline(rest));
932 out.push_str("</h2>\n");
933 continue;
934 }
935 if let Some(rest) = line.strip_prefix("# ") {
936 out.push_str("<h1>");
937 out.push_str(&render_inline(rest));
938 out.push_str("</h1>\n");
939 continue;
940 }
941
942 if line.starts_with("- ") || line.starts_with("* ") {
944 out.push_str("<li>");
945 out.push_str(&render_inline(&line[2..]));
946 out.push_str("</li>\n");
947 continue;
948 }
949
950 if line.trim().is_empty() {
952 out.push_str("<br>\n");
953 continue;
954 }
955
956 out.push_str("<p>");
958 out.push_str(&render_inline(line));
959 out.push_str("</p>\n");
960 }
961
962 if in_code_block {
964 out.push_str("<pre><code>");
965 out.push_str(&html_escape(&code_buf));
966 out.push_str("</code></pre>\n");
967 }
968
969 out
970}
971
972fn render_inline(input: &str) -> String {
974 let mut out = String::with_capacity(input.len() * 2);
975 let mut chars = input.char_indices().peekable();
976 let bytes = input.as_bytes();
977
978 while let Some((i, ch)) = chars.next() {
979 match ch {
980 '`' => {
981 let start = i + 1;
983 let end = bytes[start..]
984 .iter()
985 .position(|&b| b == b'`')
986 .map(|pos| start + pos)
987 .unwrap_or(input.len());
988 let code = &input[start..end];
989 out.push_str("<code>");
990 out.push_str(&html_escape(code));
991 out.push_str("</code>");
992 if end < input.len() {
994 for _ in input[i..=end].chars() {
995 chars.next();
996 }
997 }
998 }
999 '*' => {
1000 if bytes.get(i + 1) == Some(&b'*') {
1002 let rest = &input[i + 2..];
1003 if let Some(end_pos) = rest.find("**") {
1004 out.push_str("<strong>");
1005 out.push_str(&render_inline(&rest[..end_pos]));
1006 out.push_str("</strong>");
1007 for _ in input[i..=i + 2 + end_pos + 1].chars() {
1009 chars.next();
1010 }
1011 continue;
1012 }
1013 }
1014 let rest = &input[i + 1..];
1016 if let Some(end_pos) = rest.find('*') {
1017 out.push_str("<em>");
1018 out.push_str(&render_inline(&rest[..end_pos]));
1019 out.push_str("</em>");
1020 for _ in input[i..=i + 1 + end_pos].chars() {
1021 chars.next();
1022 }
1023 continue;
1024 }
1025 out.push('*');
1026 }
1027 '[' => {
1028 let rest = &input[i..];
1030 if let Some(link_end) = rest.find(')')
1031 && let Some(mid) = rest.find("](")
1032 {
1033 let text = &rest[1..mid];
1034 let url = &rest[mid + 2..link_end];
1035 out.push_str("<a href=\"");
1036 out.push_str(&html_escape(url));
1037 out.push_str("\">");
1038 out.push_str(&html_escape(text));
1039 out.push_str("</a>");
1040 for _ in rest[..=link_end].chars() {
1042 chars.next();
1043 }
1044 continue;
1045 }
1046 out.push('[');
1047 }
1048 '<' => {
1049 out.push_str("<");
1051 }
1052 '>' => {
1053 out.push_str(">");
1054 }
1055 '&' => {
1056 out.push_str("&");
1057 }
1058 _ => {
1059 out.push(ch);
1060 }
1061 }
1062 }
1063 out
1064}
1065
1066fn html_escape(input: &str) -> String {
1067 let mut s = String::with_capacity(input.len());
1068 for ch in input.chars() {
1069 match ch {
1070 '<' => s.push_str("<"),
1071 '>' => s.push_str(">"),
1072 '&' => s.push_str("&"),
1073 '"' => s.push_str("""),
1074 '\'' => s.push_str("'"),
1075 _ => s.push(ch),
1076 }
1077 }
1078 s
1079}
1080
1081const CSS: &str = r#"
1084/* ── Reset & base ──────────────────────────────────────────────── */
1085*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
1086
1087body {
1088 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
1089 line-height: 1.6;
1090 padding: 1rem;
1091 display: flex;
1092 min-height: 100vh;
1093}
1094
1095/* ── Dark theme (default) ─────────────────────────────────────── */
1096body.dark {
1097 background: #1a1b26;
1098 color: #c0caf5;
1099}
1100
1101/* ── Light theme ──────────────────────────────────────────────── */
1102body.light {
1103 background: #f8f9fc;
1104 color: #1a1b26;
1105}
1106
1107/* ── Theme toggle button ──────────────────────────────────────── */
1108#theme-toggle {
1109 position: fixed;
1110 top: 1rem;
1111 right: 1rem;
1112 z-index: 100;
1113 background: rgba(255,255,255,0.1);
1114 border: 1px solid rgba(255,255,255,0.2);
1115 border-radius: 8px;
1116 padding: 0.4rem 0.7rem;
1117 cursor: pointer;
1118 font-size: 1.2rem;
1119}
1120body.light #theme-toggle {
1121 background: rgba(0,0,0,0.05);
1122 border-color: rgba(0,0,0,0.15);
1123}
1124
1125/* ── Tree sidebar ──────────────────────────────────────────────── */
1126.tree-nav {
1127 width: 220px;
1128 min-width: 220px;
1129 padding: 1rem;
1130 margin-right: 1rem;
1131 border-right: 1px solid rgba(255,255,255,0.1);
1132 font-size: 0.85rem;
1133 overflow-y: auto;
1134}
1135body.light .tree-nav { border-color: rgba(0,0,0,0.12); }
1136.tree-nav h3 { margin-bottom: 0.5rem; font-size: 0.95rem; }
1137.tree-node { padding: 0.2rem 0; }
1138.tree-node a { text-decoration: none; color: inherit; opacity: 0.7; }
1139.tree-node a:hover { opacity: 1; }
1140.tree-current a { font-weight: bold; opacity: 1; }
1141body.dark .tree-current a { color: #7aa2f7; }
1142body.light .tree-current a { color: #1d4ed8; }
1143
1144/* ── Main content ──────────────────────────────────────────────── */
1145.content {
1146 flex: 1;
1147 max-width: 900px;
1148 margin: 0 auto;
1149}
1150
1151/* ── Metadata header ───────────────────────────────────────────── */
1152.meta-header { margin-bottom: 1.5rem; }
1153.meta-header h1 { font-size: 1.4rem; margin-bottom: 0.5rem; }
1154.meta-table { border-collapse: collapse; font-size: 0.9rem; }
1155.meta-table td { padding: 0.15rem 0.75rem 0.15rem 0; }
1156.meta-label { color: #7982a9; font-weight: 600; }
1157body.light .meta-label { color: #6b7280; }
1158
1159/* ── Message bubbles ───────────────────────────────────────────── */
1160.msg {
1161 border-radius: 10px;
1162 padding: 0.75rem 1rem;
1163 margin-bottom: 0.75rem;
1164 max-width: 100%;
1165 word-wrap: break-word;
1166 overflow-wrap: break-word;
1167}
1168
1169.msg-header {
1170 display: flex;
1171 justify-content: space-between;
1172 align-items: center;
1173 margin-bottom: 0.35rem;
1174 font-size: 0.82rem;
1175}
1176
1177.msg-role { font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
1178.msg-time { opacity: 0.5; font-size: 0.78rem; }
1179
1180.msg-body p { margin: 0.25rem 0; }
1181.msg-body h1, .msg-body h2, .msg-body h3 { margin: 0.6rem 0 0.25rem; }
1182.msg-body li { margin-left: 1.2rem; }
1183
1184/* ── User message ──────────────────────────────────────────────── */
1185body.dark .msg-user { background: #24283b; border-left: 4px solid #7aa2f7; }
1186body.light .msg-user { background: #eef2ff; border-left: 4px solid #6366f1; }
1187.msg-user .msg-role { color: #7aa2f7; }
1188body.light .msg-user .msg-role { color: #4f46e5; }
1189
1190/* ── Assistant message ─────────────────────────────────────────── */
1191body.dark .msg-assistant { background: #1f2335; border-left: 4px solid #9ece6a; }
1192body.light .msg-assistant { background: #f0fdf4; border-left: 4px solid #22c55e; }
1193.msg-assistant .msg-role { color: #9ece6a; }
1194body.light .msg-assistant .msg-role { color: #16a34a; }
1195
1196/* ── System message ────────────────────────────────────────────── */
1197body.dark .msg-system { background: #292e42; border-left: 4px solid #ff9e64; }
1198body.light .msg-system { background: #fffbeb; border-left: 4px solid #f59e0b; }
1199.msg-system .msg-role { color: #ff9e64; }
1200body.light .msg-system .msg-role { color: #d97706; }
1201
1202/* ── Code blocks ───────────────────────────────────────────────── */
1203pre {
1204 background: #13141c;
1205 border-radius: 6px;
1206 padding: 0.75rem 1rem;
1207 overflow-x: auto;
1208 margin: 0.5rem 0;
1209 font-size: 0.88rem;
1210}
1211body.light pre { background: #f1f5f9; }
1212pre code { font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", monospace; }
1213
1214code {
1215 background: rgba(255,255,255,0.07);
1216 padding: 0.1rem 0.3rem;
1217 border-radius: 3px;
1218 font-family: "JetBrains Mono", "Fira Code", monospace;
1219 font-size: 0.88em;
1220}
1221body.light code { background: rgba(0,0,0,0.06); }
1222
1223/* ── Thinking block (collapsible) ──────────────────────────────── */
1224.thinking-block {
1225 border: 1px dashed rgba(255,255,255,0.15);
1226 border-radius: 6px;
1227 padding: 0.5rem 0.75rem;
1228 margin: 0.4rem 0;
1229 font-size: 0.88rem;
1230}
1231body.light .thinking-block { border-color: rgba(0,0,0,0.15); }
1232.thinking-block summary {
1233 cursor: pointer;
1234 color: #bb9af7;
1235 font-weight: 600;
1236 user-select: none;
1237}
1238body.light .thinking-block summary { color: #7c3aed; }
1239.think-content {
1240 margin-top: 0.4rem;
1241 padding-top: 0.4rem;
1242 border-top: 1px dashed rgba(255,255,255,0.1);
1243 opacity: 0.8;
1244}
1245body.light .think-content { border-color: rgba(0,0,0,0.08); }
1246
1247/* ── Tool call / result ────────────────────────────────────────── */
1248.tool-call, .tool-result {
1249 border-radius: 5px;
1250 padding: 0.4rem 0.75rem;
1251 margin: 0.3rem 0;
1252 font-size: 0.88rem;
1253 font-family: monospace;
1254}
1255body.dark .tool-call { background: #2d1f3d; border-left: 3px solid #bb9af7; }
1256body.dark .tool-result { background: #1a2d2d; border-left: 3px solid #73daca; }
1257body.light .tool-call { background: #faf5ff; border-left: 3px solid #a78bfa; }
1258body.light .tool-result { background: #f0fdfa; border-left: 3px solid #14b8a6; }
1259
1260/* ── Tool call / result styles ───────────────────────────────── */
1261.tool-label {
1262 padding: 0.35rem 0.75rem;
1263 font-weight: 600;
1264 font-size: 0.85rem;
1265 font-family: monospace;
1266}
1267
1268/* Bash tool */
1269body.dark .tool-bash { border: 1px solid #3b2d5d; }
1270body.light .tool-bash { border: 1px solid #e0d4f5; }
1271body.dark .tool-bash .tool-label { background: #2d1f3d; color: #bb9af7; }
1272body.light .tool-bash .tool-label { background: #faf5ff; color: #7c3aed; }
1273.tool-bash .tool-command {
1274 background: #0d1117;
1275 border-radius: 4px;
1276 margin: 0.35rem 0.5rem;
1277 padding: 0.5rem 0.75rem;
1278 font-size: 0.85rem;
1279}
1280body.light .tool-bash .tool-command { background: #f6f8fa; }
1281
1282/* ── ANSI lines ────────────────────────────────────────────────── */
1283.ansi-line {
1284 font-family: "JetBrains Mono", "Fira Code", monospace;
1285 font-size: 0.85rem;
1286 line-height: 1.5;
1287 white-space: pre;
1288}
1289
1290/* ── Links ─────────────────────────────────────────────────────── */
1291a { color: #7aa2f7; text-decoration: underline; }
1292body.light a { color: #2563eb; }
1293"#;
1294
1295const JS: &str = r#"
1298function toggleTheme() {
1299 const body = document.body;
1300 const isDark = body.classList.contains('dark');
1301 body.classList.toggle('dark', !isDark);
1302 body.classList.toggle('light', isDark);
1303
1304 // Swap highlight.js stylesheet
1305 const darkSheet = document.getElementById('hljs-dark');
1306 const lightSheet = document.getElementById('hljs-light');
1307 if (darkSheet && lightSheet) {
1308 darkSheet.disabled = isDark;
1309 lightSheet.disabled = !isDark;
1310 }
1311}
1312
1313// Apply syntax highlighting
1314document.addEventListener('DOMContentLoaded', () => {
1315 document.querySelectorAll('pre code').forEach((block) => {
1316 hljs.highlightElement(block);
1317 });
1318});
1319"#;
1320
1321#[cfg(test)]
1326mod tests {
1327 use super::*;
1328 use crate::store::session::{AgentMessage, AssistantContentBlock, ContentValue};
1329
1330 fn make_entry(msg: AgentMessage) -> SessionEntry {
1331 SessionEntry {
1332 id: Uuid::new_v4().to_string(),
1333 parent_id: None,
1334 message: msg,
1335 timestamp: 1_700_000_000_000,
1336 }
1337 }
1338
1339 #[test]
1342 fn export_produces_valid_html_structure() {
1343 let entries = vec![
1344 make_entry(AgentMessage::User {
1345 content: "Hello".into(),
1346 }),
1347 make_entry(AgentMessage::Assistant {
1348 content: vec![AssistantContentBlock::Text {
1349 text: "Hi there!".into(),
1350 }],
1351 provider: None,
1352 model_id: None,
1353 usage: None,
1354 stop_reason: None,
1355 }),
1356 ];
1357 let meta = ExportMeta::default();
1358 let html = export_html(&entries, &meta, None, None).unwrap();
1359
1360 assert!(html.starts_with("<!DOCTYPE html>"));
1361 assert!(html.contains("<html"));
1362 assert!(html.contains("</html>"));
1363 assert!(html.contains("<head>"));
1364 assert!(html.contains("</head>"));
1365 assert!(html.contains("<body"));
1366 assert!(html.contains("</body>"));
1367 assert!(html.contains("msg-user"));
1368 assert!(html.contains("msg-assistant"));
1369 assert!(html.contains("You"));
1370 assert!(html.contains("Assistant"));
1371 assert!(html.contains("Hello"));
1372 assert!(html.contains("Hi there!"));
1373 }
1374
1375 #[test]
1376 fn export_renders_thinking_block_collapsible() {
1377 let entries = vec![make_entry(AgentMessage::Assistant {
1378 content: vec![AssistantContentBlock::Text {
1379 text: "<think\nLet me reason step by step.\n</think\n\nThe answer is 42.".into(),
1380 }],
1381 provider: None,
1382 model_id: None,
1383 usage: None,
1384 stop_reason: None,
1385 })];
1386 let meta = ExportMeta::default();
1387 let html = export_html(&entries, &meta, None, None).unwrap();
1388
1389 assert!(html.contains("<details class=\"thinking-block\">"));
1390 assert!(html.contains("<summary>💭 Thinking</summary>"));
1391 assert!(html.contains("Let me reason step by step."));
1392 assert!(html.contains("The answer is 42."));
1393 }
1394
1395 #[test]
1396 fn export_includes_metadata_header() {
1397 let entries = vec![];
1398 let meta = ExportMeta {
1399 model: Some("claude-sonnet-4".into()),
1400 provider: Some("anthropic".into()),
1401 exported_at: 1_700_000_000_000,
1402 total_user_tokens: Some(120),
1403 total_assistant_tokens: Some(350),
1404 };
1405 let html = export_html(&entries, &meta, None, None).unwrap();
1406
1407 assert!(html.contains("claude-sonnet-4"));
1408 assert!(html.contains("anthropic"));
1409 assert!(html.contains("120"));
1410 assert!(html.contains("350"));
1411 assert!(html.contains("User Tokens"));
1412 assert!(html.contains("Assistant Tokens"));
1413 }
1414
1415 #[test]
1416 fn export_renders_code_block_with_language_class() {
1417 let entries =
1418 vec![make_entry(AgentMessage::Assistant {
1419 content: vec![AssistantContentBlock::Text { text:
1420 "Here is some code:\n```rust\nfn main() {\n println!(\"hi\");\n}\n```\nDone."
1421 .into() }],
1422 provider: None,
1423 model_id: None,
1424 usage: None,
1425 stop_reason: None,
1426 })];
1427 let meta = ExportMeta::default();
1428 let html = export_html(&entries, &meta, None, None).unwrap();
1429
1430 assert!(html.contains("language-rust"));
1431 assert!(html.contains("fn main()"));
1432 assert!(html.contains("println!"));
1433 }
1434
1435 #[test]
1436 fn export_renders_session_tree_navigation() {
1437 let tree = TreeNode {
1438 session_id: Uuid::new_v4(),
1439 name: Some("root session".into()),
1440 is_current: false,
1441 children: vec![TreeNode {
1442 session_id: Uuid::new_v4(),
1443 name: Some("branch-1".into()),
1444 is_current: true,
1445 children: vec![],
1446 }],
1447 };
1448 let meta = ExportMeta::default();
1449 let html = export_html(&[], &meta, None, Some(&tree)).unwrap();
1450
1451 assert!(html.contains("tree-nav"));
1452 assert!(html.contains("tree-current"));
1453 assert!(html.contains("root session"));
1454 assert!(html.contains("branch-1"));
1455 }
1456
1457 #[test]
1458 fn export_dark_theme_default_with_toggle() {
1459 let meta = ExportMeta::default();
1460 let html = export_html(&[], &meta, None, None).unwrap();
1461
1462 assert!(html.contains("class=\"dark\""));
1463 assert!(html.contains("toggleTheme"));
1464 assert!(html.contains("theme-toggle"));
1465 }
1466
1467 #[test]
1470 fn export_options_light_theme() {
1471 let options = HtmlExportOptions {
1472 dark_theme: false,
1473 ..Default::default()
1474 };
1475 let meta = ExportMeta::default();
1476 let html = export_html_with_options(&[], &meta, None, None, &options).unwrap();
1477 assert!(html.contains("class=\"light\""));
1478 }
1479
1480 #[test]
1481 fn export_options_custom_title() {
1482 let options = HtmlExportOptions {
1483 title: Some("My Session".into()),
1484 ..Default::default()
1485 };
1486 let meta = ExportMeta::default();
1487 let html = export_html_with_options(&[], &meta, None, None, &options).unwrap();
1488 assert!(html.contains("<title>My Session</title>"));
1489 }
1490
1491 #[test]
1492 fn export_options_skip_thinking() {
1493 let entries = vec![make_entry(AgentMessage::Assistant {
1494 content: vec![AssistantContentBlock::Text {
1495 text: "<thinking>\nSecret thoughts\n</thinking>\n\nVisible answer.".into(),
1496 }],
1497 provider: None,
1498 model_id: None,
1499 usage: None,
1500 stop_reason: None,
1501 })];
1502 let options = HtmlExportOptions {
1503 include_thinking: false,
1504 ..Default::default()
1505 };
1506 let meta = ExportMeta::default();
1507 let html = export_html_with_options(&entries, &meta, None, None, &options).unwrap();
1508 assert!(!html.contains("<details class=\"thinking-block\">"));
1510 assert!(!html.contains("Secret thoughts"));
1511 assert!(html.contains("Visible answer"));
1512 }
1513
1514 #[test]
1515 fn export_options_skip_tool_calls() {
1516 let entries = vec![
1517 make_entry(AgentMessage::Assistant {
1518 content: vec![
1519 AssistantContentBlock::Text {
1520 text: "Let me run a command.".into(),
1521 },
1522 AssistantContentBlock::ToolCall {
1523 id: "tc-1".into(),
1524 name: "bash".into(),
1525 arguments: serde_json::json!({"command": "ls -la"}),
1526 },
1527 ],
1528 provider: None,
1529 model_id: None,
1530 usage: None,
1531 stop_reason: None,
1532 }),
1533 make_entry(AgentMessage::ToolResult {
1534 content: "file1.txt\nfile2.txt".into(),
1535 tool_call_id: "tc-1".to_string(),
1536 }),
1537 ];
1538 let options = HtmlExportOptions {
1539 include_tool_calls: false,
1540 ..Default::default()
1541 };
1542 let meta = ExportMeta::default();
1543 let html = export_html_with_options(&entries, &meta, None, None, &options).unwrap();
1544 assert!(html.contains("Let me run a command."));
1546 assert!(!html.contains("<div class=\"tool-call"));
1548 assert!(!html.contains("<div class=\"tool-result"));
1549 assert!(!html.contains("ls -la"));
1550 }
1551
1552 #[test]
1553 fn export_to_html_convenience() {
1554 let entries = vec![make_entry(AgentMessage::User {
1555 content: "Hello".into(),
1556 })];
1557 let meta = ExportMeta::default();
1558 let options = HtmlExportOptions::default();
1559 let html = export_to_html(&entries, &meta, &options).unwrap();
1560 assert!(html.contains("Hello"));
1561 }
1562
1563 #[test]
1566 fn ansi_to_html_plain_text_unchanged() {
1567 assert_eq!(ansi_to_html("Hello world"), "Hello world");
1568 }
1569
1570 #[test]
1571 fn ansi_to_html_escapes_html_chars() {
1572 assert_eq!(
1573 ansi_to_html("<script>alert('xss')</script>"),
1574 "<script>alert('xss')</script>"
1575 );
1576 }
1577
1578 #[test]
1579 fn ansi_to_html_standard_foreground_colors() {
1580 let input = "\x1b[31mError\x1b[0m";
1582 let result = ansi_to_html(input);
1583 assert!(result.contains("color:#800000"));
1584 assert!(result.contains("Error"));
1585 assert!(result.contains("</span>"));
1586 }
1587
1588 #[test]
1589 fn ansi_to_html_bright_foreground_colors() {
1590 let input = "\x1b[91mWarning\x1b[0m";
1592 let result = ansi_to_html(input);
1593 assert!(result.contains("color:#ff0000"));
1594 assert!(result.contains("Warning"));
1595 }
1596
1597 #[test]
1598 fn ansi_to_html_standard_background_colors() {
1599 let input = "\x1b[44mBlue bg\x1b[0m";
1601 let result = ansi_to_html(input);
1602 assert!(result.contains("background-color:#000080"));
1603 assert!(result.contains("Blue bg"));
1604 }
1605
1606 #[test]
1607 fn ansi_to_html_bright_background_colors() {
1608 let input = "\x1b[103mBright yellow\x1b[0m";
1610 let result = ansi_to_html(input);
1611 assert!(result.contains("background-color:#ffff00"));
1612 assert!(result.contains("Bright yellow"));
1613 }
1614
1615 #[test]
1616 fn ansi_to_html_bold() {
1617 let input = "\x1b[1mBold text\x1b[0m";
1618 let result = ansi_to_html(input);
1619 assert!(result.contains("font-weight:bold"));
1620 assert!(result.contains("Bold text"));
1621 }
1622
1623 #[test]
1624 fn ansi_to_html_italic() {
1625 let input = "\x1b[3mItalic text\x1b[0m";
1626 let result = ansi_to_html(input);
1627 assert!(result.contains("font-style:italic"));
1628 assert!(result.contains("Italic text"));
1629 }
1630
1631 #[test]
1632 fn ansi_to_html_underline() {
1633 let input = "\x1b[4mUnderlined\x1b[0m";
1634 let result = ansi_to_html(input);
1635 assert!(result.contains("text-decoration:underline"));
1636 assert!(result.contains("Underlined"));
1637 }
1638
1639 #[test]
1640 fn ansi_to_html_strikethrough() {
1641 let input = "\x1b[9mStruck\x1b[0m";
1642 let result = ansi_to_html(input);
1643 assert!(result.contains("text-decoration:line-through"));
1644 assert!(result.contains("Struck"));
1645 }
1646
1647 #[test]
1648 fn ansi_to_html_dim() {
1649 let input = "\x1b[2mDimmed\x1b[0m";
1650 let result = ansi_to_html(input);
1651 assert!(result.contains("opacity:0.6"));
1652 assert!(result.contains("Dimmed"));
1653 }
1654
1655 #[test]
1656 fn ansi_to_html_256_color() {
1657 let input = "\x1b[38;5;196mCustom color\x1b[0m";
1659 let result = ansi_to_html(input);
1660 assert!(result.contains("color:#"));
1661 assert!(result.contains("Custom color"));
1662 }
1663
1664 #[test]
1665 fn ansi_to_html_true_color_rgb() {
1666 let input = "\x1b[38;2;255;128;0mOrange\x1b[0m";
1668 let result = ansi_to_html(input);
1669 assert!(result.contains("color:rgb(255,128,0)"));
1670 assert!(result.contains("Orange"));
1671 }
1672
1673 #[test]
1674 fn ansi_to_html_background_true_color() {
1675 let input = "\x1b[48;2;0;128;255mBlue bg\x1b[0m";
1676 let result = ansi_to_html(input);
1677 assert!(result.contains("background-color:rgb(0,128,255)"));
1678 assert!(result.contains("Blue bg"));
1679 }
1680
1681 #[test]
1682 fn ansi_to_html_reset_clears_styles() {
1683 let input = "\x1b[1;31mBold Red\x1b[0m Normal";
1684 let result = ansi_to_html(input);
1685 assert!(result.contains("font-weight:bold"));
1686 assert!(result.contains("color:#800000"));
1687 assert!(result.contains(" Normal"));
1688 }
1689
1690 #[test]
1691 fn ansi_to_html_multiple_styles_combined() {
1692 let input = "\x1b[1;4;31mBold Red Underlined\x1b[0m";
1694 let result = ansi_to_html(input);
1695 assert!(result.contains("font-weight:bold"));
1696 assert!(result.contains("text-decoration:underline"));
1697 assert!(result.contains("color:#800000"));
1698 assert!(result.contains("Bold Red Underlined"));
1699 }
1700
1701 #[test]
1702 fn ansi_to_html_no_escapes_returns_plain() {
1703 assert_eq!(ansi_to_html("No escapes here"), "No escapes here");
1704 }
1705
1706 #[test]
1707 fn ansi_to_html_256_color_standard_range() {
1708 let input = "\x1b[38;5;2mGreen\x1b[0m";
1710 let result = ansi_to_html(input);
1711 assert!(result.contains("color:#008000"));
1712 }
1713
1714 #[test]
1715 fn ansi_to_html_256_color_grayscale() {
1716 let input = "\x1b[38;5;232mDark gray\x1b[0m";
1718 let result = ansi_to_html(input);
1719 assert!(result.contains("color:#"));
1720 assert!(result.contains("Dark gray"));
1721 }
1722
1723 #[test]
1724 fn ansi_to_html_background_256() {
1725 let input = "\x1b[48;5;4mBlue bg\x1b[0m";
1726 let result = ansi_to_html(input);
1727 assert!(result.contains("background-color:#000080"));
1728 }
1729
1730 #[test]
1731 fn ansi_lines_to_html_wraps_lines() {
1732 let lines = vec!["Line 1", "Line 2", ""];
1733 let result = ansi_lines_to_html(&lines);
1734 assert!(result.contains("<div class=\"ansi-line\">Line 1</div>"));
1735 assert!(result.contains("<div class=\"ansi-line\">Line 2</div>"));
1736 assert!(result.contains(" </div>"));
1737 }
1738
1739 #[test]
1742 fn color_256_standard_colors() {
1743 assert_eq!(color_256_to_hex(0), "#000000");
1744 assert_eq!(color_256_to_hex(7), "#c0c0c0");
1745 assert_eq!(color_256_to_hex(15), "#ffffff");
1746 }
1747
1748 #[test]
1749 fn color_256_cube_colors() {
1750 assert_eq!(color_256_to_hex(16), "#000000");
1752 assert_eq!(color_256_to_hex(231), "#ffffff");
1754 }
1755
1756 #[test]
1757 fn color_256_grayscale() {
1758 assert_eq!(color_256_to_hex(232), "#080808");
1760 assert_eq!(color_256_to_hex(255), "#eeeeee");
1762 }
1763
1764 #[test]
1767 fn markdown_renders_bold_and_italic() {
1768 let result = render_markdown("This is **bold** and *italic* text.");
1769 assert!(result.contains("<strong>bold</strong>"));
1770 assert!(result.contains("<em>italic</em>"));
1771 }
1772
1773 #[test]
1774 fn markdown_renders_inline_code() {
1775 let result = render_markdown("Use `cargo build` to compile.");
1776 assert!(result.contains("<code>cargo build</code>"));
1777 }
1778
1779 #[test]
1780 fn markdown_renders_links() {
1781 let result = render_markdown("See [docs](https://example.com) for info.");
1782 assert!(result.contains("<a href=\"https://example.com\">docs</a>"));
1783 }
1784
1785 #[test]
1786 fn html_escape_prevents_xss() {
1787 let escaped = html_escape("<script>alert('xss')</script>");
1788 assert!(!escaped.contains('<'));
1789 assert!(escaped.contains("<script"));
1790 }
1791
1792 #[test]
1795 fn tool_call_block_renders_bash() {
1796 let args = serde_json::json!({"command": "ls -la"});
1797 let html = render_tool_call_block("bash", &args);
1798 assert!(html.contains("tool-call"));
1799 assert!(html.contains("tool-bash"));
1800 assert!(html.contains("ls -la"));
1801 }
1802
1803 #[test]
1804 fn tool_call_block_renders_read() {
1805 let args = serde_json::json!({"path": "/src/main.rs"});
1806 let html = render_tool_call_block("read", &args);
1807 assert!(html.contains("tool-call"));
1808 assert!(html.contains("/src/main.rs"));
1809 }
1810
1811 #[test]
1812 fn tool_call_block_renders_write() {
1813 let args = serde_json::json!({"path": "/out.txt"});
1814 let html = render_tool_call_block("write", &args);
1815 assert!(html.contains("tool-call"));
1816 assert!(html.contains("/out.txt"));
1817 }
1818
1819 #[test]
1820 fn tool_call_block_renders_edit() {
1821 let args = serde_json::json!({"path": "/src/lib.rs"});
1822 let html = render_tool_call_block("edit", &args);
1823 assert!(html.contains("tool-call"));
1824 assert!(html.contains("/src/lib.rs"));
1825 }
1826
1827 #[test]
1828 fn tool_call_block_renders_grep() {
1829 let args = serde_json::json!({"pattern": "TODO"});
1830 let html = render_tool_call_block("grep", &args);
1831 assert!(html.contains("tool-call"));
1832 assert!(html.contains("[G]"));
1833 assert!(html.contains("TODO"));
1834 }
1835
1836 #[test]
1837 fn tool_call_block_renders_find() {
1838 let args = serde_json::json!({"path": ".", "name": "*.rs"});
1839 let html = render_tool_call_block("find", &args);
1840 assert!(html.contains("tool-call"));
1841 assert!(html.contains("[F]"));
1842 assert!(html.contains("*.rs"));
1844 assert!(!html.contains("tool-label\">[F] .</div>")); }
1846
1847 #[test]
1848 fn tool_call_block_unknown_tool_falls_back() {
1849 let args = serde_json::json!({"key": "value"});
1850 let html = render_tool_call_block("frobnicate", &args);
1851 assert!(html.contains("tool-call"));
1852 assert!(html.contains("frobnicate"));
1853 assert!(html.contains(""key""));
1854 }
1855
1856 #[test]
1857 fn tool_result_block_renders_content_and_id() {
1858 let content = ContentValue::String("command output".into());
1859 let html = render_tool_result_block(&content, "tc-42");
1860 assert!(html.contains("tool-result"));
1861 assert!(html.contains("data-tool-call-id=\"tc-42\""));
1862 assert!(html.contains("command output"));
1863 }
1864
1865 #[test]
1866 fn tool_result_block_no_msg_wrapper() {
1867 let content = ContentValue::String("output".into());
1868 let html = render_tool_result_block(&content, "tc-1");
1869 assert!(!html.contains("msg msg-"));
1871 }
1872
1873 #[test]
1874 fn export_assistant_tool_call_renders_in_order() {
1875 let entries = vec![make_entry(AgentMessage::Assistant {
1876 content: vec![
1877 AssistantContentBlock::Text {
1878 text: "alpha-marker".into(),
1879 },
1880 AssistantContentBlock::ToolCall {
1881 id: "tc-1".into(),
1882 name: "bash".into(),
1883 arguments: serde_json::json!({"command": "echo hi"}),
1884 },
1885 AssistantContentBlock::Text {
1886 text: "omega-marker".into(),
1887 },
1888 ],
1889 provider: None,
1890 model_id: None,
1891 usage: None,
1892 stop_reason: None,
1893 })];
1894 let meta = ExportMeta::default();
1895 let html = export_html(&entries, &meta, None, None).unwrap();
1896 let pos_before = html.find("alpha-marker").unwrap();
1897 let pos_tool = html.find("<div class=\"tool-call").unwrap();
1898 let pos_after = html.find("omega-marker").unwrap();
1899 assert!(pos_before < pos_tool);
1900 assert!(pos_tool < pos_after);
1901 }
1902
1903 #[test]
1904 fn export_tool_result_entry_renders() {
1905 let entries = vec![
1906 make_entry(AgentMessage::Assistant {
1907 content: vec![AssistantContentBlock::ToolCall {
1908 id: "tc-1".into(),
1909 name: "bash".into(),
1910 arguments: serde_json::json!({"command": "ls"}),
1911 }],
1912 provider: None,
1913 model_id: None,
1914 usage: None,
1915 stop_reason: None,
1916 }),
1917 make_entry(AgentMessage::ToolResult {
1918 content: "output here".into(),
1919 tool_call_id: "tc-1".to_string(),
1920 }),
1921 ];
1922 let meta = ExportMeta::default();
1923 let html = export_html(&entries, &meta, None, None).unwrap();
1924 assert!(html.contains("<div class=\"tool-result"));
1925 assert!(html.contains("data-tool-call-id=\"tc-1\""));
1926 assert!(html.contains("output here"));
1927 }
1928}