docs(prd-0021): dashboard as left tmux pane, selected agent as right pane
test / unit (pull_request) Successful in 18s
test / integration (pull_request) Successful in 1m8s

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).
This commit is contained in:
2026-05-26 14:14:02 -04:00
parent c8c72debff
commit 8b8d668602
+339
View File
@@ -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-<slug> 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 <id> …` 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 <id> -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 <pct>` or
`-l <cols>` 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