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 | 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}
180
181impl ConsoleStreamHandler {
182 pub fn new(verbose: bool) -> Self {
187 Self {
188 verbose,
189 stdout: io::stdout(),
190 stderr: io::stderr(),
191 }
192 }
193}
194
195impl StreamHandler for ConsoleStreamHandler {
196 fn on_text(&mut self, text: &str) {
197 let _ = writeln!(self.stdout, "Claude: {}", text);
198 }
199
200 fn on_tool_call(&mut self, name: &str, _id: &str, input: &serde_json::Value) {
201 match format_tool_summary(name, input) {
202 Some(summary) => {
203 let _ = writeln!(self.stdout, "[Tool] {}: {}", name, summary);
204 }
205 None => {
206 let _ = writeln!(self.stdout, "[Tool] {}", name);
207 }
208 }
209 }
210
211 fn on_tool_result(&mut self, _id: &str, output: &str) {
212 if self.verbose {
213 let _ = writeln!(self.stdout, "[Result] {}", truncate(output, 200));
214 }
215 }
216
217 fn on_error(&mut self, error: &str) {
218 let _ = writeln!(self.stdout, "[Error] {}", error);
220 let _ = writeln!(self.stderr, "[Error] {}", error);
221 }
222
223 fn on_complete(&mut self, result: &SessionResult) {
224 if self.verbose {
225 let _ = writeln!(
226 self.stdout,
227 "\n--- Session Complete ---\nDuration: {}ms | Cost: ${:.4} | Turns: {}",
228 result.duration_ms, result.total_cost_usd, result.num_turns
229 );
230 }
231 }
232}
233
234pub struct QuietStreamHandler;
236
237impl StreamHandler for QuietStreamHandler {
238 fn on_text(&mut self, _: &str) {}
239 fn on_tool_call(&mut self, _: &str, _: &str, _: &serde_json::Value) {}
240 fn on_tool_result(&mut self, _: &str, _: &str) {}
241 fn on_error(&mut self, _: &str) {}
242 fn on_complete(&mut self, _: &SessionResult) {}
243}
244
245fn text_to_lines(text: &str) -> Vec<Line<'static>> {
255 if text.is_empty() {
256 return Vec::new();
257 }
258
259 let ansi_text = if contains_ansi(text) {
263 text.to_string()
264 } else {
265 let skin = MadSkin::default();
268 skin.term_text(text).to_string()
269 };
270
271 match ansi_text.as_str().into_text() {
273 Ok(parsed_text) => {
274 parsed_text
276 .lines
277 .into_iter()
278 .map(|line| {
279 let owned_spans: Vec<Span<'static>> = line
280 .spans
281 .into_iter()
282 .map(|span| Span::styled(span.content.into_owned(), span.style))
283 .collect();
284 Line::from(owned_spans)
285 })
286 .collect()
287 }
288 Err(_) => {
289 text.split('\n')
291 .map(|line| Line::from(line.to_string()))
292 .collect()
293 }
294 }
295}
296
297#[derive(Clone)]
301enum ContentBlock {
302 Text(String),
304 NonText(Line<'static>),
306}
307
308pub struct TuiStreamHandler {
320 current_text_buffer: String,
322 blocks: Vec<ContentBlock>,
324 verbose: bool,
326 lines: Arc<Mutex<Vec<Line<'static>>>>,
328}
329
330impl TuiStreamHandler {
331 pub fn new(verbose: bool) -> Self {
336 Self {
337 current_text_buffer: String::new(),
338 blocks: Vec::new(),
339 verbose,
340 lines: Arc::new(Mutex::new(Vec::new())),
341 }
342 }
343
344 pub fn with_lines(verbose: bool, lines: Arc<Mutex<Vec<Line<'static>>>>) -> Self {
348 Self {
349 current_text_buffer: String::new(),
350 blocks: Vec::new(),
351 verbose,
352 lines,
353 }
354 }
355
356 pub fn get_lines(&self) -> Vec<Line<'static>> {
358 self.lines.lock().unwrap().clone()
359 }
360
361 pub fn flush_text_buffer(&mut self) {
363 self.update_lines();
364 }
365
366 fn freeze_current_text(&mut self) {
371 if !self.current_text_buffer.is_empty() {
372 self.blocks
373 .push(ContentBlock::Text(self.current_text_buffer.clone()));
374 self.current_text_buffer.clear();
375 }
376 }
377
378 fn update_lines(&mut self) {
384 let mut all_lines = Vec::new();
385
386 for block in &self.blocks {
388 match block {
389 ContentBlock::Text(text) => {
390 all_lines.extend(text_to_lines(text));
391 }
392 ContentBlock::NonText(line) => {
393 all_lines.push(line.clone());
394 }
395 }
396 }
397
398 if !self.current_text_buffer.is_empty() {
400 all_lines.extend(text_to_lines(&self.current_text_buffer));
401 }
402
403 *self.lines.lock().unwrap() = all_lines;
408 }
409
410 fn add_non_text_line(&mut self, line: Line<'static>) {
414 self.freeze_current_text();
415 self.blocks.push(ContentBlock::NonText(line));
416 self.update_lines();
417 }
418}
419
420impl StreamHandler for TuiStreamHandler {
421 fn on_text(&mut self, text: &str) {
422 self.current_text_buffer.push_str(text);
424
425 self.update_lines();
428 }
429
430 fn on_tool_call(&mut self, name: &str, _id: &str, input: &serde_json::Value) {
431 let mut spans = vec![Span::styled(
433 format!("\u{2699} [{}]", name),
434 Style::default().fg(RatatuiColor::Blue),
435 )];
436
437 if let Some(summary) = format_tool_summary(name, input) {
438 spans.push(Span::styled(
439 format!(" {}", summary),
440 Style::default().fg(RatatuiColor::DarkGray),
441 ));
442 }
443
444 self.add_non_text_line(Line::from(spans));
445 }
446
447 fn on_tool_result(&mut self, _id: &str, output: &str) {
448 if self.verbose {
449 let line = Line::from(Span::styled(
450 format!(" \u{2713} {}", truncate(output, 200)),
451 Style::default().fg(RatatuiColor::DarkGray),
452 ));
453 self.add_non_text_line(line);
454 }
455 }
456
457 fn on_error(&mut self, error: &str) {
458 let line = Line::from(Span::styled(
459 format!("\n\u{2717} Error: {}", error),
460 Style::default().fg(RatatuiColor::Red),
461 ));
462 self.add_non_text_line(line);
463 }
464
465 fn on_complete(&mut self, result: &SessionResult) {
466 self.flush_text_buffer();
468
469 self.add_non_text_line(Line::from(""));
471
472 let color = if result.is_error {
474 RatatuiColor::Red
475 } else {
476 RatatuiColor::Green
477 };
478 let summary = format!(
479 "Duration: {}ms | Cost: ${:.4} | Turns: {}",
480 result.duration_ms, result.total_cost_usd, result.num_turns
481 );
482 let line = Line::from(Span::styled(summary, Style::default().fg(color)));
483 self.add_non_text_line(line);
484 }
485}
486
487fn format_tool_summary(name: &str, input: &serde_json::Value) -> Option<String> {
492 match name {
493 "Read" | "Edit" | "Write" => input.get("file_path")?.as_str().map(|s| s.to_string()),
494 "Bash" => {
495 let cmd = input.get("command")?.as_str()?;
496 Some(truncate(cmd, 60))
497 }
498 "Grep" => input.get("pattern")?.as_str().map(|s| s.to_string()),
499 "Glob" => input.get("pattern")?.as_str().map(|s| s.to_string()),
500 "Task" => input.get("description")?.as_str().map(|s| s.to_string()),
501 "WebFetch" => input.get("url")?.as_str().map(|s| s.to_string()),
502 "WebSearch" => input.get("query")?.as_str().map(|s| s.to_string()),
503 "LSP" => {
504 let op = input.get("operation")?.as_str()?;
505 let file = input.get("filePath")?.as_str()?;
506 Some(format!("{} @ {}", op, file))
507 }
508 "NotebookEdit" => input.get("notebook_path")?.as_str().map(|s| s.to_string()),
509 "TodoWrite" => Some("updating todo list".to_string()),
510 _ => None,
511 }
512}
513
514fn truncate(s: &str, max_len: usize) -> String {
519 if s.chars().count() <= max_len {
520 s.to_string()
521 } else {
522 let byte_idx = s
524 .char_indices()
525 .nth(max_len)
526 .map(|(idx, _)| idx)
527 .unwrap_or(s.len());
528 format!("{}...", &s[..byte_idx])
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535 use serde_json::json;
536
537 #[test]
538 fn test_console_handler_verbose_shows_results() {
539 let mut handler = ConsoleStreamHandler::new(true);
540 let bash_input = json!({"command": "ls -la"});
541
542 handler.on_text("Hello");
544 handler.on_tool_call("Bash", "tool_1", &bash_input);
545 handler.on_tool_result("tool_1", "output");
546 handler.on_complete(&SessionResult {
547 duration_ms: 1000,
548 total_cost_usd: 0.01,
549 num_turns: 1,
550 is_error: false,
551 });
552 }
553
554 #[test]
555 fn test_console_handler_normal_skips_results() {
556 let mut handler = ConsoleStreamHandler::new(false);
557 let read_input = json!({"file_path": "src/main.rs"});
558
559 handler.on_text("Hello");
561 handler.on_tool_call("Read", "tool_1", &read_input);
562 handler.on_tool_result("tool_1", "output"); handler.on_complete(&SessionResult {
564 duration_ms: 1000,
565 total_cost_usd: 0.01,
566 num_turns: 1,
567 is_error: false,
568 }); }
570
571 #[test]
572 fn test_quiet_handler_is_silent() {
573 let mut handler = QuietStreamHandler;
574 let empty_input = json!({});
575
576 handler.on_text("Hello");
578 handler.on_tool_call("Read", "tool_1", &empty_input);
579 handler.on_tool_result("tool_1", "output");
580 handler.on_error("Something went wrong");
581 handler.on_complete(&SessionResult {
582 duration_ms: 1000,
583 total_cost_usd: 0.01,
584 num_turns: 1,
585 is_error: false,
586 });
587 }
588
589 #[test]
590 fn test_truncate_helper() {
591 assert_eq!(truncate("short", 10), "short");
592 assert_eq!(truncate("this is a long string", 10), "this is a ...");
593 }
594
595 #[test]
596 fn test_truncate_utf8_boundaries() {
597 let with_arrows = "→→→→→→→→→→";
599 assert_eq!(truncate(with_arrows, 5), "→→→→→...");
601
602 let mixed = "a→b→c→d→e";
604 assert_eq!(truncate(mixed, 5), "a→b→c...");
605
606 let emoji = "🎉🎊🎁🎈🎄";
608 assert_eq!(truncate(emoji, 3), "🎉🎊🎁...");
609 }
610
611 #[test]
612 fn test_format_tool_summary_file_tools() {
613 assert_eq!(
614 format_tool_summary("Read", &json!({"file_path": "src/main.rs"})),
615 Some("src/main.rs".to_string())
616 );
617 assert_eq!(
618 format_tool_summary("Edit", &json!({"file_path": "/path/to/file.txt"})),
619 Some("/path/to/file.txt".to_string())
620 );
621 assert_eq!(
622 format_tool_summary("Write", &json!({"file_path": "output.json"})),
623 Some("output.json".to_string())
624 );
625 }
626
627 #[test]
628 fn test_format_tool_summary_bash_truncates() {
629 let short_cmd = json!({"command": "ls -la"});
630 assert_eq!(
631 format_tool_summary("Bash", &short_cmd),
632 Some("ls -la".to_string())
633 );
634
635 let long_cmd = json!({"command": "this is a very long command that should be truncated because it exceeds sixty characters"});
636 let result = format_tool_summary("Bash", &long_cmd).unwrap();
637 assert!(result.ends_with("..."));
638 assert!(result.len() <= 70); }
640
641 #[test]
642 fn test_format_tool_summary_search_tools() {
643 assert_eq!(
644 format_tool_summary("Grep", &json!({"pattern": "TODO"})),
645 Some("TODO".to_string())
646 );
647 assert_eq!(
648 format_tool_summary("Glob", &json!({"pattern": "**/*.rs"})),
649 Some("**/*.rs".to_string())
650 );
651 }
652
653 #[test]
654 fn test_format_tool_summary_unknown_tool_returns_none() {
655 assert_eq!(
656 format_tool_summary("UnknownTool", &json!({"some_field": "value"})),
657 None
658 );
659 }
660
661 #[test]
662 fn test_format_tool_summary_missing_field_returns_none() {
663 assert_eq!(
665 format_tool_summary("Read", &json!({"wrong_field": "value"})),
666 None
667 );
668 assert_eq!(format_tool_summary("Bash", &json!({})), None);
670 }
671
672 mod tui_stream_handler {
677 use super::*;
678 use ratatui::style::{Color, Modifier};
679
680 fn collect_lines(handler: &TuiStreamHandler) -> Vec<ratatui::text::Line<'static>> {
682 handler.lines.lock().unwrap().clone()
683 }
684
685 #[test]
686 fn text_creates_line_on_newline() {
687 let mut handler = TuiStreamHandler::new(false);
689
690 handler.on_text("hello\n");
692
693 let lines = collect_lines(&handler);
696 assert_eq!(
697 lines.len(),
698 1,
699 "termimad doesn't create trailing empty line"
700 );
701 assert_eq!(lines[0].to_string(), "hello");
702 }
703
704 #[test]
705 fn partial_text_buffering() {
706 let mut handler = TuiStreamHandler::new(false);
708
709 handler.on_text("hel");
713 handler.on_text("lo\n");
714
715 let lines = collect_lines(&handler);
717 let full_text: String = lines.iter().map(|l| l.to_string()).collect();
718 assert!(
719 full_text.contains("hello"),
720 "Combined text should contain 'hello'. Lines: {:?}",
721 lines
722 );
723 }
724
725 #[test]
726 fn tool_call_produces_formatted_line() {
727 let mut handler = TuiStreamHandler::new(false);
729
730 handler.on_tool_call("Read", "tool_1", &json!({"file_path": "src/main.rs"}));
732
733 let lines = collect_lines(&handler);
735 assert_eq!(lines.len(), 1);
736 let line_text = lines[0].to_string();
737 assert!(
738 line_text.contains('\u{2699}'),
739 "Should contain gear emoji: {}",
740 line_text
741 );
742 assert!(
743 line_text.contains("Read"),
744 "Should contain tool name: {}",
745 line_text
746 );
747 assert!(
748 line_text.contains("src/main.rs"),
749 "Should contain file path: {}",
750 line_text
751 );
752 }
753
754 #[test]
755 fn tool_result_verbose_shows_content() {
756 let mut handler = TuiStreamHandler::new(true);
758
759 handler.on_tool_result("tool_1", "file contents here");
761
762 let lines = collect_lines(&handler);
764 assert_eq!(lines.len(), 1);
765 let line_text = lines[0].to_string();
766 assert!(
767 line_text.contains('\u{2713}'),
768 "Should contain checkmark: {}",
769 line_text
770 );
771 assert!(
772 line_text.contains("file contents here"),
773 "Should contain result content: {}",
774 line_text
775 );
776 }
777
778 #[test]
779 fn tool_result_quiet_is_silent() {
780 let mut handler = TuiStreamHandler::new(false);
782
783 handler.on_tool_result("tool_1", "file contents here");
785
786 let lines = collect_lines(&handler);
788 assert!(
789 lines.is_empty(),
790 "verbose=false should not produce tool result output"
791 );
792 }
793
794 #[test]
795 fn error_produces_red_styled_line() {
796 let mut handler = TuiStreamHandler::new(false);
798
799 handler.on_error("Something went wrong");
801
802 let lines = collect_lines(&handler);
804 assert_eq!(lines.len(), 1);
805 let line_text = lines[0].to_string();
806 assert!(
807 line_text.contains('\u{2717}'),
808 "Should contain X mark: {}",
809 line_text
810 );
811 assert!(
812 line_text.contains("Error"),
813 "Should contain 'Error': {}",
814 line_text
815 );
816 assert!(
817 line_text.contains("Something went wrong"),
818 "Should contain error message: {}",
819 line_text
820 );
821
822 let first_span = &lines[0].spans[0];
824 assert_eq!(
825 first_span.style.fg,
826 Some(Color::Red),
827 "Error line should have red foreground"
828 );
829 }
830
831 #[test]
832 fn long_lines_preserved_without_truncation() {
833 let mut handler = TuiStreamHandler::new(false);
835
836 let long_string: String = "a".repeat(500) + "\n";
838 handler.on_text(&long_string);
839
840 let lines = collect_lines(&handler);
843
844 let total_content: String = lines.iter().map(|l| l.to_string()).collect();
846 let a_count = total_content.chars().filter(|c| *c == 'a').count();
847 assert_eq!(
848 a_count, 500,
849 "All 500 'a' chars should be preserved. Got {}",
850 a_count
851 );
852
853 assert!(
855 !total_content.contains("..."),
856 "Content should not have ellipsis truncation"
857 );
858 }
859
860 #[test]
861 fn multiple_lines_in_single_text_call() {
862 let mut handler = TuiStreamHandler::new(false);
864 handler.on_text("line1\nline2\nline3\n");
865
866 let lines = collect_lines(&handler);
869 let full_text: String = lines
870 .iter()
871 .map(|l| l.to_string())
872 .collect::<Vec<_>>()
873 .join(" ");
874 assert!(
875 full_text.contains("line1")
876 && full_text.contains("line2")
877 && full_text.contains("line3"),
878 "All lines should be present. Lines: {:?}",
879 lines
880 );
881 }
882
883 #[test]
884 fn termimad_parity_with_non_tui_mode() {
885 let text = "Some text before:★ Insight ─────\nKey point here";
888
889 let mut handler = TuiStreamHandler::new(false);
890 handler.on_text(text);
891
892 let lines = collect_lines(&handler);
893
894 assert!(
897 lines.len() >= 2,
898 "termimad should produce multiple lines. Got: {:?}",
899 lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
900 );
901
902 let full_text: String = lines.iter().map(|l| l.to_string()).collect();
904 assert!(
905 full_text.contains("★ Insight"),
906 "Content should contain insight marker"
907 );
908 }
909
910 #[test]
911 fn tool_call_flushes_text_buffer() {
912 let mut handler = TuiStreamHandler::new(false);
914 handler.on_text("partial text");
915
916 handler.on_tool_call("Read", "id", &json!({}));
918
919 let lines = collect_lines(&handler);
921 assert_eq!(lines.len(), 2);
922 assert_eq!(lines[0].to_string(), "partial text");
923 assert!(lines[1].to_string().contains('\u{2699}'));
924 }
925
926 #[test]
927 fn interleaved_text_and_tools_preserves_chronological_order() {
928 let mut handler = TuiStreamHandler::new(false);
932
933 handler.on_text("I'll start by reviewing the scratchpad.\n");
935 handler.on_tool_call("Read", "id1", &json!({"file_path": "scratchpad.md"}));
936 handler.on_text("I found the task. Now checking the code.\n");
937 handler.on_tool_call("Read", "id2", &json!({"file_path": "main.rs"}));
938 handler.on_text("Done reviewing.\n");
939
940 let lines = collect_lines(&handler);
941
942 let text1_idx = lines
944 .iter()
945 .position(|l| l.to_string().contains("reviewing the scratchpad"));
946 let tool1_idx = lines
947 .iter()
948 .position(|l| l.to_string().contains("scratchpad.md"));
949 let text2_idx = lines
950 .iter()
951 .position(|l| l.to_string().contains("checking the code"));
952 let tool2_idx = lines.iter().position(|l| l.to_string().contains("main.rs"));
953 let text3_idx = lines
954 .iter()
955 .position(|l| l.to_string().contains("Done reviewing"));
956
957 assert!(text1_idx.is_some(), "text1 should be present");
959 assert!(tool1_idx.is_some(), "tool1 should be present");
960 assert!(text2_idx.is_some(), "text2 should be present");
961 assert!(tool2_idx.is_some(), "tool2 should be present");
962 assert!(text3_idx.is_some(), "text3 should be present");
963
964 let text1_idx = text1_idx.unwrap();
966 let tool1_idx = tool1_idx.unwrap();
967 let text2_idx = text2_idx.unwrap();
968 let tool2_idx = tool2_idx.unwrap();
969 let text3_idx = text3_idx.unwrap();
970
971 assert!(
972 text1_idx < tool1_idx,
973 "text1 ({}) should come before tool1 ({}). Lines: {:?}",
974 text1_idx,
975 tool1_idx,
976 lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
977 );
978 assert!(
979 tool1_idx < text2_idx,
980 "tool1 ({}) should come before text2 ({}). Lines: {:?}",
981 tool1_idx,
982 text2_idx,
983 lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
984 );
985 assert!(
986 text2_idx < tool2_idx,
987 "text2 ({}) should come before tool2 ({}). Lines: {:?}",
988 text2_idx,
989 tool2_idx,
990 lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
991 );
992 assert!(
993 tool2_idx < text3_idx,
994 "tool2 ({}) should come before text3 ({}). Lines: {:?}",
995 tool2_idx,
996 text3_idx,
997 lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
998 );
999 }
1000
1001 #[test]
1002 fn on_complete_flushes_buffer_and_shows_summary() {
1003 let mut handler = TuiStreamHandler::new(true);
1005 handler.on_text("final output");
1006
1007 handler.on_complete(&SessionResult {
1009 duration_ms: 1500,
1010 total_cost_usd: 0.0025,
1011 num_turns: 3,
1012 is_error: false,
1013 });
1014
1015 let lines = collect_lines(&handler);
1017 assert!(lines.len() >= 2, "Should have at least 2 lines");
1018 assert_eq!(lines[0].to_string(), "final output");
1019
1020 let summary = lines.last().unwrap().to_string();
1022 assert!(
1023 summary.contains("1500"),
1024 "Should contain duration: {}",
1025 summary
1026 );
1027 assert!(
1028 summary.contains("0.0025"),
1029 "Should contain cost: {}",
1030 summary
1031 );
1032 assert!(summary.contains('3'), "Should contain turns: {}", summary);
1033 }
1034
1035 #[test]
1036 fn on_complete_error_uses_red_style() {
1037 let mut handler = TuiStreamHandler::new(true);
1038 handler.on_complete(&SessionResult {
1039 duration_ms: 1000,
1040 total_cost_usd: 0.01,
1041 num_turns: 1,
1042 is_error: true,
1043 });
1044
1045 let lines = collect_lines(&handler);
1046 assert!(!lines.is_empty());
1047
1048 let last_line = lines.last().unwrap();
1050 assert_eq!(
1051 last_line.spans[0].style.fg,
1052 Some(Color::Red),
1053 "Error completion should have red foreground"
1054 );
1055 }
1056
1057 #[test]
1058 fn on_complete_success_uses_green_style() {
1059 let mut handler = TuiStreamHandler::new(true);
1060 handler.on_complete(&SessionResult {
1061 duration_ms: 1000,
1062 total_cost_usd: 0.01,
1063 num_turns: 1,
1064 is_error: false,
1065 });
1066
1067 let lines = collect_lines(&handler);
1068 assert!(!lines.is_empty());
1069
1070 let last_line = lines.last().unwrap();
1072 assert_eq!(
1073 last_line.spans[0].style.fg,
1074 Some(Color::Green),
1075 "Success completion should have green foreground"
1076 );
1077 }
1078
1079 #[test]
1080 fn tool_call_with_no_summary_shows_just_name() {
1081 let mut handler = TuiStreamHandler::new(false);
1082 handler.on_tool_call("UnknownTool", "id", &json!({}));
1083
1084 let lines = collect_lines(&handler);
1085 assert_eq!(lines.len(), 1);
1086 let line_text = lines[0].to_string();
1087 assert!(line_text.contains("UnknownTool"));
1088 }
1090
1091 #[test]
1092 fn get_lines_returns_clone_of_internal_lines() {
1093 let mut handler = TuiStreamHandler::new(false);
1094 handler.on_text("test\n");
1095
1096 let lines1 = handler.get_lines();
1097 let lines2 = handler.get_lines();
1098
1099 assert_eq!(lines1.len(), lines2.len());
1101 assert_eq!(lines1[0].to_string(), lines2[0].to_string());
1102 }
1103
1104 #[test]
1109 fn markdown_bold_text_renders_with_bold_modifier() {
1110 let mut handler = TuiStreamHandler::new(false);
1112
1113 handler.on_text("**important**\n");
1115
1116 let lines = collect_lines(&handler);
1118 assert!(!lines.is_empty(), "Should have at least one line");
1119
1120 let has_bold = lines.iter().any(|line| {
1122 line.spans.iter().any(|span| {
1123 span.content.contains("important")
1124 && span.style.add_modifier.contains(Modifier::BOLD)
1125 })
1126 });
1127 assert!(
1128 has_bold,
1129 "Should have bold 'important' span. Lines: {:?}",
1130 lines
1131 );
1132 }
1133
1134 #[test]
1135 fn markdown_italic_text_renders_with_italic_modifier() {
1136 let mut handler = TuiStreamHandler::new(false);
1138
1139 handler.on_text("*emphasized*\n");
1141
1142 let lines = collect_lines(&handler);
1144 assert!(!lines.is_empty(), "Should have at least one line");
1145
1146 let has_italic = lines.iter().any(|line| {
1147 line.spans.iter().any(|span| {
1148 span.content.contains("emphasized")
1149 && span.style.add_modifier.contains(Modifier::ITALIC)
1150 })
1151 });
1152 assert!(
1153 has_italic,
1154 "Should have italic 'emphasized' span. Lines: {:?}",
1155 lines
1156 );
1157 }
1158
1159 #[test]
1160 fn markdown_inline_code_renders_with_distinct_style() {
1161 let mut handler = TuiStreamHandler::new(false);
1163
1164 handler.on_text("`code`\n");
1166
1167 let lines = collect_lines(&handler);
1169 assert!(!lines.is_empty(), "Should have at least one line");
1170
1171 let has_code_style = lines.iter().any(|line| {
1172 line.spans.iter().any(|span| {
1173 span.content.contains("code")
1174 && (span.style.fg.is_some() || span.style.bg.is_some())
1175 })
1176 });
1177 assert!(
1178 has_code_style,
1179 "Should have styled 'code' span. Lines: {:?}",
1180 lines
1181 );
1182 }
1183
1184 #[test]
1185 fn markdown_header_renders_content() {
1186 let mut handler = TuiStreamHandler::new(false);
1188
1189 handler.on_text("## Section Title\n");
1191
1192 let lines = collect_lines(&handler);
1195 assert!(!lines.is_empty(), "Should have at least one line");
1196
1197 let has_header_content = lines.iter().any(|line| {
1198 line.spans
1199 .iter()
1200 .any(|span| span.content.contains("Section Title"))
1201 });
1202 assert!(
1203 has_header_content,
1204 "Should have header content. Lines: {:?}",
1205 lines
1206 );
1207 }
1208
1209 #[test]
1210 fn markdown_streaming_continuity_handles_split_formatting() {
1211 let mut handler = TuiStreamHandler::new(false);
1213
1214 handler.on_text("**bo");
1216 handler.on_text("ld**\n");
1217
1218 let lines = collect_lines(&handler);
1220
1221 let has_bold = lines.iter().any(|line| {
1222 line.spans
1223 .iter()
1224 .any(|span| span.style.add_modifier.contains(Modifier::BOLD))
1225 });
1226 assert!(
1227 has_bold,
1228 "Split markdown should still render bold. Lines: {:?}",
1229 lines
1230 );
1231 }
1232
1233 #[test]
1234 fn markdown_mixed_content_renders_correctly() {
1235 let mut handler = TuiStreamHandler::new(false);
1237
1238 handler.on_text("Normal **bold** and *italic* text\n");
1240
1241 let lines = collect_lines(&handler);
1243 assert!(!lines.is_empty(), "Should have at least one line");
1244
1245 let has_bold = lines.iter().any(|line| {
1246 line.spans.iter().any(|span| {
1247 span.content.contains("bold")
1248 && span.style.add_modifier.contains(Modifier::BOLD)
1249 })
1250 });
1251 let has_italic = lines.iter().any(|line| {
1252 line.spans.iter().any(|span| {
1253 span.content.contains("italic")
1254 && span.style.add_modifier.contains(Modifier::ITALIC)
1255 })
1256 });
1257
1258 assert!(has_bold, "Should have bold span. Lines: {:?}", lines);
1259 assert!(has_italic, "Should have italic span. Lines: {:?}", lines);
1260 }
1261
1262 #[test]
1263 fn markdown_tool_call_styling_preserved() {
1264 let mut handler = TuiStreamHandler::new(false);
1266
1267 handler.on_text("**bold**\n");
1269 handler.on_tool_call("Read", "id", &json!({"file_path": "src/main.rs"}));
1270
1271 let lines = collect_lines(&handler);
1273 assert!(lines.len() >= 2, "Should have at least 2 lines");
1274
1275 let tool_line = lines.last().unwrap();
1277 let has_blue = tool_line
1278 .spans
1279 .iter()
1280 .any(|span| span.style.fg == Some(Color::Blue));
1281 assert!(
1282 has_blue,
1283 "Tool call should preserve blue styling. Line: {:?}",
1284 tool_line
1285 );
1286 }
1287
1288 #[test]
1289 fn markdown_error_styling_preserved() {
1290 let mut handler = TuiStreamHandler::new(false);
1292
1293 handler.on_text("**bold**\n");
1295 handler.on_error("Something went wrong");
1296
1297 let lines = collect_lines(&handler);
1299 assert!(lines.len() >= 2, "Should have at least 2 lines");
1300
1301 let error_line = lines.last().unwrap();
1303 let has_red = error_line
1304 .spans
1305 .iter()
1306 .any(|span| span.style.fg == Some(Color::Red));
1307 assert!(
1308 has_red,
1309 "Error should preserve red styling. Line: {:?}",
1310 error_line
1311 );
1312 }
1313
1314 #[test]
1315 fn markdown_partial_formatting_does_not_crash() {
1316 let mut handler = TuiStreamHandler::new(false);
1318
1319 handler.on_text("**unclosed bold");
1321 handler.flush_text_buffer();
1322
1323 let lines = collect_lines(&handler);
1325 let _ = lines; }
1329
1330 #[test]
1335 fn ansi_green_text_produces_green_style() {
1336 let mut handler = TuiStreamHandler::new(false);
1338
1339 handler.on_text("\x1b[32mgreen text\x1b[0m\n");
1341
1342 let lines = collect_lines(&handler);
1344 assert!(!lines.is_empty(), "Should have at least one line");
1345
1346 let has_green = lines.iter().any(|line| {
1347 line.spans
1348 .iter()
1349 .any(|span| span.style.fg == Some(Color::Green))
1350 });
1351 assert!(
1352 has_green,
1353 "Should have green styled span. Lines: {:?}",
1354 lines
1355 );
1356 }
1357
1358 #[test]
1359 fn ansi_bold_text_produces_bold_modifier() {
1360 let mut handler = TuiStreamHandler::new(false);
1362
1363 handler.on_text("\x1b[1mbold text\x1b[0m\n");
1365
1366 let lines = collect_lines(&handler);
1368 assert!(!lines.is_empty(), "Should have at least one line");
1369
1370 let has_bold = lines.iter().any(|line| {
1371 line.spans
1372 .iter()
1373 .any(|span| span.style.add_modifier.contains(Modifier::BOLD))
1374 });
1375 assert!(has_bold, "Should have bold styled span. Lines: {:?}", lines);
1376 }
1377
1378 #[test]
1379 fn ansi_mixed_styles_preserved() {
1380 let mut handler = TuiStreamHandler::new(false);
1382
1383 handler.on_text("\x1b[1;32mbold green\x1b[0m normal\n");
1385
1386 let lines = collect_lines(&handler);
1388 assert!(!lines.is_empty(), "Should have at least one line");
1389
1390 let has_styled = lines.iter().any(|line| {
1392 line.spans.iter().any(|span| {
1393 span.style.fg == Some(Color::Green)
1394 || span.style.add_modifier.contains(Modifier::BOLD)
1395 })
1396 });
1397 assert!(
1398 has_styled,
1399 "Should have styled span with color or bold. Lines: {:?}",
1400 lines
1401 );
1402 }
1403
1404 #[test]
1405 fn ansi_plain_text_renders_without_crash() {
1406 let mut handler = TuiStreamHandler::new(false);
1408
1409 handler.on_text("plain text without ansi\n");
1411
1412 let lines = collect_lines(&handler);
1414 assert!(!lines.is_empty(), "Should have at least one line");
1415
1416 let full_text: String = lines.iter().map(|l| l.to_string()).collect();
1417 assert!(
1418 full_text.contains("plain text"),
1419 "Should contain the text. Lines: {:?}",
1420 lines
1421 );
1422 }
1423
1424 #[test]
1425 fn ansi_red_error_text_produces_red_style() {
1426 let mut handler = TuiStreamHandler::new(false);
1428
1429 handler.on_text("\x1b[31mError: something failed\x1b[0m\n");
1431
1432 let lines = collect_lines(&handler);
1434 assert!(!lines.is_empty(), "Should have at least one line");
1435
1436 let has_red = lines.iter().any(|line| {
1437 line.spans
1438 .iter()
1439 .any(|span| span.style.fg == Some(Color::Red))
1440 });
1441 assert!(has_red, "Should have red styled span. Lines: {:?}", lines);
1442 }
1443
1444 #[test]
1445 fn ansi_cyan_text_produces_cyan_style() {
1446 let mut handler = TuiStreamHandler::new(false);
1448
1449 handler.on_text("\x1b[36mcyan text\x1b[0m\n");
1451
1452 let lines = collect_lines(&handler);
1454 assert!(!lines.is_empty(), "Should have at least one line");
1455
1456 let has_cyan = lines.iter().any(|line| {
1457 line.spans
1458 .iter()
1459 .any(|span| span.style.fg == Some(Color::Cyan))
1460 });
1461 assert!(has_cyan, "Should have cyan styled span. Lines: {:?}", lines);
1462 }
1463
1464 #[test]
1465 fn ansi_underline_produces_underline_modifier() {
1466 let mut handler = TuiStreamHandler::new(false);
1468
1469 handler.on_text("\x1b[4munderlined\x1b[0m\n");
1471
1472 let lines = collect_lines(&handler);
1474 assert!(!lines.is_empty(), "Should have at least one line");
1475
1476 let has_underline = lines.iter().any(|line| {
1477 line.spans
1478 .iter()
1479 .any(|span| span.style.add_modifier.contains(Modifier::UNDERLINED))
1480 });
1481 assert!(
1482 has_underline,
1483 "Should have underlined styled span. Lines: {:?}",
1484 lines
1485 );
1486 }
1487
1488 #[test]
1489 fn ansi_multiline_preserves_colors() {
1490 let mut handler = TuiStreamHandler::new(false);
1492
1493 handler.on_text("\x1b[32mline 1 green\x1b[0m\n\x1b[31mline 2 red\x1b[0m\n");
1495
1496 let lines = collect_lines(&handler);
1498 assert!(lines.len() >= 2, "Should have at least two lines");
1499
1500 let has_green = lines.iter().any(|line| {
1501 line.spans
1502 .iter()
1503 .any(|span| span.style.fg == Some(Color::Green))
1504 });
1505 let has_red = lines.iter().any(|line| {
1506 line.spans
1507 .iter()
1508 .any(|span| span.style.fg == Some(Color::Red))
1509 });
1510
1511 assert!(has_green, "Should have green line. Lines: {:?}", lines);
1512 assert!(has_red, "Should have red line. Lines: {:?}", lines);
1513 }
1514 }
1515}
1516
1517#[cfg(test)]
1522mod ansi_detection_tests {
1523 use super::*;
1524
1525 #[test]
1526 fn contains_ansi_with_color_code() {
1527 assert!(contains_ansi("\x1b[32mgreen\x1b[0m"));
1528 }
1529
1530 #[test]
1531 fn contains_ansi_with_bold() {
1532 assert!(contains_ansi("\x1b[1mbold\x1b[0m"));
1533 }
1534
1535 #[test]
1536 fn contains_ansi_plain_text_returns_false() {
1537 assert!(!contains_ansi("hello world"));
1538 }
1539
1540 #[test]
1541 fn contains_ansi_markdown_returns_false() {
1542 assert!(!contains_ansi("**bold** and *italic*"));
1543 }
1544
1545 #[test]
1546 fn contains_ansi_empty_string_returns_false() {
1547 assert!(!contains_ansi(""));
1548 }
1549
1550 #[test]
1551 fn contains_ansi_with_escape_in_middle() {
1552 assert!(contains_ansi("prefix \x1b[31mred\x1b[0m suffix"));
1553 }
1554}