diff --git a/AGENTS.md b/AGENTS.md index b49d9e58..2fc2677b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ Each spec's own `Files` / `Code Map` section is the exhaustive file→spec mappi - **`docs/specs/layout.md`** — Tiling layout and its interaction model: panes/doors, the Lath engine pointer, passthrough/command modes, command-mode keyboard dispatch, selection overlay, spatial navigation, minimize/reattach, inline rename, kill confirmation, session lifecycle + persistence recovery, pane animations, and the workspaces-rollout ledger (`## Future`). The tiling engine internals live in tiling-engine.md; layout.md owns the interaction model on top. Touch points: `lib/src/components/Wall.tsx` + `components/wall/`, `Baseboard.tsx` / `Door.tsx` / `TerminalPane.tsx`, the registry/lifecycle/persistence modules in `lib/src/lib/`, and any keyboard/navigation/mode/workspace behavior. - **`docs/specs/shortcuts.md`** — Quick-reference table of every keyboard shortcut by mode/context. layout.md owns the behavior; update both when a binding changes. - **`docs/specs/tiling-engine.md`** — **Lath**, Dormouse's in-house headless tiling engine (it replaced dockview-react; the dependency is gone). Owns the engine internals: the pure core (split-tree model + `layout()`, the op set with Door restore tokens, sash/neighbor/autoEdge geometry, the headless animator, hierarchical hit-testing), the never-re-parent LathHost adapter with native motion and depth-cycling pointer DnD, the wall store + engine, the pane props contract, and Lath-only persistence. layout.md owns the interaction model on top. Touch points: `lib/src/lib/lath/`, the `lath-*` modules + `LathHost.tsx` in `lib/src/components/wall/`, `Wall.tsx`. -- **`docs/specs/alert.md`** — The Activity layer: WATCHING / protocol / command-exit alert tracks, attention model, TODO lifecycle, bell + TODO UI states, notification protocols (`OSC 9` / `9;4` / `99` / `777` / `BEL`) with sanitization and security rules, and the Workspace union projection. layout.md defers to it for all alert/TODO behavior. Touch points: `activity-monitor.ts`, `alert-manager.ts`, notification parsing in `terminal-protocol.ts`, the bell/TODO UI in pane headers and Doors. +- **`docs/specs/alert.md`** — The Activity layer: the WATCHING / protocol / command-exit alert tracks, the app-global rule set that makes WATCHING a property of the running command rather than of a Session, attention model, TODO lifecycle, bell + TODO UI states, notification protocols (`OSC 9` / `9;4` / `99` / `777` / `BEL`) with sanitization and security rules, and the Workspace union projection. layout.md defers to it for all alert/TODO behavior. Touch points: `activity-monitor.ts`, `alert-manager.ts`, `watched-commands.ts`, notification parsing in `terminal-protocol.ts`, the bell/TODO UI in pane headers, `TodoAlertDialog.tsx`, and Doors. - **`docs/specs/terminal-state.md`** — Per-Session semantic state: CWD, prompt/editing/running/finished lifecycle, command runs, title candidates + header derivation, grouping keys, and the keystroke fallback for shells without OSC integration. Touch points: `terminal-state.ts`, `terminal-state-store.ts`, semantic event parsing in `terminal-protocol.ts`, derived pane/door labels. - **`docs/specs/terminal-escapes.md`** — Registry of every escape sequence Dormouse parses, answers, or deliberately ignores: OSC/CSI tables pointing at the owning spec, parsing location + `pty:data` strip semantics, replay-time report filtering, iTerm2 identity, and shell-integration injection. Touch points: OSC/CSI parsing at the PTY data boundary, `terminal-report-filter.ts`, `mouse-mode-observer.ts`, `standalone/sidecar/shell-integration/`, or adding any new escape sequence. - **`docs/specs/transport.md`** — Adapter-agnostic webview ↔ host protocol: PTY lifecycle + buffering, the reconnection sequence, message contracts, persisted-session types and migrations, and universal adapter invariants. Touch points: `pty-manager.ts` / `pty-host.js` / `pty-core.js`, `message-router.ts` / `message-types.ts`, the platform adapters, `reconnect.ts` / `session-save.ts` / `session-restore.ts` / `session-types.ts`, anything crossing the webview/host boundary. diff --git a/TUTORIAL_MOCKUP.md b/TUTORIAL_MOCKUP.md new file mode 100644 index 00000000..ea45d823 --- /dev/null +++ b/TUTORIAL_MOCKUP.md @@ -0,0 +1,129 @@ +# Alert tutorial — mockup + +> Working draft. Bullets and framing only; no implementation. + +## The thesis + +**Dormouse never interrupts you about the pane you are looking at. It speaks up +about the work you turned away from.** + +Every rule in the alert system is a consequence of that one sentence. The +section should teach the sentence first and the mechanics second. + +## Why the current section falls short + +- The nine steps are a feature list (enable, spreads, tilts, rings, TODO×3, + notification, command-exit). Attention never gets a step of its own. +- Attention appears only as a warning inside the `al-ring` hint — framed as a + way to *fail* the step, not as the point of the feature. +- Nothing teaches that attention **expires**. A user who reads "it won't ring + while you're looking at it" will reasonably assume a pane they are parked in + never rings. It does, after 15 seconds of not touching it. +- Nothing teaches that attention is a **single spotlight** — clicking pane B is + what gives pane A permission to ring. + +## The arc + +Four beats. Beat 2 is the section; the rest is scaffolding around it. + +1. **A rule, not a switch** — alerts belong to a command, not a tab. +2. **Attention** — three runs of the same task, three outcomes. +3. **Two more ways the bell rings** — neither needs a rule. +4. **Nothing gets lost** — the TODO receipt. + +## Beat 1 — A rule, not a switch + +| id | title | hint | +|---|---|---| +| `al-watch-cmd` | Alert me whenever `longtask` runs | Press `s` to start a fake `longtask`, then click that pane's bell. The bell says *Alert on all "longtask"* — you are describing a command, not flagging a tab. | +| `al-spreads` | One rule, every pane running it | The second fake task lit up too, and you never touched its bell. Any pane you open later that runs `longtask` will watch as well. | + +## Beat 2 — Attention + +Section prose: + +> "Looking at it" is not focus, and it is not visibility. It is **recent +> interaction**, it belongs to **one pane at a time**, and it **wears off after +> 15 seconds**. Three runs of the same task, three different outcomes: + +| id | title | hint | +|---|---|---| +| `al-quiet-attended` | Sit in the pane — it finishes silently | Press `s`, then click into the task's pane and stay there. It goes quiet and *nothing happens*. You were watching; there was nothing to tell you. | +| `al-ring-switch` | Look away — the same task rings | Press `s` and go back to reading this. You just handed your attention to the tutorial pane, so the task's pane is free to ring the moment it goes quiet. | +| `al-ring-idle` | Stay put but idle — it rings anyway | Press `x` for a longer task, click into its pane, and then don't touch anything. Attention wears off after 15 seconds, so it rings without you ever leaving. | + +The three runs differ only in **what you did**, which is the whole lesson. Worth +saying out loud in the prose: *you never changed a setting between these.* + +Reference table (candidate for the section prose or the dialog, not a step): + +| Counts as looking | Does not | +|---|---| +| clicking a pane's body or header | the pane merely being visible | +| entering passthrough | selecting it in command mode | +| typing into it | hovering it | +| clicking a door, or `Enter` on a door | a door sitting in the baseboard | + +## Beat 3 — Two more ways the bell rings + +Both are rule-free, and both obey the same attention rule as Beat 2. + +| id | title | hint | +|---|---|---| +| `al-notif` | A program can ring the bell itself | Press `n` for a fake build that sends a notification. No rule needed — any program that asks for attention gets it, and its message rides along on the TODO tag. | +| `al-cmd-exit` | A long command finished while you were away | Dormouse noticed you watched this command start, then left. Anything that ran longer than 15 seconds and finished after you walked away is worth a word. | + +## Beat 4 — Nothing gets lost + +| id | title | hint | +|---|---|---| +| `al-todo-auto` | A dismissed ring leaves a TODO | Click the bell, or just select the pane. The ring goes away but a TODO stays, so an alert you waved away mid-thought does not vanish. | +| `al-todo-clear` | Press `Enter` in the pane to clear it | Dealing with the pane is what clears the reminder — not looking at it. | +| `al-todo-manual` | Add a TODO by hand | Press `t`, or right-click the bell. Same tag, no alert required. | + +## Full step list (10) + +``` +al-watch-cmd Alert me whenever `longtask` runs +al-spreads One rule, every pane running it +al-quiet-attended Sit in the pane — it finishes silently +al-ring-switch Look away — the same task rings +al-ring-idle Stay put but idle — it rings anyway +al-notif A program can ring the bell itself +al-cmd-exit A long command finished while you were away +al-todo-auto A dismissed ring leaves a TODO +al-todo-clear Press `Enter` in the pane to clear it +al-todo-manual Add a TODO by hand +``` + +Changes from today's nine: `al-busy` ("the bell tilts while the command works") +is **cut** as a step — it is feedback, not a concept, and it is what the user is +already staring at during Beat 2. `al-ring` is **split into three** so each +attention mechanism gets its own line. Everything else keeps its id. + +## Two things the mockup has to solve + +**1. How do you reward a non-event?** `al-quiet-attended` is credited for +*nothing happening*, which is a weak checkbox and easy to trip by accident. +Options: credit it when the task completes with the user holding attention +throughout; have the runner narrate it in place ("← no ring: you were here"); +or demote it from a step to prose and let Beat 2 be two steps. This is the crux +— the "it stayed quiet" case is the one that carries the thesis, so it deserves +to feel deliberate rather than skipped. + +**2. The demo needs two task lengths.** `al-quiet-attended` only works if the +task rings *inside* the 15-second attention window (roughly: ~3s of output, then +~5s of silence, ring at ~8s), otherwise attention expires and it rings even +though the user did everything right. `al-ring-idle` needs the opposite — a task +that outlives the window. Today's single `s` demo is tuned long, so it can +demonstrate the second but not the first. + +## Open questions + +- Is 10 steps too many? Beat 4 could collapse to two. +- Should the "counts as looking" table live in the tutorial at all, or only in + the alert dialog where it is reference material? +- Does `al-ring-idle` ask too much dead time (~20s of deliberately doing + nothing) for a browser tutorial? +- Should Beat 2 explicitly name the 15 seconds, or stay qualitative ("after a + while")? Naming it is honest and testable; it also invites "why 15?". diff --git a/docs/specs/alert.md b/docs/specs/alert.md index ca0536e9..cdc4ff69 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -6,15 +6,17 @@ Terminal alert state belongs to the **Session** Activity layer. It survives Pane Dormouse can owe the user attention in three ways: -- **WATCHING**: the user enabled the timer-based output monitor, output became busy, then went quiet while the user was not attending the Session. +- **WATCHING**: a command the user asked to be alerted about was running, its output became busy, then went quiet while the user was not attending the Session. - **Terminal report**: the PTY emitted a supported notification or progress protocol (`BEL`, `OSC 9`, `OSC 9;4`, `OSC 99`, or `OSC 777`). - **Command exit**: Dormouse saw a foreground command running while the user attended the Session, attention was lost while that same command was still running, and the command exited after at least `T_USER_ATTENTION`. -Terminal-report and command-exit alerts do not require WATCHING to be enabled. All three paths share the same attention suppression rule: do not ring if the user is actively attending that Session at the completion moment. +Terminal-report and command-exit alerts do not require WATCHING. All three share the same attention suppression rule: do not ring if the user is actively attending that Session at the completion moment. + +Internally these are three independent tracks — `watchingRingingCommand` + the `ActivityMonitor`, `protocolStatus` + `progress`, and `commandExitStatus` + `commandExitWatch`. Each runs IDLE -> busy/armed -> ringing without entangling the others, and each latches its own ring in the entry until it is cleared. ## Non-goals -- No command/process heuristics. Dormouse does not guess that `vim`, `npm dev`, agents, or test runners deserve special alert behavior. +- No process heuristics. Dormouse never decides on its own that `vim`, `npm dev`, agents, or test runners deserve alerts. WATCHING applies only to command names the user explicitly asked for. - No sound, native OS notifications, browser notifications, or separate progress-bar widget. - No process-tree introspection for command-exit alerts; normalized terminal semantic events are the reliable input. - No HTML, Markdown, ANSI styling, clickable actions, custom icons, or remote-controlled buttons in notification previews. @@ -22,16 +24,17 @@ Terminal-report and command-exit alerts do not require WATCHING to be enabled. A ## Public State -Source of truth: `AlertState` / `ActivityNotification` in `lib/src/lib/alert-manager.ts` and `SessionStatus` in `lib/src/lib/activity-monitor.ts` define the public Activity state for terminal Sessions. Internal state is deliberately split into independent tracks (`watchingStatus`, `protocolStatus`, `commandExitStatus`, plus `progress` and `commandExitWatch` companion state) so each axis evolves without entangling the others. +Source of truth: `AlertState` / `ActivityNotification` in `lib/src/lib/alert-manager.ts` and `SessionStatus` in `lib/src/lib/activity-monitor.ts`. Public `status` is a projection — first match wins: -1. `ALERT_RINGING` if `protocolStatus`, `commandExitStatus`, or `watchingStatus` is ringing, in that order. +1. `ALERT_RINGING` if any of the three tracks is ringing. 2. `OSC_NOTIF_BUSY` if protocol progress is active. -3. `COMMAND_EXIT_ARMED` if command-exit alerting is armed. -4. Otherwise `watchingStatus`. +3. The `ActivityMonitor`'s own state if WATCHING is on. WATCHING outranks the command-exit arm deliberately: a watched command is by definition running, so `COMMAND_EXIT_ARMED` would otherwise mask the monitor's busy/quiet states for the whole run, and the monitor is derived from real output. +4. `COMMAND_EXIT_ARMED` if command-exit alerting is armed. +5. Otherwise `WATCHING_DISABLED`. -Persist `status`, `watchingEnabled`, `todo`, and sanitized `notification`. Restore `todo` and `notification`, then restart WATCHING only if `watchingEnabled` is true. Restore must not recreate protocol progress, command-exit arms, or a fresh ring; replay filtering in `docs/specs/terminal-escapes.md` prevents old terminal output from firing notification side effects again. +Persist only `todo` and the sanitized `notification` (plus `status` for diagnostics). Restore replays those two and nothing else: it must not recreate a ring, protocol progress, or a command-exit arm. WATCHING is not per-Session state and is never persisted per Session — it is re-derived from the rule set below at the next command start. Replay filtering in `docs/specs/terminal-escapes.md` prevents old terminal output from firing notification side effects again. ## Attention @@ -50,7 +53,17 @@ Source of truth: `cfg.alert` in `lib/src/cfg.ts` defines `T_USER_ATTENTION` and ## WATCHING Track -WATCHING is the user-controlled output/silence monitor. It starts fresh when enabled and is disposed when disabled. Meaningful output excludes resize redraw noise during `T_RESIZE_DEBOUNCE`; theme changes, remounts, DOM reparenting, selection, and focus changes are not output. +**WATCHING is a property of the command, not of a Session.** The user maintains a set of watched command names; a Session runs the output/silence monitor exactly while its foreground command's name is in that set. Turning alerts on while `claude` runs means every Session running `claude` watches — the ones open now and the ones opened later. Turning them off anywhere removes the rule everywhere. There is no per-Session enable, and no per-Session mute. + +Rules: + +- The key is `commandArgv0(rawCommandLine)` in `lib/src/lib/terminal-state.ts`: take everything before the first pipeline/compound boundary, skip leading `VAR=value` assignments and a leading `env`, then reduce argv[0] to its basename. `claude`, `/usr/local/bin/claude --resume`, and `FOO=1 env BAR=2 claude` all key on `claude`. `foo | claude` keys on `foo`, matching what bash's `DEBUG` trap reports. +- A `commandStart` for a watched name starts a **fresh** monitor; `commandFinish`, `promptStart`, and `promptEnd` end the command and dispose it. Editing the rule set re-derives WATCHING across every live Session immediately. +- A WATCHING ring outlives its monitor. Watching switches off the moment the watched command exits, which is usually the same moment the ring was raised, so the ring and its originating command key are held in the Session entry (`watchingRingingCommand`) rather than in the monitor. +- Removing a rule is the one thing that *does* silence a WATCHING ring: it is the user saying "stop alerting on this". The latched originating key makes this work after the command has exited and its monitor is gone. A command merely ending never clears the ring. +- The rule set is app-global and persisted (`dormouse:watched-commands`). It starts empty, so WATCHING is off everywhere until the user turns it on. Source of truth: `lib/src/lib/watched-commands.ts` (renderer mirror) and `lib/src/lib/watched-command-host.ts` (multi-renderer coordinator). In VS Code the shared extension host is authoritative: the first renderer seeds it from persisted storage, edits cross the boundary as single-command mutations, and the host broadcasts its canonical snapshot to every webview. A stale webview can therefore neither replace unrelated rules nor keep reporting an obsolete rule list. + +**Limitation:** WATCHING needs the shell to report command boundaries (`OSC 633` / `OSC 133`). Shells without integration — `cmd.exe`, `fish`, or any shell where injection did not take (`docs/specs/terminal-escapes.md`) — never report a command name, so WATCHING never engages there and the bell reports "nothing is running". Terminal-report and command-exit alerts are unaffected. This is accepted rather than worked around: the keystroke fallback in `docs/specs/terminal-state.md` is renderer-side and lower confidence, and routing it into the manager would buy those shells a worse version of a feature at the cost of a second command-tracking path. | State | Meaning | |---|---| @@ -61,7 +74,7 @@ WATCHING is the user-controlled output/silence monitor. It starts fresh when ena | `MIGHT_NEED_ATTENTION` | A busy Session went quiet. Debounce state. | | `ALERT_RINGING` | WATCHING observed likely completion while the Session lacked attention. | -Source of truth: `ActivityMonitor` in `lib/src/lib/activity-monitor.ts` implements the transitions. The invariants the implementation must honor: +Source of truth: `ActivityMonitor` in `lib/src/lib/activity-monitor.ts` implements the transitions. Meaningful output excludes resize redraw noise during `T_RESIZE_DEBOUNCE`; theme changes, remounts, DOM reparenting, selection, and focus changes are not output. The invariants the implementation must honor: - Output drives the monitor up the chain `NOTHING_TO_SHOW` -> `MIGHT_BE_BUSY` -> `BUSY`; silence drives it down `BUSY` -> `MIGHT_NEED_ATTENTION` -> `ALERT_RINGING`. The `MIGHT_*` states are debounce windows in both directions. - First output starts candidate tracking without changing status; unconfirmed `MIGHT_BE_BUSY` returns to `NOTHING_TO_SHOW`; `ALERT_RINGING` ignores new output until the Session has attention. @@ -69,76 +82,17 @@ Source of truth: `ActivityMonitor` in `lib/src/lib/activity-monitor.ts` implemen - Attending or dismissing a WATCHING ring resets the monitor to `NOTHING_TO_SHOW`. - Rings must be caused by a fresh transition into `ALERT_RINGING`, never by rerender, theme change, remount, minimize, or reattach. -## Protocol Track - -Terminal notifications are explicit requests for attention and bypass the WATCHING toggle. Direct notifications ring immediately only when the Session lacks attention; if the user has attention, that notification is suppressed and unrelated protocol progress is left alone. - -`OSC 9;4` active progress sets public `status = OSC_NOTIF_BUSY`. It never rings because of silence. It rings only when a completion, clear, or error report arrives while the Session lacks attention. Completion/error while attended clears the protocol progress without TODO or ring. - -Protocol rings set `todo = true`, store the latest sanitized `ActivityNotification`, and set `protocolStatus = ALERT_RINGING`. Clearing the protocol ring returns `protocolStatus` to `IDLE` and public status falls back to command-exit or WATCHING state. - -### Standalone BEL - -A `BEL` byte outside an OSC sequence is stripped from visible output and creates the BEL notification (`TERMINAL_BELL_NOTIFICATION` in `lib/src/lib/terminal-protocol.ts`). - -If a parse batch also contains a richer OSC notification/progress event, drop the generic `BEL` detail so it cannot overwrite useful preview text. Multiple standalone bells in one batch collapse to one notification. - -### OSC 9 - -`OSC 9 ; ST` creates an OSC 9 notification with the message as body (title null); see `parseOsc9` in `lib/src/lib/terminal-protocol.ts`. - -Empty sanitized messages are ignored. OSC 9 also feeds title-candidate derivation in `docs/specs/terminal-state.md`; that does not change alert behavior. - -### OSC 9;4 Progress - -`OSC 9;4` is progress only. It has no title, body, urgency, id, app name, or action fields. - -| Sequence | Meaning | -|---|---| -| `OSC 9 ; 4 ST` or `OSC 9 ; 4 ; 0 ST` | clear progress | -| `OSC 9 ; 4 ; 1 ; <0-100> ST` | normal progress; `100` is completion | -| `OSC 9 ; 4 ; 2 ; <0-100?> ST` | error progress; percent optional | -| `OSC 9 ; 4 ; 3 ST` | indeterminate active progress | -| `OSC 9 ; 4 ; 4 ; <0-100> ST` | warning active progress | - -Rules: - -- Active normal, warning, or indeterminate progress sets `protocolStatus = OSC_NOTIF_BUSY` and does not create TODO. -- `state=1, progress=100` rings as completion if unattended. -- `state=2` rings as error if unattended. -- Clear rings as completion only if there was an active progress cycle; otherwise ignore it. -- Warning progress does not ring by itself, but completion of a warning cycle rings with a generated warning title. -- Invalid states, missing required percents for states `1` and `4`, and out-of-range percents are ignored. - -Source of truth: `ringOrSuppressProtocolProgress` / `completeProtocolProgress` in `lib/src/lib/alert-manager.ts` produce generated titles/bodies for these rings. +## Terminal reports -### OSC 777 +Terminal notifications are explicit requests for attention and are independent of WATCHING. A direct notification rings immediately only when the Session lacks attention; if the user has attention it is suppressed and unrelated protocol progress is left alone. A ring sets `todo = true`, stores the latest sanitized `ActivityNotification`, and sets `protocolStatus = ALERT_RINGING`; clearing it returns `protocolStatus` to `IDLE` and public status falls back to the other tracks. -`OSC 777 ; notify ; ; <body> ST` creates an OSC 777 notification; see `parseOsc777` in `lib/src/lib/terminal-protocol.ts`. +Sequence syntax for every row below lives in `docs/specs/terminal-escapes.md`; parsing is `lib/src/lib/terminal-protocol.ts`. What each one means here: -Only `notify` is supported. The first field after `notify` is the title; everything after the next semicolon is body, preserving additional semicolons there. Unsupported subcommands and empty sanitized notifications are ignored. - -### OSC 99 - -`OSC 99 ; <metadata> ; <payload> ST` is kitty's notification protocol. Metadata keys are single ASCII letters separated by `:`; unknown keys are ignored. - -Supported keys: - -| Key | Meaning | Dormouse behavior | -|---|---|---| -| `i` | notification id | assemble chunks for the same pending notification | -| `d` | done flag, default `1` | `d=0` stores partial data; `d=1` completes and may ring | -| `e` | encoding, `0` plain or `1` base64 | decode base64 or reject invalid payload | -| `p` | payload type, default `title` | support `title`, `body`, `?`, `close`, `alive`, `icon`, `buttons` | - -`title` and `body` chunks append to the pending notification. Completion rings once if the sanitized title or body is nonempty. If `i` is omitted, only a complete single-sequence notification is meaningful. - -Management payloads contribute no notification content: - -- `p=?` sends a support response advertising the support payload defined in `lib/src/lib/terminal-protocol.ts` (`OSC99_SUPPORT_PAYLOAD`). -- `p=close`, `p=alive`, `p=icon`, and `p=buttons` are consumed or ignored without creating notification UI of their own. Like any chunk, one carrying the (default) `d=1` done flag still completes a pending same-`i` notification, which may then ring on its previously accumulated title/body — kitty's done-flag semantics apply regardless of the final chunk's payload type. - -Source of truth: `lib/src/lib/terminal-protocol.ts` defines the pending OSC 99 chunk TTL and max-pending-id cap. +- **Standalone `BEL`** — a `BEL` outside an OSC is stripped from visible output and creates `TERMINAL_BELL_NOTIFICATION`. If the same parse batch also holds a richer OSC notification or progress event, drop the generic bells so they cannot overwrite useful preview text; multiple bells in one batch collapse to one notification. +- **`OSC 9`** — the message becomes the body, title null. Empty sanitized messages are ignored. It also feeds title-candidate derivation in `docs/specs/terminal-state.md`, which does not change alert behavior. +- **`OSC 777`** — only the `notify` subcommand is supported. The first field after `notify` is the title; everything after the next semicolon is body, preserving semicolons there. Unsupported subcommands and empty sanitized notifications are ignored. +- **`OSC 99`** (kitty) — metadata keys are single ASCII letters separated by `:`; unknown keys are ignored. `i` groups chunks of one pending notification, `d` is the done flag (default `1`), `e` selects plain or base64 payload encoding, and `p` selects the payload type (default `title`). `title`/`body` chunks append to the pending notification; completion rings once if the sanitized title or body is nonempty. Without `i`, only a complete single-sequence notification is meaningful. Management payloads contribute no content: `p=?` sends `OSC99_SUPPORT_PAYLOAD`, and `p=close` / `p=alive` / `p=icon` / `p=buttons` are consumed. Like any chunk, a management chunk carrying the default `d=1` still completes a pending same-`i` notification, which may then ring on its accumulated title/body — kitty's done-flag semantics apply regardless of the final chunk's payload type. The pending-chunk TTL and max-pending-id cap live in `terminal-protocol.ts`. +- **`OSC 9;4` progress** — progress only: no title, body, urgency, id, app name, or action fields. Active normal, warning, or indeterminate progress sets `protocolStatus = OSC_NOTIF_BUSY` and creates no TODO; it never rings because of silence. `state=1, progress=100` rings as completion and `state=2` rings as error, both only when unattended. A clear rings as completion only if there was an active cycle, otherwise it is ignored. Warning progress does not ring by itself, but completing a warning cycle rings with a generated warning title. Invalid states, missing required percents for states `1` and `4`, and out-of-range percents are ignored. Completion or error while attended clears the progress without TODO or ring. Source of truth for the generated titles/bodies: `ringOrSuppressProtocolProgress` / `completeProtocolProgress` in `lib/src/lib/alert-manager.ts`. ## Command-exit Track @@ -155,71 +109,57 @@ Rules: - Race rule: attention must be lost before the finish event is observed. - Precedence rule: a protocol ring must keep its richer `ActivityNotification`; command-exit must not overwrite it. +Command starts and finishes are also what drive the WATCHING rule above, so the two tracks share one `commandExitWatch` record and one `resolveCommandStart` helper with the terminal-state reducer. + ## Clearing And TODO `todo` is a boolean reminder. Protocol and command-exit rings create it immediately. WATCHING rings create it when the user attends, dismisses, or marks TODO, so a dismissed ring does not disappear without a trace. Clearing behavior: -- Attending a ringing Session clears active protocol/command rings, resets a WATCHING ring, sets `todo = true`, and sets `attentionDismissedRing = true`. +- Attending a ringing Session clears active rings on all three tracks, sets `todo = true`, and sets `attentionDismissedRing = true`. - Clicking the ringing bell or pressing `a` dismisses the ring, sets `todo = true`, and opens the alert/TODO dialog. -- Marking TODO clears any active ring and leaves WATCHING enabled for future cycles. -- Clearing TODO sets `todo = false`, clears `notification`, and clears active protocol/command rings. +- Marking TODO clears any active ring and leaves the WATCHING rule in place for future cycles. +- Clearing TODO sets `todo = false`, clears `notification`, and clears active rings. - Typing passthrough `Enter` into the Session clears TODO. Command-mode `Enter` that only enters passthrough does not. -- Disabling WATCHING disposes only the activity monitor. It does not clear protocol progress, command-exit arms, TODO, or notification detail. +- Removing a WATCHING rule disposes the monitors it owned and silences their WATCHING rings. It does not clear protocol progress, command-exit arms, TODO, or notification detail. - Destroying the Session clears all alert, TODO, notification, attention, protocol, and command-exit state. -`attentionDismissedRing` exists so the next bell click after an attention-based dismissal opens the dialog instead of silently disabling WATCHING. +`attentionDismissedRing` exists so the next bell click after an attention-based dismissal opens the dialog instead of silently editing a rule. Starting or stopping a WATCHING monitor, or advancing another alarm track, does not consume the flag; only the explicit dismiss path does. ## Workspace union -> See `docs/specs/glossary.md` for the Workspace / Window containers. -> -> The union *projection* is a pure function — `computeWorkspaceUnion(surfaceIds, activitySnapshot)` in `lib/src/lib/workspace-union.ts`. **VS Code surfacing is implemented**: the editor-tab title and the bottom-panel view badge reflect the union (terminal ring/TODO only; `docs/specs/vscode.md`). A browser Surface's user-set `todo` round-trips to the activity store live and persists across restart, so it is counted by the projection and shows on the surface's door today. The **standalone strip indicators** are staged — see `docs/specs/layout.md` `## Future` (workspaces-rollout). +> See `docs/specs/glossary.md` for the Workspace / Window containers and the definitions of the three union fields (`ringing`, `todo`, `count`). -A Workspace projects a **union status** over the attention state of the Surfaces it contains (terminal Sessions and browser Surfaces alike — see `docs/specs/glossary.md`): +The projection is a pure function — `computeWorkspaceUnion(surfaceIds, activitySnapshot)` in `lib/src/lib/workspace-union.ts`. It is display-only: it never enters the Activity state machine and never fires a ring of its own, so it simply mirrors whichever per-Session rings survive attention suppression. Membership includes minimized (`Doored`) Surfaces and, in standalone, the Surfaces of inactive (unmounted) Workspaces, because a Session's Activity survives minimize and unmount (glossary I2/I3) and a browser Surface's `todo` survives in its persisted `alert` blob. -- `ringing` — any member terminal Session's public `status` is `ALERT_RINGING`. Only terminal Sessions ring; a browser Surface has no BEL/OSC source and never reaches `ALERT_RINGING`. -- `todo` — any member Surface has `todo === true`. A terminal Session or a browser Surface may be flagged. -- `count` — number of member Surfaces owing attention (ringing or `todo`), for the numeric badge a host may show. - -Rules: +Where it surfaces is host-specific: -- The union is **display-only** and derived. It never enters the terminal Activity state machine, never fires a fresh ring, and produces no sound or notification of its own; it only mirrors member state. Every terminal ringing/TODO transition above remains per-Session, and every browser TODO transition remains per-browser Surface. -- Membership includes minimized (`Doored`) Surfaces and, in standalone, the Surfaces of inactive (unmounted) Workspaces. A terminal Session's Activity survives minimize and unmount (glossary I2/I3) and a browser Surface's `todo` survives in its persisted `alert` blob, so a backgrounded Surface can light up its Workspace's indicator. -- Attention suppression needs no special-casing: a per-Session ring is already suppressed while that Session is attended, so the union simply reflects whatever rings survive. - -Where the union surfaces is host-specific: - -- **Standalone:** the workspace strip that will show per-tab union indicators is staged with the rest of the strip UI — the behavioral rules live in `docs/specs/layout.md` `## Future` (workspaces-rollout). Today a browser Surface's `todo` shows on its own door, and terminal rings/TODOs show on their panes and doors as specified above. -- **VS Code:** the host reflects the **terminal** portion of each Workspace's union onto the webview's native chrome — an editor tab's icon (and optionally title) and the sidebar view's numeric badge. Browser-surface TODO stays webview-local; surfacing it on native chrome needs the webview→host Surface-state channel staged in `docs/specs/vscode.md` `## Future`. See `docs/specs/vscode.md`. +- **VS Code** reflects the terminal portion of each Workspace's union onto native chrome (editor-tab title, sidebar view badge) — implemented; see `docs/specs/vscode.md`. Browser-surface TODO stays webview-local until the webview->host Surface-state channel staged in that spec's `## Future` lands. +- **Standalone** shows terminal rings/TODOs on panes and doors, and a browser Surface's `todo` on its own door. The workspace-strip union indicators are staged with the rest of the strip — see `docs/specs/layout.md` `## Future` (workspaces-rollout). ## UI Contract ### Pane Header -The header shows: - -- an alert bell in every width tier -- a fixed-text `TODO` pill when `todo === true`, except in the minimal tier -- a hover/focus notification preview when TODO has `notification` -- a dialog from right-click or some left-click actions, containing TODO and WATCHING switches plus notification detail +The header shows an alert bell, a fixed-text `TODO` pill when `todo === true`, a hover/focus notification preview when TODO has `notification`, and a dialog opened by right-click or by some left-click actions. Placement, sizing, and width tiers belong to `docs/specs/layout.md`. Bell visual state is a pure function of public status. Source of truth: `bellIconClass` in `lib/src/components/bell-icon-class.ts` defines the tilt/animation mapping. -Tilt and animation must not change layout size. Long titles truncate before alert/TODO controls disappear. +The bell names the command it would act on ("Alert on all `claude`"), not an abstract toggle, because that is the scope of what a click changes. -Bell interactions: +Bell interactions — one transition table, in `dismissOrToggleAlert` in `lib/src/lib/session-activity-store.ts`: -- Left-click `WATCHING_DISABLED`: enable WATCHING. - Left-click `ALERT_RINGING`: dismiss, create TODO if needed, open dialog. -- Left-click after `attentionDismissedRing`: clear the flag and open dialog. -- Left-click `OSC_NOTIF_BUSY` or `COMMAND_EXIT_ARMED`: if WATCHING is enabled, disable only WATCHING; otherwise open dialog. Do not clear protocol progress or command-exit arm. -- Left-click any other WATCHING-enabled state: disable WATCHING. -- Pressing `a` on the selected Pane in command mode uses the same action. -- Right-click always opens the dialog. +- Left-click after `attentionDismissedRing`: consume the flag and open dialog. +- Otherwise, with a command running: toggle that command's WATCHING rule on or off. Turning it off drops the rule for every Session running it. +- Exception: from `OSC_NOTIF_BUSY` or `COMMAND_EXIT_ARMED` with no rule set, open the dialog instead. Those alarms need no rule, so a click on them must not create one by surprise, and it must not clear the progress or the arm. +- With no command running: change nothing and open the dialog, which explains that alerts are per command. +- Pressing `a` on the selected Pane in command mode uses the same action. Right-click always opens the dialog. - Pressing `t` toggles TODO. +The dialog carries the TODO switch, the WATCHING rule switch for the running command, notification detail, and the list of every watched command with a remove control. The list is load-bearing, not decoration: it is the only place a rule set on a since-closed Pane can be found and removed. + The TODO pill always displays `TODO`; remote notification text belongs in preview/detail surfaces, not inside the pill. Clicking the pill clears TODO. On clear, the pill briefly shows the success flourish before unmounting. ### Door @@ -237,46 +177,26 @@ Click or `Enter` on a Door reattaches into passthrough, counts as attention, and Notification text is untrusted terminal output. -Sanitization and limits: - -- Treat all text as plain text. +- Treat all text as plain text: never interpret ANSI, OSC, HTML, Markdown, URLs, paths, or emoji shortcodes as markup. - Strip C0/C1 controls after protocol parsing, collapse whitespace controls to spaces, and trim. -- Do not interpret ANSI, OSC, HTML, Markdown, URLs, paths, or emoji shortcodes as markup. -- Store at most the `TITLE_LIMIT` / `BODY_LIMIT` Unicode code points defined in `lib/src/lib/terminal-protocol.ts`. -- Store only the latest `ActivityNotification`, not unbounded history. -- Cap and expire incomplete OSC 99 parser state as described above. - -Security requirements: - +- Store at most the `TITLE_LIMIT` / `BODY_LIMIT` code points defined in `lib/src/lib/terminal-protocol.ts`, only the latest `ActivityNotification` rather than unbounded history, and cap/expire incomplete OSC 99 parser state. - Never execute commands, open URLs, copy to clipboard, read files, focus outside Dormouse, or render protocol-supplied icons/buttons/actions. -- Notification text may appear only as plain text in visible UI and accessible labels. -- Layout must tolerate long text, CJK, RTL, combining marks, and emoji without pushing fixed controls out of bounds. - -## Hardening +- Notification text may appear only as plain text in visible UI and accessible labels, and layout must tolerate long text, CJK, RTL, combining marks, and emoji without pushing fixed controls out of bounds. -- Multiple Sessions can ring independently. -- Minimize, reattach, rerender, resize, and theme changes must preserve existing alert state without creating new rings. -- An exited Session may keep ringing until attended, dismissed, disabled, or destroyed. -- Ringing must not rely on color alone, and `prefers-reduced-motion` must be respected. -- Bell, TODO, preview, and dialog controls must remain keyboard reachable; dialogs trap focus and support `Escape`. -- Tooltips, dialog copy, and future localized TODO labels must wrap in narrow layouts. +Alert-specific robustness requirements: multiple Sessions ring independently; minimize, reattach, rerender, resize, and theme changes preserve existing alert state without creating new rings; an exited Session may keep ringing until attended, dismissed, or destroyed; ringing must not rely on color alone and must respect `prefers-reduced-motion`. ## Files | File | Role | |------|------| | `lib/src/lib/activity-monitor.ts` | Per-Session WATCHING state machine (output/silence timers) | -| `lib/src/lib/alert-manager.ts` | `AlertManager`: protocol + command-exit tracks, attention, TODO, notification storage, status projection | +| `lib/src/lib/alert-manager.ts` | `AlertManager`: the three tracks, the rule set, attention, TODO, notification storage, status projection | +| `lib/src/lib/watched-commands.ts` | Persisted WATCHING rule set and its push to the host | +| `lib/src/lib/watched-command-host.ts` | First-seed + mutation/broadcast coordinator for a host shared by multiple renderers | | `lib/src/lib/terminal-protocol.ts` | Notification/progress OSC parsing (`OSC 9` / `9;4` / `99` / `777`, BEL), sanitization limits, OSC 99 chunk state | -| `lib/src/lib/session-activity-store.ts` | React activity snapshot store, primed alert state, platform delegates | +| `lib/src/lib/session-activity-store.ts` | React activity snapshot store, primed alert state, bell transition table, platform delegates | | `lib/src/lib/workspace-union.ts` | `computeWorkspaceUnion` projection | | `lib/src/components/bell-icon-class.ts` | Bell tilt/animation mapping from public status | -| `lib/src/components/wall/TerminalPaneHeader.tsx` | Bell button, TODO pill, notification preview and dialog | +| `lib/src/components/wall/TerminalPaneHeader.tsx` | Bell button, TODO pill, notification preview | +| `lib/src/components/TodoAlertDialog.tsx` | TODO + WATCHING-rule switches, notification detail, watched-command list | | `lib/src/components/Door.tsx` | Door bell + TODO display | - -## References - -- iTerm2 proprietary escape codes (OSC 9, OSC 9;4): https://iterm2.com/documentation-escape-codes.html -- kitty desktop notifications (OSC 99): https://sw.kovidgoyal.net/kitty/desktop-notifications/ -- WezTerm escape sequences and notification handling (OSC 9, OSC 777): https://wezterm.org/escape-sequences.html, https://wezterm.org/config/lua/config/notification_handling.html -- foot control sequences (OSC 9, OSC 99, OSC 777): https://manpages.ubuntu.com/manpages/resolute/man7/foot-ctlseqs.7.html diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 1475a620..06350b91 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -348,7 +348,7 @@ On startup, recovery is priority-based: ### Activity state -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 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 foreground command is running after attention was lost with WATCHING off. `watchingEnabled` reports whether the running command matches a WATCHING rule, which stays accurate when `status` is projected to a stronger protocol 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-escapes.md b/docs/specs/terminal-escapes.md index 0eea3400..c53b0e85 100644 --- a/docs/specs/terminal-escapes.md +++ b/docs/specs/terminal-escapes.md @@ -44,21 +44,21 @@ Unknown non-iTerm2 OSC families pass through to xterm.js unchanged so xterm.js c | Sequence | Purpose | Spec | |---|---|---| -| `BEL` (standalone, outside an OSC) | Generic terminal-bell notification | [alert.md](alert.md#standalone-bel) | +| `BEL` (standalone, outside an OSC) | Generic terminal-bell notification | [alert.md](alert.md#terminal-reports) | | `OSC 0 ; <title> ST` | Window/icon title | [terminal-state.md](terminal-state.md#supported-osc-inputs) | | `OSC 2 ; <title> ST` | Window title | [terminal-state.md](terminal-state.md#supported-osc-inputs) | | `OSC 7 ; file://host/path ST` | CWD (xterm-style URI) | [terminal-state.md](terminal-state.md#supported-osc-inputs) | | `OSC 8 ; <params> ; <URI> ST ... OSC 8 ; ; ST` | Explicit hyperlink region; passed through to xterm.js for rendering, then opened only after Dormouse shows the real target in a confirmation dialog. | This spec | | `OSC 10 ; ? ST` / `OSC 11 ; ? ST` / `OSC 12 ; ? ST` | Foreground / background / cursor color **query**. Dormouse answers from the active terminal theme with `OSC <code> ; rgb:RRRR/GGGG/BBBB ST` (16-bit channels) and consumes the query, so background-detecting TUIs (e.g. Codex's adaptive composer "pill") see the real colors instead of assuming dark. The parser needs the theme colors: the **standalone** frontend adapter reads them directly (`getTerminalTheme()`); the **VS Code** extension-host parser has no DOM, so the webview pushes them up via `dormouse:themeColors` (see [vscode.md](vscode.md#osc-color-query-answering)). Only the `?` (report) form is intercepted; color *set* requests pass through to xterm.js. Until the theme is known (before the first push, or if unparseable) the query falls through to xterm.js. | This spec | -| `OSC 9 ; <message> ST` | iTerm2 legacy notification | [alert.md](alert.md#osc-9) | -| `OSC 9 ; 4 ; <state> [; <progress>] ST` | iTerm2 progress | [alert.md](alert.md#osc-94-progress) | +| `OSC 9 ; <message> ST` | iTerm2 legacy notification | [alert.md](alert.md#terminal-reports) | +| `OSC 9 ; 4 ; <state> [; <progress>] ST` | iTerm2 progress | [alert.md](alert.md#terminal-reports) | | `OSC 9 ; 9 ; <cwd> ST` | CWD (Windows Terminal / ConEmu) | [terminal-state.md](terminal-state.md#supported-osc-inputs) | -| `OSC 99 ; <metadata> ; <payload> ST` | kitty desktop notification | [alert.md](alert.md#osc-99) | +| `OSC 99 ; <metadata> ; <payload> ST` | kitty desktop notification | [alert.md](alert.md#terminal-reports) | | `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 ; <commandline> [; <nonce>] ST` | VS Code command line | [terminal-state.md](terminal-state.md#supported-osc-inputs) | | `OSC 633 ; P ; Cwd=<cwd> ST` | CWD (VS Code) | [terminal-state.md](terminal-state.md#supported-osc-inputs) | -| `OSC 777 ; notify ; <title> ; <body> ST` | rxvt/WezTerm notification | [alert.md](alert.md#osc-777) | +| `OSC 777 ; notify ; <title> ; <body> ST` | rxvt/WezTerm notification | [alert.md](alert.md#terminal-reports) | | `OSC 1337 ; CurrentDir=<cwd> ST` | CWD (iTerm2 compatibility) | [terminal-state.md](terminal-state.md#supported-osc-inputs) | (`BEL` is not itself an OSC; it has a row here because a standalone `BEL` is parsed and stripped at the same PTY data boundary as the OSCs.) diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index 81a8642d..c0733946 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -167,7 +167,7 @@ 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 `user_input` command fallback is best effort and renderer-only: it is sufficient for headers and grouping, but the `AlertManager` never sees it. Command-exit alerting and command-keyed WATCHING both need real shell integration, and `docs/specs/alert.md` records that as a deliberate limitation rather than plumbing the heuristic into a second command-tracking path. 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/terminal-escapes.md`. diff --git a/docs/specs/transport.md b/docs/specs/transport.md index c5ba9384..65a3a9a5 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -97,6 +97,9 @@ Workspace union status (`docs/specs/alert.md`) adds no new message. Standalone c | Direction | Message | Source type | Contract | | --- | --- | --- | --- | +| Webview → host | `alert:initializeWatchedCommands` | `WebviewMessage` | Offer the renderer's persisted WATCHING rules as the startup seed. A multi-webview host accepts only the first seed in its lifetime and replies with its canonical snapshot. | +| Webview → host | `alert:setCommandWatched` | `WebviewMessage` | Add or remove one bare command key without replacing unrelated app-global rules. | +| Host → webview | `alert:watchedCommands` | `ExtensionMessage` | Canonical full WATCHING snapshot broadcast after initialization or mutation; each renderer replaces and persists its local mirror. | | Webview → host | `dormouse:openExternal` | `WebviewMessage` | Request the host to open a user-confirmed external URI from an OSC 8 hyperlink. Hosts must revalidate and reject malformed, control-character-bearing, or blocked pseudo-scheme targets (`javascript:`, `data:`, `blob:`, `about:`). | | Webview → host | `pty:getOpenPorts` | `WebviewMessage` | Request the TCP listening ports opened by a PTY's shell process **and all of its descendant subprocesses**. The host resolves them from the PTY's root pid and replies with `pty:openPorts`. Source of truth: `getOpenPortsForPid()` in `standalone/sidecar/pty-core.js` (the VS Code extension loads it through the `lib/pty-core.cjs` shim). | | Host → webview | `pty:openPorts` | `ExtensionMessage` | Reply to `pty:getOpenPorts`: `ports: OpenPort[]` (`{ protocol, family, address, port, pid, processName }`), de-duplicated by `(family, address, port)` and sorted by port. Empty array when the PTY is gone or enumeration fails. | diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index 75c57663..ab1fdecc 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -13,7 +13,7 @@ The website playground has canonical device-specific routes: The `tut` TUI has two device profiles, defined in `website/src/lib/tut-items.ts` (`DESKTOP_TUTORIAL_PROFILE`, `POCKET_TUTORIAL_PROFILE`): -- **Desktop** starts at the top-level menu; sections: Keyboard navigation, Alert and TODO, Copy paste. +- **Desktop** starts at the top-level menu; sections: Keyboard navigation, Alerts and attention, Copy paste. The alert section covers all three of the tracks in `docs/specs/alert.md` — the command-keyed WATCHING rule and how it spreads across panes, program-sent terminal reports, and a command exiting while the user was away. - **Pocket** starts directly inside Gesture navigation (`initialSectionId`); sections: Gesture navigation, Copy paste. All section/item titles, hints, and prose live in `tut-items.ts`; the menu, Flappy Term, and star copy live in `tut-runner.ts`. This spec does not duplicate that text. Item ids are stable — they are the localStorage key suffixes. @@ -50,9 +50,11 @@ Below the sections the menu lists `Starred on GitHub` (persisted separately, cal ### Runner-local intercepts -Two keys are intercepted by `TutRunner` while a specific section is open — they are **not** real Dormouse shortcuts: +Four keys are intercepted by `TutRunner` while a specific section is open — they are **not** real Dormouse shortcuts. The three alert demos all report their fake commands through `OSC 633 ; E / C / D` written with `FakePtyAdapter.sendOutput`, which the fake adapter runs through the real `TerminalProtocolParser`; the OSCs are stripped from visible output, so a demo never disturbs the TUI its pane is drawing. Each demo's duration must outlast `cfg.alert.userAttention` so the bell actually rings rather than being suppressed as "user is looking"; see the comments in `tut-runner.ts`. -- **`s`** (Alert section) — drives a fake busy task on the WATCHING-enabled pane via `FakePtyAdapter.pumpActivity` (no text output) and animates an in-place countdown. The duration must outlast `cfg.alert.userAttention` so the bell actually rings rather than being suppressed as "user is looking"; see the comment in `tut-runner.ts`. Falls back to `PANE_BOXED` if no WATCHING pane is known. +- **`s`** (Alerts section) — reports a fake `longtask` on *both* alert demo panes (`tut-boxed`, `tut-splash`), overriding the command their shell is really running, and drives `FakePtyAdapter.pumpActivity` on `tut-boxed` with an in-place countdown. Two panes running one command is what makes `al-spreads` observable: WATCHING is keyed on the command name, so a single bell click lights both (`docs/specs/alert.md`). The pump always targets `tut-boxed` because it is the quiet pane — `tut-splash` animates forever, so it stays `BUSY` and can never reach `ALERT_RINGING`. The fake exit is reported `WATCH_DEMO_COMMAND_MS` later, not when the burst ends: WATCHING rings on *silence from a still-running command*, and reporting the exit early would dispose the monitor before it could ring. Restarting the demo after its countdown finishes cancels that prior delayed exit before starting the new run, so an old completion cannot terminate the new fake command. Afterwards each pane's real command line is put back via `TutorialShell.reportRunningCommand()`, so a pane whose TUI is still drawing never looks idle. +- **`n`** (Alerts section) — writes a raw `OSC 777` notification to `tut-boxed`, exercising the terminal-report track, which needs no WATCHING rule. +- **`x`** (Alerts section) — starts a fake `slowbuild` on `tut-splash` and reports its exit after the same duration. Deliberately an *unwatched* command name, so the command-exit track (rather than WATCHING) owns the bell; the user has to attend the pane and leave it for the ring to arm. - **`p`** (Copy paste section) — toggles the **Place To Paste** scratch modal (`website/src/components/PlaceToPaste.tsx`) via `onTogglePlaceToPaste`. Only wired on desktop; Pocket omits the callback. ### Pocket Copy paste specifics @@ -66,6 +68,14 @@ is all the playground needs. Minimum useful behavior: * Echo typed characters and maintain a command-line buffer; Enter submits, Backspace edits. +* Report shell integration for every command it runs — `OSC 633 ; A/B` around + the prompt, `633 ; E` + `633 ; C` on launch, `633 ; D` on exit (`127` for an + unknown command). This is load-bearing rather than cosmetic: WATCHING is keyed + on the running command's name (`docs/specs/alert.md`), so without it no + playground pane could be alerted on at all — every bell would report "nothing + is running", including the pane hosting the tutorial itself. It also means + playground panes are OSC-driven, so the keystroke fallback in + `docs/specs/terminal-state.md` never engages there. * Up/Down arrows recall command history at the shell prompt; Escape, Tab, and Left/Right are no-ops at the base prompt (full-screen runners like `ascii-splash` give them behavior). * When a fake full-screen app such as `ascii-splash`, `splash`, `changelog`, or `tut` is running, `Ctrl+C` sends `\x03` to that app; if the app exits, the @@ -93,7 +103,8 @@ These exist in `dormouse-lib` (or `MobileTerminalUi`) specifically so the browse - **`WallEvent.kill` / `WallEvent.move` / `WallEvent.paneAdded`** — discriminants on the `WallEvent` union (`lib/src/components/wall/wall-types.ts`); `kill` fires from `acceptKill`, `move` from `handle-pane-shortcuts.ts` after the Cmd/Ctrl-Arrow swap. `paneAdded` fires once per pane that becomes visible (seed ids, splits, dor surfaces, restores, auto-spawn) via the Lath store-subscription leaf-id diff (seed ids announced explicitly so they are emitted too) — so the page can create a fake shell for each pane without touching the tiling engine. - **`FakePtyAdapter.pumpActivity(id, durationMs, intervalMs)`** — drives the alert manager for a fixed duration with no data output (used by the `s` busy demo). -- **`FakePtyAdapter.sendOutput(id, data)`** — pushes data through the data handlers as if the PTY produced it, also driving `alertManager.onData()`. +- **`FakePtyAdapter.sendOutput(id, data)`** — pushes data through the real protocol parser as if the PTY produced it, driving `alertManager.onData()` for visible bytes and the notification/semantic-event paths for OSCs. This is what lets the alert demos fake shell integration and a program-sent notification without a real shell. Unlike `writePty`, it is not suppressed while a scenario is playing. +- **`subscribeToWatchedCommands` / `getWatchedCommands`** (`lib/src/lib/watched-commands.ts`, re-exported from `terminal-registry`) — the WATCHING rule set, which `TutDetector` watches to credit `al-watch-cmd`. - **`MobileTerminalUi.onGestureInput(input, data)`** — optional callback fired only for radial-menu actions, so Pocket credits gesture items without mistaking native keyboard input for gestures. `SCENARIO_TUTORIAL_MOTD` was removed — the runner owns the main pane's screen. diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 1c8539e5..afbbc6dc 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -70,6 +70,7 @@ Universal PTY/transport invariants live in `docs/specs/transport.md`. The rules - **Save before kill.** `deactivate()` must save session state *before* killing PTYs. CWD and scrollback queries need live processes. See ordering in `extension.ts:deactivate()`. - **Alert state is global.** A single `AlertManager` instance in `message-router.ts` is shared across all routers and survives router disposal. PTY data feeds into it at module level, regardless of webview visibility. +- **WATCHING rules are host-authoritative.** The first webview seeds the shared host rule set after extension-host startup. Later webviews cannot replace it: rule edits arrive as per-command mutations, and `WatchedCommandHost` broadcasts the resulting canonical snapshot to every renderer so their dialogs and persisted mirrors stay synchronized. - **PTY ownership tracking.** Each router tracks its PTYs in `ownedPtyIds`. A module-level `globalOwnedPtyIds` set prevents a resuming router from stealing PTYs owned by another webview. - **mergeAlertStates on every save path.** Both the frontend periodic save (`onSaveState` callback) and the backend deactivate refresh (`refreshSavedSessionStateFromPtys`) must merge current alert states. Missing this causes alert state to revert on restore. - **retainContextWhenHidden.** Set on both `WebviewPanel` (editor tabs) and `WebviewView` (bottom panel) so that xterm.js DOM, scrollback, and PTY subscriptions survive panel hide/show without going through a resume. @@ -125,6 +126,16 @@ The two hosting primitives expose different chrome, so each uses what it support Reflection updates on every owned-PTY `AlertManager.onStateChange` and on `claim` / `release` (a webview gaining or losing a PTY). Source of truth: `attachRouter` `onUnion` / `notifyUnion` in `message-router.ts`; `extension.ts` (panel title), `webview-view-provider.ts` (view badge), `workspace-chrome.ts` (formatting). +WATCHING rules use a separate host-authoritative synchronization channel because +they are app-global rather than owned by one Workspace. Each webview offers its +persisted rules through `alert:initializeWatchedCommands`; only the first offer +after extension-host startup seeds the shared manager. Subsequent +`alert:setCommandWatched` messages mutate one key, and every attached renderer +receives `alert:watchedCommands` with the canonical full snapshot. Source of +truth: `WatchedCommandHost` in +`lib/src/lib/watched-command-host.ts` and the alert cases in +`vscode-ext/src/message-router.ts`. + ### Shell selection The VS Code view title contributes `Dormouse: Select Shell` and `Dormouse: New Terminal`. The selected shell name is mirrored into the `WebviewView.description`, and `dormouse:selectedShell` keeps the webview's default-shell slot current for split/spawn/restore paths. diff --git a/lib/.storybook/preview.ts b/lib/.storybook/preview.ts index 110eea4c..9cec1fcd 100644 --- a/lib/.storybook/preview.ts +++ b/lib/.storybook/preview.ts @@ -9,9 +9,11 @@ import { disposeAllSessions, getActivitySnapshot, getTerminalPaneStateSnapshot, + getWatchedCommands, primeActivity, removeTerminalPaneState, resetTerminalPaneState, + setCommandWatched, type ActivityState, type TerminalPaneState, } from '../src/lib/terminal-registry'; @@ -56,6 +58,16 @@ const DEFAULT_STORYBOOK_THEME = VSCODE_THEMES[PREFERRED_STORYBOOK_THEME] ? PREFERRED_STORYBOOK_THEME : FIRST_STORYBOOK_THEME; +/** + * Replace the WATCHING rule set (`docs/specs/alert.md`). The set is app-global + * and persists to localStorage, so a story that sets a rule would otherwise leak + * it into every story that runs after it — always clear before applying. + */ +function applyWatchedCommands(names: readonly string[]): void { + for (const existing of getWatchedCommands()) setCommandWatched(existing, false); + for (const name of names) setCommandWatched(name, true); +} + function setStylePropertyIfChanged( style: CSSStyleDeclaration, name: string, @@ -178,6 +190,9 @@ const preview: Preview = { byId?: Record<string, Partial<TerminalPaneState>>; } | undefined; + const primedWatchedCommands = context.parameters?.primedWatchedCommands as + | readonly string[] + | undefined; const platform = fakePlatform as FakePtyAdapter; if (scenario) platform.setDefaultScenario(scenario); @@ -187,6 +202,7 @@ const preview: Preview = { let raf2 = 0; const applyPrimedState = () => { + applyWatchedCommands(primedWatchedCommands ?? []); clearPrimedActivity(); for (const id of getTerminalPaneStateSnapshot().keys()) { removeTerminalPaneState(id); @@ -216,6 +232,7 @@ const preview: Preview = { return () => { window.cancelAnimationFrame(raf1); window.cancelAnimationFrame(raf2); + applyWatchedCommands([]); clearPrimedActivity(); for (const id of getTerminalPaneStateSnapshot().keys()) { removeTerminalPaneState(id); @@ -223,7 +240,7 @@ const preview: Preview = { platform.clearDefaultScenario(); disposeAllSessions(); }; - }, [platform, primedSessionState, primedTerminalState]); + }, [platform, primedSessionState, primedTerminalState, primedWatchedCommands]); return createElement(Story); }, diff --git a/lib/src/components/TodoAlertDialog.tsx b/lib/src/components/TodoAlertDialog.tsx index 178b44f6..cb248153 100644 --- a/lib/src/components/TodoAlertDialog.tsx +++ b/lib/src/components/TodoAlertDialog.tsx @@ -7,13 +7,17 @@ import { clampOverlayPosition, pointInConvexPolygon } from '../lib/ui-geometry'; import { clearSessionTodo, DEFAULT_ACTIVITY_STATE, - disableSessionAlert, dismissOrToggleAlert, getActivity, getActivitySnapshot, + getRunningCommandArgv0, + getTerminalPaneStateSnapshot, + getWatchedCommandsSnapshot, markSessionTodo, + setCommandWatched, subscribeToActivity, - toggleSessionAlert, + subscribeToTerminalPaneState, + subscribeToWatchedCommands, toggleSessionTodo, } from '../lib/terminal-registry'; @@ -30,6 +34,11 @@ export function TodoAlertDialog({ }) { const activityStates = useSyncExternalStore(subscribeToActivity, getActivitySnapshot); const activity = activityStates.get(sessionId) ?? DEFAULT_ACTIVITY_STATE; + const watched = useSyncExternalStore(subscribeToWatchedCommands, getWatchedCommandsSnapshot); + // WATCHING is a rule on the running command, so the switch is only meaningful + // while something is running (`docs/specs/alert.md`). + useSyncExternalStore(subscribeToTerminalPaneState, getTerminalPaneStateSnapshot); + const argv0 = getRunningCommandArgv0(sessionId); const dialogRef = useRef<HTMLDivElement>(null); const [position, setPosition] = useState<CSSProperties>({ left: triggerRect.left, @@ -132,7 +141,9 @@ export function TodoAlertDialog({ <XIcon size={12} weight="bold" /> </button> - <div className="mb-3 grid w-fit grid-cols-[auto_auto_auto] items-center gap-x-2 gap-y-2"> + {/* pr-6 keeps the right-hand switches clear of the absolutely-positioned + close button, whatever the rows end up containing. */} + <div className="mb-3 grid w-fit grid-cols-[auto_auto_auto] items-center gap-x-2 gap-y-2 pr-6"> <Shortcut>t</Shortcut> <span className="text-sm font-medium text-foreground">TODO</span> <OnOffSwitch @@ -142,16 +153,55 @@ export function TodoAlertDialog({ label="TODO" /> - <Shortcut>a</Shortcut> - <span className="text-sm font-medium text-foreground">WATCHING</span> - <OnOffSwitch - on={activity.watchingEnabled} - onEnable={() => toggleSessionAlert(sessionId)} - onDisable={() => disableSessionAlert(sessionId)} - label="WATCHING" - /> + {argv0 && ( + <> + <Shortcut>a</Shortcut> + {/* argv0 is a basename so it is normally short, but nothing enforces + that — cap it so a pathological name truncates instead of + stretching the dialog. The full name stays readable in the rule + list below and in the title attribute. */} + <span className="flex min-w-0 items-baseline gap-1.5 text-sm font-medium text-foreground"> + <span className="shrink-0">Alert on all</span> + <span className="min-w-0 max-w-48 truncate font-mono" title={argv0}>"{argv0}"</span> + </span> + <OnOffSwitch + on={watched.includes(argv0)} + onEnable={() => setCommandWatched(argv0, true)} + onDisable={() => setCommandWatched(argv0, false)} + label={`Alert on all ${argv0}`} + /> + </> + )} </div> + {!argv0 && ( + <div className="mb-3 max-w-72 border-t border-border pt-2 text-sm leading-relaxed text-muted"> + Nothing is running in this tab. Alerts are enabled per command — start + something, then press <Shortcut>a</Shortcut> to alert on every tab running it. + </div> + )} + + {watched.length > 0 && ( + <div className="mb-3 max-w-72 border-t border-border pt-2"> + <div className="mb-1 text-sm font-medium text-muted">Alerting on</div> + <ul className="flex flex-col gap-0.5"> + {watched.map((name) => ( + <li key={name} className="flex items-center justify-between gap-3"> + <span className="min-w-0 truncate font-mono text-sm text-foreground">{name}</span> + <button + type="button" + aria-label={`Stop alerting on all ${name}`} + className="shrink-0 rounded p-0.5 text-muted hover:bg-foreground/10 hover:text-foreground" + onClick={() => setCommandWatched(name, false)} + > + <XIcon size={12} weight="bold" /> + </button> + </li> + ))} + </ul> + </div> + )} + {activity.notification && ( <div className="mb-3 max-w-80 border-t border-border pt-2 text-sm leading-relaxed text-foreground"> {activity.notification.title && ( diff --git a/lib/src/components/wall/TerminalPaneHeader.tsx b/lib/src/components/wall/TerminalPaneHeader.tsx index 6f971218..76da07a5 100644 --- a/lib/src/components/wall/TerminalPaneHeader.tsx +++ b/lib/src/components/wall/TerminalPaneHeader.tsx @@ -37,6 +37,7 @@ import { } from '../../lib/terminal-registry'; import { buildAppTitleResolver, + commandArgv0, createTerminalPaneState, COMMAND_FAIL_GLYPH, deriveHeader, @@ -67,17 +68,17 @@ const tabVariant = tv({ type HeaderTier = 'full' | 'compact' | 'minimal'; -const ALERT_BUTTON_ENABLED = { aria: 'Disable watching', tooltip: '[a] Disable WATCHING' }; -const ALERT_BUTTON_LABELS: Record<SessionStatus, { aria: string; tooltip: string }> = { - 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' }, -}; +// WATCHING is a rule on the running command, so the bell says which command it +// would act on rather than naming an abstract toggle (`docs/specs/alert.md`). +function alertButtonLabelsFor(status: SessionStatus, argv0: string | null): { aria: string; tooltip: string } { + if (status === 'ALERT_RINGING') return { aria: 'Alert ringing', tooltip: 'Alert ringing' }; + if (status === 'OSC_NOTIF_BUSY') return { aria: 'Progress active', tooltip: 'Progress active' }; + if (status === 'COMMAND_EXIT_ARMED') return { aria: 'Command running', tooltip: 'Command running' }; + if (!argv0) return { aria: 'Alerts are per command', tooltip: '[a] Alerts are per command' }; + return status === 'WATCHING_DISABLED' + ? { aria: `Alert on all ${argv0}`, tooltip: `[a] Alert on all "${argv0}"` } + : { aria: `Stop alerting on all ${argv0}`, tooltip: `[a] Stop alerting on all "${argv0}"` }; +} const TODO_PREVIEW_GAP = 6; const TODO_PREVIEW_MARGIN = 8; @@ -132,7 +133,10 @@ export function TerminalPaneHeader({ id, title }: PaneProps) { const todoPill = useTodoPillContent(activity.todo); const titleCandidates = useMemo(() => titleCandidatesForDisplay(paneState), [paneState]); const showTodoPill = todoPill.visible && tier !== 'minimal'; - const alertButtonLabels = ALERT_BUTTON_LABELS[activity.status]; + const runningArgv0 = paneState.currentCommand?.rawCommandLine + ? commandArgv0(paneState.currentCommand.rawCommandLine) + : null; + const alertButtonLabels = alertButtonLabelsFor(activity.status, runningArgv0); const alertButtonAriaLabel = alertButtonLabels.aria; const alertButtonTooltip = alertButtonLabels.tooltip; const alertButtonTooltipDetail = activity.status === 'ALERT_RINGING' @@ -161,7 +165,9 @@ export function TerminalPaneHeader({ id, title }: PaneProps) { const triggerAlertButtonAction = useCallback((displayedStatus: SessionStatus, button: HTMLButtonElement) => { const result = actions.onAlertButton(id, displayedStatus); - if (result === 'dismissed' || result === 'menu') { + // 'no-command' opens the dialog too — it is where we explain that alerts are + // keyed on the running command and there is nothing running here. + if (result === 'dismissed' || result === 'menu' || result === 'no-command') { setDialogTriggerRect(button.getBoundingClientRect()); } }, [actions, id]); diff --git a/lib/src/lib/alert-manager.test.ts b/lib/src/lib/alert-manager.test.ts index 259ca7f4..f6465300 100644 --- a/lib/src/lib/alert-manager.test.ts +++ b/lib/src/lib/alert-manager.test.ts @@ -18,9 +18,21 @@ describe('AlertManager in isolation', () => { // Timing from cfg.alert: // busyCandidateGap=1500, busyConfirmGap=500, mightNeedAttention=2000, needsAttentionConfirm=3000 + /** + * WATCHING is keyed on the foreground command's name, so the only way to turn + * it on is to run a watched command (`docs/specs/alert.md`). + */ + function runWatchedCommand(id: string, commandLine = 'longtask'): void { + manager.setWatchedCommands(['longtask']); + manager.applyTerminalSemanticEvents(id, [ + { type: 'commandLine', commandLine }, + { type: 'commandStart', source: 'osc633_E', startedAt: Date.now() }, + ]); + } + it('state machine advances through silence to ALERT_RINGING', () => { const id = 'test-pty'; - manager.toggleAlert(id); + runWatchedCommand(id); expect(manager.getState(id).status).toBe('NOTHING_TO_SHOW'); // Simulate sustained output over 2 seconds @@ -49,7 +61,7 @@ describe('AlertManager in isolation', () => { it('reproduces the exact user scenario: alert set, 5s task, collapse after 2s, wait 60s', () => { const id = 'user-scenario'; - manager.toggleAlert(id); + runWatchedCommand(id); manager.clearAttention(id); for (let t = 0; t < 5_000; t += 200) { @@ -65,7 +77,7 @@ describe('AlertManager in isolation', () => { it('ALERT_RINGING latches when user has no attention (view hidden)', () => { const id = 'latch-test'; - manager.toggleAlert(id); + runWatchedCommand(id); manager.clearAttention(id); manager.onData(id); @@ -94,7 +106,7 @@ describe('AlertManager in isolation', () => { it('ALERT_RINGING resets on data when user has attention', () => { const id = 'reset-test'; - manager.toggleAlert(id); + runWatchedCommand(id); manager.attend(id); manager.onData(id); @@ -119,7 +131,7 @@ describe('AlertManager in isolation', () => { if (_id === id) states.push(state.status); }); - manager.toggleAlert(id); + runWatchedCommand(id); manager.clearAttention(id); manager.onData(id); @@ -140,7 +152,7 @@ describe('AlertManager in isolation', () => { // recovery timers — were removed when TODO was simplified to a plain boolean.) function driveToRinging(id: string): void { - manager.toggleAlert(id); + runWatchedCommand(id); manager.clearAttention(id); manager.onData(id); vi.advanceTimersByTime(1_600); @@ -244,36 +256,24 @@ describe('AlertManager in isolation', () => { }); }); - it('opens the bell menu for protocol progress when WATCHING is disabled', () => { - const id = 'osc-progress-menu'; + it('dropping the rule disposes only the monitor, leaving protocol progress armed', () => { + const id = 'osc-progress-drop-rule'; - 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); + runWatchedCommand(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'); + manager.setWatchedCommands([]); expect(manager.getState(id)).toMatchObject({ status: 'OSC_NOTIF_BUSY', watchingEnabled: false, }); }); - it('opens the dialog after an attention-dismissed ring even when WATCHING is disabled', () => { + it('attending a ring leaves attentionDismissedRing for the bell table to consume', () => { const id = 'attention-dismissed-watching-disabled'; // A protocol ring needs no WATCHING; attending it dismisses the ring and @@ -284,15 +284,33 @@ describe('AlertManager in isolation', () => { expect(manager.getState(id)).toMatchObject({ status: 'WATCHING_DISABLED', todo: true, + attentionDismissedRing: true, }); - // The next bell click must open the dialog, not silently enable WATCHING. - expect(manager.dismissOrToggleAlert(id, 'WATCHING_DISABLED')).toBe('dismissed'); - expect(manager.getState(id).watchingEnabled).toBe(false); + // An explicit dismiss is the click that consumes the flag. + manager.dismissAlert(id); + expect(manager.getState(id).attentionDismissedRing).toBe(false); + }); - // Flag consumed: the click after that enables WATCHING as usual. - expect(manager.dismissOrToggleAlert(id, 'WATCHING_DISABLED')).toBe('enabled'); - expect(manager.getState(id).watchingEnabled).toBe(true); + it('keeps attentionDismissedRing when a watched command starts before bell dismissal', () => { + const id = 'attention-dismissed-then-watched-command'; + manager.setWatchedCommands(['claude']); + manager.notifyFromProtocol(id, { source: 'OSC 9', title: null, body: 'Build finished' }); + manager.attend(id); + + manager.applyTerminalSemanticEvents(id, [ + { type: 'commandLine', commandLine: 'claude --resume' }, + { type: 'commandStart', source: 'osc633_E', startedAt: Date.now() }, + ]); + + expect(manager.getState(id)).toMatchObject({ + watchingEnabled: true, + todo: true, + attentionDismissedRing: true, + }); + + manager.dismissAlert(id); + expect(manager.getState(id).attentionDismissedRing).toBe(false); }); it('protocol completion is suppressed while the user has attention', () => { @@ -457,6 +475,139 @@ describe('AlertManager in isolation', () => { }); }); + // --- Command-keyed WATCHING --- + + it('turns WATCHING on for a watched command and off again when it finishes', () => { + const id = 'rule-lifecycle'; + manager.setWatchedCommands(['claude']); + expect(manager.getState(id).watchingEnabled).toBe(false); + + manager.applyTerminalSemanticEvents(id, [ + { type: 'commandLine', commandLine: 'claude --print hello' }, + { type: 'commandStart', source: 'osc633_E', startedAt: Date.now() }, + ]); + expect(manager.getState(id).watchingEnabled).toBe(true); + + manager.applyTerminalSemanticEvents(id, [{ type: 'commandFinish', exitCode: 0 }]); + expect(manager.getState(id).watchingEnabled).toBe(false); + }); + + it('matches on the bare program name, not the whole command line', () => { + const id = 'rule-argv0'; + manager.setWatchedCommands(['claude']); + + manager.applyTerminalSemanticEvents(id, [ + { type: 'commandLine', commandLine: 'FOO=1 env BAR=2 /usr/local/bin/claude --resume' }, + { type: 'commandStart', source: 'osc633_E', startedAt: Date.now() }, + ]); + expect(manager.getState(id).watchingEnabled).toBe(true); + }); + + it('leaves an unwatched command alone', () => { + const id = 'rule-miss'; + manager.setWatchedCommands(['claude']); + + manager.applyTerminalSemanticEvents(id, [ + { type: 'commandLine', commandLine: 'git status' }, + { type: 'commandStart', source: 'osc633_E', startedAt: Date.now() }, + ]); + expect(manager.getState(id).watchingEnabled).toBe(false); + }); + + it('turns WATCHING off at the prompt even without a finish event', () => { + const id = 'rule-prompt'; + runWatchedCommand(id); + expect(manager.getState(id).watchingEnabled).toBe(true); + + manager.applyTerminalSemanticEvents(id, [{ type: 'promptStart' }]); + expect(manager.getState(id).watchingEnabled).toBe(false); + }); + + it('applies a newly added rule to every session already running that command', () => { + const a = 'rule-live-a'; + const b = 'rule-live-b'; + for (const id of [a, b]) { + manager.applyTerminalSemanticEvents(id, [ + { type: 'commandLine', commandLine: 'claude' }, + { type: 'commandStart', source: 'osc633_E', startedAt: Date.now() }, + ]); + } + expect(manager.getState(a).watchingEnabled).toBe(false); + expect(manager.getState(b).watchingEnabled).toBe(false); + + manager.setWatchedCommands(['claude']); + expect(manager.getState(a).watchingEnabled).toBe(true); + expect(manager.getState(b).watchingEnabled).toBe(true); + + manager.setWatchedCommands([]); + expect(manager.getState(a).watchingEnabled).toBe(false); + expect(manager.getState(b).watchingEnabled).toBe(false); + }); + + it('keeps a WATCHING ring after the watched command exits and takes watching with it', () => { + const id = 'ring-outlives-monitor'; + driveToRinging(id); + + // The command exiting disposes the monitor; the ring it already raised is + // the whole point of watching, so it has to survive. + manager.applyTerminalSemanticEvents(id, [{ type: 'commandFinish', exitCode: 0 }]); + expect(manager.getState(id)).toMatchObject({ + status: 'ALERT_RINGING', + watchingEnabled: false, + }); + + manager.dismissAlert(id); + expect(manager.getState(id)).toMatchObject({ + status: 'WATCHING_DISABLED', + todo: true, + }); + }); + + it('silences a WATCHING ring when the rule is explicitly removed', () => { + const id = 'ring-dies-with-rule'; + driveToRinging(id); + + // Unlike the command ending, dropping the rule is the user saying "stop + // alerting on this" — the ring goes with it. + manager.setWatchedCommands([]); + expect(manager.getState(id)).toMatchObject({ + status: 'WATCHING_DISABLED', + watchingEnabled: false, + todo: false, + }); + }); + + it('silences a latched WATCHING ring when its rule is removed after command exit', () => { + const id = 'exited-ring-dies-with-rule'; + driveToRinging(id); + manager.applyTerminalSemanticEvents(id, [{ type: 'commandFinish', exitCode: 0 }]); + + expect(manager.getState(id)).toMatchObject({ + status: 'ALERT_RINGING', + watchingEnabled: false, + }); + + manager.setWatchedCommands([]); + expect(manager.getState(id)).toMatchObject({ + status: 'WATCHING_DISABLED', + watchingEnabled: false, + todo: false, + }); + }); + + it('keeps the command-exit arm hidden while WATCHING owns the display', () => { + const id = 'arm-under-watching'; + runWatchedCommand(id); + manager.attend(id); + manager.clearAttention(id); + + // Armed underneath, but the monitor's own state is what the bell shows. + expect(manager.getState(id).status).toBe('NOTHING_TO_SHOW'); + + manager.setWatchedCommands([]); + expect(manager.getState(id).status).toBe('COMMAND_EXIT_ARMED'); + }); + it('preserves richer protocol detail when protocol and command exit both ring', () => { const id = 'command-exit-protocol-wins'; diff --git a/lib/src/lib/alert-manager.ts b/lib/src/lib/alert-manager.ts index faaa3f59..62ee1b24 100644 --- a/lib/src/lib/alert-manager.ts +++ b/lib/src/lib/alert-manager.ts @@ -1,8 +1,9 @@ import { ActivityMonitor, type SessionStatus } from './activity-monitor'; import { cfg } from '../cfg'; import { + commandArgv0, + resolveCommandStart, DEFAULT_COMMAND_TITLE, - summarizeCommandLine, type CommandRunSource, type TerminalSemanticEvent, } from './terminal-state'; @@ -39,6 +40,8 @@ interface ActiveProtocolProgress { interface CommandExitWatch { displayCommand: string; + /** Bare program name the WATCHING rule set is keyed on; null without shell integration. */ + argv0: string | null; source: CommandRunSource; startedAt: number; seenWithAttentionAt: number | null; @@ -66,14 +69,12 @@ function normalizeNotificationTextField(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } -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 */ + /** Used by the bell transition table to detect a post-attention dismiss */ attentionDismissedRing: boolean; } @@ -85,8 +86,18 @@ export const DEFAULT_ALERT_STATE: AlertState = { attentionDismissedRing: false, }; +/** + * Three independent alarm tracks per Session, unioned into one public status. + * Each track is IDLE -> (a busy/armed state) -> ringing, and each ring latches + * in the entry until it is attended, dismissed, or TODO'd. WATCHING's ring lives + * here rather than in its `ActivityMonitor` precisely so it can outlive the + * monitor: watching turns off the moment the watched command exits, which is + * often the same moment the ring is raised. + */ interface AlertEntry { monitor: ActivityMonitor | null; + /** Command rule that raised the latched WATCHING ring, even after command exit. */ + watchingRingingCommand: string | null; protocolStatus: ProtocolStatus; progress: ActiveProtocolProgress | null; commandExitStatus: CommandExitStatus; @@ -111,6 +122,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 watchedCommands = new Set<string>(); // --- State change subscription --- @@ -137,6 +149,92 @@ export class AlertManager { entry?.monitor?.onResize(); } + // --- WATCHING rule set --- + + /** + * Replace the set of command names WATCHING applies to (`docs/specs/alert.md`). + * Pushed from the renderer, which owns the persisted copy — the extension host + * has no `localStorage` of its own. + */ + setWatchedCommands(names: string[]): void { + const next = new Set(names); + if (next.size === this.watchedCommands.size && [...next].every((name) => this.watchedCommands.has(name))) return; + this.watchedCommands = next; + for (const [id, entry] of this.entries) { + let changed = this.applyWatchingRule(id, entry); + // Dropping a rule is an explicit "stop alerting on this", so it also + // silences the ring that rule already raised. The originating key stays + // latched after command exit precisely so this still works at a prompt. + if ( + entry.watchingRingingCommand !== null + && !this.watchedCommands.has(entry.watchingRingingCommand) + ) { + entry.watchingRingingCommand = null; + changed = true; + } + if (changed) this.notify(id); + } + } + + /** Apply one command-rule mutation without replacing unrelated rules. */ + setCommandWatched(name: string, watched: boolean): void { + const trimmed = name.trim(); + if (!trimmed || this.watchedCommands.has(trimmed) === watched) return; + const next = new Set(this.watchedCommands); + if (watched) next.add(trimmed); + else next.delete(trimmed); + this.setWatchedCommands([...next]); + } + + /** Sorted snapshot used by hosts that mirror the rule set to renderers. */ + getWatchedCommands(): string[] { + return [...this.watchedCommands].sort(); + } + + /** + * WATCHING follows the foreground command's name: on while a watched command + * runs, off at the prompt. Returns whether the monitor changed. + */ + private applyWatchingRule(id: string, entry: AlertEntry): boolean { + const argv0 = entry.commandExitWatch?.argv0 ?? null; + return this.setWatching(id, entry, argv0 !== null && this.watchedCommands.has(argv0)); + } + + private setWatching(id: string, entry: AlertEntry, enabled: boolean): boolean { + if (enabled === !!entry.monitor) return false; + if (enabled) { + entry.monitor = this.createMonitor(id); + } else { + // The latched WATCHING ring deliberately survives: watching switches off + // the moment the watched command exits. Only removing its command rule + // clears it. + entry.monitor?.dispose(); + entry.monitor = null; + } + return true; + } + + private createMonitor(id: string): ActivityMonitor { + return new ActivityMonitor({ + hasAttention: () => this.hasAttention(id), + onChange: (status) => { + const entry = this.entries.get(id); + if (!entry) return; + + if (status === 'ALERT_RINGING') { + // The user is looking right at it — suppress by resetting the monitor. + if (this.hasAttention(id)) { + entry.monitor?.attend(); + return; + } + entry.watchingRingingCommand = entry.commandExitWatch?.argv0 ?? null; + } + + this.notify(id); + }, + }); + } + // --- Terminal-report protocol track --- notifyFromProtocol(id: string, notification: ActivityNotification): void { @@ -173,7 +271,6 @@ export class AlertManager { if ( entry.protocolStatus === 'OSC_NOTIF_BUSY' - && !entry.attentionDismissedRing && entry.progress?.state === progress.state && entry.progress?.percent === progress.percent ) { @@ -182,7 +279,6 @@ export class AlertManager { entry.progress = { state: progress.state, percent: progress.percent }; entry.protocolStatus = 'OSC_NOTIF_BUSY'; - entry.attentionDismissedRing = false; this.notify(id); } @@ -198,7 +294,8 @@ export class AlertManager { percent: number | null, ): void { if (this.hasAttention(id)) { - this.clearProtocolProgress(entry); + entry.protocolStatus = 'IDLE'; + entry.progress = null; this.notify(id); return; } @@ -214,24 +311,9 @@ export class AlertManager { entry.todo = true; entry.protocolStatus = 'ALERT_RINGING'; entry.progress = null; - entry.attentionDismissedRing = false; this.notify(id); } - private clearProtocolRingIfActive(entry: AlertEntry): boolean { - if (entry.protocolStatus !== 'ALERT_RINGING') return false; - entry.protocolStatus = 'IDLE'; - entry.progress = null; - return true; - } - - private clearProtocolProgress(entry: AlertEntry): boolean { - if (entry.protocolStatus === 'IDLE' && entry.progress === null) return false; - entry.protocolStatus = 'IDLE'; - entry.progress = null; - return true; - } - // --- Command-exit track --- applyTerminalSemanticEvents(id: string, events: TerminalSemanticEvent[]): void { @@ -240,30 +322,29 @@ export class AlertManager { let changed = false; for (const event of events) { - if (event.type === 'commandLine') { - if (entry.pendingCommandLine !== event.commandLine) { - entry.pendingCommandLine = event.commandLine; + switch (event.type) { + case 'commandLine': + if (entry.pendingCommandLine !== event.commandLine) { + entry.pendingCommandLine = event.commandLine; + changed = true; + } + break; + case 'commandStart': + this.startCommandExitWatch(id, entry, event); 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; - } + break; + case 'commandFinish': + changed = this.finishCommandExitWatch(id, entry, event.exitCode) || changed; + break; + case 'promptStart': + case 'promptEnd': + // A prompt means nothing is in the foreground any more, so WATCHING + // stops here even if the shell never sent a finish event. + if (entry.pendingCommandLine !== null || entry.commandExitWatch !== null) { + this.finishCommandExitWatch(id, entry, undefined); + changed = true; + } + break; } } @@ -275,19 +356,17 @@ export class AlertManager { entry: AlertEntry, event: Extract<TerminalSemanticEvent, { type: 'commandStart' }>, ): void { - const raw = entry.pendingCommandLine; - 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'; + const resolved = resolveCommandStart(entry.pendingCommandLine, event); entry.pendingCommandLine = null; if (entry.commandExitStatus !== 'ALERT_RINGING') entry.commandExitStatus = 'IDLE'; entry.commandExitWatch = { - displayCommand: raw ? summarizeCommandLine(raw) : DEFAULT_COMMAND_TITLE, - source, - startedAt: event.startedAt ?? Date.now(), + displayCommand: resolved.displayCommand, + argv0: resolved.rawCommandLine === null ? null : commandArgv0(resolved.rawCommandLine), + source: resolved.source, + startedAt: resolved.startedAt, seenWithAttentionAt: this.hasAttention(id) ? Date.now() : null, }; + this.applyWatchingRule(id, entry); } private finishCommandExitWatch( @@ -298,6 +377,7 @@ export class AlertManager { const watch = entry.commandExitWatch; entry.commandExitWatch = null; entry.pendingCommandLine = null; + this.applyWatchingRule(id, entry); const wasArmed = entry.commandExitStatus === 'COMMAND_EXIT_ARMED'; if (entry.commandExitStatus !== 'ALERT_RINGING') { @@ -307,8 +387,7 @@ export class AlertManager { if (!watch || !wasArmed) return wasArmed; if (this.hasAttention(id)) return true; - const finishedAt = Date.now(); - if (finishedAt - watch.startedAt < T_USER_ATTENTION) return true; + if (Date.now() - watch.startedAt < T_USER_ATTENTION) return true; this.setCommandExitRinging(id, entry, watch, exitCode); return true; @@ -327,7 +406,6 @@ export class AlertManager { if (entry.commandExitStatus !== 'IDLE') return false; if (entry.commandExitWatch.seenWithAttentionAt === null) return false; entry.commandExitStatus = 'COMMAND_EXIT_ARMED'; - entry.attentionDismissedRing = false; return true; } @@ -339,6 +417,7 @@ export class AlertManager { ): void { entry.commandExitStatus = 'ALERT_RINGING'; entry.todo = true; + // A protocol ring carries richer text; never overwrite it with the generic one. if (entry.protocolStatus !== 'ALERT_RINGING') { entry.notification = { source: 'COMMAND_EXIT', @@ -346,20 +425,29 @@ export class AlertManager { 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; - } - + /** Release every latched ring across the three tracks. Returns whether any was active. */ private clearAllRingsIfActive(entry: AlertEntry): boolean { - const p = this.clearProtocolRingIfActive(entry); - const c = this.clearCommandExitRingIfActive(entry); - return p || c; + let cleared = false; + if (entry.protocolStatus === 'ALERT_RINGING') { + entry.protocolStatus = 'IDLE'; + entry.progress = null; + cleared = true; + } + if (entry.commandExitStatus === 'ALERT_RINGING') { + entry.commandExitStatus = 'IDLE'; + cleared = true; + } + if (entry.watchingRingingCommand !== null) { + entry.watchingRingingCommand = null; + cleared = true; + } + // A live monitor still latched at ALERT_RINGING would re-ring on its next + // output, so release it too. + if (entry.monitor?.getStatus() === 'ALERT_RINGING') entry.monitor.attend(); + return cleared; } // --- Attention tracking --- @@ -393,17 +481,12 @@ export class AlertManager { }, T_USER_ATTENTION); } - /** - * Mark that the user is paying attention to this session. - * Equivalent to the old markSessionAttention. - */ + /** Mark that the user is paying attention to this session. */ attend(id: string): void { const entry = this.getOrCreateEntry(id); - const watchingWasRinging = entry.monitor?.getStatus() === 'ALERT_RINGING'; this.setAttention(id); - const dismissed = this.clearAllRingsIfActive(entry) || watchingWasRinging; - if (dismissed) { + if (this.clearAllRingsIfActive(entry)) { entry.attentionDismissedRing = true; entry.todo = true; } @@ -422,147 +505,37 @@ export class AlertManager { } } - // --- Monitor lifecycle --- - - private createMonitor(id: string): ActivityMonitor { - return new ActivityMonitor({ - hasAttention: () => this.hasAttention(id), - onChange: (_status) => { - const entry = this.entries.get(id); - if (!entry) return; - - // If the session has attention when it would ring, suppress by resetting - if (_status === 'ALERT_RINGING' && this.hasAttention(id)) { - entry.monitor?.attend(); - return; - } - - this.notify(id); - }, - }); - } - // --- Alert controls --- - toggleAlert(id: string): void { - const entry = this.getOrCreateEntry(id); - if (entry.monitor) { - entry.monitor.dispose(); - entry.monitor = null; - } else { - entry.monitor = this.createMonitor(id); - } - entry.attentionDismissedRing = false; - this.notify(id); - } - - disableAlert(id: string): void { - const entry = this.entries.get(id); - if (!entry?.monitor) return; - entry.monitor.dispose(); - entry.monitor = null; - entry.attentionDismissedRing = false; - this.notify(id); - } - dismissAlert(id: string): void { const entry = this.entries.get(id); if (!entry) return; const dismissed = this.clearAllRingsIfActive(entry); if (dismissed) entry.todo = true; + // The flag exists so the next bell click opens the dialog instead of + // silently changing a rule; an explicit dismiss *is* that next click. + const hadFlag = entry.attentionDismissedRing; + entry.attentionDismissedRing = false; - if (entry.monitor?.getStatus() === 'ALERT_RINGING') { - entry.todo = true; - entry.monitor.attend(); - return; // onChange fires → notify - } - - if (dismissed) this.notify(id); - } - - /** - * Apply the bell-button transition table. - * Returns the action result synchronously. - */ - dismissOrToggleAlert(id: string, displayedStatus: SessionStatus): AlertButtonActionResult { - const entry = this.entries.get(id); - if (!entry) { - this.toggleAlert(id); - return 'enabled'; - } - - switch (displayedStatus) { - case 'WATCHING_DISABLED': - // A protocol/command-exit ring needs no WATCHING, so an attention-based - // dismissal can land here too; the next click must open the dialog - // instead of silently enabling WATCHING (alert.md, attentionDismissedRing). - if (entry.attentionDismissedRing) { - entry.attentionDismissedRing = false; - this.notify(id); - return 'dismissed'; - } - 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 'menu'; - this.disableAlert(id); - return 'disabled'; - default: - if (entry.attentionDismissedRing) { - entry.attentionDismissedRing = false; - this.notify(id); - return 'dismissed'; - } - this.disableAlert(id); - return 'disabled'; - } + if (dismissed || hadFlag) this.notify(id); } // --- Todo controls --- toggleTodo(id: string): void { const entry = this.getOrCreateEntry(id); - const nextTodo = !entry.todo; - entry.todo = nextTodo; - - if (!nextTodo) { - entry.notification = null; - this.clearAllRingsIfActive(entry); - this.notify(id); - return; - } - + entry.todo = !entry.todo; + if (!entry.todo) entry.notification = null; this.clearAllRingsIfActive(entry); - if (entry.monitor?.getStatus() === 'ALERT_RINGING') { - entry.monitor.attend(); - return; // onChange fires → notify - } this.notify(id); } markTodo(id: string): void { const entry = this.getOrCreateEntry(id); - const isWatchingRinging = entry.monitor?.getStatus() === 'ALERT_RINGING'; - const wasProtocolRinging = entry.protocolStatus === 'ALERT_RINGING'; - const wasCommandExitRinging = entry.commandExitStatus === 'ALERT_RINGING'; - if (entry.todo && !wasProtocolRinging && !wasCommandExitRinging && !isWatchingRinging) return; - + const cleared = this.clearAllRingsIfActive(entry); + if (entry.todo && !cleared) return; entry.todo = true; - this.clearAllRingsIfActive(entry); - if (isWatchingRinging) { - entry.monitor!.attend(); - return; // onChange fires → notify - } this.notify(id); } @@ -611,34 +584,21 @@ export class AlertManager { } /** - * Seed alert state from a persisted session (cold-start restore). - * 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 alert state from a persisted session (cold-start restore). Only the + * TODO reminder and its notification detail survive a restart — WATCHING is + * re-derived from the rule set at the next command start, and restore must + * never resurrect a ring or an in-flight progress cycle. */ - seed(id: string, state: { status: string; todo: unknown; notification?: unknown; watchingEnabled?: unknown }): void { + seed(id: string, state: { todo: unknown; notification?: unknown }): void { const entry = this.getOrCreateEntry(id); entry.todo = state.todo === true; entry.notification = entry.todo ? normalizeActivityNotification(state.notification) : null; + entry.watchingRingingCommand = null; entry.protocolStatus = 'IDLE'; entry.progress = null; entry.commandExitStatus = 'IDLE'; entry.commandExitWatch = null; entry.pendingCommandLine = null; - - const watchingEnabled = typeof state.watchingEnabled === 'boolean' - ? state.watchingEnabled - : state.status !== 'WATCHING_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); } @@ -655,13 +615,19 @@ export class AlertManager { // --- Internals --- private getProjectedStatus(entry: AlertEntry): SessionStatus { - const watchingStatus = entry.monitor?.getStatus() ?? 'WATCHING_DISABLED'; - if (entry.protocolStatus === 'ALERT_RINGING') return 'ALERT_RINGING'; - if (entry.commandExitStatus === 'ALERT_RINGING') return 'ALERT_RINGING'; - if (watchingStatus === 'ALERT_RINGING') return 'ALERT_RINGING'; + if ( + entry.protocolStatus === 'ALERT_RINGING' + || entry.commandExitStatus === 'ALERT_RINGING' + || entry.watchingRingingCommand !== null + ) return 'ALERT_RINGING'; if (entry.protocolStatus === 'OSC_NOTIF_BUSY') return 'OSC_NOTIF_BUSY'; + // WATCHING outranks the command-exit arm: a watched command is by + // definition running, so COMMAND_EXIT_ARMED would otherwise mask the + // monitor's busy/quiet states for the entire run. The monitor is derived + // from real output, so it is the more informative of the two. + if (entry.monitor) return entry.monitor.getStatus(); if (entry.commandExitStatus === 'COMMAND_EXIT_ARMED') return 'COMMAND_EXIT_ARMED'; - return watchingStatus; + return 'WATCHING_DISABLED'; } private getOrCreateEntry(id: string): AlertEntry { @@ -669,6 +635,7 @@ export class AlertManager { if (!entry) { entry = { monitor: null, + watchingRingingCommand: null, protocolStatus: 'IDLE', progress: null, commandExitStatus: 'IDLE', diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index 05dad2d4..dbf705ae 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -1,5 +1,5 @@ import type { AlertStateDetail, OpenPort, PlatformAdapter, PtyInfo } from './types'; -import { AlertManager, type SessionStatus } from '../alert-manager'; +import { AlertManager } from '../alert-manager'; import { normalizeExternalUri } from '../external-links'; import { applyTerminalProtocolEvents, @@ -243,10 +243,9 @@ export class FakePtyAdapter implements PlatformAdapter { // Alert management (local AlertManager, same as TauriAdapter) alertRemove(id: string): void { this.alertManager.remove(id); } - alertToggle(id: string): void { this.alertManager.toggleAlert(id); } - alertDisable(id: string): void { this.alertManager.disableAlert(id); } + alertSetWatchedCommands(names: string[]): void { this.alertManager.setWatchedCommands(names); } + alertSetCommandWatched(name: string, watched: boolean): void { this.alertManager.setCommandWatched(name, watched); } alertDismiss(id: string): void { this.alertManager.dismissAlert(id); } - alertDismissOrToggle(id: string, displayedStatus: string): void { this.alertManager.dismissOrToggleAlert(id, displayedStatus as SessionStatus); } alertAttend(id: string): void { this.alertManager.attend(id); } alertResize(id: string): void { this.alertManager.onResize(id); } alertClearAttention(id?: string): void { this.alertManager.clearAttention(id); } @@ -255,6 +254,8 @@ export class FakePtyAdapter implements PlatformAdapter { alertClearTodo(id: string): void { this.alertManager.clearTodo(id); } onAlertState(handler: (detail: AlertStateDetail) => void): void { this.alertStateHandlers.add(handler); } offAlertState(handler: (detail: AlertStateDetail) => void): void { this.alertStateHandlers.delete(handler); } + onWatchedCommands(_handler: (names: string[]) => void): void {} + offWatchedCommands(_handler: (names: string[]) => void): void {} private savedState: unknown = null; saveState(state: unknown): void { this.savedState = state; } diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index de4b1825..d643947c 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -226,10 +226,11 @@ export interface PlatformAdapter { // Alert management alertRemove(id: string): void; - alertToggle(id: string): void; - alertDisable(id: string): void; + /** Offer persisted WATCHING rules as the host's startup seed. */ + alertSetWatchedCommands(names: string[]): void; + /** Mutate one bare-command WATCHING rule without replacing unrelated rules. */ + alertSetCommandWatched(name: string, watched: boolean): void; alertDismiss(id: string): void; - alertDismissOrToggle(id: string, displayedStatus: string): void; alertAttend(id: string): void; alertResize(id: string): void; alertClearAttention(id?: string): void; @@ -238,6 +239,9 @@ export interface PlatformAdapter { alertClearTodo(id: string): void; onAlertState(handler: (detail: AlertStateDetail) => void): void; offAlertState(handler: (detail: AlertStateDetail) => void): void; + /** Receive the host's canonical WATCHING rule snapshot. */ + onWatchedCommands(handler: (names: string[]) => void): void; + offWatchedCommands(handler: (names: string[]) => void): void; // State persistence saveState(state: unknown): void; diff --git a/lib/src/lib/platform/vscode-adapter.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts index 7c3a6b4b..6611c275 100644 --- a/lib/src/lib/platform/vscode-adapter.test.ts +++ b/lib/src/lib/platform/vscode-adapter.test.ts @@ -129,6 +129,35 @@ describe('VSCodeAdapter PTY exit handling', () => { }); }); + it('sends watched-command initialization and mutations as distinct messages', () => { + const adapter = new VSCodeAdapter(); + + adapter.alertSetWatchedCommands(['claude']); + adapter.alertSetCommandWatched('npm', true); + + expect(postMessage).toHaveBeenCalledWith({ + type: 'alert:initializeWatchedCommands', + names: ['claude'], + }); + expect(postMessage).toHaveBeenCalledWith({ + type: 'alert:setCommandWatched', + name: 'npm', + watched: true, + }); + }); + + it('forwards the host canonical watched-command snapshot', () => { + const adapter = new VSCodeAdapter(); + const snapshots: string[][] = []; + adapter.onWatchedCommands((names) => snapshots.push(names)); + + windowTarget.dispatchEvent(new MessageEvent('message', { + data: { type: 'alert:watchedCommands', names: ['claude', 'npm'] }, + })); + + expect(snapshots).toEqual([['claude', 'npm']]); + }); + it('parses replay buffers into semantic events and strips OSCs before forwarding', () => { const adapter = new VSCodeAdapter(); const replays: Array<{ id: string; data: string }> = []; diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index f59991cc..f7c92806 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -21,6 +21,7 @@ export class VSCodeAdapter implements PlatformAdapter { private replayHandlers = new Set<(detail: { id: string; data: string }) => void>(); private flushRequestHandlers = new Set<(detail: { requestId: string }) => void>(); private alertStateHandlers = new Set<(detail: AlertStateDetail) => void>(); + private watchedCommandHandlers = new Set<(names: string[]) => void>(); constructor() { this.vscode = acquireVsCodeApi(); @@ -99,6 +100,10 @@ export class VSCodeAdapter implements PlatformAdapter { attentionDismissedRing: msg.attentionDismissedRing, }); } + } else if (msg.type === 'alert:watchedCommands') { + for (const handler of this.watchedCommandHandlers) { + handler(msg.names); + } } else if (msg.type === 'dormouse:newTerminal') { window.dispatchEvent(new CustomEvent('dormouse:new-terminal', { detail: { @@ -390,22 +395,18 @@ export class VSCodeAdapter implements PlatformAdapter { this.vscode.postMessage({ type: 'alert:remove', id }); } - alertToggle(id: string): void { - this.vscode.postMessage({ type: 'alert:toggle', id }); + alertSetWatchedCommands(names: string[]): void { + this.vscode.postMessage({ type: 'alert:initializeWatchedCommands', names }); } - alertDisable(id: string): void { - this.vscode.postMessage({ type: 'alert:disable', id }); + alertSetCommandWatched(name: string, watched: boolean): void { + this.vscode.postMessage({ type: 'alert:setCommandWatched', name, watched }); } alertDismiss(id: string): void { this.vscode.postMessage({ type: 'alert:dismiss', id }); } - alertDismissOrToggle(id: string, displayedStatus: string): void { - this.vscode.postMessage({ type: 'alert:dismissOrToggle', id, displayedStatus }); - } - alertAttend(id: string): void { this.vscode.postMessage({ type: 'alert:attend', id }); } @@ -438,6 +439,14 @@ export class VSCodeAdapter implements PlatformAdapter { this.alertStateHandlers.delete(handler); } + onWatchedCommands(handler: (names: string[]) => void): void { + this.watchedCommandHandlers.add(handler); + } + + offWatchedCommands(handler: (names: string[]) => void): void { + this.watchedCommandHandlers.delete(handler); + } + // --- State persistence --- saveState(state: unknown): void { diff --git a/lib/src/lib/reconnect.test.ts b/lib/src/lib/reconnect.test.ts index 7486d918..d5f98981 100644 --- a/lib/src/lib/reconnect.test.ts +++ b/lib/src/lib/reconnect.test.ts @@ -64,10 +64,9 @@ function createPlatform(ptys: PtyInfo[], savedState: PersistedSession | null): P offRequestSessionFlush: vi.fn(), notifySessionFlushComplete: vi.fn(), alertRemove: vi.fn(), - alertToggle: vi.fn(), - alertDisable: vi.fn(), + alertSetWatchedCommands: vi.fn(), + alertSetCommandWatched: vi.fn(), alertDismiss: vi.fn(), - alertDismissOrToggle: vi.fn(), alertAttend: vi.fn(), alertResize: vi.fn(), alertClearAttention: vi.fn(), @@ -76,6 +75,8 @@ function createPlatform(ptys: PtyInfo[], savedState: PersistedSession | null): P alertClearTodo: vi.fn(), onAlertState: vi.fn(), offAlertState: vi.fn(), + onWatchedCommands: vi.fn(), + offWatchedCommands: vi.fn(), saveState: vi.fn(), getState: vi.fn(() => savedState), }; diff --git a/lib/src/lib/session-activity-store.ts b/lib/src/lib/session-activity-store.ts index 8c588665..644143eb 100644 --- a/lib/src/lib/session-activity-store.ts +++ b/lib/src/lib/session-activity-store.ts @@ -1,8 +1,14 @@ import type { SessionStatus } from './activity-monitor'; -import type { AlertButtonActionResult } from './alert-manager'; import type { AlertStateDetail } from './platform/types'; import type { PersistedAlertState, PersistedPane } from './session-types'; import { getPlatform } from './platform'; +import { getRunningCommandArgv0 } from './terminal-state-store'; +import { + applyWatchedCommandsFromHost, + isCommandWatched, + publishWatchedCommands, + setCommandWatched, +} from './watched-commands'; import { getEntryByPtyId, registry, @@ -10,6 +16,13 @@ import { type ActivityState, } from './terminal-store'; +/** + * What the bell click resolved to, so the caller knows whether to open the + * alert dialog. `no-command` means the pane is at a prompt: WATCHING is keyed + * on the running command, so there is nothing to enable. + */ +export type AlertButtonActionResult = 'enabled' | 'disabled' | 'dismissed' | 'menu' | 'no-command' | 'noop'; + export type { ActivityState } from './terminal-store'; export const DEFAULT_ACTIVITY_STATE: ActivityState = { @@ -94,7 +107,6 @@ export function getLivePersistedAlertState(id: string): PersistedAlertState | nu if (!state) return null; return { status: state.status, - watchingEnabled: state.watchingEnabled, todo: state.todo, notification: state.notification, }; @@ -158,6 +170,7 @@ export function consumePrimedActivity(id: string): Partial<ActivityState> | unde } let currentAlertHandler: ((detail: AlertStateDetail) => void) | null = null; +let currentWatchedCommandsHandler: ((names: string[]) => void) | null = null; export function initAlertStateReceiver(): void { const platform = getPlatform(); @@ -185,41 +198,64 @@ export function initAlertStateReceiver(): void { } }; platform.onAlertState(currentAlertHandler); + if (currentWatchedCommandsHandler) { + platform.offWatchedCommands(currentWatchedCommandsHandler); + } + currentWatchedCommandsHandler = applyWatchedCommandsFromHost; + platform.onWatchedCommands(currentWatchedCommandsHandler); + // The host cannot read renderer localStorage. Offer our persisted copy as its + // startup seed after installing the canonical-snapshot listener, so a second + // VS Code webview is corrected rather than replacing the shared rule set. + publishWatchedCommands(); } +/** + * The bell-button transition table (`docs/specs/alert.md` -> UI Contract). This + * is the only copy: WATCHING is a rule keyed on the foreground command's name, + * so enabling and disabling both resolve to a rule-set edit, and the manager + * learns about it through a command-level mutation like any other rule change. + */ export function dismissOrToggleAlert(id: string, displayedStatus: SessionStatus): AlertButtonActionResult { const entry = registry.get(id); - let result: AlertButtonActionResult; - switch (displayedStatus) { - case 'WATCHING_DISABLED': - // Mirrors AlertManager.dismissOrToggleAlert: after an attention-based - // dismissal the next click opens the dialog, even from WATCHING_DISABLED. - result = entry?.attentionDismissedRing ? 'dismissed' : 'enabled'; - break; - case 'ALERT_RINGING': - result = 'dismissed'; - break; - case 'OSC_NOTIF_BUSY': - case 'COMMAND_EXIT_ARMED': - result = entry?.attentionDismissedRing ? 'dismissed' : entry?.watchingEnabled ? 'disabled' : 'menu'; - break; - default: - if (entry?.attentionDismissedRing) { - result = 'dismissed'; - break; - } - result = 'disabled'; + + if (displayedStatus === 'ALERT_RINGING') { + dismissSessionAlert(id); + return 'dismissed'; } - getPlatform().alertDismissOrToggle(resolveTerminalSessionId(id), displayedStatus); - return result; + + // An attention-based dismissal leaves a flag behind so this next click opens + // the dialog rather than silently editing a rule. + if (entry?.attentionDismissedRing) { + dismissSessionAlert(id); + return 'dismissed'; + } + + // Everything else is "turn the rule for the running command on or off". + const argv0 = getRunningCommandArgv0(id); + if (!argv0) return 'no-command'; + + if (isCommandWatched(argv0)) { + setCommandWatched(argv0, false); + return 'disabled'; + } + + // A protocol/command-exit alarm needs no rule, so clicking through one would + // enable WATCHING by surprise. Show the detail dialog instead. + if (displayedStatus === 'OSC_NOTIF_BUSY' || displayedStatus === 'COMMAND_EXIT_ARMED') return 'menu'; + + setCommandWatched(argv0, true); + return 'enabled'; } +/** Turn the rule for whatever `id` is running on/off; no-op at a prompt. */ export function toggleSessionAlert(id: string): void { - getPlatform().alertToggle(resolveTerminalSessionId(id)); + const argv0 = getRunningCommandArgv0(id); + if (argv0) setCommandWatched(argv0, !isCommandWatched(argv0)); } export function disableSessionAlert(id: string): void { - getPlatform().alertDisable(resolveTerminalSessionId(id)); + const argv0 = getRunningCommandArgv0(id); + if (argv0) setCommandWatched(argv0, false); } export function dismissSessionAlert(id: string): void { diff --git a/lib/src/lib/session-restore.test.ts b/lib/src/lib/session-restore.test.ts index c0c19b32..8ba151dd 100644 --- a/lib/src/lib/session-restore.test.ts +++ b/lib/src/lib/session-restore.test.ts @@ -42,10 +42,9 @@ function createPlatform(savedState: PersistedSession | null): PlatformAdapter { offRequestSessionFlush: vi.fn(), notifySessionFlushComplete: vi.fn(), alertRemove: vi.fn(), - alertToggle: vi.fn(), - alertDisable: vi.fn(), + alertSetWatchedCommands: vi.fn(), + alertSetCommandWatched: vi.fn(), alertDismiss: vi.fn(), - alertDismissOrToggle: vi.fn(), alertAttend: vi.fn(), alertResize: vi.fn(), alertClearAttention: vi.fn(), @@ -54,6 +53,8 @@ function createPlatform(savedState: PersistedSession | null): PlatformAdapter { alertClearTodo: vi.fn(), onAlertState: vi.fn(), offAlertState: vi.fn(), + onWatchedCommands: vi.fn(), + offWatchedCommands: vi.fn(), saveState: vi.fn(), getState: vi.fn(() => savedState), }; diff --git a/lib/src/lib/session-save.test.ts b/lib/src/lib/session-save.test.ts index 6d5efa04..0e9129a0 100644 --- a/lib/src/lib/session-save.test.ts +++ b/lib/src/lib/session-save.test.ts @@ -50,10 +50,9 @@ function createPlatform(savedState: PersistedSession | null): PlatformAdapter { offRequestSessionFlush: () => {}, notifySessionFlushComplete: () => {}, alertRemove: () => {}, - alertToggle: () => {}, - alertDisable: () => {}, + alertSetWatchedCommands: () => {}, + alertSetCommandWatched: () => {}, alertDismiss: () => {}, - alertDismissOrToggle: () => {}, alertAttend: () => {}, alertResize: () => {}, alertClearAttention: () => {}, @@ -62,6 +61,8 @@ function createPlatform(savedState: PersistedSession | null): PlatformAdapter { alertClearTodo: () => {}, onAlertState: () => {}, offAlertState: () => {}, + onWatchedCommands: () => {}, + offWatchedCommands: () => {}, saveState: vi.fn((state: unknown) => { persistedState = state; }), diff --git a/lib/src/lib/session-types.ts b/lib/src/lib/session-types.ts index d6a7df13..7f2383ec 100644 --- a/lib/src/lib/session-types.ts +++ b/lib/src/lib/session-types.ts @@ -1,9 +1,14 @@ import type { SessionStatus } from './activity-monitor'; import { ACTIVITY_NOTIFICATION_SOURCES, type ActivityNotification, type TodoState } from './alert-manager'; +/** + * Only the TODO reminder and its notification detail survive a restart. + * WATCHING is not persisted per Session — it is derived from the global rule + * set keyed on the running command (`docs/specs/alert.md`). `status` is written + * for diagnostics and older readers; restore never resurrects a ring from it. + */ export interface PersistedAlertState { status: SessionStatus; - watchingEnabled?: boolean; todo: TodoState; notification?: ActivityNotification | null; } @@ -129,7 +134,6 @@ 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; if (typeof value.todo !== 'boolean') return false; return value.notification === undefined || value.notification === null || isActivityNotificationShape(value.notification); } diff --git a/lib/src/lib/terminal-registry.alert.test.ts b/lib/src/lib/terminal-registry.alert.test.ts index 993ce66a..dd5c138f 100644 --- a/lib/src/lib/terminal-registry.alert.test.ts +++ b/lib/src/lib/terminal-registry.alert.test.ts @@ -109,7 +109,9 @@ import { focusSession, getOrCreateTerminal, getActivity, + getWatchedCommands, initAlertStateReceiver, + setCommandWatched, isUntouched, markSessionAttention, markSessionTodo, @@ -210,6 +212,28 @@ function reattachDoorViaD(id: string): void { mountElement(id, createContainer() as unknown as HTMLElement); } +/** + * Declare a foreground command via shell integration. WATCHING is keyed on the + * running command's name, so every WATCHING story needs one (`docs/specs/alert.md`). + * OSC-only output produces no visible data, so it never counts as activity. + */ +function runCommand(id: string, commandLine = 'longtask'): void { + // sendOutput, not writePty: writePty is suppressed while a scenario plays. + fakePlatform.sendOutput(id, `\x1b]633;E;${commandLine}\x07\x1b]633;C\x07`); +} + +/** Run `commandLine` and turn its WATCHING rule on, as the bell would. */ +function enableAlert(id: string, commandLine = 'longtask'): void { + runCommand(id, commandLine); + toggleSessionAlert(id); + expect(getActivity(id).watchingEnabled).toBe(true); +} + +/** The rule set is app-global and outlives a single test. */ +function clearWatchedCommands(): void { + for (const name of getWatchedCommands()) setCommandWatched(name, false); +} + // Timing helpers based on cfg.alert values: // busyCandidateGap=1500, busyConfirmGap=500, mightNeedAttention=2000, needsAttentionConfirm=3000 @@ -234,6 +258,7 @@ function installRegistryTestGlobals(): void { vi.useFakeTimers(); fakePlatform.reset(); initAlertStateReceiver(); + clearWatchedCommands(); const documentElement = new MockElement(); vi.stubGlobal('document', { @@ -255,6 +280,7 @@ function installRegistryTestGlobals(): void { } function uninstallRegistryTestGlobals(): void { + clearWatchedCommands(); disposeAllSessions(); fakePlatform.reset(); vi.unstubAllGlobals(); @@ -348,7 +374,7 @@ describe('terminal-registry alert behavior', () => { name: 'quick-response', }), ); - toggleSessionAlert(id); + enableAlert(id); attendSession(id); advance(12_000); @@ -369,7 +395,7 @@ describe('terminal-registry alert behavior', () => { { at: 1_800, data: 'more work' }, ], { name: 'long-running' }), ); - toggleSessionAlert(id); + enableAlert(id); attendSession(id); advance(1_800); @@ -389,7 +415,7 @@ describe('terminal-registry alert behavior', () => { it('Story 3: busy session pauses, then resumes', () => { const id = 'story-3'; createSession(id); - toggleSessionAlert(id); + enableAlert(id); driveToBusy(id); advance(2_000); @@ -403,7 +429,7 @@ describe('terminal-registry alert behavior', () => { it('Story 4: completion while still attended does not ring', () => { const id = 'story-4'; createSession(id); - toggleSessionAlert(id); + enableAlert(id); attendSession(id); driveToBusy(id); @@ -419,7 +445,7 @@ describe('terminal-registry alert behavior', () => { it('Story 5: user attends to a ringing pane — turns TODO on', () => { const id = 'story-5'; createSession(id); - toggleSessionAlert(id); + enableAlert(id); driveToRingingNeedsAttention(id); attendSession(id); @@ -433,7 +459,7 @@ describe('terminal-registry alert behavior', () => { it('Story 6: dismiss resets to NOTHING_TO_SHOW and turns TODO on; can ring again later', () => { const id = 'story-6'; createSession(id); - toggleSessionAlert(id); + enableAlert(id); driveToRingingNeedsAttention(id); dismissSessionAlert(id); @@ -457,7 +483,7 @@ describe('terminal-registry alert behavior', () => { it('Story 7: marking TODO clears ring and resets status, leaves alerts enabled', () => { const id = 'story-7'; createSession(id); - toggleSessionAlert(id); + enableAlert(id); driveToRingingNeedsAttention(id); markSessionTodo(id); @@ -519,7 +545,7 @@ describe('terminal-registry alert behavior', () => { it('Story 8: disable alerts clears ring and stops tracking', () => { const id = 'story-8'; createSession(id); - toggleSessionAlert(id); + enableAlert(id); driveToRingingNeedsAttention(id); disableSessionAlert(id); @@ -542,7 +568,7 @@ describe('terminal-registry alert behavior', () => { it('Story 9: new output while ringing latches until user attends', () => { const id = 'story-9'; createSession(id); - toggleSessionAlert(id); + enableAlert(id); driveToRingingNeedsAttention(id); emitOutput(id, 'shell prompt'); @@ -561,7 +587,7 @@ describe('terminal-registry alert behavior', () => { it('Story 10: minimize preserves state, click reattach clears ring', () => { const id = 'story-10'; createSession(id); - toggleSessionAlert(id); + enableAlert(id); attendSession(id); minimizeSession(id); @@ -580,7 +606,7 @@ describe('terminal-registry alert behavior', () => { it('Story 11: minimize preserves state, d reattach does not clear ring', () => { const id = 'story-11'; createSession(id); - toggleSessionAlert(id); + enableAlert(id); attendSession(id); minimizeSession(id); @@ -596,7 +622,7 @@ describe('terminal-registry alert behavior', () => { it('Story 12: resize noise never creates a false alert', () => { const id = 'story-12'; const entry = createSession(id); - toggleSessionAlert(id); + enableAlert(id); entry.terminal.emitResize(120, 40); emitOutput(id, 'redraw noise'); @@ -613,8 +639,10 @@ describe('terminal-registry alert behavior', () => { const beta = 'story-13-b'; createSession(alpha); createSession(beta); - toggleSessionAlert(alpha); - toggleSessionAlert(beta); + // One rule, two sessions running it — enabling on alpha covers beta. + enableAlert(alpha); + runCommand(beta); + expect(getActivity(beta).watchingEnabled).toBe(true); driveToRingingNeedsAttention(alpha); driveToRingingNeedsAttention(beta); @@ -635,7 +663,7 @@ describe('terminal-registry alert behavior', () => { it('Story 14: destroying a session clears alert, TODO, and attention state', () => { const id = 'story-14'; createSession(id); - toggleSessionAlert(id); + enableAlert(id); driveToRingingNeedsAttention(id); toggleSessionTodo(id); @@ -647,8 +675,10 @@ describe('terminal-registry alert behavior', () => { disposeSession(id); expect(getActivity(id)).toEqual(DEFAULT_ACTIVITY_STATE); + // The rule outlives the Session, so the replacement watches immediately. createSession(id); - toggleSessionAlert(id); + runCommand(id); + expect(getActivity(id).watchingEnabled).toBe(true); driveToBusy(id); expireAttention(id); advance(2_000); @@ -663,7 +693,7 @@ describe('terminal-registry alert behavior', () => { it('marks attention from terminal input and clears ringing immediately', () => { const id = 'input-attention'; const entry = createSession(id); - toggleSessionAlert(id); + enableAlert(id); driveToRingingNeedsAttention(id); entry.terminal.emitInput('x'); @@ -677,7 +707,7 @@ describe('terminal-registry alert behavior', () => { it('Enter that dismisses a ringing alert leaves the auto-created TODO visible', () => { const id = 'enter-dismisses-ringing'; const entry = createSession(id); - toggleSessionAlert(id); + enableAlert(id); driveToRingingNeedsAttention(id); entry.terminal.emitInput('\r'); @@ -711,7 +741,7 @@ describe('terminal-registry alert behavior', () => { emitOutput(id, 'old output'); advance(5_000); - toggleSessionAlert(id); + enableAlert(id); expect(getActivity(id).status).toBe('NOTHING_TO_SHOW'); @@ -725,7 +755,7 @@ describe('terminal-registry alert behavior', () => { it('Enter (\\r) in passthrough clears an on-TODO', () => { const id = 'enter-clears-todo'; const entry = createSession(id); - toggleSessionAlert(id); + enableAlert(id); driveToRingingNeedsAttention(id); attendSession(id); @@ -738,7 +768,7 @@ describe('terminal-registry alert behavior', () => { it('printable input without Enter does not clear a TODO', () => { const id = 'printable-keeps-todo'; const entry = createSession(id); - toggleSessionAlert(id); + enableAlert(id); driveToRingingNeedsAttention(id); attendSession(id); @@ -751,7 +781,7 @@ describe('terminal-registry alert behavior', () => { it('focus-report control sequences do not clear a TODO', () => { const id = 'todo-focus-report'; const entry = createSession(id); - toggleSessionAlert(id); + enableAlert(id); driveToRingingNeedsAttention(id); attendSession(id); @@ -828,7 +858,7 @@ describe('terminal-registry alert behavior', () => { it('new output while ringing without attention does not turn TODO on', () => { const id = 'ringing-output-no-todo'; createSession(id); - toggleSessionAlert(id); + enableAlert(id); driveToRingingNeedsAttention(id); emitOutput(id, 'next task'); @@ -842,7 +872,7 @@ describe('terminal-registry alert behavior', () => { it('disabling alerts while ringing does not turn TODO on', () => { const id = 'disable-no-todo'; createSession(id); - toggleSessionAlert(id); + enableAlert(id); driveToRingingNeedsAttention(id); disableSessionAlert(id); @@ -856,8 +886,9 @@ describe('terminal-registry alert behavior', () => { it('alert button enables alerts from WATCHING_DISABLED', () => { const id = 'alert-button-enable'; createSession(id); + runCommand(id); - dismissOrToggleAlert(id, 'WATCHING_DISABLED'); + expect(dismissOrToggleAlert(id, 'WATCHING_DISABLED')).toBe('enabled'); expect(getActivity(id)).toMatchObject({ status: 'NOTHING_TO_SHOW', @@ -865,10 +896,41 @@ describe('terminal-registry alert behavior', () => { }); }); + it('alert button reports no-command at a prompt instead of enabling', () => { + const id = 'alert-button-no-command'; + createSession(id); + + // WATCHING is keyed on the running command; with nothing running there is + // no rule to create, so the header opens the dialog to explain that. + expect(dismissOrToggleAlert(id, 'WATCHING_DISABLED')).toBe('no-command'); + expect(getActivity(id).watchingEnabled).toBe(false); + expect(getWatchedCommands()).toEqual([]); + }); + + it('alert button turns the rule on for every session running that command', () => { + const alpha = 'alert-button-spread-a'; + const beta = 'alert-button-spread-b'; + createSession(alpha); + createSession(beta); + runCommand(alpha, 'claude --resume'); + runCommand(beta, '/usr/local/bin/claude'); + + expect(dismissOrToggleAlert(alpha, 'WATCHING_DISABLED')).toBe('enabled'); + + expect(getWatchedCommands()).toEqual(['claude']); + expect(getActivity(alpha).watchingEnabled).toBe(true); + expect(getActivity(beta).watchingEnabled).toBe(true); + + // Turning it off anywhere turns it off everywhere. + expect(dismissOrToggleAlert(beta, 'NOTHING_TO_SHOW')).toBe('disabled'); + expect(getActivity(alpha).watchingEnabled).toBe(false); + expect(getActivity(beta).watchingEnabled).toBe(false); + }); + it('alert button disables alerts from enabled non-ringing states', () => { const id = 'alert-button-disable'; createSession(id); - toggleSessionAlert(id); + enableAlert(id); driveToBusy(id); dismissOrToggleAlert(id, 'BUSY'); @@ -882,7 +944,7 @@ describe('terminal-registry alert behavior', () => { it('alert button dismisses ringing alerts and turns TODO on', () => { const id = 'alert-button-dismiss'; createSession(id); - toggleSessionAlert(id); + enableAlert(id); driveToRingingNeedsAttention(id); dismissOrToggleAlert(id, 'ALERT_RINGING'); @@ -896,7 +958,7 @@ describe('terminal-registry alert behavior', () => { it('clicking a bell rendered as ringing does not disable alerts after attention already reset it', () => { const id = 'displayed-ringing-dismiss'; createSession(id); - toggleSessionAlert(id); + enableAlert(id); driveToRingingNeedsAttention(id); markSessionAttention(id); @@ -917,7 +979,7 @@ describe('terminal-registry alert behavior', () => { it('a bell click immediately after attention clears ringing is treated as a dismiss, not disable', () => { const id = 'recent-ringing-dismiss'; createSession(id); - toggleSessionAlert(id); + enableAlert(id); driveToRingingNeedsAttention(id); markSessionAttention(id); @@ -937,7 +999,7 @@ describe('terminal-registry alert behavior', () => { it('programmatic terminal focus does not count as attention', () => { const id = 'focus-without-attention'; createSession(id); - toggleSessionAlert(id); + enableAlert(id); driveToRingingNeedsAttention(id); focusSession(id, true); @@ -951,7 +1013,7 @@ describe('terminal-registry alert behavior', () => { it('ignores prompt redraw output immediately after a resize', () => { const id = 'resize-debounce'; const session = createSession(id); - toggleSessionAlert(id); + enableAlert(id); markSessionAttention(id); session.terminal.emitResize(120, 30); diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index 5b8b37b1..e9226ff9 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -1,6 +1,6 @@ export type { SessionStatus } from './activity-monitor'; -export type { TodoState, AlertButtonActionResult } from './alert-manager'; -export type { ActivityState } from './session-activity-store'; +export type { TodoState } from './alert-manager'; +export type { ActivityState, AlertButtonActionResult } from './session-activity-store'; export type { TerminalEntry, TerminalOverlayDims } from './terminal-store'; export type { CommandRun, @@ -58,6 +58,14 @@ export type { SurfaceFocusHandle } from './terminal-lifecycle'; export { setDefaultShellOpts, getDefaultShellOpts } from './shell-defaults'; +export { + getWatchedCommands, + getWatchedCommandsSnapshot, + isCommandWatched, + setCommandWatched, + subscribeToWatchedCommands, +} from './watched-commands'; + export { applyTerminalSemanticEvents, applyTerminalSemanticEventsByPtyId, @@ -65,6 +73,7 @@ export { ensureTerminalPaneState, fillTerminalProcessCwd, fillTerminalProcessCwdByPtyId, + getRunningCommandArgv0, getTerminalPaneState, getTerminalPaneStateSnapshot, isPaneOscDriven, diff --git a/lib/src/lib/terminal-state-store.ts b/lib/src/lib/terminal-state-store.ts index 6a53ffd6..9d86a340 100644 --- a/lib/src/lib/terminal-state-store.ts +++ b/lib/src/lib/terminal-state-store.ts @@ -1,4 +1,5 @@ import { + commandArgv0, createTerminalPaneState, cwdFromManualPath, cwdFromProcessPath, @@ -63,6 +64,17 @@ export function getTerminalPaneState(id: string): TerminalPaneState { return paneStates.get(id) ?? createTerminalPaneState(); } +/** + * The bare program name of the pane's foreground command, or null when the pane + * is at a prompt (or its shell reported no command line). This is the key the + * WATCHING rule set is stored under, so the bell and the alert dialog both use + * it to decide which rule they are toggling — see `docs/specs/alert.md`. + */ +export function getRunningCommandArgv0(id: string): string | null { + const raw = paneStates.get(id)?.currentCommand?.rawCommandLine; + return raw ? commandArgv0(raw) : null; +} + // Count sessions whose latest activity is a live/running command (not an idle // shell at a prompt). The standalone quit orchestrator uses this to decide // whether a quit needs a confirmation (docs/specs/standalone.md §Quit flow). diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index d9661f4d..abfe8cf8 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -191,19 +191,16 @@ export function reduceTerminalState( if (state.pendingCommandLine === event.commandLine) return state; return { ...state, pendingCommandLine: event.commandLine }; case 'commandStart': { - const raw = state.pendingCommandLine; - const source = event.source === 'osc633_boundaries' && raw - ? 'osc633_E' - : event.source ?? (raw ? 'osc633_E' : 'osc133_boundaries'); + const resolved = resolveCommandStart(state.pendingCommandLine, event, { + now, + fallbackTitle: () => deriveFallbackCommandTitle(state), + }); return { ...state, currentCommand: { id: createId(), - rawCommandLine: raw, - displayCommand: raw ? summarizeCommandLine(raw) : deriveFallbackCommandTitle(state), + ...resolved, cwdAtStart: state.cwd, - startedAt: event.startedAt ?? now(), - source, }, activity: { kind: 'running' }, pendingCommandLine: null, @@ -376,6 +373,57 @@ export function summarizeCommandLine(raw: string): string { return truncateCommandTitle(`${visibleTokens.join(' ')}${suffix}`); } +/** + * The first word of a command line, reduced to a bare program name: anything + * after the first pipeline/compound boundary is dropped, leading `VAR=value` + * assignments and a leading `env` are skipped, and argv[0] is taken as a + * basename. `claude`, `/usr/bin/claude --print`, and `FOO=1 env BAR=2 claude` + * all yield `claude`; `foo | claude` yields `foo`. Returns null when the line + * holds no runnable word. + * + * This is the key WATCHING rules are stored under — see `docs/specs/alert.md`. + */ +export function commandArgv0(raw: string): string | null { + const commandTokens = takePrimaryCommandTokens(tokenizeCommand(raw.trim())); + const command = commandTokens[0]; + if (!command) return null; + return command.split(/[\\/]/).pop() || null; +} + +export interface ResolvedCommandStart { + rawCommandLine: string | null; + displayCommand: string; + source: CommandRunSource; + startedAt: number; +} + +/** + * Turn a `commandStart` event plus the command line staged by the preceding + * `commandLine` event into the fields a command run needs. Shared by the + * terminal-state reducer and the alert manager's command-exit track so the + * source resolution and display summarization exist in one place. + * + * `fallbackTitle` supplies the display label when the shell reported no command + * line (`OSC 133;C` carries none); it defaults to `DEFAULT_COMMAND_TITLE`. + */ +export function resolveCommandStart( + pendingCommandLine: string | null, + event: Extract<TerminalSemanticEvent, { type: 'commandStart' }>, + options: { now?: () => number; fallbackTitle?: () => string } = {}, +): ResolvedCommandStart { + const raw = pendingCommandLine; + return { + rawCommandLine: raw, + displayCommand: raw + ? summarizeCommandLine(raw) + : options.fallbackTitle?.() ?? DEFAULT_COMMAND_TITLE, + source: event.source === 'osc633_boundaries' && raw + ? 'osc633_E' + : event.source ?? (raw ? 'osc633_E' : 'osc133_boundaries'), + startedAt: event.startedAt ?? (options.now ?? Date.now)(), + }; +} + // Fold the Windows spellings of one directory to a single key so `dor ensure` // can match a surface across the dialect split. On Windows + Git Bash the shell // integration reports its cwd as a POSIX path (`/c/Users/...`) while the `dor` diff --git a/lib/src/lib/watched-command-host.test.ts b/lib/src/lib/watched-command-host.test.ts new file mode 100644 index 00000000..e57aef94 --- /dev/null +++ b/lib/src/lib/watched-command-host.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AlertManager } from './alert-manager'; +import { WatchedCommandHost } from './watched-command-host'; + +describe('WatchedCommandHost', () => { + it('keeps the first renderer seed and merges later renderer mutations', () => { + const manager = new AlertManager(); + const host = new WatchedCommandHost(manager); + const snapshots: string[][] = []; + host.subscribe((names) => snapshots.push(names)); + + host.initialize(['claude']); + // A second webview may have loaded an older renderer-local snapshot. Its + // startup seed must not replace the already-authoritative host state. + host.initialize([]); + host.setCommandWatched('npm', true); + + expect(manager.getWatchedCommands()).toEqual(['claude', 'npm']); + expect(snapshots).toEqual([ + ['claude'], + ['claude'], + ['claude', 'npm'], + ]); + }); + + it('broadcasts removals without dropping unrelated rules', () => { + const manager = new AlertManager(); + const host = new WatchedCommandHost(manager); + const listener = vi.fn(); + host.subscribe(listener); + + host.initialize(['claude', 'npm']); + host.setCommandWatched('claude', false); + + expect(manager.getWatchedCommands()).toEqual(['npm']); + expect(listener).toHaveBeenLastCalledWith(['npm']); + }); +}); diff --git a/lib/src/lib/watched-command-host.ts b/lib/src/lib/watched-command-host.ts new file mode 100644 index 00000000..8882d6bf --- /dev/null +++ b/lib/src/lib/watched-command-host.ts @@ -0,0 +1,45 @@ +import type { AlertManager } from './alert-manager'; + +type WatchedCommandTarget = Pick< + AlertManager, + 'getWatchedCommands' | 'setCommandWatched' | 'setWatchedCommands' +>; + +/** + * Coordinates one host-authoritative WATCHING rule set across multiple + * renderers. The first renderer seeds persisted state after a fresh host start; + * later renderers receive that canonical state instead of replacing it. + * Individual edits are deltas, so a stale renderer cannot drop unrelated rules. + */ +export class WatchedCommandHost { + private initialized = false; + private listeners = new Set<(names: string[]) => void>(); + + constructor(private readonly target: WatchedCommandTarget) {} + + initialize(names: string[]): void { + if (!this.initialized) { + this.initialized = true; + this.target.setWatchedCommands(names); + } + this.publish(); + } + + setCommandWatched(name: string, watched: boolean): void { + this.initialized = true; + this.target.setCommandWatched(name, watched); + this.publish(); + } + + subscribe(listener: (names: string[]) => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private publish(): void { + const names = this.target.getWatchedCommands(); + for (const listener of this.listeners) listener(names); + } +} diff --git a/lib/src/lib/watched-commands.test.ts b/lib/src/lib/watched-commands.test.ts new file mode 100644 index 00000000..1f208f4f --- /dev/null +++ b/lib/src/lib/watched-commands.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const alertSetWatchedCommands = vi.fn(); +const alertSetCommandWatched = vi.fn(); + +vi.mock('./platform', () => ({ + getPlatform: () => ({ alertSetWatchedCommands, alertSetCommandWatched }), +})); + +import { commandArgv0 } from './terminal-state'; +import { + applyWatchedCommandsFromHost, + getWatchedCommands, + isCommandWatched, + publishWatchedCommands, + setCommandWatched, + subscribeToWatchedCommands, +} from './watched-commands'; + +function clearRules(): void { + for (const name of getWatchedCommands()) setCommandWatched(name, false); +} + +afterEach(() => { + clearRules(); + alertSetWatchedCommands.mockClear(); + alertSetCommandWatched.mockClear(); +}); + +describe('commandArgv0', () => { + it.each([ + ['claude', 'claude'], + ['claude --print hello', 'claude'], + ['/usr/local/bin/claude --resume', 'claude'], + ['FOO=1 claude', 'claude'], + ['FOO=1 env BAR=2 claude', 'claude'], + ['"/opt/my tools/claude" --print', 'claude'], + // Only the first simple command counts — this matches what bash's DEBUG + // trap reports for a pipeline (docs/specs/terminal-escapes.md). + ['foo | claude', 'foo'], + ['pnpm test && claude', 'pnpm'], + ])('reduces %j to %j', (raw, expected) => { + expect(commandArgv0(raw)).toBe(expected); + }); + + it.each(['', ' ', '|', 'FOO=1'])('returns null for %j', (raw) => { + expect(commandArgv0(raw)).toBeNull(); + }); + + it('mangles an unquoted native Windows path, and that is a known limitation', () => { + // The shared tokenizer reads `\` as a POSIX escape, so backslash separators + // are eaten before the basename split can see them — `summarizeCommandLine` + // has always had the same blind spot. Harmless in practice: the shells that + // report a command line (pwsh, Git Bash, WSL) send either a bare program + // name or a POSIX path, and the mangling is at least stable, so a rule keyed + // on it still matches itself. + expect(commandArgv0('C:\\tools\\claude.exe --print')).toBe('C:toolsclaude.exe'); + }); +}); + +describe('watched-commands store', () => { + it('adds, reports, and removes rules', () => { + expect(getWatchedCommands()).toEqual([]); + expect(isCommandWatched('claude')).toBe(false); + + setCommandWatched('claude', true); + expect(getWatchedCommands()).toEqual(['claude']); + expect(isCommandWatched('claude')).toBe(true); + + setCommandWatched('claude', false); + expect(getWatchedCommands()).toEqual([]); + expect(isCommandWatched('claude')).toBe(false); + }); + + it('keeps the rule set sorted and free of duplicates and blanks', () => { + setCommandWatched('pnpm', true); + setCommandWatched('claude', true); + setCommandWatched('claude', true); + setCommandWatched(' ', true); + + expect(getWatchedCommands()).toEqual(['claude', 'pnpm']); + }); + + it('treats a null or empty name as unwatched', () => { + setCommandWatched('claude', true); + expect(isCommandWatched(null)).toBe(false); + expect(isCommandWatched(undefined)).toBe(false); + expect(isCommandWatched('')).toBe(false); + }); + + it('sends mutations as deltas and offers the full rule set only as a startup seed', () => { + setCommandWatched('claude', true); + expect(alertSetCommandWatched).toHaveBeenLastCalledWith('claude', true); + expect(alertSetWatchedCommands).not.toHaveBeenCalled(); + + // A no-op write must not churn the host. + alertSetCommandWatched.mockClear(); + setCommandWatched('claude', true); + expect(alertSetCommandWatched).not.toHaveBeenCalled(); + + publishWatchedCommands(); + expect(alertSetWatchedCommands).toHaveBeenLastCalledWith(['claude']); + }); + + it('replaces a stale renderer mirror with the host snapshot', () => { + const listener = vi.fn(); + subscribeToWatchedCommands(listener); + setCommandWatched('npm', true); + listener.mockClear(); + + applyWatchedCommandsFromHost(['claude', 'npm', 'claude']); + + expect(getWatchedCommands()).toEqual(['claude', 'npm']); + expect(listener).toHaveBeenCalledTimes(1); + expect(alertSetCommandWatched).toHaveBeenCalledTimes(1); + }); + + it('notifies subscribers on change only', () => { + const listener = vi.fn(); + const unsubscribe = subscribeToWatchedCommands(listener); + + setCommandWatched('claude', true); + expect(listener).toHaveBeenCalledTimes(1); + + setCommandWatched('claude', true); + expect(listener).toHaveBeenCalledTimes(1); + + setCommandWatched('claude', false); + expect(listener).toHaveBeenCalledTimes(2); + + unsubscribe(); + setCommandWatched('claude', true); + expect(listener).toHaveBeenCalledTimes(2); + }); +}); diff --git a/lib/src/lib/watched-commands.ts b/lib/src/lib/watched-commands.ts new file mode 100644 index 00000000..f636e46f --- /dev/null +++ b/lib/src/lib/watched-commands.ts @@ -0,0 +1,82 @@ +import { loadJson, saveJson } from './local-json-store'; +import { getPlatform } from './platform'; + +/** + * The WATCHING rule set: the bare program names (`commandArgv0` output) whose + * Sessions run the output/silence monitor. WATCHING is a property of the + * command, not of a Session — enabling it while `claude` runs enables it for + * every Session running `claude`, now and later. See `docs/specs/alert.md`. + * + * This renderer-side copy drives the UI and persists to `localStorage`. In + * VS Code it is a mirror of the extension host's authoritative copy: the first + * renderer seeds the host, mutations are sent as individual command deltas, + * and the host broadcasts its canonical snapshot to every webview. + */ +const STORAGE_KEY = 'dormouse:watched-commands'; + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((entry) => typeof entry === 'string'); +} + +function readStored(): string[] { + const raw = loadJson<string[], string[]>(STORAGE_KEY, [], isStringArray); + // Dedupe and drop blanks defensively: the key is user-visible in devtools and + // a malformed entry would otherwise show up as a blank row in the rule list. + return [...new Set(raw.map((name) => name.trim()).filter(Boolean))].sort(); +} + +function normalize(names: string[]): string[] { + return [...new Set(names.map((name) => name.trim()).filter(Boolean))].sort(); +} + +let watched: string[] = readStored(); +const listeners = new Set<() => void>(); + +export function getWatchedCommands(): string[] { + return watched; +} + +/** Stable-identity snapshot for `useSyncExternalStore`. */ +export function getWatchedCommandsSnapshot(): string[] { + return watched; +} + +export function subscribeToWatchedCommands(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function isCommandWatched(name: string | null | undefined): boolean { + if (!name) return false; + return watched.includes(name); +} + +export function setCommandWatched(name: string, on: boolean): void { + const trimmed = name.trim(); + if (!trimmed) return; + if (watched.includes(trimmed) === on) return; + watched = on + ? [...watched, trimmed].sort() + : watched.filter((entry) => entry !== trimmed); + saveJson(STORAGE_KEY, watched); + getPlatform().alertSetCommandWatched(trimmed, on); + listeners.forEach((listener) => listener()); +} + +/** Replace the renderer mirror with the host's canonical rule set. */ +export function applyWatchedCommandsFromHost(names: string[]): void { + const next = normalize(names); + if (next.length === watched.length && next.every((name, index) => name === watched[index])) return; + watched = next; + saveJson(STORAGE_KEY, watched); + listeners.forEach((listener) => listener()); +} + +/** + * Offer the renderer's persisted rule set as the host's startup seed. In + * multi-webview VS Code only the first seed after an extension-host start is + * accepted; the host replies to every renderer with its canonical snapshot. + */ +export function publishWatchedCommands(): void { + getPlatform().alertSetWatchedCommands(watched); +} diff --git a/lib/src/remote/client/remote-adapter.ts b/lib/src/remote/client/remote-adapter.ts index 20f387f5..4d7c7448 100644 --- a/lib/src/remote/client/remote-adapter.ts +++ b/lib/src/remote/client/remote-adapter.ts @@ -346,10 +346,9 @@ export class RemotePtyAdapter implements PlatformAdapter { // Alerts are Host-authoritative (surfaced via the directory snapshot), so the // phone-side alert controls are inert. alertRemove(): void {} - alertToggle(): void {} - alertDisable(): void {} + alertSetWatchedCommands(): void {} + alertSetCommandWatched(): void {} alertDismiss(): void {} - alertDismissOrToggle(): void {} alertAttend(): void {} alertResize(): void {} alertClearAttention(): void {} @@ -358,6 +357,8 @@ export class RemotePtyAdapter implements PlatformAdapter { alertClearTodo(): void {} onAlertState(): void {} offAlertState(): void {} + onWatchedCommands(): void {} + offWatchedCommands(): void {} saveState(state: unknown): void { this.#savedState = state; diff --git a/lib/src/stories/TerminalPaneHeader.stories.tsx b/lib/src/stories/TerminalPaneHeader.stories.tsx index c509cd7a..182204a6 100644 --- a/lib/src/stories/TerminalPaneHeader.stories.tsx +++ b/lib/src/stories/TerminalPaneHeader.stories.tsx @@ -10,7 +10,7 @@ import { type WallActions, } from '../components/Wall'; import type { ActivityNotification } from '../lib/alert-manager'; -import type { SetTerminalUserTitleResult } from '../lib/terminal-registry'; +import { summarizeCommandLine, type SetTerminalUserTitleResult } from '../lib/terminal-registry'; import { removeMouseSelectionState, setMouseReporting, setOverride } from '../lib/mouse-selection'; const SESSION_ID = 'tab-story'; @@ -48,12 +48,44 @@ function primedState(state: Record<string, unknown>) { }; } +/** + * Report a foreground command the way shell integration would. WATCHING is keyed + * on the running command's name (`docs/specs/alert.md`), so without this the + * bell has no rule to name and every dialog renders its "nothing is running" + * variant. `startedAt` is fixed rather than `Date.now()` for deterministic + * Chromatic snapshots. + */ +function primedRunningCommand(rawCommandLine: string) { + return { + primedTerminalState: { + byId: { + [SESSION_ID]: { + activity: { kind: 'running' as const }, + currentCommand: { + id: 'story-run', + rawCommandLine, + displayCommand: summarizeCommandLine(rawCommandLine), + cwdAtStart: null, + startedAt: 0, + source: 'osc633_E' as const, + }, + }, + }, + }, + }; +} + function primedNotificationState(notification: ActivityNotification, status = 'WATCHING_DISABLED') { - return primedState({ - status, - todo: true, - notification, - }); + return { + // A program that rang was, by definition, running something — so these + // dialogs show the rule switch alongside the notification detail. + ...primedRunningCommand('pnpm test'), + ...primedState({ + status, + todo: true, + notification, + }), + }; } function TabStory({ @@ -123,6 +155,38 @@ async function openAlertRightClickDialog() { await wait(100); } +/** + * Hover the bell so its tooltip renders — the tooltip is what carries the + * command-scoped wording, e.g. `Alert on all "claude"` vs `Alerts are per + * command`. + * + * Hover rather than focus: a programmatic `.focus()` does not reliably drive + * React's `onFocus` here, while `mouseover` is exactly what React synthesizes + * `onMouseEnter` from. Retried because the primed-state decorator applies over + * two rAFs and can re-render the header out from under an early hover; throws + * if the tooltip never appears, so a regression surfaces in the Interactions + * panel instead of as a silently empty snapshot. + */ +async function hoverAlertButton() { + const start = performance.now(); + while (performance.now() - start < 2000) { + const bell = document.querySelector<HTMLButtonElement>(`[data-alert-button-for="${SESSION_ID}"]`); + const rect = bell?.getBoundingClientRect(); + if (bell && rect) { + bell.dispatchEvent(new MouseEvent('mouseover', { + bubbles: true, + cancelable: true, + relatedTarget: document.body, + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + })); + } + await wait(50); + if (document.querySelector('[role="tooltip"]')) return; + } + throw new Error('alert bell tooltip never rendered'); +} + async function openTodoNotificationPreview() { await wait(100); const todoButton = document.querySelector<HTMLButtonElement>(`[data-session-todo-for="${SESSION_ID}"]`); @@ -297,14 +361,49 @@ export const AlertRinging: Story = { }), }; +// --- Command-keyed WATCHING (docs/specs/alert.md) -------------------------- +// +// The bell acts on the *running command's* rule, not on this pane, so what it +// offers depends on what the pane is running and whether a rule already exists. + export const AlertRightClickDialog: Story = { - parameters: primedState({ - status: 'NOTHING_TO_SHOW', - todo: false, - }), + parameters: { + ...primedRunningCommand('claude --resume'), + ...primedState({ status: 'NOTHING_TO_SHOW', todo: false, watchingEnabled: true }), + primedWatchedCommands: ['claude'], + }, + play: openAlertRightClickDialog, +}; + +/** A pane at a prompt: no argv0, so the dialog explains instead of offering a switch. */ +export const AlertDialogNoCommandRunning: Story = { + parameters: primedState({ status: 'WATCHING_DISABLED', todo: false }), play: openAlertRightClickDialog, }; +export const BellTooltipOffersRule: Story = { + parameters: { + ...primedRunningCommand('claude --resume'), + ...primedState({ status: 'WATCHING_DISABLED', todo: false }), + primedWatchedCommands: [], + }, + play: hoverAlertButton, +}; + +export const BellTooltipRemovesRule: Story = { + parameters: { + ...primedRunningCommand('claude --resume'), + ...primedState({ status: 'NOTHING_TO_SHOW', todo: false, watchingEnabled: true }), + primedWatchedCommands: ['claude'], + }, + play: hoverAlertButton, +}; + +export const BellTooltipNoCommandRunning: Story = { + parameters: primedState({ status: 'WATCHING_DISABLED', todo: false }), + play: hoverAlertButton, +}; + export const TodoOnly: Story = { parameters: primedState({ status: 'WATCHING_DISABLED', diff --git a/lib/src/stories/TodoAlertDialog.stories.tsx b/lib/src/stories/TodoAlertDialog.stories.tsx new file mode 100644 index 00000000..be1e1a07 --- /dev/null +++ b/lib/src/stories/TodoAlertDialog.stories.tsx @@ -0,0 +1,166 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { TodoAlertDialog } from '../components/TodoAlertDialog'; +import type { ActivityNotification } from '../lib/alert-manager'; +import { summarizeCommandLine } from '../lib/terminal-registry'; + +const SESSION_ID = 'dialog-story'; + +/** + * The dialog is normally anchored to a pane header's bell. Rendering it directly + * against a fixed rect keeps these stories about the dialog's own content — the + * WATCHING rule switch, the no-command explanation, and the rule list — rather + * than about the header that opens it. `TerminalPaneHeader.stories.tsx` covers + * the header-driven path. + */ +function DialogStory() { + return ( + <div style={{ height: 420 }}> + <TodoAlertDialog + triggerRect={new DOMRect(24, 16, 20, 20)} + sessionId={SESSION_ID} + onClose={() => {}} + onKeyboardActiveChange={() => {}} + /> + </div> + ); +} + +/** + * Report a foreground command the way shell integration would, so the dialog can + * resolve the argv0 its rule switch acts on (`docs/specs/alert.md`). `startedAt` + * is fixed rather than `Date.now()` to keep Chromatic snapshots deterministic. + */ +function running(rawCommandLine: string) { + return { + primedTerminalState: { + byId: { + [SESSION_ID]: { + activity: { kind: 'running' as const }, + currentCommand: { + id: 'story-run', + rawCommandLine, + displayCommand: summarizeCommandLine(rawCommandLine), + cwdAtStart: null, + startedAt: 0, + source: 'osc633_E' as const, + }, + }, + }, + }, + }; +} + +function activity(state: Record<string, unknown>) { + return { primedSessionState: { byId: { [SESSION_ID]: state } } }; +} + +const IDLE = { status: 'WATCHING_DISABLED', todo: false }; +const WATCHING = { status: 'NOTHING_TO_SHOW', todo: false, watchingEnabled: true }; + +const NOTIFICATION: ActivityNotification = { + source: 'OSC 777', + title: 'Tests complete', + body: '341 passed, 0 failed', +}; + +const meta: Meta<typeof DialogStory> = { + title: 'Components/TodoAlertDialog', + component: DialogStory, +}; + +export default meta; +type Story = StoryObj<typeof DialogStory>; + +/** + * The common case: a watched command is running, so the switch names it and the + * rule shows up in the list below. + */ +export const RuleOn: Story = { + parameters: { + ...running('claude --resume'), + ...activity(WATCHING), + primedWatchedCommands: ['claude'], + }, +}; + +/** Same pane, no rule yet — the switch offers to create one. */ +export const RuleOff: Story = { + parameters: { + ...running('claude --resume'), + ...activity(IDLE), + primedWatchedCommands: [], + }, +}; + +/** + * The state the bell opens when there is nothing to key a rule on. This is the + * only way to reach the dialog from a pane sitting at a prompt, so it carries + * the explanation of why the switch is missing. + */ +export const NoCommandRunning: Story = { + parameters: { + ...activity(IDLE), + primedWatchedCommands: [], + }, +}; + +/** + * Nothing running *and* rules already set. The list is the only place a rule + * created on a since-closed pane can be found and removed, so it has to render + * even when the switch above it cannot. + */ +export const NoCommandWithExistingRules: Story = { + parameters: { + ...activity(IDLE), + primedWatchedCommands: ['claude', 'pnpm'], + }, +}; + +/** A realistic accumulated rule set — checks list rhythm and alignment. */ +export const ManyRules: Story = { + parameters: { + ...running('pnpm test --watch'), + ...activity(WATCHING), + primedWatchedCommands: ['cargo', 'claude', 'docker', 'pnpm', 'pytest', 'tsc'], + }, +}; + +/** Every region at once: TODO on, rule on, notification detail, rule list. */ +export const RuleWithNotification: Story = { + parameters: { + ...running('pnpm test'), + ...activity({ status: 'ALERT_RINGING', todo: true, watchingEnabled: true, notification: NOTIFICATION }), + primedWatchedCommands: ['pnpm', 'claude'], + }, +}; + +/** + * A ring with no rule behind it — a program that asked for attention itself. + * The switch still offers a rule for whatever is running. + */ +export const NotificationWithoutRule: Story = { + parameters: { + ...running('make build'), + ...activity({ + status: 'ALERT_RINGING', + todo: true, + notification: { source: 'BEL', title: 'Terminal bell', body: null } satisfies ActivityNotification, + }), + primedWatchedCommands: [], + }, +}; + +/** + * argv0 is a basename, so it is normally short — but nothing enforces that. + * A pathological name must wrap or truncate instead of widening the dialog. + */ +export const LongCommandName: Story = { + parameters: { + ...running('./scripts/really-long-generated-integration-test-runner-name.sh --all'), + ...activity(WATCHING), + primedWatchedCommands: [ + './scripts/really-long-generated-integration-test-runner-name.sh'.split('/').pop()!, + 'claude', + ], + }, +}; diff --git a/standalone/src/browser-sidecar-adapter.ts b/standalone/src/browser-sidecar-adapter.ts index ca9a37f4..e8ac700b 100644 --- a/standalone/src/browser-sidecar-adapter.ts +++ b/standalone/src/browser-sidecar-adapter.ts @@ -12,7 +12,7 @@ import type { PlatformAdapter, PtyInfo, } from "dormouse-lib/lib/platform/types"; -import { AlertManager, type SessionStatus } from "dormouse-lib/lib/alert-manager"; +import { AlertManager } from "dormouse-lib/lib/alert-manager"; import { normalizeExternalUri } from "dormouse-lib/lib/external-links"; import { loadSessionState, saveSessionState } from "dormouse-lib/lib/window-persistence"; import { @@ -205,10 +205,9 @@ export class BrowserSidecarAdapter implements PlatformAdapter { notifySessionFlushComplete(_requestId: string): void {} alertRemove(id: string): void { this.alertManager.remove(id); } - alertToggle(id: string): void { this.alertManager.toggleAlert(id); } - alertDisable(id: string): void { this.alertManager.disableAlert(id); } + alertSetWatchedCommands(names: string[]): void { this.alertManager.setWatchedCommands(names); } + alertSetCommandWatched(name: string, watched: boolean): void { this.alertManager.setCommandWatched(name, watched); } alertDismiss(id: string): void { this.alertManager.dismissAlert(id); } - alertDismissOrToggle(id: string, displayedStatus: string): void { this.alertManager.dismissOrToggleAlert(id, displayedStatus as SessionStatus); } alertAttend(id: string): void { this.alertManager.attend(id); } alertResize(id: string): void { this.alertManager.onResize(id); } alertClearAttention(id?: string): void { this.alertManager.clearAttention(id); } @@ -217,6 +216,8 @@ export class BrowserSidecarAdapter implements PlatformAdapter { alertClearTodo(id: string): void { this.alertManager.clearTodo(id); } onAlertState(handler: (detail: AlertStateDetail) => void): void { this.alertStateHandlers.add(handler); } offAlertState(handler: (detail: AlertStateDetail) => void): void { this.alertStateHandlers.delete(handler); } + onWatchedCommands(_handler: (names: string[]) => void): void {} + offWatchedCommands(_handler: (names: string[]) => void): void {} private static STATE_KEY = 'dormouse.browser-sidecar.session'; diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 21b68949..219e98f8 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -15,7 +15,7 @@ import type { PlatformAdapter, PtyInfo, } from "dormouse-lib/lib/platform/types"; -import { AlertManager, type SessionStatus } from "dormouse-lib/lib/alert-manager"; +import { AlertManager } from "dormouse-lib/lib/alert-manager"; import { normalizeExternalUri } from "dormouse-lib/lib/external-links"; import { loadSessionState, saveSessionState } from "dormouse-lib/lib/window-persistence"; import { TauriSessionStore } from "./tauri-session-store"; @@ -453,22 +453,18 @@ export class TauriAdapter implements PlatformAdapter { this.alertManager.remove(id); } - alertToggle(id: string): void { - this.alertManager.toggleAlert(id); + alertSetWatchedCommands(names: string[]): void { + this.alertManager.setWatchedCommands(names); } - alertDisable(id: string): void { - this.alertManager.disableAlert(id); + alertSetCommandWatched(name: string, watched: boolean): void { + this.alertManager.setCommandWatched(name, watched); } alertDismiss(id: string): void { this.alertManager.dismissAlert(id); } - alertDismissOrToggle(id: string, displayedStatus: string): void { - this.alertManager.dismissOrToggleAlert(id, displayedStatus as SessionStatus); - } - alertAttend(id: string): void { this.alertManager.attend(id); } @@ -501,6 +497,10 @@ export class TauriAdapter implements PlatformAdapter { this.alertStateHandlers.delete(handler); } + onWatchedCommands(_handler: (names: string[]) => void): void {} + + offWatchedCommands(_handler: (names: string[]) => void): void {} + // --- State persistence --- private static STATE_KEY = 'dormouse.session'; diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index b86cb46b..7466f8ee 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import * as ptyManager from './pty-manager'; -import { AlertManager, type SessionStatus } from '../../lib/src/lib/alert-manager'; +import { AlertManager } from '../../lib/src/lib/alert-manager'; +import { WatchedCommandHost } from '../../lib/src/lib/watched-command-host'; import { applyTerminalProtocolEvents, collectTerminalSemanticEvents, @@ -42,6 +43,7 @@ const ALLOWED_WORKBENCH_COMMANDS = new Set<string>(VSCODE_WORKBENCH_COMMANDS); // Shared alert manager — survives router disposal so alert state persists // across webview collapse/expand cycles. const alertManager = new AlertManager(); +const watchedCommandHost = new WatchedCommandHost(alertManager); const alertProtocolParsers = new Map<string, TerminalProtocolParser>(); // The extension-host parser has no DOM, so webviews push their resolved terminal @@ -167,6 +169,12 @@ export function attachRouter( // Webview-facing subscriptions — only active when the webview has live content. // Subscribed on dormouse:init, unsubscribed when webview content is gone. let disconnectWebview: (() => void) | null = null; + const removeWatchedCommandListener = watchedCommandHost.subscribe((names) => { + void webview.postMessage({ + type: 'alert:watchedCommands', + names, + } satisfies ExtensionMessage); + }); function claim(id: string): void { ownedPtyIds.add(id); @@ -600,18 +608,15 @@ export function attachRouter( case 'alert:remove': alertManager.remove(msg.id); break; - case 'alert:toggle': - alertManager.toggleAlert(msg.id); + case 'alert:initializeWatchedCommands': + watchedCommandHost.initialize(msg.names); break; - case 'alert:disable': - alertManager.disableAlert(msg.id); + case 'alert:setCommandWatched': + watchedCommandHost.setCommandWatched(msg.name, msg.watched); break; case 'alert:dismiss': alertManager.dismissAlert(msg.id); break; - case 'alert:dismissOrToggle': - alertManager.dismissOrToggleAlert(msg.id, msg.displayedStatus as SessionStatus); - break; case 'alert:attend': alertManager.attend(msg.id); break; @@ -641,6 +646,7 @@ export function attachRouter( if (disposed) return; disposed = true; activeRouters.delete(router); + removeWatchedCommandListener(); resolveAllFlushRequests(); disconnectWebview?.(); disconnectWebview = null; diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index c71c9ce8..087a6ea1 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -35,10 +35,9 @@ export type WebviewMessage = | ({ type: 'dor:controlResponse' } & DorControlResponsePayload) // Alert actions | { type: 'alert:remove'; id: string } - | { type: 'alert:toggle'; id: string } - | { type: 'alert:disable'; id: string } + | { type: 'alert:initializeWatchedCommands'; names: string[] } + | { type: 'alert:setCommandWatched'; name: string; watched: boolean } | { type: 'alert:dismiss'; id: string } - | { type: 'alert:dismissOrToggle'; id: string; displayedStatus: string } | { type: 'alert:attend'; id: string } | { type: 'alert:resize'; id: string } | { type: 'alert:clearAttention'; id?: string } @@ -94,4 +93,5 @@ export type ExtensionMessage = todo: TodoState; notification: ActivityNotification | null; attentionDismissedRing: boolean; - }; + } + | { type: 'alert:watchedCommands'; names: string[] }; diff --git a/vscode-ext/src/session-state.ts b/vscode-ext/src/session-state.ts index 46c771bf..7593a999 100644 --- a/vscode-ext/src/session-state.ts +++ b/vscode-ext/src/session-state.ts @@ -20,7 +20,6 @@ function toPersistedAlert(alert: AlertState | undefined, fallback: PersistedAler if (!alert) return fallback ?? null; return { status: alert.status, - watchingEnabled: alert.watchingEnabled, todo: alert.todo, notification: alert.notification, }; diff --git a/website/src/lib/__snapshots__/tut-runner.test.ts.snap b/website/src/lib/__snapshots__/tut-runner.test.ts.snap index 0312cab8..c0705cef 100644 --- a/website/src/lib/__snapshots__/tut-runner.test.ts.snap +++ b/website/src/lib/__snapshots__/tut-runner.test.ts.snap @@ -1,23 +1,5 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`TutRunner snapshots > renders Alert and TODO with all items incomplete 1`] = ` -" - Alert and TODO 0/6 complete - Esc to go back - - ● 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 - · Bell rings when the task completes - · TODO tag appears when you dismiss the ringing alert - · Press Enter inside the pane to clear the TODO - · Manually add a TODO - - Press s here to start a fake busy task. -" -`; - exports[`TutRunner snapshots > renders Copy paste with all items incomplete 1`] = ` " Copy paste 0/4 complete @@ -76,16 +58,44 @@ exports[`TutRunner snapshots > renders Keyboard navigation with all items incomp " `; +exports[`TutRunner snapshots > renders the alert section with all items incomplete 1`] = ` +" + Alerts and attention 0/9 complete + Esc to go back + + ● Alert me whenever longtask runs + Press s to start a fake longtask, then click that pane's bell (or select + it and press a). Alerts belong to the command, not the tab — the bell + says "Alert on all longtask". + · The rule covers every pane running that command + · The bell tilts while the command works + · It rings when the command goes quiet + · Dismissing a ringing alert leaves a TODO behind + · Press Enter inside the pane to clear the TODO + · Add a TODO by hand + · A program can ring the bell itself + · A long command that finished while you were away + + Three different things can ring the bell: a rule you set on a command name, a + notification the program sends, and a long command finishing while you were + elsewhere. None of them ring while you are actually looking at the pane. + + Press s to start a fake longtask. + Press n for a program that rings the bell itself. + Press x to start a fake slowbuild. +" +`; + exports[`TutRunner snapshots > renders the top-level menu 1`] = ` " Dormouse Playground Tutorial Esc/q to exit · Enter to open · ↑↓ to navigate ❯ Keyboard navigation [0/7 complete] - Alert and TODO [0/6 complete] + Alerts and attention [0/9 complete] Copy paste [0/4 complete] Starred on GitHub [not yet] - 🐭 FlappyTerm 🐭 [LOCKED 0/17] + 🐭 FlappyTerm 🐭 [LOCKED 0/20] Reset progress diff --git a/website/src/lib/tut-detector.test.ts b/website/src/lib/tut-detector.test.ts index 524c92ae..a6277fbf 100644 --- a/website/src/lib/tut-detector.test.ts +++ b/website/src/lib/tut-detector.test.ts @@ -14,11 +14,11 @@ function activity( function makeDetectorHarness(initialActivitySnapshot = new Map<string, ActivityState>()) { let activityListener: (() => void) | null = null; + let watchedListener: (() => void) | null = null; let mouseListener: (() => void) | null = null; let activitySnapshot = initialActivitySnapshot; + let watchedCommands: string[] = []; let mouseSnapshot = new Map<string, MouseSelectionState>(); - const onWatchingDemoPaneChange = vi.fn(); - const state = new TutorialState(); const detector = new TutDetector( state, @@ -30,6 +30,13 @@ function makeDetectorHarness(initialActivitySnapshot = new Map<string, ActivityS activityListener = null; }; }, + getWatchedCommands: () => watchedCommands, + subscribeToWatchedCommands: (listener) => { + watchedListener = listener; + return () => { + watchedListener = null; + }; + }, }, { getMouseSelectionSnapshot: () => mouseSnapshot, @@ -40,7 +47,6 @@ function makeDetectorHarness(initialActivitySnapshot = new Map<string, ActivityS }; }, }, - { onWatchingDemoPaneChange }, ); detector.start(); @@ -52,6 +58,10 @@ function makeDetectorHarness(initialActivitySnapshot = new Map<string, ActivityS activitySnapshot = snapshot; activityListener?.(); }, + setWatchedCommands: (names: string[]) => { + watchedCommands = names; + watchedListener?.(); + }, setMouseSnapshot: (snapshot: Map<string, MouseSelectionState>) => { mouseSnapshot = snapshot; mouseListener?.(); @@ -60,7 +70,6 @@ function makeDetectorHarness(initialActivitySnapshot = new Map<string, ActivityS // Wall.selectPane fires on both engines); the detector reads kb-arrows from it. selectPane: (id: string) => detector.handleWallEvent({ type: "selectionChange", id, kind: "pane" }), - onWatchingDemoPaneChange, }; } @@ -157,75 +166,58 @@ describe("TutDetector", () => { expect(state.isComplete("al-ring")).toBe(true); }); - it("credits al-enable after a newly-created pane is first observed disabled", () => { - const { state, setActivitySnapshot } = makeDetectorHarness(); + it("credits al-watch-cmd once a rule exists", () => { + const { state, setWatchedCommands } = makeDetectorHarness(); - setActivitySnapshot(new Map([ - ["pane-a", activity("WATCHING_DISABLED")], - ])); - setActivitySnapshot(new Map([ - ["pane-a", activity("NOTHING_TO_SHOW")], - ])); - - expect(state.isComplete("al-enable")).toBe(true); + expect(state.isComplete("al-watch-cmd")).toBe(false); + setWatchedCommands(["longtask"]); + expect(state.isComplete("al-watch-cmd")).toBe(true); }); - it("does not credit al-enable for projected command-exit status while WATCHING is off", () => { - const { state, onWatchingDemoPaneChange, setActivitySnapshot } = makeDetectorHarness(); + it("credits al-spreads only when a second pane lights up from the same rule", () => { + const { state, setActivitySnapshot } = makeDetectorHarness(); - onWatchingDemoPaneChange.mockClear(); setActivitySnapshot(new Map([ ["pane-a", activity("WATCHING_DISABLED", false, false)], + ["pane-b", activity("WATCHING_DISABLED", false, false)], ])); setActivitySnapshot(new Map([ - ["pane-a", activity("COMMAND_EXIT_ARMED", false, false)], + ["pane-a", activity("NOTHING_TO_SHOW", false, true)], + ["pane-b", activity("WATCHING_DISABLED", 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(); + expect(state.isComplete("al-spreads")).toBe(false); setActivitySnapshot(new Map([ - ["pane-a", activity("COMMAND_EXIT_ARMED", false, false)], + ["pane-a", activity("NOTHING_TO_SHOW", false, true)], + ["pane-b", activity("NOTHING_TO_SHOW", false, true)], ])); - setActivitySnapshot(new Map([ - ["pane-a", activity("COMMAND_EXIT_ARMED", false, true)], - ])); - - expect(state.isComplete("al-enable")).toBe(true); - expect(onWatchingDemoPaneChange).toHaveBeenLastCalledWith("pane-a"); + expect(state.isComplete("al-spreads")).toBe(true); }); - 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(); + it("credits al-notif for a program-sent notification and al-cmd-exit for a command exit", () => { + const { state, setActivitySnapshot } = makeDetectorHarness(); + setActivitySnapshot(new Map([["pane-a", activity("WATCHING_DISABLED", false, false)]])); setActivitySnapshot(new Map([ - ["pane-a", activity("WATCHING_DISABLED")], - ["pane-b", activity("WATCHING_DISABLED")], - ])); - setActivitySnapshot(new Map([ - ["pane-a", activity("WATCHING_DISABLED")], - ["pane-b", activity("NOTHING_TO_SHOW")], + ["pane-a", { + ...activity("ALERT_RINGING", true, false), + notification: { source: "OSC 777", title: "Build finished", body: "3 packages" }, + }], ])); - - expect(onWatchingDemoPaneChange).toHaveBeenLastCalledWith("pane-b"); + expect(state.isComplete("al-notif")).toBe(true); + expect(state.isComplete("al-cmd-exit")).toBe(false); + // The ring set TODO itself, so this is not a hand-added one. + expect(state.isComplete("al-todo-manual")).toBe(false); setActivitySnapshot(new Map([ - ["pane-a", activity("WATCHING_DISABLED")], - ["pane-b", activity("WATCHING_DISABLED")], + ["pane-a", { + ...activity("ALERT_RINGING", true, false), + notification: { source: "COMMAND_EXIT", title: "Command finished", body: "slowbuild exited 0" }, + }], ])); - - expect(onWatchingDemoPaneChange).toHaveBeenLastCalledWith(null); + expect(state.isComplete("al-cmd-exit")).toBe(true); }); + + }); diff --git a/website/src/lib/tut-detector.ts b/website/src/lib/tut-detector.ts index 1560fc8f..125c87d2 100644 --- a/website/src/lib/tut-detector.ts +++ b/website/src/lib/tut-detector.ts @@ -9,28 +9,28 @@ type MouseSelectionState = import("dormouse-lib/lib/mouse-selection").MouseSelec interface ActivityStoreModule { subscribeToActivity: (listener: () => void) => () => void; getActivitySnapshot: () => Map<string, ActivityState>; + subscribeToWatchedCommands: (listener: () => void) => () => void; + getWatchedCommands: () => string[]; } +/** Notification sources a program emits for itself, as opposed to the ones + * Dormouse synthesizes for a command exit (`docs/specs/alert.md`). */ +const TERMINAL_REPORT_SOURCES = new Set(["BEL", "OSC 9", "OSC 9;4", "OSC 99", "OSC 777"]); + interface MouseSelectionModule { subscribeToMouseSelection: (listener: () => void) => () => void; getMouseSelectionSnapshot: () => Map<string, MouseSelectionState>; } -interface TutDetectorOptions { - onWatchingDemoPaneChange?: (id: string | null) => void; -} - export class TutDetector { private state: TutorialState; private activityStore: ActivityStoreModule; private mouseStore: MouseSelectionModule; - private onWatchingDemoPaneChange?: (id: string | null) => void; private started = false; private currentMode: WallMode = "command"; private currentPaneId: string | null = null; private commandModePanels = new Set<string>(); private watchingEnabledPaneIds = new Set<string>(); - private preferredWatchingPaneId: string | null = null; private pendingMoveTargetId: string | null = null; private pendingMoveClearTimer: ReturnType<typeof setTimeout> | null = null; private prevActivity = new Map<string, ActivityState>(); @@ -41,12 +41,10 @@ export class TutDetector { state: TutorialState, activityStore: ActivityStoreModule, mouseStore: MouseSelectionModule, - options: TutDetectorOptions = {}, ) { this.state = state; this.activityStore = activityStore; this.mouseStore = mouseStore; - this.onWatchingDemoPaneChange = options.onWatchingDemoPaneChange; } /** Seed the prev-state maps and subscribe to the activity/mouse stores. The @@ -61,12 +59,8 @@ 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.watchingEnabled) { - this.watchingEnabledPaneIds.add(id); - this.preferredWatchingPaneId ??= id; - } + if (s.watchingEnabled) this.watchingEnabledPaneIds.add(id); } - this.emitWatchingDemoPaneChange(); for (const [id, s] of this.mouseStore.getMouseSelectionSnapshot()) { this.prevMouse.set(id, { ...s }); } @@ -74,6 +68,9 @@ export class TutDetector { this.disposables.push( this.activityStore.subscribeToActivity(() => this.processActivity()), ); + this.disposables.push( + this.activityStore.subscribeToWatchedCommands(() => this.processWatchedCommands()), + ); this.disposables.push( this.mouseStore.subscribeToMouseSelection(() => this.processMouse()), ); @@ -160,6 +157,13 @@ export class TutDetector { } } + /** A rule exists at all — the user turned alerts on for a command name. */ + private processWatchedCommands(): void { + if (this.activityStore.getWatchedCommands().length > 0) { + this.state.markComplete("al-watch-cmd"); + } + } + private processActivity(): void { const snapshot = this.activityStore.getActivitySnapshot(); for (const [id, current] of snapshot) { @@ -175,16 +179,14 @@ export class TutDetector { } if (!prev.watchingEnabled && current.watchingEnabled) { - this.state.markComplete("al-enable"); this.watchingEnabledPaneIds.add(id); - this.preferredWatchingPaneId = id; - this.emitWatchingDemoPaneChange(); + // One rule, many panes: the second pane to light up never had its own + // bell clicked, which is the whole point of command-keyed WATCHING. + if (this.watchingEnabledPaneIds.size > 1) { + this.state.markComplete("al-spreads"); + } } else if (prev.watchingEnabled && !current.watchingEnabled) { this.watchingEnabledPaneIds.delete(id); - if (this.preferredWatchingPaneId === id) { - this.preferredWatchingPaneId = this.watchingEnabledPaneIds.values().next().value ?? null; - this.emitWatchingDemoPaneChange(); - } } // Gate al-busy / al-ring on a true status transition. Without the @@ -202,10 +204,20 @@ export class TutDetector { this.state.markComplete("al-ring"); } + // Credit the two rule-free alarm paths off the notification that landed, + // not off the status: both project to plain ALERT_RINGING. + const source = current.notification?.source; + if (source && source !== prev.notification?.source) { + if (source === "COMMAND_EXIT") this.state.markComplete("al-cmd-exit"); + else if (TERMINAL_REPORT_SOURCES.has(source)) this.state.markComplete("al-notif"); + } + if (!prev.todo && current.todo) { if (prev.status === "ALERT_RINGING") { this.state.markComplete("al-todo-auto"); - } else { + } else if (!source) { + // A protocol or command-exit ring sets TODO itself; only a bare + // TODO with no notification behind it was added by hand. this.state.markComplete("al-todo-manual"); } } @@ -219,10 +231,6 @@ export class TutDetector { if (!snapshot.has(id)) { this.prevActivity.delete(id); this.watchingEnabledPaneIds.delete(id); - if (this.preferredWatchingPaneId === id) { - this.preferredWatchingPaneId = this.watchingEnabledPaneIds.values().next().value ?? null; - this.emitWatchingDemoPaneChange(); - } } } } @@ -258,7 +266,4 @@ export class TutDetector { this.clearPendingMoveTarget(); } - private emitWatchingDemoPaneChange(): void { - this.onWatchingDemoPaneChange?.(this.preferredWatchingPaneId); - } } diff --git a/website/src/lib/tut-items.ts b/website/src/lib/tut-items.ts index efd1dceb..48d03c5c 100644 --- a/website/src/lib/tut-items.ts +++ b/website/src/lib/tut-items.ts @@ -21,12 +21,15 @@ const KEYBOARD_ITEM_IDS = [ ] as const; const ALERT_ITEM_IDS = [ - "al-enable", + "al-watch-cmd", + "al-spreads", "al-busy", "al-ring", "al-todo-auto", "al-todo-clear", "al-todo-manual", + "al-notif", + "al-cmd-exit", ] as const; const COPY_ITEM_IDS = [ @@ -177,28 +180,33 @@ export const DESKTOP_SECTIONS: readonly Section[] = [ }, { id: 'alert', - title: 'Alert and TODO', + title: 'Alerts and attention', items: [ { - id: 'al-enable', - 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-watch-cmd', + title: 'Alert me whenever `longtask` runs', + hint: 'Press `s` to start a fake `longtask`, then click that pane\'s bell (or select it and press `a`). Alerts belong to the command, not the tab — the bell says "Alert on all longtask".', + }, + { + id: 'al-spreads', + title: 'The rule covers every pane running that command', + hint: 'Both fake tasks light up from the one bell you clicked. Any pane you open later that runs `longtask` will watch too, with no extra clicks.', }, { id: 'al-busy', - title: 'Watch the bell tilt while a task runs', - hint: 'Press `s` here to start a fake busy task on that WATCHING-enabled pane.', + title: 'The bell tilts while the command works', + hint: 'Press `s` again if the task already finished.', }, { id: 'al-ring', - title: 'Bell rings when the task completes', + title: 'It rings when the command goes quiet', hint: `Don't type! If you type, Dormouse will think you are paying attention to this task and the bell will not ring. The bell only rings if (a) the pane is not selected or (b) you have not interacted with the pane for the past ${USER_ATTENTION_SECS} seconds.`, }, { id: 'al-todo-auto', - title: 'TODO tag appears when you dismiss the ringing alert', - hint: 'Click the bell or interact with the pane to dismiss.', + title: 'Dismissing a ringing alert leaves a TODO behind', + hint: 'Click the bell or interact with the pane to dismiss. The TODO is there so a ring you waved away does not vanish without a trace.', }, { id: 'al-todo-clear', @@ -206,9 +214,23 @@ export const DESKTOP_SECTIONS: readonly Section[] = [ }, { id: 'al-todo-manual', - title: 'Manually add a TODO', + title: 'Add a TODO by hand', hint: 'Press `t` in command mode, or right-click the bell.', }, + { + id: 'al-notif', + title: 'A program can ring the bell itself', + hint: 'Press `n` for a fake build that sends a desktop notification. This needs no rule at all — any program that emits `BEL`, `OSC 9`, `OSC 777`, or `OSC 99` rings, and its message shows on the TODO tag.', + }, + { + id: 'al-cmd-exit', + title: 'A long command that finished while you were away', + hint: + `Press \`x\` to start a slow build in another pane, click into that pane, then click back here and wait. Dormouse rings for any command that ran longer than ${USER_ATTENTION_SECS} seconds and finished after you walked away — again, no rule needed.`, + }, + ], + prose: [ + 'Three different things can ring the bell: a rule you set on a command name, a notification the program sends, and a long command finishing while you were elsewhere. None of them ring while you are actually looking at the pane.', ], }, COPY_PASTE_SECTION, diff --git a/website/src/lib/tut-runner.test.ts b/website/src/lib/tut-runner.test.ts index bc651eb6..aaf58458 100644 --- a/website/src/lib/tut-runner.test.ts +++ b/website/src/lib/tut-runner.test.ts @@ -89,7 +89,7 @@ describe("TutRunner snapshots", () => { dispose(); }); - it("renders Alert and TODO with all items incomplete", () => { + it("renders the alert section with all items incomplete", () => { const { sendKeys, lastFrame, dispose } = mountRunner(); sendKeys("\x1b[B\r"); expect(lastFrame()).toMatchSnapshot(); @@ -219,7 +219,7 @@ describe("TutRunner snapshots", () => { expect(lastFrame()).toContain("🐭 FlappyTerm 🐭"); expect(lastFrame()).not.toContain("???"); - expect(lastFrame()).toContain("[LOCKED 0/17]"); + expect(lastFrame()).toContain("[LOCKED 0/20]"); expect(lastFrame()).toContain("Dormouse Playground Tutorial"); dispose(); }); diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index 043cc1bd..8ea11a39 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -39,6 +39,22 @@ export const BUSY_DEMO_DURATION_MS = cfg.alert.userAttention + 250; */ export const BUSY_DEMO_INTERVAL_MS = Math.floor(cfg.alert.busyCandidateGap / 2); +/** + * How long the fake command keeps *running* after its output goes quiet. + * + * WATCHING rings on silence from a command that is still running — a program + * waiting for input, not one that exited (a command exiting is the other track + * entirely). Since watching is keyed on the running command, reporting the exit + * the moment the busy burst ends would dispose the monitor before it could ever + * reach ALERT_RINGING. So the fake command outlives its output by the monitor's + * full silence chain plus margin. + */ +export const WATCH_DEMO_COMMAND_MS = + BUSY_DEMO_DURATION_MS + + cfg.alert.mightNeedAttention + + cfg.alert.needsAttentionConfirm + + 2_000; + // Replace `` `KEY` `` markers with a cyan span. Uses default-foreground // (39m) to close the span so the highlight composes cleanly with // surrounding bold/italic/dim — only the color is touched. @@ -116,6 +132,10 @@ interface TutRunnerOptions { onExit: () => void; /** Called when the user presses `s` inside the Alert section. */ onTriggerBusyDemo?: () => void; + /** Called when the user presses `n` inside the Alert section. */ + onTriggerNotifyDemo?: () => void; + /** Called when the user presses `x` inside the Alert section. */ + onTriggerCommandExitDemo?: () => void; /** Called when the user presses `p` inside the Copy paste section. */ onTogglePlaceToPaste?: () => void; /** Called when the user presses `Enter` on the GitHub star prompt. */ @@ -141,6 +161,8 @@ export class TutRunner implements InteractiveProgram { private profile: TutorialProfile; private onExit: () => void; private onTriggerBusyDemo?: () => void; + private onTriggerNotifyDemo?: () => void; + private onTriggerCommandExitDemo?: () => void; private onTogglePlaceToPaste?: () => void; private onOpenGithub?: () => void; private onOpenPocket?: () => void; @@ -161,6 +183,7 @@ export class TutRunner implements InteractiveProgram { private pocketTouchModeUnsub: (() => void) | null = null; private resizeUnsub: (() => void) | null = null; private busyDemoStart: number | null = null; + private commandExitDemoStart: number | null = null; private disposed = false; constructor(options: TutRunnerOptions) { @@ -170,6 +193,8 @@ export class TutRunner implements InteractiveProgram { this.profile = options.profile ?? DESKTOP_TUTORIAL_PROFILE; this.onExit = options.onExit; this.onTriggerBusyDemo = options.onTriggerBusyDemo; + this.onTriggerNotifyDemo = options.onTriggerNotifyDemo; + this.onTriggerCommandExitDemo = options.onTriggerCommandExitDemo; this.onTogglePlaceToPaste = options.onTogglePlaceToPaste; this.onOpenGithub = options.onOpenGithub; this.onOpenPocket = options.onOpenPocket; @@ -194,10 +219,7 @@ export class TutRunner implements InteractiveProgram { this.spinnerTimer = setInterval(() => { this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length; this.render(); - if ( - this.busyDemoStart === null || - Date.now() - this.busyDemoStart >= BUSY_DEMO_DURATION_MS - ) { + if (!this.busyDemoInProgress() && !this.commandExitDemoInProgress()) { this.stopSpinnerTicks(); } }, SPINNER_INTERVAL_MS); @@ -384,17 +406,25 @@ export class TutRunner implements InteractiveProgram { this.handleEscape(); return; } - if ( - this.screen === "section" && - this.sectionId === "alert" && - (ch === "s" || ch === "S") - ) { - // Ignore presses while the demo is still running — otherwise each - // press starts a fresh pumpActivity interval that stacks on top of - // the previous one until they all expire. - if (!this.busyDemoInProgress()) this.startBusyDemo(); - i += 1; - continue; + if (this.screen === "section" && this.sectionId === "alert") { + if (ch === "s" || ch === "S") { + // Ignore presses while the demo is still running — otherwise each + // press starts a fresh pumpActivity interval that stacks on top of + // the previous one until they all expire. + if (!this.busyDemoInProgress()) this.startBusyDemo(); + i += 1; + continue; + } + if (ch === "n" || ch === "N") { + this.onTriggerNotifyDemo?.(); + i += 1; + continue; + } + if (ch === "x" || ch === "X") { + if (!this.commandExitDemoInProgress()) this.startCommandExitDemo(); + i += 1; + continue; + } } if ( this.screen === "section" && @@ -502,7 +532,10 @@ export class TutRunner implements InteractiveProgram { // Resume the spinner if we're entering Alert while a demo started // earlier is still running. Otherwise the countdown line would // render with a frozen spinner glyph (timer was stopped on Esc out). - if (section.id === "alert" && this.busyDemoInProgress()) { + if ( + section.id === "alert" && + (this.busyDemoInProgress() || this.commandExitDemoInProgress()) + ) { this.startSpinnerTicks(); } this.render(); @@ -569,6 +602,18 @@ export class TutRunner implements InteractiveProgram { this.render(); } + private commandExitDemoInProgress(): boolean { + if (this.commandExitDemoStart === null) return false; + return Date.now() - this.commandExitDemoStart < BUSY_DEMO_DURATION_MS; + } + + private startCommandExitDemo(): void { + this.commandExitDemoStart = Date.now(); + this.onTriggerCommandExitDemo?.(); + this.startSpinnerTicks(); + this.render(); + } + // --- Render --- private render(): void { @@ -866,19 +911,30 @@ export class TutRunner implements InteractiveProgram { } private renderBusyDemoLines(): string[] { - const idleHint = ` ${DIM}Press \`s\` here to start a fake busy task.${RESET}`; - if (this.busyDemoStart === null) return [idleHint]; - const elapsed = Date.now() - this.busyDemoStart; + return [ + this.renderDemoLine("s", "longtask", "Fake task", this.busyDemoStart), + ` ${DIM}Press \`n\` for a program that rings the bell itself.${RESET}`, + this.renderDemoLine("x", "slowbuild", "Slow build", this.commandExitDemoStart), + ]; + } + + /** One "press KEY / running… / done" status line for an alert demo. */ + private renderDemoLine( + key: string, + command: string, + label: string, + startedAt: number | null, + ): string { + if (startedAt === null) { + return ` ${DIM}Press \`${key}\` to start a fake \`${command}\`.${RESET}`; + } + const elapsed = Date.now() - startedAt; if (elapsed < BUSY_DEMO_DURATION_MS) { const spinner = SPINNER_FRAMES[this.spinnerFrame]; const secsLeft = Math.max(1, Math.ceil((BUSY_DEMO_DURATION_MS - elapsed) / 1_000)); - return [ - ` ${fg(33)}${spinner}${RESET} Fake task will finish in ${BOLD}${secsLeft}${RESET} seconds.`, - ]; + return ` ${fg(33)}${spinner}${RESET} ${label} finishes in ${BOLD}${secsLeft}${RESET} seconds.`; } - return [ - ` ${fg(32)}✓${RESET} Fake task finished. ${DIM}Press \`s\` to start another one.${RESET}`, - ]; + return ` ${fg(32)}✓${RESET} ${label} finished. ${DIM}Press \`${key}\` for another.${RESET}`; } private renderItem(item: Item, index: number, activeIndex: number): string[] { @@ -952,6 +1008,7 @@ export class TutRunner implements InteractiveProgram { this.stopFlappyTicks(); this.flappy = null; this.busyDemoStart = null; + this.commandExitDemoStart = null; this.stateUnsub?.(); this.stateUnsub = null; this.pocketTouchModeUnsub?.(); diff --git a/website/src/lib/tutorial-shell.ts b/website/src/lib/tutorial-shell.ts index cb1efa97..c1f41217 100644 --- a/website/src/lib/tutorial-shell.ts +++ b/website/src/lib/tutorial-shell.ts @@ -18,11 +18,26 @@ export type StartProgram = ( onExit: () => void, ) => InteractiveProgram | null; +// VS Code shell-integration sequences (`docs/specs/terminal-escapes.md`). The +// fake shell reports command boundaries like a real integrated shell, so +// playground panes get the same terminal state a native pane would. This is +// load-bearing, not decoration: WATCHING is keyed on the running command's name +// (`docs/specs/alert.md`), so without these no playground pane could be alerted +// on at all — the bell would only ever report "nothing is running". +const OSC_PROMPT_START = '\x1b]633;A\x07'; +const OSC_PROMPT_END = '\x1b]633;B\x07'; +const OSC_COMMAND_START = '\x1b]633;C\x07'; +const oscCommandLine = (commandLine: string) => `\x1b]633;E;${commandLine}\x07`; +const oscCommandFinish = (exitCode: number) => `\x1b]633;D;${exitCode}\x07`; + +/** Exit code a POSIX shell uses for an unrecognized command. */ +const EXIT_COMMAND_NOT_FOUND = 127; + /** * Minimal browser shell for playground panes. Provides line editing, - * command history, and dispatch to interactive programs (`tut`, - * `ascii-splash`, ...) supplied by the host. Output goes through - * `sendOutput`; input bytes arrive via `handleInput`. + * command history, dispatch to interactive programs (`tut`, `ascii-splash`, + * ...) supplied by the host, and shell-integration reporting for all of it. + * Output goes through `sendOutput`; input bytes arrive via `handleInput`. */ export class TutorialShell { private lineBuffer = ''; @@ -33,6 +48,7 @@ export class TutorialShell { private startProgram: StartProgram; private activeProgram: InteractiveProgram | null = null; private promptShown = false; + private runningCommandLine: string | null = null; constructor( sendOutput: SendOutput, @@ -47,22 +63,50 @@ export class TutorialShell { dispose(): void { this.activeProgram?.dispose(); this.activeProgram = null; + this.runningCommandLine = null; } /** Programmatically run a command. Used to auto-launch `tut` on mount. */ runCommand(name: string, args: string[] = []): void { if (this.activeProgram) return; + if (!this.launch(name, args, [name, ...args].join(' '))) { + this.sendOutput(`${fg(90)}Unknown command: ${name}${RESET}\r\n`); + this.finishCommand(EXIT_COMMAND_NOT_FOUND); + } + } + + /** + * Re-announce the running program's command line. The alert tutorial + * temporarily reports a different command on a pane to demo a WATCHING rule + * (`docs/specs/tutorial.md`); this restores the truth afterwards without + * disturbing the program's screen. No-op at a prompt. + */ + reportRunningCommand(): void { + if (this.runningCommandLine === null) return; + this.sendOutput(oscCommandLine(this.runningCommandLine) + OSC_COMMAND_START); + } + + /** + * Announce and start `name`. Returns false when the command is unknown, in + * which case the caller prints its own message and closes the run out. + */ + private launch(name: string, args: string[], commandLine: string): boolean { + this.runningCommandLine = commandLine; + this.sendOutput(oscCommandLine(commandLine) + OSC_COMMAND_START); const program = this.startProgram(name, args, () => { this.activeProgram = null; - this.showPrompt(); + this.finishCommand(0); }); - if (!program) { - this.sendOutput(`${fg(90)}Unknown command: ${name}${RESET}\r\n`); - this.showPrompt(); - return; - } + if (!program) return false; this.activeProgram = program; this.activeProgram.start(); + return true; + } + + private finishCommand(exitCode: number): void { + this.runningCommandLine = null; + this.sendOutput(oscCommandFinish(exitCode)); + this.showPrompt(); } handleInput(data: string): void { @@ -168,23 +212,16 @@ export class TutorialShell { return; } const [name, ...args] = cmd.split(/\s+/); - const program = this.startProgram(name, args, () => { - this.activeProgram = null; - this.showPrompt(); - }); - if (!program) { + if (!this.launch(name, args, cmd)) { this.sendOutput( `${fg(90)}Unknown command. Try ${fg(36)}tut${fg(90)}, ${fg(36)}ascii-splash${fg(90)}, or ${fg(36)}changelog${fg(90)}.${RESET}\r\n`, ); - this.showPrompt(); - return; + this.finishCommand(EXIT_COMMAND_NOT_FOUND); } - this.activeProgram = program; - this.activeProgram.start(); } private showPrompt(): void { - this.sendOutput(PROMPT); + this.sendOutput(OSC_PROMPT_START + PROMPT + OSC_PROMPT_END); this.promptShown = true; } } diff --git a/website/src/pages/PlaygroundDesktop.tsx b/website/src/pages/PlaygroundDesktop.tsx index 6220ebd0..4a4cad92 100644 --- a/website/src/pages/PlaygroundDesktop.tsx +++ b/website/src/pages/PlaygroundDesktop.tsx @@ -7,7 +7,12 @@ import { ThemePicker } from "dormouse-lib/components/ThemePicker"; import { PlaygroundShellRegistry } from "../lib/playground-shells"; import { TutorialState } from "../lib/tutorial-state"; import { TutDetector } from "../lib/tut-detector"; -import { BUSY_DEMO_DURATION_MS, BUSY_DEMO_INTERVAL_MS, TutRunner } from "../lib/tut-runner"; +import { + BUSY_DEMO_DURATION_MS, + BUSY_DEMO_INTERVAL_MS, + TutRunner, + WATCH_DEMO_COMMAND_MS, +} from "../lib/tut-runner"; import { ChangelogRunner } from "../lib/changelog-runner"; import { POCKET_PLAYGROUND_PATH, usePreferredPlayground } from "../lib/playground-routing"; import { @@ -15,12 +20,48 @@ import { DESKTOP_PLAYGROUND_LAYOUT, PANE_BOXED, PANE_MAIN, + PANE_SPLASH, type DesktopPaneSpec, } from "../lib/playground-desktop-layout"; type FakePtyAdapter = import("dormouse-lib/lib/platform/fake-adapter").FakePtyAdapter; +type TerminalRegistry = typeof import("dormouse-lib/lib/terminal-registry"); type WallEvent = import("dormouse-lib/components/Wall").WallEvent; +/** The two panes the alert section drives; the third hosts the runner itself. */ +const ALERT_DEMO_PANES = [PANE_BOXED, PANE_SPLASH] as const; + +function sendToPane( + adapter: FakePtyAdapter, + registry: TerminalRegistry, + paneId: string, + data: string, +): void { + adapter.sendOutput(registry.resolveTerminalSessionId(paneId), data); +} + +/** + * Report a foreground command the way a shell with integration would. The OSCs + * are stripped from visible output, so this never disturbs the TUI the pane is + * already drawing. + */ +function startFakeCommand( + adapter: FakePtyAdapter, + registry: TerminalRegistry, + paneId: string, + commandLine: string, +): void { + sendToPane(adapter, registry, paneId, `\x1b]633;E;${commandLine}\x07\x1b]633;C\x07`); +} + +function finishFakeCommand( + adapter: FakePtyAdapter, + registry: TerminalRegistry, + paneId: string, +): void { + sendToPane(adapter, registry, paneId, "\x1b]633;D;0\x07"); +} + function DesktopPlaygroundUnavailable() { return ( <div className="min-h-screen bg-[var(--color-bg)] text-[var(--color-text)]"> @@ -60,7 +101,8 @@ function PlaygroundDesktopExperience() { const autoStartedRef = useRef<Set<string>>(new Set()); const spawnUnsubRef = useRef<(() => void) | null>(null); const busyDemoDisposeRef = useRef<(() => void) | null>(null); - const alertDemoPaneIdRef = useRef<string | null>(null); + const busyDemoFinishTimerRef = useRef<number | null>(null); + const demoTimersRef = useRef<number[]>([]); const handleOpenGithub = useCallback(() => { window.open( @@ -108,11 +150,7 @@ function PlaygroundDesktopExperience() { const tutorialState = new TutorialState(); stateRef.current = tutorialState; - const detector = new TutDetector(tutorialState, registry, mouseSelection, { - onWatchingDemoPaneChange: (id) => { - alertDemoPaneIdRef.current = id; - }, - }); + const detector = new TutDetector(tutorialState, registry, mouseSelection); detectorRef.current = detector; // The detector now reads app state entirely through the WallEvent stream and // the activity/mouse stores — no tiling api. `start()` seeds its prev-state @@ -128,15 +166,59 @@ function PlaygroundDesktopExperience() { terminalId, state: tutorialState, onExit, + // WATCHING is keyed on the running command, so the demo has to + // report one through shell integration. Both alert panes run the + // same fake `longtask`, which is what lets one bell click light + // up the other pane (docs/specs/alert.md). onTriggerBusyDemo: () => { - const paneId = alertDemoPaneIdRef.current ?? PANE_BOXED; - const sessionId = registry.resolveTerminalSessionId(paneId); busyDemoDisposeRef.current?.(); + if (busyDemoFinishTimerRef.current !== null) { + window.clearTimeout(busyDemoFinishTimerRef.current); + busyDemoFinishTimerRef.current = null; + } + for (const paneId of ALERT_DEMO_PANES) { + startFakeCommand(adapter, registry, paneId, "longtask"); + } + // Always pump the changelog pane: it is the quiet one, so it can + // actually go silent and ring. ascii-splash animates forever, so + // it stays BUSY — which is a fine demo of the rule applying, but + // it could never reach ALERT_RINGING. busyDemoDisposeRef.current = adapter.pumpActivity( - sessionId, + registry.resolveTerminalSessionId(PANE_BOXED), BUSY_DEMO_DURATION_MS, BUSY_DEMO_INTERVAL_MS, ); + busyDemoFinishTimerRef.current = window.setTimeout(() => { + busyDemoFinishTimerRef.current = null; + for (const paneId of ALERT_DEMO_PANES) { + finishFakeCommand(adapter, registry, paneId); + // The pane's real program is still drawing, so put its + // actual command line back rather than leaving the pane + // looking idle. + shellRegistryRef.current?.ensureShell(paneId).reportRunningCommand(); + } + }, WATCH_DEMO_COMMAND_MS); + }, + // Terminal reports need no rule at all — this is a raw OSC 777 + // notification, parsed by the same code a real PTY feeds. + onTriggerNotifyDemo: () => { + sendToPane( + adapter, + registry, + PANE_BOXED, + "\x1b]777;notify;Build finished;3 packages rebuilt\x07", + ); + }, + // An unwatched command, so the command-exit track owns the bell: + // the user attends the pane, leaves, and the exit rings. + onTriggerCommandExitDemo: () => { + startFakeCommand(adapter, registry, PANE_SPLASH, "slowbuild"); + demoTimersRef.current.push( + window.setTimeout(() => { + finishFakeCommand(adapter, registry, PANE_SPLASH); + shellRegistryRef.current?.ensureShell(PANE_SPLASH).reportRunningCommand(); + }, BUSY_DEMO_DURATION_MS), + ); }, onTogglePlaceToPaste: () => setPlaceToPasteOpen((open) => !open), onOpenGithub: handleOpenGithub, @@ -189,11 +271,16 @@ function PlaygroundDesktopExperience() { shellRegistryRef.current = null; stateRef.current = null; autoStartedRef.current.clear(); - alertDemoPaneIdRef.current = null; spawnUnsubRef.current?.(); spawnUnsubRef.current = null; busyDemoDisposeRef.current?.(); busyDemoDisposeRef.current = null; + if (busyDemoFinishTimerRef.current !== null) { + window.clearTimeout(busyDemoFinishTimerRef.current); + busyDemoFinishTimerRef.current = null; + } + for (const timer of demoTimersRef.current) window.clearTimeout(timer); + demoTimersRef.current = []; }; }, [handleOpenGithub, handleOpenPocket, tryAutoStart]);