From 5216b5e49fea60d81f8bb61f62ce220cb5f775b0 Mon Sep 17 00:00:00 2001 From: Shayan Date: Fri, 9 Oct 2026 13:14:43 -0400 Subject: [PATCH] Add watch paths so integrations refresh on file changes --- CHANGELOG.md | 2 + README.md | 20 ++++++++-- config.example.toml | 9 +++++ devdash.py | 81 ++++++++++++++++++++++++++++++++++---- tests/test_devdash.py | 91 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 192 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f8f6d5..7befa3b 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. +- Integrations can watch files and directories for changes; watched integrations refresh on changes + both with and without a scheduled interval. - Configurable, case-sensitive built-in keys and per-integration actions, with private state files in watch mode, per-section key hints, and fetch countdowns on section headings. - `--profile NAME` selects the matching isolated omp profile for usage and cached readings; diff --git a/README.md b/README.md index 1a9ea72..896d45f 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,18 @@ enabled = true # false keeps the table but hides the secti env = { WTTR_LANG = "en" } # extra environment variables for the command ``` +`watch` is an optional list of file or directory paths. Paths expand `~` and environment variables; +directories are watched recursively, and missing paths are watched for creation. Keep watch roots +small data directories: devdash takes a full stat snapshot on each polling pass. A watched +integration runs at startup and after watched files change, with a short debounce. With `watch` +set and no positive `interval`, it does not poll on the global interval; actions, `r`, and file +changes still run it. Set a positive `interval` to keep scheduled runs alongside watching. An +empty list disables watching. + +```toml +watch = ["~/.local/share/prompt-tutor", "~/.config/prompt-tutor"] +``` + Integrations that share a position appear in the order they are listed in the file. ### Keys, actions, focus, and state @@ -208,8 +220,9 @@ 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, REVIEW REQUESTED, and USAGE -use the global interval. While a section is fetching, its heading says -`fetching…`. `--once` shows no countdown. +use the global interval. A watched integration without an interval shows `watching` instead of a +countdown. While a section is fetching, its heading says `fetching…`. `--once` shows no countdown +and does not watch files. 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 @@ -287,12 +300,13 @@ The key actions and state-file behavior below require a prompt-tutor build with command = ["prompt-tutor", "--once"] position = "bottom" max_rows = 16 + watch = ["~/.local/share/prompt-tutor", "~/.config/prompt-tutor"] 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 `j`/`k` for newer/older Prompts, +4. Run `devdash`. The section updates when prompt-tutor data changes; 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. diff --git a/config.example.toml b/config.example.toml index 6e7655c..4041454 100644 --- a/config.example.toml +++ b/config.example.toml @@ -59,3 +59,12 @@ order = [] # e.g. ["openai-codex", "anthropic"] # 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" } +# Prompt-tutor updates immediately when its data files change; omit interval to disable polling. +# [[integrations]] +# name = "prompt-tutor" +# title = "PROMPT TUTOR" +# command = ["prompt-tutor", "--once"] +# position = "bottom" +# max_rows = 16 +# watch = ["~/.local/share/prompt-tutor", "~/.config/prompt-tutor"] +# keys = { j = "newer", k = "older", s = "scope", J = "scroll-down", K = "scroll-up" } diff --git a/devdash.py b/devdash.py index c61dfc5..74341e3 100755 --- a/devdash.py +++ b/devdash.py @@ -521,13 +521,15 @@ def usage_rows(report): return rows -def section_status(fetched_at, fetching, next_fetch_at, now, monotonic_now): +def section_status(fetched_at, fetching, next_fetch_at, now, monotonic_now, watching=False): 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: + if watching: + parts.append("watching") + elif 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 "" @@ -911,7 +913,7 @@ def render_review(st, width, now, monotonic_now=None): # 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": {}, "keys": {}} + "interval": 0, "timeout": 10, "max_rows": 20, "env": {}, "keys": {}, "watch": []} 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 @@ -946,6 +948,9 @@ def check_integrations(items, builtins=None): raise SystemExit(f"{where} position must be one of {', '.join(POSITIONS)}") if not 0 <= spec["interval"] <= MAX_SECONDS: raise SystemExit(f"{where} interval must be 0 (the global interval) to {MAX_SECONDS}") + if not isinstance(spec["watch"], list) or not all(isinstance(path, str) for path in spec["watch"]): + raise SystemExit(f"{where} watch must be a list of paths (strings)") + spec["watch"] = [os.path.expandvars(os.path.expanduser(path)) for path in spec["watch"]] if not 1 <= spec["timeout"] <= MAX_SECONDS: raise SystemExit(f"{where} timeout must be 1 to {MAX_SECONDS}") if spec["max_rows"] < 1: @@ -1042,7 +1047,13 @@ def __init__(self, spec, interval): self.title = spec["title"] or spec["name"].upper() self.command = [os.path.expanduser(spec["command"][0]), *spec["command"][1:]] self.position = spec["position"] - self.interval = min(spec["interval"] or interval, MAX_SECONDS) # the global interval is unbounded + self.watch_paths = tuple(spec["watch"]) + self.watching = bool(self.watch_paths) + # Watching replaces interval polling unless the integration sets its own interval. + if spec["interval"] or not self.watching: + self.interval = min(spec["interval"] or interval, MAX_SECONDS) # the global interval is unbounded + else: + self.interval = 0 self.timeout = spec["timeout"] self.max_rows = spec["max_rows"] self.env = spec["env"] @@ -1061,6 +1072,58 @@ def __init__(self, spec, interval): self.actions = deque() self.refresh_requested = True + def watch_signature(self): + """Stat every path under the watch roots. Roots are followed; symlinks inside them are not.""" + signature = [] + for root in self.watch_paths: + try: + info = os.stat(root) + except OSError: + continue # a missing root is watched for its creation + signature.append((root, info.st_mtime_ns, info.st_size)) + if not os.path.isdir(root): + continue + pending = [root] + while pending: + directory = pending.pop() + try: + with os.scandir(directory) as entries: + for entry in entries: + try: + item = entry.stat(follow_symlinks=False) + except OSError: + continue + signature.append((entry.path, item.st_mtime_ns, item.st_size)) + if entry.is_dir(follow_symlinks=False): + pending.append(entry.path) + except OSError: + continue + return tuple(sorted(signature)) + + def watch_loop(self): + """Run again after watched paths change: once writes go quiet for 0.2 s, or 2 s into a + continuous burst. Passes wait at least four times their own cost, so a big tree cannot hog a core.""" + started = time.monotonic() + previous = self.watch_signature() + cost = time.monotonic() - started + changed_at = first_change = None + while True: + time.sleep(max(0.25, 4 * cost)) + with self.lock: + if self.stopped: + return + started = time.monotonic() + current = self.watch_signature() + now = time.monotonic() + cost = now - started + if current != previous: + previous = current + changed_at = now + first_change = first_change or now + if changed_at is not None and (now - changed_at >= 0.2 or now - first_change >= 2.0): + self.request_refresh(after_current=True) + changed_at = first_change = None + 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)} @@ -1124,6 +1187,8 @@ def request_refresh(self, after_current=False): self.wake.set() def loop(self, st): + if self.watching: + threading.Thread(target=self.watch_loop, daemon=True, name=f"integration-watch-{self.name}").start() next_run = time.monotonic() while True: self.wake.clear() @@ -1134,7 +1199,7 @@ def loop(self, st): 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 + run_now = action is not None or refresh_requested or (self.interval > 0 and now >= next_run) if run_now: self.running = True if run_now: @@ -1144,11 +1209,11 @@ def loop(self, st): next_run = time.monotonic() + self.interval with self.lock: self.running = False - self.next_run_at = next_run + self.next_run_at = next_run if self.interval > 0 else None if st.live is not None: st.live.refresh() continue - self.wake.wait(max(0, next_run - time.monotonic())) + self.wake.wait(max(0, next_run - time.monotonic()) if self.interval > 0 else None) def stop(self): """Stop polling and kill a running command with everything it started. @@ -1296,7 +1361,7 @@ def render_integration(ig, now, focused=False, monotonic_now=None): if monotonic_now is None: monotonic_now = time.monotonic() rows, at, err = ig.result - status = section_status(at, ig.running, ig.next_run_at, now, monotonic_now) + status = section_status(at, ig.running, ig.next_run_at, now, monotonic_now, ig.watching and ig.interval == 0) out = [line(("▸ " if focused else "", H1), (ig.title, H1), status)] if err: out.append(line((f"⚠ {err}", BAD))) diff --git a/tests/test_devdash.py b/tests/test_devdash.py index c05a90d..c3c7e95 100644 --- a/tests/test_devdash.py +++ b/tests/test_devdash.py @@ -42,6 +42,13 @@ def test_config_merges_over_defaults(tmp_path): assert cfg["interval"] == 60 +def test_integration_watch_defaults_off_and_expands_paths(tmp_path, monkeypatch): + monkeypatch.setenv("WATCH_ROOT", str(tmp_path)) + cfg = load(tmp_path, '[[integrations]]\nname = "t"\ncommand = ["date"]\nwatch = ["$WATCH_ROOT", "~/watch"]\n') + assert cfg["integrations"][0]["watch"] == [str(tmp_path), os.path.expanduser("~/watch")] + assert devdash.INTEGRATION["watch"] == [] + + @pytest.mark.parametrize("text", [ "intervl = 5\n", # typo in a top-level key "[github]\nexclude = \"acme\"\n", # string where a list belongs @@ -60,6 +67,8 @@ def test_config_merges_over_defaults(tmp_path): "[[integrations]]\nname = \"a\"\ncommand = [\"date\"]\nenv = { N = 1 }\n", "[[integrations]]\nname = \"a\"\ncommand = [\"date\"]\nintervl = 5\n", "[[integrations]]\nname = \"a\"\ncommand = [\"date\"]\n[[integrations]]\nname = \"a\"\ncommand = [\"date\"]\n", + "[[integrations]]\nname = \"a\"\ncommand = [\"date\"]\nwatch = \"path\"\n", # one path, not a list + "[[integrations]]\nname = \"a\"\ncommand = [\"date\"]\nwatch = [1]\n", ]) def test_config_rejects_mistakes(tmp_path, text): with pytest.raises(SystemExit): @@ -630,6 +639,88 @@ def test_watch_state_files_are_empty_private_and_removed(tmp_path): assert not os.path.exists(path) +def test_watch_signature_sees_nested_changes_deletes_and_late_roots(tmp_path): + watched = tmp_path / "watched" + ig = integration("pass", watch=[str(watched)]) + missing = ig.watch_signature() + (watched / "nested").mkdir(parents=True) + item = watched / "nested" / "item" + item.write_text("one") + created = ig.watch_signature() + assert created != missing + assert ig.watch_signature() == created + item.write_text("a longer value") + modified = ig.watch_signature() + assert modified != created + item.unlink() + assert ig.watch_signature() != modified + + +def test_watch_signature_follows_a_symlinked_root(tmp_path): + target = tmp_path / "target" + target.mkdir() + (tmp_path / "linked").symlink_to(target, target_is_directory=True) + ig = integration("pass", watch=[str(tmp_path / "linked")]) + before = ig.watch_signature() + (target / "item").write_text("new") + assert ig.watch_signature() != before + + +@pytest.mark.parametrize("interval", [0, 60]) +def test_watched_integration_runs_on_change_and_only_polls_with_its_own_interval(tmp_path, interval): + watched = tmp_path / "watched" + watched.mkdir() + log = tmp_path / "runs" + ig = integration("import os; open(os.environ['LOG'], 'a').write('run\\n')", + watch=[str(watched)], interval=interval, env={"LOG": str(log)}) + assert ig.interval == interval # no interval of its own: watching replaces the global interval + st = devdash.State() + st.width = 40 + thread = devdash.threading.Thread(target=ig.loop, args=(st,), daemon=True) + thread.start() + + def wait_for_runs(count): + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if log.exists() and len(log.read_text().splitlines()) >= count: + return + time.sleep(0.01) + pytest.fail(f"only {len(log.read_text().splitlines()) if log.exists() else 0} runs completed") + + try: + wait_for_runs(1) + (watched / "new-file").write_text("new") + wait_for_runs(2) + time.sleep(0.3) + assert log.read_text().splitlines() == ["run", "run"] + status = devdash.render_integration(ig, time.time(), monotonic_now=time.monotonic())[0].plain + assert ("watching" in status) == (interval == 0) + assert ("next fetch" in status) == (interval > 0) + finally: + ig.stop() + thread.join(1) + assert not thread.is_alive() + + +def test_watch_refreshes_during_writes_that_never_go_quiet(tmp_path): + log = tmp_path / "busy.log" + ig = integration("pass", watch=[str(tmp_path)]) + refreshed = [] + ig.request_refresh = lambda after_current=False: refreshed.append(time.monotonic()) + thread = devdash.threading.Thread(target=ig.watch_loop, daemon=True) + thread.start() + try: + deadline = time.monotonic() + 4 # appends every 0.05 s, faster than one 0.25 s scan + while time.monotonic() < deadline and not refreshed: + with open(log, "a") as f: + f.write("x") + time.sleep(0.05) + assert refreshed, "no refresh while writes kept coming" + finally: + ig.stop() + thread.join(1) + + def test_integration_action_queue_drops_presses_after_eight(): ig = integration("pass", keys={"j": "newer"}) actions = ["newer", "older"] * 4