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}