#![allow(dead_code)]
use std::path::Path;
use std::process::{Command, Output, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail};
use tracing::{debug, info, warn};
use crate::team::errors::TmuxError;
static PROBE_COUNTER: AtomicU64 = AtomicU64::new(1);
const SUPERVISOR_CONTROL_OPTION: &str = "@batty_supervisor_control";
pub const SUPERVISOR_PAUSE_HOTKEY: &str = "C-b P";
pub const SUPERVISOR_RESUME_HOTKEY: &str = "C-b R";
const SEND_KEYS_SUBMIT_DELAY_MS: u64 = 100;
const TMUX_COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
fn tmux_command_timeout() -> Duration {
#[cfg(test)]
if let Ok(value) = std::env::var("BATTY_TEST_TMUX_COMMAND_TIMEOUT_MS") {
if let Ok(ms) = value.parse::<u64>() {
return Duration::from_millis(ms);
}
}
TMUX_COMMAND_TIMEOUT
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SplitMode {
Lines,
Percent,
Disabled,
}
#[derive(Debug, Clone)]
pub struct TmuxCapabilities {
pub version_raw: String,
pub version: Option<(u32, u32)>,
pub pipe_pane: bool,
pub pipe_pane_only_if_missing: bool,
pub status_style: bool,
pub split_mode: SplitMode,
}
impl TmuxCapabilities {
pub fn known_good(&self) -> bool {
matches!(self.version, Some((major, minor)) if major > 3 || (major == 3 && minor >= 2))
}
pub fn remediation_message(&self) -> String {
format!(
"tmux capability check failed (detected '{}'). Batty requires working `pipe-pane` support. \
Install or upgrade tmux (recommended >= 3.2) and re-run `batty start`.",
self.version_raw
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PaneDetails {
pub id: String,
pub command: String,
pub active: bool,
pub dead: bool,
}
fn check_tmux_with_program(program: &str) -> Result<String> {
let output = Command::new(program).arg("-V").output().map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
TmuxError::NotInstalled
} else {
TmuxError::exec("tmux -V", error)
}
})?;
if !output.status.success() {
return Err(TmuxError::command_failed(
"tmux -V",
None,
&String::from_utf8_lossy(&output.stderr),
)
.into());
}
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
debug!(version = %version, "tmux found");
Ok(version)
}
pub fn check_tmux() -> Result<String> {
check_tmux_with_program("tmux")
}
pub fn tmux_available() -> bool {
check_tmux().is_ok()
}
fn parse_tmux_version(version_raw: &str) -> Option<(u32, u32)> {
let raw = version_raw.trim();
let ver = raw.strip_prefix("tmux ")?;
let mut chars = ver.chars().peekable();
let mut major = String::new();
while let Some(c) = chars.peek() {
if c.is_ascii_digit() {
major.push(*c);
chars.next();
} else {
break;
}
}
if major.is_empty() {
return None;
}
if chars.next()? != '.' {
return None;
}
let mut minor = String::new();
while let Some(c) = chars.peek() {
if c.is_ascii_digit() {
minor.push(*c);
chars.next();
} else {
break;
}
}
if minor.is_empty() {
return None;
}
Some((major.parse().ok()?, minor.parse().ok()?))
}
fn run_tmux<I, S>(args: I) -> Result<Output>
where
I: IntoIterator<Item = S>,
S: AsRef<std::ffi::OsStr>,
{
run_tmux_with_timeout(args, "tmux", None)
}
pub(crate) fn run_tmux_with_timeout<I, S>(
args: I,
command_label: &'static str,
target: Option<&str>,
) -> Result<Output>
where
I: IntoIterator<Item = S>,
S: AsRef<std::ffi::OsStr>,
{
let mut command = Command::new("tmux");
command.args(args);
command_output_with_timeout(command, command_label, target, tmux_command_timeout())
}
fn command_output_with_timeout(
mut command: Command,
command_label: &'static str,
target: Option<&str>,
timeout: Duration,
) -> Result<Output> {
command.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = command
.spawn()
.map_err(|error| TmuxError::exec(command_label, error))?;
let started_at = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => {
let elapsed = started_at.elapsed();
let output = child
.wait_with_output()
.map_err(|error| TmuxError::exec(command_label, error))?;
debug!(
command = command_label,
target,
elapsed_ms = elapsed.as_millis(),
status = ?output.status.code(),
"tmux command completed"
);
return Ok(output);
}
Ok(None) if started_at.elapsed() >= timeout => {
let elapsed = started_at.elapsed();
warn!(
command = command_label,
target,
timeout_ms = timeout.as_millis(),
elapsed_ms = elapsed.as_millis(),
pid = child.id(),
"tmux command timed out; killing subprocess"
);
let _ = child.kill();
let _ = child.wait();
return Err(TmuxError::command_failed(
command_label,
target,
&format!(
"timed out after {}ms (elapsed {}ms)",
timeout.as_millis(),
elapsed.as_millis()
),
)
.into());
}
Ok(None) => std::thread::sleep(Duration::from_millis(10)),
Err(error) => return Err(TmuxError::exec(command_label, error).into()),
}
}
}
pub fn probe_capabilities() -> Result<TmuxCapabilities> {
let version_raw = check_tmux()?;
let version = parse_tmux_version(&version_raw);
let probe_id = PROBE_COUNTER.fetch_add(1, Ordering::Relaxed);
let session = format!("batty-cap-probe-{}-{probe_id}", std::process::id());
let _ = kill_session(&session);
create_session(&session, "sleep", &["20".to_string()], "/tmp")
.with_context(|| format!("failed to create tmux probe session '{session}'"))?;
let pane = pane_id(&session)?;
let cleanup = || {
let _ = kill_session(&session);
};
let pipe_cmd = "cat >/dev/null";
let pipe_pane = match run_tmux(["pipe-pane", "-t", pane.as_str(), pipe_cmd]) {
Ok(out) => out.status.success(),
Err(_) => false,
};
let pipe_pane_only_if_missing =
match run_tmux(["pipe-pane", "-o", "-t", pane.as_str(), pipe_cmd]) {
Ok(out) => out.status.success(),
Err(_) => false,
};
let _ = run_tmux(["pipe-pane", "-t", pane.as_str()]);
let status_style = match run_tmux([
"set",
"-t",
session.as_str(),
"status-style",
"bg=colour235,fg=colour136",
]) {
Ok(out) => out.status.success(),
Err(_) => false,
};
let split_lines = match run_tmux([
"split-window",
"-v",
"-l",
"3",
"-t",
session.as_str(),
"sleep",
"1",
]) {
Ok(out) => out.status.success(),
Err(_) => false,
};
let split_percent = if split_lines {
false
} else {
match run_tmux([
"split-window",
"-v",
"-p",
"20",
"-t",
session.as_str(),
"sleep",
"1",
]) {
Ok(out) => out.status.success(),
Err(_) => false,
}
};
cleanup();
let split_mode = if split_lines {
SplitMode::Lines
} else if split_percent {
SplitMode::Percent
} else {
SplitMode::Disabled
};
Ok(TmuxCapabilities {
version_raw,
version,
pipe_pane,
pipe_pane_only_if_missing,
status_style,
split_mode,
})
}
pub fn session_name(phase: &str) -> String {
let sanitized: String = phase
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect();
format!("batty-{sanitized}")
}
pub fn session_exists(session: &str) -> bool {
run_tmux_with_timeout(["has-session", "-t", session], "has-session", Some(session))
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn server_running() -> bool {
run_tmux_with_timeout(["list-sessions"], "list-sessions", None)
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn pane_exists(target: &str) -> bool {
run_tmux_with_timeout(
["display-message", "-p", "-t", target, "#{pane_id}"],
"display-message #{pane_id}",
Some(target),
)
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn pane_dead(target: &str) -> Result<bool> {
let output = run_tmux_with_timeout(
["display-message", "-p", "-t", target, "#{pane_dead}"],
"display-message #{pane_dead}",
Some(target),
)
.with_context(|| format!("failed to query pane_dead for target '{target}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(TmuxError::command_failed(
"display-message #{pane_dead}",
Some(target),
&stderr,
)
.into());
}
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(value == "1")
}
pub fn pane_pipe_enabled(target: &str) -> Result<bool> {
let output = run_tmux_with_timeout(
["display-message", "-p", "-t", target, "#{pane_pipe}"],
"display-message #{pane_pipe}",
Some(target),
)
.with_context(|| format!("failed to query pane_pipe for target '{target}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("tmux display-message pane_pipe failed: {stderr}");
}
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(value == "1")
}
pub fn pane_id(target: &str) -> Result<String> {
let output = run_tmux_with_timeout(
["display-message", "-p", "-t", target, "#{pane_id}"],
"display-message #{pane_id}",
Some(target),
)
.with_context(|| format!("failed to resolve pane id for target '{target}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(
TmuxError::command_failed("display-message #{pane_id}", Some(target), &stderr).into(),
);
}
let pane = String::from_utf8_lossy(&output.stdout).trim().to_string();
if pane.is_empty() {
return Err(TmuxError::EmptyPaneId {
target: target.to_string(),
}
.into());
}
Ok(pane)
}
pub fn pane_dimensions(target: &str) -> Result<(u16, u16)> {
let output = run_tmux_with_timeout(
[
"display-message",
"-p",
"-t",
target,
"#{pane_width} #{pane_height}",
],
"display-message #{pane_width} #{pane_height}",
Some(target),
)
.with_context(|| format!("failed to query pane dimensions for target '{target}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(TmuxError::command_failed(
"display-message #{pane_width} #{pane_height}",
Some(target),
&stderr,
)
.into());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut parts = stdout.split_whitespace();
let width = parts
.next()
.context("tmux pane width missing")?
.parse()
.context("invalid tmux pane width")?;
let height = parts
.next()
.context("tmux pane height missing")?
.parse()
.context("invalid tmux pane height")?;
Ok((width, height))
}
pub fn pane_current_path(target: &str) -> Result<String> {
let output = run_tmux_with_timeout(
[
"display-message",
"-p",
"-t",
target,
"#{pane_current_path}",
],
"display-message #{pane_current_path}",
Some(target),
)
.with_context(|| format!("failed to resolve pane current path for target '{target}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("tmux display-message pane_current_path failed: {stderr}");
}
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if path.is_empty() {
bail!("tmux returned empty pane current path for target '{target}'");
}
Ok(path)
}
pub fn session_path(session: &str) -> Result<String> {
let output = run_tmux_with_timeout(
["display-message", "-p", "-t", session, "#{session_path}"],
"display-message #{session_path}",
Some(session),
)
.with_context(|| format!("failed to resolve session path for '{session}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("tmux display-message session_path failed: {stderr}");
}
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if path.is_empty() {
bail!("tmux returned empty session path for '{session}'");
}
Ok(path)
}
pub fn create_session(session: &str, program: &str, args: &[String], work_dir: &str) -> Result<()> {
if session_exists(session) {
return Err(TmuxError::SessionExists {
session: session.to_string(),
}
.into());
}
let mut cmd = Command::new("tmux");
cmd.args(["new-session", "-d", "-s", session, "-c", work_dir]);
cmd.args(["-x", "220", "-y", "50"]);
cmd.args(["env", "-u", "CLAUDECODE"]);
cmd.arg(program);
for arg in args {
cmd.arg(arg);
}
let output =
command_output_with_timeout(cmd, "new-session", Some(session), TMUX_COMMAND_TIMEOUT)
.with_context(|| format!("failed to create tmux session '{session}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(TmuxError::command_failed("new-session", Some(session), &stderr).into());
}
if let Err(e) = set_mouse(session, true) {
warn!(
session = session,
error = %e,
"failed to enable tmux mouse mode"
);
}
info!(session = session, "tmux session created");
Ok(())
}
pub fn create_window(
session: &str,
window_name: &str,
program: &str,
args: &[String],
work_dir: &str,
) -> Result<()> {
if !session_exists(session) {
bail!("tmux session '{session}' not found");
}
let mut cmd = Command::new("tmux");
cmd.args([
"new-window",
"-d",
"-t",
session,
"-n",
window_name,
"-c",
work_dir,
]);
cmd.args(["env", "-u", "CLAUDECODE"]);
cmd.arg(program);
for arg in args {
cmd.arg(arg);
}
let output =
command_output_with_timeout(cmd, "new-window", Some(window_name), TMUX_COMMAND_TIMEOUT)
.with_context(|| format!("failed to create tmux window '{window_name}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("tmux new-window failed: {stderr}");
}
Ok(())
}
pub fn rename_window(target: &str, new_name: &str) -> Result<()> {
let output = run_tmux_with_timeout(
["rename-window", "-t", target, new_name],
"rename-window",
Some(target),
)
.with_context(|| format!("failed to rename tmux window target '{target}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("tmux rename-window failed: {stderr}");
}
Ok(())
}
pub fn select_window(target: &str) -> Result<()> {
let output = run_tmux_with_timeout(
["select-window", "-t", target],
"select-window",
Some(target),
)
.with_context(|| format!("failed to select tmux window '{target}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("tmux select-window failed: {stderr}");
}
Ok(())
}
pub fn setup_pipe_pane(target: &str, log_path: &Path) -> Result<()> {
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create log directory: {}", parent.display()))?;
}
let pipe_cmd = format!("cat >> {}", log_path.display());
let output = run_tmux_with_timeout(
["pipe-pane", "-t", target, &pipe_cmd],
"pipe-pane",
Some(target),
)
.with_context(|| format!("failed to set up pipe-pane for target '{target}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("tmux pipe-pane failed: {stderr}");
}
info!(target = target, log = %log_path.display(), "pipe-pane configured");
Ok(())
}
pub fn setup_pipe_pane_if_missing(target: &str, log_path: &Path) -> Result<()> {
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create log directory: {}", parent.display()))?;
}
let pipe_cmd = format!("cat >> {}", log_path.display());
let output = run_tmux_with_timeout(
["pipe-pane", "-o", "-t", target, &pipe_cmd],
"pipe-pane -o",
Some(target),
)
.with_context(|| format!("failed to set up pipe-pane (-o) for target '{target}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("tmux pipe-pane -o failed: {stderr}");
}
info!(
target = target,
log = %log_path.display(),
"pipe-pane ensured (only-if-missing)"
);
Ok(())
}
pub fn attach(session: &str) -> Result<()> {
if !session_exists(session) {
bail!(
"tmux session '{session}' not found — is batty running? \
Start with `batty start` first"
);
}
let inside_tmux = std::env::var("TMUX").is_ok();
let (cmd, args) = if inside_tmux {
("switch-client", vec!["-t", session])
} else {
("attach-session", vec!["-t", session])
};
let status = Command::new("tmux")
.arg(cmd)
.args(&args)
.status()
.with_context(|| format!("failed to {cmd} to tmux session '{session}'"))?;
if !status.success() {
bail!("tmux {cmd} to '{session}' failed");
}
Ok(())
}
pub fn send_keys(target: &str, keys: &str, press_enter: bool) -> Result<()> {
if !keys.is_empty() {
let mut command = Command::new("tmux");
command.args(["send-keys", "-t", target, "-l", "--", keys]);
let output =
command_output_with_timeout(command, "send-keys", Some(target), TMUX_COMMAND_TIMEOUT)
.with_context(|| format!("failed to send keys to target '{target}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(TmuxError::command_failed("send-keys", Some(target), &stderr).into());
}
}
if press_enter {
if !keys.is_empty() {
std::thread::sleep(std::time::Duration::from_millis(SEND_KEYS_SUBMIT_DELAY_MS));
}
let mut command = Command::new("tmux");
command.args(["send-keys", "-t", target, "Enter"]);
let output = command_output_with_timeout(
command,
"send-keys Enter",
Some(target),
TMUX_COMMAND_TIMEOUT,
)
.with_context(|| format!("failed to send Enter to target '{target}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(TmuxError::command_failed("send-keys Enter", Some(target), &stderr).into());
}
}
debug!(target = target, keys = keys, "sent keys");
Ok(())
}
pub fn list_sessions_with_prefix(prefix: &str) -> Vec<String> {
let output = run_tmux_with_timeout(
["list-sessions", "-F", "#{session_name}"],
"list-sessions",
None,
);
match output {
Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|name| name.starts_with(prefix))
.map(|s| s.to_string())
.collect(),
_ => Vec::new(),
}
}
pub struct TestSession {
name: String,
}
impl TestSession {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let _ = kill_session(&name);
Self { name }
}
pub fn name(&self) -> &str {
&self.name
}
}
impl Drop for TestSession {
fn drop(&mut self) {
let _ = kill_session(&self.name);
}
}
pub fn kill_session(session: &str) -> Result<()> {
if !session_exists(session) {
return Ok(()); }
let output = run_tmux_with_timeout(
["kill-session", "-t", session],
"kill-session",
Some(session),
)
.with_context(|| format!("failed to kill tmux session '{session}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("tmux kill-session failed: {stderr}");
}
info!(session = session, "tmux session killed");
Ok(())
}
pub fn capture_pane(target: &str) -> Result<String> {
capture_pane_recent(target, 0)
}
pub fn capture_pane_recent(target: &str, lines: u32) -> Result<String> {
let mut args = vec![
"capture-pane".to_string(),
"-t".to_string(),
target.to_string(),
"-p".to_string(),
];
if lines > 0 {
args.push("-S".to_string());
args.push(format!("-{lines}"));
}
let mut command = Command::new("tmux");
command.args(&args);
let output =
command_output_with_timeout(command, "capture-pane", Some(target), TMUX_COMMAND_TIMEOUT)
.with_context(|| format!("failed to capture pane for target '{target}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(TmuxError::command_failed("capture-pane", Some(target), &stderr).into());
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
pub fn set_mouse(session: &str, enabled: bool) -> Result<()> {
let value = if enabled { "on" } else { "off" };
tmux_set(session, "mouse", value)
}
fn bind_supervisor_hotkey(session: &str, key: &str, action: &str) -> Result<()> {
let output = run_tmux_with_timeout(
[
"bind-key",
"-T",
"prefix",
key,
"set-option",
"-t",
session,
SUPERVISOR_CONTROL_OPTION,
action,
],
"bind-key",
Some(session),
)
.with_context(|| format!("failed to bind supervisor hotkey '{key}' for '{session}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("tmux bind-key {key} failed: {stderr}");
}
Ok(())
}
pub fn configure_supervisor_hotkeys(session: &str) -> Result<()> {
tmux_set(session, SUPERVISOR_CONTROL_OPTION, "")?;
bind_supervisor_hotkey(session, "P", "pause")?;
bind_supervisor_hotkey(session, "R", "resume")?;
Ok(())
}
pub fn take_supervisor_hotkey_action(session: &str) -> Result<Option<String>> {
let output = run_tmux_with_timeout(
[
"show-options",
"-v",
"-t",
session,
SUPERVISOR_CONTROL_OPTION,
],
"show-options",
Some(session),
)
.with_context(|| format!("failed to read supervisor control option for session '{session}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("tmux show-options supervisor control failed: {stderr}");
}
let action = String::from_utf8_lossy(&output.stdout).trim().to_string();
if action.is_empty() {
return Ok(None);
}
tmux_set(session, SUPERVISOR_CONTROL_OPTION, "")?;
Ok(Some(action))
}
#[cfg(test)]
pub fn list_panes(session: &str) -> Result<Vec<String>> {
let output = run_tmux_with_timeout(
["list-panes", "-t", session, "-F", "#{pane_id}"],
"list-panes",
Some(session),
)
.with_context(|| format!("failed to list panes for session '{session}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("tmux list-panes failed: {stderr}");
}
let panes = String::from_utf8_lossy(&output.stdout)
.lines()
.map(|s| s.to_string())
.collect();
Ok(panes)
}
#[cfg(test)]
fn list_window_names(session: &str) -> Result<Vec<String>> {
let output = run_tmux_with_timeout(
["list-windows", "-t", session, "-F", "#{window_name}"],
"list-windows",
Some(session),
)
.with_context(|| format!("failed to list windows for session '{session}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("tmux list-windows failed: {stderr}");
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.map(|line| line.trim().to_string())
.filter(|line| !line.is_empty())
.collect())
}
pub fn list_pane_details(session: &str) -> Result<Vec<PaneDetails>> {
let output = run_tmux_with_timeout(
[
"list-panes",
"-t",
session,
"-F",
"#{pane_id}\t#{pane_current_command}\t#{pane_active}\t#{pane_dead}",
],
"list-panes details",
Some(session),
)
.with_context(|| format!("failed to list pane details for session '{session}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("tmux list-panes details failed: {stderr}");
}
let mut panes = Vec::new();
for line in String::from_utf8_lossy(&output.stdout).lines() {
let mut parts = line.split('\t');
let Some(id) = parts.next() else { continue };
let Some(command) = parts.next() else {
continue;
};
let Some(active) = parts.next() else { continue };
let Some(dead) = parts.next() else { continue };
panes.push(PaneDetails {
id: id.to_string(),
command: command.to_string(),
active: active == "1",
dead: dead == "1",
});
}
Ok(panes)
}
pub fn split_window_horizontal(target_pane: &str, size_pct: u32) -> Result<String> {
let size = format!("{size_pct}%");
let output = run_tmux_with_timeout(
[
"split-window",
"-h",
"-t",
target_pane,
"-l",
&size,
"-P",
"-F",
"#{pane_id}",
],
"split-window -h",
Some(target_pane),
)
.with_context(|| format!("failed to split pane '{target_pane}' horizontally"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("tmux split-window -h failed: {stderr}");
}
let pane_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
debug!(target_pane, pane_id = %pane_id, size_pct, "horizontal split created");
Ok(pane_id)
}
pub fn split_window_vertical_in_pane(
_session: &str,
pane_id: &str,
size_pct: u32,
) -> Result<String> {
let size = format!("{size_pct}%");
let output = run_tmux_with_timeout(
[
"split-window",
"-v",
"-t",
pane_id,
"-l",
&size,
"-P",
"-F",
"#{pane_id}",
],
"split-window -v",
Some(pane_id),
)
.with_context(|| format!("failed to split pane '{pane_id}' vertically"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("tmux split-window -v failed for pane '{pane_id}': {stderr}");
}
let new_pane = String::from_utf8_lossy(&output.stdout).trim().to_string();
debug!(pane_id = %new_pane, parent = pane_id, size_pct, "vertical split created");
Ok(new_pane)
}
pub fn select_layout_even(target_pane: &str) -> Result<()> {
let output = run_tmux_with_timeout(
["select-layout", "-E", "-t", target_pane],
"select-layout -E",
Some(target_pane),
)
.with_context(|| format!("failed to even layout for pane '{target_pane}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("tmux select-layout -E failed: {stderr}");
}
Ok(())
}
const BATTY_BUFFER_NAME: &str = "batty-inject";
pub fn load_buffer(content: &str) -> Result<()> {
let tmp = std::env::temp_dir().join(format!("batty-buf-{}", std::process::id()));
std::fs::write(&tmp, content)
.with_context(|| format!("failed to write buffer file {}", tmp.display()))?;
let output = run_tmux_with_timeout(
[
"load-buffer",
"-b",
BATTY_BUFFER_NAME,
&tmp.to_string_lossy(),
],
"load-buffer",
Some(BATTY_BUFFER_NAME),
)
.context("failed to run tmux load-buffer")?;
let _ = std::fs::remove_file(&tmp);
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("tmux load-buffer failed: {stderr}");
}
Ok(())
}
pub fn paste_buffer(target: &str) -> Result<()> {
let output = run_tmux_with_timeout(
["paste-buffer", "-d", "-b", BATTY_BUFFER_NAME, "-t", target],
"paste-buffer",
Some(target),
)
.with_context(|| format!("failed to paste buffer into '{target}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("tmux paste-buffer failed: {stderr}");
}
Ok(())
}
pub fn kill_pane(target: &str) -> Result<()> {
let output = run_tmux_with_timeout(["kill-pane", "-t", target], "kill-pane", Some(target))
.with_context(|| format!("failed to kill pane '{target}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("not found") {
bail!("tmux kill-pane failed: {stderr}");
}
}
Ok(())
}
pub fn respawn_pane(target: &str, command: &str) -> Result<()> {
let output = run_tmux_with_timeout(
["respawn-pane", "-t", target, "-k", command],
"respawn-pane",
Some(target),
)
.with_context(|| format!("failed to respawn pane '{target}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("tmux respawn-pane failed: {stderr}");
}
Ok(())
}
pub fn tmux_set(session: &str, option: &str, value: &str) -> Result<()> {
let output = run_tmux_with_timeout(["set", "-t", session, option, value], "set", Some(session))
.with_context(|| format!("failed to set tmux option '{option}' for session '{session}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("tmux set {option} failed: {stderr}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::team::test_support::{EnvVarGuard, PATH_LOCK};
use serial_test::serial;
use std::cell::RefCell;
thread_local! {
static TMUX_TEST_PATH_GUARD: RefCell<Option<std::sync::MutexGuard<'static, ()>>> = const { RefCell::new(None) };
}
fn require_tmux_integration() -> bool {
TMUX_TEST_PATH_GUARD.with(|slot| {
if slot.borrow().is_none() {
*slot.borrow_mut() =
Some(PATH_LOCK.lock().unwrap_or_else(|error| error.into_inner()));
}
});
if tmux_available() {
return true;
}
eprintln!("skipping tmux integration test: tmux binary unavailable");
false
}
#[test]
fn session_name_convention() {
assert_eq!(session_name("phase-1"), "batty-phase-1");
assert_eq!(session_name("phase-2"), "batty-phase-2");
assert_eq!(session_name("phase-2.5"), "batty-phase-2-5");
assert_eq!(session_name("phase 3"), "batty-phase-3");
}
#[test]
#[serial]
#[cfg_attr(not(feature = "integration"), ignore)]
fn check_tmux_finds_binary() {
let version = check_tmux().unwrap();
assert!(
version.starts_with("tmux"),
"expected tmux version, got: {version}"
);
}
#[test]
fn parse_tmux_version_supports_minor_suffixes() {
assert_eq!(parse_tmux_version("tmux 3.4"), Some((3, 4)));
assert_eq!(parse_tmux_version("tmux 3.3a"), Some((3, 3)));
assert_eq!(parse_tmux_version("tmux 2.9"), Some((2, 9)));
assert_eq!(parse_tmux_version("tmux unknown"), None);
}
#[test]
fn command_timeout_reproducer_bounds_stalled_subprocess() {
let mut command = Command::new("sh");
command.args(["-c", "sleep 5"]);
let started_at = Instant::now();
let error = command_output_with_timeout(
command,
"supervision-harness-reproducer",
Some("%reproducer"),
Duration::from_millis(50),
)
.expect_err("sleeping subprocess should be killed at the timeout");
assert!(
started_at.elapsed() < Duration::from_secs(2),
"stalled subprocess should be bounded by instrumentation"
);
assert!(
error.to_string().contains("timed out after 50ms"),
"timeout error should identify the bounded wait, got: {error}"
);
}
#[test]
fn command_timeout_reproducer_preserves_success_output() {
let mut command = Command::new("sh");
command.args(["-c", "printf marker-ok"]);
let output = command_output_with_timeout(
command,
"supervision-harness-reproducer",
Some("%reproducer"),
Duration::from_secs(1),
)
.expect("quick subprocess should complete normally");
assert!(output.status.success());
assert_eq!(String::from_utf8_lossy(&output.stdout), "marker-ok");
}
#[test]
fn pane_id_is_bounded_by_tmux_command_timeout() {
let _path_lock = PATH_LOCK.lock().unwrap_or_else(|error| error.into_inner());
let tmp = tempfile::tempdir().unwrap();
let fake_tmux = tmp.path().join("tmux");
std::fs::write(&fake_tmux, "#!/bin/sh\nsleep 30\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&fake_tmux, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let path = format!(
"{}:{}",
tmp.path().display(),
std::env::var("PATH").unwrap_or_default()
);
let _path_guard = EnvVarGuard::set("PATH", &path);
let _timeout_guard = EnvVarGuard::set("BATTY_TEST_TMUX_COMMAND_TIMEOUT_MS", "50");
let started_at = Instant::now();
let error = pane_id("%hung").expect_err("hung pane_id lookup should time out");
let error_chain = format!("{error:#}");
assert!(
started_at.elapsed() < Duration::from_secs(2),
"pane_id should not block indefinitely when tmux hangs"
);
assert!(
error_chain.contains("timed out after 50ms"),
"timeout should identify bounded tmux wait, got: {error_chain}"
);
assert!(
error_chain.contains("%hung"),
"timeout should include target context, got: {error_chain}"
);
}
#[test]
fn check_tmux_reports_missing_binary() {
assert!(check_tmux_with_program("__batty_missing_tmux__").is_err());
}
#[test]
fn capabilities_known_good_matrix() {
let good = TmuxCapabilities {
version_raw: "tmux 3.2".to_string(),
version: Some((3, 2)),
pipe_pane: true,
pipe_pane_only_if_missing: true,
status_style: true,
split_mode: SplitMode::Lines,
};
assert!(good.known_good());
let fallback = TmuxCapabilities {
version_raw: "tmux 3.1".to_string(),
version: Some((3, 1)),
pipe_pane: true,
pipe_pane_only_if_missing: false,
status_style: true,
split_mode: SplitMode::Percent,
};
assert!(!fallback.known_good());
}
#[test]
#[serial]
#[cfg_attr(not(feature = "integration"), ignore)]
fn capability_probe_reports_pipe_pane() {
let caps = probe_capabilities().unwrap();
assert!(
caps.pipe_pane,
"pipe-pane should be available for batty runtime"
);
}
#[test]
#[serial]
#[cfg_attr(not(feature = "integration"), ignore)]
fn nonexistent_session_does_not_exist() {
assert!(!session_exists("batty-test-nonexistent-12345"));
}
#[test]
#[serial]
fn create_and_kill_session() {
if !require_tmux_integration() {
return;
}
let session = "batty-test-lifecycle";
let _ = kill_session(session);
create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
assert!(session_exists(session));
kill_session(session).unwrap();
assert!(!session_exists(session));
}
#[test]
#[serial]
#[cfg_attr(not(feature = "integration"), ignore)]
fn session_path_returns_working_directory() {
let session = "batty-test-session-path";
let _ = kill_session(session);
create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
let path = session_path(session).unwrap();
assert_eq!(path, "/tmp");
kill_session(session).unwrap();
}
#[test]
#[serial]
#[cfg_attr(not(feature = "integration"), ignore)]
fn pane_current_path_returns_working_directory() {
let session = "batty-test-pane-current-path";
let _ = kill_session(session);
create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
let pane = pane_id(session).unwrap();
let path = pane_current_path(&pane).unwrap();
assert_eq!(
std::fs::canonicalize(&path).unwrap(),
std::fs::canonicalize("/tmp").unwrap()
);
kill_session(session).unwrap();
}
#[test]
#[serial]
#[cfg_attr(not(feature = "integration"), ignore)]
fn duplicate_session_is_error() {
let session = "batty-test-dup";
let _ = kill_session(session);
create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
let result = create_session(session, "sleep", &["10".to_string()], "/tmp");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("already exists"));
kill_session(session).unwrap();
}
#[test]
#[serial]
#[cfg_attr(not(feature = "integration"), ignore)]
fn create_window_adds_named_window_to_existing_session() {
let session = "batty-test-window-create";
let _ = kill_session(session);
create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
rename_window(&format!("{session}:0"), "agent-1").unwrap();
create_window(session, "agent-2", "sleep", &["10".to_string()], "/tmp").unwrap();
let names = list_window_names(session).unwrap();
assert!(names.contains(&"agent-1".to_string()));
assert!(names.contains(&"agent-2".to_string()));
select_window(&format!("{session}:agent-1")).unwrap();
kill_session(session).unwrap();
}
#[test]
#[serial]
#[cfg_attr(not(feature = "integration"), ignore)]
fn create_window_unsets_claudecode_from_session_environment() {
let session = "batty-test-window-unset-claudecode";
let _ = kill_session(session);
create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
let output = Command::new("tmux")
.args(["set-environment", "-t", session, "CLAUDECODE", "1"])
.output()
.unwrap();
assert!(
output.status.success(),
"failed to set CLAUDECODE in tmux session: {}",
String::from_utf8_lossy(&output.stderr)
);
create_window(
session,
"env-check",
"bash",
&[
"-lc".to_string(),
"printf '%s' \"${CLAUDECODE:-unset}\"; sleep 1".to_string(),
],
"/tmp",
)
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(300));
let content = capture_pane(&format!("{session}:env-check")).unwrap();
assert!(
content.contains("unset"),
"expected CLAUDECODE to be unset in new window, got: {content:?}"
);
kill_session(session).unwrap();
}
#[test]
#[serial]
#[cfg_attr(not(feature = "integration"), ignore)]
fn create_session_enables_mouse_mode() {
let session = "batty-test-mouse";
let _ = kill_session(session);
create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
let output = Command::new("tmux")
.args(["show-options", "-t", session, "-v", "mouse"])
.output()
.unwrap();
assert!(output.status.success());
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(value, "on", "expected tmux mouse mode to be enabled");
kill_session(session).unwrap();
}
#[test]
#[serial]
#[cfg_attr(not(feature = "integration"), ignore)]
fn send_keys_to_session() {
let session = "batty-test-sendkeys";
let _ = kill_session(session);
create_session(session, "cat", &[], "/tmp").unwrap();
std::thread::sleep(std::time::Duration::from_millis(200));
send_keys(session, "hello", true).unwrap();
kill_session(session).unwrap();
}
#[test]
#[serial]
#[cfg_attr(not(feature = "integration"), ignore)]
fn send_keys_with_enter_submits_line() {
let session = "batty-test-sendkeys-enter";
let _ = kill_session(session);
let tmp = tempfile::tempdir().unwrap();
let log_path = tmp.path().join("sendkeys.log");
create_session(session, "cat", &[], "/tmp").unwrap();
setup_pipe_pane(session, &log_path).unwrap();
std::thread::sleep(std::time::Duration::from_millis(200));
send_keys(session, "supervisor ping", true).unwrap();
std::thread::sleep(std::time::Duration::from_millis(300));
let content = std::fs::read_to_string(&log_path).unwrap_or_default();
assert!(
content.contains("supervisor ping"),
"expected injected text in pane log, got: {content:?}"
);
assert!(
content.contains("supervisor ping\r\n") || content.contains("supervisor ping\n"),
"expected submitted line ending in pane log, got: {content:?}"
);
kill_session(session).unwrap();
}
#[test]
#[serial]
#[cfg_attr(not(feature = "integration"), ignore)]
fn send_keys_enter_only_submits_prompt() {
let session = "batty-test-sendkeys-enter-only";
let _ = kill_session(session);
let tmp = tempfile::tempdir().unwrap();
let log_path = tmp.path().join("sendkeys-enter-only.log");
create_session(session, "cat", &[], "/tmp").unwrap();
let pane = pane_id(session).unwrap();
setup_pipe_pane(&pane, &log_path).unwrap();
std::thread::sleep(std::time::Duration::from_millis(200));
send_keys(&pane, "", true).unwrap();
std::thread::sleep(std::time::Duration::from_millis(300));
let content = std::fs::read_to_string(&log_path).unwrap_or_default();
assert!(
content.contains("\r\n") || content.contains('\n'),
"expected submitted empty line in pane log, got: {content:?}"
);
kill_session(session).unwrap();
}
#[test]
#[serial]
#[cfg_attr(not(feature = "integration"), ignore)]
fn pipe_pane_captures_output() {
let session = "batty-test-pipe";
let _ = kill_session(session);
let tmp = tempfile::tempdir().unwrap();
let log_path = tmp.path().join("pty-output.log");
create_session(session, "bash", &[], "/tmp").unwrap();
let pane = pane_id(session).unwrap();
setup_pipe_pane(&pane, &log_path).unwrap();
std::thread::sleep(std::time::Duration::from_millis(200));
send_keys(&pane, "echo pipe-test-output", true).unwrap();
let mut found = false;
for _ in 0..10 {
std::thread::sleep(std::time::Duration::from_millis(200));
if log_path.exists() {
let content = std::fs::read_to_string(&log_path).unwrap_or_default();
if !content.is_empty() {
found = true;
break;
}
}
}
kill_session(session).unwrap();
assert!(found, "pipe-pane log should have captured output");
}
#[test]
#[serial]
#[cfg_attr(not(feature = "integration"), ignore)]
fn capture_pane_returns_content() {
let session = "batty-test-capture";
let _ = kill_session(session);
create_session(
session,
"bash",
&["-c".to_string(), "echo 'capture-test'; sleep 2".to_string()],
"/tmp",
)
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(500));
let content = capture_pane(session).unwrap();
assert!(
!content.trim().is_empty(),
"capture-pane should return content"
);
kill_session(session).unwrap();
}
#[test]
#[serial]
#[cfg_attr(not(feature = "integration"), ignore)]
fn capture_pane_recent_returns_content() {
let session = "batty-test-capture-recent";
let _ = kill_session(session);
create_session(
session,
"bash",
&[
"-c".to_string(),
"echo 'capture-recent-test'; sleep 2".to_string(),
],
"/tmp",
)
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(500));
let content = capture_pane_recent(session, 10).unwrap();
assert!(content.contains("capture-recent-test"));
kill_session(session).unwrap();
}
#[test]
#[serial]
#[cfg_attr(not(feature = "integration"), ignore)]
fn list_panes_returns_at_least_one() {
let session = "batty-test-panes";
let _ = kill_session(session);
create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
let panes = list_panes(session).unwrap();
assert!(!panes.is_empty(), "session should have at least one pane");
kill_session(session).unwrap();
}
#[test]
#[serial]
#[cfg_attr(not(feature = "integration"), ignore)]
fn list_pane_details_includes_active_flag() {
let session = "batty-test-pane-details";
let _ = kill_session(session);
create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
let panes = list_pane_details(session).unwrap();
assert!(
!panes.is_empty(),
"expected at least one pane detail record"
);
assert!(
panes.iter().any(|p| p.active),
"expected one active pane, got: {panes:?}"
);
kill_session(session).unwrap();
}
#[test]
#[serial]
#[cfg_attr(not(feature = "integration"), ignore)]
fn configure_supervisor_hotkeys_initializes_control_option() {
let session = "batty-test-supervisor-hotkeys";
let _ = kill_session(session);
create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
configure_supervisor_hotkeys(session).unwrap();
let output = Command::new("tmux")
.args([
"show-options",
"-v",
"-t",
session,
SUPERVISOR_CONTROL_OPTION,
])
.output()
.unwrap();
assert!(output.status.success());
assert!(String::from_utf8_lossy(&output.stdout).trim().is_empty());
kill_session(session).unwrap();
}
#[test]
#[serial]
#[cfg_attr(not(feature = "integration"), ignore)]
fn take_supervisor_hotkey_action_reads_and_clears() {
let session = "batty-test-supervisor-action";
let _ = kill_session(session);
create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
configure_supervisor_hotkeys(session).unwrap();
tmux_set(session, SUPERVISOR_CONTROL_OPTION, "pause").unwrap();
let first = take_supervisor_hotkey_action(session).unwrap();
assert_eq!(first.as_deref(), Some("pause"));
let second = take_supervisor_hotkey_action(session).unwrap();
assert!(second.is_none(), "expected action to be cleared");
kill_session(session).unwrap();
}
#[test]
#[serial]
#[cfg_attr(not(feature = "integration"), ignore)]
fn kill_nonexistent_session_is_ok() {
kill_session("batty-test-nonexistent-kill-99999").unwrap();
}
#[test]
#[serial]
#[cfg_attr(not(feature = "integration"), ignore)]
fn session_with_short_lived_process() {
let session = "batty-test-shortlived";
let _ = kill_session(session);
create_session(session, "echo", &["done".to_string()], "/tmp").unwrap();
std::thread::sleep(std::time::Duration::from_millis(500));
let _ = kill_session(session);
}
#[test]
#[serial]
#[cfg_attr(not(feature = "integration"), ignore)]
fn test_session_guard_cleanup_on_drop() {
let name = "batty-test-guard-drop";
let _ = kill_session(name);
{
let guard = TestSession::new(name);
create_session(guard.name(), "sleep", &["30".to_string()], "/tmp").unwrap();
assert!(session_exists(name));
}
assert!(!session_exists(name), "session should be killed on drop");
}
#[test]
#[serial]
#[cfg_attr(not(feature = "integration"), ignore)]
fn test_session_guard_cleanup_on_panic() {
let name = "batty-test-guard-panic";
let _ = kill_session(name);
let result = std::panic::catch_unwind(|| {
let guard = TestSession::new(name);
create_session(guard.name(), "sleep", &["30".to_string()], "/tmp").unwrap();
assert!(session_exists(name));
panic!("intentional panic to test cleanup");
#[allow(unreachable_code)]
drop(guard);
});
assert!(result.is_err(), "should have panicked");
assert!(
!session_exists(name),
"session should be cleaned up even after panic"
);
}
#[test]
fn parse_tmux_version_empty_string() {
assert_eq!(parse_tmux_version(""), None);
}
#[test]
fn parse_tmux_version_no_prefix() {
assert_eq!(parse_tmux_version("3.4"), None);
}
#[test]
fn parse_tmux_version_major_only_no_dot() {
assert_eq!(parse_tmux_version("tmux 3"), None);
}
#[test]
fn parse_tmux_version_multi_digit() {
assert_eq!(parse_tmux_version("tmux 10.12"), Some((10, 12)));
}
#[test]
fn parse_tmux_version_trailing_whitespace() {
assert_eq!(parse_tmux_version(" tmux 3.4 "), Some((3, 4)));
}
#[test]
fn parse_tmux_version_next_suffix() {
assert_eq!(parse_tmux_version("tmux next-3.5"), None);
}
#[test]
fn parse_tmux_version_dot_no_minor() {
assert_eq!(parse_tmux_version("tmux 3."), None);
}
#[test]
fn parse_tmux_version_double_suffix_letters() {
assert_eq!(parse_tmux_version("tmux 3.3ab"), Some((3, 3)));
}
#[test]
fn capabilities_known_good_version_4() {
let caps = TmuxCapabilities {
version_raw: "tmux 4.0".to_string(),
version: Some((4, 0)),
pipe_pane: true,
pipe_pane_only_if_missing: true,
status_style: true,
split_mode: SplitMode::Lines,
};
assert!(caps.known_good(), "4.0 should be known good");
}
#[test]
fn capabilities_known_good_version_2_9() {
let caps = TmuxCapabilities {
version_raw: "tmux 2.9".to_string(),
version: Some((2, 9)),
pipe_pane: true,
pipe_pane_only_if_missing: false,
status_style: true,
split_mode: SplitMode::Percent,
};
assert!(!caps.known_good(), "2.9 should not be known good");
}
#[test]
fn capabilities_known_good_version_3_0() {
let caps = TmuxCapabilities {
version_raw: "tmux 3.0".to_string(),
version: Some((3, 0)),
pipe_pane: true,
pipe_pane_only_if_missing: false,
status_style: true,
split_mode: SplitMode::Percent,
};
assert!(!caps.known_good(), "3.0 should not be known good");
}
#[test]
fn capabilities_known_good_none_version() {
let caps = TmuxCapabilities {
version_raw: "tmux unknown".to_string(),
version: None,
pipe_pane: false,
pipe_pane_only_if_missing: false,
status_style: false,
split_mode: SplitMode::Disabled,
};
assert!(!caps.known_good(), "None version should not be known good");
}
#[test]
fn capabilities_remediation_message_includes_version() {
let caps = TmuxCapabilities {
version_raw: "tmux 2.8".to_string(),
version: Some((2, 8)),
pipe_pane: false,
pipe_pane_only_if_missing: false,
status_style: false,
split_mode: SplitMode::Disabled,
};
let msg = caps.remediation_message();
assert!(
msg.contains("tmux 2.8"),
"message should include detected version"
);
assert!(
msg.contains("pipe-pane"),
"message should mention pipe-pane requirement"
);
assert!(msg.contains("3.2"), "message should recommend >= 3.2");
}
#[test]
fn session_name_empty_input() {
assert_eq!(session_name(""), "batty-");
}
#[test]
fn session_name_preserves_underscores() {
assert_eq!(session_name("my_session"), "batty-my_session");
}
#[test]
fn session_name_replaces_colons_and_slashes() {
assert_eq!(session_name("a:b/c"), "batty-a-b-c");
}
#[test]
fn session_name_replaces_multiple_dots() {
assert_eq!(session_name("v1.2.3"), "batty-v1-2-3");
}
#[test]
fn pane_details_clone_and_eq() {
let pd = PaneDetails {
id: "%5".to_string(),
command: "bash".to_string(),
active: true,
dead: false,
};
let cloned = pd.clone();
assert_eq!(pd, cloned);
assert_eq!(pd.id, "%5");
assert!(pd.active);
assert!(!pd.dead);
}
#[test]
fn pane_details_not_equal_different_id() {
let a = PaneDetails {
id: "%1".to_string(),
command: "bash".to_string(),
active: true,
dead: false,
};
let b = PaneDetails {
id: "%2".to_string(),
command: "bash".to_string(),
active: true,
dead: false,
};
assert_ne!(a, b);
}
#[test]
fn split_mode_debug_and_eq() {
assert_eq!(SplitMode::Lines, SplitMode::Lines);
assert_ne!(SplitMode::Lines, SplitMode::Percent);
assert_ne!(SplitMode::Percent, SplitMode::Disabled);
let copied = SplitMode::Lines;
assert_eq!(format!("{:?}", copied), "Lines");
}
#[test]
fn test_session_name_accessor() {
let guard = TestSession::new("batty-test-accessor");
assert_eq!(guard.name(), "batty-test-accessor");
}
#[test]
#[serial]
fn pane_exists_for_valid_pane() {
if !require_tmux_integration() {
return;
}
let session = "batty-test-pane-exists";
let _guard = TestSession::new(session);
create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
let pane = pane_id(session).unwrap();
assert!(pane_exists(&pane), "existing pane should be found");
}
#[test]
#[serial]
fn session_exists_returns_false_after_kill() {
if !require_tmux_integration() {
return;
}
let session = "batty-test-sess-exists-gone";
let _ = kill_session(session);
create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
assert!(session_exists(session));
kill_session(session).unwrap();
assert!(
!session_exists(session),
"session should not exist after kill"
);
}
#[test]
#[serial]
fn pane_dead_for_running_process() {
if !require_tmux_integration() {
return;
}
let session = "batty-test-pane-dead-alive";
let _guard = TestSession::new(session);
create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
let pane = pane_id(session).unwrap();
let dead = pane_dead(&pane).unwrap();
assert!(!dead, "running process pane should not be dead");
}
#[test]
#[serial]
fn list_sessions_with_prefix_finds_matching() {
if !require_tmux_integration() {
return;
}
let prefix = "batty-test-prefix-match";
let s1 = format!("{prefix}-aaa");
let s2 = format!("{prefix}-bbb");
let _g1 = TestSession::new(s1.clone());
let _g2 = TestSession::new(s2.clone());
create_session(&s1, "sleep", &["10".to_string()], "/tmp").unwrap();
create_session(&s2, "sleep", &["10".to_string()], "/tmp").unwrap();
let found = list_sessions_with_prefix(prefix);
assert!(
found.contains(&s1),
"should find first session, got: {found:?}"
);
assert!(
found.contains(&s2),
"should find second session, got: {found:?}"
);
}
#[test]
#[serial]
fn list_sessions_with_prefix_excludes_non_matching() {
if !require_tmux_integration() {
return;
}
let found = list_sessions_with_prefix("batty-test-zzz-nonexist-99999");
assert!(found.is_empty(), "should find no sessions for bogus prefix");
}
#[test]
#[serial]
fn split_window_horizontal_creates_new_pane() {
if !require_tmux_integration() {
return;
}
let session = "batty-test-hsplit";
let _guard = TestSession::new(session);
create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
let original = pane_id(session).unwrap();
let new_pane = split_window_horizontal(&original, 50).unwrap();
assert!(
new_pane.starts_with('%'),
"new pane id should start with %, got: {new_pane}"
);
let panes = list_panes(session).unwrap();
assert_eq!(panes.len(), 2, "should have 2 panes after split");
assert!(panes.contains(&new_pane));
}
#[test]
#[serial]
fn split_window_vertical_creates_new_pane() {
if !require_tmux_integration() {
return;
}
let session = "batty-test-vsplit";
let _guard = TestSession::new(session);
create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
let original = pane_id(session).unwrap();
let new_pane = split_window_vertical_in_pane(session, &original, 50).unwrap();
assert!(
new_pane.starts_with('%'),
"new pane id should start with %, got: {new_pane}"
);
let panes = list_panes(session).unwrap();
assert_eq!(panes.len(), 2, "should have 2 panes after split");
assert!(panes.contains(&new_pane));
}
#[test]
#[serial]
fn load_buffer_and_paste_buffer_injects_text() {
if !require_tmux_integration() {
return;
}
let session = "batty-test-paste-buf";
let _guard = TestSession::new(session);
create_session(session, "cat", &[], "/tmp").unwrap();
std::thread::sleep(std::time::Duration::from_millis(200));
let pane = pane_id(session).unwrap();
load_buffer("hello-from-buffer").unwrap();
paste_buffer(&pane).unwrap();
std::thread::sleep(std::time::Duration::from_millis(300));
let live_pane = pane_id(session).unwrap_or(pane);
let content = capture_pane(&live_pane).unwrap();
assert!(
content.contains("hello-from-buffer"),
"paste-buffer should inject text into pane, got: {content:?}"
);
}
#[test]
#[serial]
fn kill_pane_removes_pane() {
if !require_tmux_integration() {
return;
}
let session = "batty-test-kill-pane";
let _guard = TestSession::new(session);
create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
let original = pane_id(session).unwrap();
let new_pane = split_window_horizontal(&original, 50).unwrap();
let before = list_panes(session).unwrap();
assert_eq!(before.len(), 2);
kill_pane(&new_pane).unwrap();
let after = list_panes(session).unwrap();
assert_eq!(after.len(), 1, "should have 1 pane after kill");
assert!(!after.contains(&new_pane));
}
#[test]
#[serial]
fn kill_pane_nonexistent_returns_error() {
if !require_tmux_integration() {
return;
}
let result = kill_pane("batty-test-no-such-session-xyz:0.0");
let _ = result;
}
#[test]
#[serial]
fn set_mouse_disable_and_enable() {
if !require_tmux_integration() {
return;
}
let session = "batty-test-mouse-toggle";
let _guard = TestSession::new(session);
create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
set_mouse(session, false).unwrap();
let output = Command::new("tmux")
.args(["show-options", "-t", session, "-v", "mouse"])
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&output.stdout).trim(),
"off",
"mouse should be disabled"
);
set_mouse(session, true).unwrap();
let output = Command::new("tmux")
.args(["show-options", "-t", session, "-v", "mouse"])
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&output.stdout).trim(),
"on",
"mouse should be re-enabled"
);
}
#[test]
#[serial]
fn tmux_set_custom_option() {
if !require_tmux_integration() {
return;
}
let session = "batty-test-tmux-set";
let _guard = TestSession::new(session);
create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
tmux_set(session, "@batty_test_opt", "test-value").unwrap();
let output = Command::new("tmux")
.args(["show-options", "-v", "-t", session, "@batty_test_opt"])
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "test-value");
}
#[test]
#[serial]
fn create_window_fails_for_missing_session() {
if !require_tmux_integration() {
return;
}
let result = create_window(
"batty-test-nonexistent-session-99999",
"test-win",
"sleep",
&["1".to_string()],
"/tmp",
);
assert!(result.is_err(), "should fail for nonexistent session");
assert!(
result.unwrap_err().to_string().contains("not found"),
"error should mention session not found"
);
}
#[test]
#[serial]
fn setup_pipe_pane_if_missing_works() {
if !require_tmux_integration() {
return;
}
let session = "batty-test-pipe-if-missing";
let _guard = TestSession::new(session);
let tmp = tempfile::tempdir().unwrap();
let log_path = tmp.path().join("pipe-if-missing.log");
create_session(
session,
"bash",
&["-c".to_string(), "sleep 10".to_string()],
"/tmp",
)
.unwrap();
setup_pipe_pane_if_missing(session, &log_path).unwrap();
setup_pipe_pane_if_missing(session, &log_path).unwrap();
}
#[test]
#[serial]
fn select_layout_even_after_splits() {
if !require_tmux_integration() {
return;
}
let session = "batty-test-layout-even";
let _guard = TestSession::new(session);
create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
let original = pane_id(session).unwrap();
let _p2 = split_window_horizontal(&original, 50).unwrap();
select_layout_even(&original).unwrap();
}
#[test]
#[serial]
fn rename_window_changes_name() {
if !require_tmux_integration() {
return;
}
let session = "batty-test-rename-win";
let _guard = TestSession::new(session);
create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
rename_window(&format!("{session}:0"), "custom-name").unwrap();
let names = list_window_names(session).unwrap();
assert!(
names.contains(&"custom-name".to_string()),
"window should be renamed, got: {names:?}"
);
}
#[test]
#[serial]
fn select_window_switches_active() {
if !require_tmux_integration() {
return;
}
let session = "batty-test-select-win";
let _guard = TestSession::new(session);
create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
create_window(session, "second", "sleep", &["10".to_string()], "/tmp").unwrap();
select_window(&format!("{session}:second")).unwrap();
}
#[test]
#[serial]
fn capture_pane_recent_zero_lines_returns_full() {
if !require_tmux_integration() {
return;
}
let session = "batty-test-capture-zero";
let _guard = TestSession::new(session);
create_session(
session,
"bash",
&[
"-c".to_string(),
"echo 'zero-lines-test'; sleep 2".to_string(),
],
"/tmp",
)
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(500));
let content = capture_pane_recent(session, 0).unwrap();
assert!(
content.contains("zero-lines-test"),
"should capture full content with lines=0, got: {content:?}"
);
}
#[test]
#[serial]
fn list_pane_details_shows_command_info() {
if !require_tmux_integration() {
return;
}
let session = "batty-test-pane-details-cmd";
let _guard = TestSession::new(session);
create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
let details = list_pane_details(session).unwrap();
assert_eq!(details.len(), 1);
assert!(details[0].id.starts_with('%'));
assert!(
details[0].command == "sleep" || !details[0].command.is_empty(),
"command should be reported"
);
assert!(!details[0].dead, "sleep pane should not be dead");
}
#[test]
#[serial]
fn pane_id_returns_percent_prefixed() {
if !require_tmux_integration() {
return;
}
let session = "batty-test-paneid-fmt";
let _guard = TestSession::new(session);
create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
let id = pane_id(session).unwrap();
assert!(
id.starts_with('%'),
"pane id should start with %, got: {id}"
);
}
#[test]
#[serial]
fn respawn_pane_restarts_running_pane() {
if !require_tmux_integration() {
return;
}
let session = "batty-test-respawn";
let _guard = TestSession::new(session);
create_session(session, "sleep", &["30".to_string()], "/tmp").unwrap();
let pane = pane_id(session).unwrap();
respawn_pane(&pane, "sleep 10").unwrap();
std::thread::sleep(std::time::Duration::from_millis(300));
assert!(pane_exists(&pane), "respawned pane should exist");
}
}