1use std::process::Command;
8
9use crate::error::PawError;
10
11const MAX_COLLISION_RETRIES: u32 = 10;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct TmuxCommand {
19 args: Vec<String>,
20 soft: bool,
24}
25
26impl TmuxCommand {
27 fn new(args: &[&str]) -> Self {
29 Self {
30 args: args.iter().map(|&s| s.to_owned()).collect(),
31 soft: false,
32 }
33 }
34
35 fn new_soft(args: &[&str]) -> Self {
41 Self {
42 args: args.iter().map(|&s| s.to_owned()).collect(),
43 soft: true,
44 }
45 }
46
47 #[allow(dead_code)]
51 pub fn as_command_string(&self) -> String {
52 format!("tmux {}", self.args.join(" "))
53 }
54
55 fn execute(&self) -> Result<String, PawError> {
57 let output = Command::new("tmux")
58 .args(&self.args)
59 .output()
60 .map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
61
62 if output.status.success() {
63 String::from_utf8(output.stdout)
64 .map_err(|e| PawError::TmuxError(format!("invalid utf-8 in tmux output: {e}")))
65 } else {
66 let stderr = String::from_utf8_lossy(&output.stderr);
67 Err(PawError::TmuxError(stderr.trim().to_owned()))
68 }
69 }
70}
71
72#[derive(Debug, Clone)]
74pub struct PaneSpec {
75 pub branch: String,
77 pub worktree: String,
79 pub cli_command: String,
81}
82
83fn push_border_affordances(commands: &mut Vec<TmuxCommand>, session: &str) {
109 for (option, value) in [
110 ("pane-border-lines", "double"),
111 ("pane-border-style", "fg=colour238"),
112 ("pane-active-border-style", "fg=colour45,bold"),
113 ("pane-border-status", "top"),
114 (
115 "pane-border-format",
116 "#[fg=colour39,bold,reverse] #{pane_index}: #{?#{@paw_role},#{@paw_role},#{pane_title}} #[default]",
117 ),
118 ] {
119 commands.push(TmuxCommand::new_soft(&[
120 "set-option",
121 "-t",
122 session,
123 option,
124 value,
125 ]));
126 }
127}
128
129fn push_pane_title(
145 commands: &mut Vec<TmuxCommand>,
146 border_affordances: bool,
147 target: &str,
148 title: &str,
149) {
150 if border_affordances {
151 commands.push(TmuxCommand::new(&[
152 "select-pane",
153 "-t",
154 target,
155 "-T",
156 title,
157 ]));
158 commands.push(TmuxCommand::new_soft(&[
161 "set-option",
162 "-p",
163 "-t",
164 target,
165 "@paw_role",
166 title,
167 ]));
168 }
169}
170
171#[derive(Debug)]
173pub struct TmuxSession {
174 pub name: String,
176 commands: Vec<TmuxCommand>,
177}
178
179impl TmuxSession {
180 pub fn execute(&self) -> Result<(), PawError> {
186 self.execute_with(|cmd| cmd.execute().map(|_| ()), |w| eprintln!("{w}"))
187 }
188
189 fn execute_with<R, W>(&self, mut run: R, mut warn: W) -> Result<(), PawError>
194 where
195 R: FnMut(&TmuxCommand) -> Result<(), PawError>,
196 W: FnMut(String),
197 {
198 for cmd in &self.commands {
199 if let Err(e) = run(cmd) {
200 if cmd.soft {
201 warn(format!(
202 "warning: tmux option not supported: {} ({e})",
203 cmd.args.join(" ")
204 ));
205 } else {
206 return Err(e);
207 }
208 }
209 }
210 Ok(())
211 }
212
213 #[allow(dead_code)]
217 pub fn command_strings(&self) -> Vec<String> {
218 self.commands
219 .iter()
220 .map(TmuxCommand::as_command_string)
221 .collect()
222 }
223
224 pub fn pipe_pane(&mut self, pane_target: &str, log_path: &std::path::Path) -> &mut Self {
229 self.commands.push(TmuxCommand::new(&[
230 "pipe-pane",
231 "-o",
232 "-t",
233 pane_target,
234 &format!("cat >> {}", log_path.display()),
235 ]));
236 self
237 }
238
239 pub fn reapply_tiled_layout(&mut self, session_name: &str) -> &mut Self {
245 self.commands.push(TmuxCommand::new(&[
246 "select-layout",
247 "-t",
248 session_name,
249 "tiled",
250 ]));
251 self
252 }
253
254 pub fn apply_dashboard_layout(&mut self, session_name: &str) -> &mut Self {
260 self.commands.push(TmuxCommand::new(&[
261 "select-layout",
262 "-t",
263 session_name,
264 "main-horizontal",
265 ]));
266 self
267 }
268}
269
270#[derive(Debug)]
299pub struct TmuxSessionBuilder {
300 project_name: String,
301 panes: Vec<PaneSpec>,
302 mouse_mode: bool,
303 border_affordances: bool,
304 session_name_override: Option<String>,
305 env_vars: Vec<(String, String)>,
306}
307
308impl TmuxSessionBuilder {
309 pub fn new(project_name: &str) -> Self {
314 Self {
315 project_name: project_name.to_owned(),
316 panes: Vec::new(),
317 mouse_mode: true,
318 border_affordances: true,
319 session_name_override: None,
320 env_vars: Vec::new(),
321 }
322 }
323
324 #[must_use]
328 pub fn session_name(mut self, name: String) -> Self {
329 self.session_name_override = Some(name);
330 self
331 }
332
333 #[must_use]
335 pub fn add_pane(mut self, spec: PaneSpec) -> Self {
336 self.panes.push(spec);
337 self
338 }
339
340 #[must_use]
345 pub fn mouse_mode(mut self, enabled: bool) -> Self {
346 self.mouse_mode = enabled;
347 self
348 }
349
350 #[must_use]
359 pub fn border_affordances(mut self, enabled: bool) -> Self {
360 self.border_affordances = enabled;
361 self
362 }
363
364 #[must_use]
369 pub fn set_environment(mut self, key: &str, value: &str) -> Self {
370 self.env_vars.push((key.to_owned(), value.to_owned()));
371 self
372 }
373
374 #[allow(clippy::too_many_lines)]
379 pub fn build(self) -> Result<TmuxSession, PawError> {
380 if self.panes.is_empty() {
381 return Err(PawError::TmuxError(
382 "cannot create a session with no panes".to_owned(),
383 ));
384 }
385
386 let session_name = self
387 .session_name_override
388 .unwrap_or_else(|| format!("paw-{}", self.project_name));
389 let mut commands = Vec::new();
390
391 let first_worktree = &self.panes[0].worktree;
398 commands.push(TmuxCommand::new(&[
399 "new-session",
400 "-d",
401 "-s",
402 &session_name,
403 "-x",
404 "480",
405 "-y",
406 "140",
407 "-c",
408 first_worktree,
409 ]));
410
411 commands.push(TmuxCommand::new(&[
418 "set-option",
419 "-g",
420 "default-size",
421 "480x140",
422 ]));
423
424 if self.mouse_mode {
426 commands.push(TmuxCommand::new(&[
427 "set-option",
428 "-t",
429 &session_name,
430 "mouse",
431 "on",
432 ]));
433 }
434
435 if self.border_affordances {
439 push_border_affordances(&mut commands, &session_name);
440 }
441
442 for (key, value) in &self.env_vars {
444 commands.push(TmuxCommand::new(&[
445 "set-environment",
446 "-t",
447 &session_name,
448 key,
449 value,
450 ]));
451 }
452
453 let first = &self.panes[0];
457 let pane_target = format!("{session_name}:0.0");
458 push_pane_title(
459 &mut commands,
460 self.border_affordances,
461 &pane_target,
462 &first.branch,
463 );
464 commands.push(TmuxCommand::new(&[
465 "send-keys",
466 "-t",
467 &pane_target,
468 &first.cli_command,
469 "Enter",
470 ]));
471
472 for (i, pane) in self.panes.iter().enumerate().skip(1) {
474 commands.push(TmuxCommand::new(&[
476 "select-layout",
477 "-t",
478 &session_name,
479 "tiled",
480 ]));
481
482 commands.push(TmuxCommand::new(&[
488 "split-window",
489 "-t",
490 &session_name,
491 "-c",
492 &pane.worktree,
493 ]));
494
495 let pane_target = format!("{session_name}:0.{i}");
497 push_pane_title(
498 &mut commands,
499 self.border_affordances,
500 &pane_target,
501 &pane.branch,
502 );
503 commands.push(TmuxCommand::new(&[
504 "send-keys",
505 "-t",
506 &pane_target,
507 &pane.cli_command,
508 "Enter",
509 ]));
510 }
511
512 if self.panes.len() > 1 && self.panes[0].branch == "dashboard" {
514 commands.push(TmuxCommand::new(&[
516 "select-layout",
517 "-t",
518 &session_name,
519 "main-horizontal",
520 ]));
521 } else {
522 commands.push(TmuxCommand::new(&[
524 "select-layout",
525 "-t",
526 &session_name,
527 "tiled",
528 ]));
529 }
530
531 Ok(TmuxSession {
532 name: session_name,
533 commands,
534 })
535 }
536}
537
538pub fn ensure_tmux_installed() -> Result<(), PawError> {
543 which::which("tmux").map_err(|_| PawError::TmuxNotInstalled)?;
544 Ok(())
545}
546
547pub fn is_session_alive(name: &str) -> Result<bool, PawError> {
549 let status = Command::new("tmux")
550 .args(["has-session", "-t", name])
551 .stdout(std::process::Stdio::null())
552 .stderr(std::process::Stdio::null())
553 .status()
554 .map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
555
556 Ok(status.success())
557}
558
559#[derive(Debug, Clone, Copy, PartialEq, Eq)]
567pub enum SessionLiveness {
568 Alive,
570 Stale,
572 Indeterminate,
575}
576
577fn classify_liveness(spawned: bool, success: bool) -> SessionLiveness {
583 match (spawned, success) {
584 (false, _) => SessionLiveness::Indeterminate,
585 (true, true) => SessionLiveness::Alive,
586 (true, false) => SessionLiveness::Stale,
587 }
588}
589
590pub fn session_liveness(name: &str) -> SessionLiveness {
597 let spawn = Command::new("tmux")
598 .args(["has-session", "-t", name])
599 .stdout(std::process::Stdio::null())
600 .stderr(std::process::Stdio::null())
601 .status();
602 match spawn {
603 Ok(status) => classify_liveness(true, status.success()),
604 Err(_) => classify_liveness(false, false),
605 }
606}
607
608pub fn resolve_session_name(project_name: &str) -> Result<String, PawError> {
613 let base = format!("paw-{project_name}");
614
615 if !is_session_alive(&base)? {
616 return Ok(base);
617 }
618
619 for suffix in 2..=MAX_COLLISION_RETRIES + 1 {
620 let candidate = format!("{base}-{suffix}");
621 if !is_session_alive(&candidate)? {
622 return Ok(candidate);
623 }
624 }
625
626 Err(PawError::TmuxError(format!(
627 "too many session name collisions for '{base}'"
628 )))
629}
630
631pub fn attach(name: &str) -> Result<(), PawError> {
636 let status = Command::new("tmux")
637 .args(["attach-session", "-t", name])
638 .status()
639 .map_err(|e| PawError::TmuxError(format!("failed to attach to tmux session: {e}")))?;
640
641 if status.success() {
642 Ok(())
643 } else {
644 Err(PawError::TmuxError(format!(
645 "failed to attach to session '{name}'"
646 )))
647 }
648}
649
650pub fn detach_client(session_name: &str) -> Result<(), PawError> {
658 let output = Command::new("tmux")
659 .args(["detach-client", "-s", session_name])
660 .output()
661 .map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
662
663 if output.status.success() {
664 return Ok(());
665 }
666 let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
667 if stderr.contains("no clients") || stderr.contains("no current client") {
669 return Ok(());
670 }
671 Err(PawError::TmuxError(
672 String::from_utf8_lossy(&output.stderr).trim().to_owned(),
673 ))
674}
675
676pub fn kill_pane(session_name: &str, pane_index: u32) -> Result<(), PawError> {
684 let target = format!("{session_name}:0.{pane_index}");
685 let output = Command::new("tmux")
686 .args(["kill-pane", "-t", &target])
687 .output()
688 .map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
689
690 if output.status.success() {
691 return Ok(());
692 }
693 let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
694 if stderr.contains("can't find pane")
696 || stderr.contains("no such pane")
697 || stderr.contains("pane not found")
698 {
699 return Ok(());
700 }
701 Err(PawError::TmuxError(
702 String::from_utf8_lossy(&output.stderr).trim().to_owned(),
703 ))
704}
705
706pub fn kill_session(name: &str) -> Result<(), PawError> {
708 let output = Command::new("tmux")
709 .args(["kill-session", "-t", name])
710 .output()
711 .map_err(|e| PawError::TmuxError(format!("failed to kill tmux session: {e}")))?;
712
713 if output.status.success() {
714 Ok(())
715 } else {
716 let stderr = String::from_utf8_lossy(&output.stderr);
717 Err(PawError::TmuxError(stderr.trim().to_owned()))
718 }
719}
720
721pub fn build_boot_inject_args(session_name: &str, pane_index: usize, text: &str) -> Vec<String> {
730 vec![
731 "send-keys".to_string(),
732 "-l".to_string(),
733 "-t".to_string(),
734 format!("{session_name}:0.{pane_index}"),
735 text.to_string(),
736 ]
737}
738
739#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
767pub fn build_supervisor_session(
768 project_name: &str,
769 session_name_override: Option<String>,
770 supervisor: &PaneSpec,
771 dashboard: &PaneSpec,
772 agents: &[PaneSpec],
773 layout: crate::supervisor::layout::SupervisorLayout,
774 mouse_mode: bool,
775 border_affordances: bool,
776 env_vars: &[(String, String)],
777) -> Result<TmuxSession, PawError> {
778 use crate::supervisor::layout::{SUPERVISOR_AGENTS_PER_ROW, SUPERVISOR_PANE_OFFSET};
779
780 let session_name = session_name_override.unwrap_or_else(|| format!("paw-{project_name}"));
781 let mut commands: Vec<TmuxCommand> = Vec::new();
782
783 let push = |cmds: &mut Vec<TmuxCommand>, parts: &[&str]| {
784 cmds.push(TmuxCommand::new(parts));
785 };
786
787 push(
792 &mut commands,
793 &[
794 "new-session",
795 "-d",
796 "-s",
797 &session_name,
798 "-x",
799 "480",
800 "-y",
801 "140",
802 "-e",
809 "DISABLE_AUTO_UPDATE=true",
810 "-e",
811 "DISABLE_UPDATE_PROMPT=true",
812 "-c",
813 &supervisor.worktree,
814 ],
815 );
816
817 push(
823 &mut commands,
824 &["set-option", "-g", "default-size", "480x140"],
825 );
826
827 push(
831 &mut commands,
832 &[
833 "set-environment",
834 "-t",
835 &session_name,
836 "DISABLE_AUTO_UPDATE",
837 "true",
838 ],
839 );
840 push(
841 &mut commands,
842 &[
843 "set-environment",
844 "-t",
845 &session_name,
846 "DISABLE_UPDATE_PROMPT",
847 "true",
848 ],
849 );
850
851 if mouse_mode {
853 push(
854 &mut commands,
855 &["set-option", "-t", &session_name, "mouse", "on"],
856 );
857 }
858 if border_affordances {
859 push_border_affordances(&mut commands, &session_name);
860 }
861
862 for (key, value) in env_vars {
864 push(
865 &mut commands,
866 &["set-environment", "-t", &session_name, key, value],
867 );
868 }
869
870 let supervisor_target = format!("{session_name}:0.0");
871 push_pane_title(
872 &mut commands,
873 border_affordances,
874 &supervisor_target,
875 &supervisor.branch,
876 );
877 push(
881 &mut commands,
882 &["send-keys", "-t", &supervisor_target, "C-u"],
883 );
884 push(
885 &mut commands,
886 &[
887 "send-keys",
888 "-t",
889 &supervisor_target,
890 &supervisor.cli_command,
891 "Enter",
892 ],
893 );
894
895 let bottom_pct = format!("{}%", 100u16 - u16::from(layout.top_row_pct));
911 if agents.is_empty() {
920 push(
921 &mut commands,
922 &[
923 "split-window",
924 "-v",
925 "-t",
926 &supervisor_target,
927 "-l",
928 &bottom_pct,
929 ],
930 );
931 } else {
932 push(
933 &mut commands,
934 &[
935 "split-window",
936 "-v",
937 "-t",
938 &supervisor_target,
939 "-l",
940 &bottom_pct,
941 "-c",
942 &dashboard.worktree,
943 ],
944 );
945 }
946
947 let dashboard_split_cwd = agents
954 .first()
955 .map_or(dashboard.worktree.as_str(), |a| a.worktree.as_str());
956 push(
957 &mut commands,
958 &[
959 "split-window",
960 "-h",
961 "-t",
962 &supervisor_target,
963 "-l",
964 "50%",
965 "-c",
966 dashboard_split_cwd,
967 ],
968 );
969
970 let pane_one = format!("{session_name}:0.1");
972 let pane_two = format!("{session_name}:0.2");
973 push(
974 &mut commands,
975 &["swap-pane", "-s", &pane_one, "-t", &pane_two],
976 );
977
978 let dashboard_target = format!("{session_name}:0.1");
980 push_pane_title(
981 &mut commands,
982 border_affordances,
983 &dashboard_target,
984 &dashboard.branch,
985 );
986 push(
987 &mut commands,
988 &["send-keys", "-t", &dashboard_target, "C-u"],
989 );
990 push(
991 &mut commands,
992 &[
993 "send-keys",
994 "-t",
995 &dashboard_target,
996 &dashboard.cli_command,
997 "Enter",
998 ],
999 );
1000
1001 if !agents.is_empty() {
1003 let first_target = format!("{session_name}:0.{SUPERVISOR_PANE_OFFSET}");
1009 let first = &agents[0];
1010 push_pane_title(
1011 &mut commands,
1012 border_affordances,
1013 &first_target,
1014 &first.branch,
1015 );
1016 push(&mut commands, &["send-keys", "-t", &first_target, "C-u"]);
1017 push(
1018 &mut commands,
1019 &[
1020 "send-keys",
1021 "-t",
1022 &first_target,
1023 &first.cli_command,
1024 "Enter",
1025 ],
1026 );
1027
1028 let mut row_first_pane = SUPERVISOR_PANE_OFFSET;
1029
1030 for (i, agent) in agents.iter().enumerate().skip(1) {
1031 let pane_idx = SUPERVISOR_PANE_OFFSET + i;
1032 let pane_target = format!("{session_name}:0.{pane_idx}");
1033 let position_in_row = i % SUPERVISOR_AGENTS_PER_ROW;
1034 let starts_new_row = position_in_row == 0;
1035
1036 if starts_new_row {
1037 let src_target = format!("{session_name}:0.{row_first_pane}");
1040 push(
1041 &mut commands,
1042 &[
1043 "split-window",
1044 "-v",
1045 "-t",
1046 &src_target,
1047 "-c",
1048 &agent.worktree,
1049 ],
1050 );
1051 row_first_pane = pane_idx;
1052 } else {
1053 let prev_idx = pane_idx - 1;
1056 let prev_target = format!("{session_name}:0.{prev_idx}");
1057 push(
1058 &mut commands,
1059 &[
1060 "split-window",
1061 "-h",
1062 "-t",
1063 &prev_target,
1064 "-c",
1065 &agent.worktree,
1066 ],
1067 );
1068 }
1069
1070 push_pane_title(
1071 &mut commands,
1072 border_affordances,
1073 &pane_target,
1074 &agent.branch,
1075 );
1076 push(&mut commands, &["send-keys", "-t", &pane_target, "C-u"]);
1077 push(
1078 &mut commands,
1079 &["send-keys", "-t", &pane_target, &agent.cli_command, "Enter"],
1080 );
1081 }
1082 }
1083
1084 push_supervisor_resize_pass(&mut commands, &session_name, layout, agents.len());
1090
1091 Ok(TmuxSession {
1092 name: session_name,
1093 commands,
1094 })
1095}
1096
1097#[must_use]
1120pub fn build_add_agent_commands(
1121 session_name: &str,
1122 new_agent: &PaneSpec,
1123 prev_agent_count: usize,
1124 layout: crate::supervisor::layout::SupervisorLayout,
1125 border_affordances: bool,
1126) -> TmuxSession {
1127 use crate::supervisor::layout::{SUPERVISOR_AGENTS_PER_ROW, SUPERVISOR_PANE_OFFSET};
1128
1129 let mut commands: Vec<TmuxCommand> = Vec::new();
1130 let i = prev_agent_count; let pane_idx = SUPERVISOR_PANE_OFFSET + i;
1132 let pane_target = format!("{session_name}:0.{pane_idx}");
1133
1134 if i > 0 && i.is_multiple_of(SUPERVISOR_AGENTS_PER_ROW) {
1135 let prev_row_first = SUPERVISOR_PANE_OFFSET + (i - SUPERVISOR_AGENTS_PER_ROW);
1137 let src = format!("{session_name}:0.{prev_row_first}");
1138 commands.push(TmuxCommand::new(&[
1139 "split-window",
1140 "-v",
1141 "-t",
1142 &src,
1143 "-c",
1144 &new_agent.worktree,
1145 ]));
1146 } else {
1147 let prev = format!("{session_name}:0.{}", pane_idx - 1);
1149 commands.push(TmuxCommand::new(&[
1150 "split-window",
1151 "-h",
1152 "-t",
1153 &prev,
1154 "-c",
1155 &new_agent.worktree,
1156 ]));
1157 }
1158
1159 push_pane_title(
1160 &mut commands,
1161 border_affordances,
1162 &pane_target,
1163 &new_agent.branch,
1164 );
1165 commands.push(TmuxCommand::new(&["send-keys", "-t", &pane_target, "C-u"]));
1166 commands.push(TmuxCommand::new(&[
1167 "send-keys",
1168 "-t",
1169 &pane_target,
1170 &new_agent.cli_command,
1171 "Enter",
1172 ]));
1173
1174 push_supervisor_resize_pass(&mut commands, session_name, layout, prev_agent_count + 1);
1175
1176 TmuxSession {
1177 name: session_name.to_string(),
1178 commands,
1179 }
1180}
1181
1182#[must_use]
1197pub fn build_remove_retile_commands(
1198 session_name: &str,
1199 remaining_agent_count: usize,
1200 layout: crate::supervisor::layout::SupervisorLayout,
1201) -> TmuxSession {
1202 let mut commands: Vec<TmuxCommand> = Vec::new();
1203 if remaining_agent_count > 0 {
1204 push_supervisor_resize_pass(&mut commands, session_name, layout, remaining_agent_count);
1205 }
1206 TmuxSession {
1207 name: session_name.to_string(),
1208 commands,
1209 }
1210}
1211
1212fn push_supervisor_resize_pass(
1217 commands: &mut Vec<TmuxCommand>,
1218 session_name: &str,
1219 layout: crate::supervisor::layout::SupervisorLayout,
1220 agent_count: usize,
1221) {
1222 use crate::supervisor::layout::{SUPERVISOR_AGENTS_PER_ROW, SUPERVISOR_PANE_OFFSET};
1223
1224 let top_target = format!("{session_name}:0.0");
1225 let top_pct_str = format!("{}%", layout.top_row_pct);
1226 commands.push(TmuxCommand::new(&[
1227 "resize-pane",
1228 "-t",
1229 &top_target,
1230 "-y",
1231 &top_pct_str,
1232 ]));
1233
1234 let agent_row_pct_str = format_supervisor_pct(layout.agent_row_pct);
1235 for row in 0..layout.agent_rows {
1236 let pane_idx = SUPERVISOR_PANE_OFFSET + row * SUPERVISOR_AGENTS_PER_ROW;
1237 if pane_idx < SUPERVISOR_PANE_OFFSET + agent_count {
1238 let target = format!("{session_name}:0.{pane_idx}");
1239 commands.push(TmuxCommand::new(&[
1240 "resize-pane",
1241 "-t",
1242 &target,
1243 "-y",
1244 &agent_row_pct_str,
1245 ]));
1246 }
1247 }
1248}
1249
1250fn format_supervisor_pct(pct: f32) -> String {
1253 if (pct - pct.round()).abs() < 0.05 {
1254 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1255 let rounded = pct.round().clamp(0.0, 100.0) as u32;
1256 format!("{rounded}%")
1257 } else {
1258 format!("{pct:.1}%")
1259 }
1260}
1261
1262#[must_use]
1274pub fn build_supervisor_submit_argv_pair(
1275 session_name: &str,
1276 pane_index: usize,
1277 prompt: &str,
1278) -> (Vec<String>, Vec<String>) {
1279 let target = format!("{session_name}:0.{pane_index}");
1280 let first = vec![
1281 "send-keys".to_string(),
1282 "-t".to_string(),
1283 target.clone(),
1284 prompt.to_string(),
1285 "Enter".to_string(),
1286 ];
1287 let second = vec![
1288 "send-keys".to_string(),
1289 "-t".to_string(),
1290 target,
1291 "Enter".to_string(),
1292 ];
1293 (first, second)
1294}
1295
1296#[cfg(test)]
1297mod tests {
1298 use super::*;
1299
1300 fn make_pane(branch: &str, worktree: &str, cli: &str) -> PaneSpec {
1301 PaneSpec {
1302 branch: branch.to_owned(),
1303 worktree: worktree.to_owned(),
1304 cli_command: cli.to_owned(),
1305 }
1306 }
1307
1308 fn commands_containing(cmds: &[String], keyword: &str) -> Vec<String> {
1310 cmds.iter()
1311 .filter(|c| c.contains(keyword))
1312 .cloned()
1313 .collect()
1314 }
1315
1316 #[test]
1322 #[serial_test::serial]
1323 fn ensure_tmux_installed_succeeds_when_present() {
1324 assert!(ensure_tmux_installed().is_ok());
1326 }
1327
1328 #[test]
1335 fn session_is_named_after_project() {
1336 let session = TmuxSessionBuilder::new("my-project")
1337 .add_pane(make_pane("main", "/tmp/wt", "claude"))
1338 .build()
1339 .unwrap();
1340
1341 assert_eq!(session.name, "paw-my-project");
1342 }
1343
1344 #[test]
1345 fn session_creation_command_uses_session_name() {
1346 let session = TmuxSessionBuilder::new("app")
1347 .add_pane(make_pane("main", "/tmp/wt", "claude"))
1348 .build()
1349 .unwrap();
1350
1351 let cmds = session.command_strings();
1352 assert!(
1353 cmds.iter()
1354 .any(|c| c.contains("new-session") && c.contains("paw-app")),
1355 "should create a tmux session named paw-app"
1356 );
1357 }
1358
1359 #[test]
1362 fn new_session_passes_explicit_x_and_y() {
1363 let session = TmuxSessionBuilder::new("app")
1364 .add_pane(make_pane("main", "/tmp/wt", "claude"))
1365 .build()
1366 .unwrap();
1367
1368 let cmds = session.command_strings();
1369 let new_session_cmd = cmds
1370 .iter()
1371 .find(|c| c.contains("new-session"))
1372 .expect("new-session command present");
1373 assert!(
1374 new_session_cmd.contains("-x 480"),
1375 "new-session must pass -x 480; got: {new_session_cmd}"
1376 );
1377 assert!(
1378 new_session_cmd.contains("-y 140"),
1379 "new-session must pass -y 140; got: {new_session_cmd}"
1380 );
1381 }
1382
1383 #[test]
1386 fn basic_builder_sets_default_size_after_new_session() {
1387 let session = TmuxSessionBuilder::new("app")
1388 .add_pane(make_pane("main", "/tmp/wt", "claude"))
1389 .build()
1390 .unwrap();
1391
1392 let cmds = session.command_strings();
1393 let new_session_idx = cmds
1394 .iter()
1395 .position(|c| c.contains("new-session"))
1396 .expect("new-session in command list");
1397 let default_size_idx = cmds
1398 .iter()
1399 .position(|c| {
1400 c.contains("set-option") && c.contains("default-size") && c.contains("480x140")
1401 })
1402 .expect("set-option default-size 200x50 in command list");
1403 assert!(
1404 default_size_idx > new_session_idx,
1405 "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}"
1406 );
1407 }
1408
1409 #[test]
1410 fn session_name_override_replaces_default() {
1411 let session = TmuxSessionBuilder::new("my-project")
1412 .session_name("custom-session-name".to_string())
1413 .add_pane(make_pane("main", "/tmp/wt", "claude"))
1414 .build()
1415 .unwrap();
1416
1417 assert_eq!(session.name, "custom-session-name");
1418 let cmds = session.command_strings();
1419 assert!(
1420 cmds.iter()
1421 .any(|c| c.contains("new-session") && c.contains("custom-session-name")),
1422 "should use overridden session name"
1423 );
1424 }
1425
1426 #[test]
1434 fn pane_count_matches_input_for_two_panes() {
1435 let session = TmuxSessionBuilder::new("proj")
1436 .add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
1437 .add_pane(make_pane("feat/api", "/tmp/wt2", "codex"))
1438 .build()
1439 .unwrap();
1440
1441 let cmds = session.command_strings();
1442 let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
1443 .into_iter()
1444 .filter(|c| !c.trim_end().ends_with("C-u"))
1445 .collect();
1446 assert_eq!(
1447 send_keys.len(),
1448 2,
1449 "should send commands to exactly 2 panes"
1450 );
1451 }
1452
1453 #[test]
1454 fn pane_count_matches_input_for_five_panes() {
1455 let mut builder = TmuxSessionBuilder::new("proj");
1456 for i in 0..5 {
1457 builder = builder.add_pane(make_pane(
1458 &format!("feat/b{i}"),
1459 &format!("/tmp/wt{i}"),
1460 "claude",
1461 ));
1462 }
1463 let session = builder.build().unwrap();
1464
1465 let cmds = session.command_strings();
1466 let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
1467 .into_iter()
1468 .filter(|c| !c.trim_end().ends_with("C-u"))
1469 .collect();
1470 assert_eq!(
1471 send_keys.len(),
1472 5,
1473 "should send commands to exactly 5 panes"
1474 );
1475 }
1476
1477 #[test]
1478 fn building_with_no_panes_is_an_error() {
1479 let result = TmuxSessionBuilder::new("proj").build();
1480 assert!(result.is_err(), "session with no panes should fail");
1481 }
1482
1483 #[test]
1491 fn each_pane_receives_bare_cli_command_and_split_carries_worktree() {
1492 let session = TmuxSessionBuilder::new("proj")
1493 .add_pane(make_pane("feat/auth", "/home/user/wt-auth", "claude"))
1494 .add_pane(make_pane("feat/api", "/home/user/wt-api", "gemini"))
1495 .build()
1496 .unwrap();
1497
1498 let cmds = session.command_strings();
1499 let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
1500 .into_iter()
1501 .filter(|c| !c.trim_end().ends_with("C-u"))
1502 .collect();
1503
1504 assert!(
1507 send_keys[0].contains("claude"),
1508 "first pane should run claude; got: {}",
1509 send_keys[0]
1510 );
1511
1512 assert!(
1516 send_keys[1].contains("gemini"),
1517 "second pane should run gemini; got: {}",
1518 send_keys[1]
1519 );
1520 assert!(
1521 !send_keys[1].contains("cd /home/user/wt-api"),
1522 "second pane send-keys MUST NOT prefix `cd <worktree>`; got: {}",
1523 send_keys[1]
1524 );
1525
1526 let splits = commands_containing(&cmds, "split-window");
1529 assert!(
1530 splits.iter().any(|c| c.contains("-c /home/user/wt-api")),
1531 "split-window for pane 1 should pass -c /home/user/wt-api; got: {splits:?}"
1532 );
1533 }
1534
1535 #[test]
1536 fn pane_commands_are_submitted_with_enter() {
1537 let session = TmuxSessionBuilder::new("proj")
1538 .add_pane(make_pane("main", "/tmp/wt", "aider"))
1539 .build()
1540 .unwrap();
1541
1542 let cmds = session.command_strings();
1543 let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
1544 .into_iter()
1545 .filter(|c| !c.trim_end().ends_with("C-u"))
1546 .collect();
1547 assert!(
1548 send_keys[0].contains("Enter"),
1549 "send-keys should press Enter to submit"
1550 );
1551 }
1552
1553 #[test]
1554 fn each_pane_targets_a_distinct_pane_index() {
1555 let session = TmuxSessionBuilder::new("proj")
1556 .add_pane(make_pane("feat/a", "/tmp/a", "claude"))
1557 .add_pane(make_pane("feat/b", "/tmp/b", "codex"))
1558 .add_pane(make_pane("feat/c", "/tmp/c", "gemini"))
1559 .build()
1560 .unwrap();
1561
1562 let cmds = session.command_strings();
1563 let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
1564 .into_iter()
1565 .filter(|c| !c.trim_end().ends_with("C-u"))
1566 .collect();
1567
1568 assert!(
1569 send_keys[0].contains(":0.0"),
1570 "first pane should target :0.0"
1571 );
1572 assert!(
1573 send_keys[1].contains(":0.1"),
1574 "second pane should target :0.1"
1575 );
1576 assert!(
1577 send_keys[2].contains(":0.2"),
1578 "third pane should target :0.2"
1579 );
1580 }
1581
1582 #[test]
1590 fn each_pane_is_titled_with_its_branch() {
1591 let session = TmuxSessionBuilder::new("proj")
1592 .add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
1593 .add_pane(make_pane("fix/api", "/tmp/wt2", "gemini"))
1594 .build()
1595 .unwrap();
1596
1597 let cmds = session.command_strings();
1598 let select_panes = commands_containing(&cmds, "select-pane");
1599
1600 assert_eq!(select_panes.len(), 2, "each pane should get a title");
1601 assert!(
1604 select_panes[0].ends_with("-T feat/auth"),
1605 "first pane title should be 'feat/auth', got: {}",
1606 select_panes[0]
1607 );
1608 assert!(
1609 !select_panes[0].contains("claude"),
1610 "first pane title should not include the CLI command, got: {}",
1611 select_panes[0]
1612 );
1613 assert!(
1614 select_panes[1].ends_with("-T fix/api"),
1615 "second pane title should be 'fix/api', got: {}",
1616 select_panes[1]
1617 );
1618 }
1619
1620 #[test]
1626 fn each_pane_gets_a_stable_paw_role_option() {
1627 let session = TmuxSessionBuilder::new("proj")
1628 .add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
1629 .add_pane(make_pane("fix/api", "/tmp/wt2", "gemini"))
1630 .build()
1631 .unwrap();
1632
1633 let cmds = session.command_strings();
1634 let role_opts: Vec<&String> = cmds
1637 .iter()
1638 .filter(|c| c.contains("set-option") && c.contains(" -p ") && c.contains("@paw_role"))
1639 .collect();
1640 assert_eq!(
1641 role_opts.len(),
1642 2,
1643 "each pane should get a @paw_role option"
1644 );
1645 assert!(
1646 role_opts.iter().any(|c| c.ends_with("@paw_role feat/auth")),
1647 "first pane should set `@paw_role feat/auth` pane-scoped; got: {role_opts:#?}"
1648 );
1649 assert!(
1650 role_opts.iter().any(|c| c.ends_with("@paw_role fix/api")),
1651 "second pane should set `@paw_role fix/api`; got: {role_opts:#?}"
1652 );
1653 }
1654
1655 #[test]
1656 fn pane_border_status_is_configured() {
1657 let session = TmuxSessionBuilder::new("proj")
1658 .add_pane(make_pane("main", "/tmp/wt", "claude"))
1659 .build()
1660 .unwrap();
1661
1662 let cmds = session.command_strings();
1663 assert!(
1664 cmds.iter()
1665 .any(|c| c.contains("pane-border-status") && c.contains("top")),
1666 "should configure pane-border-status to top"
1667 );
1668 assert!(
1669 cmds.iter()
1670 .any(|c| c.contains("pane-border-format") && c.contains("#{pane_title}")),
1671 "should configure pane-border-format to show pane title"
1672 );
1673 }
1674
1675 const AFFORDANCE_OPTIONS: [(&str, &str); 5] = [
1684 ("pane-border-lines", "double"),
1685 ("pane-border-style", "fg=colour238"),
1686 ("pane-active-border-style", "fg=colour45,bold"),
1687 ("pane-border-status", "top"),
1688 (
1689 "pane-border-format",
1690 "#[fg=colour39,bold,reverse] #{pane_index}: #{?#{@paw_role},#{@paw_role},#{pane_title}} #[default]",
1691 ),
1692 ];
1693
1694 #[test]
1697 fn builder_emits_all_five_affordances_scoped_to_session_by_default() {
1698 let session = TmuxSessionBuilder::new("aff-default")
1699 .add_pane(make_pane("feat/a", "/tmp/wt", "claude"))
1700 .build()
1701 .unwrap();
1702 let cmds = session.command_strings();
1703 for (option, value) in AFFORDANCE_OPTIONS {
1704 assert!(
1705 cmds.iter().any(|c| c.contains("set-option")
1706 && c.contains("-t paw-aff-default")
1707 && c.contains(option)
1708 && c.contains(value)),
1709 "expected `set-option -t paw-aff-default {option} {value}`; cmds:\n{cmds:#?}"
1710 );
1711 }
1712 }
1713
1714 #[test]
1719 fn border_format_is_index_then_role_with_padding() {
1720 let session = TmuxSessionBuilder::new("fmt")
1721 .add_pane(make_pane("feat/a", "/tmp/wt", "claude"))
1722 .build()
1723 .unwrap();
1724 let format_cmd = session
1725 .command_strings()
1726 .into_iter()
1727 .find(|c| c.contains("pane-border-format"))
1728 .expect("pane-border-format set-option present");
1729 assert!(
1730 format_cmd.ends_with(
1731 "pane-border-format #[fg=colour39,bold,reverse] #{pane_index}: #{?#{@paw_role},#{@paw_role},#{pane_title}} #[default]"
1732 ),
1733 "format must be the reverse-video label bar preferring @paw_role; got: {format_cmd}"
1734 );
1735 }
1736
1737 #[test]
1740 fn active_and_inactive_border_styles_applied() {
1741 let session = TmuxSessionBuilder::new("styles")
1742 .add_pane(make_pane("feat/a", "/tmp/wt", "claude"))
1743 .build()
1744 .unwrap();
1745 let cmds = session.command_strings();
1746 assert!(
1747 cmds.iter()
1748 .any(|c| c.contains("pane-active-border-style") && c.contains("colour45,bold")),
1749 "active border must be colour45,bold; cmds:\n{cmds:#?}"
1750 );
1751 assert!(
1752 cmds.iter()
1753 .any(|c| c.contains("pane-border-style") && c.contains("colour238")),
1754 "inactive border must be colour238; cmds:\n{cmds:#?}"
1755 );
1756 }
1757
1758 #[test]
1762 fn opt_out_omits_every_affordance_and_title() {
1763 let session = TmuxSessionBuilder::new("opt-out")
1764 .add_pane(make_pane("feat/a", "/tmp/wt", "claude"))
1765 .add_pane(make_pane("feat/b", "/tmp/wt2", "gemini"))
1766 .border_affordances(false)
1767 .build()
1768 .unwrap();
1769 let cmds = session.command_strings();
1770 for (option, _value) in AFFORDANCE_OPTIONS {
1771 assert!(
1772 !cmds
1773 .iter()
1774 .any(|c| c.contains("set-option") && c.contains(option)),
1775 "opt-out must not emit set-option {option}; cmds:\n{cmds:#?}"
1776 );
1777 }
1778 assert!(
1779 !cmds
1780 .iter()
1781 .any(|c| c.contains("select-pane") && c.contains("-T")),
1782 "opt-out must not set any pane title; cmds:\n{cmds:#?}"
1783 );
1784 assert!(
1785 !cmds.iter().any(|c| c.contains("@paw_role")),
1786 "opt-out must not set the @paw_role pane option; cmds:\n{cmds:#?}"
1787 );
1788 assert_eq!(
1790 commands_containing(&cmds, "send-keys").len(),
1791 2,
1792 "both panes still receive their CLI send-keys"
1793 );
1794 }
1795
1796 #[test]
1799 fn soft_affordance_failure_warns_and_continues() {
1800 let session = TmuxSessionBuilder::new("degrade")
1801 .add_pane(make_pane("feat/a", "/tmp/wt", "claude"))
1802 .build()
1803 .unwrap();
1804
1805 let mut ran: Vec<String> = Vec::new();
1806 let mut warnings: Vec<String> = Vec::new();
1807 let result = session.execute_with(
1809 |cmd| {
1810 let s = cmd.as_command_string();
1811 ran.push(s.clone());
1812 if s.contains("pane-border-lines double") {
1813 Err(PawError::TmuxError(
1814 "unknown option: pane-border-lines".into(),
1815 ))
1816 } else {
1817 Ok(())
1818 }
1819 },
1820 |w| warnings.push(w),
1821 );
1822
1823 assert!(result.is_ok(), "soft failure must not abort the build");
1824 assert!(
1825 warnings.iter().any(|w| w.contains("pane-border-lines")),
1826 "a warning naming the unsupported option must be emitted; warnings: {warnings:#?}"
1827 );
1828 assert!(
1830 ran.iter().any(|c| c.contains("pane-active-border-style")),
1831 "active-border-style must still be applied after the double-line failure"
1832 );
1833 assert!(
1834 ran.iter().any(|c| c.contains("pane-border-status top")),
1835 "pane-border-status must still be applied after the double-line failure"
1836 );
1837 }
1838
1839 #[test]
1842 fn hard_command_failure_aborts() {
1843 let session = TmuxSessionBuilder::new("hard-fail")
1844 .add_pane(make_pane("feat/a", "/tmp/wt", "claude"))
1845 .build()
1846 .unwrap();
1847 let result = session.execute_with(
1848 |cmd| {
1849 if cmd.as_command_string().contains("new-session") {
1850 Err(PawError::TmuxError("server unreachable".into()))
1851 } else {
1852 Ok(())
1853 }
1854 },
1855 |_| {},
1856 );
1857 assert!(result.is_err(), "a hard command failure must propagate");
1858 }
1859
1860 #[test]
1863 fn supervisor_session_titles_are_roles_and_emits_affordances() {
1864 let layout = crate::supervisor::layout::supervisor_layout(2).expect("layout");
1865 let supervisor = make_pane("supervisor", "/repo", "claude");
1866 let dashboard = make_pane("dashboard", "/repo", "git-paw __dashboard");
1867 let agent = make_pane("feat/foo", "/tmp/wt", "claude");
1868 let session = build_supervisor_session(
1869 "sup",
1870 None,
1871 &supervisor,
1872 &dashboard,
1873 &[agent],
1874 layout,
1875 true,
1876 true,
1877 &[],
1878 )
1879 .expect("session builds");
1880 let cmds = session.command_strings();
1881
1882 for (option, value) in AFFORDANCE_OPTIONS {
1884 assert!(
1885 cmds.iter().any(|c| c.contains("set-option")
1886 && c.contains("-t paw-sup")
1887 && c.contains(option)
1888 && c.contains(value)),
1889 "supervisor session missing `set-option {option} {value}`; cmds:\n{cmds:#?}"
1890 );
1891 }
1892
1893 let title_for = |target: &str| -> String {
1894 cmds.iter()
1895 .find(|c| c.contains("select-pane") && c.contains(target) && c.contains("-T"))
1896 .unwrap_or_else(|| panic!("no title set for {target}; cmds:\n{cmds:#?}"))
1897 .clone()
1898 };
1899 assert!(title_for(":0.0").ends_with("-T supervisor"), "pane 0 title");
1900 assert!(title_for(":0.1").ends_with("-T dashboard"), "pane 1 title");
1901 assert!(
1902 title_for(":0.2").ends_with("-T feat/foo"),
1903 "agent pane title"
1904 );
1905 }
1906
1907 #[test]
1911 fn supervisor_build_suppresses_startup_prompts_and_clears_input() {
1912 let layout = crate::supervisor::layout::supervisor_layout(1).expect("layout");
1913 let supervisor = make_pane("supervisor", "/repo", "claude");
1914 let dashboard = make_pane("dashboard", "/repo", "git-paw __dashboard");
1915 let agent = make_pane("feat/foo", "/tmp/wt", "claude");
1916 let session = build_supervisor_session(
1917 "sup",
1918 None,
1919 &supervisor,
1920 &dashboard,
1921 &[agent],
1922 layout,
1923 true,
1924 true,
1925 &[],
1926 )
1927 .expect("session builds");
1928 let cmds = session.command_strings();
1929
1930 assert!(
1932 cmds.iter()
1933 .any(|c| c.contains("new-session") && c.contains("DISABLE_AUTO_UPDATE=true")),
1934 "new-session must set DISABLE_AUTO_UPDATE for pane 0; cmds:\n{cmds:#?}"
1935 );
1936 assert!(
1938 cmds.iter().any(|c| c.contains("set-environment")
1939 && c.contains("DISABLE_AUTO_UPDATE")
1940 && c.contains("true")),
1941 "session env must carry DISABLE_AUTO_UPDATE for split panes"
1942 );
1943 let clear_idx = cmds.iter().position(|c| {
1945 c.contains("send-keys") && c.contains(":0.0") && c.trim_end().ends_with("C-u")
1946 });
1947 let launch_idx = cmds.iter().position(|c| {
1948 c.contains("send-keys")
1949 && c.contains(":0.0")
1950 && c.contains("claude")
1951 && c.contains("Enter")
1952 });
1953 let (clear_idx, launch_idx) = (
1954 clear_idx.expect("a C-u clear is sent to pane 0"),
1955 launch_idx.expect("the CLI-launch command is sent to pane 0"),
1956 );
1957 assert!(
1958 clear_idx < launch_idx,
1959 "the C-u clear must precede the CLI-launch command on pane 0"
1960 );
1961 }
1962
1963 #[test]
1969 fn supervisor_build_compensates_first_agent_cwd_for_swap() {
1970 let layout = crate::supervisor::layout::supervisor_layout(2).expect("layout");
1971 let supervisor = make_pane("supervisor", "/repo", "claude");
1972 let dashboard = make_pane("dashboard", "/repo", "git-paw __dashboard");
1973 let a0 = make_pane("feat/foo", "/tmp/wt-foo", "claude");
1974 let a1 = make_pane("feat/bar", "/tmp/wt-bar", "claude");
1975 let session = build_supervisor_session(
1976 "sup",
1977 None,
1978 &supervisor,
1979 &dashboard,
1980 &[a0, a1],
1981 layout,
1982 true,
1983 true,
1984 &[],
1985 )
1986 .expect("session builds");
1987 let cmds = session.command_strings();
1988
1989 let vsplit = cmds
1990 .iter()
1991 .find(|c| c.contains("split-window") && c.contains("-v") && c.contains("-c"))
1992 .expect("agent-area -v split with -c");
1993 let hsplit = cmds
1994 .iter()
1995 .find(|c| c.contains("split-window") && c.contains("-h") && c.contains("-c"))
1996 .expect("dashboard -h split with -c");
1997
1998 assert!(
2002 vsplit.contains("-c /repo"),
2003 "agent-area -v split must use the dashboard cwd (swap compensation); got: {vsplit}"
2004 );
2005 assert!(
2006 hsplit.contains("-c /tmp/wt-foo"),
2007 "dashboard -h split must use the first agent's worktree (swap compensation); got: {hsplit}"
2008 );
2009 }
2010
2011 #[test]
2014 fn supervisor_session_opt_out_omits_affordances() {
2015 let layout = crate::supervisor::layout::supervisor_layout(1).expect("layout");
2016 let supervisor = make_pane("supervisor", "/repo", "claude");
2017 let dashboard = make_pane("dashboard", "/repo", "git-paw __dashboard");
2018 let agent = make_pane("feat/foo", "/tmp/wt", "claude");
2019 let session = build_supervisor_session(
2020 "sup-off",
2021 None,
2022 &supervisor,
2023 &dashboard,
2024 &[agent],
2025 layout,
2026 true,
2027 false,
2028 &[],
2029 )
2030 .expect("session builds");
2031 let cmds = session.command_strings();
2032 for (option, _value) in AFFORDANCE_OPTIONS {
2033 assert!(
2034 !cmds
2035 .iter()
2036 .any(|c| c.contains("set-option") && c.contains(option)),
2037 "opt-out supervisor session must not emit set-option {option}"
2038 );
2039 }
2040 assert!(
2041 !cmds
2042 .iter()
2043 .any(|c| c.contains("select-pane") && c.contains("-T")),
2044 "opt-out supervisor session must not set pane titles"
2045 );
2046 }
2047
2048 #[test]
2055 fn mouse_mode_enabled_by_default() {
2056 let session = TmuxSessionBuilder::new("proj")
2057 .add_pane(make_pane("main", "/tmp/wt", "claude"))
2058 .build()
2059 .unwrap();
2060
2061 let cmds = session.command_strings();
2062 assert!(
2063 cmds.iter().any(|c| c.contains("mouse on")),
2064 "mouse should be enabled by default"
2065 );
2066 }
2067
2068 #[test]
2069 fn mouse_mode_can_be_disabled() {
2070 let session = TmuxSessionBuilder::new("proj")
2071 .add_pane(make_pane("main", "/tmp/wt", "claude"))
2072 .mouse_mode(false)
2073 .build()
2074 .unwrap();
2075
2076 let cmds = session.command_strings();
2077 assert!(
2078 !cmds.iter().any(|c| c.contains("mouse on")),
2079 "no mouse-on command should be emitted when disabled"
2080 );
2081 }
2082
2083 fn create_test_session(name: &str) {
2091 let output = std::process::Command::new("tmux")
2092 .args(["new-session", "-d", "-s", name, "-x", "200", "-y", "50"])
2093 .output()
2094 .expect("create tmux session");
2095 assert!(
2096 output.status.success(),
2097 "failed to create test session '{name}'"
2098 );
2099 }
2100
2101 fn cleanup_session(name: &str) {
2103 let _ = kill_session(name);
2104 }
2105
2106 #[test]
2107 #[serial_test::serial]
2108 fn is_session_alive_returns_false_for_nonexistent() {
2109 let alive = is_session_alive("paw-definitely-does-not-exist-12345").unwrap();
2110 assert!(!alive);
2111 }
2112
2113 #[test]
2114 #[serial_test::serial]
2115 fn session_lifecycle_create_check_kill() {
2116 let name = "paw-unit-test-lifecycle";
2117 cleanup_session(name);
2118
2119 create_test_session(name);
2120 assert!(is_session_alive(name).unwrap());
2121
2122 kill_session(name).unwrap();
2123 assert!(!is_session_alive(name).unwrap());
2124 }
2125
2126 #[test]
2131 fn classify_liveness_maps_each_branch() {
2132 assert_eq!(classify_liveness(true, true), SessionLiveness::Alive);
2134 assert_eq!(classify_liveness(true, false), SessionLiveness::Stale);
2136 assert_eq!(
2138 classify_liveness(false, false),
2139 SessionLiveness::Indeterminate
2140 );
2141 assert_eq!(
2142 classify_liveness(false, true),
2143 SessionLiveness::Indeterminate
2144 );
2145 }
2146
2147 #[test]
2148 #[serial_test::serial]
2149 fn session_liveness_reports_stale_for_nonexistent() {
2150 assert_eq!(
2151 session_liveness("paw-definitely-does-not-exist-98765"),
2152 SessionLiveness::Stale
2153 );
2154 }
2155
2156 #[test]
2157 #[serial_test::serial]
2158 fn session_liveness_reports_alive_then_stale_across_lifecycle() {
2159 let name = "paw-unit-test-liveness-probe";
2160 cleanup_session(name);
2161
2162 create_test_session(name);
2163 assert_eq!(session_liveness(name), SessionLiveness::Alive);
2164
2165 kill_session(name).unwrap();
2166 assert_eq!(session_liveness(name), SessionLiveness::Stale);
2167 }
2168
2169 #[test]
2170 #[serial_test::serial]
2171 fn resolve_session_name_returns_base_when_no_collision() {
2172 let name = resolve_session_name("unit-test-no-collision-xyz").unwrap();
2173 assert_eq!(name, "paw-unit-test-no-collision-xyz");
2174 }
2175
2176 #[test]
2177 #[serial_test::serial]
2178 fn resolve_session_name_appends_suffix_on_collision() {
2179 let base_name = "paw-unit-test-collision";
2180 cleanup_session(base_name);
2181 cleanup_session(&format!("{base_name}-2"));
2182
2183 create_test_session(base_name);
2184
2185 let resolved = resolve_session_name("unit-test-collision").unwrap();
2186 assert_eq!(resolved, format!("{base_name}-2"));
2187
2188 cleanup_session(base_name);
2189 }
2190
2191 #[test]
2197 fn pipe_pane_queues_correct_command() {
2198 let mut session = TmuxSessionBuilder::new("proj")
2199 .add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
2200 .build()
2201 .unwrap();
2202
2203 let log_path = std::path::PathBuf::from("/repo/.git-paw/logs/paw-proj/feat--auth.log");
2204 session.pipe_pane("paw-proj:0.0", &log_path);
2205
2206 let cmds = session.command_strings();
2207 let pipe_cmds: Vec<&String> = cmds.iter().filter(|c| c.contains("pipe-pane")).collect();
2208 assert_eq!(pipe_cmds.len(), 1);
2209 assert!(pipe_cmds[0].contains("pipe-pane -o -t paw-proj:0.0"));
2210 assert!(pipe_cmds[0].contains("cat >> /repo/.git-paw/logs/paw-proj/feat--auth.log"));
2211 }
2212
2213 #[test]
2216 fn session_without_pipe_pane_has_no_pipe_pane_commands() {
2217 let session = TmuxSessionBuilder::new("proj")
2218 .add_pane(make_pane("main", "/tmp/wt", "claude"))
2219 .build()
2220 .unwrap();
2221
2222 let cmds = session.command_strings();
2223 assert!(
2224 !cmds.iter().any(|c| c.contains("pipe-pane")),
2225 "session built without pipe_pane calls should have no pipe-pane commands"
2226 );
2227 }
2228
2229 #[test]
2230 fn session_with_pipe_pane_differs_from_without() {
2231 let session_without = TmuxSessionBuilder::new("proj")
2232 .add_pane(make_pane("main", "/tmp/wt", "claude"))
2233 .build()
2234 .unwrap();
2235 let cmds_without = session_without.command_strings();
2236
2237 let mut session_with = TmuxSessionBuilder::new("proj")
2238 .add_pane(make_pane("main", "/tmp/wt", "claude"))
2239 .build()
2240 .unwrap();
2241 let log_path = std::path::PathBuf::from("/repo/.git-paw/logs/paw-proj/main.log");
2242 session_with.pipe_pane("paw-proj:0.0", &log_path);
2243 let cmds_with = session_with.command_strings();
2244
2245 assert_ne!(
2246 cmds_without, cmds_with,
2247 "command lists should differ when pipe-pane is added"
2248 );
2249 assert!(
2250 cmds_with.iter().any(|c| c.contains("pipe-pane")),
2251 "session with pipe_pane should contain pipe-pane command"
2252 );
2253 }
2254
2255 #[test]
2258 fn pipe_pane_appears_after_send_keys_for_pane() {
2259 let mut session = TmuxSessionBuilder::new("proj")
2260 .add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
2261 .add_pane(make_pane("feat/api", "/tmp/wt2", "codex"))
2262 .build()
2263 .unwrap();
2264
2265 let log0 = std::path::PathBuf::from("/repo/logs/feat--auth.log");
2266 let log1 = std::path::PathBuf::from("/repo/logs/feat--api.log");
2267 session.pipe_pane("paw-proj:0.0", &log0);
2268 session.pipe_pane("paw-proj:0.1", &log1);
2269
2270 let cmds = session.command_strings();
2271
2272 let last_send_keys = cmds
2274 .iter()
2275 .rposition(|c| c.contains("send-keys"))
2276 .expect("should have send-keys");
2277 let first_pipe_pane = cmds
2278 .iter()
2279 .position(|c| c.contains("pipe-pane"))
2280 .expect("should have pipe-pane");
2281
2282 assert!(
2283 first_pipe_pane > last_send_keys,
2284 "pipe-pane commands (index {first_pipe_pane}) should appear after \
2285 all send-keys commands (last at index {last_send_keys})"
2286 );
2287 }
2288
2289 #[test]
2290 fn pipe_pane_appears_in_dry_run_output() {
2291 let mut session = TmuxSessionBuilder::new("proj")
2292 .add_pane(make_pane("main", "/tmp/wt", "claude"))
2293 .build()
2294 .unwrap();
2295
2296 let log_path = std::path::PathBuf::from("/repo/.git-paw/logs/paw-proj/main.log");
2297 session.pipe_pane("paw-proj:0.0", &log_path);
2298
2299 let cmds = session.command_strings();
2300 assert!(
2301 cmds.iter().any(|c| c.starts_with("tmux pipe-pane")),
2302 "dry-run output should include pipe-pane command"
2303 );
2304 }
2305
2306 #[test]
2311 fn set_environment_emits_correct_tmux_command() {
2312 let session = TmuxSessionBuilder::new("proj")
2313 .add_pane(make_pane("main", "/tmp/wt", "claude"))
2314 .set_environment("GIT_PAW_BROKER_URL", "http://127.0.0.1:9119")
2315 .build()
2316 .unwrap();
2317
2318 let cmds = session.command_strings();
2319 let env_cmds = commands_containing(&cmds, "set-environment");
2320 assert_eq!(env_cmds.len(), 1, "should have exactly one set-environment");
2321 assert!(
2322 env_cmds[0]
2323 .contains("set-environment -t paw-proj GIT_PAW_BROKER_URL http://127.0.0.1:9119"),
2324 "set-environment command should contain key and value, got: {}",
2325 env_cmds[0]
2326 );
2327 }
2328
2329 #[test]
2330 fn set_environment_appears_before_send_keys() {
2331 let session = TmuxSessionBuilder::new("proj")
2332 .add_pane(make_pane("feat/a", "/tmp/a", "claude"))
2333 .add_pane(make_pane("feat/b", "/tmp/b", "codex"))
2334 .set_environment("GIT_PAW_BROKER_URL", "http://127.0.0.1:9119")
2335 .build()
2336 .unwrap();
2337
2338 let cmds = session.command_strings();
2339 let first_env = cmds
2340 .iter()
2341 .position(|c| c.contains("set-environment"))
2342 .expect("should have set-environment");
2343 let first_send = cmds
2344 .iter()
2345 .position(|c| c.contains("send-keys"))
2346 .expect("should have send-keys");
2347
2348 assert!(
2349 first_env < first_send,
2350 "set-environment (index {first_env}) should appear before first send-keys (index {first_send})"
2351 );
2352 }
2353
2354 #[test]
2355 fn multiple_env_vars_both_appear() {
2356 let session = TmuxSessionBuilder::new("proj")
2357 .add_pane(make_pane("main", "/tmp/wt", "claude"))
2358 .set_environment("A", "1")
2359 .set_environment("B", "2")
2360 .build()
2361 .unwrap();
2362
2363 let cmds = session.command_strings();
2364 let env_cmds = commands_containing(&cmds, "set-environment");
2365 assert_eq!(
2366 env_cmds.len(),
2367 2,
2368 "should have two set-environment commands"
2369 );
2370 assert!(env_cmds[0].contains("A 1"));
2371 assert!(env_cmds[1].contains("B 2"));
2372 }
2373
2374 #[test]
2375 fn set_environment_in_dry_run_output() {
2376 let session = TmuxSessionBuilder::new("proj")
2377 .add_pane(make_pane("main", "/tmp/wt", "claude"))
2378 .set_environment("MY_VAR", "my_val")
2379 .build()
2380 .unwrap();
2381
2382 let cmds = session.command_strings();
2383 assert!(
2384 cmds.iter().any(|c| c.starts_with("tmux set-environment")),
2385 "dry-run output should include set-environment command"
2386 );
2387 }
2388
2389 #[test]
2395 fn session_without_dashboard_uses_tiled_layout() {
2396 let session = TmuxSessionBuilder::new("proj")
2397 .add_pane(make_pane("feat/a", "/tmp/a", "claude"))
2398 .add_pane(make_pane("feat/b", "/tmp/b", "codex"))
2399 .build()
2400 .unwrap();
2401
2402 let cmds = session.command_strings();
2403 let layout_cmds: Vec<&String> = cmds
2404 .iter()
2405 .filter(|c| c.contains("select-layout"))
2406 .collect();
2407 let final_layout = layout_cmds
2408 .last()
2409 .expect("should have at least one select-layout");
2410 assert!(
2411 final_layout.contains("tiled"),
2412 "sessions without dashboard should use tiled layout, got: {final_layout}"
2413 );
2414 }
2415
2416 #[test]
2417 fn session_with_dashboard_uses_main_horizontal_layout() {
2418 let session = TmuxSessionBuilder::new("proj")
2419 .add_pane(make_pane("dashboard", "/tmp/repo", "git-paw __dashboard"))
2420 .add_pane(make_pane("feat/a", "/tmp/a", "claude"))
2421 .add_pane(make_pane("feat/b", "/tmp/b", "codex"))
2422 .build()
2423 .unwrap();
2424
2425 let cmds = session.command_strings();
2426 let layout_cmds: Vec<&String> = cmds
2427 .iter()
2428 .filter(|c| c.contains("select-layout"))
2429 .collect();
2430 let final_layout = layout_cmds
2431 .last()
2432 .expect("should have at least one select-layout");
2433 assert!(
2434 final_layout.contains("main-horizontal"),
2435 "sessions with dashboard should use main-horizontal layout, got: {final_layout}"
2436 );
2437 }
2438
2439 #[test]
2440 fn single_pane_session_uses_tiled_layout() {
2441 let session = TmuxSessionBuilder::new("proj")
2442 .add_pane(make_pane("main", "/tmp/wt", "claude"))
2443 .build()
2444 .unwrap();
2445
2446 let cmds = session.command_strings();
2447 let layout_cmds: Vec<&String> = cmds
2448 .iter()
2449 .filter(|c| c.contains("select-layout"))
2450 .collect();
2451 let final_layout = layout_cmds
2452 .last()
2453 .expect("should have at least one select-layout");
2454 assert!(
2455 final_layout.contains("tiled"),
2456 "single pane sessions should use tiled layout, got: {final_layout}"
2457 );
2458 }
2459
2460 #[test]
2461 fn dashboard_layout_appears_in_dry_run_output() {
2462 let session = TmuxSessionBuilder::new("proj")
2463 .add_pane(make_pane("dashboard", "/tmp/repo", "git-paw __dashboard"))
2464 .add_pane(make_pane("feat/a", "/tmp/a", "claude"))
2465 .build()
2466 .unwrap();
2467
2468 let cmds = session.command_strings();
2469 assert!(
2470 cmds.iter().any(|c| c.contains("main-horizontal")),
2471 "dry-run output should include main-horizontal layout command"
2472 );
2473 }
2474
2475 struct PausePaneSession {
2482 name: String,
2483 }
2484
2485 impl PausePaneSession {
2486 fn new(label: &str) -> Self {
2487 let pid = std::process::id();
2488 let nanos = std::time::SystemTime::now()
2489 .duration_since(std::time::UNIX_EPOCH)
2490 .map_or(0, |d| d.as_nanos());
2491 let name = format!("paw-pause-test-{label}-{pid}-{nanos}");
2492 let output = std::process::Command::new("tmux")
2493 .args(["new-session", "-d", "-s", &name, "-x", "200", "-y", "50"])
2494 .output()
2495 .expect("create tmux test session");
2496 assert!(
2497 output.status.success(),
2498 "failed to create test session '{name}'"
2499 );
2500 Self { name }
2501 }
2502 }
2503
2504 impl Drop for PausePaneSession {
2505 fn drop(&mut self) {
2506 let _ = kill_session(&self.name);
2507 }
2508 }
2509
2510 #[test]
2511 #[serial_test::serial]
2512 fn detach_client_succeeds_on_attached_session() {
2513 let session = PausePaneSession::new("detach-attached");
2517 detach_client(&session.name).expect("detach should succeed");
2518 assert!(is_session_alive(&session.name).unwrap());
2519 }
2520
2521 #[test]
2522 #[serial_test::serial]
2523 fn detach_client_is_noop_with_no_clients() {
2524 let session = PausePaneSession::new("detach-noop");
2525 detach_client(&session.name).expect("first detach should succeed");
2527 detach_client(&session.name).expect("second detach should succeed");
2529 assert!(is_session_alive(&session.name).unwrap());
2530 }
2531
2532 #[test]
2536 #[serial_test::serial]
2537 fn detach_client_noop_when_no_clients_attached() {
2538 let session = PausePaneSession::new("detach-9-11");
2539 detach_client(&session.name).expect("detach with no clients should be Ok");
2540 assert!(is_session_alive(&session.name).unwrap());
2541 }
2542
2543 #[test]
2544 #[serial_test::serial]
2545 fn kill_pane_removes_pane() {
2546 let session = PausePaneSession::new("killpane");
2547 let _ = std::process::Command::new("tmux")
2549 .args(["split-window", "-t", &session.name])
2550 .output();
2551 let pane_count_before = std::process::Command::new("tmux")
2552 .args(["list-panes", "-t", &session.name, "-F", "#{pane_index}"])
2553 .output()
2554 .map_or(0, |o| String::from_utf8_lossy(&o.stdout).lines().count());
2555 assert_eq!(pane_count_before, 2, "should have 2 panes before kill");
2556
2557 kill_pane(&session.name, 1).expect("kill_pane should succeed");
2558
2559 let pane_count_after = std::process::Command::new("tmux")
2560 .args(["list-panes", "-t", &session.name, "-F", "#{pane_index}"])
2561 .output()
2562 .map_or(0, |o| String::from_utf8_lossy(&o.stdout).lines().count());
2563 assert_eq!(pane_count_after, 1, "should have 1 pane after kill");
2564 }
2565
2566 #[test]
2567 #[serial_test::serial]
2568 fn kill_pane_is_noop_for_missing_pane() {
2569 let session = PausePaneSession::new("killpane-missing");
2570 kill_pane(&session.name, 99).expect("kill missing pane should be ok");
2572 assert!(is_session_alive(&session.name).unwrap());
2573 }
2574
2575 #[test]
2576 #[serial_test::serial]
2577 fn built_session_can_be_executed_and_killed() {
2578 let project = "unit-test-execute";
2579 let session_name = format!("paw-{project}");
2580 cleanup_session(&session_name);
2581
2582 let session = TmuxSessionBuilder::new(project)
2583 .add_pane(make_pane("main", "/tmp", "echo hello"))
2584 .build()
2585 .unwrap();
2586
2587 session.execute().unwrap();
2588 assert!(is_session_alive(&session_name).unwrap());
2589
2590 kill_session(&session_name).unwrap();
2591 assert!(!is_session_alive(&session_name).unwrap());
2592 }
2593
2594 #[test]
2601 fn supervisor_submit_argv_pair_has_two_invocations() {
2602 let (first, second) = build_supervisor_submit_argv_pair("paw-proj", 3, "do the thing");
2603 assert!(!first.is_empty(), "first send-keys argv must be non-empty");
2605 assert!(
2606 !second.is_empty(),
2607 "second send-keys argv must be non-empty"
2608 );
2609 }
2610
2611 #[test]
2612 fn supervisor_submit_first_invocation_sends_prompt_and_enter() {
2613 let (first, _second) = build_supervisor_submit_argv_pair("paw-proj", 3, "do the thing");
2614 assert_eq!(first[0], "send-keys");
2615 assert_eq!(first[1], "-t");
2616 assert_eq!(first[2], "paw-proj:0.3");
2617 assert_eq!(first[3], "do the thing");
2618 assert_eq!(first[4], "Enter");
2619 }
2620
2621 #[test]
2622 fn supervisor_submit_second_invocation_is_enter_only() {
2623 let (_first, second) = build_supervisor_submit_argv_pair("paw-proj", 3, "do the thing");
2624 assert_eq!(second[0], "send-keys");
2625 assert_eq!(second[1], "-t");
2626 assert_eq!(second[2], "paw-proj:0.3");
2627 assert_eq!(second[3], "Enter");
2628 assert_eq!(
2629 second.len(),
2630 4,
2631 "second invocation should be send-keys -t <target> Enter (no prompt)"
2632 );
2633 }
2634
2635 #[test]
2636 fn supervisor_submit_targets_same_pane_in_both_invocations() {
2637 let (first, second) = build_supervisor_submit_argv_pair("paw-proj", 7, "prompt");
2638 assert_eq!(first[2], second[2]);
2641 assert_eq!(first[2], "paw-proj:0.7");
2642 }
2643
2644 #[test]
2645 fn supervisor_submit_argv_pair_preserves_prompt_with_newlines_and_quotes() {
2646 let prompt = "line1\nline2 with \"quoted\" text";
2647 let (first, _second) = build_supervisor_submit_argv_pair("paw-proj", 1, prompt);
2648 assert_eq!(first[3], prompt);
2651 }
2652
2653 #[test]
2661 fn cmd_supervisor_inject_argv_has_single_enter_per_pane() {
2662 let panes: Vec<(usize, &str)> = vec![(2, "p2"), (3, "p3"), (4, "p4")];
2663
2664 let mut total_enters = 0;
2665 for (pane_idx, prompt) in &panes {
2666 let (first, _second) = build_supervisor_submit_argv_pair("paw-proj", *pane_idx, prompt);
2667 let enter_positions: Vec<usize> = first
2668 .iter()
2669 .enumerate()
2670 .filter(|(_, tok)| tok.as_str() == "Enter")
2671 .map(|(i, _)| i)
2672 .collect();
2673 assert_eq!(
2674 enter_positions.len(),
2675 1,
2676 "each per-pane invocation must send exactly one Enter; got argv: {first:?}"
2677 );
2678 let enter_pos = enter_positions[0];
2679 assert!(
2680 enter_pos > 0,
2681 "Enter token must follow a prompt-string argument; got argv: {first:?}"
2682 );
2683 assert_eq!(
2684 first[enter_pos - 1].as_str(),
2685 *prompt,
2686 "Enter token must directly follow the prompt argument; got argv: {first:?}"
2687 );
2688 total_enters += enter_positions.len();
2689 }
2690 assert_eq!(
2691 total_enters, 3,
2692 "for N=3 panes the launch flow must send exactly N=3 Enters"
2693 );
2694 }
2695
2696 fn make_layout_panes(n: usize) -> (PaneSpec, PaneSpec, Vec<PaneSpec>) {
2706 let supervisor = make_pane("supervisor", "/repo", "claude");
2707 let dashboard = make_pane("dashboard", "/repo", "git-paw __dashboard");
2708 let agents = (0..n)
2709 .map(|i| make_pane(&format!("feat/b{i}"), &format!("/tmp/wt{i}"), "claude"))
2710 .collect();
2711 (supervisor, dashboard, agents)
2712 }
2713
2714 fn build_for(agent_count: usize) -> TmuxSession {
2715 let layout =
2716 crate::supervisor::layout::supervisor_layout(agent_count).expect("layout computes");
2717 let (supervisor, dashboard, agents) = make_layout_panes(agent_count);
2718 build_supervisor_session(
2719 "proj",
2720 None,
2721 &supervisor,
2722 &dashboard,
2723 &agents,
2724 layout,
2725 true,
2726 true,
2727 &[("GIT_PAW_BROKER_URL".to_string(), "http://x".to_string())],
2728 )
2729 .expect("session builds")
2730 }
2731
2732 #[test]
2734 fn supervisor_layout_5_agents_single_row() {
2735 let session = build_for(5);
2736 let cmds = session.command_strings();
2737 let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
2738 .into_iter()
2739 .filter(|c| !c.trim_end().ends_with("C-u"))
2740 .collect();
2741 assert_eq!(
2742 send_keys.len(),
2743 7,
2744 "5 agents → 1 supervisor + 1 dashboard + 5 agents = 7 send-keys, got {send_keys:#?}"
2745 );
2746 let supervisor_pane = send_keys
2747 .iter()
2748 .find(|c| c.contains("0.0 "))
2749 .unwrap_or(&send_keys[0]);
2750 assert!(supervisor_pane.contains("claude"));
2751 let dashboard_pane = send_keys
2752 .iter()
2753 .find(|c| c.contains(":0.1 ") && c.contains("__dashboard"))
2754 .expect("dashboard send-keys at pane :0.1");
2755 let _ = dashboard_pane;
2756 let resizes = commands_containing(&cmds, "resize-pane");
2758 assert!(
2759 resizes
2760 .iter()
2761 .any(|c| c.contains(":0.0") && c.contains("60%")),
2762 "top row resize to 60%, got resizes {resizes:#?}"
2763 );
2764 assert!(
2766 resizes
2767 .iter()
2768 .any(|c| c.contains(":0.2") && c.contains("40%")),
2769 "agent-row resize to 40% at :0.2, got resizes {resizes:#?}"
2770 );
2771 }
2772
2773 #[test]
2775 fn supervisor_layout_10_agents_two_rows() {
2776 let session = build_for(10);
2777 let cmds = session.command_strings();
2778 let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
2779 .into_iter()
2780 .filter(|c| !c.trim_end().ends_with("C-u"))
2781 .collect();
2782 assert_eq!(
2783 send_keys.len(),
2784 12,
2785 "10 agents → 1 supervisor + 1 dashboard + 10 agents = 12 send-keys"
2786 );
2787 let resizes = commands_containing(&cmds, "resize-pane");
2788 assert!(
2789 resizes
2790 .iter()
2791 .any(|c| c.contains(":0.0") && c.contains("40%"))
2792 );
2793 assert!(
2794 resizes.iter().filter(|c| c.contains("30%")).count() >= 2,
2795 "two agent rows at 30% each, got {resizes:#?}"
2796 );
2797 }
2798
2799 #[test]
2801 fn supervisor_layout_11_agents_three_rows() {
2802 let session = build_for(11);
2803 let cmds = session.command_strings();
2804 let resizes = commands_containing(&cmds, "resize-pane");
2805 assert!(
2806 resizes
2807 .iter()
2808 .any(|c| c.contains(":0.0") && c.contains("28%"))
2809 );
2810 assert!(
2811 resizes.iter().filter(|c| c.contains("24%")).count() >= 3,
2812 "three agent rows at 24% each, got {resizes:#?}"
2813 );
2814 let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
2816 .into_iter()
2817 .filter(|c| !c.trim_end().ends_with("C-u"))
2818 .collect();
2819 assert_eq!(send_keys.len(), 13);
2820 assert!(send_keys.iter().any(|c| c.contains(":0.12 ")));
2821 }
2822
2823 #[test]
2825 fn supervisor_layout_20_agents_four_rows() {
2826 let session = build_for(20);
2827 let cmds = session.command_strings();
2828 let resizes = commands_containing(&cmds, "resize-pane");
2829 assert!(
2830 resizes
2831 .iter()
2832 .any(|c| c.contains(":0.0") && c.contains("28%"))
2833 );
2834 assert!(
2835 resizes.iter().filter(|c| c.contains("18%")).count() >= 4,
2836 "four agent rows at 18% each, got {resizes:#?}"
2837 );
2838 }
2839
2840 #[test]
2842 fn supervisor_layout_25_agents_five_rows() {
2843 let session = build_for(25);
2844 let cmds = session.command_strings();
2845 let resizes = commands_containing(&cmds, "resize-pane");
2846 assert!(
2847 resizes
2848 .iter()
2849 .any(|c| c.contains(":0.0") && c.contains("28%"))
2850 );
2851 assert!(
2852 resizes.iter().filter(|c| c.contains("14.4%")).count() >= 5,
2853 "five agent rows at 14.4% each, got {resizes:#?}"
2854 );
2855 }
2856
2857 #[test]
2859 fn supervisor_layout_26_agents_rejected_by_layout_helper() {
2860 let err = crate::supervisor::layout::supervisor_layout(26).expect_err("26 agents rejected");
2863 let msg = err.to_string();
2864 assert!(msg.contains("26 agents requested"));
2865 assert!(msg.contains("maximum is 25"));
2866 }
2867
2868 #[test]
2872 fn supervisor_layout_7_agents_row_major_indices() {
2873 let session = build_for(7);
2874 let cmds = session.command_strings();
2875 let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
2876 .into_iter()
2877 .filter(|c| !c.trim_end().ends_with("C-u"))
2878 .collect();
2879 assert!(
2882 send_keys
2883 .iter()
2884 .any(|c| c.contains(":0.2 ") && c.contains("claude")),
2885 "pane :0.2 is the first agent (top-left); send-keys {send_keys:#?}"
2886 );
2887 assert!(
2888 send_keys
2889 .iter()
2890 .any(|c| c.contains(":0.6 ") && c.contains("claude")),
2891 "pane :0.6 is the fifth agent (top-right of row 1)"
2892 );
2893 assert!(
2894 send_keys
2895 .iter()
2896 .any(|c| c.contains(":0.7 ") && c.contains("claude")),
2897 "pane :0.7 is the sixth agent (start of row 2)"
2898 );
2899 }
2900
2901 #[test]
2904 fn supervisor_top_row_split_50_50() {
2905 let session = build_for(3);
2906 let cmds = session.command_strings();
2907 let h_split = cmds
2908 .iter()
2909 .find(|c| c.contains("split-window") && c.contains("-h") && c.contains("-l 50%"))
2910 .unwrap_or_else(|| panic!("expected horizontal 50% split; got cmds: {cmds:#?}"));
2911 assert!(
2912 h_split.contains(":0.0") || h_split.contains("split-window -h -t paw-proj"),
2913 "horizontal split should target the supervisor pane; got: {h_split}"
2914 );
2915 }
2916
2917 #[test]
2923 fn supervisor_splits_use_l_percent_not_p() {
2924 let session = build_for(4);
2925 let cmds = session.command_strings();
2926 for cmd in &cmds {
2927 if cmd.contains("split-window") {
2928 assert!(
2929 !cmd.contains(" -p "),
2930 "split-window must not use deprecated -p flag (fails on Linux tmux 3.4 headless); got: {cmd}"
2931 );
2932 }
2933 }
2934 }
2935
2936 #[test]
2939 fn supervisor_new_session_passes_explicit_x_and_y() {
2940 let session = build_for(2);
2941 let cmds = session.command_strings();
2942 let new_session_cmd = cmds
2943 .iter()
2944 .find(|c| c.contains("new-session"))
2945 .expect("supervisor build emits a new-session command");
2946 assert!(
2947 new_session_cmd.contains("-x 480"),
2948 "supervisor new-session must pass -x 480; got: {new_session_cmd}"
2949 );
2950 assert!(
2951 new_session_cmd.contains("-y 140"),
2952 "supervisor new-session must pass -y 140; got: {new_session_cmd}"
2953 );
2954 }
2955
2956 #[test]
2958 fn supervisor_sets_default_size_after_new_session() {
2959 let session = build_for(2);
2960 let cmds = session.command_strings();
2961 let new_session_idx = cmds
2962 .iter()
2963 .position(|c| c.contains("new-session"))
2964 .expect("new-session in command list");
2965 let default_size_idx = cmds
2966 .iter()
2967 .position(|c| {
2968 c.contains("set-option") && c.contains("default-size") && c.contains("480x140")
2969 })
2970 .expect("set-option default-size 200x50 in command list");
2971 assert!(
2972 default_size_idx > new_session_idx,
2973 "set-option default-size must come AFTER new-session; got order new={new_session_idx}, default-size={default_size_idx}"
2974 );
2975 }
2976
2977 #[test]
2984 fn bare_start_with_broker_places_dashboard_at_pane_0() {
2985 let session = TmuxSessionBuilder::new("proj")
2987 .add_pane(make_pane("dashboard", "/repo", "git-paw __dashboard"))
2988 .add_pane(make_pane("feat/a", "/tmp/wt-a", "claude"))
2989 .add_pane(make_pane("feat/b", "/tmp/wt-b", "claude"))
2990 .add_pane(make_pane("feat/c", "/tmp/wt-c", "claude"))
2991 .build()
2992 .expect("session builds");
2993
2994 let cmds = session.command_strings();
2995 let dashboard_send = cmds
2996 .iter()
2997 .find(|c| c.contains("send-keys") && c.contains("__dashboard"))
2998 .expect("dashboard send-keys present");
2999 assert!(
3000 dashboard_send.contains(":0.0 "),
3001 "dashboard pane must be index 0; got: {dashboard_send}"
3002 );
3003 for (pane_idx, branch_marker, worktree) in [
3008 (1, "feat/a", "/tmp/wt-a"),
3009 (2, "feat/b", "/tmp/wt-b"),
3010 (3, "feat/c", "/tmp/wt-c"),
3011 ] {
3012 let select_target = format!(":0.{pane_idx} ");
3013 assert!(
3014 cmds.iter()
3015 .any(|c| c.contains(&select_target) && c.contains(branch_marker)),
3016 "agent {branch_marker} should land at pane {pane_idx}; cmds:\n{cmds:#?}"
3017 );
3018 let split_marker = format!("-c {worktree}");
3019 assert!(
3020 cmds.iter()
3021 .any(|c| c.contains("split-window") && c.contains(&split_marker)),
3022 "agent {branch_marker} split should carry {split_marker}; cmds:\n{cmds:#?}"
3023 );
3024 }
3025 }
3026
3027 #[test]
3030 fn broker_disabled_produces_no_dashboard_pane() {
3031 let session = TmuxSessionBuilder::new("proj")
3032 .add_pane(make_pane("feat/a", "/tmp/wt-a", "claude"))
3033 .add_pane(make_pane("feat/b", "/tmp/wt-b", "claude"))
3034 .add_pane(make_pane("feat/c", "/tmp/wt-c", "claude"))
3035 .build()
3036 .expect("session builds");
3037
3038 let cmds = session.command_strings();
3039 assert!(
3040 !cmds.iter().any(|c| c.contains("__dashboard")),
3041 "broker disabled must not add a dashboard pane; got cmds:\n{cmds:#?}"
3042 );
3043 let send_keys: Vec<&String> = cmds.iter().filter(|c| c.contains("send-keys")).collect();
3045 assert_eq!(
3046 send_keys.len(),
3047 3,
3048 "broker-disabled launch with 3 agents must emit 3 send-keys; got: {send_keys:#?}"
3049 );
3050 }
3051
3052 #[test]
3055 fn dashboard_pane_has_title_dashboard() {
3056 let session = build_for(2);
3058 let cmds = session.command_strings();
3059 let dashboard_select = cmds
3060 .iter()
3061 .find(|c| {
3062 c.contains("select-pane")
3063 && c.contains(":0.1")
3064 && c.contains("-T")
3065 && c.contains("dashboard")
3066 })
3067 .unwrap_or_else(|| {
3068 panic!("expected select-pane -T dashboard at :0.1; cmds:\n{cmds:#?}")
3069 });
3070 assert!(
3073 dashboard_select.contains("dashboard"),
3074 "dashboard pane title must include `dashboard`; got: {dashboard_select}"
3075 );
3076 }
3077
3078 #[test]
3081 fn supervisor_layout_emits_env_before_agent_send_keys() {
3082 let session = build_for(3);
3083 let cmds = session.command_strings();
3084 let first_env = cmds
3085 .iter()
3086 .position(|c| c.contains("set-environment") && c.contains("GIT_PAW_BROKER_URL"))
3087 .expect("set-environment GIT_PAW_BROKER_URL present");
3088 let first_agent_send = cmds
3089 .iter()
3090 .position(|c| c.contains("send-keys") && c.contains(":0.2 "))
3091 .expect("first agent send-keys at :0.2");
3092 assert!(
3093 first_env < first_agent_send,
3094 "set-environment must come before agent-pane send-keys"
3095 );
3096 }
3097
3098 fn every_new_session_command() -> Vec<(&'static str, String)> {
3112 let mut found: Vec<(&'static str, String)> = Vec::new();
3113
3114 let basic = TmuxSessionBuilder::new("conv-basic")
3116 .add_pane(make_pane("main", "/tmp/wt-basic", "claude"))
3117 .build()
3118 .expect("basic builder produces a session");
3119 for cmd in basic.command_strings() {
3120 if cmd.contains("new-session") {
3121 found.push(("TmuxSessionBuilder::build", cmd));
3122 }
3123 }
3124
3125 let layout = crate::supervisor::layout::supervisor_layout(2).expect("layout");
3129 let (supervisor, dashboard, agents) = make_layout_panes(2);
3130 let supervisor_session = build_supervisor_session(
3131 "conv-supervisor",
3132 None,
3133 &supervisor,
3134 &dashboard,
3135 &agents,
3136 layout,
3137 true,
3138 true,
3139 &[],
3140 )
3141 .expect("supervisor builder produces a session");
3142 for cmd in supervisor_session.command_strings() {
3143 if cmd.contains("new-session") {
3144 found.push(("build_supervisor_session", cmd));
3145 }
3146 }
3147
3148 assert!(
3149 !found.is_empty(),
3150 "expected at least one new-session command from the audited builders"
3151 );
3152 found
3153 }
3154
3155 #[test]
3159 fn every_new_session_passes_x_and_y() {
3160 for (builder, cmd) in every_new_session_command() {
3161 assert!(
3162 cmd.contains(" -x ") || cmd.ends_with(" -x"),
3163 "{builder}: new-session must pass -x; got: {cmd}"
3164 );
3165 assert!(
3166 cmd.contains(" -y ") || cmd.ends_with(" -y"),
3167 "{builder}: new-session must pass -y; got: {cmd}"
3168 );
3169 }
3170 }
3171
3172 #[test]
3176 fn every_new_session_passes_c() {
3177 for (builder, cmd) in every_new_session_command() {
3178 assert!(
3179 cmd.contains(" -c "),
3180 "{builder}: new-session must pass -c <cwd>; got: {cmd}"
3181 );
3182 }
3183 }
3184
3185 #[test]
3189 fn supervisor_layout_agent_splits_carry_worktree_no_cd_chain() {
3190 let layout = crate::supervisor::layout::supervisor_layout(2).expect("layout");
3191 let supervisor = make_pane("supervisor", "/repo", "claude");
3192 let dashboard = make_pane("dashboard", "/repo", "git-paw __dashboard");
3193 let agent_a = make_pane("feat/a", "/tmp/wt-a", "claude");
3194 let agent_b = make_pane("feat/b", "/tmp/wt-b", "claude");
3195 let session = build_supervisor_session(
3196 "proj",
3197 None,
3198 &supervisor,
3199 &dashboard,
3200 &[agent_a, agent_b],
3201 layout,
3202 true,
3203 true,
3204 &[],
3205 )
3206 .expect("session builds");
3207
3208 let cmds = session.command_strings();
3209 let splits = commands_containing(&cmds, "split-window");
3210 assert!(
3211 splits.iter().any(|c| c.contains("-c /tmp/wt-a")),
3212 "split for agent a should pass -c /tmp/wt-a; splits: {splits:#?}"
3213 );
3214 assert!(
3215 splits.iter().any(|c| c.contains("-c /tmp/wt-b")),
3216 "split for agent b should pass -c /tmp/wt-b; splits: {splits:#?}"
3217 );
3218
3219 let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
3220 .into_iter()
3221 .filter(|c| !c.trim_end().ends_with("C-u"))
3222 .collect();
3223 for entry in &send_keys {
3224 assert!(
3225 !entry.contains("cd /tmp/wt-a &&"),
3226 "no send-keys should chain `cd /tmp/wt-a &&`; got: {entry}"
3227 );
3228 assert!(
3229 !entry.contains("cd /tmp/wt-b &&"),
3230 "no send-keys should chain `cd /tmp/wt-b &&`; got: {entry}"
3231 );
3232 }
3233 }
3234
3235 #[test]
3238 fn add_agent_same_row_splits_horizontally_from_previous_pane() {
3239 let layout = crate::supervisor::layout::layout_for(5).expect("layout");
3243 let new_agent = make_pane("feat/fifth", "/tmp/wt5", "claude");
3244 let session = build_add_agent_commands("paw-x", &new_agent, 4, layout, true);
3245 let cmds = session.command_strings();
3246
3247 assert!(
3248 cmds.iter().any(|c| c.contains("split-window")
3249 && c.contains("-h")
3250 && c.contains(":0.5")
3251 && c.contains("-c /tmp/wt5")),
3252 "5th agent should -h split from pane 5 with -c worktree; cmds:\n{cmds:#?}"
3253 );
3254 assert!(
3256 cmds.iter()
3257 .any(|c| c.contains("send-keys") && c.contains(":0.6") && c.contains("claude")),
3258 "new agent CLI should launch in pane 6; cmds:\n{cmds:#?}"
3259 );
3260 }
3261
3262 #[test]
3263 fn add_agent_new_row_splits_vertically_from_previous_row_first_pane() {
3264 let layout = crate::supervisor::layout::layout_for(6).expect("layout");
3268 let new_agent = make_pane("feat/sixth", "/tmp/wt6", "claude");
3269 let session = build_add_agent_commands("paw-x", &new_agent, 5, layout, false);
3270 let cmds = session.command_strings();
3271
3272 assert!(
3273 cmds.iter().any(|c| c.contains("split-window")
3274 && c.contains("-v")
3275 && c.contains(":0.2")
3276 && c.contains("-c /tmp/wt6")),
3277 "6th agent should -v split from pane 2 (prev row first); cmds:\n{cmds:#?}"
3278 );
3279 }
3280
3281 #[test]
3282 fn add_agent_reapplies_row_height_resize_pass() {
3283 let layout = crate::supervisor::layout::layout_for(5).expect("layout");
3286 let new_agent = make_pane("feat/fifth", "/tmp/wt5", "claude");
3287 let session = build_add_agent_commands("paw-x", &new_agent, 4, layout, false);
3288 let cmds = session.command_strings();
3289
3290 let top_pct = format!("{}%", layout.top_row_pct);
3291 assert!(
3292 cmds.iter()
3293 .any(|c| c.contains("resize-pane") && c.contains(":0.0") && c.contains(&top_pct)),
3294 "re-tile should resize the top row to {top_pct}; cmds:\n{cmds:#?}"
3295 );
3296 }
3297
3298 #[test]
3299 fn remove_retile_emits_resize_pass_for_remaining_count() {
3300 let layout = crate::supervisor::layout::layout_for(4).expect("layout");
3303 let session = build_remove_retile_commands("paw-x", 4, layout);
3304 let cmds = session.command_strings();
3305
3306 let top_pct = format!("{}%", layout.top_row_pct);
3307 assert!(
3308 cmds.iter()
3309 .any(|c| c.contains("resize-pane") && c.contains(":0.0") && c.contains(&top_pct)),
3310 "remove re-tile should resize the top row; cmds:\n{cmds:#?}"
3311 );
3312 assert!(
3314 cmds.iter()
3315 .any(|c| c.contains("resize-pane") && c.contains(":0.2")),
3316 "remove re-tile should resize the first agent row (pane 2); cmds:\n{cmds:#?}"
3317 );
3318 }
3319
3320 #[test]
3321 fn remove_retile_with_zero_remaining_is_empty() {
3322 let layout = crate::supervisor::layout::layout_for(1).expect("layout");
3323 let session = build_remove_retile_commands("paw-x", 0, layout);
3324 assert!(
3325 session.command_strings().is_empty(),
3326 "removing the last agent leaves the top row untouched (no re-tile)"
3327 );
3328 }
3329}