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