feat(dashboard): Enter on agents pane re-attaches to bottle
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-<slug>` 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`).
This commit is contained in:
@@ -609,6 +609,64 @@ def _running_counts(
|
|||||||
return 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-<slug>`. 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-<slug>` — 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(
|
def _new_agent_flow(
|
||||||
stdscr: "curses._CursesWindow",
|
stdscr: "curses._CursesWindow",
|
||||||
manifest: Manifest,
|
manifest: Manifest,
|
||||||
@@ -868,14 +926,23 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if focus == PANE_AGENTS:
|
if focus == PANE_AGENTS:
|
||||||
# j/k/arrow navigate the agents list. All other keys
|
# j/k/arrow navigate the agents list. Enter re-attaches
|
||||||
# are ignored (Tab back to proposals to act on
|
# to the focused bottle (PRD 0020 chunk 3) — works for
|
||||||
# proposals). Chunk 4 will wire `e` / `p` to use
|
# both dashboard-owned bottles and bottles discovered
|
||||||
# the agents-pane selection.
|
# via `list_active_slugs` (the synth path resolves the
|
||||||
|
# in-container claude target from the slug).
|
||||||
if key in (curses.KEY_DOWN, ord("j")):
|
if key in (curses.KEY_DOWN, ord("j")):
|
||||||
selected_agent = min(selected_agent + 1, max(0, len(agents) - 1))
|
selected_agent = min(selected_agent + 1, max(0, len(agents) - 1))
|
||||||
elif key in (curses.KEY_UP, ord("k")):
|
elif key in (curses.KEY_UP, ord("k")):
|
||||||
selected_agent = max(selected_agent - 1, 0)
|
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
|
continue
|
||||||
|
|
||||||
if not pending:
|
if not pending:
|
||||||
@@ -1014,8 +1081,8 @@ def _render(
|
|||||||
row += 1
|
row += 1
|
||||||
|
|
||||||
footer = (
|
footer = (
|
||||||
"[n] new agent [Tab] switch pane [j/k] move [Enter] view "
|
"[n] new [Tab] switch [j/k] move "
|
||||||
"[a/m/r] proposal [e/p] edit selected agent [q] quit"
|
"[Enter] view/attach [a/m/r] proposal [e/p] edit [q] quit"
|
||||||
)
|
)
|
||||||
stdscr.hline(h - 2, 0, curses.ACS_HLINE, w)
|
stdscr.hline(h - 2, 0, curses.ACS_HLINE, w)
|
||||||
stdscr.addnstr(h - 1, 0, footer, w - 1, curses.A_DIM)
|
stdscr.addnstr(h - 1, 0, footer, w - 1, curses.A_DIM)
|
||||||
|
|||||||
@@ -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):
|
class TestOperatorEditFlowGuards(_FakeHomeMixin, unittest.TestCase):
|
||||||
"""Chunk-4 contract: the edit flow refuses when the selected
|
"""Chunk-4 contract: the edit flow refuses when the selected
|
||||||
agent doesn't have the required sidecar running. The discover-
|
agent doesn't have the required sidecar running. The discover-
|
||||||
|
|||||||
Reference in New Issue
Block a user