From d70b40294807900e6480912873acd12e1c4e73ec Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 13 May 2026 15:37:39 -0700 Subject: [PATCH 1/7] Use unfilled bell for disabled alerts --- docs/specs/alert.md | 2 +- lib/src/components/wall/TerminalPaneHeader.tsx | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/specs/alert.md b/docs/specs/alert.md index a81b8da8..6f3770c3 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -537,7 +537,7 @@ Alert button: - shown in all header tiers, including compact and minimal - icon-only control with tooltip and accessible label - visual states (pure function of `status`): - - `ALERT_DISABLED`: `BellSlashIcon`, muted + - `ALERT_DISABLED`: `BellIcon` unfilled, muted - `NOTHING_TO_SHOW`: `BellIcon` filled, muted, upright - `MIGHT_BE_BUSY`: `BellIcon` filled, muted, tilted slightly (-22.5°) - `BUSY`: `BellIcon` filled, muted, tilted 45° diff --git a/lib/src/components/wall/TerminalPaneHeader.tsx b/lib/src/components/wall/TerminalPaneHeader.tsx index 40edea2a..6fcf8daa 100644 --- a/lib/src/components/wall/TerminalPaneHeader.tsx +++ b/lib/src/components/wall/TerminalPaneHeader.tsx @@ -7,7 +7,6 @@ import { ArrowsInIcon, ArrowsOutIcon, BellIcon, - BellSlashIcon, CursorClickIcon, SelectionSlashIcon, SplitHorizontalIcon, @@ -271,7 +270,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { > {activity.status === 'ALERT_DISABLED' ? ( - + ) : ( )} From 0a4857e3b50bdc487d4cee5864767260da3a2373 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 13 May 2026 15:40:14 -0700 Subject: [PATCH 2/7] Rename ontology spec to glossary --- AGENTS.md | 2 +- docs/specs/{ontology.md => glossary.md} | 10 +++++----- docs/specs/layout.md | 2 +- docs/specs/terminal-state.md | 2 +- docs/specs/transport.md | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) rename docs/specs/{ontology.md => glossary.md} (94%) diff --git a/AGENTS.md b/AGENTS.md index 1b510d04..1962f1a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ pnpm build # build lib, vscode extension, and website The primary job of a spec is to be an accurate reference for the current state of the code. Read the relevant spec before modifying a feature it covers — the spec describes invariants, edge cases, and design decisions that are not obvious from the code alone. -- **`docs/specs/ontology.md`** — Canonical vocabulary for Session states, layers (Process / Registry / View / Link / Activity / Snapshot), transition verbs, and the Liskov contract on Registry APIs. Read this first. Other specs defer to it when naming a state or a verb. +- **`docs/specs/glossary.md`** — Canonical vocabulary for Session states, layers (Process / Registry / View / Link / Activity / Snapshot), transition verbs, and the Liskov contract on Registry APIs. Read this first. Other specs defer to it when naming a state or a verb. - **`docs/specs/layout.md`** — Tiling layout, pane/door containers, dockview configuration, modes (passthrough/command), keyboard shortcuts, selection overlay, spatial navigation, minimize/reattach, inline rename, session lifecycle, session persistence, and theming. Read this when touching: `Wall.tsx`, `Baseboard.tsx`, `Door.tsx`, `TerminalPane.tsx`, `spatial-nav.ts`, `layout-snapshot.ts`, `terminal-registry.ts`, `session-save.ts`, `session-restore.ts`, `reconnect.ts`, `index.css`, `theme.css`, or any keyboard/navigation/mode behavior. - **`docs/specs/alert.md`** — Activity monitoring state machine, alert trigger/clearing rules, attention model, TODO lifecycle, bell button visual states and interaction, door alert indicators, hardening (a11y, motion, i18n, overflow), notification protocols (`OSC 9` / `OSC 9;4` / `OSC 99` / `OSC 777` / `BEL`), the `ActivityNotification` model, notification text handling and security, and the notification preview/detail UI. Read this when touching: `activity-monitor.ts`, `alert-manager.ts`, `AlertManager` notification/progress paths, the alert bell or TODO pill in `Wall.tsx` (TerminalPaneHeader), alert indicators in `Door.tsx`, the `a`/`t` keyboard shortcuts, or TODO notification preview UI. Layout.md defers to this spec for all alert/TODO behavior. - **`docs/specs/terminal-state.md`** — Terminal semantic state for CWD, shell prompt/editing/running/finished lifecycle, command runs, terminal title fallback, normalized semantic OSC events (`OSC 7`, `OSC 9;9`, `OSC 133`, `OSC 633`, `OSC 1337`, `OSC 0/2`), title-candidate diagnostics, header derivation, and grouping keys. Read this when touching `terminal-state.ts`, `terminal-state-store.ts`, semantic event parsing in `terminal-protocol.ts`, adapter semantic event forwarding, or derived pane/door labels. diff --git a/docs/specs/ontology.md b/docs/specs/glossary.md similarity index 94% rename from docs/specs/ontology.md rename to docs/specs/glossary.md index 9496dddf..06661dd3 100644 --- a/docs/specs/ontology.md +++ b/docs/specs/glossary.md @@ -1,12 +1,12 @@ -# Ontology +# Glossary -This spec is the canonical vocabulary for states, entities, and transitions in mouseterm. Every other spec defers to this one when naming a state or a verb. When writing code or prose, pick names from here first. +This glossary is the canonical vocabulary for states, entities, and transitions in mouseterm. Every other spec defers to this one when naming a state or a verb. When writing code or prose, pick names from here first. ## The core idea A **Session** is the durable unit. A Session's state lives on six orthogonal axes — change one without touching the others. A caller holding a `SessionId` can reason about each axis independently. -The **Liskov contract**: a Session is substitutable across most operations regardless of which states it currently occupies. `kill` and `rename` work universally. State-gated operations (`write`, `focus`) document their preconditions in ontology terms rather than failing silently. +The **Liskov contract**: a Session is substitutable across most operations regardless of which states it currently occupies. `kill` and `rename` work universally. State-gated operations (`write`, `focus`) document their preconditions in glossary terms rather than failing silently. ## Layers @@ -126,7 +126,7 @@ A caller holding a `SessionId` can issue universal operations without branching. ## Retired / overloaded terms -Use ontology names instead of these. The left column retains a meaning only where noted. +Use glossary names instead of these. The left column retains a meaning only where noted. | Term | Status | |---|---| @@ -143,5 +143,5 @@ Use ontology names instead of these. The left column retains a meaning only wher - Layer names and state names are `PascalCase` nouns (`Paned`, `Tombstoned`). - Verbs are `camelCase` in code and lowercase in prose (`minimize`, not `Minimize`). - Event kind strings match the verb: `'minimizeChange'`, not `'detachChange'`. -- A persisted type is `Persisted` where `` is the ontology noun (`PersistedPane`, `PersistedDoor`). +- A persisted type is `Persisted` where `` is the glossary noun (`PersistedPane`, `PersistedDoor`). - A handle type is `State` (`ActivityState`, not `SessionUiState`). diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 8e75670a..65d6ff9d 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -1,6 +1,6 @@ # Layout Spec -> See `docs/specs/ontology.md` for canonical state names, layer definitions, and transition verbs. This spec uses the ontology's vocabulary throughout. +> See `docs/specs/glossary.md` for canonical state names, layer definitions, and transition verbs. This spec uses the glossary's vocabulary throughout. ## Conceptual model diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index faa855a1..0c57bc0a 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -1,6 +1,6 @@ # Terminal CWD and Command State -> See `docs/specs/ontology.md` for Session vocabulary. This spec defines the per-Session terminal semantic state that layout and grouping consume. Alert/TODO behavior and notification OSCs (OSC 9 / 9;4 / 99 / 777 / BEL) live in `docs/specs/alert.md`. The OSC sequence registry and parsing-location rules live in `docs/specs/OSC.md`. +> See `docs/specs/glossary.md` for Session vocabulary. This spec defines the per-Session terminal semantic state that layout and grouping consume. Alert/TODO behavior and notification OSCs (OSC 9 / 9;4 / 99 / 777 / BEL) live in `docs/specs/alert.md`. The OSC sequence registry and parsing-location rules live in `docs/specs/OSC.md`. ## Goal diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 9a03b382..29667be4 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -1,6 +1,6 @@ # Transport and PTY Protocol Spec -> Adapter-agnostic protocol shared by all `PlatformAdapter` implementations — the VS Code extension (`docs/specs/vscode.md`), the standalone Tauri sidecar, and the `fake-adapter.ts` used for tests and the website playground. Covers PTY lifecycle, buffering, the webview ↔ platform message protocol, persisted-session types, and the invariants every adapter must honor. See `docs/specs/ontology.md` for the Process / Link state vocabulary, `docs/specs/alert.md` for `AlertManager` semantics, and `docs/specs/terminal-state.md` for the semantic events delivered over this transport. +> Adapter-agnostic protocol shared by all `PlatformAdapter` implementations — the VS Code extension (`docs/specs/vscode.md`), the standalone Tauri sidecar, and the `fake-adapter.ts` used for tests and the website playground. Covers PTY lifecycle, buffering, the webview ↔ platform message protocol, persisted-session types, and the invariants every adapter must honor. See `docs/specs/glossary.md` for the Process / Link state vocabulary, `docs/specs/alert.md` for `AlertManager` semantics, and `docs/specs/terminal-state.md` for the semantic events delivered over this transport. ## Adapter model @@ -14,7 +14,7 @@ Each platform adapter wraps a PTY-spawning runtime and a transport channel betwe ## PTY lifecycle -PTYs are managed by the platform host, not by the webview. The webview is a view layer that **resumes** over live PTYs (host-preserved) or **restores** from a Snapshot (cold start). See `docs/specs/ontology.md` for the Process / Link states. +PTYs are managed by the platform host, not by the webview. The webview is a view layer that **resumes** over live PTYs (host-preserved) or **restores** from a Snapshot (cold start). See `docs/specs/glossary.md` for the Process / Link states. ``` Platform host (always running while the adapter is active) From de1a7d940195399483a496c9dd4fcce638f6d4b8 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 13 May 2026 16:25:48 -0700 Subject: [PATCH 3/7] Update alert model --- docs/specs/OSC.md | 6 +- docs/specs/alert.md | 233 ++++++++++++---- docs/specs/glossary.md | 2 +- docs/specs/layout.md | 2 +- docs/specs/terminal-state.md | 4 + docs/specs/transport.md | 3 +- docs/specs/tutorial.md | 6 +- lib/src/components/Door.tsx | 8 +- lib/src/components/TodoAlertDialog.tsx | 9 +- lib/src/components/bell-icon-class.ts | 2 +- .../components/wall/TerminalPaneHeader.tsx | 9 +- lib/src/lib/activity-monitor.ts | 16 +- lib/src/lib/alert-manager.test.ts | 128 ++++++++- lib/src/lib/alert-manager.ts | 260 ++++++++++++++++-- lib/src/lib/platform/fake-adapter.ts | 4 +- lib/src/lib/platform/vscode-adapter.ts | 1 + lib/src/lib/session-activity-store.ts | 18 +- lib/src/lib/session-types.ts | 2 + lib/src/lib/terminal-lifecycle.ts | 4 +- lib/src/lib/terminal-registry.alert.test.ts | 24 +- lib/src/lib/terminal-store.ts | 2 + lib/src/stories/Baseboard.stories.tsx | 4 +- lib/src/stories/Door.stories.tsx | 6 +- .../stories/TerminalPaneHeader.stories.tsx | 6 +- standalone/src/tauri-adapter.ts | 4 +- vscode-ext/README.md | 6 +- vscode-ext/src/message-router.ts | 3 + vscode-ext/src/message-types.ts | 1 + vscode-ext/src/session-state.ts | 14 +- .../lib/__snapshots__/tut-runner.test.ts.snap | 2 +- website/src/lib/tut-detector.test.ts | 40 +-- website/src/lib/tut-detector.ts | 50 ++-- website/src/lib/tut-items.ts | 4 +- website/src/lib/tut-runner.ts | 2 +- website/src/pages/Playground.tsx | 2 +- 35 files changed, 689 insertions(+), 198 deletions(-) diff --git a/docs/specs/OSC.md b/docs/specs/OSC.md index 7673ed91..e30281a7 100644 --- a/docs/specs/OSC.md +++ b/docs/specs/OSC.md @@ -18,7 +18,7 @@ Supported OSCs are parsed at the PTY data boundary in the platform adapter: After parsing, supported sequences are consumed and not re-emitted. Known unsupported iTerm2/clipboard-capable OSCs listed in [Known-unimplemented iTerm2 and clipboard-capable sequences](#known-unimplemented-iterm2-and-clipboard-capable-sequences) are also consumed and ignored. The platform sends two streams to the webview: - `pty:data` — terminal output with supported OSCs already parsed/stripped. Feeds xterm.js. -- `terminal:semanticEvents` — normalized semantic events parsed in the platform (CWD, prompt/command boundaries, titles). Feeds `TerminalPaneState`. +- `terminal:semanticEvents` — normalized semantic events parsed in the platform (CWD, prompt/command boundaries, titles). Feeds `TerminalPaneState`; command boundaries also feed the command-exit alert track defined in `docs/specs/alert.md`. - Notification-derived state is delivered through `AlertManager` calls / `alert:state` messages, not through `pty:data`. For replay (`pty:replay`), the webview re-parses semantic OSCs from the buffered raw stream during reconstruction. Replay must not re-fire alerts, activity-monitor events, or protocol notifications: saved scrollback may contain raw OSC sequences, but replay filtering suppresses all protocol side effects so a resumed Session does not re-ring on every reload. @@ -42,8 +42,8 @@ Unknown non-iTerm2 OSC families pass through to xterm.js unchanged so xterm.js c | `OSC 9 ; 4 ; [; ] ST` | iTerm2 progress | [alert.md](alert.md#osc-94-progress) | | `OSC 9 ; 9 ; ST` | CWD (Windows Terminal / ConEmu) | [terminal-state.md](terminal-state.md#supported-osc-inputs) | | `OSC 99 ; ; ST` | kitty desktop notification | [alert.md](alert.md#osc-99) | -| `OSC 133 ; A/B/C/D [...] ST` | Prompt/command boundaries | [terminal-state.md](terminal-state.md#supported-osc-inputs) | -| `OSC 633 ; A/B/C/D ST` | VS Code prompt/command boundaries | [terminal-state.md](terminal-state.md#supported-osc-inputs) | +| `OSC 133 ; A/B/C/D [...] ST` | Prompt/command boundaries; command-exit alert input | [terminal-state.md](terminal-state.md#supported-osc-inputs), [alert.md](alert.md#command-exit-track) | +| `OSC 633 ; A/B/C/D ST` | VS Code prompt/command boundaries; command-exit alert input | [terminal-state.md](terminal-state.md#supported-osc-inputs), [alert.md](alert.md#command-exit-track) | | `OSC 633 ; E ; [; ] ST` | VS Code command line | [terminal-state.md](terminal-state.md#supported-osc-inputs) | | `OSC 633 ; P ; Cwd= ST` | CWD (VS Code) | [terminal-state.md](terminal-state.md#supported-osc-inputs) | | `OSC 777 ; notify ; ; <body> ST` | rxvt/WezTerm notification | [alert.md](alert.md#osc-777) | diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 6f3770c3..ed976f8e 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -2,9 +2,15 @@ ## Goal -The alert system is an opt-in reminder for a **Session** that may finish work while the user is looking elsewhere. Alert state lives on the Session itself, not on the Pane or Door that currently displays it. +The alert system is a reminder for a **Session** that may finish work while the user is looking elsewhere. Alert state lives on the Session itself, not on the Pane or Door that currently displays it. -Explicit terminal notification/progress reports are the exception to the opt-in rule. `OSC 9`, `OSC 9;4`, `OSC 99`, `OSC 777`, and standalone terminal `BEL` handling is specified in [Notification protocols](#notification-protocols) below; those protocol signals may cock the bell or force `ALERT_RINGING` even when the activity monitor is disabled. The OSC sequence registry and parsing-location rules live in `docs/specs/OSC.md`. +There are three independent ways a Session can become alert-worthy: + +- **WATCHING**: the user explicitly enables MouseTerm's timer-based watcher, output motion becomes busy, then motion stops while the Session lacks attention. +- **Explicit protocol notification**: the PTY emits a supported terminal notification/progress report (`OSC 9`, `OSC 9;4`, `OSC 99`, `OSC 777`, or standalone `BEL`) while the Session lacks attention. +- **Command exit after attention loss**: MouseTerm observes a foreground command running while the Session has attention, attention later expires or is explicitly lost, and that same command exits after running for at least `T_USER_ATTENTION`. + +Protocol notification/progress reports and command-exit alerts are not controlled by the WATCHING toggle. The OSC sequence registry and parsing-location rules live in `docs/specs/OSC.md`; command lifecycle state comes from `docs/specs/terminal-state.md`. This spec uses semantic state names that describe what the Session currently owes the user: @@ -12,6 +18,7 @@ This spec uses semantic state names that describe what the Session currently owe - `MIGHT_BE_BUSY` - `BUSY` - `OSC_NOTIF_BUSY` +- `COMMAND_EXIT_ARMED` - `MIGHT_NEED_ATTENTION` - `ALERT_RINGING` @@ -19,9 +26,10 @@ This document is the source of truth for the naming and behavior of this state m ## Non-goals -- No command sniffing or per-tool heuristics. We do not try to guess whether `vim`, `npm dev`, `claude`, or any other command is "appropriate" for alerts. +- No per-tool allow/deny heuristics. We do not try to guess whether `vim`, `npm dev`, `claude`, or any other command is "appropriate" for alerts. - No sound, native OS notifications, or browser notifications in v1. "Alarm" means MouseTerm's existing `ALERT_RINGING` visual state. - No standalone progress bar widget. `OSC 9;4` progress updates `protocolStatus` while active; completion/error creates TODO detail. It does not add a separate progress widget to the Pane header. +- No process-tree introspection for command-exit alerts in v1. Shell integration (`OSC 133` / `OSC 633`) is the reliable path. Heuristic user-input/prompt fallback may be used as a best-effort source, but deeper shell integration remains an open TODO. - No full iTerm2/kitty/rxvt/WezTerm feature parity. Unsupported sequences are ignored unless another spec claims them. - No HTML, Markdown, ANSI styling, shell command parsing, or clickable action buttons inside TODO notification previews. - No Door-specific alert menu that overrides the existing click-to-reattach behavior from `docs/specs/layout.md`. @@ -45,24 +53,35 @@ This is guidance only. The system does not auto-enable or auto-disable alerts ba Each Session owns: -- `status: 'ALERT_DISABLED' | 'NOTHING_TO_SHOW' | 'MIGHT_BE_BUSY' | 'BUSY' | 'OSC_NOTIF_BUSY' | 'MIGHT_NEED_ATTENTION' | 'ALERT_RINGING'` +- `status: 'WATCHING_DISABLED' | 'NOTHING_TO_SHOW' | 'MIGHT_BE_BUSY' | 'BUSY' | 'OSC_NOTIF_BUSY' | 'COMMAND_EXIT_ARMED' | 'MIGHT_NEED_ATTENTION' | 'ALERT_RINGING'` - This is the public projected alert and activity state for the Session. - - `ALERT_DISABLED`: visual alert tracking is off and no protocol state is active. Default state. - - Stable states: `ALERT_DISABLED`, `NOTHING_TO_SHOW`, `BUSY`, `OSC_NOTIF_BUSY`, `ALERT_RINGING`. + - `WATCHING_DISABLED`: WATCHING is off and no stronger protocol or command-exit state is active. Default state. + - Stable states: `WATCHING_DISABLED`, `NOTHING_TO_SHOW`, `BUSY`, `OSC_NOTIF_BUSY`, `COMMAND_EXIT_ARMED`, `ALERT_RINGING`. - Transitional states: `MIGHT_BE_BUSY`, `MIGHT_NEED_ATTENTION`. - - When the user enables the visual alert track, `visualStatus` transitions from `ALERT_DISABLED` to `NOTHING_TO_SHOW` and timer-based activity tracking begins fresh from that moment. - - When the user disables the visual alert track, timer-based activity tracking stops and `visualStatus` returns to `ALERT_DISABLED`. Public `status` may still be `OSC_NOTIF_BUSY` or `ALERT_RINGING` if `protocolStatus` is active. -- `visualStatus: 'ALERT_DISABLED' | 'NOTHING_TO_SHOW' | 'MIGHT_BE_BUSY' | 'BUSY' | 'MIGHT_NEED_ATTENTION' | 'ALERT_RINGING'` - - Internal timer-based status owned by the existing visual activity monitor. + - When the user enables WATCHING, `watchingStatus` transitions from `WATCHING_DISABLED` to `NOTHING_TO_SHOW` and timer-based activity tracking begins fresh from that moment. + - When the user disables WATCHING, timer-based activity tracking stops and `watchingStatus` returns to `WATCHING_DISABLED`. Public `status` may still be `OSC_NOTIF_BUSY`, `COMMAND_EXIT_ARMED`, or `ALERT_RINGING` if another track is active. +- `watchingStatus: 'WATCHING_DISABLED' | 'NOTHING_TO_SHOW' | 'MIGHT_BE_BUSY' | 'BUSY' | 'MIGHT_NEED_ATTENTION' | 'ALERT_RINGING'` + - Internal timer-based status owned by the existing activity monitor. - It is driven only by meaningful output, silence timers, and attention. - - It may be deleted in a future terminal-report-only implementation without changing the protocol notification model. + - Spec prose should use WATCHING terminology for this track. +- `watchingEnabled: boolean` + - Public boolean exposed to UI and persistence so the WATCHING toggle remains accurate while `status` is projected to `OSC_NOTIF_BUSY`, `COMMAND_EXIT_ARMED`, or `ALERT_RINGING`. + - This is `true` exactly when the Session owns an active WATCHING monitor. - `protocolStatus: 'IDLE' | 'OSC_NOTIF_BUSY' | 'ALERT_RINGING'` - Internal terminal-report status owned by parsed terminal reports (see [Notification protocols](#notification-protocols)). - It is driven only by terminal reports such as `OSC 9`, `OSC 9;4`, `OSC 99`, `OSC 777`, and standalone `BEL`. - - It does not use output/silence timers from the visual activity monitor. + - It does not use output/silence timers from the WATCHING activity monitor. - It does use the shared attention model. A protocol completion/notification received while the user is actively attending that Session must not ring. - `OSC_NOTIF_BUSY` means a terminal report says work is in progress, but there is not yet a notification owed to the user. - `ALERT_RINGING` means a terminal report explicitly created a notification or completed/errored a reported progress cycle. +- `commandExitStatus: 'IDLE' | 'COMMAND_EXIT_ARMED' | 'ALERT_RINGING'` + - Internal command-exit status owned by terminal semantic command lifecycle events. + - It is driven by `commandStart` / `commandFinish` events from `OSC 133`, `OSC 633`, or equivalent semantic sources. + - `COMMAND_EXIT_ARMED` means MouseTerm saw a foreground command while the Session had attention, then the Session lost attention while that same command was still running. + - `ALERT_RINGING` means that same command exited after running for at least `T_USER_ATTENTION` and the Session still lacked attention. +- `commandExitWatch: CommandExitWatch | null` + - Latest foreground command eligible for command-exit alerting. + - Cleared when the command finishes, another command starts, the user returns before finish, or the Session is destroyed. - `todo: boolean` - Reminder state for the Session. Default `false`. - `false`: no TODO. @@ -82,7 +101,13 @@ Each Session also owns: `ActivityNotification` shape (intentionally small — these are the only fields rendered): ```ts -type ActivityNotificationSource = 'OSC 9' | 'OSC 9;4' | 'OSC 99' | 'OSC 777' | 'BEL'; +type ActivityNotificationSource = + | 'OSC 9' + | 'OSC 9;4' + | 'OSC 99' + | 'OSC 777' + | 'BEL' + | 'COMMAND_EXIT'; interface ActivityNotification { source: ActivityNotificationSource; @@ -98,6 +123,7 @@ Per-source mapping rules (full protocol semantics in [Notification protocols](#n - `OSC 99` stores `{ source: 'OSC 99', title, body }` after chunk assembly and sanitization. - `OSC 9;4` stores nothing while progress is active. On completion/error it generates `{ source: 'OSC 9;4', title, body }`, where `title` is a short summary such as `Progress complete`, `Progress error`, or `Progress warning`, and `body` contains the percent when available. - Standalone `BEL` stores `{ source: 'BEL', title: 'Terminal bell', body: null }`. +- Command exit stores `{ source: 'COMMAND_EXIT', title: 'Command finished', body }`, where `body` contains the display command and exit status when available. Persistence rules: @@ -115,10 +141,11 @@ The workspace owns: Important invariants: - Alert state is session-scoped and survives Pane <-> Door transitions. -- `visualStatus` describes what the timer-based track owes the user since the last explicit attention boundary. -- `protocolStatus` describes what terminal reports say independently of the visual track. +- `watchingStatus` describes what the timer-based WATCHING track owes the user since the last explicit attention boundary. +- `protocolStatus` describes what terminal reports say independently of the WATCHING track. +- `commandExitStatus` describes whether a known foreground command has been armed for exit-based alerting. - Public `status` is a projection of those tracks for existing UI. -- Destroying a Session clears `todo`, `notification`, and `protocolStatus` with it; the activity monitor is disposed. +- Destroying a Session clears `todo`, `notification`, `protocolStatus`, and `commandExitStatus` with it; the activity monitor is disposed. - Re-rendering, theme changes, resize reflow, or remounting a Pane must not create a new alert by themselves. ## Attention model @@ -140,34 +167,37 @@ These do **not** count as attention: - a Door existing in the baseboard - reattaching a Door with `d`, because that restores the Pane but stays in command mode -Attention is cleared when: +Attention is lost when: - the user has not explicitly interacted with that Session for `T_USER_ATTENTION` - the app loses focus - the Session is minimized into a Door while it had attention - the Session is destroyed -`T_USER_ATTENTION` is intentionally finite so a user can run a slow command, walk away, and still get a visual alert later even if that Pane remained selected. Start with 15s and tune with real usage. +`T_USER_ATTENTION` is intentionally finite so a user can run a slow command, walk away, and still get an alert later even if that Pane remained selected. It also acts as the minimum command runtime for command-exit alerts. Start with 15s and tune with real usage. Doors never directly hold attention. A Door can only regain attention by being restored into a Pane through an action that enters passthrough. ## State model -There are two independent state models: +There are three independent state models: -- **Visual track**: the existing timer-based activity monitor. It watches meaningful output, silence, and user attention. Its internal state is `visualStatus`. +- **WATCHING track**: the existing timer-based activity monitor. It watches meaningful output, silence, and user attention only after the user has enabled WATCHING. Its internal state is `watchingStatus`. - **Terminal-report track**: parsed terminal notification/progress reports from the PTY. It relies entirely on terminal reports and never uses the output/silence timers. Its internal state is `protocolStatus`. +- **Command-exit track**: parsed terminal semantic command lifecycle events. It arms only after the user has seen a foreground command running and later loses attention before that same command exits. Its internal state is `commandExitStatus`. The public `status` is a projection used by existing UI: 1. If `protocolStatus === 'ALERT_RINGING'`, public `status = ALERT_RINGING`. -2. Else if `visualStatus === 'ALERT_RINGING'`, public `status = ALERT_RINGING`. -3. Else if `protocolStatus === 'OSC_NOTIF_BUSY'`, public `status = OSC_NOTIF_BUSY`. -4. Else public `status = visualStatus`. +2. Else if `commandExitStatus === 'ALERT_RINGING'`, public `status = ALERT_RINGING`. +3. Else if `watchingStatus === 'ALERT_RINGING'`, public `status = ALERT_RINGING`. +4. Else if `protocolStatus === 'OSC_NOTIF_BUSY'`, public `status = OSC_NOTIF_BUSY`. +5. Else if `commandExitStatus === 'COMMAND_EXIT_ARMED'`, public `status = COMMAND_EXIT_ARMED`. +6. Else public `status = watchingStatus`. -This projection is deliberate. Deleting the visual track should leave `protocolStatus: IDLE | OSC_NOTIF_BUSY | ALERT_RINGING` plus the same public projection behavior. The terminal-report path must be able to cock the bell and ring without `ActivityMonitor`, silence timers, or meaningful-output heuristics. It still relies on the shared user-attention model. +This projection is deliberate. No single combined enum should attempt to encode every combination of WATCHING/protocol/command-exit state. The terminal-report and command-exit paths must be able to ring without enabling WATCHING. All three tracks rely on the shared user-attention model. -### Visual track +### WATCHING track The point of the state machine is not to model every output blip. It is to answer a narrow question: @@ -208,8 +238,14 @@ All values are configurable via `cfg.alert`. Total silence from last meaningful - `OSC_NOTIF_BUSY` - Stable projected state from the terminal-report track. - The terminal explicitly reported ongoing progress or a similar protocol-backed busy condition. - - It looks the same as `BUSY` in the Pane header and Door, but it does not participate in visual-track timers. - - Visual-track silence does not move it to `MIGHT_NEED_ATTENTION`; only a terminal report can clear it or promote it to `ALERT_RINGING`. + - It looks the same as `BUSY` in the Pane header and Door, but it does not participate in WATCHING timers. + - WATCHING silence does not move it to `MIGHT_NEED_ATTENTION`; only a terminal report can clear it or promote it to `ALERT_RINGING`. + +- `COMMAND_EXIT_ARMED` + - Stable projected state from the command-exit track. + - MouseTerm saw a foreground command running while the Session had attention, and attention later expired or was explicitly lost while that command was still running. + - It looks the same as `BUSY` in the Pane header and Door, but it does not participate in WATCHING timers. + - Only the same command finishing, the user returning, another command starting, or Session teardown can clear or promote it. - `MIGHT_NEED_ATTENTION` - Transitional state entered when a `BUSY` Session goes quiet. @@ -237,13 +273,13 @@ All values are configurable via `cfg.alert`. Total silence from last meaningful | `ALERT_RINGING` | new meaningful output and the Session has attention | `MIGHT_BE_BUSY` | A new work cycle may be starting. | | `ALERT_RINGING` | new meaningful output but the Session lacks attention | `ALERT_RINGING` | Latch: new output does not silently clear the alert without user awareness. | -These transition rules apply to the visual track only. `OSC_NOTIF_BUSY` is not entered, exited, or promoted by these timers. +These transition rules apply to the WATCHING track only. `OSC_NOTIF_BUSY` and `COMMAND_EXIT_ARMED` are not entered, exited, or promoted by these timers. ### Terminal-report track | Current | Event | Next | Notes | |---|---|---|---| -| `IDLE` | terminal report starts progress (`OSC 9;4` active state) | `OSC_NOTIF_BUSY` | Cock the bell without enabling the visual activity monitor. | +| `IDLE` | terminal report starts progress (`OSC 9;4` active state) | `OSC_NOTIF_BUSY` | Cock the bell without enabling WATCHING. | | `OSC_NOTIF_BUSY` | terminal report updates progress | `OSC_NOTIF_BUSY` | Refresh internal progress state. Public UI remains visually identical to `BUSY`. | | `OSC_NOTIF_BUSY` | terminal report completes progress and Session lacks attention | `ALERT_RINGING` | Create `notification`, set `todo = true`, and ring. | | `OSC_NOTIF_BUSY` | terminal report completes progress and Session has attention | `IDLE` | User already sees it; do not ring or create TODO. | @@ -254,12 +290,32 @@ These transition rules apply to the visual track only. `OSC_NOTIF_BUSY` is not e | `IDLE` | explicit progress completion report (`OSC 9;4;1;100`) and Session has attention | `IDLE` | User already sees it; do not ring or create TODO. | | `IDLE` | explicit progress error report (`OSC 9;4;2`) and Session lacks attention | `ALERT_RINGING` | Create generated error `notification`, set `todo = true`, and ring. | | `IDLE` | explicit progress error report (`OSC 9;4;2`) and Session has attention | `IDLE` | User already sees it; do not ring or create TODO. | -| `ALERT_RINGING` | explicit attention boundary / dismiss / TODO clear | `IDLE` | Public status falls back to visual projection after protocol ring clears. | +| `ALERT_RINGING` | explicit attention boundary / dismiss / TODO clear | `IDLE` | Public status falls back to the command-exit/WATCHING projection after protocol ring clears. | | any | direct notification (`OSC 9`, completed `OSC 99`, `OSC 777`, standalone `BEL`) and Session lacks attention | `ALERT_RINGING` | Create `notification`, set `todo = true`, and ring immediately. | | any | direct notification (`OSC 9`, completed `OSC 99`, `OSC 777`, standalone `BEL`) and Session has attention | unchanged | User already sees it; suppress that notification only. Do not create TODO, and do not clear unrelated active progress. | `OSC_NOTIF_BUSY` never auto-rings because of silence. If a program starts progress and never sends completion/error, MouseTerm remains cocked until another terminal report completes/errors the progress cycle or the Session is destroyed. +### Command-exit track + +The command-exit track is intentionally stricter than WATCHING. It exists for this case: "I was watching a command run, then I stopped paying attention, then that command exited." + +| Current | Event | Next | Notes | +|---|---|---|---| +| `IDLE` | command starts while Session has attention | `IDLE` | Store `commandExitWatch` for that command. Do not arm until attention is lost. | +| `IDLE` | attention becomes active while a command is already running | `IDLE` | Store or update `commandExitWatch.seenWithAttentionAt`. | +| `IDLE` | watched command is still running and attention expires or is explicitly lost | `COMMAND_EXIT_ARMED` | Store `attentionLostAt`. | +| `COMMAND_EXIT_ARMED` | same command finishes, runtime is at least `T_USER_ATTENTION`, and Session lacks attention | `ALERT_RINGING` | Create generated command-exit notification, set `todo = true`, and ring. | +| `COMMAND_EXIT_ARMED` | same command finishes too quickly | `IDLE` | Clear without ringing. | +| `COMMAND_EXIT_ARMED` | Session regains attention before finish | `IDLE` | Clear the arm; the user is watching again. | +| any | a different command starts | `IDLE` | Replace the watch with the new command if it is eligible. | +| `ALERT_RINGING` | explicit attention boundary / dismiss / TODO clear | `IDLE` | Public status falls back to the other tracks. | +| any | Session destroyed | `IDLE` | Session teardown clears command-exit state. | + +Race rule: command-exit alerting is eligible only if attention was lost before the `commandFinish` event for the same command. If command finish and attention loss are observed in the opposite order, do not ring. + +Precedence rule: if a direct protocol notification/progress completion and command finish happen in the same parse batch, protocol detail wins. The command-exit track should not overwrite a richer protocol-generated `ActivityNotification`. + ### Meaningful output `Meaningful output` means terminal output that is not suppressed as incidental UI churn. In particular: @@ -272,7 +328,7 @@ The implementation may later learn additional suppressions, but this spec only r ## Notification protocols -Protocol notifications and standalone terminal bells are explicit application requests for attention. They bypass the normal opt-in activity monitor: a Session may ring even when its alert toggle was disabled. They must not ring while the user is actively attending that Session. +Protocol notifications and standalone terminal bells are explicit application requests for attention. They bypass the WATCHING toggle: a Session may ring even when WATCHING is disabled. They must not ring while the user is actively attending that Session. Active/in-progress progress sequences do not ring immediately. They "cock" the alarm bell — MouseTerm treats active progress as an explicit finite-work cycle and exposes `OSC_NOTIF_BUSY`. Explicit completion/error progress reports may ring immediately when the Session lacks attention. @@ -444,12 +500,12 @@ Requirements: ## Alert trigger -Visual alert logic is driven by transitions in `visualStatus`. Protocol alert logic is driven by transitions in `protocolStatus`. The public `status` projection reflects whichever track currently has the strongest user-facing claim. +WATCHING alert logic is driven by transitions in `watchingStatus`. Protocol alert logic is driven by transitions in `protocolStatus`. Command-exit alert logic is driven by transitions in `commandExitStatus`. The public `status` projection reflects whichever track currently has the strongest user-facing claim. -### Ringing starts when all of these are true +### WATCHING ring starts when all of these are true -- the Session has an active visual activity monitor (i.e. `visualStatus !== 'ALERT_DISABLED'`) -- the Session's `visualStatus` transitions from `MIGHT_NEED_ATTENTION` into `ALERT_RINGING` +- the Session has WATCHING enabled (i.e. `watchingStatus !== 'WATCHING_DISABLED'`) +- the Session's `watchingStatus` transitions from `MIGHT_NEED_ATTENTION` into `ALERT_RINGING` - the Session does not currently have attention ### Protocol override @@ -460,7 +516,7 @@ Supported terminal notification reports (see [Notification protocols](#notificat - obey attention suppression because the user may already be typing into or reading that Session - set `todo = true` and attach sanitized notification detail - do not enable or disable the activity monitor -- return to `ALERT_DISABLED` after dismissal if no activity monitor was enabled before the protocol ring +- return to `WATCHING_DISABLED` after dismissal if no activity monitor was enabled before the protocol ring Implementation surface inside `AlertManager`: @@ -468,24 +524,43 @@ Implementation surface inside `AlertManager`: - `OSC 9;4` progress is tracked internally in `AlertManager`, not in public `ActivityState`. - `getState(id).status` returns `ALERT_RINGING` while the protocol ring is active. - `getState(id).status` returns `OSC_NOTIF_BUSY` while internal protocol progress is active and no stronger state is present. -- Dismiss/attend clears the protocol ring; status falls back to the visual track or `ALERT_DISABLED` if no `ActivityMonitor` exists. +- Dismiss/attend clears the protocol ring; status falls back to the command-exit/WATCHING projection or `WATCHING_DISABLED` if no stronger state exists. - Completing or erroring a protocol progress cycle creates an `ActivityNotification` and promotes it into a protocol ring only if the Session lacks attention. - Methods such as `notifyFromProtocol(id, notification)` and `updateProtocolProgress(id, state, percent)` are exposed through `PlatformAdapter` / VS Code messages. +### Command-exit override + +Terminal semantic command lifecycle events may create a command-exit ring. Command-exit rings: + +- force public `status = ALERT_RINGING` even when WATCHING is disabled +- obey attention suppression because the user may already have returned to that Session +- set `todo = true` and attach generated command-exit detail unless a richer protocol notification is already ringing +- do not enable or disable WATCHING +- return to `WATCHING_DISABLED` after dismissal if no WATCHING monitor was enabled and no protocol progress is active + +Implementation surface inside `AlertManager`: + +- A `commandExitStatus` field independent of `ActivityMonitor` and `protocolStatus`. +- A `commandExitWatch` record for the current foreground command, storing command id, display command, source, `startedAt`, `seenWithAttentionAt`, and `attentionLostAt`. +- `getState(id).status` returns `COMMAND_EXIT_ARMED` while command-exit alerting is armed and no stronger state is present. +- `applyTerminalSemanticEvents(id, events)` consumes normalized command lifecycle events; it must not parse raw OSC directly. +- Dismiss/attend/TODO-clear clears the command-exit ring; status falls back to protocol or WATCHING projection. +- Command-exit rings require command runtime `>= T_USER_ATTENTION`. + ### Ringing does not start when any of these are true - the Session already has attention at the moment it would otherwise enter `ALERT_RINGING` - the Session is merely re-rendered or reattached while already `ALERT_RINGING` - the only recent output was resize noise already ignored by the completion detector -- for visual/activity-monitor rings only: the visual alert track is disabled (`visualStatus === 'ALERT_DISABLED'`) +- for WATCHING rings only: WATCHING is disabled (`watchingStatus === 'WATCHING_DISABLED'`) This "fresh transition into `ALERT_RINGING` only" rule is critical. It prevents duplicate alerts on remount, theme change, or Pane <-> Door movement. -Resize/activity-monitor suppression rules apply only to visual rings. Attention suppression applies to both visual and protocol rings. +Resize/activity-monitor suppression rules apply only to WATCHING rings. Attention suppression applies to WATCHING, protocol, and command-exit rings. ## Alert clearing rules -For activity-monitor rings, the Session leaves `ALERT_RINGING` and returns to `NOTHING_TO_SHOW` when any of these happen: +For WATCHING rings, the Session leaves `ALERT_RINGING` and returns to `NOTHING_TO_SHOW` when any of these happen: - the user attends to the Session (clicking into the Pane, typing in passthrough, restoring a Door via click/`Enter`) - the user dismisses the alert (clicking the ringing bell, pressing `a`) @@ -494,13 +569,15 @@ For activity-monitor rings, the Session leaves `ALERT_RINGING` and returns to `N All attention-based dismissals (the first three above) set `todo = true` if it is not already set. This prevents phantom dismissals where the alert vanishes without a trace. Once the TODO is visible, the user can clear it explicitly from the pill/dialog or by typing `Enter` as passthrough input into that Session's shell (i.e., the keystroke is forwarded to the PTY). The command-mode `Enter` that *switches into* passthrough does not clear the TODO. Synthetic terminal reports (focus events, cursor-position responses) also do not count as user input for clearing. -For protocol rings (see [Notification protocols](#notification-protocols)), clearing the protocol ring sets `protocolStatus = IDLE` and returns public `status` to the projected visual-track state. If no visual activity monitor was enabled before the protocol ring, the Session returns to `ALERT_DISABLED`. +For protocol rings (see [Notification protocols](#notification-protocols)), clearing the protocol ring sets `protocolStatus = IDLE` and returns public `status` to the projected command-exit/WATCHING state. If no WATCHING monitor was enabled before the protocol ring and no command-exit state is active, the Session returns to `WATCHING_DISABLED`. + +For command-exit rings, clearing the command-exit ring sets `commandExitStatus = IDLE` and returns public `status` to the projected protocol/WATCHING state. If no WATCHING monitor was enabled and no protocol state is active, the Session returns to `WATCHING_DISABLED`. -The visual track leaves `ALERT_RINGING` and returns to `ALERT_DISABLED` when: +The WATCHING track leaves `ALERT_RINGING` and returns to `WATCHING_DISABLED` when: -- the user disables visual alerts on that Session (disposes the activity monitor) +- the user disables WATCHING on that Session (disposes the activity monitor) -Disabling visual alerts does not clear `protocolStatus`. If `protocolStatus` is `OSC_NOTIF_BUSY` or `ALERT_RINGING`, public `status` remains protocol-driven. +Disabling WATCHING does not clear `protocolStatus` or `commandExitStatus`. If either stronger track is active, public `status` remains driven by that track. The Session's alert state is cleared entirely when: @@ -508,9 +585,9 @@ The Session's alert state is cleared entirely when: If more output arrives later and the Session makes a fresh transition back into `ALERT_RINGING`, the alert rings again. -Marking a Session as TODO resets an activity-monitor alert to `NOTHING_TO_SHOW` and sets `todo = true`, but it does **not** disable future alerts. `todo` and the alert toggle are separate concerns. Protocol rings preserve the same TODO behavior; clearing TODO clears `notification` unless the user explicitly chooses a future "keep details" action. +Marking a Session as TODO resets a WATCHING alert to `NOTHING_TO_SHOW` and sets `todo = true`, but it does **not** disable future WATCHING. `todo` and the WATCHING toggle are separate concerns. Protocol and command-exit rings preserve the same TODO behavior; clearing TODO clears `notification` unless the user explicitly chooses a future "keep details" action. -Disabling alerts disposes the visual activity monitor and returns `visualStatus` to `ALERT_DISABLED`. Public `status` returns to `ALERT_DISABLED` only when `protocolStatus === 'IDLE'`. +Disabling WATCHING disposes the activity monitor and returns `watchingStatus` to `WATCHING_DISABLED`. Public `status` returns to `WATCHING_DISABLED` only when `protocolStatus === 'IDLE'` and `commandExitStatus === 'IDLE'`. ## UI @@ -537,11 +614,12 @@ Alert button: - shown in all header tiers, including compact and minimal - icon-only control with tooltip and accessible label - visual states (pure function of `status`): - - `ALERT_DISABLED`: `BellIcon` unfilled, muted + - `WATCHING_DISABLED`: `BellIcon` unfilled, muted - `NOTHING_TO_SHOW`: `BellIcon` filled, muted, upright - `MIGHT_BE_BUSY`: `BellIcon` filled, muted, tilted slightly (-22.5°) - `BUSY`: `BellIcon` filled, muted, tilted 45° - `OSC_NOTIF_BUSY`: same visual treatment as `BUSY` + - `COMMAND_EXIT_ARMED`: same visual treatment as `BUSY` - `MIGHT_NEED_ATTENTION`: `BellIcon` filled, muted, tilted 60° - `ALERT_RINGING`: `BellIcon` filled, warning color, rocking animation (±45° bell-ring keyframe); reduced-motion: static 45° tilt - escalation is conveyed by increasing tilt angle, not by a separate badge element @@ -549,15 +627,16 @@ Alert button: Interaction (`dismissOrToggleAlert` state machine): -- left-click the bell while `ALERT_DISABLED`: enables the alert (creates activity monitor) +- left-click the bell while `WATCHING_DISABLED`: enables WATCHING (creates activity monitor) - left-click the bell while `ALERT_RINGING`: dismisses the alert, creates a TODO if none exists, then opens the context menu anchored below the button - left-click the bell after an attention-based dismissal (`attentionDismissedRing` is set): clears the flag and opens the context menu. This lets the user access TODO/disable options after attending to a ringing Session without requiring a right-click. -- left-click the bell while `OSC_NOTIF_BUSY`: does not clear protocol progress. If the visual track is enabled, disables only the visual track; if the visual track is disabled, opens the context menu. -- left-click the bell in any other enabled state: disables the alert (destroys activity monitor) +- left-click the bell while `OSC_NOTIF_BUSY`: does not clear protocol progress. If WATCHING is enabled, disables only WATCHING; if WATCHING is disabled, opens the context menu. +- left-click the bell while `COMMAND_EXIT_ARMED`: does not clear the command-exit arm. If WATCHING is enabled, disables only WATCHING; if WATCHING is disabled, opens the context menu. +- left-click the bell in any other WATCHING-enabled state: disables WATCHING (destroys activity monitor) - pressing `a` on a selected Pane in command mode: same as left-click - right-click the bell (any state): opens a context menu with: - a TODO on/off switch with `[t]` shortcut hint - - an alert on/off switch with `[a]` shortcut hint + - a WATCHING on/off switch with `[a]` shortcut hint - brief description of TODO clearing behavior - tooltip includes "Right-click for options" hint @@ -593,11 +672,12 @@ A Door is display-only for alert state in v1. It must not replace the existing D Door indicators: -- show bell indicator only when `status !== 'ALERT_DISABLED'` +- show bell indicator only when `status !== 'WATCHING_DISABLED'` - show TODO pill when `todo === true` - if `status === 'ALERT_RINGING'`, the Door bell icon uses warning color and the same rocking animation as the Pane header - the Door bell icon shows the same tilt angles as the Pane header for escalation states - `OSC_NOTIF_BUSY` uses the same Door bell treatment as `BUSY` +- `COMMAND_EXIT_ARMED` uses the same Door bell treatment as `BUSY` Door interaction: @@ -667,7 +747,7 @@ Consequences: ### Door rings, user wants to inspect immediately -- User minimizes an alert-enabled Session into a Door. +- User minimizes a WATCHING-enabled Session into a Door. - The Session later transitions into `ALERT_RINGING`. - The Door rings. - User clicks the Door. @@ -675,7 +755,7 @@ Consequences: ### Door rings, user wants to keep command-mode control -- User minimizes an alert-enabled Session into a Door. +- User minimizes a WATCHING-enabled Session into a Door. - The Door starts ringing. - User presses `d` on the Door in command mode. - The Pane is restored, but the ring remains because the user has not yet explicitly attended to the Session. @@ -696,13 +776,13 @@ Consequences: - User never presses `Enter` into the terminal → TODO persists. - User later notices the TODO pill and clicks it to clear it. -### OSC 9 rings with alerts disabled +### OSC 9 rings with WATCHING disabled -- Session starts with `status = ALERT_DISABLED`, `todo = false`. +- Session starts with `status = WATCHING_DISABLED`, `todo = false`. - PTY emits `OSC 9 ; Build finished ST`. - MouseTerm stores body `Build finished`, sets `todo = true`, and reports `ALERT_RINGING`. - User clicks into the Pane. -- Ring clears. Because the activity monitor was disabled, status returns to `ALERT_DISABLED`; TODO remains until explicitly cleared or passthrough `Enter` is sent. +- Ring clears. Because WATCHING was disabled, status returns to `WATCHING_DISABLED`; TODO remains until explicitly cleared or passthrough `Enter` is sent. ### OSC 777 preserves title and body @@ -738,6 +818,26 @@ Consequences: - PTY emits `OSC 9 ; Build finished ST`. - MouseTerm does not ring and does not create a TODO because the user is already attending that Session. +### Command exits after attention expires + +- User is typing into a Session in passthrough mode, so the Session has attention. +- PTY emits `OSC 633 ; E ; pnpm\x20build ST` and `OSC 633 ; C ST`; MouseTerm stores the foreground command as seen with attention. +- User stops interacting with that Session for at least `T_USER_ATTENTION`; MouseTerm clears attention and sets public `status = COMMAND_EXIT_ARMED`. +- The same command later emits `OSC 633 ; D ; 0 ST`. +- MouseTerm rings, sets `todo = true`, and stores a generated `COMMAND_EXIT` notification. + +### Quick command exit does not ring + +- User starts a command with attention and then immediately switches away. +- The command finishes before `T_USER_ATTENTION` elapsed since command start. +- MouseTerm clears the command-exit watch without ringing. + +### Returning before command exit disarms + +- User starts a command with attention, then attention expires and public `status = COMMAND_EXIT_ARMED`. +- User clicks back into the Session before the command finishes. +- MouseTerm clears the command-exit arm. If the command later finishes while the Session still has attention, it does not ring. + ### Restore does not replay old notifications - A Session receives an OSC notification and saves state with TODO detail. @@ -746,7 +846,7 @@ Consequences: ## Verification checklist -Visual track: +WATCHING track: - Alert only rings on a fresh transition into `ALERT_RINGING` - Single quick responses stay in `NOTHING_TO_SHOW` @@ -775,14 +875,25 @@ Notification protocols: - OSC 99 `d=1` completion rings once with combined title/body. - OSC 99 `p=?` is answered and does not ring; `p=close`, `p=alive`, `p=icon`, and `p=buttons` do not ring by themselves. - Extra standalone `BEL` in the same parse batch as a richer OSC event does not replace the richer notification detail. -- Protocol notifications ring with alert disabled. +- Protocol notifications ring with WATCHING disabled. - Protocol notifications do not ring when the Session has attention. -- Dismissal returns an alert-disabled Session to `ALERT_DISABLED`. -- Dismissal returns an alert-enabled Session to its monitor-backed state. +- Dismissal returns a WATCHING-disabled Session to `WATCHING_DISABLED`. +- Dismissal returns a WATCHING-enabled Session to its monitor-backed state. - TODO pill text remains stable under very long notification text. - Hover/focus preview wraps long text and does not overflow narrow headers or Doors. - Replay/restore does not re-fire notification side effects. +Command-exit track: + +- Command start while attended stores a command-exit watch without ringing. +- Attention expiry while the same command is running sets `COMMAND_EXIT_ARMED`. +- Explicit attention loss while the same command is running sets `COMMAND_EXIT_ARMED`. +- Returning to the Session before finish clears `COMMAND_EXIT_ARMED`. +- The same command finishing after runtime `>= T_USER_ATTENTION` rings only if the Session lacks attention. +- The same command finishing before runtime `T_USER_ATTENTION` does not ring. +- A different command start replaces the prior watch. +- A protocol notification in the same parse batch as command finish wins over generated command-exit detail. + ## References - iTerm2 proprietary escape codes (OSC 9, OSC 9;4): https://iterm2.com/documentation-escape-codes.html diff --git a/docs/specs/glossary.md b/docs/specs/glossary.md index 06661dd3..fac12ba3 100644 --- a/docs/specs/glossary.md +++ b/docs/specs/glossary.md @@ -63,7 +63,7 @@ A **Session** is the tuple of its `SessionId` plus one state per layer. `Session Keep the existing state machine (see `docs/specs/alert.md` for transition rules): -`ALERT_DISABLED` · `NOTHING_TO_SHOW` · `MIGHT_BE_BUSY` · `BUSY` · `OSC_NOTIF_BUSY` · `MIGHT_NEED_ATTENTION` · `ALERT_RINGING` +`WATCHING_DISABLED` · `NOTHING_TO_SHOW` · `MIGHT_BE_BUSY` · `BUSY` · `OSC_NOTIF_BUSY` · `COMMAND_EXIT_ARMED` · `MIGHT_NEED_ATTENTION` · `ALERT_RINGING` ### Snapshot diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 65d6ff9d..803a7511 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -289,7 +289,7 @@ On startup, recovery is priority-based: ### Activity state -Each session carries `ActivityState` with `status: SessionStatus`, `todo: boolean`, and `notification: ActivityNotification | null`. `status` is the projected public status from the timer-based visual track plus the terminal-report protocol track described in `docs/specs/alert.md`; it may be `OSC_NOTIF_BUSY` when OSC progress has cocked the bell. These are synced to React via `useSyncExternalStore`. State that arrives from the platform before a registry entry exists (resume scenario) is held as "primed state" and applied when the registry entry is created. +Each session carries `ActivityState` with `status: SessionStatus`, `watchingEnabled: boolean`, `todo: boolean`, and `notification: ActivityNotification | null`. `status` is the projected public status from the timer-based WATCHING track, terminal-report protocol track, and command-exit track described in `docs/specs/alert.md`; it may be `OSC_NOTIF_BUSY` when OSC progress has cocked the bell or `COMMAND_EXIT_ARMED` when a watched foreground command is running after attention was lost. `watchingEnabled` keeps the WATCHING toggle accurate when `status` is projected to a stronger protocol or command-exit state. These are synced to React via `useSyncExternalStore`. State that arrives from the platform before a registry entry exists (resume scenario) is held as "primed state" and applied when the registry entry is created. Each session also carries `TerminalPaneState` from `docs/specs/terminal-state.md`. The frontend store is keyed by the current pane/session id, and PTY-originated semantic events are resolved through `ptyId` so swapped sessions keep their CWD and command state with the terminal content. diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index 0c57bc0a..60ab1616 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -117,6 +117,8 @@ type TerminalSemanticEvent = Feature code consumes `TerminalPaneState` or `TerminalSemanticEvent`, never raw OSC sequences. Protocol-derived semantic events are timestamped in stream order before they reach the reducer, so command-start boundaries and title candidates from the same PTY chunk remain comparable even when they were parsed in the same millisecond. +`AlertManager` also consumes command lifecycle semantic events for command-exit alerting. That alert path is specified in `docs/specs/alert.md`: a foreground command can arm an alert only after it was observed while the Session had attention and that attention later expired or was explicitly lost before the same command finished. + ## Supported OSC Inputs CWD: @@ -165,6 +167,8 @@ Non-OSC title source: - `user` — user-pinned title set via the inline rename UI (`setTerminalUserTitle`). Always wins over every other candidate. +The `user_input` command fallback is best effort. It is sufficient for headers and grouping, but command-exit alerting may treat it as lower confidence or ignore it until deeper shell integration exists. + The parser accepts both BEL and ST terminators and handles split chunks. Supported-but-malformed semantic OSCs are consumed without changing state. Unsupported OSC pass-through vs. consume/ignore behavior is defined centrally in `docs/specs/OSC.md`. ## Reducer diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 29667be4..1fbb6555 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -106,7 +106,7 @@ Message types live in `vscode-ext/src/message-types.ts` (the canonical schema; o | `pty:shells` | Available shells list response (matched by requestId) | | `mouseterm:flushSessionSave` | Request webview to save state now (host shutdown trigger, matched by requestId) | | `mouseterm:openThemeDebugger` | Command-triggered request to open the shared theme debugger dialog | -| `alert:state` | Alert state change (projected status, todo, notification, attentionDismissedRing) | +| `alert:state` | Alert state change (projected status, watchingEnabled, todo, notification, attentionDismissedRing) | The OSC parsing/stripping rules that produce `pty:data` and `terminal:semanticEvents` are specified in `docs/specs/OSC.md`. @@ -131,6 +131,7 @@ interface PersistedPane { interface PersistedAlertState { status: SessionStatus; + watchingEnabled?: boolean; todo: boolean; notification?: ActivityNotification | null; } diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index 9cbd3c97..ea5c893f 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -59,16 +59,16 @@ The detector subscribes to `subscribeToActivity()` and tracks per-id `(status, t | ID | Title | Detection | |---|---|---| -| `al-enable` | Enable alerts on a pane (click bell or `a`) | status transitions away from `ALERT_DISABLED` | +| `al-enable` | Enable WATCHING on a pane (click bell or `a`) | status transitions away from `WATCHING_DISABLED` | | `al-busy` | Watch the bell tilt while a task runs | status enters `BUSY`, `MIGHT_BE_BUSY`, or `OSC_NOTIF_BUSY` | | `al-ring` | Bell rings on completion | status enters `ALERT_RINGING` | | `al-todo-auto` | TODO appears when you dismiss the ringing alert | `todo` transitions `false → true` while previous status was `ALERT_RINGING` | | `al-todo-clear` | Press passthrough Enter to clear the TODO | `todo` transitions `true → false` | | `al-todo-manual` | Manually add a TODO (`t` or right-click) | `todo` transitions `false → true` while previous status was NOT `ALERT_RINGING` | -The detector remembers the most recent pane whose alert was enabled. The Alert section view shows a runner-local instruction: "Press `s` here to start a fake busy task." `s` is **not** a real MouseTerm shortcut; it is intercepted by `TutRunner` only while the Alert section is open. When pressed, the runner does two things: +The detector remembers the most recent pane whose WATCHING track was enabled. The Alert section view shows a runner-local instruction: "Press `s` here to start a fake busy task." `s` is **not** a real MouseTerm shortcut; it is intercepted by `TutRunner` only while the Alert section is open. When pressed, the runner does two things: -1. Resolves that pane to its current PTY session id, then calls `adapter.pumpActivity(sessionId, BUSY_DEMO_DURATION_MS, 800)` — drives the alert-manager's activity monitor on the same alert-enabled session with **no text output**, so the bell tilts to BUSY without scrolling any scenario text. The session id is resolved at trigger time so `Cmd/Ctrl+Arrow` swaps do not leave the tutorial pumping an old pane id. If no alert-enabled pane is known, the runner falls back to `PANE_BOXED` (the changelog pane). `BUSY_DEMO_DURATION_MS` is `cfg.alert.userAttention + 250` so silence begins after the attention idle window has expired, with a small scheduler-jitter guard; otherwise the "user is looking at this pane" check inside `ActivityMonitor.startNeedsAttentionConfirmTimer` would suppress the ring rather than let it fire. +1. Resolves that pane to its current PTY session id, then calls `adapter.pumpActivity(sessionId, BUSY_DEMO_DURATION_MS, 800)` — drives the alert-manager's activity monitor on the same WATCHING-enabled session with **no text output**, so the bell tilts to BUSY without scrolling any scenario text. The session id is resolved at trigger time so `Cmd/Ctrl+Arrow` swaps do not leave the tutorial pumping an old pane id. If no WATCHING-enabled pane is known, the runner falls back to `PANE_BOXED` (the changelog pane). `BUSY_DEMO_DURATION_MS` is `cfg.alert.userAttention + 250` so silence begins after the attention idle window has expired, with a small scheduler-jitter guard; otherwise the "user is looking at this pane" check inside `ActivityMonitor.startNeedsAttentionConfirmTimer` would suppress the ring rather than let it fire. 2. Animates a countdown in-place where the "Press s…" hint was: `⠋ Fake task will finish in N seconds.` ticking down to 1, then a static `✓ Fake task finished. Press s to start another one.` once the activity stops. Detection is purely timing-based via the existing `ActivityMonitor`, so no shell integration is required. ### Section 3 — Copy paste (4 items) diff --git a/lib/src/components/Door.tsx b/lib/src/components/Door.tsx index 56a18a8e..fc35b7e9 100644 --- a/lib/src/components/Door.tsx +++ b/lib/src/components/Door.tsx @@ -15,11 +15,11 @@ export interface DoorProps { export function Door({ doorId, title, - status = 'ALERT_DISABLED', + status = 'WATCHING_DISABLED', todo = false, onClick, }: DoorProps) { - const alertEnabled = status !== 'ALERT_DISABLED'; + const showBell = status !== 'WATCHING_DISABLED'; const alertRinging = status === 'ALERT_RINGING'; const todoPill = useTodoPillContent(todo); @@ -38,7 +38,7 @@ export function Door({ <span className="min-w-0 flex-1 truncate"> {title} </span> - {(todoPill.visible || alertEnabled) && ( + {(todoPill.visible || showBell) && ( <span className="flex shrink-0 items-center gap-1.5"> {todoPill.visible && ( <span @@ -48,7 +48,7 @@ export function Door({ {todoPill.body} </span> )} - {alertEnabled && ( + {showBell && ( <span className={alertRinging ? 'text-alarm-vs-door' : ''}> <BellIcon size={11} weight="fill" className={bellIconClass(status)} /> </span> diff --git a/lib/src/components/TodoAlertDialog.tsx b/lib/src/components/TodoAlertDialog.tsx index c8c86808..8aca089a 100644 --- a/lib/src/components/TodoAlertDialog.tsx +++ b/lib/src/components/TodoAlertDialog.tsx @@ -81,7 +81,6 @@ export function TodoAlertDialog({ }) { const activityStates = useSyncExternalStore(subscribeToActivity, getActivitySnapshot); const activity = activityStates.get(sessionId) ?? DEFAULT_ACTIVITY_STATE; - const alertEnabled = activity.status !== 'ALERT_DISABLED'; const dialogRef = useRef<HTMLDivElement>(null); const [position, setPosition] = useState<CSSProperties>({ left: triggerRect.left, @@ -195,14 +194,14 @@ export function TodoAlertDialog({ label="TODO" /> - {/* Alert row */} + {/* WATCHING row */} <Shortcut>a</Shortcut> - <span className="text-sm font-medium text-foreground">alert</span> + <span className="text-sm font-medium text-foreground">WATCHING</span> <OnOffSwitch - on={alertEnabled} + on={activity.watchingEnabled} onEnable={() => toggleSessionAlert(sessionId)} onDisable={() => disableSessionAlert(sessionId)} - label="alert" + label="WATCHING" /> </div> diff --git a/lib/src/components/bell-icon-class.ts b/lib/src/components/bell-icon-class.ts index f23322c6..86f47f57 100644 --- a/lib/src/components/bell-icon-class.ts +++ b/lib/src/components/bell-icon-class.ts @@ -6,7 +6,7 @@ export function bellIconClass(status: SessionStatus): string { return [ 'transition-transform', status === 'MIGHT_BE_BUSY' && '-rotate-[22.5deg]', - (status === 'BUSY' || status === 'OSC_NOTIF_BUSY') && 'rotate-45', + (status === 'BUSY' || status === 'OSC_NOTIF_BUSY' || status === 'COMMAND_EXIT_ARMED') && 'rotate-45', status === 'MIGHT_NEED_ATTENTION' && 'rotate-[60deg]', status === 'ALERT_RINGING' && ( cfg.alert.ringingPaused diff --git a/lib/src/components/wall/TerminalPaneHeader.tsx b/lib/src/components/wall/TerminalPaneHeader.tsx index 6fcf8daa..90c614f6 100644 --- a/lib/src/components/wall/TerminalPaneHeader.tsx +++ b/lib/src/components/wall/TerminalPaneHeader.tsx @@ -65,15 +65,16 @@ const tabVariant = tv({ type HeaderTier = 'full' | 'compact' | 'minimal'; -const ALERT_BUTTON_ENABLED = { aria: 'Disable alert', tooltip: '[a] Disable alerts' }; +const ALERT_BUTTON_ENABLED = { aria: 'Disable watching', tooltip: '[a] Disable WATCHING' }; const ALERT_BUTTON_LABELS: Record<SessionStatus, { aria: string; tooltip: string }> = { - ALERT_DISABLED: { aria: 'Enable alert', tooltip: '[a] Enable alerts' }, + WATCHING_DISABLED: { aria: 'Enable watching', tooltip: '[a] Enable WATCHING' }, NOTHING_TO_SHOW: ALERT_BUTTON_ENABLED, MIGHT_BE_BUSY: ALERT_BUTTON_ENABLED, BUSY: ALERT_BUTTON_ENABLED, MIGHT_NEED_ATTENTION: ALERT_BUTTON_ENABLED, ALERT_RINGING: { aria: 'Alert ringing', tooltip: 'Alert ringing' }, OSC_NOTIF_BUSY: { aria: 'Progress active', tooltip: 'Progress active' }, + COMMAND_EXIT_ARMED: { aria: 'Command running', tooltip: 'Command running' }, }; const TODO_PREVIEW_GAP = 6; const TODO_PREVIEW_MARGIN = 8; @@ -152,7 +153,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { const triggerAlertButtonAction = useCallback((displayedStatus: SessionStatus, button: HTMLButtonElement) => { const result = actions.onAlertButton(api.id, displayedStatus); - if (result === 'dismissed') { + if (result === 'dismissed' || result === 'menu') { setDialogTriggerRect(button.getBoundingClientRect()); } }, [actions, api.id]); @@ -269,7 +270,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { dataAlertButtonFor={api.id} > <span className="flex items-center justify-center"> - {activity.status === 'ALERT_DISABLED' ? ( + {activity.status === 'WATCHING_DISABLED' ? ( <BellIcon size={14} /> ) : ( <BellIcon size={14} weight="fill" className={bellIconClass(activity.status)} /> diff --git a/lib/src/lib/activity-monitor.ts b/lib/src/lib/activity-monitor.ts index c52656e0..b200294b 100644 --- a/lib/src/lib/activity-monitor.ts +++ b/lib/src/lib/activity-monitor.ts @@ -1,18 +1,18 @@ import { cfg } from '../cfg'; -export type VisualSessionStatus = - | 'ALERT_DISABLED' +export type WatchingSessionStatus = + | 'WATCHING_DISABLED' | 'NOTHING_TO_SHOW' | 'MIGHT_BE_BUSY' | 'BUSY' | 'MIGHT_NEED_ATTENTION' | 'ALERT_RINGING'; -export type SessionStatus = VisualSessionStatus | 'OSC_NOTIF_BUSY'; +export type SessionStatus = WatchingSessionStatus | 'OSC_NOTIF_BUSY' | 'COMMAND_EXIT_ARMED'; export interface ActivityMonitorOptions { hasAttention?: () => boolean; - onChange?: (status: VisualSessionStatus, previousStatus: VisualSessionStatus) => void; + onChange?: (status: WatchingSessionStatus, previousStatus: WatchingSessionStatus) => void; } const T_BUSY_CANDIDATE_GAP = cfg.alert.busyCandidateGap; @@ -22,7 +22,7 @@ const T_ALERT_RINGING_CONFIRM = cfg.alert.needsAttentionConfirm; const T_RESIZE_DEBOUNCE = cfg.alert.resizeDebounce; export class ActivityMonitor { - private status: VisualSessionStatus = 'NOTHING_TO_SHOW'; + private status: WatchingSessionStatus = 'NOTHING_TO_SHOW'; private resizeGrace = false; private busyCandidateTimer: ReturnType<typeof setTimeout> | null = null; private busyConfirmTimer: ReturnType<typeof setTimeout> | null = null; @@ -34,14 +34,14 @@ export class ActivityMonitor { private lastOutputAt: number | null = null; private outputCountSinceAttention = 0; private readonly hasAttention: () => boolean; - private readonly onChange: ((status: VisualSessionStatus, previousStatus: VisualSessionStatus) => void) | null; + private readonly onChange: ((status: WatchingSessionStatus, previousStatus: WatchingSessionStatus) => void) | null; constructor(options?: ActivityMonitorOptions) { this.hasAttention = options?.hasAttention ?? (() => false); this.onChange = options?.onChange ?? null; } - getStatus(): VisualSessionStatus { + getStatus(): WatchingSessionStatus { return this.status; } @@ -207,7 +207,7 @@ export class ActivityMonitor { this.outputCountSinceAttention = 0; } - private setStatus(status: VisualSessionStatus): void { + private setStatus(status: WatchingSessionStatus): void { if (this.status === status) return; const previousStatus = this.status; this.status = status; diff --git a/lib/src/lib/alert-manager.test.ts b/lib/src/lib/alert-manager.test.ts index ebc98d0a..93c19417 100644 --- a/lib/src/lib/alert-manager.test.ts +++ b/lib/src/lib/alert-manager.test.ts @@ -182,7 +182,7 @@ describe('AlertManager in isolation', () => { expect(manager.getState(id).todo).toBe(false); }); - it('protocol notifications ring and create TODO detail even when visual alerts are disabled', () => { + it('protocol notifications ring and create TODO detail even when WATCHING is disabled', () => { const id = 'osc-notification'; manager.notifyFromProtocol(id, { source: 'OSC 9', title: null, body: 'Build finished' }); @@ -195,20 +195,20 @@ describe('AlertManager in isolation', () => { manager.dismissAlert(id); expect(manager.getState(id)).toMatchObject({ - status: 'ALERT_DISABLED', + status: 'WATCHING_DISABLED', todo: true, notification: { source: 'OSC 9', title: null, body: 'Build finished' }, }); manager.clearTodo(id); expect(manager.getState(id)).toMatchObject({ - status: 'ALERT_DISABLED', + status: 'WATCHING_DISABLED', todo: false, notification: null, }); }); - it('terminal bell notifications ring and create TODO detail even when visual alerts are disabled', () => { + it('terminal bell notifications ring and create TODO detail even when WATCHING is disabled', () => { const id = 'terminal-bell'; applyTerminalProtocolEvents(manager, id, [ @@ -228,6 +228,7 @@ describe('AlertManager in isolation', () => { manager.updateProtocolProgress(id, { state: 'normal', percent: 25 }); expect(manager.getState(id)).toMatchObject({ status: 'OSC_NOTIF_BUSY', + watchingEnabled: false, todo: false, notification: null, }); @@ -243,6 +244,35 @@ describe('AlertManager in isolation', () => { }); }); + it('opens the bell menu for protocol progress when WATCHING is disabled', () => { + const id = 'osc-progress-menu'; + + manager.updateProtocolProgress(id, { state: 'normal', percent: 25 }); + + expect(manager.dismissOrToggleAlert(id, 'OSC_NOTIF_BUSY')).toBe('menu'); + expect(manager.getState(id)).toMatchObject({ + status: 'OSC_NOTIF_BUSY', + watchingEnabled: false, + }); + }); + + it('disables only WATCHING for protocol progress when WATCHING is enabled', () => { + const id = 'osc-progress-disable-watching'; + + manager.toggleAlert(id); + manager.updateProtocolProgress(id, { state: 'normal', percent: 25 }); + expect(manager.getState(id)).toMatchObject({ + status: 'OSC_NOTIF_BUSY', + watchingEnabled: true, + }); + + expect(manager.dismissOrToggleAlert(id, 'OSC_NOTIF_BUSY')).toBe('disabled'); + expect(manager.getState(id)).toMatchObject({ + status: 'OSC_NOTIF_BUSY', + watchingEnabled: false, + }); + }); + it('protocol completion is suppressed while the user has attention', () => { const id = 'osc-progress-attention'; @@ -252,7 +282,7 @@ describe('AlertManager in isolation', () => { manager.updateProtocolProgress(id, { state: 'normal', percent: 100 }); expect(manager.getState(id)).toMatchObject({ - status: 'ALERT_DISABLED', + status: 'WATCHING_DISABLED', todo: false, notification: null, }); @@ -265,7 +295,7 @@ describe('AlertManager in isolation', () => { manager.notifyFromProtocol(id, { source: 'OSC 777', title: 'done', body: 'Build finished' }); expect(manager.getState(id)).toMatchObject({ - status: 'ALERT_DISABLED', + status: 'WATCHING_DISABLED', todo: false, notification: null, }); @@ -296,9 +326,93 @@ describe('AlertManager in isolation', () => { ]); expect(manager.getState(id)).toMatchObject({ - status: 'ALERT_DISABLED', + status: 'WATCHING_DISABLED', + todo: false, + notification: null, + }); + }); + + it('arms and rings when an attended command loses attention before exiting', () => { + const id = 'command-exit'; + + manager.attend(id); + manager.applyTerminalSemanticEvents(id, [ + { type: 'commandLine', commandLine: 'pnpm build' }, + { type: 'commandStart', source: 'osc633_E', startedAt: Date.now() }, + ]); + + vi.advanceTimersByTime(15_000); + expect(manager.getState(id).status).toBe('COMMAND_EXIT_ARMED'); + + manager.applyTerminalSemanticEvents(id, [{ type: 'commandFinish', exitCode: 0 }]); + expect(manager.getState(id)).toMatchObject({ + status: 'ALERT_RINGING', + todo: true, + notification: { source: 'COMMAND_EXIT', title: 'Command finished', body: 'pnpm build exited 0' }, + }); + }); + + it('does not ring command-exit alerts for commands shorter than the attention window', () => { + const id = 'quick-command-exit'; + + manager.attend(id); + manager.applyTerminalSemanticEvents(id, [ + { type: 'commandLine', commandLine: 'git status' }, + { type: 'commandStart', source: 'osc633_E', startedAt: Date.now() }, + ]); + manager.clearAttention(id); + + vi.advanceTimersByTime(1_000); + expect(manager.getState(id).status).toBe('COMMAND_EXIT_ARMED'); + + manager.applyTerminalSemanticEvents(id, [{ type: 'commandFinish', exitCode: 0 }]); + expect(manager.getState(id)).toMatchObject({ + status: 'WATCHING_DISABLED', + todo: false, + notification: null, + }); + }); + + it('disarms command-exit alerts when the user returns before finish', () => { + const id = 'command-exit-return'; + + manager.attend(id); + manager.applyTerminalSemanticEvents(id, [ + { type: 'commandLine', commandLine: 'pnpm test' }, + { type: 'commandStart', source: 'osc633_E', startedAt: Date.now() }, + ]); + vi.advanceTimersByTime(15_000); + expect(manager.getState(id).status).toBe('COMMAND_EXIT_ARMED'); + + manager.attend(id); + expect(manager.getState(id).status).toBe('WATCHING_DISABLED'); + + vi.advanceTimersByTime(1_000); + manager.applyTerminalSemanticEvents(id, [{ type: 'commandFinish', exitCode: 0 }]); + expect(manager.getState(id)).toMatchObject({ + status: 'WATCHING_DISABLED', todo: false, notification: null, }); }); + + it('preserves richer protocol detail when protocol and command exit both ring', () => { + const id = 'command-exit-protocol-wins'; + + manager.attend(id); + manager.applyTerminalSemanticEvents(id, [ + { type: 'commandLine', commandLine: 'pnpm build' }, + { type: 'commandStart', source: 'osc633_E', startedAt: Date.now() }, + ]); + vi.advanceTimersByTime(15_000); + + manager.notifyFromProtocol(id, { source: 'OSC 9', title: null, body: 'Build finished' }); + manager.applyTerminalSemanticEvents(id, [{ type: 'commandFinish', exitCode: 0 }]); + + expect(manager.getState(id)).toMatchObject({ + status: 'ALERT_RINGING', + todo: true, + notification: { source: 'OSC 9', title: null, body: 'Build finished' }, + }); + }); }); diff --git a/lib/src/lib/alert-manager.ts b/lib/src/lib/alert-manager.ts index a05300dd..b74142f3 100644 --- a/lib/src/lib/alert-manager.ts +++ b/lib/src/lib/alert-manager.ts @@ -1,12 +1,18 @@ import { ActivityMonitor, type SessionStatus } from './activity-monitor'; import { cfg } from '../cfg'; +import { + DEFAULT_COMMAND_TITLE, + summarizeCommandLine, + type CommandRunSource, + type TerminalSemanticEvent, +} from './terminal-state'; export { type SessionStatus } from './activity-monitor'; /** Boolean TODO state: on (true) or off (false). */ export type TodoState = boolean; -export const ACTIVITY_NOTIFICATION_SOURCES = ['OSC 9', 'OSC 9;4', 'OSC 99', 'OSC 777', 'BEL'] as const; +export const ACTIVITY_NOTIFICATION_SOURCES = ['OSC 9', 'OSC 9;4', 'OSC 99', 'OSC 777', 'BEL', 'COMMAND_EXIT'] as const; export type ActivityNotificationSource = typeof ACTIVITY_NOTIFICATION_SOURCES[number]; export interface ActivityNotification { @@ -23,6 +29,7 @@ export interface ProtocolProgressUpdate { } type ProtocolStatus = 'IDLE' | 'OSC_NOTIF_BUSY' | 'ALERT_RINGING'; +type CommandExitStatus = 'IDLE' | 'COMMAND_EXIT_ARMED' | 'ALERT_RINGING'; type ActiveProtocolProgressState = 'normal' | 'warning' | 'indeterminate'; interface ActiveProtocolProgress { @@ -30,6 +37,16 @@ interface ActiveProtocolProgress { percent: number | null; } +interface CommandExitWatch { + id: number; + rawCommandLine: string | null; + displayCommand: string; + source: CommandRunSource; + startedAt: number; + seenWithAttentionAt: number | null; + attentionLostAt: number | null; +} + /** Migrate legacy persisted TodoState values (numeric, string, boolean) to a boolean. */ export function migrateTodoState(todo: unknown): TodoState { if (typeof todo === 'boolean') return todo; @@ -62,10 +79,11 @@ function normalizeNotificationTextField(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } -export type AlertButtonActionResult = 'enabled' | 'disabled' | 'dismissed' | 'noop'; +export type AlertButtonActionResult = 'enabled' | 'disabled' | 'dismissed' | 'menu' | 'noop'; export interface AlertState { status: SessionStatus; + watchingEnabled: boolean; todo: TodoState; notification: ActivityNotification | null; /** Used by dismissOrToggleAlert to detect post-attention dismiss */ @@ -73,7 +91,8 @@ export interface AlertState { } export const DEFAULT_ALERT_STATE: AlertState = { - status: 'ALERT_DISABLED', + status: 'WATCHING_DISABLED', + watchingEnabled: false, todo: false, notification: null, attentionDismissedRing: false, @@ -83,6 +102,9 @@ interface AlertEntry { monitor: ActivityMonitor | null; protocolStatus: ProtocolStatus; progress: ActiveProtocolProgress | null; + commandExitStatus: CommandExitStatus; + commandExitWatch: CommandExitWatch | null; + pendingCommandLine: string | null; todo: TodoState; notification: ActivityNotification | null; attentionDismissedRing: boolean; @@ -102,6 +124,7 @@ export class AlertManager { private attentionTimer: ReturnType<typeof setTimeout> | null = null; private listeners = new Set<(id: string, state: AlertState) => void>(); private lastEmitted = new Map<string, AlertState>(); + private nextCommandExitWatchId = 0; // --- State change subscription --- @@ -221,6 +244,147 @@ export class AlertManager { return true; } + // --- Command-exit track --- + + applyTerminalSemanticEvents(id: string, events: TerminalSemanticEvent[]): void { + if (events.length === 0) return; + const entry = this.getOrCreateEntry(id); + let changed = false; + + for (const event of events) { + if (event.type === 'commandLine') { + if (entry.pendingCommandLine !== event.commandLine) { + entry.pendingCommandLine = event.commandLine; + changed = true; + } + continue; + } + + if (event.type === 'commandStart') { + this.startCommandExitWatch(id, entry, event); + changed = true; + continue; + } + + if (event.type === 'commandFinish') { + changed = this.finishCommandExitWatch(id, entry, event.exitCode) || changed; + continue; + } + + if (event.type === 'promptStart' || event.type === 'promptEnd') { + if (entry.pendingCommandLine !== null) { + entry.pendingCommandLine = null; + changed = true; + } + } + } + + if (changed) this.notify(id); + } + + private startCommandExitWatch( + id: string, + entry: AlertEntry, + event: Extract<TerminalSemanticEvent, { type: 'commandStart' }>, + ): void { + const raw = entry.pendingCommandLine; + const source = event.source === 'osc633_boundaries' && raw + ? 'osc633_E' + : event.source ?? (raw ? 'osc633_E' : 'osc133_boundaries'); + entry.pendingCommandLine = null; + entry.commandExitStatus = entry.commandExitStatus === 'ALERT_RINGING' ? 'ALERT_RINGING' : 'IDLE'; + entry.commandExitWatch = { + id: ++this.nextCommandExitWatchId, + rawCommandLine: raw, + displayCommand: raw ? summarizeCommandLine(raw) : DEFAULT_COMMAND_TITLE, + source, + startedAt: event.startedAt ?? Date.now(), + seenWithAttentionAt: this.hasAttention(id) ? Date.now() : null, + attentionLostAt: null, + }; + } + + private finishCommandExitWatch( + id: string, + entry: AlertEntry, + exitCode: number | undefined, + ): boolean { + const watch = entry.commandExitWatch; + entry.commandExitWatch = null; + entry.pendingCommandLine = null; + + const wasArmed = entry.commandExitStatus === 'COMMAND_EXIT_ARMED'; + if (entry.commandExitStatus !== 'ALERT_RINGING') { + entry.commandExitStatus = 'IDLE'; + } + + if (!watch || !wasArmed) return wasArmed; + if (this.hasAttention(id)) return true; + + const finishedAt = Date.now(); + if (finishedAt - watch.startedAt < T_USER_ATTENTION) return true; + + this.setCommandExitRinging(id, entry, watch, exitCode); + return true; + } + + private markCommandExitSeen(entry: AlertEntry): boolean { + const watch = entry.commandExitWatch; + if (!watch) return false; + let changed = false; + if (watch.seenWithAttentionAt === null) { + watch.seenWithAttentionAt = Date.now(); + changed = true; + } + if (watch.attentionLostAt !== null) { + watch.attentionLostAt = null; + changed = true; + } + if (entry.commandExitStatus === 'COMMAND_EXIT_ARMED') { + entry.commandExitStatus = 'IDLE'; + changed = true; + } + return changed; + } + + private armCommandExitOnAttentionLoss(id: string): boolean { + const entry = this.entries.get(id); + if (!entry?.commandExitWatch) return false; + if (entry.commandExitStatus === 'ALERT_RINGING') return false; + const watch = entry.commandExitWatch; + if (watch.seenWithAttentionAt === null) return false; + if (watch.attentionLostAt === null) watch.attentionLostAt = Date.now(); + if (entry.commandExitStatus === 'COMMAND_EXIT_ARMED') return false; + entry.commandExitStatus = 'COMMAND_EXIT_ARMED'; + entry.attentionDismissedRing = false; + return true; + } + + private setCommandExitRinging( + id: string, + entry: AlertEntry, + watch: CommandExitWatch, + exitCode: number | undefined, + ): void { + entry.commandExitStatus = 'ALERT_RINGING'; + entry.todo = true; + if (entry.protocolStatus !== 'ALERT_RINGING') { + entry.notification = { + source: 'COMMAND_EXIT', + title: 'Command finished', + body: formatCommandExitBody(watch.displayCommand, exitCode), + }; + } + entry.attentionDismissedRing = false; + this.notify(id); + } + + private clearCommandExitRingIfActive(entry: AlertEntry): boolean { + if (entry.commandExitStatus !== 'ALERT_RINGING') return false; + entry.commandExitStatus = 'IDLE'; + return true; + } + // --- Attention tracking --- private hasAttention(id: string): boolean { @@ -235,11 +399,18 @@ export class AlertManager { } private setAttention(id: string): void { + const previousAttentionId = this.attentionId; + if (previousAttentionId && previousAttentionId !== id && this.armCommandExitOnAttentionLoss(previousAttentionId)) { + this.notify(previousAttentionId); + } this.attentionId = id; this.clearAttentionTimer(); this.attentionTimer = setTimeout(() => { if (this.attentionId === id) { this.attentionId = null; + if (this.armCommandExitOnAttentionLoss(id)) { + this.notify(id); + } } this.attentionTimer = null; }, T_USER_ATTENTION); @@ -251,8 +422,9 @@ export class AlertManager { */ attend(id: string): void { const entry = this.getOrCreateEntry(id); - const previousVisualStatus = entry.monitor?.getStatus(); + const previousWatchingStatus = entry.monitor?.getStatus(); const protocolWasRinging = entry.protocolStatus === 'ALERT_RINGING'; + const commandExitWasRinging = entry.commandExitStatus === 'ALERT_RINGING'; this.setAttention(id); if (protocolWasRinging) { @@ -261,18 +433,28 @@ export class AlertManager { entry.protocolStatus = 'IDLE'; entry.progress = null; } - if (previousVisualStatus === 'ALERT_RINGING') { + if (commandExitWasRinging) { entry.attentionDismissedRing = true; entry.todo = true; + entry.commandExitStatus = 'IDLE'; } + if (previousWatchingStatus === 'ALERT_RINGING') { + entry.attentionDismissedRing = true; + entry.todo = true; + } + this.markCommandExitSeen(entry); entry.monitor?.attend(); this.notify(id); } clearAttention(id?: string): void { if (id !== undefined && this.attentionId !== id) return; + const lostAttentionId = this.attentionId; this.attentionId = null; this.clearAttentionTimer(); + if (lostAttentionId && this.armCommandExitOnAttentionLoss(lostAttentionId)) { + this.notify(lostAttentionId); + } } // --- Monitor lifecycle --- @@ -322,7 +504,9 @@ export class AlertManager { const entry = this.entries.get(id); if (!entry) return; - const dismissed = this.clearProtocolRingIfActive(entry); + const dismissedProtocol = this.clearProtocolRingIfActive(entry); + const dismissedCommandExit = this.clearCommandExitRingIfActive(entry); + const dismissed = dismissedProtocol || dismissedCommandExit; if (dismissed) entry.todo = true; if (entry.monitor?.getStatus() === 'ALERT_RINGING') { @@ -341,25 +525,26 @@ export class AlertManager { dismissOrToggleAlert(id: string, displayedStatus: SessionStatus): AlertButtonActionResult { const entry = this.entries.get(id); if (!entry) { - // No entry yet — treat as ALERT_DISABLED → enable + // No entry yet: treat as WATCHING_DISABLED and enable WATCHING. this.toggleAlert(id); return 'enabled'; } switch (displayedStatus) { - case 'ALERT_DISABLED': + case 'WATCHING_DISABLED': this.toggleAlert(id); return 'enabled'; case 'ALERT_RINGING': this.dismissAlert(id); return 'dismissed'; case 'OSC_NOTIF_BUSY': + case 'COMMAND_EXIT_ARMED': if (entry.attentionDismissedRing) { entry.attentionDismissedRing = false; this.notify(id); return 'dismissed'; } - if (!entry.monitor) return 'noop'; + if (!entry.monitor) return 'menu'; this.disableAlert(id); return 'disabled'; default: @@ -383,11 +568,13 @@ export class AlertManager { if (!nextTodo) { entry.notification = null; this.clearProtocolRingIfActive(entry); + this.clearCommandExitRingIfActive(entry); this.notify(id); return; } this.clearProtocolRingIfActive(entry); + this.clearCommandExitRingIfActive(entry); if (entry.monitor?.getStatus() === 'ALERT_RINGING') { entry.monitor.attend(); return; // onChange fires → notify @@ -397,13 +584,15 @@ export class AlertManager { markTodo(id: string): void { const entry = this.getOrCreateEntry(id); - const isVisualRinging = entry.monitor?.getStatus() === 'ALERT_RINGING'; + const isWatchingRinging = entry.monitor?.getStatus() === 'ALERT_RINGING'; const wasProtocolRinging = entry.protocolStatus === 'ALERT_RINGING'; - if (entry.todo && !wasProtocolRinging && !isVisualRinging) return; + const wasCommandExitRinging = entry.commandExitStatus === 'ALERT_RINGING'; + if (entry.todo && !wasProtocolRinging && !wasCommandExitRinging && !isWatchingRinging) return; entry.todo = true; this.clearProtocolRingIfActive(entry); - if (isVisualRinging) { + this.clearCommandExitRingIfActive(entry); + if (isWatchingRinging) { entry.monitor!.attend(); return; // onChange fires → notify } @@ -416,6 +605,7 @@ export class AlertManager { entry.todo = false; entry.notification = null; this.clearProtocolRingIfActive(entry); + this.clearCommandExitRingIfActive(entry); this.notify(id); } @@ -426,6 +616,7 @@ export class AlertManager { if (!entry) return DEFAULT_ALERT_STATE; return { status: this.getProjectedStatus(entry), + watchingEnabled: !!entry.monitor, todo: entry.todo, notification: entry.notification, attentionDismissedRing: entry.attentionDismissedRing, @@ -455,21 +646,34 @@ export class AlertManager { /** * Seed alert state from a persisted session (cold-start restore). - * Creates an entry with the saved todo state and, if the visual alert was enabled, + * Creates an entry with the saved todo state and, if WATCHING was enabled, * creates a fresh ActivityMonitor (it will start in NOTHING_TO_SHOW until * PTY data arrives). */ - seed(id: string, state: { status: string; todo: unknown; notification?: unknown }): void { + seed(id: string, state: { status: string; todo: unknown; notification?: unknown; watchingEnabled?: unknown }): void { const entry = this.getOrCreateEntry(id); entry.todo = migrateTodoState(state.todo); entry.notification = entry.todo ? normalizeActivityNotification(state.notification) : null; entry.protocolStatus = 'IDLE'; entry.progress = null; - - if (state.status !== 'ALERT_DISABLED' && state.status !== 'OSC_NOTIF_BUSY') { + entry.commandExitStatus = 'IDLE'; + entry.commandExitWatch = null; + entry.pendingCommandLine = null; + + const watchingEnabled = typeof state.watchingEnabled === 'boolean' + ? state.watchingEnabled + // Accept legacy persisted ALERT_DISABLED as the old name for WATCHING_DISABLED. + : state.status !== 'WATCHING_DISABLED' + && state.status !== 'ALERT_DISABLED' + && state.status !== 'OSC_NOTIF_BUSY' + && state.status !== 'COMMAND_EXIT_ARMED'; + if (watchingEnabled) { if (!entry.monitor) { entry.monitor = this.createMonitor(id); } + } else if (entry.monitor) { + entry.monitor.dispose(); + entry.monitor = null; } this.notify(id); } @@ -487,11 +691,13 @@ export class AlertManager { // --- Internals --- private getProjectedStatus(entry: AlertEntry): SessionStatus { - const visualStatus = entry.monitor?.getStatus() ?? 'ALERT_DISABLED'; + const watchingStatus = entry.monitor?.getStatus() ?? 'WATCHING_DISABLED'; if (entry.protocolStatus === 'ALERT_RINGING') return 'ALERT_RINGING'; - if (visualStatus === 'ALERT_RINGING') return 'ALERT_RINGING'; + if (entry.commandExitStatus === 'ALERT_RINGING') return 'ALERT_RINGING'; + if (watchingStatus === 'ALERT_RINGING') return 'ALERT_RINGING'; if (entry.protocolStatus === 'OSC_NOTIF_BUSY') return 'OSC_NOTIF_BUSY'; - return visualStatus; + if (entry.commandExitStatus === 'COMMAND_EXIT_ARMED') return 'COMMAND_EXIT_ARMED'; + return watchingStatus; } private getOrCreateEntry(id: string): AlertEntry { @@ -501,6 +707,9 @@ export class AlertManager { monitor: null, protocolStatus: 'IDLE', progress: null, + commandExitStatus: 'IDLE', + commandExitWatch: null, + pendingCommandLine: null, todo: false, notification: null, attentionDismissedRing: false, @@ -526,10 +735,21 @@ export class AlertManager { } function alertStatesEqual(a: AlertState, b: AlertState): boolean { - if (a.status !== b.status || a.todo !== b.todo || a.attentionDismissedRing !== b.attentionDismissedRing) return false; + if ( + a.status !== b.status + || a.watchingEnabled !== b.watchingEnabled + || a.todo !== b.todo + || a.attentionDismissedRing !== b.attentionDismissedRing + ) return false; const an = a.notification; const bn = b.notification; if (an === bn) return true; if (an === null || bn === null) return false; return an.source === bn.source && an.title === bn.title && an.body === bn.body; } + +function formatCommandExitBody(displayCommand: string, exitCode: number | undefined): string { + const command = displayCommand.trim() || DEFAULT_COMMAND_TITLE; + if (exitCode === undefined) return command; + return `${command} exited ${exitCode}`; +} diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index 5a2be8ac..20cf8cb0 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -348,7 +348,9 @@ export class FakePtyAdapter implements PlatformAdapter { private emitPtyData(id: string, data: string, options: { skipActivity?: boolean } = {}): void { const parsed = this.getProtocolParser(id).process(data); applyTerminalProtocolEvents(this.alertManager, id, parsed.events); - applyTerminalSemanticEventsByPtyId(id, collectTerminalSemanticEvents(parsed.events)); + const semanticEvents = collectTerminalSemanticEvents(parsed.events); + this.alertManager.applyTerminalSemanticEvents(id, semanticEvents); + applyTerminalSemanticEventsByPtyId(id, semanticEvents); const inputHandler = this.inputHandlers.get(id); for (const response of collectTerminalProtocolResponses(parsed.events)) { inputHandler?.(response); diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 7bfbc38c..6c6c63bd 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -69,6 +69,7 @@ export class VSCodeAdapter implements PlatformAdapter { handler({ id: msg.id, status: msg.status, + watchingEnabled: msg.watchingEnabled, todo: msg.todo, notification: msg.notification ?? null, attentionDismissedRing: msg.attentionDismissedRing, diff --git a/lib/src/lib/session-activity-store.ts b/lib/src/lib/session-activity-store.ts index 361daa01..191bda00 100644 --- a/lib/src/lib/session-activity-store.ts +++ b/lib/src/lib/session-activity-store.ts @@ -13,7 +13,8 @@ import { export type { ActivityState } from './terminal-store'; export const DEFAULT_ACTIVITY_STATE: ActivityState = { - status: 'ALERT_DISABLED', + status: 'WATCHING_DISABLED', + watchingEnabled: false, todo: false, notification: null, }; @@ -57,6 +58,7 @@ function readLiveActivity(id: string): ActivityState | null { return { status: entry.alertStatus, + watchingEnabled: entry.watchingEnabled, todo: entry.todo, notification: entry.notification, }; @@ -78,6 +80,7 @@ export function getLivePersistedAlertState(id: string): PersistedAlertState | nu if (!state) return null; return { status: state.status, + watchingEnabled: state.watchingEnabled, todo: state.todo, notification: state.notification, }; @@ -120,13 +123,19 @@ export function initAlertStateReceiver(): void { const entry = getEntryByPtyId(detail.id); if (entry) { entry.alertStatus = detail.status; + entry.watchingEnabled = detail.watchingEnabled; entry.todo = detail.todo; entry.notification = detail.notification; entry.attentionDismissedRing = detail.attentionDismissedRing; primedActivityStates.delete(detail.id); notifyActivityListeners(); } else { - primeActivity(detail.id, { status: detail.status, todo: detail.todo, notification: detail.notification }); + primeActivity(detail.id, { + status: detail.status, + watchingEnabled: detail.watchingEnabled, + todo: detail.todo, + notification: detail.notification, + }); } }; platform.onAlertState(currentAlertHandler); @@ -136,14 +145,15 @@ export function dismissOrToggleAlert(id: string, displayedStatus: SessionStatus) const entry = registry.get(id); let result: AlertButtonActionResult; switch (displayedStatus) { - case 'ALERT_DISABLED': + case 'WATCHING_DISABLED': result = 'enabled'; break; case 'ALERT_RINGING': result = 'dismissed'; break; case 'OSC_NOTIF_BUSY': - result = entry?.attentionDismissedRing ? 'dismissed' : 'noop'; + case 'COMMAND_EXIT_ARMED': + result = entry?.attentionDismissedRing ? 'dismissed' : entry?.watchingEnabled ? 'disabled' : 'menu'; break; default: if (entry?.attentionDismissedRing) { diff --git a/lib/src/lib/session-types.ts b/lib/src/lib/session-types.ts index 77270027..ea812e00 100644 --- a/lib/src/lib/session-types.ts +++ b/lib/src/lib/session-types.ts @@ -4,6 +4,7 @@ import { ACTIVITY_NOTIFICATION_SOURCES, migrateTodoState, type ActivityNotificat export interface PersistedAlertState { status: SessionStatus; + watchingEnabled?: boolean; todo: TodoState; notification?: ActivityNotification | null; } @@ -86,6 +87,7 @@ function isPersistedAlertShape(value: unknown): boolean { if (value === null) return true; if (!isRecord(value)) return false; if (typeof value.status !== 'string') return false; + if (value.watchingEnabled !== undefined && typeof value.watchingEnabled !== 'boolean') return false; const t = value.todo; if (!(typeof t === 'boolean' || typeof t === 'number' || typeof t === 'string')) return false; return value.notification === undefined || value.notification === null || isActivityNotificationShape(value.notification); diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index b29fc84e..26f0bf17 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -179,7 +179,8 @@ function setupTerminalEntry(id: string): TerminalEntry { fit, element, cleanup, - alertStatus: 'ALERT_DISABLED', + alertStatus: 'WATCHING_DISABLED', + watchingEnabled: false, todo: false, notification: null, attentionDismissedRing: false, @@ -189,6 +190,7 @@ function setupTerminalEntry(id: string): TerminalEntry { const primed = consumePrimedActivity(id); if (primed) { if (primed.status !== undefined) entry.alertStatus = primed.status; + if (primed.watchingEnabled !== undefined) entry.watchingEnabled = primed.watchingEnabled; if (primed.todo !== undefined) entry.todo = primed.todo; if (primed.notification !== undefined) entry.notification = primed.notification; } diff --git a/lib/src/lib/terminal-registry.alert.test.ts b/lib/src/lib/terminal-registry.alert.test.ts index 0a0408e4..cbe7ea51 100644 --- a/lib/src/lib/terminal-registry.alert.test.ts +++ b/lib/src/lib/terminal-registry.alert.test.ts @@ -385,7 +385,7 @@ describe('terminal-registry alert behavior', () => { disableSessionAlert(id); expect(getActivity(id)).toMatchObject({ - status: 'ALERT_DISABLED', + status: 'WATCHING_DISABLED', todo: false, }); @@ -394,7 +394,7 @@ describe('terminal-registry alert behavior', () => { advance(12_000); expect(getActivity(id)).toMatchObject({ - status: 'ALERT_DISABLED', + status: 'WATCHING_DISABLED', todo: false, }); }); @@ -559,7 +559,7 @@ describe('terminal-registry alert behavior', () => { advance(12_000); expect(getActivity(id)).toMatchObject({ - status: 'ALERT_DISABLED', + status: 'WATCHING_DISABLED', todo: false, }); }); @@ -708,16 +708,16 @@ describe('terminal-registry alert behavior', () => { disableSessionAlert(id); expect(getActivity(id)).toMatchObject({ - status: 'ALERT_DISABLED', + status: 'WATCHING_DISABLED', todo: false, }); }); - it('alert button enables alerts from ALERT_DISABLED', () => { + it('alert button enables alerts from WATCHING_DISABLED', () => { const id = 'alert-button-enable'; createSession(id); - dismissOrToggleAlert(id, 'ALERT_DISABLED'); + dismissOrToggleAlert(id, 'WATCHING_DISABLED'); expect(getActivity(id)).toMatchObject({ status: 'NOTHING_TO_SHOW', @@ -734,7 +734,7 @@ describe('terminal-registry alert behavior', () => { dismissOrToggleAlert(id, 'BUSY'); expect(getActivity(id)).toMatchObject({ - status: 'ALERT_DISABLED', + status: 'WATCHING_DISABLED', todo: false, }); }); @@ -841,7 +841,7 @@ describe('terminal-registry alert behavior', () => { emitOutput(alpha, 'more work'); expect(getActivity(alpha)).toMatchObject({ - status: 'ALERT_DISABLED', + status: 'WATCHING_DISABLED', todo: false, }); expect(getActivity(beta)).toMatchObject({ @@ -860,22 +860,22 @@ describe('terminal-registry alert behavior', () => { swapTerminals(alpha, beta); expect(getActivity(alpha)).toMatchObject({ - status: 'ALERT_DISABLED', + status: 'WATCHING_DISABLED', todo: false, }); expect(getActivity(beta)).toMatchObject({ - status: 'ALERT_DISABLED', + status: 'WATCHING_DISABLED', todo: true, }); clearSessionTodo(beta); expect(getActivity(alpha)).toMatchObject({ - status: 'ALERT_DISABLED', + status: 'WATCHING_DISABLED', todo: false, }); expect(getActivity(beta)).toMatchObject({ - status: 'ALERT_DISABLED', + status: 'WATCHING_DISABLED', todo: false, }); }); diff --git a/lib/src/lib/terminal-store.ts b/lib/src/lib/terminal-store.ts index 45daf7e5..02363b25 100644 --- a/lib/src/lib/terminal-store.ts +++ b/lib/src/lib/terminal-store.ts @@ -5,6 +5,7 @@ import type { ActivityNotification, TodoState } from './alert-manager'; export interface ActivityState { status: SessionStatus; + watchingEnabled: boolean; todo: TodoState; notification: ActivityNotification | null; } @@ -16,6 +17,7 @@ export interface TerminalEntry { element: HTMLDivElement; cleanup: () => void; alertStatus: SessionStatus; + watchingEnabled: boolean; todo: TodoState; notification: ActivityNotification | null; attentionDismissedRing: boolean; diff --git a/lib/src/stories/Baseboard.stories.tsx b/lib/src/stories/Baseboard.stories.tsx index 07f47cd2..2f97c725 100644 --- a/lib/src/stories/Baseboard.stories.tsx +++ b/lib/src/stories/Baseboard.stories.tsx @@ -107,7 +107,7 @@ export const MixedDoorStates: Story = { todo: false, }, p3: { - status: 'ALERT_DISABLED', + status: 'WATCHING_DISABLED', todo: true, }, @@ -135,7 +135,7 @@ export const OverflowWithRingingDoor: Story = { todo: false, }, p7: { - status: 'ALERT_DISABLED', + status: 'WATCHING_DISABLED', todo: true, }, diff --git a/lib/src/stories/Door.stories.tsx b/lib/src/stories/Door.stories.tsx index 50275002..31b63853 100644 --- a/lib/src/stories/Door.stories.tsx +++ b/lib/src/stories/Door.stories.tsx @@ -26,14 +26,14 @@ const meta: Meta<typeof DoorStory> = { component: DoorStory, args: { title: 'build-server', - status: 'ALERT_DISABLED', + status: 'WATCHING_DISABLED', todo: false, width: 260, reducedMotion: false, }, argTypes: { title: { control: 'text' }, - status: { control: 'radio', options: ['ALERT_DISABLED', 'NOTHING_TO_SHOW', 'MIGHT_BE_BUSY', 'BUSY', 'MIGHT_NEED_ATTENTION', 'ALERT_RINGING'] }, + status: { control: 'radio', options: ['WATCHING_DISABLED', 'NOTHING_TO_SHOW', 'MIGHT_BE_BUSY', 'BUSY', 'OSC_NOTIF_BUSY', 'COMMAND_EXIT_ARMED', 'MIGHT_NEED_ATTENTION', 'ALERT_RINGING'] }, todo: { control: 'boolean' }, width: { control: 'number' }, reducedMotion: { control: 'boolean' }, @@ -47,6 +47,8 @@ export const AlertDisabled: Story = {}; export const AlertEnabled: Story = { args: { status: 'NOTHING_TO_SHOW' } }; export const AlertMightBeBusy: Story = { args: { status: 'MIGHT_BE_BUSY' } }; export const AlertBusy: Story = { args: { status: 'BUSY' } }; +export const ProgressBusy: Story = { args: { status: 'OSC_NOTIF_BUSY' } }; +export const CommandExitArmed: Story = { args: { status: 'COMMAND_EXIT_ARMED' } }; export const AlertMightNeedAttention: Story = { args: { status: 'MIGHT_NEED_ATTENTION' } }; export const AlertRinging: Story = { args: { status: 'ALERT_RINGING' } }; export const TodoOnly: Story = { args: { todo: true } }; diff --git a/lib/src/stories/TerminalPaneHeader.stories.tsx b/lib/src/stories/TerminalPaneHeader.stories.tsx index 73ab435b..ed8a091b 100644 --- a/lib/src/stories/TerminalPaneHeader.stories.tsx +++ b/lib/src/stories/TerminalPaneHeader.stories.tsx @@ -42,7 +42,7 @@ function primedState(state: Record<string, unknown>) { }; } -function primedNotificationState(notification: ActivityNotification, status = 'ALERT_DISABLED') { +function primedNotificationState(notification: ActivityNotification, status = 'WATCHING_DISABLED') { return primedState({ status, todo: true, @@ -190,7 +190,7 @@ type Story = StoryObj<typeof TabStory>; export const AlertDisabled: Story = { parameters: primedState({ - status: 'ALERT_DISABLED', + status: 'WATCHING_DISABLED', todo: false, }), @@ -246,7 +246,7 @@ export const AlertRightClickDialog: Story = { export const TodoOnly: Story = { parameters: primedState({ - status: 'ALERT_DISABLED', + status: 'WATCHING_DISABLED', todo: true, }), }; diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 559bee38..ea7b62ba 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -58,7 +58,9 @@ export class TauriAdapter implements PlatformAdapter { const { id, data } = event.payload; const parsed = this.getProtocolParser(id).process(data); applyTerminalProtocolEvents(this.alertManager, id, parsed.events); - applyTerminalSemanticEventsByPtyId(id, collectTerminalSemanticEvents(parsed.events)); + const semanticEvents = collectTerminalSemanticEvents(parsed.events); + this.alertManager.applyTerminalSemanticEvents(id, semanticEvents); + applyTerminalSemanticEventsByPtyId(id, semanticEvents); for (const response of collectTerminalProtocolResponses(parsed.events)) { invoke("pty_write", { id, data: response }); } diff --git a/vscode-ext/README.md b/vscode-ext/README.md index 8935aa45..c5217ca5 100644 --- a/vscode-ext/README.md +++ b/vscode-ext/README.md @@ -8,10 +8,10 @@ TODO: Hero GIF. ## Alert System -MouseTerm tracks activity the same way you do — visual motion. When a pane stops changing for two seconds, it marks the task complete and alerts you. Works with any CLI tool that prints to a terminal, no plugins or configuration. +MouseTerm can WATCH a pane the same way you do — visual motion. When a watched pane stops changing for two seconds, it marks the task complete and alerts you. Apps can also ask for attention with terminal notification protocols, and shell-integrated commands can alert when they finish after you stop paying attention. -- <img width="22" height="22" alt="todo-disabled" src="https://github.com/user-attachments/assets/29178d1e-062f-4e4d-8de8-250e01b73125" /> alerts disabled -- <img width="22" height="22" alt="todo-enabled" src="https://github.com/user-attachments/assets/1f6dfeb3-7d8e-4724-b777-af6b350cbc80" /> alerts enabled +- <img width="22" height="22" alt="todo-disabled" src="https://github.com/user-attachments/assets/29178d1e-062f-4e4d-8de8-250e01b73125" /> WATCHING disabled +- <img width="22" height="22" alt="todo-enabled" src="https://github.com/user-attachments/assets/1f6dfeb3-7d8e-4724-b777-af6b350cbc80" /> WATCHING enabled - <img width="22" height="22" alt="todo-armed" src="https://github.com/user-attachments/assets/a02e9b7c-4a48-459d-910a-ef0b0ae2a27f" /> task is running, will send an alert when task completes - <img width="22" height="22" alt="todo-ringing" src="https://github.com/user-attachments/assets/55082f42-ddcc-402c-b550-814d84b86630" /> task is finished and needs your attention diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index a8386d8f..ff373cce 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -59,6 +59,7 @@ ptyManager.addCallbacks({ const parsed = getAlertProtocolParser(id).process(data); applyTerminalProtocolEvents(alertManager, id, parsed.events); const semanticEvents = collectTerminalSemanticEvents(parsed.events); + alertManager.applyTerminalSemanticEvents(id, semanticEvents); if (semanticEvents.length > 0) { for (const listener of semanticEventsListeners) listener(id, semanticEvents); } @@ -191,6 +192,7 @@ export function attachRouter( type: 'alert:state', id, status: state.status, + watchingEnabled: state.watchingEnabled, todo: state.todo, notification: state.notification, attentionDismissedRing: state.attentionDismissedRing, @@ -358,6 +360,7 @@ export function attachRouter( type: 'alert:state', id, status: alertState.status, + watchingEnabled: alertState.watchingEnabled, todo: alertState.todo, notification: alertState.notification, attentionDismissedRing: alertState.attentionDismissedRing, diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index 1a66bd3f..d8651c0e 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -55,6 +55,7 @@ export type ExtensionMessage = type: 'alert:state'; id: string; status: SessionStatus; + watchingEnabled: boolean; todo: TodoState; notification: ActivityNotification | null; attentionDismissedRing: boolean; diff --git a/vscode-ext/src/session-state.ts b/vscode-ext/src/session-state.ts index 87853330..f37fdacb 100644 --- a/vscode-ext/src/session-state.ts +++ b/vscode-ext/src/session-state.ts @@ -30,7 +30,12 @@ export function mergeAlertStates(state: unknown, alertStates: Map<string, AlertS return { ...pane, alert: alert - ? { status: alert.status, todo: alert.todo, notification: alert.notification } + ? { + status: alert.status, + watchingEnabled: alert.watchingEnabled, + todo: alert.todo, + notification: alert.notification, + } : pane.alert ?? null, }; }), @@ -55,7 +60,12 @@ export async function refreshSavedSessionStateFromPtys( // Capture alert state regardless of PTY liveness const alertState = alertStates?.get(pane.id); const alert: PersistedAlertState | null = alertState - ? { status: alertState.status, todo: alertState.todo, notification: alertState.notification } + ? { + status: alertState.status, + watchingEnabled: alertState.watchingEnabled, + todo: alertState.todo, + notification: alertState.notification, + } : pane.alert ?? null; if (!ptys.has(pane.id)) { diff --git a/website/src/lib/__snapshots__/tut-runner.test.ts.snap b/website/src/lib/__snapshots__/tut-runner.test.ts.snap index 6e1dcee2..de1bd95c 100644 --- a/website/src/lib/__snapshots__/tut-runner.test.ts.snap +++ b/website/src/lib/__snapshots__/tut-runner.test.ts.snap @@ -5,7 +5,7 @@ exports[`TutRunner snapshots > renders Alert and TODO with all items incomplete Alert and TODO 0/6 complete Esc to go back - ● Enable alerts on a pane + ● Enable WATCHING on a pane Click the bell on the pane you want to use, or press a in command mode with that pane selected. · Watch the bell tilt while a task runs diff --git a/website/src/lib/tut-detector.test.ts b/website/src/lib/tut-detector.test.ts index c8a01d79..161e398f 100644 --- a/website/src/lib/tut-detector.test.ts +++ b/website/src/lib/tut-detector.test.ts @@ -4,13 +4,17 @@ import type { ActivityState } from "mouseterm-lib/lib/terminal-registry"; import { TutDetector } from "./tut-detector"; import { TutorialState } from "./tutorial-state"; +function activity(status: ActivityState["status"], todo = false): ActivityState { + return { status, watchingEnabled: status !== "WATCHING_DISABLED", todo, notification: null }; +} + function makeDetectorHarness() { let activePanelListener: ((panel: { id?: string } | undefined) => void) | null = null; let activityListener: (() => void) | null = null; let mouseListener: (() => void) | null = null; let activitySnapshot = new Map<string, ActivityState>(); let mouseSnapshot = new Map<string, MouseSelectionState>(); - const onAlertDemoPaneChange = vi.fn(); + const onWatchingDemoPaneChange = vi.fn(); const state = new TutorialState(); const detector = new TutDetector( @@ -33,7 +37,7 @@ function makeDetectorHarness() { }; }, }, - { onAlertDemoPaneChange }, + { onWatchingDemoPaneChange }, ); detector.attach({ @@ -55,7 +59,7 @@ function makeDetectorHarness() { mouseListener?.(); }, activePanelChange: (id: string) => activePanelListener?.({ id }), - onAlertDemoPaneChange, + onWatchingDemoPaneChange, }; } @@ -125,15 +129,15 @@ describe("TutDetector", () => { const { state, setActivitySnapshot } = makeDetectorHarness(); setActivitySnapshot(new Map([ - ["pane-a", { status: "NOTHING_TO_SHOW", todo: false }], + ["pane-a", activity("NOTHING_TO_SHOW")], ])); setActivitySnapshot(new Map([ - ["pane-a", { status: "BUSY", todo: false }], + ["pane-a", activity("BUSY")], ])); expect(state.isComplete("al-busy")).toBe(true); setActivitySnapshot(new Map([ - ["pane-a", { status: "ALERT_RINGING", todo: false }], + ["pane-a", activity("ALERT_RINGING")], ])); expect(state.isComplete("al-ring")).toBe(true); }); @@ -142,34 +146,34 @@ describe("TutDetector", () => { const { state, setActivitySnapshot } = makeDetectorHarness(); setActivitySnapshot(new Map([ - ["pane-a", { status: "ALERT_DISABLED", todo: false }], + ["pane-a", activity("WATCHING_DISABLED")], ])); setActivitySnapshot(new Map([ - ["pane-a", { status: "NOTHING_TO_SHOW", todo: false }], + ["pane-a", activity("NOTHING_TO_SHOW")], ])); expect(state.isComplete("al-enable")).toBe(true); }); - it("tracks the pane whose alert was enabled for the busy demo", () => { - const { onAlertDemoPaneChange, setActivitySnapshot } = makeDetectorHarness(); + it("tracks the pane whose WATCHING was enabled for the busy demo", () => { + const { onWatchingDemoPaneChange, setActivitySnapshot } = makeDetectorHarness(); setActivitySnapshot(new Map([ - ["pane-a", { status: "ALERT_DISABLED", todo: false }], - ["pane-b", { status: "ALERT_DISABLED", todo: false }], + ["pane-a", activity("WATCHING_DISABLED")], + ["pane-b", activity("WATCHING_DISABLED")], ])); setActivitySnapshot(new Map([ - ["pane-a", { status: "ALERT_DISABLED", todo: false }], - ["pane-b", { status: "NOTHING_TO_SHOW", todo: false }], + ["pane-a", activity("WATCHING_DISABLED")], + ["pane-b", activity("NOTHING_TO_SHOW")], ])); - expect(onAlertDemoPaneChange).toHaveBeenLastCalledWith("pane-b"); + expect(onWatchingDemoPaneChange).toHaveBeenLastCalledWith("pane-b"); setActivitySnapshot(new Map([ - ["pane-a", { status: "ALERT_DISABLED", todo: false }], - ["pane-b", { status: "ALERT_DISABLED", todo: false }], + ["pane-a", activity("WATCHING_DISABLED")], + ["pane-b", activity("WATCHING_DISABLED")], ])); - expect(onAlertDemoPaneChange).toHaveBeenLastCalledWith(null); + expect(onWatchingDemoPaneChange).toHaveBeenLastCalledWith(null); }); }); diff --git a/website/src/lib/tut-detector.ts b/website/src/lib/tut-detector.ts index 84a34604..80850287 100644 --- a/website/src/lib/tut-detector.ts +++ b/website/src/lib/tut-detector.ts @@ -23,20 +23,20 @@ interface MouseSelectionModule { } interface TutDetectorOptions { - onAlertDemoPaneChange?: (id: string | null) => void; + onWatchingDemoPaneChange?: (id: string | null) => void; } export class TutDetector { private state: TutorialState; private activityStore: ActivityStoreModule; private mouseStore: MouseSelectionModule; - private onAlertDemoPaneChange?: (id: string | null) => void; + private onWatchingDemoPaneChange?: (id: string | null) => void; private api: DockviewApi | null = null; private currentMode: WallMode = "command"; private currentPaneId: string | null = null; private commandModePanels = new Set<string>(); - private alertEnabledPaneIds = new Set<string>(); - private preferredAlertPaneId: string | null = null; + private watchingEnabledPaneIds = new Set<string>(); + private preferredWatchingPaneId: string | null = null; private pendingMoveTargetId: string | null = null; private prevActivity = new Map<string, ActivityState>(); private prevMouse = new Map<string, MouseSelectionState>(); @@ -51,7 +51,7 @@ export class TutDetector { this.state = state; this.activityStore = activityStore; this.mouseStore = mouseStore; - this.onAlertDemoPaneChange = options.onAlertDemoPaneChange; + this.onWatchingDemoPaneChange = options.onWatchingDemoPaneChange; } attach(api: DockviewApi): void { @@ -63,12 +63,12 @@ export class TutDetector { // mis-read as a transition from "nothing". for (const [id, s] of this.activityStore.getActivitySnapshot()) { this.prevActivity.set(id, { ...s }); - if (s.status !== "ALERT_DISABLED") { - this.alertEnabledPaneIds.add(id); - this.preferredAlertPaneId ??= id; + if (s.status !== "WATCHING_DISABLED") { + this.watchingEnabledPaneIds.add(id); + this.preferredWatchingPaneId ??= id; } } - this.emitAlertDemoPaneChange(); + this.emitWatchingDemoPaneChange(); for (const [id, s] of this.mouseStore.getMouseSelectionSnapshot()) { this.prevMouse.set(id, { ...s }); } @@ -150,23 +150,23 @@ export class TutDetector { // First time we see an id (e.g. a pane added after attach()), record // its state without firing any transitions — we have no "before" to // compare against, so treating undefined as a transition from - // ALERT_DISABLED / todo=false would falsely credit work the user + // WATCHING_DISABLED / todo=false would falsely credit work the user // didn't do (e.g. al-todo-manual when restored state has todo=true). if (!prev) { this.prevActivity.set(id, { ...current }); continue; } - if (prev.status === "ALERT_DISABLED" && current.status !== "ALERT_DISABLED") { + if (prev.status === "WATCHING_DISABLED" && current.status !== "WATCHING_DISABLED") { this.state.markComplete("al-enable"); - this.alertEnabledPaneIds.add(id); - this.preferredAlertPaneId = id; - this.emitAlertDemoPaneChange(); - } else if (prev.status !== "ALERT_DISABLED" && current.status === "ALERT_DISABLED") { - this.alertEnabledPaneIds.delete(id); - if (this.preferredAlertPaneId === id) { - this.preferredAlertPaneId = this.alertEnabledPaneIds.values().next().value ?? null; - this.emitAlertDemoPaneChange(); + this.watchingEnabledPaneIds.add(id); + this.preferredWatchingPaneId = id; + this.emitWatchingDemoPaneChange(); + } else if (prev.status !== "WATCHING_DISABLED" && current.status === "WATCHING_DISABLED") { + this.watchingEnabledPaneIds.delete(id); + if (this.preferredWatchingPaneId === id) { + this.preferredWatchingPaneId = this.watchingEnabledPaneIds.values().next().value ?? null; + this.emitWatchingDemoPaneChange(); } } @@ -201,10 +201,10 @@ export class TutDetector { for (const id of this.prevActivity.keys()) { if (!snapshot.has(id)) { this.prevActivity.delete(id); - this.alertEnabledPaneIds.delete(id); - if (this.preferredAlertPaneId === id) { - this.preferredAlertPaneId = this.alertEnabledPaneIds.values().next().value ?? null; - this.emitAlertDemoPaneChange(); + this.watchingEnabledPaneIds.delete(id); + if (this.preferredWatchingPaneId === id) { + this.preferredWatchingPaneId = this.watchingEnabledPaneIds.values().next().value ?? null; + this.emitWatchingDemoPaneChange(); } } } @@ -240,7 +240,7 @@ export class TutDetector { this.disposables = []; } - private emitAlertDemoPaneChange(): void { - this.onAlertDemoPaneChange?.(this.preferredAlertPaneId); + private emitWatchingDemoPaneChange(): void { + this.onWatchingDemoPaneChange?.(this.preferredWatchingPaneId); } } diff --git a/website/src/lib/tut-items.ts b/website/src/lib/tut-items.ts index 4bb111fc..8931f75f 100644 --- a/website/src/lib/tut-items.ts +++ b/website/src/lib/tut-items.ts @@ -87,13 +87,13 @@ export const SECTIONS: readonly Section[] = [ items: [ { id: 'al-enable', - title: 'Enable alerts on a pane', + title: 'Enable WATCHING on a pane', hint: 'Click the bell on the pane you want to use, or press `a` in command mode with that pane selected.', }, { id: 'al-busy', title: 'Watch the bell tilt while a task runs', - hint: 'Press `s` here to start a fake busy task on that alert-enabled pane.', + hint: 'Press `s` here to start a fake busy task on that WATCHING-enabled pane.', }, { id: 'al-ring', diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index 783f30f0..4fa081df 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -516,7 +516,7 @@ export class TutRunner implements InteractiveProgram { private write(data: string): void { // Runner frames are UI chrome, not task output — skip the activity - // tick so enabling alerts on the runner pane doesn't tilt the bell + // tick so enabling WATCHING on the runner pane doesn't tilt the bell // every time the menu re-renders. this.adapter.sendOutput(this.terminalId, data, { skipActivity: true }); } diff --git a/website/src/pages/Playground.tsx b/website/src/pages/Playground.tsx index 5a4b9b0b..8ea587aa 100644 --- a/website/src/pages/Playground.tsx +++ b/website/src/pages/Playground.tsx @@ -93,7 +93,7 @@ function Playground() { const tutorialState = new TutorialState(); stateRef.current = tutorialState; const detector = new TutDetector(tutorialState, registry, mouseSelection, { - onAlertDemoPaneChange: (id) => { + onWatchingDemoPaneChange: (id) => { alertDemoPaneIdRef.current = id; }, }); From 28e9f0af9d2daa7973c3540ca67435111a59f9dc Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Wed, 13 May 2026 16:55:09 -0700 Subject: [PATCH 4/7] Simplify alert-manager after code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes dead fields from CommandExitWatch (id, rawCommandLine, attentionLostAt — all write-only), flattens awkward ternaries, consolidates the four call sites that always cleared both ring tracks into a clearAllRingsIfActive helper, and extracts a toPersistedAlert helper to deduplicate the AlertState→Persisted literal in session-state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- lib/src/components/TodoAlertDialog.tsx | 2 - lib/src/lib/alert-manager.ts | 84 ++++++++------------------ vscode-ext/src/session-state.ts | 39 +++++------- 3 files changed, 40 insertions(+), 85 deletions(-) diff --git a/lib/src/components/TodoAlertDialog.tsx b/lib/src/components/TodoAlertDialog.tsx index 8aca089a..0e3b516a 100644 --- a/lib/src/components/TodoAlertDialog.tsx +++ b/lib/src/components/TodoAlertDialog.tsx @@ -184,7 +184,6 @@ export function TodoAlertDialog({ </button> <div className="mb-3 grid w-fit grid-cols-[auto_auto_auto] items-center gap-x-2 gap-y-2"> - {/* TODO row */} <Shortcut>t</Shortcut> <span className="text-sm font-medium text-foreground">TODO</span> <OnOffSwitch @@ -194,7 +193,6 @@ export function TodoAlertDialog({ label="TODO" /> - {/* WATCHING row */} <Shortcut>a</Shortcut> <span className="text-sm font-medium text-foreground">WATCHING</span> <OnOffSwitch diff --git a/lib/src/lib/alert-manager.ts b/lib/src/lib/alert-manager.ts index b74142f3..2e89718c 100644 --- a/lib/src/lib/alert-manager.ts +++ b/lib/src/lib/alert-manager.ts @@ -38,13 +38,10 @@ interface ActiveProtocolProgress { } interface CommandExitWatch { - id: number; - rawCommandLine: string | null; displayCommand: string; source: CommandRunSource; startedAt: number; seenWithAttentionAt: number | null; - attentionLostAt: number | null; } /** Migrate legacy persisted TodoState values (numeric, string, boolean) to a boolean. */ @@ -124,7 +121,6 @@ export class AlertManager { private attentionTimer: ReturnType<typeof setTimeout> | null = null; private listeners = new Set<(id: string, state: AlertState) => void>(); private lastEmitted = new Map<string, AlertState>(); - private nextCommandExitWatchId = 0; // --- State change subscription --- @@ -288,19 +284,17 @@ export class AlertManager { event: Extract<TerminalSemanticEvent, { type: 'commandStart' }>, ): void { const raw = entry.pendingCommandLine; - const source = event.source === 'osc633_boundaries' && raw - ? 'osc633_E' - : event.source ?? (raw ? 'osc633_E' : 'osc133_boundaries'); + let source: CommandRunSource; + if (event.source === 'osc633_boundaries' && raw) source = 'osc633_E'; + else if (event.source) source = event.source; + else source = raw ? 'osc633_E' : 'osc133_boundaries'; entry.pendingCommandLine = null; - entry.commandExitStatus = entry.commandExitStatus === 'ALERT_RINGING' ? 'ALERT_RINGING' : 'IDLE'; + if (entry.commandExitStatus !== 'ALERT_RINGING') entry.commandExitStatus = 'IDLE'; entry.commandExitWatch = { - id: ++this.nextCommandExitWatchId, - rawCommandLine: raw, displayCommand: raw ? summarizeCommandLine(raw) : DEFAULT_COMMAND_TITLE, source, startedAt: event.startedAt ?? Date.now(), seenWithAttentionAt: this.hasAttention(id) ? Date.now() : null, - attentionLostAt: null, }; } @@ -328,33 +322,18 @@ export class AlertManager { return true; } - private markCommandExitSeen(entry: AlertEntry): boolean { + private markCommandExitSeen(entry: AlertEntry): void { const watch = entry.commandExitWatch; - if (!watch) return false; - let changed = false; - if (watch.seenWithAttentionAt === null) { - watch.seenWithAttentionAt = Date.now(); - changed = true; - } - if (watch.attentionLostAt !== null) { - watch.attentionLostAt = null; - changed = true; - } - if (entry.commandExitStatus === 'COMMAND_EXIT_ARMED') { - entry.commandExitStatus = 'IDLE'; - changed = true; - } - return changed; + if (!watch) return; + if (watch.seenWithAttentionAt === null) watch.seenWithAttentionAt = Date.now(); + if (entry.commandExitStatus === 'COMMAND_EXIT_ARMED') entry.commandExitStatus = 'IDLE'; } private armCommandExitOnAttentionLoss(id: string): boolean { const entry = this.entries.get(id); if (!entry?.commandExitWatch) return false; - if (entry.commandExitStatus === 'ALERT_RINGING') return false; - const watch = entry.commandExitWatch; - if (watch.seenWithAttentionAt === null) return false; - if (watch.attentionLostAt === null) watch.attentionLostAt = Date.now(); - if (entry.commandExitStatus === 'COMMAND_EXIT_ARMED') return false; + if (entry.commandExitStatus !== 'IDLE') return false; + if (entry.commandExitWatch.seenWithAttentionAt === null) return false; entry.commandExitStatus = 'COMMAND_EXIT_ARMED'; entry.attentionDismissedRing = false; return true; @@ -385,6 +364,12 @@ export class AlertManager { return true; } + private clearAllRingsIfActive(entry: AlertEntry): boolean { + const p = this.clearProtocolRingIfActive(entry); + const c = this.clearCommandExitRingIfActive(entry); + return p || c; + } + // --- Attention tracking --- private hasAttention(id: string): boolean { @@ -422,23 +407,11 @@ export class AlertManager { */ attend(id: string): void { const entry = this.getOrCreateEntry(id); - const previousWatchingStatus = entry.monitor?.getStatus(); - const protocolWasRinging = entry.protocolStatus === 'ALERT_RINGING'; - const commandExitWasRinging = entry.commandExitStatus === 'ALERT_RINGING'; + const watchingWasRinging = entry.monitor?.getStatus() === 'ALERT_RINGING'; this.setAttention(id); - if (protocolWasRinging) { - entry.attentionDismissedRing = true; - entry.todo = true; - entry.protocolStatus = 'IDLE'; - entry.progress = null; - } - if (commandExitWasRinging) { - entry.attentionDismissedRing = true; - entry.todo = true; - entry.commandExitStatus = 'IDLE'; - } - if (previousWatchingStatus === 'ALERT_RINGING') { + const dismissed = this.clearAllRingsIfActive(entry) || watchingWasRinging; + if (dismissed) { entry.attentionDismissedRing = true; entry.todo = true; } @@ -504,9 +477,7 @@ export class AlertManager { const entry = this.entries.get(id); if (!entry) return; - const dismissedProtocol = this.clearProtocolRingIfActive(entry); - const dismissedCommandExit = this.clearCommandExitRingIfActive(entry); - const dismissed = dismissedProtocol || dismissedCommandExit; + const dismissed = this.clearAllRingsIfActive(entry); if (dismissed) entry.todo = true; if (entry.monitor?.getStatus() === 'ALERT_RINGING') { @@ -525,7 +496,6 @@ export class AlertManager { dismissOrToggleAlert(id: string, displayedStatus: SessionStatus): AlertButtonActionResult { const entry = this.entries.get(id); if (!entry) { - // No entry yet: treat as WATCHING_DISABLED and enable WATCHING. this.toggleAlert(id); return 'enabled'; } @@ -567,14 +537,12 @@ export class AlertManager { if (!nextTodo) { entry.notification = null; - this.clearProtocolRingIfActive(entry); - this.clearCommandExitRingIfActive(entry); + this.clearAllRingsIfActive(entry); this.notify(id); return; } - this.clearProtocolRingIfActive(entry); - this.clearCommandExitRingIfActive(entry); + this.clearAllRingsIfActive(entry); if (entry.monitor?.getStatus() === 'ALERT_RINGING') { entry.monitor.attend(); return; // onChange fires → notify @@ -590,8 +558,7 @@ export class AlertManager { if (entry.todo && !wasProtocolRinging && !wasCommandExitRinging && !isWatchingRinging) return; entry.todo = true; - this.clearProtocolRingIfActive(entry); - this.clearCommandExitRingIfActive(entry); + this.clearAllRingsIfActive(entry); if (isWatchingRinging) { entry.monitor!.attend(); return; // onChange fires → notify @@ -604,8 +571,7 @@ export class AlertManager { if (!entry.todo) return; entry.todo = false; entry.notification = null; - this.clearProtocolRingIfActive(entry); - this.clearCommandExitRingIfActive(entry); + this.clearAllRingsIfActive(entry); this.notify(id); } diff --git a/vscode-ext/src/session-state.ts b/vscode-ext/src/session-state.ts index f37fdacb..22b77e70 100644 --- a/vscode-ext/src/session-state.ts +++ b/vscode-ext/src/session-state.ts @@ -15,6 +15,16 @@ export function saveSessionState(context: vscode.ExtensionContext, state: unknow return context.workspaceState.update(SESSION_STATE_KEY, state); } +function toPersistedAlert(alert: AlertState | undefined, fallback: PersistedAlertState | null | undefined): PersistedAlertState | null { + if (!alert) return fallback ?? null; + return { + status: alert.status, + watchingEnabled: alert.watchingEnabled, + todo: alert.todo, + notification: alert.notification, + }; +} + /** * Merge current alert states into a session state object from the frontend. * Called on every periodic save so alert data is always current in workspaceState, @@ -25,20 +35,10 @@ export function mergeAlertStates(state: unknown, alertStates: Map<string, AlertS if (!parsed || !Array.isArray(parsed.panes)) return state; return { ...parsed, - panes: parsed.panes.map((pane) => { - const alert = alertStates.get(pane.id); - return { - ...pane, - alert: alert - ? { - status: alert.status, - watchingEnabled: alert.watchingEnabled, - todo: alert.todo, - notification: alert.notification, - } - : pane.alert ?? null, - }; - }), + panes: parsed.panes.map((pane) => ({ + ...pane, + alert: toPersistedAlert(alertStates.get(pane.id), pane.alert), + })), }; } @@ -57,16 +57,7 @@ export async function refreshSavedSessionStateFromPtys( const panes = await Promise.all( saved.panes.map(async (pane) => { - // Capture alert state regardless of PTY liveness - const alertState = alertStates?.get(pane.id); - const alert: PersistedAlertState | null = alertState - ? { - status: alertState.status, - watchingEnabled: alertState.watchingEnabled, - todo: alertState.todo, - notification: alertState.notification, - } - : pane.alert ?? null; + const alert = toPersistedAlert(alertStates?.get(pane.id), pane.alert); if (!ptys.has(pane.id)) { log.info(`[session] ${pane.id}: not in live PTYs, keeping saved cwd=${pane.cwd}`); From 722c2ce041e7b77cae8e7eeaf01ab0e410f0864f Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 15 May 2026 15:48:51 -0700 Subject: [PATCH 5/7] Handle command-exit watches on PTY exit --- docs/specs/alert.md | 2 ++ lib/src/lib/alert-manager.test.ts | 39 ++++++++++++++++++++++++++++ lib/src/lib/alert-manager.ts | 8 +++--- lib/src/lib/platform/fake-adapter.ts | 3 ++- standalone/src/tauri-adapter.ts | 2 +- vscode-ext/src/message-router.ts | 4 +-- 6 files changed, 51 insertions(+), 7 deletions(-) diff --git a/docs/specs/alert.md b/docs/specs/alert.md index ed976f8e..0c23b3d5 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -307,6 +307,8 @@ The command-exit track is intentionally stricter than WATCHING. It exists for th | `IDLE` | watched command is still running and attention expires or is explicitly lost | `COMMAND_EXIT_ARMED` | Store `attentionLostAt`. | | `COMMAND_EXIT_ARMED` | same command finishes, runtime is at least `T_USER_ATTENTION`, and Session lacks attention | `ALERT_RINGING` | Create generated command-exit notification, set `todo = true`, and ring. | | `COMMAND_EXIT_ARMED` | same command finishes too quickly | `IDLE` | Clear without ringing. | +| `COMMAND_EXIT_ARMED` | PTY exits before a command-finish semantic event, runtime is at least `T_USER_ATTENTION`, and Session lacks attention | `ALERT_RINGING` | Treat process exit as the fallback finish event for commands such as `exec <long command>` or shells that exit before emitting a finish marker. | +| `IDLE` | PTY exits before a command-finish semantic event | `IDLE` | Clear any stored `commandExitWatch`; a dead process must not become armed later. | | `COMMAND_EXIT_ARMED` | Session regains attention before finish | `IDLE` | Clear the arm; the user is watching again. | | any | a different command starts | `IDLE` | Replace the watch with the new command if it is eligible. | | `ALERT_RINGING` | explicit attention boundary / dismiss / TODO clear | `IDLE` | Public status falls back to the other tracks. | diff --git a/lib/src/lib/alert-manager.test.ts b/lib/src/lib/alert-manager.test.ts index 93c19417..322f440e 100644 --- a/lib/src/lib/alert-manager.test.ts +++ b/lib/src/lib/alert-manager.test.ts @@ -352,6 +352,45 @@ describe('AlertManager in isolation', () => { }); }); + it('finishes an armed command-exit watch when the PTY exits without commandFinish', () => { + const id = 'command-exit-pty-exit'; + + manager.attend(id); + manager.applyTerminalSemanticEvents(id, [ + { type: 'commandLine', commandLine: 'exec pnpm build' }, + { type: 'commandStart', source: 'osc633_E', startedAt: Date.now() }, + ]); + + vi.advanceTimersByTime(15_000); + expect(manager.getState(id).status).toBe('COMMAND_EXIT_ARMED'); + + manager.onExit(id, 1); + expect(manager.getState(id)).toMatchObject({ + status: 'ALERT_RINGING', + todo: true, + notification: { source: 'COMMAND_EXIT', title: 'Command finished', body: 'exec pnpm build exited 1' }, + }); + }); + + it('clears an unarmed command-exit watch when the PTY exits before attention loss', () => { + const id = 'command-exit-pty-exit-unarmed'; + + manager.attend(id); + manager.applyTerminalSemanticEvents(id, [ + { type: 'commandLine', commandLine: 'exec true' }, + { type: 'commandStart', source: 'osc633_E', startedAt: Date.now() }, + ]); + + manager.onExit(id, 0); + vi.advanceTimersByTime(15_000); + + expect(manager.getState(id)).toMatchObject({ + status: 'WATCHING_DISABLED', + todo: false, + notification: null, + }); + }); + it('does not ring command-exit alerts for commands shorter than the attention window', () => { const id = 'quick-command-exit'; diff --git a/lib/src/lib/alert-manager.ts b/lib/src/lib/alert-manager.ts index 2e89718c..0b6b8e3a 100644 --- a/lib/src/lib/alert-manager.ts +++ b/lib/src/lib/alert-manager.ts @@ -136,9 +136,11 @@ export class AlertManager { entry?.monitor?.onData(); } - // Intentional no-op: the monitor detects silence and transitions naturally, - // and we keep the entry so alert/todo state survives the PTY exit. - onExit(_id: string): void {} + onExit(id: string, exitCode?: number): void { + const entry = this.entries.get(id); + if (!entry) return; + if (this.finishCommandExitWatch(id, entry, exitCode)) this.notify(id); + } onResize(id: string): void { const entry = this.entries.get(id); diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index 20cf8cb0..0753deaa 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -157,6 +157,7 @@ export class FakePtyAdapter implements PlatformAdapter { this.terminalSizes.delete(id); this.inputHandlers.delete(id); this.protocolParsers.delete(id); + this.alertManager.onExit(id, 0); for (const handler of this.exitHandlers) { handler({ id, exitCode: 0 }); } @@ -321,7 +322,7 @@ export class FakePtyAdapter implements PlatformAdapter { const exitTimer = setTimeout(() => { if (!this.terminals.has(id)) return; this.activeTimers.delete(id); - this.alertManager.onExit(id); + this.alertManager.onExit(id, scenario.exitCode ?? 0); for (const handler of this.exitHandlers) { handler({ id, exitCode: scenario.exitCode ?? 0 }); } diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index ea7b62ba..2c1eb1c7 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -75,7 +75,7 @@ export class TauriAdapter implements PlatformAdapter { this.unlistenFns.push( await listen<{ id: string; exitCode: number }>("pty:exit", (event) => { - this.alertManager.onExit(event.payload.id); + this.alertManager.onExit(event.payload.id, event.payload.exitCode); this.protocolParsers.delete(event.payload.id); for (const handler of this.exitHandlers) { handler(event.payload); diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index ff373cce..a61c01ff 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -75,9 +75,9 @@ ptyManager.addCallbacks({ log.info(`[alert-feed] ${id}: ${before} → ${after}`); } }, - onExit(id: string) { + onExit(id: string, exitCode: number) { log.info(`[alert-feed] ${id}: PTY exited`); - alertManager.onExit(id); + alertManager.onExit(id, exitCode); alertProtocolParsers.delete(id); }, }); From 05b026fecb14b3ec9e4b995c78961e7fcaed52eb Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 15 May 2026 15:50:23 -0700 Subject: [PATCH 6/7] Use watchingEnabled for tutorial WATCHING detection --- docs/specs/tutorial.md | 6 ++-- website/src/lib/tut-detector.test.ts | 53 ++++++++++++++++++++++++---- website/src/lib/tut-detector.ts | 6 ++-- 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index ea5c893f..fe49e6d6 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -55,18 +55,18 @@ Note: `-` produces a `direction: 'vertical'` split (panes stack top/bottom = hor ### Section 2 — Alert and TODO (6 items) -The detector subscribes to `subscribeToActivity()` and tracks per-id `(status, todo)` transitions. +The detector subscribes to `subscribeToActivity()` and tracks per-id `(status, watchingEnabled, todo)` transitions. | ID | Title | Detection | |---|---|---| -| `al-enable` | Enable WATCHING on a pane (click bell or `a`) | status transitions away from `WATCHING_DISABLED` | +| `al-enable` | Enable WATCHING on a pane (click bell or `a`) | `watchingEnabled` transitions `false → true` | | `al-busy` | Watch the bell tilt while a task runs | status enters `BUSY`, `MIGHT_BE_BUSY`, or `OSC_NOTIF_BUSY` | | `al-ring` | Bell rings on completion | status enters `ALERT_RINGING` | | `al-todo-auto` | TODO appears when you dismiss the ringing alert | `todo` transitions `false → true` while previous status was `ALERT_RINGING` | | `al-todo-clear` | Press passthrough Enter to clear the TODO | `todo` transitions `true → false` | | `al-todo-manual` | Manually add a TODO (`t` or right-click) | `todo` transitions `false → true` while previous status was NOT `ALERT_RINGING` | -The detector remembers the most recent pane whose WATCHING track was enabled. The Alert section view shows a runner-local instruction: "Press `s` here to start a fake busy task." `s` is **not** a real MouseTerm shortcut; it is intercepted by `TutRunner` only while the Alert section is open. When pressed, the runner does two things: +The detector remembers the most recent pane whose `watchingEnabled` flag is true, even when projected `status` is currently owned by protocol or command-exit alert tracks. The Alert section view shows a runner-local instruction: "Press `s` here to start a fake busy task." `s` is **not** a real MouseTerm shortcut; it is intercepted by `TutRunner` only while the Alert section is open. When pressed, the runner does two things: 1. Resolves that pane to its current PTY session id, then calls `adapter.pumpActivity(sessionId, BUSY_DEMO_DURATION_MS, 800)` — drives the alert-manager's activity monitor on the same WATCHING-enabled session with **no text output**, so the bell tilts to BUSY without scrolling any scenario text. The session id is resolved at trigger time so `Cmd/Ctrl+Arrow` swaps do not leave the tutorial pumping an old pane id. If no WATCHING-enabled pane is known, the runner falls back to `PANE_BOXED` (the changelog pane). `BUSY_DEMO_DURATION_MS` is `cfg.alert.userAttention + 250` so silence begins after the attention idle window has expired, with a small scheduler-jitter guard; otherwise the "user is looking at this pane" check inside `ActivityMonitor.startNeedsAttentionConfirmTimer` would suppress the ring rather than let it fire. 2. Animates a countdown in-place where the "Press s…" hint was: `⠋ Fake task will finish in N seconds.` ticking down to 1, then a static `✓ Fake task finished. Press s to start another one.` once the activity stops. Detection is purely timing-based via the existing `ActivityMonitor`, so no shell integration is required. diff --git a/website/src/lib/tut-detector.test.ts b/website/src/lib/tut-detector.test.ts index 161e398f..87be501b 100644 --- a/website/src/lib/tut-detector.test.ts +++ b/website/src/lib/tut-detector.test.ts @@ -4,15 +4,19 @@ import type { ActivityState } from "mouseterm-lib/lib/terminal-registry"; import { TutDetector } from "./tut-detector"; import { TutorialState } from "./tutorial-state"; -function activity(status: ActivityState["status"], todo = false): ActivityState { - return { status, watchingEnabled: status !== "WATCHING_DISABLED", todo, notification: null }; +function activity( + status: ActivityState["status"], + todo = false, + watchingEnabled = status !== "WATCHING_DISABLED", +): ActivityState { + return { status, watchingEnabled, todo, notification: null }; } -function makeDetectorHarness() { +function makeDetectorHarness(initialActivitySnapshot = new Map<string, ActivityState>()) { let activePanelListener: ((panel: { id?: string } | undefined) => void) | null = null; let activityListener: (() => void) | null = null; let mouseListener: (() => void) | null = null; - let activitySnapshot = new Map<string, ActivityState>(); + let activitySnapshot = initialActivitySnapshot; let mouseSnapshot = new Map<string, MouseSelectionState>(); const onWatchingDemoPaneChange = vi.fn(); @@ -117,8 +121,8 @@ describe("TutDetector", () => { const { state, setActivitySnapshot } = makeDetectorHarness(); setActivitySnapshot(new Map([ - ["pane-a", { status: "BUSY", todo: false }], - ["pane-b", { status: "ALERT_RINGING", todo: false }], + ["pane-a", activity("BUSY")], + ["pane-b", activity("ALERT_RINGING")], ])); expect(state.isComplete("al-busy")).toBe(false); @@ -155,6 +159,43 @@ describe("TutDetector", () => { expect(state.isComplete("al-enable")).toBe(true); }); + it("does not credit al-enable for projected command-exit status while WATCHING is off", () => { + const { state, onWatchingDemoPaneChange, setActivitySnapshot } = makeDetectorHarness(); + + onWatchingDemoPaneChange.mockClear(); + setActivitySnapshot(new Map([ + ["pane-a", activity("WATCHING_DISABLED", false, false)], + ])); + setActivitySnapshot(new Map([ + ["pane-a", activity("COMMAND_EXIT_ARMED", false, false)], + ])); + + expect(state.isComplete("al-enable")).toBe(false); + expect(onWatchingDemoPaneChange).not.toHaveBeenCalled(); + }); + + it("credits al-enable when WATCHING turns on under an existing projected status", () => { + const { state, onWatchingDemoPaneChange, setActivitySnapshot } = makeDetectorHarness(); + + setActivitySnapshot(new Map([ + ["pane-a", activity("COMMAND_EXIT_ARMED", false, false)], + ])); + setActivitySnapshot(new Map([ + ["pane-a", activity("COMMAND_EXIT_ARMED", false, true)], + ])); + + expect(state.isComplete("al-enable")).toBe(true); + expect(onWatchingDemoPaneChange).toHaveBeenLastCalledWith("pane-a"); + }); + + it("does not seed the WATCHING demo pane from projected alert status", () => { + const { onWatchingDemoPaneChange } = makeDetectorHarness(new Map([ + ["pane-a", activity("ALERT_RINGING", false, false)], + ])); + + expect(onWatchingDemoPaneChange).toHaveBeenLastCalledWith(null); + }); + it("tracks the pane whose WATCHING was enabled for the busy demo", () => { const { onWatchingDemoPaneChange, setActivitySnapshot } = makeDetectorHarness(); diff --git a/website/src/lib/tut-detector.ts b/website/src/lib/tut-detector.ts index 80850287..fbbc54e7 100644 --- a/website/src/lib/tut-detector.ts +++ b/website/src/lib/tut-detector.ts @@ -63,7 +63,7 @@ export class TutDetector { // mis-read as a transition from "nothing". for (const [id, s] of this.activityStore.getActivitySnapshot()) { this.prevActivity.set(id, { ...s }); - if (s.status !== "WATCHING_DISABLED") { + if (s.watchingEnabled) { this.watchingEnabledPaneIds.add(id); this.preferredWatchingPaneId ??= id; } @@ -157,12 +157,12 @@ export class TutDetector { continue; } - if (prev.status === "WATCHING_DISABLED" && current.status !== "WATCHING_DISABLED") { + if (!prev.watchingEnabled && current.watchingEnabled) { this.state.markComplete("al-enable"); this.watchingEnabledPaneIds.add(id); this.preferredWatchingPaneId = id; this.emitWatchingDemoPaneChange(); - } else if (prev.status !== "WATCHING_DISABLED" && current.status === "WATCHING_DISABLED") { + } else if (prev.watchingEnabled && !current.watchingEnabled) { this.watchingEnabledPaneIds.delete(id); if (this.preferredWatchingPaneId === id) { this.preferredWatchingPaneId = this.watchingEnabledPaneIds.values().next().value ?? null; From 91ac19c598b296638525840396658e071bdfa0f8 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Fri, 15 May 2026 16:11:01 -0700 Subject: [PATCH 7/7] Fix mobile alert status build --- lib/src/components/MobileTerminalUi.tsx | 3 ++- lib/src/components/MobileWall.tsx | 18 +++++++++--------- .../keyboard/handle-pane-shortcuts.test.ts | 2 +- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/lib/src/components/MobileTerminalUi.tsx b/lib/src/components/MobileTerminalUi.tsx index 4e44efcc..313d5ce4 100644 --- a/lib/src/components/MobileTerminalUi.tsx +++ b/lib/src/components/MobileTerminalUi.tsx @@ -38,6 +38,7 @@ import { type MobileGesturePoint, type MobileGestureTrackingState, } from '../lib/mobile-gesture-menu'; +import type { SessionStatus } from '../lib/terminal-registry'; export type MobileTerminalKeyboardMode = 'sessions' | 'recent' | 'type' | 'draft'; export type MobileTerminalSection = MobileTerminalKeyboardMode; @@ -49,7 +50,7 @@ export interface MobileTerminalSessionItem { title: string; secondary?: string | null; active?: boolean; - status?: 'ALERT_DISABLED' | 'NOTHING_TO_SHOW' | 'MIGHT_BE_BUSY' | 'BUSY' | 'MIGHT_NEED_ATTENTION' | 'ALERT_RINGING' | 'OSC_NOTIF_BUSY'; + status?: SessionStatus; todo?: boolean; } diff --git a/lib/src/components/MobileWall.tsx b/lib/src/components/MobileWall.tsx index ac13f7cf..459aceed 100644 --- a/lib/src/components/MobileWall.tsx +++ b/lib/src/components/MobileWall.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from import { ArrowLineDownIcon, BellIcon, - BellSlashIcon, XIcon, } from '@phosphor-icons/react'; import { HeaderActionButton } from './HeaderActionButton'; @@ -50,13 +49,14 @@ export interface MobileWallProps { const DEFAULT_MOBILE_SESSION: MobileWallSession = { id: 'mobile-pane' }; const ALERT_BUTTON_LABELS: Record<SessionStatus, { aria: string; tooltip: string }> = { - ALERT_DISABLED: { aria: 'Enable alert', tooltip: 'Enable alerts' }, - NOTHING_TO_SHOW: { aria: 'Disable alert', tooltip: 'Disable alerts' }, - MIGHT_BE_BUSY: { aria: 'Disable alert', tooltip: 'Disable alerts' }, - BUSY: { aria: 'Disable alert', tooltip: 'Disable alerts' }, - MIGHT_NEED_ATTENTION: { aria: 'Disable alert', tooltip: 'Disable alerts' }, + WATCHING_DISABLED: { aria: 'Enable watching', tooltip: 'Enable watching' }, + NOTHING_TO_SHOW: { aria: 'Disable watching', tooltip: 'Disable watching' }, + MIGHT_BE_BUSY: { aria: 'Disable watching', tooltip: 'Disable watching' }, + BUSY: { aria: 'Disable watching', tooltip: 'Disable watching' }, + MIGHT_NEED_ATTENTION: { aria: 'Disable watching', tooltip: 'Disable watching' }, ALERT_RINGING: { aria: 'Alert ringing', tooltip: 'Alert ringing' }, OSC_NOTIF_BUSY: { aria: 'Progress active', tooltip: 'Progress active' }, + COMMAND_EXIT_ARMED: { aria: 'Command running', tooltip: 'Command running' }, }; export function useMobileWallSessionItems( @@ -178,7 +178,7 @@ function MobileWallHeader({ onMinimize: () => void; onKill: () => void; }) { - const status = session.status ?? 'ALERT_DISABLED'; + const status = session.status ?? 'WATCHING_DISABLED'; const todoPill = useTodoPillContent(session.todo === true); const alertButtonLabels = ALERT_BUTTON_LABELS[status]; const showTodoPill = todoPill.visible; @@ -199,8 +199,8 @@ function MobileWallHeader({ dataAlertButtonFor={session.id} > <span className="flex items-center justify-center"> - {status === 'ALERT_DISABLED' ? ( - <BellSlashIcon size={14} /> + {status === 'WATCHING_DISABLED' ? ( + <BellIcon size={14} /> ) : ( <BellIcon size={14} weight="fill" className={bellIconClass(status)} /> )} diff --git a/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts b/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts index 15a6ecc2..4f6617c7 100644 --- a/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts +++ b/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts @@ -7,7 +7,7 @@ import type { WallKeyboardCtx } from './types'; const terminalRegistryMocks = vi.hoisted(() => ({ dismissOrToggleAlert: vi.fn(), - getActivity: vi.fn(() => ({ status: 'ALERT_DISABLED' })), + getActivity: vi.fn(() => ({ status: 'WATCHING_DISABLED' })), isUntouched: vi.fn(), swapTerminals: vi.fn(), toggleSessionTodo: vi.fn(),