docs(prd-0021): dashboard as left tmux pane, selected agent as right pane #49
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user