Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions docs/specs/alert.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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:

Expand Down Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions docs/specs/alert.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down
48 changes: 48 additions & 0 deletions lib/src/lib/alert-engagement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
33 changes: 1 addition & 32 deletions lib/src/lib/alert-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))));
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Expand Down
29 changes: 17 additions & 12 deletions lib/src/lib/alert-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion lib/src/lib/terminal-registry.alert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading