use std::cell::{Cell, RefCell};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Padding, Paragraph, Widget, Wrap},
};
use crate::palette;
use crate::tui::app::App;
use crate::tui::backtrack::Direction;
use crate::tui::history::{HistoryCell, TranscriptRenderOptions};
use crate::tui::transcript_cache::{CachedTranscriptLine, CellId, TranscriptCache};
use crate::tui::views::{
ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, render_modal_footer,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Mode {
#[default]
Tail,
BacktrackPreview {
selected_idx: usize,
},
}
#[derive(Debug, Clone)]
struct CellSnapshot {
id: CellId,
revision: u64,
cell: HistoryCell,
}
struct FlattenedTranscript {
lines: Vec<Line<'static>>,
line_links: Vec<Vec<crate::tui::osc8::LineLink>>,
highlighted_range: Option<(usize, usize)>,
}
pub struct LiveTranscriptOverlay {
snapshots: Vec<CellSnapshot>,
options: TranscriptRenderOptions,
cache: RefCell<TranscriptCache>,
sticky_to_bottom: Cell<bool>,
scroll: Cell<usize>,
last_visible_height: Cell<usize>,
last_total_lines: Cell<usize>,
pending_g: bool,
mode: Mode,
preview_pin_pending: Cell<bool>,
}
impl LiveTranscriptOverlay {
#[must_use]
pub fn new() -> Self {
Self {
snapshots: Vec::new(),
options: TranscriptRenderOptions::default(),
cache: RefCell::new(TranscriptCache::new()),
sticky_to_bottom: Cell::new(true),
scroll: Cell::new(0),
last_visible_height: Cell::new(0),
last_total_lines: Cell::new(0),
pending_g: false,
mode: Mode::Tail,
preview_pin_pending: Cell::new(false),
}
}
pub fn set_backtrack_preview(&mut self, selected_idx: usize) {
self.mode = Mode::BacktrackPreview { selected_idx };
self.sticky_to_bottom.set(false);
self.preview_pin_pending.set(true);
}
#[allow(dead_code)] pub fn set_tail_mode(&mut self) {
self.mode = Mode::Tail;
self.sticky_to_bottom.set(true);
self.preview_pin_pending.set(false);
}
#[allow(dead_code)] #[must_use]
pub fn mode(&self) -> Mode {
self.mode
}
pub fn refresh_from_app(&mut self, app: &mut App) {
app.resync_history_revisions();
let mut new_snapshots = Vec::with_capacity(
app.history.len() + app.active_cell.as_ref().map_or(0, |a| a.entries().len()),
);
for (idx, cell) in app.history.iter().enumerate() {
let rev = app.history_revisions.get(idx).copied().unwrap_or(0);
new_snapshots.push(CellSnapshot {
id: CellId::History(idx),
revision: rev,
cell: cell.clone(),
});
}
if let Some(active) = app.active_cell.as_ref() {
let active_rev = app.active_cell_revision;
for (idx, cell) in active.entries().iter().enumerate() {
let salt = (idx as u64).wrapping_add(1);
let revision = active_rev
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add(salt);
new_snapshots.push(CellSnapshot {
id: CellId::Active(idx),
revision,
cell: cell.clone(),
});
}
}
self.snapshots = new_snapshots;
self.options = app.transcript_render_options();
}
fn flatten(&self, width: u16) -> FlattenedTranscript {
let width = width.max(1);
let mut out: Vec<Line<'static>> = Vec::new();
let mut out_links: Vec<Vec<crate::tui::osc8::LineLink>> = Vec::new();
let mut highlighted_range = None;
let highlighted_cell_idx: Option<usize> = match self.mode {
Mode::BacktrackPreview { selected_idx } => {
let mut count = 0usize;
let mut hit = None;
for (idx, snap) in self.snapshots.iter().enumerate().rev() {
if matches!(snap.cell, HistoryCell::User { .. }) {
if count == selected_idx {
hit = Some(idx);
break;
}
count += 1;
}
}
hit
}
Mode::Tail => None,
};
let mut cache = self.cache.borrow_mut();
for (cell_idx, snap) in self.snapshots.iter().enumerate() {
let rendered: Vec<CachedTranscriptLine> = match cache.get(snap.id, width, snap.revision)
{
Some(cached) => cached.to_vec(),
None => {
let rendered = snap
.cell
.lines_with_copy_metadata(width, self.options)
.into_iter()
.map(|rendered| CachedTranscriptLine {
line: rendered.line,
links: rendered.links,
})
.collect::<Vec<_>>();
cache.insert(snap.id, width, snap.revision, rendered.clone());
rendered
}
};
let mut lines = rendered
.iter()
.map(|rendered| rendered.line.clone())
.collect::<Vec<_>>();
let mut line_links = rendered
.into_iter()
.map(|rendered| rendered.links)
.collect::<Vec<_>>();
if Some(cell_idx) == highlighted_cell_idx {
let start = out.len();
lines = decorate_highlight(lines);
if let Some(first_links) = line_links.first_mut() {
*first_links = first_links.iter().map(|link| link.shifted(2)).collect();
}
out.extend(lines);
out_links.extend(line_links);
let end = out.len();
if end > start {
highlighted_range = Some((start, end));
}
} else {
out.extend(lines);
out_links.extend(line_links);
}
}
FlattenedTranscript {
lines: out,
line_links: out_links,
highlighted_range,
}
}
fn page_height(&self) -> usize {
let cached = self.last_visible_height.get();
if cached == 0 { 10 } else { cached }
}
fn half_page_height(&self) -> usize {
self.page_height().div_ceil(2).max(1)
}
fn max_scroll(&self) -> usize {
let total = self.last_total_lines.get();
let visible = self.page_height();
total.saturating_sub(visible)
}
fn scroll_up(&mut self, amount: usize) {
self.scroll.set(self.scroll.get().saturating_sub(amount));
self.sticky_to_bottom.set(false);
self.preview_pin_pending.set(false);
}
fn scroll_down(&mut self, amount: usize) {
let max = self.max_scroll();
let scroll = self.scroll.get().saturating_add(amount).min(max);
self.scroll.set(scroll);
self.preview_pin_pending.set(false);
if scroll >= max && matches!(self.mode, Mode::Tail) {
self.sticky_to_bottom.set(true);
}
}
fn jump_to_top(&mut self) {
self.scroll.set(0);
self.sticky_to_bottom.set(false);
self.preview_pin_pending.set(false);
}
fn jump_to_bottom(&mut self) {
self.scroll.set(self.max_scroll());
self.sticky_to_bottom.set(matches!(self.mode, Mode::Tail));
self.preview_pin_pending.set(false);
}
#[cfg(test)]
fn snapshot_count(&self) -> usize {
self.snapshots.len()
}
#[cfg(test)]
pub fn is_sticky(&self) -> bool {
self.sticky_to_bottom.get()
}
#[cfg(test)]
pub fn scroll_offset(&self) -> usize {
self.scroll.get()
}
}
impl Default for LiveTranscriptOverlay {
fn default() -> Self {
Self::new()
}
}
fn decorate_highlight(mut lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
if lines.is_empty() {
return lines;
}
for line in &mut lines {
for span in &mut line.spans {
span.style = span.style.add_modifier(Modifier::REVERSED);
}
}
let marker = Span::styled(
"\u{25B6} ",
Style::default()
.fg(palette::TEXT_ACCENT)
.add_modifier(Modifier::BOLD),
);
if let Some(first) = lines.first_mut() {
first.spans.insert(0, marker);
}
lines
}
fn scroll_to_show_range(
current: usize,
start: usize,
end: usize,
visible_height: usize,
max_scroll: usize,
) -> usize {
if visible_height == 0 {
return 0;
}
let end = end.max(start.saturating_add(1));
if start < current {
start.min(max_scroll)
} else if end > current.saturating_add(visible_height) {
end.saturating_sub(visible_height).min(max_scroll)
} else {
current.min(max_scroll)
}
}
impl ModalView for LiveTranscriptOverlay {
fn kind(&self) -> ModalKind {
ModalKind::LiveTranscript
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
if matches!(self.mode, Mode::BacktrackPreview { .. }) {
match key.code {
KeyCode::Left | KeyCode::Char('h') if !ctrl => {
return ViewAction::Emit(ViewEvent::BacktrackStep {
direction: Direction::Left,
});
}
KeyCode::Right | KeyCode::Char('l') if !ctrl => {
return ViewAction::Emit(ViewEvent::BacktrackStep {
direction: Direction::Right,
});
}
KeyCode::Enter => {
return ViewAction::EmitAndClose(ViewEvent::BacktrackConfirm);
}
KeyCode::Esc | KeyCode::Char('q') => {
return ViewAction::EmitAndClose(ViewEvent::BacktrackCancel);
}
_ => {}
}
}
if ctrl {
match key.code {
KeyCode::Char('d') | KeyCode::Char('D') => {
self.scroll_down(self.half_page_height());
self.pending_g = false;
return ViewAction::None;
}
KeyCode::Char('u') | KeyCode::Char('U') => {
self.scroll_up(self.half_page_height());
self.pending_g = false;
return ViewAction::None;
}
KeyCode::Char('f') | KeyCode::Char('F') => {
self.scroll_down(self.page_height());
self.pending_g = false;
return ViewAction::None;
}
KeyCode::Char('b') | KeyCode::Char('B') => {
self.scroll_up(self.page_height());
self.pending_g = false;
return ViewAction::None;
}
KeyCode::Char('t') | KeyCode::Char('T')
if key.modifiers.contains(KeyModifiers::CONTROL)
&& key.modifiers.contains(KeyModifiers::SHIFT) =>
{
return ViewAction::Close;
}
_ => {}
}
}
match key.code {
KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
KeyCode::Up | KeyCode::Char('k') => {
self.scroll_up(1);
self.pending_g = false;
ViewAction::None
}
KeyCode::Down | KeyCode::Char('j') => {
self.scroll_down(1);
self.pending_g = false;
ViewAction::None
}
KeyCode::PageUp => {
self.scroll_up(self.page_height());
self.pending_g = false;
ViewAction::None
}
KeyCode::PageDown => {
self.scroll_down(self.page_height());
self.pending_g = false;
ViewAction::None
}
KeyCode::Char(' ') if shift => {
self.scroll_up(self.page_height());
self.pending_g = false;
ViewAction::None
}
KeyCode::Char(' ') => {
self.scroll_down(self.page_height());
self.pending_g = false;
ViewAction::None
}
KeyCode::Home => {
self.jump_to_top();
self.pending_g = false;
ViewAction::None
}
KeyCode::End => {
self.jump_to_bottom();
self.pending_g = false;
ViewAction::None
}
KeyCode::Char('g') => {
if self.pending_g {
self.jump_to_top();
self.pending_g = false;
} else {
self.pending_g = true;
}
ViewAction::None
}
KeyCode::Char('G') => {
self.jump_to_bottom();
self.pending_g = false;
ViewAction::None
}
_ => ViewAction::None,
}
}
fn render(&self, area: Rect, buf: &mut Buffer) {
let popup_width = area.width.saturating_sub(2).max(1);
let popup_height = area.height.saturating_sub(2).max(1);
let popup_area = Rect {
x: 1,
y: 1,
width: popup_width,
height: popup_height,
};
Clear.render(popup_area, buf);
let title: String = match self.mode {
Mode::BacktrackPreview { selected_idx } => format!(
" Backtrack preview — turn {} (\u{2190}/\u{2192} step, Enter rewind, Esc cancel) ",
selected_idx + 1
),
Mode::Tail => {
if self.sticky_to_bottom.get() {
" Live transcript (tailing) ".to_string()
} else {
" Live transcript (paused) ".to_string()
}
}
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(palette::BORDER_COLOR))
.style(Style::default().bg(palette::WHALE_BG))
.padding(Padding::uniform(1));
let inner = block.inner(popup_area);
block.render(popup_area, buf);
let content = render_modal_footer(
inner,
buf,
&[
ActionHint::new("j/k", "scroll"),
ActionHint::new("Space/C-b", "page"),
ActionHint::new("g/G", "top/bottom"),
ActionHint::new("End", "resume tail"),
ActionHint::new("q/Esc", "close"),
],
);
let visible_height = content.height as usize;
self.last_visible_height.set(visible_height);
let content_width = content.width;
let FlattenedTranscript {
lines,
line_links,
highlighted_range,
} = self.flatten(content_width);
self.last_total_lines.set(lines.len());
let max_scroll = lines.len().saturating_sub(visible_height);
let scroll = if self.sticky_to_bottom.get() {
self.scroll.set(max_scroll);
max_scroll
} else if self.preview_pin_pending.replace(false) {
let next = highlighted_range
.map(|(start, end)| {
scroll_to_show_range(self.scroll.get(), start, end, visible_height, max_scroll)
})
.unwrap_or_else(|| self.scroll.get().min(max_scroll));
self.scroll.set(next);
next
} else {
let next = self.scroll.get().min(max_scroll);
self.scroll.set(next);
next
};
let end = (scroll + visible_height).min(lines.len());
let visible_lines: Vec<Line<'static>> = if lines.is_empty() {
vec![Line::from(Span::styled(
"(no transcript yet)",
Style::default().fg(palette::TEXT_DIM),
))]
} else {
lines[scroll..end].to_vec()
};
let visible_line_links = if lines.is_empty() {
vec![Vec::new()]
} else {
line_links[scroll..end].to_vec()
};
let paragraph = Paragraph::new(visible_lines).wrap(Wrap { trim: false });
paragraph.render(content, buf);
let regions = crate::tui::osc8::link_regions_for_lines(content, &visible_line_links);
crate::tui::osc8::overlay_frame_links(popup_area, regions);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::history::HistoryCell;
fn user(s: &str) -> HistoryCell {
HistoryCell::User {
content: s.to_string(),
}
}
fn assistant(s: &str, streaming: bool) -> HistoryCell {
HistoryCell::Assistant {
content: s.to_string(),
streaming,
}
}
fn prime_layout(view: &mut LiveTranscriptOverlay, height: u16) {
let area = Rect::new(0, 0, 60, height);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
}
fn install_snapshots(view: &mut LiveTranscriptOverlay, cells: Vec<HistoryCell>) {
view.snapshots = cells
.into_iter()
.enumerate()
.map(|(idx, cell)| CellSnapshot {
id: CellId::History(idx),
revision: 1,
cell,
})
.collect();
}
fn buffer_text(buf: &Buffer) -> String {
let mut out = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
#[test]
fn new_overlay_starts_sticky() {
let v = LiveTranscriptOverlay::new();
assert!(v.is_sticky());
assert_eq!(v.scroll_offset(), 0);
assert_eq!(v.snapshot_count(), 0);
}
#[test]
fn overlay_publishes_scrolled_url_metadata_without_escape_cells() {
let target = "https://example.test/a/very/long/path/that/wraps/in/the/live/view";
let mut view = LiveTranscriptOverlay::new();
let mut cells = (0..12)
.map(|index| user(&format!("older row {index}")))
.collect::<Vec<_>>();
cells.push(assistant(target, false));
install_snapshots(&mut view, cells);
let area = Rect::new(0, 0, 32, 16);
let mut buf = Buffer::empty(area);
let _ = crate::tui::osc8::take_frame_links();
view.render(area, &mut buf);
let regions = crate::tui::osc8::take_frame_links();
assert!(view.scroll_offset() > 0, "fixture must render a tail slice");
assert!(regions.len() > 1, "narrow overlay should wrap: {regions:?}");
assert!(regions.iter().all(|region| region.target == target));
assert!(regions.iter().all(|region| {
area.contains(ratatui::layout::Position {
x: region.col_start,
y: region.row,
}) && area.contains(ratatui::layout::Position {
x: region.col_end,
y: region.row,
})
}));
assert!((area.y..area.bottom()).all(|y| {
(area.x..area.right()).all(|x| {
let symbol = buf[(x, y)].symbol();
!symbol.contains('\x1b') && !symbol.contains("]8;;")
})
}));
}
#[test]
fn scroll_up_breaks_sticky() {
let mut v = LiveTranscriptOverlay::new();
install_snapshots(
&mut v,
(0..50).map(|i| user(&format!("line {i}"))).collect(),
);
prime_layout(&mut v, 10);
v.scroll.set(5);
v.sticky_to_bottom.set(true);
let _ = v.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
assert!(!v.is_sticky(), "scrolling up must release the sticky tail");
}
#[test]
fn end_resumes_sticky_tail() {
let mut v = LiveTranscriptOverlay::new();
install_snapshots(
&mut v,
(0..50).map(|i| user(&format!("line {i}"))).collect(),
);
prime_layout(&mut v, 10);
v.scroll.set(10);
v.sticky_to_bottom.set(false);
let _ = v.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
assert!(
v.is_sticky(),
"End must re-arm the sticky tail so streaming continues to follow"
);
}
#[test]
fn scrolling_to_max_re_arms_sticky() {
let mut v = LiveTranscriptOverlay::new();
install_snapshots(
&mut v,
(0..50).map(|i| user(&format!("line {i}"))).collect(),
);
prime_layout(&mut v, 10);
v.sticky_to_bottom.set(false);
let _ = v.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE));
v.scroll.set(0);
v.sticky_to_bottom.set(false);
let _ = v.handle_key(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE));
assert!(v.is_sticky());
}
#[test]
fn esc_closes() {
let mut v = LiveTranscriptOverlay::new();
let action = v.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(action, ViewAction::Close));
}
#[test]
fn ctrl_shift_t_closes_when_already_open() {
let mut v = LiveTranscriptOverlay::new();
let action = v.handle_key(KeyEvent::new(
KeyCode::Char('t'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
));
assert!(matches!(action, ViewAction::Close));
}
#[test]
fn render_does_not_panic_on_empty() {
let v = LiveTranscriptOverlay::new();
let area = Rect::new(0, 0, 40, 12);
let mut buf = Buffer::empty(area);
v.render(area, &mut buf);
}
#[test]
fn cache_reuses_unchanged_cells_across_renders() {
let mut v = LiveTranscriptOverlay::new();
install_snapshots(&mut v, vec![user("a"), user("b"), assistant("c", false)]);
let area = Rect::new(0, 0, 60, 16);
let mut buf = Buffer::empty(area);
v.render(area, &mut buf);
let after_first = v.cache.borrow().len();
v.render(area, &mut buf);
let after_second = v.cache.borrow().len();
assert_eq!(
after_first, after_second,
"second render should reuse every cell — no new cache entries"
);
}
#[test]
fn streaming_render_stays_within_per_frame_cell_diff_budget() {
let mut view = LiveTranscriptOverlay::new();
let mut cells = (0..30)
.map(|index| user(&format!("stable transcript row {index}")))
.collect::<Vec<_>>();
cells.push(assistant("streaming answer", true));
install_snapshots(&mut view, cells);
let area = Rect::new(0, 0, 89, 24);
let mut before = Buffer::empty(area);
view.render(area, &mut before);
let tail = view.snapshots.last_mut().expect("streaming tail");
let HistoryCell::Assistant { content, .. } = &mut tail.cell else {
panic!("fixture tail must be an assistant cell");
};
content.push_str(" + one delta");
tail.revision = tail.revision.saturating_add(1);
let mut after = Buffer::empty(area);
view.render(area, &mut after);
let changed_cells = before
.content()
.iter()
.zip(after.content())
.filter(|(left, right)| left != right)
.count();
let max_changed_cells = area.width as usize * 4;
assert!(changed_cells > 0, "stream delta did not reach the frame");
assert!(
changed_cells <= max_changed_cells,
"stream delta changed {changed_cells} cells; budget is {max_changed_cells}"
);
}
#[test]
fn cache_invalidates_on_revision_bump() {
let mut v = LiveTranscriptOverlay::new();
install_snapshots(&mut v, vec![user("a"), assistant("b", true)]);
let area = Rect::new(0, 0, 60, 16);
let mut buf = Buffer::empty(area);
v.render(area, &mut buf);
let before = v.cache.borrow().len();
v.snapshots[1].revision = 2;
v.render(area, &mut buf);
let after = v.cache.borrow().len();
assert!(
after > before,
"bumping a revision must add a new cache entry"
);
}
#[test]
fn resize_does_not_evict_unchanged_width_entries() {
let mut v = LiveTranscriptOverlay::new();
install_snapshots(&mut v, vec![user("a"), user("b")]);
let small = Rect::new(0, 0, 60, 16);
let large = Rect::new(0, 0, 80, 16);
let mut buf_s = Buffer::empty(small);
let mut buf_l = Buffer::empty(large);
v.render(small, &mut buf_s);
let after_small = v.cache.borrow().len();
v.render(large, &mut buf_l);
let after_both = v.cache.borrow().len();
assert!(
after_both > after_small,
"rendering at a new width must add new cache entries"
);
v.render(small, &mut buf_s);
let after_replay = v.cache.borrow().len();
assert_eq!(
after_replay, after_both,
"replay at old width must hit cache"
);
}
#[test]
fn backtrack_preview_disables_sticky() {
let mut v = LiveTranscriptOverlay::new();
assert!(v.is_sticky());
v.set_backtrack_preview(0);
assert!(!v.is_sticky());
assert!(matches!(
v.mode(),
Mode::BacktrackPreview { selected_idx: 0 }
));
}
#[test]
fn set_tail_mode_re_arms_sticky() {
let mut v = LiveTranscriptOverlay::new();
v.set_backtrack_preview(2);
v.set_tail_mode();
assert!(v.is_sticky());
assert!(matches!(v.mode(), Mode::Tail));
}
#[test]
fn backtrack_preview_does_not_panic_with_no_user_cells() {
let mut v = LiveTranscriptOverlay::new();
install_snapshots(&mut v, vec![assistant("hi", false)]);
v.set_backtrack_preview(0);
let area = Rect::new(0, 0, 40, 10);
let mut buf = Buffer::empty(area);
v.render(area, &mut buf);
}
#[test]
fn backtrack_preview_highlights_selected_user_cell() {
let mut v = LiveTranscriptOverlay::new();
install_snapshots(
&mut v,
vec![
user("u0"),
assistant("a0", false),
user("u1"),
assistant("a1", false),
user("u2"),
assistant("a2", false),
],
);
for sel in [0usize, 1, 2] {
v.set_backtrack_preview(sel);
let area = Rect::new(0, 0, 40, 24);
let mut buf = Buffer::empty(area);
v.render(area, &mut buf);
let mut any_content = false;
for y in 0..buf.area.height {
for x in 0..buf.area.width {
if !buf[(x, y)].symbol().is_empty() && buf[(x, y)].symbol() != " " {
any_content = true;
break;
}
}
if any_content {
break;
}
}
assert!(any_content, "preview render must produce visible content");
}
}
#[test]
fn backtrack_preview_opens_near_latest_user_not_transcript_start() {
let mut v = LiveTranscriptOverlay::new();
let mut cells = Vec::new();
for i in 0..12 {
cells.push(user(&format!("user {i}")));
cells.push(assistant(&format!("assistant {i}"), false));
}
install_snapshots(&mut v, cells);
v.set_backtrack_preview(0);
let area = Rect::new(0, 0, 48, 10);
let mut buf = Buffer::empty(area);
v.render(area, &mut buf);
let rendered = buffer_text(&buf);
assert!(
v.scroll_offset() > 0,
"preview should pin near the selected recent turn, got top offset 0"
);
assert!(
rendered.contains("user 11"),
"latest user turn should be visible after opening preview: {rendered}"
);
assert!(
!rendered.contains("user 0"),
"preview must not open at the oldest transcript line: {rendered}"
);
}
#[test]
fn live_transcript_is_usable_and_opaque_at_blocker_sizes() {
use crate::tui::views::ViewStack;
use unicode_width::UnicodeWidthStr;
const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
for (w, h) in BLOCKER_SIZES {
let overlay = LiveTranscriptOverlay::new();
let area = Rect::new(0, 0, w, h);
let mut buf = Buffer::empty(area);
for y in 0..h {
for x in 0..w {
buf[(x, y)].set_symbol("X");
}
}
let mut stack = ViewStack::new();
stack.push(overlay);
stack.render(area, &mut buf);
let rows: Vec<String> = (0..h)
.map(|y| (0..w).map(|x| buf[(x, y)].symbol().to_string()).collect())
.collect();
let text = rows.join("\n");
for label in ["scroll", "page", "top/bottom", "resume tail", "close"] {
assert!(text.contains(label), "{w}x{h}: footer missing '{label}'");
}
assert!(!text.contains('X'), "{w}x{h}: background bleed-through");
assert_eq!(
buf[(w / 2, h / 2)].bg,
palette::WHALE_BG,
"{w}x{h}: modal interior must be opaque"
);
for (y, row) in rows.iter().enumerate() {
assert!(
UnicodeWidthStr::width(row.trim_end()) <= w as usize,
"{w}x{h}: row {y} overflows width: {row:?}"
);
}
}
}
#[test]
fn backtrack_preview_out_of_range_does_not_panic() {
let mut v = LiveTranscriptOverlay::new();
install_snapshots(&mut v, vec![user("only")]);
v.set_backtrack_preview(99);
let area = Rect::new(0, 0, 40, 10);
let mut buf = Buffer::empty(area);
v.render(area, &mut buf);
}
}