Skip to main content

ralph_workflow/json_parser/
delta_display.rs

1//! Unified delta display system for streaming content.
2//!
3//! This module provides centralized logic for displaying partial vs. complete
4//! content consistently across all parsers. It handles visual distinction,
5//! real-time streaming display, and automatic transition from delta to complete.
6//!
7//! # `DeltaRenderer` Trait
8//!
9//! The `DeltaRenderer` trait defines a consistent interface for rendering
10//! streaming deltas across all parsers. Implementations must ensure:
11//! - First chunk shows prefix with accumulated content ending with carriage return
12//! - Subsequent chunks update in-place (clear line, rewrite with prefix, carriage return)
13//! - Final output adds newline when streaming completes
14//!
15//! # In-Place Updates
16//!
17//! The terminal escape sequences used for in-place updates:
18//! - `\x1b[2K` - Clears the entire line (not just to end like `\x1b[0K`)
19//! - `\r` - Returns cursor to the beginning of the line
20//!
21//! This ensures that previous content is completely erased before displaying
22//! the updated content, preventing visual artifacts.
23//!
24//! # Multi-Line In-Place Update Pattern
25//!
26//! The renderer uses a multi-line pattern with cursor positioning for in-place updates.
27//! This is the industry standard for streaming CLIs (used by Rich, Ink, Bubble Tea).
28//!
29//! ```text
30//! [ccs-glm] Hello\n\x1b[1A             <- First chunk: prefix + content + newline + cursor up
31//! \x1b[2K\r[ccs-glm] Hello World\n\x1b[1A  <- Second chunk: clear, rewrite, newline, cursor up
32//! [ccs-glm] Hello World\n\x1b[1B\n       <- Final: move cursor down + newline
33//! ```
34//!
35//! This pattern ensures:
36//! - Newline forces immediate terminal output buffer flush
37//! - Cursor positioning provides reliable in-place updates
38//! - Production-quality rendering used by major CLI libraries
39//!
40//! # Terminal Mode Detection
41//!
42//! The renderer automatically detects terminal capability and adjusts output:
43//! - **Full mode**: Uses cursor positioning for in-place updates (TTY with capable terminal)
44//! - **Basic mode**: Uses colors but no cursor positioning (e.g., `TERM=dumb`)
45//! - **None mode**: No ANSI sequences (pipes, redirects, CI environments)
46//!
47//! # Prefix Display Strategy
48//!
49//! The prefix (e.g., `[ccs-glm]`) is displayed on every delta update by default.
50//! This provides clear visual feedback about which agent is currently streaming.
51//!
52//! ## Prefix Debouncing
53//!
54//! For scenarios where prefix repetition creates visual noise (e.g., character-by-character
55//! streaming), the [`PrefixDebouncer`] can be used to control prefix display frequency.
56//! It supports both delta-count-based and time-based strategies:
57//!
58//! - **Count-based**: Show prefix every N deltas (default: every delta)
59//! - **Time-based**: Show prefix after M milliseconds since last prefix
60//!
61//! The debouncer is opt-in; the default behavior shows prefix on every delta.
62
63use crate::json_parser::terminal::TerminalMode;
64use crate::logger::Colors;
65
66#[cfg(test)]
67use std::time::{Duration, Instant};
68
69/// ANSI escape sequence for clearing the entire line.
70///
71/// This is more complete than `\x1b[0K` which only clears to the end of line.
72/// Using `\x1b[2K` ensures the entire line is cleared during in-place updates.
73pub const CLEAR_LINE: &str = "\x1b[2K";
74
75/// Sanitize content for single-line display during streaming.
76///
77/// This function prepares streamed content for in-place terminal display by:
78/// - Replacing newlines with spaces (to prevent artificial line breaks)
79/// - Collapsing multiple consecutive whitespace characters into single spaces
80/// - Trimming leading and trailing whitespace
81///
82/// NOTE: This function does NOT truncate to terminal width. Truncation during
83/// streaming causes visible "..." cut-offs as content accumulates. Terminal width
84/// truncation should only be applied for final/non-streaming display.
85///
86/// # Arguments
87/// * `content` - The raw content to sanitize
88///
89/// # Returns
90/// A sanitized string suitable for single-line display, without truncation.
91pub fn sanitize_for_display(content: &str) -> String {
92    // Replace all whitespace (including \n, \r, \t) with spaces, then collapse multiple spaces
93    let mut result = String::with_capacity(content.len());
94    let mut prev_was_whitespace = false;
95
96    for ch in content.chars() {
97        if ch.is_whitespace() {
98            if !prev_was_whitespace {
99                result.push(' ');
100                prev_was_whitespace = true;
101            }
102            // Skip consecutive whitespace characters
103        } else {
104            result.push(ch);
105            prev_was_whitespace = false;
106        }
107    }
108
109    // Trim leading and trailing whitespace for display
110    result.trim().to_string()
111}
112
113/// Configuration for streaming display behavior.
114///
115/// This struct allows customization of streaming output features like
116/// prefix debouncing and multi-line handling.
117///
118/// Default values:
119/// - `prefix_delta_threshold`: 0 (show prefix only on first delta)
120/// - `prefix_time_threshold`: None (no time-based debouncing)
121#[derive(Debug, Clone, Default)]
122#[cfg(test)]
123pub struct StreamingConfig {
124    /// Minimum number of deltas between prefix displays (0 = show on every delta)
125    pub prefix_delta_threshold: u32,
126    /// Minimum time between prefix displays (None = no time-based debouncing)
127    pub prefix_time_threshold: Option<Duration>,
128}
129
130/// Controls prefix display frequency during streaming.
131///
132/// This debouncer reduces visual noise from frequent prefix redisplay during
133/// rapid streaming (e.g., character-by-character output). It supports two
134/// debouncing strategies:
135///
136/// 1. **Count-based**: Show prefix every N deltas
137/// 2. **Time-based**: Show prefix after M milliseconds since last prefix
138///
139/// # Example
140///
141/// ```ignore
142/// use std::time::Duration;
143///
144/// let config = StreamingConfig {
145///     prefix_delta_threshold: 5,
146///     prefix_time_threshold: Some(Duration::from_millis(100)),
147/// };
148/// let mut debouncer = PrefixDebouncer::new(config);
149///
150/// // First delta always shows prefix
151/// assert!(debouncer.should_show_prefix(true));
152///
153/// // Subsequent deltas may skip prefix based on thresholds
154/// assert!(!debouncer.should_show_prefix(false)); // Delta 2: skip
155/// assert!(!debouncer.should_show_prefix(false)); // Delta 3: skip
156/// // ... after threshold reached or time elapsed, prefix shows again
157/// ```
158#[derive(Debug, Clone)]
159#[cfg(test)]
160pub struct PrefixDebouncer {
161    config: StreamingConfig,
162    delta_count: u32,
163    last_prefix_time: Option<Instant>,
164}
165
166#[cfg(test)]
167impl PrefixDebouncer {
168    /// Create a new prefix debouncer with the given configuration.
169    pub const fn new(config: StreamingConfig) -> Self {
170        Self {
171            config,
172            delta_count: 0,
173            last_prefix_time: None,
174        }
175    }
176
177    /// Reset the debouncer state (e.g., at the start of a new content block).
178    pub const fn reset(&mut self) {
179        self.delta_count = 0;
180        self.last_prefix_time = None;
181    }
182
183    /// Determine if the prefix should be shown for the current delta.
184    ///
185    /// # Arguments
186    /// * `is_first_delta` - Whether this is the first delta of a content block
187    ///
188    /// # Returns
189    /// * `true` - Show the prefix
190    /// * `false` - Skip the prefix (still perform line clearing)
191    pub fn should_show_prefix(&mut self, is_first_delta: bool) -> bool {
192        // Always show prefix on first delta
193        if is_first_delta {
194            self.delta_count = 0;
195            self.last_prefix_time = Some(Instant::now());
196            return true;
197        }
198
199        self.delta_count += 1;
200
201        // Check time-based threshold
202        if let Some(threshold) = self.config.prefix_time_threshold {
203            if let Some(last_time) = self.last_prefix_time {
204                if last_time.elapsed() >= threshold {
205                    self.delta_count = 0;
206                    self.last_prefix_time = Some(Instant::now());
207                    return true;
208                }
209            }
210        }
211
212        // Check count-based threshold
213        if self.config.prefix_delta_threshold > 0
214            && self.delta_count >= self.config.prefix_delta_threshold
215        {
216            self.delta_count = 0;
217            self.last_prefix_time = Some(Instant::now());
218            return true;
219        }
220
221        // Default behavior: only first delta shows prefix.
222        // With no thresholds configured, subsequent deltas don't show prefix.
223        // This preserves the original behavior while allowing opt-in debouncing.
224        false
225    }
226}
227
228#[cfg(test)]
229impl Default for PrefixDebouncer {
230    fn default() -> Self {
231        Self::new(StreamingConfig::default())
232    }
233}
234
235/// Renderer for streaming delta content.
236///
237/// This trait defines the contract for rendering streaming deltas consistently
238/// across all parsers. Implementations must ensure:
239///
240/// 1. **First chunk**: Shows prefix with accumulated content, ending with newline + cursor up
241/// 2. **Subsequent chunks**: Clear line, rewrite with prefix and accumulated content, newline + cursor up
242/// 3. **Completion**: Move cursor down + newline when streaming completes
243///
244/// # Rendering Rules
245///
246/// - `render_first_delta()`: Called for the first delta of a content block
247///   - Must include prefix
248///   - Must end with newline + cursor up (`\n\x1b[1A`) for in-place updates (in Full mode)
249///   - Shows the accumulated content so far
250///
251/// - `render_subsequent_delta()`: Called for subsequent deltas
252///   - Must include prefix (rewrite entire line)
253///   - Must use `\x1b[2K\r` to clear entire line and return to start (in Full mode)
254///   - Shows the full accumulated content (not just the new delta)
255///   - Must end with newline + cursor up (`\n\x1b[1A`) (in Full mode)
256///
257/// - `render_completion()`: Called when streaming completes
258///   - Returns cursor down + newline (`\x1b[1B\n`) in Full mode
259///   - Returns simple newline in Basic/None mode
260///
261/// # Terminal Mode Awareness
262///
263/// The renderer automatically adapts output based on terminal capability:
264/// - **Full mode**: Uses cursor positioning for in-place updates
265/// - **Basic mode**: Uses colors but simple line output (no cursor positioning)
266/// - **None mode**: Plain text output (no ANSI sequences)
267///
268/// # Example
269///
270/// ```ignore
271/// use crate::json_parser::delta_display::DeltaRenderer;
272/// use crate::logger::Colors;
273/// use crate::json_parser::TerminalMode;
274///
275/// let colors = Colors { enabled: true };
276/// let terminal_mode = TerminalMode::detect();
277///
278/// // First chunk
279/// let output = DeltaRenderer::render_first_delta(
280///     "Hello",
281///     "ccs-glm",
282///     colors,
283///     terminal_mode
284/// );
285///
286/// // Second chunk
287/// let output = DeltaRenderer::render_subsequent_delta(
288///     "Hello World",
289///     "ccs-glm",
290///     colors,
291///     terminal_mode
292/// );
293///
294/// // Complete
295/// let output = DeltaRenderer::render_completion(terminal_mode);
296/// ```
297pub trait DeltaRenderer {
298    /// Render the first delta of a content block.
299    ///
300    /// This is called when streaming begins for a new content block.
301    /// The output should include the prefix and the accumulated content.
302    ///
303    /// # Arguments
304    /// * `accumulated` - The full accumulated content so far
305    /// * `prefix` - The agent prefix (e.g., "ccs-glm")
306    /// * `colors` - Terminal colors
307    /// * `terminal_mode` - The detected terminal capability mode
308    ///
309    /// # Returns
310    /// A formatted string with prefix and content. In Full mode, ends with `\n\x1b[1A`.
311    fn render_first_delta(
312        accumulated: &str,
313        prefix: &str,
314        colors: Colors,
315        terminal_mode: TerminalMode,
316    ) -> String;
317
318    /// Render a subsequent delta (in-place update).
319    ///
320    /// This is called for all deltas after the first. The output should
321    /// clear the entire line and rewrite with the prefix and accumulated content
322    /// in Full mode, or append content in Basic/None mode.
323    ///
324    /// # Arguments
325    /// * `accumulated` - The full accumulated content so far
326    /// * `prefix` - The agent prefix (e.g., "ccs-glm")
327    /// * `colors` - Terminal colors
328    /// * `terminal_mode` - The detected terminal capability mode
329    ///
330    /// # Returns
331    /// A formatted string with prefix and content.
332    fn render_subsequent_delta(
333        accumulated: &str,
334        prefix: &str,
335        colors: Colors,
336        terminal_mode: TerminalMode,
337    ) -> String;
338
339    /// Render the completion of streaming.
340    ///
341    /// This is called when streaming completes to move cursor down and add newline.
342    /// This method ONLY handles cursor state cleanup - it does NOT render content.
343    ///
344    /// The streamed content is already visible on the terminal from previous deltas.
345    /// This method simply positions the cursor correctly for subsequent output.
346    ///
347    /// # Arguments
348    /// * `terminal_mode` - The detected terminal capability mode
349    ///
350    /// # Returns
351    /// A string with appropriate cursor sequence for the terminal mode.
352    fn render_completion(terminal_mode: TerminalMode) -> String {
353        match terminal_mode {
354            TerminalMode::Full => "\x1b[1B\n".to_string(),
355            TerminalMode::Basic | TerminalMode::None => "\n".to_string(),
356        }
357    }
358}
359
360/// Default implementation of `DeltaRenderer` for text content.
361///
362/// This implementation follows the multi-line rendering pattern used by production CLIs:
363/// - Prefix and content on same line ending with newline + cursor up
364/// - Content updates in-place using clear, rewrite, and newline + cursor up
365/// - Sanitizes newlines to spaces (to prevent artificial line breaks)
366/// - Uses ANSI escape codes for in-place updates with full line clear
367/// - Applies consistent color formatting
368///
369/// # Output Pattern
370///
371/// ## Full Mode (TTY with capable terminal)
372///
373/// ```text
374/// [ccs-glm] Hello\n\x1b[1A             <- First chunk: prefix + content + newline + cursor up
375/// \x1b[2K\r[ccs-glm] Hello World\n\x1b[1A  <- Second chunk: clear, rewrite, newline, cursor up
376/// [ccs-glm] Hello World\n\x1b[1B\n       <- Final: move cursor down + newline
377/// ```
378///
379/// ## Basic/None Mode (colors only or plain text)
380///
381/// ```text
382/// [ccs-glm] Hello\n                      <- First chunk: simple line output
383/// [ccs-glm] Hello World\n                <- Second chunk: full content (no in-place update)
384///                                       <- Final: just a newline
385/// ```
386///
387/// The multi-line pattern is the industry standard used by Rich, Ink, Bubble Tea
388/// and other production CLI libraries for clean streaming output.
389pub struct TextDeltaRenderer;
390
391impl DeltaRenderer for TextDeltaRenderer {
392    fn render_first_delta(
393        accumulated: &str,
394        prefix: &str,
395        colors: Colors,
396        terminal_mode: TerminalMode,
397    ) -> String {
398        // Sanitize content: replace newlines with spaces and collapse multiple whitespace
399        // NOTE: No truncation here - allow full content to accumulate during streaming
400        let sanitized = sanitize_for_display(accumulated);
401
402        match terminal_mode {
403            TerminalMode::Full => {
404                // Multi-line pattern: end with newline + cursor up for in-place updates
405                // This forces terminal output flush and positions cursor for rewrite
406                format!(
407                    "{}[{}]{} {}{}{}\n\x1b[1A",
408                    colors.dim(),
409                    prefix,
410                    colors.reset(),
411                    colors.white(),
412                    sanitized,
413                    colors.reset()
414                )
415            }
416            TerminalMode::Basic | TerminalMode::None => {
417                // Simple line output without cursor positioning
418                format!(
419                    "{}[{}]{} {}{}{}\n",
420                    colors.dim(),
421                    prefix,
422                    colors.reset(),
423                    colors.white(),
424                    sanitized,
425                    colors.reset()
426                )
427            }
428        }
429    }
430
431    fn render_subsequent_delta(
432        accumulated: &str,
433        prefix: &str,
434        colors: Colors,
435        terminal_mode: TerminalMode,
436    ) -> String {
437        // Sanitize content: replace newlines with spaces and collapse multiple whitespace
438        // NOTE: No truncation here - allow full content to accumulate during streaming
439        let sanitized = sanitize_for_display(accumulated);
440
441        match terminal_mode {
442            TerminalMode::Full => {
443                // Clear line, rewrite with prefix and accumulated content, end with newline + cursor up
444                // This creates in-place update using multi-line pattern
445                format!(
446                    "{CLEAR_LINE}\r{}[{}]{} {}{}{}\n\x1b[1A",
447                    colors.dim(),
448                    prefix,
449                    colors.reset(),
450                    colors.white(),
451                    sanitized,
452                    colors.reset()
453                )
454            }
455            TerminalMode::Basic | TerminalMode::None => {
456                // Simple line output without cursor positioning
457                // Note: This will show each update as a new line, which is intentional
458                // for non-TTY or basic terminal output
459                format!(
460                    "{}[{}]{} {}{}{}\n",
461                    colors.dim(),
462                    prefix,
463                    colors.reset(),
464                    colors.white(),
465                    sanitized,
466                    colors.reset()
467                )
468            }
469        }
470    }
471}
472
473/// Delta display formatter
474///
475/// Formats delta content for user display with consistent styling across all parsers.
476pub struct DeltaDisplayFormatter {
477    /// Whether to mark partial content visually
478    mark_partial: bool,
479}
480
481impl DeltaDisplayFormatter {
482    /// Create a new formatter with default settings
483    pub const fn new() -> Self {
484        Self { mark_partial: true }
485    }
486
487    /// Format thinking content specifically
488    ///
489    /// Thinking content has special formatting to distinguish it from regular text.
490    pub fn format_thinking(&self, content: &str, prefix: &str, colors: Colors) -> String {
491        if self.mark_partial {
492            format!(
493                "{}[{}]{} {}Thinking: {}{}{}\n",
494                colors.dim(),
495                prefix,
496                colors.reset(),
497                colors.dim(),
498                colors.cyan(),
499                content,
500                colors.reset()
501            )
502        } else {
503            format!(
504                "{}[{}]{} {}Thinking: {}{}{}\n",
505                colors.dim(),
506                prefix,
507                colors.reset(),
508                colors.cyan(),
509                colors.reset(),
510                content,
511                colors.reset()
512            )
513        }
514    }
515
516    /// Format tool input specifically
517    ///
518    /// Tool input is shown with appropriate styling.
519    ///
520    /// # Current Behavior
521    ///
522    /// Every call renders the full `[prefix]   └─ content` pattern.
523    /// This provides clarity about which agent's tool is being invoked.
524    ///
525    /// # Future Enhancement
526    ///
527    /// For streaming tool inputs with multiple deltas, consider suppressing
528    /// the `[prefix]` on continuation lines to reduce visual noise:
529    /// - First tool input line: `[prefix] Tool: name`
530    /// - Continuation: `           └─ more input` (aligned, no prefix)
531    ///
532    /// This would require tracking whether the prefix has been displayed
533    /// for the current tool block, likely via the streaming session state.
534    pub fn format_tool_input(&self, content: &str, prefix: &str, colors: Colors) -> String {
535        if self.mark_partial {
536            format!(
537                "{}[{}]{} {}  └─ {}{}{}\n",
538                colors.dim(),
539                prefix,
540                colors.reset(),
541                colors.dim(),
542                colors.reset(),
543                content,
544                colors.reset()
545            )
546        } else {
547            format!(
548                "{}[{}]{} {}  └─ {}{}\n",
549                colors.dim(),
550                prefix,
551                colors.reset(),
552                colors.reset(),
553                content,
554                colors.reset()
555            )
556        }
557    }
558}
559
560impl Default for DeltaDisplayFormatter {
561    fn default() -> Self {
562        Self::new()
563    }
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569
570    fn test_colors() -> Colors {
571        Colors { enabled: false }
572    }
573
574    #[test]
575    fn test_format_thinking_content() {
576        let formatter = DeltaDisplayFormatter::new();
577        let output = formatter.format_thinking("Thinking about this", "Claude", test_colors());
578        assert!(output.contains("Thinking"));
579        assert!(output.contains("Thinking about this"));
580    }
581
582    #[test]
583    fn test_format_tool_input() {
584        let formatter = DeltaDisplayFormatter::new();
585        let output = formatter.format_tool_input("command=ls -la", "Claude", test_colors());
586        assert!(output.contains("command=ls -la"));
587        assert!(output.contains("└─"));
588    }
589
590    // Tests for DeltaRenderer trait
591
592    #[test]
593    fn test_text_delta_renderer_first_delta_full_mode() {
594        let output = TextDeltaRenderer::render_first_delta(
595            "Hello",
596            "ccs-glm",
597            test_colors(),
598            TerminalMode::Full,
599        );
600        assert!(output.contains("[ccs-glm]"));
601        assert!(output.contains("Hello"));
602        // Multi-line pattern: ends with newline + cursor up
603        assert!(output.ends_with("\x1b[1A"));
604        assert!(output.contains('\n'));
605        assert!(output.contains("\x1b[1A"));
606    }
607
608    #[test]
609    fn test_text_delta_renderer_first_delta_none_mode() {
610        let output = TextDeltaRenderer::render_first_delta(
611            "Hello",
612            "ccs-glm",
613            test_colors(),
614            TerminalMode::None,
615        );
616        assert!(output.contains("[ccs-glm]"));
617        assert!(output.contains("Hello"));
618        // No cursor positioning in None mode
619        assert!(!output.contains("\x1b[1A"));
620        assert!(!output.contains("\x1b[2K"));
621        // But should still have newline
622        assert!(output.contains('\n'));
623        assert!(output.ends_with('\n'));
624    }
625
626    #[test]
627    fn test_text_delta_renderer_first_delta_basic_mode() {
628        let output = TextDeltaRenderer::render_first_delta(
629            "Hello",
630            "ccs-glm",
631            test_colors(),
632            TerminalMode::Basic,
633        );
634        assert!(output.contains("[ccs-glm]"));
635        assert!(output.contains("Hello"));
636        // No cursor positioning in Basic mode
637        assert!(!output.contains("\x1b[1A"));
638        assert!(!output.contains("\x1b[2K"));
639        // But should still have newline
640        assert!(output.contains('\n'));
641        assert!(output.ends_with('\n'));
642    }
643
644    #[test]
645    fn test_text_delta_renderer_subsequent_delta_full_mode() {
646        let output = TextDeltaRenderer::render_subsequent_delta(
647            "Hello World",
648            "ccs-glm",
649            test_colors(),
650            TerminalMode::Full,
651        );
652        assert!(output.contains(CLEAR_LINE));
653        assert!(output.contains('\r'));
654        assert!(output.contains("Hello World"));
655        // Multi-line pattern: ends with newline + cursor up
656        assert!(output.contains("\x1b[1A"));
657        assert!(output.ends_with("\x1b[1A"));
658        assert!(output.contains("[ccs-glm]"));
659    }
660
661    #[test]
662    fn test_text_delta_renderer_subsequent_delta_none_mode() {
663        let output = TextDeltaRenderer::render_subsequent_delta(
664            "Hello World",
665            "ccs-glm",
666            test_colors(),
667            TerminalMode::None,
668        );
669        assert!(!output.contains(CLEAR_LINE));
670        assert!(!output.contains('\r'));
671        assert!(output.contains("Hello World"));
672        // No cursor positioning in None mode
673        assert!(!output.contains("\x1b[1A"));
674        // But should still have newline
675        assert!(output.contains('\n'));
676        assert!(output.ends_with('\n'));
677    }
678
679    #[test]
680    fn test_text_delta_renderer_uses_full_line_clear() {
681        let output = TextDeltaRenderer::render_subsequent_delta(
682            "Hello World",
683            "ccs-glm",
684            test_colors(),
685            TerminalMode::Full,
686        );
687        // Should use \x1b[2K (full line clear), not \x1b[0K (clear to end)
688        assert!(output.contains("\x1b[2K"));
689        // Should NOT contain \x1b[0K
690        assert!(!output.contains("\x1b[0K"));
691    }
692
693    #[test]
694    fn test_text_delta_renderer_completion_full_mode() {
695        let output = TextDeltaRenderer::render_completion(TerminalMode::Full);
696        // Multi-line pattern: cursor down + newline
697        assert!(output.contains("\x1b[1B"));
698        assert!(output.contains('\n'));
699        assert_eq!(output, "\x1b[1B\n");
700    }
701
702    #[test]
703    fn test_text_delta_renderer_completion_none_mode() {
704        let output = TextDeltaRenderer::render_completion(TerminalMode::None);
705        // Just a newline in None mode
706        assert!(!output.contains("\x1b[1B"));
707        assert!(output.contains('\n'));
708        assert_eq!(output, "\n");
709    }
710
711    #[test]
712    fn test_text_delta_renderer_completion_basic_mode() {
713        let output = TextDeltaRenderer::render_completion(TerminalMode::Basic);
714        // Just a newline in Basic mode
715        assert!(!output.contains("\x1b[1B"));
716        assert!(output.contains('\n'));
717        assert_eq!(output, "\n");
718    }
719
720    #[test]
721    fn test_text_delta_renderer_sanitizes_newlines() {
722        let output = TextDeltaRenderer::render_first_delta(
723            "Hello\nWorld",
724            "ccs-glm",
725            test_colors(),
726            TerminalMode::Full,
727        );
728        // Newlines should be replaced with spaces
729        assert!(!output.contains("Hello\nWorld"));
730        assert!(output.contains("Hello World"));
731    }
732
733    #[test]
734    fn test_text_delta_renderer_in_place_update_sequence() {
735        let colors = test_colors();
736
737        // First chunk - multi-line pattern: ends with newline + cursor up
738        let out1 =
739            TextDeltaRenderer::render_first_delta("Hello", "ccs-glm", colors, TerminalMode::Full);
740        assert!(out1.contains("[ccs-glm]"));
741        assert!(out1.ends_with("\x1b[1A"));
742        assert!(out1.contains('\n'));
743        assert!(out1.contains("\x1b[1A"));
744
745        // Second chunk (in-place update with newline + cursor up)
746        let out2 = TextDeltaRenderer::render_subsequent_delta(
747            "Hello World",
748            "ccs-glm",
749            colors,
750            TerminalMode::Full,
751        );
752        assert!(out2.contains("\x1b[2K"));
753        assert!(out2.contains('\r'));
754        assert!(out2.contains("\x1b[1A")); // Cursor up in multi-line pattern
755        assert!(out2.contains("[ccs-glm]")); // Prefix is rewritten
756
757        // Completion
758        let out3 = TextDeltaRenderer::render_completion(TerminalMode::Full);
759        assert!(out3.contains("\x1b[1B"));
760        assert_eq!(out3, "\x1b[1B\n");
761    }
762
763    #[test]
764    fn test_full_streaming_sequence_no_extra_blank_lines() {
765        let colors = test_colors();
766
767        // Simulate a full streaming sequence and verify no extra blank lines
768        let first =
769            TextDeltaRenderer::render_first_delta("Hello", "agent", colors, TerminalMode::Full);
770        let second = TextDeltaRenderer::render_subsequent_delta(
771            "Hello World",
772            "agent",
773            colors,
774            TerminalMode::Full,
775        );
776        let complete = TextDeltaRenderer::render_completion(TerminalMode::Full);
777
778        // First delta: ends with exactly one \n followed by cursor up
779        assert!(first.ends_with("\n\x1b[1A"));
780        assert_eq!(first.matches('\n').count(), 1);
781
782        // Subsequent delta: starts with clear+return, ends with \n + cursor up
783        assert!(second.starts_with("\x1b[2K\r"));
784        assert!(second.ends_with("\n\x1b[1A"));
785        assert_eq!(second.matches('\n').count(), 1);
786
787        // Completion: exactly cursor down + one newline
788        assert_eq!(complete, "\x1b[1B\n");
789        assert_eq!(complete.matches('\n').count(), 1);
790    }
791
792    #[test]
793    fn test_non_tty_streaming_sequence_simple_output() {
794        let colors = test_colors();
795
796        // Simulate a full streaming sequence in None mode
797        let first =
798            TextDeltaRenderer::render_first_delta("Hello", "agent", colors, TerminalMode::None);
799        let second = TextDeltaRenderer::render_subsequent_delta(
800            "Hello World",
801            "agent",
802            colors,
803            TerminalMode::None,
804        );
805        let complete = TextDeltaRenderer::render_completion(TerminalMode::None);
806
807        // First delta: simple line ending with newline
808        assert!(first.contains("Hello"));
809        assert!(first.ends_with('\n'));
810        assert!(!first.contains('\x1b'));
811
812        // Second delta: simple line with full content (no in-place update)
813        assert!(second.contains("Hello World"));
814        assert!(second.ends_with('\n'));
815        assert!(!second.contains('\x1b'));
816
817        // Completion: just a newline
818        assert_eq!(complete, "\n");
819    }
820
821    #[test]
822    fn test_prefix_displayed_on_all_deltas() {
823        let colors = test_colors();
824        let prefix = "my-agent";
825
826        // First delta shows prefix
827        let first = TextDeltaRenderer::render_first_delta("A", prefix, colors, TerminalMode::Full);
828        assert!(first.contains(&format!("[{prefix}]")));
829
830        // Subsequent delta also shows prefix (design decision: prefix on every delta)
831        let subsequent =
832            TextDeltaRenderer::render_subsequent_delta("AB", prefix, colors, TerminalMode::Full);
833        assert!(subsequent.contains(&format!("[{prefix}]")));
834    }
835
836    // Tests for sanitize_for_display helper function
837
838    #[test]
839    fn test_sanitize_collapses_multiple_newlines() {
840        let result = sanitize_for_display("Hello\n\nWorld");
841        // Multiple newlines should become a single space
842        assert_eq!(result, "Hello World");
843    }
844
845    #[test]
846    fn test_sanitize_collapses_multiple_spaces() {
847        let result = sanitize_for_display("Hello   World");
848        assert_eq!(result, "Hello World");
849    }
850
851    #[test]
852    fn test_sanitize_mixed_whitespace() {
853        let result = sanitize_for_display("Hello\n\n  \t\t  World");
854        // All whitespace (newlines, spaces, tabs) collapsed to single space
855        assert_eq!(result, "Hello World");
856    }
857
858    #[test]
859    fn test_sanitize_trims_leading_trailing_whitespace() {
860        let result = sanitize_for_display("  Hello World  ");
861        assert_eq!(result, "Hello World");
862    }
863
864    #[test]
865    fn test_sanitize_only_whitespace() {
866        let result = sanitize_for_display("   \n\n   ");
867        // Only whitespace content becomes empty string
868        assert_eq!(result, "");
869    }
870
871    #[test]
872    fn test_sanitize_preserves_single_spaces() {
873        let result = sanitize_for_display("Hello World Test");
874        assert_eq!(result, "Hello World Test");
875    }
876
877    #[test]
878    fn test_sanitize_does_not_truncate() {
879        // sanitize_for_display no longer truncates - it just sanitizes whitespace
880        let long_content = "This is a very long string that should NOT be truncated anymore";
881        let result = sanitize_for_display(long_content);
882        // Should NOT be truncated
883        assert_eq!(result, long_content);
884        assert!(!result.contains("..."));
885    }
886
887    #[test]
888    fn test_delta_renderer_multiple_newlines_render_cleanly() {
889        let colors = test_colors();
890        let output = TextDeltaRenderer::render_first_delta(
891            "Hello\n\n\nWorld",
892            "agent",
893            colors,
894            TerminalMode::Full,
895        );
896        // Multiple newlines should render as single space
897        assert!(output.contains("Hello World"));
898        // Should NOT have multiple spaces
899        assert!(!output.contains("  "));
900    }
901
902    #[test]
903    fn test_delta_renderer_trailing_whitespace_trimmed() {
904        let colors = test_colors();
905        let output = TextDeltaRenderer::render_first_delta(
906            "Hello World   ",
907            "agent",
908            colors,
909            TerminalMode::Full,
910        );
911        // Trailing spaces should be trimmed
912        assert!(output.contains("Hello World"));
913        // Content should not end with space before escape sequences
914        // (it ends with reset color then \n\x1b[1A)
915    }
916
917    // Tests for StreamingConfig
918
919    #[test]
920    fn test_streaming_config_defaults() {
921        let config = StreamingConfig::default();
922        assert_eq!(config.prefix_delta_threshold, 0);
923        assert!(config.prefix_time_threshold.is_none());
924    }
925
926    // Tests for PrefixDebouncer
927
928    #[test]
929    fn test_prefix_debouncer_default_first_only() {
930        let mut debouncer = PrefixDebouncer::default();
931
932        // First delta always shows prefix
933        assert!(debouncer.should_show_prefix(true));
934
935        // With default config (no thresholds), only first delta shows prefix
936        // This preserves the original behavior
937        assert!(!debouncer.should_show_prefix(false));
938        assert!(!debouncer.should_show_prefix(false));
939        assert!(!debouncer.should_show_prefix(false));
940    }
941
942    #[test]
943    fn test_prefix_debouncer_count_threshold() {
944        let config = StreamingConfig {
945            prefix_delta_threshold: 3,
946            prefix_time_threshold: None,
947        };
948        let mut debouncer = PrefixDebouncer::new(config);
949
950        // First delta always shows prefix
951        assert!(debouncer.should_show_prefix(true));
952
953        // Next 2 deltas should skip prefix
954        assert!(!debouncer.should_show_prefix(false)); // delta 1
955        assert!(!debouncer.should_show_prefix(false)); // delta 2
956
957        // 3rd delta hits threshold, shows prefix
958        assert!(debouncer.should_show_prefix(false)); // delta 3
959
960        // Cycle resets
961        assert!(!debouncer.should_show_prefix(false)); // delta 1
962        assert!(!debouncer.should_show_prefix(false)); // delta 2
963        assert!(debouncer.should_show_prefix(false)); // delta 3
964    }
965
966    #[test]
967    fn test_prefix_debouncer_reset() {
968        let config = StreamingConfig {
969            prefix_delta_threshold: 3,
970            prefix_time_threshold: None,
971        };
972        let mut debouncer = PrefixDebouncer::new(config);
973
974        // Build up delta count
975        debouncer.should_show_prefix(true);
976        debouncer.should_show_prefix(false);
977        debouncer.should_show_prefix(false);
978
979        // Reset clears state
980        debouncer.reset();
981
982        // After reset, next delta is treated as fresh
983        // (but not "first delta" unless caller says so)
984        assert!(!debouncer.should_show_prefix(false)); // delta 1 after reset
985        assert!(!debouncer.should_show_prefix(false)); // delta 2
986        assert!(debouncer.should_show_prefix(false)); // delta 3 hits threshold
987    }
988
989    #[test]
990    fn test_prefix_debouncer_first_delta_always_shows() {
991        let config = StreamingConfig {
992            prefix_delta_threshold: 100,
993            prefix_time_threshold: None,
994        };
995        let mut debouncer = PrefixDebouncer::new(config);
996
997        // First delta always shows prefix regardless of threshold
998        assert!(debouncer.should_show_prefix(true));
999
1000        // Even after many skips, marking as first shows prefix
1001        for _ in 0..10 {
1002            debouncer.should_show_prefix(false);
1003        }
1004        assert!(debouncer.should_show_prefix(true)); // First delta again
1005    }
1006
1007    #[test]
1008    fn test_prefix_debouncer_time_threshold() {
1009        // Note: This test uses Duration::ZERO for immediate threshold.
1010        // In practice, time-based debouncing uses longer durations like 100ms.
1011        let config = StreamingConfig {
1012            prefix_delta_threshold: 0,
1013            prefix_time_threshold: Some(Duration::ZERO),
1014        };
1015        let mut debouncer = PrefixDebouncer::new(config);
1016
1017        // First delta shows prefix
1018        assert!(debouncer.should_show_prefix(true));
1019
1020        // Since threshold is ZERO, any elapsed time triggers prefix
1021        // In practice, Instant::now() moves forward, so this should show
1022        assert!(debouncer.should_show_prefix(false));
1023    }
1024}