From a9bb34cb77867636c02c3a0d0c6856f869f18c1e Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 25 May 2026 07:54:34 -0400 Subject: [PATCH] feat(dashboard): highlight newly-arrived proposals in green for 5s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a new proposal lands in the dashboard's list, the operator shouldn't have to compare the list to a mental snapshot to spot what's new. Render newly-arrived proposals in green for the first five seconds after they show up. - _try_init_green: initialise a green color pair; returns 0 if the terminal lacks color so the highlight degrades to no-op. - _main_loop tracks first_seen[proposal_id] across refresh ticks, pruning entries when a proposal leaves the queue. - _render ORs green into the existing attr (composes with selection reverse-video — terminal handles the mix). Applies to all tool types (cred-proxy-block, pipelock-block, capability-block). If a tool-specific highlight is wanted later, filter on qp.proposal.tool in _is_recent. Co-Authored-By: Claude Opus 4.7 --- claude_bottle/cli/dashboard.py | 55 +++++++++++++++++++++++++- tests/unit/test_dashboard_highlight.py | 39 ++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_dashboard_highlight.py diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index d686266..3aae60c 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -17,6 +17,7 @@ import os import subprocess import sys import tempfile +import time from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path @@ -344,6 +345,41 @@ def _list_once() -> int: _REFRESH_INTERVAL_MS = 1000 +# How long a newly-arrived proposal stays highlighted (green) in the +# list. Long enough for the operator to notice in their peripheral +# vision, short enough to fade before the queue feels permanently +# noisy. +_NEW_PROPOSAL_HIGHLIGHT_SEC = 5.0 + + +def _is_recent( + proposal_id: str, + first_seen: dict[str, float] | None, + now: float | None, +) -> bool: + """True if `proposal_id` was first seen within the highlight + window. Both `first_seen` and `now` may be None (rendered as + not-recent) so the helper is safe in cold-start paths.""" + if first_seen is None or now is None: + return False + started = first_seen.get(proposal_id) + if started is None: + return False + return (now - started) < _NEW_PROPOSAL_HIGHLIGHT_SEC + + +def _try_init_green() -> int: + """Initialise a green color pair and return its attr, or 0 if the + terminal doesn't support color. Caller ORs the returned value + into addnstr's attr argument; OR 0 is a no-op.""" + try: + curses.start_color() + curses.use_default_colors() + curses.init_pair(1, curses.COLOR_GREEN, -1) + return curses.color_pair(1) + except curses.error: + return 0 + def _main_loop(stdscr: "curses._CursesWindow") -> None: curses.curs_set(0) @@ -353,6 +389,11 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: # when the operator hits a key — a tool call landing while the # operator is just watching wouldn't appear. stdscr.timeout(_REFRESH_INTERVAL_MS) + green_attr = _try_init_green() + # Per-proposal first-seen timestamps drive the "new" highlight. + # We add entries as proposals show up and prune ones that are + # gone (approved / rejected / archived) so the dict stays small. + first_seen: dict[str, float] = {} selected = 0 status_line = "" while True: @@ -360,7 +401,14 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: if selected >= len(pending): selected = max(0, len(pending) - 1) - _render(stdscr, pending, selected, status_line) + now = time.monotonic() + live_ids = {qp.proposal.id for qp in pending} + for proposal_id in live_ids: + first_seen.setdefault(proposal_id, now) + 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) try: key = stdscr.getch() @@ -425,6 +473,9 @@ def _render( pending: list[QueuedProposal], selected: int, status_line: str, + first_seen: dict[str, float] | None = None, + now: float | None = None, + green_attr: int = 0, ) -> None: stdscr.erase() h, w = stdscr.getmaxyx() @@ -452,6 +503,8 @@ def _render( f"{p.justification[:60]}" ) attr = curses.A_REVERSE if 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) footer = ( diff --git a/tests/unit/test_dashboard_highlight.py b/tests/unit/test_dashboard_highlight.py new file mode 100644 index 0000000..37e4816 --- /dev/null +++ b/tests/unit/test_dashboard_highlight.py @@ -0,0 +1,39 @@ +"""Unit: dashboard's new-proposal highlight window. + +The curses rendering itself is exercised manually; this isolates +the pure decision `is the proposal still in its post-arrival +highlight window?`""" + +import unittest + +from claude_bottle.cli import dashboard + + +class TestIsRecent(unittest.TestCase): + def test_just_seen_is_recent(self): + self.assertTrue(dashboard._is_recent("p1", {"p1": 100.0}, now=100.5)) + + def test_seen_within_window(self): + # Default window is 5s. + self.assertTrue( + dashboard._is_recent("p1", {"p1": 100.0}, now=104.9), + ) + + def test_seen_past_window_is_not_recent(self): + self.assertFalse( + dashboard._is_recent("p1", {"p1": 100.0}, now=106.0), + ) + + def test_unknown_proposal_is_not_recent(self): + self.assertFalse( + dashboard._is_recent("p2", {"p1": 100.0}, now=100.5), + ) + + def test_none_args_safe_default(self): + self.assertFalse(dashboard._is_recent("p1", None, None)) + self.assertFalse(dashboard._is_recent("p1", {"p1": 100.0}, None)) + self.assertFalse(dashboard._is_recent("p1", None, 100.5)) + + +if __name__ == "__main__": + unittest.main()