use std::process::Command;
use crate::error::PawError;
const MAX_COLLISION_RETRIES: u32 = 10;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TmuxCommand {
args: Vec<String>,
}
impl TmuxCommand {
fn new(args: &[&str]) -> Self {
Self {
args: args.iter().map(|&s| s.to_owned()).collect(),
}
}
#[allow(dead_code)]
pub fn as_command_string(&self) -> String {
format!("tmux {}", self.args.join(" "))
}
fn execute(&self) -> Result<String, PawError> {
let output = Command::new("tmux")
.args(&self.args)
.output()
.map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
if output.status.success() {
String::from_utf8(output.stdout)
.map_err(|e| PawError::TmuxError(format!("invalid utf-8 in tmux output: {e}")))
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(PawError::TmuxError(stderr.trim().to_owned()))
}
}
}
#[derive(Debug, Clone)]
pub struct PaneSpec {
pub branch: String,
pub worktree: String,
pub cli_command: String,
}
#[derive(Debug)]
pub struct TmuxSession {
pub name: String,
commands: Vec<TmuxCommand>,
}
impl TmuxSession {
pub fn execute(&self) -> Result<(), PawError> {
for cmd in &self.commands {
cmd.execute()?;
}
Ok(())
}
#[allow(dead_code)]
pub fn command_strings(&self) -> Vec<String> {
self.commands
.iter()
.map(TmuxCommand::as_command_string)
.collect()
}
pub fn pipe_pane(&mut self, pane_target: &str, log_path: &std::path::Path) -> &mut Self {
self.commands.push(TmuxCommand::new(&[
"pipe-pane",
"-o",
"-t",
pane_target,
&format!("cat >> {}", log_path.display()),
]));
self
}
}
#[derive(Debug)]
pub struct TmuxSessionBuilder {
project_name: String,
panes: Vec<PaneSpec>,
mouse_mode: bool,
session_name_override: Option<String>,
env_vars: Vec<(String, String)>,
}
impl TmuxSessionBuilder {
pub fn new(project_name: &str) -> Self {
Self {
project_name: project_name.to_owned(),
panes: Vec::new(),
mouse_mode: true,
session_name_override: None,
env_vars: Vec::new(),
}
}
#[must_use]
pub fn session_name(mut self, name: String) -> Self {
self.session_name_override = Some(name);
self
}
#[must_use]
pub fn add_pane(mut self, spec: PaneSpec) -> Self {
self.panes.push(spec);
self
}
#[must_use]
pub fn mouse_mode(mut self, enabled: bool) -> Self {
self.mouse_mode = enabled;
self
}
#[must_use]
pub fn set_environment(mut self, key: &str, value: &str) -> Self {
self.env_vars.push((key.to_owned(), value.to_owned()));
self
}
#[allow(clippy::too_many_lines)]
pub fn build(self) -> Result<TmuxSession, PawError> {
if self.panes.is_empty() {
return Err(PawError::TmuxError(
"cannot create a session with no panes".to_owned(),
));
}
let session_name = self
.session_name_override
.unwrap_or_else(|| format!("paw-{}", self.project_name));
let mut commands = Vec::new();
let first_worktree = &self.panes[0].worktree;
commands.push(TmuxCommand::new(&[
"new-session",
"-d",
"-s",
&session_name,
"-c",
first_worktree,
]));
if self.mouse_mode {
commands.push(TmuxCommand::new(&[
"set-option",
"-t",
&session_name,
"mouse",
"on",
]));
}
commands.push(TmuxCommand::new(&[
"set-option",
"-t",
&session_name,
"pane-border-status",
"top",
]));
commands.push(TmuxCommand::new(&[
"set-option",
"-t",
&session_name,
"pane-border-format",
" #{pane_title} ",
]));
for (key, value) in &self.env_vars {
commands.push(TmuxCommand::new(&[
"set-environment",
"-t",
&session_name,
key,
value,
]));
}
let first = &self.panes[0];
let pane_target = format!("{session_name}:0.0");
let pane_title = format!("{} \u{2192} {}", first.branch, first.cli_command);
commands.push(TmuxCommand::new(&[
"select-pane",
"-t",
&pane_target,
"-T",
&pane_title,
]));
commands.push(TmuxCommand::new(&[
"send-keys",
"-t",
&pane_target,
&first.cli_command,
"Enter",
]));
for (i, pane) in self.panes.iter().enumerate().skip(1) {
commands.push(TmuxCommand::new(&[
"select-layout",
"-t",
&session_name,
"tiled",
]));
commands.push(TmuxCommand::new(&["split-window", "-t", &session_name]));
let pane_target = format!("{session_name}:0.{i}");
let pane_title = format!("{} \u{2192} {}", pane.branch, pane.cli_command);
let pane_cmd = format!("cd {} && {}", pane.worktree, pane.cli_command);
commands.push(TmuxCommand::new(&[
"select-pane",
"-t",
&pane_target,
"-T",
&pane_title,
]));
commands.push(TmuxCommand::new(&[
"send-keys",
"-t",
&pane_target,
&pane_cmd,
"Enter",
]));
}
commands.push(TmuxCommand::new(&[
"select-layout",
"-t",
&session_name,
"tiled",
]));
Ok(TmuxSession {
name: session_name,
commands,
})
}
}
pub fn ensure_tmux_installed() -> Result<(), PawError> {
which::which("tmux").map_err(|_| PawError::TmuxNotInstalled)?;
Ok(())
}
pub fn is_session_alive(name: &str) -> Result<bool, PawError> {
let status = Command::new("tmux")
.args(["has-session", "-t", name])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
Ok(status.success())
}
pub fn resolve_session_name(project_name: &str) -> Result<String, PawError> {
let base = format!("paw-{project_name}");
if !is_session_alive(&base)? {
return Ok(base);
}
for suffix in 2..=MAX_COLLISION_RETRIES + 1 {
let candidate = format!("{base}-{suffix}");
if !is_session_alive(&candidate)? {
return Ok(candidate);
}
}
Err(PawError::TmuxError(format!(
"too many session name collisions for '{base}'"
)))
}
pub fn attach(name: &str) -> Result<(), PawError> {
let status = Command::new("tmux")
.args(["attach-session", "-t", name])
.status()
.map_err(|e| PawError::TmuxError(format!("failed to attach to tmux session: {e}")))?;
if status.success() {
Ok(())
} else {
Err(PawError::TmuxError(format!(
"failed to attach to session '{name}'"
)))
}
}
pub fn kill_session(name: &str) -> Result<(), PawError> {
let output = Command::new("tmux")
.args(["kill-session", "-t", name])
.output()
.map_err(|e| PawError::TmuxError(format!("failed to kill tmux session: {e}")))?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(PawError::TmuxError(stderr.trim().to_owned()))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_pane(branch: &str, worktree: &str, cli: &str) -> PaneSpec {
PaneSpec {
branch: branch.to_owned(),
worktree: worktree.to_owned(),
cli_command: cli.to_owned(),
}
}
fn commands_containing(cmds: &[String], keyword: &str) -> Vec<String> {
cmds.iter()
.filter(|c| c.contains(keyword))
.cloned()
.collect()
}
#[test]
#[serial_test::serial]
fn ensure_tmux_installed_succeeds_when_present() {
assert!(ensure_tmux_installed().is_ok());
}
#[test]
fn session_is_named_after_project() {
let session = TmuxSessionBuilder::new("my-project")
.add_pane(make_pane("main", "/tmp/wt", "claude"))
.build()
.unwrap();
assert_eq!(session.name, "paw-my-project");
}
#[test]
fn session_creation_command_uses_session_name() {
let session = TmuxSessionBuilder::new("app")
.add_pane(make_pane("main", "/tmp/wt", "claude"))
.build()
.unwrap();
let cmds = session.command_strings();
assert!(
cmds.iter()
.any(|c| c.contains("new-session") && c.contains("paw-app")),
"should create a tmux session named paw-app"
);
}
#[test]
fn session_name_override_replaces_default() {
let session = TmuxSessionBuilder::new("my-project")
.session_name("custom-session-name".to_string())
.add_pane(make_pane("main", "/tmp/wt", "claude"))
.build()
.unwrap();
assert_eq!(session.name, "custom-session-name");
let cmds = session.command_strings();
assert!(
cmds.iter()
.any(|c| c.contains("new-session") && c.contains("custom-session-name")),
"should use overridden session name"
);
}
#[test]
fn pane_count_matches_input_for_two_panes() {
let session = TmuxSessionBuilder::new("proj")
.add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
.add_pane(make_pane("feat/api", "/tmp/wt2", "codex"))
.build()
.unwrap();
let cmds = session.command_strings();
let send_keys = commands_containing(&cmds, "send-keys");
assert_eq!(
send_keys.len(),
2,
"should send commands to exactly 2 panes"
);
}
#[test]
fn pane_count_matches_input_for_five_panes() {
let mut builder = TmuxSessionBuilder::new("proj");
for i in 0..5 {
builder = builder.add_pane(make_pane(
&format!("feat/b{i}"),
&format!("/tmp/wt{i}"),
"claude",
));
}
let session = builder.build().unwrap();
let cmds = session.command_strings();
let send_keys = commands_containing(&cmds, "send-keys");
assert_eq!(
send_keys.len(),
5,
"should send commands to exactly 5 panes"
);
}
#[test]
fn building_with_no_panes_is_an_error() {
let result = TmuxSessionBuilder::new("proj").build();
assert!(result.is_err(), "session with no panes should fail");
}
#[test]
fn each_pane_receives_cd_and_cli_command() {
let session = TmuxSessionBuilder::new("proj")
.add_pane(make_pane("feat/auth", "/home/user/wt-auth", "claude"))
.add_pane(make_pane("feat/api", "/home/user/wt-api", "gemini"))
.build()
.unwrap();
let cmds = session.command_strings();
let send_keys = commands_containing(&cmds, "send-keys");
assert!(
send_keys[0].contains("claude"),
"first pane should run claude"
);
assert!(
send_keys[1].contains("cd /home/user/wt-api && gemini"),
"second pane should cd into wt-api and run gemini"
);
}
#[test]
fn pane_commands_are_submitted_with_enter() {
let session = TmuxSessionBuilder::new("proj")
.add_pane(make_pane("main", "/tmp/wt", "aider"))
.build()
.unwrap();
let cmds = session.command_strings();
let send_keys = commands_containing(&cmds, "send-keys");
assert!(
send_keys[0].contains("Enter"),
"send-keys should press Enter to submit"
);
}
#[test]
fn each_pane_targets_a_distinct_pane_index() {
let session = TmuxSessionBuilder::new("proj")
.add_pane(make_pane("feat/a", "/tmp/a", "claude"))
.add_pane(make_pane("feat/b", "/tmp/b", "codex"))
.add_pane(make_pane("feat/c", "/tmp/c", "gemini"))
.build()
.unwrap();
let cmds = session.command_strings();
let send_keys = commands_containing(&cmds, "send-keys");
assert!(
send_keys[0].contains(":0.0"),
"first pane should target :0.0"
);
assert!(
send_keys[1].contains(":0.1"),
"second pane should target :0.1"
);
assert!(
send_keys[2].contains(":0.2"),
"third pane should target :0.2"
);
}
#[test]
fn each_pane_is_titled_with_branch_and_cli() {
let session = TmuxSessionBuilder::new("proj")
.add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
.add_pane(make_pane("fix/api", "/tmp/wt2", "gemini"))
.build()
.unwrap();
let cmds = session.command_strings();
let select_panes = commands_containing(&cmds, "select-pane");
assert_eq!(select_panes.len(), 2, "each pane should get a title");
assert!(
select_panes[0].contains("feat/auth \u{2192} claude"),
"first pane title should be 'feat/auth \u{2192} claude', got: {}",
select_panes[0]
);
assert!(
select_panes[1].contains("fix/api \u{2192} gemini"),
"second pane title should be 'fix/api \u{2192} gemini', got: {}",
select_panes[1]
);
}
#[test]
fn pane_border_status_is_configured() {
let session = TmuxSessionBuilder::new("proj")
.add_pane(make_pane("main", "/tmp/wt", "claude"))
.build()
.unwrap();
let cmds = session.command_strings();
assert!(
cmds.iter()
.any(|c| c.contains("pane-border-status") && c.contains("top")),
"should configure pane-border-status to top"
);
assert!(
cmds.iter()
.any(|c| c.contains("pane-border-format") && c.contains("#{pane_title}")),
"should configure pane-border-format to show pane title"
);
}
#[test]
fn mouse_mode_enabled_by_default() {
let session = TmuxSessionBuilder::new("proj")
.add_pane(make_pane("main", "/tmp/wt", "claude"))
.build()
.unwrap();
let cmds = session.command_strings();
assert!(
cmds.iter().any(|c| c.contains("mouse on")),
"mouse should be enabled by default"
);
}
#[test]
fn mouse_mode_can_be_disabled() {
let session = TmuxSessionBuilder::new("proj")
.add_pane(make_pane("main", "/tmp/wt", "claude"))
.mouse_mode(false)
.build()
.unwrap();
let cmds = session.command_strings();
assert!(
!cmds.iter().any(|c| c.contains("mouse on")),
"no mouse-on command should be emitted when disabled"
);
}
fn create_test_session(name: &str) {
let output = std::process::Command::new("tmux")
.args(["new-session", "-d", "-s", name])
.output()
.expect("create tmux session");
assert!(
output.status.success(),
"failed to create test session '{name}'"
);
}
fn cleanup_session(name: &str) {
let _ = kill_session(name);
}
#[test]
#[serial_test::serial]
fn is_session_alive_returns_false_for_nonexistent() {
let alive = is_session_alive("paw-definitely-does-not-exist-12345").unwrap();
assert!(!alive);
}
#[test]
#[serial_test::serial]
fn session_lifecycle_create_check_kill() {
let name = "paw-unit-test-lifecycle";
cleanup_session(name);
create_test_session(name);
assert!(is_session_alive(name).unwrap());
kill_session(name).unwrap();
assert!(!is_session_alive(name).unwrap());
}
#[test]
#[serial_test::serial]
fn resolve_session_name_returns_base_when_no_collision() {
let name = resolve_session_name("unit-test-no-collision-xyz").unwrap();
assert_eq!(name, "paw-unit-test-no-collision-xyz");
}
#[test]
#[serial_test::serial]
fn resolve_session_name_appends_suffix_on_collision() {
let base_name = "paw-unit-test-collision";
cleanup_session(base_name);
cleanup_session(&format!("{base_name}-2"));
create_test_session(base_name);
let resolved = resolve_session_name("unit-test-collision").unwrap();
assert_eq!(resolved, format!("{base_name}-2"));
cleanup_session(base_name);
}
#[test]
fn pipe_pane_queues_correct_command() {
let mut session = TmuxSessionBuilder::new("proj")
.add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
.build()
.unwrap();
let log_path = std::path::PathBuf::from("/repo/.git-paw/logs/paw-proj/feat--auth.log");
session.pipe_pane("paw-proj:0.0", &log_path);
let cmds = session.command_strings();
let pipe_cmds: Vec<&String> = cmds.iter().filter(|c| c.contains("pipe-pane")).collect();
assert_eq!(pipe_cmds.len(), 1);
assert!(pipe_cmds[0].contains("pipe-pane -o -t paw-proj:0.0"));
assert!(pipe_cmds[0].contains("cat >> /repo/.git-paw/logs/paw-proj/feat--auth.log"));
}
#[test]
fn session_without_pipe_pane_has_no_pipe_pane_commands() {
let session = TmuxSessionBuilder::new("proj")
.add_pane(make_pane("main", "/tmp/wt", "claude"))
.build()
.unwrap();
let cmds = session.command_strings();
assert!(
!cmds.iter().any(|c| c.contains("pipe-pane")),
"session built without pipe_pane calls should have no pipe-pane commands"
);
}
#[test]
fn session_with_pipe_pane_differs_from_without() {
let session_without = TmuxSessionBuilder::new("proj")
.add_pane(make_pane("main", "/tmp/wt", "claude"))
.build()
.unwrap();
let cmds_without = session_without.command_strings();
let mut session_with = TmuxSessionBuilder::new("proj")
.add_pane(make_pane("main", "/tmp/wt", "claude"))
.build()
.unwrap();
let log_path = std::path::PathBuf::from("/repo/.git-paw/logs/paw-proj/main.log");
session_with.pipe_pane("paw-proj:0.0", &log_path);
let cmds_with = session_with.command_strings();
assert_ne!(
cmds_without, cmds_with,
"command lists should differ when pipe-pane is added"
);
assert!(
cmds_with.iter().any(|c| c.contains("pipe-pane")),
"session with pipe_pane should contain pipe-pane command"
);
}
#[test]
fn pipe_pane_appears_after_send_keys_for_pane() {
let mut session = TmuxSessionBuilder::new("proj")
.add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
.add_pane(make_pane("feat/api", "/tmp/wt2", "codex"))
.build()
.unwrap();
let log0 = std::path::PathBuf::from("/repo/logs/feat--auth.log");
let log1 = std::path::PathBuf::from("/repo/logs/feat--api.log");
session.pipe_pane("paw-proj:0.0", &log0);
session.pipe_pane("paw-proj:0.1", &log1);
let cmds = session.command_strings();
let last_send_keys = cmds
.iter()
.rposition(|c| c.contains("send-keys"))
.expect("should have send-keys");
let first_pipe_pane = cmds
.iter()
.position(|c| c.contains("pipe-pane"))
.expect("should have pipe-pane");
assert!(
first_pipe_pane > last_send_keys,
"pipe-pane commands (index {first_pipe_pane}) should appear after \
all send-keys commands (last at index {last_send_keys})"
);
}
#[test]
fn pipe_pane_appears_in_dry_run_output() {
let mut session = TmuxSessionBuilder::new("proj")
.add_pane(make_pane("main", "/tmp/wt", "claude"))
.build()
.unwrap();
let log_path = std::path::PathBuf::from("/repo/.git-paw/logs/paw-proj/main.log");
session.pipe_pane("paw-proj:0.0", &log_path);
let cmds = session.command_strings();
assert!(
cmds.iter().any(|c| c.starts_with("tmux pipe-pane")),
"dry-run output should include pipe-pane command"
);
}
#[test]
fn set_environment_emits_correct_tmux_command() {
let session = TmuxSessionBuilder::new("proj")
.add_pane(make_pane("main", "/tmp/wt", "claude"))
.set_environment("GIT_PAW_BROKER_URL", "http://127.0.0.1:9119")
.build()
.unwrap();
let cmds = session.command_strings();
let env_cmds = commands_containing(&cmds, "set-environment");
assert_eq!(env_cmds.len(), 1, "should have exactly one set-environment");
assert!(
env_cmds[0]
.contains("set-environment -t paw-proj GIT_PAW_BROKER_URL http://127.0.0.1:9119"),
"set-environment command should contain key and value, got: {}",
env_cmds[0]
);
}
#[test]
fn set_environment_appears_before_send_keys() {
let session = TmuxSessionBuilder::new("proj")
.add_pane(make_pane("feat/a", "/tmp/a", "claude"))
.add_pane(make_pane("feat/b", "/tmp/b", "codex"))
.set_environment("GIT_PAW_BROKER_URL", "http://127.0.0.1:9119")
.build()
.unwrap();
let cmds = session.command_strings();
let first_env = cmds
.iter()
.position(|c| c.contains("set-environment"))
.expect("should have set-environment");
let first_send = cmds
.iter()
.position(|c| c.contains("send-keys"))
.expect("should have send-keys");
assert!(
first_env < first_send,
"set-environment (index {first_env}) should appear before first send-keys (index {first_send})"
);
}
#[test]
fn multiple_env_vars_both_appear() {
let session = TmuxSessionBuilder::new("proj")
.add_pane(make_pane("main", "/tmp/wt", "claude"))
.set_environment("A", "1")
.set_environment("B", "2")
.build()
.unwrap();
let cmds = session.command_strings();
let env_cmds = commands_containing(&cmds, "set-environment");
assert_eq!(
env_cmds.len(),
2,
"should have two set-environment commands"
);
assert!(env_cmds[0].contains("A 1"));
assert!(env_cmds[1].contains("B 2"));
}
#[test]
fn set_environment_in_dry_run_output() {
let session = TmuxSessionBuilder::new("proj")
.add_pane(make_pane("main", "/tmp/wt", "claude"))
.set_environment("MY_VAR", "my_val")
.build()
.unwrap();
let cmds = session.command_strings();
assert!(
cmds.iter().any(|c| c.starts_with("tmux set-environment")),
"dry-run output should include set-environment command"
);
}
#[test]
#[serial_test::serial]
fn built_session_can_be_executed_and_killed() {
let project = "unit-test-execute";
let session_name = format!("paw-{project}");
cleanup_session(&session_name);
let session = TmuxSessionBuilder::new(project)
.add_pane(make_pane("main", "/tmp", "echo hello"))
.build()
.unwrap();
session.execute().unwrap();
assert!(is_session_alive(&session_name).unwrap());
kill_session(&session_name).unwrap();
assert!(!is_session_alive(&session_name).unwrap());
}
}