Skip to content
Open
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
13 changes: 8 additions & 5 deletions src/grid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,14 @@ impl Grid {
self.rows
.insert(usize::from(self.scroll_bottom) + 1, self.new_row());
let removed = self.rows.remove(usize::from(self.scroll_top));
if self.scrollback_len > 0 && !self.scroll_region_active() {
// A scrolled-off row belongs in the scrollback only when it leaves the top of the
// screen, i.e. when the scroll region starts at the screen top (scroll_top == 0).
// Upstream used `!scroll_region_active()`, which dropped the row whenever ANY scroll
// region was set. That broke inline TUIs (e.g. codex) that insert finalized history
// via `SetScrollRegion(1..viewport_top)` + `\r\n` / reverse-index — they never built
// any scrollback. With `scroll_top == 0`, full-screen scrolling is unchanged and we
// save only when the region includes the screen top (matching xterm).
if self.scrollback_len > 0 && self.scroll_top == 0 {
self.scrollback.push_back(removed);
while self.scrollback.len() > self.scrollback_len {
self.scrollback.pop_front();
Expand Down Expand Up @@ -603,10 +610,6 @@ impl Grid {
self.pos.row >= self.scroll_top && self.pos.row <= self.scroll_bottom
}

fn scroll_region_active(&self) -> bool {
self.scroll_top != 0 || self.scroll_bottom != self.size.rows - 1
}

pub fn set_origin_mode(&mut self, mode: bool) {
self.origin_mode = mode;
self.set_pos(Pos { row: 0, col: 0 });
Expand Down
22 changes: 22 additions & 0 deletions tests/scroll.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,28 @@ fn scrollback_larger_than_rows() {
assert_eq!(parser.screen().contents(), gen_nums(1..=3, "\n"));
}

#[test]
fn scrollback_in_top_aligned_region() {
// Regression: inline TUIs (e.g. codex) insert finalized history into the scrollback using
// `SetScrollRegion(1..viewport_top)` + `\r\n` within the region. When the region top is the
// screen top (scroll_top == 0), scrolled-off rows must enter the scrollback — equivalent to
// full-screen scrolling, and matching xterm. Upstream's `!scroll_region_active()` dropped
// these rows unconditionally, so such TUIs never built any scrollback.
let mut parser = vt100::Parser::new(6, 10, 100);
parser.process(b"row1\r\nrow2\r\nrow3\r\nrow4\r\nrow5\r\nrow6");

// Scroll region 1..4 (screen top to row 4), cursor to region bottom, write 3 lines with
// newlines -> 3 rows should land in the scrollback.
parser.process(b"\x1b[1;4r\x1b[4;1Hh1\r\nh2\r\nh3\r\n\x1b[r");

parser.screen_mut().set_scrollback(100);
assert_eq!(parser.screen().scrollback(), 3);
let back = parser.screen().contents();
assert!(back.contains("row1"));
assert!(back.contains("row2"));
assert!(back.contains("row3"));
}

#[cfg(test)]
fn gen_nums(range: RangeInclusive<u8>, join: &str) -> String {
range
Expand Down