From 57da1e9b24bd02d5a91de3a5caefb391af3a1d93 Mon Sep 17 00:00:00 2001 From: Shayan Date: Fri, 9 Oct 2026 11:06:27 -0400 Subject: [PATCH 1/4] docs: drop stale note about prompt-tutor Watcher keys prompt-tutor --once no longer prints the key row (shayan-ys/prompt-tutor#17). --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 23c97d6..a557e75 100644 --- a/README.md +++ b/README.md @@ -249,9 +249,6 @@ prints one frame sized to `COLUMNS` and `LINES` and exits, so it works as an int 4. Run `devdash`. The section updates on the global interval; press `r` to refresh it after a prompt, and use `devdash --no-integration prompt-tutor` to hide it for one run. -The frame's last row lists the Watcher's own keys (`j/k`, `s`, `q`). They work only in the -standalone `prompt-tutor` Watcher, not inside devdash. - ## Troubleshooting **`gh is not signed in`.** Run `gh auth login`. With more than one account, pass `--github USER` From 8e56bfed8d0821849063e7fce25ab28d9fa04f57 Mon Sep 17 00:00:00 2001 From: Shayan Date: Fri, 9 Oct 2026 11:47:32 -0400 Subject: [PATCH 2/4] Add integration keybindings, focus, and fetch countdowns Integrations declare keys = { key = "action" }; a press re-runs the command with DEVDASH_ACTION and a per-process DEVDASH_STATE_FILE. Tab cycles focus between keyed integrations. [keys] remaps or disables r/q/tab, now case-sensitive. The footer lists key hints only; section headings count down to their next fetch. Global refreshes run in the background so input stays responsive. Refs #7, map #8. --- CHANGELOG.md | 9 + GLOSSARY.md | 29 +++ README.md | 58 ++++- config.example.toml | 9 + devdash.py | 476 +++++++++++++++++++++++++++++++++++------- tests/test_devdash.py | 225 ++++++++++++++++++++ 6 files changed, 726 insertions(+), 80 deletions(-) create mode 100644 GLOSSARY.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8accdf1..a162520 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ All notable changes to this project are documented here. The format follows its own thread and schedule, with a timeout that kills its whole process group, and a failure keeps its last good output on screen under the error. `--no-integration NAME` hides one integration and `--no-integrations` hides all of them. +- Configurable, case-sensitive built-in keys and per-integration actions, with private state files in watch mode, + focus-aware footer hints, and fetch countdowns on section headings. - `--profile NAME` selects the matching isolated omp profile for usage and cached readings; only that profile's authenticated providers appear. Provider headings include that provider's account email in white, for example `Codex (user@example.com)`. @@ -25,6 +27,13 @@ All notable changes to this project are documented here. The format follows - Pace icons on weekly and monthly usage bars: `>` to `>>>` when you use a quota faster than time passes, `<` to `<<<` when slower, `|` on pace. Turn them off with `usage.pace = false`. +### Changed + +- Built-in keys are case-sensitive; uppercase `R` and `Q` no longer trigger refresh or quit. +- Global PR and usage refreshes run in the background; input stays responsive while they fetch. + Pressing refresh during an active refresh schedules one follow-up. +- Section headings continue to show `fetching…` during a fetch; the footer contains only key hints. + ### Fixed - CI badge uses the latest Actions run of each workflow, then the latest check of each diff --git a/GLOSSARY.md b/GLOSSARY.md new file mode 100644 index 0000000..ab86902 --- /dev/null +++ b/GLOSSARY.md @@ -0,0 +1,29 @@ +# devdash + +A terminal dashboard that combines its own sections with output from configured integrations. + +## Language + +**Integration**: +A configured command whose output forms one dashboard section. +_Avoid_: plugin, widget + +**Built-in key**: +A devdash key for refresh, quit, or focus. +_Avoid_: integration binding, action + +**Binding**: +A key mapped to an action in one integration. +_Avoid_: built-in key, shortcut + +**Action**: +A name an integration defines and understands as a response to a binding. +_Avoid_: command, key + +**Focus**: +The one integration that receives binding keys. +_Avoid_: selection, active section + +**State file**: +An integration's state for one devdash process. +_Avoid_: cache, configuration file diff --git a/README.md b/README.md index a557e75..1ee6b95 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,11 @@ Every setting is optional. devdash reads a [TOML](https://toml.io) file from the ```toml interval = 60 # seconds between refreshes +[keys] +refresh = "r" # "" disables a built-in; Ctrl-C always quits +quit = "q" +focus = "tab" + [github] exclude = ["acme/monorepo", "some-org"] # "owner/repo", or "owner" for all of its repositories review_requested = true # show the REVIEW REQUESTED section @@ -184,6 +189,43 @@ env = { WTTR_LANG = "en" } # extra environment variables for the comma Integrations that share a position appear in the order they are listed in the file. +### Keys, actions, focus, and state + +In watch mode, `[keys]` configures the built-in `refresh`, `quit`, and `focus` keys. An empty +string disables a built-in. Key names are case-sensitive: one printable ASCII character other +than space, or `up`, `down`, `left`, `right`, `enter`, or `tab`. Arrow keys and Enter/Tab are +decoded by devdash; Ctrl-C always quits. + +An integration can declare `keys = { j = "newer", k = "older" }`, mapping keys to action names +that the integration itself understands. Bindings are case-sensitive; they cannot use a key +assigned to an enabled built-in. Remap that built-in in `[keys]` to resolve a conflict. Two +integrations can use the same binding; only the focused one receives it. Focus starts on the +first integration with bindings in screen order, and the configured focus key cycles through +those integrations. The footer is one list of key hints: enabled built-ins, the focus key when two +or more integrations are focusable, then the focused integration's bindings in config order. The +focused section's heading starts with `▸ `. + +Section headings show how long ago the last result was fetched and count down to the next fetch, +updating every second. Integrations use their own intervals; MY PRS uses the global interval, and +USAGE follows its effective usage-refetch cadence. While a section is fetching, its heading says +`fetching…`. `--once` shows no countdown. The footer contains only key hints. + +Global PR and usage refreshes run in the background, so input remains responsive. Pressing `r` +while one is running schedules one follow-up refresh; key presses after `r` in the same input batch +are still handled. + +Each binding press runs the integration's command again with `DEVDASH_ACTION` set to its action. +Other runs have no `DEVDASH_ACTION`, even if it was inherited from the environment. Presses queue +in order (up to eight waiting actions); a run already in progress finishes first, and queued +actions run before the next interval refresh. The interval starts at the end of the last run. +`refresh` wakes all integrations, and any run satisfies that request. + +In watch mode, an enabled integration that declares a `keys` table receives `DEVDASH_STATE_FILE` +pointing to an initially empty file in a private directory. The file is mode `0600`; its directory +is mode `0700`. devdash never reads the file: the integration owns its format and can use it to +preserve its view or selection between command runs. State lasts only for one devdash process. +`--once` and non-TTY runs set neither `DEVDASH_ACTION` nor `DEVDASH_STATE_FILE`. + **What a command must do.** Print the section body and exit with status 0. Any language works: a shell script, a Python file, or an existing CLI with a one-shot mode. devdash sets `COLUMNS` to the pane width and `LINES` to `max_rows`, so the command can fit its output to the room it has; @@ -205,16 +247,18 @@ devdash runs the `command` list directly, without a shell; to use pipes or `&&`, integration runs on its own thread, with no standard input and in its own process group, so a slow or hung command cannot freeze the dashboard or read your keystrokes. A timeout kills the command together with every process it started that stayed in its process group; a program -that starts its own session (a daemon, for example) escapes this. Quitting devdash, with `q`, -Ctrl-C, or by closing the pane, kills running commands the same way. Pressing `r` runs every -waiting integration again at once. With `--once`, devdash runs all integrations in parallel, -each within its own timeout, alongside the built-in GitHub and usage reads. +that starts its own session (a daemon, for example) escapes this. Quitting devdash with the +configured quit key, Ctrl-C, or by closing the pane kills running commands the same way. Pressing +the configured refresh key runs every waiting integration again at once. With `--once`, devdash +runs all integrations in parallel, each within its own timeout, alongside the built-in GitHub +and usage reads. ### Example: prompt-tutor [prompt-tutor](https://github.com/shayan-ys/prompt-tutor) reviews the English of the prompts you send to omp, and its Watcher shows the latest Review. Its one-shot mode, `prompt-tutor --once`, prints one frame sized to `COLUMNS` and `LINES` and exits, so it works as an integration as is. +The key actions and state-file behavior below require a prompt-tutor build with devdash key support. 1. Install prompt-tutor and put its Watcher on your `PATH`. Until prompt-tutor's first build is merged into its `main` branch, `main` holds only planning documents, so install the @@ -242,12 +286,14 @@ prints one frame sized to `COLUMNS` and `LINES` and exits, so it works as an int command = ["prompt-tutor", "--once"] position = "bottom" max_rows = 16 + keys = { j = "newer", k = "older", s = "scope", J = "scroll-down", K = "scroll-up" } ``` If devdash shows `⚠ cannot run prompt-tutor`, the Watcher link is not on the `PATH` that devdash sees; use its full path, for example `command = ["~/.local/bin/prompt-tutor", "--once"]`. -4. Run `devdash`. The section updates on the global interval; press `r` to refresh it after a - prompt, and use `devdash --no-integration prompt-tutor` to hide it for one run. +4. Run `devdash`. The section updates on the global interval; press `j`/`k` for newer/older Prompts, + `s` to change Scope, and `J`/`K` to scroll the Watcher frame. The integration keeps its view and + selection in the state file. Press `r` to refresh the dashboard, and use `devdash --no-integration prompt-tutor` to hide it. ## Troubleshooting diff --git a/config.example.toml b/config.example.toml index fc513f5..6e7655c 100644 --- a/config.example.toml +++ b/config.example.toml @@ -5,6 +5,12 @@ # Seconds between refreshes. interval = 60 +# Interactive keys. Empty strings disable a built-in; Ctrl-C still quits. +[keys] +refresh = "r" +quit = "q" +focus = "tab" + [github] # Repositories to hide from both PR sections. An entry is "owner/repo" for one repository, # or a bare "owner" for every repository of that user or organization. @@ -50,3 +56,6 @@ order = [] # e.g. ["openai-codex", "anthropic"] # max_rows = 20 # longer output is cut # enabled = true # env = {} # extra environment variables, e.g. { CITY = "Toronto" } +# Integration bindings map keys to action names the command understands. +# Example for a build with prompt-tutor key support: +# keys = { j = "newer", k = "older", s = "scope", J = "scroll-down", K = "scroll-up" } diff --git a/devdash.py b/devdash.py index ac29456..38e7214 100755 --- a/devdash.py +++ b/devdash.py @@ -15,7 +15,7 @@ devdash shows (see the README) Settings come from a TOML file (see --config); command-line flags win. -Keys while watching: r = refresh now, q = quit. +Keys while watching: configurable built-ins (r = refresh, q = quit, tab = focus) and focused integration bindings. """ import argparse @@ -23,7 +23,9 @@ import contextlib import hashlib import json +import math import os +import queue import re import select import selectors @@ -35,7 +37,9 @@ import threading import time import tomllib +import tempfile import tty +from collections import deque from concurrent.futures import ThreadPoolExecutor from datetime import UTC, datetime from urllib.request import Request, urlopen @@ -119,9 +123,37 @@ def line(*parts): "interval": 60, "github": {"exclude": [], "review_requested": True, "account": ""}, "usage": {"enabled": "auto", "refetch_after": 180, "pace": True, "order": [], "names": {}, "colors": {}}, + "keys": {"refresh": "r", "quit": "q", "focus": "tab"}, "integrations": [], } REPO_RULE = re.compile(r"^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)?$") +SPECIAL_KEYS = {"up", "down", "left", "right", "enter", "tab"} + + +def valid_key_name(key): + return (isinstance(key, str) and + (key in SPECIAL_KEYS or (len(key) == 1 and "!" <= key <= "~"))) + + +def check_builtin_keys(keys): + enabled = {} + for name in ("refresh", "quit", "focus"): + key = keys[name] + if not isinstance(key, str): + raise SystemExit(f"devdash: config key 'keys.{name}' must be a key name or an empty string") + if key and not valid_key_name(key): + raise SystemExit(f"devdash: config key 'keys.{name}' must be a printable key name or an empty string") + if key: + if key in enabled: + raise SystemExit(f"devdash: built-in keys '{enabled[key]}' and '{name}' both use '{key}'") + enabled[key] = name + return enabled + + +def check_key_name(key, where): + if not valid_key_name(key): + raise SystemExit(f"devdash: {where} key {key!r} must be one printable ASCII character or " + "'up', 'down', 'left', 'right', 'enter', or 'tab'") def merge(base, over, where=""): @@ -160,7 +192,8 @@ def load_config(path, explicit): cfg = merge(DEFAULTS, raw) if cfg["usage"]["enabled"] not in (True, False, "auto"): raise SystemExit("devdash: config key 'usage.enabled' must be true, false, or \"auto\"") - cfg["integrations"] = check_integrations(cfg["integrations"]) + builtins = check_builtin_keys(cfg["keys"]) + cfg["integrations"] = check_integrations(cfg["integrations"], builtins) return cfg @@ -488,9 +521,40 @@ def usage_rows(report): return rows -def render_usage(st, width, now): +def section_status(fetched_at, fetching, next_fetch_at, now, monotonic_now): + if fetching: + return (" fetching…", META) + parts = [] + if fetched_at: + parts.append(f"fetched {dur(now - fetched_at)} ago") + if next_fetch_at is not None: + seconds = max(0, math.ceil(next_fetch_at - monotonic_now)) + parts.append(f"next fetch {dur(seconds)}") + return (" " + " · ".join(parts), META) if parts else "" + + +def usage_next_fetch(st, now, monotonic_now): + deadline = st.global_next_fetch + if deadline is None or st.usage_every is None: + return deadline + since_invalidation = now - st.usage_inval_at + age = oldest_read(st.usage) + delay = max(0, 60 - since_invalidation, + st.usage_every - age if age is not None else 0) + eligible = monotonic_now + delay + if eligible <= deadline: + return deadline + ticks = math.ceil((eligible - deadline) / st.interval) + return deadline + ticks * st.interval + + +def render_usage(st, width, now, monotonic_now=None): + if monotonic_now is None: + monotonic_now = time.monotonic() data = st.usage - out = [line(("USAGE", H1), (" fetched " + dur(now - st.usage_at) + " ago", META) if st.usage_at else "")] + status = section_status(st.usage_at, st.usage_fetching, + usage_next_fetch(st, now, monotonic_now), now, monotonic_now) + out = [line(("USAGE", H1), status)] if st.usage_err: out.append(line((f"⚠ {st.usage_err}", BAD))) if not data: @@ -806,9 +870,11 @@ def rule(width): return line(("─" * width, RULE)) -def render_mine(st, width, now): - out = [line(("MY PRS ", H1), (str(len(st.mine)), H1), - (" fetched " + dur(now - st.prs_at) + " ago", META) if st.prs_at else "")] +def render_mine(st, width, now, monotonic_now=None): + if monotonic_now is None: + monotonic_now = time.monotonic() + status = section_status(st.prs_at, st.prs_fetching, st.global_next_fetch, now, monotonic_now) + out = [line(("MY PRS ", H1), (str(len(st.mine)), H1), status)] if st.prs_err: out.append(line((f"⚠ {st.prs_err}", BAD))) for repo, groups in group_mine(st.mine).items(): @@ -858,7 +924,7 @@ def render_review(st, width, now): # every other escape sequence is dropped. POSITIONS = ("top", "after-usage", "after-my-prs", "bottom") INTEGRATION = {"name": "", "title": "", "command": [], "position": "bottom", "enabled": True, - "interval": 0, "timeout": 10, "max_rows": 20, "env": {}} + "interval": 0, "timeout": 10, "max_rows": 20, "env": {}, "keys": {}} NAME_RULE = re.compile(r"[A-Za-z0-9_.-]+") MAX_OUTPUT = 64 * 1024 # bytes of standard output devdash keeps; a command that prints more is stopped ERR_TAIL = 4 * 1024 # bytes of standard error devdash keeps, the newest ones, for the error line @@ -868,8 +934,10 @@ def render_review(st, width, now): CONTROL = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]") -def check_integrations(items): - """Validate the [[integrations]] tables, disabled ones included, and fill in their defaults.""" +def check_integrations(items, builtins=None): + """Validate every integration, including disabled ones, and fill in its defaults.""" + if builtins is None: + builtins = check_builtin_keys(DEFAULTS["keys"]) specs, names = [], set() for i, raw in enumerate(items): if not isinstance(raw, dict): @@ -897,6 +965,15 @@ def check_integrations(items): raise SystemExit(f"{where} max_rows must be 1 or more") if not all(isinstance(v, str) for v in spec["env"].values()): raise SystemExit(f"{where} env values must be strings") + for key, action in spec["keys"].items(): + check_key_name(key, where) + if not isinstance(action, str) or not NAME_RULE.fullmatch(action): + raise SystemExit(f"{where} action for key {key!r} must use letters, digits, '.', '_' or '-'") + if key in builtins: + builtin = builtins[key] + raise SystemExit(f"{where} key '{key}' conflicts with built-in '{builtin}'; " + f"remap it in [keys] (for example, keys.{builtin} = \"x\")") + spec["has_keys"] = "keys" in raw specs.append(spec) return specs @@ -982,17 +1059,30 @@ def __init__(self, spec, interval): self.timeout = spec["timeout"] self.max_rows = spec["max_rows"] self.env = spec["env"] + self.keys = spec["keys"] + self.has_keys = spec.get("has_keys", bool(self.keys)) + self.state_file = None self.result = ([], 0, None) # (rows, fetched at, error) + self.next_run_at = None self.wake = threading.Event() - # stop() and fetch() share the running command under this lock, so a command - # started while devdash quits is either never started or killed. + # stop(), fetch(), and the action queue share this lock, so a command started + # while devdash quits is either never started or killed. self.lock = threading.Lock() self.proc = None + self.running = False self.stopped = False + self.actions = deque() + self.refresh_requested = True - def fetch(self, width): + def fetch(self, width, action=None): # COLUMNS and LINES tell the command how much room its section has. env = {**os.environ, **self.env, "COLUMNS": str(width), "LINES": str(self.max_rows)} + env.pop("DEVDASH_ACTION", None) + env.pop("DEVDASH_STATE_FILE", None) + if action is not None: + env["DEVDASH_ACTION"] = action + if self.state_file is not None: + env["DEVDASH_STATE_FILE"] = self.state_file with self.lock: if self.stopped: raise RuntimeError("stopped") @@ -1022,21 +1112,56 @@ def fetch(self, width): raise RuntimeError(f"exit {p.returncode}" + (f": {tail[-1]}" if tail else "")) return output_rows(out.decode(errors="replace"), self.max_rows) - def poll(self, width): + def poll(self, width, action=None): """Run the command once. A failure keeps the last good rows on screen under the error. `result` is (rows, fetched at, error), replaced as one tuple so a frame never mixes two runs.""" try: - self.result = (self.fetch(width), time.time(), None) + self.result = (self.fetch(width, action), time.time(), None) except Exception as e: # one broken integration must never stop the dashboard rows, at, _ = self.result self.result = (rows, at, plain_line(str(e))[:80]) + def queue_action(self, action): + with self.lock: + if self.stopped or len(self.actions) >= 8: + return False + self.actions.append(action) + self.wake.set() + return True + + def request_refresh(self, after_current=False): + with self.lock: + if not self.stopped and (not self.running or after_current): + self.refresh_requested = True + self.wake.set() + def loop(self, st): - while not self.stopped: + next_run = time.monotonic() + while True: self.wake.clear() - self.poll(st.width) - self.wake.wait(self.interval) + now = time.monotonic() + with self.lock: + if self.stopped: + return + action = self.actions.popleft() if self.actions else None + refresh_requested = self.refresh_requested + self.refresh_requested = False + run_now = action is not None or refresh_requested or now >= next_run + if run_now: + self.running = True + if run_now: + try: + self.poll(st.width, action) + finally: + next_run = time.monotonic() + self.interval + with self.lock: + self.running = False + self.next_run_at = next_run + if st.live is not None: + st.live.refresh() + continue + self.wake.wait(max(0, next_run - time.monotonic())) def stop(self): """Stop polling and kill a running command with everything it started. @@ -1051,13 +1176,133 @@ def stop(self): os.killpg(p.pid, signal.SIGKILL) -def render_integration(ig, now): +@contextlib.contextmanager +def integration_state_files(st): + """Give watch-mode integrations with a keys table one private, empty state file.""" + keyed = [ig for ig in st.integrations if ig.has_keys] + if not keyed: + yield + return + with tempfile.TemporaryDirectory(prefix="devdash-") as directory: + os.chmod(directory, 0o700) + try: + for index, ig in enumerate(keyed): + path = os.path.join(directory, f"{index}.state") + fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + os.fchmod(fd, 0o600) + os.close(fd) + ig.state_file = path + yield + finally: + for ig in keyed: + ig.state_file = None + + +KEY_ESCAPE_TIMEOUT = 0.05 + + +def decode_keys(data, pending=b"", flush=False): + """Decode bindable key names while preserving an incomplete trailing escape sequence.""" + buf = pending + data + keys, index = [], 0 + while index < len(buf): + byte = buf[index] + if byte == 0x1B: + if index + 1 == len(buf): + if not flush: + return keys, buf[index:] + index += 1 + continue + intro = buf[index + 1] + if intro in (ord("["), ord("O")): + final = index + 2 + while final < len(buf) and not 0x40 <= buf[final] <= 0x7E: + final += 1 + if final == len(buf): + if not flush: + return keys, buf[index:] + break + if final == index + 2 and buf[final] in (ord("A"), ord("B"), ord("C"), ord("D")): + keys.append({"A": "up", "B": "down", "C": "right", "D": "left"}[chr(buf[final])]) + index = final + 1 + continue + if intro in (ord("]"), ord("P"), ord("X"), ord("^"), ord("_")): + end, terminated = index + 2, False + while end < len(buf): + if intro == ord("]") and buf[end] == 0x07: + end += 1 + terminated = True + break + if buf[end] == 0x1B: + if end + 1 == len(buf): + break + if buf[end + 1] == ord("\\"): + end += 2 + terminated = True + break + end += 1 + if not terminated: + if not flush: + return keys, buf[index:] + break + index = end + continue + if intro == 0x1B: + index += 1 + else: + index += 2 # discard unsupported two-byte escapes, including Alt-key prefixes + continue + if byte in (0x0A, 0x0D): + keys.append("enter") + elif byte == 0x09: + keys.append("tab") + elif 0x21 <= byte <= 0x7E: + keys.append(chr(byte)) + index += 1 + return keys, b"" + + +def screen_integrations(st): + return [ig for position in POSITIONS for ig in st.integrations if ig.position == position] + + +def focusable_integrations(st): + return [ig for ig in screen_integrations(st) if ig.keys] + + +def focus_next(st): + integrations = focusable_integrations(st) + if len(integrations) < 2: + return False + current = next((i for i, ig in enumerate(integrations) if ig is st.focus), -1) + st.focus = integrations[(current + 1) % len(integrations)] + return True + + +def dispatch_key(st, key): + """Apply one key press; built-ins take precedence over the focused integration.""" + result = "ignored" + if key == st.keys["quit"] and st.keys["quit"]: + result = "quit" + elif key == st.keys["refresh"] and st.keys["refresh"]: + for ig in st.integrations: + ig.request_refresh() + result = "refresh" + elif key == st.keys["focus"] and st.keys["focus"]: + focus_next(st) + elif st.focus is not None and key in st.focus.keys: + st.focus.queue_action(st.focus.keys[key]) + if st.live is not None: + st.live.refresh() + return result + + +def render_integration(ig, now, focused=False, monotonic_now=None): + if monotonic_now is None: + monotonic_now = time.monotonic() rows, at, err = ig.result - if at: - age = (" fetched " + dur(now - at) + " ago", META) - else: - age = (" loading…", META) if not err else "" - out = [line((ig.title, H1), age)] + status = section_status(at, ig.running, ig.next_run_at, now, monotonic_now) + out = [line(("▸ " if focused else "", H1), (ig.title, H1), status)] if err: out.append(line((f"⚠ {err}", BAD))) return out + rows @@ -1077,8 +1322,6 @@ class State: mine, review = [], [] prs_at = 0 prs_err = None - me = "" - busy = False interval = 60 show_usage = True show_review = True @@ -1086,6 +1329,13 @@ class State: excluded = [] integrations = [] width = 80 # the pane width at the last render, passed to integrations as COLUMNS + keys = DEFAULTS["keys"] + focus = None + live = None + global_next_fetch = None + prs_fetching = False + usage_fetching = False + usage_every = None class Dashboard: @@ -1096,23 +1346,30 @@ def __init__(self, st): def __rich_console__(self, console, options): now = time.time() + monotonic_now = time.monotonic() st = self.st width = options.max_width if width != st.width: # integrations lay out for the new width now, not at their next run st.width = width for ig in st.integrations: - ig.wake.set() - slot = {p: [render_integration(ig, now) for ig in st.integrations if ig.position == p] + ig.request_refresh(after_current=True) + slot = {p: [render_integration(ig, now, ig is st.focus, monotonic_now) + for ig in st.integrations if ig.position == p] for p in POSITIONS} - sections = slot["top"] + ([render_usage(st, width, now)] if st.show_usage else []) - sections += slot["after-usage"] + [render_mine(st, width, now)] + slot["after-my-prs"] + sections = slot["top"] + ([render_usage(st, width, now, monotonic_now)] if st.show_usage else []) + sections += slot["after-usage"] + [render_mine(st, width, now, monotonic_now)] + slot["after-my-prs"] sections += ([render_review(st, width, now)] if st.show_review else []) + slot["bottom"] rows = list(sections[0]) for rows_of in sections[1:]: rows += [Text(), rule(width), *rows_of] - foot = f"{time.strftime('%H:%M:%S')} · every {st.interval}s · r refresh · q quit" - if st.busy: - foot = "refreshing… · " + foot + keys = st.keys + hints = [f"{keys[name]} {name}" for name in ("refresh", "quit") if keys[name]] + focusable = focusable_integrations(st) + if len(focusable) >= 2 and keys["focus"]: + hints.append(f"{keys['focus']} focus") + if st.focus is not None: + hints.extend(f"{key} {action}" for key, action in st.focus.keys.items()) + foot = " · ".join(hints) height = options.height or console.height if len(rows) > height - 2: hidden = len(rows) - (height - 3) @@ -1120,11 +1377,12 @@ def __rich_console__(self, console, options): yield Group(*rows, Text(), line((foot, META))) -def refresh(st, pool, usage_every, force): - st.busy = True +def refresh(st, usage_every, force): + st.prs_fetching = True now = time.time() - fu = None + invalidate = False if st.show_usage: + st.usage_fetching = True # Other omp sessions keep omp's shared cache fresh, so force a refetch only # when the oldest live read is older than usage_every, and never twice in a # minute: every forced call is another hit on rate-limited usage endpoints. @@ -1132,21 +1390,50 @@ def refresh(st, pool, usage_every, force): age = oldest_read(st.usage) invalidate = (usage_every is not None and since >= 60 and (force or age is None or age >= usage_every)) - fu = pool.submit(fetch_usage, invalidate, st.omp_profile) - fp = pool.submit(fetch_prs, st.excluded, st.show_review, st.gh_account) - if fu: + + results = queue.Queue() + + def fetch(section, function, *args): try: - st.usage, st.usage_at, st.usage_err = carry_over(st.usage, fu.result()), time.time(), None - if invalidate: - st.usage_inval_at = now - save_last_good(st.usage, st.cache_profile) + results.put((section, function(*args), None)) + except Exception as e: + results.put((section, None, e)) + + threading.Thread(target=fetch, args=("prs", fetch_prs, st.excluded, st.show_review, st.gh_account), + daemon=True, name="devdash-prs").start() + sections = 1 + if st.show_usage: + threading.Thread(target=fetch, args=("usage", fetch_usage, invalidate, st.omp_profile), + daemon=True, name="devdash-usage").start() + sections += 1 + if st.live is not None: + st.live.refresh() + for _ in range(sections): + section, value, error = results.get() + try: + if error is not None: + raise error + if section == "usage": + st.usage, st.usage_at, st.usage_err = carry_over(value, st.usage), time.time(), None + if invalidate: + st.usage_inval_at = now + save_last_good(st.usage, st.cache_profile) + else: + (st.mine, st.review), st.prs_at, st.prs_err = value, time.time(), None except Exception as e: # keep the last good data on screen - st.usage_err = str(e)[:80] - try: - (st.mine, st.review), st.prs_at, st.prs_err = fp.result(), time.time(), None - except Exception as e: - st.prs_err = str(e)[:80] - st.busy = False + if section == "usage": + st.usage_err = str(e)[:80] + else: + st.prs_err = str(e)[:80] + finally: + if section == "usage": + st.usage_fetching = False + else: + st.prs_fetching = False + if st.live is not None: + st.live.refresh() + if st.live is not None: + st.live.refresh() def parse_args(): @@ -1189,6 +1476,7 @@ def main(): if not shutil.which("gh"): raise SystemExit("devdash: needs the GitHub CLI (https://cli.github.com), then `gh auth login`") st = State() + st.keys = cfg["keys"] st.interval = args.interval or cfg["interval"] st.excluded = check_rules(list(gh["exclude"]) + args.exclude) st.gh_account = args.github or gh["account"] or None @@ -1202,6 +1490,7 @@ def main(): usage_every = args.usage_every if args.usage_every is not None else usage["refetch_after"] if args.cached or usage_every == 0: usage_every = None + st.usage_every = usage_every hidden = set(args.no_integration) unknown = hidden - {s["name"] for s in cfg["integrations"]} if unknown: @@ -1224,17 +1513,15 @@ def main(): hint = "; pass --github USER from `gh auth status`" if st.gh_account else "; run `gh auth login`" who = f" as {st.gh_account}" if st.gh_account else "" raise SystemExit(f"devdash: gh is not signed in{who} ({e}){hint}") from None - pool = ThreadPoolExecutor(2) - st.width = CONSOLE.width # Closing the pane sends SIGHUP; exit through `finally` so running integrations are killed. for sig in (signal.SIGHUP, signal.SIGTERM): signal.signal(sig, lambda *_: sys.exit(0)) try: if args.once or not sys.stdin.isatty(): - print_once(st, pool, usage_every) + print_once(st, usage_every) else: - watch(st, pool, usage_every) + watch(st, usage_every) except KeyboardInterrupt: pass finally: @@ -1242,11 +1529,11 @@ def main(): ig.stop() -def print_once(st, pool, usage_every): +def print_once(st, usage_every): runner = ThreadPoolExecutor(max(1, len(st.integrations))) try: jobs = [runner.submit(ig.poll, st.width) for ig in st.integrations] - refresh(st, pool, usage_every, False) + refresh(st, usage_every, False) for job in jobs: job.result() finally: @@ -1254,29 +1541,70 @@ def print_once(st, pool, usage_every): CONSOLE.print(Dashboard(st), height=10_000) -def watch(st, pool, usage_every): +def watch(st, usage_every): fd = sys.stdin.fileno() saved = termios.tcgetattr(fd) tty.setcbreak(fd) try: - for ig in st.integrations: - threading.Thread(target=ig.loop, args=(st,), daemon=True, name=f"integration-{ig.name}").start() - force = False - with Live(Dashboard(st), console=CONSOLE, screen=True, refresh_per_second=1): - while True: - refresh(st, pool, usage_every, force) - force = False - deadline = time.time() + st.interval - while (left := deadline - time.time()) > 0: - if select.select([sys.stdin], [], [], left)[0]: - key = os.read(fd, 1).decode(errors="ignore").lower() - if key == "q": - return - if key == "r": - force = True - for ig in st.integrations: - ig.wake.set() - break + with integration_state_files(st): + focusable = focusable_integrations(st) + st.focus = focusable[0] if focusable else None + try: + live = Live(Dashboard(st), console=CONSOLE, screen=True, refresh_per_second=1) + with live: + st.live = live + for ig in st.integrations: + threading.Thread(target=ig.loop, args=(st,), daemon=True, + name=f"integration-{ig.name}").start() + pending = b"" + pending_since = None + force = True + refresh_thread = None + deadline = time.monotonic() + while True: + now = time.monotonic() + if refresh_thread is None or not refresh_thread.is_alive(): + if force or now >= deadline: + # An r press during a refresh requests one follow-up run; it is not lost. + run_force = force + force = False + st.prs_fetching = True + st.usage_fetching = st.show_usage + deadline = now + st.interval + st.global_next_fetch = deadline + refresh_thread = threading.Thread( + target=refresh, args=(st, usage_every, run_force), + daemon=True, name="devdash-refresh") + refresh_thread.start() + live.refresh() + timeout = max(0, deadline - time.monotonic()) + if refresh_thread.is_alive() and timeout == 0: + timeout = 0.2 + if pending_since is not None: + timeout = min(timeout, max(0, pending_since + KEY_ESCAPE_TIMEOUT - + time.monotonic())) + if select.select([fd], [], [], timeout)[0]: + available = bytearray() + while select.select([fd], [], [], 0)[0]: + chunk = os.read(fd, 65536) + if not chunk: + break + available.extend(chunk) + keys, pending = decode_keys(bytes(available), pending) + pending_since = time.monotonic() if pending else None + for key in keys: + outcome = dispatch_key(st, key) + if outcome == "quit": + return + if outcome == "refresh": + force = True + elif pending_since is not None and time.monotonic() >= pending_since + KEY_ESCAPE_TIMEOUT: + _, pending = decode_keys(b"", pending, flush=True) + pending_since = None + finally: + st.live = None + for ig in st.integrations: + ig.stop() finally: termios.tcsetattr(fd, termios.TCSADRAIN, saved) diff --git a/tests/test_devdash.py b/tests/test_devdash.py index d1435ef..73b6b48 100644 --- a/tests/test_devdash.py +++ b/tests/test_devdash.py @@ -66,6 +66,57 @@ def test_config_rejects_mistakes(tmp_path, text): load(tmp_path, text) + +def test_key_config_accepts_case_sensitive_names_and_records_an_empty_table(tmp_path): + cfg = load(tmp_path, '[keys]\nrefresh = "R"\nquit = ""\nfocus = "tab"\n' + '[[integrations]]\nname = "t"\ncommand = ["date"]\nkeys = {}\n') + assert cfg["keys"] == {"refresh": "R", "quit": "", "focus": "tab"} + assert cfg["integrations"][0]["keys"] == {} + assert cfg["integrations"][0]["has_keys"] is True + + +@pytest.mark.parametrize("text", [ + '[keys]\nrefresh = " "\n', + '[keys]\nrefresh = "r"\nquit = "r"\n', + '[keys]\nrefresh = 1\n', + '[keys]\nunknown = "x"\n', + '[[integrations]]\nname = "t"\ncommand = ["date"]\nkeys = 1\n', + '[[integrations]]\nname = "t"\ncommand = ["date"]\nkeys = { " " = "newer" }\n', + '[[integrations]]\nname = "t"\ncommand = ["date"]\nkeys = { j = "" }\n', + '[[integrations]]\nname = "t"\ncommand = ["date"]\nkeys = { j = 7 }\n', + '[[integrations]]\nname = "t"\ncommand = ["date"]\nkeys = { "upwards" = "newer" }\n', +]) +def test_key_config_rejects_invalid_shapes_and_names(tmp_path, text): + with pytest.raises(SystemExit): + load(tmp_path, text) + + +def test_builtin_binding_conflict_names_key_integration_and_remapping(tmp_path): + text = ('[[integrations]]\nname = "t"\ncommand = ["date"]\nenabled = false\n' + 'keys = { r = "newer" }\n') + with pytest.raises(SystemExit) as error: + load(tmp_path, text) + assert "integration 't'" in str(error.value) + assert "key 'r'" in str(error.value) + assert "[keys]" in str(error.value) + + +def test_decode_keys_preserves_case_maps_special_keys_and_ignores_other_escapes(): + decoded, pending = devdash.decode_keys( + b"rR\t\r\n\x1b[A\x1bOB\x1b[C\x1bOD\x1b[1;5A\x1b[Z\x1bOP\x1b") + assert decoded == ["r", "R", "tab", "enter", "enter", "up", "down", "right", "left"] + assert pending == b"\x1b" + assert devdash.decode_keys(b"", pending, flush=True) == ([], b"") + + +def test_decode_keys_keeps_partial_sequences_until_the_next_read(): + decoded, pending = devdash.decode_keys(b"j\x1b[") + assert decoded == ["j"] and pending == b"\x1b[" + decoded, pending = devdash.decode_keys(b"A", pending) + assert decoded == ["up"] and pending == b"" + + + def test_missing_config_is_an_error_only_when_named(tmp_path): assert devdash.load_config(str(tmp_path / "none.toml"), explicit=False) == devdash.DEFAULTS with pytest.raises(SystemExit): @@ -528,6 +579,180 @@ def wait_for_pid(pid_file): pytest.fail("the command never started its child") +def test_integration_action_and_state_environment_are_scoped_to_the_run(tmp_path, monkeypatch): + code = ("import json, os; print(json.dumps({" + "'action': os.environ.get('DEVDASH_ACTION'), " + "'state': os.environ.get('DEVDASH_STATE_FILE')}))") + ig = integration(code, keys={"j": "newer"}) + monkeypatch.setenv("DEVDASH_ACTION", "inherited") + monkeypatch.setenv("DEVDASH_STATE_FILE", "inherited") + ig.poll(40) + assert json.loads(ig.result[0][0].plain) == {"action": None, "state": None} + + state_file = tmp_path / "state.json" + state_file.write_text("") + ig.state_file = str(state_file) + ig.poll(40, action="newer") + assert json.loads(ig.result[0][0].plain) == { + "action": "newer", "state": str(state_file)} + + +def test_watch_state_files_are_empty_private_and_removed(tmp_path): + cfg = load(tmp_path, '[[integrations]]\nname = "t"\ncommand = ["date"]\nkeys = {}\n') + st = devdash.State() + st.integrations = [devdash.Integration(cfg["integrations"][0], 60)] + with devdash.integration_state_files(st): + path = st.integrations[0].state_file + assert path is not None + assert os.stat(os.path.dirname(path)).st_mode & 0o777 == 0o700 + assert os.stat(path).st_mode & 0o777 == 0o600 + assert open(path, "rb").read() == b"" + assert st.integrations[0].state_file is None + assert not os.path.exists(path) + + +def test_integration_action_queue_drops_presses_after_eight(): + ig = integration("pass", keys={"j": "newer"}) + actions = ["newer", "older"] * 4 + assert all(ig.queue_action(action) for action in actions) + assert not ig.queue_action("scope") + assert list(ig.actions) == actions + + +def test_integration_action_queue_is_fifo(tmp_path): + log = tmp_path / "actions.txt" + code = ("import os; " + "open(os.environ['ACTION_LOG'], 'a').write(" + "os.environ.get('DEVDASH_ACTION', 'interval') + '\\n')") + ig = integration(code, env={"ACTION_LOG": str(log)}, keys={"j": "newer"}) + st = devdash.State() + st.width = 40 + thread = devdash.threading.Thread(target=ig.loop, args=(st,), daemon=True) + thread.start() + + def wait_for_count(count): + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + lines = log.read_text().splitlines() if log.exists() else [] + if len(lines) >= count: + return lines + time.sleep(0.01) + pytest.fail(f"only {len(lines)} integration runs completed") + + try: + wait_for_count(1) + actions = ["newer", "older"] * 4 + assert all(ig.queue_action(action) for action in actions) + assert wait_for_count(9)[:9] == ["interval", *actions] + time.sleep(0.05) + assert log.read_text().splitlines() == ["interval", *actions] + finally: + ig.stop() + thread.join(5) + assert not thread.is_alive() + + +def test_focus_cycles_in_screen_order_and_dispatches_only_to_focused_binding(): + bottom = integration("pass", name="bottom", position="bottom", keys={"j": "older"}) + top = integration("pass", name="top", position="top", keys={"k": "newer"}) + st = devdash.State() + st.keys = {"refresh": "r", "quit": "q", "focus": "tab"} + st.integrations = [bottom, top] # config order differs from screen order + focusable = devdash.focusable_integrations(st) + assert focusable == [top, bottom] + st.focus = focusable[0] + devdash.dispatch_key(st, "k") + assert list(top.actions) == ["newer"] and not bottom.actions + devdash.dispatch_key(st, "tab") + assert st.focus is bottom + devdash.dispatch_key(st, "j") + assert list(bottom.actions) == ["older"] + devdash.dispatch_key(st, "tab") + assert st.focus is top + + +def test_dashboard_marks_focus_and_lists_focused_bindings_only_in_footer(): + top = integration("pass", name="top", position="top", + keys={"j": "newer", "k": "older", "s": "scope", + "J": "scroll-down", "K": "scroll-up"}) + bottom = integration("pass", name="bottom", position="bottom", keys={"x": "other"}) + st = devdash.State() + st.show_usage = st.show_review = False + st.keys = {"refresh": "R", "quit": "", "focus": "tab"} + st.integrations = [bottom, top] + st.focus = top + console = Console(record=True, width=120) + console.print(devdash.Dashboard(st), height=10_000) + text = console.export_text() + assert "▸ TOP" in text and "BOTTOM" in text + assert text.splitlines()[-1] == ( + "R refresh · tab focus · j newer · k older · s scope · J scroll-down · K scroll-up") + assert "x other" not in text + assert text.count("j newer") == 1 + assert "q quit" not in text + assert "every " not in text and ":" not in text.splitlines()[-1] + + +def test_footer_omits_focus_key_with_fewer_than_two_focusable_integrations(): + ig = integration("pass", keys={"j": "newer"}) + st = devdash.State() + st.show_usage = st.show_review = False + st.integrations = [ig] + st.focus = ig + console = Console(record=True, width=80) + console.print(devdash.Dashboard(st), height=10_000) + footer = console.export_text().splitlines()[-1] + assert footer == "r refresh · q quit · j newer" + assert "tab focus" not in footer + + +def test_footer_truncates_with_ellipsis(): + ig = integration("pass", keys={"j": "long_action_name"}) + st = devdash.State() + st.show_usage = st.show_review = False + st.integrations = [ig] + st.focus = ig + console = Console(record=True, width=24) + console.print(devdash.Dashboard(st), height=10_000) + footer = console.export_text().splitlines()[-1] + assert len(footer) <= 24 and "…" in footer + + +def test_section_headings_show_fetch_state_and_countdown_but_once_has_none(): + now, mono = 1_800_000_000, 1_000.0 + st = devdash.State() + st.mine = [] + st.prs_at = now - 12 + st.global_next_fetch = mono + 48 + prs = devdash.render_mine(st, 80, now, mono)[0].plain + assert "fetched 12s ago · next fetch 48s" in prs + assert "next fetch 47s" in devdash.section_status( + now - 12, False, mono + 48, now, mono + 1)[0] + + st.usage_at = now - 12 + usage = devdash.render_usage(st, 80, now, mono)[0].plain + assert "fetched 12s ago · next fetch 48s" in usage + st.prs_fetching = st.usage_fetching = True + assert "fetching…" in devdash.render_mine(st, 80, now, mono)[0].plain + assert "fetching…" in devdash.render_usage(st, 80, now, mono)[0].plain + + ig = integration("pass") + ig.result = ([], now - 12, None) + ig.next_run_at = mono + 48 + assert "fetched 12s ago · next fetch 48s" in devdash.render_integration( + ig, now, monotonic_now=mono)[0].plain + ig.running = True + assert "fetching…" in devdash.render_integration(ig, now, monotonic_now=mono)[0].plain + + st.prs_fetching = st.usage_fetching = False + st.global_next_fetch = None + assert "next fetch" not in devdash.render_mine(st, 80, now, mono)[0].plain + assert "next fetch" not in devdash.render_usage(st, 80, now, mono)[0].plain + ig.running = False + ig.next_run_at = None + assert "next fetch" not in devdash.render_integration(ig, now, monotonic_now=mono)[0].plain + + def test_integration_keeps_colour_and_links_drops_screen_control_and_gets_its_room(): ig = integration( "import os; print('\\x1b[2J\\x1b[H\\x1b[1mbold\\x1b[0m '" From 85dab92710481067b2368b6ebededc23a504bcf2 Mon Sep 17 00:00:00 2001 From: Shayan Date: Fri, 9 Oct 2026 11:57:42 -0400 Subject: [PATCH 3/4] Show key hints per section; fix usage carry-over and review status Each section lists its own keys: an integration's bindings under its output, refresh under the last PR section, and quit plus focus at the bottom. REVIEW REQUESTED shows the same fetch status as MY PRS. Fresh usage reports replace the cached one again (carry_over argument order). --- CHANGELOG.md | 5 ++-- README.md | 9 ++++--- devdash.py | 31 ++++++++++++++--------- tests/test_devdash.py | 59 ++++++++++++++++++++++++++----------------- 4 files changed, 63 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a162520..1f8f6d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ All notable changes to this project are documented here. The format follows failure keeps its last good output on screen under the error. `--no-integration NAME` hides one integration and `--no-integrations` hides all of them. - Configurable, case-sensitive built-in keys and per-integration actions, with private state files in watch mode, - focus-aware footer hints, and fetch countdowns on section headings. + per-section key hints, and fetch countdowns on section headings. - `--profile NAME` selects the matching isolated omp profile for usage and cached readings; only that profile's authenticated providers appear. Provider headings include that provider's account email in white, for example `Codex (user@example.com)`. @@ -32,7 +32,8 @@ All notable changes to this project are documented here. The format follows - Built-in keys are case-sensitive; uppercase `R` and `Q` no longer trigger refresh or quit. - Global PR and usage refreshes run in the background; input stays responsive while they fetch. Pressing refresh during an active refresh schedules one follow-up. -- Section headings continue to show `fetching…` during a fetch; the footer contains only key hints. +- Section headings continue to show `fetching…` during a fetch. Each section lists its own keys; + the bottom row lists only quit and focus. ### Fixed diff --git a/README.md b/README.md index 1ee6b95..399e3bf 100644 --- a/README.md +++ b/README.md @@ -201,14 +201,15 @@ that the integration itself understands. Bindings are case-sensitive; they canno assigned to an enabled built-in. Remap that built-in in `[keys]` to resolve a conflict. Two integrations can use the same binding; only the focused one receives it. Focus starts on the first integration with bindings in screen order, and the configured focus key cycles through -those integrations. The footer is one list of key hints: enabled built-ins, the focus key when two -or more integrations are focusable, then the focused integration's bindings in config order. The -focused section's heading starts with `▸ `. +those integrations. Each section lists its own keys on its last row: an integration's bindings in +config order under its output, and the refresh key under the last PR section. The bottom row lists +the keys for the whole dashboard: quit, and the focus key when two or more integrations are +focusable. The focused section's heading starts with `▸ `. Section headings show how long ago the last result was fetched and count down to the next fetch, updating every second. Integrations use their own intervals; MY PRS uses the global interval, and USAGE follows its effective usage-refetch cadence. While a section is fetching, its heading says -`fetching…`. `--once` shows no countdown. The footer contains only key hints. +`fetching…`. `--once` shows no countdown. Global PR and usage refreshes run in the background, so input remains responsive. Pressing `r` while one is running schedules one follow-up refresh; key presses after `r` in the same input batch diff --git a/devdash.py b/devdash.py index 38e7214..2844923 100755 --- a/devdash.py +++ b/devdash.py @@ -900,8 +900,11 @@ def render_mine(st, width, now, monotonic_now=None): return out -def render_review(st, width, now): - out = [line(("REVIEW REQUESTED ", H1), (str(len(st.review)), H1)), Text()] +def render_review(st, width, now, monotonic_now=None): + if monotonic_now is None: + monotonic_now = time.monotonic() + status = section_status(st.prs_at, st.prs_fetching, st.global_next_fetch, now, monotonic_now) + out = [line(("REVIEW REQUESTED ", H1), (str(len(st.review)), H1), status), Text()] if not st.review: out.append(line(("nothing waiting on you", META))) for ri, pr in enumerate(sorted(st.review, key=lambda p: p["updatedAt"], reverse=True)): @@ -1305,7 +1308,10 @@ def render_integration(ig, now, focused=False, monotonic_now=None): out = [line(("▸ " if focused else "", H1), (ig.title, H1), status)] if err: out.append(line((f"⚠ {err}", BAD))) - return out + rows + out += rows + if ig.keys: + out.append(line((" · ".join(f"{key} {action}" for key, action in ig.keys.items()), META))) + return out @@ -1356,19 +1362,20 @@ def __rich_console__(self, console, options): slot = {p: [render_integration(ig, now, ig is st.focus, monotonic_now) for ig in st.integrations if ig.position == p] for p in POSITIONS} + keys = st.keys + mine = render_mine(st, width, now, monotonic_now) + review = render_review(st, width, now, monotonic_now) if st.show_review else None + if keys["refresh"]: # the hint sits under the last PR section + (review if review is not None else mine).append(line((f"{keys['refresh']} refresh", META))) sections = slot["top"] + ([render_usage(st, width, now, monotonic_now)] if st.show_usage else []) - sections += slot["after-usage"] + [render_mine(st, width, now, monotonic_now)] + slot["after-my-prs"] - sections += ([render_review(st, width, now)] if st.show_review else []) + slot["bottom"] + sections += slot["after-usage"] + [mine] + slot["after-my-prs"] + sections += ([review] if review is not None else []) + slot["bottom"] rows = list(sections[0]) for rows_of in sections[1:]: rows += [Text(), rule(width), *rows_of] - keys = st.keys - hints = [f"{keys[name]} {name}" for name in ("refresh", "quit") if keys[name]] - focusable = focusable_integrations(st) - if len(focusable) >= 2 and keys["focus"]: + hints = [f"{keys['quit']} quit"] if keys["quit"] else [] + if len(focusable_integrations(st)) >= 2 and keys["focus"]: hints.append(f"{keys['focus']} focus") - if st.focus is not None: - hints.extend(f"{key} {action}" for key, action in st.focus.keys.items()) foot = " · ".join(hints) height = options.height or console.height if len(rows) > height - 2: @@ -1414,7 +1421,7 @@ def fetch(section, function, *args): if error is not None: raise error if section == "usage": - st.usage, st.usage_at, st.usage_err = carry_over(value, st.usage), time.time(), None + st.usage, st.usage_at, st.usage_err = carry_over(st.usage, value), time.time(), None if invalidate: st.usage_inval_at = now save_last_good(st.usage, st.cache_profile) diff --git a/tests/test_devdash.py b/tests/test_devdash.py index 73b6b48..465374b 100644 --- a/tests/test_devdash.py +++ b/tests/test_devdash.py @@ -149,6 +149,19 @@ def fake_run(cmd, timeout=45, env=None): ] +@pytest.mark.parametrize("cached", [None, {"reports": [{"provider": "cursor", "old": True}]}]) +def test_refresh_replaces_cached_usage_with_the_fresh_report(cached, monkeypatch): + fresh = {"reports": [{"provider": "cursor"}], "accountsWithoutUsage": []} + monkeypatch.setattr(devdash, "fetch_usage", lambda invalidate, profile: fresh) + monkeypatch.setattr(devdash, "fetch_prs", lambda *args: ([], [])) + monkeypatch.setattr(devdash, "save_last_good", lambda data, profile: None) + st = devdash.State() + st.usage = cached + devdash.refresh(st, None, False) + assert st.usage_err is None + assert st.usage["reports"] == [{"provider": "cursor"}] + + def test_openrouter_reads_selected_profile_key_usage_without_storing_credential(monkeypatch): from io import BytesIO from subprocess import CompletedProcess @@ -671,29 +684,27 @@ def test_focus_cycles_in_screen_order_and_dispatches_only_to_focused_binding(): assert st.focus is top -def test_dashboard_marks_focus_and_lists_focused_bindings_only_in_footer(): - top = integration("pass", name="top", position="top", - keys={"j": "newer", "k": "older", "s": "scope", - "J": "scroll-down", "K": "scroll-up"}) +def test_each_section_lists_its_own_keys_and_footer_holds_global_keys(): + top = integration("pass", name="top", position="top", keys={"j": "newer", "k": "older"}) bottom = integration("pass", name="bottom", position="bottom", keys={"x": "other"}) st = devdash.State() - st.show_usage = st.show_review = False - st.keys = {"refresh": "R", "quit": "", "focus": "tab"} + st.show_usage = False + st.keys = {"refresh": "R", "quit": "q", "focus": "tab"} st.integrations = [bottom, top] st.focus = top console = Console(record=True, width=120) console.print(devdash.Dashboard(st), height=10_000) - text = console.export_text() - assert "▸ TOP" in text and "BOTTOM" in text - assert text.splitlines()[-1] == ( - "R refresh · tab focus · j newer · k older · s scope · J scroll-down · K scroll-up") - assert "x other" not in text - assert text.count("j newer") == 1 - assert "q quit" not in text - assert "every " not in text and ":" not in text.splitlines()[-1] + lines = [row.rstrip() for row in console.export_text().splitlines()] + assert lines[0] == "▸ TOP" + assert lines[1] == "j newer · k older" + review = next(i for i, row in enumerate(lines) if row.startswith("REVIEW REQUESTED")) + assert "R refresh" in lines[review:lines.index("BOTTOM")] + assert lines.count("R refresh") == 1 + assert lines[lines.index("BOTTOM") + 1] == "x other" + assert lines[-1] == "q quit · tab focus" -def test_footer_omits_focus_key_with_fewer_than_two_focusable_integrations(): +def test_refresh_hint_follows_my_prs_when_review_is_hidden_and_focus_needs_two(): ig = integration("pass", keys={"j": "newer"}) st = devdash.State() st.show_usage = st.show_review = False @@ -701,21 +712,21 @@ def test_footer_omits_focus_key_with_fewer_than_two_focusable_integrations(): st.focus = ig console = Console(record=True, width=80) console.print(devdash.Dashboard(st), height=10_000) - footer = console.export_text().splitlines()[-1] - assert footer == "r refresh · q quit · j newer" - assert "tab focus" not in footer + lines = [row.rstrip() for row in console.export_text().splitlines()] + assert lines.index("r refresh") < lines.index("▸ T") + assert lines[-1] == "q quit" -def test_footer_truncates_with_ellipsis(): - ig = integration("pass", keys={"j": "long_action_name"}) +def test_key_hint_rows_truncate_with_ellipsis(): + ig = integration("pass", keys={"j": "long_action_name", "k": "another_long_name"}) st = devdash.State() st.show_usage = st.show_review = False st.integrations = [ig] - st.focus = ig console = Console(record=True, width=24) console.print(devdash.Dashboard(st), height=10_000) - footer = console.export_text().splitlines()[-1] - assert len(footer) <= 24 and "…" in footer + hint = console.export_text().splitlines()[-3] + assert hint.startswith("j long_action_name") and hint.rstrip().endswith("…") + assert len(hint) <= 24 def test_section_headings_show_fetch_state_and_countdown_but_once_has_none(): @@ -734,6 +745,7 @@ def test_section_headings_show_fetch_state_and_countdown_but_once_has_none(): assert "fetched 12s ago · next fetch 48s" in usage st.prs_fetching = st.usage_fetching = True assert "fetching…" in devdash.render_mine(st, 80, now, mono)[0].plain + assert "fetching…" in devdash.render_review(st, 80, now, mono)[0].plain assert "fetching…" in devdash.render_usage(st, 80, now, mono)[0].plain ig = integration("pass") @@ -747,6 +759,7 @@ def test_section_headings_show_fetch_state_and_countdown_but_once_has_none(): st.prs_fetching = st.usage_fetching = False st.global_next_fetch = None assert "next fetch" not in devdash.render_mine(st, 80, now, mono)[0].plain + assert devdash.render_review(st, 80, now, mono)[0].plain == "REVIEW REQUESTED 0 fetched 12s ago" assert "next fetch" not in devdash.render_usage(st, 80, now, mono)[0].plain ig.running = False ig.next_run_at = None From c85c7ab025623852f868f7049161b938fb32120e Mon Sep 17 00:00:00 2001 From: Shayan Date: Fri, 9 Oct 2026 12:02:24 -0400 Subject: [PATCH 4/4] Start a pending refresh as soon as the running one ends; drop whole escapes - r during a running refresh polls the refresh thread, so the follow-up starts when it finishes instead of at the next interval. - ESC sequences with intermediate bytes (ESC ( B, ESC # 8) are dropped whole instead of leaking their final byte as a key. - USAGE counts down to the global refresh, which is when it is fetched. --- README.md | 4 ++-- devdash.py | 37 ++++++++++++++----------------------- tests/test_devdash.py | 6 ++++++ 3 files changed, 22 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 399e3bf..1a9ea72 100644 --- a/README.md +++ b/README.md @@ -207,8 +207,8 @@ the keys for the whole dashboard: quit, and the focus key when two or more integ focusable. The focused section's heading starts with `▸ `. Section headings show how long ago the last result was fetched and count down to the next fetch, -updating every second. Integrations use their own intervals; MY PRS uses the global interval, and -USAGE follows its effective usage-refetch cadence. While a section is fetching, its heading says +updating every second. Integrations use their own intervals; MY PRS, REVIEW REQUESTED, and USAGE +use the global interval. While a section is fetching, its heading says `fetching…`. `--once` shows no countdown. Global PR and usage refreshes run in the background, so input remains responsive. Pressing `r` diff --git a/devdash.py b/devdash.py index 2844923..c61dfc5 100755 --- a/devdash.py +++ b/devdash.py @@ -533,27 +533,11 @@ def section_status(fetched_at, fetching, next_fetch_at, now, monotonic_now): return (" " + " · ".join(parts), META) if parts else "" -def usage_next_fetch(st, now, monotonic_now): - deadline = st.global_next_fetch - if deadline is None or st.usage_every is None: - return deadline - since_invalidation = now - st.usage_inval_at - age = oldest_read(st.usage) - delay = max(0, 60 - since_invalidation, - st.usage_every - age if age is not None else 0) - eligible = monotonic_now + delay - if eligible <= deadline: - return deadline - ticks = math.ceil((eligible - deadline) / st.interval) - return deadline + ticks * st.interval - - def render_usage(st, width, now, monotonic_now=None): if monotonic_now is None: monotonic_now = time.monotonic() data = st.usage - status = section_status(st.usage_at, st.usage_fetching, - usage_next_fetch(st, now, monotonic_now), now, monotonic_now) + status = section_status(st.usage_at, st.usage_fetching, st.global_next_fetch, now, monotonic_now) out = [line(("USAGE", H1), status)] if st.usage_err: out.append(line((f"⚠ {st.usage_err}", BAD))) @@ -1252,8 +1236,16 @@ def decode_keys(data, pending=b"", flush=False): continue if intro == 0x1B: index += 1 - else: - index += 2 # discard unsupported two-byte escapes, including Alt-key prefixes + continue + # discard any other escape whole: ESC, intermediates 0x20-0x2F, then one final byte + final = index + 1 + while final < len(buf) and 0x20 <= buf[final] <= 0x2F: + final += 1 + if final == len(buf): + if not flush: + return keys, buf[index:] + break + index = final + 1 continue if byte in (0x0A, 0x0D): keys.append("enter") @@ -1341,7 +1333,6 @@ class State: global_next_fetch = None prs_fetching = False usage_fetching = False - usage_every = None class Dashboard: @@ -1497,7 +1488,6 @@ def main(): usage_every = args.usage_every if args.usage_every is not None else usage["refetch_after"] if args.cached or usage_every == 0: usage_every = None - st.usage_every = usage_every hidden = set(args.no_integration) unknown = hidden - {s["name"] for s in cfg["integrations"]} if unknown: @@ -1585,8 +1575,9 @@ def watch(st, usage_every): refresh_thread.start() live.refresh() timeout = max(0, deadline - time.monotonic()) - if refresh_thread.is_alive() and timeout == 0: - timeout = 0.2 + if refresh_thread.is_alive() and (force or timeout == 0): + # poll so a pending follow-up starts as soon as this refresh ends + timeout = 0.2 if timeout == 0 else min(timeout, 0.2) if pending_since is not None: timeout = min(timeout, max(0, pending_since + KEY_ESCAPE_TIMEOUT - time.monotonic())) diff --git a/tests/test_devdash.py b/tests/test_devdash.py index 465374b..c05a90d 100644 --- a/tests/test_devdash.py +++ b/tests/test_devdash.py @@ -116,6 +116,12 @@ def test_decode_keys_keeps_partial_sequences_until_the_next_read(): assert decoded == ["up"] and pending == b"" +def test_decode_keys_drops_escapes_with_intermediate_bytes_whole(): + assert devdash.decode_keys(b"\x1b(Bq\x1b#8j\x1bxk") == (["q", "j", "k"], b"") + decoded, pending = devdash.decode_keys(b"\x1b(") + assert decoded == [] and pending == b"\x1b(" + assert devdash.decode_keys(b"Bj", pending) == (["j"], b"") + def test_missing_config_is_an_error_only_when_named(tmp_path): assert devdash.load_config(str(tmp_path / "none.toml"), explicit=False) == devdash.DEFAULTS