Skip to main content

git_paw/
tmux.rs

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