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