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