From 4d80c3aece596cfdef490d7fa7468dc81e9c191a Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Mon, 5 Oct 2026 22:34:02 -0500 Subject: [PATCH 1/3] perf(tui): wrap the transcript lazily so resize and resume stay fast A width change invalidated and re-wrapped every transcript block before the next frame could paint, and the first paint of a resumed session and a replay into a painted window wrapped the whole history the same way, so each of those frames grew with the session's length. Every frame also cloned each visible line twice (into a frame-sized buffer, then into the paragraph that painted it), and each wrapped row kept its own copy of the text it showed. Blocks now keep the painted width of each source line, measured once at push, and size themselves from it at any width: an estimate that is exact unless a line wraps and never exceeds the wrap. A width change, a first paint, or a replay re-measures every block and wraps only the ones the viewport shows; the rest are wrapped as they scroll into view. While an estimate turns exact, the offset grows with any block at or below the edge it names, and scrolls, folds, and removals wrap the rows they cross first, so every frame paints what wrapping everything up front would. Rows are painted straight from the block caches into the frame buffer by a line painter that matches the paragraph cell for cell, and a wrapped row's text is a byte range of its block's selectable text instead of a copy. --- crates/tui/src/app.rs | 476 +++++++++++++++---- crates/tui/src/main.rs | 51 ++ crates/tui/src/ui/text.rs | 110 +++++ crates/tui/src/ui/transcript.rs | 819 +++++++++++++++++++++++++------- 4 files changed, 1194 insertions(+), 262 deletions(-) diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index bf2aa6c9..3473df7e 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -28,6 +28,7 @@ use crate::input::{Composer, ComposerAction}; use crate::theme; use crate::ui::sessions as sessions_ui; use crate::ui::tool_card::{self, DiffText}; +use crate::ui::text::paint_line; use crate::ui::transcript::{ wrap_lines, Term, Transcript, }; @@ -144,7 +145,8 @@ fn paint_pacing( } } -/// Fine-grained redraw reasons so spinner ticks can skip history rebuilds. +/// Fine-grained redraw reasons, so token and spinner frames can reuse the +/// header and status lines. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] struct Dirty { /// Finished transcript (blocks, scroll, selection, fold). @@ -197,18 +199,6 @@ impl Dirty { } } -/// Viewport fingerprint for reusing the history portion of `chat_buf`. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct HistReuseKey { - hist_len: usize, - start: usize, - hist_view_end: usize, - sticky: bool, - focus_scroll: bool, - selected: Option, - width: u16, -} - /// Chat-mode geometry. The input owns the terminal's bottom edge; every /// transient surface grows upward into the conversation plane. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -431,11 +421,8 @@ pub struct App { /// running metadata, so a token only rebuilds that suffix. tail_stable_len: usize, tail_buf: Vec>, - chat_buf: Vec>, - /// Lines in `chat_buf` that are sticky + history (before live tail). - hist_prefix_len: usize, - hist_reuse_key: Option, - /// Absolute transcript line for each rendered row in `chat_buf`. + /// Absolute transcript line for each painted conversation row (None for + /// the sticky header and the live tail). chat_line_map: Vec>, chat_draw_area: Rect, /// Where the composer text last painted, so the wheel and the mouse can @@ -722,9 +709,6 @@ impl App { tail_content_len: 0, tail_stable_len: 0, tail_buf: Vec::new(), - chat_buf: Vec::new(), - hist_prefix_len: 0, - hist_reuse_key: None, chat_line_map: Vec::new(), chat_draw_area: Rect::default(), composer_draw_area: Rect::default(), @@ -942,8 +926,6 @@ impl App { self.tail_content_len = 0; self.tail_stable_len = 0; self.tail_buf.clear(); - self.hist_prefix_len = 0; - self.hist_reuse_key = None; self.chat_line_map.clear(); self.chat_draw_area = Rect::default(); self.composer_draw_area = Rect::default(); @@ -3489,8 +3471,9 @@ impl App { /// Finished transcript plus the live tail, bottom anchored, honoring the /// scroll offset (0 follows the latest output). /// - /// When only the live tail is dirty (spinner / tokens), the history prefix - /// of `chat_buf` is reused and the tail is re-stitched. + /// Only the blocks the viewport shows are wrapped, and every row is + /// painted straight from its block's cache or the tail: a frame clones + /// no lines. fn draw_chat(&mut self, frame: &mut Frame, area: Rect) { let layout_started = Instant::now(); let mut content_w = area.width; @@ -3501,7 +3484,6 @@ impl App { self.perf_selection_ms = 0.0; return; } - let chat_dirty = self.dirty.chat; // Start from the previous frame's scrollbar decision. Re-deciding // from the full width on every paint re-wrapped the entire @@ -3511,7 +3493,7 @@ impl App { if self.scrollbar_reserved && area.width > 1 { content_w = area.width - 1; } - // A width change re-wraps every block, and the bottom-anchored + // A width change re-measures every block, and the bottom-anchored // offset would resolve to different content afterward. Anchor the // history line at the viewport bottom by content and restore it // after the re-wrap; positions inside the live tail keep the @@ -3527,17 +3509,20 @@ impl App { self.transcript.set_width(content_w); let mut tail_len = self.rebuild_tail(content_w); - let mut hist_len = self.transcript.len(); - let mut total = hist_len + tail_len; let visible = area.height as usize; + // Whether history overflows is exact even though most blocks are + // only estimated: an estimate never exceeds the wrap, and history + // short enough to fit is wrapped whole to count it. + let total = self + .transcript + .len_exact_up_to(visible.saturating_sub(tail_len)) + + tail_len; if total > visible && content_w == area.width && area.width > 1 { // Overflow began: rewrap once with a dedicated one-cell track. self.scrollbar_reserved = true; content_w = area.width - 1; self.transcript.set_width(content_w); tail_len = self.rebuild_tail(content_w); - hist_len = self.transcript.len(); - total = hist_len + tail_len; } else if total <= visible && content_w < area.width { // Fits again at the narrowed width, so it also fits at the full // width (a wider wrap never yields more lines): reclaim the @@ -3546,8 +3531,6 @@ impl App { content_w = area.width; self.transcript.set_width(content_w); tail_len = self.rebuild_tail(content_w); - hist_len = self.transcript.len(); - total = hist_len + tail_len; } // Keep a scrolled-up reader stationary as the live tail changes. // History pushes already bump the offset; the tail below history @@ -3563,11 +3546,12 @@ impl App { } self.last_content_w = content_w; self.last_tail_len = tail_len; + // Wrap what the view shows, now that the offset says where it is. + self.transcript.settle_view(visible, tail_len); + let hist_len = self.transcript.len(); + let total = hist_len + tail_len; if total == 0 && self.pending_approval.is_none() { - self.chat_buf.clear(); self.chat_line_map.clear(); - self.hist_prefix_len = 0; - self.hist_reuse_key = None; self.chat_draw_area = Rect::default(); self.perf_layout_ms = layout_started.elapsed().as_secs_f64() * 1000.0; self.perf_selection_ms = 0.0; @@ -3582,66 +3566,23 @@ impl App { let end = total - offset; let start = end.saturating_sub(visible); - // Fingerprint sticky presence without cloning spans; clone only if we rebuild. - let has_sticky = offset > 0 && self.transcript.has_sticky_user(start); - let focus_scroll = self.focus == Focus::Scrollback; - let selected = self.transcript.selected(); - let hist_view_end = end.min(hist_len); - let reuse_key = HistReuseKey { - hist_len, - start, - hist_view_end, - sticky: has_sticky, - focus_scroll, - selected, - width: content_w, - }; - - let rebuild_hist = chat_dirty - || self.hist_reuse_key != Some(reuse_key) - || self.hist_prefix_len > self.chat_buf.len(); - - if rebuild_hist { - self.chat_buf.clear(); - self.chat_line_map.clear(); - // One clone of sticky spans: take ownership and insert the gutter. - if has_sticky { - if let Some(mut s) = self.transcript.sticky_user_line(start) { - s.spans - .insert(0, Span::styled("❯ ", Style::default().fg(theme::DIM()))); - self.chat_buf.push(s); - self.chat_line_map.push(None); - } - } - let budget = visible.saturating_sub(self.chat_buf.len()); - let view_end = start.saturating_add(budget).min(hist_view_end); - let selected_bi = if focus_scroll { selected } else { None }; - // Single clone per viewport history line (reuse path skips this). - self.transcript - .fill_viewport(&mut self.chat_buf, start, view_end, selected_bi); - self.chat_line_map.extend((start..view_end).map(Some)); - self.hist_prefix_len = self.chat_buf.len(); - self.hist_reuse_key = Some(reuse_key); - } else { - self.chat_buf.truncate(self.hist_prefix_len); - self.chat_line_map.truncate(self.hist_prefix_len); - } - - // Stitch visible tail after the history prefix. - let budget = visible.saturating_sub(self.chat_buf.len()); - let mut idx = start.max(hist_len); - let mut taken = 0usize; - while taken < budget && idx < end { - let ti = idx - hist_len; - if ti < self.tail_buf.len() { - self.chat_buf.push(self.tail_buf[ti].clone()); - self.chat_line_map.push(None); - } - idx += 1; - taken += 1; - } + // Rows top to bottom: the sticky user header while scrolled away + // from it, the history in view, then the live tail below it. + let sticky_rows = usize::from(offset > 0 && self.transcript.has_sticky_user(start)); + let view_end = start + .saturating_add(visible - sticky_rows) + .min(end.min(hist_len)); + let history_rows = view_end.saturating_sub(start); + let tail_rows = (start.max(hist_len)..end) + .take(visible - sticky_rows - history_rows) + .count(); + let rows = sticky_rows + history_rows + tail_rows; + self.chat_line_map.clear(); + self.chat_line_map.extend(std::iter::repeat_n(None, sticky_rows)); + self.chat_line_map.extend((start..view_end).map(Some)); + self.chat_line_map.extend(std::iter::repeat_n(None, tail_rows)); - let pad = area.height.saturating_sub(self.chat_buf.len() as u16); + let pad = area.height.saturating_sub(rows as u16); let draw_area = Rect { x: area.x, y: area.y + pad, @@ -3649,7 +3590,41 @@ impl App { height: area.height - pad, }; self.chat_draw_area = draw_area; - Paragraph::new(self.chat_buf.as_slice()).render(draw_area, frame.buffer_mut()); + let row_at = |row: usize| Rect { + y: draw_area.y + row as u16, + height: 1, + ..draw_area + }; + if sticky_rows > 0 { + if let Some(line) = self.transcript.sticky_user_line(start) { + let gutter = Span::styled("❯ ", Style::default().fg(theme::DIM())); + paint_line(frame.buffer_mut(), row_at(0), Some(&gutter), line); + } + } + let selected_bi = if self.focus == Focus::Scrollback { + self.transcript.selected() + } else { + None + }; + self.transcript.paint_rows( + frame.buffer_mut(), + Rect { + height: history_rows as u16, + ..row_at(sticky_rows) + }, + start, + view_end, + selected_bi, + ); + let tail_from = start.max(hist_len) - hist_len; + for (row, line) in self.tail_buf[tail_from..tail_from + tail_rows].iter().enumerate() { + paint_line( + frame.buffer_mut(), + row_at(sticky_rows + history_rows + row), + None, + line, + ); + } self.perf_layout_ms = layout_started.elapsed().as_secs_f64() * 1000.0; let selection_started = Instant::now(); @@ -6412,6 +6387,50 @@ mod tests { } } + /// First-paint, resize, and steady-frame cost at ten thousand blocks: + /// the frames that wrapped the whole history before blocks wrapped + /// lazily, and the frame that cloned every visible row. Not a + /// correctness test; run with: + /// cargo test -p openmax --bin openmax --release -- --ignored --nocapture measure_resize_and_first_paint + #[test] + #[ignore] + fn measure_resize_and_first_paint_at_scale() { + use std::time::Instant; + const ROWS: u16 = 45; + let (mut app, dir) = app_fixture(); + push_long_history(&mut app, 10_000); + let frame_at = |app: &mut App, width: u16| { + let mut terminal = Terminal::new(TestBackend::new(width, ROWS)).unwrap(); + app.dirty.mark_chat(); + let allocations = crate::allocations(); + let started = Instant::now(); + terminal.draw(|frame| app.draw(frame)).unwrap(); + let ms = started.elapsed().as_secs_f64() * 1e3; + (ms, crate::allocations() - allocations) + }; + let (first_ms, first_allocs) = frame_at(&mut app, 140); + // A window dragged narrower and wider: every frame a new width. + let mut resize = Vec::new(); + for width in [120, 139, 100, 141, 90, 133, 72, 140, 110, 125] { + resize.push(frame_at(&mut app, width)); + } + let mut steady = Vec::new(); + for _ in 0..50 { + steady.push(frame_at(&mut app, 125)); + } + let worst = |samples: &[(f64, u64)]| { + samples.iter().fold((0f64, 0u64), |(ms, n), &(m, a)| (ms.max(m), n.max(a))) + }; + let (resize_ms, resize_allocs) = worst(&resize); + let (steady_ms, steady_allocs) = worst(&steady); + println!( + "MEASURE 10000 blocks, {ROWS} rows: first paint {first_ms:.3} ms / {first_allocs} allocs, \ + resize max {resize_ms:.3} ms / {resize_allocs} allocs, \ + steady max {steady_ms:.3} ms / {steady_allocs} allocs" + ); + fs::remove_dir_all(dir).unwrap(); + } + /// The approval timeout is persisted without the `Error:` prefix /// (agent::tool_message_content keeps it verbatim so the model reads the /// stop instruction), so replay must classify it as the failure it was: @@ -6776,6 +6795,271 @@ mod tests { fs::remove_dir_all(dir).unwrap(); } + /// A long history, as a resumed marathon session leaves it: prose that + /// wraps at narrow widths, folded and failed tool cards, and notices. + fn push_long_history(app: &mut App, blocks: usize) { + let output = "src/ledger.rs:42: let hash = sha256(&bytes); // a grep or test log line\n" + .repeat(6); + for index in 0..blocks { + match index % 4 { + 0 => app.insert_user_block(&format!( + "turn {index}: inspect the ledger reconciliation and fix the failing test" + )), + 1 => app.transcript.push_assistant(crate::ui::markdown::render(&format!( + "Reply {index} with **markdown** long enough to wrap below a hundred \ + columns, a list:\n\n- alpha item\n- beta item\n\n```rust\nlet value = \ + compute(input);\n```" + ))), + 2 => { + let ok = index % 8 == 2; + let compact = + crate::ui::tool_card::tool_block("bash", "cargo test", ok, &output, None); + app.transcript.push_tool(compact, output.clone(), ok); + } + _ => app.transcript.push(vec![Line::from(format!("history line {index}"))]), + } + } + } + + /// A width change, the first paint of a long history, and a replay into + /// a painted window wrap only the blocks the viewport shows. Wrapping the + /// whole history made each of them linear in the session's length, every + /// block's wrap and its allocations on the UI loop in one frame. Every + /// block is at least one row, so a viewport of `ROWS` rows shows at most + /// `ROWS` blocks. + #[test] + fn resize_first_paint_and_replay_wrap_only_the_blocks_in_view() { + const BLOCKS: usize = 10_000; + const ROWS: u16 = 40; + let (mut app, dir) = app_fixture(); + push_long_history(&mut app, BLOCKS); + + let wraps_during = |app: &mut App, width: u16| { + let before = crate::ui::transcript::block_wraps(); + render_app(app, width, ROWS); + crate::ui::transcript::block_wraps() - before + }; + let first_paint = wraps_during(&mut app, 100); + assert!( + first_paint <= usize::from(ROWS), + "the first paint wrapped {first_paint} of {BLOCKS} blocks" + ); + for width in [72, 130, 99] { + let resize = wraps_during(&mut app, width); + assert!( + resize <= usize::from(ROWS), + "a resize to {width} columns wrapped {resize} of {BLOCKS} blocks" + ); + } + + // Replay pushes every persisted message at the painted width. + let before = crate::ui::transcript::block_wraps(); + push_long_history(&mut app, BLOCKS); + let replay = crate::ui::transcript::block_wraps() - before; + let next_paint = wraps_during(&mut app, 99); + assert!( + replay + next_paint <= usize::from(ROWS), + "a replay wrapped {replay} blocks and its paint {next_paint}" + ); + fs::remove_dir_all(dir).unwrap(); + } + + /// One thing a reader or the agent does to the conversation view. + #[derive(Clone, Copy, Debug)] + enum ViewStep { + Size(u16, u16), + Up(usize), + Down(usize), + Top, + Follow, + Arrive(usize), + Stream, + Done, + Find(usize), + Prev, + Fold, + Expand, + Pop, + Focus, + } + + fn apply_view_step(app: &mut App, step: ViewStep, index: usize) { + match step { + ViewStep::Size(..) => {} + ViewStep::Up(n) => app.transcript.scroll_up(n), + ViewStep::Down(n) => app.transcript.scroll_down(n), + ViewStep::Top => app.transcript.select_first(), + ViewStep::Follow => app.transcript.follow(), + ViewStep::Arrive(kind) => { + let text = format!( + "arrival {index} while scrolled, long enough to wrap at narrow widths" + ); + match kind % 3 { + 0 => app.transcript.push_assistant(crate::ui::markdown::render(&text)), + 1 => app.insert_user_block(&text), + _ => { + let output = format!("{text}\n").repeat(4); + let compact = crate::ui::tool_card::tool_block( + "bash", "cargo test", false, &output, None, + ); + app.transcript.push_tool(compact, output, false); + } + } + } + ViewStep::Stream => app.on_agent_event(AgentEvent::Token { + text: format!("streamed line {index} of a reply that wraps when narrow\n"), + }), + ViewStep::Done => { + let text = app.stream_text.clone(); + app.on_agent_event(AgentEvent::MessageDone { text }); + } + ViewStep::Find(block) => app.transcript.select_find_match(block), + ViewStep::Prev => app.transcript.select_prev(), + ViewStep::Fold => { + app.transcript.toggle_fold_selected(); + } + ViewStep::Expand => { + app.transcript.expand_last_tool(); + } + ViewStep::Pop => { + app.transcript.pop_last_user(); + } + ViewStep::Focus => { + app.focus = match app.focus { + Focus::Composer => Focus::Scrollback, + Focus::Scrollback => Focus::Composer, + }; + } + } + } + + /// Paint `steps` into an app that wraps lazily and one that wraps every + /// block up front, both starting from `history` blocks, and require the + /// same frame after every step. Only the scrollbar marker, a proportion + /// of the estimated total, may differ. + fn assert_lazy_paints_like_eager(history: usize, steps: &[ViewStep]) { + let (mut lazy, lazy_dir) = app_fixture(); + let (mut eager, eager_dir) = app_fixture(); + eager.transcript.eager = true; + for app in [&mut lazy, &mut eager] { + push_long_history(app, history); + } + let (mut width, mut height) = (100, 30); + for (index, &step) in steps.iter().enumerate() { + if let ViewStep::Size(w, h) = step { + (width, height) = (w, h); + } + for app in [&mut lazy, &mut eager] { + apply_view_step(app, step, index); + } + // The header row names each fixture's own directory. + let painted = |app: &mut App| -> Vec { + rows(&render_app(app, width, height)) + .into_iter() + .skip(1) + .map(|row| row.replace('▐', " ")) + .collect() + }; + assert_eq!( + painted(&mut lazy), + painted(&mut eager), + "{history} blocks, step {index} ({step:?}) painted differently at {width}x{height}" + ); + } + fs::remove_dir_all(lazy_dir).unwrap(); + fs::remove_dir_all(eager_dir).unwrap(); + } + + /// `count` seeded steps of every kind over a history of `blocks` blocks. + fn random_view_steps(seed: u64, count: usize, blocks: usize) -> Vec { + use ViewStep::*; + let mut seed = seed; + let mut roll = |bound: usize| { + seed = seed + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + (seed >> 33) as usize % bound + }; + (0..count) + .map(|_| match roll(22) { + 0 | 1 => Size(20 + roll(121) as u16, 6 + roll(35) as u16), + 2..=5 => Up(1 + roll(80)), + 6..=9 => Down(1 + roll(80)), + 10 => Top, + 11 => Follow, + 12 => Arrive(roll(3)), + 13 | 14 => Stream, + 15 => Done, + 16 => Find(roll(blocks + 20)), + 17 => Prev, + 18 => Fold, + 19 => Expand, + 20 => Pop, + _ => Focus, + }) + .collect() + } + + /// Lazy wrapping is invisible: across scrolls, resizes, arrivals while + /// scrolled up, a streamed reply, find, block navigation, folds, and a + /// removed prompt, every frame paints exactly what wrapping every block + /// up front paints. Blocks off screen count their estimated heights, so + /// this pins that a scroll moves by exactly the rows it names and that + /// an estimate turning exact never moves what a scrolled reader is + /// looking at. A scripted walk, then seeded random ones over a long + /// history and over one short enough to fit on screen. + #[test] + fn lazy_wrapping_paints_exactly_what_eager_wrapping_paints() { + use ViewStep::*; + let scripted = [ + Size(100, 30), Up(45), Size(40, 30), Down(3), Down(3), Down(3), Down(9), + Up(200), Size(72, 24), Top, Down(7), Size(120, 24), Down(13), Size(50, 24), + Arrive(0), Arrive(1), Arrive(2), Down(20), Down(20), Down(20), Stream, Up(30), + Stream, Size(64, 18), Stream, Down(5), Done, Down(40), Down(40), Follow, + Size(90, 20), Up(100), Size(33, 20), Down(25), Follow, Stream, Stream, + Focus, Find(37), Prev, Prev, Fold, Size(45, 16), Find(150), Done, Follow, + ]; + assert_lazy_paints_like_eager(400, &scripted); + for seed in [1, 2] { + assert_lazy_paints_like_eager(400, &random_view_steps(seed, 300, 400)); + assert_lazy_paints_like_eager(6, &random_view_steps(seed, 300, 6)); + } + } + + /// Heap allocations of one frame that repaints the conversation, after + /// earlier frames settled wrapping, the scrollbar, and every cache. + fn repaint_allocations(app: &mut App, width: u16, height: u16) -> u64 { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + for _ in 0..2 { + terminal.draw(|frame| app.draw(frame)).unwrap(); + app.dirty.clear(); + } + app.dirty.mark_chat(); + let before = crate::allocations(); + terminal.draw(|frame| app.draw(frame)).unwrap(); + crate::allocations() - before + } + + /// A steady frame paints the visible rows straight from the block + /// caches. Cloning every visible line into a frame-sized vector, and + /// again into the paragraph that painted it, cost allocations per + /// visible row on every frame. So a taller window may not allocate more + /// per frame than a short one, and a frame allocates less than once per + /// row it shows. + #[test] + fn steady_frames_allocate_nothing_per_visible_row() { + let (mut app, dir) = app_fixture(); + push_long_history(&mut app, 10_000); + let short = repaint_allocations(&mut app, 100, 20); + let tall = repaint_allocations(&mut app, 100, 80); + assert!( + tall <= short, + "80 rows allocated {tall} times per frame, 20 rows {short}" + ); + assert!(tall < 80, "a frame showing 80 rows allocated {tall} times"); + fs::remove_dir_all(dir).unwrap(); + } + #[test] fn growing_taller_releases_the_scrollbar_column_with_one_rewrap() { let (mut app, dir) = app_fixture(); diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index a72e6d3a..e25f96dd 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -1810,6 +1810,57 @@ fn example_row(verdict: &open_max_core::doctor::ExampleVerdict) -> String { open_max_core::text::one_line(&row) } +/// Heap allocations made by the calling thread, for tests that bound what a +/// frame allocates. Per thread, so tests running in parallel do not count +/// each other's work. +#[cfg(test)] +pub fn allocations() -> u64 { + alloc_count::ALLOCATIONS.with(std::cell::Cell::get) +} + +#[cfg(test)] +mod alloc_count { + use std::alloc::{GlobalAlloc, Layout, System}; + use std::cell::Cell; + + thread_local! { + // Const-initialized with no destructor: reading it never allocates, + // which the allocator itself depends on. + pub static ALLOCATIONS: Cell = const { Cell::new(0) }; + } + + fn count() { + let _ = ALLOCATIONS.try_with(|n| n.set(n.get() + 1)); + } + + pub struct Counting; + + unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + count(); + unsafe { System.alloc(layout) } + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + count(); + unsafe { System.alloc_zeroed(layout) } + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + count(); + unsafe { System.realloc(ptr, layout, new_size) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } + } +} + +#[cfg(test)] +#[global_allocator] +static COUNTING_ALLOCATOR: alloc_count::Counting = alloc_count::Counting; + #[cfg(test)] pub fn test_temp_dir(prefix: &str) -> std::path::PathBuf { use std::sync::atomic::{AtomicU64, Ordering}; diff --git a/crates/tui/src/ui/text.rs b/crates/tui/src/ui/text.rs index 3dd48ab4..61793357 100644 --- a/crates/tui/src/ui/text.rs +++ b/crates/tui/src/ui/text.rs @@ -2,6 +2,10 @@ use std::borrow::Cow; +use ratatui::buffer::{Buffer, CellWidth}; +use ratatui::layout::{Alignment, Rect}; +use ratatui::style::Style; +use ratatui::text::{Line, Span, StyledGrapheme}; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; @@ -88,6 +92,68 @@ fn escape_len(seq: &[u8]) -> Option { } } +/// Paint `line` into the one-row `area` exactly as an unwrapped +/// `Paragraph` holding it paints it, from a borrowed line. A paragraph +/// takes owned text, so painting a frame's rows through one cloned each row +/// on every frame. `prefix` is painted as though it led the line, as a +/// gutter inserted into a clone of it would be. +pub fn paint_line(buf: &mut Buffer, area: Rect, prefix: Option<&Span<'_>>, line: &Line<'_>) { + let area = area.intersection(buf.area); + if area.is_empty() { + return; + } + // A span inside a line is styled over the line's own style. + let line_style = Style::default().patch(line.style); + let graphemes = || { + prefix + .into_iter() + .flat_map(move |span| span.styled_graphemes(line_style)) + .chain(line.styled_graphemes(Style::default())) + }; + let max = usize::from(area.width); + // The paragraph keeps graphemes up to the first one that would overflow + // the row, skipping any wider than the whole row, then aligns what it + // kept. + let mut x = match line.alignment.unwrap_or(Alignment::Left) { + Alignment::Left => 0, + alignment => { + let mut used = 0usize; + for grapheme in graphemes() { + let width = usize::from(grapheme.symbol.cell_width()); + if width > max { + continue; + } + if used + width > max { + break; + } + used += width; + } + let used = used as u16; + match alignment { + Alignment::Center => (area.width / 2).saturating_sub(used / 2), + _ => area.width.saturating_sub(used), + } + } + }; + let mut used = 0usize; + for StyledGrapheme { symbol, style } in graphemes() { + let width = usize::from(symbol.cell_width()); + if width > max { + continue; + } + if used + width > max { + break; + } + used += width; + if width == 0 { + continue; + } + let symbol = if symbol.is_empty() { " " } else { symbol }; + buf[(area.x + x, area.y)].set_symbol(symbol).set_style(style); + x += width as u16; + } +} + /// Clip to `max` terminal cells without splitting a grapheme cluster. pub fn clip(text: &str, max: usize) -> String { if max == 0 { @@ -195,6 +261,50 @@ pub fn line_bounds(text: &str, offset: usize) -> (usize, usize) { mod tests { use super::*; + /// `paint_line` must paint every cell exactly as the paragraph it + /// replaced: wide and zero-width graphemes, controls the renderer drops, + /// overflow, a wide grapheme straddling the edge, line and span styles, + /// alignment, and a gutter prefix. + #[test] + fn paint_line_paints_exactly_what_a_paragraph_paints() { + use ratatui::style::{Color, Modifier}; + use ratatui::widgets::{Paragraph, Widget}; + + let red = Style::default().fg(Color::Red); + let on_blue = Style::default().bg(Color::Blue).add_modifier(Modifier::BOLD); + let lines = vec![ + Line::from("plain ascii text"), + Line::from("a line far wider than the narrowest row it is painted into"), + Line::from(vec![Span::styled("漢字", red), Span::raw("ab漢"), Span::styled("字z", on_blue)]), + Line::from("e\u{301}x 👩\u{200d}💻 tab\there esc\u{1b}[31mred\u{7}bell"), + Line::from(vec![Span::raw("joined e"), Span::styled("\u{301} across spans", red)]), + Line::from(vec![Span::raw("styled line"), Span::raw(" two")]).style(on_blue), + Line::from("centered 漢字").alignment(Alignment::Center), + Line::from("right 字").alignment(Alignment::Right), + Line::from("too wide to center at all here").alignment(Alignment::Center), + Line::from(""), + Line::default().style(on_blue), + ]; + let gutter = Span::styled("❯ ", Style::default().fg(Color::DarkGray)); + for line in &lines { + for width in [1u16, 2, 3, 7, 12, 40, 80] { + for prefix in [None, Some(&gutter)] { + let area = Rect::new(2, 1, width, 1); + let canvas = Rect::new(0, 0, width + 4, 3); + let mut expected = Buffer::empty(canvas); + let mut owned = line.clone(); + if let Some(prefix) = prefix { + owned.spans.insert(0, prefix.clone()); + } + Paragraph::new(vec![owned]).render(area, &mut expected); + let mut painted = Buffer::empty(canvas); + paint_line(&mut painted, area, prefix, line); + assert_eq!(painted, expected, "{line:?} at {width} columns, prefix {prefix:?}"); + } + } + } + } + #[test] fn clip_obeys_cell_width_and_grapheme_boundaries() { assert_eq!(clip("abcdef", 4), "abc…"); diff --git a/crates/tui/src/ui/transcript.rs b/crates/tui/src/ui/transcript.rs index 95e900a6..2a99cf2e 100644 --- a/crates/tui/src/ui/transcript.rs +++ b/crates/tui/src/ui/transcript.rs @@ -4,7 +4,16 @@ //! per-block wrap caches. Tools fold by default; selection and sticky user //! headers support dual-focus navigation. Scroll offset is in wrapped lines //! from the bottom; 0 follows the latest output. - +//! +//! Blocks wrap lazily. A width change, a first paint, or a replay sizes +//! every block from its source lines' measured widths (an estimate that is +//! exact unless a line wraps, and never more than the wrap produces) and +//! wraps only the blocks the viewport shows; the rest are wrapped when they +//! scroll into view. Wrapping the whole history on each of those made them +//! linear in the session's length. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; use ratatui::prelude::CrosstermBackend; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; @@ -13,7 +22,7 @@ use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthChar; use crate::theme; -use crate::ui::text::{cell_width, strip_escapes, TAB_WIDTH}; +use crate::ui::text::{cell_width, paint_line, strip_escapes, TAB_WIDTH}; /// Frames go through one large buffer per flush: bare `Stdout` is /// line-buffered at 1 KiB, which turns a busy streaming frame into dozens of @@ -44,22 +53,100 @@ struct Block { /// allocation per key. Fold-independent (it reads compact/full_output/ /// raw, never the folded view), so no invalidation path exists. search_lower: String, + /// Cell widths of `raw` and `compact`, measured once at push: what + /// sizes the block at a width it has not been wrapped at. + raw_cells: LineCells, + compact_cells: LineCells, + /// Rows the block takes in the index at the transcript's width: the + /// length of `cache` once it is wrapped there, the estimate until then. + height: usize, cache_width: u16, cache_folded: bool, cache: Vec>, cache_maps: Vec>, + /// Plain text of the source lines shown (`compact` while folded), which + /// selection offsets and every wrapped row's map point into. selectable: String, selectable_chars: usize, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] struct CachedLineMap { - /// Character offsets into `Block::selectable_text`. + /// Character offsets into `Block::selectable`. start: usize, end: usize, + /// The same range in bytes, so a row's text is a slice of `selectable` + /// rather than a third copy of the block's text. + byte_start: usize, + byte_end: usize, /// Terminal column where selectable content starts after UI gutters. x_offset: usize, - text: String, +} + +/// Painted width of each source line, measured the way the wrapper measures +/// it, so a block can be sized at any width without wrapping it. +#[derive(Default)] +struct LineCells { + cells: Box<[u32]>, + widest: u32, +} + +impl LineCells { + fn measure(lines: &[Line<'static>]) -> Self { + let cells: Box<[u32]> = lines + .iter() + .map(|line| u32::try_from(line_cells(line)).unwrap_or(u32::MAX)) + .collect(); + let widest = cells.iter().copied().max().unwrap_or(0); + Self { cells, widest } + } + + /// Rows these lines wrap to at `width` columns, never more than + /// `wrap_lines_mapped` produces: a row holds at most `width` measured + /// cells and a line takes at least one row. Exact when every line fits, + /// which is the common case and costs no per-line work. + fn rows(&self, width: usize) -> usize { + if self.widest as usize <= width { + return self.cells.len(); + } + self.cells + .iter() + .map(|&cells| (cells as usize).div_ceil(width).max(1)) + .sum() + } +} + +/// Cells `wrap_lines_mapped` counts for one line: a tab as [`TAB_WIDTH`], +/// each other character (ASCII) or grapheme (otherwise) as it paints. A +/// grapheme counts at most a tab's width. Every row has room for that much, +/// so only a malformed wider cluster can overflow a row, and it gets a row +/// to itself: counting it whole could size the block above what the wrap +/// produces. +fn line_cells(line: &Line<'_>) -> usize { + if line.spans.iter().all(|span| span.content.is_ascii()) { + return line + .spans + .iter() + .flat_map(|span| span.content.chars()) + .map(|ch| if ch == '\t' { TAB_WIDTH } else { ch.width().unwrap_or(0) }) + .sum(); + } + // Graphemes can join across span boundaries, so measure the joined line + // exactly as the wrapper splits it. + let plain: String = line.spans.iter().map(|span| span.content.as_ref()).collect(); + plain + .graphemes(true) + .map(|grapheme| cell_width(grapheme).min(TAB_WIDTH)) + .sum() +} + +/// Columns a block's source lines wrap to at transcript width `width`. +fn content_width(kind: BlockKind, width: u16) -> u16 { + let gutter = match kind { + BlockKind::User => 2, + BlockKind::Assistant | BlockKind::Tool | BlockKind::System => 0, + }; + width.saturating_sub(gutter).max(8) } #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] @@ -105,6 +192,14 @@ fn lower_for_search(text: &str) -> String { #[cfg(test)] thread_local! { static SEARCH_BUILDS: std::cell::Cell = const { std::cell::Cell::new(0) }; + static BLOCK_WRAPS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Blocks wrapped on this thread so far: the oracle for the promise that a +/// resize, a first paint, or a replay wraps only what the viewport shows. +#[cfg(test)] +pub(crate) fn block_wraps() -> usize { + BLOCK_WRAPS.with(std::cell::Cell::get) } impl Block { @@ -114,11 +209,14 @@ impl Block { let search_lower = lower_for_search(&selectable); Self { kind, + raw_cells: LineCells::measure(&raw), + compact_cells: LineCells::default(), raw, compact: None, folded: false, full_output: None, search_lower, + height: 0, cache_width: 0, cache_folded: false, cache: Vec::new(), @@ -133,8 +231,7 @@ impl Block { /// keystroke (the card's one clipped diagnostic line rarely is the /// reason). The user can fold either state back by hand. fn tool(compact: Vec>, full_output: String, ok: bool) -> Self { - let selectable = lines_to_plain(&compact); - let selectable_chars = selectable.chars().count(); + let header_text = lines_to_plain(&compact); // Search covers the compact header plus the whole output, matching // what `search_text_line` reads back out; the folded view is not // part of it. The output is searched as the screen shows it, or a @@ -142,10 +239,10 @@ impl Block { // for the hidden code matched. Stripping never removes a newline, so // line slots still agree with the raw output. let visible = strip_escapes(&full_output); - let search_lower = if selectable.is_empty() { + let search_lower = if header_text.is_empty() { lower_for_search(&visible) } else { - lower_for_search(&format!("{selectable}\n{visible}")) + lower_for_search(&format!("{header_text}\n{visible}")) }; let header = compact .first() @@ -169,13 +266,24 @@ impl Block { .add_modifier(Modifier::ITALIC), ))); } + // The copy text is whichever view is on screen: the card while + // folded, the output while open. + let selectable = if ok { + header_text + } else { + lines_to_plain(&full_lines) + }; + let selectable_chars = selectable.chars().count(); Self { kind: BlockKind::Tool, + raw_cells: LineCells::measure(&full_lines), + compact_cells: LineCells::measure(&compact), raw: full_lines, compact: Some(compact), folded: ok, full_output: Some(full_output), search_lower, + height: 0, cache_width: 0, cache_folded: ok, cache: Vec::new(), @@ -194,19 +302,48 @@ impl Block { &self.raw } + /// Whether `cache` holds this block wrapped at `width` as it is now + /// folded. + fn is_wrapped_at(&self, width: u16) -> bool { + self.cache_width == width && self.cache_folded == self.folded && !self.cache.is_empty() + } + + /// Rows the block takes at transcript width `width`: exact once + /// wrapped there, otherwise the estimate from its measured line widths + /// (plus the spacer row), which never exceeds what the wrap produces. + fn measure(&self, width: u16) -> usize { + if width == 0 { + return 0; + } + if self.is_wrapped_at(width) { + return self.cache.len(); + } + let cells = if self.folded && self.compact.is_some() { + &self.compact_cells + } else { + &self.raw_cells + }; + cells.rows(usize::from(content_width(self.kind, width))) + 1 + } + + /// Show the block folded or open. The copy text follows the view; the + /// wrap does too, at the next `ensure_cache`. + fn set_folded(&mut self, folded: bool) { + self.folded = folded; + self.selectable = lines_to_plain(self.source_lines()); + self.selectable_chars = self.selectable.chars().count(); + self.invalidate(); + } + fn ensure_cache(&mut self, width: u16) { - if self.cache_width == width && self.cache_folded == self.folded && !self.cache.is_empty() { + if self.is_wrapped_at(width) { return; } + #[cfg(test)] + BLOCK_WRAPS.with(|counter| counter.set(counter.get() + 1)); self.cache_width = width; self.cache_folded = self.folded; - self.selectable = lines_to_plain(self.source_lines()); - self.selectable_chars = self.selectable.chars().count(); - let gutter = match self.kind { - BlockKind::User => 2, - BlockKind::Assistant | BlockKind::Tool | BlockKind::System => 0, - }; - let content_width = width.saturating_sub(gutter).max(8); + let content_width = content_width(self.kind, width); let (wrapped, maps) = wrap_lines_mapped(self.source_lines(), content_width); self.cache.clear(); self.cache_maps.clear(); @@ -244,10 +381,16 @@ pub struct Transcript { blocks: Vec, /// First wrapped line of each block; `total` closes the last range. The /// per-block caches own the only copy of every wrapped line, so - /// rebuilding after a fold toggle or width change is index arithmetic, - /// never a clone of the session's lines. + /// re-measuring after a fold toggle or width change is index + /// arithmetic, never a clone of the session's lines. A block not yet + /// wrapped at the current width counts its estimated height. block_starts: Vec, - /// Wrapped lines across all block caches. + /// Entries of `block_starts` from this block on are stale. A height + /// change marks only its own suffix, so wrapping the blocks at the + /// bottom of history never walks the whole index. + stale_from: usize, + /// Rows across all blocks (sum of their heights), kept current by every + /// height change. total: usize, width: u16, offset: usize, @@ -258,12 +401,16 @@ pub struct Transcript { unread: usize, selected: Option, text_selection: Option, - dirty: bool, - /// Full re-wraps performed (every block invalidated by a width change). - /// Oracle for the draw path's promise that steady-state frames never - /// re-wrap history. + /// Rows and live-tail rows of the viewport the last frame settled. + view: (usize, usize), + /// Width changes, each of which re-measures every block. Oracle for the + /// draw path's promise that steady-state frames never change the width. #[cfg(test)] pub(crate) rewraps: u64, + /// Wrap every block as soon as it is pushed or the width changes: the + /// eager shape, which tests hold the lazy one against. + #[cfg(test)] + pub(crate) eager: bool, } impl Transcript { @@ -286,21 +433,29 @@ impl Transcript { if lines.is_empty() { return; } - if self.width == 0 { - self.blocks.push(Block::new(kind, lines)); - self.dirty = true; - return; - } - if self.dirty { - self.ensure_index(); + self.push_block(Block::new(kind, lines)); + } + + /// Index a new last block by its estimated height; it is wrapped when a + /// frame shows it. While scrolled up the view holds still, so the offset + /// grows by the rows the block adds below it. With the view's bottom + /// edge inside the live tail, those rows move the tail under the edge, + /// so they are counted exactly. + fn push_block(&mut self, block: Block) { + let bi = self.blocks.len(); + let wrap_now = self.offset > 0 && self.offset < self.view.1; + #[cfg(test)] + let wrap_now = wrap_now || self.eager; + self.blocks.push(block); + if wrap_now && self.width > 0 { + self.blocks[bi].ensure_cache(self.width); } - let prev_len = self.total; - self.blocks.push(Block::new(kind, lines)); - let bi = self.blocks.len() - 1; - self.append_block_index(bi); - if self.offset > 0 { - let added = self.total.saturating_sub(prev_len); - self.offset = self.offset.saturating_add(added); + let height = self.blocks[bi].measure(self.width); + self.blocks[bi].height = height; + self.total += height; + self.mark_stale(bi); + if self.width > 0 && self.offset > 0 { + self.offset = self.offset.saturating_add(height); self.unread += 1; } } @@ -316,7 +471,14 @@ impl Transcript { let Some(i) = self.blocks.iter().rposition(|b| b.kind == BlockKind::User) else { return false; }; - self.blocks.remove(i); + // The view keeps its offset (the draw path clamps it once the view + // is measured), so it moves by the rows removed. + let held = self.held_block(); + self.wrap_held(i, held); + self.wrap_across(i, held, -(self.blocks[i].height as isize)); + let removed = self.blocks.remove(i); + self.total -= removed.height; + self.mark_stale(i); // Selection / sticky indices past the removed block must retreat. if let Some(sel) = self.selected { if sel == i { @@ -343,9 +505,6 @@ impl Transcript { }); } } - self.dirty = true; - self.ensure_index(); - self.offset = self.offset.min(self.total); true } @@ -355,25 +514,12 @@ impl Transcript { /// `ok` picks the initial fold state; see [`Block::tool`]. pub fn push_tool(&mut self, compact: Vec>, full_output: String, ok: bool) { - if self.width == 0 { - self.blocks.push(Block::tool(compact, full_output, ok)); - self.dirty = true; - return; - } - if self.dirty { - self.ensure_index(); - } - let prev_len = self.total; - self.blocks.push(Block::tool(compact, full_output, ok)); - let bi = self.blocks.len() - 1; - self.append_block_index(bi); - if self.offset > 0 { - let added = self.total.saturating_sub(prev_len); - self.offset = self.offset.saturating_add(added); - self.unread += 1; - } + self.push_block(Block::tool(compact, full_output, ok)); } + /// Re-measure every block for a new width. Blocks keep whatever they + /// last wrapped (a block wrapped at this width before needs no work); + /// the rest count their estimates until a frame shows them. pub fn set_width(&mut self, width: u16) { if width != self.width { #[cfg(test)] @@ -381,12 +527,23 @@ impl Transcript { self.rewraps += 1; } self.width = width; - for b in &mut self.blocks { - b.invalidate(); + #[cfg(test)] + if self.eager && width > 0 { + for block in &mut self.blocks { + block.ensure_cache(width); + } } - self.dirty = true; - self.ensure_index(); - self.offset = self.offset.min(self.total); + // The offset is left for the draw path to clamp against the + // total once the view is measured: clamping to an estimate here + // would move a view whose edge is in the live tail by however + // far the estimate is off. + let mut total = 0; + for block in &mut self.blocks { + block.height = block.measure(width); + total += block.height; + } + self.total = total; + self.mark_stale(0); } } @@ -412,17 +569,17 @@ impl Transcript { } /// Wrapped-line distance from the bottom of history to the anchored - /// content, after any re-wraps since capture. + /// content, after any re-wraps since capture. The anchored block is + /// wrapped first, so the distance into it is measured on the rows the + /// frame will paint. pub fn resolve_anchor(&mut self, anchor: WrapAnchor) -> usize { - self.ensure_index(); - let Some(&start) = self.block_starts.get(anchor.block) else { + if anchor.block >= self.blocks.len() { return 0; - }; - let end = self - .block_starts - .get(anchor.block + 1) - .copied() - .unwrap_or(self.total); + } + self.wrap_block(anchor.block); + self.ensure_index(); + let start = self.block_starts[anchor.block]; + let end = start + self.blocks[anchor.block].height; let index = (start + anchor.lines_into_block).min(end.saturating_sub(1)); self.total - index } @@ -433,36 +590,188 @@ impl Transcript { self.offset = lines_from_bottom; } - fn rebuild_index(&mut self) { - self.block_starts.clear(); - self.total = 0; - if self.width == 0 { - self.dirty = false; + fn mark_stale(&mut self, bi: usize) { + self.stale_from = self.stale_from.min(bi); + } + + /// Bring `block_starts` up to date from the first stale block on. + fn ensure_index(&mut self) { + if self.stale_from >= self.blocks.len() && self.block_starts.len() == self.blocks.len() { return; } - for bi in 0..self.blocks.len() { - self.block_starts.push(self.total); - self.blocks[bi].ensure_cache(self.width); - self.total += self.blocks[bi].cache.len(); + let from = self.stale_from.min(self.block_starts.len()).min(self.blocks.len()); + self.block_starts.truncate(from); + let mut row = match from.checked_sub(1) { + Some(prev) => self.block_starts[prev] + self.blocks[prev].height, + None => 0, + }; + for block in &self.blocks[from..] { + self.block_starts.push(row); + row += block.height; } - self.dirty = false; + debug_assert_eq!(row, self.total, "block heights and the row total disagree"); + self.stale_from = self.blocks.len(); } - /// Incrementally append one newly pushed block to the index. - /// `bi` must be the last block; width is unchanged and the index is - /// current. - fn append_block_index(&mut self, bi: usize) { + /// Wrap block `bi` at the current width unless it already is, and + /// return the rows that added: never negative, since an estimate never + /// exceeds the wrap. + fn wrap_block(&mut self, bi: usize) -> isize { + if self.width == 0 || self.blocks[bi].is_wrapped_at(self.width) { + return 0; + } self.blocks[bi].ensure_cache(self.width); - self.block_starts.push(self.total); - self.total += self.blocks[bi].cache.len(); + let before = self.blocks[bi].height; + let height = self.blocks[bi].cache.len(); + self.set_height(bi, height); + height as isize - before as isize + } + + fn set_height(&mut self, bi: usize, height: usize) { + self.total = self.total - self.blocks[bi].height + height; + self.blocks[bi].height = height; + self.mark_stale(bi); + } + + /// Fold or open block `bi`. It is wrapped at once: a fold is toggled on + /// the block in view, and the view keeps its offset across the change, + /// as it does for any content change, so it moves by the rows the fold + /// adds or removes. + fn refold(&mut self, bi: usize, folded: bool) { + let held = self.held_block(); + self.wrap_held(bi, held); + let before = self.blocks[bi].height; + self.blocks[bi].set_folded(folded); + if self + .text_selection + .is_some_and(|selection| selection_contains_block(selection, bi)) + { + self.text_selection = None; + } + if self.width > 0 { + self.blocks[bi].ensure_cache(self.width); + } + let height = self.blocks[bi].measure(self.width); + // Still indexed at its old height: the crossing is measured in the + // layout the view moves from. + self.wrap_across(bi, held, height as isize - before as isize); + self.set_height(bi, height); } - fn ensure_index(&mut self) { - if self.dirty - || (self.block_starts.is_empty() && !self.blocks.is_empty() && self.width > 0) - { - self.rebuild_index(); + /// Wrap the rows the view is about to move across when block `bi` + /// changes height by `delta` under an unchanged offset. Only a block at + /// or below the `held` edge moves the view; it then moves by `delta` + /// rows, which must be wrapped to land where wrapped rows would. + fn wrap_across(&mut self, bi: usize, held: Option, delta: isize) { + if !held.is_some_and(|held| bi >= held) { + return; + } + if delta < 0 { + self.settle(delta.unsigned_abs(), 0); + } else { + self.settle(0, delta.unsigned_abs()); + } + } + + /// Wrap exactly the blocks a `rows`-row viewport shows over a live tail + /// of `tail_len` rows, holding the content on screen still while their + /// estimates turn exact. Remembers the geometry, so a scroll before the + /// next frame knows which rows it crosses. + pub fn settle_view(&mut self, rows: usize, tail_len: usize) { + self.view = (rows, tail_len); + self.settle(0, 0); + } + + /// Wrap the blocks covering the last settled viewport extended by + /// `above` rows over it and `below` rows under it, holding the view + /// still while their estimates turn exact. The offset names an edge, + /// that many rows above the bottom: the bottom of the view, or, scrolled + /// past the top, a row above it. A block that grows at or below that + /// edge must grow the offset with it, and one that ends above it must + /// not; a block straddling it keeps its rows above it, as a resize + /// anchor counts them. So the view leaves the top only once the rows + /// above the edge, all of them in the window by then, are exact. + /// Following needs nothing (the bottom is held by construction), and + /// neither does an edge in the live tail, which history above it cannot + /// move. + /// + /// Estimates never exceed the wrap, so a growing block only pushes + /// others out of the range: the loop wraps at most the blocks the first + /// range covered, never the history beyond it. + fn settle(&mut self, above: usize, below: usize) { + let (rows, tail_len) = self.view; + if self.width == 0 || rows == 0 { + return; + } + loop { + self.ensure_index(); + let total = self.total + tail_len; + let top_offset = total.saturating_sub(rows); + let end = total - self.offset.min(top_offset); + let start = end.saturating_sub(rows); + let from = start.saturating_sub(above); + let to = end.saturating_add(below).min(self.total); + if from >= to { + return; + } + let (Some((first, _)), Some((last, _))) = (self.locate(from), self.locate(to - 1)) + else { + return; + }; + let held = self.held_block(); + let mut wrapped = false; + for bi in first..=last { + wrapped |= self.wrap_held(bi, held); + } + if !wrapped { + return; + } + } + } + + /// The first block at or below the edge the offset names (see + /// [`Self::settle`]); none while following or with the edge in the live + /// tail. + fn held_block(&mut self) -> Option { + if self.offset == 0 { + return None; } + self.ensure_index(); + let edge = (self.total + self.view.1).saturating_sub(self.offset); + self.locate(edge).map(|(bi, _)| bi) + } + + /// Wrap block `bi` unless it already is, growing the offset with it when + /// it sits at or below the `held` block, so the view holds still. + /// Returns whether it wrapped. + fn wrap_held(&mut self, bi: usize, held: Option) -> bool { + if self.width == 0 || self.blocks[bi].is_wrapped_at(self.width) { + return false; + } + let added = self.wrap_block(bi); + if held.is_some_and(|held| bi >= held) { + self.offset = self.offset.saturating_add_signed(added); + } + true + } + + /// Rows in the whole transcript, exact whenever there are at most + /// `limit` of them: the draw path asks whether history fits on screen. + /// Estimates never exceed the wrap, so a total above `limit` is above + /// it once wrapped too; at or below it, blocks are wrapped from the + /// newest until the count is exact or passes `limit`. Every block is at + /// least one row, so that wraps at most `limit + 1` blocks. + pub fn len_exact_up_to(&mut self, limit: usize) -> usize { + if self.total > limit { + return self.total; + } + let held = self.held_block(); + let mut bi = self.blocks.len(); + while self.total <= limit && bi > 0 { + bi -= 1; + self.wrap_held(bi, held); + } + self.total } /// Block owning wrapped line `idx`, and the line's offset into that @@ -471,15 +780,29 @@ impl Transcript { if idx >= self.total { return None; } + debug_assert!( + self.stale_from >= self.blocks.len() && self.block_starts.len() == self.blocks.len(), + "located a line in a stale index" + ); let bi = self.block_starts.partition_point(|&start| start <= idx) - 1; Some((bi, idx - self.block_starts[bi])) } - /// Test-support oracle access: `draw_chat` paints via `fill_viewport`. + /// Wrap every block at the current width. Test-support only: the draw + /// path wraps what the viewport shows. #[cfg(test)] - pub fn lines(&mut self) -> Vec> { + pub fn wrap_all(&mut self) { + for bi in 0..self.blocks.len() { + self.wrap_block(bi); + } self.ensure_index(); + } + + /// Test-support oracle access: `draw_chat` paints via `paint_rows`. + #[cfg(test)] + pub fn lines(&mut self) -> Vec> { let mut out = Vec::new(); + self.wrap_all(); let total = self.total; self.fill_viewport(&mut out, 0, total, None); out @@ -491,14 +814,17 @@ impl Transcript { pub fn line_at(&mut self, idx: usize) -> Option<&Line<'static>> { self.ensure_index(); let (bi, li) = self.locate(idx)?; + self.wrap_block(bi); self.blocks[bi].cache.get(li) } /// Clone history lines `[start, end)` into `out` once each, straight - /// from the per-block caches. + /// from the per-block caches, after wrapping every block. Test-support + /// oracle for what `paint_rows` paints. /// /// When `selected_bi` is `Some`, lines belonging to that block receive a /// quiet background. Text selection is painted later as a buffer overlay. + #[cfg(test)] pub fn fill_viewport( &mut self, out: &mut Vec>, @@ -506,9 +832,9 @@ impl Transcript { end: usize, selected_bi: Option, ) { - self.ensure_index(); + self.wrap_all(); // Prove the index covers every cache mutation instead of trusting - // the dirty flag's call sites: a cache edited without marking dirty + // the call sites: a cache edited without re-measuring its block // would walk the loop below out of bounds. debug_assert!( self.total == self.blocks.iter().map(|b| b.cache.len()).sum::(), @@ -534,6 +860,45 @@ impl Transcript { } } + /// Paint history lines `[start, end)` into the rows of `area` from its + /// top, straight from the per-block caches: nothing is cloned except + /// the rows of `selected_bi`, which receive a quiet background. Text + /// selection is painted later as a buffer overlay. Every block in the + /// range must be wrapped, which [`Self::settle_view`] does for the + /// viewport. + pub fn paint_rows( + &mut self, + buf: &mut Buffer, + area: Rect, + start: usize, + end: usize, + selected_bi: Option, + ) { + self.ensure_index(); + let end = end.min(self.total).min(start.saturating_add(usize::from(area.height))); + let Some((mut bi, mut li)) = self.locate(start) else { + return; + }; + for (y, _) in (area.y..).zip(start..end) { + while li >= self.blocks[bi].height { + bi += 1; + li = 0; + } + let block = &self.blocks[bi]; + debug_assert!(block.is_wrapped_at(self.width), "painted an unwrapped block"); + if let Some(line) = block.cache.get(li).filter(|_| block.is_wrapped_at(self.width)) { + let row = Rect { y, height: 1, ..area }; + if selected_bi == Some(bi) { + let line = surface_line(line.clone(), self.width, theme::BORDER()); + paint_line(buf, row, None, &line); + } else { + paint_line(buf, row, None, line); + } + } + li += 1; + } + } + pub fn len(&mut self) -> usize { self.ensure_index(); self.total @@ -594,11 +959,19 @@ impl Transcript { self.offset } + /// Scroll toward older output by exactly `n` wrapped rows. The rows + /// crossed are wrapped first: counted by estimate, a scroll past history + /// no frame has shown would land on different content than the rows it + /// claims to move. pub fn scroll_up(&mut self, n: usize) { + self.settle(n, 0); self.offset = self.offset.saturating_add(n); } + /// Scroll toward newer output by exactly `n` wrapped rows; see + /// [`Self::scroll_up`]. pub fn scroll_down(&mut self, n: usize) { + self.settle(0, n); self.offset = self.offset.saturating_sub(n); if self.offset == 0 { self.unread = 0; @@ -722,7 +1095,10 @@ impl Transcript { return; } self.selected = Some(0); - self.ensure_index(); + // With a live tail taller than the view, this offset puts the + // bottom edge a tail's height below the top of history: wrap the + // rows it counts through. + self.wrap_rows_from(0, self.view.1); self.offset = self.total; } @@ -731,19 +1107,32 @@ impl Transcript { self.follow(); } + /// Put the end of block `bi` a live tail's height above the bottom of + /// the view. The block and the history rows that height reaches below + /// it are wrapped first, so the frame paints the rows the offset counts. fn scroll_to_block(&mut self, bi: usize) { - self.ensure_index(); - if bi >= self.block_starts.len() { + if bi >= self.blocks.len() { return; } - let end = self - .block_starts - .get(bi + 1) - .copied() - .unwrap_or(self.total); + self.wrap_block(bi); + self.wrap_rows_from(bi + 1, self.view.1); + self.ensure_index(); + let end = self.block_starts[bi] + self.blocks[bi].height; self.offset = self.total.saturating_sub(end); } + /// Wrap blocks from `first` on until they cover `rows` rows. + fn wrap_rows_from(&mut self, first: usize, rows: usize) { + let mut covered = 0; + for bi in first..self.blocks.len() { + if covered >= rows { + break; + } + self.wrap_block(bi); + covered += self.blocks[bi].height; + } + } + pub fn toggle_fold_selected(&mut self) -> bool { let Some(i) = self.selected else { return false; @@ -762,16 +1151,7 @@ impl Transcript { }; self.selected = Some(i); if self.blocks[i].folded { - self.blocks[i].folded = false; - self.blocks[i].invalidate(); - if self - .text_selection - .is_some_and(|selection| selection_contains_block(selection, i)) - { - self.text_selection = None; - } - self.dirty = true; - self.ensure_index(); + self.refold(i, false); true } else { self.toggle_fold_at(i) @@ -779,22 +1159,14 @@ impl Transcript { } fn toggle_fold_at(&mut self, i: usize) -> bool { - let Some(block) = self.blocks.get_mut(i) else { + let Some(block) = self.blocks.get(i) else { return false; }; if block.compact.is_none() { return false; } - block.folded = !block.folded; - block.invalidate(); - if self - .text_selection - .is_some_and(|selection| selection_contains_block(selection, i)) - { - self.text_selection = None; - } - self.dirty = true; - self.ensure_index(); + let folded = !block.folded; + self.refold(i, folded); true } @@ -846,16 +1218,7 @@ impl Transcript { && self.blocks[bi].folded && self.blocks[bi].compact.is_some() { - self.blocks[bi].folded = false; - self.blocks[bi].invalidate(); - if self - .text_selection - .is_some_and(|selection| selection_contains_block(selection, bi)) - { - self.text_selection = None; - } - self.dirty = true; - self.ensure_index(); + self.refold(bi, false); } self.selected = Some(bi); self.scroll_to_block(bi); @@ -1026,12 +1389,29 @@ impl Transcript { } } - fn hit_test(&mut self, line_idx: usize, x: usize) -> Option { + /// Block and column map of wrapped history line `line_idx`. Rows reach + /// here from a painted frame, whose blocks are wrapped; the wrap below + /// serves only callers that address a row no frame has shown. + fn row(&mut self, line_idx: usize) -> Option<(usize, CachedLineMap)> { self.ensure_index(); let (block, li) = self.locate(line_idx)?; - let map = self.blocks[block].cache_maps.get(li)?.as_ref()?; + if !self.blocks[block].is_wrapped_at(self.width) { + let held = self.held_block(); + self.wrap_held(block, held); + } + Some((block, (*self.blocks[block].cache_maps.get(li)?)?)) + } + + /// A row's text: its slice of the block's selectable text. + fn row_text(&self, block: usize, map: CachedLineMap) -> Option<&str> { + self.blocks[block].selectable.get(map.byte_start..map.byte_end) + } + + fn hit_test(&mut self, line_idx: usize, x: usize) -> Option { + let (block, map) = self.row(line_idx)?; + let text = self.row_text(block, map)?; let relative = x.saturating_sub(map.x_offset); - let offset = map.start + display_column_to_char_offset(&map.text, relative); + let offset = map.start + display_column_to_char_offset(text, relative); Some(TextPoint { block, offset: offset.min(map.end), @@ -1040,17 +1420,16 @@ impl Transcript { /// Highlight columns for an absolute wrapped history line. pub fn selection_columns(&mut self, line_idx: usize) -> Option<(usize, usize)> { - self.ensure_index(); let selection = self.text_selection?; if selection.is_empty() { return None; } let (start, end) = normalized_selection(selection); - let (block, li) = self.locate(line_idx)?; + let (block, map) = self.row(line_idx)?; if block < start.block || block > end.block { return None; } - let map = self.blocks[block].cache_maps.get(li)?.as_ref()?; + let text = self.row_text(block, map)?; let block_len = self.blocks.get(block)?.selectable_chars; let range_start = if block == start.block { start.offset @@ -1070,9 +1449,9 @@ impl Transcript { return None; } let start_col = - map.x_offset + char_offset_to_display_column(&map.text, local_start - map.start); + map.x_offset + char_offset_to_display_column(text, local_start - map.start); let end_col = - map.x_offset + char_offset_to_display_column(&map.text, local_end - map.start); + map.x_offset + char_offset_to_display_column(text, local_end - map.start); Some((start_col, end_col.max(start_col + 1))) } @@ -1123,18 +1502,20 @@ impl Transcript { self.sticky_user_block_idx(view_start_line).is_some() } - /// First line of the nearest user block above the viewport. - pub fn sticky_user_line(&mut self, view_start_line: usize) -> Option> { + /// First line of the nearest user block above the viewport, borrowed: + /// the frame paints it in place. + pub fn sticky_user_line(&mut self, view_start_line: usize) -> Option<&Line<'static>> { let i = self.sticky_user_block_idx(view_start_line)?; - self.blocks[i].source_lines().first().cloned() + self.blocks[i].source_lines().first() } - /// Test-support oracle for the selection marker `fill_viewport` paints. + /// Test-support oracle for the selection marker `paint_rows` paints. #[cfg(test)] - pub fn is_selected_block_for_line(&self, line_idx: usize) -> bool { + pub fn is_selected_block_for_line(&mut self, line_idx: usize) -> bool { let Some(sel) = self.selected else { return false; }; + self.ensure_index(); self.locate(line_idx).map(|(bi, _)| bi) == Some(sel) } @@ -1280,22 +1661,27 @@ fn wrap_lines_mapped( let width = width.max(8) as usize; let mut out = Vec::new(); let mut maps = Vec::new(); + // Where this line starts in the joined plain text, in chars and bytes. let mut logical_start = 0usize; + let mut logical_byte = 0usize; for line in lines { let chars: Vec<(char, Style)> = line .spans .iter() .flat_map(|s| s.content.chars().map(move |c| (c, s.style))) .collect(); + let line_bytes: usize = line.spans.iter().map(|s| s.content.len()).sum(); if chars.is_empty() { out.push(Line::default()); maps.push(Some(CachedLineMap { start: logical_start, end: logical_start, + byte_start: logical_byte, + byte_end: logical_byte, x_offset: 0, - text: String::new(), })); logical_start += 1; + logical_byte += 1; continue; } let indent = hanging_indent(&chars, width); @@ -1348,11 +1734,13 @@ fn wrap_lines_mapped( row.spans.insert(0, Span::raw(" ".repeat(indent))); } out.push(row); + // ASCII: a char is a byte. maps.push(Some(CachedLineMap { start: logical_start + start, end: logical_start + cut, + byte_start: logical_byte + start, + byte_end: logical_byte + cut, x_offset: if first_row { 0 } else { indent }, - text: chars[start..cut].iter().map(|(ch, _)| ch).collect(), })); start = cut; first_row = false; @@ -1361,12 +1749,13 @@ fn wrap_lines_mapped( let plain: String = chars.iter().map(|(ch, _)| ch).collect(); let mut graphemes = Vec::new(); let mut char_start = 0usize; - for grapheme in plain.graphemes(true) { + for (byte_start, grapheme) in plain.grapheme_indices(true) { let char_end = char_start + grapheme.chars().count(); graphemes.push(( char_start, cell_width(grapheme), grapheme.chars().all(char::is_whitespace), + byte_start, )); char_start = char_end; } @@ -1379,7 +1768,7 @@ fn wrap_lines_mapped( let mut end = start; let mut last_space: Option = None; while end < graphemes.len() { - let (_, width_here, is_space) = graphemes[end]; + let (_, width_here, is_space, _) = graphemes[end]; if used + width_here > avail { break; } @@ -1397,11 +1786,11 @@ fn wrap_lines_mapped( _ => end.max(start + 1), } }; - let from_char = graphemes[start].0; - let to_char = if cut == graphemes.len() { - chars.len() + let (from_char, from_byte) = (graphemes[start].0, graphemes[start].3); + let (to_char, to_byte) = if cut == graphemes.len() { + (chars.len(), plain.len()) } else { - graphemes[cut].0 + (graphemes[cut].0, graphemes[cut].3) }; let mut row = rebuild(&chars[from_char..to_char]); if !first_row && indent > 0 { @@ -1415,17 +1804,16 @@ fn wrap_lines_mapped( maps.push(Some(CachedLineMap { start: logical_start + from_char, end: logical_start + to_char, + byte_start: logical_byte + from_byte, + byte_end: logical_byte + to_byte, x_offset: if first_row { 0 } else { indent }, - text: chars[from_char..to_char] - .iter() - .map(|(ch, _)| ch) - .collect(), })); start = cut; first_row = false; } } logical_start += chars.len() + 1; + logical_byte += line_bytes + 1; } (out, maps) } @@ -2157,6 +2545,98 @@ mod tests { assert_eq!(text(&t.lines())[0], "hello "); } + /// Lines that wrap, fit, hang under a marker, carry tabs, wide and + /// combining graphemes, a cluster split across spans, controls, and + /// nothing at all. + fn awkward_lines() -> Vec> { + vec![ + Line::from("plain prose that runs well past any narrow width you could pick"), + Line::from("short"), + Line::from(""), + Line::from("• a bullet whose continuation rows hang under the marker text"), + Line::from("12. a numbered item that also hangs once it wraps around"), + Line::from("\tindented\twith\ttabs between words that wrap"), + Line::from("漢字テキストと english mixed 漢字テキスト wraps on wide cells"), + Line::from(vec![Span::raw("joined e"), Span::raw("\u{301} across spans é é é")]), + Line::from("e\u{301}e\u{301} 👩\u{200d}💻👩\u{200d}💻 emoji 👩\u{200d}💻 and marks"), + Line::from("a_single_unbroken_token_far_longer_than_the_row_it_lands_in"), + Line::from("bell\u{7} and esc\u{1b} controls"), + ] + } + + /// A block's estimate never exceeds the rows its wrap produces (the + /// draw path relies on it to wrap only the blocks in view), and equals + /// them whenever every line fits. + #[test] + fn estimates_never_exceed_the_wrap_and_are_exact_when_lines_fit() { + for kind in [BlockKind::User, BlockKind::Assistant] { + for width in 1..=90 { + let mut block = Block::new(kind, awkward_lines()); + let estimate = block.measure(width); + block.ensure_cache(width); + let exact = block.cache.len(); + assert!(estimate <= exact, "{kind:?} at {width}: {estimate} > {exact}"); + if block.raw_cells.widest as usize <= usize::from(content_width(kind, width)) { + assert_eq!(estimate, exact, "{kind:?} at {width}"); + } + } + } + // Wide enough for every line: the estimate is the wrap. + let mut block = Block::new(BlockKind::Assistant, awkward_lines()); + let estimate = block.measure(200); + block.ensure_cache(200); + assert_eq!(estimate, block.cache.len()); + } + + /// Each wrapped row's map names its text as a byte range of the block's + /// selectable text instead of carrying a copy; that range must hold + /// exactly the characters its char range counts. + #[test] + fn row_maps_slice_exactly_the_text_they_count() { + for width in [9u16, 13, 24, 40, 200] { + let mut t = Transcript::new(); + t.set_width(width); + t.push_user(awkward_lines()); + t.push_assistant(awkward_lines()); + t.push_tool(vec![Line::from("✓ cat notes")], "first\n\tsecond 漢字\n".into(), false); + t.wrap_all(); + for block in &t.blocks { + let chars: Vec = block.selectable.chars().collect(); + for map in block.cache_maps.iter().flatten() { + let sliced = &block.selectable[map.byte_start..map.byte_end]; + let counted: String = chars[map.start..map.end].iter().collect(); + assert_eq!(sliced, counted, "width {width}"); + } + } + } + } + + /// `paint_rows` paints, row for row, what the cloned viewport lines + /// paint through a paragraph, the selected block's surface included. + #[test] + fn paint_rows_paints_what_the_cloned_lines_paint() { + use ratatui::widgets::{Paragraph, Widget}; + let mut t = Transcript::new(); + t.set_width(30); + t.push_user(awkward_lines()); + t.push_assistant(crate::ui::markdown::render("A reply.\n\n```rust\nfn a() {}\n```")); + t.push_tool(vec![Line::from("✗ bash cargo test")], "error: one\nnote: two".into(), false); + t.push(vec![Line::from("a notice")]); + let total = t.lines().len(); + for selected in [None, Some(1), Some(2)] { + for (start, end) in [(0, total), (3, 17), (total - 4, total)] { + let area = Rect::new(1, 2, 30, (end - start) as u16); + let mut expected = Buffer::empty(Rect::new(0, 0, 32, area.bottom() + 1)); + let mut painted = expected.clone(); + let mut lines = Vec::new(); + t.fill_viewport(&mut lines, start, end, selected); + Paragraph::new(lines).render(area, &mut expected); + t.paint_rows(&mut painted, area, start, end, selected); + assert_eq!(painted, expected, "rows {start}..{end}, selected {selected:?}"); + } + } + } + #[test] fn transcript_rewraps_on_width_change() { let mut t = Transcript::new(); @@ -2167,7 +2647,8 @@ mod tests { assert!(t.len() >= wide); } - // Release-only diagnostic for the synchronous resize path. It is ignored + // Release-only diagnostic for the synchronous resize path: re-measuring + // every block and wrapping the ones a 40-row view shows. It is ignored // in normal CI because elapsed-time assertions are machine-dependent. // Run with: // cargo test -p openmax --release -- --ignored --nocapture measure_transcript_resize @@ -2193,6 +2674,7 @@ mod tests { for width in [72, 120, 88, 100] { let started = Instant::now(); transcript.set_width(width); + transcript.settle_view(40, 0); std::hint::black_box(transcript.len()); eprintln!( "MEASURE transcript_resize source_lines={source_lines} width={width} elapsed_ms={:.3}", @@ -2607,6 +3089,7 @@ mod tests { assert!(t.has_sticky_user(view_start)); let sticky = t .sticky_user_line(view_start) + .cloned() .expect("sticky above mid viewport"); let plain: String = sticky.spans.iter().map(|s| s.content.as_ref()).collect(); assert!( @@ -2623,6 +3106,7 @@ mod tests { assert!(t.has_sticky_user(view_start)); let sticky2 = t .sticky_user_line(view_start) + .cloned() .expect("sticky after appends"); assert_eq!(text(&[sticky]), text(&[sticky2])); // Absolute line maps for earlier history stay valid after appends. @@ -2814,7 +3298,7 @@ mod tests { "stable prose that stays where it was put", ))]); t.push_tool(vec![Line::from("✓ bash")], "line one\nline two".into(), true); - t.ensure_index(); + t.wrap_all(); let cached = t.blocks[0].cache[0].spans[0].content.as_ptr(); assert_eq!( t.line_at(0).unwrap().spans[0].content.as_ptr(), @@ -2847,6 +3331,7 @@ mod tests { ))]); t.push_tool(vec![Line::from("✓ bash")], output.clone(), true); } + t.wrap_all(); let lines = t.len(); // Each call alternates unfold/fold on the last tool block; only @@ -2858,10 +3343,12 @@ mod tests { } let fold_ms = t0.elapsed().as_secs_f64() * 1e3 / n as f64; - // A settled resize: every block re-wraps and the tables rebuild. + // A settled resize: every block is re-measured, the view's + // blocks re-wrap, and the tables rebuild. let t0 = Instant::now(); for w in [110u16, 120, 110, 120] { t.set_width(w); + t.settle_view(40, 0); } let resize_ms = t0.elapsed().as_secs_f64() * 1e3 / 4.0; From 5b50d8c2f285edd615733cf1e7d2360becba338f Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Mon, 5 Oct 2026 22:58:22 -0500 Subject: [PATCH 2/3] fix(tui): keep a mouse selection on the painted text after an unpainted scroll A press, drag, or release handled after wheel or page input but before the next paint selected and copied text from rows above the one under the pointer. Each painted row was remembered by its absolute row index. A scroll wraps the blocks it crosses, and a block that turns from its estimate to its real height moves the index of every row below it, so the last frame's indices named other text until the next paint. Painted rows are now remembered by block and row within the block, which other blocks' heights cannot move, and resolved to the current index when a pointer event or the selection overlay reads them. The frame comment also names the one exception to painting without clones: the selected block's rows. --- crates/tui/src/app.rs | 90 ++++++++++++++++++++++++++------- crates/tui/src/ui/transcript.rs | 41 +++++++++++++-- 2 files changed, 109 insertions(+), 22 deletions(-) diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 3473df7e..0ace3759 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -30,7 +30,7 @@ use crate::ui::sessions as sessions_ui; use crate::ui::tool_card::{self, DiffText}; use crate::ui::text::paint_line; use crate::ui::transcript::{ - wrap_lines, Term, Transcript, + wrap_lines, RowRef, Term, Transcript, }; use crate::ui::{context, extensions, markdown, model_picker}; @@ -421,9 +421,11 @@ pub struct App { /// running metadata, so a token only rebuilds that suffix. tail_stable_len: usize, tail_buf: Vec>, - /// Absolute transcript line for each painted conversation row (None for - /// the sticky header and the live tail). - chat_line_map: Vec>, + /// What each painted conversation row shows (None for the sticky header + /// and the live tail), by block and row in it: input handled before the + /// next paint can wrap history above the view, which moves every + /// absolute row index below it. + chat_line_map: Vec>, chat_draw_area: Rect, /// Where the composer text last painted, so the wheel and the mouse can /// tell the prompt apart from the conversation above it. @@ -1581,12 +1583,15 @@ impl App { (cell, row) } - fn transcript_position(&self, column: u16, row: u16) -> Option<(usize, usize)> { + /// The transcript line and column under the pointer: the text the last + /// frame painted there, wherever it sits now. + fn transcript_position(&mut self, column: u16, row: u16) -> Option<(usize, usize)> { if !rect_contains(self.chat_draw_area, column, row) { return None; } let rendered_row = row.saturating_sub(self.chat_draw_area.y) as usize; - let line = self.chat_line_map.get(rendered_row).copied().flatten()?; + let painted = self.chat_line_map.get(rendered_row).copied().flatten()?; + let line = self.transcript.line_of(painted)?; let x = column.saturating_sub(self.chat_draw_area.x) as usize; Some((line, x)) } @@ -3473,7 +3478,8 @@ impl App { /// /// Only the blocks the viewport shows are wrapped, and every row is /// painted straight from its block's cache or the tail: a frame clones - /// no lines. + /// no lines except the selected block's, which take the selection + /// surface. fn draw_chat(&mut self, frame: &mut Frame, area: Rect) { let layout_started = Instant::now(); let mut content_w = area.width; @@ -3577,10 +3583,6 @@ impl App { .take(visible - sticky_rows - history_rows) .count(); let rows = sticky_rows + history_rows + tail_rows; - self.chat_line_map.clear(); - self.chat_line_map.extend(std::iter::repeat_n(None, sticky_rows)); - self.chat_line_map.extend((start..view_end).map(Some)); - self.chat_line_map.extend(std::iter::repeat_n(None, tail_rows)); let pad = area.height.saturating_sub(rows as u16); let draw_area = Rect { @@ -3606,6 +3608,8 @@ impl App { } else { None }; + self.chat_line_map.clear(); + self.chat_line_map.extend(std::iter::repeat_n(None, sticky_rows)); self.transcript.paint_rows( frame.buffer_mut(), Rect { @@ -3615,7 +3619,10 @@ impl App { start, view_end, selected_bi, + &mut self.chat_line_map, ); + self.chat_line_map.resize(sticky_rows + history_rows, None); + self.chat_line_map.extend(std::iter::repeat_n(None, tail_rows)); let tail_from = start.max(hist_len) - hist_len; for (row, line) in self.tail_buf[tail_from..tail_from + tail_rows].iter().enumerate() { paint_line( @@ -4272,14 +4279,14 @@ fn truncate_replay_output(output: &str) -> String { fn paint_text_selection( buffer: &mut ratatui::buffer::Buffer, transcript: &mut Transcript, - line_map: &[Option], + line_map: &[Option], area: Rect, ) { // One palette read for the whole overlay: the accessor takes the theme // lock, and the inner loop touches every selected cell per frame. let select_bg = theme::SELECT(); - for (row, line_idx) in line_map.iter().copied().enumerate() { - let Some(line_idx) = line_idx else { + for (row, painted) in line_map.iter().copied().enumerate() { + let Some(line_idx) = painted.and_then(|at| transcript.line_of(at)) else { continue; }; let Some((start_col, end_col)) = transcript.selection_columns(line_idx) else { @@ -4571,7 +4578,6 @@ mod tests { use ratatui::layout::Rect; use ratatui::style::Modifier; use ratatui::text::Line; - use ratatui::widgets::{Paragraph, Widget}; use ratatui::Terminal; use serde_json::json; use std::fs; @@ -5152,12 +5158,11 @@ mod tests { assert!(transcript.update_text_selection_at(0, 7)); transcript.finish_text_selection(); - let mut lines = Vec::new(); - transcript.fill_viewport(&mut lines, 0, 1, None); let area = Rect::new(0, 0, 20, 1); let mut buffer = Buffer::empty(area); - Paragraph::new(lines).render(area, &mut buffer); - paint_text_selection(&mut buffer, &mut transcript, &[Some(0)], area); + let mut rows = Vec::new(); + transcript.paint_rows(&mut buffer, area, 0, 1, None, &mut rows); + paint_text_selection(&mut buffer, &mut transcript, &rows, area); assert_eq!(buffer[(1, 0)].bg, theme::USER_BG()); assert_eq!(buffer[(2, 0)].bg, theme::SELECT()); @@ -7060,6 +7065,53 @@ mod tests { fs::remove_dir_all(dir).unwrap(); } + /// A press, drag, and release select the text the last frame painted + /// under the pointer, even when wheel input handled before the next + /// paint wrapped history above the view. Wrapping a block changes its + /// height and so the index of every row below it: resolved through the + /// last frame's row indices, the gesture selected and copied text from + /// rows above the one under the pointer. + #[tokio::test] + async fn a_selection_after_an_unpainted_scroll_takes_the_painted_text() { + let (mut app, dir) = app_fixture(); + // A word to a row leaves most of each row empty, so every block + // wraps to far more rows than its estimate counts. + for block in 0..300 { + let words: Vec = + (0..10).map(|word| format!("w{block:04}x{word:02}abcdefg")).collect(); + app.transcript.push_user(vec![Line::from(words.join(" "))]); + } + for step in 0u16..12 { + let painted = render_app(&mut app, 29, 30); + let area = app.chat_draw_area; + // Wheel input queued behind the frame, handled before the next. + for _ in 0..1 + step % 3 { + app.on_term_event(mouse(MouseEventKind::ScrollUp, area.x, area.y)) + .await + .unwrap(); + } + // Below the sticky header row, a new row each step so no two + // presses make a double click. + let row = area.y + 1 + (step * 7) % (area.height - 1); + let (from, to) = (area.x + 2, area.x + 11); + for (kind, column) in [ + (MouseEventKind::Down(MouseButton::Left), from), + (MouseEventKind::Drag(MouseButton::Left), to), + (MouseEventKind::Up(MouseButton::Left), to), + ] { + app.on_term_event(mouse(kind, column, row)).await.unwrap(); + } + let shown: String = (from..=to).map(|x| painted[(x, row)].symbol()).collect(); + assert_eq!( + app.transcript.selected_text().unwrap_or_default(), + shown.trim(), + "step {step}: the copy is not the text painted on row {row}" + ); + app.transcript.clear_text_selection(); + } + fs::remove_dir_all(dir).unwrap(); + } + #[test] fn growing_taller_releases_the_scrollbar_column_with_one_rewrap() { let (mut app, dir) = app_fixture(); diff --git a/crates/tui/src/ui/transcript.rs b/crates/tui/src/ui/transcript.rs index 2a99cf2e..3b36b1b9 100644 --- a/crates/tui/src/ui/transcript.rs +++ b/crates/tui/src/ui/transcript.rs @@ -376,6 +376,19 @@ pub struct WrapAnchor { lines_into_block: usize, } +/// A painted history row by its content: the block and the row in that +/// block's wrap. An absolute row index names other text once a block above +/// it changes height, and wrapping does that between any two frames (a +/// scroll wraps the blocks it crosses), so a pointer event resolved through +/// the last frame's indices would land rows above the text under the +/// pointer. This names the painted text until its own block changes. See +/// [`Transcript::paint_rows`] and [`Transcript::line_of`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RowRef { + block: usize, + row: usize, +} + #[derive(Default)] pub struct Transcript { blocks: Vec, @@ -865,7 +878,8 @@ impl Transcript { /// the rows of `selected_bi`, which receive a quiet background. Text /// selection is painted later as a buffer overlay. Every block in the /// range must be wrapped, which [`Self::settle_view`] does for the - /// viewport. + /// viewport. Appends to `painted` what each row shows, in order, for + /// pointer events to find it again. pub fn paint_rows( &mut self, buf: &mut Buffer, @@ -873,6 +887,7 @@ impl Transcript { start: usize, end: usize, selected_bi: Option, + painted: &mut Vec>, ) { self.ensure_index(); let end = end.min(self.total).min(start.saturating_add(usize::from(area.height))); @@ -886,7 +901,8 @@ impl Transcript { } let block = &self.blocks[bi]; debug_assert!(block.is_wrapped_at(self.width), "painted an unwrapped block"); - if let Some(line) = block.cache.get(li).filter(|_| block.is_wrapped_at(self.width)) { + let line = block.cache.get(li).filter(|_| block.is_wrapped_at(self.width)); + if let Some(line) = line { let row = Rect { y, height: 1, ..area }; if selected_bi == Some(bi) { let line = surface_line(line.clone(), self.width, theme::BORDER()); @@ -895,10 +911,24 @@ impl Transcript { paint_line(buf, row, None, line); } } + painted.push(line.map(|_| RowRef { block: bi, row: li })); li += 1; } } + /// The current index of a row a frame painted, or None once its block + /// is gone or no longer wraps to that row. Pointer events address rows + /// this way, so wrapping between the paint and the event cannot move + /// what they hit. + pub fn line_of(&mut self, at: RowRef) -> Option { + let block = self.blocks.get(at.block)?; + if !block.is_wrapped_at(self.width) || at.row >= block.cache.len() { + return None; + } + self.ensure_index(); + Some(self.block_starts[at.block] + at.row) + } + pub fn len(&mut self) -> usize { self.ensure_index(); self.total @@ -2631,8 +2661,13 @@ mod tests { let mut lines = Vec::new(); t.fill_viewport(&mut lines, start, end, selected); Paragraph::new(lines).render(area, &mut expected); - t.paint_rows(&mut painted, area, start, end, selected); + let mut rows = Vec::new(); + t.paint_rows(&mut painted, area, start, end, selected, &mut rows); assert_eq!(painted, expected, "rows {start}..{end}, selected {selected:?}"); + // Each painted row is found again at the line it painted. + let found: Vec<_> = + rows.iter().map(|at| at.and_then(|at| t.line_of(at))).collect(); + assert_eq!(found, (start..end).map(Some).collect::>()); } } } From 1833b014adbe001f3f4074e697654a8a1a2c7009 Mon Sep 17 00:00:00 2001 From: Max Loffgren Date: Tue, 6 Oct 2026 00:13:23 -0500 Subject: [PATCH 3/3] fix(tui): keep a mouse selection on the painted notice after a rejected prompt A prompt rejected by a user_prompt_submit hook is removed from the transcript after the notices its turn produced may already be on screen. Painted rows named their block by index, and the removal shifts every later block down by one, so a press or drag handled before the next paint selected and copied the notice below the one under the pointer. Blocks now carry an id assigned in push order and never reused. A painted row records the id beside the index it had when painted; when the block at that index no longer has the id, the row is found by searching the ids, which removal keeps in order, and a row of the removed block names nothing. --- crates/tui/src/app.rs | 36 +++++++++++++++++ crates/tui/src/ui/transcript.rs | 71 +++++++++++++++++++++++++++++---- 2 files changed, 100 insertions(+), 7 deletions(-) diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 0ace3759..207e38d0 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -7112,6 +7112,42 @@ mod tests { fs::remove_dir_all(dir).unwrap(); } + /// A prompt a hook rejects is rolled back from above the notices its + /// turn already painted. A selection handled before the next paint + /// takes the notice under the pointer, not the one that slid into its + /// place in the transcript. + #[tokio::test] + async fn a_selection_after_a_rejected_prompt_takes_the_painted_notice() { + let (mut app, dir) = app_fixture(); + app.insert_user_block("a prompt a hook rejects"); + app.pending_submit = Some("a prompt a hook rejects".into()); + for index in 0..3 { + app.on_agent_event(AgentEvent::HookFailed { + hook: "lint".into(), + event: "user_prompt_submit".into(), + detail: format!("notice {index}"), + }); + } + let painted = render_app(&mut app, 80, 20); + app.on_agent_event(AgentEvent::Done { + stop_reason: "blocked".into(), + }); + let shown = rows(&painted); + let row = shown.iter().position(|text| text.contains("notice 0")).unwrap(); + let byte = shown[row].find("notice 0").unwrap(); + let from = shown[row][..byte].chars().count() as u16; + let (row, to) = (row as u16, from + 7); + for (kind, column) in [ + (MouseEventKind::Down(MouseButton::Left), from), + (MouseEventKind::Drag(MouseButton::Left), to), + (MouseEventKind::Up(MouseButton::Left), to), + ] { + app.on_term_event(mouse(kind, column, row)).await.unwrap(); + } + assert_eq!(app.transcript.selected_text().as_deref(), Some("notice 0")); + fs::remove_dir_all(dir).unwrap(); + } + #[test] fn growing_taller_releases_the_scrollbar_column_with_one_rewrap() { let (mut app, dir) = app_fixture(); diff --git a/crates/tui/src/ui/transcript.rs b/crates/tui/src/ui/transcript.rs index 3b36b1b9..1f05cfe6 100644 --- a/crates/tui/src/ui/transcript.rs +++ b/crates/tui/src/ui/transcript.rs @@ -40,6 +40,9 @@ pub enum BlockKind { struct Block { kind: BlockKind, + /// Push order, never reused, so ids rise along `Transcript::blocks`. + /// Names the block for rows painted before a removal shifted its index. + id: u64, /// Full content when expanded (or the only content when not foldable). raw: Vec>, /// Compact body when foldable; shown while `folded`. @@ -209,6 +212,7 @@ impl Block { let search_lower = lower_for_search(&selectable); Self { kind, + id: 0, raw_cells: LineCells::measure(&raw), compact_cells: LineCells::default(), raw, @@ -276,6 +280,7 @@ impl Block { let selectable_chars = selectable.chars().count(); Self { kind: BlockKind::Tool, + id: 0, raw_cells: LineCells::measure(&full_lines), compact_cells: LineCells::measure(&compact), raw: full_lines, @@ -381,17 +386,23 @@ pub struct WrapAnchor { /// it changes height, and wrapping does that between any two frames (a /// scroll wraps the blocks it crosses), so a pointer event resolved through /// the last frame's indices would land rows above the text under the -/// pointer. This names the painted text until its own block changes. See +/// pointer. A block index shifts as well, when a rejected prompt is removed +/// from above the row, so the block is named by its id; the index it had +/// when painted only saves the search while nothing was removed. This +/// names the painted text until its own block changes. See /// [`Transcript::paint_rows`] and [`Transcript::line_of`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct RowRef { block: usize, + id: u64, row: usize, } #[derive(Default)] pub struct Transcript { blocks: Vec, + /// Id of the next block pushed. + next_id: u64, /// First wrapped line of each block; `total` closes the last range. The /// per-block caches own the only copy of every wrapped line, so /// re-measuring after a fold toggle or width change is index @@ -454,7 +465,9 @@ impl Transcript { /// grows by the rows the block adds below it. With the view's bottom /// edge inside the live tail, those rows move the tail under the edge, /// so they are counted exactly. - fn push_block(&mut self, block: Block) { + fn push_block(&mut self, mut block: Block) { + block.id = self.next_id; + self.next_id += 1; let bi = self.blocks.len(); let wrap_now = self.offset > 0 && self.offset < self.view.1; #[cfg(test)] @@ -911,22 +924,30 @@ impl Transcript { paint_line(buf, row, None, line); } } - painted.push(line.map(|_| RowRef { block: bi, row: li })); + painted.push(line.map(|_| RowRef { + block: bi, + id: block.id, + row: li, + })); li += 1; } } /// The current index of a row a frame painted, or None once its block /// is gone or no longer wraps to that row. Pointer events address rows - /// this way, so wrapping between the paint and the event cannot move - /// what they hit. + /// this way, so wrapping or a removal between the paint and the event + /// cannot move what they hit. pub fn line_of(&mut self, at: RowRef) -> Option { - let block = self.blocks.get(at.block)?; + let bi = match self.blocks.get(at.block) { + Some(block) if block.id == at.id => at.block, + _ => self.blocks.binary_search_by_key(&at.id, |block| block.id).ok()?, + }; + let block = &self.blocks[bi]; if !block.is_wrapped_at(self.width) || at.row >= block.cache.len() { return None; } self.ensure_index(); - Some(self.block_starts[at.block] + at.row) + Some(self.block_starts[bi] + at.row) } pub fn len(&mut self) -> usize { @@ -2672,6 +2693,42 @@ mod tests { } } + /// A prompt rejected by a hook is removed from above the notices the + /// hook already put on screen. Rows painted before the removal must + /// still name those notices: a row named by the block's index would + /// name the block that slid into it, so a selection before the next + /// paint would copy a different notice than the one under the pointer. + #[test] + fn painted_rows_keep_their_text_when_a_block_above_is_removed() { + let mut t = Transcript::new(); + t.set_width(30); + t.push_assistant(vec![Line::from("an earlier reply")]); + t.push_user(vec![Line::from("a prompt a hook rejects")]); + t.push(vec![Line::from("first hook notice")]); + t.push(vec![Line::from("second hook notice")]); + t.push(vec![Line::from("third hook notice")]); + let total = t.lines().len(); + let area = Rect::new(0, 0, 30, total as u16); + let mut rows = Vec::new(); + t.paint_rows(&mut Buffer::empty(area), area, 0, total, None, &mut rows); + let text_of = |t: &mut Transcript, at: Option| { + let line = at.and_then(|at| t.line_of(at))?; + t.line_at(line).map(|line| lines_to_plain(std::slice::from_ref(line))) + }; + let painted: Vec<_> = rows.iter().map(|&at| text_of(&mut t, at)).collect(); + let prompt = t.block_starts[1]..t.block_starts[2]; + assert!(t.pop_last_user()); + let found: Vec<_> = rows.iter().map(|&at| text_of(&mut t, at)).collect(); + // The removed prompt's rows name nothing; every other row still + // names the text it painted. + let expected: Vec<_> = painted + .into_iter() + .enumerate() + .map(|(row, text)| text.filter(|_| !prompt.contains(&row))) + .collect(); + assert_eq!(found, expected); + } + #[test] fn transcript_rewraps_on_width_change() { let mut t = Transcript::new();