From 4c5b309ac5dbeb53c8d60ccb2f1f62ce6f881bc5 Mon Sep 17 00:00:00 2001 From: Ivan Zatevakhin Date: Fri, 21 Aug 2026 16:29:24 +0100 Subject: [PATCH] refactor: extract render state --- src/acp_state.rs | 68 ++++++----- src/app.rs | 96 +-------------- src/handlers.rs | 12 +- src/lib.rs | 1 + src/render_state.rs | 243 ++++++++++++++++++++++++++++++++++++++ src/runtime/event_loop.rs | 3 +- src/runtime/mod.rs | 18 +-- src/runtime/terminal.rs | 4 +- src/ui/chat.rs | 152 ++++++++++++++++-------- src/ui/mod.rs | 55 +++++---- src/ui/popups.rs | 2 +- src/ui/start.rs | 2 +- 12 files changed, 436 insertions(+), 220 deletions(-) create mode 100644 src/render_state.rs diff --git a/src/acp_state.rs b/src/acp_state.rs index fe40705..0c53950 100644 --- a/src/acp_state.rs +++ b/src/acp_state.rs @@ -369,7 +369,7 @@ impl crate::app::App { self.chat.recent_prompt_text = None; self.chat.streaming_content.clear(); self.chat.streaming_content_message_id = None; - self.streaming_cache.invalidate(); + self.render.invalidate_content_cache(); self.set_status(LogLevel::Info, "session", "undone - reloading session"); if let Some(ref sid) = self.sessions.session_id { return Command::load_session_commands( @@ -539,7 +539,7 @@ impl crate::app::App { AcpAppEvent::PromptFailed { local_id, message } => { self.chat.end_llm_request_span(None); self.chat.rollback_pending_prompt(&local_id); - self.card_cache.invalidate(); + self.render.invalidate_card_cache(); self.push_acp_error(&message); self.set_status(LogLevel::Error, "acp", format!("error: {message}")); vec![] @@ -679,8 +679,10 @@ impl crate::app::App { fn reset_active_session_view(&mut self) { self.chat.reset_for_session_switch(); - self.invalidate_streaming_caches(); - self.card_cache.invalidate(); + self.render.invalidate_content_cache(); + self.chat.clear_streaming_thinking(); + self.render.invalidate_thinking_cache(); + self.render.invalidate_card_cache(); if self.delegates.parent_session_id.is_none() { self.delegates.clear_for_root_session(); } @@ -709,7 +711,7 @@ impl crate::app::App { .delegates .upsert_provisional_delegate(tool_call_id, target_agent_id, objective) { - self.invalidate_delegate_render_cache(); + self.render.invalidate_card_cache(); } } @@ -741,7 +743,7 @@ impl crate::app::App { error: update.error, }) { - self.invalidate_delegate_render_cache(); + self.render.invalidate_card_cache(); } } @@ -815,7 +817,7 @@ impl crate::app::App { .delegates .apply_child_snapshot(session_id, state, stats) { - self.invalidate_delegate_render_cache(); + self.render.invalidate_card_cache(); } } @@ -835,8 +837,8 @@ impl crate::app::App { match update { AcpSessionUpdate::TurnStarted => { self.chat.begin_turn(is_replay); - self.streaming_cache.invalidate(); - self.streaming_thinking_cache.invalidate(); + self.render.invalidate_content_cache(); + self.render.invalidate_thinking_cache(); self.set_status(LogLevel::Debug, "activity", "thinking..."); } AcpSessionUpdate::UserMessage { @@ -849,9 +851,9 @@ impl crate::app::App { } => { let transition = self.chat.append_streaming_content(&content, message_id); if transition.finalized_previous { - self.streaming_cache.invalidate(); - self.streaming_thinking_cache.invalidate(); - self.card_cache.invalidate(); + self.render.invalidate_content_cache(); + self.render.invalidate_thinking_cache(); + self.render.invalidate_card_cache(); } } AcpSessionUpdate::AssistantThinkingDelta { @@ -865,9 +867,9 @@ impl crate::app::App { return; } if transition.finalized_previous { - self.streaming_cache.invalidate(); - self.streaming_thinking_cache.invalidate(); - self.card_cache.invalidate(); + self.render.invalidate_content_cache(); + self.render.invalidate_thinking_cache(); + self.render.invalidate_card_cache(); } } AcpSessionUpdate::AssistantMessage { @@ -899,13 +901,13 @@ impl crate::app::App { detail.clone(), ) { self.chat.clear_streaming_thinking(); - self.streaming_thinking_cache.invalidate(); - self.card_cache.invalidate(); + self.render.invalidate_thinking_cache(); + self.render.invalidate_card_cache(); return; } self.chat.record_tool_call(); if self.chat.push_streaming_thinking_entry() { - self.streaming_thinking_cache.invalidate(); + self.render.invalidate_thinking_cache(); } self.chat.push_tool_call(tool_call_id, name, false, detail); } @@ -941,7 +943,7 @@ impl crate::app::App { } } if updated { - self.card_cache.invalidate(); + self.render.invalidate_card_cache(); } } AcpSessionUpdate::UsageUpdate { @@ -993,14 +995,14 @@ impl crate::app::App { } AcpSessionUpdate::Cancelled => { self.chat.cancel_turn(is_replay); - self.streaming_cache.invalidate(); - self.streaming_thinking_cache.invalidate(); + self.render.invalidate_content_cache(); + self.render.invalidate_thinking_cache(); self.set_status(LogLevel::Warn, "activity", "cancelled"); } AcpSessionUpdate::Finished { finish_reason } => { self.chat.finish_turn(is_replay); - self.streaming_cache.invalidate(); - self.streaming_thinking_cache.invalidate(); + self.render.invalidate_content_cache(); + self.render.invalidate_thinking_cache(); self.set_status( LogLevel::Debug, "activity", @@ -1022,14 +1024,16 @@ impl crate::app::App { transition, crate::chat_state::UserMessageTransition::Reconciled ) { - self.card_cache.invalidate(); + self.render.invalidate_card_cache(); } } fn finalize_streaming_segment(&mut self) { if self.chat.finalize_streaming_segment() { - self.invalidate_streaming_caches(); - self.card_cache.invalidate(); + self.render.invalidate_content_cache(); + self.chat.clear_streaming_thinking(); + self.render.invalidate_thinking_cache(); + self.render.invalidate_card_cache(); } } @@ -1043,13 +1047,13 @@ impl crate::app::App { thinking: Option, message_id: Option, ) { - self.streaming_cache.invalidate(); + self.render.invalidate_content_cache(); let replaced = self .chat .push_assistant_message(content, thinking, message_id); - self.streaming_thinking_cache.invalidate(); + self.render.invalidate_thinking_cache(); if replaced { - self.card_cache.invalidate(); + self.render.invalidate_card_cache(); } } @@ -1090,9 +1094,9 @@ impl crate::app::App { return; } if transition.finalized_streaming { - self.streaming_cache.invalidate(); - self.streaming_thinking_cache.invalidate(); - self.card_cache.invalidate(); + self.render.invalidate_content_cache(); + self.render.invalidate_thinking_cache(); + self.render.invalidate_card_cache(); } if supported { self.set_status( diff --git a/src/app.rs b/src/app.rs index d497f4c..d9613ab 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,54 +8,13 @@ use crate::connection_state::{ConnState, ConnectionState}; use crate::delegates_state::DelegatesState; use crate::diagnostics::{AppLogEntry, DiagnosticsState, LogLevel}; use crate::domain::activity::{DelegateChildState, DelegateStats}; -use crate::highlight::Highlighter; -use crate::markdown::CardBlock; use crate::mesh_state::MeshState; use crate::models_state::ModelsState; use crate::navigation_state::{NavigationState, Popup}; use crate::profiles_state::ProfilesState; use crate::protocol::audit::EventKind; +use crate::render_state::RenderState; use crate::session_state::SessionsState; -use crate::ui::CardCache; - -/// Cache for rendered streaming markdown to avoid re-parsing every frame. -/// Invalidated when `streaming_content` grows or is cleared. -pub struct StreamingCache { - /// Length of `streaming_content` at the time of last render. - rendered_len: usize, - /// Cached rendered blocks (without the spinner). - blocks: Vec, -} - -impl StreamingCache { - pub fn new() -> Self { - Self { - rendered_len: 0, - blocks: Vec::new(), - } - } - - /// Returns cached blocks if content length hasn't changed, otherwise None. - pub fn get(&self, content_len: usize) -> Option<&[CardBlock]> { - if content_len > 0 && content_len == self.rendered_len { - Some(&self.blocks) - } else { - None - } - } - - /// Store freshly rendered blocks and the content length they correspond to. - pub fn store(&mut self, content_len: usize, blocks: Vec) { - self.rendered_len = content_len; - self.blocks = blocks; - } - - /// Reset the cache (call when streaming_content is cleared). - pub fn invalidate(&mut self) { - self.rendered_len = 0; - self.blocks.clear(); - } -} // ── Delegation tracking ─────────────────────────────────────────────────────── @@ -177,12 +136,6 @@ pub struct App { // chat pub(crate) chat: ChatState, pub(crate) composer: ComposerState, - /// Total content height (in rows) from the last render frame. - /// Used to compensate chat scroll when content grows while the user - /// is scrolled up, so the viewport stays at the same absolute position. - pub prev_total_height: u16, - pub streaming_cache: StreamingCache, - pub streaming_thinking_cache: StreamingCache, // profile info pub(crate) profiles: ProfilesState, @@ -199,16 +152,12 @@ pub struct App { // connection and server lifecycle pub(crate) connection: ConnectionState, - // syntax highlighting - pub hl: Highlighter, - - // card cache for incremental rendering - pub(crate) card_cache: CardCache, + // temporary render-local composition + pub(crate) render: RenderState, // auth popup state pub(crate) auth: AuthState, - pub tick: u64, pub should_quit: bool, } @@ -246,18 +195,13 @@ impl App { delegates: DelegatesState::new(), chat: ChatState::new(), composer: ComposerState::new(), - prev_total_height: 0, - streaming_cache: StreamingCache::new(), - streaming_thinking_cache: StreamingCache::new(), profiles: ProfilesState::new(), models: ModelsState::new(), diagnostics: DiagnosticsState::new(), mesh: MeshState::new(), connection: ConnectionState::new(), - hl: Highlighter::new(), - card_cache: CardCache::new(), + render: RenderState::new(), auth: AuthState::new(), - tick: 0, should_quit: false, } } @@ -272,21 +216,6 @@ impl App { std::mem::take(&mut self.composer.input) } - /// Invalidate both streaming caches and clear the thinking buffer. - /// - /// Call this when a streaming turn ends (assistant message finalized, - /// new turn starts, session reloaded, etc.) so stale markdown renders - /// are discarded. - pub fn invalidate_streaming_caches(&mut self) { - self.streaming_cache.invalidate(); - self.chat.clear_streaming_thinking(); - self.streaming_thinking_cache.invalidate(); - } - - pub fn invalidate_delegate_render_cache(&mut self) { - self.card_cache.invalidate(); - } - /// Cycle through `[auto, low, medium, high, max]` (wraps around). /// Updates the nested reasoning state optimistically and /// returns the [`Command`] to forward to the server. @@ -369,19 +298,6 @@ impl App { self.set_status(LogLevel::Warn, "input", "press Esc again to stop"); } - /// Adjust `scroll_offset` to compensate for content growth so the - /// viewport stays at the same absolute position when the user is - /// scrolled up. No-op when `scroll_offset == 0` (auto-following). - /// - /// Call from the renderer after computing the new `total_height`. - pub fn compensate_scroll_for_growth(&mut self, total_height: u16) { - let growth = total_height.saturating_sub(self.prev_total_height); - if self.chat.scroll_offset > 0 && growth > 0 { - self.chat.scroll_offset = self.chat.scroll_offset.saturating_add(growth); - } - self.prev_total_height = total_height; - } - pub fn open_fork_turn_popup(&mut self) { self.navigation.popup = Popup::ForkTurnSelect; self.chat.reset_fork_selector(); @@ -389,7 +305,7 @@ impl App { pub fn push_pending_prompt(&mut self, text: String) -> String { let local_id = self.chat.push_pending_prompt(text); - self.card_cache.invalidate(); + self.render.invalidate_card_cache(); local_id } @@ -473,7 +389,7 @@ impl App { /// Mark the pending elicitation chat card with an outcome and clear the active state. pub fn resolve_elicitation(&mut self, elicitation_id: &str, outcome: &str) { self.chat.resolve_elicitation(elicitation_id, outcome); - self.card_cache.invalidate(); + self.render.invalidate_card_cache(); self.refresh_transient_status(); } diff --git a/src/handlers.rs b/src/handlers.rs index d991286..e109ba2 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -1485,14 +1485,6 @@ pub(crate) fn handle_new_session_popup_key( Ok(()) } -/// Invalidate every theme-dependent cache in `app` so that the next render -/// frame rebuilds styled lines with the current palette. -pub(crate) fn invalidate_theme_caches(app: &mut App) { - app.card_cache.invalidate(); - app.streaming_cache.invalidate(); - app.streaming_thinking_cache.invalidate(); -} - pub(crate) fn handle_theme_popup_key(app: &mut App, key: KeyEvent) -> anyhow::Result<()> { match key.code { KeyCode::Esc => { @@ -1513,7 +1505,7 @@ pub(crate) fn handle_theme_popup_key(app: &mut App, key: KeyEvent) -> anyhow::Re { theme::Theme::set_by_index(idx); theme::Theme::begin_frame(); - invalidate_theme_caches(app); + app.render.invalidate_theme_caches(); app.navigation.popup = Popup::None; save_config(app); } @@ -1601,7 +1593,7 @@ pub(crate) fn handle_chat_key( local_id: local_id.clone(), }) { app.chat.rollback_pending_prompt(&local_id); - app.card_cache.invalidate(); + app.render.invalidate_card_cache(); return Err(error.into()); } } diff --git a/src/lib.rs b/src/lib.rs index 088eba2..de9fc13 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,7 @@ mod models_state; mod navigation_state; mod profiles_state; mod protocol; +mod render_state; pub mod runtime; mod server_manager; mod session; diff --git a/src/render_state.rs b/src/render_state.rs new file mode 100644 index 0000000..42eb2e9 --- /dev/null +++ b/src/render_state.rs @@ -0,0 +1,243 @@ +use crate::highlight::Highlighter; +use crate::markdown::CardBlock; +use crate::ui::CardCache; + +/// Temporary phase 4 owner for render-local state. +pub(crate) struct RenderState { + pub(crate) highlighter: Highlighter, + pub(crate) card_cache: CardCache, + pub(crate) streaming_cache: StreamingCache, + pub(crate) streaming_thinking_cache: StreamingCache, + pub(crate) prev_total_height: u16, + pub(crate) tick: u64, +} + +impl RenderState { + pub(crate) fn new() -> Self { + Self { + highlighter: Highlighter::new(), + card_cache: CardCache::new(), + streaming_cache: StreamingCache::new(), + streaming_thinking_cache: StreamingCache::new(), + prev_total_height: 0, + tick: 0, + } + } + + pub(crate) fn invalidate_card_cache(&mut self) { + self.card_cache.invalidate(); + } + + pub(crate) fn invalidate_content_cache(&mut self) { + self.streaming_cache.invalidate(); + } + + pub(crate) fn invalidate_thinking_cache(&mut self) { + self.streaming_thinking_cache.invalidate(); + } + + pub(crate) fn invalidate_theme_caches(&mut self) { + self.invalidate_card_cache(); + self.invalidate_content_cache(); + self.invalidate_thinking_cache(); + } + + pub(crate) fn replace_tick(&mut self, tick: u64) { + self.tick = tick; + } + + pub(crate) fn compensate_scroll_for_growth( + &mut self, + total_height: u16, + scroll_offset: &mut u16, + ) { + let growth = total_height.saturating_sub(self.prev_total_height); + if *scroll_offset > 0 && growth > 0 { + *scroll_offset = (*scroll_offset).saturating_add(growth); + } + self.prev_total_height = total_height; + } +} + +/// Cache for rendered streaming markdown to avoid re-parsing every frame. +/// Invalidated when the corresponding streaming buffer grows or is cleared. +pub(crate) struct StreamingCache { + rendered_len: usize, + blocks: Vec, +} + +impl StreamingCache { + pub(crate) fn new() -> Self { + Self { + rendered_len: 0, + blocks: Vec::new(), + } + } + + pub(crate) fn get(&self, content_len: usize) -> Option<&[CardBlock]> { + if content_len > 0 && content_len == self.rendered_len { + Some(&self.blocks) + } else { + None + } + } + + pub(crate) fn store(&mut self, content_len: usize, blocks: Vec) { + self.rendered_len = content_len; + self.blocks = blocks; + } + + pub(crate) fn invalidate(&mut self) { + self.rendered_len = 0; + self.blocks.clear(); + } +} + +#[cfg(test)] +mod tests { + use ratatui::text::Line; + + use super::*; + + fn block(text: &'static str) -> CardBlock { + CardBlock::Text(Line::from(text)) + } + + fn seed_all_caches(state: &mut RenderState) { + state.card_cache.processed_messages = 2; + state.streaming_cache.store(7, vec![block("content")]); + state + .streaming_thinking_cache + .store(8, vec![block("thinking")]); + } + + #[test] + fn constructor_uses_exact_six_field_defaults() { + let RenderState { + highlighter: _, + card_cache, + streaming_cache, + streaming_thinking_cache, + prev_total_height, + tick, + } = RenderState::new(); + + assert!(card_cache.cards.is_empty()); + assert_eq!(card_cache.processed_messages, 0); + assert!(streaming_cache.get(1).is_none()); + assert!(streaming_thinking_cache.get(1).is_none()); + assert_eq!(prev_total_height, 0); + assert_eq!(tick, 0); + } + + #[test] + fn streaming_cache_hits_only_the_stored_nonzero_length() { + let mut cache = StreamingCache::new(); + cache.store(7, vec![block("content")]); + + assert!(cache.get(0).is_none()); + assert!(cache.get(6).is_none()); + assert_eq!(cache.get(7).map(<[_]>::len), Some(1)); + assert!(cache.get(8).is_none()); + } + + #[test] + fn individual_invalidations_clear_only_the_selected_cache() { + let mut state = RenderState::new(); + seed_all_caches(&mut state); + + state.invalidate_content_cache(); + assert_eq!(state.card_cache.processed_messages, 2); + assert!(state.streaming_cache.get(7).is_none()); + assert!(state.streaming_thinking_cache.get(8).is_some()); + + state.invalidate_thinking_cache(); + assert_eq!(state.card_cache.processed_messages, 2); + assert!(state.streaming_thinking_cache.get(8).is_none()); + + state.invalidate_card_cache(); + assert_eq!(state.card_cache.processed_messages, 0); + } + + #[test] + fn theme_invalidation_clears_all_three_caches() { + let mut state = RenderState::new(); + seed_all_caches(&mut state); + + state.invalidate_theme_caches(); + + assert_eq!(state.card_cache.processed_messages, 0); + assert!(state.streaming_cache.get(7).is_none()); + assert!(state.streaming_thinking_cache.get(8).is_none()); + } + + #[test] + fn session_switch_invalidation_keeps_streaming_sessions_isolated() { + let mut state = RenderState::new(); + seed_all_caches(&mut state); + + state.invalidate_content_cache(); + state.invalidate_thinking_cache(); + state.invalidate_card_cache(); + state.streaming_cache.store(4, vec![block("next")]); + + assert_eq!(state.card_cache.processed_messages, 0); + assert!(state.streaming_cache.get(7).is_none()); + assert!(state.streaming_cache.get(4).is_some()); + assert!(state.streaming_thinking_cache.get(8).is_none()); + } + + #[test] + fn external_editor_invalidation_retains_thinking_cache() { + let mut state = RenderState::new(); + seed_all_caches(&mut state); + + state.invalidate_card_cache(); + state.invalidate_content_cache(); + + assert_eq!(state.card_cache.processed_messages, 0); + assert!(state.streaming_cache.get(7).is_none()); + assert!(state.streaming_thinking_cache.get(8).is_some()); + } + + #[test] + fn delegate_invalidation_clears_only_card_cache() { + let mut state = RenderState::new(); + seed_all_caches(&mut state); + + state.invalidate_card_cache(); + + assert_eq!(state.card_cache.processed_messages, 0); + assert!(state.streaming_cache.get(7).is_some()); + assert!(state.streaming_thinking_cache.get(8).is_some()); + } + + #[test] + fn tick_replacement_preserves_wall_clock_derived_value() { + let mut state = RenderState::new(); + + state.replace_tick(17); + state.replace_tick(3); + + assert_eq!(state.tick, 3); + } + + #[test] + fn height_growth_compensates_only_when_scrolled_up() { + let mut state = RenderState::new(); + let mut scroll_offset = 4; + + state.compensate_scroll_for_growth(10, &mut scroll_offset); + assert_eq!(scroll_offset, 14); + assert_eq!(state.prev_total_height, 10); + + state.compensate_scroll_for_growth(7, &mut scroll_offset); + assert_eq!(scroll_offset, 14); + assert_eq!(state.prev_total_height, 7); + + scroll_offset = 0; + state.compensate_scroll_for_growth(12, &mut scroll_offset); + assert_eq!(scroll_offset, 0); + assert_eq!(state.prev_total_height, 12); + } +} diff --git a/src/runtime/event_loop.rs b/src/runtime/event_loop.rs index 43b8a34..feb602d 100644 --- a/src/runtime/event_loop.rs +++ b/src/runtime/event_loop.rs @@ -108,7 +108,8 @@ pub(super) async fn run_loop( let mut term_events = EventStream::new(); loop { - app.tick = tick_from_elapsed(app.diagnostics.started_at.elapsed()); + app.render + .replace_tick(tick_from_elapsed(app.diagnostics.started_at.elapsed())); app.clear_expired_cancel_confirm(); terminal.draw(|frame| ui::draw(frame, app))?; diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index cbf587f..4c2f203 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -423,23 +423,23 @@ mod tests { }) .expect("edit preview should contain a styled file span"); - app.streaming_cache.store( + app.render.streaming_cache.store( 5, vec![crate::markdown::CardBlock::Text(ratatui::text::Line::from( "stream", ))], ); - app.streaming_thinking_cache.store( + app.render.streaming_thinking_cache.store( 3, vec![crate::markdown::CardBlock::Text(ratatui::text::Line::from( "think", ))], ); - assert!(app.streaming_cache.get(5).is_some()); - assert!(app.streaming_thinking_cache.get(3).is_some()); + assert!(app.render.streaming_cache.get(5).is_some()); + assert!(app.render.streaming_thinking_cache.get(3).is_some()); assert_eq!( - app.card_cache.processed_messages, + app.render.card_cache.processed_messages, app.chat.messages.len(), "card_cache should be populated" ); @@ -449,18 +449,18 @@ mod tests { Theme::begin_frame(); let current_preview_fg = Theme::diff_file().fg.expect("diff_file should define fg"); assert_ne!(old_preview_fg, current_preview_fg); - invalidate_theme_caches(&mut app); + app.render.invalidate_theme_caches(); assert_eq!( - app.card_cache.processed_messages, 0, + app.render.card_cache.processed_messages, 0, "card_cache should be invalidated" ); assert!( - app.streaming_cache.get(5).is_none(), + app.render.streaming_cache.get(5).is_none(), "streaming_cache should be invalidated" ); assert!( - app.streaming_thinking_cache.get(3).is_none(), + app.render.streaming_thinking_cache.get(3).is_none(), "streaming_thinking_cache should be invalidated" ); assert!(matches!( diff --git a/src/runtime/terminal.rs b/src/runtime/terminal.rs index 660cbdd..708652d 100644 --- a/src/runtime/terminal.rs +++ b/src/runtime/terminal.rs @@ -45,8 +45,8 @@ pub(super) fn open_external_editor_with_terminal( terminal.hide_cursor()?; terminal.clear()?; terminal.autoresize()?; - app.card_cache.invalidate(); - app.streaming_cache.invalidate(); + app.render.invalidate_card_cache(); + app.render.invalidate_content_cache(); apply_external_editor_outcome(app, result); terminal.draw(|frame| ui::draw(frame, app))?; Ok(()) diff --git a/src/ui/chat.rs b/src/ui/chat.rs index 245f81a..decf7ba 100644 --- a/src/ui/chat.rs +++ b/src/ui/chat.rs @@ -249,29 +249,29 @@ impl Card { /// Build cards for finalized messages incrementally (cached). /// Does NOT include the streaming/thinking card — that's built separately. pub(crate) fn build_message_cards(app: &mut App) -> &[Card] { - let cache = &app.card_cache; + let cache = &app.render.card_cache; // Auto-invalidate if messages shrank (clear/retain) if app.chat.messages.len() < cache.processed_messages { - app.card_cache.invalidate(); + app.render.card_cache.invalidate(); } // Cache hit — nothing new - if app.chat.messages.len() == app.card_cache.processed_messages { - return &app.card_cache.cards; + if app.chat.messages.len() == app.render.card_cache.processed_messages { + return &app.render.card_cache.cards; } // Determine where to start processing. If the last cached card is a tool // batch, we need to pop it and re-process from the batch start, because // new tool messages might need to merge into that batch. let start_idx = if matches!( - app.card_cache.cards.last().map(|c| &c.kind), + app.render.card_cache.cards.last().map(|c| &c.kind), Some(CardKind::Tool { .. }) ) { - app.card_cache.cards.pop(); + app.render.card_cache.cards.pop(); // Scan backwards to find where the tool batch started. Hidden thinking // entries are transparent and should not split a tool batch. - let mut idx = app.card_cache.processed_messages; + let mut idx = app.render.card_cache.processed_messages; while idx > 0 { match app.chat.messages.get(idx - 1) { Some(ChatEntry::ToolCall { .. }) => idx -= 1, @@ -281,7 +281,7 @@ pub(crate) fn build_message_cards(app: &mut App) -> &[Card] { } idx } else { - app.card_cache.processed_messages + app.render.card_cache.processed_messages }; // Process new messages from start_idx @@ -326,20 +326,26 @@ pub(crate) fn build_message_cards(app: &mut App) -> &[Card] { for entry in &app.chat.messages[start_idx..] { match entry { ChatEntry::User { text, .. } => { - flush_tools(&mut pending_tools, &mut app.card_cache.cards); - let blocks = markdown::render(text, Theme::user_text(), &app.hl); - app.card_cache.cards.push(Card::new(CardKind::User, blocks)); + flush_tools(&mut pending_tools, &mut app.render.card_cache.cards); + let blocks = markdown::render(text, Theme::user_text(), &app.render.highlighter); + app.render + .card_cache + .cards + .push(Card::new(CardKind::User, blocks)); } ChatEntry::Assistant { content, thinking, .. } => { - flush_tools(&mut pending_tools, &mut app.card_cache.cards); + flush_tools(&mut pending_tools, &mut app.render.card_cache.cards); let mut blocks = Vec::new(); if app.chat.show_thinking && let Some(thinking_text) = thinking { - let mut rendered = - markdown::render(thinking_text, Theme::thinking_text(), &app.hl); + let mut rendered = markdown::render( + thinking_text, + Theme::thinking_text(), + &app.render.highlighter, + ); markdown::prepend_span_to_first_text( &mut rendered, Span::styled("\u{25CF} ", Theme::thinking()), @@ -347,20 +353,27 @@ pub(crate) fn build_message_cards(app: &mut App) -> &[Card] { blocks.extend(rendered); blocks.push(crate::markdown::CardBlock::Text(Line::default())); } - blocks.extend(markdown::render(content, Theme::assistant_text(), &app.hl)); - app.card_cache + blocks.extend(markdown::render( + content, + Theme::assistant_text(), + &app.render.highlighter, + )); + app.render + .card_cache .cards .push(Card::new(CardKind::Assistant, blocks)); } ChatEntry::Thinking { content, .. } => { if app.chat.show_thinking { - flush_tools(&mut pending_tools, &mut app.card_cache.cards); - let mut blocks = markdown::render(content, Theme::thinking_text(), &app.hl); + flush_tools(&mut pending_tools, &mut app.render.card_cache.cards); + let mut blocks = + markdown::render(content, Theme::thinking_text(), &app.render.highlighter); markdown::prepend_span_to_first_text( &mut blocks, Span::styled("\u{25CF} ", Theme::thinking()), ); - app.card_cache + app.render + .card_cache .cards .push(Card::new(CardKind::Thinking, blocks)); } @@ -565,9 +578,9 @@ pub(crate) fn build_message_cards(app: &mut App) -> &[Card] { } } ChatEntry::CompactionStart { token_estimate } => { - flush_tools(&mut pending_tools, &mut app.card_cache.cards); + flush_tools(&mut pending_tools, &mut app.render.card_cache.cards); let token_str = format!("~{} tokens", token_estimate); - app.card_cache.cards.push(Card::new( + app.render.card_cache.cards.push(Card::new( CardKind::Compaction, vec![ crate::markdown::CardBlock::Text(Line::from(vec![ @@ -589,7 +602,7 @@ pub(crate) fn build_message_cards(app: &mut App) -> &[Card] { summary, summary_len, } => { - flush_tools(&mut pending_tools, &mut app.card_cache.cards); + flush_tools(&mut pending_tools, &mut app.render.card_cache.cards); let mut blocks = vec![crate::markdown::CardBlock::Text(Line::from(vec![ Span::styled("[compact] ", Theme::status_accent()), Span::styled("Conversation summarized", Theme::status_accent()), @@ -606,21 +619,26 @@ pub(crate) fn build_message_cards(app: &mut App) -> &[Card] { )))); } blocks.push(crate::markdown::CardBlock::Text(Line::default())); - blocks.extend(markdown::render(summary, Theme::assistant_text(), &app.hl)); - app.card_cache + blocks.extend(markdown::render( + summary, + Theme::assistant_text(), + &app.render.highlighter, + )); + app.render + .card_cache .cards .push(Card::new(CardKind::Compaction, blocks)); } ChatEntry::Info(text) => { - flush_tools(&mut pending_tools, &mut app.card_cache.cards); - app.card_cache.cards.push(Card::new( + flush_tools(&mut pending_tools, &mut app.render.card_cache.cards); + app.render.card_cache.cards.push(Card::new( CardKind::Info, vec![crate::markdown::CardBlock::Text(Line::from(text.clone()))], )); } ChatEntry::Error(text) => { - flush_tools(&mut pending_tools, &mut app.card_cache.cards); - app.card_cache.cards.push(Card::new( + flush_tools(&mut pending_tools, &mut app.render.card_cache.cards); + app.render.card_cache.cards.push(Card::new( CardKind::Error, vec![crate::markdown::CardBlock::Text(Line::from(text.clone()))], )); @@ -631,7 +649,7 @@ pub(crate) fn build_message_cards(app: &mut App) -> &[Card] { outcome, .. } => { - flush_tools(&mut pending_tools, &mut app.card_cache.cards); + flush_tools(&mut pending_tools, &mut app.render.card_cache.cards); let header = crate::markdown::CardBlock::Text(Line::from(vec![ Span::styled("[?] ", Theme::status_accent()), Span::styled(message.clone(), Theme::status_accent()), @@ -659,17 +677,18 @@ pub(crate) fn build_message_cards(app: &mut App) -> &[Card] { } } } - app.card_cache + app.render + .card_cache .cards .push(Card::new(CardKind::Elicitation, card_blocks)); } } } - flush_tools(&mut pending_tools, &mut app.card_cache.cards); - app.card_cache.processed_messages = app.chat.messages.len(); + flush_tools(&mut pending_tools, &mut app.render.card_cache.cards); + app.render.card_cache.processed_messages = app.chat.messages.len(); - &app.card_cache.cards + &app.render.card_cache.cards } // ── Shared header builder ───────────────────────────────────────────────────── @@ -1100,17 +1119,23 @@ fn draw_input_panel( let (label_text, label_style) = match &app.chat.activity { ActivityState::SessionOp(SessionOp::Undo) => ( - format!("{} undoing ", spinner(SpinnerKind::Braille, app.tick)), + format!( + "{} undoing ", + spinner(SpinnerKind::Braille, app.render.tick) + ), Theme::input_undo(), ), ActivityState::SessionOp(SessionOp::Redo) => ( - format!("{} redoing ", spinner(SpinnerKind::Braille, app.tick)), + format!( + "{} redoing ", + spinner(SpinnerKind::Braille, app.render.tick) + ), Theme::input_redo(), ), _ if app.chat.cancel_confirm_active() => ( format!( "{} Esc again to stop ", - spinner(SpinnerKind::Braille, app.tick) + spinner(SpinnerKind::Braille, app.render.tick) ), Theme::input_cancel_confirm(), ), @@ -1118,7 +1143,7 @@ fn draw_input_panel( | ActivityState::RunningTool { .. } | ActivityState::Thinking | ActivityState::Streaming => ( - format!("{} ", spinner(SpinnerKind::Braille, app.tick)), + format!("{} ", spinner(SpinnerKind::Braille, app.render.tick)), Theme::input_thinking(), ), _ if app.chat.elicitation.is_some() => ( @@ -1523,7 +1548,10 @@ fn draw_mention_panel(f: &mut Frame, app: &App, area: Rect) { let mut items: Vec = Vec::new(); if app.composer.file_index_loading && app.composer.file_index.is_empty() { items.push(ListItem::new(Line::from(vec![Span::styled( - format!("{} indexing files", spinner(SpinnerKind::Braille, app.tick)), + format!( + "{} indexing files", + spinner(SpinnerKind::Braille, app.render.tick) + ), Theme::thinking(), )]))); } else if let Some(error) = &app.composer.file_index_error { @@ -1577,15 +1605,27 @@ fn draw_mention_panel(f: &mut Frame, app: &App, area: Rect) { fn build_streaming_card(app: &mut App) -> Option { let activity_text = match &app.chat.activity { ActivityState::RunningTool { name } => { - format!("{} tool: {name}", spinner(SpinnerKind::Braille, app.tick)) + format!( + "{} tool: {name}", + spinner(SpinnerKind::Braille, app.render.tick) + ) } ActivityState::Compacting { .. } => { - format!("{} compacting", spinner(SpinnerKind::Braille, app.tick)) + format!( + "{} compacting", + spinner(SpinnerKind::Braille, app.render.tick) + ) } ActivityState::Streaming => { - format!("{} streaming", spinner(SpinnerKind::Braille, app.tick)) + format!( + "{} streaming", + spinner(SpinnerKind::Braille, app.render.tick) + ) } - _ => format!("{} thinking", spinner(SpinnerKind::Braille, app.tick)), + _ => format!( + "{} thinking", + spinner(SpinnerKind::Braille, app.render.tick) + ), }; let has_thinking = app.chat.show_thinking && !app.chat.streaming_thinking.is_empty(); @@ -1597,15 +1637,16 @@ fn build_streaming_card(app: &mut App) -> Option { if has_thinking { let thinking_len = app.chat.streaming_thinking.len(); let mut thinking_blocks = - if let Some(cached) = app.streaming_thinking_cache.get(thinking_len) { + if let Some(cached) = app.render.streaming_thinking_cache.get(thinking_len) { cached.to_vec() } else { let rendered = markdown::render( &app.chat.streaming_thinking, Theme::thinking_text(), - &app.hl, + &app.render.highlighter, ); - app.streaming_thinking_cache + app.render + .streaming_thinking_cache .store(thinking_len, rendered.clone()); rendered }; @@ -1621,15 +1662,17 @@ fn build_streaming_card(app: &mut App) -> Option { if has_content { let content_len = app.chat.streaming_content.len(); - let content_blocks = if let Some(cached) = app.streaming_cache.get(content_len) { + let content_blocks = if let Some(cached) = app.render.streaming_cache.get(content_len) { cached.to_vec() } else { let rendered = markdown::render( &app.chat.streaming_content, Theme::assistant_text(), - &app.hl, + &app.render.highlighter, ); - app.streaming_cache.store(content_len, rendered.clone()); + app.render + .streaming_cache + .store(content_len, rendered.clone()); rendered }; blocks.extend(content_blocks); @@ -1991,17 +2034,23 @@ fn draw_messages(f: &mut Frame, app: &mut App, area: Rect) { // Compute total_height in a temporary scope so we can mutably access app // for scroll compensation before borrowing card_cache again for rendering. let total_height: u16 = { - let cards = app.card_cache.cards.iter().chain(streaming_card.iter()); + let cards = app + .render + .card_cache + .cards + .iter() + .chain(streaming_card.iter()); cards.map(|c| c.height(area.width)).sum() }; - if total_height == 0 && app.card_cache.cards.is_empty() && streaming_card.is_none() { + if total_height == 0 && app.render.card_cache.cards.is_empty() && streaming_card.is_none() { return; } // When the user is scrolled up, bump scroll_offset by however much // content grew so the viewport stays at the same absolute position. - app.compensate_scroll_for_growth(total_height); + app.render + .compensate_scroll_for_growth(total_height, &mut app.chat.scroll_offset); // max scroll = how far we can scroll from the bottom let max_scroll = total_height.saturating_sub(area.height); @@ -2011,6 +2060,7 @@ fn draw_messages(f: &mut Frame, app: &mut App, area: Rect) { let scroll = max_scroll.saturating_sub(app.chat.scroll_offset); let all_cards: Vec<&Card> = app + .render .card_cache .cards .iter() diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 6467e50..5037a03 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -2333,7 +2333,7 @@ mod tests { let mut app = App::new(); let cards = build_message_cards(&mut app); assert!(cards.is_empty()); - assert_eq!(app.card_cache.processed_messages, 0); + assert_eq!(app.render.card_cache.processed_messages, 0); } #[test] @@ -2346,7 +2346,7 @@ mod tests { let cards = build_message_cards(&mut app); assert_eq!(cards.len(), 1); assert_eq!(cards[0].kind, CardKind::User); - assert_eq!(app.card_cache.processed_messages, 1); + assert_eq!(app.render.card_cache.processed_messages, 1); } #[test] @@ -2409,12 +2409,12 @@ mod tests { message_id: None, }); build_message_cards(&mut app); - assert_eq!(app.card_cache.processed_messages, 1); + assert_eq!(app.render.card_cache.processed_messages, 1); // Second call with no new messages — cache hit let cards = build_message_cards(&mut app); assert_eq!(cards.len(), 1); - assert_eq!(app.card_cache.processed_messages, 1); + assert_eq!(app.render.card_cache.processed_messages, 1); } #[test] @@ -2438,7 +2438,10 @@ mod tests { let lines = rendered_card_lines(&mut app); assert!(!lines.iter().any(|line| line.contains("awaiting input"))); - assert_eq!(app.card_cache.processed_messages, app.chat.messages.len()); + assert_eq!( + app.render.card_cache.processed_messages, + app.chat.messages.len() + ); app.delegates.delegate_entries[0].child_state = DelegateChildState::PendingElicitation { elicitation_id: "elic-1".into(), @@ -2446,7 +2449,7 @@ mod tests { requested_schema: serde_json::json!({ "properties": {} }), source: "builtin:question".into(), }; - app.invalidate_delegate_render_cache(); + app.render.invalidate_card_cache(); let lines = rendered_card_lines(&mut app); assert_eq!( @@ -2506,7 +2509,7 @@ mod tests { child_state: DelegateChildState::None, }, ]; - app.invalidate_delegate_render_cache(); + app.render.invalidate_card_cache(); let lines = rendered_card_lines(&mut app); let first = lines @@ -2584,13 +2587,13 @@ mod tests { message_id: None, }); build_message_cards(&mut app); - assert_eq!(app.card_cache.processed_messages, 1); + assert_eq!(app.render.card_cache.processed_messages, 1); app.chat.messages.clear(); - app.card_cache.invalidate(); + app.render.card_cache.invalidate(); let cards = build_message_cards(&mut app); assert!(cards.is_empty()); - assert_eq!(app.card_cache.processed_messages, 0); + assert_eq!(app.render.card_cache.processed_messages, 0); } #[test] @@ -2606,7 +2609,7 @@ mod tests { message_id: None, }); build_message_cards(&mut app); - assert_eq!(app.card_cache.processed_messages, 2); + assert_eq!(app.render.card_cache.processed_messages, 2); // Simulate retain() shrinking messages (like compaction does) app.chat @@ -2645,7 +2648,7 @@ mod tests { ChatEntry::ToolCall { is_error: true, .. } )); assert_eq!( - app.card_cache.processed_messages, 0, + app.render.card_cache.processed_messages, 0, "cache must invalidate" ); let lines = rendered_card_lines(&mut app); @@ -2671,7 +2674,10 @@ mod tests { let warm_lines = rendered_card_lines(&mut app); assert!(warm_lines.iter().any(|line| line.contains("$ cargo test"))); assert!(!warm_lines.iter().any(|line| line.contains("tail sentinel"))); - assert_eq!(app.card_cache.processed_messages, app.chat.messages.len()); + assert_eq!( + app.render.card_cache.processed_messages, + app.chat.messages.len() + ); live_update( &mut app, @@ -2690,7 +2696,7 @@ mod tests { "tool detail update should be in place" ); assert_eq!( - app.card_cache.processed_messages, 0, + app.render.card_cache.processed_messages, 0, "semantic update should invalidate the warm card" ); let rebuilt_lines = rendered_card_lines(&mut app); @@ -2731,7 +2737,10 @@ mod tests { .iter() .any(|line| line.contains(" 1 ") && line.contains("- before")) ); - assert_eq!(app.card_cache.processed_messages, app.chat.messages.len()); + assert_eq!( + app.render.card_cache.processed_messages, + app.chat.messages.len() + ); live_update( &mut app, @@ -2750,7 +2759,7 @@ mod tests { "tool detail update should be in place" ); assert_eq!( - app.card_cache.processed_messages, 0, + app.render.card_cache.processed_messages, 0, "semantic update should invalidate the warm card" ); let rebuilt_lines = rendered_card_lines(&mut app); @@ -2828,7 +2837,7 @@ mod tests { }; // Full rebuild from scratch - app.card_cache.invalidate(); + app.render.card_cache.invalidate(); let full_kinds: Vec<_> = { let cards = build_message_cards(&mut app); cards.iter().map(|c| c.kind.clone()).collect() @@ -2850,12 +2859,12 @@ mod tests { message_id: None, }); build_message_cards(&mut app); - assert_eq!(app.card_cache.processed_messages, 2); + assert_eq!(app.render.card_cache.processed_messages, 2); load_session(&mut app, "delegate-session", "agent-2"); assert!(app.chat.messages.is_empty()); - assert_eq!(app.card_cache.processed_messages, 0); - assert!(app.card_cache.cards.is_empty()); + assert_eq!(app.render.card_cache.processed_messages, 0); + assert!(app.render.card_cache.cards.is_empty()); replay_session( &mut app, @@ -4045,14 +4054,14 @@ mod tests { let cards = build_message_cards(&mut app); cards.iter().map(|card| card.kind.clone()).collect() }; - let incremental_tool_lines = app.card_cache.cards[0].lines_for(80).len(); + let incremental_tool_lines = app.render.card_cache.cards[0].lines_for(80).len(); - app.card_cache.invalidate(); + app.render.card_cache.invalidate(); let full_kinds: Vec<_> = { let cards = build_message_cards(&mut app); cards.iter().map(|card| card.kind.clone()).collect() }; - let full_tool_lines = app.card_cache.cards[0].lines_for(80).len(); + let full_tool_lines = app.render.card_cache.cards[0].lines_for(80).len(); assert_eq!(incremental_kinds, full_kinds); assert_eq!(incremental_kinds, vec![CardKind::Tool { compact: false }]); diff --git a/src/ui/popups.rs b/src/ui/popups.rs index 18c8dec..ee6cd34 100644 --- a/src/ui/popups.rs +++ b/src/ui/popups.rs @@ -723,7 +723,7 @@ fn draw_delegate_tab_content(f: &mut Frame, app: &mut App, chunks: &std::rc::Rc< .map(|entry| { let status_badge = match entry.status { DelegateStatus::InProgress => { - spinner(SpinnerKind::Braille, app.tick).to_string() + spinner(SpinnerKind::Braille, app.render.tick).to_string() } DelegateStatus::Completed => CHECK_CHECKED.to_string(), DelegateStatus::Failed => CHECK_FAILED.to_string(), diff --git a/src/ui/start.rs b/src/ui/start.rs index a12462a..9d1e280 100644 --- a/src/ui/start.rs +++ b/src/ui/start.rs @@ -225,7 +225,7 @@ pub(super) fn draw_start(f: &mut Frame, app: &mut App) { // ── glitch / wave variables (shared by art and button) ─────────────────── const GLITCH_CHARS: &str = "░▒▓█▌▐▄▀┃╋╳"; - let tick = app.tick as usize; + let tick = app.render.tick as usize; let prng = |seed: usize| -> usize { let mut h = seed.wrapping_mul(2654435761); h ^= h >> 16;