返回 CodeWhale
streaming_thinking.rs
根目录 / crates / tui / src / tui / streaming_thinking.rs
1 //! Streaming-thinking lifecycle for the active cell.
2 //!
3 //! DeepSeek V4 emits `reasoning_content` chunks before final answers.
4 //! These get rendered as a "Thinking" entry inside the per-turn active
5 //! cell. This module is the single source of truth for:
6 //!
7 //! - creating a streaming thinking entry on first chunk
8 //! - appending chunks to the live entry
9 //! - showing a localized placeholder while a translation is in-flight
10 //! (and animating its elapsed/spinner suffix)
11 //! - replacing the placeholder when the translation arrives
12 //! - finalizing the entry (stopping the spinner, stamping duration)
13 //! when a thinking block ends
14 //! - stashing the reasoning buffer onto `app.last_reasoning` so the
15 //! summary survives compaction
16
17 use std::time::Duration;
18 use std::time::Instant;
19
20 use crate::tui::active_cell::ActiveCell;
21 use crate::tui::app::App;
22 use crate::tui::history::HistoryCell;
23
24 /// Debounce window for active-cell revision bumps while a thinking block is
25 /// streaming (#1620). Reasoning deltas arrive far faster than the eye can
26 /// follow, and each revision bump invalidates the active cell's wrap cache,
27 /// forcing a full re-wrap of the live tail. Coalescing intermediate bumps to
28 /// one per window keeps the perceived stream smooth without re-wrapping per
29 /// character. ~100ms ≈ 10 intermediate repaints/sec, well below the 120 FPS
30 /// frame cap (see `frame_rate_limiter`) yet imperceptible as lag.
31 ///
32 /// Correctness: this only skips *intermediate* repaints. Appended content is
33 /// never dropped — it lands in the cell immediately — and finalize always
34 /// forces a bump so the final reasoning text is fully rendered.
35 const THINKING_REVISION_THROTTLE: Duration = Duration::from_millis(100);
36
37 /// Bump the active-cell revision for a streaming thinking mutation, but at
38 /// most once per [`THINKING_REVISION_THROTTLE`] window. Returns whether a bump
39 /// was actually emitted. Skipped bumps coalesce into the next one (or into the
40 /// forced finalize bump), so no content is ever lost — only redundant
41 /// intermediate re-wraps are dropped.
42 fn bump_thinking_revision_throttled(app: &mut App, now: Instant) -> bool {
43 let due = app
44 .thinking_revision_last_bump_at
45 .is_none_or(|last| now.saturating_duration_since(last) >= THINKING_REVISION_THROTTLE);
46 if due {
47 app.thinking_revision_last_bump_at = Some(now);
48 app.bump_active_cell_revision();
49 }
50 due
51 }
52
53 /// Ensure an in-flight Thinking entry exists in `active_cell` and return its
54 /// entry index. If no thinking entry is currently streaming, push a fresh one.
55 /// P2.3: thinking shares the active cell with subsequent tool calls so the
56 /// pair render as one logical "Working…" block.
57 pub(super) fn ensure_active_entry(app: &mut App) -> usize {
58 if let Some(idx) = app.streaming_thinking_active_entry {
59 return idx;
60 }
61 if app.active_cell.is_none() {
62 app.active_cell = Some(ActiveCell::new());
63 }
64 let active = app.active_cell.as_mut().expect("active_cell just ensured");
65 let entry_idx = active.push_thinking(HistoryCell::Thinking {
66 content: String::new(),
67 streaming: true,
68 duration_secs: None,
69 });
70 app.streaming_thinking_active_entry = Some(entry_idx);
71 app.bump_active_cell_revision();
72 entry_idx
73 }
74
75 /// Append text to a streaming Thinking entry inside `active_cell`. The text is
76 /// committed to the cell immediately; the active-cell revision bump that
77 /// triggers a re-wrap of the live tail is debounced to at most one per
78 /// [`THINKING_REVISION_THROTTLE`] window (#1620). Skipped bumps coalesce into
79 /// the next append or the forced finalize bump, so no content is ever lost.
80 pub(super) fn append(app: &mut App, entry_idx: usize, text: &str) {
81 append_at(app, entry_idx, text, Instant::now());
82 }
83
84 /// `append` with an injectable clock so the debounce can be tested
85 /// deterministically.
86 fn append_at(app: &mut App, entry_idx: usize, text: &str, now: Instant) {
87 if text.is_empty() {
88 return;
89 }
90 let mutated = if let Some(active) = app.active_cell.as_mut()
91 && let Some(HistoryCell::Thinking { content, .. }) = active.entry_mut(entry_idx)
92 {
93 content.push_str(text);
94 true
95 } else {
96 false
97 };
98 if mutated {
99 bump_thinking_revision_throttled(app, now);
100 }
101 }
102
103 /// Build the spinner-decorated placeholder shown in the thinking entry
104 /// while a translation is in flight (`Thinking… (1.2s |)`).
105 fn translation_placeholder_spinner_frame(app: &App, elapsed: f32) -> &'static str {
106 let animated_frame = match (elapsed.mul_add(2.0, 0.0) as usize) % 4 {
107 0 => "|",
108 1 => "/",
109 2 => "-",
110 _ => "\\",
111 };
112 app.motion_policy().spinner_glyph(animated_frame, true)
113 }
114
115 pub(super) fn translation_placeholder_frame(app: &App) -> String {
116 let base = crate::localization::thinking_translation_placeholder(app.ui_locale);
117 let elapsed = app
118 .thinking_started_at
119 .or(app.turn_started_at)
120 .map(|started| started.elapsed().as_secs_f32())
121 .unwrap_or_default();
122 let frame = translation_placeholder_spinner_frame(app, elapsed);
123 format!("{base} ({elapsed:.1}s {frame})")
124 }
125
126 /// If the given entry is empty or still showing the translation
127 /// placeholder prefix, replace it with the latest animated frame.
128 pub(super) fn set_placeholder(app: &mut App, entry_idx: usize) {
129 let base = crate::localization::thinking_translation_placeholder(app.ui_locale);
130 let next = translation_placeholder_frame(app);
131 let mutated = if let Some(active) = app.active_cell.as_mut()
132 && let Some(HistoryCell::Thinking { content, .. }) = active.entry_mut(entry_idx)
133 && (content.is_empty() || content.starts_with(base))
134 {
135 if *content != next {
136 *content = next;
137 true
138 } else {
139 false
140 }
141 } else {
142 false
143 };
144 if mutated {
145 app.bump_active_cell_revision();
146 }
147 }
148
149 /// Advance the spinner suffix on every existing translation placeholder
150 /// in `active_cell`. Returns true when at least one cell was updated so
151 /// the dispatch loop can schedule another tick.
152 pub(super) fn animate_pending_translation(app: &mut App, translation_pending: bool) -> bool {
153 if !app.translation_enabled {
154 return false;
155 }
156 let thinking_streaming = app.streaming_thinking_active_entry.is_some();
157 if !translation_pending && !thinking_streaming {
158 return false;
159 }
160 let base = crate::localization::thinking_translation_placeholder(app.ui_locale);
161 let next = translation_placeholder_frame(app);
162
163 if let Some(active) = app.active_cell.as_mut() {
164 for idx in (0..active.entry_count()).rev() {
165 if let Some(HistoryCell::Thinking { content, .. }) = active.entry_mut(idx)
166 && content.starts_with(base)
167 && *content != next
168 {
169 *content = next.clone();
170 app.bump_active_cell_revision();
171 return true;
172 }
173 }
174 }
175 false
176 }
177
178 /// Replace a translation placeholder with the finished translated text.
179 /// Searches the active cell first, then the finalized history (covers
180 /// the case where the translation lands after the thinking block was
181 /// already moved into history).
182 pub(super) fn replace_pending_translation(
183 app: &mut App,
184 placeholder: &str,
185 translated_text: String,
186 ) {
187 if let Some(active) = app.active_cell.as_mut() {
188 for idx in (0..active.entry_count()).rev() {
189 if let Some(HistoryCell::Thinking { content, .. }) = active.entry_mut(idx)
190 && content.starts_with(placeholder)
191 {
192 *content = translated_text;
193 app.bump_active_cell_revision();
194 return;
195 }
196 }
197 }
198
199 for idx in (0..app.history.len()).rev() {
200 if let Some(HistoryCell::Thinking { content, .. }) = app.history.get_mut(idx)
201 && content.starts_with(placeholder)
202 {
203 *content = translated_text;
204 app.bump_history_cell(idx);
205 return;
206 }
207 }
208 }
209
210 /// Start a new streaming thinking block. If another thinking block is still
211 /// active, first drain its pending UI tail so a late block boundary cannot
212 /// discard content buffered inside `StreamingState`.
213 pub(super) fn start_block(app: &mut App) -> bool {
214 let finalized_previous = if app.streaming_thinking_active_entry.is_some() {
215 let finalized = finalize_current(app);
216 stash_reasoning_buffer_into_last_reasoning(app);
217 finalized
218 } else {
219 false
220 };
221
222 app.reasoning_buffer.clear();
223 app.reasoning_header = None;
224 app.thinking_started_at = Some(Instant::now());
225 app.streaming_state.reset();
226 app.streaming_state.start_thinking(0);
227 let _ = ensure_active_entry(app);
228 finalized_previous
229 }
230
231 /// Finalize the currently-streaming thinking entry: drain the pending
232 /// state buffer, compute elapsed duration, stop the spinner.
233 pub(super) fn finalize_current(app: &mut App) -> bool {
234 let duration = app
235 .thinking_started_at
236 .take()
237 .map(|t| t.elapsed().as_secs_f32());
238 let remaining = app.streaming_state.finalize_block_text(0);
239 finalize_active_entry(app, duration, &remaining)
240 }
241
242 /// Move the in-flight reasoning buffer onto `app.last_reasoning` so the
243 /// summary survives compaction or transcript trimming.
244 pub(super) fn stash_reasoning_buffer_into_last_reasoning(app: &mut App) {
245 if app.reasoning_buffer.is_empty() {
246 return;
247 }
248
249 if let Some(existing) = app.last_reasoning.as_mut()
250 && !existing.is_empty()
251 {
252 if !existing.ends_with('\n') {
253 existing.push('\n');
254 }
255 existing.push_str(&app.reasoning_buffer);
256 } else {
257 app.last_reasoning = Some(app.reasoning_buffer.clone());
258 }
259 app.reasoning_buffer.clear();
260 }
261
262 /// Finalize the in-flight thinking entry in `active_cell`: append the
263 /// collector's remaining buffered text, stop the spinner, and stamp the
264 /// duration. Returns `true` when a thinking entry was finalized (so the
265 /// dispatch loop knows the transcript was touched). No-op if no thinking
266 /// entry is currently streaming.
267 pub(super) fn finalize_active_entry(app: &mut App, duration: Option<f32>, remaining: &str) -> bool {
268 let Some(entry_idx) = app.streaming_thinking_active_entry.take() else {
269 return false;
270 };
271 if !remaining.is_empty() {
272 append(app, entry_idx, remaining);
273 }
274 if let Some(active) = app.active_cell.as_mut()
275 && let Some(HistoryCell::Thinking {
276 streaming,
277 duration_secs,
278 ..
279 }) = active.entry_mut(entry_idx)
280 {
281 *streaming = false;
282 *duration_secs = duration;
283 }
284 // Red line (#1620): finalize must force a bump so the final reasoning text
285 // is fully rendered even if the last appended chunk was throttled. Reset
286 // the debounce window so the next thinking block's first chunk renders
287 // immediately rather than being coalesced into a stale window.
288 app.thinking_revision_last_bump_at = None;
289 app.bump_active_cell_revision();
290 true
291 }
292
293 #[cfg(test)]
294 mod tests {
295 use super::*;
296 use crate::config::Config;
297 use crate::tui::app::{App, TuiOptions};
298 use std::path::PathBuf;
299
300 fn test_app() -> App {
301 let options = TuiOptions {
302 start_in_agent_mode: true,
303 skip_onboarding: false,
304 ..crate::test_support::test_tui_options(PathBuf::from("."))
305 };
306 App::new(options, &Config::default())
307 }
308
309 fn thinking_content(app: &App, entry_idx: usize) -> String {
310 match app
311 .active_cell
312 .as_ref()
313 .and_then(|active| active.entries().get(entry_idx))
314 {
315 Some(HistoryCell::Thinking { content, .. }) => content.clone(),
316 other => panic!("expected a Thinking entry at {entry_idx}, got {other:?}"),
317 }
318 }
319
320 #[test]
321 fn translation_placeholder_spinner_uses_full_motion_only() {
322 let mut app = test_app();
323 app.low_motion = false;
324 app.fancy_animations = true;
325 assert_eq!(translation_placeholder_spinner_frame(&app, 0.0), "|");
326 assert_eq!(translation_placeholder_spinner_frame(&app, 0.6), "/");
327
328 app.low_motion = true;
329 assert_eq!(translation_placeholder_spinner_frame(&app, 0.0), "⣤");
330 assert_eq!(translation_placeholder_spinner_frame(&app, 0.6), "⣤");
331
332 app.low_motion = false;
333 app.fancy_animations = false;
334 assert_eq!(translation_placeholder_spinner_frame(&app, 0.0), "›");
335 assert_eq!(translation_placeholder_spinner_frame(&app, 0.6), "›");
336 }
337
338 /// #1620: a burst of reasoning chunks inside one throttle window must
339 /// coalesce to a single active-cell revision bump (so the renderer
340 /// re-wraps the live tail ~10x/sec instead of once per character), while
341 /// every byte of content is preserved and finalize forces a final bump.
342 #[test]
343 fn issue_1620_throttles_thinking_bumps_without_losing_content() {
344 let mut app = test_app();
345 let entry = ensure_active_entry(&mut app);
346 // `ensure_active_entry` bumped once on creation; start the measurement
347 // from a clean throttle window so the first append renders immediately.
348 app.thinking_revision_last_bump_at = None;
349 let rev_before = app.active_cell_revision;
350
351 let t0 = Instant::now();
352 let chunks = [
353 "Hel", "lo, ", "this", " is", " a", " lo", "ng", " re", "ason", "ing",
354 ];
355 // All ten chunks land within a single 100ms window (5ms apart).
356 for (i, chunk) in chunks.iter().enumerate() {
357 append_at(
358 &mut app,
359 entry,
360 chunk,
361 t0 + Duration::from_millis(i as u64 * 5),
362 );
363 }
364 assert_eq!(
365 app.active_cell_revision.wrapping_sub(rev_before),
366 1,
367 "rapid chunks within one throttle window must coalesce to one bump"
368 );
369
370 // A chunk after the window expires is allowed to bump again.
371 append_at(
372 &mut app,
373 entry,
374 " stream",
375 t0 + THINKING_REVISION_THROTTLE + Duration::from_millis(10),
376 );
377 assert_eq!(
378 app.active_cell_revision.wrapping_sub(rev_before),
379 2,
380 "a chunk past the throttle window should bump once more"
381 );
382
383 // No content was dropped despite the skipped intermediate bumps.
384 let expected = format!("{} stream", chunks.concat());
385 assert_eq!(thinking_content(&app, entry), expected);
386
387 // Red line: finalize forces exactly one bump and flushes the tail.
388 let rev_pre_final = app.active_cell_revision;
389 let finalized = finalize_active_entry(&mut app, Some(1.5), " [end]");
390 assert!(finalized, "finalize should report it finalized an entry");
391 assert_eq!(
392 app.active_cell_revision,
393 rev_pre_final.wrapping_add(1),
394 "finalize must always force exactly one revision bump"
395 );
396 assert_eq!(
397 thinking_content(&app, entry),
398 format!("{expected} [end]"),
399 "finalize must not drop the trailing reasoning text"
400 );
401 assert!(
402 app.thinking_revision_last_bump_at.is_none(),
403 "finalize should reset the throttle window for the next block"
404 );
405 }
406 }
407
407 lines RUST