diff --git a/docs/prds/0021-dashboard-tmux-split-pane.md b/docs/prds/0021-dashboard-tmux-split-pane.md index f8d764b..f4f8ebe 100644 --- a/docs/prds/0021-dashboard-tmux-split-pane.md +++ b/docs/prds/0021-dashboard-tmux-split-pane.md @@ -10,55 +10,34 @@ When the dashboard runs inside tmux, lay it out as the **left pane** of a two-pane window with the **selected agent's claude session in the right pane**. Pressing Enter on an agent in the agents pane swaps the right pane to that agent's session -(respawning with `claude --continue` so the conversation -picks up where it left off). Pressing `n` to start a new agent -spawns it directly into the right pane. The dashboard is the +(respawning with `claude --continue` so the conversation picks +up where it left off). Pressing `n` to start a new agent spawns +it directly into the right pane. The dashboard is the operator's persistent left-hand surface; claude is always visible to its right. -Outside tmux, fall back to today's handoff (`curses.endwin` → -foreground claude → `stdscr.refresh`). The split-pane UX is +Outside tmux, fall back to today's handoff (`curses.endwin → +foreground claude → stdscr.refresh`). The split-pane UX is opt-in by environment — running the dashboard inside a tmux session enables it, no flag required. -This supersedes the "new-window per agent" shape of PR #48 -(option 3 from the research doc) with a tighter, -selection-driven model where the right pane reflects what the -operator is *currently focused on* rather than accumulating one -window per ever-attached bottle. - ## Problem -PR #48 (and option 3 from `docs/research/claude-code-pane-in- -dashboard.md`) gets us partway there: when `$TMUX` is set, the -dashboard's Enter / `n` keys spawn claude in a new tmux -**window** (separate tab) instead of taking over the terminal. -That's better than the handoff in two respects — the dashboard -keeps refreshing, and multiple agents get their own panes — but -it has three real rough edges: +The dashboard's "Enter to attach" (PRD 0020 chunk 3) and its +new-agent `n` flow both take over the terminal for the +duration of the claude session via `curses.endwin`. While +claude has the screen, the dashboard's proposal queue and +agents pane are invisible. Any tool call that lands during a +claude session is silent until the operator exits back to the +dashboard. -1. **No simultaneous view.** A tmux window is a separate tab. - The operator switches to claude via `C-b n`, away from the - dashboard. Watching proposals queue while talking to claude - is back to the two-terminals-side-by-side workflow the - dashboard was supposed to collapse. - -2. **Window accumulation.** Each agent ever attached adds a - tmux window. After a day with 10 bottles the operator has 10 - windows to navigate; the dashboard's agents pane and tmux's - window list duplicate each other. - -3. **Selection doesn't drive layout.** The dashboard's - selection model (PRD 0019) already knows which agent the - operator cares about right now. The new-window shape ignores - that — the operator has to manually switch tmux windows - independent of the dashboard's cursor position. - -The split-pane shape this PRD proposes uses the same selection -the dashboard already tracks: cursor on agent X + Enter → -right pane shows agent X. Move cursor to agent Y + Enter → -right pane respawns with agent Y. One window, two panes, no -accumulation. +The split-pane shape this PRD proposes keeps both surfaces +visible: dashboard always on the left, the currently-selected +agent on the right. The dashboard's existing selection model +(PRD 0019) drives which agent occupies the right pane — +moving the cursor in the agents pane is a no-op; pressing +Enter swaps the right pane to that agent's session. One +window, two panes, no terminal handoff. ## Goals / Success Criteria @@ -73,72 +52,73 @@ accumulation. 3. Pressing `n` to start a new agent (the existing chunk-2 flow from PRD 0020) directs the spawned claude session into the right pane instead of taking over the terminal. -4. Pressing `x` to stop a dashboard-owned bottle (PRD 0020 - chunk 4): if that bottle was the right-pane occupant, the - right pane is cleared (or shows a brief "stopped" message). +4. Pressing `x` to stop a dashboard-owned bottle: if that + bottle was the right-pane occupant, the right pane is + cleared (or shows a brief "stopped" message). 5. Closing the right pane manually via tmux (e.g., `C-b x`) leaves the dashboard intact; the next Enter creates a fresh right pane. 6. Outside tmux (`$TMUX` unset), the dashboard's Enter / `n` behavior falls back to today's handoff. No tmux dependency for non-tmux users. -7. The two-pane layout works whether the operator pre-arranges - tmux themselves or lets the dashboard set it up. Both paths - land at the same place. ## Non-goals -- **The embedded-emulator option (option 2 from the research - doc).** This PRD stays on the multiplexer-delegated shape. - pyte-driven in-curses rendering is a separate, much larger - decision. +- **The embedded-emulator option** from + `docs/research/claude-code-pane-in-dashboard.md`. This PRD + stays on the multiplexer-delegated shape. pyte-driven + in-curses rendering is a separate, much larger decision. - **Multi-pane / grid layout.** No "show 4 agents at once." - One left, one right. Picking that as a constraint dramatically - simplifies the state machine. + One left, one right. Picking that as a constraint + dramatically simplifies the state machine. - **Persistence across dashboard exits.** The tmux session state (which agent is in the right pane) doesn't survive a dashboard restart. The bottles themselves persist (PRD 0020's - `q`-doesn't-tear-down), but reattaching is one Enter away - after a fresh launch. -- **Replacing PR #48 entirely while it's in flight.** This PRD - ships AFTER PR #48 lands (or merges over it). The shared - helpers (`_attach_via_tmux`, `_build_tmux_attach_argv`) carry - forward; the dispatch logic changes from `new-window` to - `split-window` / `respawn-pane`. + `q`-doesn't-tear-down); reattaching is one Enter away after a + fresh launch. - **Cross-tmux-session orchestration.** The dashboard owns panes in its own session. Other tmux sessions on the same host are untouched. - **A "right pane is detached" mode** where claude runs in a pane that's not visible until expanded. Out of v1. +- **Auto-execing into a fresh tmux session** when launched + outside one. See open question #3. ## Scope ### In scope -- A `_tmux_split_pane_layout()` helper that, on first attach, - runs `tmux split-window -h …` to create the right pane and - remembers its tmux pane id so subsequent attaches use - `tmux respawn-pane -t …` to swap content. -- Pane-id state on the main loop: `right_pane_id: str | None`. - None means "no right pane yet" → next attach creates one - via split-window. Non-None → respawn-pane. -- A `_pane_exists(pane_id)` check via `tmux list-panes` - before respawn, so a manually-closed right pane gracefully - falls back to a new split. -- Updated dispatch in `_attach_to_bottle` and `_new_agent_flow`: - - `$TMUX` set → split-pane / respawn-pane path - - `$TMUX` unset → existing handoff - - PR #48's `tmux new-window` path becomes the fallback inside - the split-pane code (e.g., if `tmux split-window` fails, or - via a config knob). -- `_stop_bottle_flow` clears `right_pane_id` if the stopped - bottle was the right-pane occupant. +- A `_in_tmux()` helper that returns True when `$TMUX` is set, + with `FileNotFoundError`-safe `subprocess.run` calls + around every tmux invocation so a missing tmux binary + cleanly falls back to handoff mode. +- Two new state fields on the main loop: `right_pane_id: str | + None` (the tmux pane id we created — None = "no right pane + yet, next attach must split") and `right_pane_slug: str | + None` (which bottle is currently in the right pane, for + the stop + indicator hooks). +- A `_tmux_split_pane_create(bottle, *, resume) -> str | None` + helper that runs `tmux split-window -h -P -F '#{pane_id}' …` + and returns the new pane id, or None on failure. +- A `_tmux_respawn_pane(pane_id, bottle, *, resume) -> bool` + helper that runs `tmux respawn-pane -k -t …` and + returns success. +- A `_tmux_pane_exists(pane_id) -> bool` check via + `tmux list-panes -F '#{pane_id}'` so a manually-closed right + pane gracefully falls back to a new split. +- Modified `_attach_to_bottle` and `_new_agent_flow` (PRD + 0020) to dispatch through the tmux helpers when `$TMUX` is + set, falling back to the existing handoff otherwise. +- `_stop_bottle_flow` updated to clear `right_pane_slug` when + the stopped bottle matches. +- An indicator in the agents pane (e.g., `*` prefix or + alternate attr) marking the row whose bottle is currently + in the right pane. ### Out of scope -- Choosing the split direction (-h vs -v). The PRD assumes - horizontal split (left/right). Open question if the operator - wants vertical. +- Choosing the split direction (-h vs -v) as a runtime knob. + v1 is `-h` (left / right). See open question #1. - Sizing the split (e.g., 40/60 vs 50/50). v1 takes the tmux default; sizing knob deferred. - Persisting the right-pane occupant slug to disk so a fresh @@ -148,134 +128,170 @@ accumulation. ### State machine +Two new fields on the main loop: + ```python -# new state on _main_loop: -right_pane_id: str | None = None # tmux pane id we created -right_pane_slug: str | None = None # which bottle's session is in it +right_pane_id: str | None = None # tmux pane id, or None +right_pane_slug: str | None = None # current occupant, or None ``` -Dispatch logic: +### Dispatch + +`_attach_to_bottle` and `_new_agent_flow` (from PRD 0020) gain +a tmux branch: ```python -def _spawn_into_right_pane(bottle, slug, *, resume) -> str: - if not _in_tmux(): - # Fall back to today's handoff. - return _attach_via_handoff(stdscr, bottle, slug, resume=resume) - +def _attach_in_tmux(bottle, slug, *, resume) -> str: nonlocal right_pane_id, right_pane_slug - if right_pane_id and _pane_exists(right_pane_id): - # Respawn content in the existing pane. - ok = _tmux_respawn_pane(right_pane_id, bottle, resume=resume) - if not ok: - right_pane_id = None # fall through to create - if right_pane_id is None or not _pane_exists(right_pane_id): - right_pane_id = _tmux_split_pane_create(bottle, resume=resume) - if right_pane_id is None: - # tmux failed; fall back to handoff. - return _attach_via_handoff(stdscr, bottle, slug, resume=resume) + # If we have a remembered pane and it still exists, respawn it. + if right_pane_id and _tmux_pane_exists(right_pane_id): + if _tmux_respawn_pane(right_pane_id, bottle, resume=resume): + right_pane_slug = slug + return f"[{slug}] in right pane" + right_pane_id = None # respawn failed; fall through + + # Otherwise create a fresh split. + pane_id = _tmux_split_pane_create(bottle, resume=resume) + if pane_id is None: + # tmux failed; fall back to handoff. + return _attach_via_handoff(stdscr, bottle, slug, resume=resume) + right_pane_id = pane_id right_pane_slug = slug return f"[{slug}] in right pane" ``` -### Pane creation + respawn +The non-tmux path is unchanged from PRD 0020 — `_attach_via_ +handoff` is what those two flows already do today (curses. +endwin → attach_claude → stdscr.refresh). + +### Pane creation + +`tmux split-window -h -P -F '#{pane_id}'` opens a right pane +and prints its pane id (e.g., `%12`). The `-h` is horizontal +split (left/right); the `-P -F` combo emits the new pane's +identifier for tracking. ```python def _tmux_split_pane_create(bottle, *, resume) -> str | None: - """Open a right pane via `tmux split-window -h -P -F '#{pane_id}'`. - The -P -F combo prints the new pane's id to stdout so we - can track it for respawn.""" docker_argv = bottle.claude_docker_argv(_claude_args(resume=resume)) - result = subprocess.run( - ["tmux", "split-window", "-h", - "-P", "-F", "#{pane_id}", - *docker_argv], - capture_output=True, text=True, check=False, - ) + try: + result = subprocess.run( + ["tmux", "split-window", "-h", + "-P", "-F", "#{pane_id}", + *docker_argv], + capture_output=True, text=True, check=False, + ) + except FileNotFoundError: + return None if result.returncode != 0: return None - return result.stdout.strip() + return result.stdout.strip() or None +``` +### Pane respawn +`tmux respawn-pane -k -t …` replaces the pane's content +without re-splitting. `-k` kills the existing process first +(claude session ends; the underlying bottle is untouched). +`--continue` on the new claude invocation picks up the prior +conversation. + +```python def _tmux_respawn_pane(pane_id, bottle, *, resume) -> bool: - """Replace the pane's content via `tmux respawn-pane -t -k …`. - -k kills the existing process before respawning. Idempotent - on a pane that's already empty.""" docker_argv = bottle.claude_docker_argv(_claude_args(resume=resume)) - result = subprocess.run( - ["tmux", "respawn-pane", "-k", "-t", pane_id, *docker_argv], - capture_output=True, text=True, check=False, - ) + try: + result = subprocess.run( + ["tmux", "respawn-pane", "-k", "-t", pane_id, *docker_argv], + capture_output=True, text=True, check=False, + ) + except FileNotFoundError: + return False return result.returncode == 0 ``` ### Pane-existence check -`tmux list-panes -F '#{pane_id}'` lists pane ids in the current -window. The dashboard checks for `right_pane_id` membership -before issuing respawn-pane; if absent (operator closed the -pane via `C-b x`), it falls back to creating a fresh split. +```python +def _tmux_pane_exists(pane_id) -> bool: + try: + result = subprocess.run( + ["tmux", "list-panes", "-F", "#{pane_id}"], + capture_output=True, text=True, check=False, + ) + except FileNotFoundError: + return False + if result.returncode != 0: + return False + return pane_id in result.stdout.splitlines() +``` + +### Bottle.claude_docker_argv + +The tmux helpers need the full docker-exec argv for claude — +specifically including the `--append-system-prompt-file ` +flag that `DockerBottle.exec_claude` appends today when the +bottle has a prompt path. Refactor: split `exec_claude` into a +pure `claude_docker_argv(args, *, tty)` that returns the argv +and a thin `exec_claude` that calls `subprocess.run` on it. +Both the tmux path AND the existing foreground path use the +same argv builder. ### Stop integration -`_stop_bottle_flow` already pops the bottle from the -`bottles` dict and calls `cm.__exit__`. The new bit: if the -stopped slug matches `right_pane_slug`, clear both pane -tracking variables. The tmux pane itself stays open with -docker exec's "container not found" error showing — acceptable; -the operator hits Enter on a different agent to repurpose it -(respawn-pane will replace the broken state). +`_stop_bottle_flow` (from PRD 0020 chunk 4) gets a small +addition: after the bottle is torn down, if `right_pane_slug +== stopped_slug`, clear it. The tmux pane itself stays open +with claude's "container not found" error showing. The next +Enter on a different agent will respawn it. + +### Indicator in agents pane + +When `right_pane_slug` matches an agent row's slug, render +that row with a `*` prefix (and / or reverse-video, if the +focus indicator doesn't already conflict). Gives the operator +a clear visual link between dashboard selection and right- +pane content. ### Fallback when tmux fails Three failure modes worth handling: -1. **`$TMUX` set but tmux binary not on PATH** (rare but - possible in nested containers). Detected when - `subprocess.run` raises `FileNotFoundError` — treat as not- - in-tmux, fall back to handoff. -2. **`tmux split-window` returns non-zero** (e.g., pane size - too small, server lost). Surface a status-line error and - fall back to handoff for that one keypress. +1. **`$TMUX` set but tmux binary not on PATH.** `subprocess.run` + raises `FileNotFoundError` — treat as not-in-tmux, fall back + to handoff. Should be rare but happens in nested + containers. +2. **`tmux split-window` / `respawn-pane` returns non-zero.** + Surface a status-line error and fall back to handoff for + that one keypress. The dashboard stays usable. 3. **Right pane manually closed.** Detected at next attach via - pane-exists check; create fresh split. - -### Interaction with PR #48 - -PR #48 introduced `_in_tmux()`, `_attach_via_tmux` (using -`tmux new-window`), and `_build_tmux_attach_argv`. This PRD -reuses the first; rewrites the second; the third (window -argv builder) becomes one of several tmux-argv builders this -PRD introduces. Net change: PR #48's new-window behavior is -replaced by split-pane behavior. The diff is moderate but -contained — the abstraction shape carries through. + `_tmux_pane_exists`; create a fresh split. ## Implementation chunks Sized small. -1. **Pane state + create path.** Add `right_pane_id` / - `right_pane_slug` to the main loop; implement - `_tmux_split_pane_create`; wire Enter / `n` to use it when - `right_pane_id is None`. No respawn yet — second attach - creates a *second* split-pane (visibly wrong, but isolated). -2. **Respawn for subsequent attaches.** Implement - `_tmux_respawn_pane` + `_pane_exists`; route second-and- - subsequent attaches through respawn instead of split. The - "wrong" behavior from chunk 1 disappears. -3. **Stop integration.** `_stop_bottle_flow` clears - `right_pane_slug` when the stopped bottle matches; tests. -4. **Replace PR #48's new-window with split-pane.** Remove the - `tmux new-window` invocation; keep `_build_tmux_attach_argv` - as a shared helper if useful. Update PR #48's tests to - match the new behavior. (If PR #48 has merged by then, this - is a follow-up commit; if not, the two PRs reconcile at - merge time.) +1. **`claude_docker_argv` refactor.** Pure-ish split of + `DockerBottle.exec_claude` so both foreground and tmux + paths build on the same argv. No behavior change for the + existing tests. +2. **tmux helpers + pane state.** Add `_in_tmux`, + `_tmux_split_pane_create`, `_tmux_respawn_pane`, + `_tmux_pane_exists`, plus the two new state fields on the + main loop. Wire `_attach_to_bottle` to dispatch through + tmux when in tmux, fall back to handoff otherwise. +3. **New-agent flow integration.** `_new_agent_flow` (PRD + 0020 chunk 2) gains the same tmux dispatch — first attach + on a freshly-started agent goes into the right pane. +4. **Stop integration + right-pane indicator.** + `_stop_bottle_flow` clears `right_pane_slug` when matched; + `_format_agent_row` (PRD 0019 chunk 2) gets a `*` prefix + when its slug matches `right_pane_slug`. ## Open questions 1. **Split direction: horizontal (`-h`) vs vertical (`-v`).** - The PRD's "left / right" framing implies `-h`. But on a + The PRD's "left / right" framing implies `-h`. On a short-and-wide terminal a vertical (top / bottom) split might give the dashboard more vertical real estate. Pick `-h` for v1; revisit if the dashboard's agents-pane row @@ -292,10 +308,10 @@ Sized small. 3. **Dashboard launched OUTSIDE tmux but tmux is installed.** Should the dashboard auto-exec itself inside a fresh tmux session to get the split-pane experience? Convenient but - surprising (`./cli.py dashboard` shouldn't silently change - what session you're in). v1 leaves this off — operators - who want split-pane mode start tmux themselves and then - run the dashboard. + surprising (`./cli.py dashboard` shouldn't silently + change what session you're in). v1 leaves this off — + operators who want split-pane mode start tmux themselves + and then run the dashboard. 4. **Pane size after split.** tmux's default for `split-window -h` is 50/50. The dashboard's column widths are designed @@ -304,26 +320,26 @@ Sized small. want ~50 cols and claude gets the rest. Add `-p ` or `-l ` flag? Open question; v1 uses tmux's default. -5. **Multi-window tmux sessions.** If the operator has the - dashboard in tmux window A and unrelated work in window B, - the split-pane goes into window A as expected (tmux - commands are window-scoped by `$TMUX_PANE` context). No - special handling needed — but worth verifying behavior - when the operator switches away from window A before the - dashboard issues a split. +5. **Cursor highlighting in the agents pane to indicate "this + one's in the right pane right now."** Resolved YES in + scope — `*` prefix on the matching row. Open: should it + also use color, and which color doesn't conflict with the + PRD-0019 focus indicator? -6. **Cursor highlighting in the agents pane to indicate "this - one's in the right pane right now."** The dashboard could - mark the slug currently occupying the right pane with a - `*` prefix or alternate color in the agents pane. Probably - yes for v1 — operator clarity. The state's already - tracked (`right_pane_slug`). +6. **Concurrent dashboards in different tmux windows.** + Multiple `./cli.py dashboard` invocations in different + tmux windows would each create their own right pane — + probably fine, each has its own state, but worth + verifying that `tmux list-panes` is scoped to the right + window context. -7. **What about the proposals pane?** Approvals (`a`/`m`/`r`) - are unchanged — they don't open new panes, they apply - in-process. The proposals pane stays exactly as today, - just narrower because it shares the left pane with the - agents list. +7. **`$TMUX_PANE` vs `$TMUX`.** Tmux sets both. We only check + `$TMUX` (presence of the server socket); `$TMUX_PANE` is + the dashboard's own pane id, which we could capture to be + more precise about `tmux split-window -t ` (split + relative to the dashboard's pane specifically). Probably + not needed since `split-window` defaults to the current + active pane, but worth verifying. ## References @@ -333,7 +349,6 @@ Sized small. (`_attach_to_bottle`, `_new_agent_flow`, `_stop_bottle_flow` — the three hooks this PRD changes) - `docs/research/claude-code-pane-in-dashboard.md` — the - three-option survey; this PRD picks option 3 with a tighter + three-option survey of how to surface claude inside the + dashboard; this PRD picks option 3 with a tighter split-pane variant -- PR #48 — opt-in tmux via `tmux new-window`; this PRD - supersedes its window-mode with pane-mode