From 2ba84c5ba0648b52f976166c2b6aba3ab7bc63d9 Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 26 May 2026 14:29:59 -0400 Subject: [PATCH] feat(dashboard): stop hook clears tmux state + right-pane row marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRD 0021 chunk 4 (final). Two adjustments to close the split-pane loop: 1. `_stop_bottle_flow` clears `tmux_state['slug']` when the stopped bottle was the right-pane occupant. The pane itself stays in place (claude exits with "container not found"); the operator presses Enter on a different agent to repurpose it via respawn-pane. 2. `_render` accepts `right_pane_slug` and marks the matching agents-pane row with a `*` prefix + A_BOLD (when it's not also the focused row — focused selection still wins for visibility). Gives the operator a clear visual link between which agent the dashboard says is "active right now" and which one is visible to their right. Wired through `_main_loop`: passes `tmux_state` to `_stop_bottle_flow` on `x`, and `tmux_state.get('slug')` to `_render` on every tick. 479 unit tests pass (1 new for the tmux_state-preservation on non-owned stop). PRD 0021 implementation complete pending merge. --- claude_bottle/cli/dashboard.py | 22 +++++++++++++++++++++- tests/unit/test_dashboard_active_agents.py | 14 ++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index b35db65..bede6d1 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -654,13 +654,21 @@ def _stop_bottle_flow( stdscr: "curses._CursesWindow", bottles: dict, slug: str, + *, + tmux_state: dict | None = None, ) -> str: """Explicit per-bottle teardown (PRD 0020 chunk 4). Pops the (cm, bottle, identity) tuple from the dashboard's bottles map, snapshots the transcript best-effort, drives the launch context's __exit__ (compose down + network remove), and settles the state dir. A non-owned slug is a no-op with a - hint pointing at `./cli.py cleanup`.""" + hint pointing at `./cli.py cleanup`. + + PRD 0021: clears `tmux_state['slug']` when the stopped + bottle was the right-pane occupant. The pane itself is + left in place — the operator presses Enter on a different + agent to repurpose it (respawn-pane replaces the broken + state).""" if slug not in bottles: return ( f"[{slug}] not dashboard-owned — use ./cli.py cleanup" @@ -687,6 +695,8 @@ def _stop_bottle_flow( finally: stdscr.refresh() settle_state(identity) + if tmux_state is not None and tmux_state.get("slug") == slug: + tmux_state["slug"] = None return f"[{slug}] stopped" @@ -1120,6 +1130,7 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: agents=agents, selected_agent=selected_agent, focus=focus, + right_pane_slug=tmux_state.get("slug"), first_seen=first_seen, now=now, green_attr=green_attr, ) @@ -1197,6 +1208,7 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: else: status_line = _stop_bottle_flow( stdscr, bottles, target.slug, + tmux_state=tmux_state, ) continue @@ -1244,6 +1256,7 @@ def _render( agents: list[ActiveAgent] | None = None, selected_agent: int = 0, focus: str = PANE_PROPOSALS, + right_pane_slug: str | None = None, first_seen: dict[str, float] | None = None, now: float | None = None, green_attr: int = 0, @@ -1325,11 +1338,18 @@ def _render( if row >= h - 3: break line = _format_agent_row(a, w - 1) + in_right_pane = (a.slug == right_pane_slug) if agents_focused and i == selected_agent: # Replace the leading " " cursor with "> " and # highlight the whole row. line = "> " + line[2:] attr = curses.A_REVERSE + elif in_right_pane: + # PRD 0021: `*` marks the agent currently in the + # right tmux pane so the operator can see at a + # glance which session is visible to their right. + line = "* " + line[2:] + attr = curses.A_BOLD else: attr = curses.A_NORMAL stdscr.addnstr(row, 0, line, w - 1, attr) diff --git a/tests/unit/test_dashboard_active_agents.py b/tests/unit/test_dashboard_active_agents.py index 000cc2a..10abad4 100644 --- a/tests/unit/test_dashboard_active_agents.py +++ b/tests/unit/test_dashboard_active_agents.py @@ -455,6 +455,20 @@ class TestStopBottleFlow(unittest.TestCase): self.assertIn("not dashboard-owned", msg) self.assertIn("./cli.py cleanup", msg) + def test_non_owned_does_not_touch_tmux_state(self): + # PRD 0021: a stop on an unknown slug should never clear + # the right-pane occupant tracking, even if the slugs + # happen to match (defensive — non-owned can't be in the + # right pane via the dashboard's normal flow anyway). + tmux_state = {"pane_id": "%5", "slug": "live-bbb"} + dashboard._stop_bottle_flow( + stdscr=None, # type: ignore[arg-type] + bottles={}, + slug="ghost-zzz", + tmux_state=tmux_state, + ) + self.assertEqual({"pane_id": "%5", "slug": "live-bbb"}, tmux_state) + class TestOperatorEditFlowGuards(_FakeHomeMixin, unittest.TestCase): """Chunk-4 contract: the edit flow refuses when the selected