1use crate::agent::extension::{CommandHandler, CommandResult};
16use crate::agent::session::model::{CURRENT_SESSION_VERSION, Session, SessionHeader};
17use std::path::{Path, PathBuf};
18
19mod templates {
24 pub const TEMPLATE_HTML: &[u8] = include_bytes!("export/templates/template.html");
25 pub const TEMPLATE_CSS: &[u8] = include_bytes!("export/templates/template.css");
26 pub const TEMPLATE_JS: &[u8] = include_bytes!("export/templates/template.js");
27 pub const MARKED_JS: &[u8] = include_bytes!("export/templates/vendor/marked.min.js");
28 pub const HIGHLIGHT_JS: &[u8] = include_bytes!("export/templates/vendor/highlight.min.js");
29}
30
31pub fn get_path_command_argument(text: &str, command: &str) -> Option<String> {
42 if text == command {
43 return None;
44 }
45 let prefix = format!("{} ", command);
46 if !text.starts_with(&prefix) {
47 return None;
48 }
49
50 let args_string = text[prefix.len()..].trim_start();
51 if args_string.is_empty() {
52 return None;
53 }
54
55 let first_char = args_string.chars().next().unwrap();
56 if first_char == '"' || first_char == '\'' {
57 let closing = args_string[1..].find(first_char)?;
58 return Some(args_string[1..=closing].to_string());
59 }
60
61 let first_whitespace = args_string.find(char::is_whitespace);
62 match first_whitespace {
63 Some(idx) => Some(args_string[..idx].to_string()),
64 None => Some(args_string.to_string()),
65 }
66}
67
68#[derive(Debug)]
71pub enum ExportError {
72 NoSession,
73 InMemorySession,
74 IoError(std::io::Error),
75 JsonError(serde_json::Error),
76 TemplateError(String),
77}
78
79impl std::fmt::Display for ExportError {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 match self {
82 ExportError::NoSession => write!(f, "No active session"),
83 ExportError::InMemorySession => {
84 write!(
85 f,
86 "Cannot export an in-memory session without a session file"
87 )
88 }
89 ExportError::IoError(e) => write!(f, "IO error: {}", e),
90 ExportError::JsonError(e) => write!(f, "JSON error: {}", e),
91 ExportError::TemplateError(e) => write!(f, "Template error: {}", e),
92 }
93 }
94}
95
96impl std::error::Error for ExportError {}
97
98impl From<std::io::Error> for ExportError {
99 fn from(e: std::io::Error) -> Self {
100 ExportError::IoError(e)
101 }
102}
103
104impl From<serde_json::Error> for ExportError {
105 fn from(e: serde_json::Error) -> Self {
106 ExportError::JsonError(e)
107 }
108}
109
110pub fn export_to_jsonl(
123 session: &Session,
124 cwd: &Path,
125 output_path: Option<&str>,
126) -> Result<PathBuf, ExportError> {
127 let file_path = match output_path {
128 Some(p) => crate::builtin::resolve_path(p, cwd),
129 None => {
130 let ts = chrono::Utc::now()
131 .format("session-%Y-%m-%dT%H-%M-%S")
132 .to_string();
133 cwd.join(format!("{}.jsonl", ts))
134 }
135 };
136
137 if let Some(parent) = file_path.parent() {
139 std::fs::create_dir_all(parent)?;
140 }
141
142 let meta = session.metadata();
144 let header = SessionHeader {
145 type_: "session".to_string(),
146 version: Some(CURRENT_SESSION_VERSION),
147 id: meta.id.clone(),
148 timestamp: chrono::Utc::now().to_rfc3339(),
149 cwd: meta.cwd.clone(),
150 parent_session: meta.parent_session_path.clone(),
151 };
152
153 let branch_entries = session
155 .get_branch(None)
156 .map_err(|e| ExportError::TemplateError(format!("Failed to get branch: {}", e)))?;
157
158 let mut lines = Vec::with_capacity(branch_entries.len() + 1);
160 lines.push(serde_json::to_string(&header)?);
161
162 let mut prev_id: Option<String> = None;
164 for entry in &branch_entries {
165 let mut value = serde_json::to_value(entry)?;
166 if let Some(obj) = value.as_object_mut() {
167 match prev_id {
168 Some(ref pid) => {
169 obj.insert(
170 "parentId".to_string(),
171 serde_json::Value::String(pid.clone()),
172 );
173 }
174 None => {
175 obj.insert("parentId".to_string(), serde_json::Value::Null);
176 }
177 }
178 }
179 prev_id = Some(entry.id().to_string());
180 lines.push(serde_json::to_string(&value)?);
181 }
182
183 let content = lines.join("\n") + "\n";
184 std::fs::write(&file_path, content)?;
185
186 Ok(file_path)
187}
188
189struct ExportThemeColors {
196 theme_vars: String,
198 body_bg: String,
200 container_bg: String,
202 info_bg: String,
204}
205
206fn load_export_theme_colors(theme_name: Option<&str>) -> ExportThemeColors {
209 let colors = resolve_theme_hex_colors(theme_name.unwrap_or("dark"));
211
212 let mut lines: Vec<String> = Vec::new();
214 let export_keys = [
216 "text",
217 "dim",
218 "muted",
219 "accent",
220 "success",
221 "error",
222 "warning",
223 "border",
224 "borderAccent",
225 "selectedBg",
226 "hover",
227 "userMessageBg",
228 "userMessageText",
229 "thinkingText",
230 "customMessageBg",
231 "customMessageLabel",
232 "customMessageText",
233 "toolPendingBg",
234 "toolSuccessBg",
235 "toolErrorBg",
236 "toolOutput",
237 "toolTitle",
238 "toolDiffAdded",
239 "toolDiffRemoved",
240 "toolDiffContext",
241 "mdHeading",
242 "mdLink",
243 "mdLinkUrl",
244 "mdCode",
245 "mdCodeBlock",
246 "mdCodeBlockBorder",
247 "mdQuote",
248 "mdQuoteBorder",
249 "mdHr",
250 "mdListBullet",
251 "syntaxComment",
252 "syntaxKeyword",
253 "syntaxNumber",
254 "syntaxString",
255 "syntaxFunction",
256 "syntaxType",
257 "syntaxVariable",
258 "syntaxOperator",
259 "syntaxPunctuation",
260 ];
261
262 for key in &export_keys {
263 if let Some(value) = colors.get(*key) {
264 lines.push(format!("--{}: {};", key, value));
265 }
266 }
267
268 for (key, value) in &colors {
270 if !export_keys.contains(&key.as_str()) {
271 lines.push(format!("--{}: {};", key, value));
272 }
273 }
274
275 let theme_vars = lines.join("\n ");
276
277 let user_message_bg = colors
279 .get("userMessageBg")
280 .map(|s| s.as_str())
281 .unwrap_or("#343541");
282
283 let derived = derive_export_colors(user_message_bg);
284
285 ExportThemeColors {
286 theme_vars,
287 body_bg: derived.page_bg,
288 container_bg: derived.card_bg,
289 info_bg: derived.info_bg,
290 }
291}
292
293fn parse_hex_color(hex: &str) -> Option<(u8, u8, u8)> {
295 let hex = hex.trim_start_matches('#');
296 if hex.len() != 6 {
297 return None;
298 }
299 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
300 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
301 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
302 Some((r, g, b))
303}
304
305fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
307 let to_linear = |c: u8| {
308 let s = c as f64 / 255.0;
309 if s <= 0.03928 {
310 s / 12.92
311 } else {
312 ((s + 0.055) / 1.055).powf(2.4)
313 }
314 };
315 0.2126 * to_linear(r) + 0.7152 * to_linear(g) + 0.0722 * to_linear(b)
316}
317
318fn adjust_brightness(hex: &str, factor: f64) -> String {
320 let (r, g, b) = match parse_hex_color(hex) {
321 Some(c) => c,
322 None => return hex.to_string(),
323 };
324 let adj = |c: u8| (c as f64 * factor).clamp(0.0, 255.0).round() as u8;
325 format!("#{:02x}{:02x}{:02x}", adj(r), adj(g), adj(b))
326}
327
328struct DerivedExportColors {
330 page_bg: String,
331 card_bg: String,
332 info_bg: String,
333}
334
335fn derive_export_colors(base_color: &str) -> DerivedExportColors {
336 let rgb = match parse_hex_color(base_color) {
337 Some(c) => c,
338 None => {
339 return DerivedExportColors {
340 page_bg: "#18181e".to_string(),
341 card_bg: "#1e1e24".to_string(),
342 info_bg: "#3c3728".to_string(),
343 };
344 }
345 };
346
347 let luminance = relative_luminance(rgb.0, rgb.1, rgb.2);
348 let is_light = luminance > 0.5;
349
350 if is_light {
351 DerivedExportColors {
352 page_bg: adjust_brightness(base_color, 0.96),
353 card_bg: base_color.to_string(),
354 info_bg: format!(
355 "#{:02x}{:02x}{:02x}",
356 (rgb.0 as u16 + 10).min(255) as u8,
357 (rgb.1 as u16 + 5).min(255) as u8,
358 (rgb.2 as u16).max(20).saturating_sub(20) as u8,
359 ),
360 }
361 } else {
362 DerivedExportColors {
363 page_bg: adjust_brightness(base_color, 0.7),
364 card_bg: adjust_brightness(base_color, 0.85),
365 info_bg: format!(
366 "#{:02x}{:02x}{:02x}",
367 (rgb.0 as u16 + 20).min(255) as u8,
368 (rgb.1 as u16 + 15).min(255) as u8,
369 rgb.2,
370 ),
371 }
372 }
373}
374
375fn resolve_theme_hex_colors(theme_name: &str) -> std::collections::HashMap<String, String> {
378 let config = crate::agent::ui::theme::load_theme_config(theme_name)
380 .or_else(|_| crate::agent::ui::theme::load_theme_config("dark"))
381 .unwrap_or_else(|_| {
382 use crate::agent::ui::theme::{ColorValue, ThemeConfig};
384 let mut colors = std::collections::HashMap::new();
385 let entries: Vec<(&str, &str)> = vec![
386 ("text", "#d4d4d4"),
387 ("dim", "#666666"),
388 ("muted", "#808080"),
389 ("accent", "#8abeb7"),
390 ("success", "#b5bd68"),
391 ("error", "#cc6666"),
392 ("warning", "#e8a838"),
393 ("border", "#333"),
394 ("borderAccent", "#8abeb7"),
395 ("selectedBg", "#2a2a2a"),
396 ("hover", "#333"),
397 ("userMessageBg", "#343541"),
398 ("userMessageText", "#d4d4d4"),
399 ("thinkingText", "#808080"),
400 ("customMessageBg", "#1e1e24"),
401 ("customMessageLabel", "#8abeb7"),
402 ("customMessageText", "#d4d4d4"),
403 ("toolPendingBg", "#282832"),
404 ("toolSuccessBg", "#283228"),
405 ("toolErrorBg", "#3c2828"),
406 ("toolOutput", "#808080"),
407 ("toolTitle", "#d4d4d4"),
408 ("toolDiffAdded", "#22c55e"),
409 ("toolDiffRemoved", "#ef4444"),
410 ("toolDiffContext", "#808080"),
411 ("mdHeading", "#e8a838"),
412 ("mdLink", "#8abeb7"),
413 ("mdLinkUrl", "#5f87af"),
414 ("mdCode", "#e8a838"),
415 ("mdCodeBlock", "#808080"),
416 ("mdCodeBlockBorder", "#444"),
417 ("mdQuote", "#808080"),
418 ("mdQuoteBorder", "#555"),
419 ("mdHr", "#555"),
420 ("mdListBullet", "#8abeb7"),
421 ("syntaxComment", "#6a9955"),
422 ("syntaxKeyword", "#569cd6"),
423 ("syntaxNumber", "#b5cea8"),
424 ("syntaxString", "#ce9178"),
425 ("syntaxFunction", "#dcdcaa"),
426 ("syntaxType", "#4ec9b0"),
427 ("syntaxVariable", "#9cdcfe"),
428 ("syntaxOperator", "#d4d4d4"),
429 ("syntaxPunctuation", "#d4d4d4"),
430 ];
431 for (k, v) in entries {
432 colors.insert(k.to_string(), ColorValue::HexOrVar(v.to_string()));
433 }
434 ThemeConfig {
435 name: "dark".to_string(),
436 vars: std::collections::HashMap::new(),
437 colors,
438 }
439 });
440
441 crate::agent::ui::theme::RabTheme::resolve_colors(&config)
442}
443
444fn build_session_data_json(
451 session: &Session,
452 system_prompt: Option<&str>,
453) -> Result<serde_json::Value, ExportError> {
454 let meta = session.metadata();
455
456 let mut header = serde_json::json!({
457 "type": "session",
458 "version": CURRENT_SESSION_VERSION,
459 "id": meta.id,
460 "timestamp": meta.created_at,
461 "cwd": meta.cwd,
462 });
463
464 if let Some(ref ps) = meta.parent_session_path
466 && let Some(obj) = header.as_object_mut()
467 {
468 obj.insert(
469 "parentSession".to_string(),
470 serde_json::Value::String(ps.clone()),
471 );
472 }
473
474 let leaf_id = session.get_leaf_id();
475
476 let mut data = serde_json::json!({
477 "header": header,
478 "entries": session.get_entries(),
479 "leafId": leaf_id,
480 });
481
482 if let Some(sp) = system_prompt
484 && let Some(obj) = data.as_object_mut()
485 {
486 obj.insert(
487 "systemPrompt".to_string(),
488 serde_json::Value::String(sp.to_string()),
489 );
490 }
491
492 Ok(data)
497}
498
499pub fn export_to_html(
509 session: &Session,
510 system_prompt: Option<&str>,
511 cwd: &Path,
512 output_path: Option<&str>,
513 theme_name: Option<&str>,
514) -> Result<PathBuf, ExportError> {
515 let file_path = match output_path {
517 Some(p) => crate::builtin::resolve_path(p, cwd),
518 None => {
519 let session_id = &session.metadata().id;
520 let short_id = if session_id.len() > 8 {
521 &session_id[..8]
522 } else {
523 session_id.as_str()
524 };
525 cwd.join(format!("rab-session-{}.html", short_id))
526 }
527 };
528
529 if let Some(parent) = file_path.parent() {
531 std::fs::create_dir_all(parent)?;
532 }
533
534 let theme_name = theme_name
536 .map(|s| s.to_string())
537 .unwrap_or_else(|| crate::agent::ui::theme::current_theme().name.clone());
538
539 let session_data = build_session_data_json(session, system_prompt)?;
541 let session_data_json = serde_json::to_string(&session_data)?;
542
543 use base64::Engine as _;
545 let session_data_base64 =
546 base64::engine::general_purpose::STANDARD.encode(session_data_json.as_bytes());
547
548 let export_colors = load_export_theme_colors(Some(&theme_name));
550
551 let template_html = String::from_utf8(templates::TEMPLATE_HTML.to_vec()).map_err(|e| {
553 ExportError::TemplateError(format!("Invalid UTF-8 in template.html: {}", e))
554 })?;
555 let template_css = String::from_utf8(templates::TEMPLATE_CSS.to_vec())
556 .map_err(|e| ExportError::TemplateError(format!("Invalid UTF-8 in template.css: {}", e)))?;
557 let template_js = String::from_utf8(templates::TEMPLATE_JS.to_vec())
558 .map_err(|e| ExportError::TemplateError(format!("Invalid UTF-8 in template.js: {}", e)))?;
559 let marked_js = String::from_utf8(templates::MARKED_JS.to_vec()).map_err(|e| {
560 ExportError::TemplateError(format!("Invalid UTF-8 in marked.min.js: {}", e))
561 })?;
562 let highlight_js = String::from_utf8(templates::HIGHLIGHT_JS.to_vec()).map_err(|e| {
563 ExportError::TemplateError(format!("Invalid UTF-8 in highlight.min.js: {}", e))
564 })?;
565
566 let css = template_css
568 .replace("{{THEME_VARS}}", &export_colors.theme_vars)
569 .replace("{{BODY_BG}}", &export_colors.body_bg)
570 .replace("{{CONTAINER_BG}}", &export_colors.container_bg)
571 .replace("{{INFO_BG}}", &export_colors.info_bg);
572
573 let html = template_html
575 .replace("{{CSS}}", &css)
576 .replace("{{JS}}", &template_js)
577 .replace("{{SESSION_DATA}}", &session_data_base64)
578 .replace("{{MARKED_JS}}", &marked_js)
579 .replace("{{HIGHLIGHT_JS}}", &highlight_js);
580
581 std::fs::write(&file_path, html)?;
582
583 Ok(file_path)
584}
585
586pub struct ExportCommand;
591
592impl CommandHandler for ExportCommand {
593 fn execute(&self, args: &str) -> anyhow::Result<CommandResult> {
594 let text = if args.is_empty() {
595 "/export".to_string()
596 } else {
597 format!("/export {}", args)
598 };
599
600 let path = get_path_command_argument(&text, "/export");
601 Ok(CommandResult::ExportSession { path })
602 }
603}
604
605pub struct ImportCommand;
610
611impl CommandHandler for ImportCommand {
612 fn execute(&self, args: &str) -> anyhow::Result<CommandResult> {
613 let text = if args.is_empty() {
614 "/import".to_string()
615 } else {
616 format!("/import {}", args)
617 };
618
619 let path = get_path_command_argument(&text, "/import");
620 match path {
621 Some(p) => Ok(CommandResult::ImportSession { path: p }),
622 None => Ok(CommandResult::Info(
623 "Usage: /import <path.jsonl>".to_string(),
624 )),
625 }
626 }
627}
628
629#[cfg(test)]
632mod tests {
633 use super::*;
634
635 #[test]
636 fn test_get_path_no_arg() {
637 assert_eq!(get_path_command_argument("/export", "/export"), None);
638 assert_eq!(get_path_command_argument("/import", "/import"), None);
639 }
640
641 #[test]
642 fn test_get_path_simple() {
643 assert_eq!(
644 get_path_command_argument("/export output.html", "/export"),
645 Some("output.html".to_string())
646 );
647 }
648
649 #[test]
650 fn test_get_path_quoted_double() {
651 assert_eq!(
652 get_path_command_argument("/export \"my session.html\"", "/export"),
653 Some("my session.html".to_string())
654 );
655 }
656
657 #[test]
658 fn test_get_path_quoted_single() {
659 assert_eq!(
660 get_path_command_argument("/export 'my session.html'", "/export"),
661 Some("my session.html".to_string())
662 );
663 }
664
665 #[test]
666 fn test_get_path_no_close_quote() {
667 assert_eq!(
668 get_path_command_argument("/export \"no close", "/export"),
669 None
670 );
671 }
672
673 #[test]
674 fn test_get_path_command_prefix_mismatch() {
675 assert_eq!(
676 get_path_command_argument("/exporter out.html", "/export"),
677 None
678 );
679 }
680
681 #[test]
682 fn test_get_path_only_whitespace() {
683 assert_eq!(get_path_command_argument("/export ", "/export"), None);
684 assert_eq!(get_path_command_argument("/import ", "/import"), None);
685 }
686
687 #[test]
688 fn test_export_command_no_args() {
689 let cmd = ExportCommand;
690 let result = cmd.execute("").unwrap();
691 match result {
692 CommandResult::ExportSession { path } => assert_eq!(path, None),
693 _ => panic!("Expected ExportSession with None"),
694 }
695 }
696
697 #[test]
698 fn test_export_command_with_path() {
699 let cmd = ExportCommand;
700 let result = cmd.execute("test.html").unwrap();
701 match result {
702 CommandResult::ExportSession { path } => {
703 assert_eq!(path, Some("test.html".to_string()));
704 }
705 _ => panic!("Expected ExportSession with path"),
706 }
707 }
708
709 #[test]
710 fn test_import_command_no_args() {
711 let cmd = ImportCommand;
712 let result = cmd.execute("").unwrap();
713 match result {
714 CommandResult::Info(msg) => assert!(msg.contains("Usage:")),
715 _ => panic!("Expected Info message"),
716 }
717 }
718
719 #[test]
720 fn test_import_command_with_path() {
721 let cmd = ImportCommand;
722 let result = cmd.execute("session.jsonl").unwrap();
723 match result {
724 CommandResult::ImportSession { path } => {
725 assert_eq!(path, "session.jsonl");
726 }
727 _ => panic!("Expected ImportSession"),
728 }
729 }
730
731 #[test]
732 fn test_parse_hex_color() {
733 assert_eq!(parse_hex_color("#ff0000"), Some((255, 0, 0)));
734 assert_eq!(parse_hex_color("00ff00"), Some((0, 255, 0)));
735 assert_eq!(parse_hex_color("#fff"), None);
736 assert_eq!(parse_hex_color("invalid"), None);
737 }
738
739 #[test]
740 fn test_relative_luminance() {
741 let black = relative_luminance(0, 0, 0);
742 let white = relative_luminance(255, 255, 255);
743 assert!(black < 0.1);
744 assert!(white > 0.9);
745 }
746
747 #[test]
748 fn test_derive_export_colors_dark() {
749 let derived = derive_export_colors("#343541");
750 let page_rgb = parse_hex_color(&derived.page_bg).unwrap();
752 let card_rgb = parse_hex_color(&derived.card_bg).unwrap();
753 assert!(page_rgb.0 < card_rgb.0); }
755
756 #[test]
757 fn test_derive_export_colors_light() {
758 let derived = derive_export_colors("#ffffff");
759 let page_rgb = parse_hex_color(&derived.page_bg).unwrap();
761 assert!(page_rgb.0 < 255);
762 }
763
764 #[test]
765 fn test_adjust_brightness() {
766 let result = adjust_brightness("#808080", 0.5);
767 let rgb = parse_hex_color(&result).unwrap();
768 assert!(rgb.0 <= 70 && rgb.0 >= 60);
770 }
771}