Skip to main content

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        // Text is flushed when: tool calls arrive, on_complete is called, or on_error is called
75        // This works well for StreamJson backends (Claude) which have natural flush points
76        // Text format backends should use ConsoleStreamHandler for immediate output
77        self.text_buffer.push_str(text);
78    }
79
80    fn on_tool_result(&mut self, _id: &str, output: &str) {
81        if self.verbose {
82            let _ = self
83                .stdout
84                .queue(style::SetForegroundColor(Color::DarkGrey));
85            let _ = self
86                .stdout
87                .write(format!(" \u{2713} {}\n", truncate(output, 200)).as_bytes());
88            let _ = self.stdout.queue(style::ResetColor);
89            let _ = self.stdout.flush();
90        }
91    }
92
93    fn on_error(&mut self, error: &str) {
94        let _ = self.stdout.queue(style::SetForegroundColor(Color::Red));
95        let _ = self
96            .stdout
97            .write(format!("\n\u{2717} Error: {}\n", error).as_bytes());
98        let _ = self.stdout.queue(style::ResetColor);
99        let _ = self.stdout.flush();
100    }
101
102    fn on_complete(&mut self, result: &SessionResult) {
103        // Flush any remaining buffered text
104        self.flush_text_buffer();
105
106        let _ = self.stdout.write(b"\n");
107        let color = if result.is_error {
108            Color::Red
109        } else {
110            Color::Green
111        };
112        let _ = self.stdout.queue(style::SetForegroundColor(color));
113        let _ = self.stdout.write(
114            format!(
115                "Duration: {}ms | Cost: ${:.4} | Turns: {}\n",
116                result.duration_ms, result.total_cost_usd, result.num_turns
117            )
118            .as_bytes(),
119        );
120        let _ = self.stdout.queue(style::ResetColor);
121        let _ = self.stdout.flush();
122    }
123
124    fn on_tool_call(&mut self, name: &str, _id: &str, input: &serde_json::Value) {
125        // Flush any buffered text before showing tool call
126        self.flush_text_buffer();
127
128        // ⚙️ [ToolName]
129        let _ = self.stdout.queue(style::SetForegroundColor(Color::Blue));
130        let _ = self.stdout.write(format!("\u{2699} [{}]", name).as_bytes());
131
132        if let Some(summary) = format_tool_summary(name, input) {
133            let _ = self
134                .stdout
135                .queue(style::SetForegroundColor(Color::DarkGrey));
136            let _ = self.stdout.write(format!(" {}\n", summary).as_bytes());
137        } else {
138            let _ = self.stdout.write(b"\n");
139        }
140        let _ = self.stdout.queue(style::ResetColor);
141        let _ = self.stdout.flush();
142    }
143}
144
145/// Handler for streaming output events from Claude.
146///
147/// Implementors receive events as Claude processes and can format/display
148/// them in various ways (console output, TUI updates, logging, etc.).
149pub trait StreamHandler: Send {
150    /// Called when Claude emits text.
151    fn on_text(&mut self, text: &str);
152
153    /// Called when Claude invokes a tool.
154    ///
155    /// # Arguments
156    /// * `name` - Tool name (e.g., "Read", "Bash", "Grep")
157    /// * `id` - Unique tool invocation ID
158    /// * `input` - Tool input parameters as JSON (file paths, commands, patterns, etc.)
159    fn on_tool_call(&mut self, name: &str, id: &str, input: &serde_json::Value);
160
161    /// Called when a tool returns results (verbose only).
162    fn on_tool_result(&mut self, id: &str, output: &str);
163
164    /// Called when an error occurs.
165    fn on_error(&mut self, error: &str);
166
167    /// Called when session completes (verbose only).
168    fn on_complete(&mut self, result: &SessionResult);
169}
170
171/// Writes streaming output to stdout/stderr.
172///
173/// In normal mode, displays assistant text and tool invocations.
174/// In verbose mode, also displays tool results and session summary.
175pub struct ConsoleStreamHandler {
176    verbose: bool,
177    stdout: io::Stdout,
178    stderr: io::Stderr,
179}
180
181impl ConsoleStreamHandler {
182    /// Creates a new console handler.
183    ///
184    /// # Arguments
185    /// * `verbose` - If true, shows tool results and session summary.
186    pub fn new(verbose: bool) -> Self {
187        Self {
188            verbose,
189            stdout: io::stdout(),
190            stderr: io::stderr(),
191        }
192    }
193}
194
195impl StreamHandler for ConsoleStreamHandler {
196    fn on_text(&mut self, text: &str) {
197        let _ = writeln!(self.stdout, "Claude: {}", text);
198    }
199
200    fn on_tool_call(&mut self, name: &str, _id: &str, input: &serde_json::Value) {
201        match format_tool_summary(name, input) {
202            Some(summary) => {
203                let _ = writeln!(self.stdout, "[Tool] {}: {}", name, summary);
204            }
205            None => {
206                let _ = writeln!(self.stdout, "[Tool] {}", name);
207            }
208        }
209    }
210
211    fn on_tool_result(&mut self, _id: &str, output: &str) {
212        if self.verbose {
213            let _ = writeln!(self.stdout, "[Result] {}", truncate(output, 200));
214        }
215    }
216
217    fn on_error(&mut self, error: &str) {
218        // Write to both stdout (inline) and stderr (for separation)
219        let _ = writeln!(self.stdout, "[Error] {}", error);
220        let _ = writeln!(self.stderr, "[Error] {}", error);
221    }
222
223    fn on_complete(&mut self, result: &SessionResult) {
224        if self.verbose {
225            let _ = writeln!(
226                self.stdout,
227                "\n--- Session Complete ---\nDuration: {}ms | Cost: ${:.4} | Turns: {}",
228                result.duration_ms, result.total_cost_usd, result.num_turns
229            );
230        }
231    }
232}
233
234/// Suppresses all streaming output (for CI/silent mode).
235pub struct QuietStreamHandler;
236
237impl StreamHandler for QuietStreamHandler {
238    fn on_text(&mut self, _: &str) {}
239    fn on_tool_call(&mut self, _: &str, _: &str, _: &serde_json::Value) {}
240    fn on_tool_result(&mut self, _: &str, _: &str) {}
241    fn on_error(&mut self, _: &str) {}
242    fn on_complete(&mut self, _: &SessionResult) {}
243}
244
245/// Converts text to styled ratatui Lines, handling both ANSI and markdown.
246///
247/// When text contains ANSI escape sequences (e.g., from CLI tools like Kiro),
248/// uses `ansi_to_tui` to preserve colors and formatting. Otherwise, uses
249/// `termimad` to parse markdown (matching non-TUI mode behavior), then
250/// converts the ANSI output via `ansi_to_tui`.
251///
252/// Using `termimad` ensures parity between TUI and non-TUI modes, as both
253/// use the same markdown processing engine with the same line-breaking rules.
254fn text_to_lines(text: &str) -> Vec<Line<'static>> {
255    if text.is_empty() {
256        return Vec::new();
257    }
258
259    // Convert text to ANSI-styled string
260    // - If already contains ANSI: use as-is
261    // - If plain/markdown: process through termimad (matches non-TUI behavior)
262    let ansi_text = if contains_ansi(text) {
263        text.to_string()
264    } else {
265        // Use termimad to process markdown - this matches PrettyStreamHandler behavior
266        // and ensures consistent line-breaking between TUI and non-TUI modes
267        let skin = MadSkin::default();
268        skin.term_text(text).to_string()
269    };
270
271    // Parse ANSI codes to ratatui Text
272    match ansi_text.as_str().into_text() {
273        Ok(parsed_text) => {
274            // Convert Text to owned Lines
275            parsed_text
276                .lines
277                .into_iter()
278                .map(|line| {
279                    let owned_spans: Vec<Span<'static>> = line
280                        .spans
281                        .into_iter()
282                        .map(|span| Span::styled(span.content.into_owned(), span.style))
283                        .collect();
284                    Line::from(owned_spans)
285                })
286                .collect()
287        }
288        Err(_) => {
289            // Fallback: split on newlines and treat as plain text
290            text.split('\n')
291                .map(|line| Line::from(line.to_string()))
292                .collect()
293        }
294    }
295}
296
297/// A content block in the chronological stream.
298///
299/// Used to preserve ordering between text and non-text content (tool calls, errors).
300#[derive(Clone)]
301enum ContentBlock {
302    /// Markdown/ANSI text that was accumulated before being frozen
303    Text(String),
304    /// A single non-text line (tool call, error, completion summary, etc.)
305    NonText(Line<'static>),
306}
307
308/// Renders streaming output as ratatui Lines for TUI display.
309///
310/// This handler produces output visually equivalent to `PrettyStreamHandler`
311/// but stores it as `Line<'static>` objects for rendering in a ratatui-based TUI.
312///
313/// Text content is parsed as markdown, producing styled output for bold, italic,
314/// code, headers, etc. Tool calls and errors bypass markdown parsing to preserve
315/// their explicit styling.
316///
317/// **Chronological ordering**: When a tool call arrives, the current text buffer
318/// is "frozen" into a content block, preserving the order in which events arrived.
319pub struct TuiStreamHandler {
320    /// Buffer for accumulating current markdown text (not yet frozen)
321    current_text_buffer: String,
322    /// Chronological sequence of content blocks (frozen text + non-text events)
323    blocks: Vec<ContentBlock>,
324    /// Verbose mode (show tool results)
325    verbose: bool,
326    /// Collected output lines for rendering
327    lines: Arc<Mutex<Vec<Line<'static>>>>,
328}
329
330impl TuiStreamHandler {
331    /// Creates a new TUI handler.
332    ///
333    /// # Arguments
334    /// * `verbose` - If true, shows tool results and session summary.
335    pub fn new(verbose: bool) -> Self {
336        Self {
337            current_text_buffer: String::new(),
338            blocks: Vec::new(),
339            verbose,
340            lines: Arc::new(Mutex::new(Vec::new())),
341        }
342    }
343
344    /// Creates a TUI handler with shared lines storage.
345    ///
346    /// Use this to share output lines with the TUI application.
347    pub fn with_lines(verbose: bool, lines: Arc<Mutex<Vec<Line<'static>>>>) -> Self {
348        Self {
349            current_text_buffer: String::new(),
350            blocks: Vec::new(),
351            verbose,
352            lines,
353        }
354    }
355
356    /// Returns a clone of the collected lines.
357    pub fn get_lines(&self) -> Vec<Line<'static>> {
358        self.lines.lock().unwrap().clone()
359    }
360
361    /// Flushes any buffered markdown text by re-parsing and updating lines.
362    pub fn flush_text_buffer(&mut self) {
363        self.update_lines();
364    }
365
366    /// Freezes the current text buffer into a content block.
367    ///
368    /// This is called when a non-text event (tool call, error) arrives,
369    /// ensuring that text before the event stays before it in the output.
370    fn freeze_current_text(&mut self) {
371        if !self.current_text_buffer.is_empty() {
372            self.blocks
373                .push(ContentBlock::Text(self.current_text_buffer.clone()));
374            self.current_text_buffer.clear();
375        }
376    }
377
378    /// Re-renders all content blocks and updates the shared lines.
379    ///
380    /// Iterates through frozen blocks in chronological order, then appends
381    /// any current (unfrozen) text buffer content. This preserves the
382    /// interleaved ordering of text and non-text content.
383    fn update_lines(&mut self) {
384        let mut all_lines = Vec::new();
385
386        // Render frozen blocks in chronological order
387        for block in &self.blocks {
388            match block {
389                ContentBlock::Text(text) => {
390                    all_lines.extend(text_to_lines(text));
391                }
392                ContentBlock::NonText(line) => {
393                    all_lines.push(line.clone());
394                }
395            }
396        }
397
398        // Render current (unfrozen) text buffer for real-time updates
399        if !self.current_text_buffer.is_empty() {
400            all_lines.extend(text_to_lines(&self.current_text_buffer));
401        }
402
403        // Note: Long lines are NOT truncated here. The TUI's ContentPane widget
404        // handles soft-wrapping at viewport boundaries, preserving full content.
405
406        // Update shared lines
407        *self.lines.lock().unwrap() = all_lines;
408    }
409
410    /// Adds a non-text line (tool call, error, etc.) and updates display.
411    ///
412    /// First freezes any pending text buffer to preserve chronological order.
413    fn add_non_text_line(&mut self, line: Line<'static>) {
414        self.freeze_current_text();
415        self.blocks.push(ContentBlock::NonText(line));
416        self.update_lines();
417    }
418}
419
420impl StreamHandler for TuiStreamHandler {
421    fn on_text(&mut self, text: &str) {
422        // Append text to current buffer
423        self.current_text_buffer.push_str(text);
424
425        // Re-parse and update lines on each text chunk
426        // This handles streaming markdown correctly
427        self.update_lines();
428    }
429
430    fn on_tool_call(&mut self, name: &str, _id: &str, input: &serde_json::Value) {
431        // Build spans: ⚙️ [ToolName] summary
432        let mut spans = vec![Span::styled(
433            format!("\u{2699} [{}]", name),
434            Style::default().fg(RatatuiColor::Blue),
435        )];
436
437        if let Some(summary) = format_tool_summary(name, input) {
438            spans.push(Span::styled(
439                format!(" {}", summary),
440                Style::default().fg(RatatuiColor::DarkGray),
441            ));
442        }
443
444        self.add_non_text_line(Line::from(spans));
445    }
446
447    fn on_tool_result(&mut self, _id: &str, output: &str) {
448        if self.verbose {
449            let line = Line::from(Span::styled(
450                format!(" \u{2713} {}", truncate(output, 200)),
451                Style::default().fg(RatatuiColor::DarkGray),
452            ));
453            self.add_non_text_line(line);
454        }
455    }
456
457    fn on_error(&mut self, error: &str) {
458        let line = Line::from(Span::styled(
459            format!("\n\u{2717} Error: {}", error),
460            Style::default().fg(RatatuiColor::Red),
461        ));
462        self.add_non_text_line(line);
463    }
464
465    fn on_complete(&mut self, result: &SessionResult) {
466        // Flush any remaining buffered text
467        self.flush_text_buffer();
468
469        // Add blank line
470        self.add_non_text_line(Line::from(""));
471
472        // Add summary with color based on error status
473        let color = if result.is_error {
474            RatatuiColor::Red
475        } else {
476            RatatuiColor::Green
477        };
478        let summary = format!(
479            "Duration: {}ms | Cost: ${:.4} | Turns: {}",
480            result.duration_ms, result.total_cost_usd, result.num_turns
481        );
482        let line = Line::from(Span::styled(summary, Style::default().fg(color)));
483        self.add_non_text_line(line);
484    }
485}
486
487/// Extracts the most relevant field from tool input for display.
488///
489/// Returns a human-readable summary (file path, command, pattern, etc.) based on the tool type.
490/// Returns `None` for unknown tools or if the expected field is missing.
491fn format_tool_summary(name: &str, input: &serde_json::Value) -> Option<String> {
492    match name {
493        "Read" | "Edit" | "Write" => input.get("file_path")?.as_str().map(|s| s.to_string()),
494        "Bash" => {
495            let cmd = input.get("command")?.as_str()?;
496            Some(truncate(cmd, 60))
497        }
498        "Grep" => input.get("pattern")?.as_str().map(|s| s.to_string()),
499        "Glob" => input.get("pattern")?.as_str().map(|s| s.to_string()),
500        "Task" => input.get("description")?.as_str().map(|s| s.to_string()),
501        "WebFetch" => input.get("url")?.as_str().map(|s| s.to_string()),
502        "WebSearch" => input.get("query")?.as_str().map(|s| s.to_string()),
503        "LSP" => {
504            let op = input.get("operation")?.as_str()?;
505            let file = input.get("filePath")?.as_str()?;
506            Some(format!("{} @ {}", op, file))
507        }
508        "NotebookEdit" => input.get("notebook_path")?.as_str().map(|s| s.to_string()),
509        "TodoWrite" => Some("updating todo list".to_string()),
510        _ => None,
511    }
512}
513
514/// Truncates a string to approximately `max_len` characters, adding "..." if truncated.
515///
516/// Uses `char_indices` to find a valid UTF-8 boundary, ensuring we never slice
517/// in the middle of a multi-byte character.
518fn truncate(s: &str, max_len: usize) -> String {
519    if s.chars().count() <= max_len {
520        s.to_string()
521    } else {
522        // Find the byte index of the max_len-th character
523        let byte_idx = s
524            .char_indices()
525            .nth(max_len)
526            .map(|(idx, _)| idx)
527            .unwrap_or(s.len());
528        format!("{}...", &s[..byte_idx])
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use serde_json::json;
536
537    #[test]
538    fn test_console_handler_verbose_shows_results() {
539        let mut handler = ConsoleStreamHandler::new(true);
540        let bash_input = json!({"command": "ls -la"});
541
542        // These calls should not panic
543        handler.on_text("Hello");
544        handler.on_tool_call("Bash", "tool_1", &bash_input);
545        handler.on_tool_result("tool_1", "output");
546        handler.on_complete(&SessionResult {
547            duration_ms: 1000,
548            total_cost_usd: 0.01,
549            num_turns: 1,
550            is_error: false,
551        });
552    }
553
554    #[test]
555    fn test_console_handler_normal_skips_results() {
556        let mut handler = ConsoleStreamHandler::new(false);
557        let read_input = json!({"file_path": "src/main.rs"});
558
559        // These should not show tool results
560        handler.on_text("Hello");
561        handler.on_tool_call("Read", "tool_1", &read_input);
562        handler.on_tool_result("tool_1", "output"); // Should be silent
563        handler.on_complete(&SessionResult {
564            duration_ms: 1000,
565            total_cost_usd: 0.01,
566            num_turns: 1,
567            is_error: false,
568        }); // Should be silent
569    }
570
571    #[test]
572    fn test_quiet_handler_is_silent() {
573        let mut handler = QuietStreamHandler;
574        let empty_input = json!({});
575
576        // All of these should be no-ops
577        handler.on_text("Hello");
578        handler.on_tool_call("Read", "tool_1", &empty_input);
579        handler.on_tool_result("tool_1", "output");
580        handler.on_error("Something went wrong");
581        handler.on_complete(&SessionResult {
582            duration_ms: 1000,
583            total_cost_usd: 0.01,
584            num_turns: 1,
585            is_error: false,
586        });
587    }
588
589    #[test]
590    fn test_truncate_helper() {
591        assert_eq!(truncate("short", 10), "short");
592        assert_eq!(truncate("this is a long string", 10), "this is a ...");
593    }
594
595    #[test]
596    fn test_truncate_utf8_boundaries() {
597        // Arrow → is 3 bytes (U+2192: E2 86 92)
598        let with_arrows = "→→→→→→→→→→";
599        // Should truncate at character boundary, not byte boundary
600        assert_eq!(truncate(with_arrows, 5), "→→→→→...");
601
602        // Mixed ASCII and multi-byte
603        let mixed = "a→b→c→d→e";
604        assert_eq!(truncate(mixed, 5), "a→b→c...");
605
606        // Emoji (4-byte characters)
607        let emoji = "🎉🎊🎁🎈🎄";
608        assert_eq!(truncate(emoji, 3), "🎉🎊🎁...");
609    }
610
611    #[test]
612    fn test_format_tool_summary_file_tools() {
613        assert_eq!(
614            format_tool_summary("Read", &json!({"file_path": "src/main.rs"})),
615            Some("src/main.rs".to_string())
616        );
617        assert_eq!(
618            format_tool_summary("Edit", &json!({"file_path": "/path/to/file.txt"})),
619            Some("/path/to/file.txt".to_string())
620        );
621        assert_eq!(
622            format_tool_summary("Write", &json!({"file_path": "output.json"})),
623            Some("output.json".to_string())
624        );
625    }
626
627    #[test]
628    fn test_format_tool_summary_bash_truncates() {
629        let short_cmd = json!({"command": "ls -la"});
630        assert_eq!(
631            format_tool_summary("Bash", &short_cmd),
632            Some("ls -la".to_string())
633        );
634
635        let long_cmd = json!({"command": "this is a very long command that should be truncated because it exceeds sixty characters"});
636        let result = format_tool_summary("Bash", &long_cmd).unwrap();
637        assert!(result.ends_with("..."));
638        assert!(result.len() <= 70); // 60 chars + "..."
639    }
640
641    #[test]
642    fn test_format_tool_summary_search_tools() {
643        assert_eq!(
644            format_tool_summary("Grep", &json!({"pattern": "TODO"})),
645            Some("TODO".to_string())
646        );
647        assert_eq!(
648            format_tool_summary("Glob", &json!({"pattern": "**/*.rs"})),
649            Some("**/*.rs".to_string())
650        );
651    }
652
653    #[test]
654    fn test_format_tool_summary_unknown_tool_returns_none() {
655        assert_eq!(
656            format_tool_summary("UnknownTool", &json!({"some_field": "value"})),
657            None
658        );
659    }
660
661    #[test]
662    fn test_format_tool_summary_missing_field_returns_none() {
663        // Read without file_path
664        assert_eq!(
665            format_tool_summary("Read", &json!({"wrong_field": "value"})),
666            None
667        );
668        // Bash without command
669        assert_eq!(format_tool_summary("Bash", &json!({})), None);
670    }
671
672    // ========================================================================
673    // TuiStreamHandler Tests
674    // ========================================================================
675
676    mod tui_stream_handler {
677        use super::*;
678        use ratatui::style::{Color, Modifier};
679
680        /// Helper to collect lines from TuiStreamHandler
681        fn collect_lines(handler: &TuiStreamHandler) -> Vec<ratatui::text::Line<'static>> {
682            handler.lines.lock().unwrap().clone()
683        }
684
685        #[test]
686        fn text_creates_line_on_newline() {
687            // Given TuiStreamHandler
688            let mut handler = TuiStreamHandler::new(false);
689
690            // When on_text("hello\n") is called
691            handler.on_text("hello\n");
692
693            // Then a Line with "hello" content is produced
694            // Note: termimad (like non-TUI mode) doesn't create empty line for trailing \n
695            let lines = collect_lines(&handler);
696            assert_eq!(
697                lines.len(),
698                1,
699                "termimad doesn't create trailing empty line"
700            );
701            assert_eq!(lines[0].to_string(), "hello");
702        }
703
704        #[test]
705        fn partial_text_buffering() {
706            // Given TuiStreamHandler
707            let mut handler = TuiStreamHandler::new(false);
708
709            // When on_text("hel") then on_text("lo\n") is called
710            // Note: With markdown parsing, partial text is rendered immediately
711            // (markdown doesn't require newlines for paragraphs)
712            handler.on_text("hel");
713            handler.on_text("lo\n");
714
715            // Then the combined "hello" text is present
716            let lines = collect_lines(&handler);
717            let full_text: String = lines.iter().map(|l| l.to_string()).collect();
718            assert!(
719                full_text.contains("hello"),
720                "Combined text should contain 'hello'. Lines: {:?}",
721                lines
722            );
723        }
724
725        #[test]
726        fn tool_call_produces_formatted_line() {
727            // Given TuiStreamHandler
728            let mut handler = TuiStreamHandler::new(false);
729
730            // When on_tool_call("Read", "id", &json!({"file_path": "src/main.rs"})) is called
731            handler.on_tool_call("Read", "tool_1", &json!({"file_path": "src/main.rs"}));
732
733            // Then a Line starting with "⚙️" and containing "Read" and file path is produced
734            let lines = collect_lines(&handler);
735            assert_eq!(lines.len(), 1);
736            let line_text = lines[0].to_string();
737            assert!(
738                line_text.contains('\u{2699}'),
739                "Should contain gear emoji: {}",
740                line_text
741            );
742            assert!(
743                line_text.contains("Read"),
744                "Should contain tool name: {}",
745                line_text
746            );
747            assert!(
748                line_text.contains("src/main.rs"),
749                "Should contain file path: {}",
750                line_text
751            );
752        }
753
754        #[test]
755        fn tool_result_verbose_shows_content() {
756            // Given TuiStreamHandler with verbose=true
757            let mut handler = TuiStreamHandler::new(true);
758
759            // When on_tool_result(...) is called
760            handler.on_tool_result("tool_1", "file contents here");
761
762            // Then result content appears in output
763            let lines = collect_lines(&handler);
764            assert_eq!(lines.len(), 1);
765            let line_text = lines[0].to_string();
766            assert!(
767                line_text.contains('\u{2713}'),
768                "Should contain checkmark: {}",
769                line_text
770            );
771            assert!(
772                line_text.contains("file contents here"),
773                "Should contain result content: {}",
774                line_text
775            );
776        }
777
778        #[test]
779        fn tool_result_quiet_is_silent() {
780            // Given TuiStreamHandler with verbose=false
781            let mut handler = TuiStreamHandler::new(false);
782
783            // When on_tool_result(...) is called
784            handler.on_tool_result("tool_1", "file contents here");
785
786            // Then no output is produced
787            let lines = collect_lines(&handler);
788            assert!(
789                lines.is_empty(),
790                "verbose=false should not produce tool result output"
791            );
792        }
793
794        #[test]
795        fn error_produces_red_styled_line() {
796            // Given TuiStreamHandler
797            let mut handler = TuiStreamHandler::new(false);
798
799            // When on_error("fail") is called
800            handler.on_error("Something went wrong");
801
802            // Then a Line with red foreground style is produced
803            let lines = collect_lines(&handler);
804            assert_eq!(lines.len(), 1);
805            let line_text = lines[0].to_string();
806            assert!(
807                line_text.contains('\u{2717}'),
808                "Should contain X mark: {}",
809                line_text
810            );
811            assert!(
812                line_text.contains("Error"),
813                "Should contain 'Error': {}",
814                line_text
815            );
816            assert!(
817                line_text.contains("Something went wrong"),
818                "Should contain error message: {}",
819                line_text
820            );
821
822            // Check style is red
823            let first_span = &lines[0].spans[0];
824            assert_eq!(
825                first_span.style.fg,
826                Some(Color::Red),
827                "Error line should have red foreground"
828            );
829        }
830
831        #[test]
832        fn long_lines_preserved_without_truncation() {
833            // Given TuiStreamHandler
834            let mut handler = TuiStreamHandler::new(false);
835
836            // When on_text() receives a very long string (500+ chars)
837            let long_string: String = "a".repeat(500) + "\n";
838            handler.on_text(&long_string);
839
840            // Then content is preserved fully (termimad may wrap at terminal width)
841            // Note: termimad wraps at ~80 chars by default, so 500 chars = multiple lines
842            let lines = collect_lines(&handler);
843
844            // Verify total content is preserved (all 500 'a's present)
845            let total_content: String = lines.iter().map(|l| l.to_string()).collect();
846            let a_count = total_content.chars().filter(|c| *c == 'a').count();
847            assert_eq!(
848                a_count, 500,
849                "All 500 'a' chars should be preserved. Got {}",
850                a_count
851            );
852
853            // Should not have truncation ellipsis
854            assert!(
855                !total_content.contains("..."),
856                "Content should not have ellipsis truncation"
857            );
858        }
859
860        #[test]
861        fn multiple_lines_in_single_text_call() {
862            // When text contains multiple newlines
863            let mut handler = TuiStreamHandler::new(false);
864            handler.on_text("line1\nline2\nline3\n");
865
866            // Then all text content is present
867            // Note: Markdown parsing may combine lines into paragraphs differently
868            let lines = collect_lines(&handler);
869            let full_text: String = lines
870                .iter()
871                .map(|l| l.to_string())
872                .collect::<Vec<_>>()
873                .join(" ");
874            assert!(
875                full_text.contains("line1")
876                    && full_text.contains("line2")
877                    && full_text.contains("line3"),
878                "All lines should be present. Lines: {:?}",
879                lines
880            );
881        }
882
883        #[test]
884        fn termimad_parity_with_non_tui_mode() {
885            // Verify that TUI mode (using termimad) matches non-TUI mode output
886            // This ensures the "★ Insight" box renders consistently in both modes
887            let text = "Some text before:★ Insight ─────\nKey point here";
888
889            let mut handler = TuiStreamHandler::new(false);
890            handler.on_text(text);
891
892            let lines = collect_lines(&handler);
893
894            // termimad wraps after "★ Insight " putting dashes on their own line
895            // This matches PrettyStreamHandler (non-TUI) behavior
896            assert!(
897                lines.len() >= 2,
898                "termimad should produce multiple lines. Got: {:?}",
899                lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
900            );
901
902            // Content should be preserved
903            let full_text: String = lines.iter().map(|l| l.to_string()).collect();
904            assert!(
905                full_text.contains("★ Insight"),
906                "Content should contain insight marker"
907            );
908        }
909
910        #[test]
911        fn tool_call_flushes_text_buffer() {
912            // Given buffered text
913            let mut handler = TuiStreamHandler::new(false);
914            handler.on_text("partial text");
915
916            // When tool call arrives
917            handler.on_tool_call("Read", "id", &json!({}));
918
919            // Then buffered text is flushed as a line before tool call line
920            let lines = collect_lines(&handler);
921            assert_eq!(lines.len(), 2);
922            assert_eq!(lines[0].to_string(), "partial text");
923            assert!(lines[1].to_string().contains('\u{2699}'));
924        }
925
926        #[test]
927        fn interleaved_text_and_tools_preserves_chronological_order() {
928            // Given: text1 → tool1 → text2 → tool2
929            // Expected output order: text1, tool1, text2, tool2
930            // NOT: text1 + text2, then tool1 + tool2 (the bug we fixed)
931            let mut handler = TuiStreamHandler::new(false);
932
933            // Simulate Claude's streaming output pattern
934            handler.on_text("I'll start by reviewing the scratchpad.\n");
935            handler.on_tool_call("Read", "id1", &json!({"file_path": "scratchpad.md"}));
936            handler.on_text("I found the task. Now checking the code.\n");
937            handler.on_tool_call("Read", "id2", &json!({"file_path": "main.rs"}));
938            handler.on_text("Done reviewing.\n");
939
940            let lines = collect_lines(&handler);
941
942            // Find indices of key content
943            let text1_idx = lines
944                .iter()
945                .position(|l| l.to_string().contains("reviewing the scratchpad"));
946            let tool1_idx = lines
947                .iter()
948                .position(|l| l.to_string().contains("scratchpad.md"));
949            let text2_idx = lines
950                .iter()
951                .position(|l| l.to_string().contains("checking the code"));
952            let tool2_idx = lines.iter().position(|l| l.to_string().contains("main.rs"));
953            let text3_idx = lines
954                .iter()
955                .position(|l| l.to_string().contains("Done reviewing"));
956
957            // All content should be present
958            assert!(text1_idx.is_some(), "text1 should be present");
959            assert!(tool1_idx.is_some(), "tool1 should be present");
960            assert!(text2_idx.is_some(), "text2 should be present");
961            assert!(tool2_idx.is_some(), "tool2 should be present");
962            assert!(text3_idx.is_some(), "text3 should be present");
963
964            // Chronological order must be preserved
965            let text1_idx = text1_idx.unwrap();
966            let tool1_idx = tool1_idx.unwrap();
967            let text2_idx = text2_idx.unwrap();
968            let tool2_idx = tool2_idx.unwrap();
969            let text3_idx = text3_idx.unwrap();
970
971            assert!(
972                text1_idx < tool1_idx,
973                "text1 ({}) should come before tool1 ({}). Lines: {:?}",
974                text1_idx,
975                tool1_idx,
976                lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
977            );
978            assert!(
979                tool1_idx < text2_idx,
980                "tool1 ({}) should come before text2 ({}). Lines: {:?}",
981                tool1_idx,
982                text2_idx,
983                lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
984            );
985            assert!(
986                text2_idx < tool2_idx,
987                "text2 ({}) should come before tool2 ({}). Lines: {:?}",
988                text2_idx,
989                tool2_idx,
990                lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
991            );
992            assert!(
993                tool2_idx < text3_idx,
994                "tool2 ({}) should come before text3 ({}). Lines: {:?}",
995                tool2_idx,
996                text3_idx,
997                lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
998            );
999        }
1000
1001        #[test]
1002        fn on_complete_flushes_buffer_and_shows_summary() {
1003            // Given buffered text and verbose mode
1004            let mut handler = TuiStreamHandler::new(true);
1005            handler.on_text("final output");
1006
1007            // When on_complete is called
1008            handler.on_complete(&SessionResult {
1009                duration_ms: 1500,
1010                total_cost_usd: 0.0025,
1011                num_turns: 3,
1012                is_error: false,
1013            });
1014
1015            // Then buffer is flushed and summary line appears
1016            let lines = collect_lines(&handler);
1017            assert!(lines.len() >= 2, "Should have at least 2 lines");
1018            assert_eq!(lines[0].to_string(), "final output");
1019
1020            // Find summary line
1021            let summary = lines.last().unwrap().to_string();
1022            assert!(
1023                summary.contains("1500"),
1024                "Should contain duration: {}",
1025                summary
1026            );
1027            assert!(
1028                summary.contains("0.0025"),
1029                "Should contain cost: {}",
1030                summary
1031            );
1032            assert!(summary.contains('3'), "Should contain turns: {}", summary);
1033        }
1034
1035        #[test]
1036        fn on_complete_error_uses_red_style() {
1037            let mut handler = TuiStreamHandler::new(true);
1038            handler.on_complete(&SessionResult {
1039                duration_ms: 1000,
1040                total_cost_usd: 0.01,
1041                num_turns: 1,
1042                is_error: true,
1043            });
1044
1045            let lines = collect_lines(&handler);
1046            assert!(!lines.is_empty());
1047
1048            // Last line should be red styled for error
1049            let last_line = lines.last().unwrap();
1050            assert_eq!(
1051                last_line.spans[0].style.fg,
1052                Some(Color::Red),
1053                "Error completion should have red foreground"
1054            );
1055        }
1056
1057        #[test]
1058        fn on_complete_success_uses_green_style() {
1059            let mut handler = TuiStreamHandler::new(true);
1060            handler.on_complete(&SessionResult {
1061                duration_ms: 1000,
1062                total_cost_usd: 0.01,
1063                num_turns: 1,
1064                is_error: false,
1065            });
1066
1067            let lines = collect_lines(&handler);
1068            assert!(!lines.is_empty());
1069
1070            // Last line should be green styled for success
1071            let last_line = lines.last().unwrap();
1072            assert_eq!(
1073                last_line.spans[0].style.fg,
1074                Some(Color::Green),
1075                "Success completion should have green foreground"
1076            );
1077        }
1078
1079        #[test]
1080        fn tool_call_with_no_summary_shows_just_name() {
1081            let mut handler = TuiStreamHandler::new(false);
1082            handler.on_tool_call("UnknownTool", "id", &json!({}));
1083
1084            let lines = collect_lines(&handler);
1085            assert_eq!(lines.len(), 1);
1086            let line_text = lines[0].to_string();
1087            assert!(line_text.contains("UnknownTool"));
1088            // Should not crash or show "null" for missing summary
1089        }
1090
1091        #[test]
1092        fn get_lines_returns_clone_of_internal_lines() {
1093            let mut handler = TuiStreamHandler::new(false);
1094            handler.on_text("test\n");
1095
1096            let lines1 = handler.get_lines();
1097            let lines2 = handler.get_lines();
1098
1099            // Both should have same content
1100            assert_eq!(lines1.len(), lines2.len());
1101            assert_eq!(lines1[0].to_string(), lines2[0].to_string());
1102        }
1103
1104        // =====================================================================
1105        // Markdown Rendering Tests
1106        // =====================================================================
1107
1108        #[test]
1109        fn markdown_bold_text_renders_with_bold_modifier() {
1110            // Given TuiStreamHandler
1111            let mut handler = TuiStreamHandler::new(false);
1112
1113            // When on_text("**important**\n") is called
1114            handler.on_text("**important**\n");
1115
1116            // Then the text "important" appears with BOLD modifier
1117            let lines = collect_lines(&handler);
1118            assert!(!lines.is_empty(), "Should have at least one line");
1119
1120            // Find a span containing "important" and check it's bold
1121            let has_bold = lines.iter().any(|line| {
1122                line.spans.iter().any(|span| {
1123                    span.content.contains("important")
1124                        && span.style.add_modifier.contains(Modifier::BOLD)
1125                })
1126            });
1127            assert!(
1128                has_bold,
1129                "Should have bold 'important' span. Lines: {:?}",
1130                lines
1131            );
1132        }
1133
1134        #[test]
1135        fn markdown_italic_text_renders_with_italic_modifier() {
1136            // Given TuiStreamHandler
1137            let mut handler = TuiStreamHandler::new(false);
1138
1139            // When on_text("*emphasized*\n") is called
1140            handler.on_text("*emphasized*\n");
1141
1142            // Then the text "emphasized" appears with ITALIC modifier
1143            let lines = collect_lines(&handler);
1144            assert!(!lines.is_empty(), "Should have at least one line");
1145
1146            let has_italic = lines.iter().any(|line| {
1147                line.spans.iter().any(|span| {
1148                    span.content.contains("emphasized")
1149                        && span.style.add_modifier.contains(Modifier::ITALIC)
1150                })
1151            });
1152            assert!(
1153                has_italic,
1154                "Should have italic 'emphasized' span. Lines: {:?}",
1155                lines
1156            );
1157        }
1158
1159        #[test]
1160        fn markdown_inline_code_renders_with_distinct_style() {
1161            // Given TuiStreamHandler
1162            let mut handler = TuiStreamHandler::new(false);
1163
1164            // When on_text("`code`\n") is called
1165            handler.on_text("`code`\n");
1166
1167            // Then the text "code" appears with distinct styling (different from default)
1168            let lines = collect_lines(&handler);
1169            assert!(!lines.is_empty(), "Should have at least one line");
1170
1171            let has_code_style = lines.iter().any(|line| {
1172                line.spans.iter().any(|span| {
1173                    span.content.contains("code")
1174                        && (span.style.fg.is_some() || span.style.bg.is_some())
1175                })
1176            });
1177            assert!(
1178                has_code_style,
1179                "Should have styled 'code' span. Lines: {:?}",
1180                lines
1181            );
1182        }
1183
1184        #[test]
1185        fn markdown_header_renders_content() {
1186            // Given TuiStreamHandler
1187            let mut handler = TuiStreamHandler::new(false);
1188
1189            // When on_text("## Section Title\n") is called
1190            handler.on_text("## Section Title\n");
1191
1192            // Then "Section Title" appears in the output
1193            // Note: termimad applies ANSI styling to headers
1194            let lines = collect_lines(&handler);
1195            assert!(!lines.is_empty(), "Should have at least one line");
1196
1197            let has_header_content = lines.iter().any(|line| {
1198                line.spans
1199                    .iter()
1200                    .any(|span| span.content.contains("Section Title"))
1201            });
1202            assert!(
1203                has_header_content,
1204                "Should have header content. Lines: {:?}",
1205                lines
1206            );
1207        }
1208
1209        #[test]
1210        fn markdown_streaming_continuity_handles_split_formatting() {
1211            // Given TuiStreamHandler
1212            let mut handler = TuiStreamHandler::new(false);
1213
1214            // When markdown arrives in chunks: "**bo" then "ld**\n"
1215            handler.on_text("**bo");
1216            handler.on_text("ld**\n");
1217
1218            // Then the complete "bold" text renders with BOLD modifier
1219            let lines = collect_lines(&handler);
1220
1221            let has_bold = lines.iter().any(|line| {
1222                line.spans
1223                    .iter()
1224                    .any(|span| span.style.add_modifier.contains(Modifier::BOLD))
1225            });
1226            assert!(
1227                has_bold,
1228                "Split markdown should still render bold. Lines: {:?}",
1229                lines
1230            );
1231        }
1232
1233        #[test]
1234        fn markdown_mixed_content_renders_correctly() {
1235            // Given TuiStreamHandler
1236            let mut handler = TuiStreamHandler::new(false);
1237
1238            // When on_text() receives mixed markdown
1239            handler.on_text("Normal **bold** and *italic* text\n");
1240
1241            // Then appropriate spans have appropriate styling
1242            let lines = collect_lines(&handler);
1243            assert!(!lines.is_empty(), "Should have at least one line");
1244
1245            let has_bold = lines.iter().any(|line| {
1246                line.spans.iter().any(|span| {
1247                    span.content.contains("bold")
1248                        && span.style.add_modifier.contains(Modifier::BOLD)
1249                })
1250            });
1251            let has_italic = lines.iter().any(|line| {
1252                line.spans.iter().any(|span| {
1253                    span.content.contains("italic")
1254                        && span.style.add_modifier.contains(Modifier::ITALIC)
1255                })
1256            });
1257
1258            assert!(has_bold, "Should have bold span. Lines: {:?}", lines);
1259            assert!(has_italic, "Should have italic span. Lines: {:?}", lines);
1260        }
1261
1262        #[test]
1263        fn markdown_tool_call_styling_preserved() {
1264            // Given TuiStreamHandler with markdown text then tool call
1265            let mut handler = TuiStreamHandler::new(false);
1266
1267            // When markdown text followed by tool call
1268            handler.on_text("**bold**\n");
1269            handler.on_tool_call("Read", "id", &json!({"file_path": "src/main.rs"}));
1270
1271            // Then tool call still has blue styling
1272            let lines = collect_lines(&handler);
1273            assert!(lines.len() >= 2, "Should have at least 2 lines");
1274
1275            // Last line should be the tool call with blue color
1276            let tool_line = lines.last().unwrap();
1277            let has_blue = tool_line
1278                .spans
1279                .iter()
1280                .any(|span| span.style.fg == Some(Color::Blue));
1281            assert!(
1282                has_blue,
1283                "Tool call should preserve blue styling. Line: {:?}",
1284                tool_line
1285            );
1286        }
1287
1288        #[test]
1289        fn markdown_error_styling_preserved() {
1290            // Given TuiStreamHandler with markdown text then error
1291            let mut handler = TuiStreamHandler::new(false);
1292
1293            // When markdown text followed by error
1294            handler.on_text("**bold**\n");
1295            handler.on_error("Something went wrong");
1296
1297            // Then error still has red styling
1298            let lines = collect_lines(&handler);
1299            assert!(lines.len() >= 2, "Should have at least 2 lines");
1300
1301            // Last line should be the error with red color
1302            let error_line = lines.last().unwrap();
1303            let has_red = error_line
1304                .spans
1305                .iter()
1306                .any(|span| span.style.fg == Some(Color::Red));
1307            assert!(
1308                has_red,
1309                "Error should preserve red styling. Line: {:?}",
1310                error_line
1311            );
1312        }
1313
1314        #[test]
1315        fn markdown_partial_formatting_does_not_crash() {
1316            // Given TuiStreamHandler
1317            let mut handler = TuiStreamHandler::new(false);
1318
1319            // When incomplete markdown is sent and flushed
1320            handler.on_text("**unclosed bold");
1321            handler.flush_text_buffer();
1322
1323            // Then no panic occurs and text is present
1324            let lines = collect_lines(&handler);
1325            // Should have some output (either the partial text or nothing)
1326            // Main assertion is that we didn't panic
1327            let _ = lines; // Use the variable to avoid warning
1328        }
1329
1330        // =====================================================================
1331        // ANSI Color Preservation Tests
1332        // =====================================================================
1333
1334        #[test]
1335        fn ansi_green_text_produces_green_style() {
1336            // Given TuiStreamHandler
1337            let mut handler = TuiStreamHandler::new(false);
1338
1339            // When on_text receives ANSI green text
1340            handler.on_text("\x1b[32mgreen text\x1b[0m\n");
1341
1342            // Then the text should have green foreground color
1343            let lines = collect_lines(&handler);
1344            assert!(!lines.is_empty(), "Should have at least one line");
1345
1346            let has_green = lines.iter().any(|line| {
1347                line.spans
1348                    .iter()
1349                    .any(|span| span.style.fg == Some(Color::Green))
1350            });
1351            assert!(
1352                has_green,
1353                "Should have green styled span. Lines: {:?}",
1354                lines
1355            );
1356        }
1357
1358        #[test]
1359        fn ansi_bold_text_produces_bold_modifier() {
1360            // Given TuiStreamHandler
1361            let mut handler = TuiStreamHandler::new(false);
1362
1363            // When on_text receives ANSI bold text
1364            handler.on_text("\x1b[1mbold text\x1b[0m\n");
1365
1366            // Then the text should have BOLD modifier
1367            let lines = collect_lines(&handler);
1368            assert!(!lines.is_empty(), "Should have at least one line");
1369
1370            let has_bold = lines.iter().any(|line| {
1371                line.spans
1372                    .iter()
1373                    .any(|span| span.style.add_modifier.contains(Modifier::BOLD))
1374            });
1375            assert!(has_bold, "Should have bold styled span. Lines: {:?}", lines);
1376        }
1377
1378        #[test]
1379        fn ansi_mixed_styles_preserved() {
1380            // Given TuiStreamHandler
1381            let mut handler = TuiStreamHandler::new(false);
1382
1383            // When on_text receives mixed ANSI styles (bold + green)
1384            handler.on_text("\x1b[1;32mbold green\x1b[0m normal\n");
1385
1386            // Then the text should have appropriate styles
1387            let lines = collect_lines(&handler);
1388            assert!(!lines.is_empty(), "Should have at least one line");
1389
1390            // Check for green color
1391            let has_styled = lines.iter().any(|line| {
1392                line.spans.iter().any(|span| {
1393                    span.style.fg == Some(Color::Green)
1394                        || span.style.add_modifier.contains(Modifier::BOLD)
1395                })
1396            });
1397            assert!(
1398                has_styled,
1399                "Should have styled span with color or bold. Lines: {:?}",
1400                lines
1401            );
1402        }
1403
1404        #[test]
1405        fn ansi_plain_text_renders_without_crash() {
1406            // Given TuiStreamHandler
1407            let mut handler = TuiStreamHandler::new(false);
1408
1409            // When on_text receives plain text (no ANSI)
1410            handler.on_text("plain text without ansi\n");
1411
1412            // Then text renders normally (fallback to markdown)
1413            let lines = collect_lines(&handler);
1414            assert!(!lines.is_empty(), "Should have at least one line");
1415
1416            let full_text: String = lines.iter().map(|l| l.to_string()).collect();
1417            assert!(
1418                full_text.contains("plain text"),
1419                "Should contain the text. Lines: {:?}",
1420                lines
1421            );
1422        }
1423
1424        #[test]
1425        fn ansi_red_error_text_produces_red_style() {
1426            // Given TuiStreamHandler
1427            let mut handler = TuiStreamHandler::new(false);
1428
1429            // When on_text receives ANSI red text (like error output)
1430            handler.on_text("\x1b[31mError: something failed\x1b[0m\n");
1431
1432            // Then the text should have red foreground color
1433            let lines = collect_lines(&handler);
1434            assert!(!lines.is_empty(), "Should have at least one line");
1435
1436            let has_red = lines.iter().any(|line| {
1437                line.spans
1438                    .iter()
1439                    .any(|span| span.style.fg == Some(Color::Red))
1440            });
1441            assert!(has_red, "Should have red styled span. Lines: {:?}", lines);
1442        }
1443
1444        #[test]
1445        fn ansi_cyan_text_produces_cyan_style() {
1446            // Given TuiStreamHandler
1447            let mut handler = TuiStreamHandler::new(false);
1448
1449            // When on_text receives ANSI cyan text
1450            handler.on_text("\x1b[36mcyan text\x1b[0m\n");
1451
1452            // Then the text should have cyan foreground color
1453            let lines = collect_lines(&handler);
1454            assert!(!lines.is_empty(), "Should have at least one line");
1455
1456            let has_cyan = lines.iter().any(|line| {
1457                line.spans
1458                    .iter()
1459                    .any(|span| span.style.fg == Some(Color::Cyan))
1460            });
1461            assert!(has_cyan, "Should have cyan styled span. Lines: {:?}", lines);
1462        }
1463
1464        #[test]
1465        fn ansi_underline_produces_underline_modifier() {
1466            // Given TuiStreamHandler
1467            let mut handler = TuiStreamHandler::new(false);
1468
1469            // When on_text receives ANSI underlined text
1470            handler.on_text("\x1b[4munderlined\x1b[0m\n");
1471
1472            // Then the text should have UNDERLINED modifier
1473            let lines = collect_lines(&handler);
1474            assert!(!lines.is_empty(), "Should have at least one line");
1475
1476            let has_underline = lines.iter().any(|line| {
1477                line.spans
1478                    .iter()
1479                    .any(|span| span.style.add_modifier.contains(Modifier::UNDERLINED))
1480            });
1481            assert!(
1482                has_underline,
1483                "Should have underlined styled span. Lines: {:?}",
1484                lines
1485            );
1486        }
1487
1488        #[test]
1489        fn ansi_multiline_preserves_colors() {
1490            // Given TuiStreamHandler
1491            let mut handler = TuiStreamHandler::new(false);
1492
1493            // When on_text receives multiple ANSI-colored lines
1494            handler.on_text("\x1b[32mline 1 green\x1b[0m\n\x1b[31mline 2 red\x1b[0m\n");
1495
1496            // Then both colors should be present
1497            let lines = collect_lines(&handler);
1498            assert!(lines.len() >= 2, "Should have at least two lines");
1499
1500            let has_green = lines.iter().any(|line| {
1501                line.spans
1502                    .iter()
1503                    .any(|span| span.style.fg == Some(Color::Green))
1504            });
1505            let has_red = lines.iter().any(|line| {
1506                line.spans
1507                    .iter()
1508                    .any(|span| span.style.fg == Some(Color::Red))
1509            });
1510
1511            assert!(has_green, "Should have green line. Lines: {:?}", lines);
1512            assert!(has_red, "Should have red line. Lines: {:?}", lines);
1513        }
1514    }
1515}
1516
1517// =========================================================================
1518// ANSI Detection Tests (module-level)
1519// =========================================================================
1520
1521#[cfg(test)]
1522mod ansi_detection_tests {
1523    use super::*;
1524
1525    #[test]
1526    fn contains_ansi_with_color_code() {
1527        assert!(contains_ansi("\x1b[32mgreen\x1b[0m"));
1528    }
1529
1530    #[test]
1531    fn contains_ansi_with_bold() {
1532        assert!(contains_ansi("\x1b[1mbold\x1b[0m"));
1533    }
1534
1535    #[test]
1536    fn contains_ansi_plain_text_returns_false() {
1537        assert!(!contains_ansi("hello world"));
1538    }
1539
1540    #[test]
1541    fn contains_ansi_markdown_returns_false() {
1542        assert!(!contains_ansi("**bold** and *italic*"));
1543    }
1544
1545    #[test]
1546    fn contains_ansi_empty_string_returns_false() {
1547        assert!(!contains_ansi(""));
1548    }
1549
1550    #[test]
1551    fn contains_ansi_with_escape_in_middle() {
1552        assert!(contains_ansi("prefix \x1b[31mred\x1b[0m suffix"));
1553    }
1554}