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::{
16 borrow::Cow,
17 io::{self, Write},
18 sync::{Arc, Mutex},
19};
20use termimad::MadSkin;
21
22use crate::tool_preview::{format_tool_result, format_tool_summary};
23
24#[inline]
29pub(crate) fn contains_ansi(text: &str) -> bool {
30 text.contains("\x1b[")
31}
32
33fn sanitize_tui_block_text(text: &str) -> Cow<'_, str> {
44 let has_cr = text.contains('\r');
45 let has_other_ctrl = text
46 .chars()
47 .any(|c| matches!(c, '\u{0007}' | '\u{0008}' | '\u{000b}' | '\u{000c}'));
48
49 if !has_cr && !has_other_ctrl {
50 return Cow::Borrowed(text);
51 }
52
53 let mut s = if has_cr {
54 text.replace("\r\n", "\n").replace('\r', "\n")
56 } else {
57 text.to_string()
58 };
59
60 if has_other_ctrl {
61 s.retain(|c| !matches!(c, '\u{0007}' | '\u{0008}' | '\u{000b}' | '\u{000c}'));
62 }
63
64 Cow::Owned(s)
65}
66
67fn sanitize_tui_inline_text(text: &str) -> String {
70 let mut s = text.replace("\r\n", " ").replace(['\r', '\n'], " ");
71
72 s.retain(|c| !matches!(c, '\u{0007}' | '\u{0008}' | '\u{000b}' | '\u{000c}'));
74
75 s
76}
77
78#[derive(Debug, Clone, Default)]
80pub struct SessionResult {
81 pub duration_ms: u64,
82 pub total_cost_usd: f64,
83 pub num_turns: u32,
84 pub is_error: bool,
85 pub input_tokens: u64,
87 pub output_tokens: u64,
89 pub cache_read_tokens: u64,
91 pub cache_write_tokens: u64,
93}
94
95pub struct PrettyStreamHandler {
97 stdout: io::Stdout,
98 verbose: bool,
99 text_buffer: String,
101 skin: MadSkin,
103}
104
105impl PrettyStreamHandler {
106 pub fn new(verbose: bool) -> Self {
108 Self {
109 stdout: io::stdout(),
110 verbose,
111 text_buffer: String::new(),
112 skin: MadSkin::default(),
113 }
114 }
115
116 fn flush_text_buffer(&mut self) {
118 if self.text_buffer.is_empty() {
119 return;
120 }
121 let rendered = self.skin.term_text(&self.text_buffer);
123 let _ = self.stdout.write(rendered.to_string().as_bytes());
124 let _ = self.stdout.flush();
125 self.text_buffer.clear();
126 }
127}
128
129impl StreamHandler for PrettyStreamHandler {
130 fn on_text(&mut self, text: &str) {
131 self.text_buffer.push_str(text);
136 }
137
138 fn on_tool_result(&mut self, _id: &str, output: &str) {
139 if self.verbose {
140 let _ = self
141 .stdout
142 .queue(style::SetForegroundColor(Color::DarkGrey));
143 let _ = self
144 .stdout
145 .write(format!(" \u{2713} {}\n", truncate(output, 200)).as_bytes());
146 let _ = self.stdout.queue(style::ResetColor);
147 let _ = self.stdout.flush();
148 }
149 }
150
151 fn on_error(&mut self, error: &str) {
152 let _ = self.stdout.queue(style::SetForegroundColor(Color::Red));
153 let _ = self
154 .stdout
155 .write(format!("\n\u{2717} Error: {}\n", error).as_bytes());
156 let _ = self.stdout.queue(style::ResetColor);
157 let _ = self.stdout.flush();
158 }
159
160 fn on_complete(&mut self, result: &SessionResult) {
161 self.flush_text_buffer();
163
164 let _ = self.stdout.write(b"\n");
165 let color = if result.is_error {
166 Color::Red
167 } else {
168 Color::Green
169 };
170 let _ = self.stdout.queue(style::SetForegroundColor(color));
171 let _ = self.stdout.write(
172 format!(
173 "Duration: {}ms | Est. cost: ${:.4} | Turns: {}\n",
174 result.duration_ms, result.total_cost_usd, result.num_turns
175 )
176 .as_bytes(),
177 );
178 let _ = self.stdout.queue(style::ResetColor);
179 let _ = self.stdout.flush();
180 }
181
182 fn on_tool_call(&mut self, name: &str, _id: &str, input: &serde_json::Value) {
183 self.flush_text_buffer();
185
186 let _ = self.stdout.queue(style::SetForegroundColor(Color::Blue));
188 let _ = self.stdout.write(format!("\u{2699} [{}]", name).as_bytes());
189
190 if let Some(summary) = format_tool_summary(name, input) {
191 let _ = self
192 .stdout
193 .queue(style::SetForegroundColor(Color::DarkGrey));
194 let _ = self.stdout.write(format!(" {}\n", summary).as_bytes());
195 } else {
196 let _ = self.stdout.write(b"\n");
197 }
198 let _ = self.stdout.queue(style::ResetColor);
199 let _ = self.stdout.flush();
200 }
201}
202
203pub trait StreamHandler: Send {
208 fn on_text(&mut self, text: &str);
210
211 fn on_tool_call(&mut self, name: &str, id: &str, input: &serde_json::Value);
218
219 fn on_tool_result(&mut self, id: &str, output: &str);
221
222 fn on_error(&mut self, error: &str);
224
225 fn on_complete(&mut self, result: &SessionResult);
227}
228
229pub struct ConsoleStreamHandler {
234 verbose: bool,
235 stdout: io::Stdout,
236 stderr: io::Stderr,
237 last_was_newline: bool,
239}
240
241impl ConsoleStreamHandler {
242 pub fn new(verbose: bool) -> Self {
247 Self {
248 verbose,
249 stdout: io::stdout(),
250 stderr: io::stderr(),
251 last_was_newline: true, }
253 }
254
255 fn ensure_newline(&mut self) {
257 if !self.last_was_newline {
258 let _ = writeln!(self.stdout);
259 self.last_was_newline = true;
260 }
261 }
262}
263
264impl StreamHandler for ConsoleStreamHandler {
265 fn on_text(&mut self, text: &str) {
266 let _ = write!(self.stdout, "{}", text);
267 let _ = self.stdout.flush();
268 self.last_was_newline = text.ends_with('\n');
269 }
270
271 fn on_tool_call(&mut self, name: &str, _id: &str, input: &serde_json::Value) {
272 self.ensure_newline();
273 match format_tool_summary(name, input) {
274 Some(summary) => {
275 let _ = writeln!(self.stdout, "[Tool] {}: {}", name, summary);
276 }
277 None => {
278 let _ = writeln!(self.stdout, "[Tool] {}", name);
279 }
280 }
281 self.last_was_newline = true;
283 }
284
285 fn on_tool_result(&mut self, _id: &str, output: &str) {
286 if self.verbose {
287 let _ = writeln!(self.stdout, "[Result] {}", truncate(output, 200));
288 }
289 }
290
291 fn on_error(&mut self, error: &str) {
292 let _ = writeln!(self.stdout, "[Error] {}", error);
294 let _ = writeln!(self.stderr, "[Error] {}", error);
295 }
296
297 fn on_complete(&mut self, result: &SessionResult) {
298 if self.verbose {
299 let _ = writeln!(
300 self.stdout,
301 "\n--- Session Complete ---\nDuration: {}ms | Est. cost: ${:.4} | Turns: {}",
302 result.duration_ms, result.total_cost_usd, result.num_turns
303 );
304 }
305 }
306}
307
308pub struct QuietStreamHandler;
310
311impl StreamHandler for QuietStreamHandler {
312 fn on_text(&mut self, _: &str) {}
313 fn on_tool_call(&mut self, _: &str, _: &str, _: &serde_json::Value) {}
314 fn on_tool_result(&mut self, _: &str, _: &str) {}
315 fn on_error(&mut self, _: &str) {}
316 fn on_complete(&mut self, _: &SessionResult) {}
317}
318
319fn text_to_lines(text: &str) -> Vec<Line<'static>> {
329 if text.is_empty() {
330 return Vec::new();
331 }
332
333 let text = sanitize_tui_block_text(text);
336 let text = text.as_ref();
337 if text.is_empty() {
338 return Vec::new();
339 }
340
341 let ansi_text = if contains_ansi(text) {
345 text.to_string()
346 } else {
347 let skin = MadSkin::default();
350 skin.term_text(text).to_string()
351 };
352
353 match ansi_text.as_str().into_text() {
355 Ok(parsed_text) => {
356 parsed_text
358 .lines
359 .into_iter()
360 .map(|line| {
361 let owned_spans: Vec<Span<'static>> = line
362 .spans
363 .into_iter()
364 .map(|span| Span::styled(span.content.into_owned(), span.style))
365 .collect();
366 Line::from(owned_spans)
367 })
368 .collect()
369 }
370 Err(_) => {
371 text.split('\n')
373 .map(|line| Line::from(line.to_string()))
374 .collect()
375 }
376 }
377}
378
379#[derive(Clone)]
383enum ContentBlock {
384 Text(String),
386 NonText(Line<'static>),
388}
389
390pub struct TuiStreamHandler {
402 current_text_buffer: String,
404 blocks: Vec<ContentBlock>,
406 _verbose: bool,
408 lines: Arc<Mutex<Vec<Line<'static>>>>,
410}
411
412impl TuiStreamHandler {
413 pub fn new(verbose: bool) -> Self {
418 Self {
419 current_text_buffer: String::new(),
420 blocks: Vec::new(),
421 _verbose: verbose,
422 lines: Arc::new(Mutex::new(Vec::new())),
423 }
424 }
425
426 pub fn with_lines(verbose: bool, lines: Arc<Mutex<Vec<Line<'static>>>>) -> Self {
430 Self {
431 current_text_buffer: String::new(),
432 blocks: Vec::new(),
433 _verbose: verbose,
434 lines,
435 }
436 }
437
438 pub fn get_lines(&self) -> Vec<Line<'static>> {
440 self.lines.lock().unwrap().clone()
441 }
442
443 pub fn flush_text_buffer(&mut self) {
445 self.update_lines();
446 }
447
448 fn freeze_current_text(&mut self) {
453 if !self.current_text_buffer.is_empty() {
454 self.blocks
455 .push(ContentBlock::Text(self.current_text_buffer.clone()));
456 self.current_text_buffer.clear();
457 }
458 }
459
460 fn update_lines(&mut self) {
466 let mut all_lines = Vec::new();
467
468 for block in &self.blocks {
470 match block {
471 ContentBlock::Text(text) => {
472 all_lines.extend(text_to_lines(text));
473 }
474 ContentBlock::NonText(line) => {
475 all_lines.push(line.clone());
476 }
477 }
478 }
479
480 if !self.current_text_buffer.is_empty() {
482 all_lines.extend(text_to_lines(&self.current_text_buffer));
483 }
484
485 *self.lines.lock().unwrap() = all_lines;
490 }
491
492 fn add_non_text_line(&mut self, line: Line<'static>) {
496 self.freeze_current_text();
497 self.blocks.push(ContentBlock::NonText(line));
498 self.update_lines();
499 }
500}
501
502impl StreamHandler for TuiStreamHandler {
503 fn on_text(&mut self, text: &str) {
504 self.current_text_buffer.push_str(text);
506
507 self.update_lines();
510 }
511
512 fn on_tool_call(&mut self, name: &str, _id: &str, input: &serde_json::Value) {
513 let mut spans = vec![Span::styled(
515 format!("\u{2699} [{}]", name),
516 Style::default().fg(RatatuiColor::Blue),
517 )];
518
519 if let Some(summary) = format_tool_summary(name, input) {
520 let summary = sanitize_tui_inline_text(&summary);
521 spans.push(Span::styled(
522 format!(" {}", summary),
523 Style::default().fg(RatatuiColor::DarkGray),
524 ));
525 }
526
527 self.add_non_text_line(Line::from(spans));
528 }
529
530 fn on_tool_result(&mut self, _id: &str, output: &str) {
531 let display = format_tool_result(output);
532 if display.is_empty() {
533 return;
534 }
535 let clean = sanitize_tui_inline_text(&display);
536 let line = Line::from(vec![
537 Span::styled(" ↳ ", Style::default().fg(RatatuiColor::DarkGray)),
538 Span::styled("✓ ", Style::default().fg(RatatuiColor::Green)),
539 Span::styled(
540 truncate(&clean, 200),
541 Style::default().fg(RatatuiColor::DarkGray),
542 ),
543 ]);
544 self.add_non_text_line(line);
545 }
546
547 fn on_error(&mut self, error: &str) {
548 let clean = sanitize_tui_inline_text(error);
549 let line = Line::from(vec![
550 Span::styled(" ↳ ", Style::default().fg(RatatuiColor::DarkGray)),
551 Span::styled(
552 format!("\u{2717} Error: {}", clean),
553 Style::default().fg(RatatuiColor::Red),
554 ),
555 ]);
556 self.add_non_text_line(line);
557 }
558
559 fn on_complete(&mut self, result: &SessionResult) {
560 self.flush_text_buffer();
562
563 self.add_non_text_line(Line::from(""));
565
566 let color = if result.is_error {
568 RatatuiColor::Red
569 } else {
570 RatatuiColor::Green
571 };
572 let summary = format!(
573 "Duration: {}ms | Est. cost: ${:.4} | Turns: {}",
574 result.duration_ms, result.total_cost_usd, result.num_turns
575 );
576 let line = Line::from(Span::styled(summary, Style::default().fg(color)));
577 self.add_non_text_line(line);
578 }
579}
580
581fn truncate(s: &str, max_len: usize) -> String {
586 if s.chars().count() <= max_len {
587 s.to_string()
588 } else {
589 let byte_idx = s
591 .char_indices()
592 .nth(max_len)
593 .map(|(idx, _)| idx)
594 .unwrap_or(s.len());
595 format!("{}...", &s[..byte_idx])
596 }
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602 use serde_json::json;
603
604 #[test]
605 fn test_console_handler_verbose_shows_results() {
606 let mut handler = ConsoleStreamHandler::new(true);
607 let bash_input = json!({"command": "ls -la"});
608
609 handler.on_text("Hello");
611 handler.on_tool_call("Bash", "tool_1", &bash_input);
612 handler.on_tool_result("tool_1", "output");
613 handler.on_complete(&SessionResult {
614 duration_ms: 1000,
615 total_cost_usd: 0.01,
616 num_turns: 1,
617 is_error: false,
618 ..Default::default()
619 });
620 }
621
622 #[test]
623 fn test_console_handler_normal_skips_results() {
624 let mut handler = ConsoleStreamHandler::new(false);
625 let read_input = json!({"file_path": "src/main.rs"});
626
627 handler.on_text("Hello");
629 handler.on_tool_call("Read", "tool_1", &read_input);
630 handler.on_tool_result("tool_1", "output"); handler.on_complete(&SessionResult {
632 duration_ms: 1000,
633 total_cost_usd: 0.01,
634 num_turns: 1,
635 is_error: false,
636 ..Default::default()
637 }); }
639
640 #[test]
641 fn test_quiet_handler_is_silent() {
642 let mut handler = QuietStreamHandler;
643 let empty_input = json!({});
644
645 handler.on_text("Hello");
647 handler.on_tool_call("Read", "tool_1", &empty_input);
648 handler.on_tool_result("tool_1", "output");
649 handler.on_error("Something went wrong");
650 handler.on_complete(&SessionResult {
651 duration_ms: 1000,
652 total_cost_usd: 0.01,
653 num_turns: 1,
654 is_error: false,
655 ..Default::default()
656 });
657 }
658
659 #[test]
660 fn test_truncate_helper() {
661 assert_eq!(truncate("short", 10), "short");
662 assert_eq!(truncate("this is a long string", 10), "this is a ...");
663 }
664
665 #[test]
666 fn test_truncate_utf8_boundaries() {
667 let with_arrows = "→→→→→→→→→→";
669 assert_eq!(truncate(with_arrows, 5), "→→→→→...");
671
672 let mixed = "a→b→c→d→e";
674 assert_eq!(truncate(mixed, 5), "a→b→c...");
675
676 let emoji = "🎉🎊🎁🎈🎄";
678 assert_eq!(truncate(emoji, 3), "🎉🎊🎁...");
679 }
680
681 #[test]
682 fn test_sanitize_tui_inline_text_removes_newlines_and_carriage_returns() {
683 let s = "hello\r\nworld\nbye\rok";
684 let clean = sanitize_tui_inline_text(s);
685 assert!(!clean.contains('\r'));
686 assert!(!clean.contains('\n'));
687 }
688
689 #[test]
690 fn test_text_to_lines_sanitizes_carriage_returns() {
691 let lines = text_to_lines("alpha\rbravo\ncharlie");
692 for line in lines {
693 for span in line.spans {
694 assert!(
695 !span.content.contains('\r'),
696 "Span content should not contain carriage returns: {:?}",
697 span.content
698 );
699 }
700 }
701 }
702
703 #[test]
704 fn test_format_tool_summary_file_tools() {
705 assert_eq!(
706 format_tool_summary("Read", &json!({"file_path": "src/main.rs"})),
707 Some("src/main.rs".to_string())
708 );
709 assert_eq!(
710 format_tool_summary("Edit", &json!({"file_path": "/path/to/file.txt"})),
711 Some("/path/to/file.txt".to_string())
712 );
713 assert_eq!(
714 format_tool_summary("Write", &json!({"file_path": "output.json"})),
715 Some("output.json".to_string())
716 );
717 }
718
719 #[test]
720 fn test_format_tool_summary_bash_truncates() {
721 let short_cmd = json!({"command": "ls -la"});
722 assert_eq!(
723 format_tool_summary("Bash", &short_cmd),
724 Some("ls -la".to_string())
725 );
726
727 let long_cmd = json!({"command": "this is a very long command that should be truncated because it exceeds sixty characters"});
728 let result = format_tool_summary("Bash", &long_cmd).unwrap();
729 assert!(result.ends_with("..."));
730 assert!(result.len() <= 70); }
732
733 #[test]
734 fn test_format_tool_summary_search_tools() {
735 assert_eq!(
736 format_tool_summary("Grep", &json!({"pattern": "TODO"})),
737 Some("TODO".to_string())
738 );
739 assert_eq!(
740 format_tool_summary("Glob", &json!({"pattern": "**/*.rs"})),
741 Some("**/*.rs".to_string())
742 );
743 }
744
745 #[test]
746 fn test_format_tool_summary_unknown_tool_returns_none() {
747 assert_eq!(
748 format_tool_summary("UnknownTool", &json!({"some_field": "value"})),
749 None
750 );
751 }
752
753 #[test]
754 fn test_format_tool_summary_unknown_tool_with_common_key_uses_fallback() {
755 assert_eq!(
756 format_tool_summary("UnknownTool", &json!({"path": "/tmp/foo"})),
757 Some("/tmp/foo".to_string())
758 );
759 }
760
761 #[test]
762 fn test_format_tool_summary_acp_lowercase_tools() {
763 assert_eq!(
764 format_tool_summary("read", &json!({"path": "src/main.rs"})),
765 Some("src/main.rs".to_string())
766 );
767 assert_eq!(
768 format_tool_summary("shell", &json!({"command": "ls -la"})),
769 Some("ls -la".to_string())
770 );
771 assert_eq!(
772 format_tool_summary("ls", &json!({"path": "/tmp"})),
773 Some("/tmp".to_string())
774 );
775 assert_eq!(
776 format_tool_summary("grep", &json!({"pattern": "TODO"})),
777 Some("TODO".to_string())
778 );
779 assert_eq!(
780 format_tool_summary("glob", &json!({"pattern": "**/*.rs"})),
781 Some("**/*.rs".to_string())
782 );
783 assert_eq!(
784 format_tool_summary("write", &json!({"path": "out.txt"})),
785 Some("out.txt".to_string())
786 );
787 }
788
789 #[test]
790 fn test_format_tool_summary_missing_field_returns_none() {
791 assert_eq!(
793 format_tool_summary("Read", &json!({"wrong_field": "value"})),
794 None
795 );
796 assert_eq!(format_tool_summary("Bash", &json!({})), None);
798 }
799
800 mod tui_stream_handler {
805 use super::*;
806 use ratatui::style::{Color, Modifier};
807
808 fn collect_lines(handler: &TuiStreamHandler) -> Vec<ratatui::text::Line<'static>> {
810 handler.lines.lock().unwrap().clone()
811 }
812
813 #[test]
814 fn text_creates_line_on_newline() {
815 let mut handler = TuiStreamHandler::new(false);
817
818 handler.on_text("hello\n");
820
821 let lines = collect_lines(&handler);
824 assert_eq!(
825 lines.len(),
826 1,
827 "termimad doesn't create trailing empty line"
828 );
829 assert_eq!(lines[0].to_string(), "hello");
830 }
831
832 #[test]
833 fn partial_text_buffering() {
834 let mut handler = TuiStreamHandler::new(false);
836
837 handler.on_text("hel");
841 handler.on_text("lo\n");
842
843 let lines = collect_lines(&handler);
845 let full_text: String = lines.iter().map(|l| l.to_string()).collect();
846 assert!(
847 full_text.contains("hello"),
848 "Combined text should contain 'hello'. Lines: {:?}",
849 lines
850 );
851 }
852
853 #[test]
854 fn tool_call_produces_formatted_line() {
855 let mut handler = TuiStreamHandler::new(false);
857
858 handler.on_tool_call("Read", "tool_1", &json!({"file_path": "src/main.rs"}));
860
861 let lines = collect_lines(&handler);
863 assert_eq!(lines.len(), 1);
864 let line_text = lines[0].to_string();
865 assert!(
866 line_text.contains('\u{2699}'),
867 "Should contain gear emoji: {}",
868 line_text
869 );
870 assert!(
871 line_text.contains("Read"),
872 "Should contain tool name: {}",
873 line_text
874 );
875 assert!(
876 line_text.contains("src/main.rs"),
877 "Should contain file path: {}",
878 line_text
879 );
880 }
881
882 #[test]
883 fn tool_result_verbose_shows_content() {
884 let mut handler = TuiStreamHandler::new(true);
886
887 handler.on_tool_result("tool_1", "file contents here");
889
890 let lines = collect_lines(&handler);
892 assert_eq!(lines.len(), 1);
893 let line_text = lines[0].to_string();
894 assert!(
895 line_text.contains('\u{2713}'),
896 "Should contain checkmark: {}",
897 line_text
898 );
899 assert!(
900 line_text.contains("file contents here"),
901 "Should contain result content: {}",
902 line_text
903 );
904 }
905
906 #[test]
907 fn tool_result_quiet_shows_content() {
908 let mut handler = TuiStreamHandler::new(false);
910
911 handler.on_tool_result("tool_1", "file contents here");
913
914 let lines = collect_lines(&handler);
916 assert_eq!(lines.len(), 1);
917 let line_text = lines[0].to_string();
918 assert!(
919 line_text.contains('\u{2713}'),
920 "Should contain checkmark: {}",
921 line_text
922 );
923 assert!(
924 line_text.contains("file contents here"),
925 "Should contain result content: {}",
926 line_text
927 );
928 }
929
930 #[test]
931 fn error_produces_red_styled_line() {
932 let mut handler = TuiStreamHandler::new(false);
934
935 handler.on_error("Something went wrong");
937
938 let lines = collect_lines(&handler);
940 assert_eq!(lines.len(), 1);
941 let line_text = lines[0].to_string();
942 assert!(
943 line_text.contains('\u{2717}'),
944 "Should contain X mark: {}",
945 line_text
946 );
947 assert!(
948 line_text.contains("Error"),
949 "Should contain 'Error': {}",
950 line_text
951 );
952 assert!(
953 line_text.contains("Something went wrong"),
954 "Should contain error message: {}",
955 line_text
956 );
957
958 let error_span = &lines[0].spans[1];
960 assert_eq!(
961 error_span.style.fg,
962 Some(Color::Red),
963 "Error line should have red foreground"
964 );
965 }
966
967 #[test]
968 fn long_lines_preserved_without_truncation() {
969 let mut handler = TuiStreamHandler::new(false);
971
972 let long_string: String = "a".repeat(500) + "\n";
974 handler.on_text(&long_string);
975
976 let lines = collect_lines(&handler);
979
980 let total_content: String = lines.iter().map(|l| l.to_string()).collect();
982 let a_count = total_content.chars().filter(|c| *c == 'a').count();
983 assert_eq!(
984 a_count, 500,
985 "All 500 'a' chars should be preserved. Got {}",
986 a_count
987 );
988
989 assert!(
991 !total_content.contains("..."),
992 "Content should not have ellipsis truncation"
993 );
994 }
995
996 #[test]
997 fn multiple_lines_in_single_text_call() {
998 let mut handler = TuiStreamHandler::new(false);
1000 handler.on_text("line1\nline2\nline3\n");
1001
1002 let lines = collect_lines(&handler);
1005 let full_text: String = lines
1006 .iter()
1007 .map(|l| l.to_string())
1008 .collect::<Vec<_>>()
1009 .join(" ");
1010 assert!(
1011 full_text.contains("line1")
1012 && full_text.contains("line2")
1013 && full_text.contains("line3"),
1014 "All lines should be present. Lines: {:?}",
1015 lines
1016 );
1017 }
1018
1019 #[test]
1020 fn termimad_parity_with_non_tui_mode() {
1021 let text = "Some text before:★ Insight ─────\nKey point here";
1024
1025 let mut handler = TuiStreamHandler::new(false);
1026 handler.on_text(text);
1027
1028 let lines = collect_lines(&handler);
1029
1030 assert!(
1033 lines.len() >= 2,
1034 "termimad should produce multiple lines. Got: {:?}",
1035 lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
1036 );
1037
1038 let full_text: String = lines.iter().map(|l| l.to_string()).collect();
1040 assert!(
1041 full_text.contains("★ Insight"),
1042 "Content should contain insight marker"
1043 );
1044 }
1045
1046 #[test]
1047 fn tool_call_flushes_text_buffer() {
1048 let mut handler = TuiStreamHandler::new(false);
1050 handler.on_text("partial text");
1051
1052 handler.on_tool_call("Read", "id", &json!({}));
1054
1055 let lines = collect_lines(&handler);
1057 assert_eq!(lines.len(), 2);
1058 assert_eq!(lines[0].to_string(), "partial text");
1059 assert!(lines[1].to_string().contains('\u{2699}'));
1060 }
1061
1062 #[test]
1063 fn interleaved_text_and_tools_preserves_chronological_order() {
1064 let mut handler = TuiStreamHandler::new(false);
1068
1069 handler.on_text("I'll start by reviewing the scratchpad.\n");
1071 handler.on_tool_call("Read", "id1", &json!({"file_path": "scratchpad.md"}));
1072 handler.on_text("I found the task. Now checking the code.\n");
1073 handler.on_tool_call("Read", "id2", &json!({"file_path": "main.rs"}));
1074 handler.on_text("Done reviewing.\n");
1075
1076 let lines = collect_lines(&handler);
1077
1078 let text1_idx = lines
1080 .iter()
1081 .position(|l| l.to_string().contains("reviewing the scratchpad"));
1082 let tool1_idx = lines
1083 .iter()
1084 .position(|l| l.to_string().contains("scratchpad.md"));
1085 let text2_idx = lines
1086 .iter()
1087 .position(|l| l.to_string().contains("checking the code"));
1088 let tool2_idx = lines.iter().position(|l| l.to_string().contains("main.rs"));
1089 let text3_idx = lines
1090 .iter()
1091 .position(|l| l.to_string().contains("Done reviewing"));
1092
1093 assert!(text1_idx.is_some(), "text1 should be present");
1095 assert!(tool1_idx.is_some(), "tool1 should be present");
1096 assert!(text2_idx.is_some(), "text2 should be present");
1097 assert!(tool2_idx.is_some(), "tool2 should be present");
1098 assert!(text3_idx.is_some(), "text3 should be present");
1099
1100 let text1_idx = text1_idx.unwrap();
1102 let tool1_idx = tool1_idx.unwrap();
1103 let text2_idx = text2_idx.unwrap();
1104 let tool2_idx = tool2_idx.unwrap();
1105 let text3_idx = text3_idx.unwrap();
1106
1107 assert!(
1108 text1_idx < tool1_idx,
1109 "text1 ({}) should come before tool1 ({}). Lines: {:?}",
1110 text1_idx,
1111 tool1_idx,
1112 lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
1113 );
1114 assert!(
1115 tool1_idx < text2_idx,
1116 "tool1 ({}) should come before text2 ({}). Lines: {:?}",
1117 tool1_idx,
1118 text2_idx,
1119 lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
1120 );
1121 assert!(
1122 text2_idx < tool2_idx,
1123 "text2 ({}) should come before tool2 ({}). Lines: {:?}",
1124 text2_idx,
1125 tool2_idx,
1126 lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
1127 );
1128 assert!(
1129 tool2_idx < text3_idx,
1130 "tool2 ({}) should come before text3 ({}). Lines: {:?}",
1131 tool2_idx,
1132 text3_idx,
1133 lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
1134 );
1135 }
1136
1137 #[test]
1138 fn on_complete_flushes_buffer_and_shows_summary() {
1139 let mut handler = TuiStreamHandler::new(true);
1141 handler.on_text("final output");
1142
1143 handler.on_complete(&SessionResult {
1145 duration_ms: 1500,
1146 total_cost_usd: 0.0025,
1147 num_turns: 3,
1148 is_error: false,
1149 ..Default::default()
1150 });
1151
1152 let lines = collect_lines(&handler);
1154 assert!(lines.len() >= 2, "Should have at least 2 lines");
1155 assert_eq!(lines[0].to_string(), "final output");
1156
1157 let summary = lines.last().unwrap().to_string();
1159 assert!(
1160 summary.contains("1500"),
1161 "Should contain duration: {}",
1162 summary
1163 );
1164 assert!(
1165 summary.contains("0.0025"),
1166 "Should contain cost: {}",
1167 summary
1168 );
1169 assert!(summary.contains('3'), "Should contain turns: {}", summary);
1170 }
1171
1172 #[test]
1173 fn on_complete_error_uses_red_style() {
1174 let mut handler = TuiStreamHandler::new(true);
1175 handler.on_complete(&SessionResult {
1176 duration_ms: 1000,
1177 total_cost_usd: 0.01,
1178 num_turns: 1,
1179 is_error: true,
1180 ..Default::default()
1181 });
1182
1183 let lines = collect_lines(&handler);
1184 assert!(!lines.is_empty());
1185
1186 let last_line = lines.last().unwrap();
1188 assert_eq!(
1189 last_line.spans[0].style.fg,
1190 Some(Color::Red),
1191 "Error completion should have red foreground"
1192 );
1193 }
1194
1195 #[test]
1196 fn on_complete_success_uses_green_style() {
1197 let mut handler = TuiStreamHandler::new(true);
1198 handler.on_complete(&SessionResult {
1199 duration_ms: 1000,
1200 total_cost_usd: 0.01,
1201 num_turns: 1,
1202 is_error: false,
1203 ..Default::default()
1204 });
1205
1206 let lines = collect_lines(&handler);
1207 assert!(!lines.is_empty());
1208
1209 let last_line = lines.last().unwrap();
1211 assert_eq!(
1212 last_line.spans[0].style.fg,
1213 Some(Color::Green),
1214 "Success completion should have green foreground"
1215 );
1216 }
1217
1218 #[test]
1219 fn tool_call_with_no_summary_shows_just_name() {
1220 let mut handler = TuiStreamHandler::new(false);
1221 handler.on_tool_call("UnknownTool", "id", &json!({}));
1222
1223 let lines = collect_lines(&handler);
1224 assert_eq!(lines.len(), 1);
1225 let line_text = lines[0].to_string();
1226 assert!(line_text.contains("UnknownTool"));
1227 }
1229
1230 #[test]
1231 fn get_lines_returns_clone_of_internal_lines() {
1232 let mut handler = TuiStreamHandler::new(false);
1233 handler.on_text("test\n");
1234
1235 let lines1 = handler.get_lines();
1236 let lines2 = handler.get_lines();
1237
1238 assert_eq!(lines1.len(), lines2.len());
1240 assert_eq!(lines1[0].to_string(), lines2[0].to_string());
1241 }
1242
1243 #[test]
1248 fn markdown_bold_text_renders_with_bold_modifier() {
1249 let mut handler = TuiStreamHandler::new(false);
1251
1252 handler.on_text("**important**\n");
1254
1255 let lines = collect_lines(&handler);
1257 assert!(!lines.is_empty(), "Should have at least one line");
1258
1259 let has_bold = lines.iter().any(|line| {
1261 line.spans.iter().any(|span| {
1262 span.content.contains("important")
1263 && span.style.add_modifier.contains(Modifier::BOLD)
1264 })
1265 });
1266 assert!(
1267 has_bold,
1268 "Should have bold 'important' span. Lines: {:?}",
1269 lines
1270 );
1271 }
1272
1273 #[test]
1274 fn markdown_italic_text_renders_with_italic_modifier() {
1275 let mut handler = TuiStreamHandler::new(false);
1277
1278 handler.on_text("*emphasized*\n");
1280
1281 let lines = collect_lines(&handler);
1283 assert!(!lines.is_empty(), "Should have at least one line");
1284
1285 let has_italic = lines.iter().any(|line| {
1286 line.spans.iter().any(|span| {
1287 span.content.contains("emphasized")
1288 && span.style.add_modifier.contains(Modifier::ITALIC)
1289 })
1290 });
1291 assert!(
1292 has_italic,
1293 "Should have italic 'emphasized' span. Lines: {:?}",
1294 lines
1295 );
1296 }
1297
1298 #[test]
1299 fn markdown_inline_code_renders_with_distinct_style() {
1300 let mut handler = TuiStreamHandler::new(false);
1302
1303 handler.on_text("`code`\n");
1305
1306 let lines = collect_lines(&handler);
1308 assert!(!lines.is_empty(), "Should have at least one line");
1309
1310 let has_code_style = lines.iter().any(|line| {
1311 line.spans.iter().any(|span| {
1312 span.content.contains("code")
1313 && (span.style.fg.is_some() || span.style.bg.is_some())
1314 })
1315 });
1316 assert!(
1317 has_code_style,
1318 "Should have styled 'code' span. Lines: {:?}",
1319 lines
1320 );
1321 }
1322
1323 #[test]
1324 fn markdown_header_renders_content() {
1325 let mut handler = TuiStreamHandler::new(false);
1327
1328 handler.on_text("## Section Title\n");
1330
1331 let lines = collect_lines(&handler);
1334 assert!(!lines.is_empty(), "Should have at least one line");
1335
1336 let has_header_content = lines.iter().any(|line| {
1337 line.spans
1338 .iter()
1339 .any(|span| span.content.contains("Section Title"))
1340 });
1341 assert!(
1342 has_header_content,
1343 "Should have header content. Lines: {:?}",
1344 lines
1345 );
1346 }
1347
1348 #[test]
1349 fn markdown_streaming_continuity_handles_split_formatting() {
1350 let mut handler = TuiStreamHandler::new(false);
1352
1353 handler.on_text("**bo");
1355 handler.on_text("ld**\n");
1356
1357 let lines = collect_lines(&handler);
1359
1360 let has_bold = lines.iter().any(|line| {
1361 line.spans
1362 .iter()
1363 .any(|span| span.style.add_modifier.contains(Modifier::BOLD))
1364 });
1365 assert!(
1366 has_bold,
1367 "Split markdown should still render bold. Lines: {:?}",
1368 lines
1369 );
1370 }
1371
1372 #[test]
1373 fn markdown_mixed_content_renders_correctly() {
1374 let mut handler = TuiStreamHandler::new(false);
1376
1377 handler.on_text("Normal **bold** and *italic* text\n");
1379
1380 let lines = collect_lines(&handler);
1382 assert!(!lines.is_empty(), "Should have at least one line");
1383
1384 let has_bold = lines.iter().any(|line| {
1385 line.spans.iter().any(|span| {
1386 span.content.contains("bold")
1387 && span.style.add_modifier.contains(Modifier::BOLD)
1388 })
1389 });
1390 let has_italic = lines.iter().any(|line| {
1391 line.spans.iter().any(|span| {
1392 span.content.contains("italic")
1393 && span.style.add_modifier.contains(Modifier::ITALIC)
1394 })
1395 });
1396
1397 assert!(has_bold, "Should have bold span. Lines: {:?}", lines);
1398 assert!(has_italic, "Should have italic span. Lines: {:?}", lines);
1399 }
1400
1401 #[test]
1402 fn markdown_tool_call_styling_preserved() {
1403 let mut handler = TuiStreamHandler::new(false);
1405
1406 handler.on_text("**bold**\n");
1408 handler.on_tool_call("Read", "id", &json!({"file_path": "src/main.rs"}));
1409
1410 let lines = collect_lines(&handler);
1412 assert!(lines.len() >= 2, "Should have at least 2 lines");
1413
1414 let tool_line = lines.last().unwrap();
1416 let has_blue = tool_line
1417 .spans
1418 .iter()
1419 .any(|span| span.style.fg == Some(Color::Blue));
1420 assert!(
1421 has_blue,
1422 "Tool call should preserve blue styling. Line: {:?}",
1423 tool_line
1424 );
1425 }
1426
1427 #[test]
1428 fn markdown_error_styling_preserved() {
1429 let mut handler = TuiStreamHandler::new(false);
1431
1432 handler.on_text("**bold**\n");
1434 handler.on_error("Something went wrong");
1435
1436 let lines = collect_lines(&handler);
1438 assert!(lines.len() >= 2, "Should have at least 2 lines");
1439
1440 let error_line = lines.last().unwrap();
1442 let has_red = error_line
1443 .spans
1444 .iter()
1445 .any(|span| span.style.fg == Some(Color::Red));
1446 assert!(
1447 has_red,
1448 "Error should preserve red styling. Line: {:?}",
1449 error_line
1450 );
1451 }
1452
1453 #[test]
1454 fn markdown_partial_formatting_does_not_crash() {
1455 let mut handler = TuiStreamHandler::new(false);
1457
1458 handler.on_text("**unclosed bold");
1460 handler.flush_text_buffer();
1461
1462 let lines = collect_lines(&handler);
1464 let _ = lines; }
1468
1469 #[test]
1474 fn ansi_green_text_produces_green_style() {
1475 let mut handler = TuiStreamHandler::new(false);
1477
1478 handler.on_text("\x1b[32mgreen text\x1b[0m\n");
1480
1481 let lines = collect_lines(&handler);
1483 assert!(!lines.is_empty(), "Should have at least one line");
1484
1485 let has_green = lines.iter().any(|line| {
1486 line.spans
1487 .iter()
1488 .any(|span| span.style.fg == Some(Color::Green))
1489 });
1490 assert!(
1491 has_green,
1492 "Should have green styled span. Lines: {:?}",
1493 lines
1494 );
1495 }
1496
1497 #[test]
1498 fn ansi_bold_text_produces_bold_modifier() {
1499 let mut handler = TuiStreamHandler::new(false);
1501
1502 handler.on_text("\x1b[1mbold text\x1b[0m\n");
1504
1505 let lines = collect_lines(&handler);
1507 assert!(!lines.is_empty(), "Should have at least one line");
1508
1509 let has_bold = lines.iter().any(|line| {
1510 line.spans
1511 .iter()
1512 .any(|span| span.style.add_modifier.contains(Modifier::BOLD))
1513 });
1514 assert!(has_bold, "Should have bold styled span. Lines: {:?}", lines);
1515 }
1516
1517 #[test]
1518 fn ansi_mixed_styles_preserved() {
1519 let mut handler = TuiStreamHandler::new(false);
1521
1522 handler.on_text("\x1b[1;32mbold green\x1b[0m normal\n");
1524
1525 let lines = collect_lines(&handler);
1527 assert!(!lines.is_empty(), "Should have at least one line");
1528
1529 let has_styled = lines.iter().any(|line| {
1531 line.spans.iter().any(|span| {
1532 span.style.fg == Some(Color::Green)
1533 || span.style.add_modifier.contains(Modifier::BOLD)
1534 })
1535 });
1536 assert!(
1537 has_styled,
1538 "Should have styled span with color or bold. Lines: {:?}",
1539 lines
1540 );
1541 }
1542
1543 #[test]
1544 fn ansi_plain_text_renders_without_crash() {
1545 let mut handler = TuiStreamHandler::new(false);
1547
1548 handler.on_text("plain text without ansi\n");
1550
1551 let lines = collect_lines(&handler);
1553 assert!(!lines.is_empty(), "Should have at least one line");
1554
1555 let full_text: String = lines.iter().map(|l| l.to_string()).collect();
1556 assert!(
1557 full_text.contains("plain text"),
1558 "Should contain the text. Lines: {:?}",
1559 lines
1560 );
1561 }
1562
1563 #[test]
1564 fn ansi_red_error_text_produces_red_style() {
1565 let mut handler = TuiStreamHandler::new(false);
1567
1568 handler.on_text("\x1b[31mError: something failed\x1b[0m\n");
1570
1571 let lines = collect_lines(&handler);
1573 assert!(!lines.is_empty(), "Should have at least one line");
1574
1575 let has_red = lines.iter().any(|line| {
1576 line.spans
1577 .iter()
1578 .any(|span| span.style.fg == Some(Color::Red))
1579 });
1580 assert!(has_red, "Should have red styled span. Lines: {:?}", lines);
1581 }
1582
1583 #[test]
1584 fn ansi_cyan_text_produces_cyan_style() {
1585 let mut handler = TuiStreamHandler::new(false);
1587
1588 handler.on_text("\x1b[36mcyan text\x1b[0m\n");
1590
1591 let lines = collect_lines(&handler);
1593 assert!(!lines.is_empty(), "Should have at least one line");
1594
1595 let has_cyan = lines.iter().any(|line| {
1596 line.spans
1597 .iter()
1598 .any(|span| span.style.fg == Some(Color::Cyan))
1599 });
1600 assert!(has_cyan, "Should have cyan styled span. Lines: {:?}", lines);
1601 }
1602
1603 #[test]
1604 fn ansi_underline_produces_underline_modifier() {
1605 let mut handler = TuiStreamHandler::new(false);
1607
1608 handler.on_text("\x1b[4munderlined\x1b[0m\n");
1610
1611 let lines = collect_lines(&handler);
1613 assert!(!lines.is_empty(), "Should have at least one line");
1614
1615 let has_underline = lines.iter().any(|line| {
1616 line.spans
1617 .iter()
1618 .any(|span| span.style.add_modifier.contains(Modifier::UNDERLINED))
1619 });
1620 assert!(
1621 has_underline,
1622 "Should have underlined styled span. Lines: {:?}",
1623 lines
1624 );
1625 }
1626
1627 #[test]
1632 fn format_tool_result_shell_extracts_stdout() {
1633 let output = r#"{"items":[{"Json":{"exit_status":"exit status: 0","stderr":"","stdout":"diff --git a/ralph-config.txt b/ralph-config.txt\nindex ba67887..7a529aa 100644\n--- a/ralph-config.txt\n+++ b/ralph-config.txt\n@@ -1,2 +1,2 @@\n-timeout: 30\n+timeout: 60\n retries: 3\n"}}]}"#;
1634 let result = format_tool_result(output);
1635 assert!(
1636 result.contains("diff --git"),
1637 "Should extract stdout, got: {}",
1638 result
1639 );
1640 assert!(
1641 !result.contains("exit_status"),
1642 "Should not contain JSON keys, got: {}",
1643 result
1644 );
1645 }
1646
1647 #[test]
1648 fn format_tool_result_shell_shows_stderr_on_failure() {
1649 let output = r#"{"items":[{"Json":{"exit_status":"exit status: 1","stderr":"fatal: not a git repository","stdout":""}}]}"#;
1650 let result = format_tool_result(output);
1651 assert!(
1652 result.contains("fatal: not a git repository"),
1653 "Should show stderr, got: {}",
1654 result
1655 );
1656 }
1657
1658 #[test]
1659 fn format_tool_result_glob_shows_file_paths() {
1660 let output = r#"{"items":[{"Json":{"filePaths":["/tmp/ralph-config.txt","/tmp/ralph-notes.md"],"totalFiles":2,"truncated":false}}]}"#;
1661 let result = format_tool_result(output);
1662 assert!(
1663 result.contains("ralph-config.txt"),
1664 "Should show filename, got: {}",
1665 result
1666 );
1667 assert!(
1668 result.contains("ralph-notes.md"),
1669 "Should show filename, got: {}",
1670 result
1671 );
1672 assert!(result.contains('2'), "Should show count, got: {}", result);
1673 }
1674
1675 #[test]
1676 fn format_tool_result_text_shows_content() {
1677 let output = r#"{"items":[{"Text":"timeout: 30\nretries: 3"}]}"#;
1678 let result = format_tool_result(output);
1679 assert!(
1680 result.contains("timeout: 30"),
1681 "Should show text content, got: {}",
1682 result
1683 );
1684 }
1685
1686 #[test]
1687 fn format_tool_result_empty_text_returns_empty() {
1688 let output = r#"{"items":[{"Text":""}]}"#;
1689 let result = format_tool_result(output);
1690 assert!(
1691 result.is_empty(),
1692 "Empty text should return empty, got: {}",
1693 result
1694 );
1695 }
1696
1697 #[test]
1698 fn format_tool_result_plain_string_passthrough() {
1699 let output = "just plain text output";
1700 let result = format_tool_result(output);
1701 assert_eq!(result, output, "Non-JSON should pass through unchanged");
1702 }
1703
1704 #[test]
1705 fn format_tool_result_compacts_short_multiline_text() {
1706 let output = "README.md\nnotes.txt\nsummary.md\n";
1707 let result = format_tool_result(output);
1708 assert_eq!(result, "README.md • notes.txt • summary.md");
1709 }
1710
1711 #[test]
1712 fn format_tool_result_summarizes_long_multiline_text() {
1713 let output = "first line\nsecond line\nthird line\nfourth line\nfifth line\n";
1714 let result = format_tool_result(output);
1715 assert_eq!(result, "first line • second line (+3 more lines)");
1716 }
1717
1718 #[test]
1719 fn format_tool_result_grep_shows_matches() {
1720 let output = r#"{"items":[{"Json":{"numFiles":1,"numMatches":1,"results":[{"count":1,"file":"/Users/test/.github/workflows/deploy.yml","matches":["197: sudo apt-get install -y libwebkit2"]}]}}]}"#;
1721 let result = format_tool_result(output);
1722 assert!(
1723 result.contains("deploy.yml"),
1724 "Should show filename, got: {}",
1725 result
1726 );
1727 assert!(
1728 result.contains("apt-get"),
1729 "Should show match content, got: {}",
1730 result
1731 );
1732 assert!(
1733 !result.contains("numFiles"),
1734 "Should not contain JSON keys, got: {}",
1735 result
1736 );
1737 }
1738
1739 #[test]
1740 fn format_tool_result_unknown_json_compacts() {
1741 let output = r#"{"items":[{"Json":{"someNewField":"value"}}]}"#;
1742 let result = format_tool_result(output);
1743 assert!(
1744 !result.contains("items"),
1745 "Should strip envelope, got: {}",
1746 result
1747 );
1748 assert!(
1749 result.contains("someNewField"),
1750 "Should contain inner json, got: {}",
1751 result
1752 );
1753 }
1754
1755 #[test]
1756 fn format_tool_result_shell_prefers_stderr_when_both_present() {
1757 let output = r#"{"items":[{"Json":{"exit_status":"exit status: 1","stderr":"error: something broke","stdout":"partial output"}}]}"#;
1758 let result = format_tool_result(output);
1759 assert!(
1760 result.contains("error: something broke"),
1761 "Should prefer stderr on failure, got: {}",
1762 result
1763 );
1764 }
1765
1766 #[test]
1767 fn ansi_multiline_preserves_colors() {
1768 let mut handler = TuiStreamHandler::new(false);
1770
1771 handler.on_text("\x1b[32mline 1 green\x1b[0m\n\x1b[31mline 2 red\x1b[0m\n");
1773
1774 let lines = collect_lines(&handler);
1776 assert!(lines.len() >= 2, "Should have at least two lines");
1777
1778 let has_green = lines.iter().any(|line| {
1779 line.spans
1780 .iter()
1781 .any(|span| span.style.fg == Some(Color::Green))
1782 });
1783 let has_red = lines.iter().any(|line| {
1784 line.spans
1785 .iter()
1786 .any(|span| span.style.fg == Some(Color::Red))
1787 });
1788
1789 assert!(has_green, "Should have green line. Lines: {:?}", lines);
1790 assert!(has_red, "Should have red line. Lines: {:?}", lines);
1791 }
1792 }
1793}
1794
1795#[cfg(test)]
1800mod ansi_detection_tests {
1801 use super::*;
1802
1803 #[test]
1804 fn contains_ansi_with_color_code() {
1805 assert!(contains_ansi("\x1b[32mgreen\x1b[0m"));
1806 }
1807
1808 #[test]
1809 fn contains_ansi_with_bold() {
1810 assert!(contains_ansi("\x1b[1mbold\x1b[0m"));
1811 }
1812
1813 #[test]
1814 fn contains_ansi_plain_text_returns_false() {
1815 assert!(!contains_ansi("hello world"));
1816 }
1817
1818 #[test]
1819 fn contains_ansi_markdown_returns_false() {
1820 assert!(!contains_ansi("**bold** and *italic*"));
1821 }
1822
1823 #[test]
1824 fn contains_ansi_empty_string_returns_false() {
1825 assert!(!contains_ansi(""));
1826 }
1827
1828 #[test]
1829 fn contains_ansi_with_escape_in_middle() {
1830 assert!(contains_ansi("prefix \x1b[31mred\x1b[0m suffix"));
1831 }
1832}