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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand Down
9 changes: 9 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
81 changes: 73 additions & 8 deletions devdash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"]
Expand All @@ -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)}
Expand Down Expand Up @@ -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()
Expand All @@ -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:
Expand All @@ -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.
Expand Down Expand Up @@ -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)))
Expand Down
91 changes: 91 additions & 0 deletions tests/test_devdash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down
Loading