diff --git a/docs/specs/alert.md b/docs/specs/alert.md index b6ce55916..7cac65b47 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -38,7 +38,7 @@ Public `status` is a projection — first match wins: **Persist only** `todo` and the sanitized `notification` (plus `status` for diagnostics): -- **Must persist an unacknowledged ring, deferred or visible, as the TODO a look would leave**, with its detail. +- **Must persist an unacknowledged ring, deferred or visible, as a TODO** with its detail. - Every spawn starts the id's alert state over; a cold restore carries those two on the pane's spawn (`SpawnPtyOptions.alert`), seeded before the PTY spawns, and a live resume keeps the host's. **Never recreate a ring, a progress cycle, or a command-exit arm on restore.** - **Must read a notification this build cannot validate as none, keeping the pane**, and **never write a source outside `STRICT_READER_NOTIFICATION_SOURCES`** until no strict pre-tolerant build reads the file (rationale). - **Never persist WATCHING per Session**: it is re-derived from the rule set at the next command start. Replay filtering in `docs/specs/terminal-escapes.md` keeps old terminal output from firing notification side effects again. @@ -135,7 +135,7 @@ stateDiagram-v2 Deferred --> Showing: confirmed work ends Deferred --> NoRing: due while engaged, never shown, becomes a hold Showing --> NoRing: verb, or last source withdrawn - Deferred --> NoRing: verb, or last source withdrawn + Deferred --> NoRing: input, a verb once shown, or last source withdrawn } -- state "Hold" as Hold { @@ -154,7 +154,7 @@ stateDiagram-v2 ``` - **A hold must set TODO at once**, its detail by richness (Clearing And TODO), repeated holds merging; it leaves a progress cycle alone. Rung on idle, each held source takes its unengaged path, the acknowledged check included. A forgotten hold keeps its TODO (rationale); rule removal withdraws a held `watching` source as a ringing one. -- **With `deferAlertsUntilQuiet` enabled, a ring must be deferred exactly while the detector has confirmed work, WATCHING or not, derived and never stored**: it keeps its sources, detail, episode, and alarm deadlines (rationale). **A never-shown ring that comes due while engaged must become a hold** (rationale). **Never defer a ring carrying an `exit` source**, whatever joined it. **Never cap a deferral** (rationale); disabling the setting shows every deferred ring at once. +- **With `deferAlertsUntilQuiet` enabled, a ring must be deferred exactly while the detector has confirmed work, WATCHING or not, derived and never stored**: it keeps its sources, detail, episode, and alarm deadlines (rationale). **A ring deferred since it opened must stay a fresh completion until it shows**: only input clears it, and coming due while engaged makes it a hold (rationale). **Never defer a ring carrying an `exit` source**, whatever joined it. **Never cap a deferral** (rationale); disabling the setting shows every deferred ring at once. Two ordering rules: @@ -321,15 +321,13 @@ Detail follows one richness order: a text report (`OSC 9`, `OSC 99`, `OSC 777`) | Verb | Effect | |---|---| -| acknowledge without input (Engagement) | clears the ring, leaving `todo` on with its detail | -| acknowledge with input — any keystroke, paste, or drop — or clear TODO (pill click) | clears the ring; `todo` off, notification dropped, even if already off | -| dismiss (`a`, Pane Header) | clears the ring, leaving `todo` on with its detail; with nothing ringing or held: no change, no notify | +| acknowledge without input (Engagement), or dismiss (`a`, Pane Header) | clears the ring, leaving `todo` on with its detail; with nothing ringing or held, no change | +| acknowledge with input — any keystroke, paste, or drop — or clear TODO (pill click) | clears the ring; `todo` off, notification dropped, even if already off (rationale) | | toggle TODO (`t`) | clears the ring; `todo` flips, on keeping the ring's detail, off dropping the notification | | an await consumes a source (Await) | withdraws that source | | a rule stops covering the `watching` key | withdraws `watching` | -- **The user verbs acknowledge; a withdrawal must never acknowledge.** Each verb clears a deferred ring as a visible one and drops a hold; a withdrawal leaves `todo` and its notification as they stood. -- **Any keystroke into the pane must clear TODO** (rationale). +- **The user verbs acknowledge; a withdrawal must never acknowledge.** Each verb clears the ring (one that never showed only on input, Completion events) and drops a hold; a withdrawal leaves `todo` and its notification as they stood. - **Never summon twice for an acknowledged state.** After a user verb clears a ring or drops a hold, a report before any output only updates the TODO (Completion events); opening a ring or starting a command forgets the acknowledgement (rationale). - Command-mode `Enter` that only enters passthrough does not clear TODO. - Removing a WATCHING rule clears neither a progress cycle nor a command-exit arm. diff --git a/docs/specs/alert.rationale.md b/docs/specs/alert.rationale.md index 99305be28..279834b21 100644 --- a/docs/specs/alert.rationale.md +++ b/docs/specs/alert.rationale.md @@ -46,9 +46,9 @@ **Why deferral uses the detector's confirmed work, without a ceiling.** Deferring on every accepted output chunk let an isolated redraw hide an alert and introduced a separate quiet clock beside the busy detector (product decision, 2026-10-08). Deferring on a `MIGHT_BE_BUSY` candidate too made the ring blink: output every 1.5–2 s enters and expires a candidate each cycle, about 35 visibility flips in 30 s at 1.6 s, as with `htop`'s default refresh (measured 2026-10-08). Confirmed work ends only by settling or a command boundary, so the ring changes visibility once per burst. Once work is confirmed, sustained output can still defer indefinitely; the user preference remains to avoid summoning during animation. -**Why a deferred report opens its ring at once.** A report during animation used to wait outside the ring until quiet: invisible, unpersisted, and discarded with no TODO by a click or any user verb, while a ring that had already shown and then deferred became a TODO on the same click. Two mechanisms carried one intent with different exits (2026-10-08). One ring with derived deferral gives every verb one path. The cost: the alarm delay counts from the report, not from quiet, as it already did for a ring deferred after it showed. +**Why a deferred report opens its ring at once.** A report during animation used to wait outside the ring until quiet: invisible, unpersisted, and discarded with no TODO by a click or any user verb, while a ring that had already shown and then deferred became a TODO on the same click. Two mechanisms carried one intent with different exits (2026-10-08). One ring with derived deferral gives both one path. The cost: the alarm delay counts from the report, not from quiet, as it already did for a ring deferred after it showed. -**Why a never-shown ring that comes due while engaged is held.** Opening the ring at once let a report deferred while the user was away ring in front of them if they came back before the animation stopped — the case holds exist to spare. Until a ring has shown, it is still a fresh completion to the Session, so engagement at its reveal decides as it would have at dispatch. A ring that has shown already summoned the user, and a look still has to answer it (2026-10-08). +**Why a ring deferred since it opened stays a fresh completion.** Until a ring has shown it has summoned no one, so it is decided at its reveal as it would have been at dispatch. Opening it at once let a report deferred while the user was away ring in front of them if they came back before the animation stopped — the case holds exist to spare (2026-10-08). A click turned it into a TODO: a user checking on long-quiet work found it finished with no ring, the look itself making the TODO, a summons skipped straight to its receipt (reported 2026-10-11); a look, `a`, `t`, or the pill acts on nothing it showed. Input still answers it: typing replies to the program, which makes the owed completion stale. A ring that has shown already summoned the user, and a look still has to answer it. ## Await @@ -116,7 +116,7 @@ **Why any keystroke clears TODO.** The rule was `Enter` only, and missed `Enter` sent as a win32-input-mode or kitty `CSI 13 u` sequence and a one-key answer such as `y`; it also read the renderer's copy of TODO before the attend it had just sent had come back, so a TODO that attend created survived the key (audit, 2026-09-23). Typing into the pane is dealing with it. -**Why TODO waits for acknowledgement.** Every ring set TODO as it opened (audit, 2026-09-23), so the pill sat beside an alarm nobody had seen yet, and ringing and TODO said the same thing twice. The model is two steps (product decision, 2026-09-24): the ring summons; a look without typing turns it into a TODO, "seen, still owed"; dealing with it clears that. Before the audit, WATCHING rings made TODO only when attended while report and exit rings made it at once, so every verb diverged by source; one rule for every source keeps them identical. A restart writes an unacknowledged ring as the TODO a look would leave, so an unseen alarm is not lost. With no TODO of its own to take back, a withdrawal needs no record of what the ring found. +**Why TODO waits for acknowledgement.** Every ring set TODO as it opened (audit, 2026-09-23), so the pill sat beside an alarm nobody had seen yet, and ringing and TODO said the same thing twice. The model is two steps (product decision, 2026-09-24): the ring summons; a look without typing turns it into a TODO, "seen, still owed"; dealing with it clears that. Before the audit, WATCHING rings made TODO only when attended while report and exit rings made it at once, so every verb diverged by source; one rule for every source keeps them identical. A restart writes an unacknowledged ring as a TODO, so an unseen alarm is not lost. With no TODO of its own to take back, a withdrawal needs no record of what the ring found. **Why detail goes by richness.** The last writer used to win, with one special case keeping a report's text over an exit: an `OSC 9` reading `Build finished: 3 warnings` followed by a bell in the next PTY read showed `Terminal bell`, and `make; printf '\a'` showed `Terminal bell` over `make exited 2` (audit, 2026-09-23). One order covers every pair; equal ranks still take the newer text. A notification deferred behind animation used to be replaced by whatever came next, so the same bell in the next read replaced the message it followed; the deferral now keeps the richer of the two. diff --git a/lib/src/lib/alert-engagement.test.ts b/lib/src/lib/alert-engagement.test.ts index 439ce3e50..7a7d6b8a2 100644 --- a/lib/src/lib/alert-engagement.test.ts +++ b/lib/src/lib/alert-engagement.test.ts @@ -259,6 +259,54 @@ describe('acknowledge', () => { expect(manager.getState(PANE)).toMatchObject({ todo: false, notification: null }); }); + /** A report behind confirmed work: a ring that has not shown. */ + function deferredReport(): void { + output(PANE, 3_000); + manager.notifyFromProtocol(PANE, PERMISSION); + } + + it('leaves a never-shown deferred ring to come due after a look, held if still engaged', () => { + deferredReport(); + engage(manager, PANE); + manager.acknowledge(PANE, { input: false }); + output(PANE, 3_000); + expect(manager.getState(PANE)).toMatchObject({ status: 'WATCHING_DISABLED', todo: false }); + expect(manager.getState(PANE).episode).not.toBeNull(); + + vi.advanceTimersByTime(5_000); + expect(manager.getState(PANE)).toMatchObject({ status: 'WATCHING_DISABLED', episode: null, todo: true, notification: PERMISSION }); + }); + + it('rings a never-shown deferred ring the user looked at and left', () => { + deferredReport(); + engage(manager, PANE); + manager.acknowledge(PANE, { input: false }); + leave(manager); + vi.advanceTimersByTime(5_000); + expect(manager.getState(PANE)).toMatchObject({ status: 'ALERT_RINGING', todo: false, notification: PERMISSION }); + }); + + it('keeps a never-shown deferred ring through every verb but input', () => { + deferredReport(); + manager.dismissAlert(PANE); + manager.toggleTodo(PANE); + manager.clearTodo(PANE); + expect(manager.getState(PANE).episode).not.toBeNull(); + + keystroke(PANE); + expect(manager.getState(PANE)).toMatchObject({ episode: null, todo: false }); + vi.advanceTimersByTime(5_000); + expect(ringing(PANE)).toBe(false); + }); + + it('turns a ring that showed and then deferred into a TODO on a look', () => { + manager.notifyFromProtocol(PANE, PERMISSION); + driveToBusy(manager, PANE); + expect(ringing(PANE)).toBe(false); + manager.acknowledge(PANE, { input: false }); + expect(manager.getState(PANE)).toMatchObject({ episode: null, todo: true, notification: PERMISSION }); + }); + it('never creates an entry for an id it does not know', () => { manager.acknowledge('browser-1', { input: false }); manager.acknowledge('browser-1', { input: true }); diff --git a/lib/src/lib/alert-manager.test.ts b/lib/src/lib/alert-manager.test.ts index 88448f50d..cf55d9cbf 100644 --- a/lib/src/lib/alert-manager.test.ts +++ b/lib/src/lib/alert-manager.test.ts @@ -347,7 +347,7 @@ describe('AlertManager in isolation', () => { it.each([ ['WATCHING', 'watching', null], ['report', 'report', REPORT], - ] as const)('persists an unacknowledged %s ring as the TODO a look would leave', (_what, source, notification) => { + ] as const)('persists an unacknowledged %s ring as a TODO', (_what, source, notification) => { const id = `restart-${source}`; ringFrom(id, source); const persisted = JSON.parse(JSON.stringify(toPersistedAlertState(manager.getState(id)))); @@ -590,22 +590,6 @@ describe('AlertManager in isolation', () => { expect(manager.getState(id)).toEqual(quiet); }); - it('dismisses a ring still deferred behind animation to a TODO', () => { - const id = 'dismiss-deferred'; - driveToBusy(manager, id); - manager.notifyFromProtocol(id, { source: 'OSC 9', title: null, body: 'Build finished' }); - expect(manager.getState(id)).toMatchObject({ status: 'WATCHING_DISABLED', todo: false }); - - manager.dismissAlert(id); - - vi.advanceTimersByTime(5_000); - expect(manager.getState(id)).toMatchObject({ - status: 'WATCHING_DISABLED', - todo: true, - notification: { source: 'OSC 9', title: null, body: 'Build finished' }, - }); - }); - it('protocol completion is held while the Session is engaged', () => { const id = 'osc-progress-held'; @@ -1275,21 +1259,6 @@ describe('AlertManager in isolation', () => { }); }); - it('turns a deferred ring into a TODO when the user acknowledges', () => { - const id = 'defer-acknowledged'; - driveToBusy(manager, id); - manager.notifyFromProtocol(id, { source: 'OSC 9', title: null, body: 'Done' }); - - manager.acknowledge(id, { input: false }); - vi.advanceTimersByTime(60_000); - expect(manager.getState(id)).toMatchObject({ - status: 'WATCHING_DISABLED', - episode: null, - todo: true, - notification: { source: 'OSC 9', title: null, body: 'Done' }, - }); - }); - it('releases rather than drops deferred delivery when the setting is disabled', () => { const id = 'defer-disable'; driveToBusy(manager, id); diff --git a/lib/src/lib/alert-manager.ts b/lib/src/lib/alert-manager.ts index 56a509f17..f1cd0d2c0 100644 --- a/lib/src/lib/alert-manager.ts +++ b/lib/src/lib/alert-manager.ts @@ -134,7 +134,8 @@ interface Ring extends SourceSet { detail: ActivityNotification; /** Published as `ALERT_RINGING` at least once — set only by * `reconcileRing`, so false means deferred at every publish since it - * opened. Until then, coming due on an engaged Session makes it a hold. */ + * opened. Until then it has summoned no one: only input clears it + * (`clearRingForUser`), and coming due on an engaged Session makes it a hold. */ shown: boolean; } @@ -996,14 +997,17 @@ export class AlertManager { } /** - * A user verb stops the summons: the whole ring goes, deferred or not, with - * whatever was held. TODO is the caller's to decide, from the ring returned. - * Only clearing a ring or a hold records an acknowledgement. + * A user verb stops the summons: the ring goes with whatever was held. A + * ring still deferred and never shown has summoned no one, so only `input` + * answers it; every other verb leaves it to come due. TODO is the caller's + * to decide, from the ring returned. Only clearing a ring or a hold records + * an acknowledgement. */ - private clearRingForUser(entry: AlertEntry): Ring | null { - const { ring, held } = entry; + private clearRingForUser(entry: AlertEntry, input = false): Ring | null { + const ring = input || entry.ring?.shown || !this.isDeferred(entry) ? entry.ring : null; + const { held } = entry; if (ring === null && held === null) return null; - entry.ring = null; + if (ring !== null) entry.ring = null; entry.held = null; entry.ackedQuiet = true; if (ring?.watching || held?.watching) this.answeredWatching(entry); @@ -1085,11 +1089,11 @@ export class AlertManager { } /** - * A human interacted with the Session. Both kinds clear the ring, deferred - * or not, and whatever it held, and mark the running command seen; without `input` a - * cleared ring becomes a TODO, while `input` — keys, a paste, a drop, - * acknowledged as the host writes them — turns TODO off and opens the echo - * window, a helper's included, since its detector runs. Never creates an + * A human interacted with the Session. Both kinds clear the ring + * (`clearRingForUser`) and whatever it held, and mark the running command + * seen; without `input` a cleared ring becomes a TODO, while `input` — keys, + * a paste, a drop, acknowledged as the host writes them — turns TODO off and + * opens the echo window, a helper's included, since its detector runs. Never creates an * entry: an id with none — a browser Surface — has nothing to clear. */ acknowledge(id: string, { input }: { input: boolean }): void { @@ -1099,6 +1103,7 @@ export class AlertManager { if (this.helpers.has(id)) return; this.markSeen(entry); if (input) { + this.clearRingForUser(entry, true); this.setTodoForUser(id, entry, false); return; } diff --git a/lib/src/lib/terminal-registry.alert.test.ts b/lib/src/lib/terminal-registry.alert.test.ts index 551ef734c..9f916feea 100644 --- a/lib/src/lib/terminal-registry.alert.test.ts +++ b/lib/src/lib/terminal-registry.alert.test.ts @@ -286,7 +286,7 @@ describe('terminal-registry alert behavior', () => { createSession(id); expect(getActivity(id)).toEqual(activity); - // A ring no one has looked at persists as the TODO a look would leave. + // A ring no one has looked at persists as a TODO. expect(getLivePersistedAlertState(id)).toMatchObject({ status: 'ALERT_RINGING', todo: true }); unmountElement(id);