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