Skip to main content

ralph_workflow/json_parser/delta_display/
renderer.rs

1// Delta renderer trait and implementations.
2//
3// Contains the DeltaRenderer trait and TextDeltaRenderer implementation.
4//
5// # CCS Spam Prevention Architecture
6//
7// This module implements a three-layer approach to prevent repeated prefixed lines
8// for streaming deltas in non-TTY modes (logs, CI output):
9//
10// ## Layer 1: Suppression at Renderer Level
11//
12// Delta renderers (`TextDeltaRenderer`, `ThinkingDeltaRenderer`) return empty strings
13// in `TerminalMode::Basic` and `TerminalMode::None` for both `render_first_delta` and
14// `render_subsequent_delta`. This prevents per-delta spam at the source.
15//
16// ## Layer 2: Accumulation in StreamingSession
17//
18// `StreamingSession` (in `streaming_state/session`) accumulates all content by
19// (ContentType, index) across deltas. This state is preserved across all delta
20// events for a single message.
21//
22// ## Layer 3: Flush at Completion Boundaries
23//
24// Parser layer (ClaudeParser, CodexParser) flushes accumulated content ONCE at
25// completion boundaries:
26// - ClaudeParser: `handle_message_stop` (in `claude/delta_handling.rs`)
27// - CodexParser: `item.completed` handlers (in `codex/event_handlers/*.rs`)
28//
29// This ensures:
30// - **Full mode (TTY)**: Real-time append-only streaming (no cursor movement)
31// - **Basic/None modes**: One prefixed line per content block, regardless of delta count
32//
33// ## Validation
34//
35// Regression tests validate this architecture:
36// - `ccs_delta_spam_systematic_reproduction.rs`: systematic reproduction (all delta types, both parsers, both modes)
37// - `ccs_all_delta_types_spam_reproduction.rs`: 1000+ deltas per block
38// - `ccs_streaming_spam_all_deltas.rs`: all delta types (text/thinking/tool)
39// - `ccs_nuclear_full_log_regression.rs`: large captured logs (thousands of deltas)
40// - `ccs_streaming_edge_cases.rs`: edge cases (empty deltas, rapid transitions)
41// - `ccs_wrapping_waterfall_reproduction.rs`: wrapping/cursor-up failure reproduction
42// - `ccs_ansi_stripping_console.rs`: ANSI-stripping console behavior
43// - `codex_reasoning_spam_regression.rs`: Codex reasoning regression
44
45/// Renderer for streaming delta content.
46///
47/// This trait defines the contract for rendering streaming deltas consistently
48/// across all parsers using the append-only pattern.
49///
50/// # Append-Only Pattern (Full Mode)
51///
52/// The renderer supports true append-only streaming that works correctly under
53/// terminal line wrapping and in ANSI-stripping environments:
54///
55/// 1. **First delta**: Shows prefix with accumulated content, NO newline
56///    - Example: `[ccs/glm] Hello`
57///    - No cursor movement, content stays on current line
58///
59/// 2. **Subsequent deltas**: Parser computes and emits ONLY new suffix
60///    - Parser responsibility: track last rendered content and emit only delta
61///    - Example: parser emits ` World` (just the new text with color codes)
62///    - NO prefix rewrite, NO `\r` (carriage return), NO cursor movement
63///    - Renderers provide `render_subsequent_delta` for backward compatibility
64///      but parsers implementing append-only should bypass it
65///
66/// 3. **Completion**: Single newline to finalize the line
67///    - Example: `\n`
68///    - Moves cursor to next line after streaming completes
69///
70/// This pattern works correctly even when content wraps to multiple terminal rows
71/// because there is NO cursor movement. The terminal naturally handles wrapping,
72/// and content appears to grow incrementally on the same logical line.
73///
74/// # Why Append-Only?
75///
76/// Previous patterns using `\r` (carriage return) or `\n\x1b[1A` (newline + cursor up)
77/// fail in two scenarios:
78///
79/// 1. **Line wrapping**: When content exceeds terminal width and wraps to multiple rows,
80///    `\r` only returns to column 0 of current row (not start of logical line), and
81///    `\x1b[1A` (cursor up 1 row) + `\x1b[2K` (clear 1 row) cannot erase all wrapped rows
82/// 2. **ANSI-stripping consoles**: Many CI/log environments strip or ignore ANSI sequences,
83///    so `\n` becomes a literal newline causing waterfall spam
84///
85/// Append-only streaming eliminates both issues by never using cursor movement.
86///
87/// # Non-TTY Modes (Basic/None)
88///
89/// Per-delta output is suppressed. Content is flushed ONCE at completion boundaries
90/// by the parser layer to prevent spam in logs and CI output.
91///
92/// # Rendering Rules
93///
94/// - `render_first_delta()`: Called for the first delta of a content block
95///   - Must include prefix
96///   - Must NOT include newline (stays on current line for append-only)
97///   - Shows the accumulated content so far
98///
99/// - `render_subsequent_delta()`: Called for subsequent deltas
100///   - **Parsers implementing append-only MUST compute the suffix and bypass this method**
101///   - Renderer implementations in this repo intentionally return empty strings in all modes
102///     to avoid reintroducing cursor/CR patterns.
103///
104/// - `render_completion()`: Called when streaming completes
105///   - Returns single newline (`\n`) in Full mode to finalize the line
106///   - Returns empty string in Basic/None mode (parser already flushed with newline)
107///
108/// # Terminal Mode Awareness
109///
110/// The renderer automatically adapts output based on terminal capability:
111/// - **Full mode**: Append-only streaming (no cursor movement during deltas)
112/// - **Basic mode**: Per-delta output suppressed; parser flushes once at completion
113/// - **None mode**: Per-delta output suppressed; parser flushes once at completion, plain text
114///
115/// # Example
116///
117/// ```ignore
118/// use crate::json_parser::delta_display::DeltaRenderer;
119/// use crate::logger::Colors;
120/// use crate::json_parser::TerminalMode;
121///
122/// let colors = Colors { enabled: true };
123/// let terminal_mode = TerminalMode::detect();
124///
125/// // First chunk
126/// let output = DeltaRenderer::render_first_delta(
127///     "Hello",
128///     "ccs-glm",
129///     colors,
130///     terminal_mode
131/// );
132///
133/// // Second chunk
134/// let output = DeltaRenderer::render_subsequent_delta(
135///     "Hello World",
136///     "ccs-glm",
137///     colors,
138///     terminal_mode
139/// );
140///
141/// // Complete
142/// let output = DeltaRenderer::render_completion(terminal_mode);
143/// ```
144pub trait DeltaRenderer {
145    /// Render the first delta of a content block.
146    ///
147    /// This is called when streaming begins for a new content block.
148    /// The output should include the prefix and the accumulated content.
149    ///
150    /// # Arguments
151    /// * `accumulated` - The full accumulated content so far
152    /// * `prefix` - The agent prefix (e.g., "ccs-glm")
153    /// * `colors` - Terminal colors
154    /// * `terminal_mode` - The detected terminal capability mode
155    ///
156    /// # Returns
157    /// A formatted string with prefix and content.
158    ///
159    /// In Full mode, this MUST NOT include a trailing newline or any cursor movement.
160    /// (Append-only streaming keeps the cursor on the current line until completion.)
161    ///
162    /// In Basic/None modes, returns an empty string (per-delta output is suppressed; the parser
163    /// flushes the final newline-terminated content at completion boundaries).
164    fn render_first_delta(
165        accumulated: &str,
166        prefix: &str,
167        colors: Colors,
168        terminal_mode: TerminalMode,
169    ) -> String;
170
171    /// Render a subsequent delta (in-place update).
172    ///
173    /// This is called for all deltas after the first. The output should
174    /// clear the entire line and rewrite with the prefix and accumulated content
175    /// in Full mode, or append content in Basic/None mode.
176    ///
177    /// # Arguments
178    /// * `accumulated` - The full accumulated content so far
179    /// * `prefix` - The agent prefix (e.g., "ccs-glm")
180    /// * `colors` - Terminal colors
181    /// * `terminal_mode` - The detected terminal capability mode
182    ///
183    /// # Returns
184    /// A formatted string representing the delta.
185    ///
186    /// In the append-only contract, parsers should NOT call this method in Full mode; they should
187    /// compute the new suffix and emit it directly. The default renderer implementations return
188    /// empty strings to make incorrect usage obvious.
189    ///
190    /// In Basic/None modes, this returns an empty string (per-delta output is suppressed).
191    fn render_subsequent_delta(
192        accumulated: &str,
193        prefix: &str,
194        colors: Colors,
195        terminal_mode: TerminalMode,
196    ) -> String;
197
198    /// Render the completion of streaming.
199    ///
200    /// This is called when streaming completes to finalize the line.
201    /// In Full mode with append-only pattern, this emits a single newline to complete the line.
202    ///
203    /// The streamed content is already visible on the terminal from previous deltas.
204    /// This method simply adds the final newline for proper line termination.
205    ///
206    /// # Arguments
207    /// * `terminal_mode` - The detected terminal capability mode
208    ///
209    /// # Returns
210    /// A string with appropriate completion sequence for the terminal mode.
211    fn render_completion(terminal_mode: TerminalMode) -> String {
212        match terminal_mode {
213            TerminalMode::Full => "\n".to_string(), // Single newline at end for append-only pattern
214            // In non-TTY modes, streamed output is suppressed and the parser flushes
215            // newline-terminated content at completion boundaries. Returning a newline here
216            // would add an extra blank line if a caller invokes `render_completion`.
217            TerminalMode::Basic | TerminalMode::None => String::new(),
218        }
219    }
220}
221
222/// Default implementation of `DeltaRenderer` for text content.
223///
224/// Supports true append-only streaming pattern that works correctly under
225/// line wrapping and in ANSI-stripping environments.
226///
227/// - First delta: prefix + content (no newline, stays on current line)
228/// - Subsequent deltas: **Parser computes and emits only new suffix**
229/// - Completion: single newline to finalize the line
230/// - Sanitizes newlines to spaces (to prevent artificial line breaks)
231/// - Applies consistent color formatting
232///
233/// # Output Pattern
234///
235/// ## Full Mode (TTY with capable terminal) - Append-Only Pattern
236///
237/// ```text
238/// [ccs-glm] Hello                    <- First delta: prefix + content, NO newline
239///  World                             <- Parser emits suffix: " World" (no prefix, no \r)
240/// \n                                  <- Completion: single newline
241/// ```
242///
243/// Result: Single logical line that may wrap to multiple terminal rows.
244/// Terminal handles wrapping naturally. No cursor movement means wrapping is not an issue.
245///
246/// ## Full Mode (Legacy Pattern - Deprecated)
247///
248/// Some parsers not yet implementing append-only may still use `render_subsequent_delta`
249/// which rewrites the line with `\r`. This pattern has known issues with wrapping:
250///
251/// ```text
252/// [ccs-glm] Hello                    <- First delta
253/// \r[ccs-glm] Hello World            <- Subsequent: carriage return + full rewrite
254/// ```
255///
256/// Issue: When content wraps, `\r` only returns to column 0 of current row, not
257/// start of logical line. This causes display corruption.
258///
259/// ## Basic/None Mode (non-TTY logs)
260///
261/// In non-TTY modes, per-delta output is suppressed to avoid repeated prefixed
262/// lines for partial updates. The parser is responsible for flushing the final
263/// accumulated content once at a completion boundary (e.g. `message_stop`).
264///
265/// ```text
266/// [ccs-glm] Hello World\n
267/// ```
268///
269/// # CCS Spam Prevention (Bug Fix)
270///
271/// This implementation prevents repeated prefixed lines for CCS agents (ccs/codex,
272/// ccs/glm) in non-TTY modes. The spam fix is validated with comprehensive regression
273/// tests that simulate real-world streaming scenarios:
274///
275/// - **Ultra-extreme delta counts:** Tests verify no spam with 1000+ deltas per content block
276/// - **Multi-turn sessions:** Validates 3+ turns with 200+ deltas each (600+ total)
277/// - **All delta types:** Covers text deltas, thinking deltas, and tool input deltas
278/// - **Real-world logs:** Tests with production logs containing 12,596 total deltas
279///
280/// The multi-line pattern (in-place updates) is the industry standard used by
281/// Rich, Ink, Bubble Tea, and other production CLI libraries for clean streaming
282/// output.
283///
284/// See regression tests:
285/// - `tests/integration_tests/ccs_delta_spam_systematic_reproduction.rs` (systematic reproduction & verification)
286/// - `tests/integration_tests/ccs_all_delta_types_spam_reproduction.rs` (1000+ deltas, edge case coverage)
287/// - `tests/integration_tests/ccs_extreme_streaming_regression.rs` (500+ deltas per block)
288/// - `tests/integration_tests/ccs_streaming_spam_all_deltas.rs` (all delta types)
289/// - `tests/integration_tests/ccs_real_world_log_regression.rs` (production log regression)
290/// - `tests/integration_tests/ccs_nuclear_full_log_regression.rs` (large captured logs)
291/// - `tests/integration_tests/codex_reasoning_spam_regression.rs` (Codex reasoning regression)
292/// - `tests/integration_tests/ccs_wrapping_waterfall_reproduction.rs` (wrapping waterfall reproduction)
293/// - `tests/integration_tests/ccs_wrapping_comprehensive.rs` (wrapping + append-only behavior)
294/// - `tests/integration_tests/ccs_ansi_stripping_console.rs` (ANSI-stripping console behavior)
295///
296/// Compute the append-only suffix to emit for a snapshot-style accumulated string.
297///
298/// Providers differ in what they send as a "delta": some stream true incremental suffixes,
299/// others send the full accumulated content repeatedly (snapshot-style). Our append-only
300/// rendering contract treats the parser's sanitized accumulated content as the source of truth.
301///
302/// Given the last rendered sanitized content and the current sanitized content, return
303/// the string that should be appended to the terminal to advance the visible output.
304///
305/// Rules:
306/// - If `last_rendered` is empty, emit `current` (first delta).
307/// - If `current` starts with `last_rendered`, emit the new suffix only.
308/// - Otherwise, treat as a discontinuity/reset and emit an empty suffix.
309///
310/// ## Why discontinuities emit nothing
311///
312/// In an append-only renderer, emitting `current` on a discontinuity would append an entire
313/// replacement snapshot onto already-rendered output, producing duplicated/corrupted display.
314/// Callers that need to surface a reset must do so explicitly (e.g., finalize the current line
315/// and start a new one).
316///
317/// ## Discontinuity Detection
318///
319/// A discontinuity occurs when `current` does not start with `last_rendered` (i.e.,
320/// `current.strip_prefix(last_rendered)` returns `None`). This indicates:
321/// - Non-monotonic deltas from the provider (e.g., "Hello World" followed by "Hello Universe")
322/// - Protocol violations where content changes unexpectedly
323/// - Content resets that should be handled explicitly by the caller
324///
325/// When a discontinuity is detected, this function returns an empty string. Callers should
326/// detect this condition (when both `last_rendered` and `current` are non-empty but the
327/// result is empty) and emit appropriate warnings or metrics to track provider behavior.
328pub fn compute_append_only_suffix<'a>(last_rendered: &str, current: &'a str) -> &'a str {
329    if last_rendered.is_empty() {
330        return current;
331    }
332
333    let suffix = current.strip_prefix(last_rendered).unwrap_or_default();
334
335    // Debug assertion to help detect unexpected discontinuities during development
336    #[cfg(debug_assertions)]
337    if suffix.is_empty() && !current.is_empty() && !last_rendered.is_empty() {
338        eprintln!(
339            "Debug: Delta discontinuity detected in compute_append_only_suffix. \
340             Last rendered: {:?} (len={}), Current: {:?} (len={}). \
341             This may indicate non-monotonic deltas from the provider.",
342            &last_rendered[..last_rendered.len().min(50)],
343            last_rendered.len(),
344            &current[..current.len().min(50)],
345            current.len()
346        );
347    }
348
349    suffix
350}
351
352pub struct TextDeltaRenderer;
353
354impl DeltaRenderer for TextDeltaRenderer {
355    fn render_first_delta(
356        accumulated: &str,
357        prefix: &str,
358        colors: Colors,
359        terminal_mode: TerminalMode,
360    ) -> String {
361        // Sanitize content: replace newlines with spaces and collapse multiple whitespace
362        // NOTE: No truncation here - allow full content to accumulate during streaming
363        let sanitized = sanitize_for_display(accumulated);
364
365        match terminal_mode {
366            TerminalMode::Full => {
367                // Append-only pattern: prefix + content, NO NEWLINE
368                // This allows content to grow on same line without wrapping issues
369                format!(
370                    "{}[{}]{} {}{}{}",
371                    colors.dim(),
372                    prefix,
373                    colors.reset(),
374                    colors.white(),
375                    sanitized,
376                    colors.reset()
377                )
378            }
379            TerminalMode::Basic | TerminalMode::None => {
380                // SUPPRESS per-delta output in non-TTY modes to prevent spam.
381                // The accumulated content will be rendered ONCE at completion boundaries
382                // (message_stop, content_block_stop) by the parser layer.
383                // This prevents repeated prefixed lines in logs and CI output.
384                String::new()
385            }
386        }
387    }
388
389    fn render_subsequent_delta(
390        _accumulated: &str,
391        _prefix: &str,
392        _colors: Colors,
393        terminal_mode: TerminalMode,
394    ) -> String {
395        // DEPRECATED: This method implements a carriage return (\r) pattern that FAILS
396        // under terminal line wrapping. Parsers implementing the append-only pattern
397        // MUST NOT call this method in Full mode.
398        //
399        // WHY DEPRECATED:
400        // - The \r (carriage return) pattern rewrites the full line for each delta
401        // - When content exceeds terminal width and wraps to multiple rows, \r only
402        //   returns to column 0 of the CURRENT row, not the start of the logical line
403        // - This causes orphaned content on wrapped rows, creating a waterfall effect
404        //
405        // CORRECT PATTERN (used by ClaudeParser, CodexParser):
406        // - Parser tracks last rendered content
407        // - Parser computes suffix: new_suffix = current[last_rendered.len()..]
408        // - Parser emits ONLY the suffix directly (bypassing this method)
409        // - No prefix rewrite, no \r, no cursor movement
410        //
411        // This method returns empty string in Full mode to make tests fail explicitly
412        // if parsers incorrectly call it. Use render_first_delta + suffix emission instead.
413
414        match terminal_mode {
415            TerminalMode::Full => {
416                // CRITICAL: Parsers MUST NOT call this method in Full mode.
417                // Return empty string to make incorrect usage visible in tests.
418                //
419                // If you're seeing this in a test failure, the parser needs to:
420                // 1. Track last rendered content in parser state
421                // 2. Compute suffix directly: &sanitized[last_rendered.len()..]
422                // 3. Emit suffix with format!("{}{}{}",colors.white(), suffix, colors.reset())
423                //
424                // See ClaudeParser::handle_content_block_delta (lines 173-215) for correct pattern.
425                String::new()
426            }
427            TerminalMode::Basic | TerminalMode::None => {
428                // SUPPRESS per-delta output in non-TTY modes to prevent spam.
429                // The accumulated content will be rendered ONCE at completion boundaries
430                // (message_stop, content_block_stop) by the parser layer.
431                // This prevents repeated prefixed lines in logs and CI output.
432                String::new()
433            }
434        }
435    }
436}
437
438/// Renderer for streaming thinking deltas.
439///
440/// Supports the same append-only pattern as `TextDeltaRenderer`:
441/// - First delta: prefix + "Thinking: " + content (no newline)
442/// - Subsequent deltas: **Parser computes and emits only new suffix**
443/// - Completion: single newline via `DeltaRenderer::render_completion`
444///
445/// # Append-Only Pattern
446///
447/// For true append-only streaming in Full mode, parsers should:
448/// 1. Call `render_first_delta` for the first thinking delta (shows prefix + content)
449/// 2. Track last rendered content and emit only new suffixes directly (bypass `render_subsequent_delta`)
450/// 3. Call `render_completion` when thinking completes (adds final newline)
451///
452/// This avoids cursor movement and works correctly under terminal wrapping.
453///
454/// # CCS Spam Prevention (Bug Fix)
455///
456/// Like `TextDeltaRenderer`, this implementation suppresses per-delta output in non-TTY modes
457/// to prevent repeated "[ccs/codex] Thinking:" and "[ccs/glm] Thinking:" lines in logs.
458/// The fix is validated with ultra-extreme streaming tests (1000+ thinking deltas).
459///
460/// See comprehensive regression tests:
461/// - `tests/integration_tests/ccs_delta_spam_systematic_reproduction.rs` (NEW: systematic reproduction test)
462/// - `tests/integration_tests/ccs_all_delta_types_spam_reproduction.rs` (1000+ deltas, rapid succession, interleaved blocks)
463/// - `tests/integration_tests/ccs_extreme_streaming_regression.rs` (500+ deltas per block)
464/// - `tests/integration_tests/ccs_streaming_spam_all_deltas.rs` (all delta types)
465/// - `tests/integration_tests/codex_reasoning_spam_regression.rs` (original reasoning fix)
466pub struct ThinkingDeltaRenderer;
467
468impl DeltaRenderer for ThinkingDeltaRenderer {
469    fn render_first_delta(
470        accumulated: &str,
471        prefix: &str,
472        colors: Colors,
473        terminal_mode: TerminalMode,
474    ) -> String {
475        let sanitized = sanitize_for_display(accumulated);
476
477        match terminal_mode {
478            TerminalMode::Full => format!(
479                "{}[{}]{} {}Thinking: {}{}{}",
480                colors.dim(),
481                prefix,
482                colors.reset(),
483                colors.dim(),
484                colors.cyan(),
485                sanitized,
486                colors.reset()
487            ),
488            TerminalMode::Basic | TerminalMode::None => {
489                // SUPPRESS per-delta thinking output in non-TTY modes.
490                // Thinking content will be flushed ONCE at completion boundaries
491                // (message_stop for Claude, item.completed for Codex).
492                String::new()
493            }
494        }
495    }
496
497    fn render_subsequent_delta(
498        _accumulated: &str,
499        _prefix: &str,
500        _colors: Colors,
501        terminal_mode: TerminalMode,
502    ) -> String {
503        // DEPRECATED: This method implements a carriage return (\r) pattern that FAILS
504        // under terminal line wrapping. Parsers implementing the append-only pattern
505        // MUST NOT call this method in Full mode.
506        //
507        // WHY DEPRECATED:
508        // - The \r (carriage return) pattern rewrites the full line for each delta
509        // - When content exceeds terminal width and wraps to multiple rows, \r only
510        //   returns to column 0 of the CURRENT row, not the start of the logical line
511        // - This causes orphaned content on wrapped rows, creating a waterfall effect
512        //
513        // CORRECT PATTERN (used by ClaudeParser, CodexParser):
514        // - Parser tracks last rendered content for thinking deltas
515        // - Parser computes suffix: new_suffix = current[last_rendered.len()..]
516        // - Parser emits ONLY the suffix directly (bypassing this method)
517        // - No prefix rewrite, no \r, no cursor movement
518        //
519        // This method returns empty string in Full mode to make tests fail explicitly
520        // if parsers incorrectly call it. Use render_first_delta + suffix emission instead.
521
522        match terminal_mode {
523            TerminalMode::Full => {
524                // CRITICAL: Parsers MUST NOT call this method in Full mode.
525                // Return empty string to make incorrect usage visible in tests.
526                //
527                // If you're seeing this in a test failure, the parser needs to:
528                // 1. Track last rendered content in parser state (for thinking deltas)
529                // 2. Compute suffix directly: &sanitized[last_rendered.len()..]
530                // 3. Emit suffix with format!("{}{}{}",colors.cyan(), suffix, colors.reset())
531                //
532                // See ClaudeParser::handle_content_block_delta (thinking branch) for correct pattern.
533                String::new()
534            }
535            TerminalMode::Basic | TerminalMode::None => {
536                // SUPPRESS per-delta thinking output in non-TTY modes.
537                // Thinking content will be flushed ONCE at completion boundaries
538                // (message_stop for Claude, item.completed for Codex).
539                String::new()
540            }
541        }
542    }
543}