| 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 |