diff --git a/apps/interfaces/tui/src/run.rs b/apps/interfaces/tui/src/run.rs index 9d3742e..c98df52 100644 --- a/apps/interfaces/tui/src/run.rs +++ b/apps/interfaces/tui/src/run.rs @@ -112,9 +112,12 @@ fn run_loop_inner( } // Drain expired toasts exactly once per loop iteration (was being - // done here AND in Action::Tick before this fix). - let now_ms = chrono::Utc::now().timestamp_millis(); - state.misc.drain_expired_toasts(now_ms); + // done here AND in Action::Tick before this fix). Skip the clock + // syscall + Vec scan when there are no toasts at all. + if !state.misc.toasts.is_empty() { + let now_ms = chrono::Utc::now().timestamp_millis(); + state.misc.drain_expired_toasts(now_ms); + } // Skip render when nothing has changed — avoids expensive markdown // re-parse and layout recalculation every cycle while idle. diff --git a/apps/interfaces/tui/src/state/misc.rs b/apps/interfaces/tui/src/state/misc.rs index 6e4bccb..8154e68 100644 --- a/apps/interfaces/tui/src/state/misc.rs +++ b/apps/interfaces/tui/src/state/misc.rs @@ -179,16 +179,9 @@ impl MiscState { self.toasts.push(toast); } - /// Remove and return all toasts whose lifetime has expired at `now_ms`. - pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec { - let expired: Vec<_> = self - .toasts - .iter() - .filter(|t| t.expired(now_ms)) - .cloned() - .collect(); + /// Remove all toasts whose lifetime has expired at `now_ms`. + pub fn drain_expired_toasts(&mut self, now_ms: i64) { self.toasts.retain(|t| !t.expired(now_ms)); - expired } } diff --git a/apps/interfaces/tui/src/state/mod.rs b/apps/interfaces/tui/src/state/mod.rs index 363d2e3..4066e90 100644 --- a/apps/interfaces/tui/src/state/mod.rs +++ b/apps/interfaces/tui/src/state/mod.rs @@ -103,6 +103,13 @@ pub struct AppStateRest { pub last_render_width: u16, /// Number of messages that were in the cache when it was last built. pub cached_msg_count: usize, + /// Cache index where the last message's rendered lines begin. Used to + /// splice streaming updates (only re-render the trailing message). + pub cached_last_start: usize, + /// Content+reasoning byte length of the last message when it was last + /// rendered. Guards the streaming branch from re-rendering on ticks + /// where no new token arrived (spinner-only frames). + pub cached_last_len: usize, /// Terminal width at the time of the last full cache build. pub render_width_at_cache: u16, /// Atomic flag set when the user aborts the current turn. @@ -180,6 +187,8 @@ impl Default for AppStateRest { token_count_dirty: true, last_render_width: 0, cached_msg_count: 0, + cached_last_start: 0, + cached_last_len: 0, render_width_at_cache: 0, } } @@ -230,6 +239,8 @@ impl AppStateRest { token_count_dirty: true, last_render_width: 0, cached_msg_count: 0, + cached_last_start: 0, + cached_last_len: 0, render_width_at_cache: 0, } } diff --git a/apps/interfaces/tui/src/view/chat.rs b/apps/interfaces/tui/src/view/chat.rs index 840e581..490e39b 100644 --- a/apps/interfaces/tui/src/view/chat.rs +++ b/apps/interfaces/tui/src/view/chat.rs @@ -190,6 +190,8 @@ impl Component for ChatComponent { } state.display_lines_cache = all_lines; state.cached_msg_count = msg_count; + state.cached_last_start = state.display_lines_cache.len(); + state.cached_last_len = last_msg_len(state); state.render_width_at_cache = state.last_render_width; state.transcript_cache.dirty = false; } else if state.transcript_cache.dirty && msg_count > cached_count { @@ -205,6 +207,27 @@ impl Component for ChatComponent { state.display_lines_cache.extend(msg_lines); } state.cached_msg_count = msg_count; + state.cached_last_start = state.display_lines_cache.len(); + state.cached_last_len = last_msg_len(state); + state.transcript_cache.dirty = false; + } else if state.transcript_cache.dirty { + // Streaming update: message count unchanged, but the last + // assistant message's content grew (StreamToken). Re-render only + // that message and splice its lines back into the cache — this + // keeps updates O(new content), not O(whole history) per token. + // Without this branch, streamed tokens would never appear (the + // cache is only rebuilt on new-message or resize paths). + let cur_len = last_msg_len(state); + if cur_len != state.cached_last_len { + if let Some(last_msg) = state.transcript_cache.messages.back() { + if state.cached_last_start <= state.display_lines_cache.len() { + let new_lines = render_one_message(last_msg, content_width); + state.display_lines_cache.truncate(state.cached_last_start); + state.display_lines_cache.extend(new_lines); + } + } + state.cached_last_len = cur_len; + } state.transcript_cache.dirty = false; } @@ -326,3 +349,15 @@ fn split_spans_into_lines(spans: Vec>) -> Vec> { } lines } + +/// Byte length of the last message's content + reasoning. Used as a cheap +/// change detector for the streaming render branch — avoids re-rendering on +/// spinner-only ticks where no new token arrived. +fn last_msg_len(state: &crate::state::AppStateRest) -> usize { + state + .transcript_cache + .messages + .back() + .map(|m| m.content.len() + m.reasoning.len()) + .unwrap_or(0) +} diff --git a/apps/interfaces/tui/src/view/mod.rs b/apps/interfaces/tui/src/view/mod.rs index 7cd89b7..a528dac 100644 --- a/apps/interfaces/tui/src/view/mod.rs +++ b/apps/interfaces/tui/src/view/mod.rs @@ -86,6 +86,10 @@ pub fn draw(frame: &mut Frame, state: &AppStateRest) { } fn render_toasts(frame: &mut Frame, state: &AppStateRest) { + // Fast path: nothing to render. + if state.misc.toasts.is_empty() { + return; + } let now_ms = chrono::Utc::now().timestamp_millis(); let active: Vec<&zesdex_infrastructure::Toast> = state .misc