diff --git a/CHANGELOG.md b/CHANGELOG.md index 264fb6c..b6261ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Fixed +- TUI freezes during fast LLM streaming and parallel tool execution: biased event loop with input priority and agent event batching (#500) +- Redundant syntax highlighting and markdown parsing on every TUI frame: per-message render cache with content-hash keying (#501) + ### Added - Interactive configuration wizard via `zeph init` subcommand with 5-step setup (LLM provider, memory, channels, secrets backend, config generation) - clap-based CLI argument parsing with `--help`, `--version` support diff --git a/README.md b/README.md index 21ba498..0451275 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,7 @@ A full terminal UI powered by ratatui — not a separate monitoring tool, but an - Syntax-highlighted diff view for file edits (compact/expanded toggle) - Live metrics: token usage, filter savings, cost tracking, confidence distribution - Conversation history with message queueing +- Responsive input handling during streaming with render cache and event batching - Deferred model warmup with progress indicator ```bash diff --git a/crates/zeph-tui/src/app.rs b/crates/zeph-tui/src/app.rs index 2f4a846..a20466f 100644 --- a/crates/zeph-tui/src/app.rs +++ b/crates/zeph-tui/src/app.rs @@ -1,6 +1,8 @@ +use std::hash::{DefaultHasher, Hash, Hasher}; use std::sync::Arc; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::text::Line; use tokio::sync::{Notify, mpsc, oneshot, watch}; use tracing::debug; @@ -11,6 +13,61 @@ use crate::metrics::MetricsSnapshot; use crate::theme::Theme; use crate::widgets; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RenderCacheKey { + pub content_hash: u64, + pub terminal_width: u16, + pub tool_expanded: bool, + pub compact_tools: bool, + pub show_labels: bool, +} + +pub struct RenderCacheEntry { + pub key: RenderCacheKey, + pub lines: Vec>, +} + +#[derive(Default)] +pub struct RenderCache { + entries: Vec>, +} + +impl RenderCache { + pub fn get(&self, idx: usize, key: &RenderCacheKey) -> Option<&[Line<'static>]> { + self.entries + .get(idx) + .and_then(Option::as_ref) + .filter(|e| &e.key == key) + .map(|e| e.lines.as_slice()) + } + + pub fn put(&mut self, idx: usize, key: RenderCacheKey, lines: Vec>) { + if idx >= self.entries.len() { + self.entries.resize_with(idx + 1, || None); + } + self.entries[idx] = Some(RenderCacheEntry { key, lines }); + } + + pub fn invalidate(&mut self, idx: usize) { + if let Some(entry) = self.entries.get_mut(idx) { + *entry = None; + } + } + + pub fn clear(&mut self) { + for entry in &mut self.entries { + *entry = None; + } + } +} + +#[must_use] +pub fn content_hash(s: &str) -> u64 { + let mut h = DefaultHasher::new(); + s.hash(&mut h); + h.finish() +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InputMode { Normal, @@ -78,6 +135,7 @@ pub struct App { editing_queued: bool, hyperlinks: Vec, cancel_signal: Option>, + pub render_cache: RenderCache, } impl App { @@ -115,6 +173,7 @@ impl App { editing_queued: false, hyperlinks: Vec::new(), cancel_signal: None, + render_cache: RenderCache::default(), } } @@ -226,7 +285,10 @@ impl App { } pub fn set_show_source_labels(&mut self, v: bool) { - self.show_source_labels = v; + if self.show_source_labels != v { + self.show_source_labels = v; + self.render_cache.clear(); + } } pub fn set_hyperlinks(&mut self, links: Vec) { @@ -282,7 +344,9 @@ impl App { AppEvent::Tick => { self.throbber_state.calc_next(); } - AppEvent::Resize(_, _) => {} + AppEvent::Resize(_, _) => { + self.render_cache.clear(); + } AppEvent::MouseScroll(delta) => { if self.confirm_state.is_none() { if delta > 0 { @@ -301,6 +365,14 @@ impl App { self.agent_event_rx.recv() } + /// # Errors + /// + /// Returns `TryRecvError::Empty` if no events are pending, or `TryRecvError::Disconnected` + /// if the sender has been dropped. + pub fn try_recv_agent_event(&mut self) -> Result { + self.agent_event_rx.try_recv() + } + #[allow(clippy::too_many_lines)] pub fn handle_agent_event(&mut self, event: AgentEvent) { match event { @@ -321,6 +393,8 @@ impl App { filter_stats: None, }); } + let last_idx = self.messages.len().saturating_sub(1); + self.render_cache.invalidate(last_idx); self.scroll_offset = 0; } AgentEvent::FullMessage(text) => { @@ -342,6 +416,8 @@ impl App { && last.streaming { last.streaming = false; + let last_idx = self.messages.len().saturating_sub(1); + self.render_cache.invalidate(last_idx); } } AgentEvent::Typing => { @@ -365,13 +441,13 @@ impl App { self.scroll_offset = 0; } AgentEvent::ToolOutputChunk { chunk, .. } => { - if let Some(msg) = self + if let Some(pos) = self .messages - .iter_mut() - .rev() - .find(|m| m.role == MessageRole::Tool && m.streaming) + .iter() + .rposition(|m| m.role == MessageRole::Tool && m.streaming) { - msg.content.push_str(&chunk); + self.messages[pos].content.push_str(&chunk); + self.render_cache.invalidate(pos); } self.scroll_offset = 0; } @@ -389,17 +465,17 @@ impl App { output_len = output.len(), "TUI ToolOutput event received" ); - if let Some(msg) = self + if let Some(pos) = self .messages - .iter_mut() - .rev() - .find(|m| m.role == MessageRole::Tool && m.streaming) + .iter() + .rposition(|m| m.role == MessageRole::Tool && m.streaming) { // Shell streaming path: finalize existing streaming tool message. debug!("attaching diff to existing streaming Tool message"); - msg.streaming = false; - msg.diff_data = diff; - msg.filter_stats = filter_stats; + self.messages[pos].streaming = false; + self.messages[pos].diff_data = diff; + self.messages[pos].filter_stats = filter_stats; + self.render_cache.invalidate(pos); } else if diff.is_some() || filter_stats.is_some() { // Native tool_use path: no prior ToolStart, create the message now. debug!("creating new Tool message with diff (native path)"); @@ -459,7 +535,9 @@ impl App { if self.show_splash { widgets::splash::render(frame, layout.chat); } else { - let max_scroll = widgets::chat::render(self, frame, layout.chat); + let mut cache = std::mem::take(&mut self.render_cache); + let max_scroll = widgets::chat::render(self, frame, layout.chat, &mut cache); + self.render_cache = cache; self.scroll_offset = self.scroll_offset.min(max_scroll); } self.draw_side_panel(frame, &layout); @@ -580,9 +658,11 @@ impl App { } KeyCode::Char('e') => { self.tool_expanded = !self.tool_expanded; + self.render_cache.clear(); } KeyCode::Char('c') => { self.compact_tools = !self.compact_tools; + self.render_cache.clear(); } KeyCode::Tab => { self.active_panel = match self.active_panel { @@ -1811,4 +1891,127 @@ mod tests { } } } + + mod render_cache_tests { + use super::*; + use ratatui::text::Span; + + fn make_key(content_hash: u64, width: u16) -> RenderCacheKey { + RenderCacheKey { + content_hash, + terminal_width: width, + tool_expanded: false, + compact_tools: false, + show_labels: false, + } + } + + #[test] + fn get_returns_none_when_empty() { + let cache = RenderCache::default(); + let key = make_key(1, 80); + assert!(cache.get(0, &key).is_none()); + } + + #[test] + fn put_and_get_returns_cached_lines() { + let mut cache = RenderCache::default(); + let key = make_key(42, 80); + let lines = vec![Line::from(Span::raw("hello"))]; + cache.put(0, key, lines.clone()); + let result = cache.get(0, &key).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].spans[0].content, "hello"); + } + + #[test] + fn get_returns_none_on_key_mismatch() { + let mut cache = RenderCache::default(); + let key1 = make_key(1, 80); + let key2 = make_key(2, 80); + let lines = vec![Line::from(Span::raw("a"))]; + cache.put(0, key1, lines); + assert!(cache.get(0, &key2).is_none()); + } + + #[test] + fn get_returns_none_on_width_mismatch() { + let mut cache = RenderCache::default(); + let key80 = make_key(1, 80); + let key100 = make_key(1, 100); + let lines = vec![Line::from(Span::raw("b"))]; + cache.put(0, key80, lines); + assert!(cache.get(0, &key100).is_none()); + } + + #[test] + fn invalidate_clears_single_entry() { + let mut cache = RenderCache::default(); + let key = make_key(1, 80); + let lines = vec![Line::from(Span::raw("x"))]; + cache.put(0, key, lines); + assert!(cache.get(0, &key).is_some()); + cache.invalidate(0); + assert!(cache.get(0, &key).is_none()); + } + + #[test] + fn invalidate_out_of_bounds_is_noop() { + let mut cache = RenderCache::default(); + cache.invalidate(99); + } + + #[test] + fn clear_removes_all_entries() { + let mut cache = RenderCache::default(); + let key0 = make_key(1, 80); + let key1 = make_key(2, 80); + cache.put(0, key0, vec![Line::from(Span::raw("a"))]); + cache.put(1, key1, vec![Line::from(Span::raw("b"))]); + cache.clear(); + assert!(cache.get(0, &key0).is_none()); + assert!(cache.get(1, &key1).is_none()); + } + + #[test] + fn put_grows_entries_for_non_contiguous_index() { + let mut cache = RenderCache::default(); + let key = make_key(5, 80); + let lines = vec![Line::from(Span::raw("z"))]; + cache.put(5, key, lines); + let result = cache.get(5, &key).unwrap(); + assert_eq!(result[0].spans[0].content, "z"); + } + } + + mod try_recv_tests { + use super::*; + + #[test] + fn try_recv_returns_empty_when_no_events() { + let (mut app, _rx, _tx) = make_app(); + let result = app.try_recv_agent_event(); + assert!(matches!(result, Err(mpsc::error::TryRecvError::Empty))); + } + + #[test] + fn try_recv_returns_event_when_available() { + let (mut app, _rx, tx) = make_app(); + tx.try_send(AgentEvent::Typing).unwrap(); + let result = app.try_recv_agent_event(); + assert!(result.is_ok()); + assert!(matches!(result.unwrap(), AgentEvent::Typing)); + } + + #[test] + fn try_recv_returns_disconnected_when_sender_dropped() { + let (mut app, _rx, tx) = make_app(); + drop(tx); + let result = app.try_recv_agent_event(); + assert!(matches!( + result, + Err(mpsc::error::TryRecvError::Disconnected) + )); + } + } } diff --git a/crates/zeph-tui/src/lib.rs b/crates/zeph-tui/src/lib.rs index e9a18d8..be6ac17 100644 --- a/crates/zeph-tui/src/lib.rs +++ b/crates/zeph-tui/src/lib.rs @@ -67,11 +67,15 @@ async fn tui_loop( } tokio::select! { + biased; Some(event) = event_rx.recv() => { app.handle_event(event)?; } Some(agent_event) = app.poll_agent_event() => { app.handle_agent_event(agent_event); + while let Ok(ev) = app.try_recv_agent_event() { + app.handle_agent_event(ev); + } } _ = tick.tick() => {} } diff --git a/crates/zeph-tui/src/widgets/chat.rs b/crates/zeph-tui/src/widgets/chat.rs index d1b9672..bb26bd6 100644 --- a/crates/zeph-tui/src/widgets/chat.rs +++ b/crates/zeph-tui/src/widgets/chat.rs @@ -6,13 +6,13 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph}; use throbber_widgets_tui::{BRAILLE_SIX, Throbber, WhichUse}; -use crate::app::{App, MessageRole}; +use crate::app::{App, MessageRole, RenderCache, RenderCacheKey, content_hash}; use crate::highlight::SYNTAX_HIGHLIGHTER; use crate::hyperlink; use crate::theme::{SyntaxTheme, Theme}; /// Returns the maximum scroll offset for the rendered content. -pub fn render(app: &mut App, frame: &mut Frame, area: Rect) -> usize { +pub fn render(app: &mut App, frame: &mut Frame, area: Rect, cache: &mut RenderCache) -> usize { if area.width == 0 || area.height == 0 { return 0; } @@ -22,9 +22,22 @@ pub fn render(app: &mut App, frame: &mut Frame, area: Rect) -> usize { // 2 for block borders + 2 for accent prefix ("▎ ") added per line let wrap_width = area.width.saturating_sub(4) as usize; - let mut lines: Vec> = Vec::new(); - - for (idx, msg) in app.messages().iter().enumerate() { + let mut lines: Vec> = Vec::new(); + + let tool_expanded = app.tool_expanded(); + let compact_tools = app.compact_tools(); + let show_labels = app.show_source_labels(); + let terminal_width = area.width; + let throbber_len = BRAILLE_SIX.symbols.len(); + let throbber_idx = usize::try_from( + app.throbber_state() + .index() + .rem_euclid(i8::try_from(throbber_len).unwrap_or(i8::MAX)), + ) + .unwrap_or(0); + let messages: Vec<_> = app.messages().to_vec(); + + for (idx, msg) in messages.iter().enumerate() { let accent = match msg.role { MessageRole::User => theme.user_message, MessageRole::Assistant => theme.assistant_accent, @@ -37,17 +50,44 @@ pub fn render(app: &mut App, frame: &mut Frame, area: Rect) -> usize { lines.push(Line::from(Span::styled(sep, theme.system_message))); } - let msg_start = lines.len(); + let cache_key = RenderCacheKey { + content_hash: content_hash(&msg.content), + terminal_width, + tool_expanded, + compact_tools, + show_labels, + }; - let show_labels = app.show_source_labels(); - if msg.role == MessageRole::Tool { - render_tool_message(msg, app, &theme, wrap_width, show_labels, &mut lines); + let msg_lines: Vec> = if msg.streaming { + // Never cache streaming messages + render_message_lines( + msg, + tool_expanded, + compact_tools, + throbber_idx, + &theme, + wrap_width, + show_labels, + ) + } else if let Some(cached) = cache.get(idx, &cache_key) { + cached.to_vec() } else { - render_chat_message(msg, &theme, wrap_width, show_labels, &mut lines); - } + let rendered = render_message_lines( + msg, + tool_expanded, + compact_tools, + throbber_idx, + &theme, + wrap_width, + show_labels, + ); + cache.put(idx, cache_key, rendered.clone()); + rendered + }; - for line in &mut lines[msg_start..] { + for mut line in msg_lines { line.spans.insert(0, Span::styled("\u{258e} ", accent)); + lines.push(line); } } @@ -111,6 +151,33 @@ pub fn render_activity(app: &mut App, frame: &mut Frame, area: Rect) { frame.render_stateful_widget(throbber, area, app.throbber_state_mut()); } +fn render_message_lines( + msg: &crate::app::ChatMessage, + tool_expanded: bool, + compact_tools: bool, + throbber_idx: usize, + theme: &Theme, + wrap_width: usize, + show_labels: bool, +) -> Vec> { + let mut lines = Vec::new(); + if msg.role == MessageRole::Tool { + render_tool_message( + msg, + tool_expanded, + compact_tools, + throbber_idx, + theme, + wrap_width, + show_labels, + &mut lines, + ); + } else { + render_chat_message(msg, theme, wrap_width, show_labels, &mut lines); + } + lines +} + fn render_chat_message( msg: &crate::app::ChatMessage, theme: &Theme, @@ -228,9 +295,12 @@ fn render_scrollbar( const TOOL_OUTPUT_COLLAPSED_LINES: usize = 3; +#[allow(clippy::too_many_arguments)] fn render_tool_message( msg: &crate::app::ChatMessage, - app: &App, + tool_expanded: bool, + compact_tools: bool, + throbber_idx: usize, theme: &Theme, wrap_width: usize, show_labels: bool, @@ -247,14 +317,7 @@ fn render_tool_message( // First line is always the command ($ ...) let cmd_line = content_lines.first().copied().unwrap_or(""); let status_span = if msg.streaming { - let len = BRAILLE_SIX.symbols.len(); - let idx = usize::try_from( - app.throbber_state() - .index() - .rem_euclid(i8::try_from(len).unwrap_or(i8::MAX)), - ) - .unwrap_or(0); - let symbol = BRAILLE_SIX.symbols[idx]; + let symbol = BRAILLE_SIX.symbols[throbber_idx]; Span::styled(format!("{symbol} "), theme.streaming_cursor) } else { Span::styled("\u{2714} ".to_string(), theme.highlight) @@ -278,7 +341,7 @@ fn render_tool_message( wrapped.push(Line::from(prefixed_spans)); } let total_visual = wrapped.len(); - let show_all = app.tool_expanded() || total_visual <= TOOL_OUTPUT_COLLAPSED_LINES; + let show_all = tool_expanded || total_visual <= TOOL_OUTPUT_COLLAPSED_LINES; if show_all { lines.extend(wrapped); } else { @@ -297,7 +360,7 @@ fn render_tool_message( // Output lines (everything after the command) if content_lines.len() > 1 { - if app.compact_tools() { + if compact_tools { let line_count = content_lines.len() - 1; let noun = if line_count == 1 { "line" } else { "lines" }; let summary = format!("{indent}-- {line_count} {noun}"); @@ -318,7 +381,7 @@ fn render_tool_message( } let total_visual = wrapped.len(); - let show_all = app.tool_expanded() || total_visual <= TOOL_OUTPUT_COLLAPSED_LINES; + let show_all = tool_expanded || total_visual <= TOOL_OUTPUT_COLLAPSED_LINES; if show_all { lines.extend(wrapped); diff --git a/docs/src/architecture/performance.md b/docs/src/architecture/performance.md index bbf28ed..b65d50f 100644 --- a/docs/src/architecture/performance.md +++ b/docs/src/architecture/performance.md @@ -36,6 +36,13 @@ Context sources (summaries, cross-session recall, semantic recall, code RAG) are Context assembly and compaction pre-allocate output strings based on estimated final size, reducing intermediate allocations during prompt construction. +## TUI Render Performance + +The TUI applies two optimizations to maintain responsive input during heavy streaming: + +- **Event loop batching**: `biased` `tokio::select!` prioritizes keyboard/mouse input over agent events. Agent events are drained via `try_recv` loop, coalescing multiple streaming chunks into a single frame redraw. +- **Per-message render cache**: Syntax highlighting and markdown parsing results are cached with content-hash keys. Only messages with changed content are re-parsed. Cache invalidation triggers: content mutation, terminal resize, and view mode toggle. + ## Tokio Runtime Tokio is imported with explicit features (`macros`, `rt-multi-thread`, `signal`, `sync`) instead of the `full` meta-feature, reducing compile time and binary size. diff --git a/docs/src/changelog.md b/docs/src/changelog.md index 35aeaea..1802d65 100644 --- a/docs/src/changelog.md +++ b/docs/src/changelog.md @@ -8,6 +8,10 @@ See the full [CHANGELOG.md](https://github.com/bug-ops/zeph/blob/main/CHANGELOG. ## [Unreleased] +### Fixed +- TUI freezes during fast LLM streaming and parallel tool execution: biased event loop with input priority and agent event batching (#500) +- Redundant syntax highlighting and markdown parsing on every TUI frame: per-message render cache with content-hash keying (#501) + ## [0.10.0] - 2026-02-18 ### Fixed diff --git a/docs/src/guide/tui.md b/docs/src/guide/tui.md index 6472a7d..5c55a96 100644 --- a/docs/src/guide/tui.md +++ b/docs/src/guide/tui.md @@ -235,6 +235,24 @@ If you send a message before warmup finishes, it is queued and processed automat > **Note:** In non-TUI modes (CLI, Telegram), warmup still runs synchronously before the agent loop starts. +## Performance + +### Event Loop Batching + +The TUI render loop uses `biased` `tokio::select!` to guarantee input events are always processed before agent events. This prevents keyboard input from being starved during fast LLM streaming or parallel tool execution. + +Agent events (streaming chunks, tool output, status updates) are drained in a `try_recv` loop, batching all pending events into a single frame update. This avoids the pathological case where each streaming token triggers a separate redraw. + +### Render Cache + +Syntax highlighting (tree-sitter) and markdown parsing (pulldown-cmark) results are cached per message. The cache key is a content hash, so only messages whose content actually changed are re-rendered. Cache entries are invalidated on: + +- Content change (new streaming chunk appended) +- Terminal resize +- View mode toggle (compact/expanded) + +This eliminates redundant parsing work that previously re-processed every visible message on every frame. + ## Architecture The TUI runs as three concurrent loops: