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}
21
22impl TmuxCommand {
23 fn new(args: &[&str]) -> Self {
25 Self {
26 args: args.iter().map(|&s| s.to_owned()).collect(),
27 }
28 }
29
30 #[allow(dead_code)]
34 pub fn as_command_string(&self) -> String {
35 format!("tmux {}", self.args.join(" "))
36 }
37
38 fn execute(&self) -> Result<String, PawError> {
40 let output = Command::new("tmux")
41 .args(&self.args)
42 .output()
43 .map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
44
45 if output.status.success() {
46 String::from_utf8(output.stdout)
47 .map_err(|e| PawError::TmuxError(format!("invalid utf-8 in tmux output: {e}")))
48 } else {
49 let stderr = String::from_utf8_lossy(&output.stderr);
50 Err(PawError::TmuxError(stderr.trim().to_owned()))
51 }
52 }
53}
54
55#[derive(Debug, Clone)]
57pub struct PaneSpec {
58 pub branch: String,
60 pub worktree: String,
62 pub cli_command: String,
64}
65
66#[derive(Debug)]
68pub struct TmuxSession {
69 pub name: String,
71 commands: Vec<TmuxCommand>,
72}
73
74impl TmuxSession {
75 pub fn execute(&self) -> Result<(), PawError> {
77 for cmd in &self.commands {
78 cmd.execute()?;
79 }
80 Ok(())
81 }
82
83 #[allow(dead_code)]
87 pub fn command_strings(&self) -> Vec<String> {
88 self.commands
89 .iter()
90 .map(TmuxCommand::as_command_string)
91 .collect()
92 }
93
94 pub fn pipe_pane(&mut self, pane_target: &str, log_path: &std::path::Path) -> &mut Self {
99 self.commands.push(TmuxCommand::new(&[
100 "pipe-pane",
101 "-o",
102 "-t",
103 pane_target,
104 &format!("cat >> {}", log_path.display()),
105 ]));
106 self
107 }
108
109 pub fn reapply_tiled_layout(&mut self, session_name: &str) -> &mut Self {
115 self.commands.push(TmuxCommand::new(&[
116 "select-layout",
117 "-t",
118 session_name,
119 "tiled",
120 ]));
121 self
122 }
123
124 pub fn apply_dashboard_layout(&mut self, session_name: &str) -> &mut Self {
130 self.commands.push(TmuxCommand::new(&[
131 "select-layout",
132 "-t",
133 session_name,
134 "main-horizontal",
135 ]));
136 self
137 }
138}
139
140#[derive(Debug)]
169pub struct TmuxSessionBuilder {
170 project_name: String,
171 panes: Vec<PaneSpec>,
172 mouse_mode: bool,
173 session_name_override: Option<String>,
174 env_vars: Vec<(String, String)>,
175}
176
177impl TmuxSessionBuilder {
178 pub fn new(project_name: &str) -> Self {
183 Self {
184 project_name: project_name.to_owned(),
185 panes: Vec::new(),
186 mouse_mode: true,
187 session_name_override: None,
188 env_vars: Vec::new(),
189 }
190 }
191
192 #[must_use]
196 pub fn session_name(mut self, name: String) -> Self {
197 self.session_name_override = Some(name);
198 self
199 }
200
201 #[must_use]
203 pub fn add_pane(mut self, spec: PaneSpec) -> Self {
204 self.panes.push(spec);
205 self
206 }
207
208 #[must_use]
213 pub fn mouse_mode(mut self, enabled: bool) -> Self {
214 self.mouse_mode = enabled;
215 self
216 }
217
218 #[must_use]
223 pub fn set_environment(mut self, key: &str, value: &str) -> Self {
224 self.env_vars.push((key.to_owned(), value.to_owned()));
225 self
226 }
227
228 #[allow(clippy::too_many_lines)]
233 pub fn build(self) -> Result<TmuxSession, PawError> {
234 if self.panes.is_empty() {
235 return Err(PawError::TmuxError(
236 "cannot create a session with no panes".to_owned(),
237 ));
238 }
239
240 let session_name = self
241 .session_name_override
242 .unwrap_or_else(|| format!("paw-{}", self.project_name));
243 let mut commands = Vec::new();
244
245 let first_worktree = &self.panes[0].worktree;
252 commands.push(TmuxCommand::new(&[
253 "new-session",
254 "-d",
255 "-s",
256 &session_name,
257 "-x",
258 "200",
259 "-y",
260 "50",
261 "-c",
262 first_worktree,
263 ]));
264
265 commands.push(TmuxCommand::new(&[
272 "set-option",
273 "-g",
274 "default-size",
275 "200x50",
276 ]));
277
278 if self.mouse_mode {
280 commands.push(TmuxCommand::new(&[
281 "set-option",
282 "-t",
283 &session_name,
284 "mouse",
285 "on",
286 ]));
287 }
288
289 commands.push(TmuxCommand::new(&[
291 "set-option",
292 "-t",
293 &session_name,
294 "pane-border-status",
295 "top",
296 ]));
297 commands.push(TmuxCommand::new(&[
298 "set-option",
299 "-t",
300 &session_name,
301 "pane-border-format",
302 " #{pane_title} ",
303 ]));
304
305 for (key, value) in &self.env_vars {
307 commands.push(TmuxCommand::new(&[
308 "set-environment",
309 "-t",
310 &session_name,
311 key,
312 value,
313 ]));
314 }
315
316 let first = &self.panes[0];
318 let pane_target = format!("{session_name}:0.0");
319 let pane_title = format!("{} \u{2192} {}", first.branch, first.cli_command);
320 commands.push(TmuxCommand::new(&[
321 "select-pane",
322 "-t",
323 &pane_target,
324 "-T",
325 &pane_title,
326 ]));
327 commands.push(TmuxCommand::new(&[
328 "send-keys",
329 "-t",
330 &pane_target,
331 &first.cli_command,
332 "Enter",
333 ]));
334
335 for (i, pane) in self.panes.iter().enumerate().skip(1) {
337 commands.push(TmuxCommand::new(&[
339 "select-layout",
340 "-t",
341 &session_name,
342 "tiled",
343 ]));
344
345 commands.push(TmuxCommand::new(&[
351 "split-window",
352 "-t",
353 &session_name,
354 "-c",
355 &pane.worktree,
356 ]));
357
358 let pane_target = format!("{session_name}:0.{i}");
360 let pane_title = format!("{} \u{2192} {}", pane.branch, pane.cli_command);
361 commands.push(TmuxCommand::new(&[
362 "select-pane",
363 "-t",
364 &pane_target,
365 "-T",
366 &pane_title,
367 ]));
368 commands.push(TmuxCommand::new(&[
369 "send-keys",
370 "-t",
371 &pane_target,
372 &pane.cli_command,
373 "Enter",
374 ]));
375 }
376
377 if self.panes.len() > 1 && self.panes[0].branch == "dashboard" {
379 commands.push(TmuxCommand::new(&[
381 "select-layout",
382 "-t",
383 &session_name,
384 "main-horizontal",
385 ]));
386 } else {
387 commands.push(TmuxCommand::new(&[
389 "select-layout",
390 "-t",
391 &session_name,
392 "tiled",
393 ]));
394 }
395
396 Ok(TmuxSession {
397 name: session_name,
398 commands,
399 })
400 }
401}
402
403pub fn ensure_tmux_installed() -> Result<(), PawError> {
408 which::which("tmux").map_err(|_| PawError::TmuxNotInstalled)?;
409 Ok(())
410}
411
412pub fn is_session_alive(name: &str) -> Result<bool, PawError> {
414 let status = Command::new("tmux")
415 .args(["has-session", "-t", name])
416 .stdout(std::process::Stdio::null())
417 .stderr(std::process::Stdio::null())
418 .status()
419 .map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
420
421 Ok(status.success())
422}
423
424pub fn resolve_session_name(project_name: &str) -> Result<String, PawError> {
429 let base = format!("paw-{project_name}");
430
431 if !is_session_alive(&base)? {
432 return Ok(base);
433 }
434
435 for suffix in 2..=MAX_COLLISION_RETRIES + 1 {
436 let candidate = format!("{base}-{suffix}");
437 if !is_session_alive(&candidate)? {
438 return Ok(candidate);
439 }
440 }
441
442 Err(PawError::TmuxError(format!(
443 "too many session name collisions for '{base}'"
444 )))
445}
446
447pub fn attach(name: &str) -> Result<(), PawError> {
452 let status = Command::new("tmux")
453 .args(["attach-session", "-t", name])
454 .status()
455 .map_err(|e| PawError::TmuxError(format!("failed to attach to tmux session: {e}")))?;
456
457 if status.success() {
458 Ok(())
459 } else {
460 Err(PawError::TmuxError(format!(
461 "failed to attach to session '{name}'"
462 )))
463 }
464}
465
466pub fn detach_client(session_name: &str) -> Result<(), PawError> {
474 let output = Command::new("tmux")
475 .args(["detach-client", "-s", session_name])
476 .output()
477 .map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
478
479 if output.status.success() {
480 return Ok(());
481 }
482 let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
483 if stderr.contains("no clients") || stderr.contains("no current client") {
485 return Ok(());
486 }
487 Err(PawError::TmuxError(
488 String::from_utf8_lossy(&output.stderr).trim().to_owned(),
489 ))
490}
491
492pub fn kill_pane(session_name: &str, pane_index: u32) -> Result<(), PawError> {
500 let target = format!("{session_name}:0.{pane_index}");
501 let output = Command::new("tmux")
502 .args(["kill-pane", "-t", &target])
503 .output()
504 .map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
505
506 if output.status.success() {
507 return Ok(());
508 }
509 let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
510 if stderr.contains("can't find pane")
512 || stderr.contains("no such pane")
513 || stderr.contains("pane not found")
514 {
515 return Ok(());
516 }
517 Err(PawError::TmuxError(
518 String::from_utf8_lossy(&output.stderr).trim().to_owned(),
519 ))
520}
521
522pub fn kill_session(name: &str) -> Result<(), PawError> {
524 let output = Command::new("tmux")
525 .args(["kill-session", "-t", name])
526 .output()
527 .map_err(|e| PawError::TmuxError(format!("failed to kill tmux session: {e}")))?;
528
529 if output.status.success() {
530 Ok(())
531 } else {
532 let stderr = String::from_utf8_lossy(&output.stderr);
533 Err(PawError::TmuxError(stderr.trim().to_owned()))
534 }
535}
536
537pub fn build_boot_inject_args(session_name: &str, pane_index: usize, text: &str) -> Vec<String> {
546 vec![
547 "send-keys".to_string(),
548 "-l".to_string(),
549 "-t".to_string(),
550 format!("{session_name}:0.{pane_index}"),
551 text.to_string(),
552 ]
553}
554
555#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
583pub fn build_supervisor_session(
584 project_name: &str,
585 session_name_override: Option<String>,
586 supervisor: &PaneSpec,
587 dashboard: &PaneSpec,
588 agents: &[PaneSpec],
589 layout: crate::supervisor::layout::SupervisorLayout,
590 mouse_mode: bool,
591 env_vars: &[(String, String)],
592) -> Result<TmuxSession, PawError> {
593 use crate::supervisor::layout::{SUPERVISOR_AGENTS_PER_ROW, SUPERVISOR_PANE_OFFSET};
594
595 let session_name = session_name_override.unwrap_or_else(|| format!("paw-{project_name}"));
596 let mut commands: Vec<TmuxCommand> = Vec::new();
597
598 let push = |cmds: &mut Vec<TmuxCommand>, parts: &[&str]| {
599 cmds.push(TmuxCommand::new(parts));
600 };
601
602 push(
607 &mut commands,
608 &[
609 "new-session",
610 "-d",
611 "-s",
612 &session_name,
613 "-x",
614 "200",
615 "-y",
616 "50",
617 "-c",
618 &supervisor.worktree,
619 ],
620 );
621
622 push(
628 &mut commands,
629 &["set-option", "-g", "default-size", "200x50"],
630 );
631
632 if mouse_mode {
634 push(
635 &mut commands,
636 &["set-option", "-t", &session_name, "mouse", "on"],
637 );
638 }
639 push(
640 &mut commands,
641 &[
642 "set-option",
643 "-t",
644 &session_name,
645 "pane-border-status",
646 "top",
647 ],
648 );
649 push(
650 &mut commands,
651 &[
652 "set-option",
653 "-t",
654 &session_name,
655 "pane-border-format",
656 " #{pane_title} ",
657 ],
658 );
659
660 for (key, value) in env_vars {
662 push(
663 &mut commands,
664 &["set-environment", "-t", &session_name, key, value],
665 );
666 }
667
668 let supervisor_target = format!("{session_name}:0.0");
669 let supervisor_title = format!("{} \u{2192} {}", supervisor.branch, supervisor.cli_command);
670 push(
671 &mut commands,
672 &[
673 "select-pane",
674 "-t",
675 &supervisor_target,
676 "-T",
677 &supervisor_title,
678 ],
679 );
680 push(
681 &mut commands,
682 &[
683 "send-keys",
684 "-t",
685 &supervisor_target,
686 &supervisor.cli_command,
687 "Enter",
688 ],
689 );
690
691 let bottom_pct = format!("{}%", 100u16 - u16::from(layout.top_row_pct));
707 if let Some(first_agent) = agents.first() {
708 push(
709 &mut commands,
710 &[
711 "split-window",
712 "-v",
713 "-t",
714 &supervisor_target,
715 "-l",
716 &bottom_pct,
717 "-c",
718 &first_agent.worktree,
719 ],
720 );
721 } else {
722 push(
723 &mut commands,
724 &[
725 "split-window",
726 "-v",
727 "-t",
728 &supervisor_target,
729 "-l",
730 &bottom_pct,
731 ],
732 );
733 }
734
735 push(
739 &mut commands,
740 &[
741 "split-window",
742 "-h",
743 "-t",
744 &supervisor_target,
745 "-l",
746 "50%",
747 "-c",
748 &dashboard.worktree,
749 ],
750 );
751
752 let pane_one = format!("{session_name}:0.1");
754 let pane_two = format!("{session_name}:0.2");
755 push(
756 &mut commands,
757 &["swap-pane", "-s", &pane_one, "-t", &pane_two],
758 );
759
760 let dashboard_target = format!("{session_name}:0.1");
762 let dashboard_title = format!("{} \u{2192} {}", dashboard.branch, dashboard.cli_command);
763 push(
764 &mut commands,
765 &[
766 "select-pane",
767 "-t",
768 &dashboard_target,
769 "-T",
770 &dashboard_title,
771 ],
772 );
773 push(
774 &mut commands,
775 &[
776 "send-keys",
777 "-t",
778 &dashboard_target,
779 &dashboard.cli_command,
780 "Enter",
781 ],
782 );
783
784 if !agents.is_empty() {
786 let first_target = format!("{session_name}:0.{SUPERVISOR_PANE_OFFSET}");
792 let first = &agents[0];
793 let first_title = format!("{} \u{2192} {}", first.branch, first.cli_command);
794 push(
795 &mut commands,
796 &["select-pane", "-t", &first_target, "-T", &first_title],
797 );
798 push(
799 &mut commands,
800 &[
801 "send-keys",
802 "-t",
803 &first_target,
804 &first.cli_command,
805 "Enter",
806 ],
807 );
808
809 let mut row_first_pane = SUPERVISOR_PANE_OFFSET;
810
811 for (i, agent) in agents.iter().enumerate().skip(1) {
812 let pane_idx = SUPERVISOR_PANE_OFFSET + i;
813 let pane_target = format!("{session_name}:0.{pane_idx}");
814 let position_in_row = i % SUPERVISOR_AGENTS_PER_ROW;
815 let starts_new_row = position_in_row == 0;
816
817 if starts_new_row {
818 let src_target = format!("{session_name}:0.{row_first_pane}");
821 push(
822 &mut commands,
823 &[
824 "split-window",
825 "-v",
826 "-t",
827 &src_target,
828 "-c",
829 &agent.worktree,
830 ],
831 );
832 row_first_pane = pane_idx;
833 } else {
834 let prev_idx = pane_idx - 1;
837 let prev_target = format!("{session_name}:0.{prev_idx}");
838 push(
839 &mut commands,
840 &[
841 "split-window",
842 "-h",
843 "-t",
844 &prev_target,
845 "-c",
846 &agent.worktree,
847 ],
848 );
849 }
850
851 let title = format!("{} \u{2192} {}", agent.branch, agent.cli_command);
852 push(
853 &mut commands,
854 &["select-pane", "-t", &pane_target, "-T", &title],
855 );
856 push(
857 &mut commands,
858 &["send-keys", "-t", &pane_target, &agent.cli_command, "Enter"],
859 );
860 }
861 }
862
863 let top_pct_str = format!("{}%", layout.top_row_pct);
867 push(
868 &mut commands,
869 &["resize-pane", "-t", &supervisor_target, "-y", &top_pct_str],
870 );
871 let agent_row_pct_str = format_supervisor_pct(layout.agent_row_pct);
872 for row in 0..layout.agent_rows {
873 let pane_idx = SUPERVISOR_PANE_OFFSET + row * SUPERVISOR_AGENTS_PER_ROW;
874 if pane_idx < SUPERVISOR_PANE_OFFSET + agents.len() {
875 let target = format!("{session_name}:0.{pane_idx}");
876 push(
877 &mut commands,
878 &["resize-pane", "-t", &target, "-y", &agent_row_pct_str],
879 );
880 }
881 }
882
883 Ok(TmuxSession {
884 name: session_name,
885 commands,
886 })
887}
888
889fn format_supervisor_pct(pct: f32) -> String {
892 if (pct - pct.round()).abs() < 0.05 {
893 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
894 let rounded = pct.round().clamp(0.0, 100.0) as u32;
895 format!("{rounded}%")
896 } else {
897 format!("{pct:.1}%")
898 }
899}
900
901#[must_use]
913pub fn build_supervisor_submit_argv_pair(
914 session_name: &str,
915 pane_index: usize,
916 prompt: &str,
917) -> (Vec<String>, Vec<String>) {
918 let target = format!("{session_name}:0.{pane_index}");
919 let first = vec![
920 "send-keys".to_string(),
921 "-t".to_string(),
922 target.clone(),
923 prompt.to_string(),
924 "Enter".to_string(),
925 ];
926 let second = vec![
927 "send-keys".to_string(),
928 "-t".to_string(),
929 target,
930 "Enter".to_string(),
931 ];
932 (first, second)
933}
934
935#[cfg(test)]
936mod tests {
937 use super::*;
938
939 fn make_pane(branch: &str, worktree: &str, cli: &str) -> PaneSpec {
940 PaneSpec {
941 branch: branch.to_owned(),
942 worktree: worktree.to_owned(),
943 cli_command: cli.to_owned(),
944 }
945 }
946
947 fn commands_containing(cmds: &[String], keyword: &str) -> Vec<String> {
949 cmds.iter()
950 .filter(|c| c.contains(keyword))
951 .cloned()
952 .collect()
953 }
954
955 #[test]
961 #[serial_test::serial]
962 fn ensure_tmux_installed_succeeds_when_present() {
963 assert!(ensure_tmux_installed().is_ok());
965 }
966
967 #[test]
974 fn session_is_named_after_project() {
975 let session = TmuxSessionBuilder::new("my-project")
976 .add_pane(make_pane("main", "/tmp/wt", "claude"))
977 .build()
978 .unwrap();
979
980 assert_eq!(session.name, "paw-my-project");
981 }
982
983 #[test]
984 fn session_creation_command_uses_session_name() {
985 let session = TmuxSessionBuilder::new("app")
986 .add_pane(make_pane("main", "/tmp/wt", "claude"))
987 .build()
988 .unwrap();
989
990 let cmds = session.command_strings();
991 assert!(
992 cmds.iter()
993 .any(|c| c.contains("new-session") && c.contains("paw-app")),
994 "should create a tmux session named paw-app"
995 );
996 }
997
998 #[test]
1001 fn new_session_passes_explicit_x_and_y() {
1002 let session = TmuxSessionBuilder::new("app")
1003 .add_pane(make_pane("main", "/tmp/wt", "claude"))
1004 .build()
1005 .unwrap();
1006
1007 let cmds = session.command_strings();
1008 let new_session_cmd = cmds
1009 .iter()
1010 .find(|c| c.contains("new-session"))
1011 .expect("new-session command present");
1012 assert!(
1013 new_session_cmd.contains("-x 200"),
1014 "new-session must pass -x 200; got: {new_session_cmd}"
1015 );
1016 assert!(
1017 new_session_cmd.contains("-y 50"),
1018 "new-session must pass -y 50; got: {new_session_cmd}"
1019 );
1020 }
1021
1022 #[test]
1025 fn basic_builder_sets_default_size_after_new_session() {
1026 let session = TmuxSessionBuilder::new("app")
1027 .add_pane(make_pane("main", "/tmp/wt", "claude"))
1028 .build()
1029 .unwrap();
1030
1031 let cmds = session.command_strings();
1032 let new_session_idx = cmds
1033 .iter()
1034 .position(|c| c.contains("new-session"))
1035 .expect("new-session in command list");
1036 let default_size_idx = cmds
1037 .iter()
1038 .position(|c| {
1039 c.contains("set-option") && c.contains("default-size") && c.contains("200x50")
1040 })
1041 .expect("set-option default-size 200x50 in command list");
1042 assert!(
1043 default_size_idx > new_session_idx,
1044 "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}"
1045 );
1046 }
1047
1048 #[test]
1049 fn session_name_override_replaces_default() {
1050 let session = TmuxSessionBuilder::new("my-project")
1051 .session_name("custom-session-name".to_string())
1052 .add_pane(make_pane("main", "/tmp/wt", "claude"))
1053 .build()
1054 .unwrap();
1055
1056 assert_eq!(session.name, "custom-session-name");
1057 let cmds = session.command_strings();
1058 assert!(
1059 cmds.iter()
1060 .any(|c| c.contains("new-session") && c.contains("custom-session-name")),
1061 "should use overridden session name"
1062 );
1063 }
1064
1065 #[test]
1073 fn pane_count_matches_input_for_two_panes() {
1074 let session = TmuxSessionBuilder::new("proj")
1075 .add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
1076 .add_pane(make_pane("feat/api", "/tmp/wt2", "codex"))
1077 .build()
1078 .unwrap();
1079
1080 let cmds = session.command_strings();
1081 let send_keys = commands_containing(&cmds, "send-keys");
1082 assert_eq!(
1083 send_keys.len(),
1084 2,
1085 "should send commands to exactly 2 panes"
1086 );
1087 }
1088
1089 #[test]
1090 fn pane_count_matches_input_for_five_panes() {
1091 let mut builder = TmuxSessionBuilder::new("proj");
1092 for i in 0..5 {
1093 builder = builder.add_pane(make_pane(
1094 &format!("feat/b{i}"),
1095 &format!("/tmp/wt{i}"),
1096 "claude",
1097 ));
1098 }
1099 let session = builder.build().unwrap();
1100
1101 let cmds = session.command_strings();
1102 let send_keys = commands_containing(&cmds, "send-keys");
1103 assert_eq!(
1104 send_keys.len(),
1105 5,
1106 "should send commands to exactly 5 panes"
1107 );
1108 }
1109
1110 #[test]
1111 fn building_with_no_panes_is_an_error() {
1112 let result = TmuxSessionBuilder::new("proj").build();
1113 assert!(result.is_err(), "session with no panes should fail");
1114 }
1115
1116 #[test]
1124 fn each_pane_receives_bare_cli_command_and_split_carries_worktree() {
1125 let session = TmuxSessionBuilder::new("proj")
1126 .add_pane(make_pane("feat/auth", "/home/user/wt-auth", "claude"))
1127 .add_pane(make_pane("feat/api", "/home/user/wt-api", "gemini"))
1128 .build()
1129 .unwrap();
1130
1131 let cmds = session.command_strings();
1132 let send_keys = commands_containing(&cmds, "send-keys");
1133
1134 assert!(
1137 send_keys[0].contains("claude"),
1138 "first pane should run claude; got: {}",
1139 send_keys[0]
1140 );
1141
1142 assert!(
1146 send_keys[1].contains("gemini"),
1147 "second pane should run gemini; got: {}",
1148 send_keys[1]
1149 );
1150 assert!(
1151 !send_keys[1].contains("cd /home/user/wt-api"),
1152 "second pane send-keys MUST NOT prefix `cd <worktree>`; got: {}",
1153 send_keys[1]
1154 );
1155
1156 let splits = commands_containing(&cmds, "split-window");
1159 assert!(
1160 splits.iter().any(|c| c.contains("-c /home/user/wt-api")),
1161 "split-window for pane 1 should pass -c /home/user/wt-api; got: {splits:?}"
1162 );
1163 }
1164
1165 #[test]
1166 fn pane_commands_are_submitted_with_enter() {
1167 let session = TmuxSessionBuilder::new("proj")
1168 .add_pane(make_pane("main", "/tmp/wt", "aider"))
1169 .build()
1170 .unwrap();
1171
1172 let cmds = session.command_strings();
1173 let send_keys = commands_containing(&cmds, "send-keys");
1174 assert!(
1175 send_keys[0].contains("Enter"),
1176 "send-keys should press Enter to submit"
1177 );
1178 }
1179
1180 #[test]
1181 fn each_pane_targets_a_distinct_pane_index() {
1182 let session = TmuxSessionBuilder::new("proj")
1183 .add_pane(make_pane("feat/a", "/tmp/a", "claude"))
1184 .add_pane(make_pane("feat/b", "/tmp/b", "codex"))
1185 .add_pane(make_pane("feat/c", "/tmp/c", "gemini"))
1186 .build()
1187 .unwrap();
1188
1189 let cmds = session.command_strings();
1190 let send_keys = commands_containing(&cmds, "send-keys");
1191
1192 assert!(
1193 send_keys[0].contains(":0.0"),
1194 "first pane should target :0.0"
1195 );
1196 assert!(
1197 send_keys[1].contains(":0.1"),
1198 "second pane should target :0.1"
1199 );
1200 assert!(
1201 send_keys[2].contains(":0.2"),
1202 "third pane should target :0.2"
1203 );
1204 }
1205
1206 #[test]
1214 fn each_pane_is_titled_with_branch_and_cli() {
1215 let session = TmuxSessionBuilder::new("proj")
1216 .add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
1217 .add_pane(make_pane("fix/api", "/tmp/wt2", "gemini"))
1218 .build()
1219 .unwrap();
1220
1221 let cmds = session.command_strings();
1222 let select_panes = commands_containing(&cmds, "select-pane");
1223
1224 assert_eq!(select_panes.len(), 2, "each pane should get a title");
1225 assert!(
1226 select_panes[0].contains("feat/auth \u{2192} claude"),
1227 "first pane title should be 'feat/auth \u{2192} claude', got: {}",
1228 select_panes[0]
1229 );
1230 assert!(
1231 select_panes[1].contains("fix/api \u{2192} gemini"),
1232 "second pane title should be 'fix/api \u{2192} gemini', got: {}",
1233 select_panes[1]
1234 );
1235 }
1236
1237 #[test]
1238 fn pane_border_status_is_configured() {
1239 let session = TmuxSessionBuilder::new("proj")
1240 .add_pane(make_pane("main", "/tmp/wt", "claude"))
1241 .build()
1242 .unwrap();
1243
1244 let cmds = session.command_strings();
1245 assert!(
1246 cmds.iter()
1247 .any(|c| c.contains("pane-border-status") && c.contains("top")),
1248 "should configure pane-border-status to top"
1249 );
1250 assert!(
1251 cmds.iter()
1252 .any(|c| c.contains("pane-border-format") && c.contains("#{pane_title}")),
1253 "should configure pane-border-format to show pane title"
1254 );
1255 }
1256
1257 #[test]
1264 fn mouse_mode_enabled_by_default() {
1265 let session = TmuxSessionBuilder::new("proj")
1266 .add_pane(make_pane("main", "/tmp/wt", "claude"))
1267 .build()
1268 .unwrap();
1269
1270 let cmds = session.command_strings();
1271 assert!(
1272 cmds.iter().any(|c| c.contains("mouse on")),
1273 "mouse should be enabled by default"
1274 );
1275 }
1276
1277 #[test]
1278 fn mouse_mode_can_be_disabled() {
1279 let session = TmuxSessionBuilder::new("proj")
1280 .add_pane(make_pane("main", "/tmp/wt", "claude"))
1281 .mouse_mode(false)
1282 .build()
1283 .unwrap();
1284
1285 let cmds = session.command_strings();
1286 assert!(
1287 !cmds.iter().any(|c| c.contains("mouse on")),
1288 "no mouse-on command should be emitted when disabled"
1289 );
1290 }
1291
1292 fn create_test_session(name: &str) {
1300 let output = std::process::Command::new("tmux")
1301 .args(["new-session", "-d", "-s", name, "-x", "200", "-y", "50"])
1302 .output()
1303 .expect("create tmux session");
1304 assert!(
1305 output.status.success(),
1306 "failed to create test session '{name}'"
1307 );
1308 }
1309
1310 fn cleanup_session(name: &str) {
1312 let _ = kill_session(name);
1313 }
1314
1315 #[test]
1316 #[serial_test::serial]
1317 fn is_session_alive_returns_false_for_nonexistent() {
1318 let alive = is_session_alive("paw-definitely-does-not-exist-12345").unwrap();
1319 assert!(!alive);
1320 }
1321
1322 #[test]
1323 #[serial_test::serial]
1324 fn session_lifecycle_create_check_kill() {
1325 let name = "paw-unit-test-lifecycle";
1326 cleanup_session(name);
1327
1328 create_test_session(name);
1329 assert!(is_session_alive(name).unwrap());
1330
1331 kill_session(name).unwrap();
1332 assert!(!is_session_alive(name).unwrap());
1333 }
1334
1335 #[test]
1336 #[serial_test::serial]
1337 fn resolve_session_name_returns_base_when_no_collision() {
1338 let name = resolve_session_name("unit-test-no-collision-xyz").unwrap();
1339 assert_eq!(name, "paw-unit-test-no-collision-xyz");
1340 }
1341
1342 #[test]
1343 #[serial_test::serial]
1344 fn resolve_session_name_appends_suffix_on_collision() {
1345 let base_name = "paw-unit-test-collision";
1346 cleanup_session(base_name);
1347 cleanup_session(&format!("{base_name}-2"));
1348
1349 create_test_session(base_name);
1350
1351 let resolved = resolve_session_name("unit-test-collision").unwrap();
1352 assert_eq!(resolved, format!("{base_name}-2"));
1353
1354 cleanup_session(base_name);
1355 }
1356
1357 #[test]
1363 fn pipe_pane_queues_correct_command() {
1364 let mut session = TmuxSessionBuilder::new("proj")
1365 .add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
1366 .build()
1367 .unwrap();
1368
1369 let log_path = std::path::PathBuf::from("/repo/.git-paw/logs/paw-proj/feat--auth.log");
1370 session.pipe_pane("paw-proj:0.0", &log_path);
1371
1372 let cmds = session.command_strings();
1373 let pipe_cmds: Vec<&String> = cmds.iter().filter(|c| c.contains("pipe-pane")).collect();
1374 assert_eq!(pipe_cmds.len(), 1);
1375 assert!(pipe_cmds[0].contains("pipe-pane -o -t paw-proj:0.0"));
1376 assert!(pipe_cmds[0].contains("cat >> /repo/.git-paw/logs/paw-proj/feat--auth.log"));
1377 }
1378
1379 #[test]
1382 fn session_without_pipe_pane_has_no_pipe_pane_commands() {
1383 let session = TmuxSessionBuilder::new("proj")
1384 .add_pane(make_pane("main", "/tmp/wt", "claude"))
1385 .build()
1386 .unwrap();
1387
1388 let cmds = session.command_strings();
1389 assert!(
1390 !cmds.iter().any(|c| c.contains("pipe-pane")),
1391 "session built without pipe_pane calls should have no pipe-pane commands"
1392 );
1393 }
1394
1395 #[test]
1396 fn session_with_pipe_pane_differs_from_without() {
1397 let session_without = TmuxSessionBuilder::new("proj")
1398 .add_pane(make_pane("main", "/tmp/wt", "claude"))
1399 .build()
1400 .unwrap();
1401 let cmds_without = session_without.command_strings();
1402
1403 let mut session_with = TmuxSessionBuilder::new("proj")
1404 .add_pane(make_pane("main", "/tmp/wt", "claude"))
1405 .build()
1406 .unwrap();
1407 let log_path = std::path::PathBuf::from("/repo/.git-paw/logs/paw-proj/main.log");
1408 session_with.pipe_pane("paw-proj:0.0", &log_path);
1409 let cmds_with = session_with.command_strings();
1410
1411 assert_ne!(
1412 cmds_without, cmds_with,
1413 "command lists should differ when pipe-pane is added"
1414 );
1415 assert!(
1416 cmds_with.iter().any(|c| c.contains("pipe-pane")),
1417 "session with pipe_pane should contain pipe-pane command"
1418 );
1419 }
1420
1421 #[test]
1424 fn pipe_pane_appears_after_send_keys_for_pane() {
1425 let mut session = TmuxSessionBuilder::new("proj")
1426 .add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
1427 .add_pane(make_pane("feat/api", "/tmp/wt2", "codex"))
1428 .build()
1429 .unwrap();
1430
1431 let log0 = std::path::PathBuf::from("/repo/logs/feat--auth.log");
1432 let log1 = std::path::PathBuf::from("/repo/logs/feat--api.log");
1433 session.pipe_pane("paw-proj:0.0", &log0);
1434 session.pipe_pane("paw-proj:0.1", &log1);
1435
1436 let cmds = session.command_strings();
1437
1438 let last_send_keys = cmds
1440 .iter()
1441 .rposition(|c| c.contains("send-keys"))
1442 .expect("should have send-keys");
1443 let first_pipe_pane = cmds
1444 .iter()
1445 .position(|c| c.contains("pipe-pane"))
1446 .expect("should have pipe-pane");
1447
1448 assert!(
1449 first_pipe_pane > last_send_keys,
1450 "pipe-pane commands (index {first_pipe_pane}) should appear after \
1451 all send-keys commands (last at index {last_send_keys})"
1452 );
1453 }
1454
1455 #[test]
1456 fn pipe_pane_appears_in_dry_run_output() {
1457 let mut session = TmuxSessionBuilder::new("proj")
1458 .add_pane(make_pane("main", "/tmp/wt", "claude"))
1459 .build()
1460 .unwrap();
1461
1462 let log_path = std::path::PathBuf::from("/repo/.git-paw/logs/paw-proj/main.log");
1463 session.pipe_pane("paw-proj:0.0", &log_path);
1464
1465 let cmds = session.command_strings();
1466 assert!(
1467 cmds.iter().any(|c| c.starts_with("tmux pipe-pane")),
1468 "dry-run output should include pipe-pane command"
1469 );
1470 }
1471
1472 #[test]
1477 fn set_environment_emits_correct_tmux_command() {
1478 let session = TmuxSessionBuilder::new("proj")
1479 .add_pane(make_pane("main", "/tmp/wt", "claude"))
1480 .set_environment("GIT_PAW_BROKER_URL", "http://127.0.0.1:9119")
1481 .build()
1482 .unwrap();
1483
1484 let cmds = session.command_strings();
1485 let env_cmds = commands_containing(&cmds, "set-environment");
1486 assert_eq!(env_cmds.len(), 1, "should have exactly one set-environment");
1487 assert!(
1488 env_cmds[0]
1489 .contains("set-environment -t paw-proj GIT_PAW_BROKER_URL http://127.0.0.1:9119"),
1490 "set-environment command should contain key and value, got: {}",
1491 env_cmds[0]
1492 );
1493 }
1494
1495 #[test]
1496 fn set_environment_appears_before_send_keys() {
1497 let session = TmuxSessionBuilder::new("proj")
1498 .add_pane(make_pane("feat/a", "/tmp/a", "claude"))
1499 .add_pane(make_pane("feat/b", "/tmp/b", "codex"))
1500 .set_environment("GIT_PAW_BROKER_URL", "http://127.0.0.1:9119")
1501 .build()
1502 .unwrap();
1503
1504 let cmds = session.command_strings();
1505 let first_env = cmds
1506 .iter()
1507 .position(|c| c.contains("set-environment"))
1508 .expect("should have set-environment");
1509 let first_send = cmds
1510 .iter()
1511 .position(|c| c.contains("send-keys"))
1512 .expect("should have send-keys");
1513
1514 assert!(
1515 first_env < first_send,
1516 "set-environment (index {first_env}) should appear before first send-keys (index {first_send})"
1517 );
1518 }
1519
1520 #[test]
1521 fn multiple_env_vars_both_appear() {
1522 let session = TmuxSessionBuilder::new("proj")
1523 .add_pane(make_pane("main", "/tmp/wt", "claude"))
1524 .set_environment("A", "1")
1525 .set_environment("B", "2")
1526 .build()
1527 .unwrap();
1528
1529 let cmds = session.command_strings();
1530 let env_cmds = commands_containing(&cmds, "set-environment");
1531 assert_eq!(
1532 env_cmds.len(),
1533 2,
1534 "should have two set-environment commands"
1535 );
1536 assert!(env_cmds[0].contains("A 1"));
1537 assert!(env_cmds[1].contains("B 2"));
1538 }
1539
1540 #[test]
1541 fn set_environment_in_dry_run_output() {
1542 let session = TmuxSessionBuilder::new("proj")
1543 .add_pane(make_pane("main", "/tmp/wt", "claude"))
1544 .set_environment("MY_VAR", "my_val")
1545 .build()
1546 .unwrap();
1547
1548 let cmds = session.command_strings();
1549 assert!(
1550 cmds.iter().any(|c| c.starts_with("tmux set-environment")),
1551 "dry-run output should include set-environment command"
1552 );
1553 }
1554
1555 #[test]
1561 fn session_without_dashboard_uses_tiled_layout() {
1562 let session = TmuxSessionBuilder::new("proj")
1563 .add_pane(make_pane("feat/a", "/tmp/a", "claude"))
1564 .add_pane(make_pane("feat/b", "/tmp/b", "codex"))
1565 .build()
1566 .unwrap();
1567
1568 let cmds = session.command_strings();
1569 let layout_cmds: Vec<&String> = cmds
1570 .iter()
1571 .filter(|c| c.contains("select-layout"))
1572 .collect();
1573 let final_layout = layout_cmds
1574 .last()
1575 .expect("should have at least one select-layout");
1576 assert!(
1577 final_layout.contains("tiled"),
1578 "sessions without dashboard should use tiled layout, got: {final_layout}"
1579 );
1580 }
1581
1582 #[test]
1583 fn session_with_dashboard_uses_main_horizontal_layout() {
1584 let session = TmuxSessionBuilder::new("proj")
1585 .add_pane(make_pane("dashboard", "/tmp/repo", "git-paw __dashboard"))
1586 .add_pane(make_pane("feat/a", "/tmp/a", "claude"))
1587 .add_pane(make_pane("feat/b", "/tmp/b", "codex"))
1588 .build()
1589 .unwrap();
1590
1591 let cmds = session.command_strings();
1592 let layout_cmds: Vec<&String> = cmds
1593 .iter()
1594 .filter(|c| c.contains("select-layout"))
1595 .collect();
1596 let final_layout = layout_cmds
1597 .last()
1598 .expect("should have at least one select-layout");
1599 assert!(
1600 final_layout.contains("main-horizontal"),
1601 "sessions with dashboard should use main-horizontal layout, got: {final_layout}"
1602 );
1603 }
1604
1605 #[test]
1606 fn single_pane_session_uses_tiled_layout() {
1607 let session = TmuxSessionBuilder::new("proj")
1608 .add_pane(make_pane("main", "/tmp/wt", "claude"))
1609 .build()
1610 .unwrap();
1611
1612 let cmds = session.command_strings();
1613 let layout_cmds: Vec<&String> = cmds
1614 .iter()
1615 .filter(|c| c.contains("select-layout"))
1616 .collect();
1617 let final_layout = layout_cmds
1618 .last()
1619 .expect("should have at least one select-layout");
1620 assert!(
1621 final_layout.contains("tiled"),
1622 "single pane sessions should use tiled layout, got: {final_layout}"
1623 );
1624 }
1625
1626 #[test]
1627 fn dashboard_layout_appears_in_dry_run_output() {
1628 let session = TmuxSessionBuilder::new("proj")
1629 .add_pane(make_pane("dashboard", "/tmp/repo", "git-paw __dashboard"))
1630 .add_pane(make_pane("feat/a", "/tmp/a", "claude"))
1631 .build()
1632 .unwrap();
1633
1634 let cmds = session.command_strings();
1635 assert!(
1636 cmds.iter().any(|c| c.contains("main-horizontal")),
1637 "dry-run output should include main-horizontal layout command"
1638 );
1639 }
1640
1641 struct PausePaneSession {
1648 name: String,
1649 }
1650
1651 impl PausePaneSession {
1652 fn new(label: &str) -> Self {
1653 let pid = std::process::id();
1654 let nanos = std::time::SystemTime::now()
1655 .duration_since(std::time::UNIX_EPOCH)
1656 .map_or(0, |d| d.as_nanos());
1657 let name = format!("paw-pause-test-{label}-{pid}-{nanos}");
1658 let output = std::process::Command::new("tmux")
1659 .args(["new-session", "-d", "-s", &name, "-x", "200", "-y", "50"])
1660 .output()
1661 .expect("create tmux test session");
1662 assert!(
1663 output.status.success(),
1664 "failed to create test session '{name}'"
1665 );
1666 Self { name }
1667 }
1668 }
1669
1670 impl Drop for PausePaneSession {
1671 fn drop(&mut self) {
1672 let _ = kill_session(&self.name);
1673 }
1674 }
1675
1676 #[test]
1677 #[serial_test::serial]
1678 fn detach_client_succeeds_on_attached_session() {
1679 let session = PausePaneSession::new("detach-attached");
1683 detach_client(&session.name).expect("detach should succeed");
1684 assert!(is_session_alive(&session.name).unwrap());
1685 }
1686
1687 #[test]
1688 #[serial_test::serial]
1689 fn detach_client_is_noop_with_no_clients() {
1690 let session = PausePaneSession::new("detach-noop");
1691 detach_client(&session.name).expect("first detach should succeed");
1693 detach_client(&session.name).expect("second detach should succeed");
1695 assert!(is_session_alive(&session.name).unwrap());
1696 }
1697
1698 #[test]
1702 #[serial_test::serial]
1703 fn detach_client_noop_when_no_clients_attached() {
1704 let session = PausePaneSession::new("detach-9-11");
1705 detach_client(&session.name).expect("detach with no clients should be Ok");
1706 assert!(is_session_alive(&session.name).unwrap());
1707 }
1708
1709 #[test]
1710 #[serial_test::serial]
1711 fn kill_pane_removes_pane() {
1712 let session = PausePaneSession::new("killpane");
1713 let _ = std::process::Command::new("tmux")
1715 .args(["split-window", "-t", &session.name])
1716 .output();
1717 let pane_count_before = std::process::Command::new("tmux")
1718 .args(["list-panes", "-t", &session.name, "-F", "#{pane_index}"])
1719 .output()
1720 .map_or(0, |o| String::from_utf8_lossy(&o.stdout).lines().count());
1721 assert_eq!(pane_count_before, 2, "should have 2 panes before kill");
1722
1723 kill_pane(&session.name, 1).expect("kill_pane should succeed");
1724
1725 let pane_count_after = std::process::Command::new("tmux")
1726 .args(["list-panes", "-t", &session.name, "-F", "#{pane_index}"])
1727 .output()
1728 .map_or(0, |o| String::from_utf8_lossy(&o.stdout).lines().count());
1729 assert_eq!(pane_count_after, 1, "should have 1 pane after kill");
1730 }
1731
1732 #[test]
1733 #[serial_test::serial]
1734 fn kill_pane_is_noop_for_missing_pane() {
1735 let session = PausePaneSession::new("killpane-missing");
1736 kill_pane(&session.name, 99).expect("kill missing pane should be ok");
1738 assert!(is_session_alive(&session.name).unwrap());
1739 }
1740
1741 #[test]
1742 #[serial_test::serial]
1743 fn built_session_can_be_executed_and_killed() {
1744 let project = "unit-test-execute";
1745 let session_name = format!("paw-{project}");
1746 cleanup_session(&session_name);
1747
1748 let session = TmuxSessionBuilder::new(project)
1749 .add_pane(make_pane("main", "/tmp", "echo hello"))
1750 .build()
1751 .unwrap();
1752
1753 session.execute().unwrap();
1754 assert!(is_session_alive(&session_name).unwrap());
1755
1756 kill_session(&session_name).unwrap();
1757 assert!(!is_session_alive(&session_name).unwrap());
1758 }
1759
1760 #[test]
1767 fn supervisor_submit_argv_pair_has_two_invocations() {
1768 let (first, second) = build_supervisor_submit_argv_pair("paw-proj", 3, "do the thing");
1769 assert!(!first.is_empty(), "first send-keys argv must be non-empty");
1771 assert!(
1772 !second.is_empty(),
1773 "second send-keys argv must be non-empty"
1774 );
1775 }
1776
1777 #[test]
1778 fn supervisor_submit_first_invocation_sends_prompt_and_enter() {
1779 let (first, _second) = build_supervisor_submit_argv_pair("paw-proj", 3, "do the thing");
1780 assert_eq!(first[0], "send-keys");
1781 assert_eq!(first[1], "-t");
1782 assert_eq!(first[2], "paw-proj:0.3");
1783 assert_eq!(first[3], "do the thing");
1784 assert_eq!(first[4], "Enter");
1785 }
1786
1787 #[test]
1788 fn supervisor_submit_second_invocation_is_enter_only() {
1789 let (_first, second) = build_supervisor_submit_argv_pair("paw-proj", 3, "do the thing");
1790 assert_eq!(second[0], "send-keys");
1791 assert_eq!(second[1], "-t");
1792 assert_eq!(second[2], "paw-proj:0.3");
1793 assert_eq!(second[3], "Enter");
1794 assert_eq!(
1795 second.len(),
1796 4,
1797 "second invocation should be send-keys -t <target> Enter (no prompt)"
1798 );
1799 }
1800
1801 #[test]
1802 fn supervisor_submit_targets_same_pane_in_both_invocations() {
1803 let (first, second) = build_supervisor_submit_argv_pair("paw-proj", 7, "prompt");
1804 assert_eq!(first[2], second[2]);
1807 assert_eq!(first[2], "paw-proj:0.7");
1808 }
1809
1810 #[test]
1811 fn supervisor_submit_argv_pair_preserves_prompt_with_newlines_and_quotes() {
1812 let prompt = "line1\nline2 with \"quoted\" text";
1813 let (first, _second) = build_supervisor_submit_argv_pair("paw-proj", 1, prompt);
1814 assert_eq!(first[3], prompt);
1817 }
1818
1819 #[test]
1827 fn cmd_supervisor_inject_argv_has_single_enter_per_pane() {
1828 let panes: Vec<(usize, &str)> = vec![(2, "p2"), (3, "p3"), (4, "p4")];
1829
1830 let mut total_enters = 0;
1831 for (pane_idx, prompt) in &panes {
1832 let (first, _second) = build_supervisor_submit_argv_pair("paw-proj", *pane_idx, prompt);
1833 let enter_positions: Vec<usize> = first
1834 .iter()
1835 .enumerate()
1836 .filter(|(_, tok)| tok.as_str() == "Enter")
1837 .map(|(i, _)| i)
1838 .collect();
1839 assert_eq!(
1840 enter_positions.len(),
1841 1,
1842 "each per-pane invocation must send exactly one Enter; got argv: {first:?}"
1843 );
1844 let enter_pos = enter_positions[0];
1845 assert!(
1846 enter_pos > 0,
1847 "Enter token must follow a prompt-string argument; got argv: {first:?}"
1848 );
1849 assert_eq!(
1850 first[enter_pos - 1].as_str(),
1851 *prompt,
1852 "Enter token must directly follow the prompt argument; got argv: {first:?}"
1853 );
1854 total_enters += enter_positions.len();
1855 }
1856 assert_eq!(
1857 total_enters, 3,
1858 "for N=3 panes the launch flow must send exactly N=3 Enters"
1859 );
1860 }
1861
1862 fn make_layout_panes(n: usize) -> (PaneSpec, PaneSpec, Vec<PaneSpec>) {
1872 let supervisor = make_pane("supervisor", "/repo", "claude");
1873 let dashboard = make_pane("dashboard", "/repo", "git-paw __dashboard");
1874 let agents = (0..n)
1875 .map(|i| make_pane(&format!("feat/b{i}"), &format!("/tmp/wt{i}"), "claude"))
1876 .collect();
1877 (supervisor, dashboard, agents)
1878 }
1879
1880 fn build_for(agent_count: usize) -> TmuxSession {
1881 let layout =
1882 crate::supervisor::layout::supervisor_layout(agent_count).expect("layout computes");
1883 let (supervisor, dashboard, agents) = make_layout_panes(agent_count);
1884 build_supervisor_session(
1885 "proj",
1886 None,
1887 &supervisor,
1888 &dashboard,
1889 &agents,
1890 layout,
1891 true,
1892 &[("GIT_PAW_BROKER_URL".to_string(), "http://x".to_string())],
1893 )
1894 .expect("session builds")
1895 }
1896
1897 #[test]
1899 fn supervisor_layout_5_agents_single_row() {
1900 let session = build_for(5);
1901 let cmds = session.command_strings();
1902 let send_keys = commands_containing(&cmds, "send-keys");
1903 assert_eq!(
1904 send_keys.len(),
1905 7,
1906 "5 agents → 1 supervisor + 1 dashboard + 5 agents = 7 send-keys, got {send_keys:#?}"
1907 );
1908 let supervisor_pane = send_keys
1909 .iter()
1910 .find(|c| c.contains("0.0 "))
1911 .unwrap_or(&send_keys[0]);
1912 assert!(supervisor_pane.contains("claude"));
1913 let dashboard_pane = send_keys
1914 .iter()
1915 .find(|c| c.contains(":0.1 ") && c.contains("__dashboard"))
1916 .expect("dashboard send-keys at pane :0.1");
1917 let _ = dashboard_pane;
1918 let resizes = commands_containing(&cmds, "resize-pane");
1920 assert!(
1921 resizes
1922 .iter()
1923 .any(|c| c.contains(":0.0") && c.contains("60%")),
1924 "top row resize to 60%, got resizes {resizes:#?}"
1925 );
1926 assert!(
1928 resizes
1929 .iter()
1930 .any(|c| c.contains(":0.2") && c.contains("40%")),
1931 "agent-row resize to 40% at :0.2, got resizes {resizes:#?}"
1932 );
1933 }
1934
1935 #[test]
1937 fn supervisor_layout_10_agents_two_rows() {
1938 let session = build_for(10);
1939 let cmds = session.command_strings();
1940 let send_keys = commands_containing(&cmds, "send-keys");
1941 assert_eq!(
1942 send_keys.len(),
1943 12,
1944 "10 agents → 1 supervisor + 1 dashboard + 10 agents = 12 send-keys"
1945 );
1946 let resizes = commands_containing(&cmds, "resize-pane");
1947 assert!(
1948 resizes
1949 .iter()
1950 .any(|c| c.contains(":0.0") && c.contains("40%"))
1951 );
1952 assert!(
1953 resizes.iter().filter(|c| c.contains("30%")).count() >= 2,
1954 "two agent rows at 30% each, got {resizes:#?}"
1955 );
1956 }
1957
1958 #[test]
1960 fn supervisor_layout_11_agents_three_rows() {
1961 let session = build_for(11);
1962 let cmds = session.command_strings();
1963 let resizes = commands_containing(&cmds, "resize-pane");
1964 assert!(
1965 resizes
1966 .iter()
1967 .any(|c| c.contains(":0.0") && c.contains("28%"))
1968 );
1969 assert!(
1970 resizes.iter().filter(|c| c.contains("24%")).count() >= 3,
1971 "three agent rows at 24% each, got {resizes:#?}"
1972 );
1973 let send_keys = commands_containing(&cmds, "send-keys");
1975 assert_eq!(send_keys.len(), 13);
1976 assert!(send_keys.iter().any(|c| c.contains(":0.12 ")));
1977 }
1978
1979 #[test]
1981 fn supervisor_layout_20_agents_four_rows() {
1982 let session = build_for(20);
1983 let cmds = session.command_strings();
1984 let resizes = commands_containing(&cmds, "resize-pane");
1985 assert!(
1986 resizes
1987 .iter()
1988 .any(|c| c.contains(":0.0") && c.contains("28%"))
1989 );
1990 assert!(
1991 resizes.iter().filter(|c| c.contains("18%")).count() >= 4,
1992 "four agent rows at 18% each, got {resizes:#?}"
1993 );
1994 }
1995
1996 #[test]
1998 fn supervisor_layout_25_agents_five_rows() {
1999 let session = build_for(25);
2000 let cmds = session.command_strings();
2001 let resizes = commands_containing(&cmds, "resize-pane");
2002 assert!(
2003 resizes
2004 .iter()
2005 .any(|c| c.contains(":0.0") && c.contains("28%"))
2006 );
2007 assert!(
2008 resizes.iter().filter(|c| c.contains("14.4%")).count() >= 5,
2009 "five agent rows at 14.4% each, got {resizes:#?}"
2010 );
2011 }
2012
2013 #[test]
2015 fn supervisor_layout_26_agents_rejected_by_layout_helper() {
2016 let err = crate::supervisor::layout::supervisor_layout(26).expect_err("26 agents rejected");
2019 let msg = err.to_string();
2020 assert!(msg.contains("26 agents requested"));
2021 assert!(msg.contains("maximum is 25"));
2022 }
2023
2024 #[test]
2028 fn supervisor_layout_7_agents_row_major_indices() {
2029 let session = build_for(7);
2030 let cmds = session.command_strings();
2031 let send_keys = commands_containing(&cmds, "send-keys");
2032 assert!(
2035 send_keys
2036 .iter()
2037 .any(|c| c.contains(":0.2 ") && c.contains("claude")),
2038 "pane :0.2 is the first agent (top-left); send-keys {send_keys:#?}"
2039 );
2040 assert!(
2041 send_keys
2042 .iter()
2043 .any(|c| c.contains(":0.6 ") && c.contains("claude")),
2044 "pane :0.6 is the fifth agent (top-right of row 1)"
2045 );
2046 assert!(
2047 send_keys
2048 .iter()
2049 .any(|c| c.contains(":0.7 ") && c.contains("claude")),
2050 "pane :0.7 is the sixth agent (start of row 2)"
2051 );
2052 }
2053
2054 #[test]
2057 fn supervisor_top_row_split_50_50() {
2058 let session = build_for(3);
2059 let cmds = session.command_strings();
2060 let h_split = cmds
2061 .iter()
2062 .find(|c| c.contains("split-window") && c.contains("-h") && c.contains("-l 50%"))
2063 .unwrap_or_else(|| panic!("expected horizontal 50% split; got cmds: {cmds:#?}"));
2064 assert!(
2065 h_split.contains(":0.0") || h_split.contains("split-window -h -t paw-proj"),
2066 "horizontal split should target the supervisor pane; got: {h_split}"
2067 );
2068 }
2069
2070 #[test]
2076 fn supervisor_splits_use_l_percent_not_p() {
2077 let session = build_for(4);
2078 let cmds = session.command_strings();
2079 for cmd in &cmds {
2080 if cmd.contains("split-window") {
2081 assert!(
2082 !cmd.contains(" -p "),
2083 "split-window must not use deprecated -p flag (fails on Linux tmux 3.4 headless); got: {cmd}"
2084 );
2085 }
2086 }
2087 }
2088
2089 #[test]
2092 fn supervisor_new_session_passes_explicit_x_and_y() {
2093 let session = build_for(2);
2094 let cmds = session.command_strings();
2095 let new_session_cmd = cmds
2096 .iter()
2097 .find(|c| c.contains("new-session"))
2098 .expect("supervisor build emits a new-session command");
2099 assert!(
2100 new_session_cmd.contains("-x 200"),
2101 "supervisor new-session must pass -x 200; got: {new_session_cmd}"
2102 );
2103 assert!(
2104 new_session_cmd.contains("-y 50"),
2105 "supervisor new-session must pass -y 50; got: {new_session_cmd}"
2106 );
2107 }
2108
2109 #[test]
2111 fn supervisor_sets_default_size_after_new_session() {
2112 let session = build_for(2);
2113 let cmds = session.command_strings();
2114 let new_session_idx = cmds
2115 .iter()
2116 .position(|c| c.contains("new-session"))
2117 .expect("new-session in command list");
2118 let default_size_idx = cmds
2119 .iter()
2120 .position(|c| {
2121 c.contains("set-option") && c.contains("default-size") && c.contains("200x50")
2122 })
2123 .expect("set-option default-size 200x50 in command list");
2124 assert!(
2125 default_size_idx > new_session_idx,
2126 "set-option default-size must come AFTER new-session; got order new={new_session_idx}, default-size={default_size_idx}"
2127 );
2128 }
2129
2130 #[test]
2137 fn bare_start_with_broker_places_dashboard_at_pane_0() {
2138 let session = TmuxSessionBuilder::new("proj")
2140 .add_pane(make_pane("dashboard", "/repo", "git-paw __dashboard"))
2141 .add_pane(make_pane("feat/a", "/tmp/wt-a", "claude"))
2142 .add_pane(make_pane("feat/b", "/tmp/wt-b", "claude"))
2143 .add_pane(make_pane("feat/c", "/tmp/wt-c", "claude"))
2144 .build()
2145 .expect("session builds");
2146
2147 let cmds = session.command_strings();
2148 let dashboard_send = cmds
2149 .iter()
2150 .find(|c| c.contains("send-keys") && c.contains("__dashboard"))
2151 .expect("dashboard send-keys present");
2152 assert!(
2153 dashboard_send.contains(":0.0 "),
2154 "dashboard pane must be index 0; got: {dashboard_send}"
2155 );
2156 for (pane_idx, branch_marker, worktree) in [
2161 (1, "feat/a", "/tmp/wt-a"),
2162 (2, "feat/b", "/tmp/wt-b"),
2163 (3, "feat/c", "/tmp/wt-c"),
2164 ] {
2165 let select_target = format!(":0.{pane_idx} ");
2166 assert!(
2167 cmds.iter()
2168 .any(|c| c.contains(&select_target) && c.contains(branch_marker)),
2169 "agent {branch_marker} should land at pane {pane_idx}; cmds:\n{cmds:#?}"
2170 );
2171 let split_marker = format!("-c {worktree}");
2172 assert!(
2173 cmds.iter()
2174 .any(|c| c.contains("split-window") && c.contains(&split_marker)),
2175 "agent {branch_marker} split should carry {split_marker}; cmds:\n{cmds:#?}"
2176 );
2177 }
2178 }
2179
2180 #[test]
2183 fn broker_disabled_produces_no_dashboard_pane() {
2184 let session = TmuxSessionBuilder::new("proj")
2185 .add_pane(make_pane("feat/a", "/tmp/wt-a", "claude"))
2186 .add_pane(make_pane("feat/b", "/tmp/wt-b", "claude"))
2187 .add_pane(make_pane("feat/c", "/tmp/wt-c", "claude"))
2188 .build()
2189 .expect("session builds");
2190
2191 let cmds = session.command_strings();
2192 assert!(
2193 !cmds.iter().any(|c| c.contains("__dashboard")),
2194 "broker disabled must not add a dashboard pane; got cmds:\n{cmds:#?}"
2195 );
2196 let send_keys: Vec<&String> = cmds.iter().filter(|c| c.contains("send-keys")).collect();
2198 assert_eq!(
2199 send_keys.len(),
2200 3,
2201 "broker-disabled launch with 3 agents must emit 3 send-keys; got: {send_keys:#?}"
2202 );
2203 }
2204
2205 #[test]
2208 fn dashboard_pane_has_title_dashboard() {
2209 let session = build_for(2);
2211 let cmds = session.command_strings();
2212 let dashboard_select = cmds
2213 .iter()
2214 .find(|c| {
2215 c.contains("select-pane")
2216 && c.contains(":0.1")
2217 && c.contains("-T")
2218 && c.contains("dashboard")
2219 })
2220 .unwrap_or_else(|| {
2221 panic!("expected select-pane -T dashboard at :0.1; cmds:\n{cmds:#?}")
2222 });
2223 assert!(
2226 dashboard_select.contains("dashboard"),
2227 "dashboard pane title must include `dashboard`; got: {dashboard_select}"
2228 );
2229 }
2230
2231 #[test]
2234 fn supervisor_layout_emits_env_before_agent_send_keys() {
2235 let session = build_for(3);
2236 let cmds = session.command_strings();
2237 let first_env = cmds
2238 .iter()
2239 .position(|c| c.contains("set-environment") && c.contains("GIT_PAW_BROKER_URL"))
2240 .expect("set-environment GIT_PAW_BROKER_URL present");
2241 let first_agent_send = cmds
2242 .iter()
2243 .position(|c| c.contains("send-keys") && c.contains(":0.2 "))
2244 .expect("first agent send-keys at :0.2");
2245 assert!(
2246 first_env < first_agent_send,
2247 "set-environment must come before agent-pane send-keys"
2248 );
2249 }
2250
2251 #[test]
2255 fn supervisor_layout_agent_splits_carry_worktree_no_cd_chain() {
2256 let layout = crate::supervisor::layout::supervisor_layout(2).expect("layout");
2257 let supervisor = make_pane("supervisor", "/repo", "claude");
2258 let dashboard = make_pane("dashboard", "/repo", "git-paw __dashboard");
2259 let agent_a = make_pane("feat/a", "/tmp/wt-a", "claude");
2260 let agent_b = make_pane("feat/b", "/tmp/wt-b", "claude");
2261 let session = build_supervisor_session(
2262 "proj",
2263 None,
2264 &supervisor,
2265 &dashboard,
2266 &[agent_a, agent_b],
2267 layout,
2268 true,
2269 &[],
2270 )
2271 .expect("session builds");
2272
2273 let cmds = session.command_strings();
2274 let splits = commands_containing(&cmds, "split-window");
2275 assert!(
2276 splits.iter().any(|c| c.contains("-c /tmp/wt-a")),
2277 "split for agent a should pass -c /tmp/wt-a; splits: {splits:#?}"
2278 );
2279 assert!(
2280 splits.iter().any(|c| c.contains("-c /tmp/wt-b")),
2281 "split for agent b should pass -c /tmp/wt-b; splits: {splits:#?}"
2282 );
2283
2284 let send_keys = commands_containing(&cmds, "send-keys");
2285 for entry in &send_keys {
2286 assert!(
2287 !entry.contains("cd /tmp/wt-a &&"),
2288 "no send-keys should chain `cd /tmp/wt-a &&`; got: {entry}"
2289 );
2290 assert!(
2291 !entry.contains("cd /tmp/wt-b &&"),
2292 "no send-keys should chain `cd /tmp/wt-b &&`; got: {entry}"
2293 );
2294 }
2295 }
2296}