From e34496249769ef6b7cec5872fd872c18717c99c5 Mon Sep 17 00:00:00 2001 From: Martin Contreras Date: Tue, 20 Oct 2026 15:25:29 -0300 Subject: [PATCH 1/3] task-monitor 4/6: ratatui front end (repo view + all-repos overview, real-time): the task-monitor binary in monitor/ A Rust + ratatui front end over `task-monitor-snapshot --json` (#276, epic #272). It renders the repo view (in flight / next / done) and the all-repos overview with each repo's attention light, its signals in words and its zellij session. - Real time: filesystem events under `$TASK_FORCE_HOME/radio` (not its log), `$TASK_FORCE_HOME/monitor` and each repo's worktree base (`..info` only) trigger a debounced `--local` snapshot; a full snapshot runs every 45s (`--poll`). Both run off the UI thread with timeouts, and an older run never replaces a newer one. - Never blank: a failed or slow snapshot keeps the last good one up, marked stale, with the collector's reason in the footer. - Keys: arrows, enter (expand / enter repo), o, t, a, q. `t` stays in the monitor's zellij session and checks that the tab at the recorded id still bears the item's name (the #260 rule) before going to it. - No task-force logic in Rust: the snapshot gains `worktree_base` so the watcher does not re-derive task-work's path rule. Schema doc, example and a bats test cover it. 45 Rust tests (fixture rendering, keys, scheduler timing, watcher filter, collector failure/timeout, tab check). README section, workflow doc + steering templates, CHANGELOG. install.sh and CI stay with #277. Co-Authored-By: Claude Opus 5.5 --- .claude/gh-workflow.md | 18 + CHANGELOG.md | 9 + README.md | 22 + bin/task-monitor-snapshot | 3 +- claude-gh/steering/gh-workflow.example.md | 18 + claude-jira/steering/jira-workflow.example.md | 18 + .../steering/local-workflow.example.md | 18 + .../steering/notion-workflow.example.md | 18 + docs/task-monitor-schema.example.json | 1 + docs/task-monitor-schema.md | 3 +- kiro-gh/steering/gh-workflow.example.md | 18 + kiro-local/steering/local-workflow.example.md | 18 + .../steering/notion-workflow.example.md | 18 + lib/monitor-assemble.jq | 4 + monitor/.gitignore | 1 + monitor/Cargo.lock | 2023 +++++++++++++++++ monitor/Cargo.toml | 23 + monitor/src/actions.rs | 236 ++ monitor/src/app.rs | 570 +++++ monitor/src/main.rs | 248 ++ monitor/src/refresh.rs | 566 +++++ monitor/src/snapshot.rs | 237 ++ monitor/src/ui.rs | 956 ++++++++ monitor/tests/fixtures/multi.json | 145 ++ tests/task_monitor_snapshot.bats | 10 + 25 files changed, 5199 insertions(+), 2 deletions(-) create mode 100644 monitor/.gitignore create mode 100644 monitor/Cargo.lock create mode 100644 monitor/Cargo.toml create mode 100644 monitor/src/actions.rs create mode 100644 monitor/src/app.rs create mode 100644 monitor/src/main.rs create mode 100644 monitor/src/refresh.rs create mode 100644 monitor/src/snapshot.rs create mode 100644 monitor/src/ui.rs create mode 100644 monitor/tests/fixtures/multi.json diff --git a/.claude/gh-workflow.md b/.claude/gh-workflow.md index 7a129db..2340c74 100644 --- a/.claude/gh-workflow.md +++ b/.claude/gh-workflow.md @@ -293,6 +293,24 @@ task-monitor-snapshot --all # every repo radio knows about task-monitor-snapshot --local # no forge calls: disk + the last full run's cache ``` +`task-monitor` — the board, live, in the terminal (#276) + +The ratatui front end over that JSON. It opens on the repo view (In flight / +Next / Done) inside a repo, and on the all-repos overview (`a`, or `--all`) +outside one: one row per repo with its attention light, the signal in words, +and the zellij session it runs in. A change under radio's state or a worktree +sidecar refreshes it from disk at once (`--local`); forge state refreshes every +45s (`--poll SECS`). A failed or slow refresh never blanks the screen: the last +good snapshot stays up, marked `stale`. Keys: `↑↓` move, `⏎` expand (a done +item's full PR body, scrollable) or enter a repo, `o` open the PR or issue, `t` +jump to the item's tab (the worker's, or the PM's for a queued or done item), +`a` toggle the overview, `q` quit. `t` stays inside the monitor's own zellij +session, and goes to a tab only while the tab at the recorded id still bears the +item's name, the check `task-done` makes before closing one (#260). It holds no +task-force logic: every value is a snapshot field. Build it from the task-force +checkout with `cargo build --release --manifest-path monitor/Cargo.toml` +(`install.sh` does not build it yet; #277). + ### PM ↔ worker messaging (radio) Radio is the **canonical** coordination channel between the PM and workers — every diff --git a/CHANGELOG.md b/CHANGELOG.md index 37ad633..d3ce05f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -197,6 +197,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- **`task-monitor`: the board, live in the terminal (#276, epic #272 4/6).** A Rust + ratatui front end in `monitor/` over `task-monitor-snapshot --json`, with the two screens the epic drew. The **repo view** shows In flight (number, title, status word with its PR, the plain description, the `▶` / `⏸` / `❓` activity icon with the time since the session was last heard from, and how many reviewers are on it), Next (the PM queue or the tracker backlog, with notes) and Done (PR, short merge SHA, age, and the two-line summary). The **overview** (`a`, or `--all`, and the default outside a repo) has one row per repo: the attention light, the signals in words, the in-flight count, and the zellij session it runs in. Keys: `↑↓` move, `⏎` expands an item in place (a done item to its full PR body, scrollable) or enters a repo, `o` opens the PR or issue, `t` jumps to the item's tab, `a` toggles the overview, `q` quits. + - **Real time, and never waiting on GitHub.** Filesystem events under `$TASK_FORCE_HOME/radio` (except its `log`), `$TASK_FORCE_HOME/monitor` and each repo's worktree base (`..info` sidecars only) trigger a `--local` snapshot, debounced on the trailing edge at 400ms and capped at 2s. One local run at a time; an event during a run reruns it afterwards. A full run, the only one that calls `gh`, runs at start and then 45s after the previous one finished (`--poll SECS`, 30-600). Both run off the UI thread, with timeouts (20s / 180s) that kill a hung collector. A result never replaces one from a run that started later. + - **Never blank.** A failed or timed-out snapshot keeps the last good one on screen; the header turns to `stale ` and the footer gives the collector's own last stderr line. `forge ` marks a repo whose PR state is from the last full run. + - **`t` stays in the monitor's zellij session and verifies before it moves.** It uses the worker sidecar's `TAB_ID` (else the session's), or the PM session's for a queued or done item and an overview row. A target in another zellij session is named, not attempted. Otherwise it lists tabs and runs `zellij action go-to-tab-by-id` only when the tab at that id still bears the item's name, by the rule `aw_tab_name_is_slug` applies in `task-done` (#260). A stale id now held by another worker's tab is refused with that tab's name. + - **No task-force logic in Rust.** Every value is a snapshot field, and every field is optional, so a v1 snapshot that grows fields or drops one still renders; a different `schema` is refused, not guessed at. To keep it that way the snapshot gains one field, `worktree_base` (where task-work puts the repo's sidecars), so the watcher does not re-derive task-work's path rule. `docs/task-monitor-schema.md` and its example list it, and a new bats test pins it to the directory holding the sidecars. + - 45 Rust tests: rendering both screens, the expanded view and the stale states from fixture snapshots (including the documented example) on a `TestBackend`; every key on every screen; the debounce and poll timing; the watcher's path filter, including a `TASK_FORCE_HOME` behind a symlink (FSEvents reports the real path); the collector's flags, failure text, timeout and a snapshot larger than a pipe buffer; and the tab check against a fake zellij. Breaking the tab-name rule, the session check, the run ordering, the local-run coalescing, the log filter, the narrow-terminal fit, the error reset and the timeout kill each turned a test red. + + Build it with `cargo build --release --manifest-path monitor/Cargo.toml` and run `monitor/target/release/task-monitor`, which finds the checkout's `bin/task-monitor-snapshot` (`$TASK_MONITOR_SNAPSHOT` overrides). `install.sh` does not build or link it yet, and CI does not run its tests yet; both are #277. Docs: a README section, and a `task-monitor` paragraph in `.claude/gh-workflow.md` and all seven steering templates. **Upgrading: re-run `task-init `** only to refresh the workflow doc; nothing else installer-written changed. + - **`task-monitor-snapshot`: the board as one JSON document — in flight, next, done, and whether a repo needs you (#273, epic #272 1/6).** A new canonical root binary, the data layer the task-monitor front end will render. It prints `"schema": 1` for the current repo, or with `--all` for every repo a radio session file names, and every value in it is read deterministically from radio's state, task-work's `..info` sidecars, the tracker and the forge. No model is asked to write or summarise anything. The schema is documented in `docs/task-monitor-schema.md`, with an example taken from this repo. - **In flight** is every worktree sidecar, joined to its radio session (`STATE=`, heartbeat), its PR (found by the `task/` head branch on every tracker) and a status word derived from the latest review intent about that PR. Reviewers attach to their PR's item rather than being items. - **Titles and descriptions** come from three new tracker hooks with `_default.sh` bodies: `aw_tracker_item_describe` (gh: the issue's title and first paragraph; local: the task file; jira / notion: `ref · slug`, since the shell cannot read them), `aw_tracker_pr_item` (gh: `Closes #N`, else the head branch) and `aw_tracker_next_fallback` (gh: open issues on the configured project in none of the three configured statuses, leaving out drafts, archived items and issues with sub-issues, ordered by the project's `Priority` field when there is one; local: the `tasks/` backlog; none on jira / notion). The gh fallback is one paginated GraphQL query, because `gh project item-list` carries neither sub-issue counts nor issue state. Once a PR exists, its title and `## Summary` take over, falling back to the first paragraph after `Closes`. `tests/loadout_modules.bats` checks that all three resolve on every loadout. diff --git a/README.md b/README.md index 3847325..0fc3047 100644 --- a/README.md +++ b/README.md @@ -416,6 +416,28 @@ task-monitor-snapshot --local # disk only: no forge calls, merged with `--local` exists so a front end can refresh on every filesystem event without waiting on GitHub: it shadows `gh` with a function that fails, so every forge read falls back to the cache under `$TASK_FORCE_HOME/monitor//`. The schema (`"schema": 1`) is documented in [`docs/task-monitor-schema.md`](docs/task-monitor-schema.md), with a full example beside it. +## `task-monitor` — the board, live + +`task-monitor` is the terminal front end over that JSON (Rust + ratatui, in [`monitor/`](monitor/)). Inside a repo it opens on that repo's view; outside one, or with `--all`, on the overview of every repo radio knows about. + +``` + task-force · PM claude · ✉ 1 unread 12:04 · live +───────────────────────────────────────────────────────────────────────────── + IN FLIGHT (2) + ▸ #267 kiro upgraded agent-config breaks task-init/remove PR #270 re-review + Teach the hook merge and strip the new array hook shape ▶ 14m + … + ↑↓ move ⏎ expand o open PR/issue t jump to tab a all repos q quit +``` + +- **Real time without waiting on GitHub.** A change under `$TASK_FORCE_HOME/radio` (sessions, mailboxes), `$TASK_FORCE_HOME/monitor` (the PM queue, the forge cache) or a repo's worktree base (a `..info` sidecar appearing or going) triggers a `--local` snapshot, debounced. Forge state comes from a full snapshot every 45 seconds (`--poll SECS`, 30-600). +- **Never blank.** A snapshot that fails or times out leaves the last good one on screen; the header says `stale ` and the footer says why. `forge ` means disk state is current but PR state is from the last full run. +- **Keys.** `↑↓` move, `⏎` expand a done item to its full PR body (scrollable) or enter a repo from the overview, `o` open the PR or issue, `t` jump to the item's zellij tab (the worker's; the PM's for a queued or done item, or for an overview row), `a` toggle the overview, `q` quit. +- **`t` stays in this zellij session.** Each overview row names its repo's zellij session; `zellij action` cannot switch sessions, so a tab elsewhere is named, not attempted. Within the session, `t` goes to the recorded `TAB_ID` only while that tab still bears the item's name, the check `task-done` makes before it closes a tab (#260). +- **No task-force logic in Rust.** Every word comes from the snapshot; status words are a lookup of its `status` field. + +Build it from the task-force checkout with `cargo build --release --manifest-path monitor/Cargo.toml` and run `monitor/target/release/task-monitor`; it finds `bin/task-monitor-snapshot` beside it, and `$TASK_MONITOR_SNAPSHOT` overrides that. `install.sh` does not build or link it yet (#277). + --- ## Per-combo setup diff --git a/bin/task-monitor-snapshot b/bin/task-monitor-snapshot index a271808..03ef08d 100755 --- a/bin/task-monitor-snapshot +++ b/bin/task-monitor-snapshot @@ -455,7 +455,8 @@ _snapshot_repo() { # --- assemble. jq -n \ - --arg name "$name" --arg root "$root" --arg impl "$impl" --arg tracker "$tracker" \ + --arg name "$name" --arg root "$root" --arg worktree_base "$wtbase" \ + --arg impl "$impl" --arg tracker "$tracker" \ --arg agent "$agent" --arg now "$NOW" --arg fetched_at "$fetched_at" \ --arg next_source "$next_source" --argjson next_limit "$NEXT_LIMIT" \ --argjson forge_ok "$forge_ok" --argjson names "$names_json" \ diff --git a/claude-gh/steering/gh-workflow.example.md b/claude-gh/steering/gh-workflow.example.md index 82d7d72..22f4f81 100644 --- a/claude-gh/steering/gh-workflow.example.md +++ b/claude-gh/steering/gh-workflow.example.md @@ -289,6 +289,24 @@ task-monitor-snapshot --all # every repo radio knows about task-monitor-snapshot --local # no forge calls: disk + the last full run's cache ``` +`task-monitor` — the board, live, in the terminal (#276) + +The ratatui front end over that JSON. It opens on the repo view (In flight / +Next / Done) inside a repo, and on the all-repos overview (`a`, or `--all`) +outside one: one row per repo with its attention light, the signal in words, +and the zellij session it runs in. A change under radio's state or a worktree +sidecar refreshes it from disk at once (`--local`); forge state refreshes every +45s (`--poll SECS`). A failed or slow refresh never blanks the screen: the last +good snapshot stays up, marked `stale`. Keys: `↑↓` move, `⏎` expand (a done +item's full PR body, scrollable) or enter a repo, `o` open the PR or issue, `t` +jump to the item's tab (the worker's, or the PM's for a queued or done item), +`a` toggle the overview, `q` quit. `t` stays inside the monitor's own zellij +session, and goes to a tab only while the tab at the recorded id still bears the +item's name, the check `task-done` makes before closing one (#260). It holds no +task-force logic: every value is a snapshot field. Build it from the task-force +checkout with `cargo build --release --manifest-path monitor/Cargo.toml` +(`install.sh` does not build it yet; #277). + ### PM ↔ worker messaging (radio) Radio is the **canonical** coordination channel between the PM and workers — every diff --git a/claude-jira/steering/jira-workflow.example.md b/claude-jira/steering/jira-workflow.example.md index 96085be..67bf1ea 100644 --- a/claude-jira/steering/jira-workflow.example.md +++ b/claude-jira/steering/jira-workflow.example.md @@ -289,6 +289,24 @@ task-monitor-snapshot --all # every repo radio knows about task-monitor-snapshot --local # no forge calls: disk + the last full run's cache ``` +`task-monitor` — the board, live, in the terminal (#276) + +The ratatui front end over that JSON. It opens on the repo view (In flight / +Next / Done) inside a repo, and on the all-repos overview (`a`, or `--all`) +outside one: one row per repo with its attention light, the signal in words, +and the zellij session it runs in. A change under radio's state or a worktree +sidecar refreshes it from disk at once (`--local`); forge state refreshes every +45s (`--poll SECS`). A failed or slow refresh never blanks the screen: the last +good snapshot stays up, marked `stale`. Keys: `↑↓` move, `⏎` expand (a done +item's full PR body, scrollable) or enter a repo, `o` open the PR or issue, `t` +jump to the item's tab (the worker's, or the PM's for a queued or done item), +`a` toggle the overview, `q` quit. `t` stays inside the monitor's own zellij +session, and goes to a tab only while the tab at the recorded id still bears the +item's name, the check `task-done` makes before closing one (#260). It holds no +task-force logic: every value is a snapshot field. Build it from the task-force +checkout with `cargo build --release --manifest-path monitor/Cargo.toml` +(`install.sh` does not build it yet; #277). + ### PM ↔ worker messaging (radio) Radio is the **canonical** coordination channel between the PM and workers — every diff --git a/claude-local/steering/local-workflow.example.md b/claude-local/steering/local-workflow.example.md index a8bc981..70ec64c 100644 --- a/claude-local/steering/local-workflow.example.md +++ b/claude-local/steering/local-workflow.example.md @@ -311,6 +311,24 @@ task-monitor-snapshot --all # every repo radio knows about task-monitor-snapshot --local # no forge calls: disk + the last full run's cache ``` +`task-monitor` — the board, live, in the terminal (#276) + +The ratatui front end over that JSON. It opens on the repo view (In flight / +Next / Done) inside a repo, and on the all-repos overview (`a`, or `--all`) +outside one: one row per repo with its attention light, the signal in words, +and the zellij session it runs in. A change under radio's state or a worktree +sidecar refreshes it from disk at once (`--local`); forge state refreshes every +45s (`--poll SECS`). A failed or slow refresh never blanks the screen: the last +good snapshot stays up, marked `stale`. Keys: `↑↓` move, `⏎` expand (a done +item's full PR body, scrollable) or enter a repo, `o` open the PR or issue, `t` +jump to the item's tab (the worker's, or the PM's for a queued or done item), +`a` toggle the overview, `q` quit. `t` stays inside the monitor's own zellij +session, and goes to a tab only while the tab at the recorded id still bears the +item's name, the check `task-done` makes before closing one (#260). It holds no +task-force logic: every value is a snapshot field. Build it from the task-force +checkout with `cargo build --release --manifest-path monitor/Cargo.toml` +(`install.sh` does not build it yet; #277). + ### PM ↔ worker messaging (radio) Radio is the **canonical** coordination channel between the PM and workers — every diff --git a/claude-notion/steering/notion-workflow.example.md b/claude-notion/steering/notion-workflow.example.md index 9829c95..5db3173 100644 --- a/claude-notion/steering/notion-workflow.example.md +++ b/claude-notion/steering/notion-workflow.example.md @@ -306,6 +306,24 @@ task-monitor-snapshot --all # every repo radio knows about task-monitor-snapshot --local # no forge calls: disk + the last full run's cache ``` +`task-monitor` — the board, live, in the terminal (#276) + +The ratatui front end over that JSON. It opens on the repo view (In flight / +Next / Done) inside a repo, and on the all-repos overview (`a`, or `--all`) +outside one: one row per repo with its attention light, the signal in words, +and the zellij session it runs in. A change under radio's state or a worktree +sidecar refreshes it from disk at once (`--local`); forge state refreshes every +45s (`--poll SECS`). A failed or slow refresh never blanks the screen: the last +good snapshot stays up, marked `stale`. Keys: `↑↓` move, `⏎` expand (a done +item's full PR body, scrollable) or enter a repo, `o` open the PR or issue, `t` +jump to the item's tab (the worker's, or the PM's for a queued or done item), +`a` toggle the overview, `q` quit. `t` stays inside the monitor's own zellij +session, and goes to a tab only while the tab at the recorded id still bears the +item's name, the check `task-done` makes before closing one (#260). It holds no +task-force logic: every value is a snapshot field. Build it from the task-force +checkout with `cargo build --release --manifest-path monitor/Cargo.toml` +(`install.sh` does not build it yet; #277). + ### PM ↔ worker messaging (radio) Radio is the **canonical** coordination channel between the PM and workers — every diff --git a/docs/task-monitor-schema.example.json b/docs/task-monitor-schema.example.json index 17ebe86..ef41d45 100644 --- a/docs/task-monitor-schema.example.json +++ b/docs/task-monitor-schema.example.json @@ -6,6 +6,7 @@ { "name": "task-force", "root": "/home/you/src/task-force", + "worktree_base": "/home/you/src/task-force-worktrees", "loadout": "claude-gh", "tracker": "gh", "agent": "claude", diff --git a/docs/task-monitor-schema.md b/docs/task-monitor-schema.md index 2617d53..6ebd321 100644 --- a/docs/task-monitor-schema.md +++ b/docs/task-monitor-schema.md @@ -3,7 +3,7 @@ `task-monitor-snapshot --json` prints one JSON document describing what is in flight, next and done in a repo, plus the attention signals that say whether the repo needs you (#273, epic #272). It is the contract between the bash collector -and the ratatui front end. A full example, taken from this repo, is in +and the ratatui front end (`monitor/`, the `task-monitor` binary). A full example, taken from this repo, is in [`task-monitor-schema.example.json`](task-monitor-schema.example.json). Every value is read deterministically: from radio's on-disk state, task-work's @@ -46,6 +46,7 @@ ignore fields it does not know. |---|---|---| | `name` | string | the main repo's name, sanitized to radio's role charset (`task-force`) | | `root` | path | the main checkout | +| `worktree_base` | path | where task-work puts this repo's worktrees and their `..info` sidecars. The front end watches it for a new sidecar, so it never re-derives task-work's path rule | | `loadout` | string \| null | `claude-gh` …, from the repo's one workflow doc, else from its PM session's `LOADOUT=` | | `tracker`, `agent` | string \| null | the loadout's two halves | | `pm` | object | the PM session (see below) | diff --git a/kiro-gh/steering/gh-workflow.example.md b/kiro-gh/steering/gh-workflow.example.md index 09b53f8..933cff7 100644 --- a/kiro-gh/steering/gh-workflow.example.md +++ b/kiro-gh/steering/gh-workflow.example.md @@ -287,6 +287,24 @@ task-monitor-snapshot --all # every repo radio knows about task-monitor-snapshot --local # no forge calls: disk + the last full run's cache ``` +`task-monitor` — the board, live, in the terminal (#276) + +The ratatui front end over that JSON. It opens on the repo view (In flight / +Next / Done) inside a repo, and on the all-repos overview (`a`, or `--all`) +outside one: one row per repo with its attention light, the signal in words, +and the zellij session it runs in. A change under radio's state or a worktree +sidecar refreshes it from disk at once (`--local`); forge state refreshes every +45s (`--poll SECS`). A failed or slow refresh never blanks the screen: the last +good snapshot stays up, marked `stale`. Keys: `↑↓` move, `⏎` expand (a done +item's full PR body, scrollable) or enter a repo, `o` open the PR or issue, `t` +jump to the item's tab (the worker's, or the PM's for a queued or done item), +`a` toggle the overview, `q` quit. `t` stays inside the monitor's own zellij +session, and goes to a tab only while the tab at the recorded id still bears the +item's name, the check `task-done` makes before closing one (#260). It holds no +task-force logic: every value is a snapshot field. Build it from the task-force +checkout with `cargo build --release --manifest-path monitor/Cargo.toml` +(`install.sh` does not build it yet; #277). + ### PM ↔ worker messaging (radio) Radio is the **canonical** coordination channel between the PM and workers — every diff --git a/kiro-local/steering/local-workflow.example.md b/kiro-local/steering/local-workflow.example.md index 11a7651..dd40b12 100644 --- a/kiro-local/steering/local-workflow.example.md +++ b/kiro-local/steering/local-workflow.example.md @@ -309,6 +309,24 @@ task-monitor-snapshot --all # every repo radio knows about task-monitor-snapshot --local # no forge calls: disk + the last full run's cache ``` +`task-monitor` — the board, live, in the terminal (#276) + +The ratatui front end over that JSON. It opens on the repo view (In flight / +Next / Done) inside a repo, and on the all-repos overview (`a`, or `--all`) +outside one: one row per repo with its attention light, the signal in words, +and the zellij session it runs in. A change under radio's state or a worktree +sidecar refreshes it from disk at once (`--local`); forge state refreshes every +45s (`--poll SECS`). A failed or slow refresh never blanks the screen: the last +good snapshot stays up, marked `stale`. Keys: `↑↓` move, `⏎` expand (a done +item's full PR body, scrollable) or enter a repo, `o` open the PR or issue, `t` +jump to the item's tab (the worker's, or the PM's for a queued or done item), +`a` toggle the overview, `q` quit. `t` stays inside the monitor's own zellij +session, and goes to a tab only while the tab at the recorded id still bears the +item's name, the check `task-done` makes before closing one (#260). It holds no +task-force logic: every value is a snapshot field. Build it from the task-force +checkout with `cargo build --release --manifest-path monitor/Cargo.toml` +(`install.sh` does not build it yet; #277). + ### PM ↔ worker messaging (radio) Radio is the **canonical** coordination channel between the PM and workers — every diff --git a/kiro-notion/steering/notion-workflow.example.md b/kiro-notion/steering/notion-workflow.example.md index 5902faa..abe2b5f 100644 --- a/kiro-notion/steering/notion-workflow.example.md +++ b/kiro-notion/steering/notion-workflow.example.md @@ -284,6 +284,24 @@ task-monitor-snapshot --all # every repo radio knows about task-monitor-snapshot --local # no forge calls: disk + the last full run's cache ``` +`task-monitor` — the board, live, in the terminal (#276) + +The ratatui front end over that JSON. It opens on the repo view (In flight / +Next / Done) inside a repo, and on the all-repos overview (`a`, or `--all`) +outside one: one row per repo with its attention light, the signal in words, +and the zellij session it runs in. A change under radio's state or a worktree +sidecar refreshes it from disk at once (`--local`); forge state refreshes every +45s (`--poll SECS`). A failed or slow refresh never blanks the screen: the last +good snapshot stays up, marked `stale`. Keys: `↑↓` move, `⏎` expand (a done +item's full PR body, scrollable) or enter a repo, `o` open the PR or issue, `t` +jump to the item's tab (the worker's, or the PM's for a queued or done item), +`a` toggle the overview, `q` quit. `t` stays inside the monitor's own zellij +session, and goes to a tab only while the tab at the recorded id still bears the +item's name, the check `task-done` makes before closing one (#260). It holds no +task-force logic: every value is a snapshot field. Build it from the task-force +checkout with `cargo build --release --manifest-path monitor/Cargo.toml` +(`install.sh` does not build it yet; #277). + ### PM ↔ worker messaging (radio) Radio is the **canonical** coordination channel between the PM and workers — every diff --git a/lib/monitor-assemble.jq b/lib/monitor-assemble.jq index 48c5990..3b696ad 100644 --- a/lib/monitor-assemble.jq +++ b/lib/monitor-assemble.jq @@ -152,6 +152,10 @@ def status($pr; $last_review; $has_reviewer): | { name: $name, root: $root, + # Where task-work puts this repo's worktrees and their `..info` + # sidecars, so the front end can watch for a new one without re-deriving + # task-work's path rule. + worktree_base: $worktree_base, loadout: ($impl | nonempty), tracker: ($tracker | nonempty), agent: ($agent | nonempty), diff --git a/monitor/.gitignore b/monitor/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/monitor/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/monitor/Cargo.lock b/monitor/Cargo.lock new file mode 100644 index 0000000..8a4389b --- /dev/null +++ b/monitor/Cargo.lock @@ -0,0 +1,2023 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.2", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.9", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "finl_unicode" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bb028c8b4148c9ee0cca68fcd9add6044e81d3619f48577ddf13a263d047a2" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indoc" +version = "2.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1" +dependencies = [ + "rustversion", +] + +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "instability" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f14c90e40a6133f4805b83542a7ed7364f56070b447f56e7f4e90297122209" +dependencies = [ + "hashbrown", + "portable-atomic", + "thiserror 2.0.21", +] + +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.2", + "libc", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "mac_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3d0581a75c45969c63afd2bb87b1d8b25cfcc556c7a918cf9bb13b0fe8d2381" +dependencies = [ + "nix 0.31.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.2", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4" +dependencies = [ + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pest" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd47a460a9cbc3b3429c7e22bf0d76653e779319bae45a499ca010c9a990bb23" +dependencies = [ + "memchr", + "psm", + "stacker", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "203e46697708f7519ab1608393cf89239cafb46b8e6d08a8ffe359359b11ef44" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e84ae6340a458d3d6cfc73cf328cf3cfe90b2484cd0de25eb66aa954615ed4d5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "pest_meta" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b57f92f32b3dbce6598fd8f5cdf1f06a6072570b8103fa21728fa9036923e710" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psm" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810" +dependencies = [ + "cc", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.2", + "compact_str", + "critical-section", + "hashbrown", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.21", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.2", + "hashbrown", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "task-monitor" +version = "0.1.0" +dependencies = [ + "chrono", + "notify", + "ratatui", + "serde", + "serde_json", + "unicode-width", +] + +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.2", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.13.2", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/monitor/Cargo.toml b/monitor/Cargo.toml new file mode 100644 index 0000000..0ee9528 --- /dev/null +++ b/monitor/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "task-monitor" +version = "0.1.0" +edition = "2021" +rust-version = "1.88" +description = "Real-time terminal view of task-force's in-flight, next and done work (#272)" +license = "MIT" +publish = false + +[[bin]] +name = "task-monitor" +path = "src/main.rs" + +[dependencies] +ratatui = "0.30" +notify = "8.2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +chrono = { version = "0.4", default-features = false, features = ["std", "clock"] } +unicode-width = "0.2" + +[profile.release] +strip = true diff --git a/monitor/src/actions.rs b/monitor/src/actions.rs new file mode 100644 index 0000000..6cd4b54 --- /dev/null +++ b/monitor/src/actions.rs @@ -0,0 +1,236 @@ +//! The two things task-monitor does outside its own screen: open a URL, and +//! go to a zellij tab. Both go through a runner so tests never touch a +//! browser or a live zellij. + +use serde::Deserialize; + +/// Run a program and return its stdout, or why it failed. +pub type Run<'a> = dyn FnMut(&str, &[&str]) -> Result + 'a; +/// Read an environment variable. +pub type Env<'a> = dyn Fn(&str) -> Option + 'a; + +pub fn system_run(prog: &str, args: &[&str]) -> Result { + let out = std::process::Command::new(prog) + .args(args) + .stdin(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .map_err(|e| format!("{prog}: {e}"))?; + if out.status.success() { + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) + } else { + Err(format!( + "{prog} {} failed ({})", + args.first().copied().unwrap_or(""), + out.status + )) + } +} + +pub fn system_env(name: &str) -> Option { + std::env::var(name).ok().filter(|v| !v.is_empty()) +} + +pub fn open_url(url: &str, run: &mut Run) -> Result { + let opener = if cfg!(target_os = "macos") { + "open" + } else { + "xdg-open" + }; + run(opener, &[url]).map(|_| format!("opened {url}")) +} + +#[derive(Deserialize)] +struct Tab { + tab_id: Option, + name: Option, +} + +/// True when tab name `name` belongs to `slug`: the bare slug, or the slug +/// after a space-separated prefix (radio's `⏸️ ` / `▶️ ` / `❓︎ ` paint). The +/// same rule as `aw_tab_name_is_slug` in lib/zellij-tab.sh, which task-done +/// applies before it closes a tab (#260): keep the two in step. +pub fn tab_name_is(name: &str, slug: &str) -> bool { + !slug.is_empty() && (name == slug || name.ends_with(&format!(" {slug}"))) +} + +/// Go to `name`'s tab by its recorded id, after checking that the tab at that +/// id is still `name`'s. A tab id is only valid for one zellij server's +/// lifetime, so after a restart it can belong to another worker's tab. +/// `t` cannot cross zellij sessions (#272), so a tab in another session is +/// named rather than attempted. +pub fn goto_tab( + tab_id: Option<&str>, + name: &str, + session: Option<&str>, + env: &Env, + run: &mut Run, +) -> Result { + if name.is_empty() { + return Err("no PM session for this repo, so no tab to go to".into()); + } + if env("ZELLIJ").is_none() { + return Err( + "not inside zellij: t jumps to tabs of the monitor's own zellij session".into(), + ); + } + let here = env("ZELLIJ_SESSION_NAME"); + if let (Some(there), Some(here)) = (session, here.as_deref()) { + if there != here { + return Err(format!( + "{name} is in zellij session '{there}': switch to it with zellij's session manager" + )); + } + } + let Some(id) = tab_id.filter(|id| !id.is_empty()) else { + return Err(format!("no tab id recorded for {name}")); + }; + let Ok(id_num) = id.parse::() else { + return Err(format!("tab id '{id}' for {name} is not a number")); + }; + let listed = run("zellij", &["action", "list-tabs", "--json"])?; + let tabs: Vec = serde_json::from_str(&listed) + .map_err(|_| "zellij list-tabs returned nothing parseable".to_string())?; + match tabs.iter().find(|t| t.tab_id == Some(id_num)) { + None => Err(format!("no tab has id {id} any more (zellij restarted?)")), + Some(t) => { + let at = t.name.as_deref().unwrap_or(""); + if !tab_name_is(at, name) { + return Err(format!( + "tab id {id} is now '{at}', not {name}: the recorded id is stale" + )); + } + run("zellij", &["action", "go-to-tab-by-id", id])?; + Ok(format!("went to {at}")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const TABS: &str = r#"[{"tab_id":0,"name":"⏸️ pm-task-force","position":0}, + {"tab_id":4,"name":"▶️ kiro-config"}, + {"tab_id":5,"name":"other-kiro-config"}]"#; + + fn env_in(session: &'static str) -> impl Fn(&str) -> Option { + move |k| match k { + "ZELLIJ" => Some("0".into()), + "ZELLIJ_SESSION_NAME" => Some(session.into()), + _ => None, + } + } + + /// A zellij that lists TABS and records every call. + fn fake(calls: &mut Vec) -> impl FnMut(&str, &[&str]) -> Result + '_ { + move |prog, args| { + calls.push(format!("{prog} {}", args.join(" "))); + Ok(if args.get(1) == Some(&"list-tabs") { + TABS.into() + } else { + String::new() + }) + } + } + + #[test] + fn goes_to_a_verified_tab() { + let mut calls = vec![]; + let r = goto_tab( + Some("4"), + "kiro-config", + Some("task-force"), + &env_in("task-force"), + &mut fake(&mut calls), + ); + assert_eq!(r, Ok("went to ▶️ kiro-config".into())); + assert_eq!(calls.last().unwrap(), "zellij action go-to-tab-by-id 4"); + } + + #[test] + fn refuses_a_stale_id_now_held_by_another_tab() { + let mut calls = vec![]; + let r = goto_tab( + Some("5"), + "kiro-config", + None, + &env_in("task-force"), + &mut fake(&mut calls), + ); + assert!(r.unwrap_err().contains("is now 'other-kiro-config'")); + assert!(!calls.iter().any(|c| c.contains("go-to-tab")), "{calls:?}"); + let r = goto_tab( + Some("9"), + "kiro-config", + None, + &env_in("task-force"), + &mut fake(&mut calls), + ); + assert!(r.unwrap_err().contains("no tab has id 9")); + } + + #[test] + fn does_not_cross_zellij_sessions() { + let mut calls = vec![]; + let r = goto_tab( + Some("0"), + "pm-rec", + Some("rec"), + &env_in("task-force"), + &mut fake(&mut calls), + ); + assert!(r.unwrap_err().contains("zellij session 'rec'")); + assert!(calls.is_empty()); + } + + #[test] + fn says_why_when_there_is_nothing_to_go_to() { + let mut calls = vec![]; + let outside = |_: &str| None; + assert!( + goto_tab(Some("0"), "pm-x", None, &outside, &mut fake(&mut calls)) + .unwrap_err() + .contains("not inside zellij") + ); + assert!( + goto_tab(None, "pm-x", None, &env_in("s"), &mut fake(&mut calls)) + .unwrap_err() + .contains("no tab id") + ); + assert!( + goto_tab(Some("0"), "", None, &env_in("s"), &mut fake(&mut calls)) + .unwrap_err() + .contains("no PM session") + ); + assert!(calls.is_empty()); + let mut broken = |_: &str, _: &[&str]| Ok::<_, String>("garbage".to_string()); + assert!(goto_tab(Some("0"), "pm-x", None, &env_in("s"), &mut broken) + .unwrap_err() + .contains("parseable")); + } + + #[test] + fn tab_name_rule_matches_task_done() { + assert!(tab_name_is("kiro-config", "kiro-config")); + assert!(tab_name_is("❓︎ kiro-config", "kiro-config")); + assert!(!tab_name_is("other-kiro-config", "kiro-config")); + assert!(!tab_name_is("kiro-config", "")); + } + + #[test] + fn opens_urls_with_the_platform_opener() { + let mut calls = vec![]; + let mut run = |p: &str, a: &[&str]| { + calls.push(format!("{p} {}", a.join(" "))); + Ok(String::new()) + }; + assert!(open_url("https://x/1", &mut run).is_ok()); + let opener = if cfg!(target_os = "macos") { + "open" + } else { + "xdg-open" + }; + assert_eq!(calls, vec![format!("{opener} https://x/1")]); + } +} diff --git a/monitor/src/app.rs b/monitor/src/app.rs new file mode 100644 index 0000000..3ab836c --- /dev/null +++ b/monitor/src/app.rs @@ -0,0 +1,570 @@ +//! Screen state and key handling. Pure: a key press changes the state and +//! may return an [`Action`] for main to carry out (open a URL, go to a tab), +//! so every key is testable without a terminal, a browser or zellij. + +use std::time::Instant; + +use chrono::{DateTime, Utc}; + +use crate::snapshot::{Repo, Snapshot}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Key { + Up, + Down, + PageUp, + PageDown, + Enter, + Esc, + Char(char), + Quit, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + Quit, + Open(String), + /// Go to a zellij tab, once it is verified to still be `name`'s. + GotoTab { + tab_id: Option, + name: String, + session: Option, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum View { + /// One repo, by its snapshot `name`. + Repo(String), + Overview, +} + +/// A selectable row of the repo view, by index into its section. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Row { + InFlight(usize), + Next(usize), + Done(usize), +} + +pub fn rows(repo: &Repo) -> Vec { + (0..repo.in_flight.len()) + .map(Row::InFlight) + .chain((0..repo.next.items.len()).map(Row::Next)) + .chain((0..repo.done.items.len()).map(Row::Done)) + .collect() +} + +/// What the screen shows for freshness. The snapshot itself is never dropped +/// for a failure: the last good one stays up, marked stale. +#[derive(Debug, Clone, Default)] +pub struct Freshness { + /// When the snapshot on screen was received. + pub received_at: Option>, + /// The latest attempt failed or timed out, and why. + pub error: Option<(DateTime, String)>, +} + +pub struct App { + pub snapshot: Option, + /// When the collector run behind `snapshot` started; a slower run that + /// started earlier never replaces it. + pub snapshot_started: Option, + pub freshness: Freshness, + pub view: View, + /// The repo view's last repo, so `a` toggles back to it. + pub last_repo: Option, + pub selected: usize, + /// Selection identity, kept across refreshes that reorder the lists. + selected_key: Option, + pub overview_selected: usize, + /// The expanded item's scroll offset, when one is expanded. + pub detail: Option, + pub flash: Option<(DateTime, String)>, +} + +impl App { + /// `in_repo`: started inside a repo, so open on its view (the current + /// repo is the snapshot's first). Otherwise open on the overview. + pub fn new(in_repo: bool) -> Self { + App { + snapshot: None, + snapshot_started: None, + freshness: Freshness::default(), + view: if in_repo { + View::Repo(String::new()) + } else { + View::Overview + }, + last_repo: None, + selected: 0, + selected_key: None, + overview_selected: 0, + detail: None, + flash: None, + } + } + + /// Take a new snapshot unless a run that started later already landed. + /// Returns whether it was taken. + pub fn offer(&mut self, started: Instant, snap: Snapshot, now: DateTime) -> bool { + if self.snapshot_started.is_some_and(|s| s > started) { + return false; + } + self.snapshot = Some(snap); + self.snapshot_started = Some(started); + self.freshness.received_at = Some(now); + self.freshness.error = None; + // The empty name means "the current repo", fixed on the first snapshot. + if self.view == View::Repo(String::new()) { + let first = self.repos().first().and_then(|r| r.name.clone()); + self.view = first.map(View::Repo).unwrap_or(View::Overview); + } + self.reselect(); + true + } + + pub fn failed(&mut self, now: DateTime, why: String) { + self.freshness.error = Some((now, why)); + } + + pub fn repos(&self) -> &[Repo] { + self.snapshot + .as_ref() + .map(|s| s.repos.as_slice()) + .unwrap_or(&[]) + } + + pub fn current_repo(&self) -> Option<&Repo> { + match &self.view { + View::Repo(name) => self + .repos() + .iter() + .find(|r| r.name.as_deref() == Some(name)), + View::Overview => None, + } + } + + pub fn selected_row(&self) -> Option { + self.current_repo() + .and_then(|r| rows(r).get(self.selected).copied()) + } + + fn row_key(repo: &Repo, row: Row) -> String { + match row { + Row::InFlight(i) => format!("f:{}", repo.in_flight[i].slug.as_deref().unwrap_or("")), + Row::Next(i) => format!( + "n:{}", + repo.next.items[i].reference.as_deref().unwrap_or("") + ), + Row::Done(i) => format!( + "d:{}", + repo.done.items[i] + .pr + .as_ref() + .and_then(|p| p.number) + .unwrap_or(0) + ), + } + } + + fn remember_selection(&mut self) { + self.selected_key = self + .current_repo() + .and_then(|r| rows(r).get(self.selected).map(|&row| Self::row_key(r, row))); + } + + /// After a refresh, follow the selected item to its new position, or + /// stay at the same index when it is gone. + fn reselect(&mut self) { + let n_repos = self.repos().len(); + self.overview_selected = self.overview_selected.min(n_repos.saturating_sub(1)); + let Some(repo) = self.current_repo() else { + return; + }; + let rs = rows(repo); + if let Some(key) = &self.selected_key { + if let Some(i) = rs.iter().position(|&row| &Self::row_key(repo, row) == key) { + self.selected = i; + return; + } + } + self.selected = self.selected.min(rs.len().saturating_sub(1)); + if rs.is_empty() { + self.detail = None; + } + self.remember_selection(); + } + + pub fn flash(&mut self, now: DateTime, msg: impl Into) { + self.flash = Some((now, msg.into())); + } + + pub fn key(&mut self, key: Key, now: DateTime) -> Option { + if key == Key::Quit || key == Key::Char('q') { + return Some(Action::Quit); + } + if let Some(scroll) = self.detail { + return self.detail_key(key, scroll, now); + } + match self.view { + View::Overview => self.overview_key(key, now), + View::Repo(_) => self.repo_key(key, now), + } + } + + fn detail_key(&mut self, key: Key, scroll: u16, now: DateTime) -> Option { + match key { + Key::Up | Key::Char('k') => self.detail = Some(scroll.saturating_sub(1)), + Key::Down | Key::Char('j') => self.detail = Some(scroll.saturating_add(1)), + Key::PageUp => self.detail = Some(scroll.saturating_sub(10)), + Key::PageDown | Key::Char(' ') => self.detail = Some(scroll.saturating_add(10)), + Key::Enter | Key::Esc => self.detail = None, + Key::Char('o') | Key::Char('t') => return self.repo_key(key, now), + Key::Char('a') => { + self.detail = None; + return self.repo_key(key, now); + } + _ => {} + } + None + } + + fn overview_key(&mut self, key: Key, now: DateTime) -> Option { + let n = self.repos().len(); + match key { + Key::Up | Key::Char('k') => { + self.overview_selected = self.overview_selected.saturating_sub(1) + } + Key::Down | Key::Char('j') => { + self.overview_selected = (self.overview_selected + 1).min(n.saturating_sub(1)) + } + Key::Enter => { + if let Some(name) = self + .repos() + .get(self.overview_selected) + .and_then(|r| r.name.clone()) + { + self.enter_repo(name); + } + } + Key::Char('a') | Key::Esc => { + if let Some(name) = self.last_repo.clone() { + self.enter_repo(name); + } + } + Key::Char('t') => { + let repo = self.repos().get(self.overview_selected)?; + return Some(pm_tab(repo).unwrap_or_else(|| { + // No PM session: say so rather than doing nothing. + Action::GotoTab { + tab_id: None, + name: String::new(), + session: None, + } + })); + } + Key::Char('o') => self.flash(now, "o opens an item's PR or issue: ⏎ into a repo first"), + _ => {} + } + None + } + + fn enter_repo(&mut self, name: String) { + if self.last_repo.as_ref() != Some(&name) { + self.selected = 0; + self.selected_key = None; + } + self.view = View::Repo(name); + self.detail = None; + self.reselect(); + } + + fn repo_key(&mut self, key: Key, now: DateTime) -> Option { + let n = self.current_repo().map(|r| rows(r).len()).unwrap_or(0); + match key { + Key::Up | Key::Char('k') => { + self.selected = self.selected.saturating_sub(1); + self.remember_selection(); + } + Key::Down | Key::Char('j') => { + self.selected = (self.selected + 1).min(n.saturating_sub(1)); + self.remember_selection(); + } + Key::Enter => { + if self.selected_row().is_some() { + self.detail = Some(0); + } + } + Key::Char('a') => { + if let View::Repo(name) = &self.view { + self.last_repo = Some(name.clone()); + } + if let Some(i) = self.last_repo.as_ref().and_then(|name| { + self.repos() + .iter() + .position(|r| r.name.as_ref() == Some(name)) + }) { + self.overview_selected = i; + } + self.view = View::Overview; + self.detail = None; + } + Key::Char('o') => { + let url = self.selected_url(); + match url { + Some(u) => return Some(Action::Open(u)), + None => self.flash(now, "this item has no URL"), + } + } + Key::Char('t') => { + let (repo, row) = (self.current_repo()?, self.selected_row()); + return Some(match row { + Some(Row::InFlight(i)) => { + let item = &repo.in_flight[i]; + let session = item.session.as_ref(); + Action::GotoTab { + // The sidecar's id first, as task-done reads it. + tab_id: item + .tab_id + .clone() + .or_else(|| session.and_then(|s| s.tab_id.clone())), + name: item.slug.clone().unwrap_or_default(), + session: session.and_then(|s| s.zellij_session.clone()), + } + } + // Queued and merged items have no worker; their tab is the PM's. + _ => pm_tab(repo).unwrap_or(Action::GotoTab { + tab_id: None, + name: String::new(), + session: None, + }), + }); + } + _ => {} + } + None + } + + /// What `o` opens: an in-flight item's PR once it has one, else the item. + pub fn selected_url(&self) -> Option { + let repo = self.current_repo()?; + match self.selected_row()? { + Row::InFlight(i) => { + let item = &repo.in_flight[i]; + item.pr + .as_ref() + .and_then(|p| p.url.clone()) + .or_else(|| item.url.clone()) + } + Row::Next(i) => repo.next.items[i].url.clone(), + Row::Done(i) => { + let item = &repo.done.items[i]; + item.url + .clone() + .or_else(|| item.pr.as_ref().and_then(|p| p.url.clone())) + } + } + } +} + +fn pm_tab(repo: &Repo) -> Option { + let role = repo.pm.role.clone()?; + Some(Action::GotoTab { + tab_id: repo.pm.tab_id.clone(), + name: role, + session: repo.pm.zellij_session.clone(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::snapshot::parse; + + fn now() -> DateTime { + "2026-10-20T15:00:00Z".parse().unwrap() + } + + fn app_with(json: &str, in_repo: bool) -> App { + let mut app = App::new(in_repo); + app.offer(Instant::now(), parse(json).unwrap(), now()); + app + } + + pub(crate) const MULTI: &str = include_str!("../tests/fixtures/multi.json"); + + #[test] + fn opens_on_the_current_repo_inside_one_and_on_the_overview_outside() { + assert_eq!(app_with(MULTI, true).view, View::Repo("task-force".into())); + assert_eq!(app_with(MULTI, false).view, View::Overview); + } + + #[test] + fn arrows_move_across_sections_and_stop_at_the_ends() { + let mut app = app_with(MULTI, true); + let total = rows(app.current_repo().unwrap()).len(); + app.key(Key::Up, now()); + assert_eq!(app.selected, 0); + for _ in 0..total + 3 { + app.key(Key::Down, now()); + } + assert_eq!(app.selected, total - 1); + assert!(matches!(app.selected_row(), Some(Row::Done(_)))); + } + + #[test] + fn enter_expands_and_scrolls_then_esc_collapses() { + let mut app = app_with(MULTI, true); + app.key(Key::Enter, now()); + assert_eq!(app.detail, Some(0)); + app.key(Key::Down, now()); + app.key(Key::PageDown, now()); + assert_eq!(app.detail, Some(11)); + assert_eq!( + app.selected, 0, + "scrolling the body does not move the selection" + ); + app.key(Key::Esc, now()); + assert_eq!(app.detail, None); + } + + #[test] + fn o_opens_the_pr_once_there_is_one_else_the_issue() { + let mut app = app_with(MULTI, true); + assert_eq!( + app.key(Key::Char('o'), now()), + Some(Action::Open( + "https://github.com/o/task-force/pull/270".into() + )) + ); + app.key(Key::Down, now()); + assert_eq!( + app.key(Key::Char('o'), now()), + Some(Action::Open( + "https://github.com/o/task-force/issues/262".into() + )) + ); + } + + #[test] + fn o_without_a_url_flashes_instead_of_acting() { + let mut app = app_with(MULTI, true); + app.view = View::Repo("jira-repo".into()); + app.selected = 0; + assert_eq!(app.key(Key::Char('o'), now()), None); + assert!(app.flash.as_ref().unwrap().1.contains("no URL")); + } + + #[test] + fn t_targets_the_worker_tab_by_sidecar_id_and_slug() { + let mut app = app_with(MULTI, true); + assert_eq!( + app.key(Key::Char('t'), now()), + Some(Action::GotoTab { + tab_id: Some("4".into()), + name: "kiro-config".into(), + session: Some("task-force".into()) + }) + ); + // A worker with no sidecar id falls back to its session's. + app.key(Key::Down, now()); + assert_eq!( + app.key(Key::Char('t'), now()), + Some(Action::GotoTab { + tab_id: Some("7".into()), + name: "home-env".into(), + session: Some("task-force".into()) + }) + ); + } + + #[test] + fn t_on_a_queued_or_done_item_targets_the_pm_tab() { + let mut app = app_with(MULTI, true); + app.selected = rows(app.current_repo().unwrap()).len() - 1; + assert_eq!( + app.key(Key::Char('t'), now()), + Some(Action::GotoTab { + tab_id: Some("0".into()), + name: "pm-task-force".into(), + session: Some("task-force".into()) + }) + ); + } + + #[test] + fn a_toggles_the_overview_and_back_to_the_same_repo() { + let mut app = app_with(MULTI, true); + app.key(Key::Down, now()); + app.key(Key::Char('a'), now()); + assert_eq!(app.view, View::Overview); + assert_eq!(app.overview_selected, 0); + app.key(Key::Char('a'), now()); + assert_eq!(app.view, View::Repo("task-force".into())); + assert_eq!(app.selected, 1, "the selection survives the round trip"); + } + + #[test] + fn enter_on_an_overview_row_opens_that_repo() { + let mut app = app_with(MULTI, false); + app.key(Key::Down, now()); + app.key(Key::Enter, now()); + assert_eq!(app.view, View::Repo("recommender-systems".into())); + assert_eq!(app.selected, 0); + // t on an overview row is the PM's tab. + app.key(Key::Char('a'), now()); + assert_eq!( + app.key(Key::Char('t'), now()), + Some(Action::GotoTab { + tab_id: Some("2".into()), + name: "pm-recommender-systems".into(), + session: Some("rec".into()) + }) + ); + } + + #[test] + fn q_quits_from_every_screen() { + let mut app = app_with(MULTI, true); + assert_eq!(app.key(Key::Char('q'), now()), Some(Action::Quit)); + app.key(Key::Enter, now()); + assert_eq!(app.key(Key::Char('q'), now()), Some(Action::Quit)); + app.key(Key::Char('a'), now()); + assert_eq!(app.key(Key::Quit, now()), Some(Action::Quit)); + } + + #[test] + fn a_refresh_keeps_the_selected_item_when_the_list_reorders() { + let mut app = app_with(MULTI, true); + app.key(Key::Down, now()); // home-env + let mut snap = parse(MULTI).unwrap(); + snap.repos[0].in_flight.reverse(); + app.offer(Instant::now(), snap, now()); + let repo = app.current_repo().unwrap(); + let Some(Row::InFlight(i)) = app.selected_row() else { + panic!() + }; + assert_eq!(repo.in_flight[i].slug.as_deref(), Some("home-env")); + } + + #[test] + fn a_run_that_started_earlier_never_replaces_a_later_one() { + let mut app = App::new(true); + let early = Instant::now(); + let late = early + std::time::Duration::from_millis(5); + assert!(app.offer(late, parse(MULTI).unwrap(), now())); + assert!(!app.offer(early, parse(r#"{"schema":1,"repos":[]}"#).unwrap(), now())); + assert_eq!(app.repos().len(), 4); + } + + #[test] + fn a_failure_keeps_the_last_good_snapshot() { + let mut app = app_with(MULTI, true); + app.failed(now(), "timed out".into()); + assert_eq!(app.repos().len(), 4); + assert!(app.freshness.error.is_some()); + app.offer(Instant::now(), parse(MULTI).unwrap(), now()); + assert!(app.freshness.error.is_none()); + } +} diff --git a/monitor/src/main.rs b/monitor/src/main.rs new file mode 100644 index 0000000..25a4c28 --- /dev/null +++ b/monitor/src/main.rs @@ -0,0 +1,248 @@ +//! task-monitor: a real-time terminal view of what is in flight, next and +//! done in each repo task-force runs in, and which repo needs you (#272). +//! +//! It owns the screen, the keys and change detection, and nothing else. +//! Every value shown comes from `task-monitor-snapshot --json` +//! (docs/task-monitor-schema.md); this binary holds no task-force logic. + +mod actions; +mod app; +mod refresh; +mod snapshot; +mod ui; + +use std::path::PathBuf; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use chrono::Utc; +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; + +use app::{Action, App, Key}; +use refresh::{Collector, Mode, Scheduler, Watcher}; + +const USAGE: &str = "\ +Usage: task-monitor [--all] [--poll SECS] + +A real-time view of task-force work: the repo view (in flight, next, done) and +the all-repos overview with each repo's attention light. + + --all open on the all-repos overview (the default outside a repo) + --poll SECS how often to ask the forge for PR and CI state (30-600, default 45) + -h, --help this help + -V, --version the version + +Keys: ↑↓ move ⏎ expand / enter repo o open PR/issue t jump to tab + a all repos q quit + +Local changes (radio mail, worker state, worktree sidecars) refresh at once +from disk; forge state refreshes every --poll seconds. The data comes from +task-monitor-snapshot ($TASK_MONITOR_SNAPSHOT overrides which one). +"; + +struct Args { + all: bool, + poll: Duration, +} + +fn parse_args() -> Result { + let mut args = Args { + all: false, + poll: refresh::DEFAULT_POLL, + }; + let mut it = std::env::args().skip(1); + while let Some(a) = it.next() { + match a.as_str() { + "--all" => args.all = true, + "--poll" => { + let v = it.next().ok_or("--poll needs a number of seconds")?; + let secs: u64 = v + .parse() + .map_err(|_| format!("--poll: '{v}' is not a number of seconds"))?; + if !(30..=600).contains(&secs) { + return Err(format!("--poll: {secs}s is outside 30-600")); + } + args.poll = Duration::from_secs(secs); + } + "-h" | "--help" => { + print!("{USAGE}"); + std::process::exit(0); + } + "-V" | "--version" => { + println!("task-monitor {}", env!("CARGO_PKG_VERSION")); + std::process::exit(0); + } + other => return Err(format!("unknown argument: {other}")), + } + } + Ok(args) +} + +fn in_repo() -> bool { + std::process::Command::new("git") + .args(["rev-parse", "--is-inside-work-tree"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()) +} + +fn radio_home() -> PathBuf { + match std::env::var_os("TASK_FORCE_HOME").filter(|v| !v.is_empty()) { + Some(h) => h.into(), + None => PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".task-force"), + } +} + +enum Msg { + Key(Key), + Redraw, + Fs, + Ran { + mode: Mode, + started: Instant, + result: Result, + }, +} + +fn to_key(ev: event::KeyEvent) -> Option { + if ev.kind != KeyEventKind::Press { + return None; + } + Some(match ev.code { + KeyCode::Char('c') if ev.modifiers.contains(KeyModifiers::CONTROL) => Key::Quit, + KeyCode::Up => Key::Up, + KeyCode::Down => Key::Down, + KeyCode::PageUp => Key::PageUp, + KeyCode::PageDown => Key::PageDown, + KeyCode::Enter => Key::Enter, + KeyCode::Esc => Key::Esc, + KeyCode::Char(c) => Key::Char(c), + _ => return None, + }) +} + +fn main() { + let args = match parse_args() { + Ok(a) => a, + Err(e) => { + eprintln!("task-monitor: {e}\n\n{USAGE}"); + std::process::exit(2); + } + }; + let collector = Collector::locate(); + let mut app = App::new(!args.all && in_repo()); + + let (tx, rx) = mpsc::channel::(); + let (fs_tx, fs_rx) = mpsc::channel::<()>(); + let mut watcher = Watcher::new(radio_home(), fs_tx); + watcher.sync(None); + if !watcher.available() { + app.flash( + Utc::now(), + "no filesystem events here: refreshing on the poll only", + ); + } + { + let tx = tx.clone(); + std::thread::spawn(move || { + while fs_rx.recv().is_ok() { + if tx.send(Msg::Fs).is_err() { + break; + } + } + }); + } + { + let tx = tx.clone(); + std::thread::spawn(move || loop { + let msg = match event::read() { + Ok(Event::Key(k)) => match to_key(k) { + Some(k) => Msg::Key(k), + None => continue, + }, + Ok(Event::Resize(..)) => Msg::Redraw, + Ok(_) => continue, + Err(_) => break, + }; + if tx.send(msg).is_err() { + break; + } + }); + } + + let mut terminal = ratatui::init(); + let mut sched = Scheduler::new(Instant::now(), args.poll); + loop { + let now = Instant::now(); + for mode in sched.due(now) { + let (tx, c) = (tx.clone(), collector.clone()); + std::thread::spawn(move || { + let started = Instant::now(); + let result = c.run(mode, mode.timeout()); + let _ = tx.send(Msg::Ran { + mode, + started, + result, + }); + }); + } + if terminal + .draw(|f| ui::draw(f, &mut app, Utc::now())) + .is_err() + { + break; + } + // Wake at least once a second, so the clock and the ages move. + let wait = sched + .next_wakeup(Instant::now()) + .min(Duration::from_secs(1)); + let msg = match rx.recv_timeout(wait) { + Ok(m) => m, + Err(mpsc::RecvTimeoutError::Timeout) => continue, + Err(mpsc::RecvTimeoutError::Disconnected) => break, + }; + match msg { + Msg::Redraw => {} + Msg::Fs => sched.fs_event(Instant::now()), + Msg::Ran { + mode, + started, + result, + } => { + sched.finished(mode, Instant::now()); + match result { + Ok(snap) => { + if app.offer(started, snap, Utc::now()) { + watcher.sync(app.snapshot.as_ref()); + } + } + Err(why) => app.failed(Utc::now(), why), + } + } + Msg::Key(k) => match app.key(k, Utc::now()) { + None => {} + Some(Action::Quit) => break, + Some(Action::Open(url)) => { + let r = actions::open_url(&url, &mut actions::system_run); + app.flash(Utc::now(), r.unwrap_or_else(|e| e)); + } + Some(Action::GotoTab { + tab_id, + name, + session, + }) => { + let r = actions::goto_tab( + tab_id.as_deref(), + &name, + session.as_deref(), + &actions::system_env, + &mut actions::system_run, + ); + app.flash(Utc::now(), r.unwrap_or_else(|e| e)); + } + }, + } + } + ratatui::restore(); +} diff --git a/monitor/src/refresh.rs b/monitor/src/refresh.rs new file mode 100644 index 0000000..44f696d --- /dev/null +++ b/monitor/src/refresh.rs @@ -0,0 +1,566 @@ +//! Change detection: when to run the collector, and running it. +//! +//! Two kinds of run (#272). A *local* run (`--local`) reads disk only and +//! merges with the last full run's cache, so a filesystem event never waits +//! on GitHub. A *full* run also asks the forge, on a slow timer. Both run off +//! the UI thread; the screen keeps the last good snapshot while they do. + +use std::collections::HashSet; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::mpsc::Sender; +use std::time::{Duration, Instant}; + +use crate::snapshot::{self, Snapshot}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + Local, + Full, +} + +impl Mode { + /// A local run reads disk and is quick; a full run makes several `gh` + /// calls per repo, and with `--all` that adds up. + pub fn timeout(self) -> Duration { + match self { + Mode::Local => Duration::from_secs(20), + Mode::Full => Duration::from_secs(180), + } + } +} + +/// The trailing-edge debounce for filesystem events, and its cap: a steady +/// stream of events still refreshes at least this often. +pub const DEBOUNCE: Duration = Duration::from_millis(400); +pub const MAX_WAIT: Duration = Duration::from_secs(2); +pub const DEFAULT_POLL: Duration = Duration::from_secs(45); + +/// Decides which runs to start. Pure, so its timing is testable: main feeds +/// it events and the clock, and starts whatever [`Scheduler::due`] returns. +#[derive(Debug)] +pub struct Scheduler { + poll: Duration, + first_event: Option, + last_event: Option, + running: [bool; 2], + next_full: Instant, +} + +impl Scheduler { + /// Starts with both runs due: a local one for a quick first screen, a + /// full one for the forge state. + pub fn new(now: Instant, poll: Duration) -> Self { + Scheduler { + poll, + first_event: Some(now), + last_event: Some(now - DEBOUNCE), + running: [false; 2], + next_full: now, + } + } + + fn slot(mode: Mode) -> usize { + mode as usize + } + + pub fn fs_event(&mut self, now: Instant) { + self.first_event.get_or_insert(now); + self.last_event = Some(now); + } + + pub fn due(&mut self, now: Instant) -> Vec { + let mut out = Vec::new(); + if let (Some(first), Some(last)) = (self.first_event, self.last_event) { + let settled = + now.duration_since(last) >= DEBOUNCE || now.duration_since(first) >= MAX_WAIT; + // An event during a local run waits for it, then runs again: + // the run in flight may have read the directory before the change. + if settled && !self.running[Self::slot(Mode::Local)] { + self.first_event = None; + self.last_event = None; + self.running[Self::slot(Mode::Local)] = true; + out.push(Mode::Local); + } + } + if !self.running[Self::slot(Mode::Full)] && now >= self.next_full { + self.running[Self::slot(Mode::Full)] = true; + out.push(Mode::Full); + } + out + } + + pub fn finished(&mut self, mode: Mode, now: Instant) { + self.running[Self::slot(mode)] = false; + if mode == Mode::Full { + self.next_full = now + self.poll; + } + } + + /// How long main may sleep before something could become due. + pub fn next_wakeup(&self, now: Instant) -> Duration { + let mut wake = if self.running[Self::slot(Mode::Full)] { + Duration::MAX + } else { + self.next_full.saturating_duration_since(now) + }; + if let (Some(first), Some(last)) = (self.first_event, self.last_event) { + let at = (last + DEBOUNCE).min(first + MAX_WAIT); + wake = wake.min(at.saturating_duration_since(now)); + } + wake + } +} + +/// The collector command, `task-monitor-snapshot`. +#[derive(Debug, Clone)] +pub struct Collector { + pub program: PathBuf, +} + +impl Collector { + /// `$TASK_MONITOR_SNAPSHOT`, else the `bin/task-monitor-snapshot` of the + /// checkout this binary was built in, else whichever is on PATH. + pub fn locate() -> Self { + if let Some(p) = std::env::var_os("TASK_MONITOR_SNAPSHOT").filter(|p| !p.is_empty()) { + return Collector { program: p.into() }; + } + let sibling = std::env::current_exe() + .ok() + .and_then(|exe| exe.canonicalize().ok()) + .and_then(|exe| { + exe.ancestors() + .map(|a| a.join("bin/task-monitor-snapshot")) + .find(|p| p.is_file()) + }); + Collector { + program: sibling.unwrap_or_else(|| "task-monitor-snapshot".into()), + } + } + + pub fn args(mode: Mode) -> Vec<&'static str> { + let mut args = vec!["--json", "--all"]; + if mode == Mode::Local { + args.push("--local"); + } + args + } + + /// Run once, killing the run at `timeout`. A failure says why in one + /// line, for the footer. + pub fn run(&self, mode: Mode, timeout: Duration) -> Result { + let name = self + .program + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let mut child = Command::new(&self.program) + .args(Self::args(mode)) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("cannot run {}: {e}", self.program.display()))?; + // Drain both pipes on their own threads: a snapshot is far larger + // than a pipe buffer, and a full stderr would stall the child too. + let mut out = child.stdout.take().unwrap(); + let mut err = child.stderr.take().unwrap(); + let out_t = std::thread::spawn(move || { + let mut s = String::new(); + out.read_to_string(&mut s).map(|_| s) + }); + let err_t = std::thread::spawn(move || { + let mut s = String::new(); + let _ = err.read_to_string(&mut s); + s + }); + let deadline = Instant::now() + timeout; + let status = loop { + if let Some(st) = child.try_wait().map_err(|e| e.to_string())? { + break st; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!("{name} timed out after {}s", timeout.as_secs())); + } + std::thread::sleep(Duration::from_millis(25)); + }; + let stdout = out_t + .join() + .map_err(|_| "reader panicked".to_string())? + .map_err(|e| e.to_string())?; + let stderr = err_t.join().unwrap_or_default(); + if !status.success() { + let why = stderr + .lines() + .rev() + .find(|l| !l.trim().is_empty()) + .unwrap_or("") + .trim() + .to_string(); + let code = status + .code() + .map(|c| format!("exit {c}")) + .unwrap_or_else(|| "killed".into()); + return Err(if why.is_empty() { + format!("{name} failed ({code})") + } else { + format!("{name} failed ({code}): {why}") + }); + } + snapshot::parse(&stdout) + } +} + +/// What the watcher watches, and the events that count. +/// +/// - `/radio`, recursively: sessions, mailboxes. Not its `log`, which +/// every radio call appends to and which carries nothing the other files +/// do not. +/// - `/monitor`, recursively: the PM's queue, and the cache a full run +/// writes (so a full run's result reaches the next local run at once). +/// - each repo's worktree base, not recursively, for `..info` +/// sidecars only: the worktrees under it are full of build output. +pub struct Targets { + home: PathBuf, + bases: HashSet, +} + +impl Targets { + pub fn new(home: PathBuf) -> Self { + Targets { + home, + bases: HashSet::new(), + } + } + + pub fn radio(&self) -> PathBuf { + canon(self.home.join("radio")) + } + pub fn monitor(&self) -> PathBuf { + canon(self.home.join("monitor")) + } + + /// Learn the worktree bases a snapshot names. Returns the new ones. + pub fn learn(&mut self, snap: &Snapshot) -> Vec { + let mut new = Vec::new(); + let named = snap.repos.iter().flat_map(|r| { + r.worktree_base + .iter() + .map(PathBuf::from) + .chain(r.in_flight.iter().filter_map(|i| { + Path::new(i.worktree.as_deref()?) + .parent() + .map(Path::to_path_buf) + })) + }); + for base in named.map(canon) { + if self.bases.insert(base.clone()) { + new.push(base); + } + } + new + } + + pub fn relevant(&self, path: &Path) -> bool { + if let Ok(rest) = path.strip_prefix(self.radio()) { + return rest != Path::new("log") && !rest.starts_with("log."); + } + if path.starts_with(self.monitor()) { + return true; + } + let sidecar = path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with('.') && n.ends_with(".info")); + sidecar && path.parent().is_some_and(|p| self.bases.contains(p)) + } +} + +/// The path the kernel reports events under. FSEvents names the real path +/// (`/private/var/…` for `/var/…`), so a target reached through a symlink +/// must be compared in that form or its every event is filtered out. A path +/// that does not exist yet stays as given, and is learned again once it does. +fn canon(p: PathBuf) -> PathBuf { + p.canonicalize().unwrap_or(p) +} + +/// Keeps a filesystem watcher on [`Targets`], adding each target once it +/// exists. Events are forwarded through `notify`. +pub struct Watcher { + inner: Option, + watched: HashSet, + pub targets: std::sync::Arc>, +} + +impl Watcher { + pub fn new(home: PathBuf, notify_tx: Sender<()>) -> Self { + let targets = std::sync::Arc::new(std::sync::Mutex::new(Targets::new(home))); + let t = targets.clone(); + let inner = notify::recommended_watcher(move |res: notify::Result| { + let Ok(ev) = res else { return }; + if matches!(ev.kind, notify::EventKind::Access(_)) { + return; + } + let targets = t.lock().unwrap(); + if ev.paths.iter().any(|p| targets.relevant(p)) { + let _ = notify_tx.send(()); + } + }) + .ok(); + Watcher { + inner, + watched: HashSet::new(), + targets, + } + } + + /// Whether filesystem events are available at all. Without them the + /// slow poll still refreshes everything. + pub fn available(&self) -> bool { + self.inner.is_some() + } + + /// Watch every target that exists and is not watched yet. Called after + /// each snapshot, so a radio dir or worktree base that appears later is + /// picked up. + pub fn sync(&mut self, snap: Option<&Snapshot>) { + use notify::{RecursiveMode, Watcher as _}; + let Some(inner) = self.inner.as_mut() else { + return; + }; + let wanted: Vec<(PathBuf, RecursiveMode)> = { + let mut t = self.targets.lock().unwrap(); + if let Some(s) = snap { + t.learn(s); + } + let mut w = vec![ + (t.radio(), RecursiveMode::Recursive), + (t.monitor(), RecursiveMode::Recursive), + ]; + w.extend( + t.bases + .iter() + .cloned() + .map(|b| (b, RecursiveMode::NonRecursive)), + ); + w + }; + for (path, mode) in wanted { + if !self.watched.contains(&path) && path.is_dir() && inner.watch(&path, mode).is_ok() { + self.watched.insert(path); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ms(n: u64) -> Duration { + Duration::from_millis(n) + } + + #[test] + fn starts_with_a_local_and_a_full_run() { + let t0 = Instant::now(); + let mut s = Scheduler::new(t0, DEFAULT_POLL); + assert_eq!(s.due(t0), vec![Mode::Local, Mode::Full]); + assert_eq!(s.due(t0 + ms(10)), vec![]); + } + + #[test] + fn events_are_debounced_on_the_trailing_edge() { + let t0 = Instant::now(); + let mut s = Scheduler::new(t0, DEFAULT_POLL); + s.due(t0); + s.finished(Mode::Local, t0); + s.fs_event(t0 + ms(100)); + s.fs_event(t0 + ms(300)); + assert_eq!( + s.due(t0 + ms(600)), + vec![], + "300ms after the last event is still inside the debounce" + ); + assert_eq!(s.next_wakeup(t0 + ms(600)), ms(100)); + assert_eq!(s.due(t0 + ms(700)), vec![Mode::Local]); + } + + #[test] + fn a_steady_stream_of_events_still_refreshes() { + let t0 = Instant::now(); + let mut s = Scheduler::new(t0, DEFAULT_POLL); + s.due(t0); + s.finished(Mode::Local, t0); + let mut fired = false; + for i in 1..=30 { + let t = t0 + ms(i * 100); + s.fs_event(t); + fired |= s.due(t).contains(&Mode::Local); + } + assert!(fired, "MAX_WAIT caps the debounce"); + } + + #[test] + fn an_event_during_a_local_run_reruns_after_it() { + let t0 = Instant::now(); + let mut s = Scheduler::new(t0, DEFAULT_POLL); + s.due(t0); + s.fs_event(t0 + ms(10)); + assert_eq!(s.due(t0 + ms(1000)), vec![], "one local run at a time"); + s.finished(Mode::Local, t0 + ms(1100)); + assert_eq!(s.due(t0 + ms(1100)), vec![Mode::Local]); + } + + #[test] + fn the_full_run_repeats_on_the_poll_after_it_finishes() { + let t0 = Instant::now(); + let poll = Duration::from_secs(45); + let mut s = Scheduler::new(t0, poll); + s.due(t0); + s.finished(Mode::Local, t0); + // A slow full run: the next one is timed from when it finished. + let done = t0 + Duration::from_secs(30); + assert_eq!(s.due(done), vec![]); + s.finished(Mode::Full, done); + assert_eq!(s.due(done + poll - ms(1)), vec![]); + assert_eq!(s.due(done + poll), vec![Mode::Full]); + } + + fn targets() -> Targets { + let mut t = Targets::new("/h".into()); + let snap = snapshot::parse(include_str!("../tests/fixtures/multi.json")).unwrap(); + t.learn(&snap); + t + } + + #[test] + fn only_state_files_and_sidecars_count_as_changes() { + let t = targets(); + assert!(t.relevant(Path::new("/h/radio/sessions/pm-x.info"))); + assert!(t.relevant(Path::new("/h/radio/mailbox/pm-x/inbox/1.md"))); + assert!(t.relevant(Path::new("/h/monitor/task-force/queue"))); + assert!(!t.relevant(Path::new("/h/radio/log"))); + assert!(t.relevant(Path::new( + "/home/you/src/task-force-worktrees/.home-env.info" + ))); + assert!(t.relevant(Path::new( + "/home/you/src/recommender-systems-worktrees/.new.info" + ))); + assert!(!t.relevant(Path::new( + "/home/you/src/task-force-worktrees/home-env/target/x.o" + ))); + assert!(!t.relevant(Path::new("/home/you/src/task-force-worktrees/notes.txt"))); + assert!(!t.relevant(Path::new("/elsewhere/.x.info"))); + } + + #[test] + fn targets_behind_a_symlink_match_the_real_paths_events_name() { + let real = tmpdir("canon-real"); + std::fs::create_dir_all(real.join("radio/sessions")).unwrap(); + std::fs::create_dir_all(real.join("wt")).unwrap(); + let link = std::env::temp_dir().join(format!( + "task-monitor-test-canon-link-{}", + std::process::id() + )); + let _ = std::fs::remove_file(&link); + std::os::unix::fs::symlink(&real, &link).unwrap(); + let mut t = Targets::new(link.clone()); + let mut snap = Snapshot::default(); + snap.repos.push(crate::snapshot::Repo { + worktree_base: Some(link.join("wt").display().to_string()), + ..Default::default() + }); + t.learn(&snap); + let real = real.canonicalize().unwrap(); + assert!(t.relevant(&real.join("radio/sessions/pm-x.info"))); + assert!(t.relevant(&real.join("wt/.slug.info"))); + std::fs::remove_file(&link).unwrap(); + } + + #[test] + fn learns_each_worktree_base_once() { + let mut t = targets(); + let snap = snapshot::parse(include_str!("../tests/fixtures/multi.json")).unwrap(); + assert!(t.learn(&snap).is_empty()); + } + + fn script(dir: &Path, body: &str) -> Collector { + use std::os::unix::fs::PermissionsExt; + let p = dir.join("snap"); + std::fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + Collector { program: p } + } + + fn tmpdir(tag: &str) -> PathBuf { + let d = + std::env::temp_dir().join(format!("task-monitor-test-{tag}-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn runs_the_collector_with_the_mode_flags() { + let d = tmpdir("args"); + let c = script( + &d, + &format!( + "echo \"$@\" > {}/args; echo '{{\"schema\":1,\"repos\":[]}}'", + d.display() + ), + ); + c.run(Mode::Local, Duration::from_secs(5)).unwrap(); + assert_eq!( + std::fs::read_to_string(d.join("args")).unwrap().trim(), + "--json --all --local" + ); + c.run(Mode::Full, Duration::from_secs(5)).unwrap(); + assert_eq!( + std::fs::read_to_string(d.join("args")).unwrap().trim(), + "--json --all" + ); + } + + #[test] + fn a_failing_collector_says_why() { + let d = tmpdir("fail"); + let err = script(&d, "echo 'task-monitor-snapshot: not a repo' >&2; exit 3") + .run(Mode::Local, Duration::from_secs(5)) + .unwrap_err(); + assert!( + err.contains("exit 3") && err.contains("not a repo"), + "{err}" + ); + let err = script(&d, "echo '{'") + .run(Mode::Local, Duration::from_secs(5)) + .unwrap_err(); + assert!(err.contains("bad snapshot JSON"), "{err}"); + } + + #[test] + fn a_hung_collector_is_killed_at_the_timeout() { + let d = tmpdir("hang"); + let t = Instant::now(); + let err = script(&d, "exec sleep 30") + .run(Mode::Local, ms(300)) + .unwrap_err(); + assert!(err.contains("timed out"), "{err}"); + assert!(t.elapsed() < Duration::from_secs(5)); + } + + #[test] + fn a_large_snapshot_does_not_deadlock_the_pipe() { + let d = tmpdir("big"); + let pad = "x".repeat(1000); + let body = format!( + "printf '{{\"schema\":1,\"repos\":[],\"pad\":\"'; i=0; while [ $i -lt 400 ]; do printf '{pad}'; i=$((i+1)); done; printf '\"}}'" + ); + assert!(script(&d, &body) + .run(Mode::Full, Duration::from_secs(10)) + .is_ok()); + } +} diff --git a/monitor/src/snapshot.rs b/monitor/src/snapshot.rs new file mode 100644 index 0000000..a126bf8 --- /dev/null +++ b/monitor/src/snapshot.rs @@ -0,0 +1,237 @@ +//! The snapshot document, schema 1 (docs/task-monitor-schema.md). +//! +//! This is a reader of `task-monitor-snapshot --json` and nothing more. Every +//! field is optional and unknown fields are ignored, as the schema's +//! compatibility rule asks: version 1 may grow fields, and a snapshot missing +//! one must still render. Nothing here derives task-force state; it only +//! names what the collector already wrote. + +use serde::{Deserialize, Deserializer}; + +pub const SCHEMA: u64 = 1; + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct Snapshot { + pub schema: Option, + pub generated_at: Option, + pub mode: Option, + pub repos: Vec, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct Repo { + #[serde(deserialize_with = "scalar")] + pub name: Option, + pub root: Option, + pub worktree_base: Option, + pub loadout: Option, + pub tracker: Option, + pub agent: Option, + pub pm: Pm, + pub forge: Forge, + pub in_flight: Vec, + pub next: Next, + pub done: Done, + pub attention: Attention, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct Pm { + pub role: Option, + pub state: Option, + pub last_heartbeat: Option, + pub tab: Option, + #[serde(deserialize_with = "scalar")] + pub tab_id: Option, + pub zellij_session: Option, + pub agent: Option, + pub alias_of: Option, + pub unread: u64, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct Forge { + pub available: bool, + pub fetched_at: Option, + pub stale: bool, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct Session { + pub role: Option, + pub state: Option, + pub last_heartbeat: Option, + pub tab: Option, + #[serde(deserialize_with = "scalar")] + pub tab_id: Option, + pub zellij_session: Option, + pub agent: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct Pr { + pub number: Option, + pub url: Option, + pub title: Option, + pub state: Option, + pub is_draft: bool, + pub head: Option, + pub summary: Option, + pub merged_at: Option, + pub merge_sha: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct Reviewer { + pub pr: Option, + pub role: Option, + pub session: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct InFlight { + pub slug: Option, + #[serde(rename = "ref", deserialize_with = "scalar")] + pub reference: Option, + pub branch: Option, + pub worktree: Option, + pub label: Option, + pub number: Option, + pub title: Option, + pub description: Option, + pub url: Option, + pub pr: Option, + pub status: Option, + pub session: Option, + #[serde(deserialize_with = "scalar")] + pub tab_id: Option, + pub reviewers: Vec, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct Next { + pub source: Option, + pub items: Vec, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct NextItem { + #[serde(rename = "ref", deserialize_with = "scalar")] + pub reference: Option, + pub label: Option, + pub number: Option, + pub title: Option, + pub description: Option, + pub url: Option, + pub note: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct Done { + pub window_hours: Option, + pub min_items: Option, + pub items: Vec, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct DoneItem { + #[serde(rename = "ref", deserialize_with = "scalar")] + pub reference: Option, + pub label: Option, + pub number: Option, + pub title: Option, + pub description: Option, + pub url: Option, + pub summary: Option, + pub body: Option, + pub pr: Option, + pub merged_at: Option, + pub merge_sha: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct Attention { + pub active: bool, + pub signals: Vec, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct Signal { + pub kind: Option, + pub text: Option, +} + +/// Accept a string, a number or null where the schema names a string. A ref +/// is `"266"` today; a collector that ever emits `266` must not blank the +/// screen over it. +fn scalar<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + Ok(match serde_json::Value::deserialize(d)? { + serde_json::Value::String(s) => Some(s), + serde_json::Value::Number(n) => Some(n.to_string()), + serde_json::Value::Bool(b) => Some(b.to_string()), + _ => None, + }) +} + +/// Parse a snapshot. Refuses a different schema version, because a reader +/// that guesses at a breaking change shows wrong data rather than none. +pub fn parse(text: &str) -> Result { + let snap: Snapshot = + serde_json::from_str(text).map_err(|e| format!("bad snapshot JSON: {e}"))?; + match snap.schema { + Some(SCHEMA) => Ok(snap), + Some(v) => Err(format!( + "snapshot schema {v}, this task-monitor reads {SCHEMA}" + )), + None => Err("snapshot has no schema field".into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_the_documented_example() { + let snap = parse(include_str!("../../docs/task-monitor-schema.example.json")).unwrap(); + let repo = &snap.repos[0]; + assert_eq!(repo.name.as_deref(), Some("task-force")); + assert_eq!(repo.pm.unread, 1); + assert_eq!(repo.in_flight[0].slug.as_deref(), Some("monitor-snapshot")); + assert_eq!(repo.in_flight[0].tab_id.as_deref(), Some("1")); + assert!(repo.attention.active); + assert_eq!(repo.done.items[0].pr.as_ref().unwrap().number, Some(269)); + } + + #[test] + fn tolerates_missing_and_unknown_fields() { + let snap = parse(r#"{"schema":1,"repos":[{"name":"x","future":{"a":1},"in_flight":[{"ref":42,"tab_id":3}]}]}"#).unwrap(); + let item = &snap.repos[0].in_flight[0]; + assert_eq!(item.reference.as_deref(), Some("42")); + assert_eq!(item.tab_id.as_deref(), Some("3")); + assert!(snap.repos[0].pm.role.is_none()); + } + + #[test] + fn refuses_another_schema_version() { + assert!(parse(r#"{"schema":2,"repos":[]}"#) + .unwrap_err() + .contains("schema 2")); + assert!(parse(r#"{"repos":[]}"#).is_err()); + assert!(parse("not json").is_err()); + } +} diff --git a/monitor/src/ui.rs b/monitor/src/ui.rs new file mode 100644 index 0000000..80745e0 --- /dev/null +++ b/monitor/src/ui.rs @@ -0,0 +1,956 @@ +//! Rendering. Every word on screen is a snapshot field or a fixed label: the +//! words for a status are a lookup of the collector's `status` value, and an +//! unknown value is shown as it came rather than guessed at. + +use chrono::{DateTime, Local, Utc}; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +use crate::app::{rows, App, Row, View}; +use crate::snapshot::{DoneItem, InFlight, NextItem, Repo}; + +/// How long a flash message stays in the footer. +const FLASH_SECS: i64 = 6; + +fn dim() -> Style { + Style::default().fg(Color::DarkGray) +} +fn bold() -> Style { + Style::default().add_modifier(Modifier::BOLD) +} + +pub fn draw(f: &mut Frame, app: &mut App, now: DateTime) { + let area = f.area(); + let [header, rule1, body, rule2, status, footer] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas(area); + let w = area.width as usize; + + let (title, keys) = match (&app.view, app.detail.is_some()) { + (View::Overview, _) => ( + Line::from(Span::styled(" task-monitor · all repos", bold())), + " ↑↓ move ⏎ open repo t jump to PM tab a back q quit", + ), + (View::Repo(_), detail) => ( + repo_title(app.current_repo()), + if detail { + " ↑↓ scroll ⏎/esc close o open PR/issue t jump to tab q quit" + } else { + " ↑↓ move ⏎ expand o open PR/issue t jump to tab a all repos q quit" + }, + ), + }; + let fresh = freshness(app, now); + f.render_widget(Paragraph::new(fit(title, fresh, w)), header); + f.render_widget(Paragraph::new(rule(w)), rule1); + f.render_widget(Paragraph::new(rule(w)), rule2); + + let status_line = match (&app.flash, &app.freshness.error) { + (Some((at, msg)), _) if (now - *at).num_seconds() < FLASH_SECS => Line::from(Span::styled( + format!(" {msg}"), + Style::default().fg(Color::Yellow), + )), + (_, Some((_, why))) => Line::from(Span::styled( + format!(" snapshot failed: {why}"), + Style::default().fg(Color::Red), + )), + _ => Line::default(), + }; + f.render_widget(Paragraph::new(truncate_line(status_line, w)), status); + f.render_widget(Paragraph::new(Span::styled(keys, dim())), footer); + + if app.snapshot.is_none() { + let msg = match &app.freshness.error { + Some(_) => " no snapshot yet", + None => " loading the first snapshot…", + }; + f.render_widget(Paragraph::new(Span::styled(msg, dim())), body); + return; + } + match (&app.view, app.detail) { + (View::Overview, _) => overview(f, app, body), + (View::Repo(name), detail) => match app.current_repo() { + None => { + let msg = format!(" {name} is not in the latest snapshot · a for all repos"); + f.render_widget(Paragraph::new(Span::styled(msg, dim())), body); + } + Some(_) if detail.is_some() => detail_view(f, app, body, now), + Some(repo) => repo_view(f, repo, app.selected, body, now), + }, + } +} + +fn repo_title(repo: Option<&Repo>) -> Line<'static> { + let Some(repo) = repo else { + return Line::from(" task-monitor"); + }; + let mut spans = vec![Span::styled( + format!(" {}", repo.name.as_deref().unwrap_or("?")), + bold(), + )]; + let pm = &repo.pm; + let pm_text = match (&pm.alias_of, pm.state.as_ref(), &pm.agent) { + (Some(primary), _, _) => format!("PM via {primary}"), + (None, None, _) => "no PM".to_string(), + (None, Some(_), agent) => format!( + "PM {}", + agent.as_deref().or(repo.agent.as_deref()).unwrap_or("") + ), + }; + spans.push(Span::raw(format!(" · {}", pm_text.trim_end()))); + if pm.unread > 0 { + spans.push(Span::raw(" · ")); + spans.push(Span::styled( + format!("✉ {} unread", pm.unread), + Style::default().fg(Color::Yellow), + )); + } + Line::from(spans) +} + +/// The header's right side: the clock, then `live`, or what is stale and +/// for how long. +fn freshness(app: &App, now: DateTime) -> Line<'static> { + let clock = now.with_timezone(&Local).format("%H:%M").to_string(); + let (text, style) = if app.snapshot.is_none() { + ("loading".to_string(), dim()) + } else if app.freshness.error.is_some() { + let age = app + .freshness + .received_at + .map(|t| ago(t, now)) + .unwrap_or_default(); + (format!("stale {age}"), Style::default().fg(Color::Red)) + } else if let Some(fetched) = forge_stale_since(app) { + ( + format!( + "forge {}", + fetched + .map(|t| ago(t, now)) + .unwrap_or_else(|| "stale".into()) + ), + Style::default().fg(Color::Yellow), + ) + } else { + ("live".to_string(), Style::default().fg(Color::Green)) + }; + Line::from(vec![ + Span::raw(format!("{clock} · ")), + Span::styled(text, style), + Span::raw(" "), + ]) +} + +/// The forge state on screen is older than this run, for the repos shown: +/// `Some(fetched_at)` when stale. A repo with no forge is never stale. +fn forge_stale_since(app: &App) -> Option>> { + let shown: Vec<&Repo> = match app.current_repo() { + Some(r) => vec![r], + None => app.repos().iter().collect(), + }; + let stale: Vec<&Repo> = shown + .into_iter() + .filter(|r| r.forge.available && r.forge.stale) + .collect(); + if stale.is_empty() { + return None; + } + Some( + stale + .iter() + .filter_map(|r| r.forge.fetched_at.as_deref().and_then(parse_time)) + .min(), + ) +} + +fn overview(f: &mut Frame, app: &App, area: Rect) { + let w = area.width as usize; + let repos = app.repos(); + let name_w = repos + .iter() + .map(|r| r.name.as_deref().unwrap_or("?").width()) + .max() + .unwrap_or(0); + let mut lines = Vec::new(); + for (i, repo) in repos.iter().enumerate() { + let selected = i == app.overview_selected; + let (dot, dot_style) = if repo.attention.active { + ("●", Style::default().fg(Color::Yellow)) + } else { + ("○", dim()) + }; + let name = repo.name.as_deref().unwrap_or("?"); + let signals: Vec<&str> = repo + .attention + .signals + .iter() + .filter_map(|s| s.text.as_deref()) + .collect(); + let mut left = vec![ + Span::raw(if selected { "▸" } else { " " }), + Span::styled(format!("{dot} "), dot_style), + Span::styled( + format!("{name: Option<&str> { + repo.pm.zellij_session.as_deref().or_else(|| { + repo.in_flight + .iter() + .find_map(|i| i.session.as_ref()?.zellij_session.as_deref()) + }) +} + +fn repo_view(f: &mut Frame, repo: &Repo, selected: usize, area: Rect, now: DateTime) { + let w = area.width as usize; + let mut lines: Vec> = Vec::new(); + let mut sel_span = (0, 0); + let mut idx = 0; + // Append one selectable row, noting where the selected one landed. + let mut push = |lines: &mut Vec>, idx: &mut usize, row: Vec>| { + if *idx == selected { + sel_span = (lines.len(), lines.len() + row.len()); + } + *idx += 1; + lines.extend(row); + }; + + lines.push(section(format!("IN FLIGHT ({})", repo.in_flight.len()))); + if repo.in_flight.is_empty() { + lines.push(empty("nothing in flight")); + } + for item in &repo.in_flight { + let row = in_flight_lines(item, idx == selected, w, now); + push(&mut lines, &mut idx, row); + } + lines.push(Line::from(rule(w))); + + let source = match repo.next.source.as_deref() { + Some("queue") => " (PM queue)".to_string(), + Some("tracker") => " (tracker backlog)".to_string(), + Some("none") | None => String::new(), + Some(other) => format!(" ({other})"), + }; + lines.push(section(format!("NEXT{source}"))); + if repo.next.items.is_empty() { + lines.push(empty("nothing queued")); + } + for (i, item) in repo.next.items.iter().enumerate() { + let row = next_lines(i + 1, item, idx == selected, w); + push(&mut lines, &mut idx, row); + } + lines.push(Line::from(rule(w))); + + let window = repo + .done + .window_hours + .map(|h| format!(" (last {h}h)")) + .unwrap_or_default(); + lines.push(section(format!("DONE{window}"))); + if repo.done.items.is_empty() { + lines.push(empty("nothing merged recently")); + } + for item in &repo.done.items { + let row = done_lines(item, idx == selected, w, now); + push(&mut lines, &mut idx, row); + } + debug_assert_eq!(idx, rows(repo).len()); + + let offset = scroll_to(sel_span.0, sel_span.1, area.height as usize); + f.render_widget(Paragraph::new(lines).scroll((offset as u16, 0)), area); +} + +fn section(title: String) -> Line<'static> { + Line::from(Span::styled(format!(" {title}"), bold())) +} + +fn empty(text: &str) -> Line<'static> { + Line::from(Span::styled(format!(" {text}"), dim())) +} + +fn marker(selected: bool) -> Span<'static> { + Span::styled(if selected { " ▸ " } else { " " }, bold()) +} + +fn title_style(selected: bool) -> Style { + if selected { + bold() + } else { + Style::default() + } +} + +/// The words for a collector `status` (docs/task-monitor-schema.md). +pub fn status_words(status: &str) -> &str { + match status { + "implementing" => "implementing", + "pr-open" => "open", + "under-review" => "under review", + "changes-requested" => "changes requested", + "re-review" => "re-review", + "waiting-merge" => "waiting merge", + "merged" => "merged", + "closed" => "closed", + other => other, + } +} + +/// `▶` / `⏸` / `❓` for a session `STATE=`, or a dot with no session. +pub fn state_icon(state: Option<&str>) -> &'static str { + match state { + Some("busy") => "▶", + Some("idle") => "⏸", + Some("awaiting") => "❓", + _ => "·", + } +} + +fn in_flight_lines( + item: &InFlight, + selected: bool, + w: usize, + now: DateTime, +) -> Vec> { + let label = item + .label + .clone() + .or_else(|| item.slug.clone()) + .unwrap_or_default(); + let title = item.title.clone().unwrap_or_default(); + let status = status_words(item.status.as_deref().unwrap_or("")).to_string(); + let status = match item.pr.as_ref().and_then(|p| p.number) { + Some(n) if item.status.as_deref() == Some("pr-open") => format!("PR #{n} open"), + Some(n) => format!("PR #{n} {status}"), + None => status, + }; + let status_style = match item.status.as_deref() { + Some("changes-requested") => Style::default().fg(Color::Red), + Some("waiting-merge") | Some("merged") => Style::default().fg(Color::Green), + _ => Style::default().fg(Color::Cyan), + }; + let line1 = fit( + Line::from(vec![ + marker(selected), + Span::styled(format!("{label:<6}"), bold()), + Span::raw(" "), + Span::styled(title, title_style(selected)), + ]), + Line::from(vec![Span::styled(status, status_style), Span::raw(" ")]), + w, + ); + let session = item.session.as_ref(); + let icon = state_icon(session.and_then(|s| s.state.as_deref())); + let age = session + .and_then(|s| s.last_heartbeat.as_deref()) + .and_then(parse_time) + .map(|t| ago(t, now)); + let activity = match (session, age) { + (None, _) => "· no session".to_string(), + (Some(_), Some(age)) => format!("{icon} {age}"), + (Some(_), None) => icon.to_string(), + }; + let activity_style = match session.and_then(|s| s.state.as_deref()) { + Some("awaiting") => Style::default().fg(Color::Yellow), + _ => dim(), + }; + let mut right = vec![]; + if !item.reviewers.is_empty() { + right.push(Span::styled( + format!("{} reviewing · ", item.reviewers.len()), + dim(), + )); + } + right.push(Span::styled(activity, activity_style)); + right.push(Span::raw(" ")); + let desc = item.description.clone().unwrap_or_default(); + let line2 = fit( + Line::from(vec![ + Span::raw(" ".repeat(10)), + Span::styled(first_line(&desc), dim()), + ]), + Line::from(right), + w, + ); + vec![line1, line2] +} + +fn next_lines(n: usize, item: &NextItem, selected: bool, w: usize) -> Vec> { + let label = item + .label + .clone() + .or_else(|| item.reference.clone()) + .unwrap_or_default(); + let right = match &item.note { + Some(note) => Line::from(vec![ + Span::styled(format!("\"{note}\""), Style::default().fg(Color::Cyan)), + Span::raw(" "), + ]), + None => Line::default(), + }; + let mut out = vec![fit( + Line::from(vec![ + marker(selected), + Span::styled(format!("{n:<2} "), dim()), + Span::styled(format!("{label:<6}"), bold()), + Span::raw(" "), + Span::styled( + item.title.clone().unwrap_or_default(), + title_style(selected), + ), + ]), + right, + w, + )]; + if let Some(desc) = item.description.as_deref().filter(|d| !d.is_empty()) { + out.push(truncate_line( + Line::from(vec![ + Span::raw(" ".repeat(13)), + Span::styled(first_line(desc), dim()), + ]), + w, + )); + } + out +} + +fn done_lines(item: &DoneItem, selected: bool, w: usize, now: DateTime) -> Vec> { + let label = item + .label + .clone() + .or_else(|| item.reference.clone()) + .unwrap_or_default(); + let mut meta = Vec::new(); + if let Some(n) = item.pr.as_ref().and_then(|p| p.number) { + meta.push(format!("PR #{n}")); + } + if let Some(sha) = item.merge_sha.as_deref().filter(|s| !s.is_empty()) { + meta.push(sha.chars().take(7).collect()); + } + if let Some(t) = item.merged_at.as_deref().and_then(parse_time) { + meta.push(format!("{} ago", ago(t, now))); + } + let mut out = vec![fit( + Line::from(vec![ + marker(selected), + Span::styled(format!("{label:<6}"), bold()), + Span::raw(" "), + Span::styled( + item.title.clone().unwrap_or_default(), + title_style(selected), + ), + ]), + Line::from(vec![Span::styled(meta.join(" · "), dim()), Span::raw(" ")]), + w, + )]; + // The summary, wrapped to two lines: the account of what the solution was. + let summary = item + .description + .as_deref() + .or(item.summary.as_deref()) + .unwrap_or(""); + let indent = 10; + let wrapped = wrap(summary, w.saturating_sub(indent + 1).max(10)); + for (i, l) in wrapped.iter().take(2).enumerate() { + let text = if i == 1 && wrapped.len() > 2 { + ellipsize(l, w.saturating_sub(indent + 2)) + } else { + l.clone() + }; + out.push(Line::from(vec![ + Span::raw(" ".repeat(indent)), + Span::styled(text, dim()), + ])); + } + out +} + +/// The expanded item: its full text, scrollable. A done item shows the whole +/// PR body; the others show everything the snapshot has on them. +fn detail_view(f: &mut Frame, app: &mut App, area: Rect, now: DateTime) { + let (Some(repo), Some(row)) = (app.current_repo(), app.selected_row()) else { + app.detail = None; + return; + }; + let w = area.width as usize; + let mut head: Vec = Vec::new(); + let mut text; + match row { + Row::InFlight(i) => { + let item = &repo.in_flight[i]; + head.extend(in_flight_lines(item, true, w, now).into_iter().take(1)); + let mut meta = vec![format!("branch {}", item.branch.as_deref().unwrap_or("?"))]; + if let Some(r) = item.session.as_ref().and_then(|s| s.role.as_deref()) { + meta.push(r.to_string()); + } + for r in &item.reviewers { + meta.push(format!( + "{} {}", + r.role.as_deref().unwrap_or("reviewer"), + state_icon(r.session.as_ref().and_then(|s| s.state.as_deref())) + )); + } + head.push(Line::from(Span::styled( + format!(" {}", meta.join(" · ")), + dim(), + ))); + text = item.description.clone().unwrap_or_default(); + } + Row::Next(i) => { + let item = &repo.next.items[i]; + head.extend(next_lines(i + 1, item, true, w).into_iter().take(1)); + text = item.description.clone().unwrap_or_default(); + } + Row::Done(i) => { + let item = &repo.done.items[i]; + head.extend(done_lines(item, true, w, now).into_iter().take(1)); + text = item + .body + .clone() + .or_else(|| item.summary.clone()) + .unwrap_or_default(); + } + } + if text.trim().is_empty() { + text = "(no description)".into(); + } + head.push(Line::default()); + + let body_w = w.saturating_sub(4).max(10); + let body: Vec = text + .lines() + .flat_map(|l| wrap_indented(l, body_w)) + .collect(); + let visible = (area.height as usize).saturating_sub(head.len()).max(1); + let max_scroll = body.len().saturating_sub(visible); + let scroll = (app.detail.unwrap_or(0) as usize).min(max_scroll); + app.detail = Some(scroll as u16); + + let [top, rest] = + Layout::vertical([Constraint::Length(head.len() as u16), Constraint::Min(1)]).areas(area); + f.render_widget(Paragraph::new(head), top); + let lines: Vec = body + .into_iter() + .map(|l| Line::from(format!(" {l}"))) + .collect(); + f.render_widget(Paragraph::new(lines).scroll((scroll as u16, 0)), rest); +} + +// ---- text helpers ------------------------------------------------------ + +pub fn parse_time(s: &str) -> Option> { + DateTime::parse_from_rfc3339(s) + .ok() + .map(|t| t.with_timezone(&Utc)) +} + +/// A compact age: `40s`, `14m`, `3h`, `2d`. +pub fn ago(t: DateTime, now: DateTime) -> String { + let s = (now - t).num_seconds().max(0); + match s { + 0..=59 => format!("{s}s"), + 60..=3599 => format!("{}m", s / 60), + 3600..=86399 => format!("{}h", s / 3600), + _ => format!("{}d", s / 86400), + } +} + +fn first_line(s: &str) -> String { + s.lines().next().unwrap_or("").to_string() +} + +fn rule(w: usize) -> Span<'static> { + Span::styled("─".repeat(w), dim()) +} + +fn line_width(line: &Line) -> usize { + line.spans.iter().map(|s| s.content.width()).sum() +} + +/// `left` padded so `right` ends at the edge. When both do not fit, `left` +/// is cut first: the status on the right is what the eye scans for. +pub fn fit(left: Line<'static>, right: Line<'static>, w: usize) -> Line<'static> { + let rw = line_width(&right); + let room = w.saturating_sub(rw + 1); + let mut left = truncate_line(left, room); + let pad = w.saturating_sub(line_width(&left) + rw); + left.spans.push(Span::raw(" ".repeat(pad))); + left.spans.extend(right.spans); + truncate_line(left, w) +} + +/// Cut a line to `w` columns, ending in `…` when anything was cut. +pub fn truncate_line(line: Line<'static>, w: usize) -> Line<'static> { + if line_width(&line) <= w { + return line; + } + let mut out = Vec::new(); + let mut used = 0; + let budget = w.saturating_sub(1); + for span in line.spans { + let sw = span.content.width(); + if used + sw <= budget { + used += sw; + out.push(span); + continue; + } + let mut cut = String::new(); + for c in span.content.chars() { + let cw = c.width().unwrap_or(0); + if used + cw > budget { + break; + } + used += cw; + cut.push(c); + } + out.push(Span::styled(cut, span.style)); + break; + } + if w > 0 { + out.push(Span::raw("…")); + } + Line::from(out) +} + +fn ellipsize(s: &str, w: usize) -> String { + let mut out: String = String::new(); + let mut used = 0; + for c in s.chars() { + let cw = c.width().unwrap_or(0); + if used + cw > w { + break; + } + used += cw; + out.push(c); + } + out.push('…'); + out +} + +/// Greedy word wrap by display width; a word wider than the line is split. +pub fn wrap(text: &str, w: usize) -> Vec { + let w = w.max(1); + let mut out = Vec::new(); + let mut cur = String::new(); + let mut cur_w = 0; + for word in text.split_whitespace() { + let ww = word.width(); + if cur_w > 0 && cur_w + 1 + ww > w { + out.push(std::mem::take(&mut cur)); + cur_w = 0; + } + if ww > w { + for c in word.chars() { + let cw = c.width().unwrap_or(0); + if cur_w + cw > w { + out.push(std::mem::take(&mut cur)); + cur_w = 0; + } + cur.push(c); + cur_w += cw; + } + continue; + } + if cur_w > 0 { + cur.push(' '); + cur_w += 1; + } + cur.push_str(word); + cur_w += ww; + } + if !cur.is_empty() { + out.push(cur); + } + out +} + +/// Wrap one line of a body, keeping its leading indent on every piece so a +/// nested list stays nested. +fn wrap_indented(line: &str, w: usize) -> Vec { + let rest = line.trim_start(); + if rest.is_empty() { + return vec![String::new()]; + } + let indent = &line[..line.len() - rest.len()]; + let iw = indent.width().min(w / 2); + wrap(rest, w - iw) + .into_iter() + .map(|l| format!("{}{l}", " ".repeat(iw))) + .collect() +} + +/// The first line to show so `[start, end)` is on screen, scrolling as +/// little as possible from the top. +fn scroll_to(start: usize, end: usize, height: usize) -> usize { + if end <= height { + 0 + } else { + start.min(end.saturating_sub(height)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{App, Key}; + use crate::snapshot::parse; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + use std::time::Instant; + + const MULTI: &str = include_str!("../tests/fixtures/multi.json"); + + fn now() -> DateTime { + "2026-10-20T15:00:00Z".parse().unwrap() + } + + fn app(json: &str, in_repo: bool) -> App { + let mut app = App::new(in_repo); + app.offer(Instant::now(), parse(json).unwrap(), now()); + app + } + + fn render(app: &mut App, w: u16, h: u16) -> Vec { + let mut term = Terminal::new(TestBackend::new(w, h)).unwrap(); + term.draw(|f| draw(f, app, now())).unwrap(); + let buf = term.backend().buffer().clone(); + (0..h) + .map(|y| { + let mut s = String::new(); + let mut x = 0; + while x < w { + let cell = &buf[(x, y)]; + s.push_str(cell.symbol()); + x += cell.symbol().width().max(1) as u16; + } + s.trim_end().to_string() + }) + .collect() + } + + fn screen(lines: &[String]) -> String { + lines.join("\n") + } + + fn line_with<'a>(lines: &'a [String], needle: &str) -> &'a str { + lines + .iter() + .find(|l| l.contains(needle)) + .unwrap_or_else(|| panic!("no line contains {needle:?}:\n{}", screen(lines))) + } + + #[test] + fn repo_view_shows_the_three_sections() { + let mut a = app(MULTI, true); + let s = render(&mut a, 100, 30); + let text = screen(&s); + assert!( + s[0].starts_with(" task-force · PM claude · ✉ 1 unread"), + "{}", + s[0] + ); + assert!(s[0].ends_with("· live"), "{}", s[0]); + for want in [ + "IN FLIGHT (2)", + "NEXT (PM queue)", + "DONE (last 24h)", + "↑↓ move ⏎ expand", + ] { + assert!(text.contains(want), "missing {want:?}:\n{text}"); + } + let row = line_with(&s, "#267"); + assert!( + row.starts_with(" ▸ #267"), + "the first item is selected: {row}" + ); + assert!(row.ends_with("PR #270 re-review"), "{row}"); + assert!(line_with(&s, "Teach the hook merge").ends_with("1 reviewing · ▶ 14m")); + assert!(line_with(&s, "#262").ends_with("implementing")); + assert!(line_with(&s, "A scratch-home probe").ends_with("❓ 3m")); + assert!(line_with(&s, "#180").ends_with("\"after #267\"")); + assert!(line_with(&s, "#266").ends_with("PR #269 · 1415b86 · 2h ago")); + assert!(text.contains("Both whitespace checks use a grep helper")); + } + + #[test] + fn narrow_terminals_cut_the_title_not_the_status() { + let mut a = app(MULTI, true); + let s = render(&mut a, 60, 30); + let row = line_with(&s, "#267"); + assert!(row.ends_with("PR #270 re-review"), "{row}"); + assert!(row.contains('…'), "{row}"); + assert!(s.iter().all(|l| l.width() <= 60)); + } + + #[test] + fn the_selection_scrolls_into_view() { + let mut a = app(MULTI, true); + for _ in 0..10 { + a.key(Key::Down, now()); + } + let s = render(&mut a, 100, 12); + assert!(line_with(&s, "#266").starts_with(" ▸ #266")); + } + + #[test] + fn enter_expands_a_done_item_to_its_full_scrollable_body() { + let mut a = app(MULTI, true); + for _ in 0..10 { + a.key(Key::Down, now()); + } + a.key(Key::Enter, now()); + let s = render(&mut a, 100, 17); + let text = screen(&s); + assert!( + text.contains("Closes #266") && text.contains("line one of the body"), + "{text}" + ); + assert!( + !text.contains("THE LAST LINE"), + "the body is longer than the screen" + ); + assert!(text.contains("↑↓ scroll")); + for _ in 0..40 { + a.key(Key::Down, now()); + } + let text = screen(&render(&mut a, 100, 17)); + assert!(text.contains("THE LAST LINE OF THE BODY"), "{text}"); + assert!( + a.detail.unwrap() < 40, + "scrolling stops at the end of the body" + ); + } + + #[test] + fn overview_lights_repos_with_signals_and_names_their_session() { + let mut a = app(MULTI, false); + let s = render(&mut a, 110, 12); + assert!(s[0].starts_with(" task-monitor · all repos")); + let tf = line_with(&s, "task-force "); + assert!(tf.starts_with("▸● task-force"), "{tf}"); + assert!(tf.contains("✉ 1 unread · 1 review waiting merge"), "{tf}"); + assert!(tf.ends_with("2 in flight · zellij task-force"), "{tf}"); + assert!(line_with(&s, "recommender-systems").contains("❓ rerank-sasrec-gate is asking")); + assert!(line_with(&s, "recommender-systems").ends_with("zellij rec")); + let other = line_with(&s, "other-repo"); + assert!(other.starts_with(" ○ other-repo"), "{other}"); + assert!(other.ends_with("idle · zellij other"), "{other}"); + // No PM session and no worker session: no zellij session to name. + assert!(line_with(&s, "jira-repo").ends_with("1 in flight")); + } + + #[test] + fn a_failed_refresh_keeps_the_screen_and_marks_it_stale() { + let mut a = app(MULTI, true); + a.failed(now(), "task-monitor-snapshot timed out after 20s".into()); + let s = render(&mut a, 100, 30); + assert!(s[0].contains("stale 0s"), "{}", s[0]); + assert!( + screen(&s).contains("#267"), + "the last good snapshot is still drawn" + ); + assert!(screen(&s).contains("snapshot failed: task-monitor-snapshot timed out after 20s")); + } + + #[test] + fn a_stale_forge_is_marked_with_its_age() { + let mut a = app(MULTI, true); + a.key(Key::Char('a'), now()); + a.overview_selected = 2; + a.key(Key::Enter, now()); + let s = render(&mut a, 100, 20); + assert!(s[0].starts_with(" jira-repo · no PM"), "{}", s[0]); + assert!(s[0].ends_with("· forge 1h"), "{}", s[0]); + assert!(line_with(&s, "PROJ-42").contains("PROJ-42 · kiro-config2")); + line_with(&s, "· no session"); // panics when absent + assert!(screen(&s).contains("nothing queued")); + } + + #[test] + fn before_the_first_snapshot_it_says_so() { + let mut a = App::new(true); + let s = render(&mut a, 80, 10); + assert!(screen(&s).contains("loading the first snapshot")); + a.failed(now(), "boom".into()); + assert!(screen(&render(&mut a, 80, 10)).contains("snapshot failed: boom")); + } + + #[test] + fn renders_the_documented_example() { + let mut a = app( + include_str!("../../docs/task-monitor-schema.example.json"), + true, + ); + let text = screen(&render(&mut a, 120, 40)); + assert!(text.contains("#273"), "{text}"); + assert!(text.contains("NEXT (tracker backlog)")); + } + + #[test] + fn a_flash_shows_then_expires() { + let mut a = app(MULTI, true); + a.flash(now(), "tab 4 is now 'other', not kiro-config"); + assert!(screen(&render(&mut a, 100, 30)).contains("tab 4 is now 'other'")); + a.flash = Some(( + now() - chrono::Duration::seconds(FLASH_SECS + 1), + "old".into(), + )); + assert!(!screen(&render(&mut a, 100, 30)).contains("old")); + } + + #[test] + fn wrap_and_age_helpers() { + assert_eq!(wrap("aa bb cc", 5), vec!["aa bb", "cc"]); + assert_eq!(wrap("abcdefgh", 3), vec!["abc", "def", "gh"]); + assert_eq!(wrap_indented(" - aa bb", 6), vec![" - aa", " bb"]); + assert_eq!(wrap_indented(" ", 6), vec![""]); + let t = now(); + assert_eq!(ago(t - chrono::Duration::seconds(30), t), "30s"); + assert_eq!(ago(t - chrono::Duration::minutes(14), t), "14m"); + assert_eq!(ago(t - chrono::Duration::hours(26), t), "1d"); + assert_eq!(status_words("waiting-merge"), "waiting merge"); + assert_eq!(status_words("brand-new"), "brand-new"); + } +} diff --git a/monitor/tests/fixtures/multi.json b/monitor/tests/fixtures/multi.json new file mode 100644 index 0000000..1aad2f5 --- /dev/null +++ b/monitor/tests/fixtures/multi.json @@ -0,0 +1,145 @@ +{ + "schema": 1, + "generated_at": "2026-10-20T15:00:00Z", + "mode": "full", + "repos": [ + { + "name": "task-force", + "root": "/home/you/src/task-force", + "worktree_base": "/home/you/src/task-force-worktrees", + "loadout": "claude-gh", + "tracker": "gh", + "agent": "claude", + "pm": { + "role": "pm-task-force", "state": "idle", "last_heartbeat": "2026-10-20T14:58:00Z", + "tab": "⏸️ pm-task-force", "tab_id": "0", "zellij_session": "task-force", + "agent": "claude", "alias_of": null, "unread": 1 + }, + "forge": { "available": true, "fetched_at": "2026-10-20T14:59:30Z", "stale": false }, + "in_flight": [ + { + "slug": "kiro-config", "ref": "https://github.com/o/task-force/issues/267", + "branch": "task/kiro-config", "base": "main", + "worktree": "/home/you/src/task-force-worktrees/kiro-config", + "label": "#267", "number": 267, + "url": "https://github.com/o/task-force/issues/267", + "title": "kiro upgraded agent-config breaks task-init/remove", + "description": "Teach the hook merge and strip the new array hook shape", + "item": { "ref": "267", "label": "#267", "number": 267, "title": "the issue title", "description": "the issue body", "url": "https://github.com/o/task-force/issues/267" }, + "pr": { + "number": 270, "url": "https://github.com/o/task-force/pull/270", + "title": "kiro upgraded agent-config breaks task-init/remove", "state": "OPEN", + "is_draft": false, "head": "task/kiro-config", + "summary": "Teach the hook merge and strip the new array hook shape", + "merged_at": null, "merge_sha": null + }, + "status": "re-review", + "last_intent": { "intent": "re-review-requested", "from": "worker-task-force-kiro-config", "to": "pm-task-force", "pr": 270, "at": "2026-10-20T14:46:00Z" }, + "session": { + "role": "worker-task-force-kiro-config", "state": "busy", "last_heartbeat": "2026-10-20T14:46:00Z", + "tab": "▶️ kiro-config", "tab_id": "9", "zellij_session": "task-force", "agent": "claude" + }, + "tab_id": "4", + "reviewers": [ + { "pr": 270, "role": "reviewer-task-force-pr270", "session": { "role": "reviewer-task-force-pr270", "state": "busy", "last_heartbeat": "2026-10-20T14:55:00Z", "tab": "▶️ review-pr270", "tab_id": "8", "zellij_session": "task-force", "agent": "claude" } } + ] + }, + { + "slug": "home-env", "ref": "https://github.com/o/task-force/issues/262", + "branch": "task/home-env", "base": "main", + "worktree": "/home/you/src/task-force-worktrees/home-env", + "label": "#262", "number": 262, + "url": "https://github.com/o/task-force/issues/262", + "title": "Launched tabs ignore TASK_FORCE_HOME", + "description": "A scratch-home probe registers in the real radio state", + "pr": null, "status": "implementing", "last_intent": null, + "session": { + "role": "worker-task-force-home-env", "state": "awaiting", "last_heartbeat": "2026-10-20T14:57:00Z", + "tab": "❓︎ home-env", "tab_id": "7", "zellij_session": "task-force", "agent": "claude" + }, + "tab_id": null, + "reviewers": [] + } + ], + "next": { + "source": "queue", + "items": [ + { "ref": "180", "label": "#180", "number": 180, "title": "stale TAB_ID / ambiguous tab / .info sourcing", "description": null, "url": "https://github.com/o/task-force/issues/180", "note": "after #267" }, + { "ref": "241", "label": "#241", "number": 241, "title": "task-done unregister undone one turn later", "description": "The session file comes back a turn after task-done removed it.", "url": "https://github.com/o/task-force/issues/241", "note": null } + ] + }, + "done": { + "window_hours": 24, "min_items": 10, + "items": [ + { + "ref": "266", "label": "#266", "number": 266, + "title": "task-remove hangs under bash 3.2", + "description": "Both whitespace checks use a grep helper instead of a bash pattern substitution that never finished on large UTF-8 files.", + "url": "https://github.com/o/task-force/pull/269", + "summary": "Both whitespace checks use a grep helper instead of a bash pattern substitution that never finished on large UTF-8 files.", + "body": "## Summary\nBoth whitespace checks use a grep helper instead of a bash pattern substitution that never finished on large UTF-8 files.\n\nCloses #266\n\n## What\nline one of the body\nline two of the body\nline three of the body\nline four of the body\nline five of the body\nline six of the body\nline seven of the body\nline eight of the body\nline nine of the body\nline ten of the body\nline eleven of the body\nTHE LAST LINE OF THE BODY", + "pr": { "number": 269, "url": "https://github.com/o/task-force/pull/269", "title": "task-remove hangs under bash 3.2", "state": "MERGED", "is_draft": false, "head": "task/task-remove-hang", "merged_at": "2026-10-20T13:00:00Z", "merge_sha": "1415b86f7b499bdbb7057fcdc771084ff8717fd3" }, + "merged_at": "2026-10-20T13:00:00Z", + "merge_sha": "1415b86f7b499bdbb7057fcdc771084ff8717fd3" + } + ] + }, + "attention": { + "active": true, + "signals": [ + { "kind": "unread-inbox", "count": 1, "text": "✉ 1 unread" }, + { "kind": "review-waiting-merge", "prs": [270], "text": "1 review waiting merge" } + ] + } + }, + { + "name": "recommender-systems", + "root": "/home/you/src/recommender-systems", + "worktree_base": "/home/you/src/recommender-systems-worktrees", + "loadout": "claude-local", "tracker": "local", "agent": "claude", + "pm": { "role": "pm-recommender-systems", "state": "busy", "last_heartbeat": "2026-10-20T14:59:00Z", "tab": "▶️ pm-recommender-systems", "tab_id": "2", "zellij_session": "rec", "agent": "claude", "alias_of": null, "unread": 0 }, + "forge": { "available": false, "fetched_at": null, "stale": false }, + "in_flight": [ + { + "slug": "rerank-sasrec-gate", "ref": "tasks/12-rerank.md", "branch": "task/rerank-sasrec-gate", + "worktree": "/home/you/src/recommender-systems-worktrees/rerank-sasrec-gate", + "label": "12", "number": 12, "url": null, "title": "Gate the SASRec rerank", "description": null, + "pr": null, "status": "implementing", + "session": { "role": "worker-recommender-systems-rerank-sasrec-gate", "state": "awaiting", "last_heartbeat": "2026-10-20T14:50:00Z", "tab": "❓︎ rerank-sasrec-gate", "tab_id": "3", "zellij_session": "rec", "agent": "claude" }, + "tab_id": "3", "reviewers": [] + } + ], + "next": { "source": "none", "items": [] }, + "done": { "window_hours": 24, "min_items": 10, "items": [] }, + "attention": { "active": true, "signals": [ { "kind": "worker-awaiting", "workers": [], "text": "❓ rerank-sasrec-gate is asking" } ] } + }, + { + "name": "jira-repo", + "root": "/home/you/src/jira-repo", + "loadout": "kiro-jira", "tracker": "jira", "agent": "kiro", + "pm": { "role": "pm-jira-repo", "state": null, "last_heartbeat": null, "tab": null, "tab_id": null, "zellij_session": null, "agent": null, "alias_of": null, "unread": 0 }, + "forge": { "available": true, "fetched_at": "2026-10-20T14:00:00Z", "stale": true }, + "in_flight": [ + { + "slug": "kiro-config2", "ref": "PROJ-42", "label": "PROJ-42", "number": null, "url": null, + "title": "PROJ-42 · kiro-config2", "description": null, "pr": null, "status": "implementing", + "session": null, "tab_id": null, "reviewers": [] + } + ], + "next": { "source": "none", "items": [] }, + "done": { "window_hours": 24, "min_items": 10, "items": [] }, + "attention": { "active": false, "signals": [] } + }, + { + "name": "other-repo", + "root": "/home/you/src/other-repo", + "loadout": "claude-gh", "tracker": "gh", "agent": "claude", + "pm": { "role": "pm-other-repo", "state": "idle", "last_heartbeat": "2026-10-20T10:00:00Z", "tab": "⏸️ pm-other-repo", "tab_id": "0", "zellij_session": "other", "agent": "claude", "alias_of": null, "unread": 0 }, + "forge": { "available": true, "fetched_at": "2026-10-20T14:59:30Z", "stale": false }, + "in_flight": [], + "next": { "source": "tracker", "items": [] }, + "done": { "window_hours": 24, "min_items": 10, "items": [] }, + "attention": { "active": false, "signals": [] } + } + ] +} diff --git a/tests/task_monitor_snapshot.bats b/tests/task_monitor_snapshot.bats index ded88a3..ee90187 100644 --- a/tests/task_monitor_snapshot.bats +++ b/tests/task_monitor_snapshot.bats @@ -144,6 +144,16 @@ gh_calls() { cat "$STUB_CALLS_DIR/gh.calls" 2>/dev/null || true; } assert_equal "$(q .attention.active)" false } +@test "worktree_base names the directory holding the repo's sidecars" { + # The front end watches it for a new ..info, so it must be the + # directory task-work writes them to, not a path the reader re-derives. + sidecar issue-5 GH_URL https://github.com/acme/widgets/issues/5 + snap + assert_success + [[ -f "$(q .worktree_base)/.issue-5.info" ]] + assert_equal "$(q '.in_flight[0].worktree | split("/") | .[:-1] | join("/")')" "$(q .worktree_base)" +} + @test "--help exits 0 and names the flags" { run /bin/bash "$SNAP" --help assert_success From cf166844a7190bbea39f2a20e15dd5a4f3247b61 Mon Sep 17 00:00:00 2001 From: Martin Contreras Date: Tue, 20 Oct 2026 15:39:56 -0300 Subject: [PATCH 2/3] task-monitor 4/6: ratatui front end (repo view + all-repos overview, real-time): CI builds, tests and lints the Rust crate The PM moved the CI Rust job from #277 into this PR, so #282 is verified before it merges. A `rust` job on ubuntu-latest and macos-latest runs from `monitor/` on stable: fmt --check, build --locked, test --locked, clippy -D warnings, with Swatinem/rust-cache. A `rust-msrv` job builds and tests on 1.88, the declared rust-version (ratatui 0.30's floor), and checked locally with `cargo +1.88 test`. CHANGELOG and README say so; neither job is a required check yet. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 56 ++++++++++++++++++++++++++++++++++++++++ CHANGELOG.md | 2 +- README.md | 2 +- 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aab9c7d..28c8a54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,3 +44,59 @@ jobs: - name: Check loadout drift run: bash tools/check-drift.sh + + # The task-monitor front end (#276). Stable on both OSes; the MSRV job keeps + # `rust-version` in monitor/Cargo.toml honest (ratatui 0.30 needs 1.88). + rust: + name: Rust (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + defaults: + run: + working-directory: monitor + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: monitor + + - name: Format + run: cargo fmt --check + + - name: Build + run: cargo build --locked + + - name: Test + run: cargo test --locked + + - name: Clippy + run: cargo clippy --all-targets --locked -- -D warnings + + rust-msrv: + name: Rust MSRV (1.88) + runs-on: ubuntu-latest + defaults: + run: + working-directory: monitor + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@1.88 + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: monitor + + - name: Build and test on the declared rust-version + run: | + grep -qx 'rust-version = "1.88"' Cargo.toml + cargo build --locked + cargo test --locked diff --git a/CHANGELOG.md b/CHANGELOG.md index d3ce05f..d7b172c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -204,7 +204,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **No task-force logic in Rust.** Every value is a snapshot field, and every field is optional, so a v1 snapshot that grows fields or drops one still renders; a different `schema` is refused, not guessed at. To keep it that way the snapshot gains one field, `worktree_base` (where task-work puts the repo's sidecars), so the watcher does not re-derive task-work's path rule. `docs/task-monitor-schema.md` and its example list it, and a new bats test pins it to the directory holding the sidecars. - 45 Rust tests: rendering both screens, the expanded view and the stale states from fixture snapshots (including the documented example) on a `TestBackend`; every key on every screen; the debounce and poll timing; the watcher's path filter, including a `TASK_FORCE_HOME` behind a symlink (FSEvents reports the real path); the collector's flags, failure text, timeout and a snapshot larger than a pipe buffer; and the tab check against a fake zellij. Breaking the tab-name rule, the session check, the run ordering, the local-run coalescing, the log filter, the narrow-terminal fit, the error reset and the timeout kill each turned a test red. - Build it with `cargo build --release --manifest-path monitor/Cargo.toml` and run `monitor/target/release/task-monitor`, which finds the checkout's `bin/task-monitor-snapshot` (`$TASK_MONITOR_SNAPSHOT` overrides). `install.sh` does not build or link it yet, and CI does not run its tests yet; both are #277. Docs: a README section, and a `task-monitor` paragraph in `.claude/gh-workflow.md` and all seven steering templates. **Upgrading: re-run `task-init `** only to refresh the workflow doc; nothing else installer-written changed. + Build it with `cargo build --release --manifest-path monitor/Cargo.toml` and run `monitor/target/release/task-monitor`, which finds the checkout's `bin/task-monitor-snapshot` (`$TASK_MONITOR_SNAPSHOT` overrides). `install.sh` does not build or link it yet (#277). **CI gains a Rust job** on ubuntu-latest and macos-latest, run from `monitor/` on stable: `cargo fmt --check`, `cargo build --locked`, `cargo test --locked` and `cargo clippy --all-targets --locked -- -D warnings`, cached with `Swatinem/rust-cache` (keyed on `monitor/Cargo.lock`). A second job builds and tests on Rust 1.88, the `rust-version` in `monitor/Cargo.toml` and ratatui 0.30's floor, so that pin stays true. Neither job is a required check on `main` yet; branch protection names only the four existing ones. Docs: a README section, and a `task-monitor` paragraph in `.claude/gh-workflow.md` and all seven steering templates. **Upgrading: re-run `task-init `** only to refresh the workflow doc; nothing else installer-written changed. - **`task-monitor-snapshot`: the board as one JSON document — in flight, next, done, and whether a repo needs you (#273, epic #272 1/6).** A new canonical root binary, the data layer the task-monitor front end will render. It prints `"schema": 1` for the current repo, or with `--all` for every repo a radio session file names, and every value in it is read deterministically from radio's state, task-work's `..info` sidecars, the tracker and the forge. No model is asked to write or summarise anything. The schema is documented in `docs/task-monitor-schema.md`, with an example taken from this repo. - **In flight** is every worktree sidecar, joined to its radio session (`STATE=`, heartbeat), its PR (found by the `task/` head branch on every tracker) and a status word derived from the latest review intent about that PR. Reviewers attach to their PR's item rather than being items. diff --git a/README.md b/README.md index 0fc3047..d24d0fb 100644 --- a/README.md +++ b/README.md @@ -436,7 +436,7 @@ task-monitor-snapshot --local # disk only: no forge calls, merged with - **`t` stays in this zellij session.** Each overview row names its repo's zellij session; `zellij action` cannot switch sessions, so a tab elsewhere is named, not attempted. Within the session, `t` goes to the recorded `TAB_ID` only while that tab still bears the item's name, the check `task-done` makes before it closes a tab (#260). - **No task-force logic in Rust.** Every word comes from the snapshot; status words are a lookup of its `status` field. -Build it from the task-force checkout with `cargo build --release --manifest-path monitor/Cargo.toml` and run `monitor/target/release/task-monitor`; it finds `bin/task-monitor-snapshot` beside it, and `$TASK_MONITOR_SNAPSHOT` overrides that. `install.sh` does not build or link it yet (#277). +Build it from the task-force checkout with `cargo build --release --manifest-path monitor/Cargo.toml` and run `monitor/target/release/task-monitor`; it finds `bin/task-monitor-snapshot` beside it, and `$TASK_MONITOR_SNAPSHOT` overrides that. `install.sh` does not build or link it yet (#277). It needs Rust 1.88 or newer; CI builds, tests and lints it on Linux and macOS. --- From bffad34b7bc41571a2d9c854ce41ed6a92640e76 Mon Sep 17 00:00:00 2001 From: Martin Contreras Date: Tue, 20 Oct 2026 15:43:18 -0300 Subject: [PATCH 3/3] task-monitor 4/6: ratatui front end (repo view + all-repos overview, real-time): keep full results that land after a local run Review round 1 (PR #282): - `App::offer` dropped a full run's result whenever a local run that started later landed first. Heartbeats rewrite session files all the time, so that was the normal case and the forge data never reached the screen. A full result now replaces local data whatever their start times. When it does, its disk state is older than what it replaced, so offer returns `TakenNeedsLocal` and main schedules a local rerun. - `App::failed` marked a fresh screen stale over an older run's failure. It now takes the run's start and mode and applies the same rule as offer (`App::superseded`). A full run's failure still counts against local data, because the forge state on screen is what failed to refresh. - `to_key` gets a test: Ctrl-C is Quit, a bare c is a key, a release is ignored. 48 Rust tests. Breaking the full-run exception, the failure guard, the rerun signal and the Ctrl-C mapping each turned a test red. Also builds and tests on 1.88. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +- monitor/src/app.rs | 143 ++++++++++++++++++++++++++++++++++++++------ monitor/src/main.rs | 54 +++++++++++++++-- monitor/src/ui.rs | 21 ++++++- 4 files changed, 195 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7b172c..f25a11a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -198,11 +198,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added - **`task-monitor`: the board, live in the terminal (#276, epic #272 4/6).** A Rust + ratatui front end in `monitor/` over `task-monitor-snapshot --json`, with the two screens the epic drew. The **repo view** shows In flight (number, title, status word with its PR, the plain description, the `▶` / `⏸` / `❓` activity icon with the time since the session was last heard from, and how many reviewers are on it), Next (the PM queue or the tracker backlog, with notes) and Done (PR, short merge SHA, age, and the two-line summary). The **overview** (`a`, or `--all`, and the default outside a repo) has one row per repo: the attention light, the signals in words, the in-flight count, and the zellij session it runs in. Keys: `↑↓` move, `⏎` expands an item in place (a done item to its full PR body, scrollable) or enters a repo, `o` opens the PR or issue, `t` jumps to the item's tab, `a` toggles the overview, `q` quits. - - **Real time, and never waiting on GitHub.** Filesystem events under `$TASK_FORCE_HOME/radio` (except its `log`), `$TASK_FORCE_HOME/monitor` and each repo's worktree base (`..info` sidecars only) trigger a `--local` snapshot, debounced on the trailing edge at 400ms and capped at 2s. One local run at a time; an event during a run reruns it afterwards. A full run, the only one that calls `gh`, runs at start and then 45s after the previous one finished (`--poll SECS`, 30-600). Both run off the UI thread, with timeouts (20s / 180s) that kill a hung collector. A result never replaces one from a run that started later. + - **Real time, and never waiting on GitHub.** Filesystem events under `$TASK_FORCE_HOME/radio` (except its `log`), `$TASK_FORCE_HOME/monitor` and each repo's worktree base (`..info` sidecars only) trigger a `--local` snapshot, debounced on the trailing edge at 400ms and capped at 2s. One local run at a time; an event during a run reruns it afterwards. A full run, the only one that calls `gh`, runs at start and then 45s after the previous one finished (`--poll SECS`, 30-600). Both run off the UI thread, with timeouts (20s / 180s) that kill a hung collector. A result never replaces one from a run that started later, with one exception: a full result replaces local data even when the local run started after it, since a local run cannot ask the forge. Heartbeats rewrite session files constantly, so a local run landing mid-full-run is the normal case, and without the exception every full result would be dropped. The full result's disk state is then older than what it replaced, so a local rerun is scheduled at once. A failed run marks the screen stale under the same rule, so an old run's timeout cannot mark a fresh screen stale. - **Never blank.** A failed or timed-out snapshot keeps the last good one on screen; the header turns to `stale ` and the footer gives the collector's own last stderr line. `forge ` marks a repo whose PR state is from the last full run. - **`t` stays in the monitor's zellij session and verifies before it moves.** It uses the worker sidecar's `TAB_ID` (else the session's), or the PM session's for a queued or done item and an overview row. A target in another zellij session is named, not attempted. Otherwise it lists tabs and runs `zellij action go-to-tab-by-id` only when the tab at that id still bears the item's name, by the rule `aw_tab_name_is_slug` applies in `task-done` (#260). A stale id now held by another worker's tab is refused with that tab's name. - **No task-force logic in Rust.** Every value is a snapshot field, and every field is optional, so a v1 snapshot that grows fields or drops one still renders; a different `schema` is refused, not guessed at. To keep it that way the snapshot gains one field, `worktree_base` (where task-work puts the repo's sidecars), so the watcher does not re-derive task-work's path rule. `docs/task-monitor-schema.md` and its example list it, and a new bats test pins it to the directory holding the sidecars. - - 45 Rust tests: rendering both screens, the expanded view and the stale states from fixture snapshots (including the documented example) on a `TestBackend`; every key on every screen; the debounce and poll timing; the watcher's path filter, including a `TASK_FORCE_HOME` behind a symlink (FSEvents reports the real path); the collector's flags, failure text, timeout and a snapshot larger than a pipe buffer; and the tab check against a fake zellij. Breaking the tab-name rule, the session check, the run ordering, the local-run coalescing, the log filter, the narrow-terminal fit, the error reset and the timeout kill each turned a test red. + - 48 Rust tests: rendering both screens, the expanded view and the stale states from fixture snapshots (including the documented example) on a `TestBackend`; every key on every screen, and Ctrl-C at the key-mapping level; the run-ordering rule, including a local run landing during a full one and a failure older than the screen; the debounce and poll timing; the watcher's path filter, including a `TASK_FORCE_HOME` behind a symlink (FSEvents reports the real path); the collector's flags, failure text, timeout and a snapshot larger than a pipe buffer; and the tab check against a fake zellij. Breaking the tab-name rule, the session check, the run ordering, the local-run coalescing, the log filter, the narrow-terminal fit, the error reset and the timeout kill each turned a test red. Build it with `cargo build --release --manifest-path monitor/Cargo.toml` and run `monitor/target/release/task-monitor`, which finds the checkout's `bin/task-monitor-snapshot` (`$TASK_MONITOR_SNAPSHOT` overrides). `install.sh` does not build or link it yet (#277). **CI gains a Rust job** on ubuntu-latest and macos-latest, run from `monitor/` on stable: `cargo fmt --check`, `cargo build --locked`, `cargo test --locked` and `cargo clippy --all-targets --locked -- -D warnings`, cached with `Swatinem/rust-cache` (keyed on `monitor/Cargo.lock`). A second job builds and tests on Rust 1.88, the `rust-version` in `monitor/Cargo.toml` and ratatui 0.30's floor, so that pin stays true. Neither job is a required check on `main` yet; branch protection names only the four existing ones. Docs: a README section, and a `task-monitor` paragraph in `.claude/gh-workflow.md` and all seven steering templates. **Upgrading: re-run `task-init `** only to refresh the workflow doc; nothing else installer-written changed. diff --git a/monitor/src/app.rs b/monitor/src/app.rs index 3ab836c..3f743b5 100644 --- a/monitor/src/app.rs +++ b/monitor/src/app.rs @@ -6,6 +6,7 @@ use std::time::Instant; use chrono::{DateTime, Utc}; +use crate::refresh::Mode; use crate::snapshot::{Repo, Snapshot}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -32,6 +33,17 @@ pub enum Action { }, } +/// What [`App::offer`] did with a snapshot. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Offer { + /// A newer one is on screen. + Rejected, + Taken, + /// Taken, but its disk state is older than what it replaced: run a + /// local refresh to bring that back. + TakenNeedsLocal, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum View { /// One repo, by its snapshot `name`. @@ -67,9 +79,10 @@ pub struct Freshness { pub struct App { pub snapshot: Option, - /// When the collector run behind `snapshot` started; a slower run that - /// started earlier never replaces it. + /// When the collector run behind `snapshot` started, and its kind. See + /// [`App::superseded`] for which results replace it. pub snapshot_started: Option, + snapshot_mode: Option, pub freshness: Freshness, pub view: View, /// The repo view's last repo, so `a` toggles back to it. @@ -90,6 +103,7 @@ impl App { App { snapshot: None, snapshot_started: None, + snapshot_mode: None, freshness: Freshness::default(), view: if in_repo { View::Repo(String::new()) @@ -105,14 +119,37 @@ impl App { } } - /// Take a new snapshot unless a run that started later already landed. - /// Returns whether it was taken. - pub fn offer(&mut self, started: Instant, snap: Snapshot, now: DateTime) -> bool { - if self.snapshot_started.is_some_and(|s| s > started) { + /// Whether the outcome of a `mode` run that started at `started` is + /// older news than the snapshot on screen. A run that started later + /// wins, except that a full run is never superseded by a local one: a + /// local run cannot ask the forge, so its PR state is only the cache + /// the full run is about to refresh. With heartbeats rewriting session + /// files all the time, a local run landing mid-full-run is the normal + /// case, and a plain start-time rule would drop every full result. + fn superseded(&self, started: Instant, mode: Mode) -> bool { + let Some(shown) = self.snapshot_started else { return false; + }; + shown > started && !(mode == Mode::Full && self.snapshot_mode == Some(Mode::Local)) + } + + /// Take a run's snapshot unless it is superseded. + pub fn offer( + &mut self, + started: Instant, + mode: Mode, + snap: Snapshot, + now: DateTime, + ) -> Offer { + if self.superseded(started, mode) { + return Offer::Rejected; } + // A full run older than the local data it replaces read disk before + // that local run did: right for the forge, behind on disk. + let behind = self.snapshot_started.is_some_and(|shown| shown > started); self.snapshot = Some(snap); self.snapshot_started = Some(started); + self.snapshot_mode = Some(mode); self.freshness.received_at = Some(now); self.freshness.error = None; // The empty name means "the current repo", fixed on the first snapshot. @@ -121,11 +158,19 @@ impl App { self.view = first.map(View::Repo).unwrap_or(View::Overview); } self.reselect(); - true + if behind { + Offer::TakenNeedsLocal + } else { + Offer::Taken + } } - pub fn failed(&mut self, now: DateTime, why: String) { - self.freshness.error = Some((now, why)); + /// Mark the screen stale for a failed run, unless the screen already + /// shows something newer than that run could have. + pub fn failed(&mut self, started: Instant, mode: Mode, now: DateTime, why: String) { + if !self.superseded(started, mode) { + self.freshness.error = Some((now, why)); + } } pub fn repos(&self) -> &[Repo] { @@ -388,7 +433,7 @@ mod tests { fn app_with(json: &str, in_repo: bool) -> App { let mut app = App::new(in_repo); - app.offer(Instant::now(), parse(json).unwrap(), now()); + app.offer(Instant::now(), Mode::Full, parse(json).unwrap(), now()); app } @@ -540,7 +585,7 @@ mod tests { app.key(Key::Down, now()); // home-env let mut snap = parse(MULTI).unwrap(); snap.repos[0].in_flight.reverse(); - app.offer(Instant::now(), snap, now()); + app.offer(Instant::now(), Mode::Local, snap, now()); let repo = app.current_repo().unwrap(); let Some(Row::InFlight(i)) = app.selected_row() else { panic!() @@ -548,23 +593,87 @@ mod tests { assert_eq!(repo.in_flight[i].slug.as_deref(), Some("home-env")); } + fn empty() -> Snapshot { + parse(r#"{"schema":1,"repos":[]}"#).unwrap() + } + #[test] - fn a_run_that_started_earlier_never_replaces_a_later_one() { + fn a_run_that_started_earlier_never_replaces_a_later_one_of_its_kind() { + let early = Instant::now(); + let late = early + std::time::Duration::from_millis(5); + for mode in [Mode::Local, Mode::Full] { + let mut app = App::new(true); + assert_eq!( + app.offer(late, mode, parse(MULTI).unwrap(), now()), + Offer::Taken + ); + assert_eq!(app.offer(early, mode, empty(), now()), Offer::Rejected); + assert_eq!(app.repos().len(), 4); + } + // Nor does an older local run replace a newer full one. let mut app = App::new(true); + app.offer(late, Mode::Full, parse(MULTI).unwrap(), now()); + assert_eq!( + app.offer(early, Mode::Local, empty(), now()), + Offer::Rejected + ); + } + + #[test] + fn a_local_run_landing_during_a_full_one_does_not_drop_the_full_result() { + let full_start = Instant::now(); + let local_start = full_start + std::time::Duration::from_secs(5); + let mut app = App::new(true); + // The local run started after the full one and finished first. + assert_eq!( + app.offer(local_start, Mode::Local, empty(), now()), + Offer::Taken + ); + let mut full = parse(MULTI).unwrap(); + full.mode = Some("full".into()); + assert_eq!( + app.offer(full_start, Mode::Full, full, now()), + Offer::TakenNeedsLocal + ); + assert_eq!(app.snapshot.as_ref().unwrap().mode.as_deref(), Some("full")); + // The rerun it asks for starts later still, and replaces it. + let rerun = local_start + std::time::Duration::from_secs(1); + assert_eq!( + app.offer(rerun, Mode::Local, parse(MULTI).unwrap(), now()), + Offer::Taken + ); + } + + #[test] + fn a_failure_older_than_the_screen_does_not_mark_it_stale() { let early = Instant::now(); let late = early + std::time::Duration::from_millis(5); - assert!(app.offer(late, parse(MULTI).unwrap(), now())); - assert!(!app.offer(early, parse(r#"{"schema":1,"repos":[]}"#).unwrap(), now())); - assert_eq!(app.repos().len(), 4); + let mut app = App::new(true); + app.offer(late, Mode::Local, parse(MULTI).unwrap(), now()); + app.failed(early, Mode::Local, now(), "old local run timed out".into()); + assert!(app.freshness.error.is_none()); + app.failed( + late + std::time::Duration::from_millis(1), + Mode::Local, + now(), + "new".into(), + ); + assert!(app.freshness.error.is_some()); + // A full run's failure still counts against local data: the forge + // state on screen is what failed to refresh. + let mut app = App::new(true); + app.offer(late, Mode::Local, parse(MULTI).unwrap(), now()); + app.failed(early, Mode::Full, now(), "gh timed out".into()); + assert!(app.freshness.error.is_some()); } #[test] fn a_failure_keeps_the_last_good_snapshot() { let mut app = app_with(MULTI, true); - app.failed(now(), "timed out".into()); + app.failed(Instant::now(), Mode::Full, now(), "timed out".into()); assert_eq!(app.repos().len(), 4); assert!(app.freshness.error.is_some()); - app.offer(Instant::now(), parse(MULTI).unwrap(), now()); + app.offer(Instant::now(), Mode::Local, parse(MULTI).unwrap(), now()); assert!(app.freshness.error.is_none()); } } diff --git a/monitor/src/main.rs b/monitor/src/main.rs index 25a4c28..7dfb225 100644 --- a/monitor/src/main.rs +++ b/monitor/src/main.rs @@ -18,7 +18,7 @@ use std::time::{Duration, Instant}; use chrono::Utc; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; -use app::{Action, App, Key}; +use app::{Action, App, Key, Offer}; use refresh::{Collector, Mode, Scheduler, Watcher}; const USAGE: &str = "\ @@ -212,12 +212,16 @@ fn main() { } => { sched.finished(mode, Instant::now()); match result { - Ok(snap) => { - if app.offer(started, snap, Utc::now()) { + Ok(snap) => match app.offer(started, mode, snap, Utc::now()) { + Offer::Rejected => {} + taken => { watcher.sync(app.snapshot.as_ref()); + if taken == Offer::TakenNeedsLocal { + sched.fs_event(Instant::now()); + } } - } - Err(why) => app.failed(Utc::now(), why), + }, + Err(why) => app.failed(started, mode, Utc::now(), why), } } Msg::Key(k) => match app.key(k, Utc::now()) { @@ -246,3 +250,43 @@ fn main() { } ratatui::restore(); } + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::crossterm::event::{KeyEvent, KeyEventState}; + + fn press(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } + } + + #[test] + fn ctrl_c_quits_and_a_bare_c_is_just_a_key() { + assert_eq!( + to_key(press(KeyCode::Char('c'), KeyModifiers::CONTROL)), + Some(Key::Quit) + ); + assert_eq!( + to_key(press(KeyCode::Char('c'), KeyModifiers::NONE)), + Some(Key::Char('c')) + ); + assert_eq!( + to_key(press(KeyCode::Enter, KeyModifiers::NONE)), + Some(Key::Enter) + ); + let release = KeyEvent { + kind: KeyEventKind::Release, + ..press(KeyCode::Char('q'), KeyModifiers::NONE) + }; + assert_eq!( + to_key(release), + None, + "only presses count, or every key acts twice" + ); + } +} diff --git a/monitor/src/ui.rs b/monitor/src/ui.rs index 80745e0..f6d4038 100644 --- a/monitor/src/ui.rs +++ b/monitor/src/ui.rs @@ -748,7 +748,12 @@ mod tests { fn app(json: &str, in_repo: bool) -> App { let mut app = App::new(in_repo); - app.offer(Instant::now(), parse(json).unwrap(), now()); + app.offer( + Instant::now(), + crate::refresh::Mode::Full, + parse(json).unwrap(), + now(), + ); app } @@ -884,7 +889,12 @@ mod tests { #[test] fn a_failed_refresh_keeps_the_screen_and_marks_it_stale() { let mut a = app(MULTI, true); - a.failed(now(), "task-monitor-snapshot timed out after 20s".into()); + a.failed( + Instant::now(), + crate::refresh::Mode::Local, + now(), + "task-monitor-snapshot timed out after 20s".into(), + ); let s = render(&mut a, 100, 30); assert!(s[0].contains("stale 0s"), "{}", s[0]); assert!( @@ -913,7 +923,12 @@ mod tests { let mut a = App::new(true); let s = render(&mut a, 80, 10); assert!(screen(&s).contains("loading the first snapshot")); - a.failed(now(), "boom".into()); + a.failed( + Instant::now(), + crate::refresh::Mode::Local, + now(), + "boom".into(), + ); assert!(screen(&render(&mut a, 80, 10)).contains("snapshot failed: boom")); }