aboutsummaryrefslogtreecommitdiffstats
path: root/zellij-server
diff options
context:
space:
mode:
authorNitin Gupta <nitingupta910@gmail.com>2026-03-31 07:28:07 -0700
committerGitHub <noreply@github.com>2026-03-31 16:28:07 +0200
commit50c029afaf3f1b412583f7cfe7aa9f0ee984ffa8 (patch)
tree1f9a46487ee7725615b6975e14fda9a67c47dc10 /zellij-server
parentProper error when providing layout name with a tab (#4958) (diff)
downloadsl0ppij-50c029afaf3f1b412583f7cfe7aa9f0ee984ffa8.tar.gz
sl0ppij-50c029afaf3f1b412583f7cfe7aa9f0ee984ffa8.tar.xz
sl0ppij-50c029afaf3f1b412583f7cfe7aa9f0ee984ffa8.zip
Preserve scrollback for partial scroll regions starting at top (#4941)
* Preserve scrollback for partial scroll regions starting at top When a partial scroll region starts at row 0 (the top of the viewport), lines scrolled off the top were silently discarded via viewport.remove(0) instead of being transferred to lines_above (the scrollback buffer). This affected both add_canonical_line and rotate_scroll_region_down. Applications like Codex CLI use partial scroll regions to push rendered content into terminal scrollback (via DECSTBM + newlines). In tmux, these lines are preserved in the scrollback buffer and accessible via copy-mode scrolling. In Zellij, they were lost, causing mouse wheel scrolling to show only stale pre-application content. Transfer lines to lines_above when scroll_region_top == 0 and alternate screen is not active, matching the behavior of tmux. * add selection handling to CSI S, add matching handling for CSI M, add tests * add pr --------- Co-authored-by: Aram Drevekenin <aram@poor.dev>
Diffstat (limited to 'zellij-server')
-rw-r--r--zellij-server/src/panes/grid.rs27
-rw-r--r--zellij-server/src/panes/unit/grid_tests.rs227
2 files changed, 251 insertions, 3 deletions
diff --git a/zellij-server/src/panes/grid.rs b/zellij-server/src/panes/grid.rs
index 9e37ef8e..b7dfa701 100644
--- a/zellij-server/src/panes/grid.rs
+++ b/zellij-server/src/panes/grid.rs
@@ -1666,7 +1666,13 @@ impl Grid {
let mut pad_character = EMPTY_TERMINAL_CHARACTER;
pad_character.styles = self.cursor.pending_styles.clone();
for _ in 0..count {
- if scroll_region_top < self.viewport.len() {
+ if scroll_region_top == 0
+ && self.alternate_screen_state.is_none()
+ && !self.viewport.is_empty()
+ {
+ self.transfer_rows_to_lines_above(1);
+ self.selection.move_up(1);
+ } else if scroll_region_top < self.viewport.len() {
self.viewport.remove(scroll_region_top);
}
let columns = VecDeque::from(vec![pad_character.clone(); self.width]);
@@ -1719,7 +1725,15 @@ impl Grid {
self.viewport.push_back(Row::new().canonical());
self.selection.move_up(1);
} else {
- if scroll_region_top < self.viewport.len() {
+ if scroll_region_top == 0
+ && self.alternate_screen_state.is_none()
+ && !self.viewport.is_empty()
+ {
+ // Partial scroll region starting at top: preserve
+ // scrolled-off lines in scrollback
+ self.transfer_rows_to_lines_above(1);
+ self.selection.move_up(1);
+ } else if scroll_region_top < self.viewport.len() {
self.viewport.remove(scroll_region_top);
}
if self.viewport.len() >= scroll_region_bottom {
@@ -2035,7 +2049,14 @@ impl Grid {
// so we delete the current line(s) and add an empty line at the end of the scroll
// region
for _ in 0..count {
- if current_line_index < self.viewport.len() {
+ if current_line_index == 0
+ && scroll_region_top == 0
+ && self.alternate_screen_state.is_none()
+ && !self.viewport.is_empty()
+ {
+ self.transfer_rows_to_lines_above(1);
+ self.selection.move_up(1);
+ } else if current_line_index < self.viewport.len() {
self.viewport.remove(current_line_index);
}
let columns = VecDeque::from(vec![pad_character.clone(); self.width]);
diff --git a/zellij-server/src/panes/unit/grid_tests.rs b/zellij-server/src/panes/unit/grid_tests.rs
index 17c74006..1563f90c 100644
--- a/zellij-server/src/panes/unit/grid_tests.rs
+++ b/zellij-server/src/panes/unit/grid_tests.rs
@@ -5365,3 +5365,230 @@ fn layer_ordering() {
assert!(HighlightLayer::Hint < HighlightLayer::Tool);
assert!(HighlightLayer::Tool < HighlightLayer::ActionFeedback);
}
+
+fn row_text(row: &super::super::Row) -> String {
+ row.columns
+ .iter()
+ .map(|c| c.character)
+ .collect::<String>()
+ .trim_end()
+ .to_string()
+}
+
+fn scrollback_texts(grid: &Grid) -> Vec<String> {
+ grid.lines_above.iter().map(|r| row_text(r)).collect()
+}
+
+fn viewport_texts(grid: &Grid) -> Vec<String> {
+ grid.viewport.iter().map(|r| row_text(r)).collect()
+}
+
+fn create_grid_with_size_and_raw(rows: usize, cols: usize, content: &[u8]) -> Grid {
+ let mut vte_parser = vte::Parser::new();
+ let sixel_image_store = Rc::new(RefCell::new(SixelImageStore::default()));
+ let terminal_emulator_color_codes = Rc::new(RefCell::new(HashMap::new()));
+ let mut grid = Grid::new(
+ rows,
+ cols,
+ Rc::new(RefCell::new(Palette::default())),
+ terminal_emulator_color_codes,
+ Rc::new(RefCell::new(LinkHandler::new())),
+ Rc::new(RefCell::new(None)),
+ sixel_image_store,
+ Style::default(),
+ false,
+ true,
+ true,
+ true,
+ false,
+ );
+ for byte in content {
+ vte_parser.advance(&mut grid, *byte);
+ }
+ grid
+}
+
+fn feed_bytes(grid: &mut Grid, bytes: &[u8]) {
+ let mut vte_parser = vte::Parser::new();
+ for byte in bytes {
+ vte_parser.advance(grid, *byte);
+ }
+}
+
+// All tests below use a 10-row, 40-col grid with scroll region 1;8
+// (0-based: rows 0-7), leaving rows 8-9 outside the region.
+const PARTIAL_SR: &[u8] = b"\x1b[1;8r";
+const FILL_8_LINES: &[u8] = b"AAA\r\nBBB\r\nCCC\r\nDDD\r\nEEE\r\nFFF\r\nGGG\r\nHHH";
+
+#[test]
+fn partial_scroll_region_newline_transfers_to_scrollback() {
+ let mut content: Vec<u8> = Vec::new();
+ content.extend_from_slice(PARTIAL_SR);
+ content.extend_from_slice(FILL_8_LINES);
+ content.extend_from_slice(b"\r\nIII\r\nJJJ");
+
+ let grid = create_grid_with_size_and_raw(10, 40, &content);
+
+ assert_eq!(scrollback_texts(&grid), vec!["AAA", "BBB"]);
+ let vp = viewport_texts(&grid);
+ assert_eq!(vp[0], "CCC");
+ assert_eq!(vp[1], "DDD");
+ assert_eq!(vp[6], "III");
+ assert_eq!(vp[7], "JJJ");
+}
+
+#[test]
+fn partial_scroll_region_csi_s_transfers_to_scrollback() {
+ let mut content: Vec<u8> = Vec::new();
+ content.extend_from_slice(PARTIAL_SR);
+ content.extend_from_slice(FILL_8_LINES);
+ content.extend_from_slice(b"\x1b[3S");
+
+ let grid = create_grid_with_size_and_raw(10, 40, &content);
+
+ assert_eq!(scrollback_texts(&grid), vec!["AAA", "BBB", "CCC"]);
+ let vp = viewport_texts(&grid);
+ assert_eq!(vp[0], "DDD");
+ assert_eq!(vp[4], "HHH");
+ assert_eq!(vp[5], "");
+ assert_eq!(vp[7], "");
+}
+
+#[test]
+fn partial_scroll_region_csi_m_at_top_transfers_to_scrollback() {
+ let mut content: Vec<u8> = Vec::new();
+ content.extend_from_slice(PARTIAL_SR);
+ content.extend_from_slice(FILL_8_LINES);
+ content.extend_from_slice(b"\x1b[1;1H\x1b[2M");
+
+ let grid = create_grid_with_size_and_raw(10, 40, &content);
+
+ assert_eq!(scrollback_texts(&grid), vec!["AAA", "BBB"]);
+ let vp = viewport_texts(&grid);
+ assert_eq!(vp[0], "CCC");
+ assert_eq!(vp[5], "HHH");
+ assert_eq!(vp[6], "");
+ assert_eq!(vp[7], "");
+}
+
+#[test]
+fn partial_scroll_region_csi_m_mid_region_does_not_transfer() {
+ let mut content: Vec<u8> = Vec::new();
+ content.extend_from_slice(PARTIAL_SR);
+ content.extend_from_slice(FILL_8_LINES);
+ // Cursor to row 3, delete 2 lines
+ content.extend_from_slice(b"\x1b[4;1H\x1b[2M");
+
+ let grid = create_grid_with_size_and_raw(10, 40, &content);
+
+ assert_eq!(grid.lines_above.len(), 0);
+ let vp = viewport_texts(&grid);
+ assert_eq!(vp[0], "AAA");
+ assert_eq!(vp[2], "CCC");
+ assert_eq!(vp[3], "FFF");
+ assert_eq!(vp[5], "HHH");
+ assert_eq!(vp[6], "");
+ assert_eq!(vp[7], "");
+}
+
+#[test]
+fn partial_scroll_region_does_not_transfer_on_alternate_screen() {
+ let mut content: Vec<u8> = Vec::new();
+ content.extend_from_slice(b"\x1b[?1049h");
+ content.extend_from_slice(PARTIAL_SR);
+ content.extend_from_slice(FILL_8_LINES);
+ content.extend_from_slice(b"\x1b[3S");
+
+ let grid = create_grid_with_size_and_raw(10, 40, &content);
+
+ assert_eq!(grid.lines_above.len(), 0);
+ let vp = viewport_texts(&grid);
+ assert_eq!(vp[0], "DDD");
+ assert_eq!(vp[4], "HHH");
+}
+
+#[test]
+fn partial_scroll_region_nonzero_top_does_not_transfer() {
+ let mut content: Vec<u8> = Vec::new();
+ content.extend_from_slice(b"AAA\r\nBBB\r\nCCC\r\nDDD\r\nEEE\r\nFFF");
+ // Scroll region rows 3-6 (1-based), so top is row 2, not 0
+ content.extend_from_slice(b"\x1b[3;6r");
+ content.extend_from_slice(b"\x1b[2S");
+
+ let grid = create_grid_with_size_and_raw(10, 40, &content);
+
+ assert_eq!(grid.lines_above.len(), 0);
+ let vp = viewport_texts(&grid);
+ assert_eq!(vp[0], "AAA");
+ assert_eq!(vp[1], "BBB");
+ assert_eq!(vp[2], "EEE");
+ assert_eq!(vp[3], "FFF");
+ assert_eq!(vp[4], "");
+ assert_eq!(vp[5], "");
+}
+
+#[test]
+fn partial_scroll_region_selection_adjusted_on_newline() {
+ let mut grid = create_grid_with_size_and_raw(10, 40, FILL_8_LINES);
+ feed_bytes(&mut grid, PARTIAL_SR);
+
+ let pos = Position::new(3, 5);
+ grid.start_selection(&pos);
+ grid.end_selection(&pos);
+ let start_before = grid.selection.start.line.0;
+ let end_before = grid.selection.end.line.0;
+
+ feed_bytes(&mut grid, b"\x1b[8;1H\r\nnew line");
+
+ assert_eq!(grid.selection.start.line.0, start_before - 1);
+ assert_eq!(grid.selection.end.line.0, end_before - 1);
+}
+
+#[test]
+fn partial_scroll_region_selection_adjusted_on_csi_s() {
+ let mut grid = create_grid_with_size_and_raw(10, 40, FILL_8_LINES);
+ feed_bytes(&mut grid, PARTIAL_SR);
+
+ let pos = Position::new(4, 2);
+ grid.start_selection(&pos);
+ grid.end_selection(&pos);
+ let start_before = grid.selection.start.line.0;
+ let end_before = grid.selection.end.line.0;
+
+ feed_bytes(&mut grid, b"\x1b[2S");
+
+ assert_eq!(grid.selection.start.line.0, start_before - 2);
+ assert_eq!(grid.selection.end.line.0, end_before - 2);
+}
+
+#[test]
+fn partial_scroll_region_selection_adjusted_on_csi_m() {
+ let mut grid = create_grid_with_size_and_raw(10, 40, FILL_8_LINES);
+ feed_bytes(&mut grid, PARTIAL_SR);
+
+ let pos = Position::new(5, 2);
+ grid.start_selection(&pos);
+ grid.end_selection(&pos);
+ let start_before = grid.selection.start.line.0;
+ let end_before = grid.selection.end.line.0;
+
+ feed_bytes(&mut grid, b"\x1b[3M");
+
+ assert_eq!(grid.selection.start.line.0, start_before - 3);
+ assert_eq!(grid.selection.end.line.0, end_before - 3);
+}
+
+#[test]
+fn partial_scroll_region_all_three_mechanisms_transfer_in_order() {
+ let mut content: Vec<u8> = Vec::new();
+ content.extend_from_slice(PARTIAL_SR);
+ content.extend_from_slice(FILL_8_LINES);
+ // Newline scrolls AAA off, CSI S scrolls BBB off, CSI M scrolls CCC off
+ content.extend_from_slice(b"\r\nIII");
+ content.extend_from_slice(b"\x1b[1S");
+ content.extend_from_slice(b"\x1b[1;1H\x1b[1M");
+
+ let grid = create_grid_with_size_and_raw(10, 40, &content);
+
+ assert_eq!(scrollback_texts(&grid), vec!["AAA", "BBB", "CCC"]);
+}