返回 CodeWhale
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+Shift+T` while the engine is streaming (`Ctrl+T` now
4 //! cycles reasoning effort). Behaviour:
5 //!
6 //! - At-bottom (`sticky_to_bottom = true`) — every refresh re-pins scroll to
7 //! the new tail, so streaming output appears to flow off the bottom edge.
8 //! - Scroll up — `sticky_to_bottom` flips to `false`; subsequent refreshes
9 //! leave scroll position alone so the user can read history without being
10 //! yanked back down.
11 //! - Scroll back to bottom (End / G / paging past the tail) — `sticky` flips
12 //! to `true` again; auto-tail resumes.
13 //! - Esc / `q` — close, returning to the normal view. The engine never
14 //! pauses while the overlay is open; new chunks accumulate in the cells
15 //! exactly as they would on the normal screen.
16 //!
17 //! Cache strategy: the overlay holds its own `TranscriptCache` keyed by
18 //! `(CellId, width, revision)`. Revisions come from the same per-cell
19 //! counters the main transcript already maintains (`App.history_revisions`
20 //! and `App.active_cell_revision`). Resize invalidates the cells whose width
21 //! key just changed; revision bumps invalidate only the cells that mutated;
22 //! cells that didn't change reuse their existing wrap.
23
24 use std::cell::{Cell, RefCell};
25
26 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
27 use ratatui::{
28 buffer::Buffer,
29 layout::Rect,
30 style::{Modifier, Style},
31 text::{Line, Span},
32 widgets::{Block, Borders, Clear, Padding, Paragraph, Widget, Wrap},
33 };
34
35 use crate::palette;
36 use crate::tui::app::App;
37 use crate::tui::backtrack::Direction;
38 use crate::tui::history::{HistoryCell, TranscriptRenderOptions};
39 use crate::tui::transcript_cache::{CachedTranscriptLine, CellId, TranscriptCache};
40 use crate::tui::views::{
41 ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, render_modal_footer,
42 };
43
44 /// Render mode for the overlay. `Tail` is the original sticky-tail
45 /// behaviour (#94). `BacktrackPreview` (#133) highlights the Nth-from-tail
46 /// `HistoryCell::User` so the user can see which turn Esc-Esc-Enter will
47 /// roll back to. The mode also disables sticky-tail (we want the user to
48 /// scan history, not be yanked to live output) and pins scroll near the
49 /// highlighted cell on transitions.
50 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51 pub enum Mode {
52 #[default]
53 Tail,
54 BacktrackPreview {
55 selected_idx: usize,
56 },
57 }
58
59 /// Snapshot of one cell, refreshed every frame from `App`. Owns the cell so
60 /// the overlay's `render(&self)` can wrap without re-borrowing `App`.
61 #[derive(Debug, Clone)]
62 struct CellSnapshot {
63 id: CellId,
64 revision: u64,
65 cell: HistoryCell,
66 }
67
68 struct FlattenedTranscript {
69 lines: Vec<Line<'static>>,
70 line_links: Vec<Vec<crate::tui::osc8::LineLink>>,
71 highlighted_range: Option<(usize, usize)>,
72 }
73
74 pub struct LiveTranscriptOverlay {
75 /// Latest cell snapshots (history + active). Refreshed via
76 /// `refresh_from_app` immediately before each render so streaming
77 /// mutations show up on the next paint.
78 snapshots: Vec<CellSnapshot>,
79 /// Render options sampled from `App` at refresh time so toggles like
80 /// `show_thinking` propagate into the overlay live.
81 options: TranscriptRenderOptions,
82 /// Wrapped-line cache. `RefCell` so `render(&self)` can write through.
83 cache: RefCell<TranscriptCache>,
84 /// Sticky-tail flag: when `true`, refresh re-pins scroll to the bottom.
85 /// Flipped to `false` when the user scrolls up; flipped back to `true`
86 /// when they scroll past the last visible line.
87 sticky_to_bottom: Cell<bool>,
88 /// Current top-of-viewport line offset into the flattened line list.
89 scroll: Cell<usize>,
90 /// Visible content height from the last render. Used by paging keys
91 /// before the next render frame populates a fresh value.
92 last_visible_height: Cell<usize>,
93 /// Last total line count after wrapping; cached so `handle_key` can
94 /// clamp scroll without re-wrapping. Updated by `render`.
95 last_total_lines: Cell<usize>,
96 /// Pending `gg` second keystroke for Vim-style jump-to-top.
97 pending_g: bool,
98 /// Render mode — `Tail` is the live-stream mode; `BacktrackPreview`
99 /// highlights the selected user message (#133).
100 mode: Mode,
101 /// Set when a backtrack selection changes. The next render pins the
102 /// selected cell into view once we know the wrapped line range.
103 preview_pin_pending: Cell<bool>,
104 /// Bumped by `refresh_from_app` whenever any snapshot actually changed.
105 /// Lets the flatten cache below be keyed in O(1) (#3904).
106 snapshots_generation: Cell<u64>,
107 /// Cached flattened line vector. `flatten` used to re-clone every cell's
108 /// cached wrapped lines into a fresh `Vec` on every frame; now it is
109 /// rebuilt only when the snapshots, width, mode, or render options change.
110 flat_cache: RefCell<Option<FlatCache>>,
111 /// How many `HistoryCell` deep clones `refresh_from_app` has performed.
112 /// Diagnostics + the #3904 regression tests.
113 cell_clones: Cell<u64>,
114 /// How many times `flatten` actually rebuilt the flat line vector.
115 flattens: Cell<u64>,
116 }
117
118 /// Key that fully determines the output of `flatten`.
119 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
120 struct FlatKey {
121 generation: u64,
122 width: u16,
123 mode: Mode,
124 options: TranscriptRenderOptions,
125 }
126
127 struct FlatCache {
128 key: FlatKey,
129 flat: FlattenedTranscript,
130 }
131
132 impl LiveTranscriptOverlay {
133 #[must_use]
134 pub fn new() -> Self {
135 Self {
136 snapshots: Vec::new(),
137 options: TranscriptRenderOptions::default(),
138 cache: RefCell::new(TranscriptCache::new()),
139 sticky_to_bottom: Cell::new(true),
140 scroll: Cell::new(0),
141 last_visible_height: Cell::new(0),
142 last_total_lines: Cell::new(0),
143 pending_g: false,
144 mode: Mode::Tail,
145 preview_pin_pending: Cell::new(false),
146 snapshots_generation: Cell::new(0),
147 flat_cache: RefCell::new(None),
148 cell_clones: Cell::new(0),
149 flattens: Cell::new(0),
150 }
151 }
152
153 /// Switch the overlay into backtrack-preview mode. Sticky-tail is
154 /// turned off so the highlighted cell stays in view while the user
155 /// steps through prior turns. The wrap cache stays valid because the
156 /// underlying snapshot data hasn't changed — only the post-wrap
157 /// highlight overlay does.
158 pub fn set_backtrack_preview(&mut self, selected_idx: usize) {
159 self.mode = Mode::BacktrackPreview { selected_idx };
160 self.sticky_to_bottom.set(false);
161 self.preview_pin_pending.set(true);
162 }
163
164 /// Return the overlay to live-tail mode (used when backtrack is
165 /// confirmed or canceled). Re-arms sticky-tail so streaming resumes.
166 #[allow(dead_code)] // exposed for callers that retain an overlay across a backtrack cancel; current UI just pops the view.
167 pub fn set_tail_mode(&mut self) {
168 self.mode = Mode::Tail;
169 self.sticky_to_bottom.set(true);
170 self.preview_pin_pending.set(false);
171 }
172
173 /// For tests + UI: current mode.
174 #[allow(dead_code)] // currently consumed only by tests; kept public for symmetry with `set_*` setters.
175 #[must_use]
176 pub fn mode(&self) -> Mode {
177 self.mode
178 }
179
180 /// Pull the latest cells + revisions from `App` so the next `render` shows
181 /// streaming mutations. Must be called before `view_stack.render` while
182 /// this overlay is on top; otherwise the cells stay frozen at whatever
183 /// state they were in when the overlay was first opened.
184 pub fn refresh_from_app(&mut self, app: &mut App) {
185 app.resync_history_revisions();
186 let mut slot = 0usize;
187 let mut changed = false;
188 for (idx, cell) in app.history.iter().enumerate() {
189 let rev = app.history_revisions.get(idx).copied().unwrap_or(0);
190 changed |= self.store_snapshot(slot, CellId::History(idx), rev, cell);
191 slot += 1;
192 }
193 if let Some(active) = app.active_cell.as_ref() {
194 let active_rev = app.active_cell_revision;
195 for (idx, cell) in active.entries().iter().enumerate() {
196 let salt = (idx as u64).wrapping_add(1);
197 // Share the main transcript's revision derivation instead of
198 // keeping an inline copy of the same mixing constant (#3904).
199 let revision = crate::tui::widgets::active_entry_revision(active_rev, salt);
200 changed |= self.store_snapshot(slot, CellId::Active(idx), revision, cell);
201 slot += 1;
202 }
203 }
204 if self.snapshots.len() != slot {
205 self.snapshots.truncate(slot);
206 changed = true;
207 }
208 let options = app.transcript_render_options();
209 if self.options != options {
210 self.options = options;
211 changed = true;
212 }
213 if changed {
214 self.snapshots_generation
215 .set(self.snapshots_generation.get().wrapping_add(1));
216 }
217 }
218
219 /// Write the snapshot for `slot`, cloning the cell **only** when its
220 /// `(CellId, revision)` differs from what is already stored (#3904).
221 ///
222 /// Returns whether anything changed. The overlay tails a live stream, so
223 /// this runs at streaming cadence; the old code deep-cloned every history
224 /// cell every frame even though revisions were already available.
225 fn store_snapshot(
226 &mut self,
227 slot: usize,
228 id: CellId,
229 revision: u64,
230 cell: &HistoryCell,
231 ) -> bool {
232 match self.snapshots.get_mut(slot) {
233 Some(existing) if existing.id == id && existing.revision == revision => false,
234 Some(existing) => {
235 existing.id = id;
236 existing.revision = revision;
237 existing.cell = cell.clone();
238 self.cell_clones.set(self.cell_clones.get() + 1);
239 true
240 }
241 None => {
242 self.snapshots.push(CellSnapshot {
243 id,
244 revision,
245 cell: cell.clone(),
246 });
247 self.cell_clones.set(self.cell_clones.get() + 1);
248 true
249 }
250 }
251 }
252
253 /// Wrap each cell (using the cache) and return the flat line vector.
254 /// In `BacktrackPreview` mode the lines belonging to the selected
255 /// `HistoryCell::User` are decorated with a leading `▶` marker on the
256 /// first line and reverse-video styling on every line so the eye
257 /// snaps to them at a glance. The decoration is applied *after* the
258 /// cache lookup so toggling preview mode never invalidates wraps.
259 /// Cached wrapper around [`Self::flatten`] (#3904).
260 ///
261 /// The overlay renders at streaming cadence while the main transcript
262 /// renders underneath in the same frame, so re-flattening the whole
263 /// transcript per frame roughly doubled per-frame allocations. The flat
264 /// vector only depends on the snapshots, the width, the mode, and the
265 /// render options — all of which are in `FlatKey`.
266 fn flattened(&self, width: u16) -> std::cell::Ref<'_, FlattenedTranscript> {
267 let key = FlatKey {
268 generation: self.snapshots_generation.get(),
269 width: width.max(1),
270 mode: self.mode,
271 options: self.options,
272 };
273 {
274 let mut cache = self.flat_cache.borrow_mut();
275 let stale = cache.as_ref().is_none_or(|cached| cached.key != key);
276 if stale {
277 let flat = self.flatten(key.width);
278 self.flattens.set(self.flattens.get() + 1);
279 *cache = Some(FlatCache { key, flat });
280 }
281 }
282 std::cell::Ref::map(self.flat_cache.borrow(), |cache| {
283 &cache.as_ref().expect("flat cache populated above").flat
284 })
285 }
286
287 fn flatten(&self, width: u16) -> FlattenedTranscript {
288 let width = width.max(1);
289 let mut out: Vec<Line<'static>> = Vec::new();
290 let mut out_links: Vec<Vec<crate::tui::osc8::LineLink>> = Vec::new();
291 let mut highlighted_range = None;
292
293 // Pre-compute which cell index (in `self.snapshots`) is the one
294 // the user has selected via Esc-Esc. We walk snapshots backwards
295 // counting User cells; the snapshot index whose count matches
296 // `selected_idx + 1` is the highlighted one.
297 let highlighted_cell_idx: Option<usize> = match self.mode {
298 Mode::BacktrackPreview { selected_idx } => {
299 let mut count = 0usize;
300 let mut hit = None;
301 for (idx, snap) in self.snapshots.iter().enumerate().rev() {
302 if matches!(snap.cell, HistoryCell::User { .. }) {
303 if count == selected_idx {
304 hit = Some(idx);
305 break;
306 }
307 count += 1;
308 }
309 }
310 hit
311 }
312 Mode::Tail => None,
313 };
314
315 let mut cache = self.cache.borrow_mut();
316 for (cell_idx, snap) in self.snapshots.iter().enumerate() {
317 let rendered: Vec<CachedTranscriptLine> = match cache.get(snap.id, width, snap.revision)
318 {
319 Some(cached) => cached.to_vec(),
320 None => {
321 let rendered = snap
322 .cell
323 .lines_with_copy_metadata(width, self.options)
324 .into_iter()
325 .map(|rendered| CachedTranscriptLine {
326 line: rendered.line,
327 links: rendered.links,
328 })
329 .collect::<Vec<_>>();
330 cache.insert(snap.id, width, snap.revision, rendered.clone());
331 rendered
332 }
333 };
334 let mut lines = rendered
335 .iter()
336 .map(|rendered| rendered.line.clone())
337 .collect::<Vec<_>>();
338 let mut line_links = rendered
339 .into_iter()
340 .map(|rendered| rendered.links)
341 .collect::<Vec<_>>();
342
343 if Some(cell_idx) == highlighted_cell_idx {
344 let start = out.len();
345 lines = decorate_highlight(lines);
346 if let Some(first_links) = line_links.first_mut() {
347 *first_links = first_links.iter().map(|link| link.shifted(2)).collect();
348 }
349 out.extend(lines);
350 out_links.extend(line_links);
351 let end = out.len();
352 if end > start {
353 highlighted_range = Some((start, end));
354 }
355 } else {
356 out.extend(lines);
357 out_links.extend(line_links);
358 }
359 }
360 FlattenedTranscript {
361 lines: out,
362 line_links: out_links,
363 highlighted_range,
364 }
365 }
366
367 fn page_height(&self) -> usize {
368 let cached = self.last_visible_height.get();
369 if cached == 0 { 10 } else { cached }
370 }
371
372 fn half_page_height(&self) -> usize {
373 self.page_height().div_ceil(2).max(1)
374 }
375
376 fn max_scroll(&self) -> usize {
377 let total = self.last_total_lines.get();
378 let visible = self.page_height();
379 total.saturating_sub(visible)
380 }
381
382 fn scroll_up(&mut self, amount: usize) {
383 self.scroll.set(self.scroll.get().saturating_sub(amount));
384 // Any upward motion exits sticky-tail; explicit user intent.
385 self.sticky_to_bottom.set(false);
386 self.preview_pin_pending.set(false);
387 }
388
389 fn scroll_down(&mut self, amount: usize) {
390 let max = self.max_scroll();
391 let scroll = self.scroll.get().saturating_add(amount).min(max);
392 self.scroll.set(scroll);
393 self.preview_pin_pending.set(false);
394 if scroll >= max && matches!(self.mode, Mode::Tail) {
395 self.sticky_to_bottom.set(true);
396 }
397 }
398
399 fn jump_to_top(&mut self) {
400 self.scroll.set(0);
401 self.sticky_to_bottom.set(false);
402 self.preview_pin_pending.set(false);
403 }
404
405 fn jump_to_bottom(&mut self) {
406 self.scroll.set(self.max_scroll());
407 self.sticky_to_bottom.set(matches!(self.mode, Mode::Tail));
408 self.preview_pin_pending.set(false);
409 }
410
411 /// For tests: snapshot count.
412 #[cfg(test)]
413 fn snapshot_count(&self) -> usize {
414 self.snapshots.len()
415 }
416
417 /// For tests: whether sticky-tail is currently armed.
418 #[cfg(test)]
419 pub fn is_sticky(&self) -> bool {
420 self.sticky_to_bottom.get()
421 }
422
423 /// For tests: current scroll offset.
424 #[cfg(test)]
425 pub fn scroll_offset(&self) -> usize {
426 self.scroll.get()
427 }
428 }
429
430 impl Default for LiveTranscriptOverlay {
431 fn default() -> Self {
432 Self::new()
433 }
434 }
435
436 /// Apply a backtrack-preview highlight to the lines belonging to a single
437 /// `HistoryCell::User`. The first line gets a `▶ ` prefix in accent color
438 /// (so the marker remains visible even on terminals where reverse-video
439 /// is washed out); every line in the cell gets `Modifier::REVERSED` so
440 /// the cell visually pops out of the surrounding transcript. Internal
441 /// span structure is preserved so syntax/role coloring underneath the
442 /// reverse stays readable.
443 fn decorate_highlight(mut lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
444 if lines.is_empty() {
445 return lines;
446 }
447 for line in &mut lines {
448 for span in &mut line.spans {
449 span.style = span.style.add_modifier(Modifier::REVERSED);
450 }
451 }
452 let marker = Span::styled(
453 "\u{25B6} ",
454 Style::default()
455 .fg(palette::TEXT_ACCENT)
456 .add_modifier(Modifier::BOLD),
457 );
458 if let Some(first) = lines.first_mut() {
459 first.spans.insert(0, marker);
460 }
461 lines
462 }
463
464 fn scroll_to_show_range(
465 current: usize,
466 start: usize,
467 end: usize,
468 visible_height: usize,
469 max_scroll: usize,
470 ) -> usize {
471 if visible_height == 0 {
472 return 0;
473 }
474 let end = end.max(start.saturating_add(1));
475 if start < current {
476 start.min(max_scroll)
477 } else if end > current.saturating_add(visible_height) {
478 end.saturating_sub(visible_height).min(max_scroll)
479 } else {
480 current.min(max_scroll)
481 }
482 }
483
484 impl ModalView for LiveTranscriptOverlay {
485 fn kind(&self) -> ModalKind {
486 ModalKind::LiveTranscript
487 }
488
489 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
490 self
491 }
492
493 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
494 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
495 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
496
497 // Backtrack-preview mode (#133) intercepts Left/Right/Enter/Esc
498 // before the normal scroll handlers so the user can step through
499 // prior user messages without their input being interpreted as
500 // pager navigation. Other keys (page up/down, gg/G, etc.) still
501 // fall through so the user can scroll the transcript while
502 // previewing.
503 if matches!(self.mode, Mode::BacktrackPreview { .. }) {
504 match key.code {
505 KeyCode::Left | KeyCode::Char('h') if !ctrl => {
506 return ViewAction::Emit(ViewEvent::BacktrackStep {
507 direction: Direction::Left,
508 });
509 }
510 KeyCode::Right | KeyCode::Char('l') if !ctrl => {
511 return ViewAction::Emit(ViewEvent::BacktrackStep {
512 direction: Direction::Right,
513 });
514 }
515 KeyCode::Enter => {
516 return ViewAction::EmitAndClose(ViewEvent::BacktrackConfirm);
517 }
518 KeyCode::Esc | KeyCode::Char('q') => {
519 return ViewAction::EmitAndClose(ViewEvent::BacktrackCancel);
520 }
521 _ => {}
522 }
523 }
524
525 if ctrl {
526 match key.code {
527 KeyCode::Char('d') | KeyCode::Char('D') => {
528 self.scroll_down(self.half_page_height());
529 self.pending_g = false;
530 return ViewAction::None;
531 }
532 KeyCode::Char('u') | KeyCode::Char('U') => {
533 self.scroll_up(self.half_page_height());
534 self.pending_g = false;
535 return ViewAction::None;
536 }
537 KeyCode::Char('f') | KeyCode::Char('F') => {
538 self.scroll_down(self.page_height());
539 self.pending_g = false;
540 return ViewAction::None;
541 }
542 KeyCode::Char('b') | KeyCode::Char('B') => {
543 self.scroll_up(self.page_height());
544 self.pending_g = false;
545 return ViewAction::None;
546 }
547 // Ctrl+Shift+T toggles the overlay closed when already open.
548 KeyCode::Char('t') | KeyCode::Char('T')
549 if key.modifiers.contains(KeyModifiers::CONTROL)
550 && key.modifiers.contains(KeyModifiers::SHIFT) =>
551 {
552 return ViewAction::Close;
553 }
554 _ => {}
555 }
556 }
557
558 match key.code {
559 KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
560 KeyCode::Up | KeyCode::Char('k') => {
561 self.scroll_up(1);
562 self.pending_g = false;
563 ViewAction::None
564 }
565 KeyCode::Down | KeyCode::Char('j') => {
566 self.scroll_down(1);
567 self.pending_g = false;
568 ViewAction::None
569 }
570 KeyCode::PageUp => {
571 self.scroll_up(self.page_height());
572 self.pending_g = false;
573 ViewAction::None
574 }
575 KeyCode::PageDown => {
576 self.scroll_down(self.page_height());
577 self.pending_g = false;
578 ViewAction::None
579 }
580 KeyCode::Char(' ') if shift => {
581 self.scroll_up(self.page_height());
582 self.pending_g = false;
583 ViewAction::None
584 }
585 KeyCode::Char(' ') => {
586 self.scroll_down(self.page_height());
587 self.pending_g = false;
588 ViewAction::None
589 }
590 KeyCode::Home => {
591 self.jump_to_top();
592 self.pending_g = false;
593 ViewAction::None
594 }
595 KeyCode::End => {
596 self.jump_to_bottom();
597 self.pending_g = false;
598 ViewAction::None
599 }
600 KeyCode::Char('g') => {
601 if self.pending_g {
602 self.jump_to_top();
603 self.pending_g = false;
604 } else {
605 self.pending_g = true;
606 }
607 ViewAction::None
608 }
609 KeyCode::Char('G') => {
610 self.jump_to_bottom();
611 self.pending_g = false;
612 ViewAction::None
613 }
614 _ => ViewAction::None,
615 }
616 }
617
618 fn render(&self, area: Rect, buf: &mut Buffer) {
619 let popup_width = area.width.saturating_sub(2).max(1);
620 let popup_height = area.height.saturating_sub(2).max(1);
621 let popup_area = Rect {
622 x: 1,
623 y: 1,
624 width: popup_width,
625 height: popup_height,
626 };
627
628 Clear.render(popup_area, buf);
629
630 let title: String = match self.mode {
631 Mode::BacktrackPreview { selected_idx } => format!(
632 " Backtrack preview — turn {} (\u{2190}/\u{2192} step, Enter rewind, Esc cancel) ",
633 selected_idx + 1
634 ),
635 Mode::Tail => {
636 if self.sticky_to_bottom.get() {
637 " Live transcript (tailing) ".to_string()
638 } else {
639 " Live transcript (paused) ".to_string()
640 }
641 }
642 };
643
644 let block = Block::default()
645 .title(title)
646 .borders(Borders::ALL)
647 .border_style(Style::default().fg(palette::BORDER_COLOR))
648 .style(Style::default().bg(palette::WHALE_BG))
649 .padding(Padding::uniform(1));
650 let inner = block.inner(popup_area);
651 block.render(popup_area, buf);
652
653 // Wrapping action footer along the bottom of the inner area; the body
654 // fills the rows above it.
655 let content = render_modal_footer(
656 inner,
657 buf,
658 &[
659 ActionHint::new("j/k", "scroll"),
660 ActionHint::new("Space/C-b", "page"),
661 ActionHint::new("g/G", "top/bottom"),
662 ActionHint::new("End", "resume tail"),
663 ActionHint::new("q/Esc", "close"),
664 ],
665 );
666
667 // `content` already excludes the border, padding, and footer rows.
668 let visible_height = content.height as usize;
669 self.last_visible_height.set(visible_height);
670
671 // Wrap content using the per-cell cache at the body width.
672 let content_width = content.width;
673 let flat = self.flattened(content_width);
674 let FlattenedTranscript {
675 lines,
676 line_links,
677 highlighted_range,
678 } = &*flat;
679 let highlighted_range = *highlighted_range;
680 self.last_total_lines.set(lines.len());
681
682 let max_scroll = lines.len().saturating_sub(visible_height);
683 // Sticky-tail: every render re-pins scroll to the bottom unless the
684 // user has explicitly scrolled away. Without this, streaming new
685 // content would push the visible window backwards as `scroll` stays
686 // fixed against a growing total.
687 let scroll = if self.sticky_to_bottom.get() {
688 self.scroll.set(max_scroll);
689 max_scroll
690 } else if self.preview_pin_pending.replace(false) {
691 let next = highlighted_range
692 .map(|(start, end)| {
693 scroll_to_show_range(self.scroll.get(), start, end, visible_height, max_scroll)
694 })
695 .unwrap_or_else(|| self.scroll.get().min(max_scroll));
696 self.scroll.set(next);
697 next
698 } else {
699 let next = self.scroll.get().min(max_scroll);
700 self.scroll.set(next);
701 next
702 };
703 let end = (scroll + visible_height).min(lines.len());
704 let visible_lines: Vec<Line<'static>> = if lines.is_empty() {
705 vec![Line::from(Span::styled(
706 "(no transcript yet)",
707 Style::default().fg(palette::TEXT_DIM),
708 ))]
709 } else {
710 lines[scroll..end].to_vec()
711 };
712 let visible_line_links = if lines.is_empty() {
713 vec![Vec::new()]
714 } else {
715 line_links[scroll..end].to_vec()
716 };
717
718 let paragraph = Paragraph::new(visible_lines).wrap(Wrap { trim: false });
719 paragraph.render(content, buf);
720
721 // Targets stay beside the visible lines, so the popup never writes an
722 // escape payload into the buffer. Replace the opaque popup's portion
723 // of the frame map so links in the obscured transcript cannot leak
724 // through unrelated modal text.
725 let regions = crate::tui::osc8::link_regions_for_lines(content, &visible_line_links);
726 crate::tui::osc8::overlay_frame_links(popup_area, regions);
727 }
728 }
729
730 #[cfg(test)]
731 mod tests {
732 use super::*;
733 use crate::tui::history::HistoryCell;
734
735 fn user(s: &str) -> HistoryCell {
736 HistoryCell::User {
737 content: s.to_string(),
738 }
739 }
740
741 fn assistant(s: &str, streaming: bool) -> HistoryCell {
742 HistoryCell::Assistant {
743 content: s.to_string(),
744 streaming,
745 }
746 }
747
748 /// Force a render so `last_visible_height` and `last_total_lines` are
749 /// populated; otherwise paging keys use the constant fallback.
750 fn prime_layout(view: &mut LiveTranscriptOverlay, height: u16) {
751 let area = Rect::new(0, 0, 60, height);
752 let mut buf = Buffer::empty(area);
753 view.render(area, &mut buf);
754 }
755
756 fn install_snapshots(view: &mut LiveTranscriptOverlay, cells: Vec<HistoryCell>) {
757 view.snapshots = cells
758 .into_iter()
759 .enumerate()
760 .map(|(idx, cell)| CellSnapshot {
761 id: CellId::History(idx),
762 revision: 1,
763 cell,
764 })
765 .collect();
766 view.snapshots_generation
767 .set(view.snapshots_generation.get().wrapping_add(1));
768 }
769
770 fn mark_snapshots_changed(view: &LiveTranscriptOverlay) {
771 view.snapshots_generation
772 .set(view.snapshots_generation.get().wrapping_add(1));
773 }
774
775 fn buffer_text(buf: &Buffer) -> String {
776 let mut out = String::new();
777 for y in 0..buf.area.height {
778 for x in 0..buf.area.width {
779 out.push_str(buf[(x, y)].symbol());
780 }
781 out.push('\n');
782 }
783 out
784 }
785
786 #[test]
787 fn new_overlay_starts_sticky() {
788 let v = LiveTranscriptOverlay::new();
789 assert!(v.is_sticky());
790 assert_eq!(v.scroll_offset(), 0);
791 assert_eq!(v.snapshot_count(), 0);
792 }
793
794 #[test]
795 fn overlay_publishes_scrolled_url_metadata_without_escape_cells() {
796 let target = "https://example.test/a/very/long/path/that/wraps/in/the/live/view";
797 let mut view = LiveTranscriptOverlay::new();
798 let mut cells = (0..12)
799 .map(|index| user(&format!("older row {index}")))
800 .collect::<Vec<_>>();
801 cells.push(assistant(target, false));
802 install_snapshots(&mut view, cells);
803
804 let area = Rect::new(0, 0, 32, 16);
805 let mut buf = Buffer::empty(area);
806 let _ = crate::tui::osc8::take_frame_links();
807 view.render(area, &mut buf);
808 let regions = crate::tui::osc8::take_frame_links();
809
810 assert!(view.scroll_offset() > 0, "fixture must render a tail slice");
811 assert!(regions.len() > 1, "narrow overlay should wrap: {regions:?}");
812 assert!(regions.iter().all(|region| region.target == target));
813 assert!(regions.iter().all(|region| {
814 area.contains(ratatui::layout::Position {
815 x: region.col_start,
816 y: region.row,
817 }) && area.contains(ratatui::layout::Position {
818 x: region.col_end,
819 y: region.row,
820 })
821 }));
822 assert!((area.y..area.bottom()).all(|y| {
823 (area.x..area.right()).all(|x| {
824 let symbol = buf[(x, y)].symbol();
825 !symbol.contains('\x1b') && !symbol.contains("]8;;")
826 })
827 }));
828 }
829
830 #[test]
831 fn scroll_up_breaks_sticky() {
832 let mut v = LiveTranscriptOverlay::new();
833 install_snapshots(
834 &mut v,
835 (0..50).map(|i| user(&format!("line {i}"))).collect(),
836 );
837 prime_layout(&mut v, 10);
838 // Force scroll non-zero so scroll_up actually moves.
839 v.scroll.set(5);
840 v.sticky_to_bottom.set(true);
841 let _ = v.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
842 assert!(!v.is_sticky(), "scrolling up must release the sticky tail");
843 }
844
845 #[test]
846 fn end_resumes_sticky_tail() {
847 let mut v = LiveTranscriptOverlay::new();
848 install_snapshots(
849 &mut v,
850 (0..50).map(|i| user(&format!("line {i}"))).collect(),
851 );
852 prime_layout(&mut v, 10);
853 // Drop out of sticky mode by scrolling up.
854 v.scroll.set(10);
855 v.sticky_to_bottom.set(false);
856 let _ = v.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
857 assert!(
858 v.is_sticky(),
859 "End must re-arm the sticky tail so streaming continues to follow"
860 );
861 }
862
863 #[test]
864 fn scrolling_to_max_re_arms_sticky() {
865 let mut v = LiveTranscriptOverlay::new();
866 install_snapshots(
867 &mut v,
868 (0..50).map(|i| user(&format!("line {i}"))).collect(),
869 );
870 prime_layout(&mut v, 10);
871 v.sticky_to_bottom.set(false);
872 // PageDown once should not re-arm since we're not yet at the tail.
873 let _ = v.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE));
874 // Now jump explicitly to bottom and verify re-arm.
875 v.scroll.set(0);
876 v.sticky_to_bottom.set(false);
877 let _ = v.handle_key(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE));
878 assert!(v.is_sticky());
879 }
880
881 #[test]
882 fn esc_closes() {
883 let mut v = LiveTranscriptOverlay::new();
884 let action = v.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
885 assert!(matches!(action, ViewAction::Close));
886 }
887
888 #[test]
889 fn ctrl_shift_t_closes_when_already_open() {
890 // The overlay toggle moved to Ctrl+Shift+T (Wave 7 M3: plain Ctrl+T
891 // now cycles reasoning effort).
892 let mut v = LiveTranscriptOverlay::new();
893 let action = v.handle_key(KeyEvent::new(
894 KeyCode::Char('t'),
895 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
896 ));
897 assert!(matches!(action, ViewAction::Close));
898 }
899
900 #[test]
901 fn render_does_not_panic_on_empty() {
902 let v = LiveTranscriptOverlay::new();
903 let area = Rect::new(0, 0, 40, 12);
904 let mut buf = Buffer::empty(area);
905 v.render(area, &mut buf);
906 }
907
908 #[test]
909 fn cache_reuses_unchanged_cells_across_renders() {
910 // Same revisions across two renders should reuse cache entries; only
911 // a "modified" cell (different revision) forces a new wrap. Verify by
912 // counting cache size — it grows by 1 per unique (cell, width, rev).
913 let mut v = LiveTranscriptOverlay::new();
914 install_snapshots(&mut v, vec![user("a"), user("b"), assistant("c", false)]);
915 let area = Rect::new(0, 0, 60, 16);
916 let mut buf = Buffer::empty(area);
917 v.render(area, &mut buf);
918 let after_first = v.cache.borrow().len();
919 v.render(area, &mut buf);
920 let after_second = v.cache.borrow().len();
921 assert_eq!(
922 after_first, after_second,
923 "second render should reuse every cell — no new cache entries"
924 );
925 }
926
927 #[test]
928 fn streaming_render_stays_within_per_frame_cell_diff_budget() {
929 let mut view = LiveTranscriptOverlay::new();
930 let mut cells = (0..30)
931 .map(|index| user(&format!("stable transcript row {index}")))
932 .collect::<Vec<_>>();
933 cells.push(assistant("streaming answer", true));
934 install_snapshots(&mut view, cells);
935
936 let area = Rect::new(0, 0, 89, 24);
937 let mut before = Buffer::empty(area);
938 view.render(area, &mut before);
939
940 let tail = view.snapshots.last_mut().expect("streaming tail");
941 let HistoryCell::Assistant { content, .. } = &mut tail.cell else {
942 panic!("fixture tail must be an assistant cell");
943 };
944 content.push_str(" + one delta");
945 tail.revision = tail.revision.saturating_add(1);
946 mark_snapshots_changed(&view);
947
948 let mut after = Buffer::empty(area);
949 view.render(area, &mut after);
950 let changed_cells = before
951 .content()
952 .iter()
953 .zip(after.content())
954 .filter(|(left, right)| left != right)
955 .count();
956
957 // A short stream delta may change its text row and a wrap boundary,
958 // but it must not invalidate the viewport. Four rows is a generous
959 // ceiling that still catches a full-frame repaint regression.
960 let max_changed_cells = area.width as usize * 4;
961 assert!(changed_cells > 0, "stream delta did not reach the frame");
962 assert!(
963 changed_cells <= max_changed_cells,
964 "stream delta changed {changed_cells} cells; budget is {max_changed_cells}"
965 );
966 }
967
968 #[test]
969 fn cache_invalidates_on_revision_bump() {
970 let mut v = LiveTranscriptOverlay::new();
971 install_snapshots(&mut v, vec![user("a"), assistant("b", true)]);
972 let area = Rect::new(0, 0, 60, 16);
973 let mut buf = Buffer::empty(area);
974 v.render(area, &mut buf);
975 let before = v.cache.borrow().len();
976 // Bump the streaming assistant's revision (simulating a delta) and
977 // re-render. We expect the cache to grow by one new entry — the new
978 // (cell, width, new_rev) — while the user cell entry is reused.
979 v.snapshots[1].revision = 2;
980 mark_snapshots_changed(&v);
981 v.render(area, &mut buf);
982 let after = v.cache.borrow().len();
983 assert!(
984 after > before,
985 "bumping a revision must add a new cache entry"
986 );
987 }
988
989 #[test]
990 fn resize_does_not_evict_unchanged_width_entries() {
991 // Render at width=60, then again at width=80. Both wraps must
992 // co-exist in the cache so flipping back to width=60 hits cache.
993 let mut v = LiveTranscriptOverlay::new();
994 install_snapshots(&mut v, vec![user("a"), user("b")]);
995 let small = Rect::new(0, 0, 60, 16);
996 let large = Rect::new(0, 0, 80, 16);
997 let mut buf_s = Buffer::empty(small);
998 let mut buf_l = Buffer::empty(large);
999 v.render(small, &mut buf_s);
1000 let after_small = v.cache.borrow().len();
1001 v.render(large, &mut buf_l);
1002 let after_both = v.cache.borrow().len();
1003 assert!(
1004 after_both > after_small,
1005 "rendering at a new width must add new cache entries"
1006 );
1007 // Flip back to small — should NOT add any new entries (cache hits).
1008 v.render(small, &mut buf_s);
1009 let after_replay = v.cache.borrow().len();
1010 assert_eq!(
1011 after_replay, after_both,
1012 "replay at old width must hit cache"
1013 );
1014 }
1015
1016 #[test]
1017 fn backtrack_preview_disables_sticky() {
1018 let mut v = LiveTranscriptOverlay::new();
1019 assert!(v.is_sticky());
1020 v.set_backtrack_preview(0);
1021 assert!(!v.is_sticky());
1022 assert!(matches!(
1023 v.mode(),
1024 Mode::BacktrackPreview { selected_idx: 0 }
1025 ));
1026 }
1027
1028 #[test]
1029 fn set_tail_mode_re_arms_sticky() {
1030 let mut v = LiveTranscriptOverlay::new();
1031 v.set_backtrack_preview(2);
1032 v.set_tail_mode();
1033 assert!(v.is_sticky());
1034 assert!(matches!(v.mode(), Mode::Tail));
1035 }
1036
1037 #[test]
1038 fn backtrack_preview_does_not_panic_with_no_user_cells() {
1039 // Render in preview mode against a transcript that has zero User
1040 // cells — the highlight scan should miss gracefully.
1041 let mut v = LiveTranscriptOverlay::new();
1042 install_snapshots(&mut v, vec![assistant("hi", false)]);
1043 v.set_backtrack_preview(0);
1044 let area = Rect::new(0, 0, 40, 10);
1045 let mut buf = Buffer::empty(area);
1046 v.render(area, &mut buf);
1047 }
1048
1049 #[test]
1050 fn backtrack_preview_highlights_selected_user_cell() {
1051 // With 3 user cells (oldest → newest: u0, u1, u2), `selected_idx
1052 // = 0` should highlight u2 (newest), `= 1` u1, `= 2` u0. We can
1053 // detect the highlight by scanning the rendered buffer for the
1054 // marker glyph.
1055 let mut v = LiveTranscriptOverlay::new();
1056 install_snapshots(
1057 &mut v,
1058 vec![
1059 user("u0"),
1060 assistant("a0", false),
1061 user("u1"),
1062 assistant("a1", false),
1063 user("u2"),
1064 assistant("a2", false),
1065 ],
1066 );
1067 for sel in [0usize, 1, 2] {
1068 v.set_backtrack_preview(sel);
1069 // Force Tail re-render between iterations to confirm marker
1070 // really moves rather than smearing.
1071 let area = Rect::new(0, 0, 40, 24);
1072 let mut buf = Buffer::empty(area);
1073 v.render(area, &mut buf);
1074 // Just verify the cell index resolved without panicking and
1075 // the buffer is non-empty. Detailed marker placement is
1076 // visual, hence not asserted here.
1077 let mut any_content = false;
1078 for y in 0..buf.area.height {
1079 for x in 0..buf.area.width {
1080 if !buf[(x, y)].symbol().is_empty() && buf[(x, y)].symbol() != " " {
1081 any_content = true;
1082 break;
1083 }
1084 }
1085 if any_content {
1086 break;
1087 }
1088 }
1089 assert!(any_content, "preview render must produce visible content");
1090 }
1091 }
1092
1093 #[test]
1094 fn backtrack_preview_opens_near_latest_user_not_transcript_start() {
1095 let mut v = LiveTranscriptOverlay::new();
1096 let mut cells = Vec::new();
1097 for i in 0..12 {
1098 cells.push(user(&format!("user {i}")));
1099 cells.push(assistant(&format!("assistant {i}"), false));
1100 }
1101 install_snapshots(&mut v, cells);
1102
1103 v.set_backtrack_preview(0);
1104 let area = Rect::new(0, 0, 48, 10);
1105 let mut buf = Buffer::empty(area);
1106 v.render(area, &mut buf);
1107 let rendered = buffer_text(&buf);
1108
1109 assert!(
1110 v.scroll_offset() > 0,
1111 "preview should pin near the selected recent turn, got top offset 0"
1112 );
1113 assert!(
1114 rendered.contains("user 11"),
1115 "latest user turn should be visible after opening preview: {rendered}"
1116 );
1117 assert!(
1118 !rendered.contains("user 0"),
1119 "preview must not open at the oldest transcript line: {rendered}"
1120 );
1121 }
1122
1123 #[test]
1124 fn live_transcript_is_usable_and_opaque_at_blocker_sizes() {
1125 use crate::tui::views::ViewStack;
1126 use unicode_width::UnicodeWidthStr;
1127
1128 const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
1129 for (w, h) in BLOCKER_SIZES {
1130 // Construct an empty overlay: transcript cells paint their own
1131 // backgrounds, so an empty body keeps the interior as the modal ink
1132 // and lets us assert opacity at the center cell directly.
1133 let overlay = LiveTranscriptOverlay::new();
1134
1135 let area = Rect::new(0, 0, w, h);
1136 let mut buf = Buffer::empty(area);
1137 for y in 0..h {
1138 for x in 0..w {
1139 buf[(x, y)].set_symbol("X");
1140 }
1141 }
1142 let mut stack = ViewStack::new();
1143 stack.push(overlay);
1144 stack.render(area, &mut buf);
1145
1146 let rows: Vec<String> = (0..h)
1147 .map(|y| (0..w).map(|x| buf[(x, y)].symbol().to_string()).collect())
1148 .collect();
1149 let text = rows.join("\n");
1150
1151 // Footer keeps every action.
1152 for label in ["scroll", "page", "top/bottom", "resume tail", "close"] {
1153 assert!(text.contains(label), "{w}x{h}: footer missing '{label}'");
1154 }
1155
1156 // Composited frame is fully opaque.
1157 assert!(!text.contains('X'), "{w}x{h}: background bleed-through");
1158 assert_eq!(
1159 buf[(w / 2, h / 2)].bg,
1160 palette::WHALE_BG,
1161 "{w}x{h}: modal interior must be opaque"
1162 );
1163
1164 // No horizontal overflow.
1165 for (y, row) in rows.iter().enumerate() {
1166 assert!(
1167 UnicodeWidthStr::width(row.trim_end()) <= w as usize,
1168 "{w}x{h}: row {y} overflows width: {row:?}"
1169 );
1170 }
1171 }
1172 }
1173
1174 #[test]
1175 fn backtrack_preview_out_of_range_does_not_panic() {
1176 // Selecting beyond the user-cell count should simply not
1177 // highlight anything — no panic, no marker.
1178 let mut v = LiveTranscriptOverlay::new();
1179 install_snapshots(&mut v, vec![user("only")]);
1180 v.set_backtrack_preview(99);
1181 let area = Rect::new(0, 0, 40, 10);
1182 let mut buf = Buffer::empty(area);
1183 v.render(area, &mut buf);
1184 }
1185 }
1186
1186 lines RUST