Files
bot-bottle/docs/prds/0021-dashboard-tmux-split-pane.md
T
didericis 8b8d668602
test / unit (pull_request) Successful in 18s
test / integration (pull_request) Successful in 1m8s
docs(prd-0021): dashboard as left tmux pane, selected agent as right pane
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).
2026-05-26 14:14:02 -04:00

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:

  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

# 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:

  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