返回 DeepSeek-TUI-2026
live_transcript.rs
根目录 / crates / tui / src / tui / live_transcript.rs
1 //! Full-screen live transcript overlay with sticky-bottom auto-scroll (#94).
2 //!
3 //! Toggled with `Ctrl+T` while the engine is streaming. Behaviour:
4 //!
5 //! - At-bottom (`sticky_to_bottom = true`) — every refresh re-pins scroll to
6 //! the new tail, so streaming output appears to flow off the bottom edge.
7 //! - Scroll up — `sticky_to_bottom` flips to `false`; subsequent refreshes
8 //! leave scroll position alone so the user can read history without being
9 //! yanked back down.
10 //! - Scroll back to bottom (End / G / paging past the tail) — `sticky` flips
11 //! to `true` again; auto-tail resumes.
12 //! - Esc / `q` — close, returning to the normal view. The engine never
13 //! pauses while the overlay is open; new chunks accumulate in the cells
14 //! exactly as they would on the normal screen.
15 //!
16 //! Cache strategy: the overlay holds its own `TranscriptCache` keyed by
17 //! `(CellId, width, revision)`. Revisions come from the same per-cell
18 //! counters the main transcript already maintains (`App.history_revisions`
19 //! and `App.active_cell_revision`). Resize invalidates the cells whose width
20 //! key just changed; revision bumps invalidate only the cells that mutated;
21 //! cells that didn't change reuse their existing wrap.
22
23 use std::cell::RefCell;
24
25 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
26 use ratatui::{
27 buffer::Buffer,
28 layout::Rect,
29 style::{Modifier, Style},
30 text::{Line, Span},
31 widgets::{Block, Borders, Clear, Padding, Paragraph, Widget, Wrap},
32 };
33
34 use crate::palette;
35 use crate::tui::app::App;
36 use crate::tui::backtrack::Direction;
37 use crate::tui::history::{HistoryCell, TranscriptRenderOptions};
38 use crate::tui::transcript_cache::{CellId, TranscriptCache};
39 use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
40
41 /// Render mode for the overlay. `Tail` is the original Ctrl+T sticky-tail
42 /// behaviour (#94). `BacktrackPreview` (#133) highlights the Nth-from-tail
43 /// `HistoryCell::User` so the user can see which turn Esc-Esc-Enter will
44 /// roll back to. The mode also disables sticky-tail (we want the user to
45 /// scan history, not be yanked to live output) and pins scroll near the
46 /// highlighted cell on transitions.
47 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48 pub enum Mode {
49 #[default]
50 Tail,
51 BacktrackPreview {
52 selected_idx: usize,
53 },
54 }
55
56 /// Single-line footer hint. Kept short so it fits on narrow terminals.
57 const FOOTER_HINT: &str =
58 " j/k scroll Space/b page g/G top/bottom End=resume tail q/Esc close ";
59
60 /// Snapshot of one cell, refreshed every frame from `App`. Owns the cell so
61 /// the overlay's `render(&self)` can wrap without re-borrowing `App`.
62 #[derive(Debug, Clone)]
63 struct CellSnapshot {
64 id: CellId,
65 revision: u64,
66 cell: HistoryCell,
67 }
68
69 pub struct LiveTranscriptOverlay {
70 /// Latest cell snapshots (history + active). Refreshed via
71 /// `refresh_from_app` immediately before each render so streaming
72 /// mutations show up on the next paint.
73 snapshots: Vec<CellSnapshot>,
74 /// Render options sampled from `App` at refresh time so toggles like
75 /// `show_thinking` propagate into the overlay live.
76 options: TranscriptRenderOptions,
77 /// Wrapped-line cache. `RefCell` so `render(&self)` can write through.
78 cache: RefCell<TranscriptCache>,
79 /// Sticky-tail flag: when `true`, refresh re-pins scroll to the bottom.
80 /// Flipped to `false` when the user scrolls up; flipped back to `true`
81 /// when they scroll past the last visible line.
82 sticky_to_bottom: bool,
83 /// Current top-of-viewport line offset into the flattened line list.
84 scroll: usize,
85 /// Visible content height from the last render. Used by paging keys
86 /// before the next render frame populates a fresh value.
87 last_visible_height: RefCell<usize>,
88 /// Last total line count after wrapping; cached so `handle_key` can
89 /// clamp scroll without re-wrapping. Updated by `render`.
90 last_total_lines: RefCell<usize>,
91 /// Pending `gg` second keystroke for Vim-style jump-to-top.
92 pending_g: bool,
93 /// Render mode — `Tail` is the live-stream mode; `BacktrackPreview`
94 /// highlights the selected user message (#133).
95 mode: Mode,
96 }
97
98 impl LiveTranscriptOverlay {
99 #[must_use]
100 pub fn new() -> Self {
101 Self {
102 snapshots: Vec::new(),
103 options: TranscriptRenderOptions::default(),
104 cache: RefCell::new(TranscriptCache::new()),
105 sticky_to_bottom: true,
106 scroll: 0,
107 last_visible_height: RefCell::new(0),
108 last_total_lines: RefCell::new(0),
109 pending_g: false,
110 mode: Mode::Tail,
111 }
112 }
113
114 /// Switch the overlay into backtrack-preview mode. Sticky-tail is
115 /// turned off so the highlighted cell stays in view while the user
116 /// steps through prior turns. The wrap cache stays valid because the
117 /// underlying snapshot data hasn't changed — only the post-wrap
118 /// highlight overlay does.
119 pub fn set_backtrack_preview(&mut self, selected_idx: usize) {
120 self.mode = Mode::BacktrackPreview { selected_idx };
121 self.sticky_to_bottom = false;
122 }
123
124 /// Return the overlay to live-tail mode (used when backtrack is
125 /// confirmed or canceled). Re-arms sticky-tail so streaming resumes.
126 #[allow(dead_code)] // exposed for callers that retain an overlay across a backtrack cancel; current UI just pops the view.
127 pub fn set_tail_mode(&mut self) {
128 self.mode = Mode::Tail;
129 self.sticky_to_bottom = true;
130 }
131
132 /// For tests + UI: current mode.
133 #[allow(dead_code)] // currently consumed only by tests; kept public for symmetry with `set_*` setters.
134 #[must_use]
135 pub fn mode(&self) -> Mode {
136 self.mode
137 }
138
139 /// Pull the latest cells + revisions from `App` so the next `render` shows
140 /// streaming mutations. Must be called before `view_stack.render` while
141 /// this overlay is on top; otherwise the cells stay frozen at whatever
142 /// state they were in when the overlay was first opened.
143 pub fn refresh_from_app(&mut self, app: &mut App) {
144 app.resync_history_revisions();
145 let mut new_snapshots = Vec::with_capacity(
146 app.history.len() + app.active_cell.as_ref().map_or(0, |a| a.entries().len()),
147 );
148 for (idx, cell) in app.history.iter().enumerate() {
149 let rev = app.history_revisions.get(idx).copied().unwrap_or(0);
150 new_snapshots.push(CellSnapshot {
151 id: CellId::History(idx),
152 revision: rev,
153 cell: cell.clone(),
154 });
155 }
156 if let Some(active) = app.active_cell.as_ref() {
157 let active_rev = app.active_cell_revision;
158 for (idx, cell) in active.entries().iter().enumerate() {
159 let salt = (idx as u64).wrapping_add(1);
160 // Salt mirrors the main-transcript scheme so cache keys are
161 // stable across the two overlays for the same active entry.
162 let revision = active_rev
163 .wrapping_mul(0x9E37_79B9_7F4A_7C15)
164 .wrapping_add(salt);
165 new_snapshots.push(CellSnapshot {
166 id: CellId::Active(idx),
167 revision,
168 cell: cell.clone(),
169 });
170 }
171 }
172 self.snapshots = new_snapshots;
173 self.options = app.transcript_render_options();
174 }
175
176 /// Wrap each cell (using the cache) and return the flat line vector.
177 /// In `BacktrackPreview` mode the lines belonging to the selected
178 /// `HistoryCell::User` are decorated with a leading `▶` marker on the
179 /// first line and reverse-video styling on every line so the eye
180 /// snaps to them at a glance. The decoration is applied *after* the
181 /// cache lookup so toggling preview mode never invalidates wraps.
182 fn flatten(&self, width: u16) -> Vec<Line<'static>> {
183 let width = width.max(1);
184 let mut out: Vec<Line<'static>> = Vec::new();
185
186 // Pre-compute which cell index (in `self.snapshots`) is the one
187 // the user has selected via Esc-Esc. We walk snapshots backwards
188 // counting User cells; the snapshot index whose count matches
189 // `selected_idx + 1` is the highlighted one.
190 let highlighted_cell_idx: Option<usize> = match self.mode {
191 Mode::BacktrackPreview { selected_idx } => {
192 let mut count = 0usize;
193 let mut hit = None;
194 for (idx, snap) in self.snapshots.iter().enumerate().rev() {
195 if matches!(snap.cell, HistoryCell::User { .. }) {
196 if count == selected_idx {
197 hit = Some(idx);
198 break;
199 }
200 count += 1;
201 }
202 }
203 hit
204 }
205 Mode::Tail => None,
206 };
207
208 let mut cache = self.cache.borrow_mut();
209 for (cell_idx, snap) in self.snapshots.iter().enumerate() {
210 let lines: Vec<Line<'static>> = match cache.get(snap.id, width, snap.revision) {
211 Some(cached) => cached.to_vec(),
212 None => {
213 let rendered = snap.cell.lines_with_options(width, self.options);
214 cache.insert(snap.id, width, snap.revision, rendered.clone());
215 rendered
216 }
217 };
218
219 if Some(cell_idx) == highlighted_cell_idx {
220 out.extend(decorate_highlight(lines));
221 } else {
222 out.extend(lines);
223 }
224 }
225 out
226 }
227
228 fn page_height(&self) -> usize {
229 let cached = *self.last_visible_height.borrow();
230 if cached == 0 { 10 } else { cached }
231 }
232
233 fn half_page_height(&self) -> usize {
234 self.page_height().div_ceil(2).max(1)
235 }
236
237 fn max_scroll(&self) -> usize {
238 let total = *self.last_total_lines.borrow();
239 let visible = self.page_height();
240 total.saturating_sub(visible)
241 }
242
243 fn scroll_up(&mut self, amount: usize) {
244 self.scroll = self.scroll.saturating_sub(amount);
245 // Any upward motion exits sticky-tail; explicit user intent.
246 self.sticky_to_bottom = false;
247 }
248
249 fn scroll_down(&mut self, amount: usize) {
250 let max = self.max_scroll();
251 self.scroll = (self.scroll + amount).min(max);
252 if self.scroll >= max {
253 self.sticky_to_bottom = true;
254 }
255 }
256
257 fn jump_to_top(&mut self) {
258 self.scroll = 0;
259 self.sticky_to_bottom = false;
260 }
261
262 fn jump_to_bottom(&mut self) {
263 self.scroll = self.max_scroll();
264 self.sticky_to_bottom = true;
265 }
266
267 /// For tests: snapshot count.
268 #[cfg(test)]
269 fn snapshot_count(&self) -> usize {
270 self.snapshots.len()
271 }
272
273 /// For tests: whether sticky-tail is currently armed.
274 #[cfg(test)]
275 pub fn is_sticky(&self) -> bool {
276 self.sticky_to_bottom
277 }
278
279 /// For tests: current scroll offset.
280 #[cfg(test)]
281 pub fn scroll_offset(&self) -> usize {
282 self.scroll
283 }
284 }
285
286 impl Default for LiveTranscriptOverlay {
287 fn default() -> Self {
288 Self::new()
289 }
290 }
291
292 /// Apply a backtrack-preview highlight to the lines belonging to a single
293 /// `HistoryCell::User`. The first line gets a `▶ ` prefix in accent color
294 /// (so the marker remains visible even on terminals where reverse-video
295 /// is washed out); every line in the cell gets `Modifier::REVERSED` so
296 /// the cell visually pops out of the surrounding transcript. Internal
297 /// span structure is preserved so syntax/role coloring underneath the
298 /// reverse stays readable.
299 fn decorate_highlight(mut lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
300 if lines.is_empty() {
301 return lines;
302 }
303 for line in &mut lines {
304 for span in &mut line.spans {
305 span.style = span.style.add_modifier(Modifier::REVERSED);
306 }
307 }
308 let marker = Span::styled(
309 "\u{25B6} ",
310 Style::default()
311 .fg(palette::TEXT_ACCENT)
312 .add_modifier(Modifier::BOLD),
313 );
314 if let Some(first) = lines.first_mut() {
315 first.spans.insert(0, marker);
316 }
317 lines
318 }
319
320 impl ModalView for LiveTranscriptOverlay {
321 fn kind(&self) -> ModalKind {
322 ModalKind::LiveTranscript
323 }
324
325 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
326 self
327 }
328
329 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
330 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
331 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
332
333 // Backtrack-preview mode (#133) intercepts Left/Right/Enter/Esc
334 // before the normal scroll handlers so the user can step through
335 // prior user messages without their input being interpreted as
336 // pager navigation. Other keys (page up/down, gg/G, etc.) still
337 // fall through so the user can scroll the transcript while
338 // previewing.
339 if matches!(self.mode, Mode::BacktrackPreview { .. }) {
340 match key.code {
341 KeyCode::Left | KeyCode::Char('h') if !ctrl => {
342 return ViewAction::Emit(ViewEvent::BacktrackStep {
343 direction: Direction::Left,
344 });
345 }
346 KeyCode::Right | KeyCode::Char('l') if !ctrl => {
347 return ViewAction::Emit(ViewEvent::BacktrackStep {
348 direction: Direction::Right,
349 });
350 }
351 KeyCode::Enter => {
352 return ViewAction::EmitAndClose(ViewEvent::BacktrackConfirm);
353 }
354 KeyCode::Esc | KeyCode::Char('q') => {
355 return ViewAction::EmitAndClose(ViewEvent::BacktrackCancel);
356 }
357 _ => {}
358 }
359 }
360
361 if ctrl {
362 match key.code {
363 KeyCode::Char('d') | KeyCode::Char('D') => {
364 self.scroll_down(self.half_page_height());
365 self.pending_g = false;
366 return ViewAction::None;
367 }
368 KeyCode::Char('u') | KeyCode::Char('U') => {
369 self.scroll_up(self.half_page_height());
370 self.pending_g = false;
371 return ViewAction::None;
372 }
373 KeyCode::Char('f') | KeyCode::Char('F') => {
374 self.scroll_down(self.page_height());
375 self.pending_g = false;
376 return ViewAction::None;
377 }
378 KeyCode::Char('b') | KeyCode::Char('B') => {
379 self.scroll_up(self.page_height());
380 self.pending_g = false;
381 return ViewAction::None;
382 }
383 // Ctrl+T toggles the overlay closed when already open.
384 KeyCode::Char('t') | KeyCode::Char('T') => return ViewAction::Close,
385 _ => {}
386 }
387 }
388
389 match key.code {
390 KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
391 KeyCode::Up | KeyCode::Char('k') => {
392 self.scroll_up(1);
393 self.pending_g = false;
394 ViewAction::None
395 }
396 KeyCode::Down | KeyCode::Char('j') => {
397 self.scroll_down(1);
398 self.pending_g = false;
399 ViewAction::None
400 }
401 KeyCode::PageUp => {
402 self.scroll_up(self.page_height());
403 self.pending_g = false;
404 ViewAction::None
405 }
406 KeyCode::PageDown => {
407 self.scroll_down(self.page_height());
408 self.pending_g = false;
409 ViewAction::None
410 }
411 KeyCode::Char(' ') if shift => {
412 self.scroll_up(self.page_height());
413 self.pending_g = false;
414 ViewAction::None
415 }
416 KeyCode::Char(' ') => {
417 self.scroll_down(self.page_height());
418 self.pending_g = false;
419 ViewAction::None
420 }
421 KeyCode::Home => {
422 self.jump_to_top();
423 self.pending_g = false;
424 ViewAction::None
425 }
426 KeyCode::End => {
427 self.jump_to_bottom();
428 self.pending_g = false;
429 ViewAction::None
430 }
431 KeyCode::Char('g') => {
432 if self.pending_g {
433 self.jump_to_top();
434 self.pending_g = false;
435 } else {
436 self.pending_g = true;
437 }
438 ViewAction::None
439 }
440 KeyCode::Char('G') => {
441 self.jump_to_bottom();
442 self.pending_g = false;
443 ViewAction::None
444 }
445 _ => ViewAction::None,
446 }
447 }
448
449 fn render(&self, area: Rect, buf: &mut Buffer) {
450 let popup_width = area.width.saturating_sub(2).max(1);
451 let popup_height = area.height.saturating_sub(2).max(1);
452 let popup_area = Rect {
453 x: 1,
454 y: 1,
455 width: popup_width,
456 height: popup_height,
457 };
458
459 Clear.render(popup_area, buf);
460
461 // Compute inner content height once: borders eat 1 row top + 1 bottom,
462 // padding eats 1 more on each side.
463 let visible_height = popup_area.height.saturating_sub(4) as usize;
464 *self.last_visible_height.borrow_mut() = visible_height;
465
466 // Wrap content using the per-cell cache; subtract padding from width
467 // so wrapped lines fit between the inner edges.
468 let content_width = popup_width.saturating_sub(4);
469 let lines = self.flatten(content_width);
470 *self.last_total_lines.borrow_mut() = lines.len();
471
472 let max_scroll = lines.len().saturating_sub(visible_height);
473 // Sticky-tail: every render re-pins scroll to the bottom unless the
474 // user has explicitly scrolled away. Without this, streaming new
475 // content would push the visible window backwards as `scroll` stays
476 // fixed against a growing total.
477 let scroll = if self.sticky_to_bottom {
478 max_scroll
479 } else {
480 self.scroll.min(max_scroll)
481 };
482 let end = (scroll + visible_height).min(lines.len());
483 let visible_lines: Vec<Line<'static>> = if lines.is_empty() {
484 vec![Line::from(Span::styled(
485 "(no transcript yet)",
486 Style::default().fg(palette::TEXT_DIM),
487 ))]
488 } else {
489 lines[scroll..end].to_vec()
490 };
491
492 let title: String = match self.mode {
493 Mode::BacktrackPreview { selected_idx } => format!(
494 " Backtrack preview — turn {} (\u{2190}/\u{2192} step, Enter rewind, Esc cancel) ",
495 selected_idx + 1
496 ),
497 Mode::Tail => {
498 if self.sticky_to_bottom {
499 " Live transcript (tailing) ".to_string()
500 } else {
501 " Live transcript (paused) ".to_string()
502 }
503 }
504 };
505
506 let footer = Line::from(Span::styled(
507 FOOTER_HINT,
508 Style::default().fg(palette::TEXT_HINT),
509 ));
510 let block = Block::default()
511 .title(title)
512 .title_bottom(footer)
513 .borders(Borders::ALL)
514 .border_style(Style::default().fg(palette::BORDER_COLOR))
515 .style(Style::default().bg(palette::DEEPSEEK_INK))
516 .padding(Padding::uniform(1));
517
518 let paragraph = Paragraph::new(visible_lines)
519 .block(block)
520 .wrap(Wrap { trim: false });
521 paragraph.render(popup_area, buf);
522 }
523 }
524
525 #[cfg(test)]
526 mod tests {
527 use super::*;
528 use crate::tui::history::HistoryCell;
529
530 fn user(s: &str) -> HistoryCell {
531 HistoryCell::User {
532 content: s.to_string(),
533 }
534 }
535
536 fn assistant(s: &str, streaming: bool) -> HistoryCell {
537 HistoryCell::Assistant {
538 content: s.to_string(),
539 streaming,
540 }
541 }
542
543 /// Force a render so `last_visible_height` and `last_total_lines` are
544 /// populated; otherwise paging keys use the constant fallback.
545 fn prime_layout(view: &mut LiveTranscriptOverlay, height: u16) {
546 let area = Rect::new(0, 0, 60, height);
547 let mut buf = Buffer::empty(area);
548 view.render(area, &mut buf);
549 }
550
551 fn install_snapshots(view: &mut LiveTranscriptOverlay, cells: Vec<HistoryCell>) {
552 view.snapshots = cells
553 .into_iter()
554 .enumerate()
555 .map(|(idx, cell)| CellSnapshot {
556 id: CellId::History(idx),
557 revision: 1,
558 cell,
559 })
560 .collect();
561 }
562
563 #[test]
564 fn new_overlay_starts_sticky() {
565 let v = LiveTranscriptOverlay::new();
566 assert!(v.is_sticky());
567 assert_eq!(v.scroll_offset(), 0);
568 assert_eq!(v.snapshot_count(), 0);
569 }
570
571 #[test]
572 fn scroll_up_breaks_sticky() {
573 let mut v = LiveTranscriptOverlay::new();
574 install_snapshots(
575 &mut v,
576 (0..50).map(|i| user(&format!("line {i}"))).collect(),
577 );
578 prime_layout(&mut v, 10);
579 // Force scroll non-zero so scroll_up actually moves.
580 v.scroll = 5;
581 v.sticky_to_bottom = true;
582 let _ = v.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
583 assert!(!v.is_sticky(), "scrolling up must release the sticky tail");
584 }
585
586 #[test]
587 fn end_resumes_sticky_tail() {
588 let mut v = LiveTranscriptOverlay::new();
589 install_snapshots(
590 &mut v,
591 (0..50).map(|i| user(&format!("line {i}"))).collect(),
592 );
593 prime_layout(&mut v, 10);
594 // Drop out of sticky mode by scrolling up.
595 v.scroll = 10;
596 v.sticky_to_bottom = false;
597 let _ = v.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
598 assert!(
599 v.is_sticky(),
600 "End must re-arm the sticky tail so streaming continues to follow"
601 );
602 }
603
604 #[test]
605 fn scrolling_to_max_re_arms_sticky() {
606 let mut v = LiveTranscriptOverlay::new();
607 install_snapshots(
608 &mut v,
609 (0..50).map(|i| user(&format!("line {i}"))).collect(),
610 );
611 prime_layout(&mut v, 10);
612 v.sticky_to_bottom = false;
613 // PageDown once should not re-arm since we're not yet at the tail.
614 let _ = v.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE));
615 // Now jump explicitly to bottom and verify re-arm.
616 v.scroll = 0;
617 v.sticky_to_bottom = false;
618 let _ = v.handle_key(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE));
619 assert!(v.is_sticky());
620 }
621
622 #[test]
623 fn esc_closes() {
624 let mut v = LiveTranscriptOverlay::new();
625 let action = v.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
626 assert!(matches!(action, ViewAction::Close));
627 }
628
629 #[test]
630 fn ctrl_t_closes_when_already_open() {
631 let mut v = LiveTranscriptOverlay::new();
632 let action = v.handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL));
633 assert!(matches!(action, ViewAction::Close));
634 }
635
636 #[test]
637 fn render_does_not_panic_on_empty() {
638 let v = LiveTranscriptOverlay::new();
639 let area = Rect::new(0, 0, 40, 12);
640 let mut buf = Buffer::empty(area);
641 v.render(area, &mut buf);
642 }
643
644 #[test]
645 fn cache_reuses_unchanged_cells_across_renders() {
646 // Same revisions across two renders should reuse cache entries; only
647 // a "modified" cell (different revision) forces a new wrap. Verify by
648 // counting cache size — it grows by 1 per unique (cell, width, rev).
649 let mut v = LiveTranscriptOverlay::new();
650 install_snapshots(&mut v, vec![user("a"), user("b"), assistant("c", false)]);
651 let area = Rect::new(0, 0, 60, 16);
652 let mut buf = Buffer::empty(area);
653 v.render(area, &mut buf);
654 let after_first = v.cache.borrow().len();
655 v.render(area, &mut buf);
656 let after_second = v.cache.borrow().len();
657 assert_eq!(
658 after_first, after_second,
659 "second render should reuse every cell — no new cache entries"
660 );
661 }
662
663 #[test]
664 fn cache_invalidates_on_revision_bump() {
665 let mut v = LiveTranscriptOverlay::new();
666 install_snapshots(&mut v, vec![user("a"), assistant("b", true)]);
667 let area = Rect::new(0, 0, 60, 16);
668 let mut buf = Buffer::empty(area);
669 v.render(area, &mut buf);
670 let before = v.cache.borrow().len();
671 // Bump the streaming assistant's revision (simulating a delta) and
672 // re-render. We expect the cache to grow by one new entry — the new
673 // (cell, width, new_rev) — while the user cell entry is reused.
674 v.snapshots[1].revision = 2;
675 v.render(area, &mut buf);
676 let after = v.cache.borrow().len();
677 assert!(
678 after > before,
679 "bumping a revision must add a new cache entry"
680 );
681 }
682
683 #[test]
684 fn resize_does_not_evict_unchanged_width_entries() {
685 // Render at width=60, then again at width=80. Both wraps must
686 // co-exist in the cache so flipping back to width=60 hits cache.
687 let mut v = LiveTranscriptOverlay::new();
688 install_snapshots(&mut v, vec![user("a"), user("b")]);
689 let small = Rect::new(0, 0, 60, 16);
690 let large = Rect::new(0, 0, 80, 16);
691 let mut buf_s = Buffer::empty(small);
692 let mut buf_l = Buffer::empty(large);
693 v.render(small, &mut buf_s);
694 let after_small = v.cache.borrow().len();
695 v.render(large, &mut buf_l);
696 let after_both = v.cache.borrow().len();
697 assert!(
698 after_both > after_small,
699 "rendering at a new width must add new cache entries"
700 );
701 // Flip back to small — should NOT add any new entries (cache hits).
702 v.render(small, &mut buf_s);
703 let after_replay = v.cache.borrow().len();
704 assert_eq!(
705 after_replay, after_both,
706 "replay at old width must hit cache"
707 );
708 }
709
710 #[test]
711 fn backtrack_preview_disables_sticky() {
712 let mut v = LiveTranscriptOverlay::new();
713 assert!(v.is_sticky());
714 v.set_backtrack_preview(0);
715 assert!(!v.is_sticky());
716 assert!(matches!(
717 v.mode(),
718 Mode::BacktrackPreview { selected_idx: 0 }
719 ));
720 }
721
722 #[test]
723 fn set_tail_mode_re_arms_sticky() {
724 let mut v = LiveTranscriptOverlay::new();
725 v.set_backtrack_preview(2);
726 v.set_tail_mode();
727 assert!(v.is_sticky());
728 assert!(matches!(v.mode(), Mode::Tail));
729 }
730
731 #[test]
732 fn backtrack_preview_does_not_panic_with_no_user_cells() {
733 // Render in preview mode against a transcript that has zero User
734 // cells — the highlight scan should miss gracefully.
735 let mut v = LiveTranscriptOverlay::new();
736 install_snapshots(&mut v, vec![assistant("hi", false)]);
737 v.set_backtrack_preview(0);
738 let area = Rect::new(0, 0, 40, 10);
739 let mut buf = Buffer::empty(area);
740 v.render(area, &mut buf);
741 }
742
743 #[test]
744 fn backtrack_preview_highlights_selected_user_cell() {
745 // With 3 user cells (oldest → newest: u0, u1, u2), `selected_idx
746 // = 0` should highlight u2 (newest), `= 1` u1, `= 2` u0. We can
747 // detect the highlight by scanning the rendered buffer for the
748 // marker glyph.
749 let mut v = LiveTranscriptOverlay::new();
750 install_snapshots(
751 &mut v,
752 vec![
753 user("u0"),
754 assistant("a0", false),
755 user("u1"),
756 assistant("a1", false),
757 user("u2"),
758 assistant("a2", false),
759 ],
760 );
761 for sel in [0usize, 1, 2] {
762 v.set_backtrack_preview(sel);
763 // Force Tail re-render between iterations to confirm marker
764 // really moves rather than smearing.
765 let area = Rect::new(0, 0, 40, 24);
766 let mut buf = Buffer::empty(area);
767 v.render(area, &mut buf);
768 // Just verify the cell index resolved without panicking and
769 // the buffer is non-empty. Detailed marker placement is
770 // visual, hence not asserted here.
771 let mut any_content = false;
772 for y in 0..buf.area.height {
773 for x in 0..buf.area.width {
774 if !buf[(x, y)].symbol().is_empty() && buf[(x, y)].symbol() != " " {
775 any_content = true;
776 break;
777 }
778 }
779 if any_content {
780 break;
781 }
782 }
783 assert!(any_content, "preview render must produce visible content");
784 }
785 }
786
787 #[test]
788 fn backtrack_preview_out_of_range_does_not_panic() {
789 // Selecting beyond the user-cell count should simply not
790 // highlight anything — no panic, no marker.
791 let mut v = LiveTranscriptOverlay::new();
792 install_snapshots(&mut v, vec![user("only")]);
793 v.set_backtrack_preview(99);
794 let area = Rect::new(0, 0, 40, 10);
795 let mut buf = Buffer::empty(area);
796 v.render(area, &mut buf);
797 }
798 }
799
799 lines RUST