Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 147 additions & 3 deletions src/panopticon/terminal/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 = (
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand Down
110 changes: 109 additions & 1 deletion tests/terminal/test_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import contextlib
import threading
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any

Expand All @@ -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,
Expand All @@ -29,6 +32,7 @@
_repo_cell,
_short_tokens,
_slug_cell,
_snooze_label,
_status_cell,
_turn_cell,
render_detail,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Loading