返回 CodeWhale
transcript_legacy.rs
根目录 / crates / tui / src / tui / live_transcript / transcript_legacy.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::tui::app::App;
36 use crate::tui::backtrack::Direction;
37 use crate::tui::history::{HistoryCell, TranscriptRenderOptions};
38 use crate::tui::transcript_cache::{CachedTranscriptLine, CellId, TranscriptCache};
39 use crate::tui::views::{
40 ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, render_modal_footer,
41 };
42 use codewhale_palette as palette;
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 pub fn set_tail_mode(&mut self) {
167 self.mode = Mode::Tail;
168 self.sticky_to_bottom.set(true);
169 self.preview_pin_pending.set(false);
170 }
171
172 /// For tests + UI: current mode.
173 #[must_use]
174 pub fn mode(&self) -> Mode {
175 self.mode
176 }
177
178 /// Pull the latest cells + revisions from `App` so the next `render` shows
179 /// streaming mutations. Must be called before `view_stack.render` while
180 /// this overlay is on top; otherwise the cells stay frozen at whatever
181 /// state they were in when the overlay was first opened.
182 pub fn refresh_from_app(&mut self, app: &mut App) {
183 app.resync_history_revisions();
184 let mut slot = 0usize;
185 let mut changed = false;
186 for (idx, cell) in app.history.iter().enumerate() {
187 let rev = app.history_revisions.get(idx).copied().unwrap_or(0);
188 changed |= self.store_snapshot(slot, CellId::History(idx), rev, cell);
189 slot += 1;
190 }
191 if let Some(active) = app.active_cell.as_ref() {
192 let active_rev = app.active_cell_revision;
193 for (idx, cell) in active.entries().iter().enumerate() {
194 let salt = (idx as u64).wrapping_add(1);
195 // Share the main transcript's revision derivation instead of
196 // keeping an inline copy of the same mixing constant (#3904).
197 let revision = crate::tui::widgets::active_entry_revision(active_rev, salt);
198 changed |= self.store_snapshot(slot, CellId::Active(idx), revision, cell);
199 slot += 1;
200 }
201 }
202 if self.snapshots.len() != slot {
203 self.snapshots.truncate(slot);
204 changed = true;
205 }
206 let options = app.transcript_render_options();
207 if self.options != options {
208 self.options = options;
209 // Per-cell wraps are keyed by (cell, width, revision) only, so a
210 // theme, locale or thinking toggle would otherwise reuse lines
211 // rendered under the old options (U04-m1).
212 self.cache.get_mut().clear();
213 changed = true;
214 }
215 if changed {
216 self.snapshots_generation
217 .set(self.snapshots_generation.get().wrapping_add(1));
218 }
219 }
220
221 /// Write the snapshot for `slot`, cloning the cell **only** when its
222 /// `(CellId, revision)` differs from what is already stored (#3904).
223 ///
224 /// Returns whether anything changed. The overlay tails a live stream, so
225 /// this runs at streaming cadence; the old code deep-cloned every history
226 /// cell every frame even though revisions were already available.
227 fn store_snapshot(
228 &mut self,
229 slot: usize,
230 id: CellId,
231 revision: u64,
232 cell: &HistoryCell,
233 ) -> bool {
234 match self.snapshots.get_mut(slot) {
235 Some(existing) if existing.id == id && existing.revision == revision => false,
236 Some(existing) => {
237 existing.id = id;
238 existing.revision = revision;
239 existing.cell = cell.clone();
240 self.cell_clones.set(self.cell_clones.get() + 1);
241 true
242 }
243 None => {
244 self.snapshots.push(CellSnapshot {
245 id,
246 revision,
247 cell: cell.clone(),
248 });
249 self.cell_clones.set(self.cell_clones.get() + 1);
250 true
251 }
252 }
253 }
254
255 /// Wrap each cell (using the cache) and return the flat line vector.
256 /// In `BacktrackPreview` mode the lines belonging to the selected
257 /// `HistoryCell::User` are decorated with a leading `▶` marker on the
258 /// first line and reverse-video styling on every line so the eye
259 /// snaps to them at a glance. The decoration is applied *after* the
260 /// cache lookup so toggling preview mode never invalidates wraps.
261 /// Cached wrapper around [`Self::flatten`] (#3904).
262 ///
263 /// The overlay renders at streaming cadence while the main transcript
264 /// renders underneath in the same frame, so re-flattening the whole
265 /// transcript per frame roughly doubled per-frame allocations. The flat
266 /// vector only depends on the snapshots, the width, the mode, and the
267 /// render options — all of which are in `FlatKey`.
268 fn flattened(&self, width: u16) -> std::cell::Ref<'_, FlattenedTranscript> {
269 let key = FlatKey {
270 generation: self.snapshots_generation.get(),
271 width: width.max(1),
272 mode: self.mode,
273 options: self.options,
274 };
275 {
276 let mut cache = self.flat_cache.borrow_mut();
277 let stale = cache.as_ref().is_none_or(|cached| cached.key != key);
278 if stale {
279 let flat = self.flatten(key.width);
280 self.flattens.set(self.flattens.get() + 1);
281 *cache = Some(FlatCache { key, flat });
282 }
283 }
284 std::cell::Ref::map(self.flat_cache.borrow(), |cache| {
285 &cache.as_ref().expect("flat cache populated above").flat
286 })
287 }
288
289 fn flatten(&self, width: u16) -> FlattenedTranscript {
290 let width = width.max(1);
291 let mut out: Vec<Line<'static>> = Vec::new();
292 let mut out_links: Vec<Vec<crate::tui::osc8::LineLink>> = Vec::new();
293 let mut highlighted_range = None;
294
295 // Pre-compute which cell index (in `self.snapshots`) is the one
296 // the user has selected via Esc-Esc. We walk snapshots backwards
297 // counting User cells; the snapshot index whose count matches
298 // `selected_idx + 1` is the highlighted one.
299 let highlighted_cell_idx: Option<usize> = match self.mode {
300 Mode::BacktrackPreview { selected_idx } => {
301 let mut count = 0usize;
302 let mut hit = None;
303 for (idx, snap) in self.snapshots.iter().enumerate().rev() {
304 if matches!(snap.cell, HistoryCell::User { .. }) {
305 if count == selected_idx {
306 hit = Some(idx);
307 break;
308 }
309 count += 1;
310 }
311 }
312 hit
313 }
314 Mode::Tail => None,
315 };
316
317 let mut cache = self.cache.borrow_mut();
318 // Room for every cell plus a new revision of each, so the sweep
319 // below never evicts entries it is about to read (a 1,000-cell
320 // transcript against the old fixed 512 cap missed every lookup).
321 cache.ensure_capacity(self.snapshots.len().saturating_mul(2));
322 for (cell_idx, snap) in self.snapshots.iter().enumerate() {
323 // Borrow the cached slice and clone each line (and its links)
324 // exactly once into the flattened output.
325 let split = |cached: &[CachedTranscriptLine]| -> (Vec<Line<'static>>, Vec<_>) {
326 cached
327 .iter()
328 .map(|rendered| (rendered.line.clone(), rendered.links.clone()))
329 .unzip()
330 };
331 let (lines, mut line_links) = match cache.get(snap.id, width, snap.revision) {
332 Some(cached) => split(cached),
333 None => {
334 let rendered = snap
335 .cell
336 .lines_with_copy_metadata(width, self.options)
337 .into_iter()
338 .map(|rendered| CachedTranscriptLine {
339 line: rendered.line,
340 links: rendered.links,
341 })
342 .collect::<Vec<_>>();
343 let split_lines = split(&rendered);
344 cache.insert(snap.id, width, snap.revision, rendered);
345 split_lines
346 }
347 };
348
349 if Some(cell_idx) == highlighted_cell_idx {
350 let start = out.len();
351 let lines = decorate_highlight(lines);
352 if let Some(first_links) = line_links.first_mut() {
353 *first_links = first_links.iter().map(|link| link.shifted(2)).collect();
354 }
355 out.extend(lines);
356 out_links.extend(line_links);
357 let end = out.len();
358 if end > start {
359 highlighted_range = Some((start, end));
360 }
361 } else {
362 out.extend(lines);
363 out_links.extend(line_links);
364 }
365 }
366 FlattenedTranscript {
367 lines: out,
368 line_links: out_links,
369 highlighted_range,
370 }
371 }
372
373 fn page_height(&self) -> usize {
374 let cached = self.last_visible_height.get();
375 if cached == 0 { 10 } else { cached }
376 }
377
378 fn half_page_height(&self) -> usize {
379 self.page_height().div_ceil(2).max(1)
380 }
381
382 fn max_scroll(&self) -> usize {
383 let total = self.last_total_lines.get();
384 let visible = self.page_height();
385 total.saturating_sub(visible)
386 }
387
388 fn scroll_up(&mut self, amount: usize) {
389 self.scroll.set(self.scroll.get().saturating_sub(amount));
390 // Any upward motion exits sticky-tail; explicit user intent.
391 self.sticky_to_bottom.set(false);
392 self.preview_pin_pending.set(false);
393 }
394
395 fn scroll_down(&mut self, amount: usize) {
396 let max = self.max_scroll();
397 let scroll = self.scroll.get().saturating_add(amount).min(max);
398 self.scroll.set(scroll);
399 self.preview_pin_pending.set(false);
400 if scroll >= max && matches!(self.mode, Mode::Tail) {
401 self.sticky_to_bottom.set(true);
402 }
403 }
404
405 fn jump_to_top(&mut self) {
406 self.scroll.set(0);
407 self.sticky_to_bottom.set(false);
408 self.preview_pin_pending.set(false);
409 }
410
411 fn jump_to_bottom(&mut self) {
412 self.scroll.set(self.max_scroll());
413 self.sticky_to_bottom.set(matches!(self.mode, Mode::Tail));
414 self.preview_pin_pending.set(false);
415 }
416
417 /// For tests: snapshot count.
418 #[cfg(test)]
419 fn snapshot_count(&self) -> usize {
420 self.snapshots.len()
421 }
422
423 /// For tests: whether sticky-tail is currently armed.
424 #[cfg(test)]
425 pub fn is_sticky(&self) -> bool {
426 self.sticky_to_bottom.get()
427 }
428
429 /// For tests: current scroll offset.
430 #[cfg(test)]
431 pub fn scroll_offset(&self) -> usize {
432 self.scroll.get()
433 }
434 }
435
436 impl Default for LiveTranscriptOverlay {
437 fn default() -> Self {
438 Self::new()
439 }
440 }
441
442 /// Apply a backtrack-preview highlight to the lines belonging to a single
443 /// `HistoryCell::User`. The first line gets a `▶ ` prefix in accent color
444 /// (so the marker remains visible even on terminals where reverse-video
445 /// is washed out); every line in the cell gets `Modifier::REVERSED` so
446 /// the cell visually pops out of the surrounding transcript. Internal
447 /// span structure is preserved so syntax/role coloring underneath the
448 /// reverse stays readable.
449 fn decorate_highlight(mut lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
450 if lines.is_empty() {
451 return lines;
452 }
453 for line in &mut lines {
454 for span in &mut line.spans {
455 span.style = span.style.add_modifier(Modifier::REVERSED);
456 }
457 }
458 let marker = Span::styled(
459 "\u{25B6} ",
460 Style::default()
461 .fg(palette::TEXT_ACCENT)
462 .add_modifier(Modifier::BOLD),
463 );
464 if let Some(first) = lines.first_mut() {
465 first.spans.insert(0, marker);
466 }
467 lines
468 }
469
470 fn scroll_to_show_range(
471 current: usize,
472 start: usize,
473 end: usize,
474 visible_height: usize,
475 max_scroll: usize,
476 ) -> usize {
477 if visible_height == 0 {
478 return 0;
479 }
480 let end = end.max(start.saturating_add(1));
481 if start < current {
482 start.min(max_scroll)
483 } else if end > current.saturating_add(visible_height) {
484 end.saturating_sub(visible_height).min(max_scroll)
485 } else {
486 current.min(max_scroll)
487 }
488 }
489
490 impl ModalView for LiveTranscriptOverlay {
491 fn kind(&self) -> ModalKind {
492 ModalKind::LiveTranscript
493 }
494
495 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
496 self
497 }
498
499 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
500 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
501 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
502
503 // Backtrack-preview mode (#133) intercepts Left/Right/Enter/Esc
504 // before the normal scroll handlers so the user can step through
505 // prior user messages without their input being interpreted as
506 // pager navigation. Other keys (page up/down, gg/G, etc.) still
507 // fall through so the user can scroll the transcript while
508 // previewing.
509 if matches!(self.mode, Mode::BacktrackPreview { .. }) {
510 match key.code {
511 KeyCode::Left | KeyCode::Char('h') if !ctrl => {
512 return ViewAction::Emit(ViewEvent::BacktrackStep {
513 direction: Direction::Left,
514 });
515 }
516 KeyCode::Right | KeyCode::Char('l') if !ctrl => {
517 return ViewAction::Emit(ViewEvent::BacktrackStep {
518 direction: Direction::Right,
519 });
520 }
521 KeyCode::Enter => {
522 return ViewAction::EmitAndClose(ViewEvent::BacktrackConfirm);
523 }
524 KeyCode::Esc | KeyCode::Char('q') => {
525 return ViewAction::EmitAndClose(ViewEvent::BacktrackCancel);
526 }
527 _ => {}
528 }
529 }
530
531 if ctrl {
532 match key.code {
533 KeyCode::Char('d') | KeyCode::Char('D') => {
534 self.scroll_down(self.half_page_height());
535 self.pending_g = false;
536 return ViewAction::None;
537 }
538 KeyCode::Char('u') | KeyCode::Char('U') => {
539 self.scroll_up(self.half_page_height());
540 self.pending_g = false;
541 return ViewAction::None;
542 }
543 KeyCode::Char('f') | KeyCode::Char('F') => {
544 self.scroll_down(self.page_height());
545 self.pending_g = false;
546 return ViewAction::None;
547 }
548 KeyCode::Char('b') | KeyCode::Char('B') => {
549 self.scroll_up(self.page_height());
550 self.pending_g = false;
551 return ViewAction::None;
552 }
553 // Ctrl+Shift+T toggles the overlay closed when already open.
554 KeyCode::Char('t') | KeyCode::Char('T')
555 if key.modifiers.contains(KeyModifiers::CONTROL)
556 && key.modifiers.contains(KeyModifiers::SHIFT) =>
557 {
558 return ViewAction::Close;
559 }
560 _ => {}
561 }
562 }
563
564 match key.code {
565 KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
566 KeyCode::Up | KeyCode::Char('k') => {
567 self.scroll_up(1);
568 self.pending_g = false;
569 ViewAction::None
570 }
571 KeyCode::Down | KeyCode::Char('j') => {
572 self.scroll_down(1);
573 self.pending_g = false;
574 ViewAction::None
575 }
576 KeyCode::PageUp => {
577 self.scroll_up(self.page_height());
578 self.pending_g = false;
579 ViewAction::None
580 }
581 KeyCode::PageDown => {
582 self.scroll_down(self.page_height());
583 self.pending_g = false;
584 ViewAction::None
585 }
586 KeyCode::Char(' ') if shift => {
587 self.scroll_up(self.page_height());
588 self.pending_g = false;
589 ViewAction::None
590 }
591 KeyCode::Char(' ') => {
592 self.scroll_down(self.page_height());
593 self.pending_g = false;
594 ViewAction::None
595 }
596 KeyCode::Home => {
597 self.jump_to_top();
598 self.pending_g = false;
599 ViewAction::None
600 }
601 KeyCode::End => {
602 self.jump_to_bottom();
603 self.pending_g = false;
604 ViewAction::None
605 }
606 KeyCode::Char('g') => {
607 if self.pending_g {
608 self.jump_to_top();
609 self.pending_g = false;
610 } else {
611 self.pending_g = true;
612 }
613 ViewAction::None
614 }
615 KeyCode::Char('G') => {
616 self.jump_to_bottom();
617 self.pending_g = false;
618 ViewAction::None
619 }
620 _ => ViewAction::None,
621 }
622 }
623
624 fn render(&self, area: Rect, buf: &mut Buffer) {
625 let popup_width = area.width.saturating_sub(2).max(1);
626 let popup_height = area.height.saturating_sub(2).max(1);
627 let popup_area = Rect {
628 x: 1,
629 y: 1,
630 width: popup_width,
631 height: popup_height,
632 };
633
634 Clear.render(popup_area, buf);
635
636 let title: String = match self.mode {
637 Mode::BacktrackPreview { selected_idx } => format!(
638 " Backtrack preview — turn {} (\u{2190}/\u{2192} step, Enter rewind, Esc cancel) ",
639 selected_idx + 1
640 ),
641 Mode::Tail => {
642 if self.sticky_to_bottom.get() {
643 " Live transcript (tailing) ".to_string()
644 } else {
645 " Live transcript (paused) ".to_string()
646 }
647 }
648 };
649
650 let block = Block::default()
651 .title(title)
652 .borders(Borders::ALL)
653 .border_style(Style::default().fg(palette::BORDER_COLOR))
654 .style(Style::default().bg(palette::WHALE_BG))
655 .padding(Padding::uniform(1));
656 let inner = block.inner(popup_area);
657 block.render(popup_area, buf);
658
659 // Wrapping action footer along the bottom of the inner area; the body
660 // fills the rows above it.
661 let content = render_modal_footer(
662 inner,
663 buf,
664 &[
665 ActionHint::new("j/k", "scroll"),
666 ActionHint::new("Space/C-b", "page"),
667 ActionHint::new("g/G", "top/bottom"),
668 ActionHint::new("End", "resume tail"),
669 ActionHint::new("q/Esc", "close"),
670 ],
671 );
672
673 // `content` already excludes the border, padding, and footer rows.
674 let visible_height = content.height as usize;
675 self.last_visible_height.set(visible_height);
676
677 // Wrap content using the per-cell cache at the body width.
678 let content_width = content.width;
679 let flat = self.flattened(content_width);
680 let FlattenedTranscript {
681 lines,
682 line_links,
683 highlighted_range,
684 } = &*flat;
685 let highlighted_range = *highlighted_range;
686 self.last_total_lines.set(lines.len());
687
688 let max_scroll = lines.len().saturating_sub(visible_height);
689 // Sticky-tail: every render re-pins scroll to the bottom unless the
690 // user has explicitly scrolled away. Without this, streaming new
691 // content would push the visible window backwards as `scroll` stays
692 // fixed against a growing total.
693 let scroll = if self.sticky_to_bottom.get() {
694 self.scroll.set(max_scroll);
695 max_scroll
696 } else if self.preview_pin_pending.replace(false) {
697 let next = highlighted_range
698 .map(|(start, end)| {
699 scroll_to_show_range(self.scroll.get(), start, end, visible_height, max_scroll)
700 })
701 .unwrap_or_else(|| self.scroll.get().min(max_scroll));
702 self.scroll.set(next);
703 next
704 } else {
705 let next = self.scroll.get().min(max_scroll);
706 self.scroll.set(next);
707 next
708 };
709 let end = (scroll + visible_height).min(lines.len());
710 let visible_lines: Vec<Line<'static>> = if lines.is_empty() {
711 vec![Line::from(Span::styled(
712 "(no transcript yet)",
713 Style::default().fg(palette::TEXT_DIM),
714 ))]
715 } else {
716 lines[scroll..end].to_vec()
717 };
718 let visible_line_links = if lines.is_empty() {
719 vec![Vec::new()]
720 } else {
721 line_links[scroll..end].to_vec()
722 };
723
724 let paragraph = Paragraph::new(visible_lines).wrap(Wrap { trim: false });
725 paragraph.render(content, buf);
726
727 // Targets stay beside the visible lines, so the popup never writes an
728 // escape payload into the buffer. Replace the opaque popup's portion
729 // of the frame map so links in the obscured transcript cannot leak
730 // through unrelated modal text.
731 let regions = crate::tui::osc8::link_regions_for_lines(content, &visible_line_links);
732 crate::tui::osc8::overlay_frame_links(popup_area, regions);
733 }
734 }
735
736 // Frozen production source boundary.
737
738 impl LiveTranscriptOverlay {
739 pub(super) fn frozen_snapshot_count(&self) -> usize {
740 self.snapshot_count()
741 }
742 pub(super) fn frozen_metrics(&self) -> (usize, usize, usize, bool) {
743 (
744 self.scroll.get(),
745 self.last_visible_height.get(),
746 self.last_total_lines.get(),
747 self.sticky_to_bottom.get(),
748 )
749 }
750 }
751
751 lines RUST