From 8b8d66860231e8c6e8bf4cea16a2ac87546a2603 Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 26 May 2026 14:14:02 -0400 Subject: [PATCH] docs(prd-0021): dashboard as left tmux pane, selected agent as right pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Draft a PRD that tightens PR #48's tmux integration from "one new window per attach" to "one persistent right pane that the dashboard's selection drives." Inside tmux (`\$TMUX` set): dashboard in the left pane; pressing Enter or `n` spawns claude in the right pane via `tmux split-window` on first attach, then `tmux respawn-pane` on subsequent attaches so the operator-focused agent is always the visible one. Outside tmux: falls back to today's handoff. Opt-in by environment; no flag. Sized into 4 chunks (pane state + create → respawn → stop integration → supersede PR #48's new-window). Seven open questions called out, the biggest being whether the dashboard should auto-exec into a fresh tmux session when launched outside one (v1 says no — operators start tmux themselves). --- docs/prds/0021-dashboard-tmux-split-pane.md | 339 ++++++++++++++++++++ 1 file changed, 339 insertions(+) create mode 100644 docs/prds/0021-dashboard-tmux-split-pane.md diff --git a/docs/prds/0021-dashboard-tmux-split-pane.md b/docs/prds/0021-dashboard-tmux-split-pane.md new file mode 100644 index 0000000..f8d764b --- /dev/null +++ b/docs/prds/0021-dashboard-tmux-split-pane.md @@ -0,0 +1,339 @@ +# PRD 0021: Dashboard as left tmux pane, selected agent as right pane + +- **Status:** Draft +- **Author:** didericis +- **Created:** 2026-05-26 + +## Summary + +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 +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 +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: + +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. + +## Goals / Success Criteria + +1. When the operator runs `./cli.py dashboard` from inside a + tmux session (`$TMUX` set), the dashboard establishes a + two-pane layout: dashboard in the left pane, an initially- + empty right pane reserved for claude sessions. +2. Pressing Enter on a focused agent row spawns / respawns the + right pane with `docker exec -it claude-bottle- claude + --continue --dangerously-skip-permissions`. The right pane's + prior content (if any) is replaced. +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). +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. +- **Multi-pane / grid layout.** No "show 4 agents at once." + 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`. +- **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. + +## 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. + +### Out of scope + +- Choosing the split direction (-h vs -v). The PRD assumes + horizontal split (left/right). Open question if the operator + wants vertical. +- 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 + dashboard restart can restore it. + +## Proposed design + +### State machine + +```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 +``` + +Dispatch logic: + +```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) + + 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) + + right_pane_slug = slug + return f"[{slug}] in right pane" +``` + +### Pane creation + respawn + +```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, + ) + if result.returncode != 0: + return None + return result.stdout.strip() + + +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, + ) + 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. + +### 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). + +### 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. +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. + +## 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.) + +## Open questions + +1. **Split direction: horizontal (`-h`) vs vertical (`-v`).** + The PRD's "left / right" framing implies `-h`. But 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 + count proves cramped. + +2. **What happens to claude when the right pane is killed?** + `tmux kill-pane` SIGHUPs the process inside; claude + shuts down. The bottle stays up (compose project is + independent of the pane). Next attach starts a new claude + process with `--continue` and picks up the conversation — + so the operator loses nothing except the pane's screen + buffer. Document this; no special handling needed. + +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. + +4. **Pane size after split.** tmux's default for `split-window + -h` is 50/50. The dashboard's column widths are designed + for ~80 cols; if the terminal is 160 cols total, 50/50 is + fine. On narrower terminals (~120) the dashboard might + 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. + +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`). + +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. + +## References + +- PRD 0019 — agents pane + selection model (the source of + truth for which agent is "selected" right now) +- PRD 0020 — start + attach from the dashboard + (`_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 + split-pane variant +- PR #48 — opt-in tmux via `tmux new-window`; this PRD + supersedes its window-mode with pane-mode