Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions crates/codeoid-tui/src/state/scrollback_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize>,
}

/// 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 {
Expand All @@ -50,6 +97,7 @@ impl ScrollbackBuild {
self.session_id = None;
self.lines.clear();
self.total_rendered_rows = 0;
self.row_offsets.clear();
}
}

Expand All @@ -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));
}
}
180 changes: 161 additions & 19 deletions crates/codeoid-tui/src/ui/scrollback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize> = 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
Expand All @@ -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<Line>` 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<Line<'static>> = 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)
Expand Down Expand Up @@ -433,3 +447,131 @@ fn short_sub(sub: &str) -> String {
// spiffe://…/agent/<name> → <name>, 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<TestBackend>) -> 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"
);
}
}
Loading