ralph_adapters/
stream_handler.rs

1//! Stream handler trait and implementations for processing Claude stream events.
2//!
3//! The `StreamHandler` trait abstracts over how stream events are displayed,
4//! allowing for different output strategies (console, quiet, TUI, etc.).
5
6use 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/// Detects if text contains ANSI escape sequences.
20///
21/// Checks for the common ANSI escape sequence prefix `\x1b[` (ESC + `[`)
22/// which is used for colors, formatting, and cursor control.
23#[inline]
24pub(crate) fn contains_ansi(text: &str) -> bool {
25    text.contains("\x1b[")
26}
27
28/// Session completion result data.
29#[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
37/// Renders streaming output with colors and markdown.
38pub struct PrettyStreamHandler {
39    stdout: io::Stdout,
40    verbose: bool,
41    /// Buffer for accumulating text before markdown rendering
42    text_buffer: String,
43    /// Skin for markdown rendering
44    skin: MadSkin,
45}
46
47impl PrettyStreamHandler {
48    /// Creates a new pretty handler.
49    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    /// Flush buffered text as rendered markdown.
59    fn flush_text_buffer(&mut self) {
60        if self.text_buffer.is_empty() {
61            return;
62        }
63        // Render markdown to string, then write
64        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        // Buffer text for markdown rendering
74        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        // Flush any remaining buffered text
101        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        // Flush any buffered text before showing tool call
123        self.flush_text_buffer();
124
125        // ⚙️ [ToolName]
126        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
142/// Handler for streaming output events from Claude.
143///
144/// Implementors receive events as Claude processes and can format/display
145/// them in various ways (console output, TUI updates, logging, etc.).
146pub trait StreamHandler: Send {
147    /// Called when Claude emits text.
148    fn on_text(&mut self, text: &str);
149
150    /// Called when Claude invokes a tool.
151    ///
152    /// # Arguments
153    /// * `name` - Tool name (e.g., "Read", "Bash", "Grep")
154    /// * `id` - Unique tool invocation ID
155    /// * `input` - Tool input parameters as JSON (file paths, commands, patterns, etc.)
156    fn on_tool_call(&mut self, name: &str, id: &str, input: &serde_json::Value);
157
158    /// Called when a tool returns results (verbose only).
159    fn on_tool_result(&mut self, id: &str, output: &str);
160
161    /// Called when an error occurs.
162    fn on_error(&mut self, error: &str);
163
164    /// Called when session completes (verbose only).
165    fn on_complete(&mut self, result: &SessionResult);
166}
167
168/// Writes streaming output to stdout/stderr.
169///
170/// In normal mode, displays assistant text and tool invocations.
171/// In verbose mode, also displays tool results and session summary.
172pub struct ConsoleStreamHandler {
173    verbose: bool,
174    stdout: io::Stdout,
175    stderr: io::Stderr,
176}
177
178impl ConsoleStreamHandler {
179    /// Creates a new console handler.
180    ///
181    /// # Arguments
182    /// * `verbose` - If true, shows tool results and session summary.
183    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        // Write to both stdout (inline) and stderr (for separation)
216        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
231/// Suppresses all streaming output (for CI/silent mode).
232pub 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
242/// Converts text to styled ratatui Lines, handling both ANSI and markdown.
243///
244/// When text contains ANSI escape sequences (e.g., from CLI tools like Kiro),
245/// uses `ansi_to_tui` to preserve colors and formatting. Otherwise, uses
246/// `termimad` to parse markdown (matching non-TUI mode behavior), then
247/// converts the ANSI output via `ansi_to_tui`.
248///
249/// Using `termimad` ensures parity between TUI and non-TUI modes, as both
250/// use the same markdown processing engine with the same line-breaking rules.
251fn text_to_lines(text: &str) -> Vec<Line<'static>> {
252    if text.is_empty() {
253        return Vec::new();
254    }
255
256    // Convert text to ANSI-styled string
257    // - If already contains ANSI: use as-is
258    // - If plain/markdown: process through termimad (matches non-TUI behavior)
259    let ansi_text = if contains_ansi(text) {
260        text.to_string()
261    } else {
262        // Use termimad to process markdown - this matches PrettyStreamHandler behavior
263        // and ensures consistent line-breaking between TUI and non-TUI modes
264        let skin = MadSkin::default();
265        skin.term_text(text).to_string()
266    };
267
268    // Parse ANSI codes to ratatui Text
269    match ansi_text.as_str().into_text() {
270        Ok(parsed_text) => {
271            // Convert Text to owned Lines
272            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            // Fallback: split on newlines and treat as plain text
287            text.split('\n')
288                .map(|line| Line::from(line.to_string()))
289                .collect()
290        }
291    }
292}
293
294/// A content block in the chronological stream.
295///
296/// Used to preserve ordering between text and non-text content (tool calls, errors).
297#[derive(Clone)]
298enum ContentBlock {
299    /// Markdown/ANSI text that was accumulated before being frozen
300    Text(String),
301    /// A single non-text line (tool call, error, completion summary, etc.)
302    NonText(Line<'static>),
303}
304
305/// Renders streaming output as ratatui Lines for TUI display.
306///
307/// This handler produces output visually equivalent to `PrettyStreamHandler`
308/// but stores it as `Line<'static>` objects for rendering in a ratatui-based TUI.
309///
310/// Text content is parsed as markdown, producing styled output for bold, italic,
311/// code, headers, etc. Tool calls and errors bypass markdown parsing to preserve
312/// their explicit styling.
313///
314/// **Chronological ordering**: When a tool call arrives, the current text buffer
315/// is "frozen" into a content block, preserving the order in which events arrived.
316pub struct TuiStreamHandler {
317    /// Buffer for accumulating current markdown text (not yet frozen)
318    current_text_buffer: String,
319    /// Chronological sequence of content blocks (frozen text + non-text events)
320    blocks: Vec<ContentBlock>,
321    /// Verbose mode (show tool results)
322    verbose: bool,
323    /// Collected output lines for rendering
324    lines: Arc<Mutex<Vec<Line<'static>>>>,
325}
326
327impl TuiStreamHandler {
328    /// Creates a new TUI handler.
329    ///
330    /// # Arguments
331    /// * `verbose` - If true, shows tool results and session summary.
332    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    /// Creates a TUI handler with shared lines storage.
342    ///
343    /// Use this to share output lines with the TUI application.
344    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    /// Returns a clone of the collected lines.
354    pub fn get_lines(&self) -> Vec<Line<'static>> {
355        self.lines.lock().unwrap().clone()
356    }
357
358    /// Flushes any buffered markdown text by re-parsing and updating lines.
359    pub fn flush_text_buffer(&mut self) {
360        self.update_lines();
361    }
362
363    /// Freezes the current text buffer into a content block.
364    ///
365    /// This is called when a non-text event (tool call, error) arrives,
366    /// ensuring that text before the event stays before it in the output.
367    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    /// Re-renders all content blocks and updates the shared lines.
376    ///
377    /// Iterates through frozen blocks in chronological order, then appends
378    /// any current (unfrozen) text buffer content. This preserves the
379    /// interleaved ordering of text and non-text content.
380    fn update_lines(&mut self) {
381        let mut all_lines = Vec::new();
382
383        // Render frozen blocks in chronological order
384        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        // Render current (unfrozen) text buffer for real-time updates
396        if !self.current_text_buffer.is_empty() {
397            all_lines.extend(text_to_lines(&self.current_text_buffer));
398        }
399
400        // Note: Long lines are NOT truncated here. The TUI's ContentPane widget
401        // handles soft-wrapping at viewport boundaries, preserving full content.
402
403        // Update shared lines
404        *self.lines.lock().unwrap() = all_lines;
405    }
406
407    /// Adds a non-text line (tool call, error, etc.) and updates display.
408    ///
409    /// First freezes any pending text buffer to preserve chronological order.
410    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        // Append text to current buffer
420        self.current_text_buffer.push_str(text);
421
422        // Re-parse and update lines on each text chunk
423        // This handles streaming markdown correctly
424        self.update_lines();
425    }
426
427    fn on_tool_call(&mut self, name: &str, _id: &str, input: &serde_json::Value) {
428        // Build spans: ⚙️ [ToolName] summary
429        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        // Flush any remaining buffered text
464        self.flush_text_buffer();
465
466        // Add blank line
467        self.add_non_text_line(Line::from(""));
468
469        // Add summary with color based on error status
470        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
484/// Extracts the most relevant field from tool input for display.
485///
486/// Returns a human-readable summary (file path, command, pattern, etc.) based on the tool type.
487/// Returns `None` for unknown tools or if the expected field is missing.
488fn 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
511/// Truncates a string to approximately `max_len` characters, adding "..." if truncated.
512///
513/// Uses `char_indices` to find a valid UTF-8 boundary, ensuring we never slice
514/// in the middle of a multi-byte character.
515fn truncate(s: &str, max_len: usize) -> String {
516    if s.chars().count() <= max_len {
517        s.to_string()
518    } else {
519        // Find the byte index of the max_len-th character
520        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        // These calls should not panic
540        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        // These should not show tool results
557        handler.on_text("Hello");
558        handler.on_tool_call("Read", "tool_1", &read_input);
559        handler.on_tool_result("tool_1", "output"); // Should be silent
560        handler.on_complete(&SessionResult {
561            duration_ms: 1000,
562            total_cost_usd: 0.01,
563            num_turns: 1,
564            is_error: false,
565        }); // Should be silent
566    }
567
568    #[test]
569    fn test_quiet_handler_is_silent() {
570        let mut handler = QuietStreamHandler;
571        let empty_input = json!({});
572
573        // All of these should be no-ops
574        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        // Arrow → is 3 bytes (U+2192: E2 86 92)
595        let with_arrows = "→→→→→→→→→→";
596        // Should truncate at character boundary, not byte boundary
597        assert_eq!(truncate(with_arrows, 5), "→→→→→...");
598
599        // Mixed ASCII and multi-byte
600        let mixed = "a→b→c→d→e";
601        assert_eq!(truncate(mixed, 5), "a→b→c...");
602
603        // Emoji (4-byte characters)
604        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); // 60 chars + "..."
636    }
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        // Read without file_path
661        assert_eq!(
662            format_tool_summary("Read", &json!({"wrong_field": "value"})),
663            None
664        );
665        // Bash without command
666        assert_eq!(format_tool_summary("Bash", &json!({})), None);
667    }
668
669    // ========================================================================
670    // TuiStreamHandler Tests
671    // ========================================================================
672
673    mod tui_stream_handler {
674        use super::*;
675        use ratatui::style::{Color, Modifier};
676
677        /// Helper to collect lines from TuiStreamHandler
678        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            // Given TuiStreamHandler
685            let mut handler = TuiStreamHandler::new(false);
686
687            // When on_text("hello\n") is called
688            handler.on_text("hello\n");
689
690            // Then a Line with "hello" content is produced
691            // Note: termimad (like non-TUI mode) doesn't create empty line for trailing \n
692            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            // Given TuiStreamHandler
704            let mut handler = TuiStreamHandler::new(false);
705
706            // When on_text("hel") then on_text("lo\n") is called
707            // Note: With markdown parsing, partial text is rendered immediately
708            // (markdown doesn't require newlines for paragraphs)
709            handler.on_text("hel");
710            handler.on_text("lo\n");
711
712            // Then the combined "hello" text is present
713            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            // Given TuiStreamHandler
725            let mut handler = TuiStreamHandler::new(false);
726
727            // When on_tool_call("Read", "id", &json!({"file_path": "src/main.rs"})) is called
728            handler.on_tool_call("Read", "tool_1", &json!({"file_path": "src/main.rs"}));
729
730            // Then a Line starting with "⚙️" and containing "Read" and file path is produced
731            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            // Given TuiStreamHandler with verbose=true
754            let mut handler = TuiStreamHandler::new(true);
755
756            // When on_tool_result(...) is called
757            handler.on_tool_result("tool_1", "file contents here");
758
759            // Then result content appears in output
760            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            // Given TuiStreamHandler with verbose=false
778            let mut handler = TuiStreamHandler::new(false);
779
780            // When on_tool_result(...) is called
781            handler.on_tool_result("tool_1", "file contents here");
782
783            // Then no output is produced
784            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            // Given TuiStreamHandler
794            let mut handler = TuiStreamHandler::new(false);
795
796            // When on_error("fail") is called
797            handler.on_error("Something went wrong");
798
799            // Then a Line with red foreground style is produced
800            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            // Check style is red
820            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            // Given TuiStreamHandler
831            let mut handler = TuiStreamHandler::new(false);
832
833            // When on_text() receives a very long string (500+ chars)
834            let long_string: String = "a".repeat(500) + "\n";
835            handler.on_text(&long_string);
836
837            // Then content is preserved fully (termimad may wrap at terminal width)
838            // Note: termimad wraps at ~80 chars by default, so 500 chars = multiple lines
839            let lines = collect_lines(&handler);
840
841            // Verify total content is preserved (all 500 'a's present)
842            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            // Should not have truncation ellipsis
851            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            // When text contains multiple newlines
860            let mut handler = TuiStreamHandler::new(false);
861            handler.on_text("line1\nline2\nline3\n");
862
863            // Then all text content is present
864            // Note: Markdown parsing may combine lines into paragraphs differently
865            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            // Verify that TUI mode (using termimad) matches non-TUI mode output
883            // This ensures the "★ Insight" box renders consistently in both modes
884            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            // termimad wraps after "★ Insight " putting dashes on their own line
892            // This matches PrettyStreamHandler (non-TUI) behavior
893            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            // Content should be preserved
900            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            // Given buffered text
910            let mut handler = TuiStreamHandler::new(false);
911            handler.on_text("partial text");
912
913            // When tool call arrives
914            handler.on_tool_call("Read", "id", &json!({}));
915
916            // Then buffered text is flushed as a line before tool call line
917            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            // Given: text1 → tool1 → text2 → tool2
926            // Expected output order: text1, tool1, text2, tool2
927            // NOT: text1 + text2, then tool1 + tool2 (the bug we fixed)
928            let mut handler = TuiStreamHandler::new(false);
929
930            // Simulate Claude's streaming output pattern
931            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            // Find indices of key content
940            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            // All content should be present
955            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            // Chronological order must be preserved
962            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            // Given buffered text and verbose mode
1001            let mut handler = TuiStreamHandler::new(true);
1002            handler.on_text("final output");
1003
1004            // When on_complete is called
1005            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            // Then buffer is flushed and summary line appears
1013            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            // Find summary line
1018            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            // Last line should be red styled for error
1046            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            // Last line should be green styled for success
1068            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            // Should not crash or show "null" for missing summary
1086        }
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            // Both should have same content
1097            assert_eq!(lines1.len(), lines2.len());
1098            assert_eq!(lines1[0].to_string(), lines2[0].to_string());
1099        }
1100
1101        // =====================================================================
1102        // Markdown Rendering Tests
1103        // =====================================================================
1104
1105        #[test]
1106        fn markdown_bold_text_renders_with_bold_modifier() {
1107            // Given TuiStreamHandler
1108            let mut handler = TuiStreamHandler::new(false);
1109
1110            // When on_text("**important**\n") is called
1111            handler.on_text("**important**\n");
1112
1113            // Then the text "important" appears with BOLD modifier
1114            let lines = collect_lines(&handler);
1115            assert!(!lines.is_empty(), "Should have at least one line");
1116
1117            // Find a span containing "important" and check it's bold
1118            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            // Given TuiStreamHandler
1134            let mut handler = TuiStreamHandler::new(false);
1135
1136            // When on_text("*emphasized*\n") is called
1137            handler.on_text("*emphasized*\n");
1138
1139            // Then the text "emphasized" appears with ITALIC modifier
1140            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            // Given TuiStreamHandler
1159            let mut handler = TuiStreamHandler::new(false);
1160
1161            // When on_text("`code`\n") is called
1162            handler.on_text("`code`\n");
1163
1164            // Then the text "code" appears with distinct styling (different from default)
1165            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            // Given TuiStreamHandler
1184            let mut handler = TuiStreamHandler::new(false);
1185
1186            // When on_text("## Section Title\n") is called
1187            handler.on_text("## Section Title\n");
1188
1189            // Then "Section Title" appears in the output
1190            // Note: termimad applies ANSI styling to headers
1191            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            // Given TuiStreamHandler
1209            let mut handler = TuiStreamHandler::new(false);
1210
1211            // When markdown arrives in chunks: "**bo" then "ld**\n"
1212            handler.on_text("**bo");
1213            handler.on_text("ld**\n");
1214
1215            // Then the complete "bold" text renders with BOLD modifier
1216            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            // Given TuiStreamHandler
1233            let mut handler = TuiStreamHandler::new(false);
1234
1235            // When on_text() receives mixed markdown
1236            handler.on_text("Normal **bold** and *italic* text\n");
1237
1238            // Then appropriate spans have appropriate styling
1239            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            // Given TuiStreamHandler with markdown text then tool call
1262            let mut handler = TuiStreamHandler::new(false);
1263
1264            // When markdown text followed by tool call
1265            handler.on_text("**bold**\n");
1266            handler.on_tool_call("Read", "id", &json!({"file_path": "src/main.rs"}));
1267
1268            // Then tool call still has blue styling
1269            let lines = collect_lines(&handler);
1270            assert!(lines.len() >= 2, "Should have at least 2 lines");
1271
1272            // Last line should be the tool call with blue color
1273            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            // Given TuiStreamHandler with markdown text then error
1288            let mut handler = TuiStreamHandler::new(false);
1289
1290            // When markdown text followed by error
1291            handler.on_text("**bold**\n");
1292            handler.on_error("Something went wrong");
1293
1294            // Then error still has red styling
1295            let lines = collect_lines(&handler);
1296            assert!(lines.len() >= 2, "Should have at least 2 lines");
1297
1298            // Last line should be the error with red color
1299            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            // Given TuiStreamHandler
1314            let mut handler = TuiStreamHandler::new(false);
1315
1316            // When incomplete markdown is sent and flushed
1317            handler.on_text("**unclosed bold");
1318            handler.flush_text_buffer();
1319
1320            // Then no panic occurs and text is present
1321            let lines = collect_lines(&handler);
1322            // Should have some output (either the partial text or nothing)
1323            // Main assertion is that we didn't panic
1324            let _ = lines; // Use the variable to avoid warning
1325        }
1326
1327        // =====================================================================
1328        // ANSI Color Preservation Tests
1329        // =====================================================================
1330
1331        #[test]
1332        fn ansi_green_text_produces_green_style() {
1333            // Given TuiStreamHandler
1334            let mut handler = TuiStreamHandler::new(false);
1335
1336            // When on_text receives ANSI green text
1337            handler.on_text("\x1b[32mgreen text\x1b[0m\n");
1338
1339            // Then the text should have green foreground color
1340            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            // Given TuiStreamHandler
1358            let mut handler = TuiStreamHandler::new(false);
1359
1360            // When on_text receives ANSI bold text
1361            handler.on_text("\x1b[1mbold text\x1b[0m\n");
1362
1363            // Then the text should have BOLD modifier
1364            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            // Given TuiStreamHandler
1378            let mut handler = TuiStreamHandler::new(false);
1379
1380            // When on_text receives mixed ANSI styles (bold + green)
1381            handler.on_text("\x1b[1;32mbold green\x1b[0m normal\n");
1382
1383            // Then the text should have appropriate styles
1384            let lines = collect_lines(&handler);
1385            assert!(!lines.is_empty(), "Should have at least one line");
1386
1387            // Check for green color
1388            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            // Given TuiStreamHandler
1404            let mut handler = TuiStreamHandler::new(false);
1405
1406            // When on_text receives plain text (no ANSI)
1407            handler.on_text("plain text without ansi\n");
1408
1409            // Then text renders normally (fallback to markdown)
1410            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            // Given TuiStreamHandler
1424            let mut handler = TuiStreamHandler::new(false);
1425
1426            // When on_text receives ANSI red text (like error output)
1427            handler.on_text("\x1b[31mError: something failed\x1b[0m\n");
1428
1429            // Then the text should have red foreground color
1430            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            // Given TuiStreamHandler
1444            let mut handler = TuiStreamHandler::new(false);
1445
1446            // When on_text receives ANSI cyan text
1447            handler.on_text("\x1b[36mcyan text\x1b[0m\n");
1448
1449            // Then the text should have cyan foreground color
1450            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            // Given TuiStreamHandler
1464            let mut handler = TuiStreamHandler::new(false);
1465
1466            // When on_text receives ANSI underlined text
1467            handler.on_text("\x1b[4munderlined\x1b[0m\n");
1468
1469            // Then the text should have UNDERLINED modifier
1470            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            // Given TuiStreamHandler
1488            let mut handler = TuiStreamHandler::new(false);
1489
1490            // When on_text receives multiple ANSI-colored lines
1491            handler.on_text("\x1b[32mline 1 green\x1b[0m\n\x1b[31mline 2 red\x1b[0m\n");
1492
1493            // Then both colors should be present
1494            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// =========================================================================
1515// ANSI Detection Tests (module-level)
1516// =========================================================================
1517
1518#[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}