Merge branch 'feat/v067-transcript-overlay' (#94 live transcript overlay Ctrl+T)

This commit is contained in:
Hunter Bown
2026-04-27 22:44:33 -05:00
17 changed files with 907 additions and 1 deletions
+8
View File
@@ -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') => {
+4
View File
@@ -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,
+4
View File
@@ -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,
+5
View File
@@ -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",
+567
View File
@@ -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<CellSnapshot>,
/// 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<TranscriptCache>,
/// 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<usize>,
/// Last total line count after wrapping; cached so `handle_key` can
/// clamp scroll without re-wrapping. Updated by `render`.
last_total_lines: RefCell<usize>,
/// 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<Line<'static>> {
let width = width.max(1);
let mut out: Vec<Line<'static>> = Vec::new();
let mut cache = self.cache.borrow_mut();
for snap in &self.snapshots {
let lines: Vec<Line<'static>> = 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<Line<'static>> = 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<HistoryCell>) {
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"
);
}
}
+2
View File
@@ -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;
+4
View File
@@ -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,
+4
View File
@@ -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 {
+4
View File
@@ -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') => {
+4
View File
@@ -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 {
+4
View File
@@ -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 {
+219
View File
@@ -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<Key, Vec<Line<'static>>>,
/// 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<Key>,
}
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<Line<'static>>) {
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);
}
}
+44
View File
@@ -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);
@@ -3092,11 +3099,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::<LiveTranscriptOverlay>() {
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,
+4
View File
@@ -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),
+4
View File
@@ -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,
+22 -1
View File
@@ -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<dyn ModalView>) {
self.views.push(view);
}
pub fn pop(&mut self) -> Option<Box<dyn ModalView>> {
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;
@@ -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 => {