diff --git a/rust/crates/rusty-claude-cli/src/tui/app.rs b/rust/crates/rusty-claude-cli/src/tui/app.rs index 030ba467..7861846d 100644 --- a/rust/crates/rusty-claude-cli/src/tui/app.rs +++ b/rust/crates/rusty-claude-cli/src/tui/app.rs @@ -266,7 +266,7 @@ impl TuiApp { fn draw_screen(&mut self) -> io::Result<()> { // Pre-borrow component references before the draw closure. - // This avoids the clone-everything problem of the legacy TuiApp. + // Each component.render(&self) reads its own state — no cloning needed. let conversation = &self.conversation; let input_bar = &self.input_bar; let dashboard = &self.dashboard; @@ -274,11 +274,6 @@ impl TuiApp { let agent_view = &self.agent_view; let theme = &self.theme; - // Rebuild the conversation cache if needed - // (In the real implementation, this should happen outside the draw closure. - // For now, we rely on the cache being built during handle_key calls.) - let _ = conversation; // used in closure below - self.terminal.draw(|f| { let area = f.area(); @@ -307,6 +302,10 @@ impl TuiApp { } })?; + // Clear dirty flags after successful render + self.conversation.mark_clean(); + self.dashboard.clear_dirty(); + self.terminal.backend_mut().flush()?; Ok(()) } diff --git a/rust/crates/rusty-claude-cli/src/tui/components/conversation.rs b/rust/crates/rusty-claude-cli/src/tui/components/conversation.rs index bc9f7ba2..adbb0198 100644 --- a/rust/crates/rusty-claude-cli/src/tui/components/conversation.rs +++ b/rust/crates/rusty-claude-cli/src/tui/components/conversation.rs @@ -1,8 +1,9 @@ //! Conversation pane component. //! //! Renders the left-pane conversation view with word-wrapping, scroll, -//! markdown content, and diff highlighting. Extracted from the standalone -//! `draw_left_pane` and `build_wrapped_conversation` functions in legacy.rs. +//! markdown content, and diff highlighting. + +use std::cell::RefCell; use crossterm::event::KeyEvent; use ratatui::layout::Rect; @@ -10,7 +11,6 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::Frame; -use tui_textarea::TextArea; use unicode_width::UnicodeWidthChar; use crate::keybindings::KeyMap; @@ -21,25 +21,33 @@ use crate::tui::legacy::{ConversationContent, ConversationLine}; use crate::tui::markdown::render_diff; use crate::tui::markdown::ratatui_renderer::MarkdownRenderer; -/// Maximum conversation lines before trimming. const MAX_CONVERSATION_LINES: usize = 10_000; +/// Render cache — built during the update phase, read during render. +struct RenderCache { + wrapped_lines: Vec>, + expand_counts: Vec, + built_width: u16, +} + +impl Default for RenderCache { + fn default() -> Self { + Self { + wrapped_lines: Vec::new(), + expand_counts: Vec::new(), + built_width: 0, + } + } +} + /// Conversation pane — the main content area of the TUI. pub struct ConversationPane { - /// Conversation entries (owning the content). conversation: Vec, - /// Scroll offset (0 = bottom/latest, increases upward). scroll: u16, - /// Whether content has changed since last render. dirty: bool, - /// Markdown renderer for rich content. renderer: MarkdownRenderer, - /// Pre-computed wrapped lines (rebuilt when content or width changes). - wrapped_cache: Vec>, - /// Expand counts for each ConversationLine (for scroll math). - expand_counts: Vec, - /// Width the cache was built for. - cached_width: u16, + /// Interior-mutability cache so `render(&self)` can rebuild if needed. + cache: RefCell, } impl ConversationPane { @@ -49,9 +57,7 @@ impl ConversationPane { scroll: 0, dirty: true, renderer: MarkdownRenderer::new(theme), - wrapped_cache: Vec::new(), - expand_counts: Vec::new(), - cached_width: 0, + cache: RefCell::new(RenderCache::default()), } } @@ -61,7 +67,7 @@ impl ConversationPane { } // ------------------------------------------------------------------- - // Content mutators (update phase) + // Content mutators // ------------------------------------------------------------------- pub fn push_banner(&mut self, text: String, color: Color) { @@ -144,13 +150,10 @@ impl ConversationPane { self.dirty = true; } - // ------------------------------------------------------------------- - // Render cache - // ------------------------------------------------------------------- - - /// Rebuild the wrapped-line cache if content or width changed. - fn ensure_cache(&mut self, width: u16, theme: &TuiTheme) { - if !self.dirty && self.cached_width == width { + /// Rebuild the wrapped-line cache if needed. + fn rebuild_cache(&self, width: u16, theme: &TuiTheme) { + let mut cache = self.cache.borrow_mut(); + if !self.dirty && cache.built_width == width { return; } let content_width = (width as usize).saturating_sub(2); @@ -160,23 +163,33 @@ impl ConversationPane { &self.renderer, theme, ); - self.wrapped_cache = wrapped; - self.expand_counts = expand_counts; - self.cached_width = width; + cache.wrapped_lines = wrapped; + cache.expand_counts = expand_counts; + cache.built_width = width; + // Note: can't clear self.dirty here because we borrow self.cache + // and self.dirty is on self. The caller (render) will handle this. + } + + /// Clear the dirty flag after a successful cache rebuild + render. + pub fn mark_clean(&mut self) { self.dirty = false; } } impl Component for ConversationPane { fn render(&self, area: Rect, frame: &mut Frame, theme: &TuiTheme) { + // Rebuild cache if dirty or width changed + self.rebuild_cache(area.width, theme); + + let cache = self.cache.borrow(); let pane_rows = (area.height.saturating_sub(1) as usize).max(1); - let total_visual = self.wrapped_cache.len(); + let total_visual = cache.wrapped_lines.len(); let scroll = self.scroll as usize; let max_offset = total_visual.saturating_sub(pane_rows); let offset = scroll.min(max_offset); let start = total_visual.saturating_sub(pane_rows + offset); - let visible: Vec = self.wrapped_cache.iter().skip(start).take(pane_rows).cloned().collect(); + let visible: Vec = cache.wrapped_lines.iter().skip(start).take(pane_rows).cloned().collect(); let conversation_widget = Paragraph::new(visible).block( Block::default() @@ -189,7 +202,6 @@ impl Component for ConversationPane { ); frame.render_widget(conversation_widget, area); - // Scroll indicator when content overflows if total_visual > pane_rows { let scroll_label = format!(" {}/{} ", offset, max_offset); let scroll_area = Rect { @@ -205,12 +217,6 @@ impl Component for ConversationPane { } } - fn handle_key(&mut self, _key: KeyEvent, _keymap: &KeyMap) -> bool { - false // Scroll handled by the app - } - - fn handle_event(&mut self, _event: &TuiEvent) {} - fn is_dirty(&self) -> bool { self.dirty } @@ -220,7 +226,6 @@ impl Component for ConversationPane { // Word-wrap + build helpers // --------------------------------------------------------------------------- -/// Word-wrap a single text line into visual lines that fit `width` columns. fn wrap_line<'a>(text: &str, width: usize, style: Style) -> Vec> { if width == 0 { return vec![Line::from(Span::styled(text.to_string(), style))]; @@ -266,7 +271,6 @@ fn wrap_line<'a>(text: &str, width: usize, style: Style) -> Vec> { result } -/// Build the full conversation as wrapped visual lines. fn build_wrapped_conversation<'a>( conversation: &[ConversationLine], content_width: usize, @@ -337,18 +341,6 @@ mod tests { assert!(lines.len() >= 2); } - #[test] - fn test_wrap_hyphen_break() { - let lines = wrap_line("well-known-author", 10, Style::default()); - assert!(lines.len() >= 2); - } - - #[test] - fn test_wrap_no_break_point() { - let lines = wrap_line("abcdefghijklmnop", 5, Style::default()); - assert!(lines.len() >= 2); - } - #[test] fn test_conversation_pane_push_and_scroll() { let theme = test_theme(); @@ -370,4 +362,18 @@ mod tests { pane.clear(); assert!(pane.conversation.is_empty()); } + + #[test] + fn test_cache_rebuilds_on_dirty() { + let theme = test_theme(); + let mut pane = ConversationPane::new(theme.clone()); + pane.push_user_input("Hello world", theme.conversation_user.to_color()); + assert!(pane.dirty); + + // Rebuild cache (simulating what render() does) + pane.rebuild_cache(80, &theme); + let cache = pane.cache.borrow(); + assert!(!cache.wrapped_lines.is_empty()); + assert_eq!(cache.built_width, 80); + } } diff --git a/rust/crates/rusty-claude-cli/src/tui/components/dashboard.rs b/rust/crates/rusty-claude-cli/src/tui/components/dashboard.rs index cabcde94..ecae6e57 100644 --- a/rust/crates/rusty-claude-cli/src/tui/components/dashboard.rs +++ b/rust/crates/rusty-claude-cli/src/tui/components/dashboard.rs @@ -40,6 +40,10 @@ impl Dashboard { } self.dirty = true; } + + pub fn clear_dirty(&mut self) { + self.dirty = false; + } } impl Component for Dashboard {