1use ansi_to_tui::IntoText;
7use crossterm::{
8 QueueableCommand,
9 style::{self, Color},
10};
11use ratatui::{
12 style::{Color as RatatuiColor, Style},
13 text::{Line, Span},
14};
15use std::io::{self, Write};
16use std::sync::{Arc, Mutex};
17use termimad::MadSkin;
18
19#[inline]
24pub(crate) fn contains_ansi(text: &str) -> bool {
25 text.contains("\x1b[")
26}
27
28#[derive(Debug, Clone)]
30pub struct SessionResult {
31 pub duration_ms: u64,
32 pub total_cost_usd: f64,
33 pub num_turns: u32,
34 pub is_error: bool,
35}
36
37pub struct PrettyStreamHandler {
39 stdout: io::Stdout,
40 verbose: bool,
41 text_buffer: String,
43 skin: MadSkin,
45}
46
47impl PrettyStreamHandler {
48 pub fn new(verbose: bool) -> Self {
50 Self {
51 stdout: io::stdout(),
52 verbose,
53 text_buffer: String::new(),
54 skin: MadSkin::default(),
55 }
56 }
57
58 fn flush_text_buffer(&mut self) {
60 if self.text_buffer.is_empty() {
61 return;
62 }
63 let rendered = self.skin.term_text(&self.text_buffer);
65 let _ = self.stdout.write(rendered.to_string().as_bytes());
66 let _ = self.stdout.flush();
67 self.text_buffer.clear();
68 }
69}
70
71impl StreamHandler for PrettyStreamHandler {
72 fn on_text(&mut self, text: &str) {
73 self.text_buffer.push_str(text);
78 }
79
80 fn on_tool_result(&mut self, _id: &str, output: &str) {
81 if self.verbose {
82 let _ = self
83 .stdout
84 .queue(style::SetForegroundColor(Color::DarkGrey));
85 let _ = self
86 .stdout
87 .write(format!(" \u{2713} {}\n", truncate(output, 200)).as_bytes());
88 let _ = self.stdout.queue(style::ResetColor);
89 let _ = self.stdout.flush();
90 }
91 }
92
93 fn on_error(&mut self, error: &str) {
94 let _ = self.stdout.queue(style::SetForegroundColor(Color::Red));
95 let _ = self
96 .stdout
97 .write(format!("\n\u{2717} Error: {}\n", error).as_bytes());
98 let _ = self.stdout.queue(style::ResetColor);
99 let _ = self.stdout.flush();
100 }
101
102 fn on_complete(&mut self, result: &SessionResult) {
103 self.flush_text_buffer();
105
106 let _ = self.stdout.write(b"\n");
107 let color = if result.is_error {
108 Color::Red
109 } else {
110 Color::Green
111 };
112 let _ = self.stdout.queue(style::SetForegroundColor(color));
113 let _ = self.stdout.write(
114 format!(
115 "Duration: {}ms | Est. cost: ${:.4} | Turns: {}\n",
116 result.duration_ms, result.total_cost_usd, result.num_turns
117 )
118 .as_bytes(),
119 );
120 let _ = self.stdout.queue(style::ResetColor);
121 let _ = self.stdout.flush();
122 }
123
124 fn on_tool_call(&mut self, name: &str, _id: &str, input: &serde_json::Value) {
125 self.flush_text_buffer();
127
128 let _ = self.stdout.queue(style::SetForegroundColor(Color::Blue));
130 let _ = self.stdout.write(format!("\u{2699} [{}]", name).as_bytes());
131
132 if let Some(summary) = format_tool_summary(name, input) {
133 let _ = self
134 .stdout
135 .queue(style::SetForegroundColor(Color::DarkGrey));
136 let _ = self.stdout.write(format!(" {}\n", summary).as_bytes());
137 } else {
138 let _ = self.stdout.write(b"\n");
139 }
140 let _ = self.stdout.queue(style::ResetColor);
141 let _ = self.stdout.flush();
142 }
143}
144
145pub trait StreamHandler: Send {
150 fn on_text(&mut self, text: &str);
152
153 fn on_tool_call(&mut self, name: &str, id: &str, input: &serde_json::Value);
160
161 fn on_tool_result(&mut self, id: &str, output: &str);
163
164 fn on_error(&mut self, error: &str);
166
167 fn on_complete(&mut self, result: &SessionResult);
169}
170
171pub struct ConsoleStreamHandler {
176 verbose: bool,
177 stdout: io::Stdout,
178 stderr: io::Stderr,
179 last_was_newline: bool,
181}
182
183impl ConsoleStreamHandler {
184 pub fn new(verbose: bool) -> Self {
189 Self {
190 verbose,
191 stdout: io::stdout(),
192 stderr: io::stderr(),
193 last_was_newline: true, }
195 }
196
197 fn ensure_newline(&mut self) {
199 if !self.last_was_newline {
200 let _ = writeln!(self.stdout);
201 self.last_was_newline = true;
202 }
203 }
204}
205
206impl StreamHandler for ConsoleStreamHandler {
207 fn on_text(&mut self, text: &str) {
208 let _ = write!(self.stdout, "{}", text);
209 self.last_was_newline = text.ends_with('\n');
210 }
211
212 fn on_tool_call(&mut self, name: &str, _id: &str, input: &serde_json::Value) {
213 self.ensure_newline();
214 match format_tool_summary(name, input) {
215 Some(summary) => {
216 let _ = writeln!(self.stdout, "[Tool] {}: {}", name, summary);
217 }
218 None => {
219 let _ = writeln!(self.stdout, "[Tool] {}", name);
220 }
221 }
222 self.last_was_newline = true;
224 }
225
226 fn on_tool_result(&mut self, _id: &str, output: &str) {
227 if self.verbose {
228 let _ = writeln!(self.stdout, "[Result] {}", truncate(output, 200));
229 }
230 }
231
232 fn on_error(&mut self, error: &str) {
233 let _ = writeln!(self.stdout, "[Error] {}", error);
235 let _ = writeln!(self.stderr, "[Error] {}", error);
236 }
237
238 fn on_complete(&mut self, result: &SessionResult) {
239 if self.verbose {
240 let _ = writeln!(
241 self.stdout,
242 "\n--- Session Complete ---\nDuration: {}ms | Est. cost: ${:.4} | Turns: {}",
243 result.duration_ms, result.total_cost_usd, result.num_turns
244 );
245 }
246 }
247}
248
249pub struct QuietStreamHandler;
251
252impl StreamHandler for QuietStreamHandler {
253 fn on_text(&mut self, _: &str) {}
254 fn on_tool_call(&mut self, _: &str, _: &str, _: &serde_json::Value) {}
255 fn on_tool_result(&mut self, _: &str, _: &str) {}
256 fn on_error(&mut self, _: &str) {}
257 fn on_complete(&mut self, _: &SessionResult) {}
258}
259
260fn text_to_lines(text: &str) -> Vec<Line<'static>> {
270 if text.is_empty() {
271 return Vec::new();
272 }
273
274 let ansi_text = if contains_ansi(text) {
278 text.to_string()
279 } else {
280 let skin = MadSkin::default();
283 skin.term_text(text).to_string()
284 };
285
286 match ansi_text.as_str().into_text() {
288 Ok(parsed_text) => {
289 parsed_text
291 .lines
292 .into_iter()
293 .map(|line| {
294 let owned_spans: Vec<Span<'static>> = line
295 .spans
296 .into_iter()
297 .map(|span| Span::styled(span.content.into_owned(), span.style))
298 .collect();
299 Line::from(owned_spans)
300 })
301 .collect()
302 }
303 Err(_) => {
304 text.split('\n')
306 .map(|line| Line::from(line.to_string()))
307 .collect()
308 }
309 }
310}
311
312#[derive(Clone)]
316enum ContentBlock {
317 Text(String),
319 NonText(Line<'static>),
321}
322
323pub struct TuiStreamHandler {
335 current_text_buffer: String,
337 blocks: Vec<ContentBlock>,
339 verbose: bool,
341 lines: Arc<Mutex<Vec<Line<'static>>>>,
343}
344
345impl TuiStreamHandler {
346 pub fn new(verbose: bool) -> Self {
351 Self {
352 current_text_buffer: String::new(),
353 blocks: Vec::new(),
354 verbose,
355 lines: Arc::new(Mutex::new(Vec::new())),
356 }
357 }
358
359 pub fn with_lines(verbose: bool, lines: Arc<Mutex<Vec<Line<'static>>>>) -> Self {
363 Self {
364 current_text_buffer: String::new(),
365 blocks: Vec::new(),
366 verbose,
367 lines,
368 }
369 }
370
371 pub fn get_lines(&self) -> Vec<Line<'static>> {
373 self.lines.lock().unwrap().clone()
374 }
375
376 pub fn flush_text_buffer(&mut self) {
378 self.update_lines();
379 }
380
381 fn freeze_current_text(&mut self) {
386 if !self.current_text_buffer.is_empty() {
387 self.blocks
388 .push(ContentBlock::Text(self.current_text_buffer.clone()));
389 self.current_text_buffer.clear();
390 }
391 }
392
393 fn update_lines(&mut self) {
399 let mut all_lines = Vec::new();
400
401 for block in &self.blocks {
403 match block {
404 ContentBlock::Text(text) => {
405 all_lines.extend(text_to_lines(text));
406 }
407 ContentBlock::NonText(line) => {
408 all_lines.push(line.clone());
409 }
410 }
411 }
412
413 if !self.current_text_buffer.is_empty() {
415 all_lines.extend(text_to_lines(&self.current_text_buffer));
416 }
417
418 *self.lines.lock().unwrap() = all_lines;
423 }
424
425 fn add_non_text_line(&mut self, line: Line<'static>) {
429 self.freeze_current_text();
430 self.blocks.push(ContentBlock::NonText(line));
431 self.update_lines();
432 }
433}
434
435impl StreamHandler for TuiStreamHandler {
436 fn on_text(&mut self, text: &str) {
437 self.current_text_buffer.push_str(text);
439
440 self.update_lines();
443 }
444
445 fn on_tool_call(&mut self, name: &str, _id: &str, input: &serde_json::Value) {
446 let mut spans = vec![Span::styled(
448 format!("\u{2699} [{}]", name),
449 Style::default().fg(RatatuiColor::Blue),
450 )];
451
452 if let Some(summary) = format_tool_summary(name, input) {
453 spans.push(Span::styled(
454 format!(" {}", summary),
455 Style::default().fg(RatatuiColor::DarkGray),
456 ));
457 }
458
459 self.add_non_text_line(Line::from(spans));
460 }
461
462 fn on_tool_result(&mut self, _id: &str, output: &str) {
463 if self.verbose {
464 let line = Line::from(Span::styled(
465 format!(" \u{2713} {}", truncate(output, 200)),
466 Style::default().fg(RatatuiColor::DarkGray),
467 ));
468 self.add_non_text_line(line);
469 }
470 }
471
472 fn on_error(&mut self, error: &str) {
473 let line = Line::from(Span::styled(
474 format!("\n\u{2717} Error: {}", error),
475 Style::default().fg(RatatuiColor::Red),
476 ));
477 self.add_non_text_line(line);
478 }
479
480 fn on_complete(&mut self, result: &SessionResult) {
481 self.flush_text_buffer();
483
484 self.add_non_text_line(Line::from(""));
486
487 let color = if result.is_error {
489 RatatuiColor::Red
490 } else {
491 RatatuiColor::Green
492 };
493 let summary = format!(
494 "Duration: {}ms | Est. cost: ${:.4} | Turns: {}",
495 result.duration_ms, result.total_cost_usd, result.num_turns
496 );
497 let line = Line::from(Span::styled(summary, Style::default().fg(color)));
498 self.add_non_text_line(line);
499 }
500}
501
502fn format_tool_summary(name: &str, input: &serde_json::Value) -> Option<String> {
507 match name {
508 "Read" | "Edit" | "Write" => input.get("file_path")?.as_str().map(|s| s.to_string()),
509 "Bash" => {
510 let cmd = input.get("command")?.as_str()?;
511 Some(truncate(cmd, 60))
512 }
513 "Grep" => input.get("pattern")?.as_str().map(|s| s.to_string()),
514 "Glob" => input.get("pattern")?.as_str().map(|s| s.to_string()),
515 "Task" => input.get("description")?.as_str().map(|s| s.to_string()),
516 "WebFetch" => input.get("url")?.as_str().map(|s| s.to_string()),
517 "WebSearch" => input.get("query")?.as_str().map(|s| s.to_string()),
518 "LSP" => {
519 let op = input.get("operation")?.as_str()?;
520 let file = input.get("filePath")?.as_str()?;
521 Some(format!("{} @ {}", op, file))
522 }
523 "NotebookEdit" => input.get("notebook_path")?.as_str().map(|s| s.to_string()),
524 "TodoWrite" => Some("updating todo list".to_string()),
525 _ => None,
526 }
527}
528
529fn truncate(s: &str, max_len: usize) -> String {
534 if s.chars().count() <= max_len {
535 s.to_string()
536 } else {
537 let byte_idx = s
539 .char_indices()
540 .nth(max_len)
541 .map(|(idx, _)| idx)
542 .unwrap_or(s.len());
543 format!("{}...", &s[..byte_idx])
544 }
545}
546
547#[cfg(test)]
548mod tests {
549 use super::*;
550 use serde_json::json;
551
552 #[test]
553 fn test_console_handler_verbose_shows_results() {
554 let mut handler = ConsoleStreamHandler::new(true);
555 let bash_input = json!({"command": "ls -la"});
556
557 handler.on_text("Hello");
559 handler.on_tool_call("Bash", "tool_1", &bash_input);
560 handler.on_tool_result("tool_1", "output");
561 handler.on_complete(&SessionResult {
562 duration_ms: 1000,
563 total_cost_usd: 0.01,
564 num_turns: 1,
565 is_error: false,
566 });
567 }
568
569 #[test]
570 fn test_console_handler_normal_skips_results() {
571 let mut handler = ConsoleStreamHandler::new(false);
572 let read_input = json!({"file_path": "src/main.rs"});
573
574 handler.on_text("Hello");
576 handler.on_tool_call("Read", "tool_1", &read_input);
577 handler.on_tool_result("tool_1", "output"); handler.on_complete(&SessionResult {
579 duration_ms: 1000,
580 total_cost_usd: 0.01,
581 num_turns: 1,
582 is_error: false,
583 }); }
585
586 #[test]
587 fn test_quiet_handler_is_silent() {
588 let mut handler = QuietStreamHandler;
589 let empty_input = json!({});
590
591 handler.on_text("Hello");
593 handler.on_tool_call("Read", "tool_1", &empty_input);
594 handler.on_tool_result("tool_1", "output");
595 handler.on_error("Something went wrong");
596 handler.on_complete(&SessionResult {
597 duration_ms: 1000,
598 total_cost_usd: 0.01,
599 num_turns: 1,
600 is_error: false,
601 });
602 }
603
604 #[test]
605 fn test_truncate_helper() {
606 assert_eq!(truncate("short", 10), "short");
607 assert_eq!(truncate("this is a long string", 10), "this is a ...");
608 }
609
610 #[test]
611 fn test_truncate_utf8_boundaries() {
612 let with_arrows = "→→→→→→→→→→";
614 assert_eq!(truncate(with_arrows, 5), "→→→→→...");
616
617 let mixed = "a→b→c→d→e";
619 assert_eq!(truncate(mixed, 5), "a→b→c...");
620
621 let emoji = "🎉🎊🎁🎈🎄";
623 assert_eq!(truncate(emoji, 3), "🎉🎊🎁...");
624 }
625
626 #[test]
627 fn test_format_tool_summary_file_tools() {
628 assert_eq!(
629 format_tool_summary("Read", &json!({"file_path": "src/main.rs"})),
630 Some("src/main.rs".to_string())
631 );
632 assert_eq!(
633 format_tool_summary("Edit", &json!({"file_path": "/path/to/file.txt"})),
634 Some("/path/to/file.txt".to_string())
635 );
636 assert_eq!(
637 format_tool_summary("Write", &json!({"file_path": "output.json"})),
638 Some("output.json".to_string())
639 );
640 }
641
642 #[test]
643 fn test_format_tool_summary_bash_truncates() {
644 let short_cmd = json!({"command": "ls -la"});
645 assert_eq!(
646 format_tool_summary("Bash", &short_cmd),
647 Some("ls -la".to_string())
648 );
649
650 let long_cmd = json!({"command": "this is a very long command that should be truncated because it exceeds sixty characters"});
651 let result = format_tool_summary("Bash", &long_cmd).unwrap();
652 assert!(result.ends_with("..."));
653 assert!(result.len() <= 70); }
655
656 #[test]
657 fn test_format_tool_summary_search_tools() {
658 assert_eq!(
659 format_tool_summary("Grep", &json!({"pattern": "TODO"})),
660 Some("TODO".to_string())
661 );
662 assert_eq!(
663 format_tool_summary("Glob", &json!({"pattern": "**/*.rs"})),
664 Some("**/*.rs".to_string())
665 );
666 }
667
668 #[test]
669 fn test_format_tool_summary_unknown_tool_returns_none() {
670 assert_eq!(
671 format_tool_summary("UnknownTool", &json!({"some_field": "value"})),
672 None
673 );
674 }
675
676 #[test]
677 fn test_format_tool_summary_missing_field_returns_none() {
678 assert_eq!(
680 format_tool_summary("Read", &json!({"wrong_field": "value"})),
681 None
682 );
683 assert_eq!(format_tool_summary("Bash", &json!({})), None);
685 }
686
687 mod tui_stream_handler {
692 use super::*;
693 use ratatui::style::{Color, Modifier};
694
695 fn collect_lines(handler: &TuiStreamHandler) -> Vec<ratatui::text::Line<'static>> {
697 handler.lines.lock().unwrap().clone()
698 }
699
700 #[test]
701 fn text_creates_line_on_newline() {
702 let mut handler = TuiStreamHandler::new(false);
704
705 handler.on_text("hello\n");
707
708 let lines = collect_lines(&handler);
711 assert_eq!(
712 lines.len(),
713 1,
714 "termimad doesn't create trailing empty line"
715 );
716 assert_eq!(lines[0].to_string(), "hello");
717 }
718
719 #[test]
720 fn partial_text_buffering() {
721 let mut handler = TuiStreamHandler::new(false);
723
724 handler.on_text("hel");
728 handler.on_text("lo\n");
729
730 let lines = collect_lines(&handler);
732 let full_text: String = lines.iter().map(|l| l.to_string()).collect();
733 assert!(
734 full_text.contains("hello"),
735 "Combined text should contain 'hello'. Lines: {:?}",
736 lines
737 );
738 }
739
740 #[test]
741 fn tool_call_produces_formatted_line() {
742 let mut handler = TuiStreamHandler::new(false);
744
745 handler.on_tool_call("Read", "tool_1", &json!({"file_path": "src/main.rs"}));
747
748 let lines = collect_lines(&handler);
750 assert_eq!(lines.len(), 1);
751 let line_text = lines[0].to_string();
752 assert!(
753 line_text.contains('\u{2699}'),
754 "Should contain gear emoji: {}",
755 line_text
756 );
757 assert!(
758 line_text.contains("Read"),
759 "Should contain tool name: {}",
760 line_text
761 );
762 assert!(
763 line_text.contains("src/main.rs"),
764 "Should contain file path: {}",
765 line_text
766 );
767 }
768
769 #[test]
770 fn tool_result_verbose_shows_content() {
771 let mut handler = TuiStreamHandler::new(true);
773
774 handler.on_tool_result("tool_1", "file contents here");
776
777 let lines = collect_lines(&handler);
779 assert_eq!(lines.len(), 1);
780 let line_text = lines[0].to_string();
781 assert!(
782 line_text.contains('\u{2713}'),
783 "Should contain checkmark: {}",
784 line_text
785 );
786 assert!(
787 line_text.contains("file contents here"),
788 "Should contain result content: {}",
789 line_text
790 );
791 }
792
793 #[test]
794 fn tool_result_quiet_is_silent() {
795 let mut handler = TuiStreamHandler::new(false);
797
798 handler.on_tool_result("tool_1", "file contents here");
800
801 let lines = collect_lines(&handler);
803 assert!(
804 lines.is_empty(),
805 "verbose=false should not produce tool result output"
806 );
807 }
808
809 #[test]
810 fn error_produces_red_styled_line() {
811 let mut handler = TuiStreamHandler::new(false);
813
814 handler.on_error("Something went wrong");
816
817 let lines = collect_lines(&handler);
819 assert_eq!(lines.len(), 1);
820 let line_text = lines[0].to_string();
821 assert!(
822 line_text.contains('\u{2717}'),
823 "Should contain X mark: {}",
824 line_text
825 );
826 assert!(
827 line_text.contains("Error"),
828 "Should contain 'Error': {}",
829 line_text
830 );
831 assert!(
832 line_text.contains("Something went wrong"),
833 "Should contain error message: {}",
834 line_text
835 );
836
837 let first_span = &lines[0].spans[0];
839 assert_eq!(
840 first_span.style.fg,
841 Some(Color::Red),
842 "Error line should have red foreground"
843 );
844 }
845
846 #[test]
847 fn long_lines_preserved_without_truncation() {
848 let mut handler = TuiStreamHandler::new(false);
850
851 let long_string: String = "a".repeat(500) + "\n";
853 handler.on_text(&long_string);
854
855 let lines = collect_lines(&handler);
858
859 let total_content: String = lines.iter().map(|l| l.to_string()).collect();
861 let a_count = total_content.chars().filter(|c| *c == 'a').count();
862 assert_eq!(
863 a_count, 500,
864 "All 500 'a' chars should be preserved. Got {}",
865 a_count
866 );
867
868 assert!(
870 !total_content.contains("..."),
871 "Content should not have ellipsis truncation"
872 );
873 }
874
875 #[test]
876 fn multiple_lines_in_single_text_call() {
877 let mut handler = TuiStreamHandler::new(false);
879 handler.on_text("line1\nline2\nline3\n");
880
881 let lines = collect_lines(&handler);
884 let full_text: String = lines
885 .iter()
886 .map(|l| l.to_string())
887 .collect::<Vec<_>>()
888 .join(" ");
889 assert!(
890 full_text.contains("line1")
891 && full_text.contains("line2")
892 && full_text.contains("line3"),
893 "All lines should be present. Lines: {:?}",
894 lines
895 );
896 }
897
898 #[test]
899 fn termimad_parity_with_non_tui_mode() {
900 let text = "Some text before:★ Insight ─────\nKey point here";
903
904 let mut handler = TuiStreamHandler::new(false);
905 handler.on_text(text);
906
907 let lines = collect_lines(&handler);
908
909 assert!(
912 lines.len() >= 2,
913 "termimad should produce multiple lines. Got: {:?}",
914 lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
915 );
916
917 let full_text: String = lines.iter().map(|l| l.to_string()).collect();
919 assert!(
920 full_text.contains("★ Insight"),
921 "Content should contain insight marker"
922 );
923 }
924
925 #[test]
926 fn tool_call_flushes_text_buffer() {
927 let mut handler = TuiStreamHandler::new(false);
929 handler.on_text("partial text");
930
931 handler.on_tool_call("Read", "id", &json!({}));
933
934 let lines = collect_lines(&handler);
936 assert_eq!(lines.len(), 2);
937 assert_eq!(lines[0].to_string(), "partial text");
938 assert!(lines[1].to_string().contains('\u{2699}'));
939 }
940
941 #[test]
942 fn interleaved_text_and_tools_preserves_chronological_order() {
943 let mut handler = TuiStreamHandler::new(false);
947
948 handler.on_text("I'll start by reviewing the scratchpad.\n");
950 handler.on_tool_call("Read", "id1", &json!({"file_path": "scratchpad.md"}));
951 handler.on_text("I found the task. Now checking the code.\n");
952 handler.on_tool_call("Read", "id2", &json!({"file_path": "main.rs"}));
953 handler.on_text("Done reviewing.\n");
954
955 let lines = collect_lines(&handler);
956
957 let text1_idx = lines
959 .iter()
960 .position(|l| l.to_string().contains("reviewing the scratchpad"));
961 let tool1_idx = lines
962 .iter()
963 .position(|l| l.to_string().contains("scratchpad.md"));
964 let text2_idx = lines
965 .iter()
966 .position(|l| l.to_string().contains("checking the code"));
967 let tool2_idx = lines.iter().position(|l| l.to_string().contains("main.rs"));
968 let text3_idx = lines
969 .iter()
970 .position(|l| l.to_string().contains("Done reviewing"));
971
972 assert!(text1_idx.is_some(), "text1 should be present");
974 assert!(tool1_idx.is_some(), "tool1 should be present");
975 assert!(text2_idx.is_some(), "text2 should be present");
976 assert!(tool2_idx.is_some(), "tool2 should be present");
977 assert!(text3_idx.is_some(), "text3 should be present");
978
979 let text1_idx = text1_idx.unwrap();
981 let tool1_idx = tool1_idx.unwrap();
982 let text2_idx = text2_idx.unwrap();
983 let tool2_idx = tool2_idx.unwrap();
984 let text3_idx = text3_idx.unwrap();
985
986 assert!(
987 text1_idx < tool1_idx,
988 "text1 ({}) should come before tool1 ({}). Lines: {:?}",
989 text1_idx,
990 tool1_idx,
991 lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
992 );
993 assert!(
994 tool1_idx < text2_idx,
995 "tool1 ({}) should come before text2 ({}). Lines: {:?}",
996 tool1_idx,
997 text2_idx,
998 lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
999 );
1000 assert!(
1001 text2_idx < tool2_idx,
1002 "text2 ({}) should come before tool2 ({}). Lines: {:?}",
1003 text2_idx,
1004 tool2_idx,
1005 lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
1006 );
1007 assert!(
1008 tool2_idx < text3_idx,
1009 "tool2 ({}) should come before text3 ({}). Lines: {:?}",
1010 tool2_idx,
1011 text3_idx,
1012 lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
1013 );
1014 }
1015
1016 #[test]
1017 fn on_complete_flushes_buffer_and_shows_summary() {
1018 let mut handler = TuiStreamHandler::new(true);
1020 handler.on_text("final output");
1021
1022 handler.on_complete(&SessionResult {
1024 duration_ms: 1500,
1025 total_cost_usd: 0.0025,
1026 num_turns: 3,
1027 is_error: false,
1028 });
1029
1030 let lines = collect_lines(&handler);
1032 assert!(lines.len() >= 2, "Should have at least 2 lines");
1033 assert_eq!(lines[0].to_string(), "final output");
1034
1035 let summary = lines.last().unwrap().to_string();
1037 assert!(
1038 summary.contains("1500"),
1039 "Should contain duration: {}",
1040 summary
1041 );
1042 assert!(
1043 summary.contains("0.0025"),
1044 "Should contain cost: {}",
1045 summary
1046 );
1047 assert!(summary.contains('3'), "Should contain turns: {}", summary);
1048 }
1049
1050 #[test]
1051 fn on_complete_error_uses_red_style() {
1052 let mut handler = TuiStreamHandler::new(true);
1053 handler.on_complete(&SessionResult {
1054 duration_ms: 1000,
1055 total_cost_usd: 0.01,
1056 num_turns: 1,
1057 is_error: true,
1058 });
1059
1060 let lines = collect_lines(&handler);
1061 assert!(!lines.is_empty());
1062
1063 let last_line = lines.last().unwrap();
1065 assert_eq!(
1066 last_line.spans[0].style.fg,
1067 Some(Color::Red),
1068 "Error completion should have red foreground"
1069 );
1070 }
1071
1072 #[test]
1073 fn on_complete_success_uses_green_style() {
1074 let mut handler = TuiStreamHandler::new(true);
1075 handler.on_complete(&SessionResult {
1076 duration_ms: 1000,
1077 total_cost_usd: 0.01,
1078 num_turns: 1,
1079 is_error: false,
1080 });
1081
1082 let lines = collect_lines(&handler);
1083 assert!(!lines.is_empty());
1084
1085 let last_line = lines.last().unwrap();
1087 assert_eq!(
1088 last_line.spans[0].style.fg,
1089 Some(Color::Green),
1090 "Success completion should have green foreground"
1091 );
1092 }
1093
1094 #[test]
1095 fn tool_call_with_no_summary_shows_just_name() {
1096 let mut handler = TuiStreamHandler::new(false);
1097 handler.on_tool_call("UnknownTool", "id", &json!({}));
1098
1099 let lines = collect_lines(&handler);
1100 assert_eq!(lines.len(), 1);
1101 let line_text = lines[0].to_string();
1102 assert!(line_text.contains("UnknownTool"));
1103 }
1105
1106 #[test]
1107 fn get_lines_returns_clone_of_internal_lines() {
1108 let mut handler = TuiStreamHandler::new(false);
1109 handler.on_text("test\n");
1110
1111 let lines1 = handler.get_lines();
1112 let lines2 = handler.get_lines();
1113
1114 assert_eq!(lines1.len(), lines2.len());
1116 assert_eq!(lines1[0].to_string(), lines2[0].to_string());
1117 }
1118
1119 #[test]
1124 fn markdown_bold_text_renders_with_bold_modifier() {
1125 let mut handler = TuiStreamHandler::new(false);
1127
1128 handler.on_text("**important**\n");
1130
1131 let lines = collect_lines(&handler);
1133 assert!(!lines.is_empty(), "Should have at least one line");
1134
1135 let has_bold = lines.iter().any(|line| {
1137 line.spans.iter().any(|span| {
1138 span.content.contains("important")
1139 && span.style.add_modifier.contains(Modifier::BOLD)
1140 })
1141 });
1142 assert!(
1143 has_bold,
1144 "Should have bold 'important' span. Lines: {:?}",
1145 lines
1146 );
1147 }
1148
1149 #[test]
1150 fn markdown_italic_text_renders_with_italic_modifier() {
1151 let mut handler = TuiStreamHandler::new(false);
1153
1154 handler.on_text("*emphasized*\n");
1156
1157 let lines = collect_lines(&handler);
1159 assert!(!lines.is_empty(), "Should have at least one line");
1160
1161 let has_italic = lines.iter().any(|line| {
1162 line.spans.iter().any(|span| {
1163 span.content.contains("emphasized")
1164 && span.style.add_modifier.contains(Modifier::ITALIC)
1165 })
1166 });
1167 assert!(
1168 has_italic,
1169 "Should have italic 'emphasized' span. Lines: {:?}",
1170 lines
1171 );
1172 }
1173
1174 #[test]
1175 fn markdown_inline_code_renders_with_distinct_style() {
1176 let mut handler = TuiStreamHandler::new(false);
1178
1179 handler.on_text("`code`\n");
1181
1182 let lines = collect_lines(&handler);
1184 assert!(!lines.is_empty(), "Should have at least one line");
1185
1186 let has_code_style = lines.iter().any(|line| {
1187 line.spans.iter().any(|span| {
1188 span.content.contains("code")
1189 && (span.style.fg.is_some() || span.style.bg.is_some())
1190 })
1191 });
1192 assert!(
1193 has_code_style,
1194 "Should have styled 'code' span. Lines: {:?}",
1195 lines
1196 );
1197 }
1198
1199 #[test]
1200 fn markdown_header_renders_content() {
1201 let mut handler = TuiStreamHandler::new(false);
1203
1204 handler.on_text("## Section Title\n");
1206
1207 let lines = collect_lines(&handler);
1210 assert!(!lines.is_empty(), "Should have at least one line");
1211
1212 let has_header_content = lines.iter().any(|line| {
1213 line.spans
1214 .iter()
1215 .any(|span| span.content.contains("Section Title"))
1216 });
1217 assert!(
1218 has_header_content,
1219 "Should have header content. Lines: {:?}",
1220 lines
1221 );
1222 }
1223
1224 #[test]
1225 fn markdown_streaming_continuity_handles_split_formatting() {
1226 let mut handler = TuiStreamHandler::new(false);
1228
1229 handler.on_text("**bo");
1231 handler.on_text("ld**\n");
1232
1233 let lines = collect_lines(&handler);
1235
1236 let has_bold = lines.iter().any(|line| {
1237 line.spans
1238 .iter()
1239 .any(|span| span.style.add_modifier.contains(Modifier::BOLD))
1240 });
1241 assert!(
1242 has_bold,
1243 "Split markdown should still render bold. Lines: {:?}",
1244 lines
1245 );
1246 }
1247
1248 #[test]
1249 fn markdown_mixed_content_renders_correctly() {
1250 let mut handler = TuiStreamHandler::new(false);
1252
1253 handler.on_text("Normal **bold** and *italic* text\n");
1255
1256 let lines = collect_lines(&handler);
1258 assert!(!lines.is_empty(), "Should have at least one line");
1259
1260 let has_bold = lines.iter().any(|line| {
1261 line.spans.iter().any(|span| {
1262 span.content.contains("bold")
1263 && span.style.add_modifier.contains(Modifier::BOLD)
1264 })
1265 });
1266 let has_italic = lines.iter().any(|line| {
1267 line.spans.iter().any(|span| {
1268 span.content.contains("italic")
1269 && span.style.add_modifier.contains(Modifier::ITALIC)
1270 })
1271 });
1272
1273 assert!(has_bold, "Should have bold span. Lines: {:?}", lines);
1274 assert!(has_italic, "Should have italic span. Lines: {:?}", lines);
1275 }
1276
1277 #[test]
1278 fn markdown_tool_call_styling_preserved() {
1279 let mut handler = TuiStreamHandler::new(false);
1281
1282 handler.on_text("**bold**\n");
1284 handler.on_tool_call("Read", "id", &json!({"file_path": "src/main.rs"}));
1285
1286 let lines = collect_lines(&handler);
1288 assert!(lines.len() >= 2, "Should have at least 2 lines");
1289
1290 let tool_line = lines.last().unwrap();
1292 let has_blue = tool_line
1293 .spans
1294 .iter()
1295 .any(|span| span.style.fg == Some(Color::Blue));
1296 assert!(
1297 has_blue,
1298 "Tool call should preserve blue styling. Line: {:?}",
1299 tool_line
1300 );
1301 }
1302
1303 #[test]
1304 fn markdown_error_styling_preserved() {
1305 let mut handler = TuiStreamHandler::new(false);
1307
1308 handler.on_text("**bold**\n");
1310 handler.on_error("Something went wrong");
1311
1312 let lines = collect_lines(&handler);
1314 assert!(lines.len() >= 2, "Should have at least 2 lines");
1315
1316 let error_line = lines.last().unwrap();
1318 let has_red = error_line
1319 .spans
1320 .iter()
1321 .any(|span| span.style.fg == Some(Color::Red));
1322 assert!(
1323 has_red,
1324 "Error should preserve red styling. Line: {:?}",
1325 error_line
1326 );
1327 }
1328
1329 #[test]
1330 fn markdown_partial_formatting_does_not_crash() {
1331 let mut handler = TuiStreamHandler::new(false);
1333
1334 handler.on_text("**unclosed bold");
1336 handler.flush_text_buffer();
1337
1338 let lines = collect_lines(&handler);
1340 let _ = lines; }
1344
1345 #[test]
1350 fn ansi_green_text_produces_green_style() {
1351 let mut handler = TuiStreamHandler::new(false);
1353
1354 handler.on_text("\x1b[32mgreen text\x1b[0m\n");
1356
1357 let lines = collect_lines(&handler);
1359 assert!(!lines.is_empty(), "Should have at least one line");
1360
1361 let has_green = lines.iter().any(|line| {
1362 line.spans
1363 .iter()
1364 .any(|span| span.style.fg == Some(Color::Green))
1365 });
1366 assert!(
1367 has_green,
1368 "Should have green styled span. Lines: {:?}",
1369 lines
1370 );
1371 }
1372
1373 #[test]
1374 fn ansi_bold_text_produces_bold_modifier() {
1375 let mut handler = TuiStreamHandler::new(false);
1377
1378 handler.on_text("\x1b[1mbold text\x1b[0m\n");
1380
1381 let lines = collect_lines(&handler);
1383 assert!(!lines.is_empty(), "Should have at least one line");
1384
1385 let has_bold = lines.iter().any(|line| {
1386 line.spans
1387 .iter()
1388 .any(|span| span.style.add_modifier.contains(Modifier::BOLD))
1389 });
1390 assert!(has_bold, "Should have bold styled span. Lines: {:?}", lines);
1391 }
1392
1393 #[test]
1394 fn ansi_mixed_styles_preserved() {
1395 let mut handler = TuiStreamHandler::new(false);
1397
1398 handler.on_text("\x1b[1;32mbold green\x1b[0m normal\n");
1400
1401 let lines = collect_lines(&handler);
1403 assert!(!lines.is_empty(), "Should have at least one line");
1404
1405 let has_styled = lines.iter().any(|line| {
1407 line.spans.iter().any(|span| {
1408 span.style.fg == Some(Color::Green)
1409 || span.style.add_modifier.contains(Modifier::BOLD)
1410 })
1411 });
1412 assert!(
1413 has_styled,
1414 "Should have styled span with color or bold. Lines: {:?}",
1415 lines
1416 );
1417 }
1418
1419 #[test]
1420 fn ansi_plain_text_renders_without_crash() {
1421 let mut handler = TuiStreamHandler::new(false);
1423
1424 handler.on_text("plain text without ansi\n");
1426
1427 let lines = collect_lines(&handler);
1429 assert!(!lines.is_empty(), "Should have at least one line");
1430
1431 let full_text: String = lines.iter().map(|l| l.to_string()).collect();
1432 assert!(
1433 full_text.contains("plain text"),
1434 "Should contain the text. Lines: {:?}",
1435 lines
1436 );
1437 }
1438
1439 #[test]
1440 fn ansi_red_error_text_produces_red_style() {
1441 let mut handler = TuiStreamHandler::new(false);
1443
1444 handler.on_text("\x1b[31mError: something failed\x1b[0m\n");
1446
1447 let lines = collect_lines(&handler);
1449 assert!(!lines.is_empty(), "Should have at least one line");
1450
1451 let has_red = lines.iter().any(|line| {
1452 line.spans
1453 .iter()
1454 .any(|span| span.style.fg == Some(Color::Red))
1455 });
1456 assert!(has_red, "Should have red styled span. Lines: {:?}", lines);
1457 }
1458
1459 #[test]
1460 fn ansi_cyan_text_produces_cyan_style() {
1461 let mut handler = TuiStreamHandler::new(false);
1463
1464 handler.on_text("\x1b[36mcyan text\x1b[0m\n");
1466
1467 let lines = collect_lines(&handler);
1469 assert!(!lines.is_empty(), "Should have at least one line");
1470
1471 let has_cyan = lines.iter().any(|line| {
1472 line.spans
1473 .iter()
1474 .any(|span| span.style.fg == Some(Color::Cyan))
1475 });
1476 assert!(has_cyan, "Should have cyan styled span. Lines: {:?}", lines);
1477 }
1478
1479 #[test]
1480 fn ansi_underline_produces_underline_modifier() {
1481 let mut handler = TuiStreamHandler::new(false);
1483
1484 handler.on_text("\x1b[4munderlined\x1b[0m\n");
1486
1487 let lines = collect_lines(&handler);
1489 assert!(!lines.is_empty(), "Should have at least one line");
1490
1491 let has_underline = lines.iter().any(|line| {
1492 line.spans
1493 .iter()
1494 .any(|span| span.style.add_modifier.contains(Modifier::UNDERLINED))
1495 });
1496 assert!(
1497 has_underline,
1498 "Should have underlined styled span. Lines: {:?}",
1499 lines
1500 );
1501 }
1502
1503 #[test]
1504 fn ansi_multiline_preserves_colors() {
1505 let mut handler = TuiStreamHandler::new(false);
1507
1508 handler.on_text("\x1b[32mline 1 green\x1b[0m\n\x1b[31mline 2 red\x1b[0m\n");
1510
1511 let lines = collect_lines(&handler);
1513 assert!(lines.len() >= 2, "Should have at least two lines");
1514
1515 let has_green = lines.iter().any(|line| {
1516 line.spans
1517 .iter()
1518 .any(|span| span.style.fg == Some(Color::Green))
1519 });
1520 let has_red = lines.iter().any(|line| {
1521 line.spans
1522 .iter()
1523 .any(|span| span.style.fg == Some(Color::Red))
1524 });
1525
1526 assert!(has_green, "Should have green line. Lines: {:?}", lines);
1527 assert!(has_red, "Should have red line. Lines: {:?}", lines);
1528 }
1529 }
1530}
1531
1532#[cfg(test)]
1537mod ansi_detection_tests {
1538 use super::*;
1539
1540 #[test]
1541 fn contains_ansi_with_color_code() {
1542 assert!(contains_ansi("\x1b[32mgreen\x1b[0m"));
1543 }
1544
1545 #[test]
1546 fn contains_ansi_with_bold() {
1547 assert!(contains_ansi("\x1b[1mbold\x1b[0m"));
1548 }
1549
1550 #[test]
1551 fn contains_ansi_plain_text_returns_false() {
1552 assert!(!contains_ansi("hello world"));
1553 }
1554
1555 #[test]
1556 fn contains_ansi_markdown_returns_false() {
1557 assert!(!contains_ansi("**bold** and *italic*"));
1558 }
1559
1560 #[test]
1561 fn contains_ansi_empty_string_returns_false() {
1562 assert!(!contains_ansi(""));
1563 }
1564
1565 #[test]
1566 fn contains_ansi_with_escape_in_middle() {
1567 assert!(contains_ansi("prefix \x1b[31mred\x1b[0m suffix"));
1568 }
1569}