diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-07 00:04:32 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-07 00:04:32 +0300 |
| commit | a51902ede97afd84b469f838f25ab79287bd375d (patch) | |
| tree | 986cf68f33370450eae9152ac3c2015aeebe5882 /src | |
| parent | 6ccf156a3dd23f419c03892ddc1136e72957f66a (diff) | |
fix: use byte-based pane scrollback limit
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/input.rs | 11 | ||||
| -rw-r--r-- | src/app/mod.rs | 27 | ||||
| -rw-r--r-- | src/app/state.rs | 2 | ||||
| -rw-r--r-- | src/config.rs | 38 | ||||
| -rw-r--r-- | src/ghostty/mod.rs | 53 | ||||
| -rw-r--r-- | src/main.rs | 3 | ||||
| -rw-r--r-- | src/pane.rs | 32 | ||||
| -rw-r--r-- | src/persist.rs | 6 | ||||
| -rw-r--r-- | src/workspace.rs | 18 |
9 files changed, 181 insertions, 9 deletions
diff --git a/src/app/input.rs b/src/app/input.rs index 64731bf3..17f283c8 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -2804,9 +2804,14 @@ impl AppState { .and_then(|rt| rt.cwd()); if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { - if let Ok(new_id) = - ws.split_focused(direction, new_rows, new_cols, cwd, self.host_terminal_theme) - { + if let Ok(new_id) = ws.split_focused( + direction, + new_rows, + new_cols, + cwd, + self.pane_scrollback_limit_bytes, + self.host_terminal_theme, + ) { ws.layout.focus_pane(new_id); self.mode = Mode::Terminal; } diff --git a/src/app/mod.rs b/src/app/mod.rs index 6d677b0c..9020658a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -170,6 +170,7 @@ impl App { &snap, 24, 80, + config.advanced.scrollback_limit_bytes, event_tx.clone(), render_notify.clone(), render_dirty.clone(), @@ -205,6 +206,11 @@ impl App { ) }; + info!( + pane_scrollback_limit_bytes = config.advanced.scrollback_limit_bytes, + "using pane scrollback configuration" + ); + let latest_release_notes_available = crate::release_notes::load_latest().is_some(); let mode = if config.should_show_onboarding() { @@ -267,6 +273,7 @@ impl App { sidebar_collapsed: false, agent_panel_scope, confirm_close: config.ui.confirm_close, + pane_scrollback_limit_bytes: config.advanced.scrollback_limit_bytes, accent: crate::config::parse_color(&config.ui.accent), sound: config.ui.sound.clone(), toast_config: config.ui.toast.clone(), @@ -1243,7 +1250,15 @@ impl App { .workspaces .get_mut(ws_idx) .ok_or_else(|| std::io::Error::other("workspace disappeared")) - .and_then(|ws| ws.create_tab(rows, cols, cwd, self.state.host_terminal_theme)); + .and_then(|ws| { + ws.create_tab( + rows, + cols, + cwd, + self.state.pane_scrollback_limit_bytes, + self.state.host_terminal_theme, + ) + }); match result { Ok(tab_idx) => { if params.focus { @@ -1442,6 +1457,7 @@ impl App { rows, cols, params.cwd.map(std::path::PathBuf::from), + self.state.pane_scrollback_limit_bytes, self.state.host_terminal_theme, ) { Ok(new_pane_id) => new_pane_id, @@ -1973,7 +1989,13 @@ impl App { }; let (rows, cols) = self.state.estimate_pane_size(); let ws = &mut self.state.workspaces[ws_idx]; - let idx = ws.create_tab(rows, cols, initial_cwd, self.state.host_terminal_theme)?; + let idx = ws.create_tab( + rows, + cols, + initial_cwd, + self.state.pane_scrollback_limit_bytes, + self.state.host_terminal_theme, + )?; if focus { ws.switch_tab(idx); self.state.mode = Mode::Terminal; @@ -1991,6 +2013,7 @@ impl App { initial_cwd, rows, cols, + self.state.pane_scrollback_limit_bytes, self.state.host_terminal_theme, self.event_tx.clone(), self.render_notify.clone(), diff --git a/src/app/state.rs b/src/app/state.rs index 84920131..a0955a16 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -601,6 +601,7 @@ pub struct AppState { pub sidebar_collapsed: bool, pub agent_panel_scope: AgentPanelScope, pub confirm_close: bool, + pub pane_scrollback_limit_bytes: usize, #[allow(dead_code)] // kept for backward compat; palette.accent is the source of truth pub accent: Color, pub sound: SoundConfig, @@ -710,6 +711,7 @@ impl AppState { sidebar_collapsed: false, agent_panel_scope: AgentPanelScope::CurrentWorkspace, confirm_close: true, + pane_scrollback_limit_bytes: crate::config::DEFAULT_SCROLLBACK_LIMIT_BYTES, accent: Color::Cyan, sound: SoundConfig { enabled: false, diff --git a/src/config.rs b/src/config.rs index 8733d5cc..bd6b93b9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,6 +4,7 @@ use crossterm::event::{KeyCode, KeyModifiers}; use serde::Deserialize; pub const CONFIG_PATH_ENV_VAR: &str = "HERDR_CONFIG_PATH"; +pub const DEFAULT_SCROLLBACK_LIMIT_BYTES: usize = 10_000_000; use tracing::warn; pub fn app_dir_name() -> &'static str { @@ -151,11 +152,14 @@ pub struct UiConfig { pub sound: SoundConfig, } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Deserialize)] #[serde(default)] pub struct AdvancedConfig { /// Allow launching herdr inside an existing herdr pane. Default: false. pub allow_nested: bool, + /// Maximum scrollback buffer size in bytes retained per pane terminal. Default: 10000000. + #[serde(alias = "scrollback_lines")] + pub scrollback_limit_bytes: usize, } #[derive(Debug, Clone, Deserialize)] @@ -337,6 +341,15 @@ impl Default for SoundConfig { } } +impl Default for AdvancedConfig { + fn default() -> Self { + Self { + allow_nested: false, + scrollback_limit_bytes: DEFAULT_SCROLLBACK_LIMIT_BYTES, + } + } +} + impl Default for AgentSoundOverrides { fn default() -> Self { Self { @@ -1356,13 +1369,34 @@ path = "sounds/notification.wav" } #[test] - fn advanced_allow_nested_parses() { + fn advanced_defaults_include_scrollback_limit_bytes() { + let config = Config::default(); + assert_eq!( + config.advanced.scrollback_limit_bytes, + DEFAULT_SCROLLBACK_LIMIT_BYTES + ); + } + + #[test] + fn advanced_config_parses() { let toml = r#" [advanced] allow_nested = true +scrollback_limit_bytes = 12345 "#; let config: Config = toml::from_str(toml).unwrap(); assert!(config.advanced.allow_nested); + assert_eq!(config.advanced.scrollback_limit_bytes, 12345); + } + + #[test] + fn advanced_legacy_scrollback_lines_alias_parses() { + let toml = r#" +[advanced] +scrollback_lines = 12345 +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.advanced.scrollback_limit_bytes, 12345); } #[test] diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index e9ca1f86..297b0e6b 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -1115,6 +1115,19 @@ impl<'a> RowCellIter<'a> { mod tests { use super::*; + fn write_numbered_lines(terminal: &mut Terminal, count: usize) { + for i in 0..count { + terminal.write(format!("{i:06}\r\n").as_bytes()); + } + } + + fn write_padded_lines(terminal: &mut Terminal, count: usize, width: usize) { + let line = format!("{}\r\n", "x".repeat(width)); + for _ in 0..count { + terminal.write(line.as_bytes()); + } + } + #[test] fn focus_encoding_matches_expected_sequences() { assert_eq!(encode_focus(FocusEvent::Gained).unwrap(), b"\x1b[I"); @@ -1194,6 +1207,46 @@ mod tests { } #[test] + fn zero_max_scrollback_disables_history() { + let mut terminal = Terminal::new(80, 3, 0).unwrap(); + write_numbered_lines(&mut terminal, 3000); + assert_eq!(terminal.scrollback_rows().unwrap(), 0); + } + + #[test] + fn max_scrollback_limit_bytes_retains_more_history_for_larger_limits() { + let mut small = Terminal::new(80, 3, 1_000_000).unwrap(); + let mut large = Terminal::new(80, 3, 10_000_000).unwrap(); + + write_padded_lines(&mut small, 20_000, 70); + write_padded_lines(&mut large, 20_000, 70); + + let small_scrollback = small.scrollback_rows().unwrap(); + let large_scrollback = large.scrollback_rows().unwrap(); + + assert!( + large_scrollback > small_scrollback, + "expected larger byte limit to retain more history, got small={small_scrollback}, large={large_scrollback}" + ); + } + + #[test] + fn large_negative_scroll_delta_reaches_top_of_scrollback() { + let mut terminal = Terminal::new(80, 3, 1_000_000).unwrap(); + write_numbered_lines(&mut terminal, 1000); + + let before = terminal.scrollbar().unwrap(); + assert!(before.total > before.len); + + terminal.scroll_viewport_bottom(); + terminal.scroll_viewport_delta(-10_000); + + let after = terminal.scrollbar().unwrap(); + assert_eq!(after.offset, 0); + assert_eq!(after.len, before.len); + } + + #[test] fn terminal_and_render_state_smoke_test() { let mut terminal = Terminal::new(8, 3, 100).unwrap(); assert_eq!(terminal.cols().unwrap(), 8); diff --git a/src/main.rs b/src/main.rs index 4fdfc9de..aeeb277c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -151,6 +151,9 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration [advanced] # Allow launching herdr from inside a herdr-managed pane. # allow_nested = false +# Maximum scrollback buffer size in bytes retained per pane terminal. +# Matches Ghostty's default scrollback-limit behavior. +# scrollback_limit_bytes = 10000000 "##; fn should_block_nested(config: &config::Config) -> bool { diff --git a/src/pane.rs b/src/pane.rs index a170d295..e2afccdc 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -1344,6 +1344,7 @@ impl PaneRuntime { rows: u16, cols: u16, cwd: std::path::PathBuf, + scrollback_limit_bytes: usize, _host_terminal_theme: crate::terminal_theme::TerminalTheme, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, @@ -1362,7 +1363,12 @@ impl PaneRuntime { // --- Writer channel --- let (input_tx, mut input_rx) = mpsc::channel::<Bytes>(32); - let terminal = crate::ghostty::Terminal::new(cols, rows, 10000) + info!( + pane = pane_id.raw(), + rows, cols, scrollback_limit_bytes, "spawning pane terminal" + ); + + let terminal = crate::ghostty::Terminal::new(cols, rows, scrollback_limit_bytes) .map_err(|e| std::io::Error::other(e.to_string()))?; let pane_terminal = GhosttyPaneTerminal::new(terminal, input_tx.clone())?; pane_terminal.apply_host_terminal_theme(_host_terminal_theme); @@ -1807,6 +1813,12 @@ impl PaneRuntime { mod tests { use super::*; + fn write_numbered_lines(terminal: &mut crate::ghostty::Terminal, count: usize) { + for i in 0..count { + terminal.write(format!("{i:06}\r\n").as_bytes()); + } + } + #[test] fn ghostty_keyboard_protocol_tracks_live_terminal_flags() { let (tx, _rx) = mpsc::channel(4); @@ -1934,6 +1946,24 @@ mod tests { } #[test] + fn pane_scrollback_controls_reach_top_without_ui_interference() { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(80, 3, 100).unwrap(); + write_numbered_lines(&mut terminal, 1000); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + let before = pane.scroll_metrics().expect("scroll metrics before scroll"); + assert!(before.max_offset_from_bottom > 0); + assert_eq!(before.offset_from_bottom, 0); + + pane.set_scroll_offset_from_bottom(before.max_offset_from_bottom); + + let after = pane.scroll_metrics().expect("scroll metrics after scroll"); + assert_eq!(after.offset_from_bottom, after.max_offset_from_bottom); + assert!(pane.visible_text().contains("000000")); + } + + #[test] fn synchronized_output_suppresses_intermediate_render_requests_until_batch_ends() { let (tx, _rx) = mpsc::channel(4); let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); diff --git a/src/persist.rs b/src/persist.rs index 9c87cf4d..480c1248 100644 --- a/src/persist.rs +++ b/src/persist.rs @@ -273,6 +273,7 @@ pub fn restore( snapshot: &SessionSnapshot, rows: u16, cols: u16, + scrollback_limit_bytes: usize, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, @@ -285,6 +286,7 @@ pub fn restore( ws_snap, rows, cols, + scrollback_limit_bytes, events.clone(), render_notify.clone(), render_dirty.clone(), @@ -297,6 +299,7 @@ fn restore_workspace( snap: &WorkspaceSnapshot, rows: u16, cols: u16, + scrollback_limit_bytes: usize, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, @@ -311,6 +314,7 @@ fn restore_workspace( idx + 1, rows, cols, + scrollback_limit_bytes, events.clone(), render_notify.clone(), render_dirty.clone(), @@ -346,6 +350,7 @@ fn restore_tab( number: usize, rows: u16, cols: u16, + scrollback_limit_bytes: usize, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, @@ -377,6 +382,7 @@ fn restore_tab( rows, cols, cwd.clone(), + scrollback_limit_bytes, crate::terminal_theme::TerminalTheme::default(), events.clone(), render_notify.clone(), diff --git a/src/workspace.rs b/src/workspace.rs index 01a96b3f..9a679040 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -58,6 +58,7 @@ impl Tab { initial_cwd: PathBuf, rows: u16, cols: u16, + scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, @@ -69,6 +70,7 @@ impl Tab { rows, cols, initial_cwd.clone(), + scrollback_limit_bytes, host_terminal_theme, events.clone(), render_notify.clone(), @@ -113,6 +115,7 @@ impl Tab { rows: u16, cols: u16, cwd: Option<PathBuf>, + scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, ) -> std::io::Result<PaneId> { let new_id = self.layout.split_focused(direction); @@ -123,6 +126,7 @@ impl Tab { rows, cols, actual_cwd.clone(), + scrollback_limit_bytes, host_terminal_theme, self.events.clone(), self.render_notify.clone(), @@ -281,6 +285,7 @@ impl Workspace { initial_cwd: PathBuf, rows: u16, cols: u16, + scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, @@ -291,6 +296,7 @@ impl Workspace { initial_cwd.clone(), rows, cols, + scrollback_limit_bytes, host_terminal_theme, events, render_notify, @@ -339,6 +345,7 @@ impl Workspace { rows: u16, cols: u16, cwd: PathBuf, + scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, ) -> std::io::Result<usize> { let number = self.tabs.len() + 1; @@ -360,6 +367,7 @@ impl Workspace { cwd, rows, cols, + scrollback_limit_bytes, host_terminal_theme, events, render_notify, @@ -398,12 +406,20 @@ impl Workspace { rows: u16, cols: u16, cwd: Option<PathBuf>, + scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, ) -> std::io::Result<PaneId> { let new_id = self .active_tab_mut() .expect("workspace must always have at least one tab") - .split_focused(direction, rows, cols, cwd, host_terminal_theme)?; + .split_focused( + direction, + rows, + cols, + cwd, + scrollback_limit_bytes, + host_terminal_theme, + )?; self.register_new_pane(new_id); Ok(new_id) } |
