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