From cfd8f269ba8b7c2697f8bf1f2a07a998b520d20a Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 26 May 2026 01:23:59 -0400 Subject: [PATCH] feat(dashboard): render active agents pane below proposals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRD 0019 chunk 2. The TUI's main render now draws two panes: proposals on top (existing), active agents on the bottom (new). Header counts both totals. The agents pane refreshes on the same 1s tick — agents starting/stopping reflect without operator action. Each agent row shows slug, agent name, started-time (HH:MM:SS of the metadata.json timestamp), and the bracketed list of sidecars currently up. The `agent` service is filtered out of the displayed list — it's always present so it'd be noise; the sidecars are the differentiator. A bottle whose only running service is `agent` (sidecars still warming up) renders as `(starting)`. No selection model yet — that's chunk 3. The cursor stays in the proposals pane; `j/k`/arrow nav and the proposal action keys are unchanged. --- claude_bottle/cli/dashboard.py | 80 ++++++++++++++++++++-- tests/unit/test_dashboard_active_agents.py | 52 ++++++++++++++ 2 files changed, 126 insertions(+), 6 deletions(-) diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index a541248..6ac35c5 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -533,6 +533,11 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: if selected >= len(pending): selected = max(0, len(pending) - 1) + # PRD 0019 chunk 2: agents pane shows what's currently + # running on the same 1s refresh cadence. No selection + # model yet — that's chunk 3. + agents = discover_active_agents() + now = time.monotonic() live_ids = {qp.proposal.id for qp in pending} for proposal_id in live_ids: @@ -540,7 +545,11 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: for stale_id in list(first_seen.keys() - live_ids): del first_seen[stale_id] - _render(stdscr, pending, selected, status_line, first_seen, now, green_attr) + _render( + stdscr, pending, selected, status_line, + agents=agents, + first_seen=first_seen, now=now, green_attr=green_attr, + ) try: key = stdscr.getch() @@ -605,30 +614,43 @@ def _render( pending: list[QueuedProposal], selected: int, status_line: str, + *, + agents: list[ActiveAgent] | None = None, first_seen: dict[str, float] | None = None, now: float | None = None, green_attr: int = 0, ) -> None: stdscr.erase() h, w = stdscr.getmaxyx() - header = f"claude-bottle dashboard ({len(pending)} pending)" + agents = agents or [] + header = ( + f"claude-bottle dashboard " + f"({len(pending)} pending, {len(agents)} active)" + ) stdscr.addnstr(0, 0, header, w - 1, curses.A_BOLD) stdscr.hline(1, 0, curses.ACS_HLINE, w) + # ----- proposals pane (top) ----- + row = 2 + stdscr.addnstr(row, 0, "proposals:", w - 1, curses.A_DIM) + row += 1 if not pending: stdscr.addnstr( - 3, 2, + row, 2, "no pending proposals; agents will queue here when they call a " "supervise tool", w - 4, ) + row += 1 else: for i, qp in enumerate(pending): - row = 2 + i - if row >= h - 2: + if row >= h - 4 - max(1, len(agents) + 2): break p = qp.proposal - ts_short = p.arrival_timestamp.split("T", 1)[1][:8] if "T" in p.arrival_timestamp else p.arrival_timestamp + ts_short = ( + p.arrival_timestamp.split("T", 1)[1][:8] + if "T" in p.arrival_timestamp else p.arrival_timestamp + ) line = ( f"{'> ' if i == selected else ' '}" f"[{p.bottle_slug}] {p.tool:<20} {ts_short} " @@ -638,6 +660,29 @@ def _render( if _is_recent(p.id, first_seen, now): attr |= green_attr stdscr.addnstr(row, 0, line, w - 1, attr) + row += 1 + + # ----- agents pane (bottom) ----- + # One blank-line separator + a "active agents:" label, then one + # row per agent. No selection model yet — that's chunk 3. Stops + # before the status / footer area so they always stay visible. + row += 1 + if row < h - 3: + stdscr.addnstr(row, 0, "active agents:", w - 1, curses.A_DIM) + row += 1 + if not agents: + if row < h - 3: + stdscr.addnstr( + row, 2, + "no active bottles; ./cli.py start ", + w - 4, curses.A_DIM, + ) + else: + for a in agents: + if row >= h - 3: + break + stdscr.addnstr(row, 0, _format_agent_row(a, w - 1), w - 1) + row += 1 footer = ( "[Enter] view [a] approve [m] modify [r] reject " @@ -650,6 +695,29 @@ def _render( stdscr.refresh() +def _format_agent_row(a: ActiveAgent, maxw: int) -> str: + """One-line agent row: ` started + []`. The `agent` service is filtered out of the + displayed list — it's always present for an active bottle, so + listing it carries no information; the sidecars are the + differentiator. Truncated to `maxw` because the renderer's + addnstr only enforces width if we hand it a properly-sized + string.""" + started = ( + a.started_at.split("T", 1)[1][:8] + if "T" in a.started_at else (a.started_at or "?") + ) + sidecars = tuple(s for s in a.services if s != "agent") + services = ",".join(sidecars) if sidecars else "(starting)" + line = ( + f" {a.slug} {a.agent_name} " + f"started {started} [{services}]" + ) + if len(line) > maxw: + return line[: max(0, maxw - 1)] + "…" + return line + + def _detail_view( stdscr: "curses._CursesWindow", qp: QueuedProposal, diff --git a/tests/unit/test_dashboard_active_agents.py b/tests/unit/test_dashboard_active_agents.py index bbd2d0c..3540cde 100644 --- a/tests/unit/test_dashboard_active_agents.py +++ b/tests/unit/test_dashboard_active_agents.py @@ -172,5 +172,57 @@ class TestDiscoverActiveAgents(_FakeHomeMixin, unittest.TestCase): ) +class TestFormatAgentRow(unittest.TestCase): + """One-line row formatting for the agents pane (PRD 0019 chunk 2).""" + + def _agent(self, **overrides) -> dashboard.ActiveAgent: + defaults = dict( + slug="dev-abc12", + agent_name="implementer", + started_at="2026-05-26T02:55:01+00:00", + services=("egress", "git-gate", "pipelock", "supervise"), + ) + defaults.update(overrides) + return dashboard.ActiveAgent(**defaults) + + def test_renders_slug_name_time_services(self): + s = dashboard._format_agent_row(self._agent(), 200) + self.assertIn("dev-abc12", s) + self.assertIn("implementer", s) + self.assertIn("02:55:01", s) + self.assertIn("egress,git-gate,pipelock,supervise", s) + + def test_starting_label_when_no_services(self): + # Race window: compose project is up but containers haven't + # been picked up by `docker ps` yet. + s = dashboard._format_agent_row(self._agent(services=()), 200) + self.assertIn("(starting)", s) + + def test_filters_agent_service_from_display(self): + # The `agent` service is always present for an active bottle; + # listing it is noise. The row should show only the sidecars. + s = dashboard._format_agent_row( + self._agent(services=("agent", "pipelock", "supervise")), 200, + ) + self.assertIn("[pipelock,supervise]", s) + self.assertNotIn("agent,", s) + self.assertNotIn(",agent", s) + + def test_only_agent_service_shows_starting(self): + # A bottle whose only running service is `agent` (sidecars + # still warming up) renders as `(starting)`. + s = dashboard._format_agent_row(self._agent(services=("agent",)), 200) + self.assertIn("(starting)", s) + + def test_question_mark_when_no_started_at(self): + s = dashboard._format_agent_row(self._agent(started_at=""), 200) + self.assertIn("started ?", s) + + def test_truncates_to_maxw(self): + s = dashboard._format_agent_row(self._agent(), 30) + self.assertLessEqual(len(s), 30) + self.assertTrue(s.endswith("…")) + + if __name__ == "__main__": unittest.main() -- 2.52.0