From 0d377421d14b16c44f5ac0ab3cbc03f1fdb39e4a Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Tue, 23 Jun 2026 22:16:16 +0800 Subject: [PATCH 1/2] perf: window the transcript render to O(viewport) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scrollback re-processed the ENTIRE transcript every frame. Even on a cache hit it cloned the full Vec and handed it to ratatui's Paragraph, which word-wraps all lines (then discards everything above the scroll offset) just to show ~40 visible rows. On a multi-thousand-line session every mouse-wheel tick re-wrapped the whole buffer — visibly sluggish, exactly the O(transcript)-per-scroll the symptom pointed at. Cache a prefix sum of per-line wrapped-row counts on each (already rare) rebuild, then on render binary-search the handful of logical lines that intersect the viewport and hand ratatui only that slice. Scrolling is now O(viewport), not O(transcript); the per-frame full-buffer clone + re-wrap are gone. `visible_window` is a pure, unit-tested function (incl. a property test that the returned slice always covers the requested window). Per-line counts use ratatui's own line_count, so the layout stays byte-for-byte identical to before — only the work per frame changed. Co-Authored-By: Claude Opus 4.8 --- .../codeoid-tui/src/state/scrollback_build.rs | 111 ++++++++++++++++++ crates/codeoid-tui/src/ui/scrollback.rs | 52 +++++--- 2 files changed, 144 insertions(+), 19 deletions(-) diff --git a/crates/codeoid-tui/src/state/scrollback_build.rs b/crates/codeoid-tui/src/state/scrollback_build.rs index 856b732..a7599c9 100644 --- a/crates/codeoid-tui/src/state/scrollback_build.rs +++ b/crates/codeoid-tui/src/state/scrollback_build.rs @@ -29,6 +29,53 @@ pub struct ScrollbackBuild { /// Pre-computed `total_rendered_rows(&lines, width)`, so scroll math /// reuses it without a second O(N) walk. pub total_rendered_rows: usize, + /// Prefix sum of wrapped rows: `row_offsets[i]` is the number of + /// screen rows occupied by logical lines `0..i`. Length is + /// `lines.len() + 1`, so `row_offsets.last() == total_rendered_rows`. + /// Lets the renderer binary-search the few logical lines that + /// intersect the viewport and hand ratatui only that slice — each + /// frame (and each scroll tick) becomes O(viewport), not O(transcript). + /// See [`visible_window`]. + pub row_offsets: Vec, +} + +/// Pick the logical-line slice that covers the viewport. +/// +/// `row_offsets` is the prefix sum from [`ScrollbackBuild::row_offsets`] +/// (length `lines + 1`). `y` is the topmost visible wrapped row; +/// `viewport_rows` is the viewport height. Returns +/// `(first_line, intra_offset, last_line_exclusive)` such that rendering +/// `lines[first_line..last_line_exclusive]` scrolled down by `intra_offset` +/// rows shows exactly the window `[y, y + viewport_rows)`. +/// +/// O(log lines) — two binary searches, no walk over the buffer. +#[must_use] +pub fn visible_window( + row_offsets: &[usize], + y: usize, + viewport_rows: usize, +) -> (usize, usize, usize) { + let n = row_offsets.len().saturating_sub(1); // number of logical lines + if n == 0 { + return (0, 0, 0); + } + let total = row_offsets[n]; + // Clamp the top row into range so the searches can't run off the end. + let y = y.min(total.saturating_sub(1)); + // First logical line whose row range contains `y`: the largest `i` + // with `row_offsets[i] <= y`. + let first = row_offsets + .partition_point(|&o| o <= y) + .saturating_sub(1) + .min(n - 1); + let intra = y - row_offsets[first]; + // Last (exclusive): the first line that starts at or after the bottom + // of the window, so the slice fully covers `[y, y + viewport_rows)`. + let bottom = y.saturating_add(viewport_rows); + let last = row_offsets + .partition_point(|&o| o < bottom) + .clamp(first + 1, n); + (first, intra, last) } impl ScrollbackBuild { @@ -50,6 +97,7 @@ impl ScrollbackBuild { self.session_id = None; self.lines.clear(); self.total_rendered_rows = 0; + self.row_offsets.clear(); } } @@ -64,3 +112,66 @@ impl std::fmt::Debug for ScrollbackBuild { .finish() } } + +#[cfg(test)] +mod tests { + use super::visible_window; + + // Three logical lines wrapping to 2, 3, and 4 rows → total 9 rows. + // row_offsets = [0, 2, 5, 9]: line0=rows 0..2, line1=2..5, line2=5..9. + const OFFSETS: &[usize] = &[0, 2, 5, 9]; + + #[test] + fn window_in_the_middle() { + // Top row 4 (3rd row of line1), viewport 3 → rows [4,7). + let (first, intra, last) = visible_window(OFFSETS, 4, 3); + assert_eq!((first, intra, last), (1, 2, 3)); + } + + #[test] + fn window_at_top() { + let (first, intra, last) = visible_window(OFFSETS, 0, 3); + assert_eq!((first, intra), (0, 0)); + // Must cover rows [0,3): lines 0 (0..2) and 1 (2..5). + assert_eq!(last, 2); + } + + #[test] + fn window_at_bottom() { + // total 9, viewport 4 → top row 5. rows [5,9) = all of line2. + let (first, intra, last) = visible_window(OFFSETS, 5, 4); + assert_eq!((first, intra, last), (2, 0, 3)); + } + + #[test] + fn window_covers_whole_buffer_when_short() { + // Viewport taller than the content: one line, 2 rows. + let offsets = &[0usize, 2]; + let (first, intra, last) = visible_window(offsets, 0, 10); + assert_eq!((first, intra, last), (0, 0, 1)); + } + + #[test] + fn slice_always_covers_the_window() { + // Property: for any y, the returned slice spans at least + // [y, y+viewport) and starts at-or-before y. + let viewport = 3; + for y in 0..OFFSETS[OFFSETS.len() - 1] { + let (first, intra, last) = visible_window(OFFSETS, y, viewport); + let clamped_y = y.min(OFFSETS[3] - 1); + assert!(OFFSETS[first] <= clamped_y, "first starts after y"); + assert_eq!(OFFSETS[first] + intra, clamped_y); + assert!(first < last && last <= 3); + // Rows available in the slice after skipping intra must fill + // the viewport (unless we hit the end of the buffer). + let rows_in_slice = OFFSETS[last] - OFFSETS[first]; + assert!(rows_in_slice >= intra + viewport || last == 3); + } + } + + #[test] + fn empty_offsets_are_safe() { + assert_eq!(visible_window(&[], 0, 10), (0, 0, 0)); + assert_eq!(visible_window(&[0], 0, 10), (0, 0, 0)); + } +} diff --git a/crates/codeoid-tui/src/ui/scrollback.rs b/crates/codeoid-tui/src/ui/scrollback.rs index ffac601..c8a3a75 100644 --- a/crates/codeoid-tui/src/ui/scrollback.rs +++ b/crates/codeoid-tui/src/ui/scrollback.rs @@ -140,18 +140,28 @@ pub fn render(frame: &mut Frame<'_>, area: Rect, state: &mut AppState) { ])); } - // Use Paragraph's own line_count so we never disagree with the - // widget that actually lays out the content — a hand-rolled row - // counter that under-reports by even one row clips the bottom - // of the transcript at scroll_offset = 0. - let total = Paragraph::new(lines.clone()) - .wrap(Wrap { trim: false }) - .line_count(inner_width); + // Per-logical-line wrapped-row prefix sum. We use Paragraph's own + // line_count per line (ratatui wraps each Line independently, so + // the per-line counts sum to the whole-buffer count) — this keeps + // the scroll math byte-for-byte consistent with how the windowed + // slice is laid out on render, and never under-reports the bottom + // row. Built once per cache miss; the render path below never + // walks the whole buffer again. + let mut row_offsets: Vec = Vec::with_capacity(lines.len() + 1); + let mut acc = 0usize; + row_offsets.push(0); + for line in &lines { + acc += Paragraph::new(vec![line.clone()]) + .wrap(Wrap { trim: false }) + .line_count(inner_width); + row_offsets.push(acc); + } scrollback_build.session_id = Some(session_id.clone()); scrollback_build.width = inner_width; scrollback_build.epoch = epoch; scrollback_build.lines = lines; - scrollback_build.total_rendered_rows = total; + scrollback_build.total_rendered_rows = acc; + scrollback_build.row_offsets = row_offsets; } // Scroll math reuses the precomputed total. While the user is @@ -164,18 +174,22 @@ pub fn render(frame: &mut Frame<'_>, area: Rect, state: &mut AppState) { state.note_total_rendered(total_rendered); let max_y = total_rendered.saturating_sub(viewport_rows); - let y = max_y - .saturating_sub(state.scroll_offset as usize) - .min(u16::MAX as usize) as u16; - - // Paragraph::new requires `Vec` by value, so we clone the - // outer Vec from the cache. The Tier 2 / custom-widget path - // eliminates this clone entirely, but it's already an order of - // magnitude cheaper than re-running the per-message renderer. - let lines = state.scrollback_build.lines.clone(); - let paragraph = Paragraph::new(lines) + let y = max_y.saturating_sub(state.scroll_offset as usize); + + // Hand ratatui only the logical lines that intersect the viewport, + // not the whole transcript. This is what keeps scrolling O(viewport): + // a 10k-line session re-wraps ~viewport rows per frame instead of all + // 10k. `y` is the top visible wrapped row; the slice + intra-line + // scroll offset reproduce exactly that window. + let (first, intra, last) = crate::state::scrollback_build::visible_window( + &state.scrollback_build.row_offsets, + y, + viewport_rows, + ); + let window: Vec> = state.scrollback_build.lines[first..last].to_vec(); + let paragraph = Paragraph::new(window) .wrap(Wrap { trim: false }) - .scroll((y, 0)) + .scroll((intra.min(u16::MAX as usize) as u16, 0)) .block( Block::default() .borders(Borders::ALL) From f844da7c1d9382aac88b5bf0ee62b252c6971b2e Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Tue, 23 Jun 2026 22:35:41 +0800 Subject: [PATCH 2/2] test: cover the windowed scrollback render via TestBackend Add two TestBackend tests: one asserts the prefix sum is built and the latest message shows at the bottom (following mode); the other scrolls to the top and asserts the window tracks the offset (earliest line visible, latest off-screen). Exercises the cache-miss build + windowed render path. Co-Authored-By: Claude Opus 4.8 --- crates/codeoid-tui/src/ui/scrollback.rs | 128 ++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/crates/codeoid-tui/src/ui/scrollback.rs b/crates/codeoid-tui/src/ui/scrollback.rs index c8a3a75..916d4cc 100644 --- a/crates/codeoid-tui/src/ui/scrollback.rs +++ b/crates/codeoid-tui/src/ui/scrollback.rs @@ -447,3 +447,131 @@ fn short_sub(sub: &str) -> String { // spiffe://…/agent/, else tail path segment. sub.rsplit('/').next().unwrap_or(sub).to_string() } + +#[cfg(test)] +mod tests { + use super::*; + use codeoid_protocol::{AuthOkMsg, IdentityType, MessageIdentity, SessionStatus}; + use ratatui::backend::TestBackend; + use ratatui::buffer::Cell; + use ratatui::Terminal; + + fn mk_state() -> AppState { + AppState::new(AuthOkMsg { + identity: MessageIdentity { + sub: "spiffe://x".into(), + name: Some("Me".into()), + kind: IdentityType::Human, + }, + scopes: vec![], + protocol_version: Some(1), + }) + } + + fn mk_session(id: &str) -> SessionInfo { + SessionInfo { + id: id.into(), + name: "demo".into(), + workdir: "/tmp".into(), + status: SessionStatus::Idle, + created_by: "u".into(), + created_at: "2026-06-23T00:00:00Z".into(), + attached_clients: 0, + mode: None, + turns_remaining: None, + pinned_files: None, + agent_uri: None, + subagents: None, + usage: None, + rotation: None, + queued_messages: None, + model: None, + fallback_model: None, + } + } + + fn user_msg(sid: &str, id: &str, content: &str) -> SessionMessage { + SessionMessage { + session_id: sid.into(), + message_id: id.into(), + role: MessageRole::User, + content: content.into(), + parts: None, + identity: MessageIdentity { + sub: "spiffe://x/agent/t".into(), + name: None, + kind: IdentityType::Agent, + }, + tool: None, + metadata: None, + timestamp: "2026-06-23T00:00:00Z".into(), + } + } + + fn buf_text(terminal: &Terminal) -> String { + terminal + .backend() + .buffer() + .content + .iter() + .map(Cell::symbol) + .collect() + } + + #[test] + fn builds_prefix_sum_and_shows_latest_at_bottom() { + let mut state = mk_state(); + state.sessions.upsert(mk_session("s1")); // auto-focuses + for i in 0..5 { + state.messages.apply_message(user_msg( + "s1", + &format!("m{i}"), + &format!("hello message {i}"), + )); + } + let mut terminal = Terminal::new(TestBackend::new(40, 14)).unwrap(); + terminal.draw(|f| render(f, f.area(), &mut state)).unwrap(); + + // The cache miss built the prefix sum; its last entry is the total. + let off = &state.scrollback_build.row_offsets; + assert!(off.len() >= 2, "prefix sum not built"); + assert_eq!( + *off.last().unwrap(), + state.scrollback_build.total_rendered_rows + ); + + // Following the bottom → the most recent message is on screen. + assert!( + buf_text(&terminal).contains("hello message 4"), + "latest message should be visible" + ); + } + + #[test] + fn windowed_render_follows_scroll_offset() { + let mut state = mk_state(); + state.sessions.upsert(mk_session("s1")); + for i in 0..40 { + state + .messages + .apply_message(user_msg("s1", &format!("m{i}"), &format!("LINE{i:02}"))); + } + let mut terminal = Terminal::new(TestBackend::new(40, 10)).unwrap(); + // First render populates total + row_offsets at the bottom. + terminal.draw(|f| render(f, f.area(), &mut state)).unwrap(); + // Scroll to the very top; the window must now show the earliest lines + // and not the latest — i.e. the slice tracked the offset. + state.scroll_offset = u16::MAX; + terminal.draw(|f| render(f, f.area(), &mut state)).unwrap(); + + let text = buf_text(&terminal); + assert!( + text.contains("LINE00"), + "top of transcript should be visible when scrolled up: {text}" + ); + assert!( + !text.contains("LINE39"), + "the latest line must be off-screen when scrolled to the top" + ); + } +}