From f88f810b1a8270553b0fe2041a350a5b7184da59 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 27 Apr 2026 22:36:54 -0500 Subject: [PATCH] feat(tui): #94 live transcript overlay (Ctrl+T) with sticky-bottom auto-scroll Pager view gains a sticky_to_bottom mode: scroll-up pauses auto-tail, scrolling back to bottom resumes it. Wrapped lines cached by (cell_id, width, revision); revisions bumped on live-cell mutation so resize doesn't reflow the world. Ctrl+T toggles; Esc returns. Engine continues streaming while overlay open. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/tui/src/tui/approval.rs | 8 + crates/tui/src/tui/command_palette.rs | 4 + crates/tui/src/tui/file_picker.rs | 4 + crates/tui/src/tui/keybindings.rs | 5 + crates/tui/src/tui/live_transcript.rs | 567 ++++++++++++++++++++++ crates/tui/src/tui/mod.rs | 2 + crates/tui/src/tui/model_picker.rs | 4 + crates/tui/src/tui/pager.rs | 4 + crates/tui/src/tui/plan_prompt.rs | 4 + crates/tui/src/tui/provider_picker.rs | 4 + crates/tui/src/tui/session_picker.rs | 4 + crates/tui/src/tui/transcript_cache.rs | 219 +++++++++ crates/tui/src/tui/ui.rs | 44 ++ crates/tui/src/tui/user_input.rs | 4 + crates/tui/src/tui/views/help.rs | 4 + crates/tui/src/tui/views/mod.rs | 23 +- crates/tui/src/tui/views/status_picker.rs | 4 + 17 files changed, 907 insertions(+), 1 deletion(-) create mode 100644 crates/tui/src/tui/live_transcript.rs create mode 100644 crates/tui/src/tui/transcript_cache.rs diff --git a/crates/tui/src/tui/approval.rs b/crates/tui/src/tui/approval.rs index 353cab7c..901d8759 100644 --- a/crates/tui/src/tui/approval.rs +++ b/crates/tui/src/tui/approval.rs @@ -503,6 +503,10 @@ impl ModalView for ApprovalView { ModalKind::Approval } + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + fn handle_key(&mut self, key: KeyEvent) -> ViewAction { match key.code { KeyCode::Up | KeyCode::Char('k') => { @@ -762,6 +766,10 @@ impl ModalView for ElevationView { ModalKind::Elevation } + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + fn handle_key(&mut self, key: KeyEvent) -> ViewAction { match key.code { KeyCode::Up | KeyCode::Char('k') => { diff --git a/crates/tui/src/tui/command_palette.rs b/crates/tui/src/tui/command_palette.rs index e6f0e036..163b5a4a 100644 --- a/crates/tui/src/tui/command_palette.rs +++ b/crates/tui/src/tui/command_palette.rs @@ -423,6 +423,10 @@ impl ModalView for CommandPaletteView { ModalKind::CommandPalette } + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + fn handle_key(&mut self, key: KeyEvent) -> ViewAction { match key.code { KeyCode::Esc => ViewAction::Close, diff --git a/crates/tui/src/tui/file_picker.rs b/crates/tui/src/tui/file_picker.rs index 60fd1ee4..8e0d7214 100644 --- a/crates/tui/src/tui/file_picker.rs +++ b/crates/tui/src/tui/file_picker.rs @@ -152,6 +152,10 @@ impl ModalView for FilePickerView { ModalKind::FilePicker } + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + fn handle_key(&mut self, key: KeyEvent) -> ViewAction { match key.code { KeyCode::Esc => ViewAction::Close, diff --git a/crates/tui/src/tui/keybindings.rs b/crates/tui/src/tui/keybindings.rs index e67cb4da..c7a60626 100644 --- a/crates/tui/src/tui/keybindings.rs +++ b/crates/tui/src/tui/keybindings.rs @@ -183,6 +183,11 @@ pub const KEYBINDINGS: &[KeybindingEntry] = &[ description: "Open thinking pager", section: KeybindingSection::Submission, }, + KeybindingEntry { + chord: "Ctrl+T", + description: "Open live transcript overlay (sticky-tail auto-scroll)", + section: KeybindingSection::Submission, + }, // --- Modes --- KeybindingEntry { chord: "Tab / Shift+Tab", diff --git a/crates/tui/src/tui/live_transcript.rs b/crates/tui/src/tui/live_transcript.rs new file mode 100644 index 00000000..dc724f46 --- /dev/null +++ b/crates/tui/src/tui/live_transcript.rs @@ -0,0 +1,567 @@ +//! Full-screen live transcript overlay with sticky-bottom auto-scroll (#94). +//! +//! Toggled with `Ctrl+T` while the engine is streaming. Behaviour: +//! +//! - At-bottom (`sticky_to_bottom = true`) — every refresh re-pins scroll to +//! the new tail, so streaming output appears to flow off the bottom edge. +//! - Scroll up — `sticky_to_bottom` flips to `false`; subsequent refreshes +//! leave scroll position alone so the user can read history without being +//! yanked back down. +//! - Scroll back to bottom (End / G / paging past the tail) — `sticky` flips +//! to `true` again; auto-tail resumes. +//! - Esc / `q` — close, returning to the normal view. The engine never +//! pauses while the overlay is open; new chunks accumulate in the cells +//! exactly as they would on the normal screen. +//! +//! Cache strategy: the overlay holds its own `TranscriptCache` keyed by +//! `(CellId, width, revision)`. Revisions come from the same per-cell +//! counters the main transcript already maintains (`App.history_revisions` +//! and `App.active_cell_revision`). Resize invalidates the cells whose width +//! key just changed; revision bumps invalidate only the cells that mutated; +//! cells that didn't change reuse their existing wrap. + +use std::cell::RefCell; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::Style, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Padding, Paragraph, Widget, Wrap}, +}; + +use crate::palette; +use crate::tui::app::App; +use crate::tui::history::{HistoryCell, TranscriptRenderOptions}; +use crate::tui::transcript_cache::{CellId, TranscriptCache}; +use crate::tui::views::{ModalKind, ModalView, ViewAction}; + +/// Single-line footer hint. Kept short so it fits on narrow terminals. +const FOOTER_HINT: &str = + " j/k scroll Space/b page g/G top/bottom End=resume tail q/Esc close "; + +/// Snapshot of one cell, refreshed every frame from `App`. Owns the cell so +/// the overlay's `render(&self)` can wrap without re-borrowing `App`. +#[derive(Debug, Clone)] +struct CellSnapshot { + id: CellId, + revision: u64, + cell: HistoryCell, +} + +pub struct LiveTranscriptOverlay { + /// Latest cell snapshots (history + active). Refreshed via + /// `refresh_from_app` immediately before each render so streaming + /// mutations show up on the next paint. + snapshots: Vec, + /// Render options sampled from `App` at refresh time so toggles like + /// `show_thinking` propagate into the overlay live. + options: TranscriptRenderOptions, + /// Wrapped-line cache. `RefCell` so `render(&self)` can write through. + cache: RefCell, + /// Sticky-tail flag: when `true`, refresh re-pins scroll to the bottom. + /// Flipped to `false` when the user scrolls up; flipped back to `true` + /// when they scroll past the last visible line. + sticky_to_bottom: bool, + /// Current top-of-viewport line offset into the flattened line list. + scroll: usize, + /// Visible content height from the last render. Used by paging keys + /// before the next render frame populates a fresh value. + last_visible_height: RefCell, + /// Last total line count after wrapping; cached so `handle_key` can + /// clamp scroll without re-wrapping. Updated by `render`. + last_total_lines: RefCell, + /// Pending `gg` second keystroke for Vim-style jump-to-top. + pending_g: bool, +} + +impl LiveTranscriptOverlay { + #[must_use] + pub fn new() -> Self { + Self { + snapshots: Vec::new(), + options: TranscriptRenderOptions::default(), + cache: RefCell::new(TranscriptCache::new()), + sticky_to_bottom: true, + scroll: 0, + last_visible_height: RefCell::new(0), + last_total_lines: RefCell::new(0), + pending_g: false, + } + } + + /// Pull the latest cells + revisions from `App` so the next `render` shows + /// streaming mutations. Must be called before `view_stack.render` while + /// this overlay is on top; otherwise the cells stay frozen at whatever + /// state they were in when the overlay was first opened. + pub fn refresh_from_app(&mut self, app: &mut App) { + app.resync_history_revisions(); + let mut new_snapshots = Vec::with_capacity( + app.history.len() + app.active_cell.as_ref().map_or(0, |a| a.entries().len()), + ); + for (idx, cell) in app.history.iter().enumerate() { + let rev = app.history_revisions.get(idx).copied().unwrap_or(0); + new_snapshots.push(CellSnapshot { + id: CellId::History(idx), + revision: rev, + cell: cell.clone(), + }); + } + if let Some(active) = app.active_cell.as_ref() { + let active_rev = app.active_cell_revision; + for (idx, cell) in active.entries().iter().enumerate() { + let salt = (idx as u64).wrapping_add(1); + // Salt mirrors the main-transcript scheme so cache keys are + // stable across the two overlays for the same active entry. + let revision = active_rev + .wrapping_mul(0x9E37_79B9_7F4A_7C15) + .wrapping_add(salt); + new_snapshots.push(CellSnapshot { + id: CellId::Active(idx), + revision, + cell: cell.clone(), + }); + } + } + self.snapshots = new_snapshots; + self.options = app.transcript_render_options(); + } + + /// Wrap each cell (using the cache) and return the flat line vector. + fn flatten(&self, width: u16) -> Vec> { + let width = width.max(1); + let mut out: Vec> = Vec::new(); + let mut cache = self.cache.borrow_mut(); + for snap in &self.snapshots { + let lines: Vec> = match cache.get(snap.id, width, snap.revision) { + Some(cached) => cached.to_vec(), + None => { + let rendered = snap.cell.lines_with_options(width, self.options); + cache.insert(snap.id, width, snap.revision, rendered.clone()); + rendered + } + }; + out.extend(lines); + } + out + } + + fn page_height(&self) -> usize { + let cached = *self.last_visible_height.borrow(); + if cached == 0 { 10 } else { cached } + } + + fn half_page_height(&self) -> usize { + self.page_height().div_ceil(2).max(1) + } + + fn max_scroll(&self) -> usize { + let total = *self.last_total_lines.borrow(); + let visible = self.page_height(); + total.saturating_sub(visible) + } + + fn scroll_up(&mut self, amount: usize) { + self.scroll = self.scroll.saturating_sub(amount); + // Any upward motion exits sticky-tail; explicit user intent. + self.sticky_to_bottom = false; + } + + fn scroll_down(&mut self, amount: usize) { + let max = self.max_scroll(); + self.scroll = (self.scroll + amount).min(max); + if self.scroll >= max { + self.sticky_to_bottom = true; + } + } + + fn jump_to_top(&mut self) { + self.scroll = 0; + self.sticky_to_bottom = false; + } + + fn jump_to_bottom(&mut self) { + self.scroll = self.max_scroll(); + self.sticky_to_bottom = true; + } + + /// For tests: snapshot count. + #[cfg(test)] + fn snapshot_count(&self) -> usize { + self.snapshots.len() + } + + /// For tests: whether sticky-tail is currently armed. + #[cfg(test)] + pub fn is_sticky(&self) -> bool { + self.sticky_to_bottom + } + + /// For tests: current scroll offset. + #[cfg(test)] + pub fn scroll_offset(&self) -> usize { + self.scroll + } +} + +impl Default for LiveTranscriptOverlay { + fn default() -> Self { + Self::new() + } +} + +impl ModalView for LiveTranscriptOverlay { + fn kind(&self) -> ModalKind { + ModalKind::LiveTranscript + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn handle_key(&mut self, key: KeyEvent) -> ViewAction { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let shift = key.modifiers.contains(KeyModifiers::SHIFT); + + if ctrl { + match key.code { + KeyCode::Char('d') | KeyCode::Char('D') => { + self.scroll_down(self.half_page_height()); + self.pending_g = false; + return ViewAction::None; + } + KeyCode::Char('u') | KeyCode::Char('U') => { + self.scroll_up(self.half_page_height()); + self.pending_g = false; + return ViewAction::None; + } + KeyCode::Char('f') | KeyCode::Char('F') => { + self.scroll_down(self.page_height()); + self.pending_g = false; + return ViewAction::None; + } + KeyCode::Char('b') | KeyCode::Char('B') => { + self.scroll_up(self.page_height()); + self.pending_g = false; + return ViewAction::None; + } + // Ctrl+T toggles the overlay closed when already open. + KeyCode::Char('t') | KeyCode::Char('T') => return ViewAction::Close, + _ => {} + } + } + + match key.code { + KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close, + KeyCode::Up | KeyCode::Char('k') => { + self.scroll_up(1); + self.pending_g = false; + ViewAction::None + } + KeyCode::Down | KeyCode::Char('j') => { + self.scroll_down(1); + self.pending_g = false; + ViewAction::None + } + KeyCode::PageUp => { + self.scroll_up(self.page_height()); + self.pending_g = false; + ViewAction::None + } + KeyCode::PageDown => { + self.scroll_down(self.page_height()); + self.pending_g = false; + ViewAction::None + } + KeyCode::Char(' ') if shift => { + self.scroll_up(self.page_height()); + self.pending_g = false; + ViewAction::None + } + KeyCode::Char(' ') => { + self.scroll_down(self.page_height()); + self.pending_g = false; + ViewAction::None + } + KeyCode::Home => { + self.jump_to_top(); + self.pending_g = false; + ViewAction::None + } + KeyCode::End => { + self.jump_to_bottom(); + self.pending_g = false; + ViewAction::None + } + KeyCode::Char('g') => { + if self.pending_g { + self.jump_to_top(); + self.pending_g = false; + } else { + self.pending_g = true; + } + ViewAction::None + } + KeyCode::Char('G') => { + self.jump_to_bottom(); + self.pending_g = false; + ViewAction::None + } + _ => ViewAction::None, + } + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + let popup_width = area.width.saturating_sub(2).max(1); + let popup_height = area.height.saturating_sub(2).max(1); + let popup_area = Rect { + x: 1, + y: 1, + width: popup_width, + height: popup_height, + }; + + Clear.render(popup_area, buf); + + // Compute inner content height once: borders eat 1 row top + 1 bottom, + // padding eats 1 more on each side. + let visible_height = popup_area.height.saturating_sub(4) as usize; + *self.last_visible_height.borrow_mut() = visible_height; + + // Wrap content using the per-cell cache; subtract padding from width + // so wrapped lines fit between the inner edges. + let content_width = popup_width.saturating_sub(4); + let lines = self.flatten(content_width); + *self.last_total_lines.borrow_mut() = lines.len(); + + let max_scroll = lines.len().saturating_sub(visible_height); + // Sticky-tail: every render re-pins scroll to the bottom unless the + // user has explicitly scrolled away. Without this, streaming new + // content would push the visible window backwards as `scroll` stays + // fixed against a growing total. + let scroll = if self.sticky_to_bottom { + max_scroll + } else { + self.scroll.min(max_scroll) + }; + let end = (scroll + visible_height).min(lines.len()); + let visible_lines: Vec> = if lines.is_empty() { + vec![Line::from(Span::styled( + "(no transcript yet)", + Style::default().fg(palette::TEXT_DIM), + ))] + } else { + lines[scroll..end].to_vec() + }; + + let title = if self.sticky_to_bottom { + " Live transcript (tailing) " + } else { + " Live transcript (paused) " + }; + + let footer = Line::from(Span::styled( + FOOTER_HINT, + Style::default().fg(palette::TEXT_HINT), + )); + let block = Block::default() + .title(title) + .title_bottom(footer) + .borders(Borders::ALL) + .border_style(Style::default().fg(palette::BORDER_COLOR)) + .style(Style::default().bg(palette::DEEPSEEK_INK)) + .padding(Padding::uniform(1)); + + let paragraph = Paragraph::new(visible_lines) + .block(block) + .wrap(Wrap { trim: false }); + paragraph.render(popup_area, buf); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::history::HistoryCell; + + fn user(s: &str) -> HistoryCell { + HistoryCell::User { + content: s.to_string(), + } + } + + fn assistant(s: &str, streaming: bool) -> HistoryCell { + HistoryCell::Assistant { + content: s.to_string(), + streaming, + } + } + + /// Force a render so `last_visible_height` and `last_total_lines` are + /// populated; otherwise paging keys use the constant fallback. + fn prime_layout(view: &mut LiveTranscriptOverlay, height: u16) { + let area = Rect::new(0, 0, 60, height); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + } + + fn install_snapshots(view: &mut LiveTranscriptOverlay, cells: Vec) { + view.snapshots = cells + .into_iter() + .enumerate() + .map(|(idx, cell)| CellSnapshot { + id: CellId::History(idx), + revision: 1, + cell, + }) + .collect(); + } + + #[test] + fn new_overlay_starts_sticky() { + let v = LiveTranscriptOverlay::new(); + assert!(v.is_sticky()); + assert_eq!(v.scroll_offset(), 0); + assert_eq!(v.snapshot_count(), 0); + } + + #[test] + fn scroll_up_breaks_sticky() { + let mut v = LiveTranscriptOverlay::new(); + install_snapshots( + &mut v, + (0..50).map(|i| user(&format!("line {i}"))).collect(), + ); + prime_layout(&mut v, 10); + // Force scroll non-zero so scroll_up actually moves. + v.scroll = 5; + v.sticky_to_bottom = true; + let _ = v.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); + assert!(!v.is_sticky(), "scrolling up must release the sticky tail"); + } + + #[test] + fn end_resumes_sticky_tail() { + let mut v = LiveTranscriptOverlay::new(); + install_snapshots( + &mut v, + (0..50).map(|i| user(&format!("line {i}"))).collect(), + ); + prime_layout(&mut v, 10); + // Drop out of sticky mode by scrolling up. + v.scroll = 10; + v.sticky_to_bottom = false; + let _ = v.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)); + assert!( + v.is_sticky(), + "End must re-arm the sticky tail so streaming continues to follow" + ); + } + + #[test] + fn scrolling_to_max_re_arms_sticky() { + let mut v = LiveTranscriptOverlay::new(); + install_snapshots( + &mut v, + (0..50).map(|i| user(&format!("line {i}"))).collect(), + ); + prime_layout(&mut v, 10); + v.sticky_to_bottom = false; + // PageDown once should not re-arm since we're not yet at the tail. + let _ = v.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE)); + // Now jump explicitly to bottom and verify re-arm. + v.scroll = 0; + v.sticky_to_bottom = false; + let _ = v.handle_key(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE)); + assert!(v.is_sticky()); + } + + #[test] + fn esc_closes() { + let mut v = LiveTranscriptOverlay::new(); + let action = v.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(matches!(action, ViewAction::Close)); + } + + #[test] + fn ctrl_t_closes_when_already_open() { + let mut v = LiveTranscriptOverlay::new(); + let action = v.handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)); + assert!(matches!(action, ViewAction::Close)); + } + + #[test] + fn render_does_not_panic_on_empty() { + let v = LiveTranscriptOverlay::new(); + let area = Rect::new(0, 0, 40, 12); + let mut buf = Buffer::empty(area); + v.render(area, &mut buf); + } + + #[test] + fn cache_reuses_unchanged_cells_across_renders() { + // Same revisions across two renders should reuse cache entries; only + // a "modified" cell (different revision) forces a new wrap. Verify by + // counting cache size — it grows by 1 per unique (cell, width, rev). + let mut v = LiveTranscriptOverlay::new(); + install_snapshots(&mut v, vec![user("a"), user("b"), assistant("c", false)]); + let area = Rect::new(0, 0, 60, 16); + let mut buf = Buffer::empty(area); + v.render(area, &mut buf); + let after_first = v.cache.borrow().len(); + v.render(area, &mut buf); + let after_second = v.cache.borrow().len(); + assert_eq!( + after_first, after_second, + "second render should reuse every cell — no new cache entries" + ); + } + + #[test] + fn cache_invalidates_on_revision_bump() { + let mut v = LiveTranscriptOverlay::new(); + install_snapshots(&mut v, vec![user("a"), assistant("b", true)]); + let area = Rect::new(0, 0, 60, 16); + let mut buf = Buffer::empty(area); + v.render(area, &mut buf); + let before = v.cache.borrow().len(); + // Bump the streaming assistant's revision (simulating a delta) and + // re-render. We expect the cache to grow by one new entry — the new + // (cell, width, new_rev) — while the user cell entry is reused. + v.snapshots[1].revision = 2; + v.render(area, &mut buf); + let after = v.cache.borrow().len(); + assert!( + after > before, + "bumping a revision must add a new cache entry" + ); + } + + #[test] + fn resize_does_not_evict_unchanged_width_entries() { + // Render at width=60, then again at width=80. Both wraps must + // co-exist in the cache so flipping back to width=60 hits cache. + let mut v = LiveTranscriptOverlay::new(); + install_snapshots(&mut v, vec![user("a"), user("b")]); + let small = Rect::new(0, 0, 60, 16); + let large = Rect::new(0, 0, 80, 16); + let mut buf_s = Buffer::empty(small); + let mut buf_l = Buffer::empty(large); + v.render(small, &mut buf_s); + let after_small = v.cache.borrow().len(); + v.render(large, &mut buf_l); + let after_both = v.cache.borrow().len(); + assert!( + after_both > after_small, + "rendering at a new width must add new cache entries" + ); + // Flip back to small — should NOT add any new entries (cache hits). + v.render(small, &mut buf_s); + let after_replay = v.cache.borrow().len(); + assert_eq!( + after_replay, after_both, + "replay at old width must hit cache" + ); + } +} diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index 6c9ef0bb..4d2db80f 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -15,6 +15,7 @@ pub mod file_picker; pub mod frame_rate_limiter; pub mod history; pub mod keybindings; +pub mod live_transcript; pub mod markdown_render; pub mod model_picker; pub mod onboarding; @@ -30,6 +31,7 @@ pub mod sidebar; pub mod slash_menu; pub mod streaming; pub mod transcript; +pub mod transcript_cache; pub mod ui; mod ui_text; pub mod user_input; diff --git a/crates/tui/src/tui/model_picker.rs b/crates/tui/src/tui/model_picker.rs index f52f120f..87bf043f 100644 --- a/crates/tui/src/tui/model_picker.rs +++ b/crates/tui/src/tui/model_picker.rs @@ -232,6 +232,10 @@ impl ModalView for ModelPickerView { ModalKind::ModelPicker } + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + fn handle_key(&mut self, key: KeyEvent) -> ViewAction { match key.code { KeyCode::Esc => ViewAction::Close, diff --git a/crates/tui/src/tui/pager.rs b/crates/tui/src/tui/pager.rs index 1e196dab..266e0c68 100644 --- a/crates/tui/src/tui/pager.rs +++ b/crates/tui/src/tui/pager.rs @@ -180,6 +180,10 @@ impl ModalView for PagerView { ModalKind::Pager } + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + fn handle_key(&mut self, key: KeyEvent) -> ViewAction { if self.search_mode { match key.code { diff --git a/crates/tui/src/tui/plan_prompt.rs b/crates/tui/src/tui/plan_prompt.rs index 0ce6565d..2bf84ff7 100644 --- a/crates/tui/src/tui/plan_prompt.rs +++ b/crates/tui/src/tui/plan_prompt.rs @@ -131,6 +131,10 @@ impl ModalView for PlanPromptView { ModalKind::PlanPrompt } + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + fn handle_key(&mut self, key: KeyEvent) -> ViewAction { match key.code { KeyCode::Up | KeyCode::Char('k') => { diff --git a/crates/tui/src/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index fd75f367..28332377 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -240,6 +240,10 @@ impl ModalView for ProviderPickerView { ModalKind::ProviderPicker } + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + fn handle_key(&mut self, key: KeyEvent) -> ViewAction { match self.stage { Stage::List => match key.code { diff --git a/crates/tui/src/tui/session_picker.rs b/crates/tui/src/tui/session_picker.rs index 8a6af13f..7fff607c 100644 --- a/crates/tui/src/tui/session_picker.rs +++ b/crates/tui/src/tui/session_picker.rs @@ -243,6 +243,10 @@ impl ModalView for SessionPickerView { ModalKind::SessionPicker } + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + fn handle_key(&mut self, key: KeyEvent) -> ViewAction { if self.search_mode { match key.code { diff --git a/crates/tui/src/tui/transcript_cache.rs b/crates/tui/src/tui/transcript_cache.rs new file mode 100644 index 00000000..6b4591fc --- /dev/null +++ b/crates/tui/src/tui/transcript_cache.rs @@ -0,0 +1,219 @@ +//! Wrapped-line cache for the live transcript overlay (#94). +//! +//! Each cell's rendered output is cached under a `(CellId, width, revision)` +//! key. The revision portion comes from `App.history_revisions` (or the +//! synthetic active-cell revision); the cache invalidates entries the moment +//! a cell mutates because the upstream tag changes. Width changes invalidate +//! everything for that cell because wrap layout depends on width. +//! +//! Live cells (the streaming assistant body, in-flight tool entries) bump +//! their revision on every mutation, so the cache always reflects the latest +//! frame of their output without ever paying for a re-wrap of unrelated +//! cells. Resize-driven re-wrap is bounded to the cells whose width key just +//! changed; nothing else is invalidated. +//! +//! The cache is bounded to keep memory predictable on long sessions. +//! Eviction is a simple insertion-order scheme — a strict LRU would be +//! overkill for the access pattern (full sweep on every render frame). + +use std::collections::HashMap; +use std::collections::VecDeque; + +use ratatui::text::Line; + +/// Soft cap on the number of cached entries before insertion-order eviction +/// kicks in. Sized for the worst-case "5,000-line transcript at 200 cells, +/// resize twice" pattern; well under a megabyte even with 10 KB cells. +const DEFAULT_CAPACITY: usize = 512; + +/// Identifier for a transcript cell within a live render. `History(idx)` +/// addresses a finalized history cell at the given index; +/// `Active(entry_idx)` addresses the synthetic active-cell entry while a +/// turn is in flight. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CellId { + History(usize), + Active(usize), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct Key { + cell: CellId, + width: u16, + revision: u64, +} + +/// Bounded cache of wrapped lines. Keyed by `(cell_id, width, revision)` — +/// any change to a cell's revision (mutation), the terminal width (resize), +/// or the cell's identity (insert/delete shifting indices) misses the cache. +#[derive(Debug)] +pub struct TranscriptCache { + capacity: usize, + entries: HashMap>>, + /// Insertion order so we can evict the oldest entry when full. Two-step + /// (HashMap + VecDeque) so insertion is O(1) and lookup stays O(1). + insertion_order: VecDeque, +} + +impl Default for TranscriptCache { + fn default() -> Self { + Self::with_capacity(DEFAULT_CAPACITY) + } +} + +impl TranscriptCache { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn with_capacity(capacity: usize) -> Self { + Self { + capacity: capacity.max(1), + entries: HashMap::with_capacity(capacity.max(1)), + insertion_order: VecDeque::with_capacity(capacity.max(1)), + } + } + + /// Look up wrapped lines previously rendered at this exact key. Returns + /// `None` if the cell never wrapped at this width/revision before. + #[must_use] + pub fn get(&self, cell: CellId, width: u16, revision: u64) -> Option<&[Line<'static>]> { + let key = Key { + cell, + width, + revision, + }; + self.entries.get(&key).map(Vec::as_slice) + } + + /// Cache a fresh wrap result. If the cache is at capacity the oldest + /// inserted entry is evicted first. + pub fn insert(&mut self, cell: CellId, width: u16, revision: u64, lines: Vec>) { + let key = Key { + cell, + width, + revision, + }; + // Replace an existing key in place — keep its position in the + // insertion-order queue so we don't trigger spurious eviction. + if self.entries.insert(key, lines).is_some() { + return; + } + if self.entries.len() > self.capacity + && let Some(oldest) = self.insertion_order.pop_front() + { + self.entries.remove(&oldest); + } + self.insertion_order.push_back(key); + } + + /// Drop every cached entry. Used when the underlying transcript shape + /// changes drastically (e.g. session reset). + #[allow(dead_code)] // Reserved for /clear and session-reset call sites. + pub fn clear(&mut self) { + self.entries.clear(); + self.insertion_order.clear(); + } + + #[cfg(test)] + pub fn len(&self) -> usize { + self.entries.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::text::Span; + + fn line(s: &str) -> Line<'static> { + Line::from(Span::raw(s.to_string())) + } + + #[test] + fn miss_returns_none() { + let cache = TranscriptCache::new(); + assert!(cache.get(CellId::History(0), 80, 1).is_none()); + } + + #[test] + fn round_trip_returns_inserted_lines() { + let mut cache = TranscriptCache::new(); + let lines = vec![line("hello"), line("world")]; + cache.insert(CellId::History(0), 80, 1, lines.clone()); + let got = cache + .get(CellId::History(0), 80, 1) + .expect("entry should be cached"); + assert_eq!(got.len(), 2); + assert_eq!(got[0].spans[0].content, "hello"); + } + + #[test] + fn revision_bump_invalidates_cell() { + let mut cache = TranscriptCache::new(); + cache.insert(CellId::History(0), 80, 1, vec![line("v1")]); + // Hit at rev=1 + assert!(cache.get(CellId::History(0), 80, 1).is_some()); + // Miss at rev=2 — caller is expected to re-wrap and insert again. + assert!(cache.get(CellId::History(0), 80, 2).is_none()); + } + + #[test] + fn width_change_invalidates_cell() { + let mut cache = TranscriptCache::new(); + cache.insert(CellId::History(0), 80, 1, vec![line("v1")]); + assert!(cache.get(CellId::History(0), 80, 1).is_some()); + assert!(cache.get(CellId::History(0), 100, 1).is_none()); + } + + #[test] + fn active_cells_are_distinct_from_history() { + let mut cache = TranscriptCache::new(); + cache.insert(CellId::History(0), 80, 1, vec![line("history")]); + cache.insert(CellId::Active(0), 80, 1, vec![line("active")]); + assert_eq!( + cache.get(CellId::History(0), 80, 1).unwrap()[0].spans[0].content, + "history" + ); + assert_eq!( + cache.get(CellId::Active(0), 80, 1).unwrap()[0].spans[0].content, + "active" + ); + } + + #[test] + fn reinsert_same_key_does_not_evict() { + // Capacity 2 — re-inserting an existing key must not cause the other + // entry to be evicted; otherwise re-rendering the same cell on every + // frame would churn unrelated entries out of the cache. + let mut cache = TranscriptCache::with_capacity(2); + cache.insert(CellId::History(0), 80, 1, vec![line("a")]); + cache.insert(CellId::History(1), 80, 1, vec![line("b")]); + cache.insert(CellId::History(0), 80, 1, vec![line("a-prime")]); + assert!(cache.get(CellId::History(1), 80, 1).is_some()); + } + + #[test] + fn capacity_evicts_oldest_on_overflow() { + let mut cache = TranscriptCache::with_capacity(2); + cache.insert(CellId::History(0), 80, 1, vec![line("a")]); + cache.insert(CellId::History(1), 80, 1, vec![line("b")]); + cache.insert(CellId::History(2), 80, 1, vec![line("c")]); + // Oldest (History(0)) should be gone; the two newer keys remain. + assert!(cache.get(CellId::History(0), 80, 1).is_none()); + assert!(cache.get(CellId::History(1), 80, 1).is_some()); + assert!(cache.get(CellId::History(2), 80, 1).is_some()); + assert_eq!(cache.len(), 2); + } + + #[test] + fn clear_drops_everything() { + let mut cache = TranscriptCache::new(); + cache.insert(CellId::History(0), 80, 1, vec![line("v1")]); + cache.clear(); + assert!(cache.get(CellId::History(0), 80, 1).is_none()); + assert_eq!(cache.len(), 0); + } +} diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 10be3751..0987e792 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -54,6 +54,7 @@ use crate::tui::command_palette::{ CommandPaletteView, build_entries as build_command_palette_entries, }; use crate::tui::event_broker::EventBroker; +use crate::tui::live_transcript::LiveTranscriptOverlay; use crate::tui::onboarding; use crate::tui::pager::PagerView; use crate::tui::plan_prompt::PlanPromptView; @@ -1379,6 +1380,12 @@ async fn run_event_loop( { continue; } + KeyCode::Char('t') | KeyCode::Char('T') + if key.modifiers == KeyModifiers::CONTROL => + { + toggle_live_transcript_overlay(app); + continue; + } KeyCode::Char('1') if key.modifiers.contains(KeyModifiers::ALT) => { if key.modifiers.contains(KeyModifiers::CONTROL) { app.set_sidebar_focus(SidebarFocus::Plan); @@ -3070,11 +3077,48 @@ fn render(f: &mut Frame, app: &mut App) { render_footer(f, chunks[4], app); if !app.view_stack.is_empty() { + // The live transcript overlay snapshots the app's history + active + // cell on each render so streaming mutations propagate. Other views + // are static and skip this refresh. + if app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) { + refresh_live_transcript_overlay(app); + } let buf = f.buffer_mut(); app.view_stack.render(size, buf); } } +/// Pull the latest snapshot of cells / revisions / render options into the +/// live transcript overlay sitting on top of the view stack. No-op if the +/// top view isn't a `LiveTranscriptOverlay`. +fn refresh_live_transcript_overlay(app: &mut App) { + // Pop+push lets us hold &mut to the overlay while also borrowing `app` + // mutably for the snapshot — direct re-borrow through `view_stack` + // would otherwise alias `app`. + let Some(mut overlay) = app.view_stack.pop() else { + return; + }; + if let Some(typed) = overlay.as_any_mut().downcast_mut::() { + typed.refresh_from_app(app); + } + app.view_stack.push_boxed(overlay); +} + +/// Toggle the live transcript overlay on `Ctrl+T`. Closes the overlay if it's +/// already on top; otherwise pushes a fresh one in sticky-tail mode. +fn toggle_live_transcript_overlay(app: &mut App) { + if app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) { + app.view_stack.pop(); + app.needs_redraw = true; + return; + } + let mut overlay = LiveTranscriptOverlay::new(); + overlay.refresh_from_app(app); + app.view_stack.push(overlay); + app.status_message = Some("Live transcript: tailing (Esc to close)".to_string()); + app.needs_redraw = true; +} + async fn handle_view_events( app: &mut App, config: &mut Config, diff --git a/crates/tui/src/tui/user_input.rs b/crates/tui/src/tui/user_input.rs index 1296a0cb..b3d44ad2 100644 --- a/crates/tui/src/tui/user_input.rs +++ b/crates/tui/src/tui/user_input.rs @@ -238,6 +238,10 @@ impl ModalView for UserInputView { ModalKind::UserInput } + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + fn handle_key(&mut self, key: KeyEvent) -> ViewAction { match self.mode { InputMode::Selecting => self.handle_selecting_key(key), diff --git a/crates/tui/src/tui/views/help.rs b/crates/tui/src/tui/views/help.rs index 1693440d..acb20a87 100644 --- a/crates/tui/src/tui/views/help.rs +++ b/crates/tui/src/tui/views/help.rs @@ -222,6 +222,10 @@ impl ModalView for HelpView { ModalKind::Help } + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + fn handle_key(&mut self, key: KeyEvent) -> ViewAction { match key.code { KeyCode::Esc => ViewAction::Close, diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 2866d991..d59e4160 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -22,6 +22,7 @@ pub enum ModalKind { Help, SubAgents, Pager, + LiveTranscript, SessionPicker, Config, ModelPicker, @@ -131,7 +132,7 @@ pub enum ViewAction { EmitAndClose(ViewEvent), } -pub trait ModalView { +pub trait ModalView: std::any::Any { fn kind(&self) -> ModalKind; fn handle_key(&mut self, key: KeyEvent) -> ViewAction; fn render(&self, area: Rect, buf: &mut Buffer); @@ -141,6 +142,11 @@ pub trait ModalView { fn tick(&mut self) -> ViewAction { ViewAction::None } + /// Erased downcast hook for views that need a typed reference back from + /// the boxed trait object (e.g. the live transcript overlay needs `&mut` + /// access from outside the trait so it can refresh its snapshot of the + /// app's transcript state right before render). + fn as_any_mut(&mut self) -> &mut dyn std::any::Any; } #[derive(Default)] @@ -165,6 +171,13 @@ impl ViewStack { self.views.push(Box::new(view)); } + /// Push an already-boxed view back onto the stack. Used by call sites + /// that pop a view, mutate it externally, and need to restore it without + /// the generic `push` re-boxing dance. + pub fn push_boxed(&mut self, view: Box) { + self.views.push(view); + } + pub fn pop(&mut self) -> Option> { self.views.pop() } @@ -631,6 +644,10 @@ impl ModalView for ConfigView { ModalKind::Config } + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + fn handle_key(&mut self, key: KeyEvent) -> ViewAction { if self.editing.is_some() { return self.handle_editing_key(key); @@ -836,6 +853,10 @@ impl ModalView for SubAgentsView { ModalKind::SubAgents } + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + fn handle_key(&mut self, key: KeyEvent) -> ViewAction { use crossterm::event::KeyCode; diff --git a/crates/tui/src/tui/views/status_picker.rs b/crates/tui/src/tui/views/status_picker.rs index 879001c8..2cbf576e 100644 --- a/crates/tui/src/tui/views/status_picker.rs +++ b/crates/tui/src/tui/views/status_picker.rs @@ -107,6 +107,10 @@ impl ModalView for StatusPickerView { ModalKind::StatusPicker } + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + fn handle_key(&mut self, key: KeyEvent) -> ViewAction { match key.code { KeyCode::Esc => {