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