docs(prd-0021): dashboard as left tmux pane, selected agent as right pane #49

Merged
didericis merged 16 commits from dashboard-tmux-split-pane into main 2026-05-26 15:40:55 -04:00
2 changed files with 107 additions and 0 deletions
Showing only changes of commit 8d6e382af5 - Show all commits
+58
View File
@@ -890,6 +890,42 @@ def _route_op_to_right_pane(
yield True 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: def _ensure_right_pane(tmux_state: dict, argv: list[str]) -> str | None:
"""Run `argv` in the dashboard's right pane — respawn an """Run `argv` in the dashboard's right pane — respawn an
existing tracked pane if one is alive, split-window to 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, stdscr, bottles, target.slug,
tmux_state=tmux_state, 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 continue
if not pending: if not pending:
@@ -379,6 +379,55 @@ class TestBottleForSlug(unittest.TestCase):
self.assertEqual("", hint) 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): class TestTmuxPaneArgvBuilders(unittest.TestCase):
"""Pure argv builders for the tmux split-pane integration """Pure argv builders for the tmux split-pane integration
(PRD 0021 chunk 2). The subprocess invocation itself is (PRD 0021 chunk 2). The subprocess invocation itself is