Skip to main content

ralph_workflow/json_parser/delta_display/
renderer_impls.rs

1// Delta renderer implementations.
2//
3// Contains TextDeltaRenderer and ThinkingDeltaRenderer implementations of the DeltaRenderer trait.
4
5/// Default implementation of `DeltaRenderer` for text content.
6///
7/// Supports true append-only streaming pattern that works correctly under
8/// line wrapping and in ANSI-stripping environments.
9///
10/// - First delta: prefix + content (no newline, stays on current line)
11/// - Subsequent deltas: **Parser computes and emits only new suffix**
12/// - Completion: single newline via `DeltaRenderer::render_completion`
13/// - Sanitizes newlines to spaces (to prevent artificial line breaks)
14/// - Applies consistent color formatting
15///
16/// # Output Pattern
17///
18/// ## Full Mode (TTY with capable terminal) - Append-Only Pattern
19///
20/// ```text
21/// [ccs-glm] Hello                    <- First delta: prefix + content, NO newline
22///  World                             <- Parser emits suffix: " World" (no prefix, no \r)
23/// \n                                  <- Completion: single newline
24/// ```
25///
26/// Result: Single logical line that may wrap to multiple terminal rows.
27/// Terminal handles wrapping naturally. No cursor movement means wrapping is not an issue.
28///
29/// ## Full Mode (Legacy Pattern - Deprecated)
30///
31/// Some parsers not yet implementing append-only may still use `render_subsequent_delta`
32/// which rewrites the line with `\r`. This pattern has known issues with wrapping:
33///
34/// ```text
35/// [ccs-glm] Hello                    <- First delta
36/// \r[ccs-glm] Hello World            <- Subsequent: carriage return + full rewrite
37/// ```
38///
39/// Issue: When content wraps, `\r` only returns to column 0 of current row, not
40/// start of logical line. This causes display corruption.
41///
42/// ## Basic/None Mode (non-TTY logs)
43///
44/// In non-TTY modes, per-delta output is suppressed to avoid repeated prefixed
45/// lines for partial updates. The parser is responsible for flushing the final
46/// accumulated content once at a completion boundary (e.g. `message_stop`).
47///
48/// ```text
49/// [ccs-glm] Hello World\n
50/// ```
51///
52/// # CCS Spam Prevention (Bug Fix)
53///
54/// This implementation prevents repeated prefixed lines for CCS agents (ccs/codex,
55/// ccs/glm) in non-TTY modes. The spam fix is validated with comprehensive regression
56/// tests that simulate real-world streaming scenarios:
57///
58/// - **Ultra-extreme delta counts:** Tests verify no spam with 1000+ deltas per content block
59/// - **Multi-turn sessions:** Validates 3+ turns with 200+ deltas each (600+ total)
60/// - **All delta types:** Covers text deltas, thinking deltas, and tool input deltas
61/// - **Real-world logs:** Tests with production logs containing 12,596 total deltas
62///
63/// The multi-line pattern (in-place updates) is the industry standard used by
64/// Rich, Ink, Bubble Tea, and other production CLI libraries for clean streaming
65/// output.
66///
67/// See regression tests:
68/// - `tests/integration_tests/ccs_delta_spam_systematic_reproduction.rs` (systematic reproduction & verification)
69/// - `tests/integration_tests/ccs_all_delta_types_spam_reproduction.rs` (1000+ deltas, edge case coverage)
70/// - `tests/integration_tests/ccs_extreme_streaming_regression.rs` (500+ deltas per block)
71/// - `tests/integration_tests/ccs_streaming_spam_all_deltas.rs` (all delta types)
72/// - `tests/integration_tests/ccs_real_world_log_regression.rs` (production log regression)
73/// - `tests/integration_tests/ccs_nuclear_full_log_regression.rs` (large captured logs)
74/// - `tests/integration_tests/codex_reasoning_spam_regression.rs` (Codex reasoning regression)
75/// - `tests/integration_tests/ccs_wrapping_waterfall_reproduction.rs` (wrapping waterfall reproduction)
76/// - `tests/integration_tests/ccs_wrapping_comprehensive.rs` (wrapping + append-only behavior)
77/// - `tests/integration_tests/ccs_ansi_stripping_console.rs` (ANSI-stripping console behavior)
78pub struct TextDeltaRenderer;
79
80impl DeltaRenderer for TextDeltaRenderer {
81    fn render_first_delta(
82        accumulated: &str,
83        prefix: &str,
84        colors: Colors,
85        terminal_mode: TerminalMode,
86    ) -> String {
87        // Sanitize content: replace newlines with spaces and collapse multiple whitespace
88        // NOTE: No truncation here - allow full content to accumulate during streaming
89        let sanitized = sanitize_for_display(accumulated);
90
91        match terminal_mode {
92            TerminalMode::Full => {
93                // Append-only pattern: prefix + content, NO NEWLINE
94                // This allows content to grow on same line without wrapping issues
95                format!(
96                    "{}[{}]{} {}{}{}",
97                    colors.dim(),
98                    prefix,
99                    colors.reset(),
100                    colors.white(),
101                    sanitized,
102                    colors.reset()
103                )
104            }
105            TerminalMode::Basic | TerminalMode::None => {
106                // SUPPRESS per-delta output in non-TTY modes to prevent spam.
107                // The accumulated content will be rendered ONCE at completion boundaries
108                // (message_stop, content_block_stop) by the parser layer.
109                // This prevents repeated prefixed lines in logs and CI output.
110                String::new()
111            }
112        }
113    }
114
115    fn render_subsequent_delta(
116        _accumulated: &str,
117        _prefix: &str,
118        _colors: Colors,
119        terminal_mode: TerminalMode,
120    ) -> String {
121        // DEPRECATED: This method implements a carriage return (\r) pattern that FAILS
122        // under terminal line wrapping. Parsers implementing the append-only pattern
123        // MUST NOT call this method in Full mode.
124        //
125        // WHY DEPRECATED:
126        // - The \r (carriage return) pattern rewrites the full line for each delta
127        // - When content exceeds terminal width and wraps to multiple rows, \r only
128        //   returns to column 0 of the CURRENT row, not the start of the logical line
129        // - This causes orphaned content on wrapped rows, creating a waterfall effect
130        //
131        // CORRECT PATTERN (used by ClaudeParser, CodexParser):
132        // - Parser tracks last rendered content
133        // - Parser computes suffix: new_suffix = current[last_rendered.len()..]
134        // - Parser emits ONLY the suffix directly (bypassing this method)
135        // - No prefix rewrite, no \r, no cursor movement
136        //
137        // This method returns empty string in Full mode to make tests fail explicitly
138        // if parsers incorrectly call it. Use render_first_delta + suffix emission instead.
139
140        match terminal_mode {
141            TerminalMode::Full => {
142                // CRITICAL: Parsers MUST NOT call this method in Full mode.
143                // Return empty string to make incorrect usage visible in tests.
144                //
145                // If you're seeing this in a test failure, the parser needs to:
146                // 1. Track last rendered content in parser state
147                // 2. Compute suffix directly: &sanitized[last_rendered.len()..]
148                // 3. Emit suffix with format!("{}{}{}",colors.white(), suffix, colors.reset())
149                //
150                // See ClaudeParser::handle_content_block_delta (lines 173-215) for correct pattern.
151                String::new()
152            }
153            TerminalMode::Basic | TerminalMode::None => {
154                // SUPPRESS per-delta output in non-TTY modes to prevent spam.
155                // The accumulated content will be rendered ONCE at completion boundaries
156                // (message_stop, content_block_stop) by the parser layer.
157                // This prevents repeated prefixed lines in logs and CI output.
158                String::new()
159            }
160        }
161    }
162}
163
164/// Renderer for streaming thinking deltas.
165///
166/// Supports the same append-only pattern as `TextDeltaRenderer`:
167/// - First delta: prefix + "Thinking: " + content (no newline)
168/// - Subsequent deltas: **Parser computes and emits only new suffix**
169/// - Completion: single newline via `DeltaRenderer::render_completion`
170///
171/// # Append-Only Pattern
172///
173/// For true append-only streaming in Full mode, parsers should:
174/// 1. Call `render_first_delta` for the first thinking delta (shows prefix + content)
175/// 2. Track last rendered content and emit only new suffixes directly (bypass `render_subsequent_delta`)
176/// 3. Call `render_completion` when thinking completes (adds final newline)
177///
178/// This avoids cursor movement and works correctly under terminal wrapping.
179///
180/// # CCS Spam Prevention (Bug Fix)
181///
182/// Like `TextDeltaRenderer`, this implementation suppresses per-delta output in non-TTY modes
183/// to prevent repeated "[ccs/codex] Thinking:" and "[ccs/glm] Thinking:" lines in logs.
184/// The fix is validated with ultra-extreme streaming tests (1000+ thinking deltas).
185///
186/// See comprehensive regression tests:
187/// - `tests/integration_tests/ccs_delta_spam_systematic_reproduction.rs` (NEW: systematic reproduction test)
188/// - `tests/integration_tests/ccs_all_delta_types_spam_reproduction.rs` (1000+ deltas, rapid succession, interleaved blocks)
189/// - `tests/integration_tests/ccs_extreme_streaming_regression.rs` (500+ deltas per block)
190/// - `tests/integration_tests/ccs_streaming_spam_all_deltas.rs` (all delta types)
191/// - `tests/integration_tests/codex_reasoning_spam_regression.rs` (original reasoning fix)
192pub struct ThinkingDeltaRenderer;
193
194impl DeltaRenderer for ThinkingDeltaRenderer {
195    fn render_first_delta(
196        accumulated: &str,
197        prefix: &str,
198        colors: Colors,
199        terminal_mode: TerminalMode,
200    ) -> String {
201        let sanitized = sanitize_for_display(accumulated);
202
203        match terminal_mode {
204            TerminalMode::Full => format!(
205                "{}[{}]{} {}Thinking: {}{}{}",
206                colors.dim(),
207                prefix,
208                colors.reset(),
209                colors.dim(),
210                colors.cyan(),
211                sanitized,
212                colors.reset()
213            ),
214            TerminalMode::Basic | TerminalMode::None => {
215                // SUPPRESS per-delta thinking output in non-TTY modes.
216                // Thinking content will be flushed ONCE at completion boundaries
217                // (message_stop for Claude, item.completed for Codex).
218                String::new()
219            }
220        }
221    }
222
223    fn render_subsequent_delta(
224        _accumulated: &str,
225        _prefix: &str,
226        _colors: Colors,
227        terminal_mode: TerminalMode,
228    ) -> String {
229        // DEPRECATED: This method implements a carriage return (\r) pattern that FAILS
230        // under terminal line wrapping. Parsers implementing the append-only pattern
231        // MUST NOT call this method in Full mode.
232        //
233        // WHY DEPRECATED:
234        // - The \r (carriage return) pattern rewrites the full line for each delta
235        // - When content exceeds terminal width and wraps to multiple rows, \r only
236        //   returns to column 0 of the CURRENT row, not the start of the logical line
237        // - This causes orphaned content on wrapped rows, creating a waterfall effect
238        //
239        // CORRECT PATTERN (used by ClaudeParser, CodexParser):
240        // - Parser tracks last rendered content for thinking deltas
241        // - Parser computes suffix: new_suffix = current[last_rendered.len()..]
242        // - Parser emits ONLY the suffix directly (bypassing this method)
243        // - No prefix rewrite, no \r, no cursor movement
244        //
245        // This method returns empty string in Full mode to make tests fail explicitly
246        // if parsers incorrectly call it. Use render_first_delta + suffix emission instead.
247
248        match terminal_mode {
249            TerminalMode::Full => {
250                // CRITICAL: Parsers MUST NOT call this method in Full mode.
251                // Return empty string to make incorrect usage visible in tests.
252                //
253                // If you're seeing this in a test failure, the parser needs to:
254                // 1. Track last rendered content in parser state (for thinking deltas)
255                // 2. Compute suffix directly: &sanitized[last_rendered.len()..]
256                // 3. Emit suffix with format!("{}{}{}",colors.cyan(), suffix, colors.reset())
257                //
258                // See ClaudeParser::handle_content_block_delta (thinking branch) for correct pattern.
259                String::new()
260            }
261            TerminalMode::Basic | TerminalMode::None => {
262                // SUPPRESS per-delta thinking output in non-TTY modes.
263                // Thinking content will be flushed ONCE at completion boundaries
264                // (message_stop for Claude, item.completed for Codex).
265                String::new()
266            }
267        }
268    }
269}