aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-07 00:04:32 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-07 00:04:32 +0300
commita51902ede97afd84b469f838f25ab79287bd375d (patch)
tree986cf68f33370450eae9152ac3c2015aeebe5882 /src
parent6ccf156a3dd23f419c03892ddc1136e72957f66a (diff)
fix: use byte-based pane scrollback limit
Diffstat (limited to 'src')
-rw-r--r--src/app/input.rs11
-rw-r--r--src/app/mod.rs27
-rw-r--r--src/app/state.rs2
-rw-r--r--src/config.rs38
-rw-r--r--src/ghostty/mod.rs53
-rw-r--r--src/main.rs3
-rw-r--r--src/pane.rs32
-rw-r--r--src/persist.rs6
-rw-r--r--src/workspace.rs18
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)
}