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}