Merge branch 'feat/v067-transcript-overlay' (#94 live transcript overlay Ctrl+T)
This commit is contained in:
@@ -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') => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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') => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,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 => {
|
||||
|
||||
Reference in New Issue
Block a user