Skip to main content

batty_cli/
tmux.rs

1//! tmux session management for batty.
2//!
3//! Wraps tmux CLI commands for session lifecycle, output capture via pipe-pane,
4//! input injection via send-keys, and pane management. This replaces the
5//! portable-pty direct approach from Phase 1 with tmux-based supervision.
6#![allow(dead_code)]
7
8use std::path::Path;
9use std::process::{Command, Output};
10use std::sync::atomic::{AtomicU64, Ordering};
11
12use anyhow::{Context, Result, bail};
13use tracing::{debug, info, warn};
14
15use crate::team::errors::TmuxError;
16
17static PROBE_COUNTER: AtomicU64 = AtomicU64::new(1);
18const SUPERVISOR_CONTROL_OPTION: &str = "@batty_supervisor_control";
19
20/// Default tmux-prefix hotkey for pausing Batty supervision.
21pub const SUPERVISOR_PAUSE_HOTKEY: &str = "C-b P";
22/// Default tmux-prefix hotkey for resuming Batty supervision.
23pub const SUPERVISOR_RESUME_HOTKEY: &str = "C-b R";
24const SEND_KEYS_SUBMIT_DELAY_MS: u64 = 100;
25
26/// Known split strategies for creating the orchestrator log pane.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum SplitMode {
29    Lines,
30    Percent,
31    Disabled,
32}
33
34/// tmux capability probe result used by orchestrator startup.
35#[derive(Debug, Clone)]
36pub struct TmuxCapabilities {
37    pub version_raw: String,
38    pub version: Option<(u32, u32)>,
39    pub pipe_pane: bool,
40    pub pipe_pane_only_if_missing: bool,
41    pub status_style: bool,
42    pub split_mode: SplitMode,
43}
44
45impl TmuxCapabilities {
46    /// Known-good range documented for Batty runtime behavior.
47    ///
48    /// Current matrix:
49    /// - 3.2+ known-good
50    /// - 3.1 supported with fallbacks
51    /// - older versions unsupported
52    pub fn known_good(&self) -> bool {
53        matches!(self.version, Some((major, minor)) if major > 3 || (major == 3 && minor >= 2))
54    }
55
56    pub fn remediation_message(&self) -> String {
57        format!(
58            "tmux capability check failed (detected '{}'). Batty requires working `pipe-pane` support. \
59Install or upgrade tmux (recommended >= 3.2) and re-run `batty start`.",
60            self.version_raw
61        )
62    }
63}
64
65/// Metadata for a tmux pane.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct PaneDetails {
68    pub id: String,
69    pub command: String,
70    pub active: bool,
71    pub dead: bool,
72}
73
74/// Check that tmux is installed and reachable.
75pub fn check_tmux() -> Result<String> {
76    let output = Command::new("tmux")
77        .arg("-V")
78        .output()
79        .map_err(|error| TmuxError::exec("tmux -V", error))?;
80
81    if !output.status.success() {
82        return Err(TmuxError::command_failed(
83            "tmux -V",
84            None,
85            &String::from_utf8_lossy(&output.stderr),
86        )
87        .into());
88    }
89
90    let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
91    debug!(version = %version, "tmux found");
92    Ok(version)
93}
94
95fn parse_tmux_version(version_raw: &str) -> Option<(u32, u32)> {
96    let raw = version_raw.trim();
97    let ver = raw.strip_prefix("tmux ")?;
98    let mut chars = ver.chars().peekable();
99
100    let mut major = String::new();
101    while let Some(c) = chars.peek() {
102        if c.is_ascii_digit() {
103            major.push(*c);
104            chars.next();
105        } else {
106            break;
107        }
108    }
109    if major.is_empty() {
110        return None;
111    }
112
113    if chars.next()? != '.' {
114        return None;
115    }
116
117    let mut minor = String::new();
118    while let Some(c) = chars.peek() {
119        if c.is_ascii_digit() {
120            minor.push(*c);
121            chars.next();
122        } else {
123            break;
124        }
125    }
126    if minor.is_empty() {
127        return None;
128    }
129
130    Some((major.parse().ok()?, minor.parse().ok()?))
131}
132
133fn run_tmux<I, S>(args: I) -> Result<Output>
134where
135    I: IntoIterator<Item = S>,
136    S: AsRef<std::ffi::OsStr>,
137{
138    Command::new("tmux")
139        .args(args)
140        .output()
141        .map_err(|error| TmuxError::exec("tmux", error).into())
142}
143
144/// Probe tmux capabilities used by Batty and choose compatible behavior.
145pub fn probe_capabilities() -> Result<TmuxCapabilities> {
146    let version_raw = check_tmux()?;
147    let version = parse_tmux_version(&version_raw);
148
149    let probe_id = PROBE_COUNTER.fetch_add(1, Ordering::Relaxed);
150    let session = format!("batty-cap-probe-{}-{probe_id}", std::process::id());
151    let _ = kill_session(&session);
152    create_session(&session, "sleep", &["20".to_string()], "/tmp")
153        .with_context(|| format!("failed to create tmux probe session '{session}'"))?;
154    let pane = pane_id(&session)?;
155
156    let cleanup = || {
157        let _ = kill_session(&session);
158    };
159
160    let pipe_cmd = "cat >/dev/null";
161    let pipe_pane = match run_tmux(["pipe-pane", "-t", pane.as_str(), pipe_cmd]) {
162        Ok(out) => out.status.success(),
163        Err(_) => false,
164    };
165
166    let pipe_pane_only_if_missing =
167        match run_tmux(["pipe-pane", "-o", "-t", pane.as_str(), pipe_cmd]) {
168            Ok(out) => out.status.success(),
169            Err(_) => false,
170        };
171
172    // Stop piping in probe session (best-effort).
173    let _ = run_tmux(["pipe-pane", "-t", pane.as_str()]);
174
175    let status_style = match run_tmux([
176        "set",
177        "-t",
178        session.as_str(),
179        "status-style",
180        "bg=colour235,fg=colour136",
181    ]) {
182        Ok(out) => out.status.success(),
183        Err(_) => false,
184    };
185
186    let split_lines = match run_tmux([
187        "split-window",
188        "-v",
189        "-l",
190        "3",
191        "-t",
192        session.as_str(),
193        "sleep",
194        "1",
195    ]) {
196        Ok(out) => out.status.success(),
197        Err(_) => false,
198    };
199    let split_percent = if split_lines {
200        false
201    } else {
202        match run_tmux([
203            "split-window",
204            "-v",
205            "-p",
206            "20",
207            "-t",
208            session.as_str(),
209            "sleep",
210            "1",
211        ]) {
212            Ok(out) => out.status.success(),
213            Err(_) => false,
214        }
215    };
216
217    cleanup();
218
219    let split_mode = if split_lines {
220        SplitMode::Lines
221    } else if split_percent {
222        SplitMode::Percent
223    } else {
224        SplitMode::Disabled
225    };
226
227    Ok(TmuxCapabilities {
228        version_raw,
229        version,
230        pipe_pane,
231        pipe_pane_only_if_missing,
232        status_style,
233        split_mode,
234    })
235}
236
237/// Convention for session names: `batty-<phase>`.
238pub fn session_name(phase: &str) -> String {
239    // tmux target parsing treats '.' as pane separators, so session names
240    // should avoid dots and other punctuation that can be interpreted.
241    let sanitized: String = phase
242        .chars()
243        .map(|c| {
244            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
245                c
246            } else {
247                '-'
248            }
249        })
250        .collect();
251    format!("batty-{sanitized}")
252}
253
254/// Check if a tmux session exists.
255pub fn session_exists(session: &str) -> bool {
256    Command::new("tmux")
257        .args(["has-session", "-t", session])
258        .output()
259        .map(|o| o.status.success())
260        .unwrap_or(false)
261}
262
263/// Check if a specific tmux pane target exists.
264pub fn pane_exists(target: &str) -> bool {
265    Command::new("tmux")
266        .args(["display-message", "-p", "-t", target, "#{pane_id}"])
267        .output()
268        .map(|o| o.status.success())
269        .unwrap_or(false)
270}
271
272/// Check whether a pane target is dead (`remain-on-exit` pane).
273pub fn pane_dead(target: &str) -> Result<bool> {
274    let output = Command::new("tmux")
275        .args(["display-message", "-p", "-t", target, "#{pane_dead}"])
276        .output()
277        .with_context(|| format!("failed to query pane_dead for target '{target}'"))?;
278
279    if !output.status.success() {
280        let stderr = String::from_utf8_lossy(&output.stderr);
281        return Err(TmuxError::command_failed(
282            "display-message #{pane_dead}",
283            Some(target),
284            &stderr,
285        )
286        .into());
287    }
288
289    let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
290    Ok(value == "1")
291}
292
293/// Check whether a pane currently has an active `pipe-pane` command.
294pub fn pane_pipe_enabled(target: &str) -> Result<bool> {
295    let output = Command::new("tmux")
296        .args(["display-message", "-p", "-t", target, "#{pane_pipe}"])
297        .output()
298        .with_context(|| format!("failed to query pane_pipe for target '{target}'"))?;
299
300    if !output.status.success() {
301        let stderr = String::from_utf8_lossy(&output.stderr);
302        bail!("tmux display-message pane_pipe failed: {stderr}");
303    }
304
305    let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
306    Ok(value == "1")
307}
308
309/// Get the active pane id for a session target (for example: `%3`).
310pub fn pane_id(target: &str) -> Result<String> {
311    let output = Command::new("tmux")
312        .args(["display-message", "-p", "-t", target, "#{pane_id}"])
313        .output()
314        .with_context(|| format!("failed to resolve pane id for target '{target}'"))?;
315
316    if !output.status.success() {
317        let stderr = String::from_utf8_lossy(&output.stderr);
318        return Err(
319            TmuxError::command_failed("display-message #{pane_id}", Some(target), &stderr).into(),
320        );
321    }
322
323    let pane = String::from_utf8_lossy(&output.stdout).trim().to_string();
324    if pane.is_empty() {
325        return Err(TmuxError::EmptyPaneId {
326            target: target.to_string(),
327        }
328        .into());
329    }
330    Ok(pane)
331}
332
333/// Return `(pane_width, pane_height)` for a tmux pane target.
334pub fn pane_dimensions(target: &str) -> Result<(u16, u16)> {
335    let output = Command::new("tmux")
336        .args([
337            "display-message",
338            "-p",
339            "-t",
340            target,
341            "#{pane_width} #{pane_height}",
342        ])
343        .output()
344        .with_context(|| format!("failed to query pane dimensions for target '{target}'"))?;
345
346    if !output.status.success() {
347        let stderr = String::from_utf8_lossy(&output.stderr);
348        return Err(TmuxError::command_failed(
349            "display-message #{pane_width} #{pane_height}",
350            Some(target),
351            &stderr,
352        )
353        .into());
354    }
355
356    let stdout = String::from_utf8_lossy(&output.stdout);
357    let mut parts = stdout.split_whitespace();
358    let width = parts
359        .next()
360        .context("tmux pane width missing")?
361        .parse()
362        .context("invalid tmux pane width")?;
363    let height = parts
364        .next()
365        .context("tmux pane height missing")?
366        .parse()
367        .context("invalid tmux pane height")?;
368    Ok((width, height))
369}
370
371/// Get the current working directory for a pane target.
372pub fn pane_current_path(target: &str) -> Result<String> {
373    let output = Command::new("tmux")
374        .args([
375            "display-message",
376            "-p",
377            "-t",
378            target,
379            "#{pane_current_path}",
380        ])
381        .output()
382        .with_context(|| format!("failed to resolve pane current path for target '{target}'"))?;
383
384    if !output.status.success() {
385        let stderr = String::from_utf8_lossy(&output.stderr);
386        bail!("tmux display-message pane_current_path failed: {stderr}");
387    }
388
389    let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
390    if path.is_empty() {
391        bail!("tmux returned empty pane current path for target '{target}'");
392    }
393    Ok(path)
394}
395
396/// Get the configured session working directory path.
397pub fn session_path(session: &str) -> Result<String> {
398    let output = Command::new("tmux")
399        .args(["display-message", "-p", "-t", session, "#{session_path}"])
400        .output()
401        .with_context(|| format!("failed to resolve session path for '{session}'"))?;
402
403    if !output.status.success() {
404        let stderr = String::from_utf8_lossy(&output.stderr);
405        bail!("tmux display-message session_path failed: {stderr}");
406    }
407
408    let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
409    if path.is_empty() {
410        bail!("tmux returned empty session path for '{session}'");
411    }
412    Ok(path)
413}
414
415/// Create a detached tmux session running the given command.
416///
417/// The session is created with `new-session -d` so it starts in the background.
418/// The executor command is the initial command the session runs.
419pub fn create_session(session: &str, program: &str, args: &[String], work_dir: &str) -> Result<()> {
420    if session_exists(session) {
421        return Err(TmuxError::SessionExists {
422            session: session.to_string(),
423        }
424        .into());
425    }
426
427    // Build the full command string for tmux
428    // tmux new-session -d -s <name> -c <work_dir> <program> <args...>
429    let mut cmd = Command::new("tmux");
430    cmd.args(["new-session", "-d", "-s", session, "-c", work_dir]);
431    // Set a generous size so the PTY isn't tiny
432    cmd.args(["-x", "220", "-y", "50"]);
433    // Unset CLAUDECODE so nested Claude Code sessions can launch.
434    // Without this, Claude Code detects the parent session's env var and
435    // refuses to start ("cannot be launched inside another Claude Code session").
436    cmd.args(["env", "-u", "CLAUDECODE"]);
437    cmd.arg(program);
438    for arg in args {
439        cmd.arg(arg);
440    }
441
442    let output = cmd
443        .output()
444        .with_context(|| format!("failed to create tmux session '{session}'"))?;
445
446    if !output.status.success() {
447        let stderr = String::from_utf8_lossy(&output.stderr);
448        return Err(TmuxError::command_failed("new-session", Some(session), &stderr).into());
449    }
450
451    if let Err(e) = set_mouse(session, true) {
452        warn!(
453            session = session,
454            error = %e,
455            "failed to enable tmux mouse mode"
456        );
457    }
458
459    info!(session = session, "tmux session created");
460    Ok(())
461}
462
463/// Create a detached tmux window in an existing session running the given command.
464pub fn create_window(
465    session: &str,
466    window_name: &str,
467    program: &str,
468    args: &[String],
469    work_dir: &str,
470) -> Result<()> {
471    if !session_exists(session) {
472        bail!("tmux session '{session}' not found");
473    }
474
475    let mut cmd = Command::new("tmux");
476    cmd.args([
477        "new-window",
478        "-d",
479        "-t",
480        session,
481        "-n",
482        window_name,
483        "-c",
484        work_dir,
485    ]);
486    // Unset CLAUDECODE so nested Claude Code sessions can launch from
487    // additional windows (for example, parallel agent slots).
488    cmd.args(["env", "-u", "CLAUDECODE"]);
489    cmd.arg(program);
490    for arg in args {
491        cmd.arg(arg);
492    }
493
494    let output = cmd
495        .output()
496        .with_context(|| format!("failed to create tmux window '{window_name}'"))?;
497
498    if !output.status.success() {
499        let stderr = String::from_utf8_lossy(&output.stderr);
500        bail!("tmux new-window failed: {stderr}");
501    }
502
503    Ok(())
504}
505
506/// Rename an existing tmux window target (e.g. `session:0` or `session:old`).
507pub fn rename_window(target: &str, new_name: &str) -> Result<()> {
508    let output = Command::new("tmux")
509        .args(["rename-window", "-t", target, new_name])
510        .output()
511        .with_context(|| format!("failed to rename tmux window target '{target}'"))?;
512
513    if !output.status.success() {
514        let stderr = String::from_utf8_lossy(&output.stderr);
515        bail!("tmux rename-window failed: {stderr}");
516    }
517
518    Ok(())
519}
520
521/// Select a tmux window target (e.g. `session:agent-1`).
522pub fn select_window(target: &str) -> Result<()> {
523    let output = Command::new("tmux")
524        .args(["select-window", "-t", target])
525        .output()
526        .with_context(|| format!("failed to select tmux window '{target}'"))?;
527
528    if !output.status.success() {
529        let stderr = String::from_utf8_lossy(&output.stderr);
530        bail!("tmux select-window failed: {stderr}");
531    }
532
533    Ok(())
534}
535
536/// Set up pipe-pane to capture all output from the target pane to a log file.
537///
538/// Uses `tmux pipe-pane -t <session> "cat >> <log_path>"` to stream all PTY
539/// output to a file. This is the foundation for event extraction.
540pub fn setup_pipe_pane(target: &str, log_path: &Path) -> Result<()> {
541    // Ensure log directory exists
542    if let Some(parent) = log_path.parent() {
543        std::fs::create_dir_all(parent)
544            .with_context(|| format!("failed to create log directory: {}", parent.display()))?;
545    }
546
547    let pipe_cmd = format!("cat >> {}", log_path.display());
548    let output = Command::new("tmux")
549        .args(["pipe-pane", "-t", target, &pipe_cmd])
550        .output()
551        .with_context(|| format!("failed to set up pipe-pane for target '{target}'"))?;
552
553    if !output.status.success() {
554        let stderr = String::from_utf8_lossy(&output.stderr);
555        bail!("tmux pipe-pane failed: {stderr}");
556    }
557
558    info!(target = target, log = %log_path.display(), "pipe-pane configured");
559    Ok(())
560}
561
562/// Set up pipe-pane only if none is configured yet (`tmux pipe-pane -o`).
563pub fn setup_pipe_pane_if_missing(target: &str, log_path: &Path) -> Result<()> {
564    if let Some(parent) = log_path.parent() {
565        std::fs::create_dir_all(parent)
566            .with_context(|| format!("failed to create log directory: {}", parent.display()))?;
567    }
568
569    let pipe_cmd = format!("cat >> {}", log_path.display());
570    let output = Command::new("tmux")
571        .args(["pipe-pane", "-o", "-t", target, &pipe_cmd])
572        .output()
573        .with_context(|| format!("failed to set up pipe-pane (-o) for target '{target}'"))?;
574
575    if !output.status.success() {
576        let stderr = String::from_utf8_lossy(&output.stderr);
577        bail!("tmux pipe-pane -o failed: {stderr}");
578    }
579
580    info!(
581        target = target,
582        log = %log_path.display(),
583        "pipe-pane ensured (only-if-missing)"
584    );
585    Ok(())
586}
587
588/// Attach to an existing tmux session (blocks until detach/exit).
589///
590/// If already inside tmux, uses `switch-client` instead of `attach-session`.
591pub fn attach(session: &str) -> Result<()> {
592    if !session_exists(session) {
593        bail!(
594            "tmux session '{session}' not found — is batty running? \
595             Start with `batty start` first"
596        );
597    }
598
599    let inside_tmux = std::env::var("TMUX").is_ok();
600
601    let (cmd, args) = if inside_tmux {
602        ("switch-client", vec!["-t", session])
603    } else {
604        ("attach-session", vec!["-t", session])
605    };
606
607    let status = Command::new("tmux")
608        .arg(cmd)
609        .args(&args)
610        .status()
611        .with_context(|| format!("failed to {cmd} to tmux session '{session}'"))?;
612
613    if !status.success() {
614        bail!("tmux {cmd} to '{session}' failed");
615    }
616
617    Ok(())
618}
619
620/// Send keys to a tmux target (session or pane).
621///
622/// This is how batty injects responses into the executor's PTY.
623/// The `keys` string is sent literally, followed by Enter if `press_enter` is true.
624pub fn send_keys(target: &str, keys: &str, press_enter: bool) -> Result<()> {
625    if !keys.is_empty() {
626        // `-l` sends text literally so punctuation/symbols are not interpreted as
627        // tmux key names.
628        let output = Command::new("tmux")
629            .args(["send-keys", "-t", target, "-l", "--", keys])
630            .output()
631            .with_context(|| format!("failed to send keys to target '{target}'"))?;
632
633        if !output.status.success() {
634            let stderr = String::from_utf8_lossy(&output.stderr);
635            return Err(TmuxError::command_failed("send-keys", Some(target), &stderr).into());
636        }
637    }
638
639    if press_enter {
640        // Keep submission as a separate keypress so the target app processes the
641        // literal text first, matching the watcher script's behavior.
642        if !keys.is_empty() {
643            std::thread::sleep(std::time::Duration::from_millis(SEND_KEYS_SUBMIT_DELAY_MS));
644        }
645
646        let output = Command::new("tmux")
647            .args(["send-keys", "-t", target, "Enter"])
648            .output()
649            .with_context(|| format!("failed to send Enter to target '{target}'"))?;
650
651        if !output.status.success() {
652            let stderr = String::from_utf8_lossy(&output.stderr);
653            return Err(TmuxError::command_failed("send-keys Enter", Some(target), &stderr).into());
654        }
655    }
656
657    debug!(target = target, keys = keys, "sent keys");
658    Ok(())
659}
660
661/// List all tmux session names matching a prefix.
662pub fn list_sessions_with_prefix(prefix: &str) -> Vec<String> {
663    let output = Command::new("tmux")
664        .args(["list-sessions", "-F", "#{session_name}"])
665        .output();
666
667    match output {
668        Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)
669            .lines()
670            .filter(|name| name.starts_with(prefix))
671            .map(|s| s.to_string())
672            .collect(),
673        _ => Vec::new(),
674    }
675}
676
677/// RAII guard that kills a tmux session on drop.
678///
679/// Ensures test sessions are cleaned up even on panic/assert failure.
680/// Use in tests instead of manual `kill_session()` calls.
681pub struct TestSession {
682    name: String,
683}
684
685impl TestSession {
686    /// Create a new test session guard wrapping an existing session name.
687    pub fn new(name: impl Into<String>) -> Self {
688        Self { name: name.into() }
689    }
690
691    /// The session name.
692    pub fn name(&self) -> &str {
693        &self.name
694    }
695}
696
697impl Drop for TestSession {
698    fn drop(&mut self) {
699        let _ = kill_session(&self.name);
700    }
701}
702
703/// Kill a tmux session.
704pub fn kill_session(session: &str) -> Result<()> {
705    if !session_exists(session) {
706        return Ok(()); // already gone
707    }
708
709    let output = Command::new("tmux")
710        .args(["kill-session", "-t", session])
711        .output()
712        .with_context(|| format!("failed to kill tmux session '{session}'"))?;
713
714    if !output.status.success() {
715        let stderr = String::from_utf8_lossy(&output.stderr);
716        bail!("tmux kill-session failed: {stderr}");
717    }
718
719    info!(session = session, "tmux session killed");
720    Ok(())
721}
722
723/// Capture the current visible content of a tmux target (session or pane).
724///
725/// Returns the text currently shown in the pane (useful for prompt detection
726/// when pipe-pane output has a lag).
727pub fn capture_pane(target: &str) -> Result<String> {
728    capture_pane_recent(target, 0)
729}
730
731/// Capture only the most recent visible lines of a tmux target.
732pub fn capture_pane_recent(target: &str, lines: u32) -> Result<String> {
733    let mut args = vec![
734        "capture-pane".to_string(),
735        "-t".to_string(),
736        target.to_string(),
737        "-p".to_string(),
738    ];
739    if lines > 0 {
740        args.push("-S".to_string());
741        args.push(format!("-{lines}"));
742    }
743
744    let output = Command::new("tmux")
745        .args(&args)
746        .output()
747        .with_context(|| format!("failed to capture pane for target '{target}'"))?;
748
749    if !output.status.success() {
750        let stderr = String::from_utf8_lossy(&output.stderr);
751        return Err(TmuxError::command_failed("capture-pane", Some(target), &stderr).into());
752    }
753
754    Ok(String::from_utf8_lossy(&output.stdout).to_string())
755}
756
757/// Enable/disable tmux mouse mode for a session.
758pub fn set_mouse(session: &str, enabled: bool) -> Result<()> {
759    let value = if enabled { "on" } else { "off" };
760    tmux_set(session, "mouse", value)
761}
762
763fn bind_supervisor_hotkey(session: &str, key: &str, action: &str) -> Result<()> {
764    let output = Command::new("tmux")
765        .args([
766            "bind-key",
767            "-T",
768            "prefix",
769            key,
770            "set-option",
771            "-t",
772            session,
773            SUPERVISOR_CONTROL_OPTION,
774            action,
775        ])
776        .output()
777        .with_context(|| format!("failed to bind supervisor hotkey '{key}' for '{session}'"))?;
778
779    if !output.status.success() {
780        let stderr = String::from_utf8_lossy(&output.stderr);
781        bail!("tmux bind-key {key} failed: {stderr}");
782    }
783
784    Ok(())
785}
786
787/// Configure per-session supervisor hotkeys:
788/// - `Prefix + Shift+P` -> pause automation
789/// - `Prefix + Shift+R` -> resume automation
790pub fn configure_supervisor_hotkeys(session: &str) -> Result<()> {
791    tmux_set(session, SUPERVISOR_CONTROL_OPTION, "")?;
792    bind_supervisor_hotkey(session, "P", "pause")?;
793    bind_supervisor_hotkey(session, "R", "resume")?;
794    Ok(())
795}
796
797/// Read and clear a queued supervisor hotkey action for the session.
798///
799/// Returns `Some("pause")` / `Some("resume")` when set, or `None` when idle.
800pub fn take_supervisor_hotkey_action(session: &str) -> Result<Option<String>> {
801    let output = Command::new("tmux")
802        .args([
803            "show-options",
804            "-v",
805            "-t",
806            session,
807            SUPERVISOR_CONTROL_OPTION,
808        ])
809        .output()
810        .with_context(|| {
811            format!("failed to read supervisor control option for session '{session}'")
812        })?;
813
814    if !output.status.success() {
815        let stderr = String::from_utf8_lossy(&output.stderr);
816        bail!("tmux show-options supervisor control failed: {stderr}");
817    }
818
819    let action = String::from_utf8_lossy(&output.stdout).trim().to_string();
820    if action.is_empty() {
821        return Ok(None);
822    }
823
824    tmux_set(session, SUPERVISOR_CONTROL_OPTION, "")?;
825    Ok(Some(action))
826}
827
828/// List panes in a session.
829///
830/// Returns a list of pane IDs (e.g., ["%0", "%1"]).
831#[cfg(test)]
832pub fn list_panes(session: &str) -> Result<Vec<String>> {
833    let output = Command::new("tmux")
834        .args(["list-panes", "-t", session, "-F", "#{pane_id}"])
835        .output()
836        .with_context(|| format!("failed to list panes for session '{session}'"))?;
837
838    if !output.status.success() {
839        let stderr = String::from_utf8_lossy(&output.stderr);
840        bail!("tmux list-panes failed: {stderr}");
841    }
842
843    let panes = String::from_utf8_lossy(&output.stdout)
844        .lines()
845        .map(|s| s.to_string())
846        .collect();
847
848    Ok(panes)
849}
850
851#[cfg(test)]
852fn list_window_names(session: &str) -> Result<Vec<String>> {
853    let output = Command::new("tmux")
854        .args(["list-windows", "-t", session, "-F", "#{window_name}"])
855        .output()
856        .with_context(|| format!("failed to list windows for session '{session}'"))?;
857
858    if !output.status.success() {
859        let stderr = String::from_utf8_lossy(&output.stderr);
860        bail!("tmux list-windows failed: {stderr}");
861    }
862
863    Ok(String::from_utf8_lossy(&output.stdout)
864        .lines()
865        .map(|line| line.trim().to_string())
866        .filter(|line| !line.is_empty())
867        .collect())
868}
869
870/// List panes in a session with command/active/dead metadata.
871pub fn list_pane_details(session: &str) -> Result<Vec<PaneDetails>> {
872    let output = Command::new("tmux")
873        .args([
874            "list-panes",
875            "-t",
876            session,
877            "-F",
878            "#{pane_id}\t#{pane_current_command}\t#{pane_active}\t#{pane_dead}",
879        ])
880        .output()
881        .with_context(|| format!("failed to list pane details for session '{session}'"))?;
882
883    if !output.status.success() {
884        let stderr = String::from_utf8_lossy(&output.stderr);
885        bail!("tmux list-panes details failed: {stderr}");
886    }
887
888    let mut panes = Vec::new();
889    for line in String::from_utf8_lossy(&output.stdout).lines() {
890        let mut parts = line.split('\t');
891        let Some(id) = parts.next() else { continue };
892        let Some(command) = parts.next() else {
893            continue;
894        };
895        let Some(active) = parts.next() else { continue };
896        let Some(dead) = parts.next() else { continue };
897        panes.push(PaneDetails {
898            id: id.to_string(),
899            command: command.to_string(),
900            active: active == "1",
901            dead: dead == "1",
902        });
903    }
904
905    Ok(panes)
906}
907
908/// Helper: run `tmux set -t <session> <option> <value>`.
909/// Split a pane horizontally (creates a new pane to the right).
910///
911/// `target_pane` is a tmux pane ID (e.g., `%0`). Returns the new pane's ID.
912pub fn split_window_horizontal(target_pane: &str, size_pct: u32) -> Result<String> {
913    let size = format!("{size_pct}%");
914    let output = Command::new("tmux")
915        .args([
916            "split-window",
917            "-h",
918            "-t",
919            target_pane,
920            "-l",
921            &size,
922            "-P",
923            "-F",
924            "#{pane_id}",
925        ])
926        .output()
927        .with_context(|| format!("failed to split pane '{target_pane}' horizontally"))?;
928
929    if !output.status.success() {
930        let stderr = String::from_utf8_lossy(&output.stderr);
931        bail!("tmux split-window -h failed: {stderr}");
932    }
933
934    let pane_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
935    debug!(target_pane, pane_id = %pane_id, size_pct, "horizontal split created");
936    Ok(pane_id)
937}
938
939/// Split a specific pane vertically (creates a new pane below).
940///
941/// Returns the new pane's ID.
942pub fn split_window_vertical_in_pane(
943    _session: &str,
944    pane_id: &str,
945    size_pct: u32,
946) -> Result<String> {
947    // Pane IDs (%N) are globally unique in tmux — use them directly as targets
948    let size = format!("{size_pct}%");
949    let output = Command::new("tmux")
950        .args([
951            "split-window",
952            "-v",
953            "-t",
954            pane_id,
955            "-l",
956            &size,
957            "-P",
958            "-F",
959            "#{pane_id}",
960        ])
961        .output()
962        .with_context(|| format!("failed to split pane '{pane_id}' vertically"))?;
963
964    if !output.status.success() {
965        let stderr = String::from_utf8_lossy(&output.stderr);
966        bail!("tmux split-window -v failed for pane '{pane_id}': {stderr}");
967    }
968
969    let new_pane = String::from_utf8_lossy(&output.stdout).trim().to_string();
970    debug!(pane_id = %new_pane, parent = pane_id, size_pct, "vertical split created");
971    Ok(new_pane)
972}
973
974/// Evenly spread a pane and any adjacent panes in its layout cell.
975pub fn select_layout_even(target_pane: &str) -> Result<()> {
976    let output = Command::new("tmux")
977        .args(["select-layout", "-E", "-t", target_pane])
978        .output()
979        .with_context(|| format!("failed to even layout for pane '{target_pane}'"))?;
980
981    if !output.status.success() {
982        let stderr = String::from_utf8_lossy(&output.stderr);
983        bail!("tmux select-layout -E failed: {stderr}");
984    }
985
986    Ok(())
987}
988
989/// Load text into a tmux paste buffer.
990/// Named buffer used by batty to avoid clobbering the user's paste buffer.
991const BATTY_BUFFER_NAME: &str = "batty-inject";
992
993/// Load text into a named tmux paste buffer.
994///
995/// Uses a dedicated buffer name so we never clobber the user's default
996/// paste buffer (which is what Ctrl-] / middle-click uses).
997pub fn load_buffer(content: &str) -> Result<()> {
998    let tmp = std::env::temp_dir().join(format!("batty-buf-{}", std::process::id()));
999    std::fs::write(&tmp, content)
1000        .with_context(|| format!("failed to write buffer file {}", tmp.display()))?;
1001
1002    let output = Command::new("tmux")
1003        .args([
1004            "load-buffer",
1005            "-b",
1006            BATTY_BUFFER_NAME,
1007            &tmp.to_string_lossy(),
1008        ])
1009        .output()
1010        .context("failed to run tmux load-buffer")?;
1011
1012    let _ = std::fs::remove_file(&tmp);
1013
1014    if !output.status.success() {
1015        let stderr = String::from_utf8_lossy(&output.stderr);
1016        bail!("tmux load-buffer failed: {stderr}");
1017    }
1018
1019    Ok(())
1020}
1021
1022/// Paste the named batty buffer into a target pane and delete the buffer.
1023///
1024/// The `-d` flag deletes the buffer after pasting so it doesn't linger.
1025/// The `-b` flag selects the batty-specific buffer (never the user's default).
1026pub fn paste_buffer(target: &str) -> Result<()> {
1027    let output = Command::new("tmux")
1028        .args(["paste-buffer", "-d", "-b", BATTY_BUFFER_NAME, "-t", target])
1029        .output()
1030        .with_context(|| format!("failed to paste buffer into '{target}'"))?;
1031
1032    if !output.status.success() {
1033        let stderr = String::from_utf8_lossy(&output.stderr);
1034        bail!("tmux paste-buffer failed: {stderr}");
1035    }
1036
1037    Ok(())
1038}
1039
1040/// Kill a specific tmux pane.
1041pub fn kill_pane(target: &str) -> Result<()> {
1042    let output = Command::new("tmux")
1043        .args(["kill-pane", "-t", target])
1044        .output()
1045        .with_context(|| format!("failed to kill pane '{target}'"))?;
1046
1047    if !output.status.success() {
1048        let stderr = String::from_utf8_lossy(&output.stderr);
1049        // Don't error if already dead
1050        if !stderr.contains("not found") {
1051            bail!("tmux kill-pane failed: {stderr}");
1052        }
1053    }
1054
1055    Ok(())
1056}
1057
1058/// Respawn a dead pane with a new command.
1059pub fn respawn_pane(target: &str, command: &str) -> Result<()> {
1060    let output = Command::new("tmux")
1061        .args(["respawn-pane", "-t", target, "-k", command])
1062        .output()
1063        .with_context(|| format!("failed to respawn pane '{target}'"))?;
1064
1065    if !output.status.success() {
1066        let stderr = String::from_utf8_lossy(&output.stderr);
1067        bail!("tmux respawn-pane failed: {stderr}");
1068    }
1069
1070    Ok(())
1071}
1072
1073/// Helper: run `tmux set -t <session> <option> <value>`.
1074pub fn tmux_set(session: &str, option: &str, value: &str) -> Result<()> {
1075    let output = Command::new("tmux")
1076        .args(["set", "-t", session, option, value])
1077        .output()
1078        .with_context(|| format!("failed to set tmux option '{option}' for session '{session}'"))?;
1079
1080    if !output.status.success() {
1081        let stderr = String::from_utf8_lossy(&output.stderr);
1082        bail!("tmux set {option} failed: {stderr}");
1083    }
1084
1085    Ok(())
1086}
1087
1088/// # Test categories
1089///
1090/// Tests in this module are split into **unit** and **integration** categories:
1091///
1092/// - **Unit tests** — pure logic (parsing, string manipulation). Run with `cargo test`.
1093/// - **Integration tests** — require a running tmux server. Gated behind the `integration`
1094///   Cargo feature: `cargo test --features integration`. These tests are marked with
1095///   `#[cfg_attr(not(feature = "integration"), ignore)]` and `#[serial]`.
1096#[cfg(test)]
1097mod tests {
1098    use super::*;
1099    use serial_test::serial;
1100
1101    #[test]
1102    fn session_name_convention() {
1103        assert_eq!(session_name("phase-1"), "batty-phase-1");
1104        assert_eq!(session_name("phase-2"), "batty-phase-2");
1105        assert_eq!(session_name("phase-2.5"), "batty-phase-2-5");
1106        assert_eq!(session_name("phase 3"), "batty-phase-3");
1107    }
1108
1109    #[test]
1110    #[serial]
1111    #[cfg_attr(not(feature = "integration"), ignore)]
1112    fn check_tmux_finds_binary() {
1113        let version = check_tmux().unwrap();
1114        assert!(
1115            version.starts_with("tmux"),
1116            "expected tmux version, got: {version}"
1117        );
1118    }
1119
1120    #[test]
1121    fn parse_tmux_version_supports_minor_suffixes() {
1122        assert_eq!(parse_tmux_version("tmux 3.4"), Some((3, 4)));
1123        assert_eq!(parse_tmux_version("tmux 3.3a"), Some((3, 3)));
1124        assert_eq!(parse_tmux_version("tmux 2.9"), Some((2, 9)));
1125        assert_eq!(parse_tmux_version("tmux unknown"), None);
1126    }
1127
1128    #[test]
1129    fn capabilities_known_good_matrix() {
1130        let good = TmuxCapabilities {
1131            version_raw: "tmux 3.2".to_string(),
1132            version: Some((3, 2)),
1133            pipe_pane: true,
1134            pipe_pane_only_if_missing: true,
1135            status_style: true,
1136            split_mode: SplitMode::Lines,
1137        };
1138        assert!(good.known_good());
1139
1140        let fallback = TmuxCapabilities {
1141            version_raw: "tmux 3.1".to_string(),
1142            version: Some((3, 1)),
1143            pipe_pane: true,
1144            pipe_pane_only_if_missing: false,
1145            status_style: true,
1146            split_mode: SplitMode::Percent,
1147        };
1148        assert!(!fallback.known_good());
1149    }
1150
1151    #[test]
1152    #[serial]
1153    #[cfg_attr(not(feature = "integration"), ignore)]
1154    fn capability_probe_reports_pipe_pane() {
1155        let caps = probe_capabilities().unwrap();
1156        assert!(
1157            caps.pipe_pane,
1158            "pipe-pane should be available for batty runtime"
1159        );
1160    }
1161
1162    #[test]
1163    #[serial]
1164    #[cfg_attr(not(feature = "integration"), ignore)]
1165    fn nonexistent_session_does_not_exist() {
1166        assert!(!session_exists("batty-test-nonexistent-12345"));
1167    }
1168
1169    #[test]
1170    #[serial]
1171    fn create_and_kill_session() {
1172        let session = "batty-test-lifecycle";
1173        // Clean up in case a previous test left it
1174        let _ = kill_session(session);
1175
1176        // Create
1177        create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1178        assert!(session_exists(session));
1179
1180        // Kill
1181        kill_session(session).unwrap();
1182        assert!(!session_exists(session));
1183    }
1184
1185    #[test]
1186    #[serial]
1187    #[cfg_attr(not(feature = "integration"), ignore)]
1188    fn session_path_returns_working_directory() {
1189        let session = "batty-test-session-path";
1190        let _ = kill_session(session);
1191
1192        create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1193        let path = session_path(session).unwrap();
1194        assert_eq!(path, "/tmp");
1195
1196        kill_session(session).unwrap();
1197    }
1198
1199    #[test]
1200    #[serial]
1201    #[cfg_attr(not(feature = "integration"), ignore)]
1202    fn pane_current_path_returns_working_directory() {
1203        let session = "batty-test-pane-current-path";
1204        let _ = kill_session(session);
1205
1206        create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1207        let pane = pane_id(session).unwrap();
1208        let path = pane_current_path(&pane).unwrap();
1209        assert_eq!(
1210            std::fs::canonicalize(&path).unwrap(),
1211            std::fs::canonicalize("/tmp").unwrap()
1212        );
1213
1214        kill_session(session).unwrap();
1215    }
1216
1217    #[test]
1218    #[serial]
1219    #[cfg_attr(not(feature = "integration"), ignore)]
1220    fn duplicate_session_is_error() {
1221        let session = "batty-test-dup";
1222        let _ = kill_session(session);
1223
1224        create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1225
1226        let result = create_session(session, "sleep", &["10".to_string()], "/tmp");
1227        assert!(result.is_err());
1228        assert!(result.unwrap_err().to_string().contains("already exists"));
1229
1230        kill_session(session).unwrap();
1231    }
1232
1233    #[test]
1234    #[serial]
1235    #[cfg_attr(not(feature = "integration"), ignore)]
1236    fn create_window_adds_named_window_to_existing_session() {
1237        let session = "batty-test-window-create";
1238        let _ = kill_session(session);
1239
1240        create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1241        rename_window(&format!("{session}:0"), "agent-1").unwrap();
1242        create_window(session, "agent-2", "sleep", &["10".to_string()], "/tmp").unwrap();
1243
1244        let names = list_window_names(session).unwrap();
1245        assert!(names.contains(&"agent-1".to_string()));
1246        assert!(names.contains(&"agent-2".to_string()));
1247
1248        select_window(&format!("{session}:agent-1")).unwrap();
1249        kill_session(session).unwrap();
1250    }
1251
1252    #[test]
1253    #[serial]
1254    #[cfg_attr(not(feature = "integration"), ignore)]
1255    fn create_window_unsets_claudecode_from_session_environment() {
1256        let session = "batty-test-window-unset-claudecode";
1257        let _ = kill_session(session);
1258
1259        create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1260
1261        let output = Command::new("tmux")
1262            .args(["set-environment", "-t", session, "CLAUDECODE", "1"])
1263            .output()
1264            .unwrap();
1265        assert!(
1266            output.status.success(),
1267            "failed to set CLAUDECODE in tmux session: {}",
1268            String::from_utf8_lossy(&output.stderr)
1269        );
1270
1271        create_window(
1272            session,
1273            "env-check",
1274            "bash",
1275            &[
1276                "-lc".to_string(),
1277                "printf '%s' \"${CLAUDECODE:-unset}\"; sleep 1".to_string(),
1278            ],
1279            "/tmp",
1280        )
1281        .unwrap();
1282
1283        std::thread::sleep(std::time::Duration::from_millis(300));
1284
1285        let content = capture_pane(&format!("{session}:env-check")).unwrap();
1286        assert!(
1287            content.contains("unset"),
1288            "expected CLAUDECODE to be unset in new window, got: {content:?}"
1289        );
1290
1291        kill_session(session).unwrap();
1292    }
1293
1294    #[test]
1295    #[serial]
1296    #[cfg_attr(not(feature = "integration"), ignore)]
1297    fn create_session_enables_mouse_mode() {
1298        let session = "batty-test-mouse";
1299        let _ = kill_session(session);
1300
1301        create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1302
1303        let output = Command::new("tmux")
1304            .args(["show-options", "-t", session, "-v", "mouse"])
1305            .output()
1306            .unwrap();
1307        assert!(output.status.success());
1308        let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
1309        assert_eq!(value, "on", "expected tmux mouse mode to be enabled");
1310
1311        kill_session(session).unwrap();
1312    }
1313
1314    #[test]
1315    #[serial]
1316    #[cfg_attr(not(feature = "integration"), ignore)]
1317    fn send_keys_to_session() {
1318        let session = "batty-test-sendkeys";
1319        let _ = kill_session(session);
1320
1321        // Create a session running cat (waits for input)
1322        create_session(session, "cat", &[], "/tmp").unwrap();
1323
1324        // Give it a moment to start
1325        std::thread::sleep(std::time::Duration::from_millis(200));
1326
1327        // Send keys should succeed
1328        send_keys(session, "hello", true).unwrap();
1329
1330        // Clean up
1331        kill_session(session).unwrap();
1332    }
1333
1334    #[test]
1335    #[serial]
1336    #[cfg_attr(not(feature = "integration"), ignore)]
1337    fn send_keys_with_enter_submits_line() {
1338        let session = "batty-test-sendkeys-enter";
1339        let _ = kill_session(session);
1340
1341        let tmp = tempfile::tempdir().unwrap();
1342        let log_path = tmp.path().join("sendkeys.log");
1343
1344        create_session(session, "cat", &[], "/tmp").unwrap();
1345        setup_pipe_pane(session, &log_path).unwrap();
1346        std::thread::sleep(std::time::Duration::from_millis(200));
1347
1348        send_keys(session, "supervisor ping", true).unwrap();
1349        std::thread::sleep(std::time::Duration::from_millis(300));
1350
1351        let content = std::fs::read_to_string(&log_path).unwrap_or_default();
1352        assert!(
1353            content.contains("supervisor ping"),
1354            "expected injected text in pane log, got: {content:?}"
1355        );
1356        assert!(
1357            content.contains("supervisor ping\r\n") || content.contains("supervisor ping\n"),
1358            "expected submitted line ending in pane log, got: {content:?}"
1359        );
1360
1361        kill_session(session).unwrap();
1362    }
1363
1364    #[test]
1365    #[serial]
1366    #[cfg_attr(not(feature = "integration"), ignore)]
1367    fn send_keys_enter_only_submits_prompt() {
1368        let session = "batty-test-sendkeys-enter-only";
1369        let _ = kill_session(session);
1370
1371        let tmp = tempfile::tempdir().unwrap();
1372        let log_path = tmp.path().join("sendkeys-enter-only.log");
1373
1374        create_session(session, "cat", &[], "/tmp").unwrap();
1375        let pane = pane_id(session).unwrap();
1376        setup_pipe_pane(&pane, &log_path).unwrap();
1377        std::thread::sleep(std::time::Duration::from_millis(200));
1378
1379        send_keys(&pane, "", true).unwrap();
1380        std::thread::sleep(std::time::Duration::from_millis(300));
1381
1382        let content = std::fs::read_to_string(&log_path).unwrap_or_default();
1383        assert!(
1384            content.contains("\r\n") || content.contains('\n'),
1385            "expected submitted empty line in pane log, got: {content:?}"
1386        );
1387
1388        kill_session(session).unwrap();
1389    }
1390
1391    #[test]
1392    #[serial]
1393    #[cfg_attr(not(feature = "integration"), ignore)]
1394    fn pipe_pane_captures_output() {
1395        let session = "batty-test-pipe";
1396        let _ = kill_session(session);
1397
1398        let tmp = tempfile::tempdir().unwrap();
1399        let log_path = tmp.path().join("pty-output.log");
1400
1401        // Create session with bash, set up pipe-pane FIRST, then trigger output
1402        create_session(session, "bash", &[], "/tmp").unwrap();
1403        let pane = pane_id(session).unwrap();
1404
1405        // Set up pipe-pane before generating output
1406        setup_pipe_pane(&pane, &log_path).unwrap();
1407
1408        // Small delay to ensure pipe-pane is active
1409        std::thread::sleep(std::time::Duration::from_millis(200));
1410
1411        // Now generate output that pipe-pane will capture
1412        send_keys(&pane, "echo pipe-test-output", true).unwrap();
1413
1414        // Wait with retries for the output to appear in the log
1415        let mut found = false;
1416        for _ in 0..10 {
1417            std::thread::sleep(std::time::Duration::from_millis(200));
1418            if log_path.exists() {
1419                let content = std::fs::read_to_string(&log_path).unwrap_or_default();
1420                if !content.is_empty() {
1421                    found = true;
1422                    break;
1423                }
1424            }
1425        }
1426
1427        kill_session(session).unwrap();
1428        assert!(found, "pipe-pane log should have captured output");
1429    }
1430
1431    #[test]
1432    #[serial]
1433    #[cfg_attr(not(feature = "integration"), ignore)]
1434    fn capture_pane_returns_content() {
1435        let session = "batty-test-capture";
1436        let _ = kill_session(session);
1437
1438        create_session(
1439            session,
1440            "bash",
1441            &["-c".to_string(), "echo 'capture-test'; sleep 2".to_string()],
1442            "/tmp",
1443        )
1444        .unwrap();
1445        std::thread::sleep(std::time::Duration::from_millis(500));
1446
1447        let content = capture_pane(session).unwrap();
1448        // Should have some content (at least the echo output or prompt)
1449        assert!(
1450            !content.trim().is_empty(),
1451            "capture-pane should return content"
1452        );
1453
1454        kill_session(session).unwrap();
1455    }
1456
1457    #[test]
1458    #[serial]
1459    #[cfg_attr(not(feature = "integration"), ignore)]
1460    fn capture_pane_recent_returns_content() {
1461        let session = "batty-test-capture-recent";
1462        let _ = kill_session(session);
1463
1464        create_session(
1465            session,
1466            "bash",
1467            &[
1468                "-c".to_string(),
1469                "echo 'capture-recent-test'; sleep 2".to_string(),
1470            ],
1471            "/tmp",
1472        )
1473        .unwrap();
1474        std::thread::sleep(std::time::Duration::from_millis(500));
1475
1476        let content = capture_pane_recent(session, 10).unwrap();
1477        assert!(content.contains("capture-recent-test"));
1478
1479        kill_session(session).unwrap();
1480    }
1481
1482    #[test]
1483    #[serial]
1484    #[cfg_attr(not(feature = "integration"), ignore)]
1485    fn list_panes_returns_at_least_one() {
1486        let session = "batty-test-panes";
1487        let _ = kill_session(session);
1488
1489        create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1490
1491        let panes = list_panes(session).unwrap();
1492        assert!(!panes.is_empty(), "session should have at least one pane");
1493
1494        kill_session(session).unwrap();
1495    }
1496
1497    #[test]
1498    #[serial]
1499    #[cfg_attr(not(feature = "integration"), ignore)]
1500    fn list_pane_details_includes_active_flag() {
1501        let session = "batty-test-pane-details";
1502        let _ = kill_session(session);
1503
1504        create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1505
1506        let panes = list_pane_details(session).unwrap();
1507        assert!(
1508            !panes.is_empty(),
1509            "expected at least one pane detail record"
1510        );
1511        assert!(
1512            panes.iter().any(|p| p.active),
1513            "expected one active pane, got: {panes:?}"
1514        );
1515
1516        kill_session(session).unwrap();
1517    }
1518
1519    #[test]
1520    #[serial]
1521    #[cfg_attr(not(feature = "integration"), ignore)]
1522    fn configure_supervisor_hotkeys_initializes_control_option() {
1523        let session = "batty-test-supervisor-hotkeys";
1524        let _ = kill_session(session);
1525
1526        create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1527        configure_supervisor_hotkeys(session).unwrap();
1528
1529        let output = Command::new("tmux")
1530            .args([
1531                "show-options",
1532                "-v",
1533                "-t",
1534                session,
1535                SUPERVISOR_CONTROL_OPTION,
1536            ])
1537            .output()
1538            .unwrap();
1539        assert!(output.status.success());
1540        assert!(String::from_utf8_lossy(&output.stdout).trim().is_empty());
1541
1542        kill_session(session).unwrap();
1543    }
1544
1545    #[test]
1546    #[serial]
1547    #[cfg_attr(not(feature = "integration"), ignore)]
1548    fn take_supervisor_hotkey_action_reads_and_clears() {
1549        let session = "batty-test-supervisor-action";
1550        let _ = kill_session(session);
1551
1552        create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1553        configure_supervisor_hotkeys(session).unwrap();
1554        tmux_set(session, SUPERVISOR_CONTROL_OPTION, "pause").unwrap();
1555
1556        let first = take_supervisor_hotkey_action(session).unwrap();
1557        assert_eq!(first.as_deref(), Some("pause"));
1558
1559        let second = take_supervisor_hotkey_action(session).unwrap();
1560        assert!(second.is_none(), "expected action to be cleared");
1561
1562        kill_session(session).unwrap();
1563    }
1564
1565    #[test]
1566    #[serial]
1567    #[cfg_attr(not(feature = "integration"), ignore)]
1568    fn kill_nonexistent_session_is_ok() {
1569        // Should not error — idempotent
1570        kill_session("batty-test-nonexistent-kill-99999").unwrap();
1571    }
1572
1573    #[test]
1574    #[serial]
1575    #[cfg_attr(not(feature = "integration"), ignore)]
1576    fn session_with_short_lived_process() {
1577        let session = "batty-test-shortlived";
1578        let _ = kill_session(session);
1579
1580        // echo exits immediately
1581        create_session(session, "echo", &["done".to_string()], "/tmp").unwrap();
1582
1583        // Give it a moment for the process to exit
1584        std::thread::sleep(std::time::Duration::from_millis(500));
1585
1586        // Session may or may not still exist depending on tmux remain-on-exit
1587        // Either way, kill should be safe
1588        let _ = kill_session(session);
1589    }
1590
1591    #[test]
1592    #[serial]
1593    #[cfg_attr(not(feature = "integration"), ignore)]
1594    fn test_session_guard_cleanup_on_drop() {
1595        let name = "batty-test-guard-drop";
1596        let _ = kill_session(name);
1597
1598        {
1599            let guard = TestSession::new(name);
1600            create_session(guard.name(), "sleep", &["30".to_string()], "/tmp").unwrap();
1601            assert!(session_exists(name));
1602            // guard dropped here
1603        }
1604
1605        assert!(!session_exists(name), "session should be killed on drop");
1606    }
1607
1608    #[test]
1609    #[serial]
1610    #[cfg_attr(not(feature = "integration"), ignore)]
1611    fn test_session_guard_cleanup_on_panic() {
1612        let name = "batty-test-guard-panic";
1613        let _ = kill_session(name);
1614
1615        let result = std::panic::catch_unwind(|| {
1616            let guard = TestSession::new(name);
1617            create_session(guard.name(), "sleep", &["30".to_string()], "/tmp").unwrap();
1618            assert!(session_exists(name));
1619            panic!("intentional panic to test cleanup");
1620            #[allow(unreachable_code)]
1621            drop(guard);
1622        });
1623
1624        assert!(result.is_err(), "should have panicked");
1625        // Give drop a moment to complete (panic unwind runs destructors)
1626        assert!(
1627            !session_exists(name),
1628            "session should be cleaned up even after panic"
1629        );
1630    }
1631
1632    // --- parse_tmux_version edge cases ---
1633
1634    #[test]
1635    fn parse_tmux_version_empty_string() {
1636        assert_eq!(parse_tmux_version(""), None);
1637    }
1638
1639    #[test]
1640    fn parse_tmux_version_no_prefix() {
1641        // Missing "tmux " prefix
1642        assert_eq!(parse_tmux_version("3.4"), None);
1643    }
1644
1645    #[test]
1646    fn parse_tmux_version_major_only_no_dot() {
1647        assert_eq!(parse_tmux_version("tmux 3"), None);
1648    }
1649
1650    #[test]
1651    fn parse_tmux_version_multi_digit() {
1652        assert_eq!(parse_tmux_version("tmux 10.12"), Some((10, 12)));
1653    }
1654
1655    #[test]
1656    fn parse_tmux_version_trailing_whitespace() {
1657        assert_eq!(parse_tmux_version("  tmux 3.4  "), Some((3, 4)));
1658    }
1659
1660    #[test]
1661    fn parse_tmux_version_next_suffix() {
1662        // "next-3.5" style dev builds
1663        assert_eq!(parse_tmux_version("tmux next-3.5"), None);
1664    }
1665
1666    #[test]
1667    fn parse_tmux_version_dot_no_minor() {
1668        assert_eq!(parse_tmux_version("tmux 3."), None);
1669    }
1670
1671    #[test]
1672    fn parse_tmux_version_double_suffix_letters() {
1673        assert_eq!(parse_tmux_version("tmux 3.3ab"), Some((3, 3)));
1674    }
1675
1676    // --- TmuxCapabilities edge cases ---
1677
1678    #[test]
1679    fn capabilities_known_good_version_4() {
1680        let caps = TmuxCapabilities {
1681            version_raw: "tmux 4.0".to_string(),
1682            version: Some((4, 0)),
1683            pipe_pane: true,
1684            pipe_pane_only_if_missing: true,
1685            status_style: true,
1686            split_mode: SplitMode::Lines,
1687        };
1688        assert!(caps.known_good(), "4.0 should be known good");
1689    }
1690
1691    #[test]
1692    fn capabilities_known_good_version_2_9() {
1693        let caps = TmuxCapabilities {
1694            version_raw: "tmux 2.9".to_string(),
1695            version: Some((2, 9)),
1696            pipe_pane: true,
1697            pipe_pane_only_if_missing: false,
1698            status_style: true,
1699            split_mode: SplitMode::Percent,
1700        };
1701        assert!(!caps.known_good(), "2.9 should not be known good");
1702    }
1703
1704    #[test]
1705    fn capabilities_known_good_version_3_0() {
1706        let caps = TmuxCapabilities {
1707            version_raw: "tmux 3.0".to_string(),
1708            version: Some((3, 0)),
1709            pipe_pane: true,
1710            pipe_pane_only_if_missing: false,
1711            status_style: true,
1712            split_mode: SplitMode::Percent,
1713        };
1714        assert!(!caps.known_good(), "3.0 should not be known good");
1715    }
1716
1717    #[test]
1718    fn capabilities_known_good_none_version() {
1719        let caps = TmuxCapabilities {
1720            version_raw: "tmux unknown".to_string(),
1721            version: None,
1722            pipe_pane: false,
1723            pipe_pane_only_if_missing: false,
1724            status_style: false,
1725            split_mode: SplitMode::Disabled,
1726        };
1727        assert!(!caps.known_good(), "None version should not be known good");
1728    }
1729
1730    #[test]
1731    fn capabilities_remediation_message_includes_version() {
1732        let caps = TmuxCapabilities {
1733            version_raw: "tmux 2.8".to_string(),
1734            version: Some((2, 8)),
1735            pipe_pane: false,
1736            pipe_pane_only_if_missing: false,
1737            status_style: false,
1738            split_mode: SplitMode::Disabled,
1739        };
1740        let msg = caps.remediation_message();
1741        assert!(
1742            msg.contains("tmux 2.8"),
1743            "message should include detected version"
1744        );
1745        assert!(
1746            msg.contains("pipe-pane"),
1747            "message should mention pipe-pane requirement"
1748        );
1749        assert!(msg.contains("3.2"), "message should recommend >= 3.2");
1750    }
1751
1752    // --- session_name edge cases ---
1753
1754    #[test]
1755    fn session_name_empty_input() {
1756        assert_eq!(session_name(""), "batty-");
1757    }
1758
1759    #[test]
1760    fn session_name_preserves_underscores() {
1761        assert_eq!(session_name("my_session"), "batty-my_session");
1762    }
1763
1764    #[test]
1765    fn session_name_replaces_colons_and_slashes() {
1766        assert_eq!(session_name("a:b/c"), "batty-a-b-c");
1767    }
1768
1769    #[test]
1770    fn session_name_replaces_multiple_dots() {
1771        assert_eq!(session_name("v1.2.3"), "batty-v1-2-3");
1772    }
1773
1774    // --- PaneDetails struct ---
1775
1776    #[test]
1777    fn pane_details_clone_and_eq() {
1778        let pd = PaneDetails {
1779            id: "%5".to_string(),
1780            command: "bash".to_string(),
1781            active: true,
1782            dead: false,
1783        };
1784        let cloned = pd.clone();
1785        assert_eq!(pd, cloned);
1786        assert_eq!(pd.id, "%5");
1787        assert!(pd.active);
1788        assert!(!pd.dead);
1789    }
1790
1791    #[test]
1792    fn pane_details_not_equal_different_id() {
1793        let a = PaneDetails {
1794            id: "%1".to_string(),
1795            command: "bash".to_string(),
1796            active: true,
1797            dead: false,
1798        };
1799        let b = PaneDetails {
1800            id: "%2".to_string(),
1801            command: "bash".to_string(),
1802            active: true,
1803            dead: false,
1804        };
1805        assert_ne!(a, b);
1806    }
1807
1808    // --- SplitMode ---
1809
1810    #[test]
1811    fn split_mode_debug_and_eq() {
1812        assert_eq!(SplitMode::Lines, SplitMode::Lines);
1813        assert_ne!(SplitMode::Lines, SplitMode::Percent);
1814        assert_ne!(SplitMode::Percent, SplitMode::Disabled);
1815        let copied = SplitMode::Lines;
1816        assert_eq!(format!("{:?}", copied), "Lines");
1817    }
1818
1819    // --- TestSession ---
1820
1821    #[test]
1822    fn test_session_name_accessor() {
1823        let guard = TestSession::new("batty-test-accessor");
1824        assert_eq!(guard.name(), "batty-test-accessor");
1825        // Don't actually create a tmux session — just test the struct
1826    }
1827
1828    // --- Integration tests requiring tmux ---
1829
1830    #[test]
1831    #[serial]
1832    fn pane_exists_for_valid_pane() {
1833        let session = "batty-test-pane-exists";
1834        let _guard = TestSession::new(session);
1835        create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1836
1837        let pane = pane_id(session).unwrap();
1838        assert!(pane_exists(&pane), "existing pane should be found");
1839    }
1840
1841    #[test]
1842    #[serial]
1843    fn session_exists_returns_false_after_kill() {
1844        let session = "batty-test-sess-exists-gone";
1845        let _ = kill_session(session);
1846        create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1847        assert!(session_exists(session));
1848
1849        kill_session(session).unwrap();
1850        assert!(
1851            !session_exists(session),
1852            "session should not exist after kill"
1853        );
1854    }
1855
1856    #[test]
1857    #[serial]
1858    fn pane_dead_for_running_process() {
1859        let session = "batty-test-pane-dead-alive";
1860        let _guard = TestSession::new(session);
1861        create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1862
1863        let pane = pane_id(session).unwrap();
1864        let dead = pane_dead(&pane).unwrap();
1865        assert!(!dead, "running process pane should not be dead");
1866    }
1867
1868    #[test]
1869    #[serial]
1870    fn list_sessions_with_prefix_finds_matching() {
1871        let prefix = "batty-test-prefix-match";
1872        let s1 = format!("{prefix}-aaa");
1873        let s2 = format!("{prefix}-bbb");
1874        let _g1 = TestSession::new(s1.clone());
1875        let _g2 = TestSession::new(s2.clone());
1876
1877        create_session(&s1, "sleep", &["10".to_string()], "/tmp").unwrap();
1878        create_session(&s2, "sleep", &["10".to_string()], "/tmp").unwrap();
1879
1880        let found = list_sessions_with_prefix(prefix);
1881        assert!(
1882            found.contains(&s1),
1883            "should find first session, got: {found:?}"
1884        );
1885        assert!(
1886            found.contains(&s2),
1887            "should find second session, got: {found:?}"
1888        );
1889    }
1890
1891    #[test]
1892    #[serial]
1893    fn list_sessions_with_prefix_excludes_non_matching() {
1894        let found = list_sessions_with_prefix("batty-test-zzz-nonexist-99999");
1895        assert!(found.is_empty(), "should find no sessions for bogus prefix");
1896    }
1897
1898    #[test]
1899    #[serial]
1900    fn split_window_horizontal_creates_new_pane() {
1901        let session = "batty-test-hsplit";
1902        let _guard = TestSession::new(session);
1903        create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1904
1905        let original = pane_id(session).unwrap();
1906        let new_pane = split_window_horizontal(&original, 50).unwrap();
1907        assert!(
1908            new_pane.starts_with('%'),
1909            "new pane id should start with %, got: {new_pane}"
1910        );
1911
1912        let panes = list_panes(session).unwrap();
1913        assert_eq!(panes.len(), 2, "should have 2 panes after split");
1914        assert!(panes.contains(&new_pane));
1915    }
1916
1917    #[test]
1918    #[serial]
1919    fn split_window_vertical_creates_new_pane() {
1920        let session = "batty-test-vsplit";
1921        let _guard = TestSession::new(session);
1922        create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1923
1924        let original = pane_id(session).unwrap();
1925        let new_pane = split_window_vertical_in_pane(session, &original, 50).unwrap();
1926        assert!(
1927            new_pane.starts_with('%'),
1928            "new pane id should start with %, got: {new_pane}"
1929        );
1930
1931        let panes = list_panes(session).unwrap();
1932        assert_eq!(panes.len(), 2, "should have 2 panes after split");
1933        assert!(panes.contains(&new_pane));
1934    }
1935
1936    #[test]
1937    #[serial]
1938    fn load_buffer_and_paste_buffer_injects_text() {
1939        let session = "batty-test-paste-buf";
1940        let _guard = TestSession::new(session);
1941        create_session(session, "cat", &[], "/tmp").unwrap();
1942        std::thread::sleep(std::time::Duration::from_millis(200));
1943
1944        let pane = pane_id(session).unwrap();
1945        load_buffer("hello-from-buffer").unwrap();
1946        paste_buffer(&pane).unwrap();
1947
1948        std::thread::sleep(std::time::Duration::from_millis(300));
1949        let live_pane = pane_id(session).unwrap_or(pane);
1950        let content = capture_pane(&live_pane).unwrap();
1951        assert!(
1952            content.contains("hello-from-buffer"),
1953            "paste-buffer should inject text into pane, got: {content:?}"
1954        );
1955    }
1956
1957    #[test]
1958    #[serial]
1959    fn kill_pane_removes_pane() {
1960        let session = "batty-test-kill-pane";
1961        let _guard = TestSession::new(session);
1962        create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1963
1964        let original = pane_id(session).unwrap();
1965        let new_pane = split_window_horizontal(&original, 50).unwrap();
1966        let before = list_panes(session).unwrap();
1967        assert_eq!(before.len(), 2);
1968
1969        kill_pane(&new_pane).unwrap();
1970        let after = list_panes(session).unwrap();
1971        assert_eq!(after.len(), 1, "should have 1 pane after kill");
1972        assert!(!after.contains(&new_pane));
1973    }
1974
1975    #[test]
1976    #[serial]
1977    fn kill_pane_nonexistent_returns_error() {
1978        // tmux returns "can't find pane" for nonexistent pane IDs,
1979        // which kill_pane only suppresses when it says "not found"
1980        let result = kill_pane("batty-test-no-such-session-xyz:0.0");
1981        // Either succeeds (tmux says "not found") or errors — both are valid
1982        // The key guarantee is it doesn't panic
1983        let _ = result;
1984    }
1985
1986    #[test]
1987    #[serial]
1988    fn set_mouse_disable_and_enable() {
1989        let session = "batty-test-mouse-toggle";
1990        let _guard = TestSession::new(session);
1991        create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1992
1993        // Mouse is enabled by create_session; disable it
1994        set_mouse(session, false).unwrap();
1995        let output = Command::new("tmux")
1996            .args(["show-options", "-t", session, "-v", "mouse"])
1997            .output()
1998            .unwrap();
1999        assert_eq!(
2000            String::from_utf8_lossy(&output.stdout).trim(),
2001            "off",
2002            "mouse should be disabled"
2003        );
2004
2005        // Re-enable
2006        set_mouse(session, true).unwrap();
2007        let output = Command::new("tmux")
2008            .args(["show-options", "-t", session, "-v", "mouse"])
2009            .output()
2010            .unwrap();
2011        assert_eq!(
2012            String::from_utf8_lossy(&output.stdout).trim(),
2013            "on",
2014            "mouse should be re-enabled"
2015        );
2016    }
2017
2018    #[test]
2019    #[serial]
2020    fn tmux_set_custom_option() {
2021        let session = "batty-test-tmux-set";
2022        let _guard = TestSession::new(session);
2023        create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
2024
2025        tmux_set(session, "@batty_test_opt", "test-value").unwrap();
2026
2027        let output = Command::new("tmux")
2028            .args(["show-options", "-v", "-t", session, "@batty_test_opt"])
2029            .output()
2030            .unwrap();
2031        assert!(output.status.success());
2032        assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "test-value");
2033    }
2034
2035    #[test]
2036    #[serial]
2037    fn create_window_fails_for_missing_session() {
2038        let result = create_window(
2039            "batty-test-nonexistent-session-99999",
2040            "test-win",
2041            "sleep",
2042            &["1".to_string()],
2043            "/tmp",
2044        );
2045        assert!(result.is_err(), "should fail for nonexistent session");
2046        assert!(
2047            result.unwrap_err().to_string().contains("not found"),
2048            "error should mention session not found"
2049        );
2050    }
2051
2052    #[test]
2053    #[serial]
2054    fn setup_pipe_pane_if_missing_works() {
2055        let session = "batty-test-pipe-if-missing";
2056        let _guard = TestSession::new(session);
2057        let tmp = tempfile::tempdir().unwrap();
2058        let log_path = tmp.path().join("pipe-if-missing.log");
2059
2060        create_session(
2061            session,
2062            "bash",
2063            &["-c".to_string(), "sleep 10".to_string()],
2064            "/tmp",
2065        )
2066        .unwrap();
2067
2068        // Use session target instead of pane ID for reliability
2069        // First call should set up pipe-pane
2070        setup_pipe_pane_if_missing(session, &log_path).unwrap();
2071
2072        // Second call should be a no-op (not error)
2073        setup_pipe_pane_if_missing(session, &log_path).unwrap();
2074    }
2075
2076    #[test]
2077    #[serial]
2078    fn select_layout_even_after_splits() {
2079        let session = "batty-test-layout-even";
2080        let _guard = TestSession::new(session);
2081        create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
2082
2083        let original = pane_id(session).unwrap();
2084        let _p2 = split_window_horizontal(&original, 50).unwrap();
2085
2086        // Should not error
2087        select_layout_even(&original).unwrap();
2088    }
2089
2090    #[test]
2091    #[serial]
2092    fn rename_window_changes_name() {
2093        let session = "batty-test-rename-win";
2094        let _guard = TestSession::new(session);
2095        create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
2096
2097        rename_window(&format!("{session}:0"), "custom-name").unwrap();
2098        let names = list_window_names(session).unwrap();
2099        assert!(
2100            names.contains(&"custom-name".to_string()),
2101            "window should be renamed, got: {names:?}"
2102        );
2103    }
2104
2105    #[test]
2106    #[serial]
2107    fn select_window_switches_active() {
2108        let session = "batty-test-select-win";
2109        let _guard = TestSession::new(session);
2110        create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
2111        create_window(session, "second", "sleep", &["10".to_string()], "/tmp").unwrap();
2112
2113        // Select the second window — should not error
2114        select_window(&format!("{session}:second")).unwrap();
2115    }
2116
2117    #[test]
2118    #[serial]
2119    fn capture_pane_recent_zero_lines_returns_full() {
2120        let session = "batty-test-capture-zero";
2121        let _guard = TestSession::new(session);
2122        create_session(
2123            session,
2124            "bash",
2125            &[
2126                "-c".to_string(),
2127                "echo 'zero-lines-test'; sleep 2".to_string(),
2128            ],
2129            "/tmp",
2130        )
2131        .unwrap();
2132        std::thread::sleep(std::time::Duration::from_millis(500));
2133
2134        // lines=0 means no -S flag, should return full pane content
2135        let content = capture_pane_recent(session, 0).unwrap();
2136        assert!(
2137            content.contains("zero-lines-test"),
2138            "should capture full content with lines=0, got: {content:?}"
2139        );
2140    }
2141
2142    #[test]
2143    #[serial]
2144    fn list_pane_details_shows_command_info() {
2145        let session = "batty-test-pane-details-cmd";
2146        let _guard = TestSession::new(session);
2147        create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
2148
2149        let details = list_pane_details(session).unwrap();
2150        assert_eq!(details.len(), 1);
2151        assert!(details[0].id.starts_with('%'));
2152        assert!(
2153            details[0].command == "sleep" || !details[0].command.is_empty(),
2154            "command should be reported"
2155        );
2156        assert!(!details[0].dead, "sleep pane should not be dead");
2157    }
2158
2159    #[test]
2160    #[serial]
2161    fn pane_id_returns_percent_prefixed() {
2162        let session = "batty-test-paneid-fmt";
2163        let _guard = TestSession::new(session);
2164        create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
2165
2166        let id = pane_id(session).unwrap();
2167        assert!(
2168            id.starts_with('%'),
2169            "pane id should start with %, got: {id}"
2170        );
2171    }
2172
2173    #[test]
2174    #[serial]
2175    fn respawn_pane_restarts_running_pane() {
2176        let session = "batty-test-respawn";
2177        let _guard = TestSession::new(session);
2178        // Start with a long-running command so session stays alive
2179        create_session(session, "sleep", &["30".to_string()], "/tmp").unwrap();
2180
2181        let pane = pane_id(session).unwrap();
2182        // Respawn with -k kills the running process and starts a new one
2183        respawn_pane(&pane, "sleep 10").unwrap();
2184        std::thread::sleep(std::time::Duration::from_millis(300));
2185
2186        // Pane should still exist and not be dead
2187        assert!(pane_exists(&pane), "respawned pane should exist");
2188    }
2189}