From 0656221828ea94c02327df2be99f172bcb17d26c Mon Sep 17 00:00:00 2001 From: panopticon-agent Date: Wed, 5 Aug 2026 15:53:47 +0000 Subject: [PATCH] =?UTF-8?q?Dashboard=20snooze=20controls:=20e/E=20hotkeys,?= =?UTF-8?q?=20dim=20presentation,=20snoozed=C2=B7Xh-left=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the dashboard half of the snooze feature. `e` toggles a fixed 12h operator snooze on the highlighted task; `E` records the reserved indefinite deadline. An active snooze mutes the whole row (dim) and replaces the turn cell with `snoozed · Nh left` (or bare `snoozed` for the indefinite value); at/after a finite deadline the row resumes its ordinary presentation without mutating the stored fact (expiry is display-only). All snooze reads/writes go through an injectable display-clock seam, and a one-shot Textual timer repaints when a finite snooze's label is due to change — the change feed emits no event for a clock-only tick. No attention/piercing behavior: a snooze always mutes until it expires. Co-Authored-By: Claude Opus 4.8 --- src/panopticon/terminal/dashboard.py | 150 ++++++++++++++++++++++++++- tests/terminal/test_dashboard.py | 110 +++++++++++++++++++- 2 files changed, 256 insertions(+), 4 deletions(-) diff --git a/src/panopticon/terminal/dashboard.py b/src/panopticon/terminal/dashboard.py index f6358a3c..f4ce28e7 100644 --- a/src/panopticon/terminal/dashboard.py +++ b/src/panopticon/terminal/dashboard.py @@ -68,7 +68,8 @@ import webbrowser from collections.abc import Callable, Iterable from dataclasses import dataclass -from datetime import datetime +from datetime import UTC, datetime, timedelta +from math import ceil from pathlib import Path from typing import Any, TypeVar @@ -80,6 +81,7 @@ from textual.containers import Vertical, VerticalScroll from textual.css.query import NoMatches from textual.screen import ModalScreen +from textual.timer import Timer from textual.widget import Widget from textual.widgets import ( Checkbox, @@ -314,7 +316,64 @@ def _matches(task: JsonObj, query: str) -> bool: # Turn-column colors, matching cloude-cade's dashboard ball tags: agent=green, # user=yellow, blocked=red. Blocked takes precedence (cloude-cade draws it as its own # red tag); here it keeps the turn value but appends ⚠ and colors the whole cell red. -def _turn_cell(task: JsonObj) -> Text: +# Fixed operator snooze controls: `e` means "not today"; `E` records the reserved sticky value. +# A snooze always mutes until it expires — there is no attention/piercing here (that field was +# deliberately dropped from this fork), so the turn-column precedence is just: snoozed > normal. +_SNOOZE_DURATION = timedelta(hours=12) +_INDEFINITE_SNOOZE_UNTIL = "9999-12-31T23:59:59+00:00" + + +def _snooze_remaining(task: JsonObj, now: datetime) -> float | None: + """Active seconds remaining; +inf for the reserved sticky deadline; None if inactive.""" + raw = task.get("snoozed_until") + if not isinstance(raw, str): + return None + if raw == _INDEFINITE_SNOOZE_UNTIL: + return float("inf") + try: + deadline = datetime.fromisoformat(raw) + except ValueError: + return None + if deadline.tzinfo is None: + deadline = deadline.replace(tzinfo=UTC) + if now.tzinfo is None: + now = now.replace(tzinfo=UTC) + seconds = (deadline - now).total_seconds() + if seconds <= 0: + return None + return seconds + + +def _snooze_label(task: JsonObj, now: datetime) -> str | None: + """The active snooze label at ``now``; expired or invalid facts are inactive (None).""" + seconds = _snooze_remaining(task, now) + if seconds is None: + return None + if seconds == float("inf"): + return "snoozed" + if seconds < 60: + remaining = "<1m" + elif seconds < 3600: + remaining = f"{ceil(seconds / 60)}m" + else: + remaining = f"{ceil(seconds / 3600)}h" + return f"snoozed · {remaining} left" + + +def _snooze_refresh_delay(task: JsonObj, now: datetime) -> float | None: + """Seconds until a finite snooze's displayed duration changes or expires (None if none).""" + seconds = _snooze_remaining(task, now) + if seconds is None or seconds == float("inf"): + return None + if seconds < 60: + return seconds + unit = 60 if seconds < 3600 else 3600 + return max(0.05, seconds - (ceil(seconds / unit) - 1) * unit) + + +def _turn_cell(task: JsonObj, now: datetime | None = None) -> Text: + if now is not None and (label := _snooze_label(task, now)) is not None: + return Text(label, style="dim") if task.get("blocked"): return Text(f"{task['turn']} ⚠", style="red") color = "green" if task["turn"] == "agent" else "yellow" @@ -1433,6 +1492,14 @@ def binding(self) -> Binding: Hotkey("r", "refresh", "Refresh", "Refresh from the task service now", show=False), Hotkey("R", "respawn", "Respawn", "Respawn a down task (release its claim)", show=False), Hotkey("p", "open_url", "Open URL", "Open the task's URL in the browser", show=False), + Hotkey("e", "snooze", "Snooze", "Snooze the highlighted task for 12 hours", show=False), + Hotkey( + "E", + "snooze_indefinitely", + "Snooze sticky", + "Snooze the highlighted task indefinitely", + show=False, + ), Hotkey("g", "repos", "Repos", "Repo config (list / create / edit repos)", show=False), Hotkey("a", "artifacts", "Artifacts", "List the task's artifacts", show=False), Hotkey("s", "service", "Service", "Switch to the task-service session", show=False), @@ -1574,9 +1641,14 @@ def __init__( on_runner: Callable[[], bool] | None = None, artifacts_root: str | Path = ARTIFACTS_DIR, refresh_interval: float | None = REFRESH_INTERVAL, + now: Callable[[], datetime] | None = None, ) -> None: super().__init__() self._client = client + self._now = now or (lambda: datetime.now(UTC)) # injectable display clock (snooze seam) + self._snooze_timer: Timer | None = ( + None # one-shot: repaint when a finite snooze ticks/expires + ) self._on_switch = on_switch # supervisor hook: record the pick + detach (None standalone) self._on_service = on_service # `s` hook: switch to the service session; True if one exists self._on_runner = on_runner # `u` hook: switch to the runner session; True if one exists @@ -1669,6 +1741,9 @@ def _watch_feed(self) -> None: return def on_unmount(self) -> None: + if self._snooze_timer is not None: # stop the pending snooze-display repaint + self._snooze_timer.stop() + self._snooze_timer = None if self._artifact_tmp is not None: # remove the REST-open scratch dir on exit self._artifact_tmp.cleanup() self._artifact_tmp = None @@ -1682,6 +1757,7 @@ def _artifact_tmpdir(self) -> str: def action_refresh(self) -> None: table = self.query_one("#tasks", DataTable) selected = self._current # keep the operator's highlight across the rebuild (feed refresh) + display_now = self._now() # one clock read per repaint drives every snooze label/dimming table.clear() ordered = sorted(self._client.list_tasks(), key=_make_sort_key(self._sort_by_updated)) new_multi_runner = ( @@ -1759,7 +1835,7 @@ def _add_row(task: JsonObj, prefix: str) -> None: ) else: state_cell: Text | str = task["state"] - turn_cell = _turn_cell(task) + turn_cell = _turn_cell(task, display_now) status_cell = _status_cell(task) runner_cell: Text | None = ( Text(task.get("runner_host") or "") if self._multi_runner else None @@ -1774,6 +1850,15 @@ def _add_row(task: JsonObj, prefix: str) -> None: runner_cell = _dim(runner_cell) repo_cell = _dim(repo_cell) slug_cell_real = _dim(slug_cell_real) + elif _snooze_label(task, display_now) is not None: + # An active snooze mutes the whole row (the turn cell already carries the label). + state_cell = _dim(state_cell) + turn_cell = _dim(turn_cell) + status_cell = _dim(status_cell) + if runner_cell is not None: + runner_cell = _dim(runner_cell) + repo_cell = _dim(repo_cell) + slug_cell_real = _dim(slug_cell_real) runner_extra = (runner_cell,) if runner_cell is not None else () table.add_row( state_cell, @@ -1793,6 +1878,29 @@ def _add_row(task: JsonObj, prefix: str) -> None: if target is not None: table.move_cursor(row=table.get_row_index(target)) self._current = target # `d` opens the detail modal for whatever's highlighted + self._schedule_snooze_refresh(display_now) + + def _schedule_snooze_refresh(self, now: datetime) -> None: + """(Re)arm a one-shot timer for the soonest finite-snooze display change. + + A snooze ticking down or expiring is a clock-only change — the task service emits no event + for it — so the change-feed worker won't repaint. This timer fills that gap; an indefinite + snooze never changes, so it contributes no delay.""" + if self._snooze_timer is not None: + self._snooze_timer.stop() + self._snooze_timer = None + delays = [ + delay + for task in self._tasks.values() + if (delay := _snooze_refresh_delay(task, now)) is not None + ] + if delays: + self._snooze_timer = self.set_timer(min(delays), self._refresh_snooze_display) + + def _refresh_snooze_display(self) -> None: + """Repaint when a finite snooze's label is due to change (fired by the one-shot timer).""" + self._snooze_timer = None + self.action_refresh() def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: key = event.row_key.value @@ -1876,6 +1984,42 @@ def action_drop(self) -> None: return self.action_refresh() + def action_snooze(self) -> None: + """`e`: toggle a fixed twelve-hour operator snooze on the highlighted task. + + Snoozing a task mutes it (dims the row, shows `snoozed · Nh left`) until the deadline; a + second `e` while it's active clears it. The 12h window is a hard constant.""" + task_id = self._current + if task_id is None: + return + task = self._tasks.get(task_id) + if task is None: + return + now = self._now() + # Active (finite or indefinite) → toggle off; otherwise record now + 12h. + until = ( + None if _snooze_label(task, now) is not None else (now + _SNOOZE_DURATION).isoformat() + ) + if self._set_snooze(task_id, until): + self.action_refresh() + + def action_snooze_indefinitely(self) -> None: + """`E`: record the reserved sticky snooze deadline (mute until explicitly un-snoozed).""" + task_id = self._current + if task_id is None: + return + if self._set_snooze(task_id, _INDEFINITE_SNOOZE_UNTIL): + self.action_refresh() + + def _set_snooze(self, task_id: str, until: str | None) -> bool: + """Persist one snooze fact; a failed REST write notifies rather than taking down the TUI.""" + try: + self._client.set_snooze(task_id, until) + return True + except httpx.HTTPStatusError as exc: + self.notify(f"Can't snooze: {_detail(exc)}", severity="error") + return False + def action_respawn(self) -> None: """`R`: kill any running container/session for this task and respawn it. diff --git a/tests/terminal/test_dashboard.py b/tests/terminal/test_dashboard.py index be92bbeb..8f851025 100644 --- a/tests/terminal/test_dashboard.py +++ b/tests/terminal/test_dashboard.py @@ -7,6 +7,7 @@ import contextlib import threading +from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any @@ -18,6 +19,8 @@ from panopticon.terminal import dashboard from panopticon.terminal.dashboard import ( _ENSEMBLE_KEY_PREFIX, + _INDEFINITE_SNOOZE_UNTIL, + _SNOOZE_DURATION, Dashboard, SpaceCheckbox, TaskDetailScreen, @@ -29,6 +32,7 @@ _repo_cell, _short_tokens, _slug_cell, + _snooze_label, _status_cell, _turn_cell, render_detail, @@ -112,6 +116,7 @@ def __init__( self.created: list[tuple[str, str, str | None]] = [] self.applied: list[tuple[str, str]] = [] self.released: list[str] = [] + self.snoozed: list[tuple[str, str | None]] = [] self.created_repos: list[dict[str, Any]] = [] self.updated_repos: list[tuple[str, dict[str, Any]]] = [] # When set, create_repo/update_repo raise a 400 carrying this detail (mimics the task @@ -228,6 +233,13 @@ def apply_operation(self, task_id: str, operation: str) -> dict[str, Any]: self.applied.append((task_id, operation)) return {"id": task_id} + def set_snooze(self, task_id: str, until: str | None) -> dict[str, Any]: + self.snoozed.append((task_id, until)) + for t in self._tasks: # reflect the write in list_tasks (as the real service does) + if t["id"] == task_id: + t["snoozed_until"] = until + return {"id": task_id, "snoozed_until": until} + def get_task(self, task_id: str) -> dict[str, Any]: for t in self._tasks: if t["id"] == task_id: @@ -522,6 +534,102 @@ def test_dim_helper_str_and_text() -> None: assert str(t.style) == "green" +# --- snooze: fixed 12h `e` / indefinite `E`, dim presentation, clock-driven label --------------- + +_NOW = datetime(2026, 8, 5, 12, 0, 0, tzinfo=UTC) + + +def _at(hours: float) -> str: + """An ISO deadline `hours` from the fixed test clock.""" + return (_NOW + timedelta(hours=hours)).isoformat() + + +def test_snooze_label_buckets_hours_minutes_and_indefinite() -> None: + # A finite deadline renders "snoozed · Nh/Nm left"; the reserved value renders bare "snoozed". + assert _snooze_label({"snoozed_until": _at(4)}, _NOW) == "snoozed · 4h left" + assert _snooze_label({"snoozed_until": _at(4.5)}, _NOW) == "snoozed · 5h left" # ceil + assert _snooze_label({"snoozed_until": (_NOW + timedelta(minutes=30)).isoformat()}, _NOW) == ( + "snoozed · 30m left" + ) + assert _snooze_label({"snoozed_until": (_NOW + timedelta(seconds=20)).isoformat()}, _NOW) == ( + "snoozed · <1m left" + ) + assert _snooze_label({"snoozed_until": _INDEFINITE_SNOOZE_UNTIL}, _NOW) == "snoozed" + + +def test_snooze_label_inactive_for_past_missing_or_invalid() -> None: + assert _snooze_label({"snoozed_until": _at(-1)}, _NOW) is None # already elapsed + assert _snooze_label({}, _NOW) is None # no fact + assert _snooze_label({"snoozed_until": None}, _NOW) is None + assert _snooze_label({"snoozed_until": "not-a-date"}, _NOW) is None + + +def test_snooze_keys_are_bound_exactly_once() -> None: + keys = [hk.key for hk in dashboard.HOTKEYS] + assert keys.count("e") == 1 + assert keys.count("E") == 1 + + +async def test_pressing_e_records_a_twelve_hour_snooze() -> None: + client = _FakeClient([dict(_TASK)]) # copy: set_snooze mutates the task dict in place + app = Dashboard(client, now=lambda: _NOW) # type: ignore[arg-type] + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("e") + await pilot.pause() + # Exactly 12h after the injected clock — the window is a hard constant, no config surface. + assert client.snoozed == [("task-abcdef0123", (_NOW + _SNOOZE_DURATION).isoformat())] + assert timedelta(hours=12) == _SNOOZE_DURATION + + +async def test_pressing_e_again_toggles_the_snooze_off() -> None: + client = _FakeClient([{**_TASK, "snoozed_until": _at(6)}]) # already actively snoozed + app = Dashboard(client, now=lambda: _NOW) # type: ignore[arg-type] + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("e") + await pilot.pause() + assert client.snoozed == [("task-abcdef0123", None)] # cleared, not re-armed + + +async def test_pressing_capital_e_records_the_indefinite_snooze() -> None: + client = _FakeClient([dict(_TASK)]) # copy: set_snooze mutates the task dict in place + app = Dashboard(client, now=lambda: _NOW) # type: ignore[arg-type] + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("E") + await pilot.pause() + assert client.snoozed == [("task-abcdef0123", _INDEFINITE_SNOOZE_UNTIL)] + + +async def test_active_snooze_dims_the_row_and_labels_the_turn_cell() -> None: + task = {**_TASK, "snoozed_until": _at(4)} + app = Dashboard(_FakeClient([task]), now=lambda: _NOW) # type: ignore[arg-type] + async with app.run_test() as pilot: + await pilot.pause() + table = app.query_one("#tasks", DataTable) + row = table.get_row("task-abcdef0123") + assert row[1].plain == "snoozed · 4h left" # turn cell carries the label + for cell in row: # the whole row is muted + assert cell._spans and all(s.style == "dim" for s in cell._spans) + + +async def test_expired_snooze_resumes_normal_presentation_without_mutating() -> None: + task = {**_TASK, "snoozed_until": _at(-1)} # deadline already passed at _NOW + client = _FakeClient([task]) + app = Dashboard(client, now=lambda: _NOW) # type: ignore[arg-type] + async with app.run_test() as pilot: + await pilot.pause() + table = app.query_one("#tasks", DataTable) + row = table.get_row("task-abcdef0123") + assert row[1].plain == "agent" and row[1].style == "green" # ordinary turn derivation + slug_cell = row[4] + assert not any(s.style == "dim" for s in slug_cell._spans) # not muted + # Expiry is display-only: the dashboard never wrote the stored fact. + assert client.snoozed == [] + assert task["snoozed_until"] == _at(-1) + + async def _settle(pilot: Any, predicate: Any, *, tries: int = 100, step: float = 0.02) -> None: """Pump the event loop until ``predicate()`` holds (or we run out of tries). The feed worker runs on a thread and marshals the rebuild back via ``call_from_thread``, so we poll rather than @@ -2170,7 +2278,7 @@ def test_footer_shows_only_the_essential_keys() -> None: shown = {b.key for b in Dashboard.BINDINGS if b.show} hidden = {b.key for b in Dashboard.BINDINGS if not b.show} assert shown == {"t", "n", "x", "/", "d", "question_mark", "q"} - assert hidden == {"o", "r", "R", "p", "g", "a", "s", "u", "y", "Y", "escape"} + assert hidden == {"o", "r", "R", "p", "e", "E", "g", "a", "s", "u", "y", "Y", "escape"} def test_bindings_and_help_derive_from_the_single_hotkey_table() -> None: