From 0ef1443247381eecce345d21d37a7f5b5d2b22c4 Mon Sep 17 00:00:00 2001 From: Levi Zim Date: Thu, 26 Mar 2026 08:15:11 +0700 Subject: [PATCH 1/4] tui: implement scrollback --- crates/tracexec-core/src/cli/args.rs | 7 +++ crates/tracexec-core/src/cli/config.rs | 1 + crates/tracexec-tui/src/app.rs | 1 + crates/tracexec-tui/src/app/ui.rs | 16 +++++ crates/tracexec-tui/src/pseudo_term.rs | 87 ++++++++++++++++++++++++-- 5 files changed, 107 insertions(+), 5 deletions(-) diff --git a/crates/tracexec-core/src/cli/args.rs b/crates/tracexec-core/src/cli/args.rs index 4653b2d9..4abac281 100644 --- a/crates/tracexec-core/src/cli/args.rs +++ b/crates/tracexec-core/src/cli/args.rs @@ -490,6 +490,12 @@ pub struct TuiModeArgs { help = "Max number of events to keep in TUI (0=unlimited)" )] pub max_events: Option, + #[clap( + long, + help = "Number of scrollback lines to keep in the pseudo terminal (1000 by default)", + requires = "tty" + )] + pub scrollback_lines: Option, } #[derive(Args, Debug, Default, Clone)] @@ -515,6 +521,7 @@ impl TuiModeArgs { self.layout = self.layout.or(config.layout); self.frame_rate = self.frame_rate.or(config.frame_rate); self.max_events = self.max_events.or(config.max_events); + self.scrollback_lines = self.scrollback_lines.or(config.scrollback_lines); self.follow |= config.follow.unwrap_or_default(); if (!self.terminate_on_exit) && (!self.kill_on_exit) { match config.exit_handling { diff --git a/crates/tracexec-core/src/cli/config.rs b/crates/tracexec-core/src/cli/config.rs index 0a176737..9c99e854 100644 --- a/crates/tracexec-core/src/cli/config.rs +++ b/crates/tracexec-core/src/cli/config.rs @@ -96,6 +96,7 @@ pub struct TuiModeConfig { #[serde(default, deserialize_with = "deserialize_frame_rate")] pub frame_rate: Option, pub max_events: Option, + pub scrollback_lines: Option, } #[derive(Debug, Default, Clone, Deserialize, Serialize)] diff --git a/crates/tracexec-tui/src/app.rs b/crates/tracexec-tui/src/app.rs index 376d9da7..79198ca3 100644 --- a/crates/tracexec-tui/src/app.rs +++ b/crates/tracexec-tui/src/app.rs @@ -167,6 +167,7 @@ impl App { pixel_height: 0, }, pty_master, + tui_args.scrollback_lines.unwrap_or(1000), )?; if active_pane == ActivePane::Terminal { term.focus(true); diff --git a/crates/tracexec-tui/src/app/ui.rs b/crates/tracexec-tui/src/app/ui.rs index 9f7c1c4a..0adc79c7 100644 --- a/crates/tracexec-tui/src/app/ui.rs +++ b/crates/tracexec-tui/src/app/ui.rs @@ -235,6 +235,22 @@ impl App { items.extend(help_item!("Q", "Quit")); } else { // Terminal + if let Some(term) = self.term.as_ref() { + if term.is_scrollback_mode() { + // In scrollback mode - show navigation keys highlighted + items.extend([ + help_key("Ctrl+U"), + fancy_help_desc("Exit\u{00a0}Scroll"), + "\u{200b}".into(), + ]); + items.extend(help_item!("↑↓", "Scroll")); + items.extend(help_item!("PgUp/PgDn", "Page")); + items.extend(help_item!("Home/End", "Jump")); + } else { + // Normal mode - show how to enter scrollback + items.extend(help_item!("Ctrl+U", "Scroll")); + } + } if let Some(h) = self.hit_manager_state.as_ref() && h.count() > 0 { diff --git a/crates/tracexec-tui/src/pseudo_term.rs b/crates/tracexec-tui/src/pseudo_term.rs index dac88d44..f4836b93 100644 --- a/crates/tracexec-tui/src/pseudo_term.rs +++ b/crates/tracexec-tui/src/pseudo_term.rs @@ -22,6 +22,7 @@ // SOFTWARE. use std::{ + cell::Cell, io::{ BufWriter, Write, @@ -74,16 +75,20 @@ pub struct PseudoTerminalPane { master_cancellation_token: CancellationToken, size: PtySize, focus: bool, + scrollback_mode: Cell, + scrollback_lines: usize, } const ESCAPE: u8 = 27; impl PseudoTerminalPane { - pub fn new(size: PtySize, pty_master: UnixMasterPty) -> color_eyre::Result { - let parser = vt100::Parser::new(size.rows, size.cols, 0); - // let screen = parser.screen(); + pub fn new( + size: PtySize, + pty_master: UnixMasterPty, + scrollback_lines: usize, + ) -> color_eyre::Result { + let parser = vt100::Parser::new(size.rows, size.cols, scrollback_lines); let parser = Arc::new(RwLock::new(parser)); - // let term = PseudoTerminal::new(screen); let reader_task = { let mut reader = pty_master.try_clone_reader()?; @@ -141,10 +146,74 @@ impl PseudoTerminalPane { master_tx: tx, master_cancellation_token, focus: false, + scrollback_mode: Cell::new(false), + scrollback_lines, }) } pub async fn handle_key_event(&self, key: &KeyEvent) -> bool { + if let KeyCode::Char(ch) = key.code + && (ch == 'u' || ch == 'U') + && key.modifiers == KeyModifiers::CONTROL + { + self.scrollback_mode.set(!self.scrollback_mode.get()); + let mut parser = self.parser.write().unwrap(); + let screen = parser.screen_mut(); + screen.set_scrollback(0); + return true; + } + + // Handle scrollback navigation when in scrollback mode + if self.scrollback_mode.get() { + let mut parser = self.parser.write().unwrap(); + let screen = parser.screen_mut(); + let viewport_height = self.size.rows as usize; + let max_offset = self + .scrollback_lines + // .min(screen.scrollback_len()) Waiting for https://github.com/doy/vt100-rust/pull/27 + .saturating_sub(viewport_height); + + match key.code { + KeyCode::Up => { + let current = screen.scrollback(); + if current < max_offset { + screen.set_scrollback(current + 1); + } + return true; + } + KeyCode::Down => { + let current = screen.scrollback(); + if current > 0 { + screen.set_scrollback(current - 1); + } + return true; + } + KeyCode::PageUp => { + let current = screen.scrollback(); + let available_above = max_offset.saturating_sub(current); + let step = viewport_height.min(available_above); + screen.set_scrollback(current + step); + return true; + } + KeyCode::PageDown => { + let current = screen.scrollback(); + let step = viewport_height.min(current); + screen.set_scrollback(current - step); + return true; + } + KeyCode::Home => { + screen.set_scrollback(max_offset); + return true; + } + KeyCode::End => { + screen.set_scrollback(0); + return true; + } + _ => {} + } + return true; + } + let input_bytes = match key.code { KeyCode::Char(ch) => { let mut send = vec![0; 4]; @@ -234,6 +303,10 @@ impl PseudoTerminalPane { self.focus = focus; } + pub fn is_scrollback_mode(&self) -> bool { + self.scrollback_mode.get() + } + /// Closes pty master pub fn exit(&self) { self.master_cancellation_token.cancel() @@ -250,7 +323,10 @@ impl Widget for &PseudoTerminalPane { if !self.focus { cursor.hide(); } - let pseudo_term = PseudoTerminal::new(parser.screen()).cursor(cursor); + + let screen = parser.screen(); + + let pseudo_term = PseudoTerminal::new(screen).cursor(cursor); pseudo_term.render(area, buf); } } @@ -291,6 +367,7 @@ mod tests { pixel_height: 0, }, pty_pair.master, + 10, )?; assert!( From 820a9fe230b611a1bf5bd6be2900c23e1d3d9422 Mon Sep 17 00:00:00 2001 From: Levi Zim Date: Thu, 26 Mar 2026 12:17:27 +0700 Subject: [PATCH 2/4] tui, fix: use spawn_blocking for reader_task --- crates/tracexec-tui/src/pseudo_term.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tracexec-tui/src/pseudo_term.rs b/crates/tracexec-tui/src/pseudo_term.rs index f4836b93..94bdec9f 100644 --- a/crates/tracexec-tui/src/pseudo_term.rs +++ b/crates/tracexec-tui/src/pseudo_term.rs @@ -93,7 +93,7 @@ impl PseudoTerminalPane { let reader_task = { let mut reader = pty_master.try_clone_reader()?; let parser = parser.clone(); - tokio::spawn(async move { + tokio::task::spawn_blocking(move || { let mut processed_buf = Vec::new(); let mut buf = [0u8; 8192]; From 2209333243f364e0f99eeca62fff1ffa234aeaf2 Mon Sep 17 00:00:00 2001 From: Levi Zim Date: Thu, 26 Mar 2026 12:17:56 +0700 Subject: [PATCH 3/4] tui, test: add tests for scroll back --- Cargo.lock | 1 + crates/tracexec-tui/Cargo.toml | 1 + crates/tracexec-tui/src/pseudo_term.rs | 348 ++++++++++++++++++++++++- 3 files changed, 342 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 96611f37..f6111bc3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3269,6 +3269,7 @@ dependencies = [ "tracexec-backend-ptrace", "tracexec-core", "tracing", + "tracing-test", "tui-popup", "tui-prompts", "tui-scrollview", diff --git a/crates/tracexec-tui/Cargo.toml b/crates/tracexec-tui/Cargo.toml index 1f573b29..cb8ecf50 100644 --- a/crates/tracexec-tui/Cargo.toml +++ b/crates/tracexec-tui/Cargo.toml @@ -55,3 +55,4 @@ indexset = "0.15" [dev-dependencies] insta = "1.41.0" serial_test = { workspace = true } +tracing-test = { workspace = true } diff --git a/crates/tracexec-tui/src/pseudo_term.rs b/crates/tracexec-tui/src/pseudo_term.rs index 94bdec9f..59637cdd 100644 --- a/crates/tracexec-tui/src/pseudo_term.rs +++ b/crates/tracexec-tui/src/pseudo_term.rs @@ -27,6 +27,7 @@ use std::{ BufWriter, Write, }, + ops::Deref, sync::{ Arc, RwLock, @@ -61,6 +62,7 @@ use tui_term::widget::{ Cursor, PseudoTerminal, }; +use vt100::Parser; pub struct PseudoTerminalPane { // cannot move out of `parser` because it is borrowed @@ -168,16 +170,19 @@ impl PseudoTerminalPane { let mut parser = self.parser.write().unwrap(); let screen = parser.screen_mut(); let viewport_height = self.size.rows as usize; - let max_offset = self - .scrollback_lines - // .min(screen.scrollback_len()) Waiting for https://github.com/doy/vt100-rust/pull/27 - .saturating_sub(viewport_height); + let max_offset = self.scrollback_lines; + // .min(screen.scrollback_len()) Waiting for https://github.com/doy/vt100-rust/pull/27 match key.code { KeyCode::Up => { let current = screen.scrollback(); if current < max_offset { + trace!( + "Scrolling up: current={}, max_offset={}", + current, max_offset + ); screen.set_scrollback(current + 1); + trace!("New scrollback offset: {}", screen.scrollback()); } return true; } @@ -307,6 +312,14 @@ impl PseudoTerminalPane { self.scrollback_mode.get() } + pub fn scrollback(&self) -> usize { + self.parser.read().unwrap().screen().scrollback() + } + + pub fn parser(&self) -> impl Deref { + self.parser.read().unwrap() + } + /// Closes pty master pub fn exit(&self) { self.master_cancellation_token.cancel() @@ -333,6 +346,8 @@ impl Widget for &PseudoTerminalPane { #[cfg(test)] mod tests { + use std::time::Duration; + use crossterm::event::{ KeyCode, KeyEvent, @@ -342,11 +357,15 @@ mod tests { Buffer, Rect, }; - use tracexec_core::pty::{ - PtySize, - PtySystem, - native_pty_system, + use tracexec_core::{ + pty::{ + PtySize, + PtySystem, + native_pty_system, + }, + tracee, }; + use tracing::debug; use super::*; @@ -431,4 +450,317 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn scrollback_toggle_mode() -> color_eyre::Result<()> { + let pty_system = native_pty_system(); + let pty_pair = pty_system.openpty(PtySize { + rows: 12, + cols: 40, + pixel_width: 0, + pixel_height: 0, + })?; + let term = PseudoTerminalPane::new( + PtySize { + rows: 12, + cols: 40, + pixel_width: 0, + pixel_height: 0, + }, + pty_pair.master, + 10, + )?; + + // Initially not in scrollback mode + assert!(!term.is_scrollback_mode()); + + // Toggle on with Ctrl+U + term + .handle_key_event(&KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)) + .await; + assert!(term.is_scrollback_mode()); + + // Toggle off with Ctrl+U + term + .handle_key_event(&KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)) + .await; + assert!(!term.is_scrollback_mode()); + + Ok(()) + } + + #[tokio::test] + async fn scrollback_get_initial_state() -> color_eyre::Result<()> { + let pty_system = native_pty_system(); + let pty_pair = pty_system.openpty(PtySize { + rows: 12, + cols: 40, + pixel_width: 0, + pixel_height: 0, + })?; + let term = PseudoTerminalPane::new( + PtySize { + rows: 12, + cols: 40, + pixel_width: 0, + pixel_height: 0, + }, + pty_pair.master, + 100, + )?; + + // Initial scrollback offset should be 0 (live view) + assert_eq!(term.scrollback(), 0); + + // After entering scrollback mode, it should still be 0 (reset to live) + term + .handle_key_event(&KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)) + .await; + assert!(term.is_scrollback_mode()); + assert_eq!(term.scrollback(), 0); + + Ok(()) + } + + #[tokio::test] + #[tracing_test::traced_test] + async fn scrollback_scroll_up_down() -> color_eyre::Result<()> { + // console_subscriber::init(); + use nix::sys::wait::waitpid; + use tracexec_core::{ + cmdbuilder::CommandBuilder, + pty, + }; + + let pty_system = native_pty_system(); + let pty_pair = pty_system.openpty(PtySize { + rows: 3, + cols: 40, + pixel_width: 0, + pixel_height: 0, + })?; + let term = PseudoTerminalPane::new( + PtySize { + rows: 3, + cols: 40, + pixel_width: 0, + pixel_height: 0, + }, + pty_pair.master, + 100, + )?; + + // Spawn a shell command through the PTY that generates 150 lines of output + let mut cmd = CommandBuilder::new("sh"); + cmd.arg("-c"); + cmd.arg("for i in $(seq 1 150); do echo \"Line $i: test output\"; done"); + + debug!("Spawning command through PTY: {:?}", cmd); + + let child_pid = pty::spawn_command(Some(&pty_pair.slave), cmd, move |_| { + tracee::lead_session_and_control_terminal()?; + Ok(()) + })?; + + // Reap the child process + waitpid(child_pid, None)?; + + // Wait for the reader task to process all output and update the parser state + tokio::time::sleep(Duration::from_secs(1)).await; + + // Enter scrollback mode + term + .handle_key_event(&KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)) + .await; + assert!(term.is_scrollback_mode()); + assert_eq!(term.scrollback(), 0); + + // Scroll up (increase offset) + let result = term + .handle_key_event(&KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)) + .await; + assert!(result); + assert!(term.is_scrollback_mode()); + assert_eq!(term.scrollback(), 1); + + // Scroll up more + let result = term + .handle_key_event(&KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)) + .await; + assert!(result); // Key is consumed + assert!(term.is_scrollback_mode()); + assert_eq!(term.scrollback(), 2); + + // Scroll down (decrease offset) + let result = term + .handle_key_event(&KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)) + .await; + assert!(result); // Key is consumed + assert!(term.is_scrollback_mode()); + assert_eq!(term.scrollback(), 1); + + // Switch back resets offset + let result = term + .handle_key_event(&KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)) + .await; + assert!(result); // Key is consumed + assert!(!term.is_scrollback_mode()); + assert_eq!(term.scrollback(), 0); + + Ok(()) + } + + #[tokio::test] + async fn scrollback_page_navigation() -> color_eyre::Result<()> { + use nix::sys::wait::waitpid; + use tracexec_core::{ + cmdbuilder::CommandBuilder, + pty, + }; + + let pty_system = native_pty_system(); + let pty_pair = pty_system.openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + })?; + let term = PseudoTerminalPane::new( + PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }, + pty_pair.master, + 100, + )?; + + // Spawn a shell command through the PTY that generates 100 lines of output + let mut cmd = CommandBuilder::new("sh"); + cmd.arg("-c"); + cmd.arg("for i in $(seq 1 100); do echo \"Line $i\"; done"); + + let child_pid = pty::spawn_command(Some(&pty_pair.slave), cmd, move |_| { + tracee::lead_session_and_control_terminal()?; + Ok(()) + })?; + + // Reap child + waitpid(child_pid, None)?; + + // Give the reader task time to process the output + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + + // Enter scrollback mode + term + .handle_key_event(&KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)) + .await; + + // Page up should shift by viewport height (24) + let result = term + .handle_key_event(&KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE)) + .await; + assert!(result); + assert_eq!(term.scrollback(), 24); + + // Page up again + let result = term + .handle_key_event(&KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE)) + .await; + assert!(result); + assert_eq!(term.scrollback(), 48); + + // Page down should decrease offset + let result = term + .handle_key_event(&KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE)) + .await; + assert!(result); + assert_eq!(term.scrollback(), 24); + + // Page down back to live + let result = term + .handle_key_event(&KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE)) + .await; + assert!(result); + assert_eq!(term.scrollback(), 0); + + Ok(()) + } + + #[tokio::test] + async fn scrollback_home_end_navigation() -> color_eyre::Result<()> { + use nix::sys::wait::waitpid; + use tracexec_core::{ + cmdbuilder::CommandBuilder, + pty, + }; + + let pty_system = native_pty_system(); + let pty_pair = pty_system.openpty(PtySize { + rows: 12, + cols: 40, + pixel_width: 0, + pixel_height: 0, + })?; + let term = PseudoTerminalPane::new( + PtySize { + rows: 12, + cols: 40, + pixel_width: 0, + pixel_height: 0, + }, + pty_pair.master, + 100, + )?; + + // Spawn a shell command through the PTY that generates 120 lines of output + let mut cmd = CommandBuilder::new("sh"); + cmd.arg("-c"); + cmd.arg("for i in $(seq 1 120); do echo \"Line $i\"; done"); + + let child_pid = pty::spawn_command(Some(&pty_pair.slave), cmd, move |_| { + tracee::lead_session_and_control_terminal()?; + Ok(()) + })?; + // Give the reader task time to process the output + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + + // Reap child + waitpid(child_pid, None)?; + + // Enter scrollback mode + term + .handle_key_event(&KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)) + .await; + + // Scroll up a bit + term + .handle_key_event(&KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)) + .await; + term + .handle_key_event(&KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)) + .await; + term + .handle_key_event(&KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)) + .await; + assert_eq!(term.scrollback(), 3); + + // Home should jump to max offset + let result = term + .handle_key_event(&KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)) + .await; + assert!(result); + // max_offset = 100 (scrollback_lines configured) + assert_eq!(term.scrollback(), 100); + + // End should jump back to 0 (live) + let result = term + .handle_key_event(&KeyEvent::new(KeyCode::End, KeyModifiers::NONE)) + .await; + assert!(result); + assert_eq!(term.scrollback(), 0); + + Ok(()) + } } From 4a300928d27afeba4108d63f5cde58ff67abd9f4 Mon Sep 17 00:00:00 2001 From: Levi Zim Date: Thu, 26 Mar 2026 12:25:17 +0700 Subject: [PATCH 4/4] tui, docs: document scrollback_lines --- README.md | 2 ++ config.toml | 3 +++ 2 files changed, 5 insertions(+) diff --git a/README.md b/README.md index a4ddd55b..437db456 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,8 @@ Options: Set the frame rate of the TUI (60 by default) -m, --max-events Max number of events to keep in TUI (0=unlimited) + --scrollback-lines + Number of scrollback lines to keep in the pseudo terminal (1000 by default) -D, --default-external-command Set the default external command to run when using "Detach, Stop and Run Command" feature in Hit Manager -b, --add-breakpoint diff --git a/config.toml b/config.toml index c0dc0e49..6dda5e64 100644 --- a/config.toml +++ b/config.toml @@ -72,6 +72,9 @@ # Max number of events to keep in TUI. (0=unlimited) # max_events = 1_000_000 +# Number of scrollback lines to keep in the pseudo terminal +# scrollback_lines = 1000 + # # Config for Log mode #