feat: report program status over OSC 7501#20

merged3
main
feat/program-status
opened7h agobyVversecafe

Closes #18. Implements the whole program side of the Program Status Protocol (OSC 7501): every state and kind, child records, clear, and feature detection. Terminals such as Rex can then show whether an ashlar app, and each piece of work inside it, is idle, working, blocked, done, or in error.

API

tsx
1mount(App, { driver, statusApp: "deploy" }); // app for root reports; children inherit it
2
3useProgramStatus(() => ({ state: "working", msg })); // nearest record (root by default)
4<StatusRecord id="eu-west" title="EU West"> // scope: reports inside go to eu-west
5 <ProgressBar value={p()} status /> // working + percentage
6</StatusRecord>
7<Spinner status /> // working, no percentage
8
9const eu = screen.status.record("eu-west"); // handles; ids checked once
10eu.report({ state: "blocked", kind: "permission", msg: "Approve deploy?" });
11eu.clear(); // eu-west and its children
12screen.status.clear(); // every record
13await screen.status.detect(); // { states, kinds } | null, cached

Behavior

  • Spec rules in one place (formatProgramStatus):
    • kind only with blocked, and progress only with working/blocked, rounded and clamped to 0–100.
    • msg/title are base64 UTF-8 on one line (control characters become spaces), cut with an ellipsis at a UTF-8 boundary past 2048/192 bytes. Long error messages still reach the terminal; every report stays under the 4096-byte cap.
    • An invalid state, kind, or app drops the report: report() returns false and the hook warns. Ids follow the spec's grammar: record() throws on a bad one, and <StatusRecord> warns instead.
  • Writes go straight to the terminal outside the frame diff, like OSC 52. Identical reports are written once per record. The dedup map is capped at the spec's 256 records, oldest first, matching terminal eviction.
  • Several reporters on one record take turns. The latest report is shown; when its component goes away, the newest remaining report is shown again.
  • Lifecycle: with no reporters left, a working/blocked root becomes idle, and a child record is cleared unless it's done/error.
    • This waits a microtask, so a component taking over the record in the same update reports first.
    • Screen unmount, SIGINT, and SIGTERM do it synchronously for every record.
    • Reports after unmount still write, so an app can leave done/error behind before it exits.
  • Detection sends OSC 7501 ; ? then CSI c and resolves when the device-attributes reply arrives, which always comes last, so no reply is left to leak into the shell.
    • It resolves null if only device attributes answer, after 1 s (an unref'd timer), without input, or after unmount, in which case nothing is sent.
    • The reply's states/kinds lists default to the spec's fixed sets.
    • Reports are never held back waiting for it.
  • <StatusRecord> follows id changes and settles the old record. Its app/title/msg fill in fields a report leaves undefined.

Left out

  • OSC 9;4 progress. It shares the number 9 with iTerm2 notifications and garbles output in terminals that know only one of them. It could be an opt-in later.
  • A useStalled mapping: it isn't a state, and <Spinner status> already reports working.

Demos

  • bun --conditions=development demo/src/demos/program-status.tsx: a three-region deploy. The root is working, each region is a child record with its progress, EU West blocks on a permission prompt (y/n), and the deploy ends done or error with the regions cleared.
  • bun --conditions=development demo/src/demos/program-status-states.tsx: set each state, kind, progress, and message by hand.
  • Both are in the bun demo menu. In Rex, switch to another tab to see the indicators.

Verification

  • Unit and screen tests cover:
    • the formatter: bytes, text cutting, invalid values;
    • the id grammar, including the 128-byte limit;
    • subtree clear, and unmount/stop semantics, including idle children;
    • detection: reply order, defaults, the DA1-first case, timeout, and after stop;
    • the stdin reply path and SIGTERM, through a mounted screen with a stubbed TTY;
    • StatusRecord paths, labels, and id changes;
    • several reporters on one record;
    • gap-free handoff.
  • Rex 0.0.1793377904: both demos were driven with rex send-key while recording rex events --all --json. The event log showed the expected program_status_changed and program_status_removed sequence for approve, deny, restart, quit, and cancel mid-deploy (regions cleared, then root idle).

Independent of the Rex detection PR (#19).

Added 2 commits

  • feat: report program status over OSC 75015d01127
  • feat: expose every OSC 7501 record, clear, and detection6a8ad2a

Screen recording of the program-status demo in Rex: hovering the background tab shows the root record Blocked while deploying, with the child records underneath (AP South at 70%, EU West blocked on the permission prompt, US East done). After approval, it shows Done · Deployed to 3 regions with the region records cleared.

Rex tab hover card: Blocked with child records, then Done

Added 1 commit

  • fix: share program status records and harden detectionddcbc6c