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).
14 KiB
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:
-
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. -
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.
-
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
- When the operator runs
./cli.py dashboardfrom inside a tmux session ($TMUXset), the dashboard establishes a two-pane layout: dashboard in the left pane, an initially- empty right pane reserved for claude sessions. - 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. - Pressing
nto 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. - Pressing
xto 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). - Closing the right pane manually via tmux (e.g.,
C-b x) leaves the dashboard intact; the next Enter creates a fresh right pane. - Outside tmux (
$TMUXunset), the dashboard's Enter /nbehavior falls back to today's handoff. No tmux dependency for non-tmux users. - 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 fromnew-windowtosplit-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, runstmux split-window -h …to create the right pane and remembers its tmux pane id so subsequent attaches usetmux 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 viatmux list-panesbefore respawn, so a manually-closed right pane gracefully falls back to a new split. - Updated dispatch in
_attach_to_bottleand_new_agent_flow:$TMUXset → split-pane / respawn-pane path$TMUXunset → existing handoff- PR #48's
tmux new-windowpath becomes the fallback inside the split-pane code (e.g., iftmux split-windowfails, or via a config knob).
_stop_bottle_flowclearsright_pane_idif 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
# 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:
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
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:
$TMUXset but tmux binary not on PATH (rare but possible in nested containers). Detected whensubprocess.runraisesFileNotFoundError— treat as not- in-tmux, fall back to handoff.tmux split-windowreturns non-zero (e.g., pane size too small, server lost). Surface a status-line error and fall back to handoff for that one keypress.- 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.
- Pane state + create path. Add
right_pane_id/right_pane_slugto the main loop; implement_tmux_split_pane_create; wire Enter /nto use it whenright_pane_id is None. No respawn yet — second attach creates a second split-pane (visibly wrong, but isolated). - 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. - Stop integration.
_stop_bottle_flowclearsright_pane_slugwhen the stopped bottle matches; tests. - Replace PR #48's new-window with split-pane. Remove the
tmux new-windowinvocation; keep_build_tmux_attach_argvas 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
-
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-hfor v1; revisit if the dashboard's agents-pane row count proves cramped. -
What happens to claude when the right pane is killed?
tmux kill-paneSIGHUPs 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--continueand picks up the conversation — so the operator loses nothing except the pane's screen buffer. Document this; no special handling needed. -
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 dashboardshouldn'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. -
Pane size after split. tmux's default for
split-window -his 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. -
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_PANEcontext). No special handling needed — but worth verifying behavior when the operator switches away from window A before the dashboard issues a split. -
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). -
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