use std::time::Duration;
use std::time::Instant;
use crate::tui::active_cell::ActiveCell;
use crate::tui::app::App;
use crate::tui::history::HistoryCell;
const THINKING_REVISION_THROTTLE: Duration = Duration::from_millis(100);
fn bump_thinking_revision_throttled(app: &mut App, now: Instant) -> bool {
let due = app
.thinking_revision_last_bump_at
.is_none_or(|last| now.saturating_duration_since(last) >= THINKING_REVISION_THROTTLE);
if due {
app.thinking_revision_last_bump_at = Some(now);
app.bump_active_cell_revision();
}
due
}
pub(super) fn ensure_active_entry(app: &mut App) -> usize {
if let Some(idx) = app.streaming_thinking_active_entry {
return idx;
}
if app.active_cell.is_none() {
app.active_cell = Some(ActiveCell::new());
}
let active = app.active_cell.as_mut().expect("active_cell just ensured");
let entry_idx = active.push_thinking(HistoryCell::Thinking {
content: String::new(),
streaming: true,
duration_secs: None,
});
app.streaming_thinking_active_entry = Some(entry_idx);
app.bump_active_cell_revision();
entry_idx
}
pub(super) fn append(app: &mut App, entry_idx: usize, text: &str) {
append_at(app, entry_idx, text, Instant::now());
}
fn append_at(app: &mut App, entry_idx: usize, text: &str, now: Instant) {
if text.is_empty() {
return;
}
let mutated = if let Some(active) = app.active_cell.as_mut()
&& let Some(HistoryCell::Thinking { content, .. }) = active.entry_mut(entry_idx)
{
content.push_str(text);
true
} else {
false
};
if mutated {
bump_thinking_revision_throttled(app, now);
}
}
pub(super) fn translation_placeholder_frame(app: &App) -> String {
let base = crate::localization::thinking_translation_placeholder(app.ui_locale);
let elapsed = app
.thinking_started_at
.or(app.turn_started_at)
.map(|started| started.elapsed().as_secs_f32())
.unwrap_or_default();
let frame = match (elapsed.mul_add(2.0, 0.0) as usize) % 4 {
0 => "|",
1 => "/",
2 => "-",
_ => "\\",
};
format!("{base} ({elapsed:.1}s {frame})")
}
pub(super) fn set_placeholder(app: &mut App, entry_idx: usize) {
let base = crate::localization::thinking_translation_placeholder(app.ui_locale);
let next = translation_placeholder_frame(app);
let mutated = if let Some(active) = app.active_cell.as_mut()
&& let Some(HistoryCell::Thinking { content, .. }) = active.entry_mut(entry_idx)
&& (content.is_empty() || content.starts_with(base))
{
if *content != next {
*content = next;
true
} else {
false
}
} else {
false
};
if mutated {
app.bump_active_cell_revision();
}
}
pub(super) fn animate_pending_translation(app: &mut App, translation_pending: bool) -> bool {
if !app.translation_enabled {
return false;
}
let thinking_streaming = app.streaming_thinking_active_entry.is_some();
if !translation_pending && !thinking_streaming {
return false;
}
let base = crate::localization::thinking_translation_placeholder(app.ui_locale);
let next = translation_placeholder_frame(app);
if let Some(active) = app.active_cell.as_mut() {
for idx in (0..active.entry_count()).rev() {
if let Some(HistoryCell::Thinking { content, .. }) = active.entry_mut(idx)
&& content.starts_with(base)
&& *content != next
{
*content = next.clone();
app.bump_active_cell_revision();
return true;
}
}
}
false
}
pub(super) fn replace_pending_translation(
app: &mut App,
placeholder: &str,
translated_text: String,
) {
if let Some(active) = app.active_cell.as_mut() {
for idx in (0..active.entry_count()).rev() {
if let Some(HistoryCell::Thinking { content, .. }) = active.entry_mut(idx)
&& content.starts_with(placeholder)
{
*content = translated_text;
app.bump_active_cell_revision();
return;
}
}
}
for idx in (0..app.history.len()).rev() {
if let Some(HistoryCell::Thinking { content, .. }) = app.history.get_mut(idx)
&& content.starts_with(placeholder)
{
*content = translated_text;
app.bump_history_cell(idx);
return;
}
}
}
pub(super) fn start_block(app: &mut App) -> bool {
let finalized_previous = if app.streaming_thinking_active_entry.is_some() {
let finalized = finalize_current(app);
stash_reasoning_buffer_into_last_reasoning(app);
finalized
} else {
false
};
app.reasoning_buffer.clear();
app.reasoning_header = None;
app.thinking_started_at = Some(Instant::now());
app.streaming_state.reset();
app.streaming_state.start_thinking(0, None);
let _ = ensure_active_entry(app);
finalized_previous
}
pub(super) fn finalize_current(app: &mut App) -> bool {
let duration = app
.thinking_started_at
.take()
.map(|t| t.elapsed().as_secs_f32());
let remaining = app.streaming_state.finalize_block_text(0);
finalize_active_entry(app, duration, &remaining)
}
pub(super) fn stash_reasoning_buffer_into_last_reasoning(app: &mut App) {
if app.reasoning_buffer.is_empty() {
return;
}
if let Some(existing) = app.last_reasoning.as_mut()
&& !existing.is_empty()
{
if !existing.ends_with('\n') {
existing.push('\n');
}
existing.push_str(&app.reasoning_buffer);
} else {
app.last_reasoning = Some(app.reasoning_buffer.clone());
}
app.reasoning_buffer.clear();
}
pub(super) fn finalize_active_entry(app: &mut App, duration: Option<f32>, remaining: &str) -> bool {
let Some(entry_idx) = app.streaming_thinking_active_entry.take() else {
return false;
};
if !remaining.is_empty() {
append(app, entry_idx, remaining);
}
if let Some(active) = app.active_cell.as_mut()
&& let Some(HistoryCell::Thinking {
streaming,
duration_secs,
..
}) = active.entry_mut(entry_idx)
{
*streaming = false;
*duration_secs = duration;
}
app.thinking_revision_last_bump_at = None;
app.bump_active_cell_revision();
true
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::app::{App, TuiOptions};
use std::path::PathBuf;
fn test_app() -> App {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("."),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 1,
skills_dir: PathBuf::from("."),
memory_path: PathBuf::from("memory.md"),
notes_path: PathBuf::from("notes.txt"),
mcp_config_path: PathBuf::from("mcp.json"),
use_memory: false,
start_in_agent_mode: true,
skip_onboarding: false,
yolo: false,
resume_session_id: None,
initial_input: None,
};
App::new(options, &Config::default())
}
fn thinking_content(app: &App, entry_idx: usize) -> String {
match app
.active_cell
.as_ref()
.and_then(|active| active.entries().get(entry_idx))
{
Some(HistoryCell::Thinking { content, .. }) => content.clone(),
other => panic!("expected a Thinking entry at {entry_idx}, got {other:?}"),
}
}
#[test]
fn issue_1620_throttles_thinking_bumps_without_losing_content() {
let mut app = test_app();
let entry = ensure_active_entry(&mut app);
app.thinking_revision_last_bump_at = None;
let rev_before = app.active_cell_revision;
let t0 = Instant::now();
let chunks = [
"Hel", "lo, ", "this", " is", " a", " lo", "ng", " re", "ason", "ing",
];
for (i, chunk) in chunks.iter().enumerate() {
append_at(
&mut app,
entry,
chunk,
t0 + Duration::from_millis(i as u64 * 5),
);
}
assert_eq!(
app.active_cell_revision.wrapping_sub(rev_before),
1,
"rapid chunks within one throttle window must coalesce to one bump"
);
append_at(
&mut app,
entry,
" stream",
t0 + THINKING_REVISION_THROTTLE + Duration::from_millis(10),
);
assert_eq!(
app.active_cell_revision.wrapping_sub(rev_before),
2,
"a chunk past the throttle window should bump once more"
);
let expected = format!("{} stream", chunks.concat());
assert_eq!(thinking_content(&app, entry), expected);
let rev_pre_final = app.active_cell_revision;
let finalized = finalize_active_entry(&mut app, Some(1.5), " [end]");
assert!(finalized, "finalize should report it finalized an entry");
assert_eq!(
app.active_cell_revision,
rev_pre_final.wrapping_add(1),
"finalize must always force exactly one revision bump"
);
assert_eq!(
thinking_content(&app, entry),
format!("{expected} [end]"),
"finalize must not drop the trailing reasoning text"
);
assert!(
app.thinking_revision_last_bump_at.is_none(),
"finalize should reset the throttle window for the next block"
);
}
}