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::path::{Path, PathBuf};
8use std::process::Command;
9use std::time::Duration;
10
11use crate::error::PawError;
12
13/// Maximum number of session name collision retries.
14const MAX_COLLISION_RETRIES: u32 = 10;
15
16/// A single tmux CLI invocation, stored as its argument list.
17///
18/// Can be inspected as a string (for dry-run / testing) or executed.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct TmuxCommand {
21    args: Vec<String>,
22    /// When `true`, a non-zero exit is treated as a non-fatal warning rather
23    /// than aborting the build. Used for the border-affordance `set-option`
24    /// invocations, which older tmux versions may not recognise (design D4).
25    soft: bool,
26}
27
28impl TmuxCommand {
29    /// Create a new tmux command from the given arguments.
30    fn new(args: &[&str]) -> Self {
31        Self {
32            args: args.iter().map(|&s| s.to_owned()).collect(),
33            soft: false,
34        }
35    }
36
37    /// Create a "soft" tmux command whose failure is non-fatal.
38    ///
39    /// On a non-zero exit (e.g. an option unsupported by an older tmux), the
40    /// session executor emits a stderr warning naming the failed invocation
41    /// and continues with the remaining commands. See [`TmuxSession::execute`].
42    fn new_soft(args: &[&str]) -> Self {
43        Self {
44            args: args.iter().map(|&s| s.to_owned()).collect(),
45            soft: true,
46        }
47    }
48
49    /// Return a human-readable command string (e.g. `tmux new-session -d -s paw-proj`).
50    // Not called by production code — used by `TmuxSession::command_strings()` for
51    // dry-run contract tests that verify the commands shown to users via `--dry-run`.
52    #[allow(dead_code)]
53    pub fn as_command_string(&self) -> String {
54        format!("tmux {}", self.args.join(" "))
55    }
56
57    /// Execute the command against the live tmux server.
58    fn execute(&self) -> Result<String, PawError> {
59        let output = Command::new("tmux")
60            .args(&self.args)
61            .output()
62            .map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
63
64        if output.status.success() {
65            String::from_utf8(output.stdout)
66                .map_err(|e| PawError::TmuxError(format!("invalid utf-8 in tmux output: {e}")))
67        } else {
68            let stderr = String::from_utf8_lossy(&output.stderr);
69            Err(PawError::TmuxError(stderr.trim().to_owned()))
70        }
71    }
72}
73
74/// Specification for a single pane: which branch/worktree to `cd` into and which CLI to run.
75#[derive(Debug, Clone)]
76pub struct PaneSpec {
77    /// Branch name (e.g. `feat/auth`). Used for the pane title.
78    pub branch: String,
79    /// Absolute path to the git worktree directory.
80    pub worktree: String,
81    /// The CLI command to execute inside the pane.
82    pub cli_command: String,
83}
84
85/// Push the five border-affordance `set-option` invocations onto `commands`,
86/// scoped to `session` (`-t <session>`, never the server or other windows).
87///
88/// The options give git-paw-managed sessions heavier, labelled, and
89/// active-highlighted pane borders so the supervisor↔agent boundary is
90/// visually distinct (see `supervisor-pane-affordances` spec):
91///
92/// - `pane-border-lines double` — `═║` double-line borders (tmux 3.2+) that
93///   read as a stronger row separator than single/heavy lines. tmux has no
94///   inter-pane margin/padding (panes tile flush), so the divider weight plus
95///   the label bar below are the only levers for perceived separation.
96/// - `pane-border-style fg=colour238` — dim inactive borders
97/// - `pane-active-border-style fg=colour45,bold` — focused pane pops
98/// - `pane-border-status top` — label strip above each pane
99/// - `pane-border-format '#[fg=colour39,bold,reverse] #{pane_index}: #{?#{@paw_role},#{@paw_role},#{pane_title}} #[default]'`
100///   — a reverse-video colored label *bar* per pane (reads as a header chip,
101///   not plain text on the line), preferring the git-paw-set `@paw_role` pane
102///   option over `#{pane_title}`. The format prefers `@paw_role` because the
103///   agent CLI emits OSC title escape sequences that overwrite `#{pane_title}`
104///   with its current activity; the pane-scoped `@paw_role` option (set by
105///   [`push_pane_title`]) is not clobbered, so the role label survives. A pane
106///   without `@paw_role` (e.g. a user-created pane) falls back to `#{pane_title}`.
107///
108/// Each is queued as a *soft* command: a non-zero exit on an older tmux that
109/// lacks the option produces a stderr warning and the build continues (D4).
110fn push_border_affordances(commands: &mut Vec<TmuxCommand>, session: &str) {
111    for (option, value) in [
112        ("pane-border-lines", "double"),
113        ("pane-border-style", "fg=colour238"),
114        ("pane-active-border-style", "fg=colour45,bold"),
115        ("pane-border-status", "top"),
116        (
117            "pane-border-format",
118            "#[fg=colour39,bold,reverse] #{pane_index}: #{?#{@paw_role},#{@paw_role},#{pane_title}} #[default]",
119        ),
120    ] {
121        commands.push(TmuxCommand::new_soft(&[
122            "set-option",
123            "-t",
124            session,
125            option,
126            value,
127        ]));
128    }
129}
130
131/// Queue the pane-title invocations that label a pane, but only when
132/// `border_affordances` is enabled. The title is the pane's role or branch id
133/// (`supervisor`, `dashboard`, or e.g. `feat/foo`) and renders in the
134/// `pane-border-format` strip configured by [`push_border_affordances`].
135///
136/// Two commands are queued:
137/// - `select-pane -T <title>` sets `#{pane_title}` (the OSC-style title).
138/// - `set-option -p @paw_role <title>` sets a pane-scoped user option.
139///
140/// Both carry the same label, but the agent CLI overwrites `#{pane_title}` via
141/// its own OSC title escape sequences as it works, so the `select-pane -T`
142/// value does not survive. The pane-scoped `@paw_role` option is git-paw's and
143/// is never clobbered, so the border-format prefers it (see
144/// [`push_border_affordances`]) and the role label stays stable for the life
145/// of the pane.
146fn push_pane_title(
147    commands: &mut Vec<TmuxCommand>,
148    border_affordances: bool,
149    target: &str,
150    title: &str,
151) {
152    if border_affordances {
153        commands.push(TmuxCommand::new(&[
154            "select-pane",
155            "-t",
156            target,
157            "-T",
158            title,
159        ]));
160        // Pane-scoped user option: stable, not clobbered by the CLI's OSC
161        // title sequences. The border-format prefers this over `#{pane_title}`.
162        commands.push(TmuxCommand::new_soft(&[
163            "set-option",
164            "-p",
165            "-t",
166            target,
167            "@paw_role",
168            title,
169        ]));
170    }
171}
172
173/// A fully-resolved tmux session ready to execute or inspect.
174#[derive(Debug)]
175pub struct TmuxSession {
176    /// The resolved session name (e.g. `paw-myproject` or `paw-myproject-2`).
177    pub name: String,
178    commands: Vec<TmuxCommand>,
179}
180
181impl TmuxSession {
182    /// Execute all accumulated tmux commands against the live tmux server.
183    ///
184    /// Soft commands (the border affordances) that fail produce a stderr
185    /// warning naming the failed invocation and do not abort the build; any
186    /// other command failure propagates as an error.
187    pub fn execute(&self) -> Result<(), PawError> {
188        self.execute_with(|cmd| cmd.execute().map(|_| ()), |w| eprintln!("{w}"))
189    }
190
191    /// Run every queued command via `run`, routing non-fatal warnings to
192    /// `warn`. Pulled out of [`execute`](Self::execute) so the soft-failure
193    /// contract (warn + continue for soft commands, abort for the rest) can be
194    /// exercised without a live tmux server.
195    fn execute_with<R, W>(&self, mut run: R, mut warn: W) -> Result<(), PawError>
196    where
197        R: FnMut(&TmuxCommand) -> Result<(), PawError>,
198        W: FnMut(String),
199    {
200        for cmd in &self.commands {
201            if let Err(e) = run(cmd) {
202                if cmd.soft {
203                    warn(format!(
204                        "warning: tmux option not supported: {} ({e})",
205                        cmd.args.join(" ")
206                    ));
207                } else {
208                    return Err(e);
209                }
210            }
211        }
212        Ok(())
213    }
214
215    /// Return all commands as human-readable strings (for dry-run / testing).
216    // Not called by production code — used by unit tests as the dry-run contract
217    // surface to verify the tmux commands shown to users via `--dry-run`.
218    #[allow(dead_code)]
219    pub fn command_strings(&self) -> Vec<String> {
220        self.commands
221            .iter()
222            .map(TmuxCommand::as_command_string)
223            .collect()
224    }
225
226    /// Queue a `pipe-pane` command to capture pane output to a log file.
227    ///
228    /// Appends `tmux pipe-pane -o -t <pane_target> "cat >> <log_path>"` to the
229    /// command queue. Should be called after the pane has been created.
230    pub fn pipe_pane(&mut self, pane_target: &str, log_path: &std::path::Path) -> &mut Self {
231        self.commands.push(TmuxCommand::new(&[
232            "pipe-pane",
233            "-o",
234            "-t",
235            pane_target,
236            &format!("cat >> {}", log_path.display()),
237        ]));
238        self
239    }
240
241    /// Queue a command to reapply the tiled layout after any resize operation.
242    ///
243    /// This ensures that the layout remains consistent even when tmux windows
244    /// are resized from unattached clients. Should be called after any operation
245    /// that might affect window dimensions.
246    pub fn reapply_tiled_layout(&mut self, session_name: &str) -> &mut Self {
247        self.commands.push(TmuxCommand::new(&[
248            "select-layout",
249            "-t",
250            session_name,
251            "tiled",
252        ]));
253        self
254    }
255
256    /// Queue a command to apply the main-horizontal layout for dashboard sessions.
257    ///
258    /// This layout puts the dashboard pane in a full-width row at the top,
259    /// with worktree panes tiled below. Should be used when a dashboard pane
260    /// is present (pane 0) and worktree panes follow.
261    pub fn apply_dashboard_layout(&mut self, session_name: &str) -> &mut Self {
262        self.commands.push(TmuxCommand::new(&[
263            "select-layout",
264            "-t",
265            session_name,
266            "main-horizontal",
267        ]));
268        self
269    }
270}
271
272/// Builder that accumulates tmux operations for creating and configuring a session.
273///
274/// Can either execute operations against a live tmux server or return them
275/// as command strings for testing and dry-run.
276///
277/// # Examples
278///
279/// ```no_run
280/// use git_paw::tmux::{TmuxSessionBuilder, PaneSpec};
281///
282/// let session = TmuxSessionBuilder::new("my-project")
283///     .add_pane(PaneSpec {
284///         branch: "feat/auth".into(),
285///         worktree: "/tmp/my-project-feat-auth".into(),
286///         cli_command: "claude".into(),
287///     })
288///     .mouse_mode(true)
289///     .build()?;
290///
291/// // Dry-run: inspect commands
292/// for cmd in session.command_strings() {
293///     println!("{cmd}");
294/// }
295///
296/// // Or execute for real
297/// session.execute()?;
298/// # Ok::<(), git_paw::error::PawError>(())
299/// ```
300#[derive(Debug)]
301pub struct TmuxSessionBuilder {
302    project_name: String,
303    panes: Vec<PaneSpec>,
304    mouse_mode: bool,
305    border_affordances: bool,
306    session_name_override: Option<String>,
307    env_vars: Vec<(String, String)>,
308}
309
310impl TmuxSessionBuilder {
311    /// Create a new builder for the given project name.
312    ///
313    /// The session will be named `paw-<project_name>` unless overridden
314    /// with [`session_name`](Self::session_name).
315    pub fn new(project_name: &str) -> Self {
316        Self {
317            project_name: project_name.to_owned(),
318            panes: Vec::new(),
319            mouse_mode: true,
320            border_affordances: true,
321            session_name_override: None,
322            env_vars: Vec::new(),
323        }
324    }
325
326    /// Override the session name instead of deriving it from the project name.
327    ///
328    /// Use this with [`resolve_session_name`] to handle name collisions.
329    #[must_use]
330    pub fn session_name(mut self, name: String) -> Self {
331        self.session_name_override = Some(name);
332        self
333    }
334
335    /// Add a pane that will `cd` into the worktree and run the CLI command.
336    #[must_use]
337    pub fn add_pane(mut self, spec: PaneSpec) -> Self {
338        self.panes.push(spec);
339        self
340    }
341
342    /// Enable or disable mouse mode for the session (default: `true`).
343    ///
344    /// When enabled, users can click to switch panes, drag borders to resize,
345    /// and scroll. This is set per-session and does not affect other tmux sessions.
346    #[must_use]
347    pub fn mouse_mode(mut self, enabled: bool) -> Self {
348        self.mouse_mode = enabled;
349        self
350    }
351
352    /// Enable or disable the border affordances for the session (default:
353    /// `true`).
354    ///
355    /// When enabled, the session receives heavy borders, dim/active border
356    /// styling, and a per-pane label strip, and each pane's title is set to
357    /// its role/branch id. When disabled, none of these `set-option` or
358    /// `select-pane -T` invocations are emitted and the session inherits the
359    /// user's default tmux styling. Driven by `[layout].border_affordances`.
360    #[must_use]
361    pub fn border_affordances(mut self, enabled: bool) -> Self {
362        self.border_affordances = enabled;
363        self
364    }
365
366    /// Set a session-level environment variable.
367    ///
368    /// The resulting `tmux set-environment -t <session> <key> <value>` command
369    /// is emitted before any `send-keys` commands so all panes inherit it.
370    #[must_use]
371    pub fn set_environment(mut self, key: &str, value: &str) -> Self {
372        self.env_vars.push((key.to_owned(), value.to_owned()));
373        self
374    }
375
376    /// Build the full sequence of tmux commands without executing anything.
377    ///
378    /// Returns a [`TmuxSession`] that can be executed or inspected.
379    /// Returns an error if no panes have been added.
380    #[allow(clippy::too_many_lines)]
381    pub fn build(self) -> Result<TmuxSession, PawError> {
382        if self.panes.is_empty() {
383            return Err(PawError::TmuxError(
384                "cannot create a session with no panes".to_owned(),
385            ));
386        }
387
388        let session_name = self
389            .session_name_override
390            .unwrap_or_else(|| format!("paw-{}", self.project_name));
391        let mut commands = Vec::new();
392
393        // 1. Create detached session (pane 0 is implicit).
394        // Use -c to set pane 0's working directory directly, avoiding a race
395        // condition where send-keys fires before the shell is ready.
396        // -x/-y give tmux explicit dimensions so it can start without an
397        // attached client — required in non-TTY environments (CI, integration
398        // tests). The user's real terminal resizes the session on attach.
399        let first_worktree = &self.panes[0].worktree;
400        commands.push(TmuxCommand::new(&[
401            "new-session",
402            "-d",
403            "-s",
404            &session_name,
405            "-x",
406            "480",
407            "-y",
408            "140",
409            "-c",
410            first_worktree,
411        ]));
412
413        // 2. Pin default-size globally so subsequent split-window operations
414        // have a fallback size context. On Linux tmux 3.4+, `-x/-y` on
415        // new-session alone is insufficient — subsequent splits still fail
416        // with `size missing` because the per-session dimensions aren't
417        // propagated to the layout engine when no client is attached.
418        // set-option requires a running server (new-session above starts it).
419        commands.push(TmuxCommand::new(&[
420            "set-option",
421            "-g",
422            "default-size",
423            "480x140",
424        ]));
425
426        // 2. Mouse mode
427        if self.mouse_mode {
428            commands.push(TmuxCommand::new(&[
429                "set-option",
430                "-t",
431                &session_name,
432                "mouse",
433                "on",
434            ]));
435        }
436
437        // 3. Border affordances — heavy borders, dim/active styling, and the
438        //    per-pane label strip. Gated by `border_affordances`; when off the
439        //    session inherits the user's default tmux styling.
440        if self.border_affordances {
441            push_border_affordances(&mut commands, &session_name);
442        }
443
444        // 4. Session-level environment variables (before any send-keys)
445        for (key, value) in &self.env_vars {
446            commands.push(TmuxCommand::new(&[
447                "set-environment",
448                "-t",
449                &session_name,
450                key,
451                value,
452            ]));
453        }
454
455        // 5. First pane — already exists as pane 0 (directory set by -c above).
456        //    The title is the pane's role/branch id (not the CLI command) so it
457        //    reads cleanly in the label strip configured above.
458        let first = &self.panes[0];
459        let pane_target = format!("{session_name}:0.0");
460        push_pane_title(
461            &mut commands,
462            self.border_affordances,
463            &pane_target,
464            &first.branch,
465        );
466        commands.push(TmuxCommand::new(&[
467            "send-keys",
468            "-t",
469            &pane_target,
470            &first.cli_command,
471            "Enter",
472        ]));
473
474        // 6. Subsequent panes — tiled layout before each split
475        for (i, pane) in self.panes.iter().enumerate().skip(1) {
476            // Apply tiled layout before split to ensure space
477            commands.push(TmuxCommand::new(&[
478                "select-layout",
479                "-t",
480                &session_name,
481                "tiled",
482            ]));
483
484            // Split window to create new pane. Pass `-c <worktree>` so the
485            // new pane's shell starts in the agent worktree directly — this
486            // avoids the `cd <worktree> && <cli>` send-keys race where the
487            // `cd` prefix is lost when send-keys fires before the shell is
488            // ready to accept input.
489            commands.push(TmuxCommand::new(&[
490                "split-window",
491                "-t",
492                &session_name,
493                "-c",
494                &pane.worktree,
495            ]));
496
497            // Title and command for the new pane
498            let pane_target = format!("{session_name}:0.{i}");
499            push_pane_title(
500                &mut commands,
501                self.border_affordances,
502                &pane_target,
503                &pane.branch,
504            );
505            commands.push(TmuxCommand::new(&[
506                "send-keys",
507                "-t",
508                &pane_target,
509                &pane.cli_command,
510                "Enter",
511            ]));
512        }
513
514        // 7. Final layout - use main-horizontal if we have a dashboard, otherwise tiled
515        if self.panes.len() > 1 && self.panes[0].branch == "dashboard" {
516            // Dashboard layout: dashboard pane takes full width at top, worktree panes tiled below
517            commands.push(TmuxCommand::new(&[
518                "select-layout",
519                "-t",
520                &session_name,
521                "main-horizontal",
522            ]));
523        } else {
524            // Standard tiled layout for sessions without dashboard
525            commands.push(TmuxCommand::new(&[
526                "select-layout",
527                "-t",
528                &session_name,
529                "tiled",
530            ]));
531        }
532
533        Ok(TmuxSession {
534            name: session_name,
535            commands,
536        })
537    }
538}
539
540/// Check that tmux is installed on PATH.
541///
542/// Returns `Ok(())` if found, or `Err(PawError::TmuxNotInstalled)` with
543/// install instructions if missing.
544pub fn ensure_tmux_installed() -> Result<(), PawError> {
545    which::which("tmux").map_err(|_| PawError::TmuxNotInstalled)?;
546    Ok(())
547}
548
549/// Check whether a tmux session with the given name is currently alive.
550pub fn is_session_alive(name: &str) -> Result<bool, PawError> {
551    let status = Command::new("tmux")
552        .args(["has-session", "-t", name])
553        .stdout(std::process::Stdio::null())
554        .stderr(std::process::Stdio::null())
555        .status()
556        .map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
557
558    Ok(status.success())
559}
560
561/// Outcome of a session-liveness probe (design D3 of `session-bugfixes`).
562///
563/// Distinguishes a genuinely-absent tmux session (`Stale`) from a probe that
564/// could not be run at all (`Indeterminate`, e.g. the `tmux` binary is
565/// missing). Receipt-staleness detection SHALL NOT report `🔴 stale` on an
566/// `Indeterminate` probe — a missing tmux binary is not evidence the session
567/// died.
568#[derive(Debug, Clone, Copy, PartialEq, Eq)]
569pub enum SessionLiveness {
570    /// `tmux has-session` returned exit 0 — the session exists.
571    Alive,
572    /// `tmux has-session` ran and returned non-zero — the session is gone.
573    Stale,
574    /// The probe could not be run (tmux binary absent/unreachable). The
575    /// caller SHALL preserve the receipt's current state.
576    Indeterminate,
577}
578
579/// Pure mapping from a probe's raw outcome to a [`SessionLiveness`].
580///
581/// `spawned` is whether the `tmux has-session` process started at all;
582/// `success` is its exit-status success (only meaningful when `spawned`).
583/// Extracted so each branch is unit-testable without a real tmux server.
584fn classify_liveness(spawned: bool, success: bool) -> SessionLiveness {
585    match (spawned, success) {
586        (false, _) => SessionLiveness::Indeterminate,
587        (true, true) => SessionLiveness::Alive,
588        (true, false) => SessionLiveness::Stale,
589    }
590}
591
592/// Probe a tmux session's liveness via a single `tmux has-session` call.
593///
594/// This is the cheap staleness check used by `status`, `start`, and
595/// `purge --stale` (spec: "Liveness probe is cheap"). It runs exactly one
596/// `tmux has-session -t <name>` invocation and never probes the broker or
597/// agent processes.
598pub fn session_liveness(name: &str) -> SessionLiveness {
599    let spawn = Command::new("tmux")
600        .args(["has-session", "-t", name])
601        .stdout(std::process::Stdio::null())
602        .stderr(std::process::Stdio::null())
603        .status();
604    match spawn {
605        Ok(status) => classify_liveness(true, status.success()),
606        Err(_) => classify_liveness(false, false),
607    }
608}
609
610/// Resolve a unique session name, handling collisions with existing sessions.
611///
612/// Starts with `paw-<project_name>` and appends `-2`, `-3`, etc. if the name
613/// is already taken by another session.
614pub fn resolve_session_name(project_name: &str) -> Result<String, PawError> {
615    let base = format!("paw-{project_name}");
616
617    if !is_session_alive(&base)? {
618        return Ok(base);
619    }
620
621    for suffix in 2..=MAX_COLLISION_RETRIES + 1 {
622        let candidate = format!("{base}-{suffix}");
623        if !is_session_alive(&candidate)? {
624            return Ok(candidate);
625        }
626    }
627
628    Err(PawError::TmuxError(format!(
629        "too many session name collisions for '{base}'"
630    )))
631}
632
633/// Attach the current terminal to the named tmux session.
634///
635/// This replaces the current process's stdio. Returns an error if the
636/// session does not exist or tmux fails.
637pub fn attach(name: &str) -> Result<(), PawError> {
638    let status = Command::new("tmux")
639        .args(["attach-session", "-t", name])
640        .status()
641        .map_err(|e| PawError::TmuxError(format!("failed to attach to tmux session: {e}")))?;
642
643    if status.success() {
644        Ok(())
645    } else {
646        Err(PawError::TmuxError(format!(
647            "failed to attach to session '{name}'"
648        )))
649    }
650}
651
652/// Detach all clients attached to the named tmux session.
653///
654/// Wraps `tmux detach-client -s <session>`. Idempotent: returns `Ok(())`
655/// if the command succeeds OR if tmux reports the session has no
656/// clients attached (the typical no-op error path on already-detached
657/// sessions). Leaves the tmux server, the session, and every pane
658/// process untouched.
659pub fn detach_client(session_name: &str) -> Result<(), PawError> {
660    let output = Command::new("tmux")
661        .args(["detach-client", "-s", session_name])
662        .output()
663        .map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
664
665    if output.status.success() {
666        return Ok(());
667    }
668    let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
669    // "no clients attached" is the idempotent no-op case.
670    if stderr.contains("no clients") || stderr.contains("no current client") {
671        return Ok(());
672    }
673    Err(PawError::TmuxError(
674        String::from_utf8_lossy(&output.stderr).trim().to_owned(),
675    ))
676}
677
678/// Kill a single pane within a session by `(session, pane_index)`.
679///
680/// Wraps `tmux kill-pane -t <session>:0.<index>`. Returns `Ok(())` if
681/// the pane was killed OR if tmux reports the pane does not exist
682/// (idempotent no-op on missing panes). Used by the pause flow to take
683/// down the dashboard pane (which owns the broker subprocess) without
684/// killing the rest of the session.
685pub fn kill_pane(session_name: &str, pane_index: u32) -> Result<(), PawError> {
686    let target = format!("{session_name}:0.{pane_index}");
687    let output = Command::new("tmux")
688        .args(["kill-pane", "-t", &target])
689        .output()
690        .map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
691
692    if output.status.success() {
693        return Ok(());
694    }
695    let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
696    // Pane-doesn't-exist is the idempotent no-op case.
697    if stderr.contains("can't find pane")
698        || stderr.contains("no such pane")
699        || stderr.contains("pane not found")
700    {
701        return Ok(());
702    }
703    Err(PawError::TmuxError(
704        String::from_utf8_lossy(&output.stderr).trim().to_owned(),
705    ))
706}
707
708/// Kill the named tmux session.
709pub fn kill_session(name: &str) -> Result<(), PawError> {
710    let output = Command::new("tmux")
711        .args(["kill-session", "-t", name])
712        .output()
713        .map_err(|e| PawError::TmuxError(format!("failed to kill tmux session: {e}")))?;
714
715    if output.status.success() {
716        Ok(())
717    } else {
718        let stderr = String::from_utf8_lossy(&output.stderr);
719        Err(PawError::TmuxError(stderr.trim().to_owned()))
720    }
721}
722
723/// Kill a single pane addressed by its tmux pane id (`%N`), regardless of the
724/// process running in it (a CLI, a bare shell, or anything else).
725///
726/// Used by the `remove` path (design D2, G2a): the target pane is resolved
727/// from the removed branch's worktree via [`resolve_pane_id_for_worktree`] so
728/// the kill never depends on the JSON-position arithmetic that broke under an
729/// orphan pane. A pane that has already gone is treated as an idempotent no-op,
730/// matching [`kill_pane`]'s missing-pane tolerance.
731pub fn kill_pane_by_id(pane_id: &str) -> Result<(), PawError> {
732    let output = Command::new("tmux")
733        .args(["kill-pane", "-t", pane_id])
734        .output()
735        .map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
736
737    if output.status.success() {
738        return Ok(());
739    }
740    let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
741    if stderr.contains("can't find pane")
742        || stderr.contains("no such pane")
743        || stderr.contains("pane not found")
744    {
745        return Ok(());
746    }
747    Err(PawError::TmuxError(
748        String::from_utf8_lossy(&output.stderr).trim().to_owned(),
749    ))
750}
751
752/// List every live pane in `session_name`'s window as
753/// `(pane_id, pane_current_path)` pairs via
754/// `tmux list-panes -F '#{pane_id} #{pane_current_path}'`.
755///
756/// Returns an empty vector (not an error) when the session/server is gone, so
757/// callers on a torn-down session degrade to "no live panes" rather than
758/// failing. Only a genuine tmux execution failure surfaces as an error.
759pub fn list_panes_with_paths(session_name: &str) -> Result<Vec<(String, String)>, PawError> {
760    let output = Command::new("tmux")
761        .args([
762            "list-panes",
763            "-t",
764            session_name,
765            "-F",
766            "#{pane_id} #{pane_current_path}",
767        ])
768        .output()
769        .map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
770
771    if !output.status.success() {
772        let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
773        if stderr.contains("can't find")
774            || stderr.contains("no such")
775            || stderr.contains("no server running")
776        {
777            return Ok(Vec::new());
778        }
779        return Err(PawError::TmuxError(
780            String::from_utf8_lossy(&output.stderr).trim().to_owned(),
781        ));
782    }
783
784    let text = String::from_utf8_lossy(&output.stdout);
785    let mut panes = Vec::new();
786    for line in text.lines() {
787        if let Some((id, path)) = line.split_once(' ') {
788            panes.push((id.to_string(), path.to_string()));
789        }
790    }
791    Ok(panes)
792}
793
794/// Resolve the tmux pane id (`%N`) whose `pane_current_path` matches
795/// `worktree_path` in `session_name` (design D2, G2a).
796///
797/// Both the queried `pane_current_path` and `worktree_path` are canonicalised
798/// before comparison so a symlinked temp root (e.g. macOS `/var` →
799/// `/private/var`) does not defeat the match. Returns `Ok(None)` when no live
800/// pane maps to the worktree (the agent's pane is already gone), so the caller
801/// can treat removal as an idempotent no-op.
802pub fn resolve_pane_id_for_worktree(
803    session_name: &str,
804    worktree_path: &Path,
805) -> Result<Option<String>, PawError> {
806    let want = canonical_or_self(worktree_path);
807    for (pane_id, path) in list_panes_with_paths(session_name)? {
808        if canonical_or_self(Path::new(&path)) == want {
809            return Ok(Some(pane_id));
810        }
811    }
812    Ok(None)
813}
814
815/// Canonicalise `path`, falling back to the path itself when it cannot be
816/// resolved (e.g. a temp dir already removed). Keeps comparisons total.
817fn canonical_or_self(path: &Path) -> PathBuf {
818    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
819}
820
821/// Pure JSON↔tmux reconciliation (design D3, G2b): given the session-JSON
822/// agents as `(branch, worktree_path)` pairs and the set of live pane
823/// `pane_current_path` values, return the branches whose worktree maps to no
824/// live pane.
825///
826/// Split from [`reconcile_agents_to_panes`] so the divergence logic is unit
827/// testable without a live tmux server. Paths are canonicalised on both sides
828/// (with a self fallback) so the comparison is symlink-tolerant.
829#[must_use]
830pub fn agents_without_live_pane(
831    agents: &[(String, PathBuf)],
832    live_pane_paths: &[PathBuf],
833) -> Vec<String> {
834    let live: Vec<PathBuf> = live_pane_paths
835        .iter()
836        .map(|p| canonical_or_self(p))
837        .collect();
838    agents
839        .iter()
840        .filter(|(_, wt)| {
841            let want = canonical_or_self(wt);
842            !live.contains(&want)
843        })
844        .map(|(branch, _)| branch.clone())
845        .collect()
846}
847
848/// Reconcile the session-JSON agents against the live tmux panes in
849/// `session_name` and report any agent (by branch) whose worktree maps to no
850/// live pane (the v0.8.0 G2 desync). Returns an empty vector when every agent
851/// maps to a pane.
852///
853/// Surfaced (not auto-repaired) on the `add` path so a dropped/orphaned pane is
854/// visible and recoverable rather than silent.
855pub fn reconcile_agents_to_panes(
856    session_name: &str,
857    agents: &[(String, PathBuf)],
858) -> Result<Vec<String>, PawError> {
859    let live: Vec<PathBuf> = list_panes_with_paths(session_name)?
860        .into_iter()
861        .map(|(_, path)| PathBuf::from(path))
862        .collect();
863    Ok(agents_without_live_pane(agents, &live))
864}
865
866// ---------------------------------------------------------------------------
867// Launch-readiness gate (design D1, G1)
868// ---------------------------------------------------------------------------
869
870/// Substrings that positively identify a launched agent CLI's interactive
871/// ready state, as opposed to a bare shell prompt. Conservative phrase matches
872/// drawn from the agent CLIs git-paw supports — a bare shell that merely echoed
873/// a failed command never contains any of these, so a match means the CLI's UI
874/// is up and the boot block is safe to inject.
875///
876/// Extend this when a new agent CLI surfaces a different ready banner. An
877/// unrecognised CLI whose UI matches nothing here falls back to fixed-budget
878/// injection (never worse than the prior fixed-sleep launch).
879pub const CLI_READY_MARKERS: &[&str] = &[
880    "? for shortcuts",
881    "? for help",
882    "Welcome to Claude Code",
883    "esc to interrupt",
884    "Bypassing Permissions",
885    "│ >",
886];
887
888/// Default per-attempt readiness timeout (ms). Matches the prior fixed
889/// pre-injection sleep so the conservative fall-back path (an unrecognised CLI
890/// that never matches a marker) is never slower than the old behaviour; a
891/// recognised CLI returns as soon as its marker appears, typically sooner.
892/// Overridable via `GIT_PAW_READINESS_TIMEOUT_MS` so tests exercise the
893/// fall-back path quickly.
894const READINESS_TIMEOUT_MS: u64 = 2000;
895/// Interval between readiness polls (ms).
896const READINESS_POLL_INTERVAL_MS: u64 = 150;
897/// Number of CLI relaunch attempts after a bare-shell timeout before falling
898/// back to injection.
899const READINESS_RELAUNCH_ATTEMPTS: usize = 1;
900
901/// Classification of a captured pane's content for the launch-readiness gate.
902#[derive(Debug, Clone, Copy, PartialEq, Eq)]
903pub enum PaneReadiness {
904    /// A CLI-readiness marker was observed; the boot block is safe to inject.
905    Ready,
906    /// The pane is still a bare shell prompt (the CLI never started) — the
907    /// G1 condition; relaunch is warranted.
908    BareShell,
909    /// Neither ready nor an obvious bare shell (e.g. a blank/clearing screen
910    /// or an unrecognised CLI). Wait, then conservatively fall back.
911    Indeterminate,
912}
913
914/// Outcome of gating a pane before boot-block injection.
915#[derive(Debug, Clone, Copy, PartialEq, Eq)]
916pub enum GateOutcome {
917    /// A CLI-readiness marker was observed; inject the boot block.
918    Ready,
919    /// The readiness budget elapsed without a positive classification (an
920    /// unrecognised CLI, or a relaunched-but-never-ready pane). The caller
921    /// injects anyway — behaviour matches the prior fixed-sleep launch.
922    FellBack,
923}
924
925/// Per-attempt timeout, poll interval, and relaunch budget for the gate.
926#[derive(Debug, Clone, Copy)]
927pub struct ReadinessBudget {
928    /// Interval between `capture-pane` polls.
929    pub poll_interval: Duration,
930    /// How long to poll for readiness within a single attempt before declaring
931    /// the attempt timed out.
932    pub timeout: Duration,
933    /// Number of CLI relaunch attempts after a bare-shell timeout.
934    pub relaunch_attempts: usize,
935}
936
937impl Default for ReadinessBudget {
938    fn default() -> Self {
939        let timeout_ms = std::env::var("GIT_PAW_READINESS_TIMEOUT_MS")
940            .ok()
941            .and_then(|v| v.parse::<u64>().ok())
942            .unwrap_or(READINESS_TIMEOUT_MS);
943        Self {
944            poll_interval: Duration::from_millis(READINESS_POLL_INTERVAL_MS),
945            timeout: Duration::from_millis(timeout_ms),
946            relaunch_attempts: READINESS_RELAUNCH_ATTEMPTS,
947        }
948    }
949}
950
951/// Returns whether `captured` (the last non-empty line) looks like a returned
952/// shell prompt — ending in a common prompt sigil. Used to distinguish the
953/// G1 bare-shell condition from a CLI whose UI simply has not rendered yet.
954fn looks_like_bare_shell(captured: &str) -> bool {
955    match captured.lines().rev().find(|l| !l.trim().is_empty()) {
956        Some(line) => {
957            let trimmed = line.trim_end();
958            trimmed.ends_with('$')
959                || trimmed.ends_with('%')
960                || trimmed.ends_with('#')
961                || trimmed.ends_with('❯')
962                || trimmed.ends_with('➜')
963        }
964        None => false,
965    }
966}
967
968/// Classify a captured pane's content for the readiness gate.
969#[must_use]
970pub fn classify_pane_readiness(captured: &str) -> PaneReadiness {
971    if CLI_READY_MARKERS.iter().any(|m| captured.contains(m)) {
972        PaneReadiness::Ready
973    } else if looks_like_bare_shell(captured) {
974        PaneReadiness::BareShell
975    } else {
976        PaneReadiness::Indeterminate
977    }
978}
979
980/// Core readiness loop, generic over the capture, relaunch, and sleep
981/// primitives so it is unit-testable without a live tmux server or wall-clock
982/// waits (design D1).
983///
984/// Polls `capture` on `budget.poll_interval` until a [`PaneReadiness::Ready`]
985/// classification is seen or `budget.timeout` elapses. On a bare-shell timeout
986/// it invokes `relaunch` and re-polls, up to `budget.relaunch_attempts`. An
987/// indeterminate or persistently-bare pane returns [`GateOutcome::FellBack`].
988fn gate_pane_generic<C, R, S>(
989    budget: ReadinessBudget,
990    mut capture: C,
991    mut relaunch: R,
992    mut sleep: S,
993) -> GateOutcome
994where
995    C: FnMut() -> Option<String>,
996    R: FnMut(),
997    S: FnMut(Duration),
998{
999    for attempt in 0..=budget.relaunch_attempts {
1000        let mut waited = Duration::ZERO;
1001        loop {
1002            let captured = capture().unwrap_or_default();
1003            if classify_pane_readiness(&captured) == PaneReadiness::Ready {
1004                return GateOutcome::Ready;
1005            }
1006            if waited >= budget.timeout {
1007                break;
1008            }
1009            sleep(budget.poll_interval);
1010            waited = waited.saturating_add(budget.poll_interval);
1011        }
1012        // Attempt timed out. Relaunch only when the pane is positively a bare
1013        // shell AND a relaunch attempt remains; otherwise fall back.
1014        let final_state = classify_pane_readiness(&capture().unwrap_or_default());
1015        if final_state == PaneReadiness::BareShell && attempt < budget.relaunch_attempts {
1016            relaunch();
1017        } else {
1018            break;
1019        }
1020    }
1021    GateOutcome::FellBack
1022}
1023
1024/// Gate an agent pane before boot-block injection (design D1, G1).
1025///
1026/// Polls `tmux capture-pane` for a CLI-readiness marker. If the pane is still a
1027/// bare shell when the per-attempt timeout elapses, relaunches `cli_command`
1028/// into the pane (clearing the input line with `C-u` first, as the launch path
1029/// does) and re-polls, up to the relaunch budget. An unrecognised CLI whose UI
1030/// matches no marker falls back to [`GateOutcome::FellBack`] so the caller
1031/// injects anyway — never worse than the prior fixed-sleep launch.
1032#[must_use]
1033pub fn gate_pane_for_injection(
1034    session_name: &str,
1035    pane_index: usize,
1036    cli_command: &str,
1037) -> GateOutcome {
1038    gate_pane_generic(
1039        ReadinessBudget::default(),
1040        || crate::supervisor::permission_prompt::capture_pane(session_name, pane_index),
1041        || relaunch_cli_into_pane(session_name, pane_index, cli_command),
1042        std::thread::sleep,
1043    )
1044}
1045
1046/// Relaunch `cli_command` into a pane that never reached readiness: clear the
1047/// input line with `C-u` (matching the launch path) then send the command and
1048/// `Enter`. Best-effort — tmux errors are swallowed so the fall-back injection
1049/// still proceeds.
1050fn relaunch_cli_into_pane(session_name: &str, pane_index: usize, cli_command: &str) {
1051    let target = format!("{session_name}:0.{pane_index}");
1052    let _ = Command::new("tmux")
1053        .args(["send-keys", "-t", &target, "C-u"])
1054        .status();
1055    let _ = Command::new("tmux")
1056        .args(["send-keys", "-t", &target, cli_command, "Enter"])
1057        .status();
1058}
1059
1060/// Builds the argv for `tmux send-keys` that injects `text` into
1061/// `<session_name>:0.<pane_index>` literally (`-l`) and *without* a trailing
1062/// `Enter` key.
1063///
1064/// Pulled out as a free function so the manual-mode boot-block injection in
1065/// `cmd_start` and tests share a single source of truth: the call must be
1066/// `send-keys -l -t <target> <text>` (the `-l` flag must come *before* `-t`,
1067/// otherwise tmux parses it as a key spec rather than the literal flag).
1068pub fn build_boot_inject_args(session_name: &str, pane_index: usize, text: &str) -> Vec<String> {
1069    vec![
1070        "send-keys".to_string(),
1071        "-l".to_string(),
1072        "-t".to_string(),
1073        format!("{session_name}:0.{pane_index}"),
1074        text.to_string(),
1075    ]
1076}
1077
1078/// Build the tmux commands that materialise the supervisor-mode pane layout
1079/// described in `openspec/changes/supervisor-as-pane/specs/tmux-orchestration/`.
1080///
1081/// Pane ordering:
1082///
1083/// - Pane 0: supervisor agent (top-left, 50% of the top row)
1084/// - Pane 1: dashboard (top-right, 50% of the top row)
1085/// - Panes 2..N+1: coding agents, row-major (left-to-right, top-to-bottom),
1086///   up to [`crate::supervisor::layout::SUPERVISOR_AGENTS_PER_ROW`] columns
1087///   per row
1088///
1089/// Sequence (see design D2):
1090///
1091/// 1. `new-session -d` creates pane 0 (supervisor).
1092/// 2. `split-window -v -p <bottom_pct>` on pane 0 creates the full-width agent
1093///    area as pane 1 (temporary index).
1094/// 3. `split-window -h -p 50` on pane 0 creates the top-right pane (pane 2),
1095///    the dashboard candidate.
1096/// 4. `swap-pane -s :0.1 -t :0.2` reorders the indices so pane 1 = dashboard
1097///    and pane 2 = agent area.
1098/// 5. For each subsequent agent: `split-window -h` within the current row to
1099///    add a sibling, or `split-window -v` to start a new row.
1100/// 6. Final pass: `resize-pane -t <pane> -y <pct>%` enforces the height
1101///    proportions from the layout table.
1102///
1103/// `select-layout` is intentionally avoided here — it does not preserve the
1104/// predictable pane-index ordering the rest of the system relies on.
1105#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
1106pub fn build_supervisor_session(
1107    project_name: &str,
1108    session_name_override: Option<String>,
1109    supervisor: &PaneSpec,
1110    dashboard: &PaneSpec,
1111    agents: &[PaneSpec],
1112    layout: crate::supervisor::layout::SupervisorLayout,
1113    mouse_mode: bool,
1114    border_affordances: bool,
1115    env_vars: &[(String, String)],
1116) -> Result<TmuxSession, PawError> {
1117    use crate::supervisor::layout::{SUPERVISOR_AGENTS_PER_ROW, SUPERVISOR_PANE_OFFSET};
1118
1119    let session_name = session_name_override.unwrap_or_else(|| format!("paw-{project_name}"));
1120    let mut commands: Vec<TmuxCommand> = Vec::new();
1121
1122    let push = |cmds: &mut Vec<TmuxCommand>, parts: &[&str]| {
1123        cmds.push(TmuxCommand::new(parts));
1124    };
1125
1126    // 1. Create the detached session with pane 0 = supervisor.
1127    // -x/-y give tmux explicit dimensions so it can start without an attached
1128    // client (required in non-TTY environments like CI). The real terminal
1129    // resizes the session on attach.
1130    push(
1131        &mut commands,
1132        &[
1133            "new-session",
1134            "-d",
1135            "-s",
1136            &session_name,
1137            "-x",
1138            "480",
1139            "-y",
1140            "140",
1141            // Suppress interactive shell startup prompts that would otherwise
1142            // fire as pane 0's shell reads its rc and could swallow the first
1143            // keystroke of the CLI-launch command (W2-2: oh-my-zsh's
1144            // `Would you like to update? [Y/n]` ate the leading `c` of the CLI
1145            // name). `-e` sets the variables BEFORE the shell starts, so the
1146            // framework never prompts. Inert for shells that don't read them.
1147            "-e",
1148            "DISABLE_AUTO_UPDATE=true",
1149            "-e",
1150            "DISABLE_UPDATE_PROMPT=true",
1151            "-c",
1152            &supervisor.worktree,
1153        ],
1154    );
1155
1156    // 2. Pin default-size globally so subsequent split-window operations
1157    // have a fallback size context. On Linux tmux 3.4+, `-x/-y` on
1158    // new-session alone is insufficient — subsequent splits fail with
1159    // `size missing` because the per-session dimensions aren't propagated
1160    // to the layout engine when no client is attached.
1161    push(
1162        &mut commands,
1163        &["set-option", "-g", "default-size", "480x140"],
1164    );
1165
1166    // Carry the shell-startup-prompt suppression (W2-2) into the session
1167    // environment too, so the agent panes created by later `split-window`
1168    // calls inherit it (the `-e` flags above only cover pane 0's shell).
1169    push(
1170        &mut commands,
1171        &[
1172            "set-environment",
1173            "-t",
1174            &session_name,
1175            "DISABLE_AUTO_UPDATE",
1176            "true",
1177        ],
1178    );
1179    push(
1180        &mut commands,
1181        &[
1182            "set-environment",
1183            "-t",
1184            &session_name,
1185            "DISABLE_UPDATE_PROMPT",
1186            "true",
1187        ],
1188    );
1189
1190    // 2. Mouse + pane border config.
1191    if mouse_mode {
1192        push(
1193            &mut commands,
1194            &["set-option", "-t", &session_name, "mouse", "on"],
1195        );
1196    }
1197    if border_affordances {
1198        push_border_affordances(&mut commands, &session_name);
1199    }
1200
1201    // 3. Session-level environment variables (before any send-keys).
1202    for (key, value) in env_vars {
1203        push(
1204            &mut commands,
1205            &["set-environment", "-t", &session_name, key, value],
1206        );
1207    }
1208
1209    let supervisor_target = format!("{session_name}:0.0");
1210    push_pane_title(
1211        &mut commands,
1212        border_affordances,
1213        &supervisor_target,
1214        &supervisor.branch,
1215    );
1216    // Clear the input line before launching (W2-2): a stray shell-startup
1217    // prompt or buffered keystroke could otherwise corrupt the leading
1218    // character of the CLI command. `C-u` on a clean prompt is a no-op.
1219    push(
1220        &mut commands,
1221        &["send-keys", "-t", &supervisor_target, "C-u"],
1222    );
1223    push(
1224        &mut commands,
1225        &[
1226            "send-keys",
1227            "-t",
1228            &supervisor_target,
1229            &supervisor.cli_command,
1230            "Enter",
1231        ],
1232    );
1233
1234    // 4. Split pane 0 vertically -> creates the full-width agent area (now
1235    //    index 1, swapped to index 2 below). When there is at least one
1236    //    coding agent we pass `-c <first_agent.worktree>` so the agent area
1237    //    pane is born in the first agent's worktree directly — this avoids
1238    //    the `cd <worktree> && <cli>` send-keys race that previously left
1239    //    resumed agent panes anchored in the supervisor's cwd.
1240    //
1241    // Use `-l <N>%` (the modern tmux 3.1+ form) instead of the deprecated
1242    // `-p <N>`. On Linux tmux 3.4 (Ubuntu 24.04 apt-package), `-p`
1243    // resolves the percentage against the parent pane's laid-out size,
1244    // which is empty on a detached server with no attached client — tmux
1245    // bails with `cmd-split-window.c: "size missing"`. `-l <N>%` resolves
1246    // against the window's `-y` dimension instead, which is the value we
1247    // set on `new-session -x 200 -y 50`, so the split math succeeds in
1248    // headless mode. macOS tmux 3.6a tolerates either form.
1249    let bottom_pct = format!("{}%", 100u16 - u16::from(layout.top_row_pct));
1250    // W3-1: step 6 swaps panes 1 and 2. `swap-pane` carries each pane's cwd
1251    // to the OTHER index, but the CLI commands + titles are sent post-swap by
1252    // index — so the `-c` cwds must be assigned to COMPENSATE for the swap.
1253    // The agent-area split (which lands at the dashboard's index-1 after the
1254    // swap) therefore gets the dashboard's cwd, and the dashboard split (which
1255    // lands at the agent's index-2) gets the first agent's worktree. Without
1256    // this compensation the first agent's pane inherits the supervisor's
1257    // repo-root cwd and its commits land on the wrong branch (contamination).
1258    if agents.is_empty() {
1259        push(
1260            &mut commands,
1261            &[
1262                "split-window",
1263                "-v",
1264                "-t",
1265                &supervisor_target,
1266                "-l",
1267                &bottom_pct,
1268            ],
1269        );
1270    } else {
1271        push(
1272            &mut commands,
1273            &[
1274                "split-window",
1275                "-v",
1276                "-t",
1277                &supervisor_target,
1278                "-l",
1279                &bottom_pct,
1280                "-c",
1281                &dashboard.worktree,
1282            ],
1283        );
1284    }
1285
1286    // 5. Split pane 0 horizontally -> creates the top-right pane (currently
1287    //    index 2, swapped to index 1 below) at 50% width.
1288    // Same `-l <N>%` reasoning as step 4. Per the W3-1 swap-compensation note
1289    // above, this split (which lands at the agent's index-2 after the swap)
1290    // is born in the FIRST agent's worktree, so the agent's CLI — sent to
1291    // index 2 post-swap — runs in its own worktree, not the repo root.
1292    let dashboard_split_cwd = agents
1293        .first()
1294        .map_or(dashboard.worktree.as_str(), |a| a.worktree.as_str());
1295    push(
1296        &mut commands,
1297        &[
1298            "split-window",
1299            "-h",
1300            "-t",
1301            &supervisor_target,
1302            "-l",
1303            "50%",
1304            "-c",
1305            dashboard_split_cwd,
1306        ],
1307    );
1308
1309    // 6. Swap indices so pane 1 = dashboard, pane 2 = agent area.
1310    let pane_one = format!("{session_name}:0.1");
1311    let pane_two = format!("{session_name}:0.2");
1312    push(
1313        &mut commands,
1314        &["swap-pane", "-s", &pane_one, "-t", &pane_two],
1315    );
1316
1317    // 7. Set dashboard title + run its command in pane 1 (after swap).
1318    let dashboard_target = format!("{session_name}:0.1");
1319    push_pane_title(
1320        &mut commands,
1321        border_affordances,
1322        &dashboard_target,
1323        &dashboard.branch,
1324    );
1325    push(
1326        &mut commands,
1327        &["send-keys", "-t", &dashboard_target, "C-u"],
1328    );
1329    push(
1330        &mut commands,
1331        &[
1332            "send-keys",
1333            "-t",
1334            &dashboard_target,
1335            &dashboard.cli_command,
1336            "Enter",
1337        ],
1338    );
1339
1340    // 8. Populate the agent grid.
1341    if !agents.is_empty() {
1342        // First agent: the agent area is already pane 2 (post-swap) and was
1343        // created with `-c <first.worktree>` above, so its shell is already
1344        // running in the first agent's worktree. Send only the bare CLI
1345        // command — no `cd <worktree> && <cli>` chain, which would race with
1346        // shell startup.
1347        let first_target = format!("{session_name}:0.{SUPERVISOR_PANE_OFFSET}");
1348        let first = &agents[0];
1349        push_pane_title(
1350            &mut commands,
1351            border_affordances,
1352            &first_target,
1353            &first.branch,
1354        );
1355        push(&mut commands, &["send-keys", "-t", &first_target, "C-u"]);
1356        push(
1357            &mut commands,
1358            &[
1359                "send-keys",
1360                "-t",
1361                &first_target,
1362                &first.cli_command,
1363                "Enter",
1364            ],
1365        );
1366
1367        let mut row_first_pane = SUPERVISOR_PANE_OFFSET;
1368
1369        for (i, agent) in agents.iter().enumerate().skip(1) {
1370            let pane_idx = SUPERVISOR_PANE_OFFSET + i;
1371            let pane_target = format!("{session_name}:0.{pane_idx}");
1372            let position_in_row = i % SUPERVISOR_AGENTS_PER_ROW;
1373            let starts_new_row = position_in_row == 0;
1374
1375            if starts_new_row {
1376                // Vertical split from this row's first pane to add a new row
1377                // below.
1378                let src_target = format!("{session_name}:0.{row_first_pane}");
1379                push(
1380                    &mut commands,
1381                    &[
1382                        "split-window",
1383                        "-v",
1384                        "-t",
1385                        &src_target,
1386                        "-c",
1387                        &agent.worktree,
1388                    ],
1389                );
1390                row_first_pane = pane_idx;
1391            } else {
1392                // Horizontal split from the previous pane to add a sibling in
1393                // the same row.
1394                let prev_idx = pane_idx - 1;
1395                let prev_target = format!("{session_name}:0.{prev_idx}");
1396                push(
1397                    &mut commands,
1398                    &[
1399                        "split-window",
1400                        "-h",
1401                        "-t",
1402                        &prev_target,
1403                        "-c",
1404                        &agent.worktree,
1405                    ],
1406                );
1407            }
1408
1409            push_pane_title(
1410                &mut commands,
1411                border_affordances,
1412                &pane_target,
1413                &agent.branch,
1414            );
1415            push(&mut commands, &["send-keys", "-t", &pane_target, "C-u"]);
1416            push(
1417                &mut commands,
1418                &["send-keys", "-t", &pane_target, &agent.cli_command, "Enter"],
1419            );
1420        }
1421    }
1422
1423    // 9. Final pass: resize-pane to enforce the layout-table heights. One
1424    //    resize-pane per row (top + each agent row). Shared with the add /
1425    //    remove re-tile path via `push_supervisor_resize_pass` so an
1426    //    incrementally re-tiled grid matches a start-time grid of the same
1427    //    agent count. Percentages use `<pct>%` syntax which tmux 3.x accepts.
1428    push_supervisor_resize_pass(&mut commands, &session_name, layout, agents.len());
1429
1430    Ok(TmuxSession {
1431        name: session_name,
1432        commands,
1433    })
1434}
1435
1436/// Build the tmux commands that splice ONE new agent pane into a running
1437/// supervisor-mode session and re-tile the grid to `layout` (design D1, the
1438/// add path).
1439///
1440/// `prev_agent_count` is the number of coding agents already in the session
1441/// (N); the new agent becomes agent index N (0-based), landing at pane
1442/// `SUPERVISOR_PANE_OFFSET + N`. The split mirrors `build_supervisor_session`'s
1443/// grid logic:
1444///
1445/// - When the new agent starts a fresh row (`N % AGENTS_PER_ROW == 0`, N > 0),
1446///   `split-window -v` from the previous row's first pane.
1447/// - Otherwise `split-window -h` from the immediately preceding pane.
1448///
1449/// `select-layout` is intentionally avoided (as in `build_supervisor_session`)
1450/// so existing panes keep their indices for in-flight `send-keys` targeting;
1451/// the new pane gets the next index. A final `resize-pane` pass per row
1452/// enforces `layout`'s height proportions for the new total (N+1).
1453///
1454/// Returns a [`TmuxSession`] so the caller runs it with
1455/// [`TmuxSession::execute`] and tests inspect it with
1456/// [`TmuxSession::command_strings`]. The boot-prompt submit is the caller's
1457/// responsibility (it differs for active vs. paused sessions).
1458#[must_use]
1459pub fn build_add_agent_commands(
1460    session_name: &str,
1461    new_agent: &PaneSpec,
1462    prev_agent_count: usize,
1463    layout: crate::supervisor::layout::SupervisorLayout,
1464    border_affordances: bool,
1465) -> TmuxSession {
1466    use crate::supervisor::layout::{SUPERVISOR_AGENTS_PER_ROW, SUPERVISOR_PANE_OFFSET};
1467
1468    let mut commands: Vec<TmuxCommand> = Vec::new();
1469    let i = prev_agent_count; // 0-based agent index of the new agent
1470    let pane_idx = SUPERVISOR_PANE_OFFSET + i;
1471    let pane_target = format!("{session_name}:0.{pane_idx}");
1472
1473    if i > 0 && i.is_multiple_of(SUPERVISOR_AGENTS_PER_ROW) {
1474        // New row: vertical split from the previous row's first pane.
1475        let prev_row_first = SUPERVISOR_PANE_OFFSET + (i - SUPERVISOR_AGENTS_PER_ROW);
1476        let src = format!("{session_name}:0.{prev_row_first}");
1477        commands.push(TmuxCommand::new(&[
1478            "split-window",
1479            "-v",
1480            "-t",
1481            &src,
1482            "-c",
1483            &new_agent.worktree,
1484        ]));
1485    } else {
1486        // Same row: horizontal split from the immediately preceding pane.
1487        let prev = format!("{session_name}:0.{}", pane_idx - 1);
1488        commands.push(TmuxCommand::new(&[
1489            "split-window",
1490            "-h",
1491            "-t",
1492            &prev,
1493            "-c",
1494            &new_agent.worktree,
1495        ]));
1496    }
1497
1498    push_pane_title(
1499        &mut commands,
1500        border_affordances,
1501        &pane_target,
1502        &new_agent.branch,
1503    );
1504    commands.push(TmuxCommand::new(&["send-keys", "-t", &pane_target, "C-u"]));
1505    commands.push(TmuxCommand::new(&[
1506        "send-keys",
1507        "-t",
1508        &pane_target,
1509        &new_agent.cli_command,
1510        "Enter",
1511    ]));
1512
1513    push_supervisor_resize_pass(&mut commands, session_name, layout, prev_agent_count + 1);
1514
1515    TmuxSession {
1516        name: session_name.to_string(),
1517        commands,
1518    }
1519}
1520
1521/// Build the tmux commands that re-tile a supervisor-mode grid AFTER one
1522/// agent's pane has been killed (design D6, the remove path).
1523///
1524/// The caller kills the target pane first (via [`kill_pane`]); tmux then
1525/// renumbers the remaining panes to be contiguous, so each surviving row's
1526/// first pane is still addressable at `SUPERVISOR_PANE_OFFSET + row * AGENTS_PER_ROW`.
1527/// This emits the per-row `resize-pane` pass for `layout` (computed for the new,
1528/// smaller `remaining_agent_count`) so the grid re-flows to the proportions a
1529/// start of that many agents would produce, without leaving a hole.
1530///
1531/// Returns an empty command set when no agents remain (the supervisor +
1532/// dashboard top row is left as-is). Branch→pane mapping for the survivors is
1533/// re-derived by the supervisor via `pane_current_path` each sweep, so the
1534/// transient index shift is invisible to targeting.
1535#[must_use]
1536pub fn build_remove_retile_commands(
1537    session_name: &str,
1538    remaining_agent_count: usize,
1539    layout: crate::supervisor::layout::SupervisorLayout,
1540) -> TmuxSession {
1541    let mut commands: Vec<TmuxCommand> = Vec::new();
1542    if remaining_agent_count > 0 {
1543        push_supervisor_resize_pass(&mut commands, session_name, layout, remaining_agent_count);
1544    }
1545    TmuxSession {
1546        name: session_name.to_string(),
1547        commands,
1548    }
1549}
1550
1551/// Push the per-row `resize-pane -y <pct>%` pass that enforces a supervisor
1552/// layout's height proportions: one resize for the top row (supervisor +
1553/// dashboard) and one per agent row (targeting each row's first pane). Shared
1554/// by the start-time builder's final pass and the add/remove re-tile builders.
1555///
1556/// The per-row equal-width rebalance is applied separately at runtime by
1557/// [`rebalance_agent_rows`] (design D4, G3): it needs the live window width to
1558/// resize each row to exact, equal columns, which a pure command builder
1559/// cannot know.
1560fn push_supervisor_resize_pass(
1561    commands: &mut Vec<TmuxCommand>,
1562    session_name: &str,
1563    layout: crate::supervisor::layout::SupervisorLayout,
1564    agent_count: usize,
1565) {
1566    use crate::supervisor::layout::{SUPERVISOR_AGENTS_PER_ROW, SUPERVISOR_PANE_OFFSET};
1567
1568    let top_target = format!("{session_name}:0.0");
1569    let top_pct_str = format!("{}%", layout.top_row_pct);
1570    commands.push(TmuxCommand::new(&[
1571        "resize-pane",
1572        "-t",
1573        &top_target,
1574        "-y",
1575        &top_pct_str,
1576    ]));
1577
1578    let agent_row_pct_str = format_supervisor_pct(layout.agent_row_pct);
1579    for row in 0..layout.agent_rows {
1580        let pane_idx = SUPERVISOR_PANE_OFFSET + row * SUPERVISOR_AGENTS_PER_ROW;
1581        if pane_idx < SUPERVISOR_PANE_OFFSET + agent_count {
1582            let target = format!("{session_name}:0.{pane_idx}");
1583            commands.push(TmuxCommand::new(&[
1584                "resize-pane",
1585                "-t",
1586                &target,
1587                "-y",
1588                &agent_row_pct_str,
1589            ]));
1590        }
1591    }
1592}
1593
1594/// Compute the per-pane column-width resize targets for the equal-width row
1595/// rebalance (design D4, G3), given the live `window_width` and total
1596/// `agent_count`.
1597///
1598/// Agents are spliced into a row by successive `split-window -h`, and each
1599/// `-h` split halves the *current* pane — so a raw 3-agent row renders
1600/// 50/25/25, not equal thirds (the v0.8.0 G3 failure). For each agent row
1601/// (up to [`crate::supervisor::layout::SUPERVISOR_AGENTS_PER_ROW`] panes) this
1602/// resizes every pane but the last to `(window_width - separators) /
1603/// panes_in_row` columns — an equal share after accounting for the
1604/// one-column pane separators — and lets the last pane absorb the rounding
1605/// remainder, leaving the row equal-width within a one-column tolerance.
1606/// Returns `(pane_index, columns)` pairs for the panes to resize (the last
1607/// pane of each row is omitted).
1608///
1609/// Pure (no IO) so the row arithmetic is unit-testable; the live application
1610/// is [`rebalance_agent_rows`].
1611#[must_use]
1612pub fn agent_row_widths(window_width: usize, agent_count: usize) -> Vec<(usize, usize)> {
1613    use crate::supervisor::layout::{SUPERVISOR_AGENTS_PER_ROW, SUPERVISOR_PANE_OFFSET};
1614
1615    let mut targets = Vec::new();
1616    if window_width == 0 {
1617        return targets;
1618    }
1619    let mut row_first_agent = 0;
1620    while row_first_agent < agent_count {
1621        let panes_in_row = (agent_count - row_first_agent).min(SUPERVISOR_AGENTS_PER_ROW);
1622        if panes_in_row > 1 {
1623            let separators = panes_in_row - 1;
1624            let per = window_width.saturating_sub(separators) / panes_in_row;
1625            for j in 0..(panes_in_row - 1) {
1626                targets.push((SUPERVISOR_PANE_OFFSET + row_first_agent + j, per));
1627            }
1628        }
1629        row_first_agent += panes_in_row;
1630    }
1631    targets
1632}
1633
1634/// Query the live window width (columns) of `session_name`'s window 0.
1635///
1636/// Returns `Ok(None)` when the session/window is gone so callers degrade to a
1637/// no-op rather than failing.
1638fn query_window_width(session_name: &str) -> Result<Option<usize>, PawError> {
1639    let target = format!("{session_name}:0");
1640    let output = Command::new("tmux")
1641        .args(["display-message", "-p", "-t", &target, "#{window_width}"])
1642        .output()
1643        .map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
1644    if !output.status.success() {
1645        return Ok(None);
1646    }
1647    Ok(String::from_utf8_lossy(&output.stdout).trim().parse().ok())
1648}
1649
1650/// Rebalance every agent row to equal width on the live window (design D4, G3).
1651///
1652/// Queries the live window width, then resizes each row's panes (all but the
1653/// last, which absorbs the remainder) to an equal column share via
1654/// `tmux resize-pane -x <cols>`, so a row of agents renders equal-width within
1655/// a one-column tolerance instead of the 50/25/25 raw `-h` splits produce. A
1656/// no-op for 0 or 1 agents, or when the window is gone. Setting a pane's `-x`
1657/// width only moves its boundary with the horizontal neighbour in the same
1658/// row, so this never disturbs the top-row supervisor/dashboard 50/50 split
1659/// nor the per-row vertical heights.
1660///
1661/// Must run AFTER the splits (start/add) or the kill + height re-tile (remove)
1662/// settle so pane indices are contiguous (panes 2..N+1). No agent row exceeds
1663/// [`crate::supervisor::layout::SUPERVISOR_AGENTS_PER_ROW`] (5) by
1664/// construction, bounding the smallest equal-width target to ~20% per pane
1665/// (design D5). Best-effort per resize; one pane's tmux failure does not abort
1666/// the rest.
1667pub fn rebalance_agent_rows(session_name: &str, agent_count: usize) -> Result<(), PawError> {
1668    let Some(window_width) = query_window_width(session_name)? else {
1669        return Ok(());
1670    };
1671    for (pane_idx, cols) in agent_row_widths(window_width, agent_count) {
1672        let target = format!("{session_name}:0.{pane_idx}");
1673        let cols_str = cols.to_string();
1674        let _ = Command::new("tmux")
1675            .args(["resize-pane", "-t", &target, "-x", &cols_str])
1676            .status();
1677    }
1678    Ok(())
1679}
1680
1681/// Format a row-height percentage. Whole numbers render as "28%"; the 14.4%
1682/// bucket renders as "14.4%".
1683fn format_supervisor_pct(pct: f32) -> String {
1684    if (pct - pct.round()).abs() < 0.05 {
1685        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1686        let rounded = pct.round().clamp(0.0, 100.0) as u32;
1687        format!("{rounded}%")
1688    } else {
1689        format!("{pct:.1}%")
1690    }
1691}
1692
1693/// Build the argv pair for submitting a supervisor-mode initial prompt to a
1694/// coding agent pane. The first argv pastes the prompt and sends `Enter`
1695/// (which paste-aware CLIs consume to confirm the paste buffer). The second
1696/// argv sends a second `Enter` to actually submit the buffered content. On
1697/// non-paste-aware CLIs the first `Enter` submits and the second `Enter` is
1698/// a benign no-op or blank prompt.
1699///
1700/// Returns a tuple `(first_argv, second_argv)`. Callers are expected to
1701/// invoke `tmux send-keys <first_argv>`, sleep `SUBMIT_DELAY_MS`, then invoke
1702/// `tmux send-keys <second_argv>` as a separate process invocation so the
1703/// CLI has wall-clock time to render the paste-buffer placeholder.
1704#[must_use]
1705pub fn build_supervisor_submit_argv_pair(
1706    session_name: &str,
1707    pane_index: usize,
1708    prompt: &str,
1709) -> (Vec<String>, Vec<String>) {
1710    let target = format!("{session_name}:0.{pane_index}");
1711    let first = vec![
1712        "send-keys".to_string(),
1713        "-t".to_string(),
1714        target.clone(),
1715        prompt.to_string(),
1716        "Enter".to_string(),
1717    ];
1718    let second = vec![
1719        "send-keys".to_string(),
1720        "-t".to_string(),
1721        target,
1722        "Enter".to_string(),
1723    ];
1724    (first, second)
1725}
1726
1727#[cfg(test)]
1728mod tests {
1729    use super::*;
1730
1731    fn make_pane(branch: &str, worktree: &str, cli: &str) -> PaneSpec {
1732        PaneSpec {
1733            branch: branch.to_owned(),
1734            worktree: worktree.to_owned(),
1735            cli_command: cli.to_owned(),
1736        }
1737    }
1738
1739    /// Helper: extract command strings matching a keyword from a session's commands.
1740    fn commands_containing(cmds: &[String], keyword: &str) -> Vec<String> {
1741        cmds.iter()
1742            .filter(|c| c.contains(keyword))
1743            .cloned()
1744            .collect()
1745    }
1746
1747    // -----------------------------------------------------------------------
1748    // AC: Checks tmux presence with actionable error
1749    // Behavioral: verifies the public contract — does the system detect tmux?
1750    // -----------------------------------------------------------------------
1751
1752    #[test]
1753    #[serial_test::serial]
1754    fn ensure_tmux_installed_succeeds_when_present() {
1755        // Requires #[serial] because detect tests modify PATH.
1756        assert!(ensure_tmux_installed().is_ok());
1757    }
1758
1759    // -----------------------------------------------------------------------
1760    // AC: Creates named sessions, handles collision
1761    // Behavioral: session name is a public field used by attach, status, and
1762    // dry-run output. The exact naming convention is the public contract.
1763    // -----------------------------------------------------------------------
1764
1765    #[test]
1766    fn session_is_named_after_project() {
1767        let session = TmuxSessionBuilder::new("my-project")
1768            .add_pane(make_pane("main", "/tmp/wt", "claude"))
1769            .build()
1770            .unwrap();
1771
1772        assert_eq!(session.name, "paw-my-project");
1773    }
1774
1775    #[test]
1776    fn session_creation_command_uses_session_name() {
1777        let session = TmuxSessionBuilder::new("app")
1778            .add_pane(make_pane("main", "/tmp/wt", "claude"))
1779            .build()
1780            .unwrap();
1781
1782        let cmds = session.command_strings();
1783        assert!(
1784            cmds.iter()
1785                .any(|c| c.contains("new-session") && c.contains("paw-app")),
1786            "should create a tmux session named paw-app"
1787        );
1788    }
1789
1790    /// AC: Session creation passes explicit dimensions for headless environments
1791    /// — basic builder.
1792    #[test]
1793    fn new_session_passes_explicit_x_and_y() {
1794        let session = TmuxSessionBuilder::new("app")
1795            .add_pane(make_pane("main", "/tmp/wt", "claude"))
1796            .build()
1797            .unwrap();
1798
1799        let cmds = session.command_strings();
1800        let new_session_cmd = cmds
1801            .iter()
1802            .find(|c| c.contains("new-session"))
1803            .expect("new-session command present");
1804        assert!(
1805            new_session_cmd.contains("-x 480"),
1806            "new-session must pass -x 480; got: {new_session_cmd}"
1807        );
1808        assert!(
1809            new_session_cmd.contains("-y 140"),
1810            "new-session must pass -y 140; got: {new_session_cmd}"
1811        );
1812    }
1813
1814    /// AC: Session creation sets global default-size after new-session
1815    /// — basic builder.
1816    #[test]
1817    fn basic_builder_sets_default_size_after_new_session() {
1818        let session = TmuxSessionBuilder::new("app")
1819            .add_pane(make_pane("main", "/tmp/wt", "claude"))
1820            .build()
1821            .unwrap();
1822
1823        let cmds = session.command_strings();
1824        let new_session_idx = cmds
1825            .iter()
1826            .position(|c| c.contains("new-session"))
1827            .expect("new-session in command list");
1828        let default_size_idx = cmds
1829            .iter()
1830            .position(|c| {
1831                c.contains("set-option") && c.contains("default-size") && c.contains("480x140")
1832            })
1833            .expect("set-option default-size 200x50 in command list");
1834        assert!(
1835            default_size_idx > new_session_idx,
1836            "set-option default-size must come AFTER new-session (set-option needs a running server); got order new={new_session_idx}, default-size={default_size_idx}"
1837        );
1838    }
1839
1840    #[test]
1841    fn session_name_override_replaces_default() {
1842        let session = TmuxSessionBuilder::new("my-project")
1843            .session_name("custom-session-name".to_string())
1844            .add_pane(make_pane("main", "/tmp/wt", "claude"))
1845            .build()
1846            .unwrap();
1847
1848        assert_eq!(session.name, "custom-session-name");
1849        let cmds = session.command_strings();
1850        assert!(
1851            cmds.iter()
1852                .any(|c| c.contains("new-session") && c.contains("custom-session-name")),
1853            "should use overridden session name"
1854        );
1855    }
1856
1857    // -----------------------------------------------------------------------
1858    // AC: Dynamic pane count based on input
1859    // Dry-run contract: verifies the number of commands matches the number of
1860    // panes the user requested. Actual pane creation verified by e2e test
1861    // tmux_session_with_five_panes_and_different_clis.
1862    // -----------------------------------------------------------------------
1863
1864    #[test]
1865    fn pane_count_matches_input_for_two_panes() {
1866        let session = TmuxSessionBuilder::new("proj")
1867            .add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
1868            .add_pane(make_pane("feat/api", "/tmp/wt2", "codex"))
1869            .build()
1870            .unwrap();
1871
1872        let cmds = session.command_strings();
1873        let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
1874            .into_iter()
1875            .filter(|c| !c.trim_end().ends_with("C-u"))
1876            .collect();
1877        assert_eq!(
1878            send_keys.len(),
1879            2,
1880            "should send commands to exactly 2 panes"
1881        );
1882    }
1883
1884    #[test]
1885    fn pane_count_matches_input_for_five_panes() {
1886        let mut builder = TmuxSessionBuilder::new("proj");
1887        for i in 0..5 {
1888            builder = builder.add_pane(make_pane(
1889                &format!("feat/b{i}"),
1890                &format!("/tmp/wt{i}"),
1891                "claude",
1892            ));
1893        }
1894        let session = builder.build().unwrap();
1895
1896        let cmds = session.command_strings();
1897        let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
1898            .into_iter()
1899            .filter(|c| !c.trim_end().ends_with("C-u"))
1900            .collect();
1901        assert_eq!(
1902            send_keys.len(),
1903            5,
1904            "should send commands to exactly 5 panes"
1905        );
1906    }
1907
1908    #[test]
1909    fn building_with_no_panes_is_an_error() {
1910        let result = TmuxSessionBuilder::new("proj").build();
1911        assert!(result.is_err(), "session with no panes should fail");
1912    }
1913
1914    // -----------------------------------------------------------------------
1915    // AC: Correct commands sent to panes
1916    // Dry-run contract: users see these exact commands in --dry-run output,
1917    // so the format (CLI command in send-keys, worktree on split-window -c)
1918    // is user-facing.
1919    // -----------------------------------------------------------------------
1920
1921    #[test]
1922    fn each_pane_receives_bare_cli_command_and_split_carries_worktree() {
1923        let session = TmuxSessionBuilder::new("proj")
1924            .add_pane(make_pane("feat/auth", "/home/user/wt-auth", "claude"))
1925            .add_pane(make_pane("feat/api", "/home/user/wt-api", "gemini"))
1926            .build()
1927            .unwrap();
1928
1929        let cmds = session.command_strings();
1930        let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
1931            .into_iter()
1932            .filter(|c| !c.trim_end().ends_with("C-u"))
1933            .collect();
1934
1935        // Pane 0 uses `-c` on `new-session` for its directory and runs only
1936        // the bare CLI command.
1937        assert!(
1938            send_keys[0].contains("claude"),
1939            "first pane should run claude; got: {}",
1940            send_keys[0]
1941        );
1942
1943        // Subsequent panes must not prefix `cd <worktree> &&` — the cwd is
1944        // baked into the split via `-c <worktree>` instead, avoiding the
1945        // send-keys race documented at the call site.
1946        assert!(
1947            send_keys[1].contains("gemini"),
1948            "second pane should run gemini; got: {}",
1949            send_keys[1]
1950        );
1951        assert!(
1952            !send_keys[1].contains("cd /home/user/wt-api"),
1953            "second pane send-keys MUST NOT prefix `cd <worktree>`; got: {}",
1954            send_keys[1]
1955        );
1956
1957        // The split-window that creates pane 1 should carry the worktree as
1958        // `-c <worktree>`.
1959        let splits = commands_containing(&cmds, "split-window");
1960        assert!(
1961            splits.iter().any(|c| c.contains("-c /home/user/wt-api")),
1962            "split-window for pane 1 should pass -c /home/user/wt-api; got: {splits:?}"
1963        );
1964    }
1965
1966    #[test]
1967    fn pane_commands_are_submitted_with_enter() {
1968        let session = TmuxSessionBuilder::new("proj")
1969            .add_pane(make_pane("main", "/tmp/wt", "aider"))
1970            .build()
1971            .unwrap();
1972
1973        let cmds = session.command_strings();
1974        let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
1975            .into_iter()
1976            .filter(|c| !c.trim_end().ends_with("C-u"))
1977            .collect();
1978        assert!(
1979            send_keys[0].contains("Enter"),
1980            "send-keys should press Enter to submit"
1981        );
1982    }
1983
1984    #[test]
1985    fn each_pane_targets_a_distinct_pane_index() {
1986        let session = TmuxSessionBuilder::new("proj")
1987            .add_pane(make_pane("feat/a", "/tmp/a", "claude"))
1988            .add_pane(make_pane("feat/b", "/tmp/b", "codex"))
1989            .add_pane(make_pane("feat/c", "/tmp/c", "gemini"))
1990            .build()
1991            .unwrap();
1992
1993        let cmds = session.command_strings();
1994        let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
1995            .into_iter()
1996            .filter(|c| !c.trim_end().ends_with("C-u"))
1997            .collect();
1998
1999        assert!(
2000            send_keys[0].contains(":0.0"),
2001            "first pane should target :0.0"
2002        );
2003        assert!(
2004            send_keys[1].contains(":0.1"),
2005            "second pane should target :0.1"
2006        );
2007        assert!(
2008            send_keys[2].contains(":0.2"),
2009            "third pane should target :0.2"
2010        );
2011    }
2012
2013    // -----------------------------------------------------------------------
2014    // AC: Pane titles show branch and CLI
2015    // Dry-run contract: title format is user-visible in both --dry-run output
2016    // and tmux pane borders. Actual tmux titles verified by e2e test
2017    // tmux_session_with_five_panes_and_different_clis.
2018    // -----------------------------------------------------------------------
2019
2020    #[test]
2021    fn each_pane_is_titled_with_its_branch() {
2022        let session = TmuxSessionBuilder::new("proj")
2023            .add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
2024            .add_pane(make_pane("fix/api", "/tmp/wt2", "gemini"))
2025            .build()
2026            .unwrap();
2027
2028        let cmds = session.command_strings();
2029        let select_panes = commands_containing(&cmds, "select-pane");
2030
2031        assert_eq!(select_panes.len(), 2, "each pane should get a title");
2032        // The title is the pane's branch id only — the CLI command is no
2033        // longer part of the title (it reads cleanly in the label strip).
2034        assert!(
2035            select_panes[0].ends_with("-T feat/auth"),
2036            "first pane title should be 'feat/auth', got: {}",
2037            select_panes[0]
2038        );
2039        assert!(
2040            !select_panes[0].contains("claude"),
2041            "first pane title should not include the CLI command, got: {}",
2042            select_panes[0]
2043        );
2044        assert!(
2045            select_panes[1].ends_with("-T fix/api"),
2046            "second pane title should be 'fix/api', got: {}",
2047            select_panes[1]
2048        );
2049    }
2050
2051    /// Scenario: Each pane also gets a pane-scoped `@paw_role` user option
2052    /// carrying its role label. This is the clobber-proof source of the border
2053    /// label: the agent CLI overwrites `#{pane_title}` via OSC sequences, but
2054    /// the `@paw_role` pane option git-paw sets is never overwritten, so the
2055    /// `pane-border-format` conditional keeps showing the role.
2056    #[test]
2057    fn each_pane_gets_a_stable_paw_role_option() {
2058        let session = TmuxSessionBuilder::new("proj")
2059            .add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
2060            .add_pane(make_pane("fix/api", "/tmp/wt2", "gemini"))
2061            .build()
2062            .unwrap();
2063
2064        let cmds = session.command_strings();
2065        // Pane-scoped option assignments only — exclude the pane-border-format
2066        // command, which also mentions @paw_role inside its conditional.
2067        let role_opts: Vec<&String> = cmds
2068            .iter()
2069            .filter(|c| c.contains("set-option") && c.contains(" -p ") && c.contains("@paw_role"))
2070            .collect();
2071        assert_eq!(
2072            role_opts.len(),
2073            2,
2074            "each pane should get a @paw_role option"
2075        );
2076        assert!(
2077            role_opts.iter().any(|c| c.ends_with("@paw_role feat/auth")),
2078            "first pane should set `@paw_role feat/auth` pane-scoped; got: {role_opts:#?}"
2079        );
2080        assert!(
2081            role_opts.iter().any(|c| c.ends_with("@paw_role fix/api")),
2082            "second pane should set `@paw_role fix/api`; got: {role_opts:#?}"
2083        );
2084    }
2085
2086    #[test]
2087    fn pane_border_status_is_configured() {
2088        let session = TmuxSessionBuilder::new("proj")
2089            .add_pane(make_pane("main", "/tmp/wt", "claude"))
2090            .build()
2091            .unwrap();
2092
2093        let cmds = session.command_strings();
2094        assert!(
2095            cmds.iter()
2096                .any(|c| c.contains("pane-border-status") && c.contains("top")),
2097            "should configure pane-border-status to top"
2098        );
2099        assert!(
2100            cmds.iter()
2101                .any(|c| c.contains("pane-border-format") && c.contains("#{pane_title}")),
2102            "should configure pane-border-format to show pane title"
2103        );
2104    }
2105
2106    // -----------------------------------------------------------------------
2107    // supervisor-pane-affordances: heavy borders + per-pane labels + active
2108    // highlight, scoped to the session, with a config opt-out and graceful
2109    // degradation on older tmux.
2110    // -----------------------------------------------------------------------
2111
2112    /// The five affordance `set-option` invocations a session must carry when
2113    /// affordances are on, paired with their exact values.
2114    const AFFORDANCE_OPTIONS: [(&str, &str); 5] = [
2115        ("pane-border-lines", "double"),
2116        ("pane-border-style", "fg=colour238"),
2117        ("pane-active-border-style", "fg=colour45,bold"),
2118        ("pane-border-status", "top"),
2119        (
2120            "pane-border-format",
2121            "#[fg=colour39,bold,reverse] #{pane_index}: #{?#{@paw_role},#{@paw_role},#{pane_title}} #[default]",
2122        ),
2123    ];
2124
2125    /// Scenario: Heavy border option is set on the session — and the other
2126    /// four affordance options, all scoped with `-t <session>`.
2127    #[test]
2128    fn builder_emits_all_five_affordances_scoped_to_session_by_default() {
2129        let session = TmuxSessionBuilder::new("aff-default")
2130            .add_pane(make_pane("feat/a", "/tmp/wt", "claude"))
2131            .build()
2132            .unwrap();
2133        let cmds = session.command_strings();
2134        for (option, value) in AFFORDANCE_OPTIONS {
2135            assert!(
2136                cmds.iter().any(|c| c.contains("set-option")
2137                    && c.contains("-t paw-aff-default")
2138                    && c.contains(option)
2139                    && c.contains(value)),
2140                "expected `set-option -t paw-aff-default {option} {value}`; cmds:\n{cmds:#?}"
2141            );
2142        }
2143    }
2144
2145    /// Scenario: Border format includes index and the role label — the format
2146    /// string is exactly ` #{pane_index}: #{?#{@paw_role},#{@paw_role},#{pane_title}} `
2147    /// (spaces preserved). The conditional prefers the git-paw-set `@paw_role`
2148    /// pane option (not clobbered by the CLI) over `#{pane_title}`.
2149    #[test]
2150    fn border_format_is_index_then_role_with_padding() {
2151        let session = TmuxSessionBuilder::new("fmt")
2152            .add_pane(make_pane("feat/a", "/tmp/wt", "claude"))
2153            .build()
2154            .unwrap();
2155        let format_cmd = session
2156            .command_strings()
2157            .into_iter()
2158            .find(|c| c.contains("pane-border-format"))
2159            .expect("pane-border-format set-option present");
2160        assert!(
2161            format_cmd.ends_with(
2162                "pane-border-format #[fg=colour39,bold,reverse] #{pane_index}: #{?#{@paw_role},#{@paw_role},#{pane_title}} #[default]"
2163            ),
2164            "format must be the reverse-video label bar preferring @paw_role; got: {format_cmd}"
2165        );
2166    }
2167
2168    /// Scenario: Active border style is applied — a bright bold colour for the
2169    /// active border and a dim colour for inactive borders.
2170    #[test]
2171    fn active_and_inactive_border_styles_applied() {
2172        let session = TmuxSessionBuilder::new("styles")
2173            .add_pane(make_pane("feat/a", "/tmp/wt", "claude"))
2174            .build()
2175            .unwrap();
2176        let cmds = session.command_strings();
2177        assert!(
2178            cmds.iter()
2179                .any(|c| c.contains("pane-active-border-style") && c.contains("colour45,bold")),
2180            "active border must be colour45,bold; cmds:\n{cmds:#?}"
2181        );
2182        assert!(
2183            cmds.iter()
2184                .any(|c| c.contains("pane-border-style") && c.contains("colour238")),
2185            "inactive border must be colour238; cmds:\n{cmds:#?}"
2186        );
2187    }
2188
2189    /// Scenario: Explicit false skips all affordances — none of the five
2190    /// `set-option` invocations and none of the per-pane `select-pane -T`
2191    /// title sets are emitted, but the CLI still launches.
2192    #[test]
2193    fn opt_out_omits_every_affordance_and_title() {
2194        let session = TmuxSessionBuilder::new("opt-out")
2195            .add_pane(make_pane("feat/a", "/tmp/wt", "claude"))
2196            .add_pane(make_pane("feat/b", "/tmp/wt2", "gemini"))
2197            .border_affordances(false)
2198            .build()
2199            .unwrap();
2200        let cmds = session.command_strings();
2201        for (option, _value) in AFFORDANCE_OPTIONS {
2202            assert!(
2203                !cmds
2204                    .iter()
2205                    .any(|c| c.contains("set-option") && c.contains(option)),
2206                "opt-out must not emit set-option {option}; cmds:\n{cmds:#?}"
2207            );
2208        }
2209        assert!(
2210            !cmds
2211                .iter()
2212                .any(|c| c.contains("select-pane") && c.contains("-T")),
2213            "opt-out must not set any pane title; cmds:\n{cmds:#?}"
2214        );
2215        assert!(
2216            !cmds.iter().any(|c| c.contains("@paw_role")),
2217            "opt-out must not set the @paw_role pane option; cmds:\n{cmds:#?}"
2218        );
2219        // The CLI still runs in each pane — opt-out only drops the styling.
2220        assert_eq!(
2221            commands_containing(&cmds, "send-keys").len(),
2222            2,
2223            "both panes still receive their CLI send-keys"
2224        );
2225    }
2226
2227    /// Scenario: Unsupported option produces a stderr warning, and other
2228    /// affordances still apply (graceful degradation on older tmux, design D4).
2229    #[test]
2230    fn soft_affordance_failure_warns_and_continues() {
2231        let session = TmuxSessionBuilder::new("degrade")
2232            .add_pane(make_pane("feat/a", "/tmp/wt", "claude"))
2233            .build()
2234            .unwrap();
2235
2236        let mut ran: Vec<String> = Vec::new();
2237        let mut warnings: Vec<String> = Vec::new();
2238        // Simulate a tmux that rejects only `pane-border-lines double`.
2239        let result = session.execute_with(
2240            |cmd| {
2241                let s = cmd.as_command_string();
2242                ran.push(s.clone());
2243                if s.contains("pane-border-lines double") {
2244                    Err(PawError::TmuxError(
2245                        "unknown option: pane-border-lines".into(),
2246                    ))
2247                } else {
2248                    Ok(())
2249                }
2250            },
2251            |w| warnings.push(w),
2252        );
2253
2254        assert!(result.is_ok(), "soft failure must not abort the build");
2255        assert!(
2256            warnings.iter().any(|w| w.contains("pane-border-lines")),
2257            "a warning naming the unsupported option must be emitted; warnings: {warnings:#?}"
2258        );
2259        // The other affordances (shipped since tmux 2.3) still ran.
2260        assert!(
2261            ran.iter().any(|c| c.contains("pane-active-border-style")),
2262            "active-border-style must still be applied after the double-line failure"
2263        );
2264        assert!(
2265            ran.iter().any(|c| c.contains("pane-border-status top")),
2266            "pane-border-status must still be applied after the double-line failure"
2267        );
2268    }
2269
2270    /// A non-soft command failure aborts the build (the double-line tolerance is
2271    /// scoped to the soft affordance commands, not every command).
2272    #[test]
2273    fn hard_command_failure_aborts() {
2274        let session = TmuxSessionBuilder::new("hard-fail")
2275            .add_pane(make_pane("feat/a", "/tmp/wt", "claude"))
2276            .build()
2277            .unwrap();
2278        let result = session.execute_with(
2279            |cmd| {
2280                if cmd.as_command_string().contains("new-session") {
2281                    Err(PawError::TmuxError("server unreachable".into()))
2282                } else {
2283                    Ok(())
2284                }
2285            },
2286            |_| {},
2287        );
2288        assert!(result.is_err(), "a hard command failure must propagate");
2289    }
2290
2291    /// Scenario: Supervisor/dashboard/agent pane titles are their role/branch
2292    /// id, and the supervisor builder also emits all five affordances.
2293    #[test]
2294    fn supervisor_session_titles_are_roles_and_emits_affordances() {
2295        let layout = crate::supervisor::layout::supervisor_layout(2).expect("layout");
2296        let supervisor = make_pane("supervisor", "/repo", "claude");
2297        let dashboard = make_pane("dashboard", "/repo", "git-paw __dashboard");
2298        let agent = make_pane("feat/foo", "/tmp/wt", "claude");
2299        let session = build_supervisor_session(
2300            "sup",
2301            None,
2302            &supervisor,
2303            &dashboard,
2304            &[agent],
2305            layout,
2306            true,
2307            true,
2308            &[],
2309        )
2310        .expect("session builds");
2311        let cmds = session.command_strings();
2312
2313        // All five affordances present and scoped.
2314        for (option, value) in AFFORDANCE_OPTIONS {
2315            assert!(
2316                cmds.iter().any(|c| c.contains("set-option")
2317                    && c.contains("-t paw-sup")
2318                    && c.contains(option)
2319                    && c.contains(value)),
2320                "supervisor session missing `set-option {option} {value}`; cmds:\n{cmds:#?}"
2321            );
2322        }
2323
2324        let title_for = |target: &str| -> String {
2325            cmds.iter()
2326                .find(|c| c.contains("select-pane") && c.contains(target) && c.contains("-T"))
2327                .unwrap_or_else(|| panic!("no title set for {target}; cmds:\n{cmds:#?}"))
2328                .clone()
2329        };
2330        assert!(title_for(":0.0").ends_with("-T supervisor"), "pane 0 title");
2331        assert!(title_for(":0.1").ends_with("-T dashboard"), "pane 1 title");
2332        assert!(
2333            title_for(":0.2").ends_with("-T feat/foo"),
2334            "agent pane title"
2335        );
2336    }
2337
2338    /// W2-2 (supervisor-cli-launch-robustness): the supervisor build suppresses
2339    /// shell startup prompts (so an oh-my-zsh-style update prompt can't eat the
2340    /// CLI-launch keystroke) and clears the input line before each launch.
2341    #[test]
2342    fn supervisor_build_suppresses_startup_prompts_and_clears_input() {
2343        let layout = crate::supervisor::layout::supervisor_layout(1).expect("layout");
2344        let supervisor = make_pane("supervisor", "/repo", "claude");
2345        let dashboard = make_pane("dashboard", "/repo", "git-paw __dashboard");
2346        let agent = make_pane("feat/foo", "/tmp/wt", "claude");
2347        let session = build_supervisor_session(
2348            "sup",
2349            None,
2350            &supervisor,
2351            &dashboard,
2352            &[agent],
2353            layout,
2354            true,
2355            true,
2356            &[],
2357        )
2358        .expect("session builds");
2359        let cmds = session.command_strings();
2360
2361        // Pane 0's shell gets the suppression env via `new-session -e`.
2362        assert!(
2363            cmds.iter()
2364                .any(|c| c.contains("new-session") && c.contains("DISABLE_AUTO_UPDATE=true")),
2365            "new-session must set DISABLE_AUTO_UPDATE for pane 0; cmds:\n{cmds:#?}"
2366        );
2367        // Later split panes inherit it via session environment.
2368        assert!(
2369            cmds.iter().any(|c| c.contains("set-environment")
2370                && c.contains("DISABLE_AUTO_UPDATE")
2371                && c.contains("true")),
2372            "session env must carry DISABLE_AUTO_UPDATE for split panes"
2373        );
2374        // A `C-u` clear precedes the supervisor pane's CLI-launch command.
2375        let clear_idx = cmds.iter().position(|c| {
2376            c.contains("send-keys") && c.contains(":0.0") && c.trim_end().ends_with("C-u")
2377        });
2378        let launch_idx = cmds.iter().position(|c| {
2379            c.contains("send-keys")
2380                && c.contains(":0.0")
2381                && c.contains("claude")
2382                && c.contains("Enter")
2383        });
2384        let (clear_idx, launch_idx) = (
2385            clear_idx.expect("a C-u clear is sent to pane 0"),
2386            launch_idx.expect("the CLI-launch command is sent to pane 0"),
2387        );
2388        assert!(
2389            clear_idx < launch_idx,
2390            "the C-u clear must precede the CLI-launch command on pane 0"
2391        );
2392    }
2393
2394    /// W3-1 (supervisor-first-agent-cwd): the split `-c` cwds are assigned to
2395    /// compensate for the pane-1/2 swap, so the first agent's CLI (sent to
2396    /// index 2 after the swap) runs in its worktree, not the repo root. The
2397    /// agent-area `-v` split takes the dashboard's cwd; the dashboard `-h`
2398    /// split takes the first agent's worktree.
2399    #[test]
2400    fn supervisor_build_compensates_first_agent_cwd_for_swap() {
2401        let layout = crate::supervisor::layout::supervisor_layout(2).expect("layout");
2402        let supervisor = make_pane("supervisor", "/repo", "claude");
2403        let dashboard = make_pane("dashboard", "/repo", "git-paw __dashboard");
2404        let a0 = make_pane("feat/foo", "/tmp/wt-foo", "claude");
2405        let a1 = make_pane("feat/bar", "/tmp/wt-bar", "claude");
2406        let session = build_supervisor_session(
2407            "sup",
2408            None,
2409            &supervisor,
2410            &dashboard,
2411            &[a0, a1],
2412            layout,
2413            true,
2414            true,
2415            &[],
2416        )
2417        .expect("session builds");
2418        let cmds = session.command_strings();
2419
2420        let vsplit = cmds
2421            .iter()
2422            .find(|c| c.contains("split-window") && c.contains("-v") && c.contains("-c"))
2423            .expect("agent-area -v split with -c");
2424        let hsplit = cmds
2425            .iter()
2426            .find(|c| c.contains("split-window") && c.contains("-h") && c.contains("-c"))
2427            .expect("dashboard -h split with -c");
2428
2429        // Agent-area split is born in the dashboard's cwd (it lands at the
2430        // dashboard's post-swap index); dashboard split is born in the first
2431        // agent's worktree (it lands at the agent's post-swap index).
2432        assert!(
2433            vsplit.contains("-c /repo"),
2434            "agent-area -v split must use the dashboard cwd (swap compensation); got: {vsplit}"
2435        );
2436        assert!(
2437            hsplit.contains("-c /tmp/wt-foo"),
2438            "dashboard -h split must use the first agent's worktree (swap compensation); got: {hsplit}"
2439        );
2440    }
2441
2442    /// Scenario: opt-out applies to the supervisor builder too — no affordance
2443    /// set-options and no `select-pane -T` titles.
2444    #[test]
2445    fn supervisor_session_opt_out_omits_affordances() {
2446        let layout = crate::supervisor::layout::supervisor_layout(1).expect("layout");
2447        let supervisor = make_pane("supervisor", "/repo", "claude");
2448        let dashboard = make_pane("dashboard", "/repo", "git-paw __dashboard");
2449        let agent = make_pane("feat/foo", "/tmp/wt", "claude");
2450        let session = build_supervisor_session(
2451            "sup-off",
2452            None,
2453            &supervisor,
2454            &dashboard,
2455            &[agent],
2456            layout,
2457            true,
2458            false,
2459            &[],
2460        )
2461        .expect("session builds");
2462        let cmds = session.command_strings();
2463        for (option, _value) in AFFORDANCE_OPTIONS {
2464            assert!(
2465                !cmds
2466                    .iter()
2467                    .any(|c| c.contains("set-option") && c.contains(option)),
2468                "opt-out supervisor session must not emit set-option {option}"
2469            );
2470        }
2471        assert!(
2472            !cmds
2473                .iter()
2474                .any(|c| c.contains("select-pane") && c.contains("-T")),
2475            "opt-out supervisor session must not set pane titles"
2476        );
2477    }
2478
2479    // -----------------------------------------------------------------------
2480    // AC: Mouse mode (per-session, configurable, default on)
2481    // Dry-run contract: users see mouse config in --dry-run output.
2482    // Actual tmux behavior verified by e2e test tmux_mouse_mode_enabled_by_default.
2483    // -----------------------------------------------------------------------
2484
2485    #[test]
2486    fn mouse_mode_enabled_by_default() {
2487        let session = TmuxSessionBuilder::new("proj")
2488            .add_pane(make_pane("main", "/tmp/wt", "claude"))
2489            .build()
2490            .unwrap();
2491
2492        let cmds = session.command_strings();
2493        assert!(
2494            cmds.iter().any(|c| c.contains("mouse on")),
2495            "mouse should be enabled by default"
2496        );
2497    }
2498
2499    #[test]
2500    fn mouse_mode_can_be_disabled() {
2501        let session = TmuxSessionBuilder::new("proj")
2502            .add_pane(make_pane("main", "/tmp/wt", "claude"))
2503            .mouse_mode(false)
2504            .build()
2505            .unwrap();
2506
2507        let cmds = session.command_strings();
2508        assert!(
2509            !cmds.iter().any(|c| c.contains("mouse on")),
2510            "no mouse-on command should be emitted when disabled"
2511        );
2512    }
2513
2514    // -----------------------------------------------------------------------
2515    // AC: Session liveness and collision handling
2516    // Behavioral: tests against a real tmux server — verifies observable
2517    // outcomes (session exists, session is killed, names are unique).
2518    // -----------------------------------------------------------------------
2519
2520    /// Helper to create a detached tmux session for testing.
2521    fn create_test_session(name: &str) {
2522        let output = std::process::Command::new("tmux")
2523            .args(["new-session", "-d", "-s", name, "-x", "200", "-y", "50"])
2524            .output()
2525            .expect("create tmux session");
2526        assert!(
2527            output.status.success(),
2528            "failed to create test session '{name}'"
2529        );
2530    }
2531
2532    /// Helper to kill a tmux session, ignoring errors.
2533    fn cleanup_session(name: &str) {
2534        let _ = kill_session(name);
2535    }
2536
2537    #[test]
2538    #[serial_test::serial]
2539    fn is_session_alive_returns_false_for_nonexistent() {
2540        let alive = is_session_alive("paw-definitely-does-not-exist-12345").unwrap();
2541        assert!(!alive);
2542    }
2543
2544    #[test]
2545    #[serial_test::serial]
2546    fn session_lifecycle_create_check_kill() {
2547        let name = "paw-unit-test-lifecycle";
2548        cleanup_session(name);
2549
2550        create_test_session(name);
2551        assert!(is_session_alive(name).unwrap());
2552
2553        kill_session(name).unwrap();
2554        assert!(!is_session_alive(name).unwrap());
2555    }
2556
2557    // -----------------------------------------------------------------------
2558    // session-bugfixes Bug 2 — SessionLiveness probe (tasks 3.1–3.3)
2559    // -----------------------------------------------------------------------
2560
2561    #[test]
2562    fn classify_liveness_maps_each_branch() {
2563        // tmux ran and the session exists.
2564        assert_eq!(classify_liveness(true, true), SessionLiveness::Alive);
2565        // tmux ran and the session is gone.
2566        assert_eq!(classify_liveness(true, false), SessionLiveness::Stale);
2567        // tmux could not be spawned at all (binary missing) — inconclusive.
2568        assert_eq!(
2569            classify_liveness(false, false),
2570            SessionLiveness::Indeterminate
2571        );
2572        assert_eq!(
2573            classify_liveness(false, true),
2574            SessionLiveness::Indeterminate
2575        );
2576    }
2577
2578    #[test]
2579    #[serial_test::serial]
2580    fn session_liveness_reports_stale_for_nonexistent() {
2581        assert_eq!(
2582            session_liveness("paw-definitely-does-not-exist-98765"),
2583            SessionLiveness::Stale
2584        );
2585    }
2586
2587    #[test]
2588    #[serial_test::serial]
2589    fn session_liveness_reports_alive_then_stale_across_lifecycle() {
2590        let name = "paw-unit-test-liveness-probe";
2591        cleanup_session(name);
2592
2593        create_test_session(name);
2594        assert_eq!(session_liveness(name), SessionLiveness::Alive);
2595
2596        kill_session(name).unwrap();
2597        assert_eq!(session_liveness(name), SessionLiveness::Stale);
2598    }
2599
2600    #[test]
2601    #[serial_test::serial]
2602    fn resolve_session_name_returns_base_when_no_collision() {
2603        let name = resolve_session_name("unit-test-no-collision-xyz").unwrap();
2604        assert_eq!(name, "paw-unit-test-no-collision-xyz");
2605    }
2606
2607    #[test]
2608    #[serial_test::serial]
2609    fn resolve_session_name_appends_suffix_on_collision() {
2610        let base_name = "paw-unit-test-collision";
2611        cleanup_session(base_name);
2612        cleanup_session(&format!("{base_name}-2"));
2613
2614        create_test_session(base_name);
2615
2616        let resolved = resolve_session_name("unit-test-collision").unwrap();
2617        assert_eq!(resolved, format!("{base_name}-2"));
2618
2619        cleanup_session(base_name);
2620    }
2621
2622    // -----------------------------------------------------------------------
2623    // AC: pipe-pane logging integration
2624    // Dry-run contract: verifies the pipe-pane command is queued correctly.
2625    // -----------------------------------------------------------------------
2626
2627    #[test]
2628    fn pipe_pane_queues_correct_command() {
2629        let mut session = TmuxSessionBuilder::new("proj")
2630            .add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
2631            .build()
2632            .unwrap();
2633
2634        let log_path = std::path::PathBuf::from("/repo/.git-paw/logs/paw-proj/feat--auth.log");
2635        session.pipe_pane("paw-proj:0.0", &log_path);
2636
2637        let cmds = session.command_strings();
2638        let pipe_cmds: Vec<&String> = cmds.iter().filter(|c| c.contains("pipe-pane")).collect();
2639        assert_eq!(pipe_cmds.len(), 1);
2640        assert!(pipe_cmds[0].contains("pipe-pane -o -t paw-proj:0.0"));
2641        assert!(pipe_cmds[0].contains("cat >> /repo/.git-paw/logs/paw-proj/feat--auth.log"));
2642    }
2643
2644    // --- Gap #10: pipe-pane conditional on logging ---
2645
2646    #[test]
2647    fn session_without_pipe_pane_has_no_pipe_pane_commands() {
2648        let session = TmuxSessionBuilder::new("proj")
2649            .add_pane(make_pane("main", "/tmp/wt", "claude"))
2650            .build()
2651            .unwrap();
2652
2653        let cmds = session.command_strings();
2654        assert!(
2655            !cmds.iter().any(|c| c.contains("pipe-pane")),
2656            "session built without pipe_pane calls should have no pipe-pane commands"
2657        );
2658    }
2659
2660    #[test]
2661    fn session_with_pipe_pane_differs_from_without() {
2662        let session_without = TmuxSessionBuilder::new("proj")
2663            .add_pane(make_pane("main", "/tmp/wt", "claude"))
2664            .build()
2665            .unwrap();
2666        let cmds_without = session_without.command_strings();
2667
2668        let mut session_with = TmuxSessionBuilder::new("proj")
2669            .add_pane(make_pane("main", "/tmp/wt", "claude"))
2670            .build()
2671            .unwrap();
2672        let log_path = std::path::PathBuf::from("/repo/.git-paw/logs/paw-proj/main.log");
2673        session_with.pipe_pane("paw-proj:0.0", &log_path);
2674        let cmds_with = session_with.command_strings();
2675
2676        assert_ne!(
2677            cmds_without, cmds_with,
2678            "command lists should differ when pipe-pane is added"
2679        );
2680        assert!(
2681            cmds_with.iter().any(|c| c.contains("pipe-pane")),
2682            "session with pipe_pane should contain pipe-pane command"
2683        );
2684    }
2685
2686    // --- Gap #11: pipe-pane ordering ---
2687
2688    #[test]
2689    fn pipe_pane_appears_after_send_keys_for_pane() {
2690        let mut session = TmuxSessionBuilder::new("proj")
2691            .add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
2692            .add_pane(make_pane("feat/api", "/tmp/wt2", "codex"))
2693            .build()
2694            .unwrap();
2695
2696        let log0 = std::path::PathBuf::from("/repo/logs/feat--auth.log");
2697        let log1 = std::path::PathBuf::from("/repo/logs/feat--api.log");
2698        session.pipe_pane("paw-proj:0.0", &log0);
2699        session.pipe_pane("paw-proj:0.1", &log1);
2700
2701        let cmds = session.command_strings();
2702
2703        // Find the last send-keys index and first pipe-pane index
2704        let last_send_keys = cmds
2705            .iter()
2706            .rposition(|c| c.contains("send-keys"))
2707            .expect("should have send-keys");
2708        let first_pipe_pane = cmds
2709            .iter()
2710            .position(|c| c.contains("pipe-pane"))
2711            .expect("should have pipe-pane");
2712
2713        assert!(
2714            first_pipe_pane > last_send_keys,
2715            "pipe-pane commands (index {first_pipe_pane}) should appear after \
2716             all send-keys commands (last at index {last_send_keys})"
2717        );
2718    }
2719
2720    #[test]
2721    fn pipe_pane_appears_in_dry_run_output() {
2722        let mut session = TmuxSessionBuilder::new("proj")
2723            .add_pane(make_pane("main", "/tmp/wt", "claude"))
2724            .build()
2725            .unwrap();
2726
2727        let log_path = std::path::PathBuf::from("/repo/.git-paw/logs/paw-proj/main.log");
2728        session.pipe_pane("paw-proj:0.0", &log_path);
2729
2730        let cmds = session.command_strings();
2731        assert!(
2732            cmds.iter().any(|c| c.starts_with("tmux pipe-pane")),
2733            "dry-run output should include pipe-pane command"
2734        );
2735    }
2736
2737    // -----------------------------------------------------------------------
2738    // AC: set_environment emits correct commands
2739    // -----------------------------------------------------------------------
2740
2741    #[test]
2742    fn set_environment_emits_correct_tmux_command() {
2743        let session = TmuxSessionBuilder::new("proj")
2744            .add_pane(make_pane("main", "/tmp/wt", "claude"))
2745            .set_environment("GIT_PAW_BROKER_URL", "http://127.0.0.1:9119")
2746            .build()
2747            .unwrap();
2748
2749        let cmds = session.command_strings();
2750        let env_cmds = commands_containing(&cmds, "set-environment");
2751        assert_eq!(env_cmds.len(), 1, "should have exactly one set-environment");
2752        assert!(
2753            env_cmds[0]
2754                .contains("set-environment -t paw-proj GIT_PAW_BROKER_URL http://127.0.0.1:9119"),
2755            "set-environment command should contain key and value, got: {}",
2756            env_cmds[0]
2757        );
2758    }
2759
2760    #[test]
2761    fn set_environment_appears_before_send_keys() {
2762        let session = TmuxSessionBuilder::new("proj")
2763            .add_pane(make_pane("feat/a", "/tmp/a", "claude"))
2764            .add_pane(make_pane("feat/b", "/tmp/b", "codex"))
2765            .set_environment("GIT_PAW_BROKER_URL", "http://127.0.0.1:9119")
2766            .build()
2767            .unwrap();
2768
2769        let cmds = session.command_strings();
2770        let first_env = cmds
2771            .iter()
2772            .position(|c| c.contains("set-environment"))
2773            .expect("should have set-environment");
2774        let first_send = cmds
2775            .iter()
2776            .position(|c| c.contains("send-keys"))
2777            .expect("should have send-keys");
2778
2779        assert!(
2780            first_env < first_send,
2781            "set-environment (index {first_env}) should appear before first send-keys (index {first_send})"
2782        );
2783    }
2784
2785    #[test]
2786    fn multiple_env_vars_both_appear() {
2787        let session = TmuxSessionBuilder::new("proj")
2788            .add_pane(make_pane("main", "/tmp/wt", "claude"))
2789            .set_environment("A", "1")
2790            .set_environment("B", "2")
2791            .build()
2792            .unwrap();
2793
2794        let cmds = session.command_strings();
2795        let env_cmds = commands_containing(&cmds, "set-environment");
2796        assert_eq!(
2797            env_cmds.len(),
2798            2,
2799            "should have two set-environment commands"
2800        );
2801        assert!(env_cmds[0].contains("A 1"));
2802        assert!(env_cmds[1].contains("B 2"));
2803    }
2804
2805    #[test]
2806    fn set_environment_in_dry_run_output() {
2807        let session = TmuxSessionBuilder::new("proj")
2808            .add_pane(make_pane("main", "/tmp/wt", "claude"))
2809            .set_environment("MY_VAR", "my_val")
2810            .build()
2811            .unwrap();
2812
2813        let cmds = session.command_strings();
2814        assert!(
2815            cmds.iter().any(|c| c.starts_with("tmux set-environment")),
2816            "dry-run output should include set-environment command"
2817        );
2818    }
2819
2820    // -----------------------------------------------------------------------
2821    // AC: Dashboard layout selection
2822    // Behavioral: verifies the correct layout is chosen based on pane structure
2823    // -----------------------------------------------------------------------
2824
2825    #[test]
2826    fn session_without_dashboard_uses_tiled_layout() {
2827        let session = TmuxSessionBuilder::new("proj")
2828            .add_pane(make_pane("feat/a", "/tmp/a", "claude"))
2829            .add_pane(make_pane("feat/b", "/tmp/b", "codex"))
2830            .build()
2831            .unwrap();
2832
2833        let cmds = session.command_strings();
2834        let layout_cmds: Vec<&String> = cmds
2835            .iter()
2836            .filter(|c| c.contains("select-layout"))
2837            .collect();
2838        let final_layout = layout_cmds
2839            .last()
2840            .expect("should have at least one select-layout");
2841        assert!(
2842            final_layout.contains("tiled"),
2843            "sessions without dashboard should use tiled layout, got: {final_layout}"
2844        );
2845    }
2846
2847    #[test]
2848    fn session_with_dashboard_uses_main_horizontal_layout() {
2849        let session = TmuxSessionBuilder::new("proj")
2850            .add_pane(make_pane("dashboard", "/tmp/repo", "git-paw __dashboard"))
2851            .add_pane(make_pane("feat/a", "/tmp/a", "claude"))
2852            .add_pane(make_pane("feat/b", "/tmp/b", "codex"))
2853            .build()
2854            .unwrap();
2855
2856        let cmds = session.command_strings();
2857        let layout_cmds: Vec<&String> = cmds
2858            .iter()
2859            .filter(|c| c.contains("select-layout"))
2860            .collect();
2861        let final_layout = layout_cmds
2862            .last()
2863            .expect("should have at least one select-layout");
2864        assert!(
2865            final_layout.contains("main-horizontal"),
2866            "sessions with dashboard should use main-horizontal layout, got: {final_layout}"
2867        );
2868    }
2869
2870    #[test]
2871    fn single_pane_session_uses_tiled_layout() {
2872        let session = TmuxSessionBuilder::new("proj")
2873            .add_pane(make_pane("main", "/tmp/wt", "claude"))
2874            .build()
2875            .unwrap();
2876
2877        let cmds = session.command_strings();
2878        let layout_cmds: Vec<&String> = cmds
2879            .iter()
2880            .filter(|c| c.contains("select-layout"))
2881            .collect();
2882        let final_layout = layout_cmds
2883            .last()
2884            .expect("should have at least one select-layout");
2885        assert!(
2886            final_layout.contains("tiled"),
2887            "single pane sessions should use tiled layout, got: {final_layout}"
2888        );
2889    }
2890
2891    #[test]
2892    fn dashboard_layout_appears_in_dry_run_output() {
2893        let session = TmuxSessionBuilder::new("proj")
2894            .add_pane(make_pane("dashboard", "/tmp/repo", "git-paw __dashboard"))
2895            .add_pane(make_pane("feat/a", "/tmp/a", "claude"))
2896            .build()
2897            .unwrap();
2898
2899        let cmds = session.command_strings();
2900        assert!(
2901            cmds.iter().any(|c| c.contains("main-horizontal")),
2902            "dry-run output should include main-horizontal layout command"
2903        );
2904    }
2905
2906    // -----------------------------------------------------------------------
2907    // AC: detach_client + kill_pane behave idempotently
2908    // -----------------------------------------------------------------------
2909
2910    /// Helper that yields a unique detached test session name and cleans it
2911    /// up on drop. Used to keep pause-related tmux tests hermetic.
2912    struct PausePaneSession {
2913        name: String,
2914    }
2915
2916    impl PausePaneSession {
2917        fn new(label: &str) -> Self {
2918            let pid = std::process::id();
2919            let nanos = std::time::SystemTime::now()
2920                .duration_since(std::time::UNIX_EPOCH)
2921                .map_or(0, |d| d.as_nanos());
2922            let name = format!("paw-pause-test-{label}-{pid}-{nanos}");
2923            let output = std::process::Command::new("tmux")
2924                .args(["new-session", "-d", "-s", &name, "-x", "200", "-y", "50"])
2925                .output()
2926                .expect("create tmux test session");
2927            assert!(
2928                output.status.success(),
2929                "failed to create test session '{name}'"
2930            );
2931            Self { name }
2932        }
2933    }
2934
2935    impl Drop for PausePaneSession {
2936        fn drop(&mut self) {
2937            let _ = kill_session(&self.name);
2938        }
2939    }
2940
2941    #[test]
2942    #[serial_test::serial]
2943    fn detach_client_succeeds_on_attached_session() {
2944        // No client is actually attached in headless test, but a detached
2945        // session under tmux server is the closest the unit layer can get
2946        // without a pty; the public contract is "exit Ok" either way.
2947        let session = PausePaneSession::new("detach-attached");
2948        detach_client(&session.name).expect("detach should succeed");
2949        assert!(is_session_alive(&session.name).unwrap());
2950    }
2951
2952    #[test]
2953    #[serial_test::serial]
2954    fn detach_client_is_noop_with_no_clients() {
2955        let session = PausePaneSession::new("detach-noop");
2956        // First call: no clients attached.
2957        detach_client(&session.name).expect("first detach should succeed");
2958        // Second call: also no clients (still alive).
2959        detach_client(&session.name).expect("second detach should succeed");
2960        assert!(is_session_alive(&session.name).unwrap());
2961    }
2962
2963    /// Spec-aligned alias of `detach_client_is_noop_with_no_clients`
2964    /// (task 9.11). A detached test session has no client attached;
2965    /// `detach_client` must still return Ok(()).
2966    #[test]
2967    #[serial_test::serial]
2968    fn detach_client_noop_when_no_clients_attached() {
2969        let session = PausePaneSession::new("detach-9-11");
2970        detach_client(&session.name).expect("detach with no clients should be Ok");
2971        assert!(is_session_alive(&session.name).unwrap());
2972    }
2973
2974    #[test]
2975    #[serial_test::serial]
2976    fn kill_pane_removes_pane() {
2977        let session = PausePaneSession::new("killpane");
2978        // Add a second pane so the kill doesn't take down the whole session.
2979        let _ = std::process::Command::new("tmux")
2980            .args(["split-window", "-t", &session.name])
2981            .output();
2982        let pane_count_before = std::process::Command::new("tmux")
2983            .args(["list-panes", "-t", &session.name, "-F", "#{pane_index}"])
2984            .output()
2985            .map_or(0, |o| String::from_utf8_lossy(&o.stdout).lines().count());
2986        assert_eq!(pane_count_before, 2, "should have 2 panes before kill");
2987
2988        kill_pane(&session.name, 1).expect("kill_pane should succeed");
2989
2990        let pane_count_after = std::process::Command::new("tmux")
2991            .args(["list-panes", "-t", &session.name, "-F", "#{pane_index}"])
2992            .output()
2993            .map_or(0, |o| String::from_utf8_lossy(&o.stdout).lines().count());
2994        assert_eq!(pane_count_after, 1, "should have 1 pane after kill");
2995    }
2996
2997    #[test]
2998    #[serial_test::serial]
2999    fn kill_pane_is_noop_for_missing_pane() {
3000        let session = PausePaneSession::new("killpane-missing");
3001        // Pane index 99 does not exist — should not error.
3002        kill_pane(&session.name, 99).expect("kill missing pane should be ok");
3003        assert!(is_session_alive(&session.name).unwrap());
3004    }
3005
3006    #[test]
3007    #[serial_test::serial]
3008    fn built_session_can_be_executed_and_killed() {
3009        let project = "unit-test-execute";
3010        let session_name = format!("paw-{project}");
3011        cleanup_session(&session_name);
3012
3013        let session = TmuxSessionBuilder::new(project)
3014            .add_pane(make_pane("main", "/tmp", "echo hello"))
3015            .build()
3016            .unwrap();
3017
3018        session.execute().unwrap();
3019        assert!(is_session_alive(&session_name).unwrap());
3020
3021        kill_session(&session_name).unwrap();
3022        assert!(!is_session_alive(&session_name).unwrap());
3023    }
3024
3025    // -----------------------------------------------------------------------
3026    // AC: Supervisor-mode initial prompt is injected as a paste + two Enters
3027    // Behavioral: callers iterate the argv pair and run each as a separate
3028    // `tmux send-keys` invocation. The pair shape is the public contract.
3029    // -----------------------------------------------------------------------
3030
3031    #[test]
3032    fn supervisor_submit_argv_pair_has_two_invocations() {
3033        let (first, second) = build_supervisor_submit_argv_pair("paw-proj", 3, "do the thing");
3034        // Both invocations are non-empty argv vectors.
3035        assert!(!first.is_empty(), "first send-keys argv must be non-empty");
3036        assert!(
3037            !second.is_empty(),
3038            "second send-keys argv must be non-empty"
3039        );
3040    }
3041
3042    #[test]
3043    fn supervisor_submit_first_invocation_sends_prompt_and_enter() {
3044        let (first, _second) = build_supervisor_submit_argv_pair("paw-proj", 3, "do the thing");
3045        assert_eq!(first[0], "send-keys");
3046        assert_eq!(first[1], "-t");
3047        assert_eq!(first[2], "paw-proj:0.3");
3048        assert_eq!(first[3], "do the thing");
3049        assert_eq!(first[4], "Enter");
3050    }
3051
3052    #[test]
3053    fn supervisor_submit_second_invocation_is_enter_only() {
3054        let (_first, second) = build_supervisor_submit_argv_pair("paw-proj", 3, "do the thing");
3055        assert_eq!(second[0], "send-keys");
3056        assert_eq!(second[1], "-t");
3057        assert_eq!(second[2], "paw-proj:0.3");
3058        assert_eq!(second[3], "Enter");
3059        assert_eq!(
3060            second.len(),
3061            4,
3062            "second invocation should be send-keys -t <target> Enter (no prompt)"
3063        );
3064    }
3065
3066    #[test]
3067    fn supervisor_submit_targets_same_pane_in_both_invocations() {
3068        let (first, second) = build_supervisor_submit_argv_pair("paw-proj", 7, "prompt");
3069        // The target (third positional arg after `send-keys -t`) must match
3070        // so the second Enter lands in the same pane the prompt was sent to.
3071        assert_eq!(first[2], second[2]);
3072        assert_eq!(first[2], "paw-proj:0.7");
3073    }
3074
3075    #[test]
3076    fn supervisor_submit_argv_pair_preserves_prompt_with_newlines_and_quotes() {
3077        let prompt = "line1\nline2 with \"quoted\" text";
3078        let (first, _second) = build_supervisor_submit_argv_pair("paw-proj", 1, prompt);
3079        // The prompt is passed verbatim as its own argv element; tmux's
3080        // send-keys treats it as literal text. No shell escaping needed.
3081        assert_eq!(first[3], prompt);
3082    }
3083
3084    // Maps to scenario `Launch flow sends exactly one Enter per pane`
3085    // (cmd_supervisor invariant) from prompt-submit-fix. The
3086    // `submit_prompt_to_pane` helper in main.rs sends prompt + one Enter
3087    // per pane and is shaped identically to the FIRST argv returned by
3088    // `build_supervisor_submit_argv_pair`. We count Enter tokens across
3089    // the first-argv portion of N=3 invocations to lock in the
3090    // single-Enter-per-pane invariant. (test-coverage-v0-5-0 task 3.1)
3091    #[test]
3092    fn cmd_supervisor_inject_argv_has_single_enter_per_pane() {
3093        let panes: Vec<(usize, &str)> = vec![(2, "p2"), (3, "p3"), (4, "p4")];
3094
3095        let mut total_enters = 0;
3096        for (pane_idx, prompt) in &panes {
3097            let (first, _second) = build_supervisor_submit_argv_pair("paw-proj", *pane_idx, prompt);
3098            let enter_positions: Vec<usize> = first
3099                .iter()
3100                .enumerate()
3101                .filter(|(_, tok)| tok.as_str() == "Enter")
3102                .map(|(i, _)| i)
3103                .collect();
3104            assert_eq!(
3105                enter_positions.len(),
3106                1,
3107                "each per-pane invocation must send exactly one Enter; got argv: {first:?}"
3108            );
3109            let enter_pos = enter_positions[0];
3110            assert!(
3111                enter_pos > 0,
3112                "Enter token must follow a prompt-string argument; got argv: {first:?}"
3113            );
3114            assert_eq!(
3115                first[enter_pos - 1].as_str(),
3116                *prompt,
3117                "Enter token must directly follow the prompt argument; got argv: {first:?}"
3118            );
3119            total_enters += enter_positions.len();
3120        }
3121        assert_eq!(
3122            total_enters, 3,
3123            "for N=3 panes the launch flow must send exactly N=3 Enters"
3124        );
3125    }
3126
3127    // -----------------------------------------------------------------------
3128    // build_supervisor_session — layout-shape contract (tasks 9.1–9.7)
3129    //
3130    // Behavioral: we inspect the emitted command strings to verify the layout
3131    // shape. The exact tmux side effects are integration-tested elsewhere;
3132    // here we lock in the deterministic command sequence the supervisor-mode
3133    // pane assumptions depend on (supervisor=0, dashboard=1, agents=2+).
3134    // -----------------------------------------------------------------------
3135
3136    fn make_layout_panes(n: usize) -> (PaneSpec, PaneSpec, Vec<PaneSpec>) {
3137        let supervisor = make_pane("supervisor", "/repo", "claude");
3138        let dashboard = make_pane("dashboard", "/repo", "git-paw __dashboard");
3139        let agents = (0..n)
3140            .map(|i| make_pane(&format!("feat/b{i}"), &format!("/tmp/wt{i}"), "claude"))
3141            .collect();
3142        (supervisor, dashboard, agents)
3143    }
3144
3145    fn build_for(agent_count: usize) -> TmuxSession {
3146        let layout =
3147            crate::supervisor::layout::supervisor_layout(agent_count).expect("layout computes");
3148        let (supervisor, dashboard, agents) = make_layout_panes(agent_count);
3149        build_supervisor_session(
3150            "proj",
3151            None,
3152            &supervisor,
3153            &dashboard,
3154            &agents,
3155            layout,
3156            true,
3157            true,
3158            &[("GIT_PAW_BROKER_URL".to_string(), "http://x".to_string())],
3159        )
3160        .expect("session builds")
3161    }
3162
3163    /// 9.1 — 5-agent layout: 1 agent row, top 60% / agent row 40%.
3164    #[test]
3165    fn supervisor_layout_5_agents_single_row() {
3166        let session = build_for(5);
3167        let cmds = session.command_strings();
3168        let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
3169            .into_iter()
3170            .filter(|c| !c.trim_end().ends_with("C-u"))
3171            .collect();
3172        assert_eq!(
3173            send_keys.len(),
3174            7,
3175            "5 agents → 1 supervisor + 1 dashboard + 5 agents = 7 send-keys, got {send_keys:#?}"
3176        );
3177        let supervisor_pane = send_keys
3178            .iter()
3179            .find(|c| c.contains("0.0 "))
3180            .unwrap_or(&send_keys[0]);
3181        assert!(supervisor_pane.contains("claude"));
3182        let dashboard_pane = send_keys
3183            .iter()
3184            .find(|c| c.contains(":0.1 ") && c.contains("__dashboard"))
3185            .expect("dashboard send-keys at pane :0.1");
3186        let _ = dashboard_pane;
3187        // Top row resize-pane uses 60%.
3188        let resizes = commands_containing(&cmds, "resize-pane");
3189        assert!(
3190            resizes
3191                .iter()
3192                .any(|c| c.contains(":0.0") && c.contains("60%")),
3193            "top row resize to 60%, got resizes {resizes:#?}"
3194        );
3195        // Single agent row resize at pane :0.2 with 40%.
3196        assert!(
3197            resizes
3198                .iter()
3199                .any(|c| c.contains(":0.2") && c.contains("40%")),
3200            "agent-row resize to 40% at :0.2, got resizes {resizes:#?}"
3201        );
3202    }
3203
3204    /// 9.2 — 10-agent layout: 2 rows of 5, top 40% / each agent row 30%.
3205    #[test]
3206    fn supervisor_layout_10_agents_two_rows() {
3207        let session = build_for(10);
3208        let cmds = session.command_strings();
3209        let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
3210            .into_iter()
3211            .filter(|c| !c.trim_end().ends_with("C-u"))
3212            .collect();
3213        assert_eq!(
3214            send_keys.len(),
3215            12,
3216            "10 agents → 1 supervisor + 1 dashboard + 10 agents = 12 send-keys"
3217        );
3218        let resizes = commands_containing(&cmds, "resize-pane");
3219        assert!(
3220            resizes
3221                .iter()
3222                .any(|c| c.contains(":0.0") && c.contains("40%"))
3223        );
3224        assert!(
3225            resizes.iter().filter(|c| c.contains("30%")).count() >= 2,
3226            "two agent rows at 30% each, got {resizes:#?}"
3227        );
3228    }
3229
3230    /// 9.3 — 11-agent layout: 3 agent rows (5+5+1), top 28% / each agent row 24%.
3231    #[test]
3232    fn supervisor_layout_11_agents_three_rows() {
3233        let session = build_for(11);
3234        let cmds = session.command_strings();
3235        let resizes = commands_containing(&cmds, "resize-pane");
3236        assert!(
3237            resizes
3238                .iter()
3239                .any(|c| c.contains(":0.0") && c.contains("28%"))
3240        );
3241        assert!(
3242            resizes.iter().filter(|c| c.contains("24%")).count() >= 3,
3243            "three agent rows at 24% each, got {resizes:#?}"
3244        );
3245        // 11 agents start at pane 2 and run through pane 12.
3246        let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
3247            .into_iter()
3248            .filter(|c| !c.trim_end().ends_with("C-u"))
3249            .collect();
3250        assert_eq!(send_keys.len(), 13);
3251        assert!(send_keys.iter().any(|c| c.contains(":0.12 ")));
3252    }
3253
3254    /// 9.4 — 20-agent layout: 4 rows of 5, top 28% / each agent row 18%.
3255    #[test]
3256    fn supervisor_layout_20_agents_four_rows() {
3257        let session = build_for(20);
3258        let cmds = session.command_strings();
3259        let resizes = commands_containing(&cmds, "resize-pane");
3260        assert!(
3261            resizes
3262                .iter()
3263                .any(|c| c.contains(":0.0") && c.contains("28%"))
3264        );
3265        assert!(
3266            resizes.iter().filter(|c| c.contains("18%")).count() >= 4,
3267            "four agent rows at 18% each, got {resizes:#?}"
3268        );
3269    }
3270
3271    /// 9.5 — 25-agent layout: 5 rows of 5, top 28% / each agent row 14.4%.
3272    #[test]
3273    fn supervisor_layout_25_agents_five_rows() {
3274        let session = build_for(25);
3275        let cmds = session.command_strings();
3276        let resizes = commands_containing(&cmds, "resize-pane");
3277        assert!(
3278            resizes
3279                .iter()
3280                .any(|c| c.contains(":0.0") && c.contains("28%"))
3281        );
3282        assert!(
3283            resizes.iter().filter(|c| c.contains("14.4%")).count() >= 5,
3284            "five agent rows at 14.4% each, got {resizes:#?}"
3285        );
3286    }
3287
3288    /// 9.6 — 26-agent attempt errors before any tmux command runs.
3289    #[test]
3290    fn supervisor_layout_26_agents_rejected_by_layout_helper() {
3291        // The layout helper is the single gate for the hard cap; the tmux
3292        // builder is unreachable when supervisor_layout errors.
3293        let err = crate::supervisor::layout::supervisor_layout(26).expect_err("26 agents rejected");
3294        let msg = err.to_string();
3295        assert!(msg.contains("26 agents requested"));
3296        assert!(msg.contains("maximum is 25"));
3297    }
3298
3299    /// 9.7 — pane indices follow row-major order. With 7 agents, pane 2 is
3300    /// the first agent (top-left), pane 6 is the fifth (top-right of row 1),
3301    /// pane 7 is the sixth (start of row 2).
3302    #[test]
3303    fn supervisor_layout_7_agents_row_major_indices() {
3304        let session = build_for(7);
3305        let cmds = session.command_strings();
3306        let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
3307            .into_iter()
3308            .filter(|c| !c.trim_end().ends_with("C-u"))
3309            .collect();
3310        // pane :0.2 is the first agent — its send-keys must contain its CLI
3311        // command. Likewise :0.6 (fifth agent) and :0.7 (sixth agent).
3312        assert!(
3313            send_keys
3314                .iter()
3315                .any(|c| c.contains(":0.2 ") && c.contains("claude")),
3316            "pane :0.2 is the first agent (top-left); send-keys {send_keys:#?}"
3317        );
3318        assert!(
3319            send_keys
3320                .iter()
3321                .any(|c| c.contains(":0.6 ") && c.contains("claude")),
3322            "pane :0.6 is the fifth agent (top-right of row 1)"
3323        );
3324        assert!(
3325            send_keys
3326                .iter()
3327                .any(|c| c.contains(":0.7 ") && c.contains("claude")),
3328            "pane :0.7 is the sixth agent (start of row 2)"
3329        );
3330    }
3331
3332    // Maps to scenario `Top row is split 50/50 between supervisor and
3333    // dashboard` from supervisor-as-pane. (test-coverage-v0-5-0 task 12.7)
3334    #[test]
3335    fn supervisor_top_row_split_50_50() {
3336        let session = build_for(3);
3337        let cmds = session.command_strings();
3338        let h_split = cmds
3339            .iter()
3340            .find(|c| c.contains("split-window") && c.contains("-h") && c.contains("-l 50%"))
3341            .unwrap_or_else(|| panic!("expected horizontal 50% split; got cmds: {cmds:#?}"));
3342        assert!(
3343            h_split.contains(":0.0") || h_split.contains("split-window -h -t paw-proj"),
3344            "horizontal split should target the supervisor pane; got: {h_split}"
3345        );
3346    }
3347
3348    /// AC: Supervisor splits use `-l <N>%` (tmux 3.1+ syntax), not the
3349    /// deprecated `-p <N>` form. Headless Linux tmux 3.4 fails on `-p`
3350    /// with `size missing` because the resolver consults pane geometry
3351    /// (unresolved without an attached client) rather than window
3352    /// geometry. Pin the convention so no future call site regresses.
3353    #[test]
3354    fn supervisor_splits_use_l_percent_not_p() {
3355        let session = build_for(4);
3356        let cmds = session.command_strings();
3357        for cmd in &cmds {
3358            if cmd.contains("split-window") {
3359                assert!(
3360                    !cmd.contains(" -p "),
3361                    "split-window must not use deprecated -p flag (fails on Linux tmux 3.4 headless); got: {cmd}"
3362                );
3363            }
3364        }
3365    }
3366
3367    /// AC: Supervisor session passes -x/-y to new-session for headless
3368    /// environments.
3369    #[test]
3370    fn supervisor_new_session_passes_explicit_x_and_y() {
3371        let session = build_for(2);
3372        let cmds = session.command_strings();
3373        let new_session_cmd = cmds
3374            .iter()
3375            .find(|c| c.contains("new-session"))
3376            .expect("supervisor build emits a new-session command");
3377        assert!(
3378            new_session_cmd.contains("-x 480"),
3379            "supervisor new-session must pass -x 480; got: {new_session_cmd}"
3380        );
3381        assert!(
3382            new_session_cmd.contains("-y 140"),
3383            "supervisor new-session must pass -y 140; got: {new_session_cmd}"
3384        );
3385    }
3386
3387    /// AC: Supervisor session sets global default-size after new-session.
3388    #[test]
3389    fn supervisor_sets_default_size_after_new_session() {
3390        let session = build_for(2);
3391        let cmds = session.command_strings();
3392        let new_session_idx = cmds
3393            .iter()
3394            .position(|c| c.contains("new-session"))
3395            .expect("new-session in command list");
3396        let default_size_idx = cmds
3397            .iter()
3398            .position(|c| {
3399                c.contains("set-option") && c.contains("default-size") && c.contains("480x140")
3400            })
3401            .expect("set-option default-size 200x50 in command list");
3402        assert!(
3403            default_size_idx > new_session_idx,
3404            "set-option default-size must come AFTER new-session; got order new={new_session_idx}, default-size={default_size_idx}"
3405        );
3406    }
3407
3408    // Maps to scenario `Broker enabled in bare-start mode adds dashboard as
3409    // pane 0` from supervisor-as-pane. The bare-start tmux build uses
3410    // `TmuxSessionBuilder::add_pane(...)` in source order — production code
3411    // adds the dashboard pane first when broker is enabled. We mirror that
3412    // order in the test fixture so the pane-index contract is asserted.
3413    // (test-coverage-v0-5-0 task 12.1)
3414    #[test]
3415    fn bare_start_with_broker_places_dashboard_at_pane_0() {
3416        // Mirror cmd_start with broker enabled: dashboard first, then agents.
3417        let session = TmuxSessionBuilder::new("proj")
3418            .add_pane(make_pane("dashboard", "/repo", "git-paw __dashboard"))
3419            .add_pane(make_pane("feat/a", "/tmp/wt-a", "claude"))
3420            .add_pane(make_pane("feat/b", "/tmp/wt-b", "claude"))
3421            .add_pane(make_pane("feat/c", "/tmp/wt-c", "claude"))
3422            .build()
3423            .expect("session builds");
3424
3425        let cmds = session.command_strings();
3426        let dashboard_send = cmds
3427            .iter()
3428            .find(|c| c.contains("send-keys") && c.contains("__dashboard"))
3429            .expect("dashboard send-keys present");
3430        assert!(
3431            dashboard_send.contains(":0.0 "),
3432            "dashboard pane must be index 0; got: {dashboard_send}"
3433        );
3434        // Each agent pane carries its worktree on the `split-window -c`
3435        // (the pane is created in the worktree directly to avoid the
3436        // `cd && cli` send-keys race) AND has a `select-pane -T` at the
3437        // expected pane index.
3438        for (pane_idx, branch_marker, worktree) in [
3439            (1, "feat/a", "/tmp/wt-a"),
3440            (2, "feat/b", "/tmp/wt-b"),
3441            (3, "feat/c", "/tmp/wt-c"),
3442        ] {
3443            let select_target = format!(":0.{pane_idx} ");
3444            assert!(
3445                cmds.iter()
3446                    .any(|c| c.contains(&select_target) && c.contains(branch_marker)),
3447                "agent {branch_marker} should land at pane {pane_idx}; cmds:\n{cmds:#?}"
3448            );
3449            let split_marker = format!("-c {worktree}");
3450            assert!(
3451                cmds.iter()
3452                    .any(|c| c.contains("split-window") && c.contains(&split_marker)),
3453                "agent {branch_marker} split should carry {split_marker}; cmds:\n{cmds:#?}"
3454            );
3455        }
3456    }
3457
3458    // Maps to scenario `Broker disabled produces no dashboard pane` from
3459    // supervisor-as-pane. (test-coverage-v0-5-0 task 12.2)
3460    #[test]
3461    fn broker_disabled_produces_no_dashboard_pane() {
3462        let session = TmuxSessionBuilder::new("proj")
3463            .add_pane(make_pane("feat/a", "/tmp/wt-a", "claude"))
3464            .add_pane(make_pane("feat/b", "/tmp/wt-b", "claude"))
3465            .add_pane(make_pane("feat/c", "/tmp/wt-c", "claude"))
3466            .build()
3467            .expect("session builds");
3468
3469        let cmds = session.command_strings();
3470        assert!(
3471            !cmds.iter().any(|c| c.contains("__dashboard")),
3472            "broker disabled must not add a dashboard pane; got cmds:\n{cmds:#?}"
3473        );
3474        // Three send-keys (one per agent pane), no dashboard send-keys.
3475        let send_keys: Vec<&String> = cmds.iter().filter(|c| c.contains("send-keys")).collect();
3476        assert_eq!(
3477            send_keys.len(),
3478            3,
3479            "broker-disabled launch with 3 agents must emit 3 send-keys; got: {send_keys:#?}"
3480        );
3481    }
3482
3483    // Maps to scenario `Dashboard pane title` from supervisor-as-pane.
3484    // (test-coverage-v0-5-0 task 12.3)
3485    #[test]
3486    fn dashboard_pane_has_title_dashboard() {
3487        // Use the supervisor layout (the dashboard-bearing argv builder).
3488        let session = build_for(2);
3489        let cmds = session.command_strings();
3490        let dashboard_select = cmds
3491            .iter()
3492            .find(|c| {
3493                c.contains("select-pane")
3494                    && c.contains(":0.1")
3495                    && c.contains("-T")
3496                    && c.contains("dashboard")
3497            })
3498            .unwrap_or_else(|| {
3499                panic!("expected select-pane -T dashboard at :0.1; cmds:\n{cmds:#?}")
3500            });
3501        // The shipped title shape is `<branch> → <cli_command>` with branch =
3502        // "dashboard". Confirm the title argument contains the bare word.
3503        assert!(
3504            dashboard_select.contains("dashboard"),
3505            "dashboard pane title must include `dashboard`; got: {dashboard_select}"
3506        );
3507    }
3508
3509    /// Sanity: `env_vars` surface as set-environment commands BEFORE any
3510    /// agent-pane send-keys, so coding agents inherit `GIT_PAW_BROKER_URL`.
3511    #[test]
3512    fn supervisor_layout_emits_env_before_agent_send_keys() {
3513        let session = build_for(3);
3514        let cmds = session.command_strings();
3515        let first_env = cmds
3516            .iter()
3517            .position(|c| c.contains("set-environment") && c.contains("GIT_PAW_BROKER_URL"))
3518            .expect("set-environment GIT_PAW_BROKER_URL present");
3519        let first_agent_send = cmds
3520            .iter()
3521            .position(|c| c.contains("send-keys") && c.contains(":0.2 "))
3522            .expect("first agent send-keys at :0.2");
3523        assert!(
3524            first_env < first_agent_send,
3525            "set-environment must come before agent-pane send-keys"
3526        );
3527    }
3528
3529    // -----------------------------------------------------------------------
3530    // Convention enforcement (cold-start-ci-parity §3): every `new-session`
3531    // command produced by every builder in this module SHALL pass `-x`/`-y`
3532    // (headless tmux needs explicit size) and `-c <cwd>` (avoid the
3533    // send-keys cd race).
3534    //
3535    // Every new builder that emits `new-session` MUST be added to
3536    // `every_new_session_command()` below so these tests cover it.
3537    // -----------------------------------------------------------------------
3538
3539    /// Collect every `new-session` argv string produced by every public
3540    /// builder in this module. Add the next builder's output here when a
3541    /// new entry point is introduced.
3542    fn every_new_session_command() -> Vec<(&'static str, String)> {
3543        let mut found: Vec<(&'static str, String)> = Vec::new();
3544
3545        // Builder 1: basic TmuxSessionBuilder.
3546        let basic = TmuxSessionBuilder::new("conv-basic")
3547            .add_pane(make_pane("main", "/tmp/wt-basic", "claude"))
3548            .build()
3549            .expect("basic builder produces a session");
3550        for cmd in basic.command_strings() {
3551            if cmd.contains("new-session") {
3552                found.push(("TmuxSessionBuilder::build", cmd));
3553            }
3554        }
3555
3556        // Builder 2: supervisor-mode layout. Build a small variant so the
3557        // sample is fast; the new-session shape doesn't depend on agent
3558        // count.
3559        let layout = crate::supervisor::layout::supervisor_layout(2).expect("layout");
3560        let (supervisor, dashboard, agents) = make_layout_panes(2);
3561        let supervisor_session = build_supervisor_session(
3562            "conv-supervisor",
3563            None,
3564            &supervisor,
3565            &dashboard,
3566            &agents,
3567            layout,
3568            true,
3569            true,
3570            &[],
3571        )
3572        .expect("supervisor builder produces a session");
3573        for cmd in supervisor_session.command_strings() {
3574            if cmd.contains("new-session") {
3575                found.push(("build_supervisor_session", cmd));
3576            }
3577        }
3578
3579        assert!(
3580            !found.is_empty(),
3581            "expected at least one new-session command from the audited builders"
3582        );
3583        found
3584    }
3585
3586    /// Every `new-session` argv SHALL carry `-x` and `-y` so tmux can size
3587    /// the session without an attached client. Regression guard for the
3588    /// v0.5.0 `Tmux error: size missing` cold-start bug.
3589    #[test]
3590    fn every_new_session_passes_x_and_y() {
3591        for (builder, cmd) in every_new_session_command() {
3592            assert!(
3593                cmd.contains(" -x ") || cmd.ends_with(" -x"),
3594                "{builder}: new-session must pass -x; got: {cmd}"
3595            );
3596            assert!(
3597                cmd.contains(" -y ") || cmd.ends_with(" -y"),
3598                "{builder}: new-session must pass -y; got: {cmd}"
3599            );
3600        }
3601    }
3602
3603    /// Every `new-session` argv SHALL carry `-c <cwd>` so pane 0 starts in
3604    /// the agent's worktree without a follow-up `cd` send-keys race. Bug B
3605    /// regression guard from the v0.5.0 dogfood report.
3606    #[test]
3607    fn every_new_session_passes_c() {
3608        for (builder, cmd) in every_new_session_command() {
3609            assert!(
3610                cmd.contains(" -c "),
3611                "{builder}: new-session must pass -c <cwd>; got: {cmd}"
3612            );
3613        }
3614    }
3615
3616    /// Bug B regression coverage: every agent pane SHALL be created with
3617    /// `-c <agent.worktree>` on its split, and the follow-up `send-keys`
3618    /// SHALL NOT use the `cd <worktree> && <cli>` race chain.
3619    #[test]
3620    fn supervisor_layout_agent_splits_carry_worktree_no_cd_chain() {
3621        let layout = crate::supervisor::layout::supervisor_layout(2).expect("layout");
3622        let supervisor = make_pane("supervisor", "/repo", "claude");
3623        let dashboard = make_pane("dashboard", "/repo", "git-paw __dashboard");
3624        let agent_a = make_pane("feat/a", "/tmp/wt-a", "claude");
3625        let agent_b = make_pane("feat/b", "/tmp/wt-b", "claude");
3626        let session = build_supervisor_session(
3627            "proj",
3628            None,
3629            &supervisor,
3630            &dashboard,
3631            &[agent_a, agent_b],
3632            layout,
3633            true,
3634            true,
3635            &[],
3636        )
3637        .expect("session builds");
3638
3639        let cmds = session.command_strings();
3640        let splits = commands_containing(&cmds, "split-window");
3641        assert!(
3642            splits.iter().any(|c| c.contains("-c /tmp/wt-a")),
3643            "split for agent a should pass -c /tmp/wt-a; splits: {splits:#?}"
3644        );
3645        assert!(
3646            splits.iter().any(|c| c.contains("-c /tmp/wt-b")),
3647            "split for agent b should pass -c /tmp/wt-b; splits: {splits:#?}"
3648        );
3649
3650        let send_keys: Vec<String> = commands_containing(&cmds, "send-keys")
3651            .into_iter()
3652            .filter(|c| !c.trim_end().ends_with("C-u"))
3653            .collect();
3654        for entry in &send_keys {
3655            assert!(
3656                !entry.contains("cd /tmp/wt-a &&"),
3657                "no send-keys should chain `cd /tmp/wt-a &&`; got: {entry}"
3658            );
3659            assert!(
3660                !entry.contains("cd /tmp/wt-b &&"),
3661                "no send-keys should chain `cd /tmp/wt-b &&`; got: {entry}"
3662            );
3663        }
3664    }
3665
3666    // -- add/remove re-tile builders (git-paw-add D1/D6) --
3667
3668    #[test]
3669    fn add_agent_same_row_splits_horizontally_from_previous_pane() {
3670        // 4 agents already present (single row, indices 2..=5); adding a 5th
3671        // (agent index 4) stays in the same row -> horizontal split from the
3672        // immediately-preceding pane (index 5), new pane at index 6.
3673        let layout = crate::supervisor::layout::layout_for(5).expect("layout");
3674        let new_agent = make_pane("feat/fifth", "/tmp/wt5", "claude");
3675        let session = build_add_agent_commands("paw-x", &new_agent, 4, layout, true);
3676        let cmds = session.command_strings();
3677
3678        assert!(
3679            cmds.iter().any(|c| c.contains("split-window")
3680                && c.contains("-h")
3681                && c.contains(":0.5")
3682                && c.contains("-c /tmp/wt5")),
3683            "5th agent should -h split from pane 5 with -c worktree; cmds:\n{cmds:#?}"
3684        );
3685        // New pane is targeted at index 6 for title + launch.
3686        assert!(
3687            cmds.iter()
3688                .any(|c| c.contains("send-keys") && c.contains(":0.6") && c.contains("claude")),
3689            "new agent CLI should launch in pane 6; cmds:\n{cmds:#?}"
3690        );
3691    }
3692
3693    #[test]
3694    fn add_agent_new_row_splits_vertically_from_previous_row_first_pane() {
3695        // 5 agents present (one full row, indices 2..=6); adding a 6th (agent
3696        // index 5) starts a new row -> vertical split from the previous row's
3697        // first pane (index 2).
3698        let layout = crate::supervisor::layout::layout_for(6).expect("layout");
3699        let new_agent = make_pane("feat/sixth", "/tmp/wt6", "claude");
3700        let session = build_add_agent_commands("paw-x", &new_agent, 5, layout, false);
3701        let cmds = session.command_strings();
3702
3703        assert!(
3704            cmds.iter().any(|c| c.contains("split-window")
3705                && c.contains("-v")
3706                && c.contains(":0.2")
3707                && c.contains("-c /tmp/wt6")),
3708            "6th agent should -v split from pane 2 (prev row first); cmds:\n{cmds:#?}"
3709        );
3710    }
3711
3712    #[test]
3713    fn add_agent_reapplies_row_height_resize_pass() {
3714        // The re-tile must end with the same per-row resize pass start uses:
3715        // one resize for the top row (:0.0) at top_row_pct, one per agent row.
3716        let layout = crate::supervisor::layout::layout_for(5).expect("layout");
3717        let new_agent = make_pane("feat/fifth", "/tmp/wt5", "claude");
3718        let session = build_add_agent_commands("paw-x", &new_agent, 4, layout, false);
3719        let cmds = session.command_strings();
3720
3721        let top_pct = format!("{}%", layout.top_row_pct);
3722        assert!(
3723            cmds.iter()
3724                .any(|c| c.contains("resize-pane") && c.contains(":0.0") && c.contains(&top_pct)),
3725            "re-tile should resize the top row to {top_pct}; cmds:\n{cmds:#?}"
3726        );
3727    }
3728
3729    #[test]
3730    fn remove_retile_emits_resize_pass_for_remaining_count() {
3731        // After removing one of 5 agents, the grid re-tiles to the 4-agent
3732        // layout: a top-row resize plus one agent-row resize (single row).
3733        let layout = crate::supervisor::layout::layout_for(4).expect("layout");
3734        let session = build_remove_retile_commands("paw-x", 4, layout);
3735        let cmds = session.command_strings();
3736
3737        let top_pct = format!("{}%", layout.top_row_pct);
3738        assert!(
3739            cmds.iter()
3740                .any(|c| c.contains("resize-pane") && c.contains(":0.0") && c.contains(&top_pct)),
3741            "remove re-tile should resize the top row; cmds:\n{cmds:#?}"
3742        );
3743        // 4 agents -> 1 agent row -> exactly one agent-row resize (pane :0.2).
3744        assert!(
3745            cmds.iter()
3746                .any(|c| c.contains("resize-pane") && c.contains(":0.2")),
3747            "remove re-tile should resize the first agent row (pane 2); cmds:\n{cmds:#?}"
3748        );
3749    }
3750
3751    #[test]
3752    fn remove_retile_with_zero_remaining_is_empty() {
3753        let layout = crate::supervisor::layout::layout_for(1).expect("layout");
3754        let session = build_remove_retile_commands("paw-x", 0, layout);
3755        assert!(
3756            session.command_strings().is_empty(),
3757            "removing the last agent leaves the top row untouched (no re-tile)"
3758        );
3759    }
3760
3761    // -----------------------------------------------------------------------
3762    // G3 — per-row equal-width rebalance arithmetic (design D4)
3763    // -----------------------------------------------------------------------
3764
3765    /// A 3-agent single row resizes the first two agent panes to an equal
3766    /// third (the last absorbs the remainder), so the row is equal thirds —
3767    /// NOT the raw 50/25/25 a chain of `-h` splits produces. At a 480-col
3768    /// window: (480-2)/3 = 159 cols each; the remainder pane lands at 162,
3769    /// within a one-column-per-pane tolerance of the 160 ideal.
3770    #[test]
3771    fn agent_row_widths_three_agents_equal_thirds() {
3772        let targets = agent_row_widths(480, 3);
3773        // Panes :0.2 and :0.3 resized; :0.4 omitted (absorbs remainder).
3774        assert_eq!(targets, vec![(2, 159), (3, 159)]);
3775    }
3776
3777    /// A full 5-agent row targets an equal fifth per pane (resizing the first
3778    /// four). At 480 cols: (480-4)/5 = 95 each.
3779    #[test]
3780    fn agent_row_widths_five_agents_equal_fifths() {
3781        let targets = agent_row_widths(480, 5);
3782        assert_eq!(targets, vec![(2, 95), (3, 95), (4, 95), (5, 95)]);
3783    }
3784
3785    /// A single agent needs no rebalance; two rows (6 agents = 5 + 1) rebalance
3786    /// only the full first row — the lone second-row pane (:0.7) is omitted.
3787    #[test]
3788    fn agent_row_widths_skips_single_pane_rows() {
3789        assert!(
3790            agent_row_widths(480, 1).is_empty(),
3791            "a lone agent needs no width rebalance"
3792        );
3793        let targets = agent_row_widths(480, 6);
3794        // Row 1 panes :0.2..:0.5 resized (4); row 2's lone pane :0.7 omitted.
3795        assert_eq!(targets.len(), 4, "only the full first row rebalances");
3796        assert!(
3797            targets.iter().all(|(idx, _)| *idx >= 2 && *idx <= 5),
3798            "no second-row pane resized; got {targets:?}"
3799        );
3800    }
3801
3802    /// The rebalance never targets the top row (panes 0/1), so the
3803    /// supervisor/dashboard 50/50 split is untouched (spec 4.3 / D4).
3804    #[test]
3805    fn agent_row_widths_never_touch_top_row() {
3806        for n in 1..=crate::supervisor::layout::SUPERVISOR_MAX_AGENTS {
3807            for (idx, _) in agent_row_widths(480, n) {
3808                assert!(idx >= 2, "rebalance must skip panes 0 and 1 for n={n}");
3809            }
3810        }
3811    }
3812
3813    /// No agent row exceeds 5 panes, so the smallest equal-width target stays
3814    /// at ~20% of the window (design D5). At a 480-col window every per-pane
3815    /// target is >= floor(480/5)-ish; assert none drops below 20% of width.
3816    #[test]
3817    fn agent_row_widths_minimum_is_one_fifth() {
3818        let window = 480usize;
3819        let floor = window / 5; // 96 cols = 20%
3820        for n in 1..=crate::supervisor::layout::SUPERVISOR_MAX_AGENTS {
3821            for (idx, cols) in agent_row_widths(window, n) {
3822                assert!(
3823                    cols + 1 >= floor,
3824                    "pane {idx} width {cols} below the ~20% floor ({floor}) for n={n}"
3825                );
3826            }
3827        }
3828    }
3829
3830    /// A zero-width window (no live geometry) yields no resize targets.
3831    #[test]
3832    fn agent_row_widths_zero_window_is_empty() {
3833        assert!(agent_row_widths(0, 5).is_empty());
3834    }
3835
3836    // -----------------------------------------------------------------------
3837    // 6.5 — JSON↔tmux reconciliation (design D3, G2b)
3838    // -----------------------------------------------------------------------
3839
3840    #[test]
3841    fn reconcile_reports_agent_with_no_live_pane() {
3842        let agents = vec![
3843            ("feat/a".to_string(), PathBuf::from("/tmp/wt-a")),
3844            ("feat/b".to_string(), PathBuf::from("/tmp/wt-b")),
3845            ("feat/c".to_string(), PathBuf::from("/tmp/wt-c")),
3846        ];
3847        // Live panes map only to a and c; b's pane was dropped.
3848        let live = vec![PathBuf::from("/tmp/wt-a"), PathBuf::from("/tmp/wt-c")];
3849        let missing = agents_without_live_pane(&agents, &live);
3850        assert_eq!(
3851            missing,
3852            vec!["feat/b".to_string()],
3853            "only b has no live pane"
3854        );
3855    }
3856
3857    #[test]
3858    fn reconcile_passes_when_every_agent_maps() {
3859        let agents = vec![
3860            ("feat/a".to_string(), PathBuf::from("/tmp/wt-a")),
3861            ("feat/b".to_string(), PathBuf::from("/tmp/wt-b")),
3862        ];
3863        let live = vec![
3864            PathBuf::from("/tmp/wt-b"),
3865            PathBuf::from("/tmp/wt-a"),
3866            PathBuf::from("/tmp/wt-supervisor"),
3867        ];
3868        assert!(
3869            agents_without_live_pane(&agents, &live).is_empty(),
3870            "all agents map to a live pane → no divergence"
3871        );
3872    }
3873
3874    // -----------------------------------------------------------------------
3875    // 6.6 — launch-readiness gate (design D1, G1)
3876    // -----------------------------------------------------------------------
3877
3878    fn test_budget() -> ReadinessBudget {
3879        // Tiny budget so the IO-free generic core runs instantly: timeout =
3880        // 2 poll intervals, one relaunch attempt.
3881        ReadinessBudget {
3882            poll_interval: Duration::from_millis(1),
3883            timeout: Duration::from_millis(2),
3884            relaunch_attempts: 1,
3885        }
3886    }
3887
3888    #[test]
3889    fn classify_distinguishes_ready_bareshell_indeterminate() {
3890        assert_eq!(
3891            classify_pane_readiness("some banner\n? for shortcuts\n"),
3892            PaneReadiness::Ready
3893        );
3894        assert_eq!(
3895            classify_pane_readiness("user@host ~/repo % "),
3896            PaneReadiness::BareShell
3897        );
3898        assert_eq!(
3899            classify_pane_readiness("\n\n   \n"),
3900            PaneReadiness::Indeterminate,
3901            "a blank/clearing screen is not yet a bare shell"
3902        );
3903    }
3904
3905    #[test]
3906    fn gate_returns_ready_without_relaunch_when_marker_present() {
3907        let mut relaunches = 0;
3908        let outcome = gate_pane_generic(
3909            test_budget(),
3910            || Some("welcome\n? for shortcuts".to_string()),
3911            || relaunches += 1,
3912            |_| {},
3913        );
3914        assert_eq!(outcome, GateOutcome::Ready);
3915        assert_eq!(relaunches, 0, "a ready pane is never relaunched");
3916    }
3917
3918    #[test]
3919    fn gate_relaunches_a_persistent_bare_shell_then_falls_back() {
3920        let mut relaunches = 0;
3921        let outcome = gate_pane_generic(
3922            test_budget(),
3923            || Some("user@host:~$ ".to_string()),
3924            || relaunches += 1,
3925            |_| {},
3926        );
3927        assert_eq!(
3928            outcome,
3929            GateOutcome::FellBack,
3930            "a never-ready bare shell falls back after the relaunch budget"
3931        );
3932        assert_eq!(
3933            relaunches, 1,
3934            "exactly one relaunch fires (relaunch_attempts = 1)"
3935        );
3936    }
3937
3938    #[test]
3939    fn gate_does_not_relaunch_an_unrecognised_cli() {
3940        let mut relaunches = 0;
3941        // Content that is neither a known marker nor an obvious shell prompt.
3942        let outcome = gate_pane_generic(
3943            test_budget(),
3944            || Some("custom-cli interactive session — type a command".to_string()),
3945            || relaunches += 1,
3946            |_| {},
3947        );
3948        assert_eq!(outcome, GateOutcome::FellBack);
3949        assert_eq!(
3950            relaunches, 0,
3951            "an unrecognised (indeterminate) CLI falls back without relaunching"
3952        );
3953    }
3954
3955    #[test]
3956    fn gate_becomes_ready_after_a_relaunch() {
3957        let mut captures = 0;
3958        let mut relaunches = 0;
3959        let outcome = gate_pane_generic(
3960            test_budget(),
3961            || {
3962                captures += 1;
3963                // Bare shell until the relaunch fires (~4 captures for the
3964                // first attempt's poll loop + final classification), then the
3965                // CLI marker appears.
3966                if captures > 4 {
3967                    Some("? for shortcuts".to_string())
3968                } else {
3969                    Some("user@host:~$ ".to_string())
3970                }
3971            },
3972            || relaunches += 1,
3973            |_| {},
3974        );
3975        assert_eq!(outcome, GateOutcome::Ready);
3976        assert_eq!(
3977            relaunches, 1,
3978            "the bare shell was relaunched once before going ready"
3979        );
3980    }
3981}