| 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 |