feat(tui): optimize performance by caching display lines and token counts, and improve action handling
This commit is contained in:
@@ -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 => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user