1use std::path::{Path, PathBuf};
8use std::process::Command;
9use std::time::Duration;
10
11use crate::error::PawError;
12
13const MAX_COLLISION_RETRIES: u32 = 10;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct TmuxCommand {
21 args: Vec<String>,
22 soft: bool,
26}
27
28impl TmuxCommand {
29 fn new(args: &[&str]) -> Self {
31 Self {
32 args: args.iter().map(|&s| s.to_owned()).collect(),
33 soft: false,
34 }
35 }
36
37 fn new_soft(args: &[&str]) -> Self {
43 Self {
44 args: args.iter().map(|&s| s.to_owned()).collect(),
45 soft: true,
46 }
47 }
48
49 #[allow(dead_code)]
53 pub fn as_command_string(&self) -> String {
54 format!("tmux {}", self.args.join(" "))
55 }
56
57 fn execute(&self) -> Result<String, PawError> {
59 let output = Command::new("tmux")
60 .args(&self.args)
61 .output()
62 .map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
63
64 if output.status.success() {
65 String::from_utf8(output.stdout)
66 .map_err(|e| PawError::TmuxError(format!("invalid utf-8 in tmux output: {e}")))
67 } else {
68 let stderr = String::from_utf8_lossy(&output.stderr);
69 Err(PawError::TmuxError(stderr.trim().to_owned()))
70 }
71 }
72}
73
74#[derive(Debug, Clone)]
76pub struct PaneSpec {
77 pub branch: String,
79 pub worktree: String,
81 pub cli_command: String,
83}
84
85fn push_border_affordances(commands: &mut Vec<TmuxCommand>, session: &str) {
111 for (option, value) in [
112 ("pane-border-lines", "double"),
113 ("pane-border-style", "fg=colour238"),
114 ("pane-active-border-style", "fg=colour45,bold"),
115 ("pane-border-status", "top"),
116 (
117 "pane-border-format",
118 "#[fg=colour39,bold,reverse] #{pane_index}: #{?#{@paw_role},#{@paw_role},#{pane_title}} #[default]",
119 ),
120 ] {
121 commands.push(TmuxCommand::new_soft(&[
122 "set-option",
123 "-t",
124 session,
125 option,
126 value,
127 ]));
128 }
129}
130
131fn push_pane_title(
147 commands: &mut Vec<TmuxCommand>,
148 border_affordances: bool,
149 target: &str,
150 title: &str,
151) {
152 if border_affordances {
153 commands.push(TmuxCommand::new(&[
154 "select-pane",
155 "-t",
156 target,
157 "-T",
158 title,
159 ]));
160 commands.push(TmuxCommand::new_soft(&[
163 "set-option",
164 "-p",
165 "-t",
166 target,
167 "@paw_role",
168 title,
169 ]));
170 }
171}
172
173#[derive(Debug)]
175pub struct TmuxSession {
176 pub name: String,
178 commands: Vec<TmuxCommand>,
179}
180
181impl TmuxSession {
182 pub fn execute(&self) -> Result<(), PawError> {
188 self.execute_with(|cmd| cmd.execute().map(|_| ()), |w| eprintln!("{w}"))
189 }
190
191 fn execute_with<R, W>(&self, mut run: R, mut warn: W) -> Result<(), PawError>
196 where
197 R: FnMut(&TmuxCommand) -> Result<(), PawError>,
198 W: FnMut(String),
199 {
200 for cmd in &self.commands {
201 if let Err(e) = run(cmd) {
202 if cmd.soft {
203 warn(format!(
204 "warning: tmux option not supported: {} ({e})",
205 cmd.args.join(" ")
206 ));
207 } else {
208 return Err(e);
209 }
210 }
211 }
212 Ok(())
213 }
214
215 #[allow(dead_code)]
219 pub fn command_strings(&self) -> Vec<String> {
220 self.commands
221 .iter()
222 .map(TmuxCommand::as_command_string)
223 .collect()
224 }
225
226 pub fn pipe_pane(&mut self, pane_target: &str, log_path: &std::path::Path) -> &mut Self {
231 self.commands.push(TmuxCommand::new(&[
232 "pipe-pane",
233 "-o",
234 "-t",
235 pane_target,
236 &format!("cat >> {}", log_path.display()),
237 ]));
238 self
239 }
240
241 pub fn reapply_tiled_layout(&mut self, session_name: &str) -> &mut Self {
247 self.commands.push(TmuxCommand::new(&[
248 "select-layout",
249 "-t",
250 session_name,
251 "tiled",
252 ]));
253 self
254 }
255
256 pub fn apply_dashboard_layout(&mut self, session_name: &str) -> &mut Self {
262 self.commands.push(TmuxCommand::new(&[
263 "select-layout",
264 "-t",
265 session_name,
266 "main-horizontal",
267 ]));
268 self
269 }
270}
271
272#[derive(Debug)]
301pub struct TmuxSessionBuilder {
302 project_name: String,
303 panes: Vec<PaneSpec>,
304 mouse_mode: bool,
305 border_affordances: bool,
306 session_name_override: Option<String>,
307 env_vars: Vec<(String, String)>,
308}
309
310impl TmuxSessionBuilder {
311 pub fn new(project_name: &str) -> Self {
316 Self {
317 project_name: project_name.to_owned(),
318 panes: Vec::new(),
319 mouse_mode: true,
320 border_affordances: true,
321 session_name_override: None,
322 env_vars: Vec::new(),
323 }
324 }
325
326 #[must_use]
330 pub fn session_name(mut self, name: String) -> Self {
331 self.session_name_override = Some(name);
332 self
333 }
334
335 #[must_use]
337 pub fn add_pane(mut self, spec: PaneSpec) -> Self {
338 self.panes.push(spec);
339 self
340 }
341
342 #[must_use]
347 pub fn mouse_mode(mut self, enabled: bool) -> Self {
348 self.mouse_mode = enabled;
349 self
350 }
351
352 #[must_use]
361 pub fn border_affordances(mut self, enabled: bool) -> Self {
362 self.border_affordances = enabled;
363 self
364 }
365
366 #[must_use]
371 pub fn set_environment(mut self, key: &str, value: &str) -> Self {
372 self.env_vars.push((key.to_owned(), value.to_owned()));
373 self
374 }
375
376 #[allow(clippy::too_many_lines)]
381 pub fn build(self) -> Result<TmuxSession, PawError> {
382 if self.panes.is_empty() {
383 return Err(PawError::TmuxError(
384 "cannot create a session with no panes".to_owned(),
385 ));
386 }
387
388 let session_name = self
389 .session_name_override
390 .unwrap_or_else(|| format!("paw-{}", self.project_name));
391 let mut commands = Vec::new();
392
393 let first_worktree = &self.panes[0].worktree;
400 commands.push(TmuxCommand::new(&[
401 "new-session",
402 "-d",
403 "-s",
404 &session_name,
405 "-x",
406 "480",
407 "-y",
408 "140",
409 "-c",
410 first_worktree,
411 ]));
412
413 commands.push(TmuxCommand::new(&[
420 "set-option",
421 "-g",
422 "default-size",
423 "480x140",
424 ]));
425
426 if self.mouse_mode {
428 commands.push(TmuxCommand::new(&[
429 "set-option",
430 "-t",
431 &session_name,
432 "mouse",
433 "on",
434 ]));
435 }
436
437 if self.border_affordances {
441 push_border_affordances(&mut commands, &session_name);
442 }
443
444 for (key, value) in &self.env_vars {
446 commands.push(TmuxCommand::new(&[
447 "set-environment",
448 "-t",
449 &session_name,
450 key,
451 value,
452 ]));
453 }
454
455 let first = &self.panes[0];
459 let pane_target = format!("{session_name}:0.0");
460 push_pane_title(
461 &mut commands,
462 self.border_affordances,
463 &pane_target,
464 &first.branch,
465 );
466 commands.push(TmuxCommand::new(&[
467 "send-keys",
468 "-t",
469 &pane_target,
470 &first.cli_command,
471 "Enter",
472 ]));
473
474 for (i, pane) in self.panes.iter().enumerate().skip(1) {
476 commands.push(TmuxCommand::new(&[
478 "select-layout",
479 "-t",
480 &session_name,
481 "tiled",
482 ]));
483
484 commands.push(TmuxCommand::new(&[
490 "split-window",
491 "-t",
492 &session_name,
493 "-c",
494 &pane.worktree,
495 ]));
496
497 let pane_target = format!("{session_name}:0.{i}");
499 push_pane_title(
500 &mut commands,
501 self.border_affordances,
502 &pane_target,
503 &pane.branch,
504 );
505 commands.push(TmuxCommand::new(&[
506 "send-keys",
507 "-t",
508 &pane_target,
509 &pane.cli_command,
510 "Enter",
511 ]));
512 }
513
514 if self.panes.len() > 1 && self.panes[0].branch == "dashboard" {
516 commands.push(TmuxCommand::new(&[
518 "select-layout",
519 "-t",
520 &session_name,
521 "main-horizontal",
522 ]));
523 } else {
524 commands.push(TmuxCommand::new(&[
526 "select-layout",
527 "-t",
528 &session_name,
529 "tiled",
530 ]));
531 }
532
533 Ok(TmuxSession {
534 name: session_name,
535 commands,
536 })
537 }
538}
539
540pub fn ensure_tmux_installed() -> Result<(), PawError> {
545 which::which("tmux").map_err(|_| PawError::TmuxNotInstalled)?;
546 Ok(())
547}
548
549pub fn is_session_alive(name: &str) -> Result<bool, PawError> {
551 let status = Command::new("tmux")
552 .args(["has-session", "-t", name])
553 .stdout(std::process::Stdio::null())
554 .stderr(std::process::Stdio::null())
555 .status()
556 .map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
557
558 Ok(status.success())
559}
560
561#[derive(Debug, Clone, Copy, PartialEq, Eq)]
569pub enum SessionLiveness {
570 Alive,
572 Stale,
574 Indeterminate,
577}
578
579fn classify_liveness(spawned: bool, success: bool) -> SessionLiveness {
585 match (spawned, success) {
586 (false, _) => SessionLiveness::Indeterminate,
587 (true, true) => SessionLiveness::Alive,
588 (true, false) => SessionLiveness::Stale,
589 }
590}
591
592pub fn session_liveness(name: &str) -> SessionLiveness {
599 let spawn = Command::new("tmux")
600 .args(["has-session", "-t", name])
601 .stdout(std::process::Stdio::null())
602 .stderr(std::process::Stdio::null())
603 .status();
604 match spawn {
605 Ok(status) => classify_liveness(true, status.success()),
606 Err(_) => classify_liveness(false, false),
607 }
608}
609
610pub fn resolve_session_name(project_name: &str) -> Result<String, PawError> {
615 let base = format!("paw-{project_name}");
616
617 if !is_session_alive(&base)? {
618 return Ok(base);
619 }
620
621 for suffix in 2..=MAX_COLLISION_RETRIES + 1 {
622 let candidate = format!("{base}-{suffix}");
623 if !is_session_alive(&candidate)? {
624 return Ok(candidate);
625 }
626 }
627
628 Err(PawError::TmuxError(format!(
629 "too many session name collisions for '{base}'"
630 )))
631}
632
633pub fn attach(name: &str) -> Result<(), PawError> {
638 let status = Command::new("tmux")
639 .args(["attach-session", "-t", name])
640 .status()
641 .map_err(|e| PawError::TmuxError(format!("failed to attach to tmux session: {e}")))?;
642
643 if status.success() {
644 Ok(())
645 } else {
646 Err(PawError::TmuxError(format!(
647 "failed to attach to session '{name}'"
648 )))
649 }
650}
651
652pub fn detach_client(session_name: &str) -> Result<(), PawError> {
660 let output = Command::new("tmux")
661 .args(["detach-client", "-s", session_name])
662 .output()
663 .map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
664
665 if output.status.success() {
666 return Ok(());
667 }
668 let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
669 if stderr.contains("no clients") || stderr.contains("no current client") {
671 return Ok(());
672 }
673 Err(PawError::TmuxError(
674 String::from_utf8_lossy(&output.stderr).trim().to_owned(),
675 ))
676}
677
678pub fn kill_pane(session_name: &str, pane_index: u32) -> Result<(), PawError> {
686 let target = format!("{session_name}:0.{pane_index}");
687 let output = Command::new("tmux")
688 .args(["kill-pane", "-t", &target])
689 .output()
690 .map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
691
692 if output.status.success() {
693 return Ok(());
694 }
695 let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
696 if stderr.contains("can't find pane")
698 || stderr.contains("no such pane")
699 || stderr.contains("pane not found")
700 {
701 return Ok(());
702 }
703 Err(PawError::TmuxError(
704 String::from_utf8_lossy(&output.stderr).trim().to_owned(),
705 ))
706}
707
708pub fn kill_session(name: &str) -> Result<(), PawError> {
710 let output = Command::new("tmux")
711 .args(["kill-session", "-t", name])
712 .output()
713 .map_err(|e| PawError::TmuxError(format!("failed to kill tmux session: {e}")))?;
714
715 if output.status.success() {
716 Ok(())
717 } else {
718 let stderr = String::from_utf8_lossy(&output.stderr);
719 Err(PawError::TmuxError(stderr.trim().to_owned()))
720 }
721}
722
723pub fn kill_pane_by_id(pane_id: &str) -> Result<(), PawError> {
732 let output = Command::new("tmux")
733 .args(["kill-pane", "-t", pane_id])
734 .output()
735 .map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
736
737 if output.status.success() {
738 return Ok(());
739 }
740 let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
741 if stderr.contains("can't find pane")
742 || stderr.contains("no such pane")
743 || stderr.contains("pane not found")
744 {
745 return Ok(());
746 }
747 Err(PawError::TmuxError(
748 String::from_utf8_lossy(&output.stderr).trim().to_owned(),
749 ))
750}
751
752pub fn list_panes_with_paths(session_name: &str) -> Result<Vec<(String, String)>, PawError> {
760 let output = Command::new("tmux")
761 .args([
762 "list-panes",
763 "-t",
764 session_name,
765 "-F",
766 "#{pane_id} #{pane_current_path}",
767 ])
768 .output()
769 .map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
770
771 if !output.status.success() {
772 let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
773 if stderr.contains("can't find")
774 || stderr.contains("no such")
775 || stderr.contains("no server running")
776 {
777 return Ok(Vec::new());
778 }
779 return Err(PawError::TmuxError(
780 String::from_utf8_lossy(&output.stderr).trim().to_owned(),
781 ));
782 }
783
784 let text = String::from_utf8_lossy(&output.stdout);
785 let mut panes = Vec::new();
786 for line in text.lines() {
787 if let Some((id, path)) = line.split_once(' ') {
788 panes.push((id.to_string(), path.to_string()));
789 }
790 }
791 Ok(panes)
792}
793
794pub fn resolve_pane_id_for_worktree(
803 session_name: &str,
804 worktree_path: &Path,
805) -> Result<Option<String>, PawError> {
806 let want = canonical_or_self(worktree_path);
807 for (pane_id, path) in list_panes_with_paths(session_name)? {
808 if canonical_or_self(Path::new(&path)) == want {
809 return Ok(Some(pane_id));
810 }
811 }
812 Ok(None)
813}
814
815fn canonical_or_self(path: &Path) -> PathBuf {
818 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
819}
820
821#[must_use]
830pub fn agents_without_live_pane(
831 agents: &[(String, PathBuf)],
832 live_pane_paths: &[PathBuf],
833) -> Vec<String> {
834 let live: Vec<PathBuf> = live_pane_paths
835 .iter()
836 .map(|p| canonical_or_self(p))
837 .collect();
838 agents
839 .iter()
840 .filter(|(_, wt)| {
841 let want = canonical_or_self(wt);
842 !live.contains(&want)
843 })
844 .map(|(branch, _)| branch.clone())
845 .collect()
846}
847
848pub fn reconcile_agents_to_panes(
856 session_name: &str,
857 agents: &[(String, PathBuf)],
858) -> Result<Vec<String>, PawError> {
859 let live: Vec<PathBuf> = list_panes_with_paths(session_name)?
860 .into_iter()
861 .map(|(_, path)| PathBuf::from(path))
862 .collect();
863 Ok(agents_without_live_pane(agents, &live))
864}
865
866pub const CLI_READY_MARKERS: &[&str] = &[
880 "? for shortcuts",
881 "? for help",
882 "Welcome to Claude Code",
883 "esc to interrupt",
884 "Bypassing Permissions",
885 "│ >",
886];
887
888const READINESS_TIMEOUT_MS: u64 = 2000;
895const READINESS_POLL_INTERVAL_MS: u64 = 150;
897const READINESS_RELAUNCH_ATTEMPTS: usize = 1;
900
901#[derive(Debug, Clone, Copy, PartialEq, Eq)]
903pub enum PaneReadiness {
904 Ready,
906 BareShell,
909 Indeterminate,
912}
913
914#[derive(Debug, Clone, Copy, PartialEq, Eq)]
916pub enum GateOutcome {
917 Ready,
919 FellBack,
923}
924
925#[derive(Debug, Clone, Copy)]
927pub struct ReadinessBudget {
928 pub poll_interval: Duration,
930 pub timeout: Duration,
933 pub relaunch_attempts: usize,
935}
936
937impl Default for ReadinessBudget {
938 fn default() -> Self {
939 let timeout_ms = std::env::var("GIT_PAW_READINESS_TIMEOUT_MS")
940 .ok()
941 .and_then(|v| v.parse::<u64>().ok())
942 .unwrap_or(READINESS_TIMEOUT_MS);
943 Self {
944 poll_interval: Duration::from_millis(READINESS_POLL_INTERVAL_MS),
945 timeout: Duration::from_millis(timeout_ms),
946 relaunch_attempts: READINESS_RELAUNCH_ATTEMPTS,
947 }
948 }
949}
950
951fn looks_like_bare_shell(captured: &str) -> bool {
955 match captured.lines().rev().find(|l| !l.trim().is_empty()) {
956 Some(line) => {
957 let trimmed = line.trim_end();
958 trimmed.ends_with('$')
959 || trimmed.ends_with('%')
960 || trimmed.ends_with('#')
961 || trimmed.ends_with('❯')
962 || trimmed.ends_with('➜')
963 }
964 None => false,
965 }
966}
967
968#[must_use]
970pub fn classify_pane_readiness(captured: &str) -> PaneReadiness {
971 if CLI_READY_MARKERS.iter().any(|m| captured.contains(m)) {
972 PaneReadiness::Ready
973 } else if looks_like_bare_shell(captured) {
974 PaneReadiness::BareShell
975 } else {
976 PaneReadiness::Indeterminate
977 }
978}
979
980fn gate_pane_generic<C, R, S>(
989 budget: ReadinessBudget,
990 mut capture: C,
991 mut relaunch: R,
992 mut sleep: S,
993) -> GateOutcome
994where
995 C: FnMut() -> Option<String>,
996 R: FnMut(),
997 S: FnMut(Duration),
998{
999 for attempt in 0..=budget.relaunch_attempts {
1000 let mut waited = Duration::ZERO;
1001 loop {
1002 let captured = capture().unwrap_or_default();
1003 if classify_pane_readiness(&captured) == PaneReadiness::Ready {
1004 return GateOutcome::Ready;
1005 }
1006 if waited >= budget.timeout {
1007 break;
1008 }
1009 sleep(budget.poll_interval);
1010 waited = waited.saturating_add(budget.poll_interval);
1011 }
1012 let final_state = classify_pane_readiness(&capture().unwrap_or_default());
1015 if final_state == PaneReadiness::BareShell && attempt < budget.relaunch_attempts {
1016 relaunch();
1017 } else {
1018 break;
1019 }
1020 }
1021 GateOutcome::FellBack
1022}
1023
1024#[must_use]
1033pub fn gate_pane_for_injection(
1034 session_name: &str,
1035 pane_index: usize,
1036 cli_command: &str,
1037) -> GateOutcome {
1038 gate_pane_generic(
1039 ReadinessBudget::default(),
1040 || crate::supervisor::permission_prompt::capture_pane(session_name, pane_index),
1041 || relaunch_cli_into_pane(session_name, pane_index, cli_command),
1042 std::thread::sleep,
1043 )
1044}
1045
1046fn relaunch_cli_into_pane(session_name: &str, pane_index: usize, cli_command: &str) {
1051 let target = format!("{session_name}:0.{pane_index}");
1052 let _ = Command::new("tmux")
1053 .args(["send-keys", "-t", &target, "C-u"])
1054 .status();
1055 let _ = Command::new("tmux")
1056 .args(["send-keys", "-t", &target, cli_command, "Enter"])
1057 .status();
1058}
1059
1060pub fn build_boot_inject_args(session_name: &str, pane_index: usize, text: &str) -> Vec<String> {
1069 vec![
1070 "send-keys".to_string(),
1071 "-l".to_string(),
1072 "-t".to_string(),
1073 format!("{session_name}:0.{pane_index}"),
1074 text.to_string(),
1075 ]
1076}
1077
1078#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
1106pub fn build_supervisor_session(
1107 project_name: &str,
1108 session_name_override: Option<String>,
1109 supervisor: &PaneSpec,
1110 dashboard: &PaneSpec,
1111 agents: &[PaneSpec],
1112 layout: crate::supervisor::layout::SupervisorLayout,
1113 mouse_mode: bool,
1114 border_affordances: bool,
1115 env_vars: &[(String, String)],
1116) -> Result<TmuxSession, PawError> {
1117 use crate::supervisor::layout::{SUPERVISOR_AGENTS_PER_ROW, SUPERVISOR_PANE_OFFSET};
1118
1119 let session_name = session_name_override.unwrap_or_else(|| format!("paw-{project_name}"));
1120 let mut commands: Vec<TmuxCommand> = Vec::new();
1121
1122 let push = |cmds: &mut Vec<TmuxCommand>, parts: &[&str]| {
1123 cmds.push(TmuxCommand::new(parts));
1124 };
1125
1126 push(
1131 &mut commands,
1132 &[
1133 "new-session",
1134 "-d",
1135 "-s",
1136 &session_name,
1137 "-x",
1138 "480",
1139 "-y",
1140 "140",
1141 "-e",
1148 "DISABLE_AUTO_UPDATE=true",
1149 "-e",
1150 "DISABLE_UPDATE_PROMPT=true",
1151 "-c",
1152 &supervisor.worktree,
1153 ],
1154 );
1155
1156 push(
1162 &mut commands,
1163 &["set-option", "-g", "default-size", "480x140"],
1164 );
1165
1166 push(
1170 &mut commands,
1171 &[
1172 "set-environment",
1173 "-t",
1174 &session_name,
1175 "DISABLE_AUTO_UPDATE",
1176 "true",
1177 ],
1178 );
1179 push(
1180 &mut commands,
1181 &[
1182 "set-environment",
1183 "-t",
1184 &session_name,
1185 "DISABLE_UPDATE_PROMPT",
1186 "true",
1187 ],
1188 );
1189
1190 if mouse_mode {
1192 push(
1193 &mut commands,
1194 &["set-option", "-t", &session_name, "mouse", "on"],
1195 );
1196 }
1197 if border_affordances {
1198 push_border_affordances(&mut commands, &session_name);
1199 }
1200
1201 for (key, value) in env_vars {
1203 push(
1204 &mut commands,
1205 &["set-environment", "-t", &session_name, key, value],
1206 );
1207 }
1208
1209 let supervisor_target = format!("{session_name}:0.0");
1210 push_pane_title(
1211 &mut commands,
1212 border_affordances,
1213 &supervisor_target,
1214 &supervisor.branch,
1215 );
1216 push(
1220 &mut commands,
1221 &["send-keys", "-t", &supervisor_target, "C-u"],
1222 );
1223 push(
1224 &mut commands,
1225 &[
1226 "send-keys",
1227 "-t",
1228 &supervisor_target,
1229 &supervisor.cli_command,
1230 "Enter",
1231 ],
1232 );
1233
1234 let bottom_pct = format!("{}%", 100u16 - u16::from(layout.top_row_pct));
1250 if agents.is_empty() {
1259 push(
1260 &mut commands,
1261 &[
1262 "split-window",
1263 "-v",
1264 "-t",
1265 &supervisor_target,
1266 "-l",
1267 &bottom_pct,
1268 ],
1269 );
1270 } else {
1271 push(
1272 &mut commands,
1273 &[
1274 "split-window",
1275 "-v",
1276 "-t",
1277 &supervisor_target,
1278 "-l",
1279 &bottom_pct,
1280 "-c",
1281 &dashboard.worktree,
1282 ],
1283 );
1284 }
1285
1286 let dashboard_split_cwd = agents
1293 .first()
1294 .map_or(dashboard.worktree.as_str(), |a| a.worktree.as_str());
1295 push(
1296 &mut commands,
1297 &[
1298 "split-window",
1299 "-h",
1300 "-t",
1301 &supervisor_target,
1302 "-l",
1303 "50%",
1304 "-c",
1305 dashboard_split_cwd,
1306 ],
1307 );
1308
1309 let pane_one = format!("{session_name}:0.1");
1311 let pane_two = format!("{session_name}:0.2");
1312 push(
1313 &mut commands,
1314 &["swap-pane", "-s", &pane_one, "-t", &pane_two],
1315 );
1316
1317 let dashboard_target = format!("{session_name}:0.1");
1319 push_pane_title(
1320 &mut commands,
1321 border_affordances,
1322 &dashboard_target,
1323 &dashboard.branch,
1324 );
1325 push(
1326 &mut commands,
1327 &["send-keys", "-t", &dashboard_target, "C-u"],
1328 );
1329 push(
1330 &mut commands,
1331 &[
1332 "send-keys",
1333 "-t",
1334 &dashboard_target,
1335 &dashboard.cli_command,
1336 "Enter",
1337 ],
1338 );
1339
1340 if !agents.is_empty() {
1342 let first_target = format!("{session_name}:0.{SUPERVISOR_PANE_OFFSET}");
1348 let first = &agents[0];
1349 push_pane_title(
1350 &mut commands,
1351 border_affordances,
1352 &first_target,
1353 &first.branch,
1354 );
1355 push(&mut commands, &["send-keys", "-t", &first_target, "C-u"]);
1356 push(
1357 &mut commands,
1358 &[
1359 "send-keys",
1360 "-t",
1361 &first_target,
1362 &first.cli_command,
1363 "Enter",
1364 ],
1365 );
1366
1367 let mut row_first_pane = SUPERVISOR_PANE_OFFSET;
1368
1369 for (i, agent) in agents.iter().enumerate().skip(1) {
1370 let pane_idx = SUPERVISOR_PANE_OFFSET + i;
1371 let pane_target = format!("{session_name}:0.{pane_idx}");
1372 let position_in_row = i % SUPERVISOR_AGENTS_PER_ROW;
1373 let starts_new_row = position_in_row == 0;
1374
1375 if starts_new_row {
1376 let src_target = format!("{session_name}:0.{row_first_pane}");
1379 push(
1380 &mut commands,
1381 &[
1382 "split-window",
1383 "-v",
1384 "-t",
1385 &src_target,
1386 "-c",
1387 &agent.worktree,
1388 ],
1389 );
1390 row_first_pane = pane_idx;
1391 } else {
1392 let prev_idx = pane_idx - 1;
1395 let prev_target = format!("{session_name}:0.{prev_idx}");
1396 push(
1397 &mut commands,
1398 &[
1399 "split-window",
1400 "-h",
1401 "-t",
1402 &prev_target,
1403 "-c",
1404 &agent.worktree,
1405 ],
1406 );
1407 }
1408
1409 push_pane_title(
1410 &mut commands,
1411 border_affordances,
1412 &pane_target,
1413 &agent.branch,
1414 );
1415 push(&mut commands, &["send-keys", "-t", &pane_target, "C-u"]);
1416 push(
1417 &mut commands,
1418 &["send-keys", "-t", &pane_target, &agent.cli_command, "Enter"],
1419 );
1420 }
1421 }
1422
1423 push_supervisor_resize_pass(&mut commands, &session_name, layout, agents.len());
1429
1430 Ok(TmuxSession {
1431 name: session_name,
1432 commands,
1433 })
1434}
1435
1436#[must_use]
1459pub fn build_add_agent_commands(
1460 session_name: &str,
1461 new_agent: &PaneSpec,
1462 prev_agent_count: usize,
1463 layout: crate::supervisor::layout::SupervisorLayout,
1464 border_affordances: bool,
1465) -> TmuxSession {
1466 use crate::supervisor::layout::{SUPERVISOR_AGENTS_PER_ROW, SUPERVISOR_PANE_OFFSET};
1467
1468 let mut commands: Vec<TmuxCommand> = Vec::new();
1469 let i = prev_agent_count; let pane_idx = SUPERVISOR_PANE_OFFSET + i;
1471 let pane_target = format!("{session_name}:0.{pane_idx}");
1472
1473 if i > 0 && i.is_multiple_of(SUPERVISOR_AGENTS_PER_ROW) {
1474 let prev_row_first = SUPERVISOR_PANE_OFFSET + (i - SUPERVISOR_AGENTS_PER_ROW);
1476 let src = format!("{session_name}:0.{prev_row_first}");
1477 commands.push(TmuxCommand::new(&[
1478 "split-window",
1479 "-v",
1480 "-t",
1481 &src,
1482 "-c",
1483 &new_agent.worktree,
1484 ]));
1485 } else {
1486 let prev = format!("{session_name}:0.{}", pane_idx - 1);
1488 commands.push(TmuxCommand::new(&[
1489 "split-window",
1490 "-h",
1491 "-t",
1492 &prev,
1493 "-c",
1494 &new_agent.worktree,
1495 ]));
1496 }
1497
1498 push_pane_title(
1499 &mut commands,
1500 border_affordances,
1501 &pane_target,
1502 &new_agent.branch,
1503 );
1504 commands.push(TmuxCommand::new(&["send-keys", "-t", &pane_target, "C-u"]));
1505 commands.push(TmuxCommand::new(&[
1506 "send-keys",
1507 "-t",
1508 &pane_target,
1509 &new_agent.cli_command,
1510 "Enter",
1511 ]));
1512
1513 push_supervisor_resize_pass(&mut commands, session_name, layout, prev_agent_count + 1);
1514
1515 TmuxSession {
1516 name: session_name.to_string(),
1517 commands,
1518 }
1519}
1520
1521#[must_use]
1536pub fn build_remove_retile_commands(
1537 session_name: &str,
1538 remaining_agent_count: usize,
1539 layout: crate::supervisor::layout::SupervisorLayout,
1540) -> TmuxSession {
1541 let mut commands: Vec<TmuxCommand> = Vec::new();
1542 if remaining_agent_count > 0 {
1543 push_supervisor_resize_pass(&mut commands, session_name, layout, remaining_agent_count);
1544 }
1545 TmuxSession {
1546 name: session_name.to_string(),
1547 commands,
1548 }
1549}
1550
1551fn push_supervisor_resize_pass(
1561 commands: &mut Vec<TmuxCommand>,
1562 session_name: &str,
1563 layout: crate::supervisor::layout::SupervisorLayout,
1564 agent_count: usize,
1565) {
1566 use crate::supervisor::layout::{SUPERVISOR_AGENTS_PER_ROW, SUPERVISOR_PANE_OFFSET};
1567
1568 let top_target = format!("{session_name}:0.0");
1569 let top_pct_str = format!("{}%", layout.top_row_pct);
1570 commands.push(TmuxCommand::new(&[
1571 "resize-pane",
1572 "-t",
1573 &top_target,
1574 "-y",
1575 &top_pct_str,
1576 ]));
1577
1578 let agent_row_pct_str = format_supervisor_pct(layout.agent_row_pct);
1579 for row in 0..layout.agent_rows {
1580 let pane_idx = SUPERVISOR_PANE_OFFSET + row * SUPERVISOR_AGENTS_PER_ROW;
1581 if pane_idx < SUPERVISOR_PANE_OFFSET + agent_count {
1582 let target = format!("{session_name}:0.{pane_idx}");
1583 commands.push(TmuxCommand::new(&[
1584 "resize-pane",
1585 "-t",
1586 &target,
1587 "-y",
1588 &agent_row_pct_str,
1589 ]));
1590 }
1591 }
1592}
1593
1594#[must_use]
1612pub fn agent_row_widths(window_width: usize, agent_count: usize) -> Vec<(usize, usize)> {
1613 use crate::supervisor::layout::{SUPERVISOR_AGENTS_PER_ROW, SUPERVISOR_PANE_OFFSET};
1614
1615 let mut targets = Vec::new();
1616 if window_width == 0 {
1617 return targets;
1618 }
1619 let mut row_first_agent = 0;
1620 while row_first_agent < agent_count {
1621 let panes_in_row = (agent_count - row_first_agent).min(SUPERVISOR_AGENTS_PER_ROW);
1622 if panes_in_row > 1 {
1623 let separators = panes_in_row - 1;
1624 let per = window_width.saturating_sub(separators) / panes_in_row;
1625 for j in 0..(panes_in_row - 1) {
1626 targets.push((SUPERVISOR_PANE_OFFSET + row_first_agent + j, per));
1627 }
1628 }
1629 row_first_agent += panes_in_row;
1630 }
1631 targets
1632}
1633
1634fn query_window_width(session_name: &str) -> Result<Option<usize>, PawError> {
1639 let target = format!("{session_name}:0");
1640 let output = Command::new("tmux")
1641 .args(["display-message", "-p", "-t", &target, "#{window_width}"])
1642 .output()
1643 .map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
1644 if !output.status.success() {
1645 return Ok(None);
1646 }
1647 Ok(String::from_utf8_lossy(&output.stdout).trim().parse().ok())
1648}
1649
1650pub fn rebalance_agent_rows(session_name: &str, agent_count: usize) -> Result<(), PawError> {
1668 let Some(window_width) = query_window_width(session_name)? else {
1669 return Ok(());
1670 };
1671 for (pane_idx, cols) in agent_row_widths(window_width, agent_count) {
1672 let target = format!("{session_name}:0.{pane_idx}");
1673 let cols_str = cols.to_string();
1674 let _ = Command::new("tmux")
1675 .args(["resize-pane", "-t", &target, "-x", &cols_str])
1676 .status();
1677 }
1678 Ok(())
1679}
1680
1681fn format_supervisor_pct(pct: f32) -> String {
1684 if (pct - pct.round()).abs() < 0.05 {
1685 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1686 let rounded = pct.round().clamp(0.0, 100.0) as u32;
1687 format!("{rounded}%")
1688 } else {
1689 format!("{pct:.1}%")
1690 }
1691}
1692
1693#[must_use]
1705pub fn build_supervisor_submit_argv_pair(
1706 session_name: &str,
1707 pane_index: usize,
1708 prompt: &str,
1709) -> (Vec<String>, Vec<String>) {
1710 let target = format!("{session_name}:0.{pane_index}");
1711 let first = vec![
1712 "send-keys".to_string(),
1713 "-t".to_string(),
1714 target.clone(),
1715 prompt.to_string(),
1716 "Enter".to_string(),
1717 ];
1718 let second = vec![
1719 "send-keys".to_string(),
1720 "-t".to_string(),
1721 target,
1722 "Enter".to_string(),
1723 ];
1724 (first, second)
1725}
1726
1727#[cfg(test)]
1728mod tests {
1729 use super::*;
1730
1731 fn make_pane(branch: &str, worktree: &str, cli: &str) -> PaneSpec {
1732 PaneSpec {
1733 branch: branch.to_owned(),
1734 worktree: worktree.to_owned(),
1735 cli_command: cli.to_owned(),
1736 }
1737 }
1738
1739 fn commands_containing(cmds: &[String], keyword: &str) -> Vec<String> {
1741 cmds.iter()
1742 .filter(|c| c.contains(keyword))
1743 .cloned()
1744 .collect()
1745 }
1746
1747 #[test]
1753 #[serial_test::serial]
1754 fn ensure_tmux_installed_succeeds_when_present() {
1755 assert!(ensure_tmux_installed().is_ok());
1757 }
1758
1759 #[test]
1766 fn session_is_named_after_project() {
1767 let session = TmuxSessionBuilder::new("my-project")
1768 .add_pane(make_pane("main", "/tmp/wt", "claude"))
1769 .build()
1770 .unwrap();
1771
1772 assert_eq!(session.name, "paw-my-project");
1773 }
1774
1775 #[test]
1776 fn session_creation_command_uses_session_name() {
1777 let session = TmuxSessionBuilder::new("app")
1778 .add_pane(make_pane("main", "/tmp/wt", "claude"))
1779 .build()
1780 .unwrap();
1781
1782 let cmds = session.command_strings();
1783 assert!(
1784 cmds.iter()
1785 .any(|c| c.contains("new-session") && c.contains("paw-app")),
1786 "should create a tmux session named paw-app"
1787 );
1788 }
1789
1790 #[test]
1793 fn new_session_passes_explicit_x_and_y() {
1794 let session = TmuxSessionBuilder::new("app")
1795 .add_pane(make_pane("main", "/tmp/wt", "claude"))
1796 .build()
1797 .unwrap();
1798
1799 let cmds = session.command_strings();
1800 let new_session_cmd = cmds
1801 .iter()
1802 .find(|c| c.contains("new-session"))
1803 .expect("new-session command present");
1804 assert!(
1805 new_session_cmd.contains("-x 480"),
1806 "new-session must pass -x 480; got: {new_session_cmd}"
1807 );
1808 assert!(
1809 new_session_cmd.contains("-y 140"),
1810 "new-session must pass -y 140; got: {new_session_cmd}"
1811 );
1812 }
1813
1814 #[test]
1817 fn basic_builder_sets_default_size_after_new_session() {
1818 let session = TmuxSessionBuilder::new("app")
1819 .add_pane(make_pane("main", "/tmp/wt", "claude"))
1820 .build()
1821 .unwrap();
1822
1823 let cmds = session.command_strings();
1824 let new_session_idx = cmds
1825 .iter()
1826 .position(|c| c.contains("new-session"))
1827 .expect("new-session in command list");
1828 let default_size_idx = cmds
1829 .iter()
1830 .position(|c| {
1831 c.contains("set-option") && c.contains("default-size") && c.contains("480x140")
1832 })
1833 .expect("set-option default-size 200x50 in command list");
1834 assert!(
1835 default_size_idx > new_session_idx,
1836 "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}"
1837 );
1838 }
1839
1840 #[test]
1841 fn session_name_override_replaces_default() {
1842 let session = TmuxSessionBuilder::new("my-project")
1843 .session_name("custom-session-name".to_string())
1844 .add_pane(make_pane("main", "/tmp/wt", "claude"))
1845 .build()
1846 .unwrap();
1847
1848 assert_eq!(session.name, "custom-session-name");
1849 let cmds = session.command_strings();
1850 assert!(
1851 cmds.iter()
1852 .any(|c| c.contains("new-session") && c.contains("custom-session-name")),
1853 "should use overridden session name"
1854 );
1855 }
1856
1857 #[test]
1865 fn pane_count_matches_input_for_two_panes() {
1866 let session = TmuxSessionBuilder::new("proj")
1867 .add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
1868 .add_pane(make_pane("feat/api", "/tmp/wt2", "codex"))
1869 .build()
1870 .unwrap();
1871
1872 let cmds = session.command_strings();
1873 let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
1874 .into_iter()
1875 .filter(|c| !c.trim_end().ends_with("C-u"))
1876 .collect();
1877 assert_eq!(
1878 send_keys.len(),
1879 2,
1880 "should send commands to exactly 2 panes"
1881 );
1882 }
1883
1884 #[test]
1885 fn pane_count_matches_input_for_five_panes() {
1886 let mut builder = TmuxSessionBuilder::new("proj");
1887 for i in 0..5 {
1888 builder = builder.add_pane(make_pane(
1889 &format!("feat/b{i}"),
1890 &format!("/tmp/wt{i}"),
1891 "claude",
1892 ));
1893 }
1894 let session = builder.build().unwrap();
1895
1896 let cmds = session.command_strings();
1897 let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
1898 .into_iter()
1899 .filter(|c| !c.trim_end().ends_with("C-u"))
1900 .collect();
1901 assert_eq!(
1902 send_keys.len(),
1903 5,
1904 "should send commands to exactly 5 panes"
1905 );
1906 }
1907
1908 #[test]
1909 fn building_with_no_panes_is_an_error() {
1910 let result = TmuxSessionBuilder::new("proj").build();
1911 assert!(result.is_err(), "session with no panes should fail");
1912 }
1913
1914 #[test]
1922 fn each_pane_receives_bare_cli_command_and_split_carries_worktree() {
1923 let session = TmuxSessionBuilder::new("proj")
1924 .add_pane(make_pane("feat/auth", "/home/user/wt-auth", "claude"))
1925 .add_pane(make_pane("feat/api", "/home/user/wt-api", "gemini"))
1926 .build()
1927 .unwrap();
1928
1929 let cmds = session.command_strings();
1930 let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
1931 .into_iter()
1932 .filter(|c| !c.trim_end().ends_with("C-u"))
1933 .collect();
1934
1935 assert!(
1938 send_keys[0].contains("claude"),
1939 "first pane should run claude; got: {}",
1940 send_keys[0]
1941 );
1942
1943 assert!(
1947 send_keys[1].contains("gemini"),
1948 "second pane should run gemini; got: {}",
1949 send_keys[1]
1950 );
1951 assert!(
1952 !send_keys[1].contains("cd /home/user/wt-api"),
1953 "second pane send-keys MUST NOT prefix `cd <worktree>`; got: {}",
1954 send_keys[1]
1955 );
1956
1957 let splits = commands_containing(&cmds, "split-window");
1960 assert!(
1961 splits.iter().any(|c| c.contains("-c /home/user/wt-api")),
1962 "split-window for pane 1 should pass -c /home/user/wt-api; got: {splits:?}"
1963 );
1964 }
1965
1966 #[test]
1967 fn pane_commands_are_submitted_with_enter() {
1968 let session = TmuxSessionBuilder::new("proj")
1969 .add_pane(make_pane("main", "/tmp/wt", "aider"))
1970 .build()
1971 .unwrap();
1972
1973 let cmds = session.command_strings();
1974 let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
1975 .into_iter()
1976 .filter(|c| !c.trim_end().ends_with("C-u"))
1977 .collect();
1978 assert!(
1979 send_keys[0].contains("Enter"),
1980 "send-keys should press Enter to submit"
1981 );
1982 }
1983
1984 #[test]
1985 fn each_pane_targets_a_distinct_pane_index() {
1986 let session = TmuxSessionBuilder::new("proj")
1987 .add_pane(make_pane("feat/a", "/tmp/a", "claude"))
1988 .add_pane(make_pane("feat/b", "/tmp/b", "codex"))
1989 .add_pane(make_pane("feat/c", "/tmp/c", "gemini"))
1990 .build()
1991 .unwrap();
1992
1993 let cmds = session.command_strings();
1994 let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
1995 .into_iter()
1996 .filter(|c| !c.trim_end().ends_with("C-u"))
1997 .collect();
1998
1999 assert!(
2000 send_keys[0].contains(":0.0"),
2001 "first pane should target :0.0"
2002 );
2003 assert!(
2004 send_keys[1].contains(":0.1"),
2005 "second pane should target :0.1"
2006 );
2007 assert!(
2008 send_keys[2].contains(":0.2"),
2009 "third pane should target :0.2"
2010 );
2011 }
2012
2013 #[test]
2021 fn each_pane_is_titled_with_its_branch() {
2022 let session = TmuxSessionBuilder::new("proj")
2023 .add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
2024 .add_pane(make_pane("fix/api", "/tmp/wt2", "gemini"))
2025 .build()
2026 .unwrap();
2027
2028 let cmds = session.command_strings();
2029 let select_panes = commands_containing(&cmds, "select-pane");
2030
2031 assert_eq!(select_panes.len(), 2, "each pane should get a title");
2032 assert!(
2035 select_panes[0].ends_with("-T feat/auth"),
2036 "first pane title should be 'feat/auth', got: {}",
2037 select_panes[0]
2038 );
2039 assert!(
2040 !select_panes[0].contains("claude"),
2041 "first pane title should not include the CLI command, got: {}",
2042 select_panes[0]
2043 );
2044 assert!(
2045 select_panes[1].ends_with("-T fix/api"),
2046 "second pane title should be 'fix/api', got: {}",
2047 select_panes[1]
2048 );
2049 }
2050
2051 #[test]
2057 fn each_pane_gets_a_stable_paw_role_option() {
2058 let session = TmuxSessionBuilder::new("proj")
2059 .add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
2060 .add_pane(make_pane("fix/api", "/tmp/wt2", "gemini"))
2061 .build()
2062 .unwrap();
2063
2064 let cmds = session.command_strings();
2065 let role_opts: Vec<&String> = cmds
2068 .iter()
2069 .filter(|c| c.contains("set-option") && c.contains(" -p ") && c.contains("@paw_role"))
2070 .collect();
2071 assert_eq!(
2072 role_opts.len(),
2073 2,
2074 "each pane should get a @paw_role option"
2075 );
2076 assert!(
2077 role_opts.iter().any(|c| c.ends_with("@paw_role feat/auth")),
2078 "first pane should set `@paw_role feat/auth` pane-scoped; got: {role_opts:#?}"
2079 );
2080 assert!(
2081 role_opts.iter().any(|c| c.ends_with("@paw_role fix/api")),
2082 "second pane should set `@paw_role fix/api`; got: {role_opts:#?}"
2083 );
2084 }
2085
2086 #[test]
2087 fn pane_border_status_is_configured() {
2088 let session = TmuxSessionBuilder::new("proj")
2089 .add_pane(make_pane("main", "/tmp/wt", "claude"))
2090 .build()
2091 .unwrap();
2092
2093 let cmds = session.command_strings();
2094 assert!(
2095 cmds.iter()
2096 .any(|c| c.contains("pane-border-status") && c.contains("top")),
2097 "should configure pane-border-status to top"
2098 );
2099 assert!(
2100 cmds.iter()
2101 .any(|c| c.contains("pane-border-format") && c.contains("#{pane_title}")),
2102 "should configure pane-border-format to show pane title"
2103 );
2104 }
2105
2106 const AFFORDANCE_OPTIONS: [(&str, &str); 5] = [
2115 ("pane-border-lines", "double"),
2116 ("pane-border-style", "fg=colour238"),
2117 ("pane-active-border-style", "fg=colour45,bold"),
2118 ("pane-border-status", "top"),
2119 (
2120 "pane-border-format",
2121 "#[fg=colour39,bold,reverse] #{pane_index}: #{?#{@paw_role},#{@paw_role},#{pane_title}} #[default]",
2122 ),
2123 ];
2124
2125 #[test]
2128 fn builder_emits_all_five_affordances_scoped_to_session_by_default() {
2129 let session = TmuxSessionBuilder::new("aff-default")
2130 .add_pane(make_pane("feat/a", "/tmp/wt", "claude"))
2131 .build()
2132 .unwrap();
2133 let cmds = session.command_strings();
2134 for (option, value) in AFFORDANCE_OPTIONS {
2135 assert!(
2136 cmds.iter().any(|c| c.contains("set-option")
2137 && c.contains("-t paw-aff-default")
2138 && c.contains(option)
2139 && c.contains(value)),
2140 "expected `set-option -t paw-aff-default {option} {value}`; cmds:\n{cmds:#?}"
2141 );
2142 }
2143 }
2144
2145 #[test]
2150 fn border_format_is_index_then_role_with_padding() {
2151 let session = TmuxSessionBuilder::new("fmt")
2152 .add_pane(make_pane("feat/a", "/tmp/wt", "claude"))
2153 .build()
2154 .unwrap();
2155 let format_cmd = session
2156 .command_strings()
2157 .into_iter()
2158 .find(|c| c.contains("pane-border-format"))
2159 .expect("pane-border-format set-option present");
2160 assert!(
2161 format_cmd.ends_with(
2162 "pane-border-format #[fg=colour39,bold,reverse] #{pane_index}: #{?#{@paw_role},#{@paw_role},#{pane_title}} #[default]"
2163 ),
2164 "format must be the reverse-video label bar preferring @paw_role; got: {format_cmd}"
2165 );
2166 }
2167
2168 #[test]
2171 fn active_and_inactive_border_styles_applied() {
2172 let session = TmuxSessionBuilder::new("styles")
2173 .add_pane(make_pane("feat/a", "/tmp/wt", "claude"))
2174 .build()
2175 .unwrap();
2176 let cmds = session.command_strings();
2177 assert!(
2178 cmds.iter()
2179 .any(|c| c.contains("pane-active-border-style") && c.contains("colour45,bold")),
2180 "active border must be colour45,bold; cmds:\n{cmds:#?}"
2181 );
2182 assert!(
2183 cmds.iter()
2184 .any(|c| c.contains("pane-border-style") && c.contains("colour238")),
2185 "inactive border must be colour238; cmds:\n{cmds:#?}"
2186 );
2187 }
2188
2189 #[test]
2193 fn opt_out_omits_every_affordance_and_title() {
2194 let session = TmuxSessionBuilder::new("opt-out")
2195 .add_pane(make_pane("feat/a", "/tmp/wt", "claude"))
2196 .add_pane(make_pane("feat/b", "/tmp/wt2", "gemini"))
2197 .border_affordances(false)
2198 .build()
2199 .unwrap();
2200 let cmds = session.command_strings();
2201 for (option, _value) in AFFORDANCE_OPTIONS {
2202 assert!(
2203 !cmds
2204 .iter()
2205 .any(|c| c.contains("set-option") && c.contains(option)),
2206 "opt-out must not emit set-option {option}; cmds:\n{cmds:#?}"
2207 );
2208 }
2209 assert!(
2210 !cmds
2211 .iter()
2212 .any(|c| c.contains("select-pane") && c.contains("-T")),
2213 "opt-out must not set any pane title; cmds:\n{cmds:#?}"
2214 );
2215 assert!(
2216 !cmds.iter().any(|c| c.contains("@paw_role")),
2217 "opt-out must not set the @paw_role pane option; cmds:\n{cmds:#?}"
2218 );
2219 assert_eq!(
2221 commands_containing(&cmds, "send-keys").len(),
2222 2,
2223 "both panes still receive their CLI send-keys"
2224 );
2225 }
2226
2227 #[test]
2230 fn soft_affordance_failure_warns_and_continues() {
2231 let session = TmuxSessionBuilder::new("degrade")
2232 .add_pane(make_pane("feat/a", "/tmp/wt", "claude"))
2233 .build()
2234 .unwrap();
2235
2236 let mut ran: Vec<String> = Vec::new();
2237 let mut warnings: Vec<String> = Vec::new();
2238 let result = session.execute_with(
2240 |cmd| {
2241 let s = cmd.as_command_string();
2242 ran.push(s.clone());
2243 if s.contains("pane-border-lines double") {
2244 Err(PawError::TmuxError(
2245 "unknown option: pane-border-lines".into(),
2246 ))
2247 } else {
2248 Ok(())
2249 }
2250 },
2251 |w| warnings.push(w),
2252 );
2253
2254 assert!(result.is_ok(), "soft failure must not abort the build");
2255 assert!(
2256 warnings.iter().any(|w| w.contains("pane-border-lines")),
2257 "a warning naming the unsupported option must be emitted; warnings: {warnings:#?}"
2258 );
2259 assert!(
2261 ran.iter().any(|c| c.contains("pane-active-border-style")),
2262 "active-border-style must still be applied after the double-line failure"
2263 );
2264 assert!(
2265 ran.iter().any(|c| c.contains("pane-border-status top")),
2266 "pane-border-status must still be applied after the double-line failure"
2267 );
2268 }
2269
2270 #[test]
2273 fn hard_command_failure_aborts() {
2274 let session = TmuxSessionBuilder::new("hard-fail")
2275 .add_pane(make_pane("feat/a", "/tmp/wt", "claude"))
2276 .build()
2277 .unwrap();
2278 let result = session.execute_with(
2279 |cmd| {
2280 if cmd.as_command_string().contains("new-session") {
2281 Err(PawError::TmuxError("server unreachable".into()))
2282 } else {
2283 Ok(())
2284 }
2285 },
2286 |_| {},
2287 );
2288 assert!(result.is_err(), "a hard command failure must propagate");
2289 }
2290
2291 #[test]
2294 fn supervisor_session_titles_are_roles_and_emits_affordances() {
2295 let layout = crate::supervisor::layout::supervisor_layout(2).expect("layout");
2296 let supervisor = make_pane("supervisor", "/repo", "claude");
2297 let dashboard = make_pane("dashboard", "/repo", "git-paw __dashboard");
2298 let agent = make_pane("feat/foo", "/tmp/wt", "claude");
2299 let session = build_supervisor_session(
2300 "sup",
2301 None,
2302 &supervisor,
2303 &dashboard,
2304 &[agent],
2305 layout,
2306 true,
2307 true,
2308 &[],
2309 )
2310 .expect("session builds");
2311 let cmds = session.command_strings();
2312
2313 for (option, value) in AFFORDANCE_OPTIONS {
2315 assert!(
2316 cmds.iter().any(|c| c.contains("set-option")
2317 && c.contains("-t paw-sup")
2318 && c.contains(option)
2319 && c.contains(value)),
2320 "supervisor session missing `set-option {option} {value}`; cmds:\n{cmds:#?}"
2321 );
2322 }
2323
2324 let title_for = |target: &str| -> String {
2325 cmds.iter()
2326 .find(|c| c.contains("select-pane") && c.contains(target) && c.contains("-T"))
2327 .unwrap_or_else(|| panic!("no title set for {target}; cmds:\n{cmds:#?}"))
2328 .clone()
2329 };
2330 assert!(title_for(":0.0").ends_with("-T supervisor"), "pane 0 title");
2331 assert!(title_for(":0.1").ends_with("-T dashboard"), "pane 1 title");
2332 assert!(
2333 title_for(":0.2").ends_with("-T feat/foo"),
2334 "agent pane title"
2335 );
2336 }
2337
2338 #[test]
2342 fn supervisor_build_suppresses_startup_prompts_and_clears_input() {
2343 let layout = crate::supervisor::layout::supervisor_layout(1).expect("layout");
2344 let supervisor = make_pane("supervisor", "/repo", "claude");
2345 let dashboard = make_pane("dashboard", "/repo", "git-paw __dashboard");
2346 let agent = make_pane("feat/foo", "/tmp/wt", "claude");
2347 let session = build_supervisor_session(
2348 "sup",
2349 None,
2350 &supervisor,
2351 &dashboard,
2352 &[agent],
2353 layout,
2354 true,
2355 true,
2356 &[],
2357 )
2358 .expect("session builds");
2359 let cmds = session.command_strings();
2360
2361 assert!(
2363 cmds.iter()
2364 .any(|c| c.contains("new-session") && c.contains("DISABLE_AUTO_UPDATE=true")),
2365 "new-session must set DISABLE_AUTO_UPDATE for pane 0; cmds:\n{cmds:#?}"
2366 );
2367 assert!(
2369 cmds.iter().any(|c| c.contains("set-environment")
2370 && c.contains("DISABLE_AUTO_UPDATE")
2371 && c.contains("true")),
2372 "session env must carry DISABLE_AUTO_UPDATE for split panes"
2373 );
2374 let clear_idx = cmds.iter().position(|c| {
2376 c.contains("send-keys") && c.contains(":0.0") && c.trim_end().ends_with("C-u")
2377 });
2378 let launch_idx = cmds.iter().position(|c| {
2379 c.contains("send-keys")
2380 && c.contains(":0.0")
2381 && c.contains("claude")
2382 && c.contains("Enter")
2383 });
2384 let (clear_idx, launch_idx) = (
2385 clear_idx.expect("a C-u clear is sent to pane 0"),
2386 launch_idx.expect("the CLI-launch command is sent to pane 0"),
2387 );
2388 assert!(
2389 clear_idx < launch_idx,
2390 "the C-u clear must precede the CLI-launch command on pane 0"
2391 );
2392 }
2393
2394 #[test]
2400 fn supervisor_build_compensates_first_agent_cwd_for_swap() {
2401 let layout = crate::supervisor::layout::supervisor_layout(2).expect("layout");
2402 let supervisor = make_pane("supervisor", "/repo", "claude");
2403 let dashboard = make_pane("dashboard", "/repo", "git-paw __dashboard");
2404 let a0 = make_pane("feat/foo", "/tmp/wt-foo", "claude");
2405 let a1 = make_pane("feat/bar", "/tmp/wt-bar", "claude");
2406 let session = build_supervisor_session(
2407 "sup",
2408 None,
2409 &supervisor,
2410 &dashboard,
2411 &[a0, a1],
2412 layout,
2413 true,
2414 true,
2415 &[],
2416 )
2417 .expect("session builds");
2418 let cmds = session.command_strings();
2419
2420 let vsplit = cmds
2421 .iter()
2422 .find(|c| c.contains("split-window") && c.contains("-v") && c.contains("-c"))
2423 .expect("agent-area -v split with -c");
2424 let hsplit = cmds
2425 .iter()
2426 .find(|c| c.contains("split-window") && c.contains("-h") && c.contains("-c"))
2427 .expect("dashboard -h split with -c");
2428
2429 assert!(
2433 vsplit.contains("-c /repo"),
2434 "agent-area -v split must use the dashboard cwd (swap compensation); got: {vsplit}"
2435 );
2436 assert!(
2437 hsplit.contains("-c /tmp/wt-foo"),
2438 "dashboard -h split must use the first agent's worktree (swap compensation); got: {hsplit}"
2439 );
2440 }
2441
2442 #[test]
2445 fn supervisor_session_opt_out_omits_affordances() {
2446 let layout = crate::supervisor::layout::supervisor_layout(1).expect("layout");
2447 let supervisor = make_pane("supervisor", "/repo", "claude");
2448 let dashboard = make_pane("dashboard", "/repo", "git-paw __dashboard");
2449 let agent = make_pane("feat/foo", "/tmp/wt", "claude");
2450 let session = build_supervisor_session(
2451 "sup-off",
2452 None,
2453 &supervisor,
2454 &dashboard,
2455 &[agent],
2456 layout,
2457 true,
2458 false,
2459 &[],
2460 )
2461 .expect("session builds");
2462 let cmds = session.command_strings();
2463 for (option, _value) in AFFORDANCE_OPTIONS {
2464 assert!(
2465 !cmds
2466 .iter()
2467 .any(|c| c.contains("set-option") && c.contains(option)),
2468 "opt-out supervisor session must not emit set-option {option}"
2469 );
2470 }
2471 assert!(
2472 !cmds
2473 .iter()
2474 .any(|c| c.contains("select-pane") && c.contains("-T")),
2475 "opt-out supervisor session must not set pane titles"
2476 );
2477 }
2478
2479 #[test]
2486 fn mouse_mode_enabled_by_default() {
2487 let session = TmuxSessionBuilder::new("proj")
2488 .add_pane(make_pane("main", "/tmp/wt", "claude"))
2489 .build()
2490 .unwrap();
2491
2492 let cmds = session.command_strings();
2493 assert!(
2494 cmds.iter().any(|c| c.contains("mouse on")),
2495 "mouse should be enabled by default"
2496 );
2497 }
2498
2499 #[test]
2500 fn mouse_mode_can_be_disabled() {
2501 let session = TmuxSessionBuilder::new("proj")
2502 .add_pane(make_pane("main", "/tmp/wt", "claude"))
2503 .mouse_mode(false)
2504 .build()
2505 .unwrap();
2506
2507 let cmds = session.command_strings();
2508 assert!(
2509 !cmds.iter().any(|c| c.contains("mouse on")),
2510 "no mouse-on command should be emitted when disabled"
2511 );
2512 }
2513
2514 fn create_test_session(name: &str) {
2522 let output = std::process::Command::new("tmux")
2523 .args(["new-session", "-d", "-s", name, "-x", "200", "-y", "50"])
2524 .output()
2525 .expect("create tmux session");
2526 assert!(
2527 output.status.success(),
2528 "failed to create test session '{name}'"
2529 );
2530 }
2531
2532 fn cleanup_session(name: &str) {
2534 let _ = kill_session(name);
2535 }
2536
2537 #[test]
2538 #[serial_test::serial]
2539 fn is_session_alive_returns_false_for_nonexistent() {
2540 let alive = is_session_alive("paw-definitely-does-not-exist-12345").unwrap();
2541 assert!(!alive);
2542 }
2543
2544 #[test]
2545 #[serial_test::serial]
2546 fn session_lifecycle_create_check_kill() {
2547 let name = "paw-unit-test-lifecycle";
2548 cleanup_session(name);
2549
2550 create_test_session(name);
2551 assert!(is_session_alive(name).unwrap());
2552
2553 kill_session(name).unwrap();
2554 assert!(!is_session_alive(name).unwrap());
2555 }
2556
2557 #[test]
2562 fn classify_liveness_maps_each_branch() {
2563 assert_eq!(classify_liveness(true, true), SessionLiveness::Alive);
2565 assert_eq!(classify_liveness(true, false), SessionLiveness::Stale);
2567 assert_eq!(
2569 classify_liveness(false, false),
2570 SessionLiveness::Indeterminate
2571 );
2572 assert_eq!(
2573 classify_liveness(false, true),
2574 SessionLiveness::Indeterminate
2575 );
2576 }
2577
2578 #[test]
2579 #[serial_test::serial]
2580 fn session_liveness_reports_stale_for_nonexistent() {
2581 assert_eq!(
2582 session_liveness("paw-definitely-does-not-exist-98765"),
2583 SessionLiveness::Stale
2584 );
2585 }
2586
2587 #[test]
2588 #[serial_test::serial]
2589 fn session_liveness_reports_alive_then_stale_across_lifecycle() {
2590 let name = "paw-unit-test-liveness-probe";
2591 cleanup_session(name);
2592
2593 create_test_session(name);
2594 assert_eq!(session_liveness(name), SessionLiveness::Alive);
2595
2596 kill_session(name).unwrap();
2597 assert_eq!(session_liveness(name), SessionLiveness::Stale);
2598 }
2599
2600 #[test]
2601 #[serial_test::serial]
2602 fn resolve_session_name_returns_base_when_no_collision() {
2603 let name = resolve_session_name("unit-test-no-collision-xyz").unwrap();
2604 assert_eq!(name, "paw-unit-test-no-collision-xyz");
2605 }
2606
2607 #[test]
2608 #[serial_test::serial]
2609 fn resolve_session_name_appends_suffix_on_collision() {
2610 let base_name = "paw-unit-test-collision";
2611 cleanup_session(base_name);
2612 cleanup_session(&format!("{base_name}-2"));
2613
2614 create_test_session(base_name);
2615
2616 let resolved = resolve_session_name("unit-test-collision").unwrap();
2617 assert_eq!(resolved, format!("{base_name}-2"));
2618
2619 cleanup_session(base_name);
2620 }
2621
2622 #[test]
2628 fn pipe_pane_queues_correct_command() {
2629 let mut session = TmuxSessionBuilder::new("proj")
2630 .add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
2631 .build()
2632 .unwrap();
2633
2634 let log_path = std::path::PathBuf::from("/repo/.git-paw/logs/paw-proj/feat--auth.log");
2635 session.pipe_pane("paw-proj:0.0", &log_path);
2636
2637 let cmds = session.command_strings();
2638 let pipe_cmds: Vec<&String> = cmds.iter().filter(|c| c.contains("pipe-pane")).collect();
2639 assert_eq!(pipe_cmds.len(), 1);
2640 assert!(pipe_cmds[0].contains("pipe-pane -o -t paw-proj:0.0"));
2641 assert!(pipe_cmds[0].contains("cat >> /repo/.git-paw/logs/paw-proj/feat--auth.log"));
2642 }
2643
2644 #[test]
2647 fn session_without_pipe_pane_has_no_pipe_pane_commands() {
2648 let session = TmuxSessionBuilder::new("proj")
2649 .add_pane(make_pane("main", "/tmp/wt", "claude"))
2650 .build()
2651 .unwrap();
2652
2653 let cmds = session.command_strings();
2654 assert!(
2655 !cmds.iter().any(|c| c.contains("pipe-pane")),
2656 "session built without pipe_pane calls should have no pipe-pane commands"
2657 );
2658 }
2659
2660 #[test]
2661 fn session_with_pipe_pane_differs_from_without() {
2662 let session_without = TmuxSessionBuilder::new("proj")
2663 .add_pane(make_pane("main", "/tmp/wt", "claude"))
2664 .build()
2665 .unwrap();
2666 let cmds_without = session_without.command_strings();
2667
2668 let mut session_with = TmuxSessionBuilder::new("proj")
2669 .add_pane(make_pane("main", "/tmp/wt", "claude"))
2670 .build()
2671 .unwrap();
2672 let log_path = std::path::PathBuf::from("/repo/.git-paw/logs/paw-proj/main.log");
2673 session_with.pipe_pane("paw-proj:0.0", &log_path);
2674 let cmds_with = session_with.command_strings();
2675
2676 assert_ne!(
2677 cmds_without, cmds_with,
2678 "command lists should differ when pipe-pane is added"
2679 );
2680 assert!(
2681 cmds_with.iter().any(|c| c.contains("pipe-pane")),
2682 "session with pipe_pane should contain pipe-pane command"
2683 );
2684 }
2685
2686 #[test]
2689 fn pipe_pane_appears_after_send_keys_for_pane() {
2690 let mut session = TmuxSessionBuilder::new("proj")
2691 .add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
2692 .add_pane(make_pane("feat/api", "/tmp/wt2", "codex"))
2693 .build()
2694 .unwrap();
2695
2696 let log0 = std::path::PathBuf::from("/repo/logs/feat--auth.log");
2697 let log1 = std::path::PathBuf::from("/repo/logs/feat--api.log");
2698 session.pipe_pane("paw-proj:0.0", &log0);
2699 session.pipe_pane("paw-proj:0.1", &log1);
2700
2701 let cmds = session.command_strings();
2702
2703 let last_send_keys = cmds
2705 .iter()
2706 .rposition(|c| c.contains("send-keys"))
2707 .expect("should have send-keys");
2708 let first_pipe_pane = cmds
2709 .iter()
2710 .position(|c| c.contains("pipe-pane"))
2711 .expect("should have pipe-pane");
2712
2713 assert!(
2714 first_pipe_pane > last_send_keys,
2715 "pipe-pane commands (index {first_pipe_pane}) should appear after \
2716 all send-keys commands (last at index {last_send_keys})"
2717 );
2718 }
2719
2720 #[test]
2721 fn pipe_pane_appears_in_dry_run_output() {
2722 let mut session = TmuxSessionBuilder::new("proj")
2723 .add_pane(make_pane("main", "/tmp/wt", "claude"))
2724 .build()
2725 .unwrap();
2726
2727 let log_path = std::path::PathBuf::from("/repo/.git-paw/logs/paw-proj/main.log");
2728 session.pipe_pane("paw-proj:0.0", &log_path);
2729
2730 let cmds = session.command_strings();
2731 assert!(
2732 cmds.iter().any(|c| c.starts_with("tmux pipe-pane")),
2733 "dry-run output should include pipe-pane command"
2734 );
2735 }
2736
2737 #[test]
2742 fn set_environment_emits_correct_tmux_command() {
2743 let session = TmuxSessionBuilder::new("proj")
2744 .add_pane(make_pane("main", "/tmp/wt", "claude"))
2745 .set_environment("GIT_PAW_BROKER_URL", "http://127.0.0.1:9119")
2746 .build()
2747 .unwrap();
2748
2749 let cmds = session.command_strings();
2750 let env_cmds = commands_containing(&cmds, "set-environment");
2751 assert_eq!(env_cmds.len(), 1, "should have exactly one set-environment");
2752 assert!(
2753 env_cmds[0]
2754 .contains("set-environment -t paw-proj GIT_PAW_BROKER_URL http://127.0.0.1:9119"),
2755 "set-environment command should contain key and value, got: {}",
2756 env_cmds[0]
2757 );
2758 }
2759
2760 #[test]
2761 fn set_environment_appears_before_send_keys() {
2762 let session = TmuxSessionBuilder::new("proj")
2763 .add_pane(make_pane("feat/a", "/tmp/a", "claude"))
2764 .add_pane(make_pane("feat/b", "/tmp/b", "codex"))
2765 .set_environment("GIT_PAW_BROKER_URL", "http://127.0.0.1:9119")
2766 .build()
2767 .unwrap();
2768
2769 let cmds = session.command_strings();
2770 let first_env = cmds
2771 .iter()
2772 .position(|c| c.contains("set-environment"))
2773 .expect("should have set-environment");
2774 let first_send = cmds
2775 .iter()
2776 .position(|c| c.contains("send-keys"))
2777 .expect("should have send-keys");
2778
2779 assert!(
2780 first_env < first_send,
2781 "set-environment (index {first_env}) should appear before first send-keys (index {first_send})"
2782 );
2783 }
2784
2785 #[test]
2786 fn multiple_env_vars_both_appear() {
2787 let session = TmuxSessionBuilder::new("proj")
2788 .add_pane(make_pane("main", "/tmp/wt", "claude"))
2789 .set_environment("A", "1")
2790 .set_environment("B", "2")
2791 .build()
2792 .unwrap();
2793
2794 let cmds = session.command_strings();
2795 let env_cmds = commands_containing(&cmds, "set-environment");
2796 assert_eq!(
2797 env_cmds.len(),
2798 2,
2799 "should have two set-environment commands"
2800 );
2801 assert!(env_cmds[0].contains("A 1"));
2802 assert!(env_cmds[1].contains("B 2"));
2803 }
2804
2805 #[test]
2806 fn set_environment_in_dry_run_output() {
2807 let session = TmuxSessionBuilder::new("proj")
2808 .add_pane(make_pane("main", "/tmp/wt", "claude"))
2809 .set_environment("MY_VAR", "my_val")
2810 .build()
2811 .unwrap();
2812
2813 let cmds = session.command_strings();
2814 assert!(
2815 cmds.iter().any(|c| c.starts_with("tmux set-environment")),
2816 "dry-run output should include set-environment command"
2817 );
2818 }
2819
2820 #[test]
2826 fn session_without_dashboard_uses_tiled_layout() {
2827 let session = TmuxSessionBuilder::new("proj")
2828 .add_pane(make_pane("feat/a", "/tmp/a", "claude"))
2829 .add_pane(make_pane("feat/b", "/tmp/b", "codex"))
2830 .build()
2831 .unwrap();
2832
2833 let cmds = session.command_strings();
2834 let layout_cmds: Vec<&String> = cmds
2835 .iter()
2836 .filter(|c| c.contains("select-layout"))
2837 .collect();
2838 let final_layout = layout_cmds
2839 .last()
2840 .expect("should have at least one select-layout");
2841 assert!(
2842 final_layout.contains("tiled"),
2843 "sessions without dashboard should use tiled layout, got: {final_layout}"
2844 );
2845 }
2846
2847 #[test]
2848 fn session_with_dashboard_uses_main_horizontal_layout() {
2849 let session = TmuxSessionBuilder::new("proj")
2850 .add_pane(make_pane("dashboard", "/tmp/repo", "git-paw __dashboard"))
2851 .add_pane(make_pane("feat/a", "/tmp/a", "claude"))
2852 .add_pane(make_pane("feat/b", "/tmp/b", "codex"))
2853 .build()
2854 .unwrap();
2855
2856 let cmds = session.command_strings();
2857 let layout_cmds: Vec<&String> = cmds
2858 .iter()
2859 .filter(|c| c.contains("select-layout"))
2860 .collect();
2861 let final_layout = layout_cmds
2862 .last()
2863 .expect("should have at least one select-layout");
2864 assert!(
2865 final_layout.contains("main-horizontal"),
2866 "sessions with dashboard should use main-horizontal layout, got: {final_layout}"
2867 );
2868 }
2869
2870 #[test]
2871 fn single_pane_session_uses_tiled_layout() {
2872 let session = TmuxSessionBuilder::new("proj")
2873 .add_pane(make_pane("main", "/tmp/wt", "claude"))
2874 .build()
2875 .unwrap();
2876
2877 let cmds = session.command_strings();
2878 let layout_cmds: Vec<&String> = cmds
2879 .iter()
2880 .filter(|c| c.contains("select-layout"))
2881 .collect();
2882 let final_layout = layout_cmds
2883 .last()
2884 .expect("should have at least one select-layout");
2885 assert!(
2886 final_layout.contains("tiled"),
2887 "single pane sessions should use tiled layout, got: {final_layout}"
2888 );
2889 }
2890
2891 #[test]
2892 fn dashboard_layout_appears_in_dry_run_output() {
2893 let session = TmuxSessionBuilder::new("proj")
2894 .add_pane(make_pane("dashboard", "/tmp/repo", "git-paw __dashboard"))
2895 .add_pane(make_pane("feat/a", "/tmp/a", "claude"))
2896 .build()
2897 .unwrap();
2898
2899 let cmds = session.command_strings();
2900 assert!(
2901 cmds.iter().any(|c| c.contains("main-horizontal")),
2902 "dry-run output should include main-horizontal layout command"
2903 );
2904 }
2905
2906 struct PausePaneSession {
2913 name: String,
2914 }
2915
2916 impl PausePaneSession {
2917 fn new(label: &str) -> Self {
2918 let pid = std::process::id();
2919 let nanos = std::time::SystemTime::now()
2920 .duration_since(std::time::UNIX_EPOCH)
2921 .map_or(0, |d| d.as_nanos());
2922 let name = format!("paw-pause-test-{label}-{pid}-{nanos}");
2923 let output = std::process::Command::new("tmux")
2924 .args(["new-session", "-d", "-s", &name, "-x", "200", "-y", "50"])
2925 .output()
2926 .expect("create tmux test session");
2927 assert!(
2928 output.status.success(),
2929 "failed to create test session '{name}'"
2930 );
2931 Self { name }
2932 }
2933 }
2934
2935 impl Drop for PausePaneSession {
2936 fn drop(&mut self) {
2937 let _ = kill_session(&self.name);
2938 }
2939 }
2940
2941 #[test]
2942 #[serial_test::serial]
2943 fn detach_client_succeeds_on_attached_session() {
2944 let session = PausePaneSession::new("detach-attached");
2948 detach_client(&session.name).expect("detach should succeed");
2949 assert!(is_session_alive(&session.name).unwrap());
2950 }
2951
2952 #[test]
2953 #[serial_test::serial]
2954 fn detach_client_is_noop_with_no_clients() {
2955 let session = PausePaneSession::new("detach-noop");
2956 detach_client(&session.name).expect("first detach should succeed");
2958 detach_client(&session.name).expect("second detach should succeed");
2960 assert!(is_session_alive(&session.name).unwrap());
2961 }
2962
2963 #[test]
2967 #[serial_test::serial]
2968 fn detach_client_noop_when_no_clients_attached() {
2969 let session = PausePaneSession::new("detach-9-11");
2970 detach_client(&session.name).expect("detach with no clients should be Ok");
2971 assert!(is_session_alive(&session.name).unwrap());
2972 }
2973
2974 #[test]
2975 #[serial_test::serial]
2976 fn kill_pane_removes_pane() {
2977 let session = PausePaneSession::new("killpane");
2978 let _ = std::process::Command::new("tmux")
2980 .args(["split-window", "-t", &session.name])
2981 .output();
2982 let pane_count_before = std::process::Command::new("tmux")
2983 .args(["list-panes", "-t", &session.name, "-F", "#{pane_index}"])
2984 .output()
2985 .map_or(0, |o| String::from_utf8_lossy(&o.stdout).lines().count());
2986 assert_eq!(pane_count_before, 2, "should have 2 panes before kill");
2987
2988 kill_pane(&session.name, 1).expect("kill_pane should succeed");
2989
2990 let pane_count_after = std::process::Command::new("tmux")
2991 .args(["list-panes", "-t", &session.name, "-F", "#{pane_index}"])
2992 .output()
2993 .map_or(0, |o| String::from_utf8_lossy(&o.stdout).lines().count());
2994 assert_eq!(pane_count_after, 1, "should have 1 pane after kill");
2995 }
2996
2997 #[test]
2998 #[serial_test::serial]
2999 fn kill_pane_is_noop_for_missing_pane() {
3000 let session = PausePaneSession::new("killpane-missing");
3001 kill_pane(&session.name, 99).expect("kill missing pane should be ok");
3003 assert!(is_session_alive(&session.name).unwrap());
3004 }
3005
3006 #[test]
3007 #[serial_test::serial]
3008 fn built_session_can_be_executed_and_killed() {
3009 let project = "unit-test-execute";
3010 let session_name = format!("paw-{project}");
3011 cleanup_session(&session_name);
3012
3013 let session = TmuxSessionBuilder::new(project)
3014 .add_pane(make_pane("main", "/tmp", "echo hello"))
3015 .build()
3016 .unwrap();
3017
3018 session.execute().unwrap();
3019 assert!(is_session_alive(&session_name).unwrap());
3020
3021 kill_session(&session_name).unwrap();
3022 assert!(!is_session_alive(&session_name).unwrap());
3023 }
3024
3025 #[test]
3032 fn supervisor_submit_argv_pair_has_two_invocations() {
3033 let (first, second) = build_supervisor_submit_argv_pair("paw-proj", 3, "do the thing");
3034 assert!(!first.is_empty(), "first send-keys argv must be non-empty");
3036 assert!(
3037 !second.is_empty(),
3038 "second send-keys argv must be non-empty"
3039 );
3040 }
3041
3042 #[test]
3043 fn supervisor_submit_first_invocation_sends_prompt_and_enter() {
3044 let (first, _second) = build_supervisor_submit_argv_pair("paw-proj", 3, "do the thing");
3045 assert_eq!(first[0], "send-keys");
3046 assert_eq!(first[1], "-t");
3047 assert_eq!(first[2], "paw-proj:0.3");
3048 assert_eq!(first[3], "do the thing");
3049 assert_eq!(first[4], "Enter");
3050 }
3051
3052 #[test]
3053 fn supervisor_submit_second_invocation_is_enter_only() {
3054 let (_first, second) = build_supervisor_submit_argv_pair("paw-proj", 3, "do the thing");
3055 assert_eq!(second[0], "send-keys");
3056 assert_eq!(second[1], "-t");
3057 assert_eq!(second[2], "paw-proj:0.3");
3058 assert_eq!(second[3], "Enter");
3059 assert_eq!(
3060 second.len(),
3061 4,
3062 "second invocation should be send-keys -t <target> Enter (no prompt)"
3063 );
3064 }
3065
3066 #[test]
3067 fn supervisor_submit_targets_same_pane_in_both_invocations() {
3068 let (first, second) = build_supervisor_submit_argv_pair("paw-proj", 7, "prompt");
3069 assert_eq!(first[2], second[2]);
3072 assert_eq!(first[2], "paw-proj:0.7");
3073 }
3074
3075 #[test]
3076 fn supervisor_submit_argv_pair_preserves_prompt_with_newlines_and_quotes() {
3077 let prompt = "line1\nline2 with \"quoted\" text";
3078 let (first, _second) = build_supervisor_submit_argv_pair("paw-proj", 1, prompt);
3079 assert_eq!(first[3], prompt);
3082 }
3083
3084 #[test]
3092 fn cmd_supervisor_inject_argv_has_single_enter_per_pane() {
3093 let panes: Vec<(usize, &str)> = vec![(2, "p2"), (3, "p3"), (4, "p4")];
3094
3095 let mut total_enters = 0;
3096 for (pane_idx, prompt) in &panes {
3097 let (first, _second) = build_supervisor_submit_argv_pair("paw-proj", *pane_idx, prompt);
3098 let enter_positions: Vec<usize> = first
3099 .iter()
3100 .enumerate()
3101 .filter(|(_, tok)| tok.as_str() == "Enter")
3102 .map(|(i, _)| i)
3103 .collect();
3104 assert_eq!(
3105 enter_positions.len(),
3106 1,
3107 "each per-pane invocation must send exactly one Enter; got argv: {first:?}"
3108 );
3109 let enter_pos = enter_positions[0];
3110 assert!(
3111 enter_pos > 0,
3112 "Enter token must follow a prompt-string argument; got argv: {first:?}"
3113 );
3114 assert_eq!(
3115 first[enter_pos - 1].as_str(),
3116 *prompt,
3117 "Enter token must directly follow the prompt argument; got argv: {first:?}"
3118 );
3119 total_enters += enter_positions.len();
3120 }
3121 assert_eq!(
3122 total_enters, 3,
3123 "for N=3 panes the launch flow must send exactly N=3 Enters"
3124 );
3125 }
3126
3127 fn make_layout_panes(n: usize) -> (PaneSpec, PaneSpec, Vec<PaneSpec>) {
3137 let supervisor = make_pane("supervisor", "/repo", "claude");
3138 let dashboard = make_pane("dashboard", "/repo", "git-paw __dashboard");
3139 let agents = (0..n)
3140 .map(|i| make_pane(&format!("feat/b{i}"), &format!("/tmp/wt{i}"), "claude"))
3141 .collect();
3142 (supervisor, dashboard, agents)
3143 }
3144
3145 fn build_for(agent_count: usize) -> TmuxSession {
3146 let layout =
3147 crate::supervisor::layout::supervisor_layout(agent_count).expect("layout computes");
3148 let (supervisor, dashboard, agents) = make_layout_panes(agent_count);
3149 build_supervisor_session(
3150 "proj",
3151 None,
3152 &supervisor,
3153 &dashboard,
3154 &agents,
3155 layout,
3156 true,
3157 true,
3158 &[("GIT_PAW_BROKER_URL".to_string(), "http://x".to_string())],
3159 )
3160 .expect("session builds")
3161 }
3162
3163 #[test]
3165 fn supervisor_layout_5_agents_single_row() {
3166 let session = build_for(5);
3167 let cmds = session.command_strings();
3168 let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
3169 .into_iter()
3170 .filter(|c| !c.trim_end().ends_with("C-u"))
3171 .collect();
3172 assert_eq!(
3173 send_keys.len(),
3174 7,
3175 "5 agents → 1 supervisor + 1 dashboard + 5 agents = 7 send-keys, got {send_keys:#?}"
3176 );
3177 let supervisor_pane = send_keys
3178 .iter()
3179 .find(|c| c.contains("0.0 "))
3180 .unwrap_or(&send_keys[0]);
3181 assert!(supervisor_pane.contains("claude"));
3182 let dashboard_pane = send_keys
3183 .iter()
3184 .find(|c| c.contains(":0.1 ") && c.contains("__dashboard"))
3185 .expect("dashboard send-keys at pane :0.1");
3186 let _ = dashboard_pane;
3187 let resizes = commands_containing(&cmds, "resize-pane");
3189 assert!(
3190 resizes
3191 .iter()
3192 .any(|c| c.contains(":0.0") && c.contains("60%")),
3193 "top row resize to 60%, got resizes {resizes:#?}"
3194 );
3195 assert!(
3197 resizes
3198 .iter()
3199 .any(|c| c.contains(":0.2") && c.contains("40%")),
3200 "agent-row resize to 40% at :0.2, got resizes {resizes:#?}"
3201 );
3202 }
3203
3204 #[test]
3206 fn supervisor_layout_10_agents_two_rows() {
3207 let session = build_for(10);
3208 let cmds = session.command_strings();
3209 let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
3210 .into_iter()
3211 .filter(|c| !c.trim_end().ends_with("C-u"))
3212 .collect();
3213 assert_eq!(
3214 send_keys.len(),
3215 12,
3216 "10 agents → 1 supervisor + 1 dashboard + 10 agents = 12 send-keys"
3217 );
3218 let resizes = commands_containing(&cmds, "resize-pane");
3219 assert!(
3220 resizes
3221 .iter()
3222 .any(|c| c.contains(":0.0") && c.contains("40%"))
3223 );
3224 assert!(
3225 resizes.iter().filter(|c| c.contains("30%")).count() >= 2,
3226 "two agent rows at 30% each, got {resizes:#?}"
3227 );
3228 }
3229
3230 #[test]
3232 fn supervisor_layout_11_agents_three_rows() {
3233 let session = build_for(11);
3234 let cmds = session.command_strings();
3235 let resizes = commands_containing(&cmds, "resize-pane");
3236 assert!(
3237 resizes
3238 .iter()
3239 .any(|c| c.contains(":0.0") && c.contains("28%"))
3240 );
3241 assert!(
3242 resizes.iter().filter(|c| c.contains("24%")).count() >= 3,
3243 "three agent rows at 24% each, got {resizes:#?}"
3244 );
3245 let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
3247 .into_iter()
3248 .filter(|c| !c.trim_end().ends_with("C-u"))
3249 .collect();
3250 assert_eq!(send_keys.len(), 13);
3251 assert!(send_keys.iter().any(|c| c.contains(":0.12 ")));
3252 }
3253
3254 #[test]
3256 fn supervisor_layout_20_agents_four_rows() {
3257 let session = build_for(20);
3258 let cmds = session.command_strings();
3259 let resizes = commands_containing(&cmds, "resize-pane");
3260 assert!(
3261 resizes
3262 .iter()
3263 .any(|c| c.contains(":0.0") && c.contains("28%"))
3264 );
3265 assert!(
3266 resizes.iter().filter(|c| c.contains("18%")).count() >= 4,
3267 "four agent rows at 18% each, got {resizes:#?}"
3268 );
3269 }
3270
3271 #[test]
3273 fn supervisor_layout_25_agents_five_rows() {
3274 let session = build_for(25);
3275 let cmds = session.command_strings();
3276 let resizes = commands_containing(&cmds, "resize-pane");
3277 assert!(
3278 resizes
3279 .iter()
3280 .any(|c| c.contains(":0.0") && c.contains("28%"))
3281 );
3282 assert!(
3283 resizes.iter().filter(|c| c.contains("14.4%")).count() >= 5,
3284 "five agent rows at 14.4% each, got {resizes:#?}"
3285 );
3286 }
3287
3288 #[test]
3290 fn supervisor_layout_26_agents_rejected_by_layout_helper() {
3291 let err = crate::supervisor::layout::supervisor_layout(26).expect_err("26 agents rejected");
3294 let msg = err.to_string();
3295 assert!(msg.contains("26 agents requested"));
3296 assert!(msg.contains("maximum is 25"));
3297 }
3298
3299 #[test]
3303 fn supervisor_layout_7_agents_row_major_indices() {
3304 let session = build_for(7);
3305 let cmds = session.command_strings();
3306 let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
3307 .into_iter()
3308 .filter(|c| !c.trim_end().ends_with("C-u"))
3309 .collect();
3310 assert!(
3313 send_keys
3314 .iter()
3315 .any(|c| c.contains(":0.2 ") && c.contains("claude")),
3316 "pane :0.2 is the first agent (top-left); send-keys {send_keys:#?}"
3317 );
3318 assert!(
3319 send_keys
3320 .iter()
3321 .any(|c| c.contains(":0.6 ") && c.contains("claude")),
3322 "pane :0.6 is the fifth agent (top-right of row 1)"
3323 );
3324 assert!(
3325 send_keys
3326 .iter()
3327 .any(|c| c.contains(":0.7 ") && c.contains("claude")),
3328 "pane :0.7 is the sixth agent (start of row 2)"
3329 );
3330 }
3331
3332 #[test]
3335 fn supervisor_top_row_split_50_50() {
3336 let session = build_for(3);
3337 let cmds = session.command_strings();
3338 let h_split = cmds
3339 .iter()
3340 .find(|c| c.contains("split-window") && c.contains("-h") && c.contains("-l 50%"))
3341 .unwrap_or_else(|| panic!("expected horizontal 50% split; got cmds: {cmds:#?}"));
3342 assert!(
3343 h_split.contains(":0.0") || h_split.contains("split-window -h -t paw-proj"),
3344 "horizontal split should target the supervisor pane; got: {h_split}"
3345 );
3346 }
3347
3348 #[test]
3354 fn supervisor_splits_use_l_percent_not_p() {
3355 let session = build_for(4);
3356 let cmds = session.command_strings();
3357 for cmd in &cmds {
3358 if cmd.contains("split-window") {
3359 assert!(
3360 !cmd.contains(" -p "),
3361 "split-window must not use deprecated -p flag (fails on Linux tmux 3.4 headless); got: {cmd}"
3362 );
3363 }
3364 }
3365 }
3366
3367 #[test]
3370 fn supervisor_new_session_passes_explicit_x_and_y() {
3371 let session = build_for(2);
3372 let cmds = session.command_strings();
3373 let new_session_cmd = cmds
3374 .iter()
3375 .find(|c| c.contains("new-session"))
3376 .expect("supervisor build emits a new-session command");
3377 assert!(
3378 new_session_cmd.contains("-x 480"),
3379 "supervisor new-session must pass -x 480; got: {new_session_cmd}"
3380 );
3381 assert!(
3382 new_session_cmd.contains("-y 140"),
3383 "supervisor new-session must pass -y 140; got: {new_session_cmd}"
3384 );
3385 }
3386
3387 #[test]
3389 fn supervisor_sets_default_size_after_new_session() {
3390 let session = build_for(2);
3391 let cmds = session.command_strings();
3392 let new_session_idx = cmds
3393 .iter()
3394 .position(|c| c.contains("new-session"))
3395 .expect("new-session in command list");
3396 let default_size_idx = cmds
3397 .iter()
3398 .position(|c| {
3399 c.contains("set-option") && c.contains("default-size") && c.contains("480x140")
3400 })
3401 .expect("set-option default-size 200x50 in command list");
3402 assert!(
3403 default_size_idx > new_session_idx,
3404 "set-option default-size must come AFTER new-session; got order new={new_session_idx}, default-size={default_size_idx}"
3405 );
3406 }
3407
3408 #[test]
3415 fn bare_start_with_broker_places_dashboard_at_pane_0() {
3416 let session = TmuxSessionBuilder::new("proj")
3418 .add_pane(make_pane("dashboard", "/repo", "git-paw __dashboard"))
3419 .add_pane(make_pane("feat/a", "/tmp/wt-a", "claude"))
3420 .add_pane(make_pane("feat/b", "/tmp/wt-b", "claude"))
3421 .add_pane(make_pane("feat/c", "/tmp/wt-c", "claude"))
3422 .build()
3423 .expect("session builds");
3424
3425 let cmds = session.command_strings();
3426 let dashboard_send = cmds
3427 .iter()
3428 .find(|c| c.contains("send-keys") && c.contains("__dashboard"))
3429 .expect("dashboard send-keys present");
3430 assert!(
3431 dashboard_send.contains(":0.0 "),
3432 "dashboard pane must be index 0; got: {dashboard_send}"
3433 );
3434 for (pane_idx, branch_marker, worktree) in [
3439 (1, "feat/a", "/tmp/wt-a"),
3440 (2, "feat/b", "/tmp/wt-b"),
3441 (3, "feat/c", "/tmp/wt-c"),
3442 ] {
3443 let select_target = format!(":0.{pane_idx} ");
3444 assert!(
3445 cmds.iter()
3446 .any(|c| c.contains(&select_target) && c.contains(branch_marker)),
3447 "agent {branch_marker} should land at pane {pane_idx}; cmds:\n{cmds:#?}"
3448 );
3449 let split_marker = format!("-c {worktree}");
3450 assert!(
3451 cmds.iter()
3452 .any(|c| c.contains("split-window") && c.contains(&split_marker)),
3453 "agent {branch_marker} split should carry {split_marker}; cmds:\n{cmds:#?}"
3454 );
3455 }
3456 }
3457
3458 #[test]
3461 fn broker_disabled_produces_no_dashboard_pane() {
3462 let session = TmuxSessionBuilder::new("proj")
3463 .add_pane(make_pane("feat/a", "/tmp/wt-a", "claude"))
3464 .add_pane(make_pane("feat/b", "/tmp/wt-b", "claude"))
3465 .add_pane(make_pane("feat/c", "/tmp/wt-c", "claude"))
3466 .build()
3467 .expect("session builds");
3468
3469 let cmds = session.command_strings();
3470 assert!(
3471 !cmds.iter().any(|c| c.contains("__dashboard")),
3472 "broker disabled must not add a dashboard pane; got cmds:\n{cmds:#?}"
3473 );
3474 let send_keys: Vec<&String> = cmds.iter().filter(|c| c.contains("send-keys")).collect();
3476 assert_eq!(
3477 send_keys.len(),
3478 3,
3479 "broker-disabled launch with 3 agents must emit 3 send-keys; got: {send_keys:#?}"
3480 );
3481 }
3482
3483 #[test]
3486 fn dashboard_pane_has_title_dashboard() {
3487 let session = build_for(2);
3489 let cmds = session.command_strings();
3490 let dashboard_select = cmds
3491 .iter()
3492 .find(|c| {
3493 c.contains("select-pane")
3494 && c.contains(":0.1")
3495 && c.contains("-T")
3496 && c.contains("dashboard")
3497 })
3498 .unwrap_or_else(|| {
3499 panic!("expected select-pane -T dashboard at :0.1; cmds:\n{cmds:#?}")
3500 });
3501 assert!(
3504 dashboard_select.contains("dashboard"),
3505 "dashboard pane title must include `dashboard`; got: {dashboard_select}"
3506 );
3507 }
3508
3509 #[test]
3512 fn supervisor_layout_emits_env_before_agent_send_keys() {
3513 let session = build_for(3);
3514 let cmds = session.command_strings();
3515 let first_env = cmds
3516 .iter()
3517 .position(|c| c.contains("set-environment") && c.contains("GIT_PAW_BROKER_URL"))
3518 .expect("set-environment GIT_PAW_BROKER_URL present");
3519 let first_agent_send = cmds
3520 .iter()
3521 .position(|c| c.contains("send-keys") && c.contains(":0.2 "))
3522 .expect("first agent send-keys at :0.2");
3523 assert!(
3524 first_env < first_agent_send,
3525 "set-environment must come before agent-pane send-keys"
3526 );
3527 }
3528
3529 fn every_new_session_command() -> Vec<(&'static str, String)> {
3543 let mut found: Vec<(&'static str, String)> = Vec::new();
3544
3545 let basic = TmuxSessionBuilder::new("conv-basic")
3547 .add_pane(make_pane("main", "/tmp/wt-basic", "claude"))
3548 .build()
3549 .expect("basic builder produces a session");
3550 for cmd in basic.command_strings() {
3551 if cmd.contains("new-session") {
3552 found.push(("TmuxSessionBuilder::build", cmd));
3553 }
3554 }
3555
3556 let layout = crate::supervisor::layout::supervisor_layout(2).expect("layout");
3560 let (supervisor, dashboard, agents) = make_layout_panes(2);
3561 let supervisor_session = build_supervisor_session(
3562 "conv-supervisor",
3563 None,
3564 &supervisor,
3565 &dashboard,
3566 &agents,
3567 layout,
3568 true,
3569 true,
3570 &[],
3571 )
3572 .expect("supervisor builder produces a session");
3573 for cmd in supervisor_session.command_strings() {
3574 if cmd.contains("new-session") {
3575 found.push(("build_supervisor_session", cmd));
3576 }
3577 }
3578
3579 assert!(
3580 !found.is_empty(),
3581 "expected at least one new-session command from the audited builders"
3582 );
3583 found
3584 }
3585
3586 #[test]
3590 fn every_new_session_passes_x_and_y() {
3591 for (builder, cmd) in every_new_session_command() {
3592 assert!(
3593 cmd.contains(" -x ") || cmd.ends_with(" -x"),
3594 "{builder}: new-session must pass -x; got: {cmd}"
3595 );
3596 assert!(
3597 cmd.contains(" -y ") || cmd.ends_with(" -y"),
3598 "{builder}: new-session must pass -y; got: {cmd}"
3599 );
3600 }
3601 }
3602
3603 #[test]
3607 fn every_new_session_passes_c() {
3608 for (builder, cmd) in every_new_session_command() {
3609 assert!(
3610 cmd.contains(" -c "),
3611 "{builder}: new-session must pass -c <cwd>; got: {cmd}"
3612 );
3613 }
3614 }
3615
3616 #[test]
3620 fn supervisor_layout_agent_splits_carry_worktree_no_cd_chain() {
3621 let layout = crate::supervisor::layout::supervisor_layout(2).expect("layout");
3622 let supervisor = make_pane("supervisor", "/repo", "claude");
3623 let dashboard = make_pane("dashboard", "/repo", "git-paw __dashboard");
3624 let agent_a = make_pane("feat/a", "/tmp/wt-a", "claude");
3625 let agent_b = make_pane("feat/b", "/tmp/wt-b", "claude");
3626 let session = build_supervisor_session(
3627 "proj",
3628 None,
3629 &supervisor,
3630 &dashboard,
3631 &[agent_a, agent_b],
3632 layout,
3633 true,
3634 true,
3635 &[],
3636 )
3637 .expect("session builds");
3638
3639 let cmds = session.command_strings();
3640 let splits = commands_containing(&cmds, "split-window");
3641 assert!(
3642 splits.iter().any(|c| c.contains("-c /tmp/wt-a")),
3643 "split for agent a should pass -c /tmp/wt-a; splits: {splits:#?}"
3644 );
3645 assert!(
3646 splits.iter().any(|c| c.contains("-c /tmp/wt-b")),
3647 "split for agent b should pass -c /tmp/wt-b; splits: {splits:#?}"
3648 );
3649
3650 let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
3651 .into_iter()
3652 .filter(|c| !c.trim_end().ends_with("C-u"))
3653 .collect();
3654 for entry in &send_keys {
3655 assert!(
3656 !entry.contains("cd /tmp/wt-a &&"),
3657 "no send-keys should chain `cd /tmp/wt-a &&`; got: {entry}"
3658 );
3659 assert!(
3660 !entry.contains("cd /tmp/wt-b &&"),
3661 "no send-keys should chain `cd /tmp/wt-b &&`; got: {entry}"
3662 );
3663 }
3664 }
3665
3666 #[test]
3669 fn add_agent_same_row_splits_horizontally_from_previous_pane() {
3670 let layout = crate::supervisor::layout::layout_for(5).expect("layout");
3674 let new_agent = make_pane("feat/fifth", "/tmp/wt5", "claude");
3675 let session = build_add_agent_commands("paw-x", &new_agent, 4, layout, true);
3676 let cmds = session.command_strings();
3677
3678 assert!(
3679 cmds.iter().any(|c| c.contains("split-window")
3680 && c.contains("-h")
3681 && c.contains(":0.5")
3682 && c.contains("-c /tmp/wt5")),
3683 "5th agent should -h split from pane 5 with -c worktree; cmds:\n{cmds:#?}"
3684 );
3685 assert!(
3687 cmds.iter()
3688 .any(|c| c.contains("send-keys") && c.contains(":0.6") && c.contains("claude")),
3689 "new agent CLI should launch in pane 6; cmds:\n{cmds:#?}"
3690 );
3691 }
3692
3693 #[test]
3694 fn add_agent_new_row_splits_vertically_from_previous_row_first_pane() {
3695 let layout = crate::supervisor::layout::layout_for(6).expect("layout");
3699 let new_agent = make_pane("feat/sixth", "/tmp/wt6", "claude");
3700 let session = build_add_agent_commands("paw-x", &new_agent, 5, layout, false);
3701 let cmds = session.command_strings();
3702
3703 assert!(
3704 cmds.iter().any(|c| c.contains("split-window")
3705 && c.contains("-v")
3706 && c.contains(":0.2")
3707 && c.contains("-c /tmp/wt6")),
3708 "6th agent should -v split from pane 2 (prev row first); cmds:\n{cmds:#?}"
3709 );
3710 }
3711
3712 #[test]
3713 fn add_agent_reapplies_row_height_resize_pass() {
3714 let layout = crate::supervisor::layout::layout_for(5).expect("layout");
3717 let new_agent = make_pane("feat/fifth", "/tmp/wt5", "claude");
3718 let session = build_add_agent_commands("paw-x", &new_agent, 4, layout, false);
3719 let cmds = session.command_strings();
3720
3721 let top_pct = format!("{}%", layout.top_row_pct);
3722 assert!(
3723 cmds.iter()
3724 .any(|c| c.contains("resize-pane") && c.contains(":0.0") && c.contains(&top_pct)),
3725 "re-tile should resize the top row to {top_pct}; cmds:\n{cmds:#?}"
3726 );
3727 }
3728
3729 #[test]
3730 fn remove_retile_emits_resize_pass_for_remaining_count() {
3731 let layout = crate::supervisor::layout::layout_for(4).expect("layout");
3734 let session = build_remove_retile_commands("paw-x", 4, layout);
3735 let cmds = session.command_strings();
3736
3737 let top_pct = format!("{}%", layout.top_row_pct);
3738 assert!(
3739 cmds.iter()
3740 .any(|c| c.contains("resize-pane") && c.contains(":0.0") && c.contains(&top_pct)),
3741 "remove re-tile should resize the top row; cmds:\n{cmds:#?}"
3742 );
3743 assert!(
3745 cmds.iter()
3746 .any(|c| c.contains("resize-pane") && c.contains(":0.2")),
3747 "remove re-tile should resize the first agent row (pane 2); cmds:\n{cmds:#?}"
3748 );
3749 }
3750
3751 #[test]
3752 fn remove_retile_with_zero_remaining_is_empty() {
3753 let layout = crate::supervisor::layout::layout_for(1).expect("layout");
3754 let session = build_remove_retile_commands("paw-x", 0, layout);
3755 assert!(
3756 session.command_strings().is_empty(),
3757 "removing the last agent leaves the top row untouched (no re-tile)"
3758 );
3759 }
3760
3761 #[test]
3771 fn agent_row_widths_three_agents_equal_thirds() {
3772 let targets = agent_row_widths(480, 3);
3773 assert_eq!(targets, vec![(2, 159), (3, 159)]);
3775 }
3776
3777 #[test]
3780 fn agent_row_widths_five_agents_equal_fifths() {
3781 let targets = agent_row_widths(480, 5);
3782 assert_eq!(targets, vec![(2, 95), (3, 95), (4, 95), (5, 95)]);
3783 }
3784
3785 #[test]
3788 fn agent_row_widths_skips_single_pane_rows() {
3789 assert!(
3790 agent_row_widths(480, 1).is_empty(),
3791 "a lone agent needs no width rebalance"
3792 );
3793 let targets = agent_row_widths(480, 6);
3794 assert_eq!(targets.len(), 4, "only the full first row rebalances");
3796 assert!(
3797 targets.iter().all(|(idx, _)| *idx >= 2 && *idx <= 5),
3798 "no second-row pane resized; got {targets:?}"
3799 );
3800 }
3801
3802 #[test]
3805 fn agent_row_widths_never_touch_top_row() {
3806 for n in 1..=crate::supervisor::layout::SUPERVISOR_MAX_AGENTS {
3807 for (idx, _) in agent_row_widths(480, n) {
3808 assert!(idx >= 2, "rebalance must skip panes 0 and 1 for n={n}");
3809 }
3810 }
3811 }
3812
3813 #[test]
3817 fn agent_row_widths_minimum_is_one_fifth() {
3818 let window = 480usize;
3819 let floor = window / 5; for n in 1..=crate::supervisor::layout::SUPERVISOR_MAX_AGENTS {
3821 for (idx, cols) in agent_row_widths(window, n) {
3822 assert!(
3823 cols + 1 >= floor,
3824 "pane {idx} width {cols} below the ~20% floor ({floor}) for n={n}"
3825 );
3826 }
3827 }
3828 }
3829
3830 #[test]
3832 fn agent_row_widths_zero_window_is_empty() {
3833 assert!(agent_row_widths(0, 5).is_empty());
3834 }
3835
3836 #[test]
3841 fn reconcile_reports_agent_with_no_live_pane() {
3842 let agents = vec![
3843 ("feat/a".to_string(), PathBuf::from("/tmp/wt-a")),
3844 ("feat/b".to_string(), PathBuf::from("/tmp/wt-b")),
3845 ("feat/c".to_string(), PathBuf::from("/tmp/wt-c")),
3846 ];
3847 let live = vec![PathBuf::from("/tmp/wt-a"), PathBuf::from("/tmp/wt-c")];
3849 let missing = agents_without_live_pane(&agents, &live);
3850 assert_eq!(
3851 missing,
3852 vec!["feat/b".to_string()],
3853 "only b has no live pane"
3854 );
3855 }
3856
3857 #[test]
3858 fn reconcile_passes_when_every_agent_maps() {
3859 let agents = vec![
3860 ("feat/a".to_string(), PathBuf::from("/tmp/wt-a")),
3861 ("feat/b".to_string(), PathBuf::from("/tmp/wt-b")),
3862 ];
3863 let live = vec![
3864 PathBuf::from("/tmp/wt-b"),
3865 PathBuf::from("/tmp/wt-a"),
3866 PathBuf::from("/tmp/wt-supervisor"),
3867 ];
3868 assert!(
3869 agents_without_live_pane(&agents, &live).is_empty(),
3870 "all agents map to a live pane → no divergence"
3871 );
3872 }
3873
3874 fn test_budget() -> ReadinessBudget {
3879 ReadinessBudget {
3882 poll_interval: Duration::from_millis(1),
3883 timeout: Duration::from_millis(2),
3884 relaunch_attempts: 1,
3885 }
3886 }
3887
3888 #[test]
3889 fn classify_distinguishes_ready_bareshell_indeterminate() {
3890 assert_eq!(
3891 classify_pane_readiness("some banner\n? for shortcuts\n"),
3892 PaneReadiness::Ready
3893 );
3894 assert_eq!(
3895 classify_pane_readiness("user@host ~/repo % "),
3896 PaneReadiness::BareShell
3897 );
3898 assert_eq!(
3899 classify_pane_readiness("\n\n \n"),
3900 PaneReadiness::Indeterminate,
3901 "a blank/clearing screen is not yet a bare shell"
3902 );
3903 }
3904
3905 #[test]
3906 fn gate_returns_ready_without_relaunch_when_marker_present() {
3907 let mut relaunches = 0;
3908 let outcome = gate_pane_generic(
3909 test_budget(),
3910 || Some("welcome\n? for shortcuts".to_string()),
3911 || relaunches += 1,
3912 |_| {},
3913 );
3914 assert_eq!(outcome, GateOutcome::Ready);
3915 assert_eq!(relaunches, 0, "a ready pane is never relaunched");
3916 }
3917
3918 #[test]
3919 fn gate_relaunches_a_persistent_bare_shell_then_falls_back() {
3920 let mut relaunches = 0;
3921 let outcome = gate_pane_generic(
3922 test_budget(),
3923 || Some("user@host:~$ ".to_string()),
3924 || relaunches += 1,
3925 |_| {},
3926 );
3927 assert_eq!(
3928 outcome,
3929 GateOutcome::FellBack,
3930 "a never-ready bare shell falls back after the relaunch budget"
3931 );
3932 assert_eq!(
3933 relaunches, 1,
3934 "exactly one relaunch fires (relaunch_attempts = 1)"
3935 );
3936 }
3937
3938 #[test]
3939 fn gate_does_not_relaunch_an_unrecognised_cli() {
3940 let mut relaunches = 0;
3941 let outcome = gate_pane_generic(
3943 test_budget(),
3944 || Some("custom-cli interactive session — type a command".to_string()),
3945 || relaunches += 1,
3946 |_| {},
3947 );
3948 assert_eq!(outcome, GateOutcome::FellBack);
3949 assert_eq!(
3950 relaunches, 0,
3951 "an unrecognised (indeterminate) CLI falls back without relaunching"
3952 );
3953 }
3954
3955 #[test]
3956 fn gate_becomes_ready_after_a_relaunch() {
3957 let mut captures = 0;
3958 let mut relaunches = 0;
3959 let outcome = gate_pane_generic(
3960 test_budget(),
3961 || {
3962 captures += 1;
3963 if captures > 4 {
3967 Some("? for shortcuts".to_string())
3968 } else {
3969 Some("user@host:~$ ".to_string())
3970 }
3971 },
3972 || relaunches += 1,
3973 |_| {},
3974 );
3975 assert_eq!(outcome, GateOutcome::Ready);
3976 assert_eq!(
3977 relaunches, 1,
3978 "the bare shell was relaunched once before going ready"
3979 );
3980 }
3981}