Files
bot-bottle/docs/prds/0021-dashboard-tmux-split-pane.md
didericis-codex c0e1f5fd70
test / unit (pull_request) Successful in 36s
test / integration (pull_request) Successful in 54s
docs(prd): supersede dashboard agent PRDs
2026-06-03 17:25:32 +00:00

14 KiB

PRD 0021: Dashboard as left tmux pane, selected agent as right pane

  • Status: Superseded by PRD 0049
  • 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.

Problem

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.

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

  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 bot-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: 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.

Non-goals

  • 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.
  • 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); 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 _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 <id> … 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) 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 dashboard restart can restore it.

Proposed design

State machine

Two new fields on the main loop:

right_pane_id: str | None = None     # tmux pane id, or None
right_pane_slug: str | None = None   # current occupant, or None

Dispatch

_attach_to_bottle and _new_agent_flow (from PRD 0020) gain a tmux branch:

def _attach_in_tmux(bottle, slug, *, resume) -> str:
    nonlocal right_pane_id, right_pane_slug

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

The non-tmux path is unchanged from PRD 0020 — _attach_via_ handoff is what those two flows already do today (curses. endwin → attach_agent → 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.

def _tmux_split_pane_create(bottle, *, resume) -> str | None:
    docker_argv = bottle.claude_docker_argv(_claude_args(resume=resume))
    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() or None

Pane respawn

tmux respawn-pane -k -t <id> … 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.

def _tmux_respawn_pane(pane_id, bottle, *, resume) -> bool:
    docker_argv = bottle.claude_docker_argv(_claude_args(resume=resume))
    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

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 <path> flag that DockerBottle.exec_agent appends today when the bottle has a prompt path. Refactor: split exec_agent into a pure claude_docker_argv(args, *, tty) that returns the argv and a thin exec_agent 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 (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. 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 _tmux_pane_exists; create a fresh split.

Implementation chunks

Sized small.

  1. claude_docker_argv refactor. Pure-ish split of DockerBottle.exec_agent 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. 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. 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. 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. $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 <pane> (split relative to the dashboard's pane specifically). Probably not needed since split-window defaults to the current active pane, but worth verifying.

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 of how to surface claude inside the dashboard; this PRD picks option 3 with a tighter split-pane variant