From 9203d880d3a8b4fdb33baac524d78999e807518a Mon Sep 17 00:00:00 2001 From: Lukasz Chojnowski Date: Sun, 2 Aug 2026 13:22:55 +0200 Subject: [PATCH] feat: add Herdr terminal jump support --- AGENTS.md | 19 +- README.md | 8 +- src/app.rs | 2 +- src/jump/herdr.rs | 616 ++++++++++++++++++++++++++++++++++++++++++++++ src/jump/mod.rs | 20 +- 5 files changed, 650 insertions(+), 15 deletions(-) create mode 100644 src/jump/herdr.rs diff --git a/AGENTS.md b/AGENTS.md index d1a254c..a0091fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -274,7 +274,7 @@ Tracks child processes that have open ports. When a parent session dies but the | Key | Action | |-----|--------| | `↑`/`↓` or `k`/`j` | Select session in list | -| `Enter` | Jump to session terminal (cmux / tmux / iTerm2) | +| `Enter` | Jump to session terminal (Herdr / cmux / tmux / iTerm2) | | `x` | Kill selected session (SIGKILL) | | `X` | Kill all orphan ports | | `q` | Quit | @@ -364,22 +364,27 @@ Each adapter returns a three-way `JumpAttempt`: - `Failed(msg)` — this backend owns the process but the focus command errored; stop and surface `": "` in the status line. -Order (most specific first), mutually exclusive by controlling tty: +Order (most specific first): -1. **cmux** (`jump/cmux.rs`) — reads `CMUX_WORKSPACE_ID` (a UUID cmux exports +1. **Herdr** (`jump/herdr.rs`) — when abtop runs inside Herdr, validates the + selected agent PID against Herdr's pane process inventory and calls + `herdr agent focus`. The inherited pane ID is the fast path; `agent list` + recovers the current ID after a cross-workspace move. Only the same Herdr + session is searched. +2. **cmux** (`jump/cmux.rs`) — reads `CMUX_WORKSPACE_ID` (a UUID cmux exports into every surface, inherited by the agent) from the process environment via `ps eww`, then `cmux select-workspace --workspace `. -2. **tmux** (`jump/tmux.rs`) — only when abtop itself runs inside tmux (`$TMUX`). +3. **tmux** (`jump/tmux.rs`) — only when abtop itself runs inside tmux (`$TMUX`). Maps PID → pane via `tmux list-panes -a -F '#{pane_pid} #{session_name}:#{window_index}.#{pane_index}'` + process-tree descent, then `switch-client` / `select-window` / `select-pane`. PID in no pane → `NotApplicable` (lets another backend try). -3. **iTerm2** (`jump/iterm2.rs`) — resolves the PID's controlling tty (`ps -o tty=`), +4. **iTerm2** (`jump/iterm2.rs`) — resolves the PID's controlling tty (`ps -o tty=`), then AppleScript selects the session whose `tty` matches and brings its window/app to the front. First call triggers a one-time macOS Automation permission prompt; until granted, `osascript` exits non-zero → `Failed`. -Parsing/registry logic is unit-tested in `jump/mod.rs`; the thin `ps`/`osascript`/ -`tmux` I/O wrappers are verified manually. +Parsing/registry logic is unit-tested in `jump/mod.rs` and each backend; the +thin CLI and platform I/O wrappers are also verified manually. ## Privacy diff --git a/README.md b/README.md index 68116b7..d52c7a0 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,10 @@ Mouse capture is off by default so terminal drag selection and copy keep working ### Terminal Jump -Press `Enter` to focus the terminal running the selected agent. abtop supports cmux, tmux, and iTerm2 on macOS. +Press `Enter` to focus the terminal running the selected agent. abtop supports +Herdr 0.7.0 or newer, cmux, tmux, and iTerm2 on macOS. When abtop runs inside +Herdr, it can jump to agents in any pane, tab, or workspace in the same Herdr +session; no additional setup is required. ```bash tmux new -s work @@ -73,6 +76,9 @@ tmux new -s work # → Enter on a session in abtop jumps to its pane ``` +The same flow works inside Herdr: run abtop in one pane, run agents elsewhere +in the same session, then press `Enter` on the selected abtop row. + ## Supported Agents | Feature | Claude Code | Codex CLI | OpenCode | diff --git a/src/app.rs b/src/app.rs index c9de3cc..842f9d1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -791,7 +791,7 @@ impl App { } /// Jump to the terminal running the selected session's agent process. - /// Delegates to the terminal-jumper registry (cmux / tmux / iTerm2); + /// Delegates to the terminal-jumper registry (Herdr / cmux / tmux / iTerm2); /// see [`crate::jump`]. No-op when nothing is selected or no backend /// recognizes the process. pub fn jump_to_session(&mut self) -> JumpOutcome { diff --git a/src/jump/herdr.rs b/src/jump/herdr.rs new file mode 100644 index 0000000..e729c66 --- /dev/null +++ b/src/jump/herdr.rs @@ -0,0 +1,616 @@ +//! Herdr backend. +//! +//! Herdr injects its pane and socket identity into every managed pane process. +//! The inherited pane ID is the fast path, while `agent list` recovers the +//! current public ID after a cross-workspace pane move. Before focusing, every +//! candidate is checked against Herdr's foreground-process inventory so a +//! stale ID or PID collision cannot redirect the jump. + +use super::{pid_env_var, JumpAttempt, TerminalJumper}; +use serde::Deserialize; +use std::collections::HashSet; +use std::process::Command; + +const HERDR_ENV: &str = "HERDR_ENV"; +const HERDR_PANE_ID: &str = "HERDR_PANE_ID"; +const HERDR_SOCKET_PATH: &str = "HERDR_SOCKET_PATH"; + +pub struct HerdrJumper; + +impl TerminalJumper for HerdrJumper { + fn name(&self) -> &'static str { + "herdr" + } + + fn try_jump(&self, pid: u32) -> JumpAttempt { + try_jump_with( + pid, + |name| std::env::var(name).ok(), + |name| pid_env_var(pid, name), + run_herdr, + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CommandOutput { + success: bool, + status: String, + stdout: Vec, + stderr: Vec, +} + +#[derive(Debug, Deserialize)] +struct AgentListEnvelope { + result: AgentListResult, +} + +#[derive(Debug, Deserialize)] +struct AgentListResult { + #[serde(default)] + agents: Vec, +} + +#[derive(Debug, Deserialize)] +struct AgentPane { + pane_id: String, +} + +#[derive(Debug, Deserialize)] +struct ProcessInfoEnvelope { + result: ProcessInfoResult, +} + +#[derive(Debug, Deserialize)] +struct ProcessInfoResult { + process_info: ProcessInfo, +} + +#[derive(Debug, Deserialize)] +struct ProcessInfo { + pane_id: String, + #[serde(default)] + foreground_processes: Vec, +} + +#[derive(Debug, Deserialize)] +struct ProcessPid { + pid: u32, +} + +#[derive(Debug, Deserialize)] +struct FocusEnvelope { + result: FocusResult, +} + +#[derive(Debug, Deserialize)] +struct FocusResult { + agent: FocusedAgent, +} + +#[derive(Debug, Deserialize)] +struct FocusedAgent { + pane_id: String, +} + +#[derive(Debug, Deserialize)] +struct ErrorEnvelope { + error: ErrorBody, +} + +#[derive(Debug, Deserialize)] +struct ErrorBody { + code: String, + message: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PaneProbe { + OwnsPid, + DifferentPid, + Missing, +} + +fn try_jump_with( + pid: u32, + mut current_env: impl FnMut(&str) -> Option, + mut target_env: impl FnMut(&str) -> Option, + mut run: impl FnMut(&str, &[String]) -> Result, +) -> JumpAttempt { + if non_empty(current_env(HERDR_ENV)).as_deref() != Some("1") { + return JumpAttempt::NotApplicable; + } + let Some(socket_path) = non_empty(current_env(HERDR_SOCKET_PATH)) else { + return JumpAttempt::NotApplicable; + }; + + let target_socket = non_empty(target_env(HERDR_SOCKET_PATH)); + let same_session_claimed = target_socket.as_deref() == Some(socket_path.as_str()); + + let inherited_pane = non_empty(target_env(HERDR_PANE_ID)); + let mut inspected = HashSet::new(); + if let Some(pane_id) = inherited_pane.as_deref() { + inspected.insert(pane_id.to_string()); + match probe_pane(&socket_path, pane_id, pid, &mut run) { + Ok(PaneProbe::OwnsPid) => { + return focus_pane(&socket_path, pane_id, &mut run); + } + Ok(PaneProbe::DifferentPid | PaneProbe::Missing) => {} + Err(message) => return JumpAttempt::Failed(message), + } + } + + let list_args = strings(&["agent", "list"]); + let list_output = match run(&socket_path, &list_args) { + Ok(output) => output, + Err(message) => return JumpAttempt::Failed(message), + }; + if !list_output.success { + return JumpAttempt::Failed(format_command_failure("agent list", &list_output)); + } + let listed = match serde_json::from_slice::(&list_output.stdout) { + Ok(envelope) => envelope, + Err(error) => { + return JumpAttempt::Failed(format!("agent list returned invalid JSON ({error})")); + } + }; + + let mut matches = Vec::new(); + for agent in listed.result.agents { + if agent.pane_id.is_empty() || !inspected.insert(agent.pane_id.clone()) { + continue; + } + match probe_pane(&socket_path, &agent.pane_id, pid, &mut run) { + Ok(PaneProbe::OwnsPid) => matches.push(agent.pane_id), + Ok(PaneProbe::DifferentPid | PaneProbe::Missing) => {} + Err(message) => return JumpAttempt::Failed(message), + } + } + + match matches.as_slice() { + [pane_id] => focus_pane(&socket_path, pane_id, &mut run), + [] if same_session_claimed => { + JumpAttempt::Failed(format!("no current pane owns PID {pid}")) + } + [] => JumpAttempt::NotApplicable, + _ => JumpAttempt::Failed(format!("multiple panes claim PID {pid}")), + } +} + +fn probe_pane( + socket_path: &str, + pane_id: &str, + pid: u32, + run: &mut impl FnMut(&str, &[String]) -> Result, +) -> Result { + let args = strings(&["pane", "process-info", "--pane", pane_id]); + let output = run(socket_path, &args)?; + if !output.success { + if error_code(&output).as_deref() == Some("pane_not_found") { + return Ok(PaneProbe::Missing); + } + return Err(format_command_failure("pane process-info", &output)); + } + + let envelope = serde_json::from_slice::(&output.stdout) + .map_err(|error| format!("pane process-info returned invalid JSON ({error})"))?; + let info = envelope.result.process_info; + if info.pane_id != pane_id { + return Err(format!( + "pane process-info returned {} for requested {pane_id}", + info.pane_id + )); + } + if info + .foreground_processes + .iter() + .any(|process| process.pid == pid) + { + Ok(PaneProbe::OwnsPid) + } else { + Ok(PaneProbe::DifferentPid) + } +} + +fn focus_pane( + socket_path: &str, + pane_id: &str, + run: &mut impl FnMut(&str, &[String]) -> Result, +) -> JumpAttempt { + let args = strings(&["agent", "focus", pane_id]); + let output = match run(socket_path, &args) { + Ok(output) => output, + Err(message) => return JumpAttempt::Failed(message), + }; + if !output.success { + return JumpAttempt::Failed(format_command_failure("agent focus", &output)); + } + let envelope = match serde_json::from_slice::(&output.stdout) { + Ok(envelope) => envelope, + Err(error) => { + return JumpAttempt::Failed(format!("agent focus returned invalid JSON ({error})")); + } + }; + if envelope.result.agent.pane_id != pane_id { + return JumpAttempt::Failed(format!( + "agent focus returned {} for requested {pane_id}", + envelope.result.agent.pane_id + )); + } + JumpAttempt::Jumped +} + +fn run_herdr(socket_path: &str, args: &[String]) -> Result { + let output = Command::new("herdr") + .args(args) + .env(HERDR_SOCKET_PATH, socket_path) + .output() + .map_err(|error| format!("CLI not runnable ({error})"))?; + Ok(CommandOutput { + success: output.status.success(), + status: output.status.to_string(), + stdout: output.stdout, + stderr: output.stderr, + }) +} + +fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() +} + +fn non_empty(value: Option) -> Option { + value.filter(|candidate| !candidate.trim().is_empty()) +} + +fn error_code(output: &CommandOutput) -> Option { + serde_json::from_slice::(&output.stdout) + .ok() + .map(|envelope| envelope.error.code) +} + +fn format_command_failure(action: &str, output: &CommandOutput) -> String { + let detail = serde_json::from_slice::(&output.stdout) + .ok() + .map(|envelope| envelope.error.message) + .and_then(|message| concise_detail(message.as_bytes())) + .or_else(|| concise_detail(&output.stderr)) + .or_else(|| concise_detail(&output.stdout)); + let mut message = format!("{action} exited {}", output.status); + if let Some(detail) = detail { + message.push_str(": "); + message.push_str(&detail); + } + message +} + +fn concise_detail(output: &[u8]) -> Option { + let text = String::from_utf8_lossy(output); + let normalized = text.split_whitespace().collect::>().join(" "); + if normalized.is_empty() { + return None; + } + let mut detail: String = normalized.chars().take(160).collect(); + if normalized.chars().count() > 160 { + detail.push('…'); + } + Some(detail) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::VecDeque; + + fn ok(json: &str) -> Result { + Ok(CommandOutput { + success: true, + status: "exit status: 0".to_string(), + stdout: json.as_bytes().to_vec(), + stderr: Vec::new(), + }) + } + + fn failed(json: &str) -> Result { + Ok(CommandOutput { + success: false, + status: "exit status: 1".to_string(), + stdout: json.as_bytes().to_vec(), + stderr: Vec::new(), + }) + } + + fn process_info(pane_id: &str, pids: &[u32]) -> String { + let processes = pids + .iter() + .map(|pid| serde_json::json!({"pid": pid})) + .collect::>(); + serde_json::json!({ + "result": { + "process_info": { + "pane_id": pane_id, + "foreground_processes": processes + } + } + }) + .to_string() + } + + fn agent_list(pane_ids: &[&str]) -> String { + let agents = pane_ids + .iter() + .map(|pane_id| serde_json::json!({"pane_id": pane_id})) + .collect::>(); + serde_json::json!({"result": {"agents": agents}}).to_string() + } + + fn focused(pane_id: &str) -> String { + serde_json::json!({"result": {"agent": {"pane_id": pane_id}}}).to_string() + } + + fn scripted( + script: Vec<(Vec, Result)>, + ) -> impl FnMut(&str, &[String]) -> Result { + let mut script = VecDeque::from(script); + move |socket_path, args| { + assert_eq!(socket_path, "/tmp/herdr.sock"); + let (expected, output) = script.pop_front().expect("unexpected Herdr command"); + assert_eq!(args, expected.as_slice()); + output + } + } + + #[test] + fn outside_herdr_is_not_applicable() { + let mut called = false; + let attempt = try_jump_with( + 42, + |_| None, + |_| None, + |_, _| { + called = true; + ok("{}") + }, + ); + assert_eq!(attempt, JumpAttempt::NotApplicable); + assert!(!called); + } + + #[test] + fn another_herdr_session_falls_through_after_current_server_check() { + let script = vec![ + ( + strings(&["pane", "process-info", "--pane", "w2:p1"]), + failed(r#"{"error":{"code":"pane_not_found","message":"pane not found"}}"#), + ), + (strings(&["agent", "list"]), ok(&agent_list(&[]))), + ]; + let attempt = try_jump_with( + 42, + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + _ => None, + }, + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/other.sock".to_string()), + HERDR_PANE_ID => Some("w2:p1".to_string()), + _ => None, + }, + scripted(script), + ); + assert_eq!(attempt, JumpAttempt::NotApplicable); + } + + #[test] + fn inherited_pane_with_exact_pid_focuses_directly() { + let script = vec![ + ( + strings(&["pane", "process-info", "--pane", "w1:p2"]), + ok(&process_info("w1:p2", &[7, 42])), + ), + (strings(&["agent", "focus", "w1:p2"]), ok(&focused("w1:p2"))), + ]; + let attempt = try_jump_with( + 42, + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + _ => None, + }, + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + HERDR_PANE_ID => Some("w1:p2".to_string()), + _ => None, + }, + scripted(script), + ); + assert_eq!(attempt, JumpAttempt::Jumped); + } + + #[test] + fn stale_inherited_pane_falls_back_to_current_agent_list() { + let script = vec![ + ( + strings(&["pane", "process-info", "--pane", "w1:p2"]), + failed(r#"{"error":{"code":"pane_not_found","message":"pane not found"}}"#), + ), + (strings(&["agent", "list"]), ok(&agent_list(&["w2:p9"]))), + ( + strings(&["pane", "process-info", "--pane", "w2:p9"]), + ok(&process_info("w2:p9", &[42])), + ), + (strings(&["agent", "focus", "w2:p9"]), ok(&focused("w2:p9"))), + ]; + let attempt = try_jump_with( + 42, + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + _ => None, + }, + |name| match name { + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + HERDR_PANE_ID => Some("w1:p2".to_string()), + _ => None, + }, + scripted(script), + ); + assert_eq!(attempt, JumpAttempt::Jumped); + } + + #[test] + fn no_matching_current_server_pane_falls_through() { + let script = vec![ + (strings(&["agent", "list"]), ok(&agent_list(&["w1:p1"]))), + ( + strings(&["pane", "process-info", "--pane", "w1:p1"]), + ok(&process_info("w1:p1", &[7])), + ), + ]; + let attempt = try_jump_with( + 42, + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + _ => None, + }, + |_| None, + scripted(script), + ); + assert_eq!(attempt, JumpAttempt::NotApplicable); + } + + #[test] + fn claimed_same_session_without_current_pane_is_a_failure() { + let script = vec![(strings(&["agent", "list"]), ok(&agent_list(&[])))]; + let attempt = try_jump_with( + 42, + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + _ => None, + }, + |name| match name { + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + _ => None, + }, + scripted(script), + ); + assert_eq!( + attempt, + JumpAttempt::Failed("no current pane owns PID 42".to_string()) + ); + } + + #[test] + fn multiple_pid_matches_fail_closed() { + let script = vec![ + ( + strings(&["agent", "list"]), + ok(&agent_list(&["w1:p1", "w1:p2"])), + ), + ( + strings(&["pane", "process-info", "--pane", "w1:p1"]), + ok(&process_info("w1:p1", &[42])), + ), + ( + strings(&["pane", "process-info", "--pane", "w1:p2"]), + ok(&process_info("w1:p2", &[42])), + ), + ]; + let attempt = try_jump_with( + 42, + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + _ => None, + }, + |_| None, + scripted(script), + ); + assert_eq!( + attempt, + JumpAttempt::Failed("multiple panes claim PID 42".to_string()) + ); + } + + #[test] + fn malformed_agent_list_is_reported() { + let script = vec![(strings(&["agent", "list"]), ok("not-json"))]; + let attempt = try_jump_with( + 42, + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + _ => None, + }, + |_| None, + scripted(script), + ); + assert!(matches!( + attempt, + JumpAttempt::Failed(message) if message.starts_with("agent list returned invalid JSON") + )); + } + + #[test] + fn focus_response_must_name_the_requested_pane() { + let script = vec![ + ( + strings(&["pane", "process-info", "--pane", "w1:p2"]), + ok(&process_info("w1:p2", &[42])), + ), + (strings(&["agent", "focus", "w1:p2"]), ok(&focused("w1:p3"))), + ]; + let attempt = try_jump_with( + 42, + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + _ => None, + }, + |name| match name { + HERDR_PANE_ID => Some("w1:p2".to_string()), + _ => None, + }, + scripted(script), + ); + assert_eq!( + attempt, + JumpAttempt::Failed("agent focus returned w1:p3 for requested w1:p2".to_string()) + ); + } + + #[test] + fn command_failure_uses_structured_error_message() { + let script = vec![ + ( + strings(&["pane", "process-info", "--pane", "w1:p2"]), + ok(&process_info("w1:p2", &[42])), + ), + ( + strings(&["agent", "focus", "w1:p2"]), + failed(r#"{"error":{"code":"agent_not_found","message":"agent not found"}}"#), + ), + ]; + let attempt = try_jump_with( + 42, + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + _ => None, + }, + |name| match name { + HERDR_PANE_ID => Some("w1:p2".to_string()), + _ => None, + }, + scripted(script), + ); + assert_eq!( + attempt, + JumpAttempt::Failed("agent focus exited exit status: 1: agent not found".to_string()) + ); + } +} diff --git a/src/jump/mod.rs b/src/jump/mod.rs index e1d42fa..5b49e9a 100644 --- a/src/jump/mod.rs +++ b/src/jump/mod.rs @@ -11,6 +11,7 @@ //! process. Only `Failed`/`Jumped` stop the walk. mod cmux; +mod herdr; #[cfg(target_os = "macos")] mod iterm2; mod tmux; @@ -54,12 +55,13 @@ pub fn resolve(jumpers: &[Box], pid: u32) -> JumpOutcome { } /// The registry: the single ordered source of truth for supported terminals. -/// Order = most specific first: cmux (env-tagged) → tmux (multiplexer) → -/// iTerm2 on macOS (emulator). They are mutually exclusive by tty, so order -/// only matters for the multiplexer-inside-emulator case. +/// Order = most specific first: Herdr (multiplexer) → cmux (env-tagged) → +/// tmux (multiplexer) → iTerm2 on macOS (emulator). Order matters when a +/// multiplexer inherits its outer terminal's environment. #[cfg(target_os = "macos")] pub fn jumpers() -> Vec> { vec![ + Box::new(herdr::HerdrJumper), Box::new(cmux::CmuxJumper), Box::new(tmux::TmuxJumper), Box::new(iterm2::ITerm2Jumper), @@ -71,7 +73,11 @@ pub fn jumpers() -> Vec> { /// sessions no-op cleanly instead of trying macOS-only `osascript`. #[cfg(not(target_os = "macos"))] pub fn jumpers() -> Vec> { - vec![Box::new(cmux::CmuxJumper), Box::new(tmux::TmuxJumper)] + vec![ + Box::new(herdr::HerdrJumper), + Box::new(cmux::CmuxJumper), + Box::new(tmux::TmuxJumper), + ] } /// Entry point used by the app: run the selected PID through the registry. @@ -325,10 +331,12 @@ mod tests { #[test] fn jumpers_include_platform_backends() { #[cfg(target_os = "macos")] - assert_eq!(jumpers().len(), 3); + assert_eq!(jumpers().len(), 4); #[cfg(not(target_os = "macos"))] - assert_eq!(jumpers().len(), 2); + assert_eq!(jumpers().len(), 3); + + assert_eq!(jumpers()[0].name(), "herdr"); } // ---- find_pane_target (tmux) ----