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