diff --git a/src/grid.rs b/src/grid.rs index 7768d06..512a82c 100644 --- a/src/grid.rs +++ b/src/grid.rs @@ -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(); @@ -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 }); diff --git a/tests/scroll.rs b/tests/scroll.rs index a73a688..2181ff8 100644 --- a/tests/scroll.rs +++ b/tests/scroll.rs @@ -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, join: &str) -> String { range