From 8d6e382af5954b1e983924e81b01577b8d72569d Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 26 May 2026 15:21:20 -0400 Subject: [PATCH] feat(dashboard): auto-focus next agent on stop, or close pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After `x` stops a dashboard-owned bottle, slide focus to the next agent in the agents pane (the one filling the stopped row, or the new last row if the stopped was last) and respawn the right pane with that agent's claude session via `--continue`. If no agents remain, close the right pane via `tmux kill-pane`. Two new helpers: - `_tmux_close_right_pane(tmux_state)` — kills the tracked pane (if it exists) and clears pane_id / slug. - `_pick_next_after_stop(agents_before, selected_index, stopped_slug)` — pure chooser returning (new_index, agent) or None. Tested directly. Outside tmux, only the selected_agent index slides; no auto-attach (foreground handoff would take over the terminal, disruptive). 485 unit tests pass (6 new for the pick helper). --- claude_bottle/cli/dashboard.py | 58 ++++++++++++++++++++++ tests/unit/test_dashboard_active_agents.py | 49 ++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index 9d43c64..6dcff5d 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -890,6 +890,42 @@ def _route_op_to_right_pane( yield True +def _tmux_close_right_pane(tmux_state: dict) -> None: + """Close the tracked right pane via `tmux kill-pane`. Clears + both pane_id and slug in `tmux_state`. Used after the last + dashboard-owned agent is stopped — no claude session left + to host, so the pane shouldn't linger.""" + pane_id = tmux_state.get("pane_id") + if pane_id and _tmux_pane_exists(pane_id): + try: + subprocess.run( + ["tmux", "kill-pane", "-t", pane_id], + capture_output=True, text=True, check=False, + ) + except FileNotFoundError: + pass + tmux_state["pane_id"] = None + tmux_state["slug"] = None + + +def _pick_next_after_stop( + agents_before: list[ActiveAgent], + selected_index: int, + stopped_slug: str, +) -> tuple[int, ActiveAgent] | None: + """After stopping `stopped_slug` from the agents list, choose + the agent that should take focus next. The agent below the + stopped row (which slides up to fill its index) is the + natural pick; if the stopped agent was last, the row above + instead. Returns (new_index, agent) or None if no agents + remain. Pure — easy to unit-test.""" + new_agents = [a for a in agents_before if a.slug != stopped_slug] + if not new_agents: + return None + new_index = min(max(selected_index, 0), len(new_agents) - 1) + return new_index, new_agents[new_index] + + def _ensure_right_pane(tmux_state: dict, argv: list[str]) -> str | None: """Run `argv` in the dashboard's right pane — respawn an existing tracked pane if one is alive, split-window to @@ -1347,6 +1383,28 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: stdscr, bottles, target.slug, tmux_state=tmux_state, ) + # PRD 0021 follow-up: after stop, slide focus + # to the next agent in the list (the one that + # filled the stopped row) and respawn the + # right pane with its claude session. If + # nothing's left, close the right pane. + pick = _pick_next_after_stop( + agents, selected_agent, target.slug, + ) + if pick is None: + _tmux_close_right_pane(tmux_state) + else: + new_index, next_agent = pick + selected_agent = new_index + if _in_tmux(): + manifest = manifest_cache[0] + bottle, _hint = _bottle_for_slug( + next_agent.slug, bottles, manifest, + ) + _attach_in_tmux( + stdscr, bottle, next_agent.slug, + resume=True, tmux_state=tmux_state, + ) continue if not pending: diff --git a/tests/unit/test_dashboard_active_agents.py b/tests/unit/test_dashboard_active_agents.py index 10abad4..a202772 100644 --- a/tests/unit/test_dashboard_active_agents.py +++ b/tests/unit/test_dashboard_active_agents.py @@ -379,6 +379,55 @@ class TestBottleForSlug(unittest.TestCase): self.assertEqual("", hint) +class TestPickNextAfterStop(unittest.TestCase): + """After `x` stops a bottle, the dashboard slides focus to + the next agent — the one filling the stopped row, or the + new last row if the stopped was last. Pure helper, easy + to unit-test.""" + + def _agent(self, slug: str) -> dashboard.ActiveAgent: + return dashboard.ActiveAgent( + slug=slug, agent_name=slug, started_at="", services=(), + ) + + def test_empty_list_returns_none(self): + self.assertIsNone( + dashboard._pick_next_after_stop([], 0, "anything"), + ) + + def test_only_agent_being_stopped_returns_none(self): + # Stopping the last agent → nothing to focus. + agents = [self._agent("only")] + self.assertIsNone( + dashboard._pick_next_after_stop(agents, 0, "only"), + ) + + def test_middle_row_slides_up_to_same_index(self): + agents = [self._agent("a"), self._agent("b"), self._agent("c")] + # Cursor was on "b" at index 1; stopping "b" → "c" now sits + # at index 1 and takes focus. + out = dashboard._pick_next_after_stop(agents, 1, "b") + self.assertEqual((1, self._agent("c")), out) + + def test_last_row_wraps_to_new_last(self): + agents = [self._agent("a"), self._agent("b"), self._agent("c")] + # Cursor on "c" at index 2; stopping "c" leaves a 2-agent + # list — index 2 is out of bounds, fall back to new last (1). + out = dashboard._pick_next_after_stop(agents, 2, "c") + self.assertEqual((1, self._agent("b")), out) + + def test_first_row(self): + agents = [self._agent("a"), self._agent("b")] + out = dashboard._pick_next_after_stop(agents, 0, "a") + self.assertEqual((0, self._agent("b")), out) + + def test_clamps_negative_selection(self): + # Defensive: a stale negative index doesn't crash. + agents = [self._agent("a"), self._agent("b")] + out = dashboard._pick_next_after_stop(agents, -1, "a") + self.assertEqual((0, self._agent("b")), out) + + class TestTmuxPaneArgvBuilders(unittest.TestCase): """Pure argv builders for the tmux split-pane integration (PRD 0021 chunk 2). The subprocess invocation itself is