aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/mod.rs12
-rw-r--r--src/app/state.rs16
-rw-r--r--src/config.rs20
-rw-r--r--src/config/io.rs9
-rw-r--r--src/config/model.rs58
-rw-r--r--src/main.rs6
-rw-r--r--src/server/headless.rs47
7 files changed, 156 insertions, 12 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 49a905ae..35244115 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -619,6 +619,7 @@ impl App {
outer_terminal_focus: None,
prefix_code,
prefix_mods,
+ headless_size: config.headless_size(),
default_sidebar_width: config.ui.sidebar_width,
sidebar_width,
sidebar_min_width,
@@ -1539,6 +1540,14 @@ impl App {
}
}
+ if !invalid_section("server") {
+ if let Some(diagnostic) = config.invalid_headless_size_diagnostic() {
+ diagnostics.push(format!("{diagnostic}; keeping current [server] settings"));
+ } else {
+ self.state.headless_size = config.headless_size();
+ }
+ }
+
if !invalid_section("advanced") {
self.state.pane_scrollback_limit_bytes = config.advanced.scrollback_limit_bytes;
}
@@ -2974,7 +2983,7 @@ mod tests {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
- "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[update]\nversion_check = false\nmanifest_check = false\n[ui]\nagent_panel_sort = \"priority\"\nredraw_on_focus_gained = false\ncopy_on_select = false\nright_click_passthrough_modifier = \"ctrl\"\nprompt_new_workspace_name = true\n[ui.toast]\ndelivery = \"herdr\"\n[experimental]\nswitch_ascii_input_source_in_prefix = true\n",
+ "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[update]\nversion_check = false\nmanifest_check = false\n[server]\nheadless_cols = 160\nheadless_rows = 50\n[ui]\nagent_panel_sort = \"priority\"\nredraw_on_focus_gained = false\ncopy_on_select = false\nright_click_passthrough_modifier = \"ctrl\"\nprompt_new_workspace_name = true\n[ui.toast]\ndelivery = \"herdr\"\n[experimental]\nswitch_ascii_input_source_in_prefix = true\n",
)
.unwrap();
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
@@ -3008,6 +3017,7 @@ mod tests {
let report = app.reload_config();
assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied);
+ assert_eq!(app.state.headless_size, (160, 50));
assert_eq!(app.state.prefix_code, KeyCode::Char('a'));
assert_eq!(app.state.prefix_mods, KeyModifiers::CONTROL);
assert!(app
diff --git a/src/app/state.rs b/src/app/state.rs
index ea550472..e03d90a9 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -1428,6 +1428,8 @@ pub struct AppState {
// Config
pub prefix_code: KeyCode,
pub prefix_mods: KeyModifiers,
+ /// Virtual terminal size (columns, rows) used when no client is attached.
+ pub(crate) headless_size: (u16, u16),
pub default_sidebar_width: u16,
pub sidebar_width: u16,
pub sidebar_min_width: u16,
@@ -1618,7 +1620,7 @@ impl AppState {
if let Some(info) = self.view.pane_infos.first() {
(info.rect.height, info.rect.width)
} else {
- (24, 80)
+ (self.headless_size.1, self.headless_size.0)
}
}
@@ -1800,6 +1802,10 @@ impl AppState {
outer_terminal_focus: None,
prefix_code: KeyCode::Char('b'),
prefix_mods: KeyModifiers::CONTROL,
+ headless_size: (
+ crate::config::DEFAULT_HEADLESS_COLS,
+ crate::config::DEFAULT_HEADLESS_ROWS,
+ ),
default_sidebar_width: 26,
sidebar_width: 26,
sidebar_min_width: 18,
@@ -2243,6 +2249,14 @@ mod tests {
use crossterm::event::KeyEvent;
#[test]
+ fn pane_size_estimate_uses_headless_size_before_first_view() {
+ let mut state = AppState::test_new();
+ state.headless_size = (132, 41);
+
+ assert_eq!(state.estimate_pane_size(), (41, 132));
+ }
+
+ #[test]
fn agent_terminal_keeps_final_child_cursor_exposed() {
let mut state = AppState::test_new();
let ws = crate::workspace::Workspace::test_new("test");
diff --git a/src/config.rs b/src/config.rs
index 7e7aecd2..0595ae2f 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -53,6 +53,8 @@ pub const CONFIG_PATH_ENV_VAR: &str = "HERDR_CONFIG_PATH";
pub const DEFAULT_SCROLLBACK_LIMIT_BYTES: usize = 10_000_000;
pub const DEFAULT_MOUSE_SCROLL_LINES: usize = 3;
pub const DEFAULT_MOBILE_WIDTH_THRESHOLD: u16 = 64;
+pub const DEFAULT_HEADLESS_COLS: u16 = 120;
+pub const DEFAULT_HEADLESS_ROWS: u16 = 40;
#[cfg(test)]
pub(crate) fn app_dir_name() -> &'static str {
@@ -90,9 +92,27 @@ impl Config {
.chain(tab_bar_right_diagnostics(&self.ui.tab_bar_right))
.chain(window_title_diagnostics(&self.ui.window_title))
.chain(self.invalid_sidebar_bounds_diagnostic())
+ .chain(self.invalid_headless_size_diagnostic())
.collect()
}
+ pub(crate) fn headless_size(&self) -> (u16, u16) {
+ if self.invalid_headless_size_diagnostic().is_some() {
+ (DEFAULT_HEADLESS_COLS, DEFAULT_HEADLESS_ROWS)
+ } else {
+ (self.server.headless_cols, self.server.headless_rows)
+ }
+ }
+
+ pub(crate) fn invalid_headless_size_diagnostic(&self) -> Option<String> {
+ (self.server.headless_cols == 0 || self.server.headless_rows == 0).then(|| {
+ format!(
+ "server.headless_cols and server.headless_rows must be greater than zero (got {}x{})",
+ self.server.headless_cols, self.server.headless_rows
+ )
+ })
+ }
+
pub(crate) fn invalid_sidebar_bounds_diagnostic(&self) -> Option<String> {
validated_sidebar_bounds(self.ui.sidebar_min_width, self.ui.sidebar_max_width)
.is_none()
diff --git a/src/config/io.rs b/src/config/io.rs
index 7ed82395..898cf077 100644
--- a/src/config/io.rs
+++ b/src/config/io.rs
@@ -10,6 +10,7 @@ const KNOWN_TOP_LEVEL_CONFIG_KEYS: &[&str] = &[
"keys",
"onboarding",
"remote",
+ "server",
"session",
"terminal",
"theme",
@@ -294,6 +295,14 @@ fn load_live_config_from_str(content: &str) -> Result<LoadedConfig, Vec<String>>
);
load_live_section(
table,
+ "server",
+ "server config",
+ &mut diagnostics,
+ &mut invalid_sections,
+ |section| config.server = section,
+ );
+ load_live_section(
+ table,
"update",
"update config",
&mut diagnostics,
diff --git a/src/config/model.rs b/src/config/model.rs
index 6d6083ad..ad779d26 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -315,6 +315,7 @@ pub struct Config {
pub theme: ThemeConfig,
pub terminal: TerminalConfig,
pub session: SessionConfig,
+ pub server: ServerConfig,
pub update: UpdateConfig,
pub keys: KeysConfig,
pub ui: UiConfig,
@@ -940,6 +941,15 @@ impl ImeCursorShape {
#[derive(Debug, Deserialize)]
#[serde(default)]
+pub struct ServerConfig {
+ /// Virtual terminal width used when no client is attached. Default: 120.
+ pub headless_cols: u16,
+ /// Virtual terminal height used when no client is attached. Default: 40.
+ pub headless_rows: u16,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(default)]
pub struct AdvancedConfig {
/// Maximum scrollback buffer size in bytes retained per pane terminal. Default: 10000000.
#[serde(alias = "scrollback_lines")]
@@ -1200,6 +1210,15 @@ impl<'de> Deserialize<'de> for ToastConfig {
}
}
+impl Default for ServerConfig {
+ fn default() -> Self {
+ Self {
+ headless_cols: crate::config::DEFAULT_HEADLESS_COLS,
+ headless_rows: crate::config::DEFAULT_HEADLESS_ROWS,
+ }
+ }
+}
+
impl Default for AdvancedConfig {
fn default() -> Self {
Self {
@@ -1818,6 +1837,45 @@ delay_seconds = {}
}
#[test]
+ fn server_headless_size_defaults_and_parses() {
+ let default_config = Config::default();
+ assert_eq!(
+ default_config.server.headless_cols,
+ crate::config::DEFAULT_HEADLESS_COLS
+ );
+ assert_eq!(
+ default_config.server.headless_rows,
+ crate::config::DEFAULT_HEADLESS_ROWS
+ );
+
+ let config: Config = toml::from_str(
+ r#"[server]
+headless_cols = 160
+headless_rows = 50
+"#,
+ )
+ .unwrap();
+ assert_eq!(config.server.headless_cols, 160);
+ assert_eq!(config.server.headless_rows, 50);
+
+ let invalid: Config = toml::from_str(
+ r#"[server]
+headless_cols = 0
+headless_rows = 50
+"#,
+ )
+ .unwrap();
+ assert!(invalid.invalid_headless_size_diagnostic().is_some());
+ assert_eq!(
+ invalid.headless_size(),
+ (
+ crate::config::DEFAULT_HEADLESS_COLS,
+ crate::config::DEFAULT_HEADLESS_ROWS
+ )
+ );
+ }
+
+ #[test]
fn advanced_defaults_include_scrollback_limit_bytes() {
let config = Config::default();
assert_eq!(
diff --git a/src/main.rs b/src/main.rs
index 8505e5cc..c159db3f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -251,6 +251,12 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# workspaces = "" # e.g. "ctrl+shift" makes ctrl+shift+1..9 switch workspaces directly
# agents = "" # e.g. "alt" makes alt+1..9 focus agent rows directly
+# Size of the virtual terminal used when no client is attached.
+# Attached clients always use their own terminal size.
+[server]
+# headless_cols = 120
+# headless_rows = 40
+
# [worktrees]
# directory = "~/.herdr/worktrees"
diff --git a/src/server/headless.rs b/src/server/headless.rs
index ac1e7db2..354b0008 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -252,10 +252,6 @@ fn dirty_patch_intersects_hyperlinks(
// Constants
// ---------------------------------------------------------------------------
-/// Default shared runtime size (columns, rows) when no clients are attached.
-const MIN_COLS: u16 = 80;
-const MIN_ROWS: u16 = 24;
-
/// Timeout for in-flight API requests during shutdown.
#[allow(dead_code)]
const SHUTDOWN_API_TIMEOUT: Duration = Duration::from_secs(5);
@@ -326,8 +322,10 @@ pub struct HeadlessServer {
deferred_alt_screen_reads: Vec<api::ApiRequestMessage>,
/// Monotonic activity counter used to pick the most recently active client.
next_activity_stamp: u64,
- /// Shared pane runtime size derived from the foreground client,
- /// or MIN_COLS × MIN_ROWS when no clients are connected.
+ /// Configured virtual terminal size used when no clients are connected.
+ headless_size: (u16, u16),
+ /// Shared pane runtime size derived from the foreground client, or the
+ /// configured headless size when no clients are connected.
effective_size: (u16, u16),
/// Flag set when shutdown is initiated.
shutting_down: bool,
@@ -495,6 +493,7 @@ impl HeadlessServer {
spawn_windows_client_accept_thread(listener, should_quit.clone(), server_event_tx.clone());
let server_keybindings = app_keybindings(&app);
+ let headless_size = app.state.headless_size;
let (server_config_diagnostic, server_config_diagnostic_without_keybindings) =
server_config_diagnostic_summaries(config_diagnostics);
#[cfg(not(unix))]
@@ -521,7 +520,8 @@ impl HeadlessServer {
pending_alt_screen_reads: Vec::new(),
deferred_alt_screen_reads: Vec::new(),
next_activity_stamp: 1,
- effective_size: (MIN_COLS, MIN_ROWS),
+ headless_size,
+ effective_size: headless_size,
shutting_down: false,
handoff_in_progress: false,
#[cfg(unix)]
@@ -1139,6 +1139,14 @@ impl HeadlessServer {
}
}
+ fn sync_headless_view_geometry(&mut self) {
+ crate::ui::compute_view_without_resizing_panes(
+ &mut self.app.state,
+ &self.app.terminal_runtimes,
+ Rect::new(0, 0, self.headless_size.0, self.headless_size.1),
+ );
+ }
+
fn sync_foreground_client_state(&mut self) {
self.app.direct_graphics_available = self.direct_graphics_available();
self.app.pixel_mouse_available = self.foreground_client_id.is_some_and(|id| {
@@ -1150,9 +1158,10 @@ impl HeadlessServer {
self.retire_all_direct_graphics();
}
let Some(client_id) = self.foreground_client_id else {
- self.effective_size = (MIN_COLS, MIN_ROWS);
+ self.effective_size = self.headless_size;
self.app.state.outer_terminal_focus = None;
self.app.state.host_cell_size = crate::kitty_graphics::HostCellSize::default();
+ self.sync_headless_view_geometry();
let server_keybindings = self.server_keybindings.clone();
apply_keybindings(&mut self.app, &server_keybindings);
self.sync_visible_server_config_diagnostic(false);
@@ -1160,9 +1169,10 @@ impl HeadlessServer {
};
let Some(client) = self.clients.get(&client_id) else {
self.foreground_client_id = None;
- self.effective_size = (MIN_COLS, MIN_ROWS);
+ self.effective_size = self.headless_size;
self.app.state.outer_terminal_focus = None;
self.app.state.host_cell_size = crate::kitty_graphics::HostCellSize::default();
+ self.sync_headless_view_geometry();
let server_keybindings = self.server_keybindings.clone();
apply_keybindings(&mut self.app, &server_keybindings);
self.sync_visible_server_config_diagnostic(false);
@@ -1507,6 +1517,7 @@ impl HeadlessServer {
let report = self.app.apply_config_from_disk(notify_success);
self.app.take_config_reloaded_from_disk();
self.server_keybindings = app_keybindings(&self.app);
+ self.headless_size = self.app.state.headless_size;
let (server_config_diagnostic, server_config_diagnostic_without_keybindings) =
server_config_diagnostic_summaries(&report.diagnostics);
self.server_config_diagnostic = server_config_diagnostic;
@@ -5306,6 +5317,7 @@ mod tests {
#[cfg(windows)]
spawn_windows_client_accept_thread(listener, should_quit.clone(), server_event_tx.clone());
let server_keybindings = app_keybindings(&app);
+ let headless_size = app.state.headless_size;
HeadlessServer {
app,
@@ -5329,7 +5341,8 @@ mod tests {
pending_alt_screen_reads: Vec::new(),
deferred_alt_screen_reads: Vec::new(),
next_activity_stamp: 1,
- effective_size: (MIN_COLS, MIN_ROWS),
+ headless_size,
+ effective_size: headless_size,
shutting_down: false,
handoff_in_progress: false,
#[cfg(unix)]
@@ -5380,6 +5393,20 @@ mod tests {
}
}
+ #[test]
+ fn default_headless_size_is_effective_without_clients() {
+ let server = test_headless_server();
+
+ assert_eq!(
+ server.headless_size,
+ (
+ crate::config::DEFAULT_HEADLESS_COLS,
+ crate::config::DEFAULT_HEADLESS_ROWS
+ )
+ );
+ assert_eq!(server.effective_size, server.headless_size);
+ }
+
#[tokio::test]
async fn headless_api_reads_latest_title_without_spinner_event_flooding() {
let event_hub = api::EventHub::default();