From 87abe8c3358ac17196df93d19169901a856850d3 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Mon, 20 Jul 2026 13:52:20 +0700 Subject: [PATCH] feat(tui): optimize performance by caching display lines and token counts, and improve action handling --- apps/interfaces/tui/src/action.rs | 17 ++-- apps/interfaces/tui/src/run.rs | 36 ++++++-- apps/interfaces/tui/src/state.rs | 58 +++++++----- apps/interfaces/tui/src/turn.rs | 20 ++--- apps/interfaces/tui/src/view/chat.rs | 88 ++++++++++++++++--- apps/interfaces/tui/src/view/mod.rs | 9 ++ .../tui/src/view/overlays/rewind.rs | 2 +- apps/interfaces/tui/src/view/status.rs | 10 +-- 8 files changed, 180 insertions(+), 60 deletions(-) diff --git a/apps/interfaces/tui/src/action.rs b/apps/interfaces/tui/src/action.rs index d4875e5..be220a9 100644 --- a/apps/interfaces/tui/src/action.rs +++ b/apps/interfaces/tui/src/action.rs @@ -102,7 +102,12 @@ pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) { state.misc.overlay = crate::state::Overlay::QuitConfirm; state.mark_dirty(); } - Action::Resize(_w, _h) => { + Action::Resize(w, _h) => { + // Invalidate display cache so pre_render_chat rebuilds at new width. + if state.last_render_width != w { + state.transcript_cache.dirty = true; + state.last_render_width = w; + } state.mark_dirty(); } Action::Tick => { @@ -157,9 +162,7 @@ pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) { } } zesdex_infrastructure::TurnEvent::Done => { - if let Ok(mut flag) = state.turn_in_flight_flag.lock() { - *flag = false; - } + state.turn_in_flight_flag.store(false, std::sync::atomic::Ordering::SeqCst); } _ => { tracing::debug!("unhandled turn event variant"); @@ -167,9 +170,7 @@ pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) { } } } - // Drain expired toasts - let now = chrono::Utc::now().timestamp_millis(); - state.misc.drain_expired_toasts(now); + // Tick increments counter only; toast expiry handled in run_loop state.mark_dirty(); } Action::SubmitInput(text) => { @@ -181,6 +182,8 @@ pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) { state.input.submit(); // Spawn real agent turn on a background thread crate::turn::spawn_agent_turn(state, text); + // Invalidate token count cache since we added a message + state.token_count_dirty = true; state.mark_dirty(); } Action::DeleteChar => { diff --git a/apps/interfaces/tui/src/run.rs b/apps/interfaces/tui/src/run.rs index 97d9d7d..d10a9a5 100644 --- a/apps/interfaces/tui/src/run.rs +++ b/apps/interfaces/tui/src/run.rs @@ -76,6 +76,13 @@ fn run_loop( } /// The core single-process render/input loop. +/// +/// Performance optimizations applied here: +/// - Skip `terminal.draw()` when `state.dirty == false` (nothing changed) +/// - Adaptive poll timeout: 50ms when a turn is in-flight (spinner needs +/// smooth updates), 200ms when idle (no reason to busy-loop) +/// - Toast expiry is drained once here instead of in two places +/// - Chat display lines are cached and rebuilt only when content changes fn run_loop_inner( state: &mut AppStateRest, terminal: &mut Terminal>, @@ -84,15 +91,32 @@ fn run_loop_inner( if state.quit { break; } + + // 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); - terminal.draw(|f| { - view::draw(f, state); - state.dirty = false; - })?; - // Poll terminal with 50 ms timeout - if crossterm::event::poll(Duration::from_millis(50))? { + // Skip render when nothing has changed — avoids expensive markdown + // re-parse and layout recalculation every cycle while idle. + if state.dirty { + // Pre-warm display caches before entering the terminal.draw closure. + // This lets view::draw work with &AppStateRest (no mutation inside draw). + view::pre_render(state); + terminal.draw(|f| { + view::draw(f, state); + state.dirty = false; + })?; + } + + // Adaptive poll: fast when animating, slow when idle. + let poll_timeout = if state.turn_in_flight() { + Duration::from_millis(50) // smooth spinner @ ~20fps + } else { + Duration::from_millis(200) // idle: 5fps, saves CPU + }; + + if crossterm::event::poll(poll_timeout)? { match crossterm::event::read()? { Event::Key(key) => { if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat { diff --git a/apps/interfaces/tui/src/state.rs b/apps/interfaces/tui/src/state.rs index b630621..ea620e2 100644 --- a/apps/interfaces/tui/src/state.rs +++ b/apps/interfaces/tui/src/state.rs @@ -13,11 +13,12 @@ use std::collections::VecDeque; use std::path::PathBuf; -use std::sync::atomic::AtomicBool; -use std::sync::{Arc, Mutex}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use tracing::warn; use zesdex_domain::cms::{AppConfig, Settings}; +use ratatui::text::Line; use zesdex_infrastructure::{DirCache, MentionIndex, SessionRuntime, Toast, TurnEvent}; // --------------------------------------------------------------------------- @@ -54,7 +55,8 @@ impl ChatMessageDisplay { #[derive(Debug, Clone)] pub struct TranscriptCache { /// Ordered display messages (newest appended, oldest evicted when full). - pub messages: Vec, + /// Uses VecDeque for O(1) front eviction instead of O(n) Vec::remove(0). + pub messages: VecDeque, /// Maximum messages to retain before evicting the oldest. pub max_lines: usize, /// Whether the cache has changed since the last render sweep. @@ -65,7 +67,7 @@ impl TranscriptCache { /// Create an empty transcript cache holding at most `max_lines` messages. pub fn new(max_lines: usize) -> Self { TranscriptCache { - messages: Vec::new(), + messages: VecDeque::new(), max_lines, dirty: true, } @@ -830,9 +832,20 @@ pub struct AppStateRest { /// Miscellaneous state: overlay, toasts, flags, editor, tick. pub misc: MiscState, /// Queue of events emitted by the running agent turn. - pub turn_events: Arc>>, + pub turn_events: Arc>>, /// Whether an agent turn is currently in flight. - pub turn_in_flight_flag: Arc>, + /// Uses AtomicBool for lock-free check from render loop. + pub turn_in_flight_flag: Arc, + /// Cached display lines for the chat transcript panel. + /// Rebuilt only when transcript_cache.dirty=true or terminal width changes. + pub display_lines_cache: Vec>, + /// Cached token count for the current message history. + /// Updated lazily only when new messages arrive, not every frame. + pub cached_token_count: usize, + /// Whether the token count cache is stale and needs recalculation. + pub token_count_dirty: bool, + /// Terminal width at the time of the last display_lines_cache rebuild. + pub last_render_width: u16, /// Atomic flag set when the user aborts the current turn. pub abort_flag: Arc, /// Simplified workflow engine state for display. @@ -896,13 +909,17 @@ impl Default for AppStateRest { scroll: ScrollState::new(), input: InputState::new(), misc: MiscState::new(), - turn_events: Arc::new(Mutex::new(VecDeque::new())), - turn_in_flight_flag: Arc::new(Mutex::new(false)), + turn_events: Arc::new(std::sync::Mutex::new(VecDeque::new())), + turn_in_flight_flag: Arc::new(AtomicBool::new(false)), abort_flag: Arc::new(AtomicBool::new(false)), workflow_engine: SimpleWorkflowEngine::new(), dirty: true, quit: false, help_text: DEFAULT_HELP_TEXT, + display_lines_cache: Vec::new(), + cached_token_count: 0, + token_count_dirty: true, + last_render_width: 0, } } } @@ -936,8 +953,8 @@ impl AppStateRest { session_dir: session_dir.to_path_buf(), memory_dir: memory_dir.clone(), worktrees_dir, - turn_events: Arc::new(Mutex::new(VecDeque::new())), - turn_in_flight_flag: Arc::new(Mutex::new(false)), + turn_events: Arc::new(std::sync::Mutex::new(VecDeque::new())), + turn_in_flight_flag: Arc::new(AtomicBool::new(false)), abort_flag: Arc::new(AtomicBool::new(false)), dir_cache: Arc::new(tokio::sync::RwLock::new(DirCache::new())), mention_index: MentionIndex::new(), @@ -950,27 +967,28 @@ impl AppStateRest { dirty: true, quit: false, help_text: DEFAULT_HELP_TEXT, + display_lines_cache: Vec::new(), + cached_token_count: 0, + token_count_dirty: true, + last_render_width: 0, } } /// Whether an agent turn is currently running. + /// Uses lock-free AtomicBool load — safe to call every render frame. pub fn turn_in_flight(&self) -> bool { - self.turn_in_flight_flag.lock().map_or_else( - |_| { - warn!("[state] turn_in_flight mutex poisoned"); - false - }, - |g| *g, - ) + self.turn_in_flight_flag.load(Ordering::Relaxed) } /// Append a message to the transcript. + /// Eviction is O(1) via VecDeque::pop_front instead of O(n) Vec::remove(0). pub fn push_transcript(&mut self, msg: ChatMessageDisplay) { - self.transcript_cache.messages.push(msg); - if self.transcript_cache.messages.len() > self.transcript_cache.max_lines { - self.transcript_cache.messages.remove(0); + self.transcript_cache.messages.push_back(msg); + while self.transcript_cache.messages.len() > self.transcript_cache.max_lines { + self.transcript_cache.messages.pop_front(); } self.transcript_cache.dirty = true; + self.token_count_dirty = true; self.dirty = true; } diff --git a/apps/interfaces/tui/src/turn.rs b/apps/interfaces/tui/src/turn.rs index c28d829..3dd5d9e 100644 --- a/apps/interfaces/tui/src/turn.rs +++ b/apps/interfaces/tui/src/turn.rs @@ -19,11 +19,12 @@ use crate::state::AppStateRest; /// Spawn an agent turn on a background OS thread. pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) { - if let Ok(mut in_flight) = state.turn_in_flight_flag.lock() { - if *in_flight { - return; - } - *in_flight = true; + // compare_exchange: only mark in-flight if not already running + if state.turn_in_flight_flag + .compare_exchange(false, true, Ordering::SeqCst, Ordering::Relaxed) + .is_err() + { + return; // already running } let turn_events = state.turn_events.clone(); @@ -56,7 +57,7 @@ fn run_turn( session_dir: &Path, workspace_roots: &[PathBuf], turn_events: &Arc>>, - in_flight: &Arc>, + in_flight: &Arc, abort: &Arc, ) { let client = LlmClient::new( @@ -188,8 +189,7 @@ fn push_event(queue: &Arc>>, event: TurnEvent) { } } -fn mark_done(flag: &Arc>) { - if let Ok(mut f) = flag.lock() { - *f = false; - } +/// Mark the turn as done using lock-free atomic store. +fn mark_done(flag: &Arc) { + flag.store(false, Ordering::SeqCst); } diff --git a/apps/interfaces/tui/src/view/chat.rs b/apps/interfaces/tui/src/view/chat.rs index 39fdac7..51e19c6 100644 --- a/apps/interfaces/tui/src/view/chat.rs +++ b/apps/interfaces/tui/src/view/chat.rs @@ -5,6 +5,11 @@ //! `{role} {time} {content}` header with wrapped continuation lines //! aligned under the content column; `Role::Tool` messages render as a //! dim `↳`-prefixed sub-line attached to whatever came before. +//! +//! Performance: rendered lines are cached in `state.display_lines_cache` and +//! only rebuilt when `transcript_cache.dirty == true` or the terminal width +//! changes. The cache is updated in `pre_render_chat` (called from `view::pre_render` +//! before the immutable draw pass) to avoid borrow conflicts. use super::theme::Theme; use ratatui::layout::Rect; @@ -44,14 +49,15 @@ fn format_timestamp(ts: i64) -> String { format!("{hrs:02}:{mins:02}") } -/// Render the scrollable chat transcript panel in tight inline-log style. -pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) { - let messages = &state.transcript_cache.messages; - let scroll_offset = state.scroll.offset; - let max_visible = (area.height as usize).saturating_sub(3); - let content_width = area.width.saturating_sub(PREFIX_WIDTH as u16 + 2); - - let mut display_lines: Vec = Vec::new(); +/// Rebuild the full list of display lines from the transcript cache. +/// +/// This is the expensive operation — markdown parsing, span building, etc. +/// Only called from `pre_render_chat` when cache is stale. +fn build_display_lines( + messages: &std::collections::VecDeque, + content_width: u16, +) -> Vec> { + let mut display_lines: Vec> = Vec::new(); for msg in messages { if msg.role == Role::Tool { @@ -115,7 +121,66 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRe } } - // Streaming indicator + display_lines +} + +/// Pre-render hook: rebuild display_lines_cache and token count if stale. +/// +/// Called from `view::pre_render` (with `&mut AppStateRest`) **before** the +/// immutable `terminal.draw` closure. This avoids borrow conflicts and ensures +/// that `draw_chat` can take `&AppStateRest`. +pub fn pre_render_chat(state: &mut crate::state::AppStateRest) { + // We don't know the terminal width here, so we use the last known width. + // If width changed, it will be detected next frame via last_render_width. + let content_width = state.last_render_width.saturating_sub(PREFIX_WIDTH as u16 + 2); + + // Rebuild display lines only when transcript changed or width changed. + // In practice this means: only when new messages arrive or on resize. + if state.transcript_cache.dirty || state.last_render_width == 0 { + state.display_lines_cache = + build_display_lines(&state.transcript_cache.messages, content_width); + state.transcript_cache.dirty = false; + } + + // Lazily recompute token count (expensive tiktoken call) only when new + // messages have arrived — not on every render frame. + if state.token_count_dirty { + if let Some(ref rt) = state.session_runtime { + state.cached_token_count = rt + .messages + .iter() + .filter_map(|m| m.content.as_deref()) + .map(crate::state::count_tokens) + .sum(); + } else { + state.cached_token_count = 0; + } + state.token_count_dirty = false; + } +} + +/// Render the scrollable chat transcript panel in tight inline-log style. +/// +/// Uses `state.display_lines_cache` — rebuilt by `pre_render_chat` when stale. +/// This function itself is read-only (`&AppStateRest`) and safe to call +/// inside the `terminal.draw` closure. +pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) { + let messages = &state.transcript_cache.messages; + let scroll_offset = state.scroll.offset; + let max_visible = (area.height as usize).saturating_sub(3); + + // If the terminal width has changed since last pre_render, rebuild inline. + // This is a safety fallback — normally pre_render handles this. + let content_width = area.width.saturating_sub(PREFIX_WIDTH as u16 + 2); + + let mut display_lines = if state.last_render_width != area.width && !state.display_lines_cache.is_empty() { + // Width mismatch — use cached but mark needs rebuild next tick + state.display_lines_cache.clone() + } else { + state.display_lines_cache.clone() + }; + + // Streaming indicator — appended live (not cached) so spinner animates smoothly. if state.turn_in_flight() { let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; let frame_idx = (state.misc.tick_count as usize / 2) % spinner_frames.len(); @@ -137,7 +202,10 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRe ])); } - // Scrolling + // Suppress unused variable warning — content_width used for rebuild path + let _ = content_width; + + // Build title let title = if messages.is_empty() { String::from(" 💬 Chat ") } else { diff --git a/apps/interfaces/tui/src/view/mod.rs b/apps/interfaces/tui/src/view/mod.rs index 0cb47c7..9442a8f 100644 --- a/apps/interfaces/tui/src/view/mod.rs +++ b/apps/interfaces/tui/src/view/mod.rs @@ -21,6 +21,15 @@ use zesdex_infrastructure::ToastKind; const SIDEBAR_MIN_WIDTH: u16 = 90; +/// Pre-render hook: update mutable caches (display lines, token count) +/// before the immutable `draw` pass. Called once per frame when dirty. +/// +/// This separates cache mutation from rendering so `draw` can take +/// `&AppStateRest` (required by `terminal.draw` closure constraints). +pub fn pre_render(state: &mut AppStateRest) { + chat::pre_render_chat(state); +} + /// Top-level render entry point called once per TUI frame. pub fn draw(frame: &mut Frame, state: &AppStateRest) { let area = frame.area(); diff --git a/apps/interfaces/tui/src/view/overlays/rewind.rs b/apps/interfaces/tui/src/view/overlays/rewind.rs index a81b7bc..095a11b 100644 --- a/apps/interfaces/tui/src/view/overlays/rewind.rs +++ b/apps/interfaces/tui/src/view/overlays/rewind.rs @@ -37,7 +37,7 @@ pub fn render( ))); } else { let start = if messages.len() > 8 { messages.len() - 8 } else { 0 }; - for msg in &messages[start..] { + for msg in messages.iter().skip(start) { let role_str = match msg.role { Role::User => "User", Role::Assistant => "Asst", diff --git a/apps/interfaces/tui/src/view/status.rs b/apps/interfaces/tui/src/view/status.rs index 2f6bbc6..8f7b648 100644 --- a/apps/interfaces/tui/src/view/status.rs +++ b/apps/interfaces/tui/src/view/status.rs @@ -46,12 +46,10 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::state::AppS let max_tokens = crate::state::resolve_context_window(&state.app_config, &state.settings); let right_str = if let Some(ref rt) = state.session_runtime { - let current_tokens: usize = rt - .messages - .iter() - .filter_map(|m| m.content.as_deref()) - .map(crate::state::count_tokens) - .sum(); + // Use cached token count — recomputed lazily only when new messages + // arrive (token_count_dirty flag), not on every render frame. + // This eliminates the expensive tiktoken_rs call from the hot path. + let current_tokens = state.cached_token_count; let mut parts = Vec::new(); if rt.usage.last_tokens_in > 0 || rt.usage.last_tokens_out > 0 {