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>,
soft: bool,
}
impl TmuxCommand {
fn new(args: &[&str]) -> Self {
Self {
args: args.iter().map(|&s| s.to_owned()).collect(),
soft: false,
}
}
fn new_soft(args: &[&str]) -> Self {
Self {
args: args.iter().map(|&s| s.to_owned()).collect(),
soft: true,
}
}
#[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,
}
fn push_border_affordances(commands: &mut Vec<TmuxCommand>, session: &str) {
for (option, value) in [
("pane-border-lines", "double"),
("pane-border-style", "fg=colour238"),
("pane-active-border-style", "fg=colour45,bold"),
("pane-border-status", "top"),
(
"pane-border-format",
"#[fg=colour39,bold,reverse] #{pane_index}: #{?#{@paw_role},#{@paw_role},#{pane_title}} #[default]",
),
] {
commands.push(TmuxCommand::new_soft(&[
"set-option",
"-t",
session,
option,
value,
]));
}
}
fn push_pane_title(
commands: &mut Vec<TmuxCommand>,
border_affordances: bool,
target: &str,
title: &str,
) {
if border_affordances {
commands.push(TmuxCommand::new(&[
"select-pane",
"-t",
target,
"-T",
title,
]));
commands.push(TmuxCommand::new_soft(&[
"set-option",
"-p",
"-t",
target,
"@paw_role",
title,
]));
}
}
#[derive(Debug)]
pub struct TmuxSession {
pub name: String,
commands: Vec<TmuxCommand>,
}
impl TmuxSession {
pub fn execute(&self) -> Result<(), PawError> {
self.execute_with(|cmd| cmd.execute().map(|_| ()), |w| eprintln!("{w}"))
}
fn execute_with<R, W>(&self, mut run: R, mut warn: W) -> Result<(), PawError>
where
R: FnMut(&TmuxCommand) -> Result<(), PawError>,
W: FnMut(String),
{
for cmd in &self.commands {
if let Err(e) = run(cmd) {
if cmd.soft {
warn(format!(
"warning: tmux option not supported: {} ({e})",
cmd.args.join(" ")
));
} else {
return Err(e);
}
}
}
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
}
pub fn reapply_tiled_layout(&mut self, session_name: &str) -> &mut Self {
self.commands.push(TmuxCommand::new(&[
"select-layout",
"-t",
session_name,
"tiled",
]));
self
}
pub fn apply_dashboard_layout(&mut self, session_name: &str) -> &mut Self {
self.commands.push(TmuxCommand::new(&[
"select-layout",
"-t",
session_name,
"main-horizontal",
]));
self
}
}
#[derive(Debug)]
pub struct TmuxSessionBuilder {
project_name: String,
panes: Vec<PaneSpec>,
mouse_mode: bool,
border_affordances: 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,
border_affordances: 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 border_affordances(mut self, enabled: bool) -> Self {
self.border_affordances = 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,
"-x",
"480",
"-y",
"140",
"-c",
first_worktree,
]));
commands.push(TmuxCommand::new(&[
"set-option",
"-g",
"default-size",
"480x140",
]));
if self.mouse_mode {
commands.push(TmuxCommand::new(&[
"set-option",
"-t",
&session_name,
"mouse",
"on",
]));
}
if self.border_affordances {
push_border_affordances(&mut commands, &session_name);
}
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");
push_pane_title(
&mut commands,
self.border_affordances,
&pane_target,
&first.branch,
);
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,
"-c",
&pane.worktree,
]));
let pane_target = format!("{session_name}:0.{i}");
push_pane_title(
&mut commands,
self.border_affordances,
&pane_target,
&pane.branch,
);
commands.push(TmuxCommand::new(&[
"send-keys",
"-t",
&pane_target,
&pane.cli_command,
"Enter",
]));
}
if self.panes.len() > 1 && self.panes[0].branch == "dashboard" {
commands.push(TmuxCommand::new(&[
"select-layout",
"-t",
&session_name,
"main-horizontal",
]));
} else {
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())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionLiveness {
Alive,
Stale,
Indeterminate,
}
fn classify_liveness(spawned: bool, success: bool) -> SessionLiveness {
match (spawned, success) {
(false, _) => SessionLiveness::Indeterminate,
(true, true) => SessionLiveness::Alive,
(true, false) => SessionLiveness::Stale,
}
}
pub fn session_liveness(name: &str) -> SessionLiveness {
let spawn = Command::new("tmux")
.args(["has-session", "-t", name])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
match spawn {
Ok(status) => classify_liveness(true, status.success()),
Err(_) => classify_liveness(false, false),
}
}
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 detach_client(session_name: &str) -> Result<(), PawError> {
let output = Command::new("tmux")
.args(["detach-client", "-s", session_name])
.output()
.map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
if stderr.contains("no clients") || stderr.contains("no current client") {
return Ok(());
}
Err(PawError::TmuxError(
String::from_utf8_lossy(&output.stderr).trim().to_owned(),
))
}
pub fn kill_pane(session_name: &str, pane_index: u32) -> Result<(), PawError> {
let target = format!("{session_name}:0.{pane_index}");
let output = Command::new("tmux")
.args(["kill-pane", "-t", &target])
.output()
.map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
if stderr.contains("can't find pane")
|| stderr.contains("no such pane")
|| stderr.contains("pane not found")
{
return Ok(());
}
Err(PawError::TmuxError(
String::from_utf8_lossy(&output.stderr).trim().to_owned(),
))
}
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()))
}
}
pub fn build_boot_inject_args(session_name: &str, pane_index: usize, text: &str) -> Vec<String> {
vec![
"send-keys".to_string(),
"-l".to_string(),
"-t".to_string(),
format!("{session_name}:0.{pane_index}"),
text.to_string(),
]
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
pub fn build_supervisor_session(
project_name: &str,
session_name_override: Option<String>,
supervisor: &PaneSpec,
dashboard: &PaneSpec,
agents: &[PaneSpec],
layout: crate::supervisor::layout::SupervisorLayout,
mouse_mode: bool,
border_affordances: bool,
env_vars: &[(String, String)],
) -> Result<TmuxSession, PawError> {
use crate::supervisor::layout::{SUPERVISOR_AGENTS_PER_ROW, SUPERVISOR_PANE_OFFSET};
let session_name = session_name_override.unwrap_or_else(|| format!("paw-{project_name}"));
let mut commands: Vec<TmuxCommand> = Vec::new();
let push = |cmds: &mut Vec<TmuxCommand>, parts: &[&str]| {
cmds.push(TmuxCommand::new(parts));
};
push(
&mut commands,
&[
"new-session",
"-d",
"-s",
&session_name,
"-x",
"480",
"-y",
"140",
"-e",
"DISABLE_AUTO_UPDATE=true",
"-e",
"DISABLE_UPDATE_PROMPT=true",
"-c",
&supervisor.worktree,
],
);
push(
&mut commands,
&["set-option", "-g", "default-size", "480x140"],
);
push(
&mut commands,
&[
"set-environment",
"-t",
&session_name,
"DISABLE_AUTO_UPDATE",
"true",
],
);
push(
&mut commands,
&[
"set-environment",
"-t",
&session_name,
"DISABLE_UPDATE_PROMPT",
"true",
],
);
if mouse_mode {
push(
&mut commands,
&["set-option", "-t", &session_name, "mouse", "on"],
);
}
if border_affordances {
push_border_affordances(&mut commands, &session_name);
}
for (key, value) in env_vars {
push(
&mut commands,
&["set-environment", "-t", &session_name, key, value],
);
}
let supervisor_target = format!("{session_name}:0.0");
push_pane_title(
&mut commands,
border_affordances,
&supervisor_target,
&supervisor.branch,
);
push(
&mut commands,
&["send-keys", "-t", &supervisor_target, "C-u"],
);
push(
&mut commands,
&[
"send-keys",
"-t",
&supervisor_target,
&supervisor.cli_command,
"Enter",
],
);
let bottom_pct = format!("{}%", 100u16 - u16::from(layout.top_row_pct));
if agents.is_empty() {
push(
&mut commands,
&[
"split-window",
"-v",
"-t",
&supervisor_target,
"-l",
&bottom_pct,
],
);
} else {
push(
&mut commands,
&[
"split-window",
"-v",
"-t",
&supervisor_target,
"-l",
&bottom_pct,
"-c",
&dashboard.worktree,
],
);
}
let dashboard_split_cwd = agents
.first()
.map_or(dashboard.worktree.as_str(), |a| a.worktree.as_str());
push(
&mut commands,
&[
"split-window",
"-h",
"-t",
&supervisor_target,
"-l",
"50%",
"-c",
dashboard_split_cwd,
],
);
let pane_one = format!("{session_name}:0.1");
let pane_two = format!("{session_name}:0.2");
push(
&mut commands,
&["swap-pane", "-s", &pane_one, "-t", &pane_two],
);
let dashboard_target = format!("{session_name}:0.1");
push_pane_title(
&mut commands,
border_affordances,
&dashboard_target,
&dashboard.branch,
);
push(
&mut commands,
&["send-keys", "-t", &dashboard_target, "C-u"],
);
push(
&mut commands,
&[
"send-keys",
"-t",
&dashboard_target,
&dashboard.cli_command,
"Enter",
],
);
if !agents.is_empty() {
let first_target = format!("{session_name}:0.{SUPERVISOR_PANE_OFFSET}");
let first = &agents[0];
push_pane_title(
&mut commands,
border_affordances,
&first_target,
&first.branch,
);
push(&mut commands, &["send-keys", "-t", &first_target, "C-u"]);
push(
&mut commands,
&[
"send-keys",
"-t",
&first_target,
&first.cli_command,
"Enter",
],
);
let mut row_first_pane = SUPERVISOR_PANE_OFFSET;
for (i, agent) in agents.iter().enumerate().skip(1) {
let pane_idx = SUPERVISOR_PANE_OFFSET + i;
let pane_target = format!("{session_name}:0.{pane_idx}");
let position_in_row = i % SUPERVISOR_AGENTS_PER_ROW;
let starts_new_row = position_in_row == 0;
if starts_new_row {
let src_target = format!("{session_name}:0.{row_first_pane}");
push(
&mut commands,
&[
"split-window",
"-v",
"-t",
&src_target,
"-c",
&agent.worktree,
],
);
row_first_pane = pane_idx;
} else {
let prev_idx = pane_idx - 1;
let prev_target = format!("{session_name}:0.{prev_idx}");
push(
&mut commands,
&[
"split-window",
"-h",
"-t",
&prev_target,
"-c",
&agent.worktree,
],
);
}
push_pane_title(
&mut commands,
border_affordances,
&pane_target,
&agent.branch,
);
push(&mut commands, &["send-keys", "-t", &pane_target, "C-u"]);
push(
&mut commands,
&["send-keys", "-t", &pane_target, &agent.cli_command, "Enter"],
);
}
}
push_supervisor_resize_pass(&mut commands, &session_name, layout, agents.len());
Ok(TmuxSession {
name: session_name,
commands,
})
}
#[must_use]
pub fn build_add_agent_commands(
session_name: &str,
new_agent: &PaneSpec,
prev_agent_count: usize,
layout: crate::supervisor::layout::SupervisorLayout,
border_affordances: bool,
) -> TmuxSession {
use crate::supervisor::layout::{SUPERVISOR_AGENTS_PER_ROW, SUPERVISOR_PANE_OFFSET};
let mut commands: Vec<TmuxCommand> = Vec::new();
let i = prev_agent_count; let pane_idx = SUPERVISOR_PANE_OFFSET + i;
let pane_target = format!("{session_name}:0.{pane_idx}");
if i > 0 && i.is_multiple_of(SUPERVISOR_AGENTS_PER_ROW) {
let prev_row_first = SUPERVISOR_PANE_OFFSET + (i - SUPERVISOR_AGENTS_PER_ROW);
let src = format!("{session_name}:0.{prev_row_first}");
commands.push(TmuxCommand::new(&[
"split-window",
"-v",
"-t",
&src,
"-c",
&new_agent.worktree,
]));
} else {
let prev = format!("{session_name}:0.{}", pane_idx - 1);
commands.push(TmuxCommand::new(&[
"split-window",
"-h",
"-t",
&prev,
"-c",
&new_agent.worktree,
]));
}
push_pane_title(
&mut commands,
border_affordances,
&pane_target,
&new_agent.branch,
);
commands.push(TmuxCommand::new(&["send-keys", "-t", &pane_target, "C-u"]));
commands.push(TmuxCommand::new(&[
"send-keys",
"-t",
&pane_target,
&new_agent.cli_command,
"Enter",
]));
push_supervisor_resize_pass(&mut commands, session_name, layout, prev_agent_count + 1);
TmuxSession {
name: session_name.to_string(),
commands,
}
}
#[must_use]
pub fn build_remove_retile_commands(
session_name: &str,
remaining_agent_count: usize,
layout: crate::supervisor::layout::SupervisorLayout,
) -> TmuxSession {
let mut commands: Vec<TmuxCommand> = Vec::new();
if remaining_agent_count > 0 {
push_supervisor_resize_pass(&mut commands, session_name, layout, remaining_agent_count);
}
TmuxSession {
name: session_name.to_string(),
commands,
}
}
fn push_supervisor_resize_pass(
commands: &mut Vec<TmuxCommand>,
session_name: &str,
layout: crate::supervisor::layout::SupervisorLayout,
agent_count: usize,
) {
use crate::supervisor::layout::{SUPERVISOR_AGENTS_PER_ROW, SUPERVISOR_PANE_OFFSET};
let top_target = format!("{session_name}:0.0");
let top_pct_str = format!("{}%", layout.top_row_pct);
commands.push(TmuxCommand::new(&[
"resize-pane",
"-t",
&top_target,
"-y",
&top_pct_str,
]));
let agent_row_pct_str = format_supervisor_pct(layout.agent_row_pct);
for row in 0..layout.agent_rows {
let pane_idx = SUPERVISOR_PANE_OFFSET + row * SUPERVISOR_AGENTS_PER_ROW;
if pane_idx < SUPERVISOR_PANE_OFFSET + agent_count {
let target = format!("{session_name}:0.{pane_idx}");
commands.push(TmuxCommand::new(&[
"resize-pane",
"-t",
&target,
"-y",
&agent_row_pct_str,
]));
}
}
}
fn format_supervisor_pct(pct: f32) -> String {
if (pct - pct.round()).abs() < 0.05 {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let rounded = pct.round().clamp(0.0, 100.0) as u32;
format!("{rounded}%")
} else {
format!("{pct:.1}%")
}
}
#[must_use]
pub fn build_supervisor_submit_argv_pair(
session_name: &str,
pane_index: usize,
prompt: &str,
) -> (Vec<String>, Vec<String>) {
let target = format!("{session_name}:0.{pane_index}");
let first = vec![
"send-keys".to_string(),
"-t".to_string(),
target.clone(),
prompt.to_string(),
"Enter".to_string(),
];
let second = vec![
"send-keys".to_string(),
"-t".to_string(),
target,
"Enter".to_string(),
];
(first, second)
}
#[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 new_session_passes_explicit_x_and_y() {
let session = TmuxSessionBuilder::new("app")
.add_pane(make_pane("main", "/tmp/wt", "claude"))
.build()
.unwrap();
let cmds = session.command_strings();
let new_session_cmd = cmds
.iter()
.find(|c| c.contains("new-session"))
.expect("new-session command present");
assert!(
new_session_cmd.contains("-x 480"),
"new-session must pass -x 480; got: {new_session_cmd}"
);
assert!(
new_session_cmd.contains("-y 140"),
"new-session must pass -y 140; got: {new_session_cmd}"
);
}
#[test]
fn basic_builder_sets_default_size_after_new_session() {
let session = TmuxSessionBuilder::new("app")
.add_pane(make_pane("main", "/tmp/wt", "claude"))
.build()
.unwrap();
let cmds = session.command_strings();
let new_session_idx = cmds
.iter()
.position(|c| c.contains("new-session"))
.expect("new-session in command list");
let default_size_idx = cmds
.iter()
.position(|c| {
c.contains("set-option") && c.contains("default-size") && c.contains("480x140")
})
.expect("set-option default-size 200x50 in command list");
assert!(
default_size_idx > new_session_idx,
"set-option default-size must come AFTER new-session (set-option needs a running server); got order new={new_session_idx}, default-size={default_size_idx}"
);
}
#[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: Vec<String> = commands_containing(&cmds, "send-keys")
.into_iter()
.filter(|c| !c.trim_end().ends_with("C-u"))
.collect();
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: Vec<String> = commands_containing(&cmds, "send-keys")
.into_iter()
.filter(|c| !c.trim_end().ends_with("C-u"))
.collect();
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_bare_cli_command_and_split_carries_worktree() {
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: Vec<String> = commands_containing(&cmds, "send-keys")
.into_iter()
.filter(|c| !c.trim_end().ends_with("C-u"))
.collect();
assert!(
send_keys[0].contains("claude"),
"first pane should run claude; got: {}",
send_keys[0]
);
assert!(
send_keys[1].contains("gemini"),
"second pane should run gemini; got: {}",
send_keys[1]
);
assert!(
!send_keys[1].contains("cd /home/user/wt-api"),
"second pane send-keys MUST NOT prefix `cd <worktree>`; got: {}",
send_keys[1]
);
let splits = commands_containing(&cmds, "split-window");
assert!(
splits.iter().any(|c| c.contains("-c /home/user/wt-api")),
"split-window for pane 1 should pass -c /home/user/wt-api; got: {splits:?}"
);
}
#[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: Vec<String> = commands_containing(&cmds, "send-keys")
.into_iter()
.filter(|c| !c.trim_end().ends_with("C-u"))
.collect();
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: Vec<String> = commands_containing(&cmds, "send-keys")
.into_iter()
.filter(|c| !c.trim_end().ends_with("C-u"))
.collect();
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_its_branch() {
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].ends_with("-T feat/auth"),
"first pane title should be 'feat/auth', got: {}",
select_panes[0]
);
assert!(
!select_panes[0].contains("claude"),
"first pane title should not include the CLI command, got: {}",
select_panes[0]
);
assert!(
select_panes[1].ends_with("-T fix/api"),
"second pane title should be 'fix/api', got: {}",
select_panes[1]
);
}
#[test]
fn each_pane_gets_a_stable_paw_role_option() {
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 role_opts: Vec<&String> = cmds
.iter()
.filter(|c| c.contains("set-option") && c.contains(" -p ") && c.contains("@paw_role"))
.collect();
assert_eq!(
role_opts.len(),
2,
"each pane should get a @paw_role option"
);
assert!(
role_opts.iter().any(|c| c.ends_with("@paw_role feat/auth")),
"first pane should set `@paw_role feat/auth` pane-scoped; got: {role_opts:#?}"
);
assert!(
role_opts.iter().any(|c| c.ends_with("@paw_role fix/api")),
"second pane should set `@paw_role fix/api`; got: {role_opts:#?}"
);
}
#[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"
);
}
const AFFORDANCE_OPTIONS: [(&str, &str); 5] = [
("pane-border-lines", "double"),
("pane-border-style", "fg=colour238"),
("pane-active-border-style", "fg=colour45,bold"),
("pane-border-status", "top"),
(
"pane-border-format",
"#[fg=colour39,bold,reverse] #{pane_index}: #{?#{@paw_role},#{@paw_role},#{pane_title}} #[default]",
),
];
#[test]
fn builder_emits_all_five_affordances_scoped_to_session_by_default() {
let session = TmuxSessionBuilder::new("aff-default")
.add_pane(make_pane("feat/a", "/tmp/wt", "claude"))
.build()
.unwrap();
let cmds = session.command_strings();
for (option, value) in AFFORDANCE_OPTIONS {
assert!(
cmds.iter().any(|c| c.contains("set-option")
&& c.contains("-t paw-aff-default")
&& c.contains(option)
&& c.contains(value)),
"expected `set-option -t paw-aff-default {option} {value}`; cmds:\n{cmds:#?}"
);
}
}
#[test]
fn border_format_is_index_then_role_with_padding() {
let session = TmuxSessionBuilder::new("fmt")
.add_pane(make_pane("feat/a", "/tmp/wt", "claude"))
.build()
.unwrap();
let format_cmd = session
.command_strings()
.into_iter()
.find(|c| c.contains("pane-border-format"))
.expect("pane-border-format set-option present");
assert!(
format_cmd.ends_with(
"pane-border-format #[fg=colour39,bold,reverse] #{pane_index}: #{?#{@paw_role},#{@paw_role},#{pane_title}} #[default]"
),
"format must be the reverse-video label bar preferring @paw_role; got: {format_cmd}"
);
}
#[test]
fn active_and_inactive_border_styles_applied() {
let session = TmuxSessionBuilder::new("styles")
.add_pane(make_pane("feat/a", "/tmp/wt", "claude"))
.build()
.unwrap();
let cmds = session.command_strings();
assert!(
cmds.iter()
.any(|c| c.contains("pane-active-border-style") && c.contains("colour45,bold")),
"active border must be colour45,bold; cmds:\n{cmds:#?}"
);
assert!(
cmds.iter()
.any(|c| c.contains("pane-border-style") && c.contains("colour238")),
"inactive border must be colour238; cmds:\n{cmds:#?}"
);
}
#[test]
fn opt_out_omits_every_affordance_and_title() {
let session = TmuxSessionBuilder::new("opt-out")
.add_pane(make_pane("feat/a", "/tmp/wt", "claude"))
.add_pane(make_pane("feat/b", "/tmp/wt2", "gemini"))
.border_affordances(false)
.build()
.unwrap();
let cmds = session.command_strings();
for (option, _value) in AFFORDANCE_OPTIONS {
assert!(
!cmds
.iter()
.any(|c| c.contains("set-option") && c.contains(option)),
"opt-out must not emit set-option {option}; cmds:\n{cmds:#?}"
);
}
assert!(
!cmds
.iter()
.any(|c| c.contains("select-pane") && c.contains("-T")),
"opt-out must not set any pane title; cmds:\n{cmds:#?}"
);
assert!(
!cmds.iter().any(|c| c.contains("@paw_role")),
"opt-out must not set the @paw_role pane option; cmds:\n{cmds:#?}"
);
assert_eq!(
commands_containing(&cmds, "send-keys").len(),
2,
"both panes still receive their CLI send-keys"
);
}
#[test]
fn soft_affordance_failure_warns_and_continues() {
let session = TmuxSessionBuilder::new("degrade")
.add_pane(make_pane("feat/a", "/tmp/wt", "claude"))
.build()
.unwrap();
let mut ran: Vec<String> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
let result = session.execute_with(
|cmd| {
let s = cmd.as_command_string();
ran.push(s.clone());
if s.contains("pane-border-lines double") {
Err(PawError::TmuxError(
"unknown option: pane-border-lines".into(),
))
} else {
Ok(())
}
},
|w| warnings.push(w),
);
assert!(result.is_ok(), "soft failure must not abort the build");
assert!(
warnings.iter().any(|w| w.contains("pane-border-lines")),
"a warning naming the unsupported option must be emitted; warnings: {warnings:#?}"
);
assert!(
ran.iter().any(|c| c.contains("pane-active-border-style")),
"active-border-style must still be applied after the double-line failure"
);
assert!(
ran.iter().any(|c| c.contains("pane-border-status top")),
"pane-border-status must still be applied after the double-line failure"
);
}
#[test]
fn hard_command_failure_aborts() {
let session = TmuxSessionBuilder::new("hard-fail")
.add_pane(make_pane("feat/a", "/tmp/wt", "claude"))
.build()
.unwrap();
let result = session.execute_with(
|cmd| {
if cmd.as_command_string().contains("new-session") {
Err(PawError::TmuxError("server unreachable".into()))
} else {
Ok(())
}
},
|_| {},
);
assert!(result.is_err(), "a hard command failure must propagate");
}
#[test]
fn supervisor_session_titles_are_roles_and_emits_affordances() {
let layout = crate::supervisor::layout::supervisor_layout(2).expect("layout");
let supervisor = make_pane("supervisor", "/repo", "claude");
let dashboard = make_pane("dashboard", "/repo", "git-paw __dashboard");
let agent = make_pane("feat/foo", "/tmp/wt", "claude");
let session = build_supervisor_session(
"sup",
None,
&supervisor,
&dashboard,
&[agent],
layout,
true,
true,
&[],
)
.expect("session builds");
let cmds = session.command_strings();
for (option, value) in AFFORDANCE_OPTIONS {
assert!(
cmds.iter().any(|c| c.contains("set-option")
&& c.contains("-t paw-sup")
&& c.contains(option)
&& c.contains(value)),
"supervisor session missing `set-option {option} {value}`; cmds:\n{cmds:#?}"
);
}
let title_for = |target: &str| -> String {
cmds.iter()
.find(|c| c.contains("select-pane") && c.contains(target) && c.contains("-T"))
.unwrap_or_else(|| panic!("no title set for {target}; cmds:\n{cmds:#?}"))
.clone()
};
assert!(title_for(":0.0").ends_with("-T supervisor"), "pane 0 title");
assert!(title_for(":0.1").ends_with("-T dashboard"), "pane 1 title");
assert!(
title_for(":0.2").ends_with("-T feat/foo"),
"agent pane title"
);
}
#[test]
fn supervisor_build_suppresses_startup_prompts_and_clears_input() {
let layout = crate::supervisor::layout::supervisor_layout(1).expect("layout");
let supervisor = make_pane("supervisor", "/repo", "claude");
let dashboard = make_pane("dashboard", "/repo", "git-paw __dashboard");
let agent = make_pane("feat/foo", "/tmp/wt", "claude");
let session = build_supervisor_session(
"sup",
None,
&supervisor,
&dashboard,
&[agent],
layout,
true,
true,
&[],
)
.expect("session builds");
let cmds = session.command_strings();
assert!(
cmds.iter()
.any(|c| c.contains("new-session") && c.contains("DISABLE_AUTO_UPDATE=true")),
"new-session must set DISABLE_AUTO_UPDATE for pane 0; cmds:\n{cmds:#?}"
);
assert!(
cmds.iter().any(|c| c.contains("set-environment")
&& c.contains("DISABLE_AUTO_UPDATE")
&& c.contains("true")),
"session env must carry DISABLE_AUTO_UPDATE for split panes"
);
let clear_idx = cmds.iter().position(|c| {
c.contains("send-keys") && c.contains(":0.0") && c.trim_end().ends_with("C-u")
});
let launch_idx = cmds.iter().position(|c| {
c.contains("send-keys")
&& c.contains(":0.0")
&& c.contains("claude")
&& c.contains("Enter")
});
let (clear_idx, launch_idx) = (
clear_idx.expect("a C-u clear is sent to pane 0"),
launch_idx.expect("the CLI-launch command is sent to pane 0"),
);
assert!(
clear_idx < launch_idx,
"the C-u clear must precede the CLI-launch command on pane 0"
);
}
#[test]
fn supervisor_build_compensates_first_agent_cwd_for_swap() {
let layout = crate::supervisor::layout::supervisor_layout(2).expect("layout");
let supervisor = make_pane("supervisor", "/repo", "claude");
let dashboard = make_pane("dashboard", "/repo", "git-paw __dashboard");
let a0 = make_pane("feat/foo", "/tmp/wt-foo", "claude");
let a1 = make_pane("feat/bar", "/tmp/wt-bar", "claude");
let session = build_supervisor_session(
"sup",
None,
&supervisor,
&dashboard,
&[a0, a1],
layout,
true,
true,
&[],
)
.expect("session builds");
let cmds = session.command_strings();
let vsplit = cmds
.iter()
.find(|c| c.contains("split-window") && c.contains("-v") && c.contains("-c"))
.expect("agent-area -v split with -c");
let hsplit = cmds
.iter()
.find(|c| c.contains("split-window") && c.contains("-h") && c.contains("-c"))
.expect("dashboard -h split with -c");
assert!(
vsplit.contains("-c /repo"),
"agent-area -v split must use the dashboard cwd (swap compensation); got: {vsplit}"
);
assert!(
hsplit.contains("-c /tmp/wt-foo"),
"dashboard -h split must use the first agent's worktree (swap compensation); got: {hsplit}"
);
}
#[test]
fn supervisor_session_opt_out_omits_affordances() {
let layout = crate::supervisor::layout::supervisor_layout(1).expect("layout");
let supervisor = make_pane("supervisor", "/repo", "claude");
let dashboard = make_pane("dashboard", "/repo", "git-paw __dashboard");
let agent = make_pane("feat/foo", "/tmp/wt", "claude");
let session = build_supervisor_session(
"sup-off",
None,
&supervisor,
&dashboard,
&[agent],
layout,
true,
false,
&[],
)
.expect("session builds");
let cmds = session.command_strings();
for (option, _value) in AFFORDANCE_OPTIONS {
assert!(
!cmds
.iter()
.any(|c| c.contains("set-option") && c.contains(option)),
"opt-out supervisor session must not emit set-option {option}"
);
}
assert!(
!cmds
.iter()
.any(|c| c.contains("select-pane") && c.contains("-T")),
"opt-out supervisor session must not set pane titles"
);
}
#[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, "-x", "200", "-y", "50"])
.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]
fn classify_liveness_maps_each_branch() {
assert_eq!(classify_liveness(true, true), SessionLiveness::Alive);
assert_eq!(classify_liveness(true, false), SessionLiveness::Stale);
assert_eq!(
classify_liveness(false, false),
SessionLiveness::Indeterminate
);
assert_eq!(
classify_liveness(false, true),
SessionLiveness::Indeterminate
);
}
#[test]
#[serial_test::serial]
fn session_liveness_reports_stale_for_nonexistent() {
assert_eq!(
session_liveness("paw-definitely-does-not-exist-98765"),
SessionLiveness::Stale
);
}
#[test]
#[serial_test::serial]
fn session_liveness_reports_alive_then_stale_across_lifecycle() {
let name = "paw-unit-test-liveness-probe";
cleanup_session(name);
create_test_session(name);
assert_eq!(session_liveness(name), SessionLiveness::Alive);
kill_session(name).unwrap();
assert_eq!(session_liveness(name), SessionLiveness::Stale);
}
#[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]
fn session_without_dashboard_uses_tiled_layout() {
let session = TmuxSessionBuilder::new("proj")
.add_pane(make_pane("feat/a", "/tmp/a", "claude"))
.add_pane(make_pane("feat/b", "/tmp/b", "codex"))
.build()
.unwrap();
let cmds = session.command_strings();
let layout_cmds: Vec<&String> = cmds
.iter()
.filter(|c| c.contains("select-layout"))
.collect();
let final_layout = layout_cmds
.last()
.expect("should have at least one select-layout");
assert!(
final_layout.contains("tiled"),
"sessions without dashboard should use tiled layout, got: {final_layout}"
);
}
#[test]
fn session_with_dashboard_uses_main_horizontal_layout() {
let session = TmuxSessionBuilder::new("proj")
.add_pane(make_pane("dashboard", "/tmp/repo", "git-paw __dashboard"))
.add_pane(make_pane("feat/a", "/tmp/a", "claude"))
.add_pane(make_pane("feat/b", "/tmp/b", "codex"))
.build()
.unwrap();
let cmds = session.command_strings();
let layout_cmds: Vec<&String> = cmds
.iter()
.filter(|c| c.contains("select-layout"))
.collect();
let final_layout = layout_cmds
.last()
.expect("should have at least one select-layout");
assert!(
final_layout.contains("main-horizontal"),
"sessions with dashboard should use main-horizontal layout, got: {final_layout}"
);
}
#[test]
fn single_pane_session_uses_tiled_layout() {
let session = TmuxSessionBuilder::new("proj")
.add_pane(make_pane("main", "/tmp/wt", "claude"))
.build()
.unwrap();
let cmds = session.command_strings();
let layout_cmds: Vec<&String> = cmds
.iter()
.filter(|c| c.contains("select-layout"))
.collect();
let final_layout = layout_cmds
.last()
.expect("should have at least one select-layout");
assert!(
final_layout.contains("tiled"),
"single pane sessions should use tiled layout, got: {final_layout}"
);
}
#[test]
fn dashboard_layout_appears_in_dry_run_output() {
let session = TmuxSessionBuilder::new("proj")
.add_pane(make_pane("dashboard", "/tmp/repo", "git-paw __dashboard"))
.add_pane(make_pane("feat/a", "/tmp/a", "claude"))
.build()
.unwrap();
let cmds = session.command_strings();
assert!(
cmds.iter().any(|c| c.contains("main-horizontal")),
"dry-run output should include main-horizontal layout command"
);
}
struct PausePaneSession {
name: String,
}
impl PausePaneSession {
fn new(label: &str) -> Self {
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let name = format!("paw-pause-test-{label}-{pid}-{nanos}");
let output = std::process::Command::new("tmux")
.args(["new-session", "-d", "-s", &name, "-x", "200", "-y", "50"])
.output()
.expect("create tmux test session");
assert!(
output.status.success(),
"failed to create test session '{name}'"
);
Self { name }
}
}
impl Drop for PausePaneSession {
fn drop(&mut self) {
let _ = kill_session(&self.name);
}
}
#[test]
#[serial_test::serial]
fn detach_client_succeeds_on_attached_session() {
let session = PausePaneSession::new("detach-attached");
detach_client(&session.name).expect("detach should succeed");
assert!(is_session_alive(&session.name).unwrap());
}
#[test]
#[serial_test::serial]
fn detach_client_is_noop_with_no_clients() {
let session = PausePaneSession::new("detach-noop");
detach_client(&session.name).expect("first detach should succeed");
detach_client(&session.name).expect("second detach should succeed");
assert!(is_session_alive(&session.name).unwrap());
}
#[test]
#[serial_test::serial]
fn detach_client_noop_when_no_clients_attached() {
let session = PausePaneSession::new("detach-9-11");
detach_client(&session.name).expect("detach with no clients should be Ok");
assert!(is_session_alive(&session.name).unwrap());
}
#[test]
#[serial_test::serial]
fn kill_pane_removes_pane() {
let session = PausePaneSession::new("killpane");
let _ = std::process::Command::new("tmux")
.args(["split-window", "-t", &session.name])
.output();
let pane_count_before = std::process::Command::new("tmux")
.args(["list-panes", "-t", &session.name, "-F", "#{pane_index}"])
.output()
.map_or(0, |o| String::from_utf8_lossy(&o.stdout).lines().count());
assert_eq!(pane_count_before, 2, "should have 2 panes before kill");
kill_pane(&session.name, 1).expect("kill_pane should succeed");
let pane_count_after = std::process::Command::new("tmux")
.args(["list-panes", "-t", &session.name, "-F", "#{pane_index}"])
.output()
.map_or(0, |o| String::from_utf8_lossy(&o.stdout).lines().count());
assert_eq!(pane_count_after, 1, "should have 1 pane after kill");
}
#[test]
#[serial_test::serial]
fn kill_pane_is_noop_for_missing_pane() {
let session = PausePaneSession::new("killpane-missing");
kill_pane(&session.name, 99).expect("kill missing pane should be ok");
assert!(is_session_alive(&session.name).unwrap());
}
#[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());
}
#[test]
fn supervisor_submit_argv_pair_has_two_invocations() {
let (first, second) = build_supervisor_submit_argv_pair("paw-proj", 3, "do the thing");
assert!(!first.is_empty(), "first send-keys argv must be non-empty");
assert!(
!second.is_empty(),
"second send-keys argv must be non-empty"
);
}
#[test]
fn supervisor_submit_first_invocation_sends_prompt_and_enter() {
let (first, _second) = build_supervisor_submit_argv_pair("paw-proj", 3, "do the thing");
assert_eq!(first[0], "send-keys");
assert_eq!(first[1], "-t");
assert_eq!(first[2], "paw-proj:0.3");
assert_eq!(first[3], "do the thing");
assert_eq!(first[4], "Enter");
}
#[test]
fn supervisor_submit_second_invocation_is_enter_only() {
let (_first, second) = build_supervisor_submit_argv_pair("paw-proj", 3, "do the thing");
assert_eq!(second[0], "send-keys");
assert_eq!(second[1], "-t");
assert_eq!(second[2], "paw-proj:0.3");
assert_eq!(second[3], "Enter");
assert_eq!(
second.len(),
4,
"second invocation should be send-keys -t <target> Enter (no prompt)"
);
}
#[test]
fn supervisor_submit_targets_same_pane_in_both_invocations() {
let (first, second) = build_supervisor_submit_argv_pair("paw-proj", 7, "prompt");
assert_eq!(first[2], second[2]);
assert_eq!(first[2], "paw-proj:0.7");
}
#[test]
fn supervisor_submit_argv_pair_preserves_prompt_with_newlines_and_quotes() {
let prompt = "line1\nline2 with \"quoted\" text";
let (first, _second) = build_supervisor_submit_argv_pair("paw-proj", 1, prompt);
assert_eq!(first[3], prompt);
}
#[test]
fn cmd_supervisor_inject_argv_has_single_enter_per_pane() {
let panes: Vec<(usize, &str)> = vec![(2, "p2"), (3, "p3"), (4, "p4")];
let mut total_enters = 0;
for (pane_idx, prompt) in &panes {
let (first, _second) = build_supervisor_submit_argv_pair("paw-proj", *pane_idx, prompt);
let enter_positions: Vec<usize> = first
.iter()
.enumerate()
.filter(|(_, tok)| tok.as_str() == "Enter")
.map(|(i, _)| i)
.collect();
assert_eq!(
enter_positions.len(),
1,
"each per-pane invocation must send exactly one Enter; got argv: {first:?}"
);
let enter_pos = enter_positions[0];
assert!(
enter_pos > 0,
"Enter token must follow a prompt-string argument; got argv: {first:?}"
);
assert_eq!(
first[enter_pos - 1].as_str(),
*prompt,
"Enter token must directly follow the prompt argument; got argv: {first:?}"
);
total_enters += enter_positions.len();
}
assert_eq!(
total_enters, 3,
"for N=3 panes the launch flow must send exactly N=3 Enters"
);
}
fn make_layout_panes(n: usize) -> (PaneSpec, PaneSpec, Vec<PaneSpec>) {
let supervisor = make_pane("supervisor", "/repo", "claude");
let dashboard = make_pane("dashboard", "/repo", "git-paw __dashboard");
let agents = (0..n)
.map(|i| make_pane(&format!("feat/b{i}"), &format!("/tmp/wt{i}"), "claude"))
.collect();
(supervisor, dashboard, agents)
}
fn build_for(agent_count: usize) -> TmuxSession {
let layout =
crate::supervisor::layout::supervisor_layout(agent_count).expect("layout computes");
let (supervisor, dashboard, agents) = make_layout_panes(agent_count);
build_supervisor_session(
"proj",
None,
&supervisor,
&dashboard,
&agents,
layout,
true,
true,
&[("GIT_PAW_BROKER_URL".to_string(), "http://x".to_string())],
)
.expect("session builds")
}
#[test]
fn supervisor_layout_5_agents_single_row() {
let session = build_for(5);
let cmds = session.command_strings();
let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
.into_iter()
.filter(|c| !c.trim_end().ends_with("C-u"))
.collect();
assert_eq!(
send_keys.len(),
7,
"5 agents → 1 supervisor + 1 dashboard + 5 agents = 7 send-keys, got {send_keys:#?}"
);
let supervisor_pane = send_keys
.iter()
.find(|c| c.contains("0.0 "))
.unwrap_or(&send_keys[0]);
assert!(supervisor_pane.contains("claude"));
let dashboard_pane = send_keys
.iter()
.find(|c| c.contains(":0.1 ") && c.contains("__dashboard"))
.expect("dashboard send-keys at pane :0.1");
let _ = dashboard_pane;
let resizes = commands_containing(&cmds, "resize-pane");
assert!(
resizes
.iter()
.any(|c| c.contains(":0.0") && c.contains("60%")),
"top row resize to 60%, got resizes {resizes:#?}"
);
assert!(
resizes
.iter()
.any(|c| c.contains(":0.2") && c.contains("40%")),
"agent-row resize to 40% at :0.2, got resizes {resizes:#?}"
);
}
#[test]
fn supervisor_layout_10_agents_two_rows() {
let session = build_for(10);
let cmds = session.command_strings();
let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
.into_iter()
.filter(|c| !c.trim_end().ends_with("C-u"))
.collect();
assert_eq!(
send_keys.len(),
12,
"10 agents → 1 supervisor + 1 dashboard + 10 agents = 12 send-keys"
);
let resizes = commands_containing(&cmds, "resize-pane");
assert!(
resizes
.iter()
.any(|c| c.contains(":0.0") && c.contains("40%"))
);
assert!(
resizes.iter().filter(|c| c.contains("30%")).count() >= 2,
"two agent rows at 30% each, got {resizes:#?}"
);
}
#[test]
fn supervisor_layout_11_agents_three_rows() {
let session = build_for(11);
let cmds = session.command_strings();
let resizes = commands_containing(&cmds, "resize-pane");
assert!(
resizes
.iter()
.any(|c| c.contains(":0.0") && c.contains("28%"))
);
assert!(
resizes.iter().filter(|c| c.contains("24%")).count() >= 3,
"three agent rows at 24% each, got {resizes:#?}"
);
let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
.into_iter()
.filter(|c| !c.trim_end().ends_with("C-u"))
.collect();
assert_eq!(send_keys.len(), 13);
assert!(send_keys.iter().any(|c| c.contains(":0.12 ")));
}
#[test]
fn supervisor_layout_20_agents_four_rows() {
let session = build_for(20);
let cmds = session.command_strings();
let resizes = commands_containing(&cmds, "resize-pane");
assert!(
resizes
.iter()
.any(|c| c.contains(":0.0") && c.contains("28%"))
);
assert!(
resizes.iter().filter(|c| c.contains("18%")).count() >= 4,
"four agent rows at 18% each, got {resizes:#?}"
);
}
#[test]
fn supervisor_layout_25_agents_five_rows() {
let session = build_for(25);
let cmds = session.command_strings();
let resizes = commands_containing(&cmds, "resize-pane");
assert!(
resizes
.iter()
.any(|c| c.contains(":0.0") && c.contains("28%"))
);
assert!(
resizes.iter().filter(|c| c.contains("14.4%")).count() >= 5,
"five agent rows at 14.4% each, got {resizes:#?}"
);
}
#[test]
fn supervisor_layout_26_agents_rejected_by_layout_helper() {
let err = crate::supervisor::layout::supervisor_layout(26).expect_err("26 agents rejected");
let msg = err.to_string();
assert!(msg.contains("26 agents requested"));
assert!(msg.contains("maximum is 25"));
}
#[test]
fn supervisor_layout_7_agents_row_major_indices() {
let session = build_for(7);
let cmds = session.command_strings();
let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
.into_iter()
.filter(|c| !c.trim_end().ends_with("C-u"))
.collect();
assert!(
send_keys
.iter()
.any(|c| c.contains(":0.2 ") && c.contains("claude")),
"pane :0.2 is the first agent (top-left); send-keys {send_keys:#?}"
);
assert!(
send_keys
.iter()
.any(|c| c.contains(":0.6 ") && c.contains("claude")),
"pane :0.6 is the fifth agent (top-right of row 1)"
);
assert!(
send_keys
.iter()
.any(|c| c.contains(":0.7 ") && c.contains("claude")),
"pane :0.7 is the sixth agent (start of row 2)"
);
}
#[test]
fn supervisor_top_row_split_50_50() {
let session = build_for(3);
let cmds = session.command_strings();
let h_split = cmds
.iter()
.find(|c| c.contains("split-window") && c.contains("-h") && c.contains("-l 50%"))
.unwrap_or_else(|| panic!("expected horizontal 50% split; got cmds: {cmds:#?}"));
assert!(
h_split.contains(":0.0") || h_split.contains("split-window -h -t paw-proj"),
"horizontal split should target the supervisor pane; got: {h_split}"
);
}
#[test]
fn supervisor_splits_use_l_percent_not_p() {
let session = build_for(4);
let cmds = session.command_strings();
for cmd in &cmds {
if cmd.contains("split-window") {
assert!(
!cmd.contains(" -p "),
"split-window must not use deprecated -p flag (fails on Linux tmux 3.4 headless); got: {cmd}"
);
}
}
}
#[test]
fn supervisor_new_session_passes_explicit_x_and_y() {
let session = build_for(2);
let cmds = session.command_strings();
let new_session_cmd = cmds
.iter()
.find(|c| c.contains("new-session"))
.expect("supervisor build emits a new-session command");
assert!(
new_session_cmd.contains("-x 480"),
"supervisor new-session must pass -x 480; got: {new_session_cmd}"
);
assert!(
new_session_cmd.contains("-y 140"),
"supervisor new-session must pass -y 140; got: {new_session_cmd}"
);
}
#[test]
fn supervisor_sets_default_size_after_new_session() {
let session = build_for(2);
let cmds = session.command_strings();
let new_session_idx = cmds
.iter()
.position(|c| c.contains("new-session"))
.expect("new-session in command list");
let default_size_idx = cmds
.iter()
.position(|c| {
c.contains("set-option") && c.contains("default-size") && c.contains("480x140")
})
.expect("set-option default-size 200x50 in command list");
assert!(
default_size_idx > new_session_idx,
"set-option default-size must come AFTER new-session; got order new={new_session_idx}, default-size={default_size_idx}"
);
}
#[test]
fn bare_start_with_broker_places_dashboard_at_pane_0() {
let session = TmuxSessionBuilder::new("proj")
.add_pane(make_pane("dashboard", "/repo", "git-paw __dashboard"))
.add_pane(make_pane("feat/a", "/tmp/wt-a", "claude"))
.add_pane(make_pane("feat/b", "/tmp/wt-b", "claude"))
.add_pane(make_pane("feat/c", "/tmp/wt-c", "claude"))
.build()
.expect("session builds");
let cmds = session.command_strings();
let dashboard_send = cmds
.iter()
.find(|c| c.contains("send-keys") && c.contains("__dashboard"))
.expect("dashboard send-keys present");
assert!(
dashboard_send.contains(":0.0 "),
"dashboard pane must be index 0; got: {dashboard_send}"
);
for (pane_idx, branch_marker, worktree) in [
(1, "feat/a", "/tmp/wt-a"),
(2, "feat/b", "/tmp/wt-b"),
(3, "feat/c", "/tmp/wt-c"),
] {
let select_target = format!(":0.{pane_idx} ");
assert!(
cmds.iter()
.any(|c| c.contains(&select_target) && c.contains(branch_marker)),
"agent {branch_marker} should land at pane {pane_idx}; cmds:\n{cmds:#?}"
);
let split_marker = format!("-c {worktree}");
assert!(
cmds.iter()
.any(|c| c.contains("split-window") && c.contains(&split_marker)),
"agent {branch_marker} split should carry {split_marker}; cmds:\n{cmds:#?}"
);
}
}
#[test]
fn broker_disabled_produces_no_dashboard_pane() {
let session = TmuxSessionBuilder::new("proj")
.add_pane(make_pane("feat/a", "/tmp/wt-a", "claude"))
.add_pane(make_pane("feat/b", "/tmp/wt-b", "claude"))
.add_pane(make_pane("feat/c", "/tmp/wt-c", "claude"))
.build()
.expect("session builds");
let cmds = session.command_strings();
assert!(
!cmds.iter().any(|c| c.contains("__dashboard")),
"broker disabled must not add a dashboard pane; got cmds:\n{cmds:#?}"
);
let send_keys: Vec<&String> = cmds.iter().filter(|c| c.contains("send-keys")).collect();
assert_eq!(
send_keys.len(),
3,
"broker-disabled launch with 3 agents must emit 3 send-keys; got: {send_keys:#?}"
);
}
#[test]
fn dashboard_pane_has_title_dashboard() {
let session = build_for(2);
let cmds = session.command_strings();
let dashboard_select = cmds
.iter()
.find(|c| {
c.contains("select-pane")
&& c.contains(":0.1")
&& c.contains("-T")
&& c.contains("dashboard")
})
.unwrap_or_else(|| {
panic!("expected select-pane -T dashboard at :0.1; cmds:\n{cmds:#?}")
});
assert!(
dashboard_select.contains("dashboard"),
"dashboard pane title must include `dashboard`; got: {dashboard_select}"
);
}
#[test]
fn supervisor_layout_emits_env_before_agent_send_keys() {
let session = build_for(3);
let cmds = session.command_strings();
let first_env = cmds
.iter()
.position(|c| c.contains("set-environment") && c.contains("GIT_PAW_BROKER_URL"))
.expect("set-environment GIT_PAW_BROKER_URL present");
let first_agent_send = cmds
.iter()
.position(|c| c.contains("send-keys") && c.contains(":0.2 "))
.expect("first agent send-keys at :0.2");
assert!(
first_env < first_agent_send,
"set-environment must come before agent-pane send-keys"
);
}
fn every_new_session_command() -> Vec<(&'static str, String)> {
let mut found: Vec<(&'static str, String)> = Vec::new();
let basic = TmuxSessionBuilder::new("conv-basic")
.add_pane(make_pane("main", "/tmp/wt-basic", "claude"))
.build()
.expect("basic builder produces a session");
for cmd in basic.command_strings() {
if cmd.contains("new-session") {
found.push(("TmuxSessionBuilder::build", cmd));
}
}
let layout = crate::supervisor::layout::supervisor_layout(2).expect("layout");
let (supervisor, dashboard, agents) = make_layout_panes(2);
let supervisor_session = build_supervisor_session(
"conv-supervisor",
None,
&supervisor,
&dashboard,
&agents,
layout,
true,
true,
&[],
)
.expect("supervisor builder produces a session");
for cmd in supervisor_session.command_strings() {
if cmd.contains("new-session") {
found.push(("build_supervisor_session", cmd));
}
}
assert!(
!found.is_empty(),
"expected at least one new-session command from the audited builders"
);
found
}
#[test]
fn every_new_session_passes_x_and_y() {
for (builder, cmd) in every_new_session_command() {
assert!(
cmd.contains(" -x ") || cmd.ends_with(" -x"),
"{builder}: new-session must pass -x; got: {cmd}"
);
assert!(
cmd.contains(" -y ") || cmd.ends_with(" -y"),
"{builder}: new-session must pass -y; got: {cmd}"
);
}
}
#[test]
fn every_new_session_passes_c() {
for (builder, cmd) in every_new_session_command() {
assert!(
cmd.contains(" -c "),
"{builder}: new-session must pass -c <cwd>; got: {cmd}"
);
}
}
#[test]
fn supervisor_layout_agent_splits_carry_worktree_no_cd_chain() {
let layout = crate::supervisor::layout::supervisor_layout(2).expect("layout");
let supervisor = make_pane("supervisor", "/repo", "claude");
let dashboard = make_pane("dashboard", "/repo", "git-paw __dashboard");
let agent_a = make_pane("feat/a", "/tmp/wt-a", "claude");
let agent_b = make_pane("feat/b", "/tmp/wt-b", "claude");
let session = build_supervisor_session(
"proj",
None,
&supervisor,
&dashboard,
&[agent_a, agent_b],
layout,
true,
true,
&[],
)
.expect("session builds");
let cmds = session.command_strings();
let splits = commands_containing(&cmds, "split-window");
assert!(
splits.iter().any(|c| c.contains("-c /tmp/wt-a")),
"split for agent a should pass -c /tmp/wt-a; splits: {splits:#?}"
);
assert!(
splits.iter().any(|c| c.contains("-c /tmp/wt-b")),
"split for agent b should pass -c /tmp/wt-b; splits: {splits:#?}"
);
let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
.into_iter()
.filter(|c| !c.trim_end().ends_with("C-u"))
.collect();
for entry in &send_keys {
assert!(
!entry.contains("cd /tmp/wt-a &&"),
"no send-keys should chain `cd /tmp/wt-a &&`; got: {entry}"
);
assert!(
!entry.contains("cd /tmp/wt-b &&"),
"no send-keys should chain `cd /tmp/wt-b &&`; got: {entry}"
);
}
}
#[test]
fn add_agent_same_row_splits_horizontally_from_previous_pane() {
let layout = crate::supervisor::layout::layout_for(5).expect("layout");
let new_agent = make_pane("feat/fifth", "/tmp/wt5", "claude");
let session = build_add_agent_commands("paw-x", &new_agent, 4, layout, true);
let cmds = session.command_strings();
assert!(
cmds.iter().any(|c| c.contains("split-window")
&& c.contains("-h")
&& c.contains(":0.5")
&& c.contains("-c /tmp/wt5")),
"5th agent should -h split from pane 5 with -c worktree; cmds:\n{cmds:#?}"
);
assert!(
cmds.iter()
.any(|c| c.contains("send-keys") && c.contains(":0.6") && c.contains("claude")),
"new agent CLI should launch in pane 6; cmds:\n{cmds:#?}"
);
}
#[test]
fn add_agent_new_row_splits_vertically_from_previous_row_first_pane() {
let layout = crate::supervisor::layout::layout_for(6).expect("layout");
let new_agent = make_pane("feat/sixth", "/tmp/wt6", "claude");
let session = build_add_agent_commands("paw-x", &new_agent, 5, layout, false);
let cmds = session.command_strings();
assert!(
cmds.iter().any(|c| c.contains("split-window")
&& c.contains("-v")
&& c.contains(":0.2")
&& c.contains("-c /tmp/wt6")),
"6th agent should -v split from pane 2 (prev row first); cmds:\n{cmds:#?}"
);
}
#[test]
fn add_agent_reapplies_row_height_resize_pass() {
let layout = crate::supervisor::layout::layout_for(5).expect("layout");
let new_agent = make_pane("feat/fifth", "/tmp/wt5", "claude");
let session = build_add_agent_commands("paw-x", &new_agent, 4, layout, false);
let cmds = session.command_strings();
let top_pct = format!("{}%", layout.top_row_pct);
assert!(
cmds.iter()
.any(|c| c.contains("resize-pane") && c.contains(":0.0") && c.contains(&top_pct)),
"re-tile should resize the top row to {top_pct}; cmds:\n{cmds:#?}"
);
}
#[test]
fn remove_retile_emits_resize_pass_for_remaining_count() {
let layout = crate::supervisor::layout::layout_for(4).expect("layout");
let session = build_remove_retile_commands("paw-x", 4, layout);
let cmds = session.command_strings();
let top_pct = format!("{}%", layout.top_row_pct);
assert!(
cmds.iter()
.any(|c| c.contains("resize-pane") && c.contains(":0.0") && c.contains(&top_pct)),
"remove re-tile should resize the top row; cmds:\n{cmds:#?}"
);
assert!(
cmds.iter()
.any(|c| c.contains("resize-pane") && c.contains(":0.2")),
"remove re-tile should resize the first agent row (pane 2); cmds:\n{cmds:#?}"
);
}
#[test]
fn remove_retile_with_zero_remaining_is_empty() {
let layout = crate::supervisor::layout::layout_for(1).expect("layout");
let session = build_remove_retile_commands("paw-x", 0, layout);
assert!(
session.command_strings().is_empty(),
"removing the last agent leaves the top row untouched (no re-tile)"
);
}
}