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