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.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 => {
+30 -6
View File
@@ -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<CrosstermBackend<io::Stdout>>,
@@ -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 {
+38 -20
View File
@@ -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<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.
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<Mutex<VecDeque<TurnEvent>>>,
pub turn_events: Arc<std::sync::Mutex<VecDeque<TurnEvent>>>,
/// 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.
pub abort_flag: Arc<AtomicBool>,
/// 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;
}
+10 -10
View File
@@ -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<Mutex<VecDeque<TurnEvent>>>,
in_flight: &Arc<Mutex<bool>>,
in_flight: &Arc<AtomicBool>,
abort: &Arc<AtomicBool>,
) {
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>>) {
if let Ok(mut f) = flag.lock() {
*f = false;
}
/// Mark the turn as done using lock-free atomic store.
fn mark_done(flag: &Arc<AtomicBool>) {
flag.store(false, Ordering::SeqCst);
}
+78 -10
View File
@@ -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<Line> = 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<crate::state::ChatMessageDisplay>,
content_width: u16,
) -> Vec<Line<'static>> {
let mut display_lines: Vec<Line<'static>> = 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 {
+9
View File
@@ -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();
@@ -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",
+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 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 {