Skip to main content

git_paw/
tmux.rs

1//! Tmux session and pane orchestration.
2//!
3//! Checks tmux availability, creates sessions, splits panes, sends commands,
4//! applies layouts, and manages attach/reattach. Uses a builder pattern for
5//! testability and dry-run support.
6
7use std::process::Command;
8
9use crate::error::PawError;
10
11/// Maximum number of session name collision retries.
12const MAX_COLLISION_RETRIES: u32 = 10;
13
14/// A single tmux CLI invocation, stored as its argument list.
15///
16/// Can be inspected as a string (for dry-run / testing) or executed.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct TmuxCommand {
19    args: Vec<String>,
20}
21
22impl TmuxCommand {
23    /// Create a new tmux command from the given arguments.
24    fn new(args: &[&str]) -> Self {
25        Self {
26            args: args.iter().map(|&s| s.to_owned()).collect(),
27        }
28    }
29
30    /// Return a human-readable command string (e.g. `tmux new-session -d -s paw-proj`).
31    // Not called by production code — used by `TmuxSession::command_strings()` for
32    // dry-run contract tests that verify the commands shown to users via `--dry-run`.
33    #[allow(dead_code)]
34    pub fn as_command_string(&self) -> String {
35        format!("tmux {}", self.args.join(" "))
36    }
37
38    /// Execute the command against the live tmux server.
39    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/// Specification for a single pane: which branch/worktree to `cd` into and which CLI to run.
56#[derive(Debug, Clone)]
57pub struct PaneSpec {
58    /// Branch name (e.g. `feat/auth`). Used for the pane title.
59    pub branch: String,
60    /// Absolute path to the git worktree directory.
61    pub worktree: String,
62    /// The CLI command to execute inside the pane.
63    pub cli_command: String,
64}
65
66/// A fully-resolved tmux session ready to execute or inspect.
67#[derive(Debug)]
68pub struct TmuxSession {
69    /// The resolved session name (e.g. `paw-myproject` or `paw-myproject-2`).
70    pub name: String,
71    commands: Vec<TmuxCommand>,
72}
73
74impl TmuxSession {
75    /// Execute all accumulated tmux commands against the live tmux server.
76    pub fn execute(&self) -> Result<(), PawError> {
77        for cmd in &self.commands {
78            cmd.execute()?;
79        }
80        Ok(())
81    }
82
83    /// Return all commands as human-readable strings (for dry-run / testing).
84    // Not called by production code — used by unit tests as the dry-run contract
85    // surface to verify the tmux commands shown to users via `--dry-run`.
86    #[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    /// Queue a `pipe-pane` command to capture pane output to a log file.
95    ///
96    /// Appends `tmux pipe-pane -o -t <pane_target> "cat >> <log_path>"` to the
97    /// command queue. Should be called after the pane has been created.
98    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    /// Queue a command to reapply the tiled layout after any resize operation.
110    ///
111    /// This ensures that the layout remains consistent even when tmux windows
112    /// are resized from unattached clients. Should be called after any operation
113    /// that might affect window dimensions.
114    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    /// Queue a command to apply the main-horizontal layout for dashboard sessions.
125    ///
126    /// This layout puts the dashboard pane in a full-width row at the top,
127    /// with worktree panes tiled below. Should be used when a dashboard pane
128    /// is present (pane 0) and worktree panes follow.
129    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/// Builder that accumulates tmux operations for creating and configuring a session.
141///
142/// Can either execute operations against a live tmux server or return them
143/// as command strings for testing and dry-run.
144///
145/// # Examples
146///
147/// ```no_run
148/// use git_paw::tmux::{TmuxSessionBuilder, PaneSpec};
149///
150/// let session = TmuxSessionBuilder::new("my-project")
151///     .add_pane(PaneSpec {
152///         branch: "feat/auth".into(),
153///         worktree: "/tmp/my-project-feat-auth".into(),
154///         cli_command: "claude".into(),
155///     })
156///     .mouse_mode(true)
157///     .build()?;
158///
159/// // Dry-run: inspect commands
160/// for cmd in session.command_strings() {
161///     println!("{cmd}");
162/// }
163///
164/// // Or execute for real
165/// session.execute()?;
166/// # Ok::<(), git_paw::error::PawError>(())
167/// ```
168#[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    /// Create a new builder for the given project name.
179    ///
180    /// The session will be named `paw-<project_name>` unless overridden
181    /// with [`session_name`](Self::session_name).
182    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    /// Override the session name instead of deriving it from the project name.
193    ///
194    /// Use this with [`resolve_session_name`] to handle name collisions.
195    #[must_use]
196    pub fn session_name(mut self, name: String) -> Self {
197        self.session_name_override = Some(name);
198        self
199    }
200
201    /// Add a pane that will `cd` into the worktree and run the CLI command.
202    #[must_use]
203    pub fn add_pane(mut self, spec: PaneSpec) -> Self {
204        self.panes.push(spec);
205        self
206    }
207
208    /// Enable or disable mouse mode for the session (default: `true`).
209    ///
210    /// When enabled, users can click to switch panes, drag borders to resize,
211    /// and scroll. This is set per-session and does not affect other tmux sessions.
212    #[must_use]
213    pub fn mouse_mode(mut self, enabled: bool) -> Self {
214        self.mouse_mode = enabled;
215        self
216    }
217
218    /// Set a session-level environment variable.
219    ///
220    /// The resulting `tmux set-environment -t <session> <key> <value>` command
221    /// is emitted before any `send-keys` commands so all panes inherit it.
222    #[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    /// Build the full sequence of tmux commands without executing anything.
229    ///
230    /// Returns a [`TmuxSession`] that can be executed or inspected.
231    /// Returns an error if no panes have been added.
232    #[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        // 1. Create detached session (pane 0 is implicit).
246        // Use -c to set pane 0's working directory directly, avoiding a race
247        // condition where send-keys fires before the shell is ready.
248        // -x/-y give tmux explicit dimensions so it can start without an
249        // attached client — required in non-TTY environments (CI, integration
250        // tests). The user's real terminal resizes the session on attach.
251        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        // 2. Pin default-size globally so subsequent split-window operations
266        // have a fallback size context. On Linux tmux 3.4+, `-x/-y` on
267        // new-session alone is insufficient — subsequent splits still fail
268        // with `size missing` because the per-session dimensions aren't
269        // propagated to the layout engine when no client is attached.
270        // set-option requires a running server (new-session above starts it).
271        commands.push(TmuxCommand::new(&[
272            "set-option",
273            "-g",
274            "default-size",
275            "200x50",
276        ]));
277
278        // 2. Mouse mode
279        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        // 3. Pane border titles — show branch/CLI in each pane's border
290        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        // 4. Session-level environment variables (before any send-keys)
306        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        // 5. First pane — already exists as pane 0 (directory set by -c above)
317        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        // 6. Subsequent panes — tiled layout before each split
336        for (i, pane) in self.panes.iter().enumerate().skip(1) {
337            // Apply tiled layout before split to ensure space
338            commands.push(TmuxCommand::new(&[
339                "select-layout",
340                "-t",
341                &session_name,
342                "tiled",
343            ]));
344
345            // Split window to create new pane. Pass `-c <worktree>` so the
346            // new pane's shell starts in the agent worktree directly — this
347            // avoids the `cd <worktree> && <cli>` send-keys race where the
348            // `cd` prefix is lost when send-keys fires before the shell is
349            // ready to accept input.
350            commands.push(TmuxCommand::new(&[
351                "split-window",
352                "-t",
353                &session_name,
354                "-c",
355                &pane.worktree,
356            ]));
357
358            // Title and command for the new pane
359            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        // 7. Final layout - use main-horizontal if we have a dashboard, otherwise tiled
378        if self.panes.len() > 1 && self.panes[0].branch == "dashboard" {
379            // Dashboard layout: dashboard pane takes full width at top, worktree panes tiled below
380            commands.push(TmuxCommand::new(&[
381                "select-layout",
382                "-t",
383                &session_name,
384                "main-horizontal",
385            ]));
386        } else {
387            // Standard tiled layout for sessions without dashboard
388            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
403/// Check that tmux is installed on PATH.
404///
405/// Returns `Ok(())` if found, or `Err(PawError::TmuxNotInstalled)` with
406/// install instructions if missing.
407pub fn ensure_tmux_installed() -> Result<(), PawError> {
408    which::which("tmux").map_err(|_| PawError::TmuxNotInstalled)?;
409    Ok(())
410}
411
412/// Check whether a tmux session with the given name is currently alive.
413pub 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
424/// Resolve a unique session name, handling collisions with existing sessions.
425///
426/// Starts with `paw-<project_name>` and appends `-2`, `-3`, etc. if the name
427/// is already taken by another session.
428pub 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
447/// Attach the current terminal to the named tmux session.
448///
449/// This replaces the current process's stdio. Returns an error if the
450/// session does not exist or tmux fails.
451pub 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
466/// Detach all clients attached to the named tmux session.
467///
468/// Wraps `tmux detach-client -s <session>`. Idempotent: returns `Ok(())`
469/// if the command succeeds OR if tmux reports the session has no
470/// clients attached (the typical no-op error path on already-detached
471/// sessions). Leaves the tmux server, the session, and every pane
472/// process untouched.
473pub 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    // "no clients attached" is the idempotent no-op case.
484    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
492/// Kill a single pane within a session by `(session, pane_index)`.
493///
494/// Wraps `tmux kill-pane -t <session>:0.<index>`. Returns `Ok(())` if
495/// the pane was killed OR if tmux reports the pane does not exist
496/// (idempotent no-op on missing panes). Used by the pause flow to take
497/// down the dashboard pane (which owns the broker subprocess) without
498/// killing the rest of the session.
499pub 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    // Pane-doesn't-exist is the idempotent no-op case.
511    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
522/// Kill the named tmux session.
523pub 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
537/// Builds the argv for `tmux send-keys` that injects `text` into
538/// `<session_name>:0.<pane_index>` literally (`-l`) and *without* a trailing
539/// `Enter` key.
540///
541/// Pulled out as a free function so the manual-mode boot-block injection in
542/// `cmd_start` and tests share a single source of truth: the call must be
543/// `send-keys -l -t <target> <text>` (the `-l` flag must come *before* `-t`,
544/// otherwise tmux parses it as a key spec rather than the literal flag).
545pub 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/// Build the tmux commands that materialise the supervisor-mode pane layout
556/// described in `openspec/changes/supervisor-as-pane/specs/tmux-orchestration/`.
557///
558/// Pane ordering:
559///
560/// - Pane 0: supervisor agent (top-left, 50% of the top row)
561/// - Pane 1: dashboard (top-right, 50% of the top row)
562/// - Panes 2..N+1: coding agents, row-major (left-to-right, top-to-bottom),
563///   up to [`crate::supervisor::layout::SUPERVISOR_AGENTS_PER_ROW`] columns
564///   per row
565///
566/// Sequence (see design D2):
567///
568/// 1. `new-session -d` creates pane 0 (supervisor).
569/// 2. `split-window -v -p <bottom_pct>` on pane 0 creates the full-width agent
570///    area as pane 1 (temporary index).
571/// 3. `split-window -h -p 50` on pane 0 creates the top-right pane (pane 2),
572///    the dashboard candidate.
573/// 4. `swap-pane -s :0.1 -t :0.2` reorders the indices so pane 1 = dashboard
574///    and pane 2 = agent area.
575/// 5. For each subsequent agent: `split-window -h` within the current row to
576///    add a sibling, or `split-window -v` to start a new row.
577/// 6. Final pass: `resize-pane -t <pane> -y <pct>%` enforces the height
578///    proportions from the layout table.
579///
580/// `select-layout` is intentionally avoided here — it does not preserve the
581/// predictable pane-index ordering the rest of the system relies on.
582#[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    // 1. Create the detached session with pane 0 = supervisor.
603    // -x/-y give tmux explicit dimensions so it can start without an attached
604    // client (required in non-TTY environments like CI). The real terminal
605    // resizes the session on attach.
606    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    // 2. Pin default-size globally so subsequent split-window operations
623    // have a fallback size context. On Linux tmux 3.4+, `-x/-y` on
624    // new-session alone is insufficient — subsequent splits fail with
625    // `size missing` because the per-session dimensions aren't propagated
626    // to the layout engine when no client is attached.
627    push(
628        &mut commands,
629        &["set-option", "-g", "default-size", "200x50"],
630    );
631
632    // 2. Mouse + pane border config.
633    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    // 3. Session-level environment variables (before any send-keys).
661    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    // 4. Split pane 0 vertically -> creates the full-width agent area (now
692    //    index 1, swapped to index 2 below). When there is at least one
693    //    coding agent we pass `-c <first_agent.worktree>` so the agent area
694    //    pane is born in the first agent's worktree directly — this avoids
695    //    the `cd <worktree> && <cli>` send-keys race that previously left
696    //    resumed agent panes anchored in the supervisor's cwd.
697    //
698    // Use `-l <N>%` (the modern tmux 3.1+ form) instead of the deprecated
699    // `-p <N>`. On Linux tmux 3.4 (Ubuntu 24.04 apt-package), `-p`
700    // resolves the percentage against the parent pane's laid-out size,
701    // which is empty on a detached server with no attached client — tmux
702    // bails with `cmd-split-window.c: "size missing"`. `-l <N>%` resolves
703    // against the window's `-y` dimension instead, which is the value we
704    // set on `new-session -x 200 -y 50`, so the split math succeeds in
705    // headless mode. macOS tmux 3.6a tolerates either form.
706    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    // 5. Split pane 0 horizontally -> creates the top-right pane (currently
736    //    index 2, swapped to index 1 below) at 50% width.
737    // Same `-l <N>%` reasoning as step 4.
738    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    // 6. Swap indices so pane 1 = dashboard, pane 2 = agent area.
753    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    // 7. Set dashboard title + run its command in pane 1 (after swap).
761    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    // 8. Populate the agent grid.
785    if !agents.is_empty() {
786        // First agent: the agent area is already pane 2 (post-swap) and was
787        // created with `-c <first.worktree>` above, so its shell is already
788        // running in the first agent's worktree. Send only the bare CLI
789        // command — no `cd <worktree> && <cli>` chain, which would race with
790        // shell startup.
791        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                // Vertical split from this row's first pane to add a new row
819                // below.
820                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                // Horizontal split from the previous pane to add a sibling in
835                // the same row.
836                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    // 9. Final pass: resize-pane to enforce the layout-table heights. One
864    //    resize-pane per row (top + each agent row). Percentages here are
865    //    `<pct>%` syntax which tmux 3.x accepts on `-y`.
866    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
889/// Format a row-height percentage. Whole numbers render as "28%"; the 14.4%
890/// bucket renders as "14.4%".
891fn 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/// Build the argv pair for submitting a supervisor-mode initial prompt to a
902/// coding agent pane. The first argv pastes the prompt and sends `Enter`
903/// (which paste-aware CLIs consume to confirm the paste buffer). The second
904/// argv sends a second `Enter` to actually submit the buffered content. On
905/// non-paste-aware CLIs the first `Enter` submits and the second `Enter` is
906/// a benign no-op or blank prompt.
907///
908/// Returns a tuple `(first_argv, second_argv)`. Callers are expected to
909/// invoke `tmux send-keys <first_argv>`, sleep `SUBMIT_DELAY_MS`, then invoke
910/// `tmux send-keys <second_argv>` as a separate process invocation so the
911/// CLI has wall-clock time to render the paste-buffer placeholder.
912#[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    /// Helper: extract command strings matching a keyword from a session's commands.
948    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    // -----------------------------------------------------------------------
956    // AC: Checks tmux presence with actionable error
957    // Behavioral: verifies the public contract — does the system detect tmux?
958    // -----------------------------------------------------------------------
959
960    #[test]
961    #[serial_test::serial]
962    fn ensure_tmux_installed_succeeds_when_present() {
963        // Requires #[serial] because detect tests modify PATH.
964        assert!(ensure_tmux_installed().is_ok());
965    }
966
967    // -----------------------------------------------------------------------
968    // AC: Creates named sessions, handles collision
969    // Behavioral: session name is a public field used by attach, status, and
970    // dry-run output. The exact naming convention is the public contract.
971    // -----------------------------------------------------------------------
972
973    #[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    /// AC: Session creation passes explicit dimensions for headless environments
999    /// — basic builder.
1000    #[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    /// AC: Session creation sets global default-size after new-session
1023    /// — basic builder.
1024    #[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    // -----------------------------------------------------------------------
1066    // AC: Dynamic pane count based on input
1067    // Dry-run contract: verifies the number of commands matches the number of
1068    // panes the user requested. Actual pane creation verified by e2e test
1069    // tmux_session_with_five_panes_and_different_clis.
1070    // -----------------------------------------------------------------------
1071
1072    #[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    // -----------------------------------------------------------------------
1117    // AC: Correct commands sent to panes
1118    // Dry-run contract: users see these exact commands in --dry-run output,
1119    // so the format (CLI command in send-keys, worktree on split-window -c)
1120    // is user-facing.
1121    // -----------------------------------------------------------------------
1122
1123    #[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        // Pane 0 uses `-c` on `new-session` for its directory and runs only
1135        // the bare CLI command.
1136        assert!(
1137            send_keys[0].contains("claude"),
1138            "first pane should run claude; got: {}",
1139            send_keys[0]
1140        );
1141
1142        // Subsequent panes must not prefix `cd <worktree> &&` — the cwd is
1143        // baked into the split via `-c <worktree>` instead, avoiding the
1144        // send-keys race documented at the call site.
1145        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        // The split-window that creates pane 1 should carry the worktree as
1157        // `-c <worktree>`.
1158        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    // -----------------------------------------------------------------------
1207    // AC: Pane titles show branch and CLI
1208    // Dry-run contract: title format is user-visible in both --dry-run output
1209    // and tmux pane borders. Actual tmux titles verified by e2e test
1210    // tmux_session_with_five_panes_and_different_clis.
1211    // -----------------------------------------------------------------------
1212
1213    #[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    // -----------------------------------------------------------------------
1258    // AC: Mouse mode (per-session, configurable, default on)
1259    // Dry-run contract: users see mouse config in --dry-run output.
1260    // Actual tmux behavior verified by e2e test tmux_mouse_mode_enabled_by_default.
1261    // -----------------------------------------------------------------------
1262
1263    #[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    // -----------------------------------------------------------------------
1293    // AC: Session liveness and collision handling
1294    // Behavioral: tests against a real tmux server — verifies observable
1295    // outcomes (session exists, session is killed, names are unique).
1296    // -----------------------------------------------------------------------
1297
1298    /// Helper to create a detached tmux session for testing.
1299    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    /// Helper to kill a tmux session, ignoring errors.
1311    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    // -----------------------------------------------------------------------
1358    // AC: pipe-pane logging integration
1359    // Dry-run contract: verifies the pipe-pane command is queued correctly.
1360    // -----------------------------------------------------------------------
1361
1362    #[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    // --- Gap #10: pipe-pane conditional on logging ---
1380
1381    #[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    // --- Gap #11: pipe-pane ordering ---
1422
1423    #[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        // Find the last send-keys index and first pipe-pane index
1439        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    // -----------------------------------------------------------------------
1473    // AC: set_environment emits correct commands
1474    // -----------------------------------------------------------------------
1475
1476    #[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    // -----------------------------------------------------------------------
1556    // AC: Dashboard layout selection
1557    // Behavioral: verifies the correct layout is chosen based on pane structure
1558    // -----------------------------------------------------------------------
1559
1560    #[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    // -----------------------------------------------------------------------
1642    // AC: detach_client + kill_pane behave idempotently
1643    // -----------------------------------------------------------------------
1644
1645    /// Helper that yields a unique detached test session name and cleans it
1646    /// up on drop. Used to keep pause-related tmux tests hermetic.
1647    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        // No client is actually attached in headless test, but a detached
1680        // session under tmux server is the closest the unit layer can get
1681        // without a pty; the public contract is "exit Ok" either way.
1682        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        // First call: no clients attached.
1692        detach_client(&session.name).expect("first detach should succeed");
1693        // Second call: also no clients (still alive).
1694        detach_client(&session.name).expect("second detach should succeed");
1695        assert!(is_session_alive(&session.name).unwrap());
1696    }
1697
1698    /// Spec-aligned alias of `detach_client_is_noop_with_no_clients`
1699    /// (task 9.11). A detached test session has no client attached;
1700    /// `detach_client` must still return Ok(()).
1701    #[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        // Add a second pane so the kill doesn't take down the whole session.
1714        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        // Pane index 99 does not exist — should not error.
1737        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    // -----------------------------------------------------------------------
1761    // AC: Supervisor-mode initial prompt is injected as a paste + two Enters
1762    // Behavioral: callers iterate the argv pair and run each as a separate
1763    // `tmux send-keys` invocation. The pair shape is the public contract.
1764    // -----------------------------------------------------------------------
1765
1766    #[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        // Both invocations are non-empty argv vectors.
1770        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        // The target (third positional arg after `send-keys -t`) must match
1805        // so the second Enter lands in the same pane the prompt was sent to.
1806        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        // The prompt is passed verbatim as its own argv element; tmux's
1815        // send-keys treats it as literal text. No shell escaping needed.
1816        assert_eq!(first[3], prompt);
1817    }
1818
1819    // Maps to scenario `Launch flow sends exactly one Enter per pane`
1820    // (cmd_supervisor invariant) from prompt-submit-fix. The
1821    // `submit_prompt_to_pane` helper in main.rs sends prompt + one Enter
1822    // per pane and is shaped identically to the FIRST argv returned by
1823    // `build_supervisor_submit_argv_pair`. We count Enter tokens across
1824    // the first-argv portion of N=3 invocations to lock in the
1825    // single-Enter-per-pane invariant. (test-coverage-v0-5-0 task 3.1)
1826    #[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    // -----------------------------------------------------------------------
1863    // build_supervisor_session — layout-shape contract (tasks 9.1–9.7)
1864    //
1865    // Behavioral: we inspect the emitted command strings to verify the layout
1866    // shape. The exact tmux side effects are integration-tested elsewhere;
1867    // here we lock in the deterministic command sequence the supervisor-mode
1868    // pane assumptions depend on (supervisor=0, dashboard=1, agents=2+).
1869    // -----------------------------------------------------------------------
1870
1871    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    /// 9.1 — 5-agent layout: 1 agent row, top 60% / agent row 40%.
1898    #[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        // Top row resize-pane uses 60%.
1919        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        // Single agent row resize at pane :0.2 with 40%.
1927        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    /// 9.2 — 10-agent layout: 2 rows of 5, top 40% / each agent row 30%.
1936    #[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    /// 9.3 — 11-agent layout: 3 agent rows (5+5+1), top 28% / each agent row 24%.
1959    #[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        // 11 agents start at pane 2 and run through pane 12.
1974        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    /// 9.4 — 20-agent layout: 4 rows of 5, top 28% / each agent row 18%.
1980    #[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    /// 9.5 — 25-agent layout: 5 rows of 5, top 28% / each agent row 14.4%.
1997    #[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    /// 9.6 — 26-agent attempt errors before any tmux command runs.
2014    #[test]
2015    fn supervisor_layout_26_agents_rejected_by_layout_helper() {
2016        // The layout helper is the single gate for the hard cap; the tmux
2017        // builder is unreachable when supervisor_layout errors.
2018        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    /// 9.7 — pane indices follow row-major order. With 7 agents, pane 2 is
2025    /// the first agent (top-left), pane 6 is the fifth (top-right of row 1),
2026    /// pane 7 is the sixth (start of row 2).
2027    #[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        // pane :0.2 is the first agent — its send-keys must contain its CLI
2033        // command. Likewise :0.6 (fifth agent) and :0.7 (sixth agent).
2034        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    // Maps to scenario `Top row is split 50/50 between supervisor and
2055    // dashboard` from supervisor-as-pane. (test-coverage-v0-5-0 task 12.7)
2056    #[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    /// AC: Supervisor splits use `-l <N>%` (tmux 3.1+ syntax), not the
2071    /// deprecated `-p <N>` form. Headless Linux tmux 3.4 fails on `-p`
2072    /// with `size missing` because the resolver consults pane geometry
2073    /// (unresolved without an attached client) rather than window
2074    /// geometry. Pin the convention so no future call site regresses.
2075    #[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    /// AC: Supervisor session passes -x/-y to new-session for headless
2090    /// environments.
2091    #[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    /// AC: Supervisor session sets global default-size after new-session.
2110    #[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    // Maps to scenario `Broker enabled in bare-start mode adds dashboard as
2131    // pane 0` from supervisor-as-pane. The bare-start tmux build uses
2132    // `TmuxSessionBuilder::add_pane(...)` in source order — production code
2133    // adds the dashboard pane first when broker is enabled. We mirror that
2134    // order in the test fixture so the pane-index contract is asserted.
2135    // (test-coverage-v0-5-0 task 12.1)
2136    #[test]
2137    fn bare_start_with_broker_places_dashboard_at_pane_0() {
2138        // Mirror cmd_start with broker enabled: dashboard first, then agents.
2139        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        // Each agent pane carries its worktree on the `split-window -c`
2157        // (the pane is created in the worktree directly to avoid the
2158        // `cd && cli` send-keys race) AND has a `select-pane -T` at the
2159        // expected pane index.
2160        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    // Maps to scenario `Broker disabled produces no dashboard pane` from
2181    // supervisor-as-pane. (test-coverage-v0-5-0 task 12.2)
2182    #[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        // Three send-keys (one per agent pane), no dashboard send-keys.
2197        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    // Maps to scenario `Dashboard pane title` from supervisor-as-pane.
2206    // (test-coverage-v0-5-0 task 12.3)
2207    #[test]
2208    fn dashboard_pane_has_title_dashboard() {
2209        // Use the supervisor layout (the dashboard-bearing argv builder).
2210        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        // The shipped title shape is `<branch> → <cli_command>` with branch =
2224        // "dashboard". Confirm the title argument contains the bare word.
2225        assert!(
2226            dashboard_select.contains("dashboard"),
2227            "dashboard pane title must include `dashboard`; got: {dashboard_select}"
2228        );
2229    }
2230
2231    /// Sanity: `env_vars` surface as set-environment commands BEFORE any
2232    /// agent-pane send-keys, so coding agents inherit `GIT_PAW_BROKER_URL`.
2233    #[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    /// Bug B regression coverage: every agent pane SHALL be created with
2252    /// `-c <agent.worktree>` on its split, and the follow-up `send-keys`
2253    /// SHALL NOT use the `cd <worktree> && <cli>` race chain.
2254    #[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}