From 572306ddb6dc50dad229b654fe588920f76f24b2 Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 26 May 2026 03:39:58 -0400 Subject: [PATCH] feat(dashboard): Enter on agents pane re-attaches to bottle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRD 0020 chunk 3. Enter on a focused agents-pane row drops to a claude session inside the selected bottle. Works for both dashboard-owned bottles (looks up the stored Bottle handle in the main loop's `bottles` dict) and externally-discovered ones (synthesizes a DockerBottle from the slug → `claude-bottle-` container name). For the synthesized path, the `--append-system-prompt-file` target resolves via metadata.json + the manifest's agent prompt if both can be read; otherwise the re-attach runs without the flag (claude defaults to no system prompt, the bottle's other state is untouched). Shares the curses.endwin → attach → refresh handoff with the chunk-2 new-agent flow via a new `_attach_to_bottle` helper. Footer reshuffled to advertise `[Enter] view/attach`. 464 unit tests pass (3 new for `_bottle_for_slug`). --- claude_bottle/cli/dashboard.py | 79 ++++++++++++++++++++-- tests/unit/test_dashboard_active_agents.py | 22 ++++++ 2 files changed, 95 insertions(+), 6 deletions(-) diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index d88c993..3db7069 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -609,6 +609,64 @@ def _running_counts( return counts +def _bottle_for_slug( + slug: str, + bottles: dict, + manifest: Manifest | None, +) -> tuple["object", str]: + """Return `(bottle_handle, prompt_path_hint)` for a re-attach. + If the slug is in `bottles` (dashboard-owned), return the stored + handle directly. Otherwise synthesize a `DockerBottle` from the + container name `claude-bottle-`. For synthesized bottles + the prompt-file path comes from the manifest's agent if we can + resolve it via metadata.json + the loaded manifest; otherwise + the re-attach runs without `--append-system-prompt-file`. + + Returns the empty string for prompt_path_hint when we omit the + flag — the caller passes None to DockerBottle in that case.""" + from ..backend.docker.bottle import DockerBottle + from ..backend.docker.bottle_state import read_metadata + if slug in bottles: + _cm, bottle, _identity = bottles[slug] + return bottle, "" + # The container hosting the agent's claude process is named + # `claude-bottle-` — set by the compose renderer + # (no service suffix on the agent service, by design). + container_name = f"claude-bottle-{slug}" + prompt_path: str | None = None + metadata = read_metadata(slug) + if metadata is not None and manifest is not None: + agent = manifest.agents.get(metadata.agent_name) + if agent is not None and agent.prompt: + container_home = os.environ.get( + "CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node", + ) + prompt_path = f"{container_home}/.claude-bottle-prompt.txt" + synth = DockerBottle( + container=container_name, + teardown=lambda: None, + prompt_path_in_container=prompt_path, + ) + return synth, (prompt_path or "") + + +def _attach_to_bottle( + stdscr: "curses._CursesWindow", + bottle, + slug: str, +) -> str: + """Handoff: curses.endwin → attach claude → curses refresh. + Returns the post-attach status-line message.""" + curses.endwin() + try: + exit_code = attach_claude(bottle, remote_control=False) + except BaseException: + stdscr.refresh() + raise + stdscr.refresh() + return f"[{slug}] claude session ended (exit {exit_code})" + + def _new_agent_flow( stdscr: "curses._CursesWindow", manifest: Manifest, @@ -868,14 +926,23 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: continue if focus == PANE_AGENTS: - # j/k/arrow navigate the agents list. All other keys - # are ignored (Tab back to proposals to act on - # proposals). Chunk 4 will wire `e` / `p` to use - # the agents-pane selection. + # j/k/arrow navigate the agents list. Enter re-attaches + # to the focused bottle (PRD 0020 chunk 3) — works for + # both dashboard-owned bottles and bottles discovered + # via `list_active_slugs` (the synth path resolves the + # in-container claude target from the slug). if key in (curses.KEY_DOWN, ord("j")): selected_agent = min(selected_agent + 1, max(0, len(agents) - 1)) elif key in (curses.KEY_UP, ord("k")): selected_agent = max(selected_agent - 1, 0) + elif key in (curses.KEY_ENTER, 10, 13): + target = _selected_agent(focus, agents, selected_agent) + if target is None: + status_line = "no agent selected" + 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) continue if not pending: @@ -1014,8 +1081,8 @@ def _render( row += 1 footer = ( - "[n] new agent [Tab] switch pane [j/k] move [Enter] view " - "[a/m/r] proposal [e/p] edit selected agent [q] quit" + "[n] new [Tab] switch [j/k] move " + "[Enter] view/attach [a/m/r] proposal [e/p] edit [q] quit" ) stdscr.hline(h - 2, 0, curses.ACS_HLINE, w) stdscr.addnstr(h - 1, 0, footer, w - 1, curses.A_DIM) diff --git a/tests/unit/test_dashboard_active_agents.py b/tests/unit/test_dashboard_active_agents.py index d221647..331e595 100644 --- a/tests/unit/test_dashboard_active_agents.py +++ b/tests/unit/test_dashboard_active_agents.py @@ -357,6 +357,28 @@ class TestSelectedAgent(unittest.TestCase): ) +class TestBottleForSlug(unittest.TestCase): + """Re-attach target resolution (PRD 0020 chunk 3). Dashboard- + owned bottles return the stored handle as-is; non-owned bottles + get a synthesized DockerBottle backed by the slug-derived + container name.""" + + def test_owned_bottle_returns_held_handle(self): + sentinel = object() + bottles = {"dev-abc": (None, sentinel, "dev-abc")} + bottle, _ = dashboard._bottle_for_slug("dev-abc", bottles, None) + self.assertIs(sentinel, bottle) + + def test_unowned_synthesizes_docker_bottle(self): + bottle, _ = dashboard._bottle_for_slug("dev-xyz", {}, None) + # The synth wraps the slug-derived container name. + self.assertEqual("claude-bottle-dev-xyz", bottle.name) + + def test_unowned_without_manifest_omits_prompt_path(self): + bottle, hint = dashboard._bottle_for_slug("dev-xyz", {}, None) + self.assertEqual("", hint) + + class TestOperatorEditFlowGuards(_FakeHomeMixin, unittest.TestCase): """Chunk-4 contract: the edit flow refuses when the selected agent doesn't have the required sidecar running. The discover-