feat(dashboard): Tab toggle + per-pane selection state #40

Merged
didericis merged 1 commits from chunk-3-pane-selection into main 2026-05-26 01:44:25 -04:00
2 changed files with 119 additions and 14 deletions
+87 -14
View File
@@ -513,6 +513,14 @@ def _try_init_green() -> int:
return 0
# PRD 0019 chunk 3: which pane the j/k/arrow keys move through.
# Tab toggles. The proposals pane is the default focus — proposal
# action keys (a/m/r/Enter) require it; agent-scoped keys (e/p,
# chunk 4) require the agents pane.
PANE_PROPOSALS = "proposals"
PANE_AGENTS = "agents"
def _main_loop(stdscr: "curses._CursesWindow") -> None:
curses.curs_set(0)
# Auto-refresh: getch() returns -1 after the timeout if no key
@@ -527,16 +535,17 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None:
# gone (approved / rejected / archived) so the dict stays small.
first_seen: dict[str, float] = {}
selected = 0
selected_agent = 0
focus = PANE_PROPOSALS
status_line = ""
while True:
pending = discover_pending()
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()
if selected_agent >= len(agents):
selected_agent = max(0, len(agents) - 1)
now = time.monotonic()
live_ids = {qp.proposal.id for qp in pending}
@@ -548,6 +557,8 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None:
_render(
stdscr, pending, selected, status_line,
agents=agents,
selected_agent=selected_agent,
focus=focus,
first_seen=first_seen, now=now, green_attr=green_attr,
)
@@ -568,12 +579,27 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None:
if key in (ord("q"), 27): # q or ESC
return
if key == 9: # Tab
focus = PANE_AGENTS if focus == PANE_PROPOSALS else PANE_PROPOSALS
continue
if key == ord("e"):
status_line = _operator_edit_routes_flow(stdscr)
continue
if key == ord("p"):
status_line = _operator_edit_allowlist_flow(stdscr)
continue
if focus == PANE_AGENTS:
# j/k/arrow navigate the agents list. All other keys
# are ignored (Tab back to proposals to act on
# proposals). Chunk 4 will wire `e` / `p` to use
# the agents-pane selection.
if key in (curses.KEY_DOWN, ord("j")):
selected_agent = min(selected_agent + 1, max(0, len(agents) - 1))
elif key in (curses.KEY_UP, ord("k")):
selected_agent = max(selected_agent - 1, 0)
continue
if not pending:
continue
qp = pending[selected]
@@ -616,6 +642,8 @@ def _render(
status_line: str,
*,
agents: list[ActiveAgent] | None = None,
selected_agent: int = 0,
focus: str = PANE_PROPOSALS,
first_seen: dict[str, float] | None = None,
now: float | None = None,
green_attr: int = 0,
@@ -630,9 +658,15 @@ def _render(
stdscr.addnstr(0, 0, header, w - 1, curses.A_BOLD)
stdscr.hline(1, 0, curses.ACS_HLINE, w)
proposals_focused = focus == PANE_PROPOSALS
agents_focused = focus == PANE_AGENTS
# ----- proposals pane (top) -----
row = 2
stdscr.addnstr(row, 0, "proposals:", w - 1, curses.A_DIM)
proposals_label = "proposals:"
if proposals_focused:
proposals_label += " (focused)"
stdscr.addnstr(row, 0, proposals_label, w - 1, curses.A_DIM)
row += 1
if not pending:
stdscr.addnstr(
@@ -651,24 +685,33 @@ def _render(
p.arrival_timestamp.split("T", 1)[1][:8]
if "T" in p.arrival_timestamp else p.arrival_timestamp
)
cursor = "> " if (proposals_focused and i == selected) else " "
line = (
f"{'> ' if i == selected else ' '}"
f"{cursor}"
f"[{p.bottle_slug}] {p.tool:<20} {ts_short} "
f"{p.justification[:60]}"
)
attr = curses.A_REVERSE if i == selected else curses.A_NORMAL
attr = (
curses.A_REVERSE
if (proposals_focused and i == selected)
else curses.A_NORMAL
)
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.
# One blank-line separator + an "active agents:" label, then
# one row per agent. Reverse-video the selected row when this
# pane has focus. Stops before the status / footer area so
# they always stay visible.
row += 1
agents_label = "active agents:"
if agents_focused:
agents_label += " (focused)"
if row < h - 3:
stdscr.addnstr(row, 0, "active agents:", w - 1, curses.A_DIM)
stdscr.addnstr(row, 0, agents_label, w - 1, curses.A_DIM)
row += 1
if not agents:
if row < h - 3:
@@ -678,23 +721,53 @@ def _render(
w - 4, curses.A_DIM,
)
else:
for a in agents:
for i, a in enumerate(agents):
if row >= h - 3:
break
stdscr.addnstr(row, 0, _format_agent_row(a, w - 1), w - 1)
line = _format_agent_row(a, w - 1)
if agents_focused and i == selected_agent:
# Replace the leading " " cursor with "> " and
# highlight the whole row.
line = "> " + line[2:]
attr = curses.A_REVERSE
else:
attr = curses.A_NORMAL
stdscr.addnstr(row, 0, line, w - 1, attr)
row += 1
footer = (
"[Enter] view [a] approve [m] modify [r] reject "
"[e] routes edit [p] pipelock edit [j/k] move [q] quit"
"[Tab] switch pane [j/k] move [Enter] view "
"[a/m/r] proposal [e/p] edit [q] quit"
)
stdscr.hline(h - 2, 0, curses.ACS_HLINE, w)
stdscr.addnstr(h - 1, 0, footer, w - 1, curses.A_DIM)
if status_line:
stdscr.addnstr(h - 3, 0, status_line, w - 1, curses.A_BOLD)
else:
# When idle: surface which agent is currently selected so
# the operator knows what `e` / `p` will target after chunk
# 4 wires the agent-scoped edit verbs.
sel = _selection_status(focus, agents, selected_agent)
if sel:
stdscr.addnstr(h - 3, 0, sel, w - 1, curses.A_DIM)
stdscr.refresh()
def _selection_status(
focus: str, agents: list[ActiveAgent], selected_agent: int,
) -> str:
"""Status-line text for the idle state. Surfaces the agents-
pane selection so the operator can tell what an agent-scoped
edit verb (chunk 4) would target."""
if focus != PANE_AGENTS:
return ""
if not agents:
return "[no active agents]"
if 0 <= selected_agent < len(agents):
return f"[selected: {agents[selected_agent].slug}]"
return "[no agent selected]"
def _format_agent_row(a: ActiveAgent, maxw: int) -> str:
"""One-line agent row: ` <slug> <agent_name> started <HH:MM:SS>
[<sidecars>]`. The `agent` service is filtered out of the
@@ -224,5 +224,37 @@ class TestFormatAgentRow(unittest.TestCase):
self.assertTrue(s.endswith(""))
class TestSelectionStatus(unittest.TestCase):
"""Idle-state status-line text for the agents-pane focus
(PRD 0019 chunk 3). Empty when the proposals pane is focused;
surfaces the selected agent (or a clear placeholder) when the
agents pane is focused."""
def _agent(self, slug: str) -> dashboard.ActiveAgent:
return dashboard.ActiveAgent(
slug=slug, agent_name="x", started_at="", services=(),
)
def test_empty_when_proposals_focused(self):
s = dashboard._selection_status(
dashboard.PANE_PROPOSALS, [self._agent("a-1")], 0,
)
self.assertEqual("", s)
def test_no_agents_message_when_agents_pane_empty(self):
s = dashboard._selection_status(dashboard.PANE_AGENTS, [], 0)
self.assertEqual("[no active agents]", s)
def test_shows_selected_slug(self):
agents = [self._agent("a-1"), self._agent("b-2"), self._agent("c-3")]
s = dashboard._selection_status(dashboard.PANE_AGENTS, agents, 1)
self.assertEqual("[selected: b-2]", s)
def test_out_of_bounds_falls_back_to_no_selection(self):
agents = [self._agent("only")]
s = dashboard._selection_status(dashboard.PANE_AGENTS, agents, 99)
self.assertEqual("[no agent selected]", s)
if __name__ == "__main__":
unittest.main()