From 527f0715fad788cbbf9605b8d5923b519755c345 Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Sat, 23 May 2026 02:05:33 +0700 Subject: [PATCH] feat: support clearing scrollback Add Screen::clear_scrollback and handle CSI 3 J as xterm's erase saved lines sequence. This lets embedders clear scrollback through terminal semantics without resetting parser state. Co-authored-by: Codex --- CHANGELOG.md | 6 ++++ Cargo.lock | 6 ++-- examples/generate_fixture.rs | 5 +-- src/grid.rs | 5 +++ src/screen.rs | 11 +++++++ tests/scroll.rs | 59 ++++++++++++++++++++++++++++++++++++ 6 files changed, 85 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d589ed7..399499f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Added + +* Support for clearing scrollback with `Screen::clear_scrollback` and `CSI 3 J`. + ## [0.16.2] - 2025-07-11 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 2f4673a..c0074f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -175,9 +175,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.1" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", "rand_core 0.9.3", @@ -331,7 +331,7 @@ dependencies = [ "itoa", "nix", "quickcheck", - "rand 0.9.1", + "rand 0.9.4", "serde", "serde_json", "terminal_size", diff --git a/examples/generate_fixture.rs b/examples/generate_fixture.rs index 2f82617..e03b6ca 100644 --- a/examples/generate_fixture.rs +++ b/examples/generate_fixture.rs @@ -14,9 +14,8 @@ fn main() { .unwrap(); let inputs = std::io::BufReader::new(inputs); - let mut i = 1; let mut prev_input = vec![]; - for line in inputs.lines() { + for (i, line) in (1..).zip(inputs.lines()) { let line = line.unwrap(); let input = helpers::unhex(line.as_bytes()); @@ -36,7 +35,5 @@ fn main() { )) .unwrap(); serde_json::to_writer_pretty(output_file, &screen).unwrap(); - - i += 1; } } diff --git a/src/grid.rs b/src/grid.rs index 7768d06..1bb42f7 100644 --- a/src/grid.rs +++ b/src/grid.rs @@ -199,6 +199,11 @@ impl Grid { self.scrollback_offset = rows.min(self.scrollback.len()); } + pub fn clear_scrollback(&mut self) { + self.scrollback.clear(); + self.scrollback_offset = 0; + } + pub fn write_contents(&self, contents: &mut String) { let mut wrapping = false; for row in self.visible_rows() { diff --git a/src/screen.rs b/src/screen.rs index 7dfec97..44d9816 100644 --- a/src/screen.rs +++ b/src/screen.rs @@ -114,6 +114,16 @@ impl Screen { self.grid_mut().set_scrollback(rows); } + /// Clears the scrollback history for the active screen and returns the + /// scrollback position to the live viewport. + /// + /// This does not erase the visible screen contents or reset terminal + /// modes. To model a terminal action that clears both the visible display + /// and scrollback history, process both `CSI 2 J` and `CSI 3 J`. + pub fn clear_scrollback(&mut self) { + self.grid_mut().clear_scrollback(); + } + /// Returns the current position in the scrollback. /// /// This position indicates the offset from the top of the screen, and is @@ -1062,6 +1072,7 @@ impl Screen { 0 => self.grid_mut().erase_all_forward(attrs), 1 => self.grid_mut().erase_all_backward(attrs), 2 => self.grid_mut().erase_all(attrs), + 3 => self.clear_scrollback(), _ => unhandled(self), } } diff --git a/tests/scroll.rs b/tests/scroll.rs index a73a688..b141b1f 100644 --- a/tests/scroll.rs +++ b/tests/scroll.rs @@ -191,6 +191,65 @@ fn scrollback_larger_than_rows() { assert_eq!(parser.screen().contents(), gen_nums(1..=3, "\n")); } +#[test] +fn clear_scrollback() { + let mut parser = vt100::Parser::new(3, 20, 10); + + parser.process(gen_nums(1..=6, "\r\n").as_bytes()); + parser.screen_mut().set_scrollback(3); + assert_eq!(parser.screen().scrollback(), 3); + assert_eq!(parser.screen().contents(), gen_nums(1..=3, "\n")); + + parser.screen_mut().clear_scrollback(); + assert_eq!(parser.screen().scrollback(), 0); + assert_eq!(parser.screen().contents(), gen_nums(4..=6, "\n")); + + parser.screen_mut().set_scrollback(10); + assert_eq!(parser.screen().scrollback(), 0); +} + +#[test] +fn erase_saved_lines_csi_3j() { + let mut parser = vt100::Parser::new(3, 20, 10); + + parser.process(gen_nums(1..=6, "\r\n").as_bytes()); + assert_eq!(parser.screen().contents(), gen_nums(4..=6, "\n")); + + parser.screen_mut().set_scrollback(2); + assert_eq!(parser.screen().scrollback(), 2); + parser.process(b"\x1b[3J"); + + assert_eq!(parser.screen().scrollback(), 0); + assert_eq!(parser.screen().contents(), gen_nums(4..=6, "\n")); + parser.screen_mut().set_scrollback(10); + assert_eq!(parser.screen().scrollback(), 0); +} + +#[test] +fn erase_saved_lines_csi_3j_preserves_modes() { + let mut parser = vt100::Parser::new(3, 20, 10); + + parser.process(b"\x1b[?1h\x1b[?25l\x1b[?1000h\x1b[?1006h\x1b[?2004h"); + parser.process(gen_nums(1..=6, "\r\n").as_bytes()); + + parser.screen_mut().set_scrollback(2); + parser.process(b"\x1b[3J"); + + assert_eq!(parser.screen().scrollback(), 0); + assert_eq!(parser.screen().contents(), gen_nums(4..=6, "\n")); + assert!(parser.screen().application_cursor()); + assert!(parser.screen().hide_cursor()); + assert!(parser.screen().bracketed_paste()); + assert_eq!( + parser.screen().mouse_protocol_mode(), + vt100::MouseProtocolMode::PressRelease + ); + assert_eq!( + parser.screen().mouse_protocol_encoding(), + vt100::MouseProtocolEncoding::Sgr + ); +} + #[cfg(test)] fn gen_nums(range: RangeInclusive, join: &str) -> String { range