mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-21 16:01:04 +00:00
@@ -16,6 +16,7 @@
|
||||
- The plugin marketplace now discovers valid manifests at repository roots and subdirectories, groups multiple plugins under each repository, and publishes their versions and exact default-branch commits.
|
||||
|
||||
### Changed
|
||||
- Headless servers now use a configurable 120×40 virtual terminal instead of 80×24 when no client is attached, giving newly created panes a practical default size. (#2828)
|
||||
- Desktop tab labels are now centered in their tabs, so the active-tab highlight has symmetric padding.
|
||||
- Bumped the client/server protocol version to 20 for pane terminal bell forwarding.
|
||||
- Experimental pane graphics now support bounded named layers, acknowledged full-RGBA primary-layer direct file frames on audited local terminals, owned BGRA fallback, exact pixel mouse input, and placement-only resize replay.
|
||||
|
||||
@@ -50,6 +50,18 @@ You can also open the global menu in Herdr and choose `reload config`.
|
||||
|
||||
Reload applies most UI settings without restarting panes. Startup-only settings still need a restart.
|
||||
|
||||
## Headless terminal size
|
||||
|
||||
When no client is attached, the server uses a 120×40 virtual terminal for layout and newly created panes. Change that fallback for headless orchestration with:
|
||||
|
||||
```toml
|
||||
[server]
|
||||
headless_cols = 160
|
||||
headless_rows = 50
|
||||
```
|
||||
|
||||
An attached client remains authoritative for the shared runtime size. After it detaches, existing pane PTYs retain their last attached size while new headless layout uses the configured fallback.
|
||||
|
||||
## Terminal defaults
|
||||
|
||||
Set the executable Herdr uses for newly created interactive panes:
|
||||
|
||||
@@ -12,6 +12,24 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
"title": "Server",
|
||||
"keys": [
|
||||
{
|
||||
"key": "server.headless_cols",
|
||||
"type": "integer",
|
||||
"default": "120",
|
||||
"description": "Virtual terminal width used for layout and newly created panes when no client is attached. Must be greater than zero."
|
||||
},
|
||||
{
|
||||
"key": "server.headless_rows",
|
||||
"type": "integer",
|
||||
"default": "40",
|
||||
"description": "Virtual terminal height used for layout and newly created panes when no client is attached. Must be greater than zero."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "theme",
|
||||
"title": "Theme",
|
||||
|
||||
+11
-1
@@ -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
|
||||
|
||||
+15
-1
@@ -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,
|
||||
@@ -2242,6 +2248,14 @@ mod tests {
|
||||
use super::*;
|
||||
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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -10,6 +10,7 @@ const KNOWN_TOP_LEVEL_CONFIG_KEYS: &[&str] = &[
|
||||
"keys",
|
||||
"onboarding",
|
||||
"remote",
|
||||
"server",
|
||||
"session",
|
||||
"terminal",
|
||||
"theme",
|
||||
@@ -292,6 +293,14 @@ fn load_live_config_from_str(content: &str) -> Result<LoadedConfig, Vec<String>>
|
||||
&mut invalid_sections,
|
||||
|section| config.session = section,
|
||||
);
|
||||
load_live_section(
|
||||
table,
|
||||
"server",
|
||||
"server config",
|
||||
&mut diagnostics,
|
||||
&mut invalid_sections,
|
||||
|section| config.server = section,
|
||||
);
|
||||
load_live_section(
|
||||
table,
|
||||
"update",
|
||||
|
||||
@@ -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,
|
||||
@@ -938,6 +939,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 {
|
||||
@@ -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 {
|
||||
@@ -1817,6 +1836,45 @@ delay_seconds = {}
|
||||
assert!(!config.should_show_onboarding());
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
+37
-10
@@ -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();
|
||||
|
||||
+1
-1
@@ -502,7 +502,7 @@ fn workspace_list_and_create_round_trip() {
|
||||
let recent = send_request(
|
||||
&socket_path,
|
||||
&format!(
|
||||
r#"{{"id":"req_11","method":"pane.read","params":{{"pane_id":"{}","source":"recent","lines":20}}}}"#,
|
||||
r#"{{"id":"req_11","method":"pane.read","params":{{"pane_id":"{}","source":"recent","lines":50}}}}"#,
|
||||
pane_id
|
||||
),
|
||||
);
|
||||
|
||||
+140
-5
@@ -19,6 +19,19 @@ use support::{
|
||||
wait_for_disconnect, wait_for_message_variant, wait_for_socket, wait_until, CURRENT_PROTOCOL,
|
||||
};
|
||||
|
||||
const CUSTOM_HEADLESS_SIZE_CONFIG: &str = r#"onboarding = false
|
||||
|
||||
[server]
|
||||
headless_cols = 132
|
||||
headless_rows = 41
|
||||
|
||||
[ui]
|
||||
sidebar_start_collapsed = true
|
||||
sidebar_collapsed_mode = "hidden"
|
||||
hide_tab_bar_when_single_tab = true
|
||||
pane_scrollbars = false
|
||||
"#;
|
||||
|
||||
fn unique_test_dir() -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -70,19 +83,31 @@ fn test_lock() -> MutexGuard<'static, ()> {
|
||||
}
|
||||
|
||||
fn spawn_server(
|
||||
config_home: &PathBuf,
|
||||
runtime_dir: &PathBuf,
|
||||
api_socket_path: &PathBuf,
|
||||
client_socket_path: &PathBuf,
|
||||
) -> SpawnedHerdr {
|
||||
spawn_server_with_config(
|
||||
config_home,
|
||||
runtime_dir,
|
||||
api_socket_path,
|
||||
client_socket_path,
|
||||
"onboarding = false\n",
|
||||
)
|
||||
}
|
||||
|
||||
fn spawn_server_with_config(
|
||||
config_home: &PathBuf,
|
||||
runtime_dir: &PathBuf,
|
||||
api_socket_path: &PathBuf,
|
||||
_client_socket_path: &PathBuf,
|
||||
config: &str,
|
||||
) -> SpawnedHerdr {
|
||||
fs::create_dir_all(config_home.join("herdr")).unwrap();
|
||||
fs::create_dir_all(runtime_dir).unwrap();
|
||||
register_runtime_dir(runtime_dir);
|
||||
fs::write(
|
||||
config_home.join("herdr/config.toml"),
|
||||
"onboarding = false\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(config_home.join("herdr/config.toml"), config).unwrap();
|
||||
|
||||
let pair = native_pty_system()
|
||||
.openpty(PtySize {
|
||||
@@ -99,6 +124,7 @@ fn spawn_server(
|
||||
cmd.env("XDG_RUNTIME_DIR", runtime_dir);
|
||||
cmd.env("HERDR_SOCKET_PATH", api_socket_path);
|
||||
cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
|
||||
cmd.env("HERDR_CONFIG_PATH", config_home.join("herdr/config.toml"));
|
||||
cmd.env("SHELL", "/bin/sh");
|
||||
cmd.env_remove("HERDR_ENV");
|
||||
|
||||
@@ -638,6 +664,115 @@ fn server_persists_after_client_connection_drop() {
|
||||
cleanup_spawned_herdr(spawned, base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_created_without_client_uses_configured_headless_size() {
|
||||
let _lock = test_lock();
|
||||
let base = unique_test_dir();
|
||||
let config_home = base.join("config");
|
||||
let runtime_dir = base.join("runtime");
|
||||
let api_socket = runtime_dir.join("herdr.sock");
|
||||
let client_socket = runtime_dir.join("herdr-client.sock");
|
||||
|
||||
let spawned = spawn_server_with_config(
|
||||
&config_home,
|
||||
&runtime_dir,
|
||||
&api_socket,
|
||||
&client_socket,
|
||||
CUSTOM_HEADLESS_SIZE_CONFIG,
|
||||
);
|
||||
wait_for_socket(&api_socket, Duration::from_secs(10));
|
||||
|
||||
let create = workspace_create(&api_socket, "headless-size");
|
||||
let pane_id = create["result"]["root_pane"]["pane_id"]
|
||||
.as_str()
|
||||
.expect("root pane id")
|
||||
.to_string();
|
||||
|
||||
// A second request cannot run until the full render triggered by workspace
|
||||
// creation has applied the virtual headless geometry.
|
||||
assert!(ping_socket(&api_socket).contains("pong"));
|
||||
let size = read_pane_tty_size_after_marker(
|
||||
&api_socket,
|
||||
&pane_id,
|
||||
"HEADLESS_SIZE",
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
|
||||
assert_eq!(size, (41, 132));
|
||||
|
||||
cleanup_spawned_herdr(spawned, base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_created_after_detach_uses_configured_headless_size() {
|
||||
let _lock = test_lock();
|
||||
let base = unique_test_dir();
|
||||
let config_home = base.join("config");
|
||||
let runtime_dir = base.join("runtime");
|
||||
let api_socket = runtime_dir.join("herdr.sock");
|
||||
let client_socket = runtime_dir.join("herdr-client.sock");
|
||||
|
||||
let spawned = spawn_server_with_config(
|
||||
&config_home,
|
||||
&runtime_dir,
|
||||
&api_socket,
|
||||
&client_socket,
|
||||
CUSTOM_HEADLESS_SIZE_CONFIG,
|
||||
);
|
||||
wait_for_socket(&api_socket, Duration::from_secs(10));
|
||||
wait_for_socket(&client_socket, Duration::from_secs(10));
|
||||
|
||||
let mut stream = UnixStream::connect(&client_socket).expect("client should connect");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream, CURRENT_PROTOCOL, 160, 50).expect("handshake should succeed");
|
||||
assert_eq!(version, CURRENT_PROTOCOL);
|
||||
assert!(error.is_none(), "{error:?}");
|
||||
drain_messages(&mut stream);
|
||||
|
||||
let first = workspace_create(&api_socket, "attached-size");
|
||||
let first_pane_id = first["result"]["root_pane"]["pane_id"]
|
||||
.as_str()
|
||||
.expect("first root pane id")
|
||||
.to_string();
|
||||
let attached_size = read_pane_tty_size_after_marker(
|
||||
&api_socket,
|
||||
&first_pane_id,
|
||||
"ATTACHED_SIZE",
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
assert_eq!(attached_size, (50, 160));
|
||||
|
||||
send_detach(&mut stream).expect("send detach");
|
||||
assert!(
|
||||
wait_for_disconnect(&mut stream, Duration::from_secs(2)).expect("wait for detach"),
|
||||
"detached client connection should close"
|
||||
);
|
||||
drop(stream);
|
||||
|
||||
let second = workspace_create(&api_socket, "headless-size");
|
||||
let second_pane_id = second["result"]["root_pane"]["pane_id"]
|
||||
.as_str()
|
||||
.expect("second root pane id")
|
||||
.to_string();
|
||||
let headless_size = read_pane_tty_size_after_marker(
|
||||
&api_socket,
|
||||
&second_pane_id,
|
||||
"HEADLESS_SIZE_AFTER_DETACH",
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
let preserved_size = read_pane_tty_size_after_marker(
|
||||
&api_socket,
|
||||
&first_pane_id,
|
||||
"PRESERVED_SIZE_AFTER_DETACH",
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
|
||||
assert_eq!(headless_size, (41, 132));
|
||||
assert_eq!(preserved_size, attached_size);
|
||||
|
||||
cleanup_spawned_herdr(spawned, base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detached_output_preserves_last_attached_pty_size() {
|
||||
let _lock = test_lock();
|
||||
|
||||
Reference in New Issue
Block a user