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.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 => {
|
||||||
|
|||||||
@@ -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);
|
||||||
terminal.draw(|f| {
|
|
||||||
view::draw(f, state);
|
|
||||||
state.dirty = false;
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Poll terminal with 50 ms timeout
|
// Skip render when nothing has changed — avoids expensive markdown
|
||||||
if crossterm::event::poll(Duration::from_millis(50))? {
|
// 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()? {
|
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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user