diff --git a/README.md b/README.md index b19f615..82e0fbf 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,23 @@ herdr-spreader apply [--file ] | `-f, --file ` | Path to a layout YAML file. If omitted, searched in `$HERDR_PLUGIN_CONFIG_DIR/` (set automatically when run as a herdr plugin), then `$XDG_CONFIG_HOME/herdr-spreader/`, then `$HOME/.config/herdr-spreader/`. Each directory is checked for `config.yaml` then `config.yml`. Run `herdr plugin config-dir herdr-spreader` to see or create the plugin config directory. | | `--dry-run` | Print the plan of operations that would be performed (one `BackendOp` per line) without spawning `herdr` or modifying any workspace. Path resolution still runs, so the printed paths reflect your real `root`/`cwd`/`~` expansion — only execution is skipped. | +### Shell readiness + +A pane that was just created has a shell which has not started its line editor +yet. A command sent before that point is echoed to the PTY and then discarded +during shell startup, so it silently never runs, or arrives truncated. +`herdr-spreader` therefore waits for each new pane to settle before sending its +`command`. The defaults suit a heavyweight interactive shell — Oh My Zsh, +powerlevel10k, a `fastfetch` banner — and rarely need changing. + +| Environment variable | Default | Description | +|---|---|---| +| `HERDR_SPREADER_READY_FLOOR_MS` | `1500` | Minimum wait before a command is sent to a new pane. A floor is needed on top of the settle check because prompts such as powerlevel10k's *instant prompt* paint very early, which makes a pane look ready while its real line editor still does not exist. | +| `HERDR_SPREADER_READY_TIMEOUT_MS` | `10000` | Upper bound on waiting for a pane to settle, after which the command is sent anyway. Set to `0` to disable waiting entirely. | + +Raise the floor if commands are still swallowed on a slow machine; lower it, or +set the timeout to `0`, for a minimal shell that starts instantly. + ## Configuration reference A layout file has four levels: the **file** (top level), **workspaces**, **tabs**, and **panes** (splits within a tab) — mirroring herdr's own workspace → tab → pane model, with the file itself holding a list of workspaces so one YAML file can describe more than one. diff --git a/src/backend/cli.rs b/src/backend/cli.rs index c63fa0a..445dc2e 100644 --- a/src/backend/cli.rs +++ b/src/backend/cli.rs @@ -1,6 +1,7 @@ use std::collections::BTreeMap; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; use serde::Deserialize; @@ -9,6 +10,28 @@ use super::{ }; use crate::config::{SplitDirection, WaitFor}; +/// Environment variable overriding the minimum wait before a command is sent. +const READY_FLOOR_ENV: &str = "HERDR_SPREADER_READY_FLOOR_MS"; +/// Environment variable overriding how long to wait for a pane to settle. +const READY_TIMEOUT_ENV: &str = "HERDR_SPREADER_READY_TIMEOUT_MS"; +/// Minimum time to wait before sending a command to a freshly created pane. +const DEFAULT_READY_FLOOR_MS: u64 = 1_500; +/// Upper bound on waiting for a pane to settle before sending anyway. +const DEFAULT_READY_TIMEOUT_MS: u64 = 10_000; +/// How often the pane is polled while waiting for it to settle. +const READY_POLL_INTERVAL: Duration = Duration::from_millis(300); +/// Consecutive identical reads required before a pane counts as settled. +const READY_STABLE_POLLS: u32 = 3; + +/// Parse a millisecond duration, falling back when unset or unparseable. +fn parse_ms(value: Option<&str>, fallback: u64) -> u64 { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .and_then(|value| value.parse().ok()) + .unwrap_or(fallback) +} + pub(crate) fn workspace_create_args(opts: &WorkspaceOpts) -> Vec { let mut args = vec!["workspace".to_string(), "create".to_string()]; push_cwd(&mut args, opts.cwd.as_ref()); @@ -253,12 +276,37 @@ pub(crate) fn choose_focus_strategy(socket_path: Option<&str>) -> FocusStrategy pub struct CliBackend { bin: PathBuf, socket_path: Option, + ready_floor: Duration, + ready_timeout: Duration, } impl CliBackend { #[must_use] pub fn new(bin: PathBuf, socket_path: Option) -> Self { - Self { bin, socket_path } + Self { + bin, + socket_path, + ready_floor: Duration::from_millis(parse_ms( + std::env::var(READY_FLOOR_ENV).ok().as_deref(), + DEFAULT_READY_FLOOR_MS, + )), + ready_timeout: Duration::from_millis(parse_ms( + std::env::var(READY_TIMEOUT_ENV).ok().as_deref(), + DEFAULT_READY_TIMEOUT_MS, + )), + } + } + + /// Override how long `run` waits for a pane's shell to become ready. + /// + /// A zero timeout disables the wait entirely, which is useful in tests + /// that drive the backend against a scripted fake `herdr` binary and + /// assert on the exact argv sequence. + #[must_use] + pub fn with_ready_settings(mut self, floor: Duration, timeout: Duration) -> Self { + self.ready_floor = floor; + self.ready_timeout = timeout; + self } pub fn resolve_bin(env: &BTreeMap) -> PathBuf { @@ -266,6 +314,43 @@ impl CliBackend { .map_or_else(|| PathBuf::from(DEFAULT_HERDR_BIN), PathBuf::from) } + /// Block until the pane's shell is ready to accept a typed command. + /// + /// A pane that was just created has a shell which has not started its line + /// editor yet. Text sent before that point is echoed to the PTY and then + /// discarded during shell startup, so the command silently never runs, or + /// arrives truncated. Poll the pane and wait for its output to stop + /// changing, which indicates the prompt has finished painting. + /// + /// Output stability alone is not sufficient: prompts such as + /// powerlevel10k's "instant prompt" paint very early, so the pane looks + /// settled while the real line editor still does not exist. A minimum + /// floor is therefore enforced on top of the stability check. + fn wait_shell_ready(&self, pane_id: &str) { + let (floor, timeout) = (self.ready_floor, self.ready_timeout); + let read_args = vec!["pane".to_string(), "read".to_string(), pane_id.to_string()]; + let started = Instant::now(); + let mut previous: Option = None; + let mut stable_polls = 0_u32; + + while started.elapsed() < timeout { + std::thread::sleep(READY_POLL_INTERVAL); + let current = self.exec(&read_args).unwrap_or_default(); + + if previous.as_deref() == Some(current.as_str()) { + stable_polls += 1; + } else { + stable_polls = 0; + } + previous = Some(current.clone()); + + let settled = !current.trim().is_empty() && stable_polls >= READY_STABLE_POLLS; + if settled && started.elapsed() >= floor { + return; + } + } + } + fn exec(&self, args: &[String]) -> Result { let output = std::process::Command::new(&self.bin) .args(args) @@ -401,6 +486,7 @@ impl HerdrBackend for CliBackend { } fn run(&mut self, pane_id: &str, command: &str) -> Result<(), BackendError> { + self.wait_shell_ready(pane_id); self.exec(&pane_run_args(pane_id, command))?; Ok(()) } @@ -431,6 +517,22 @@ mod tests { use crate::backend::{SplitOpts, TabOpts, WorkspaceOpts}; use crate::config::{SplitDirection, WaitFor}; + #[test] + fn should_fall_back_to_default_ms_when_value_is_absent_or_invalid() { + assert_eq!(parse_ms(None, 1_500), 1_500); + assert_eq!(parse_ms(Some(""), 1_500), 1_500); + assert_eq!(parse_ms(Some(" "), 1_500), 1_500); + assert_eq!(parse_ms(Some("soon"), 1_500), 1_500); + assert_eq!(parse_ms(Some("-1"), 1_500), 1_500); + } + + #[test] + fn should_parse_ms_overrides_including_zero_and_surrounding_whitespace() { + assert_eq!(parse_ms(Some("0"), 1_500), 0); + assert_eq!(parse_ms(Some("250"), 1_500), 250); + assert_eq!(parse_ms(Some(" 2500 "), 1_500), 2_500); + } + #[test] fn should_build_workspace_create_argv_with_cwd_label_env_and_no_focus() { let mut env = BTreeMap::new(); diff --git a/tests/cli_backend_integration.rs b/tests/cli_backend_integration.rs index 95c2edd..6a19bf5 100644 --- a/tests/cli_backend_integration.rs +++ b/tests/cli_backend_integration.rs @@ -24,6 +24,7 @@ use std::collections::BTreeMap; use std::path::PathBuf; use std::sync::Mutex; +use std::time::Duration; use herdr_spreader::backend::cli::CliBackend; use herdr_spreader::backend::{SplitOpts, TabOpts, WorkspaceOpts}; @@ -111,7 +112,8 @@ fn should_thread_ids_and_apply_focus_via_create_flags_across_two_workspaces_agai } let file = build_spread_file(); - let mut backend = CliBackend::new(fake_herdr_path(), None); + let mut backend = CliBackend::new(fake_herdr_path(), None) + .with_ready_settings(Duration::ZERO, Duration::ZERO); engine::apply(&file, &mut backend).expect("apply against fake herdr should succeed"); @@ -176,7 +178,8 @@ fn should_focus_second_pane_when_focus_true_is_on_second_pane() { } let file = build_spread_file_with_focus_on_second_pane(); - let mut backend = CliBackend::new(fake_herdr_path(), None); + let mut backend = CliBackend::new(fake_herdr_path(), None) + .with_ready_settings(Duration::ZERO, Duration::ZERO); engine::apply(&file, &mut backend).expect("apply against fake herdr should succeed");