feat(tui): optimize performance by caching display lines and token counts, and improve action handling

This commit is contained in:
asepharyana
2026-07-20 13:52:20 +07:00
parent 148ba4e07b
commit 87abe8c335
8 changed files with 180 additions and 60 deletions
+10 -7
View File
@@ -102,7 +102,12 @@ pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) {
state.misc.overlay = crate::state::Overlay::QuitConfirm; state.misc.overlay = crate::state::Overlay::QuitConfirm;
state.mark_dirty(); 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(); state.mark_dirty();
} }
Action::Tick => { Action::Tick => {
@@ -157,9 +162,7 @@ pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) {
} }
} }
zesdex_infrastructure::TurnEvent::Done => { zesdex_infrastructure::TurnEvent::Done => {
if let Ok(mut flag) = state.turn_in_flight_flag.lock() { state.turn_in_flight_flag.store(false, std::sync::atomic::Ordering::SeqCst);
*flag = false;
}
} }
_ => { _ => {
tracing::debug!("unhandled turn event variant"); tracing::debug!("unhandled turn event variant");
@@ -167,9 +170,7 @@ pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) {
} }
} }
} }
// Drain expired toasts // Tick increments counter only; toast expiry handled in run_loop
let now = chrono::Utc::now().timestamp_millis();
state.misc.drain_expired_toasts(now);
state.mark_dirty(); state.mark_dirty();
} }
Action::SubmitInput(text) => { Action::SubmitInput(text) => {
@@ -181,6 +182,8 @@ pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) {
state.input.submit(); state.input.submit();
// Spawn real agent turn on a background thread // Spawn real agent turn on a background thread
crate::turn::spawn_agent_turn(state, text); crate::turn::spawn_agent_turn(state, text);
// Invalidate token count cache since we added a message
state.token_count_dirty = true;
state.mark_dirty(); state.mark_dirty();
} }
Action::DeleteChar => { Action::DeleteChar => {
+26 -2
View File
@@ -76,6 +76,13 @@ fn run_loop(
} }
/// The core single-process render/input 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( fn run_loop_inner(
state: &mut AppStateRest, state: &mut AppStateRest,
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
@@ -84,15 +91,32 @@ fn run_loop_inner(
if state.quit { if state.quit {
break; 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(); let now_ms = chrono::Utc::now().timestamp_millis();
state.misc.drain_expired_toasts(now_ms); 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.
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| { terminal.draw(|f| {
view::draw(f, state); view::draw(f, state);
state.dirty = false; state.dirty = false;
})?; })?;
}
// Poll terminal with 50 ms timeout // Adaptive poll: fast when animating, slow when idle.
if crossterm::event::poll(Duration::from_millis(50))? { 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()? { match crossterm::event::read()? {
Event::Key(key) => { Event::Key(key) => {
if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat { if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat {
+38 -20
View File
@@ -13,11 +13,12 @@
use std::collections::VecDeque; use std::collections::VecDeque;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::AtomicBool; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::Arc;
use tracing::warn; use tracing::warn;
use zesdex_domain::cms::{AppConfig, Settings}; use zesdex_domain::cms::{AppConfig, Settings};
use ratatui::text::Line;
use zesdex_infrastructure::{DirCache, MentionIndex, SessionRuntime, Toast, TurnEvent}; use zesdex_infrastructure::{DirCache, MentionIndex, SessionRuntime, Toast, TurnEvent};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -54,7 +55,8 @@ impl ChatMessageDisplay {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct TranscriptCache { pub struct TranscriptCache {
/// Ordered display messages (newest appended, oldest evicted when full). /// Ordered display messages (newest appended, oldest evicted when full).
pub messages: Vec<ChatMessageDisplay>, /// Uses VecDeque for O(1) front eviction instead of O(n) Vec::remove(0).
pub messages: VecDeque<ChatMessageDisplay>,
/// Maximum messages to retain before evicting the oldest. /// Maximum messages to retain before evicting the oldest.
pub max_lines: usize, pub max_lines: usize,
/// Whether the cache has changed since the last render sweep. /// 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. /// Create an empty transcript cache holding at most `max_lines` messages.
pub fn new(max_lines: usize) -> Self { pub fn new(max_lines: usize) -> Self {
TranscriptCache { TranscriptCache {
messages: Vec::new(), messages: VecDeque::new(),
max_lines, max_lines,
dirty: true, dirty: true,
} }
@@ -830,9 +832,20 @@ pub struct AppStateRest {
/// Miscellaneous state: overlay, toasts, flags, editor, tick. /// Miscellaneous state: overlay, toasts, flags, editor, tick.
pub misc: MiscState, pub misc: MiscState,
/// Queue of events emitted by the running agent turn. /// Queue of events emitted by the running agent turn.
pub turn_events: Arc<Mutex<VecDeque<TurnEvent>>>, pub turn_events: Arc<std::sync::Mutex<VecDeque<TurnEvent>>>,
/// Whether an agent turn is currently in flight. /// Whether an agent turn is currently in flight.
pub turn_in_flight_flag: Arc<Mutex<bool>>, /// Uses AtomicBool for lock-free check from render loop.
pub turn_in_flight_flag: Arc<AtomicBool>,
/// Cached display lines for the chat transcript panel.
/// Rebuilt only when transcript_cache.dirty=true or terminal width changes.
pub display_lines_cache: Vec<Line<'static>>,
/// 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. /// Atomic flag set when the user aborts the current turn.
pub abort_flag: Arc<AtomicBool>, pub abort_flag: Arc<AtomicBool>,
/// Simplified workflow engine state for display. /// Simplified workflow engine state for display.
@@ -896,13 +909,17 @@ impl Default for AppStateRest {
scroll: ScrollState::new(), scroll: ScrollState::new(),
input: InputState::new(), input: InputState::new(),
misc: MiscState::new(), misc: MiscState::new(),
turn_events: Arc::new(Mutex::new(VecDeque::new())), turn_events: Arc::new(std::sync::Mutex::new(VecDeque::new())),
turn_in_flight_flag: Arc::new(Mutex::new(false)), turn_in_flight_flag: Arc::new(AtomicBool::new(false)),
abort_flag: Arc::new(AtomicBool::new(false)), abort_flag: Arc::new(AtomicBool::new(false)),
workflow_engine: SimpleWorkflowEngine::new(), workflow_engine: SimpleWorkflowEngine::new(),
dirty: true, dirty: true,
quit: false, quit: false,
help_text: DEFAULT_HELP_TEXT, 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(), session_dir: session_dir.to_path_buf(),
memory_dir: memory_dir.clone(), memory_dir: memory_dir.clone(),
worktrees_dir, worktrees_dir,
turn_events: Arc::new(Mutex::new(VecDeque::new())), turn_events: Arc::new(std::sync::Mutex::new(VecDeque::new())),
turn_in_flight_flag: Arc::new(Mutex::new(false)), turn_in_flight_flag: Arc::new(AtomicBool::new(false)),
abort_flag: Arc::new(AtomicBool::new(false)), abort_flag: Arc::new(AtomicBool::new(false)),
dir_cache: Arc::new(tokio::sync::RwLock::new(DirCache::new())), dir_cache: Arc::new(tokio::sync::RwLock::new(DirCache::new())),
mention_index: MentionIndex::new(), mention_index: MentionIndex::new(),
@@ -950,27 +967,28 @@ impl AppStateRest {
dirty: true, dirty: true,
quit: false, quit: false,
help_text: DEFAULT_HELP_TEXT, 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. /// 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 { pub fn turn_in_flight(&self) -> bool {
self.turn_in_flight_flag.lock().map_or_else( self.turn_in_flight_flag.load(Ordering::Relaxed)
|_| {
warn!("[state] turn_in_flight mutex poisoned");
false
},
|g| *g,
)
} }
/// Append a message to the transcript. /// 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) { pub fn push_transcript(&mut self, msg: ChatMessageDisplay) {
self.transcript_cache.messages.push(msg); self.transcript_cache.messages.push_back(msg);
if self.transcript_cache.messages.len() > self.transcript_cache.max_lines { while self.transcript_cache.messages.len() > self.transcript_cache.max_lines {
self.transcript_cache.messages.remove(0); self.transcript_cache.messages.pop_front();
} }
self.transcript_cache.dirty = true; self.transcript_cache.dirty = true;
self.token_count_dirty = true;
self.dirty = true; self.dirty = true;
} }
+10 -10
View File
@@ -19,11 +19,12 @@ use crate::state::AppStateRest;
/// Spawn an agent turn on a background OS thread. /// Spawn an agent turn on a background OS thread.
pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) { pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
if let Ok(mut in_flight) = state.turn_in_flight_flag.lock() { // compare_exchange: only mark in-flight if not already running
if *in_flight { if state.turn_in_flight_flag
return; .compare_exchange(false, true, Ordering::SeqCst, Ordering::Relaxed)
} .is_err()
*in_flight = true; {
return; // already running
} }
let turn_events = state.turn_events.clone(); let turn_events = state.turn_events.clone();
@@ -56,7 +57,7 @@ fn run_turn(
session_dir: &Path, session_dir: &Path,
workspace_roots: &[PathBuf], workspace_roots: &[PathBuf],
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>, turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
in_flight: &Arc<Mutex<bool>>, in_flight: &Arc<AtomicBool>,
abort: &Arc<AtomicBool>, abort: &Arc<AtomicBool>,
) { ) {
let client = LlmClient::new( let client = LlmClient::new(
@@ -188,8 +189,7 @@ fn push_event(queue: &Arc<Mutex<VecDeque<TurnEvent>>>, event: TurnEvent) {
} }
} }
fn mark_done(flag: &Arc<Mutex<bool>>) { /// Mark the turn as done using lock-free atomic store.
if let Ok(mut f) = flag.lock() { fn mark_done(flag: &Arc<AtomicBool>) {
*f = false; flag.store(false, Ordering::SeqCst);
}
} }
+78 -10
View File
@@ -5,6 +5,11 @@
//! `{role} {time} {content}` header with wrapped continuation lines //! `{role} {time} {content}` header with wrapped continuation lines
//! aligned under the content column; `Role::Tool` messages render as a //! aligned under the content column; `Role::Tool` messages render as a
//! dim `↳`-prefixed sub-line attached to whatever came before. //! 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 super::theme::Theme;
use ratatui::layout::Rect; use ratatui::layout::Rect;
@@ -44,14 +49,15 @@ fn format_timestamp(ts: i64) -> String {
format!("{hrs:02}:{mins:02}") format!("{hrs:02}:{mins:02}")
} }
/// Render the scrollable chat transcript panel in tight inline-log style. /// Rebuild the full list of display lines from the transcript cache.
pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) { ///
let messages = &state.transcript_cache.messages; /// This is the expensive operation — markdown parsing, span building, etc.
let scroll_offset = state.scroll.offset; /// Only called from `pre_render_chat` when cache is stale.
let max_visible = (area.height as usize).saturating_sub(3); fn build_display_lines(
let content_width = area.width.saturating_sub(PREFIX_WIDTH as u16 + 2); messages: &std::collections::VecDeque<crate::state::ChatMessageDisplay>,
content_width: u16,
let mut display_lines: Vec<Line> = Vec::new(); ) -> Vec<Line<'static>> {
let mut display_lines: Vec<Line<'static>> = Vec::new();
for msg in messages { for msg in messages {
if msg.role == Role::Tool { 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() { if state.turn_in_flight() {
let spinner_frames = ["", "", "", "", "", "", "", "", "", ""]; let spinner_frames = ["", "", "", "", "", "", "", "", "", ""];
let frame_idx = (state.misc.tick_count as usize / 2) % spinner_frames.len(); 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() { let title = if messages.is_empty() {
String::from(" 💬 Chat ") String::from(" 💬 Chat ")
} else { } else {
+9
View File
@@ -21,6 +21,15 @@ use zesdex_infrastructure::ToastKind;
const SIDEBAR_MIN_WIDTH: u16 = 90; 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. /// Top-level render entry point called once per TUI frame.
pub fn draw(frame: &mut Frame, state: &AppStateRest) { pub fn draw(frame: &mut Frame, state: &AppStateRest) {
let area = frame.area(); let area = frame.area();
@@ -37,7 +37,7 @@ pub fn render(
))); )));
} else { } else {
let start = if messages.len() > 8 { messages.len() - 8 } else { 0 }; 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 { let role_str = match msg.role {
Role::User => "User", Role::User => "User",
Role::Assistant => "Asst", Role::Assistant => "Asst",
+4 -6
View File
@@ -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 max_tokens = crate::state::resolve_context_window(&state.app_config, &state.settings);
let right_str = if let Some(ref rt) = state.session_runtime { let right_str = if let Some(ref rt) = state.session_runtime {
let current_tokens: usize = rt // Use cached token count — recomputed lazily only when new messages
.messages // arrive (token_count_dirty flag), not on every render frame.
.iter() // This eliminates the expensive tiktoken_rs call from the hot path.
.filter_map(|m| m.content.as_deref()) let current_tokens = state.cached_token_count;
.map(crate::state::count_tokens)
.sum();
let mut parts = Vec::new(); let mut parts = Vec::new();
if rt.usage.last_tokens_in > 0 || rt.usage.last_tokens_out > 0 { if rt.usage.last_tokens_in > 0 || rt.usage.last_tokens_out > 0 {