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