From 994487827704b14fbac01771fab7a9749b9209ff Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 26 May 2026 14:26:40 -0400 Subject: [PATCH] feat(dashboard): tmux split-pane helpers + Enter dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRD 0021 chunk 2. New tmux integration: when `\$TMUX` is set and the operator presses Enter on a focused agent row, the dashboard spawns / respawns the right pane with that bottle's claude session instead of taking over the terminal via curses.endwin. Mechanics: - `_in_tmux()` — true when `\$TMUX` is set. - `_tmux_split_pane_create` — first attach: `tmux split-window -h -P -F '#{pane_id}'` opens a right pane and prints its id for tracking. - `_tmux_respawn_pane` — subsequent attaches: `tmux respawn-pane -k -t ` swaps the content without re-splitting. - `_tmux_pane_exists` — `tmux list-panes` check before respawn so a manually-closed pane gracefully falls back to a fresh split. - `_attach_in_tmux` — owns the create-or-respawn state machine, mutates `tmux_state` ({pane_id, slug}) so the main loop tracks the right-pane occupant. - `_attach_via_handoff` — the previous curses-endwin path, extracted as the fallback when tmux is missing or fails. - `_attach_to_bottle` dispatches: in tmux + state available → `_attach_in_tmux`; otherwise → handoff. Main loop gets `tmux_state: dict = {"pane_id": None, "slug": None}`. Chunks 3 + 4 wire it through the new-agent flow and the stop hook. `FileNotFoundError`-safe `subprocess.run` calls around every tmux invocation — a missing tmux binary cleanly falls back to the handoff for that keypress. 478 unit tests pass (10 new for the pure argv builders + `_claude_runtime_args`). --- claude_bottle/cli/dashboard.py | 185 +++++++++++++++++++-- tests/unit/test_dashboard_active_agents.py | 59 +++++++ 2 files changed, 232 insertions(+), 12 deletions(-) diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index 04faefd..47b5a25 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -690,21 +690,123 @@ def _stop_bottle_flow( return f"[{slug}] stopped" -def _attach_to_bottle( +# --- tmux split-pane integration (PRD 0021) -------------------------------- +# +# When `$TMUX` is set the dashboard lays itself out as the left +# pane of a two-pane window with the operator's currently-selected +# agent in the right pane. First attach creates the right pane via +# `tmux split-window`; subsequent attaches respawn that pane with +# the new agent's claude session. The dashboard remembers the +# pane id + occupant slug in `tmux_state` so the same pane is +# reused across attaches. + + +def _in_tmux() -> bool: + """True when the dashboard is running inside a tmux session. + Tmux sets `$TMUX` to the path of its server socket.""" + return bool(os.environ.get("TMUX")) + + +def _claude_runtime_args(*, resume: bool, remote_control: bool = False) -> list[str]: + """The argv the dashboard hands to `bottle.claude_docker_argv` + on every attach — matches what `attach_claude` builds for the + foreground handoff so both surfaces produce the same claude + invocation.""" + args = ["--dangerously-skip-permissions"] + if remote_control: + args.append("--remote-control") + if resume: + args.append("--continue") + return args + + +def _build_split_pane_argv(docker_argv: list[str]) -> list[str]: + """Pure helper: wrap a docker-exec argv with `tmux split-window + -h -P -F '#{pane_id}'`. The `-P -F` combo tells tmux to print + the new pane's id on stdout so we can track it for later + `respawn-pane` calls.""" + return [ + "tmux", "split-window", "-h", + "-P", "-F", "#{pane_id}", + *docker_argv, + ] + + +def _build_respawn_pane_argv(pane_id: str, docker_argv: list[str]) -> list[str]: + """Pure helper: wrap a docker-exec argv with `tmux respawn-pane + -k -t `. `-k` kills the existing process in the pane + before respawning.""" + return ["tmux", "respawn-pane", "-k", "-t", pane_id, *docker_argv] + + +def _tmux_split_pane_create(bottle, *, resume: bool) -> str | None: + """Open a right pane via `tmux split-window -h`. Returns the + new pane's id on success, None on any failure (tmux missing, + nonzero exit, empty stdout).""" + docker_argv = bottle.claude_docker_argv( + _claude_runtime_args(resume=resume), + ) + try: + result = subprocess.run( + _build_split_pane_argv(docker_argv), + capture_output=True, text=True, check=False, + ) + except FileNotFoundError: + return None + if result.returncode != 0: + return None + pane_id = (result.stdout or "").strip() + return pane_id or None + + +def _tmux_respawn_pane(pane_id: str, bottle, *, resume: bool) -> bool: + """Replace the content of `pane_id` with a fresh claude + session via `tmux respawn-pane -k`. Returns True on success.""" + docker_argv = bottle.claude_docker_argv( + _claude_runtime_args(resume=resume), + ) + try: + result = subprocess.run( + _build_respawn_pane_argv(pane_id, docker_argv), + capture_output=True, text=True, check=False, + ) + except FileNotFoundError: + return False + return result.returncode == 0 + + +def _tmux_pane_exists(pane_id: str) -> bool: + """True when `pane_id` appears in `tmux list-panes -F + '#{pane_id}'`. Used before respawn-pane to detect a pane the + operator manually closed via `C-b x`; an absent pane id means + we need to create a fresh split.""" + 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 or "").splitlines() + + +def _attach_via_handoff( stdscr: "curses._CursesWindow", bottle, slug: str, + *, + resume: bool, ) -> str: - """Handoff: curses.endwin → attach claude → curses refresh. - Re-entry into a running bottle from the dashboard always - passes `--resume` so claude picks up its prior conversation - rather than starting a fresh transcript — the first attach - happens via `_new_agent_flow` which sets up the transcript - in the first place. Returns the post-attach status-line - message.""" + """Foreground handoff: curses.endwin → attach claude → curses + refresh. The non-tmux path (and the failover from + `_attach_in_tmux` when tmux misbehaves).""" curses.endwin() try: - exit_code = attach_claude(bottle, remote_control=False, resume=True) + exit_code = attach_claude( + bottle, remote_control=False, resume=resume, + ) except BaseException: stdscr.refresh() raise @@ -712,6 +814,58 @@ def _attach_to_bottle( return f"[{slug}] claude session ended (exit {exit_code})" +def _attach_in_tmux( + stdscr: "curses._CursesWindow", + bottle, + slug: str, + *, + resume: bool, + tmux_state: dict, +) -> str: + """Spawn / respawn the right pane with `bottle`'s claude + session. Mutates `tmux_state` ({'pane_id': str|None, + 'slug': str|None}) so the main loop can track which slug is + in the right pane (used by the agents-pane indicator + the + explicit-stop hook in chunk 4).""" + pane_id = tmux_state.get("pane_id") + if pane_id and _tmux_pane_exists(pane_id): + if _tmux_respawn_pane(pane_id, bottle, resume=resume): + tmux_state["slug"] = slug + return f"[{slug}] in right pane" + # respawn failed — fall through to create a fresh split. + tmux_state["pane_id"] = None + + new_pane_id = _tmux_split_pane_create(bottle, resume=resume) + if new_pane_id is None: + # tmux failed (missing binary, server died, size error). + # One status-line failover to the curses handoff so the + # operator still gets a session. + return _attach_via_handoff(stdscr, bottle, slug, resume=resume) + tmux_state["pane_id"] = new_pane_id + tmux_state["slug"] = slug + return f"[{slug}] in right pane" + + +def _attach_to_bottle( + stdscr: "curses._CursesWindow", + bottle, + slug: str, + *, + tmux_state: dict | None = None, +) -> str: + """Re-attach to a running bottle. Inside tmux (`$TMUX` set + + `tmux_state` provided) the claude session opens in the + right pane (created on first attach, respawned on + subsequent). Outside tmux it's a curses-endwin handoff that + blocks until the operator exits claude. Re-attach always uses + `--continue` — first attach happens via `_new_agent_flow`.""" + if _in_tmux() and tmux_state is not None: + return _attach_in_tmux( + stdscr, bottle, slug, resume=True, tmux_state=tmux_state, + ) + return _attach_via_handoff(stdscr, bottle, slug, resume=True) + + def _new_agent_flow( stdscr: "curses._CursesWindow", manifest: Manifest, @@ -912,9 +1066,14 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: # PRD 0020: bottles spun up from inside this dashboard session. # Each entry: slug -> (context-manager, Bottle handle, identity). # We hold the context manager so chunk 4's `x` can call __exit__ - # on it; chunk 5 quit-cleanup intentionally does NOT iterate this - # dict (the user wants quit to leave bottles running). + # on it; quit (`q`) intentionally does NOT iterate this dict + # (the user wants quit to leave bottles running). bottles: dict[str, tuple] = {} + # PRD 0021: tmux split-pane state. Empty when not in tmux or + # before the first attach. Mutated by `_attach_in_tmux` / + # `_stop_bottle_flow` to track which bottle's session is in + # the right pane right now. + tmux_state: dict = {"pane_id": None, "slug": None} # Manifest is loaded lazily on first `n` so the dashboard # doesn't fail to start in a directory with no manifest (e.g., # when the operator is purely watching pre-existing bottles). @@ -1010,7 +1169,9 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: else: manifest = manifest_cache[0] # may be None; that's ok bottle, _hint = _bottle_for_slug(target.slug, bottles, manifest) - status_line = _attach_to_bottle(stdscr, bottle, target.slug) + status_line = _attach_to_bottle( + stdscr, bottle, target.slug, tmux_state=tmux_state, + ) elif key == ord("x"): target = _selected_agent(focus, agents, selected_agent) if target is None: diff --git a/tests/unit/test_dashboard_active_agents.py b/tests/unit/test_dashboard_active_agents.py index 11486c6..000cc2a 100644 --- a/tests/unit/test_dashboard_active_agents.py +++ b/tests/unit/test_dashboard_active_agents.py @@ -379,6 +379,65 @@ class TestBottleForSlug(unittest.TestCase): self.assertEqual("", hint) +class TestTmuxPaneArgvBuilders(unittest.TestCase): + """Pure argv builders for the tmux split-pane integration + (PRD 0021 chunk 2). The subprocess invocation itself is + environment-dependent; here we lock the wrapping shape so + a regression surfaces in CI without needing a real tmux.""" + + DOCKER_ARGV = [ + "docker", "exec", "-it", + "claude-bottle-dev-abc", + "claude", "--dangerously-skip-permissions", "--continue", + ] + + def test_split_pane_argv_horizontal_with_pane_id_capture(self): + argv = dashboard._build_split_pane_argv(self.DOCKER_ARGV) + self.assertEqual( + ["tmux", "split-window", "-h", + "-P", "-F", "#{pane_id}", + *self.DOCKER_ARGV], + argv, + ) + + def test_respawn_pane_argv_kills_existing_process(self): + argv = dashboard._build_respawn_pane_argv("%12", self.DOCKER_ARGV) + self.assertEqual( + ["tmux", "respawn-pane", "-k", "-t", "%12", *self.DOCKER_ARGV], + argv, + ) + + def test_respawn_pane_argv_threads_pane_id_unmodified(self): + # Pane ids contain `%`; make sure we pass them straight + # through to `-t` without quoting or substitution surprises. + argv = dashboard._build_respawn_pane_argv("%abc.123", ["sh"]) + self.assertIn("%abc.123", argv) + + +class TestClaudeRuntimeArgs(unittest.TestCase): + """The argv passed to `bottle.claude_docker_argv` on each + attach. Locked here so the tmux + foreground paths build + identical claude invocations.""" + + def test_default_skip_permissions_only(self): + self.assertEqual( + ["--dangerously-skip-permissions"], + dashboard._claude_runtime_args(resume=False), + ) + + def test_resume_appends_continue(self): + self.assertEqual( + ["--dangerously-skip-permissions", "--continue"], + dashboard._claude_runtime_args(resume=True), + ) + + def test_remote_control(self): + args = dashboard._claude_runtime_args( + resume=False, remote_control=True, + ) + self.assertIn("--remote-control", args) + + class TestStopBottleFlow(unittest.TestCase): """Explicit per-bottle stop (PRD 0020 chunk 4). The non-owned path is the one safe to test without curses + docker — the