Merge pull request 'feat(dashboard): Tab toggle + per-pane selection state' (#40) from chunk-3-pane-selection into main
This commit was merged in pull request #40.
This commit is contained in:
@@ -513,6 +513,14 @@ def _try_init_green() -> int:
|
|||||||
return 0
|
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:
|
def _main_loop(stdscr: "curses._CursesWindow") -> None:
|
||||||
curses.curs_set(0)
|
curses.curs_set(0)
|
||||||
# Auto-refresh: getch() returns -1 after the timeout if no key
|
# 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.
|
# gone (approved / rejected / archived) so the dict stays small.
|
||||||
first_seen: dict[str, float] = {}
|
first_seen: dict[str, float] = {}
|
||||||
selected = 0
|
selected = 0
|
||||||
|
selected_agent = 0
|
||||||
|
focus = PANE_PROPOSALS
|
||||||
status_line = ""
|
status_line = ""
|
||||||
while True:
|
while True:
|
||||||
pending = discover_pending()
|
pending = discover_pending()
|
||||||
if selected >= len(pending):
|
if selected >= len(pending):
|
||||||
selected = max(0, len(pending) - 1)
|
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()
|
agents = discover_active_agents()
|
||||||
|
if selected_agent >= len(agents):
|
||||||
|
selected_agent = max(0, len(agents) - 1)
|
||||||
|
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
live_ids = {qp.proposal.id for qp in pending}
|
live_ids = {qp.proposal.id for qp in pending}
|
||||||
@@ -548,6 +557,8 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None:
|
|||||||
_render(
|
_render(
|
||||||
stdscr, pending, selected, status_line,
|
stdscr, pending, selected, status_line,
|
||||||
agents=agents,
|
agents=agents,
|
||||||
|
selected_agent=selected_agent,
|
||||||
|
focus=focus,
|
||||||
first_seen=first_seen, now=now, green_attr=green_attr,
|
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
|
if key in (ord("q"), 27): # q or ESC
|
||||||
return
|
return
|
||||||
|
if key == 9: # Tab
|
||||||
|
focus = PANE_AGENTS if focus == PANE_PROPOSALS else PANE_PROPOSALS
|
||||||
|
continue
|
||||||
if key == ord("e"):
|
if key == ord("e"):
|
||||||
status_line = _operator_edit_routes_flow(stdscr)
|
status_line = _operator_edit_routes_flow(stdscr)
|
||||||
continue
|
continue
|
||||||
if key == ord("p"):
|
if key == ord("p"):
|
||||||
status_line = _operator_edit_allowlist_flow(stdscr)
|
status_line = _operator_edit_allowlist_flow(stdscr)
|
||||||
continue
|
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:
|
if not pending:
|
||||||
continue
|
continue
|
||||||
qp = pending[selected]
|
qp = pending[selected]
|
||||||
@@ -616,6 +642,8 @@ def _render(
|
|||||||
status_line: str,
|
status_line: str,
|
||||||
*,
|
*,
|
||||||
agents: list[ActiveAgent] | None = None,
|
agents: list[ActiveAgent] | None = None,
|
||||||
|
selected_agent: int = 0,
|
||||||
|
focus: str = PANE_PROPOSALS,
|
||||||
first_seen: dict[str, float] | None = None,
|
first_seen: dict[str, float] | None = None,
|
||||||
now: float | None = None,
|
now: float | None = None,
|
||||||
green_attr: int = 0,
|
green_attr: int = 0,
|
||||||
@@ -630,9 +658,15 @@ def _render(
|
|||||||
stdscr.addnstr(0, 0, header, w - 1, curses.A_BOLD)
|
stdscr.addnstr(0, 0, header, w - 1, curses.A_BOLD)
|
||||||
stdscr.hline(1, 0, curses.ACS_HLINE, w)
|
stdscr.hline(1, 0, curses.ACS_HLINE, w)
|
||||||
|
|
||||||
|
proposals_focused = focus == PANE_PROPOSALS
|
||||||
|
agents_focused = focus == PANE_AGENTS
|
||||||
|
|
||||||
# ----- proposals pane (top) -----
|
# ----- proposals pane (top) -----
|
||||||
row = 2
|
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
|
row += 1
|
||||||
if not pending:
|
if not pending:
|
||||||
stdscr.addnstr(
|
stdscr.addnstr(
|
||||||
@@ -651,24 +685,33 @@ def _render(
|
|||||||
p.arrival_timestamp.split("T", 1)[1][:8]
|
p.arrival_timestamp.split("T", 1)[1][:8]
|
||||||
if "T" in p.arrival_timestamp else p.arrival_timestamp
|
if "T" in p.arrival_timestamp else p.arrival_timestamp
|
||||||
)
|
)
|
||||||
|
cursor = "> " if (proposals_focused and i == selected) else " "
|
||||||
line = (
|
line = (
|
||||||
f"{'> ' if i == selected else ' '}"
|
f"{cursor}"
|
||||||
f"[{p.bottle_slug}] {p.tool:<20} {ts_short} "
|
f"[{p.bottle_slug}] {p.tool:<20} {ts_short} "
|
||||||
f"{p.justification[:60]}"
|
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):
|
if _is_recent(p.id, first_seen, now):
|
||||||
attr |= green_attr
|
attr |= green_attr
|
||||||
stdscr.addnstr(row, 0, line, w - 1, attr)
|
stdscr.addnstr(row, 0, line, w - 1, attr)
|
||||||
row += 1
|
row += 1
|
||||||
|
|
||||||
# ----- agents pane (bottom) -----
|
# ----- agents pane (bottom) -----
|
||||||
# One blank-line separator + a "active agents:" label, then one
|
# One blank-line separator + an "active agents:" label, then
|
||||||
# row per agent. No selection model yet — that's chunk 3. Stops
|
# one row per agent. Reverse-video the selected row when this
|
||||||
# before the status / footer area so they always stay visible.
|
# pane has focus. Stops before the status / footer area so
|
||||||
|
# they always stay visible.
|
||||||
row += 1
|
row += 1
|
||||||
|
agents_label = "active agents:"
|
||||||
|
if agents_focused:
|
||||||
|
agents_label += " (focused)"
|
||||||
if row < h - 3:
|
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
|
row += 1
|
||||||
if not agents:
|
if not agents:
|
||||||
if row < h - 3:
|
if row < h - 3:
|
||||||
@@ -678,23 +721,53 @@ def _render(
|
|||||||
w - 4, curses.A_DIM,
|
w - 4, curses.A_DIM,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
for a in agents:
|
for i, a in enumerate(agents):
|
||||||
if row >= h - 3:
|
if row >= h - 3:
|
||||||
break
|
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
|
row += 1
|
||||||
|
|
||||||
footer = (
|
footer = (
|
||||||
"[Enter] view [a] approve [m] modify [r] reject "
|
"[Tab] switch pane [j/k] move [Enter] view "
|
||||||
"[e] routes edit [p] pipelock edit [j/k] move [q] quit"
|
"[a/m/r] proposal [e/p] edit [q] quit"
|
||||||
)
|
)
|
||||||
stdscr.hline(h - 2, 0, curses.ACS_HLINE, w)
|
stdscr.hline(h - 2, 0, curses.ACS_HLINE, w)
|
||||||
stdscr.addnstr(h - 1, 0, footer, w - 1, curses.A_DIM)
|
stdscr.addnstr(h - 1, 0, footer, w - 1, curses.A_DIM)
|
||||||
if status_line:
|
if status_line:
|
||||||
stdscr.addnstr(h - 3, 0, status_line, w - 1, curses.A_BOLD)
|
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()
|
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:
|
def _format_agent_row(a: ActiveAgent, maxw: int) -> str:
|
||||||
"""One-line agent row: ` <slug> <agent_name> started <HH:MM:SS>
|
"""One-line agent row: ` <slug> <agent_name> started <HH:MM:SS>
|
||||||
[<sidecars>]`. The `agent` service is filtered out of the
|
[<sidecars>]`. The `agent` service is filtered out of the
|
||||||
|
|||||||
@@ -224,5 +224,37 @@ class TestFormatAgentRow(unittest.TestCase):
|
|||||||
self.assertTrue(s.endswith("…"))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user