Skip to main content

ralph_workflow/json_parser/delta_display/
renderer_trait.rs

1// Delta renderer trait definition.
2//
3// Contains the DeltaRenderer trait and compute_append_only_suffix helper.
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#[cfg(any(test, debug_assertions))]
11use std::io::Write;
12//
13// ## Layer 1: Suppression at Renderer Level
14//
15// Delta renderers (`TextDeltaRenderer`, `ThinkingDeltaRenderer`) return empty strings
16// in `TerminalMode::Basic` and `TerminalMode::None` for both `render_first_delta` and
17// `render_subsequent_delta`. This prevents per-delta spam at the source.
18//
19// ## Layer 2: Accumulation in StreamingSession
20//
21// `StreamingSession` (in `streaming_state/session`) accumulates all content by
22// (ContentType, index) across deltas. This state is preserved across all delta
23// events for a single message.
24//
25// ## Layer 3: Flush at Completion Boundaries
26//
27// Parser layer (ClaudeParser, CodexParser) flushes accumulated content ONCE at
28// completion boundaries:
29// - ClaudeParser: `handle_message_stop` (in `claude/delta_handling.rs`)
30// - CodexParser: `item.completed` handlers (in `codex/event_handlers/*.rs`)
31//
32// This ensures:
33// - **Full mode (TTY)**: Real-time append-only streaming (no cursor movement)
34// - **Basic/None modes**: One prefixed line per content block, regardless of delta count
35//
36// ## Validation
37//
38// Regression tests validate this architecture:
39// - `ccs_delta_spam_systematic_reproduction.rs`: systematic reproduction (all delta types, both parsers, both modes)
40// - `ccs_all_delta_types_spam_reproduction.rs`: 1000+ deltas per block
41// - `ccs_streaming_spam_all_deltas.rs`: all delta types (text/thinking/tool)
42// - `ccs_nuclear_full_log_regression.rs`: large captured logs (thousands of deltas)
43// - `ccs_streaming_edge_cases.rs`: edge cases (empty deltas, rapid transitions)
44// - `ccs_wrapping_waterfall_reproduction.rs`: wrapping/cursor-up failure reproduction
45// - `ccs_ansi_stripping_console.rs`: ANSI-stripping console behavior
46// - `codex_reasoning_spam_regression.rs`: Codex reasoning regression
47
48/// Renderer for streaming delta content.
49///
50/// This trait defines the contract for rendering streaming deltas consistently
51/// across all parsers using the append-only pattern.
52///
53/// # Append-Only Pattern (Full Mode)
54///
55/// The renderer supports true append-only streaming that works correctly under
56/// terminal line wrapping and in ANSI-stripping environments:
57///
58/// 1. **First delta**: Shows prefix with accumulated content, NO newline
59///    - Example: `[ccs/glm] Hello`
60///    - No cursor movement, content stays on current line
61///
62/// 2. **Subsequent deltas**: Parser computes and emits ONLY new suffix
63///    - Parser responsibility: track last rendered content and emit only delta
64///    - Example: parser emits ` World` (just the new text with color codes)
65///    - NO prefix rewrite, NO `\r` (carriage return), NO cursor movement
66///    - Renderers provide `render_subsequent_delta` for backward compatibility
67///      but parsers implementing append-only should bypass it
68///
69/// 3. **Completion**: Single newline to finalize the line
70///    - Example: `\n`
71///    - Moves cursor to next line after streaming completes
72///
73/// This pattern works correctly even when content wraps to multiple terminal rows
74/// because there is NO cursor movement. The terminal naturally handles wrapping,
75/// and content appears to grow incrementally on the same logical line.
76///
77/// # Why Append-Only?
78///
79/// Previous patterns using `\r` (carriage return) or `\n\x1b[1A` (newline + cursor up)
80/// fail in two scenarios:
81///
82/// 1. **Line wrapping**: When content exceeds terminal width and wraps to multiple rows,
83///    `\r` only returns to column 0 of current row (not start of logical line), and
84///    `\x1b[1A` (cursor up 1 row) + `\x1b[2K` (clear 1 row) cannot erase all wrapped rows
85/// 2. **ANSI-stripping consoles**: Many CI/log environments strip or ignore ANSI sequences,
86///    so `\n` becomes a literal newline causing waterfall spam
87///
88/// Append-only streaming eliminates both issues by never using cursor movement.
89///
90/// # Non-TTY Modes (Basic/None)
91///
92/// Per-delta output is suppressed. Content is flushed ONCE at completion boundaries
93/// by the parser layer to prevent spam in logs and CI output.
94///
95/// # Rendering Rules
96///
97/// - `render_first_delta()`: Called for the first delta of a content block
98///   - Must include prefix
99///   - Must NOT include newline (stays on current line for append-only)
100///   - Shows the accumulated content so far
101///
102/// - `render_subsequent_delta()`: Called for subsequent deltas
103///   - **Parsers implementing append-only MUST compute the suffix and bypass this method**
104///   - Renderer implementations in this repo intentionally return empty strings in all modes
105///     to avoid reintroducing cursor/CR patterns.
106///
107/// - `render_completion()`: Called when streaming completes
108///   - Returns single newline (`\n`) in Full mode to finalize the line
109///   - Returns empty string in Basic/None mode (parser already flushed with newline)
110///
111/// # Terminal Mode Awareness
112///
113/// The renderer automatically adapts output based on terminal capability:
114/// - **Full mode**: Append-only streaming (no cursor movement during deltas)
115/// - **Basic mode**: Per-delta output suppressed; parser flushes once at completion
116/// - **None mode**: Per-delta output suppressed; parser flushes once at completion, plain text
117///
118/// # Example
119///
120/// ```ignore
121/// use crate::json_parser::delta_display::DeltaRenderer;
122/// use crate::logger::Colors;
123/// use crate::json_parser::TerminalMode;
124///
125/// let colors = Colors { enabled: true };
126/// let terminal_mode = TerminalMode::detect();
127///
128/// // First chunk
129/// let output = DeltaRenderer::render_first_delta(
130///     "Hello",
131///     "ccs-glm",
132///     colors,
133///     terminal_mode
134/// );
135///
136/// // Second chunk
137/// let output = DeltaRenderer::render_subsequent_delta(
138///     "Hello World",
139///     "ccs-glm",
140///     colors,
141///     terminal_mode
142/// );
143///
144/// // Complete
145/// let output = DeltaRenderer::render_completion(terminal_mode);
146/// ```
147pub trait DeltaRenderer {
148    /// Render the first delta of a content block.
149    ///
150    /// This is called when streaming begins for a new content block.
151    /// The output should include the prefix and the accumulated content.
152    ///
153    /// # Arguments
154    /// * `accumulated` - The full accumulated content so far
155    /// * `prefix` - The agent prefix (e.g., "ccs-glm")
156    /// * `colors` - Terminal colors
157    /// * `terminal_mode` - The detected terminal capability mode
158    ///
159    /// # Returns
160    /// A formatted string with prefix and content.
161    ///
162    /// In Full mode, this MUST NOT include a trailing newline or any cursor movement.
163    /// (Append-only streaming keeps the cursor on the current line until completion.)
164    ///
165    /// In Basic/None modes, returns an empty string (per-delta output is suppressed; the parser
166    /// flushes the final newline-terminated content at completion boundaries).
167    fn render_first_delta(
168        accumulated: &str,
169        prefix: &str,
170        colors: Colors,
171        terminal_mode: TerminalMode,
172    ) -> String;
173
174    /// Render a subsequent delta (in-place update).
175    ///
176    /// This is called for all deltas after the first. The output should
177    /// clear the entire line and rewrite with the prefix and accumulated content
178    /// in Full mode, or append content in Basic/None mode.
179    ///
180    /// # Arguments
181    /// * `accumulated` - The full accumulated content so far
182    /// * `prefix` - The agent prefix (e.g., "ccs-glm")
183    /// * `colors` - Terminal colors
184    /// * `terminal_mode` - The detected terminal capability mode
185    ///
186    /// # Returns
187    /// A formatted string representing the delta.
188    ///
189    /// In the append-only contract, parsers should NOT call this method in Full mode; they should
190    /// compute the new suffix and emit it directly. The default renderer implementations return
191    /// empty strings to make incorrect usage obvious.
192    ///
193    /// In Basic/None modes, this returns an empty string (per-delta output is suppressed).
194    fn render_subsequent_delta(
195        accumulated: &str,
196        prefix: &str,
197        colors: Colors,
198        terminal_mode: TerminalMode,
199    ) -> String;
200
201    /// Render the completion of streaming.
202    ///
203    /// This is called when streaming completes to finalize the line.
204    /// In Full mode with append-only pattern, this emits a single newline to complete the line.
205    ///
206    /// The streamed content is already visible on the terminal from previous deltas.
207    /// This method simply adds the final newline for proper line termination.
208    ///
209    /// # Arguments
210    /// * `terminal_mode` - The detected terminal capability mode
211    ///
212    /// # Returns
213    /// A string with appropriate completion sequence for the terminal mode.
214    #[must_use]
215    fn render_completion(terminal_mode: TerminalMode) -> String {
216        match terminal_mode {
217            TerminalMode::Full => "\n".to_string(), // Single newline at end for append-only pattern
218            // In non-TTY modes, streamed output is suppressed and the parser flushes
219            // newline-terminated content at completion boundaries. Returning a newline here
220            // would add an extra blank line if a caller invokes `render_completion`.
221            TerminalMode::Basic | TerminalMode::None => String::new(),
222        }
223    }
224}
225
226/// Compute the append-only suffix to emit for a snapshot-style accumulated string.
227///
228/// Providers differ in what they send as a "delta": some stream true incremental suffixes,
229/// others send the full accumulated content repeatedly (snapshot-style). Our append-only
230/// rendering contract treats the parser's sanitized accumulated content as the source of truth.
231///
232/// Given the last rendered sanitized content and the current sanitized content, return
233/// the string that should be appended to the terminal to advance the visible output.
234///
235/// Rules:
236/// - If `last_rendered` is empty, emit `current` (first delta).
237/// - If `current` starts with `last_rendered`, emit the new suffix only.
238/// - Otherwise, treat as a discontinuity/reset and emit an empty suffix.
239///
240/// ## Why discontinuities emit nothing
241///
242/// In an append-only renderer, emitting `current` on a discontinuity would append an entire
243/// replacement snapshot onto already-rendered output, producing duplicated/corrupted display.
244/// Callers that need to surface a reset must do so explicitly (e.g., finalize the current line
245/// and start a new one).
246///
247/// ## Discontinuity Detection
248///
249/// A discontinuity occurs when `current` does not start with `last_rendered` (i.e.,
250/// `current.strip_prefix(last_rendered)` returns `None`). This indicates:
251/// - Non-monotonic deltas from the provider (e.g., "Hello World" followed by "Hello Universe")
252/// - Protocol violations where content changes unexpectedly
253/// - Content resets that should be handled explicitly by the caller
254///
255/// When a discontinuity is detected, this function returns an empty string. Callers should
256/// detect this condition (when both `last_rendered` and `current` are non-empty but the
257/// result is empty) and emit appropriate warnings or metrics to track provider behavior.
258#[must_use]
259pub fn compute_append_only_suffix<'a>(last_rendered: &str, current: &'a str) -> &'a str {
260    if last_rendered.is_empty() {
261        return current;
262    }
263    let suffix = current.strip_prefix(last_rendered).unwrap_or_default();
264    debug_log_discontinuity(last_rendered, current, suffix);
265    suffix
266}
267
268#[cfg(debug_assertions)]
269fn debug_log_discontinuity(last_rendered: &str, current: &str, suffix: &str) {
270    if suffix.is_empty() && !current.is_empty() && !last_rendered.is_empty() {
271        let _ = writeln!(
272            std::io::stderr(),
273            "Debug: Delta discontinuity detected in compute_append_only_suffix. \
274             Last rendered: {:?} (len={}), Current: {:?} (len={}). \
275             This may indicate non-monotonic deltas from the provider.",
276            &last_rendered[..last_rendered.len().min(50)],
277            last_rendered.len(),
278            &current[..current.len().min(50)],
279            current.len()
280        );
281    }
282}
283
284#[cfg(not(debug_assertions))]
285#[inline]
286fn debug_log_discontinuity(_last_rendered: &str, _current: &str, _suffix: &str) {}