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);
75 }
76
77 fn on_tool_result(&mut self, _id: &str, output: &str) {
78 if self.verbose {
79 let _ = self
80 .stdout
81 .queue(style::SetForegroundColor(Color::DarkGrey));
82 let _ = self
83 .stdout
84 .write(format!(" \u{2713} {}\n", truncate(output, 200)).as_bytes());
85 let _ = self.stdout.queue(style::ResetColor);
86 let _ = self.stdout.flush();
87 }
88 }
89
90 fn on_error(&mut self, error: &str) {
91 let _ = self.stdout.queue(style::SetForegroundColor(Color::Red));
92 let _ = self
93 .stdout
94 .write(format!("\n\u{2717} Error: {}\n", error).as_bytes());
95 let _ = self.stdout.queue(style::ResetColor);
96 let _ = self.stdout.flush();
97 }
98
99 fn on_complete(&mut self, result: &SessionResult) {
100 self.flush_text_buffer();
102
103 let _ = self.stdout.write(b"\n");
104 let color = if result.is_error {
105 Color::Red
106 } else {
107 Color::Green
108 };
109 let _ = self.stdout.queue(style::SetForegroundColor(color));
110 let _ = self.stdout.write(
111 format!(
112 "Duration: {}ms | Cost: ${:.4} | Turns: {}\n",
113 result.duration_ms, result.total_cost_usd, result.num_turns
114 )
115 .as_bytes(),
116 );
117 let _ = self.stdout.queue(style::ResetColor);
118 let _ = self.stdout.flush();
119 }
120
121 fn on_tool_call(&mut self, name: &str, _id: &str, input: &serde_json::Value) {
122 self.flush_text_buffer();
124
125 let _ = self.stdout.queue(style::SetForegroundColor(Color::Blue));
127 let _ = self.stdout.write(format!("\u{2699} [{}]", name).as_bytes());
128
129 if let Some(summary) = format_tool_summary(name, input) {
130 let _ = self
131 .stdout
132 .queue(style::SetForegroundColor(Color::DarkGrey));
133 let _ = self.stdout.write(format!(" {}\n", summary).as_bytes());
134 } else {
135 let _ = self.stdout.write(b"\n");
136 }
137 let _ = self.stdout.queue(style::ResetColor);
138 let _ = self.stdout.flush();
139 }
140}
141
142pub trait StreamHandler: Send {
147 fn on_text(&mut self, text: &str);
149
150 fn on_tool_call(&mut self, name: &str, id: &str, input: &serde_json::Value);
157
158 fn on_tool_result(&mut self, id: &str, output: &str);
160
161 fn on_error(&mut self, error: &str);
163
164 fn on_complete(&mut self, result: &SessionResult);
166}
167
168pub struct ConsoleStreamHandler {
173 verbose: bool,
174 stdout: io::Stdout,
175 stderr: io::Stderr,
176}
177
178impl ConsoleStreamHandler {
179 pub fn new(verbose: bool) -> Self {
184 Self {
185 verbose,
186 stdout: io::stdout(),
187 stderr: io::stderr(),
188 }
189 }
190}
191
192impl StreamHandler for ConsoleStreamHandler {
193 fn on_text(&mut self, text: &str) {
194 let _ = writeln!(self.stdout, "Claude: {}", text);
195 }
196
197 fn on_tool_call(&mut self, name: &str, _id: &str, input: &serde_json::Value) {
198 match format_tool_summary(name, input) {
199 Some(summary) => {
200 let _ = writeln!(self.stdout, "[Tool] {}: {}", name, summary);
201 }
202 None => {
203 let _ = writeln!(self.stdout, "[Tool] {}", name);
204 }
205 }
206 }
207
208 fn on_tool_result(&mut self, _id: &str, output: &str) {
209 if self.verbose {
210 let _ = writeln!(self.stdout, "[Result] {}", truncate(output, 200));
211 }
212 }
213
214 fn on_error(&mut self, error: &str) {
215 let _ = writeln!(self.stdout, "[Error] {}", error);
217 let _ = writeln!(self.stderr, "[Error] {}", error);
218 }
219
220 fn on_complete(&mut self, result: &SessionResult) {
221 if self.verbose {
222 let _ = writeln!(
223 self.stdout,
224 "\n--- Session Complete ---\nDuration: {}ms | Cost: ${:.4} | Turns: {}",
225 result.duration_ms, result.total_cost_usd, result.num_turns
226 );
227 }
228 }
229}
230
231pub struct QuietStreamHandler;
233
234impl StreamHandler for QuietStreamHandler {
235 fn on_text(&mut self, _: &str) {}
236 fn on_tool_call(&mut self, _: &str, _: &str, _: &serde_json::Value) {}
237 fn on_tool_result(&mut self, _: &str, _: &str) {}
238 fn on_error(&mut self, _: &str) {}
239 fn on_complete(&mut self, _: &SessionResult) {}
240}
241
242fn text_to_lines(text: &str) -> Vec<Line<'static>> {
252 if text.is_empty() {
253 return Vec::new();
254 }
255
256 let ansi_text = if contains_ansi(text) {
260 text.to_string()
261 } else {
262 let skin = MadSkin::default();
265 skin.term_text(text).to_string()
266 };
267
268 match ansi_text.as_str().into_text() {
270 Ok(parsed_text) => {
271 parsed_text
273 .lines
274 .into_iter()
275 .map(|line| {
276 let owned_spans: Vec<Span<'static>> = line
277 .spans
278 .into_iter()
279 .map(|span| Span::styled(span.content.into_owned(), span.style))
280 .collect();
281 Line::from(owned_spans)
282 })
283 .collect()
284 }
285 Err(_) => {
286 text.split('\n')
288 .map(|line| Line::from(line.to_string()))
289 .collect()
290 }
291 }
292}
293
294#[derive(Clone)]
298enum ContentBlock {
299 Text(String),
301 NonText(Line<'static>),
303}
304
305pub struct TuiStreamHandler {
317 current_text_buffer: String,
319 blocks: Vec<ContentBlock>,
321 verbose: bool,
323 lines: Arc<Mutex<Vec<Line<'static>>>>,
325}
326
327impl TuiStreamHandler {
328 pub fn new(verbose: bool) -> Self {
333 Self {
334 current_text_buffer: String::new(),
335 blocks: Vec::new(),
336 verbose,
337 lines: Arc::new(Mutex::new(Vec::new())),
338 }
339 }
340
341 pub fn with_lines(verbose: bool, lines: Arc<Mutex<Vec<Line<'static>>>>) -> Self {
345 Self {
346 current_text_buffer: String::new(),
347 blocks: Vec::new(),
348 verbose,
349 lines,
350 }
351 }
352
353 pub fn get_lines(&self) -> Vec<Line<'static>> {
355 self.lines.lock().unwrap().clone()
356 }
357
358 pub fn flush_text_buffer(&mut self) {
360 self.update_lines();
361 }
362
363 fn freeze_current_text(&mut self) {
368 if !self.current_text_buffer.is_empty() {
369 self.blocks
370 .push(ContentBlock::Text(self.current_text_buffer.clone()));
371 self.current_text_buffer.clear();
372 }
373 }
374
375 fn update_lines(&mut self) {
381 let mut all_lines = Vec::new();
382
383 for block in &self.blocks {
385 match block {
386 ContentBlock::Text(text) => {
387 all_lines.extend(text_to_lines(text));
388 }
389 ContentBlock::NonText(line) => {
390 all_lines.push(line.clone());
391 }
392 }
393 }
394
395 if !self.current_text_buffer.is_empty() {
397 all_lines.extend(text_to_lines(&self.current_text_buffer));
398 }
399
400 *self.lines.lock().unwrap() = all_lines;
405 }
406
407 fn add_non_text_line(&mut self, line: Line<'static>) {
411 self.freeze_current_text();
412 self.blocks.push(ContentBlock::NonText(line));
413 self.update_lines();
414 }
415}
416
417impl StreamHandler for TuiStreamHandler {
418 fn on_text(&mut self, text: &str) {
419 self.current_text_buffer.push_str(text);
421
422 self.update_lines();
425 }
426
427 fn on_tool_call(&mut self, name: &str, _id: &str, input: &serde_json::Value) {
428 let mut spans = vec![Span::styled(
430 format!("\u{2699} [{}]", name),
431 Style::default().fg(RatatuiColor::Blue),
432 )];
433
434 if let Some(summary) = format_tool_summary(name, input) {
435 spans.push(Span::styled(
436 format!(" {}", summary),
437 Style::default().fg(RatatuiColor::DarkGray),
438 ));
439 }
440
441 self.add_non_text_line(Line::from(spans));
442 }
443
444 fn on_tool_result(&mut self, _id: &str, output: &str) {
445 if self.verbose {
446 let line = Line::from(Span::styled(
447 format!(" \u{2713} {}", truncate(output, 200)),
448 Style::default().fg(RatatuiColor::DarkGray),
449 ));
450 self.add_non_text_line(line);
451 }
452 }
453
454 fn on_error(&mut self, error: &str) {
455 let line = Line::from(Span::styled(
456 format!("\n\u{2717} Error: {}", error),
457 Style::default().fg(RatatuiColor::Red),
458 ));
459 self.add_non_text_line(line);
460 }
461
462 fn on_complete(&mut self, result: &SessionResult) {
463 self.flush_text_buffer();
465
466 self.add_non_text_line(Line::from(""));
468
469 let color = if result.is_error {
471 RatatuiColor::Red
472 } else {
473 RatatuiColor::Green
474 };
475 let summary = format!(
476 "Duration: {}ms | Cost: ${:.4} | Turns: {}",
477 result.duration_ms, result.total_cost_usd, result.num_turns
478 );
479 let line = Line::from(Span::styled(summary, Style::default().fg(color)));
480 self.add_non_text_line(line);
481 }
482}
483
484fn format_tool_summary(name: &str, input: &serde_json::Value) -> Option<String> {
489 match name {
490 "Read" | "Edit" | "Write" => input.get("file_path")?.as_str().map(|s| s.to_string()),
491 "Bash" => {
492 let cmd = input.get("command")?.as_str()?;
493 Some(truncate(cmd, 60))
494 }
495 "Grep" => input.get("pattern")?.as_str().map(|s| s.to_string()),
496 "Glob" => input.get("pattern")?.as_str().map(|s| s.to_string()),
497 "Task" => input.get("description")?.as_str().map(|s| s.to_string()),
498 "WebFetch" => input.get("url")?.as_str().map(|s| s.to_string()),
499 "WebSearch" => input.get("query")?.as_str().map(|s| s.to_string()),
500 "LSP" => {
501 let op = input.get("operation")?.as_str()?;
502 let file = input.get("filePath")?.as_str()?;
503 Some(format!("{} @ {}", op, file))
504 }
505 "NotebookEdit" => input.get("notebook_path")?.as_str().map(|s| s.to_string()),
506 "TodoWrite" => Some("updating todo list".to_string()),
507 _ => None,
508 }
509}
510
511fn truncate(s: &str, max_len: usize) -> String {
516 if s.chars().count() <= max_len {
517 s.to_string()
518 } else {
519 let byte_idx = s
521 .char_indices()
522 .nth(max_len)
523 .map(|(idx, _)| idx)
524 .unwrap_or(s.len());
525 format!("{}...", &s[..byte_idx])
526 }
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532 use serde_json::json;
533
534 #[test]
535 fn test_console_handler_verbose_shows_results() {
536 let mut handler = ConsoleStreamHandler::new(true);
537 let bash_input = json!({"command": "ls -la"});
538
539 handler.on_text("Hello");
541 handler.on_tool_call("Bash", "tool_1", &bash_input);
542 handler.on_tool_result("tool_1", "output");
543 handler.on_complete(&SessionResult {
544 duration_ms: 1000,
545 total_cost_usd: 0.01,
546 num_turns: 1,
547 is_error: false,
548 });
549 }
550
551 #[test]
552 fn test_console_handler_normal_skips_results() {
553 let mut handler = ConsoleStreamHandler::new(false);
554 let read_input = json!({"file_path": "src/main.rs"});
555
556 handler.on_text("Hello");
558 handler.on_tool_call("Read", "tool_1", &read_input);
559 handler.on_tool_result("tool_1", "output"); handler.on_complete(&SessionResult {
561 duration_ms: 1000,
562 total_cost_usd: 0.01,
563 num_turns: 1,
564 is_error: false,
565 }); }
567
568 #[test]
569 fn test_quiet_handler_is_silent() {
570 let mut handler = QuietStreamHandler;
571 let empty_input = json!({});
572
573 handler.on_text("Hello");
575 handler.on_tool_call("Read", "tool_1", &empty_input);
576 handler.on_tool_result("tool_1", "output");
577 handler.on_error("Something went wrong");
578 handler.on_complete(&SessionResult {
579 duration_ms: 1000,
580 total_cost_usd: 0.01,
581 num_turns: 1,
582 is_error: false,
583 });
584 }
585
586 #[test]
587 fn test_truncate_helper() {
588 assert_eq!(truncate("short", 10), "short");
589 assert_eq!(truncate("this is a long string", 10), "this is a ...");
590 }
591
592 #[test]
593 fn test_truncate_utf8_boundaries() {
594 let with_arrows = "→→→→→→→→→→";
596 assert_eq!(truncate(with_arrows, 5), "→→→→→...");
598
599 let mixed = "a→b→c→d→e";
601 assert_eq!(truncate(mixed, 5), "a→b→c...");
602
603 let emoji = "🎉🎊🎁🎈🎄";
605 assert_eq!(truncate(emoji, 3), "🎉🎊🎁...");
606 }
607
608 #[test]
609 fn test_format_tool_summary_file_tools() {
610 assert_eq!(
611 format_tool_summary("Read", &json!({"file_path": "src/main.rs"})),
612 Some("src/main.rs".to_string())
613 );
614 assert_eq!(
615 format_tool_summary("Edit", &json!({"file_path": "/path/to/file.txt"})),
616 Some("/path/to/file.txt".to_string())
617 );
618 assert_eq!(
619 format_tool_summary("Write", &json!({"file_path": "output.json"})),
620 Some("output.json".to_string())
621 );
622 }
623
624 #[test]
625 fn test_format_tool_summary_bash_truncates() {
626 let short_cmd = json!({"command": "ls -la"});
627 assert_eq!(
628 format_tool_summary("Bash", &short_cmd),
629 Some("ls -la".to_string())
630 );
631
632 let long_cmd = json!({"command": "this is a very long command that should be truncated because it exceeds sixty characters"});
633 let result = format_tool_summary("Bash", &long_cmd).unwrap();
634 assert!(result.ends_with("..."));
635 assert!(result.len() <= 70); }
637
638 #[test]
639 fn test_format_tool_summary_search_tools() {
640 assert_eq!(
641 format_tool_summary("Grep", &json!({"pattern": "TODO"})),
642 Some("TODO".to_string())
643 );
644 assert_eq!(
645 format_tool_summary("Glob", &json!({"pattern": "**/*.rs"})),
646 Some("**/*.rs".to_string())
647 );
648 }
649
650 #[test]
651 fn test_format_tool_summary_unknown_tool_returns_none() {
652 assert_eq!(
653 format_tool_summary("UnknownTool", &json!({"some_field": "value"})),
654 None
655 );
656 }
657
658 #[test]
659 fn test_format_tool_summary_missing_field_returns_none() {
660 assert_eq!(
662 format_tool_summary("Read", &json!({"wrong_field": "value"})),
663 None
664 );
665 assert_eq!(format_tool_summary("Bash", &json!({})), None);
667 }
668
669 mod tui_stream_handler {
674 use super::*;
675 use ratatui::style::{Color, Modifier};
676
677 fn collect_lines(handler: &TuiStreamHandler) -> Vec<ratatui::text::Line<'static>> {
679 handler.lines.lock().unwrap().clone()
680 }
681
682 #[test]
683 fn text_creates_line_on_newline() {
684 let mut handler = TuiStreamHandler::new(false);
686
687 handler.on_text("hello\n");
689
690 let lines = collect_lines(&handler);
693 assert_eq!(
694 lines.len(),
695 1,
696 "termimad doesn't create trailing empty line"
697 );
698 assert_eq!(lines[0].to_string(), "hello");
699 }
700
701 #[test]
702 fn partial_text_buffering() {
703 let mut handler = TuiStreamHandler::new(false);
705
706 handler.on_text("hel");
710 handler.on_text("lo\n");
711
712 let lines = collect_lines(&handler);
714 let full_text: String = lines.iter().map(|l| l.to_string()).collect();
715 assert!(
716 full_text.contains("hello"),
717 "Combined text should contain 'hello'. Lines: {:?}",
718 lines
719 );
720 }
721
722 #[test]
723 fn tool_call_produces_formatted_line() {
724 let mut handler = TuiStreamHandler::new(false);
726
727 handler.on_tool_call("Read", "tool_1", &json!({"file_path": "src/main.rs"}));
729
730 let lines = collect_lines(&handler);
732 assert_eq!(lines.len(), 1);
733 let line_text = lines[0].to_string();
734 assert!(
735 line_text.contains('\u{2699}'),
736 "Should contain gear emoji: {}",
737 line_text
738 );
739 assert!(
740 line_text.contains("Read"),
741 "Should contain tool name: {}",
742 line_text
743 );
744 assert!(
745 line_text.contains("src/main.rs"),
746 "Should contain file path: {}",
747 line_text
748 );
749 }
750
751 #[test]
752 fn tool_result_verbose_shows_content() {
753 let mut handler = TuiStreamHandler::new(true);
755
756 handler.on_tool_result("tool_1", "file contents here");
758
759 let lines = collect_lines(&handler);
761 assert_eq!(lines.len(), 1);
762 let line_text = lines[0].to_string();
763 assert!(
764 line_text.contains('\u{2713}'),
765 "Should contain checkmark: {}",
766 line_text
767 );
768 assert!(
769 line_text.contains("file contents here"),
770 "Should contain result content: {}",
771 line_text
772 );
773 }
774
775 #[test]
776 fn tool_result_quiet_is_silent() {
777 let mut handler = TuiStreamHandler::new(false);
779
780 handler.on_tool_result("tool_1", "file contents here");
782
783 let lines = collect_lines(&handler);
785 assert!(
786 lines.is_empty(),
787 "verbose=false should not produce tool result output"
788 );
789 }
790
791 #[test]
792 fn error_produces_red_styled_line() {
793 let mut handler = TuiStreamHandler::new(false);
795
796 handler.on_error("Something went wrong");
798
799 let lines = collect_lines(&handler);
801 assert_eq!(lines.len(), 1);
802 let line_text = lines[0].to_string();
803 assert!(
804 line_text.contains('\u{2717}'),
805 "Should contain X mark: {}",
806 line_text
807 );
808 assert!(
809 line_text.contains("Error"),
810 "Should contain 'Error': {}",
811 line_text
812 );
813 assert!(
814 line_text.contains("Something went wrong"),
815 "Should contain error message: {}",
816 line_text
817 );
818
819 let first_span = &lines[0].spans[0];
821 assert_eq!(
822 first_span.style.fg,
823 Some(Color::Red),
824 "Error line should have red foreground"
825 );
826 }
827
828 #[test]
829 fn long_lines_preserved_without_truncation() {
830 let mut handler = TuiStreamHandler::new(false);
832
833 let long_string: String = "a".repeat(500) + "\n";
835 handler.on_text(&long_string);
836
837 let lines = collect_lines(&handler);
840
841 let total_content: String = lines.iter().map(|l| l.to_string()).collect();
843 let a_count = total_content.chars().filter(|c| *c == 'a').count();
844 assert_eq!(
845 a_count, 500,
846 "All 500 'a' chars should be preserved. Got {}",
847 a_count
848 );
849
850 assert!(
852 !total_content.contains("..."),
853 "Content should not have ellipsis truncation"
854 );
855 }
856
857 #[test]
858 fn multiple_lines_in_single_text_call() {
859 let mut handler = TuiStreamHandler::new(false);
861 handler.on_text("line1\nline2\nline3\n");
862
863 let lines = collect_lines(&handler);
866 let full_text: String = lines
867 .iter()
868 .map(|l| l.to_string())
869 .collect::<Vec<_>>()
870 .join(" ");
871 assert!(
872 full_text.contains("line1")
873 && full_text.contains("line2")
874 && full_text.contains("line3"),
875 "All lines should be present. Lines: {:?}",
876 lines
877 );
878 }
879
880 #[test]
881 fn termimad_parity_with_non_tui_mode() {
882 let text = "Some text before:★ Insight ─────\nKey point here";
885
886 let mut handler = TuiStreamHandler::new(false);
887 handler.on_text(text);
888
889 let lines = collect_lines(&handler);
890
891 assert!(
894 lines.len() >= 2,
895 "termimad should produce multiple lines. Got: {:?}",
896 lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
897 );
898
899 let full_text: String = lines.iter().map(|l| l.to_string()).collect();
901 assert!(
902 full_text.contains("★ Insight"),
903 "Content should contain insight marker"
904 );
905 }
906
907 #[test]
908 fn tool_call_flushes_text_buffer() {
909 let mut handler = TuiStreamHandler::new(false);
911 handler.on_text("partial text");
912
913 handler.on_tool_call("Read", "id", &json!({}));
915
916 let lines = collect_lines(&handler);
918 assert_eq!(lines.len(), 2);
919 assert_eq!(lines[0].to_string(), "partial text");
920 assert!(lines[1].to_string().contains('\u{2699}'));
921 }
922
923 #[test]
924 fn interleaved_text_and_tools_preserves_chronological_order() {
925 let mut handler = TuiStreamHandler::new(false);
929
930 handler.on_text("I'll start by reviewing the scratchpad.\n");
932 handler.on_tool_call("Read", "id1", &json!({"file_path": "scratchpad.md"}));
933 handler.on_text("I found the task. Now checking the code.\n");
934 handler.on_tool_call("Read", "id2", &json!({"file_path": "main.rs"}));
935 handler.on_text("Done reviewing.\n");
936
937 let lines = collect_lines(&handler);
938
939 let text1_idx = lines
941 .iter()
942 .position(|l| l.to_string().contains("reviewing the scratchpad"));
943 let tool1_idx = lines
944 .iter()
945 .position(|l| l.to_string().contains("scratchpad.md"));
946 let text2_idx = lines
947 .iter()
948 .position(|l| l.to_string().contains("checking the code"));
949 let tool2_idx = lines.iter().position(|l| l.to_string().contains("main.rs"));
950 let text3_idx = lines
951 .iter()
952 .position(|l| l.to_string().contains("Done reviewing"));
953
954 assert!(text1_idx.is_some(), "text1 should be present");
956 assert!(tool1_idx.is_some(), "tool1 should be present");
957 assert!(text2_idx.is_some(), "text2 should be present");
958 assert!(tool2_idx.is_some(), "tool2 should be present");
959 assert!(text3_idx.is_some(), "text3 should be present");
960
961 let text1_idx = text1_idx.unwrap();
963 let tool1_idx = tool1_idx.unwrap();
964 let text2_idx = text2_idx.unwrap();
965 let tool2_idx = tool2_idx.unwrap();
966 let text3_idx = text3_idx.unwrap();
967
968 assert!(
969 text1_idx < tool1_idx,
970 "text1 ({}) should come before tool1 ({}). Lines: {:?}",
971 text1_idx,
972 tool1_idx,
973 lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
974 );
975 assert!(
976 tool1_idx < text2_idx,
977 "tool1 ({}) should come before text2 ({}). Lines: {:?}",
978 tool1_idx,
979 text2_idx,
980 lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
981 );
982 assert!(
983 text2_idx < tool2_idx,
984 "text2 ({}) should come before tool2 ({}). Lines: {:?}",
985 text2_idx,
986 tool2_idx,
987 lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
988 );
989 assert!(
990 tool2_idx < text3_idx,
991 "tool2 ({}) should come before text3 ({}). Lines: {:?}",
992 tool2_idx,
993 text3_idx,
994 lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
995 );
996 }
997
998 #[test]
999 fn on_complete_flushes_buffer_and_shows_summary() {
1000 let mut handler = TuiStreamHandler::new(true);
1002 handler.on_text("final output");
1003
1004 handler.on_complete(&SessionResult {
1006 duration_ms: 1500,
1007 total_cost_usd: 0.0025,
1008 num_turns: 3,
1009 is_error: false,
1010 });
1011
1012 let lines = collect_lines(&handler);
1014 assert!(lines.len() >= 2, "Should have at least 2 lines");
1015 assert_eq!(lines[0].to_string(), "final output");
1016
1017 let summary = lines.last().unwrap().to_string();
1019 assert!(
1020 summary.contains("1500"),
1021 "Should contain duration: {}",
1022 summary
1023 );
1024 assert!(
1025 summary.contains("0.0025"),
1026 "Should contain cost: {}",
1027 summary
1028 );
1029 assert!(summary.contains('3'), "Should contain turns: {}", summary);
1030 }
1031
1032 #[test]
1033 fn on_complete_error_uses_red_style() {
1034 let mut handler = TuiStreamHandler::new(true);
1035 handler.on_complete(&SessionResult {
1036 duration_ms: 1000,
1037 total_cost_usd: 0.01,
1038 num_turns: 1,
1039 is_error: true,
1040 });
1041
1042 let lines = collect_lines(&handler);
1043 assert!(!lines.is_empty());
1044
1045 let last_line = lines.last().unwrap();
1047 assert_eq!(
1048 last_line.spans[0].style.fg,
1049 Some(Color::Red),
1050 "Error completion should have red foreground"
1051 );
1052 }
1053
1054 #[test]
1055 fn on_complete_success_uses_green_style() {
1056 let mut handler = TuiStreamHandler::new(true);
1057 handler.on_complete(&SessionResult {
1058 duration_ms: 1000,
1059 total_cost_usd: 0.01,
1060 num_turns: 1,
1061 is_error: false,
1062 });
1063
1064 let lines = collect_lines(&handler);
1065 assert!(!lines.is_empty());
1066
1067 let last_line = lines.last().unwrap();
1069 assert_eq!(
1070 last_line.spans[0].style.fg,
1071 Some(Color::Green),
1072 "Success completion should have green foreground"
1073 );
1074 }
1075
1076 #[test]
1077 fn tool_call_with_no_summary_shows_just_name() {
1078 let mut handler = TuiStreamHandler::new(false);
1079 handler.on_tool_call("UnknownTool", "id", &json!({}));
1080
1081 let lines = collect_lines(&handler);
1082 assert_eq!(lines.len(), 1);
1083 let line_text = lines[0].to_string();
1084 assert!(line_text.contains("UnknownTool"));
1085 }
1087
1088 #[test]
1089 fn get_lines_returns_clone_of_internal_lines() {
1090 let mut handler = TuiStreamHandler::new(false);
1091 handler.on_text("test\n");
1092
1093 let lines1 = handler.get_lines();
1094 let lines2 = handler.get_lines();
1095
1096 assert_eq!(lines1.len(), lines2.len());
1098 assert_eq!(lines1[0].to_string(), lines2[0].to_string());
1099 }
1100
1101 #[test]
1106 fn markdown_bold_text_renders_with_bold_modifier() {
1107 let mut handler = TuiStreamHandler::new(false);
1109
1110 handler.on_text("**important**\n");
1112
1113 let lines = collect_lines(&handler);
1115 assert!(!lines.is_empty(), "Should have at least one line");
1116
1117 let has_bold = lines.iter().any(|line| {
1119 line.spans.iter().any(|span| {
1120 span.content.contains("important")
1121 && span.style.add_modifier.contains(Modifier::BOLD)
1122 })
1123 });
1124 assert!(
1125 has_bold,
1126 "Should have bold 'important' span. Lines: {:?}",
1127 lines
1128 );
1129 }
1130
1131 #[test]
1132 fn markdown_italic_text_renders_with_italic_modifier() {
1133 let mut handler = TuiStreamHandler::new(false);
1135
1136 handler.on_text("*emphasized*\n");
1138
1139 let lines = collect_lines(&handler);
1141 assert!(!lines.is_empty(), "Should have at least one line");
1142
1143 let has_italic = lines.iter().any(|line| {
1144 line.spans.iter().any(|span| {
1145 span.content.contains("emphasized")
1146 && span.style.add_modifier.contains(Modifier::ITALIC)
1147 })
1148 });
1149 assert!(
1150 has_italic,
1151 "Should have italic 'emphasized' span. Lines: {:?}",
1152 lines
1153 );
1154 }
1155
1156 #[test]
1157 fn markdown_inline_code_renders_with_distinct_style() {
1158 let mut handler = TuiStreamHandler::new(false);
1160
1161 handler.on_text("`code`\n");
1163
1164 let lines = collect_lines(&handler);
1166 assert!(!lines.is_empty(), "Should have at least one line");
1167
1168 let has_code_style = lines.iter().any(|line| {
1169 line.spans.iter().any(|span| {
1170 span.content.contains("code")
1171 && (span.style.fg.is_some() || span.style.bg.is_some())
1172 })
1173 });
1174 assert!(
1175 has_code_style,
1176 "Should have styled 'code' span. Lines: {:?}",
1177 lines
1178 );
1179 }
1180
1181 #[test]
1182 fn markdown_header_renders_content() {
1183 let mut handler = TuiStreamHandler::new(false);
1185
1186 handler.on_text("## Section Title\n");
1188
1189 let lines = collect_lines(&handler);
1192 assert!(!lines.is_empty(), "Should have at least one line");
1193
1194 let has_header_content = lines.iter().any(|line| {
1195 line.spans
1196 .iter()
1197 .any(|span| span.content.contains("Section Title"))
1198 });
1199 assert!(
1200 has_header_content,
1201 "Should have header content. Lines: {:?}",
1202 lines
1203 );
1204 }
1205
1206 #[test]
1207 fn markdown_streaming_continuity_handles_split_formatting() {
1208 let mut handler = TuiStreamHandler::new(false);
1210
1211 handler.on_text("**bo");
1213 handler.on_text("ld**\n");
1214
1215 let lines = collect_lines(&handler);
1217
1218 let has_bold = lines.iter().any(|line| {
1219 line.spans
1220 .iter()
1221 .any(|span| span.style.add_modifier.contains(Modifier::BOLD))
1222 });
1223 assert!(
1224 has_bold,
1225 "Split markdown should still render bold. Lines: {:?}",
1226 lines
1227 );
1228 }
1229
1230 #[test]
1231 fn markdown_mixed_content_renders_correctly() {
1232 let mut handler = TuiStreamHandler::new(false);
1234
1235 handler.on_text("Normal **bold** and *italic* text\n");
1237
1238 let lines = collect_lines(&handler);
1240 assert!(!lines.is_empty(), "Should have at least one line");
1241
1242 let has_bold = lines.iter().any(|line| {
1243 line.spans.iter().any(|span| {
1244 span.content.contains("bold")
1245 && span.style.add_modifier.contains(Modifier::BOLD)
1246 })
1247 });
1248 let has_italic = lines.iter().any(|line| {
1249 line.spans.iter().any(|span| {
1250 span.content.contains("italic")
1251 && span.style.add_modifier.contains(Modifier::ITALIC)
1252 })
1253 });
1254
1255 assert!(has_bold, "Should have bold span. Lines: {:?}", lines);
1256 assert!(has_italic, "Should have italic span. Lines: {:?}", lines);
1257 }
1258
1259 #[test]
1260 fn markdown_tool_call_styling_preserved() {
1261 let mut handler = TuiStreamHandler::new(false);
1263
1264 handler.on_text("**bold**\n");
1266 handler.on_tool_call("Read", "id", &json!({"file_path": "src/main.rs"}));
1267
1268 let lines = collect_lines(&handler);
1270 assert!(lines.len() >= 2, "Should have at least 2 lines");
1271
1272 let tool_line = lines.last().unwrap();
1274 let has_blue = tool_line
1275 .spans
1276 .iter()
1277 .any(|span| span.style.fg == Some(Color::Blue));
1278 assert!(
1279 has_blue,
1280 "Tool call should preserve blue styling. Line: {:?}",
1281 tool_line
1282 );
1283 }
1284
1285 #[test]
1286 fn markdown_error_styling_preserved() {
1287 let mut handler = TuiStreamHandler::new(false);
1289
1290 handler.on_text("**bold**\n");
1292 handler.on_error("Something went wrong");
1293
1294 let lines = collect_lines(&handler);
1296 assert!(lines.len() >= 2, "Should have at least 2 lines");
1297
1298 let error_line = lines.last().unwrap();
1300 let has_red = error_line
1301 .spans
1302 .iter()
1303 .any(|span| span.style.fg == Some(Color::Red));
1304 assert!(
1305 has_red,
1306 "Error should preserve red styling. Line: {:?}",
1307 error_line
1308 );
1309 }
1310
1311 #[test]
1312 fn markdown_partial_formatting_does_not_crash() {
1313 let mut handler = TuiStreamHandler::new(false);
1315
1316 handler.on_text("**unclosed bold");
1318 handler.flush_text_buffer();
1319
1320 let lines = collect_lines(&handler);
1322 let _ = lines; }
1326
1327 #[test]
1332 fn ansi_green_text_produces_green_style() {
1333 let mut handler = TuiStreamHandler::new(false);
1335
1336 handler.on_text("\x1b[32mgreen text\x1b[0m\n");
1338
1339 let lines = collect_lines(&handler);
1341 assert!(!lines.is_empty(), "Should have at least one line");
1342
1343 let has_green = lines.iter().any(|line| {
1344 line.spans
1345 .iter()
1346 .any(|span| span.style.fg == Some(Color::Green))
1347 });
1348 assert!(
1349 has_green,
1350 "Should have green styled span. Lines: {:?}",
1351 lines
1352 );
1353 }
1354
1355 #[test]
1356 fn ansi_bold_text_produces_bold_modifier() {
1357 let mut handler = TuiStreamHandler::new(false);
1359
1360 handler.on_text("\x1b[1mbold text\x1b[0m\n");
1362
1363 let lines = collect_lines(&handler);
1365 assert!(!lines.is_empty(), "Should have at least one line");
1366
1367 let has_bold = lines.iter().any(|line| {
1368 line.spans
1369 .iter()
1370 .any(|span| span.style.add_modifier.contains(Modifier::BOLD))
1371 });
1372 assert!(has_bold, "Should have bold styled span. Lines: {:?}", lines);
1373 }
1374
1375 #[test]
1376 fn ansi_mixed_styles_preserved() {
1377 let mut handler = TuiStreamHandler::new(false);
1379
1380 handler.on_text("\x1b[1;32mbold green\x1b[0m normal\n");
1382
1383 let lines = collect_lines(&handler);
1385 assert!(!lines.is_empty(), "Should have at least one line");
1386
1387 let has_styled = lines.iter().any(|line| {
1389 line.spans.iter().any(|span| {
1390 span.style.fg == Some(Color::Green)
1391 || span.style.add_modifier.contains(Modifier::BOLD)
1392 })
1393 });
1394 assert!(
1395 has_styled,
1396 "Should have styled span with color or bold. Lines: {:?}",
1397 lines
1398 );
1399 }
1400
1401 #[test]
1402 fn ansi_plain_text_renders_without_crash() {
1403 let mut handler = TuiStreamHandler::new(false);
1405
1406 handler.on_text("plain text without ansi\n");
1408
1409 let lines = collect_lines(&handler);
1411 assert!(!lines.is_empty(), "Should have at least one line");
1412
1413 let full_text: String = lines.iter().map(|l| l.to_string()).collect();
1414 assert!(
1415 full_text.contains("plain text"),
1416 "Should contain the text. Lines: {:?}",
1417 lines
1418 );
1419 }
1420
1421 #[test]
1422 fn ansi_red_error_text_produces_red_style() {
1423 let mut handler = TuiStreamHandler::new(false);
1425
1426 handler.on_text("\x1b[31mError: something failed\x1b[0m\n");
1428
1429 let lines = collect_lines(&handler);
1431 assert!(!lines.is_empty(), "Should have at least one line");
1432
1433 let has_red = lines.iter().any(|line| {
1434 line.spans
1435 .iter()
1436 .any(|span| span.style.fg == Some(Color::Red))
1437 });
1438 assert!(has_red, "Should have red styled span. Lines: {:?}", lines);
1439 }
1440
1441 #[test]
1442 fn ansi_cyan_text_produces_cyan_style() {
1443 let mut handler = TuiStreamHandler::new(false);
1445
1446 handler.on_text("\x1b[36mcyan text\x1b[0m\n");
1448
1449 let lines = collect_lines(&handler);
1451 assert!(!lines.is_empty(), "Should have at least one line");
1452
1453 let has_cyan = lines.iter().any(|line| {
1454 line.spans
1455 .iter()
1456 .any(|span| span.style.fg == Some(Color::Cyan))
1457 });
1458 assert!(has_cyan, "Should have cyan styled span. Lines: {:?}", lines);
1459 }
1460
1461 #[test]
1462 fn ansi_underline_produces_underline_modifier() {
1463 let mut handler = TuiStreamHandler::new(false);
1465
1466 handler.on_text("\x1b[4munderlined\x1b[0m\n");
1468
1469 let lines = collect_lines(&handler);
1471 assert!(!lines.is_empty(), "Should have at least one line");
1472
1473 let has_underline = lines.iter().any(|line| {
1474 line.spans
1475 .iter()
1476 .any(|span| span.style.add_modifier.contains(Modifier::UNDERLINED))
1477 });
1478 assert!(
1479 has_underline,
1480 "Should have underlined styled span. Lines: {:?}",
1481 lines
1482 );
1483 }
1484
1485 #[test]
1486 fn ansi_multiline_preserves_colors() {
1487 let mut handler = TuiStreamHandler::new(false);
1489
1490 handler.on_text("\x1b[32mline 1 green\x1b[0m\n\x1b[31mline 2 red\x1b[0m\n");
1492
1493 let lines = collect_lines(&handler);
1495 assert!(lines.len() >= 2, "Should have at least two lines");
1496
1497 let has_green = lines.iter().any(|line| {
1498 line.spans
1499 .iter()
1500 .any(|span| span.style.fg == Some(Color::Green))
1501 });
1502 let has_red = lines.iter().any(|line| {
1503 line.spans
1504 .iter()
1505 .any(|span| span.style.fg == Some(Color::Red))
1506 });
1507
1508 assert!(has_green, "Should have green line. Lines: {:?}", lines);
1509 assert!(has_red, "Should have red line. Lines: {:?}", lines);
1510 }
1511 }
1512}
1513
1514#[cfg(test)]
1519mod ansi_detection_tests {
1520 use super::*;
1521
1522 #[test]
1523 fn contains_ansi_with_color_code() {
1524 assert!(contains_ansi("\x1b[32mgreen\x1b[0m"));
1525 }
1526
1527 #[test]
1528 fn contains_ansi_with_bold() {
1529 assert!(contains_ansi("\x1b[1mbold\x1b[0m"));
1530 }
1531
1532 #[test]
1533 fn contains_ansi_plain_text_returns_false() {
1534 assert!(!contains_ansi("hello world"));
1535 }
1536
1537 #[test]
1538 fn contains_ansi_markdown_returns_false() {
1539 assert!(!contains_ansi("**bold** and *italic*"));
1540 }
1541
1542 #[test]
1543 fn contains_ansi_empty_string_returns_false() {
1544 assert!(!contains_ansi(""));
1545 }
1546
1547 #[test]
1548 fn contains_ansi_with_escape_in_middle() {
1549 assert!(contains_ansi("prefix \x1b[31mred\x1b[0m suffix"));
1550 }
1551}