From f594a824913f9ee1a83b6fe23aa4028578cbcbfc Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 28 Jul 2026 13:02:40 -0700 Subject: [PATCH 1/6] Alerts: add Alarm settings with inactivity timeout and spoken alarms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an app-global Alarm settings dialog, opened from the far right of the baseboard, and makes two of its settings real: - Inactivity timeout — T_USER_ATTENTION moves from a module constant to instance state on AlertManager. Both of its uses follow the setting: attention expiry, and the minimum runtime a command needs before its exit may ring. Changing it re-arms a live timer from that moment, so shortening the window applies immediately. - Spoken alarms — a ring left unattended for a configurable delay speaks the pane's name through speechSynthesis. Only the derived label is ever spoken, never ActivityNotification text: any program can emit OSC 9, and speaking its text would let it choose what the machine says out loud. Push notifications render disabled; the design is staged in alert.md's new Future section, and the fields already persist so landing it changes no stored shape. Settings persist to `dormouse:alert-settings` and reach the host the same way the WATCHING rule set does. That shape is required, not incidental: under VS Code the AlertManager lives in the extension host and each webview has its own origin, hence its own localStorage. The whole blob is relayed so two webviews cannot disagree about whether alarms speak, and the host revalidates everything — these are renderer-supplied numbers that become host timers. Also fixes the baseboard door-fitting budget, which never subtracted anything on the right: `notice` was unaccounted for, and the overflow arrow and notice each carried their own ml-auto, splitting the free space between them. They are now one ResizeObserver-measured cluster, which also survives a notice that appears from its own internal state without re-rendering the baseboard. Shared vocabulary extracted along the way: OnOffSwitch and NumericInput into design.tsx, the watched-command list into WatchedCommandList (shared with the bell popover), and the id-keyed pane-label derivation into deriveSessionLabel (shared with the dev-server chip). Co-Authored-By: Claude Opus 5 (1M context) --- docs/specs/alert.md | 64 +++++- docs/specs/layout.md | 5 +- docs/specs/transport.md | 7 + docs/specs/vscode.md | 9 + lib/.storybook/preview.ts | 13 +- lib/src/components/AlertSettingsDialog.tsx | 177 +++++++++++++++++ lib/src/components/Baseboard.tsx | 84 ++++++-- lib/src/components/TodoAlertDialog.tsx | 50 +---- lib/src/components/Wall.tsx | 4 + lib/src/components/WatchedCommandList.tsx | 42 ++++ lib/src/components/design.tsx | 82 +++++++- .../wall/AgentBrowserScreenModal.tsx | 11 +- lib/src/components/wall/use-alert-speech.ts | 11 ++ .../components/wall/use-dev-server-ports.ts | 19 +- lib/src/lib/alert-manager.test.ts | 82 ++++++++ lib/src/lib/alert-manager.ts | 28 ++- lib/src/lib/alert-settings-host.ts | 60 ++++++ lib/src/lib/alert-settings.test.ts | 141 ++++++++++++++ lib/src/lib/alert-settings.ts | 123 ++++++++++++ lib/src/lib/alert-speech.test.ts | 182 ++++++++++++++++++ lib/src/lib/alert-speech.ts | 95 +++++++++ lib/src/lib/platform/fake-adapter.ts | 6 + lib/src/lib/platform/types.ts | 10 + lib/src/lib/platform/vscode-adapter.ts | 21 ++ lib/src/lib/session-activity-store.ts | 11 +- lib/src/lib/session-label.ts | 26 +++ lib/src/lib/terminal-registry.ts | 11 ++ lib/src/remote/client/remote-adapter.ts | 3 + .../stories/AlertSettingsDialog.stories.tsx | 90 +++++++++ lib/src/stories/Baseboard.stories.tsx | 33 +++- standalone/src/browser-sidecar-adapter.ts | 5 + standalone/src/tauri-adapter.ts | 11 ++ vscode-ext/src/message-router.ts | 17 ++ vscode-ext/src/message-types.ts | 6 +- 34 files changed, 1437 insertions(+), 102 deletions(-) create mode 100644 lib/src/components/AlertSettingsDialog.tsx create mode 100644 lib/src/components/WatchedCommandList.tsx create mode 100644 lib/src/components/wall/use-alert-speech.ts create mode 100644 lib/src/lib/alert-settings-host.ts create mode 100644 lib/src/lib/alert-settings.test.ts create mode 100644 lib/src/lib/alert-settings.ts create mode 100644 lib/src/lib/alert-speech.test.ts create mode 100644 lib/src/lib/alert-speech.ts create mode 100644 lib/src/lib/session-label.ts create mode 100644 lib/src/stories/AlertSettingsDialog.stories.tsx diff --git a/docs/specs/alert.md b/docs/specs/alert.md index cdc4ff69..accbb7e9 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -17,7 +17,7 @@ Internally these are three independent tracks — `watchingRingingCommand` + the ## Non-goals - 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 native OS notifications, browser notifications, or separate progress-bar widget. The one audible channel is the opt-in spoken alarm below, which says a Pane name and nothing else; Dormouse plays no sound effects. - 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. - No Door-specific alert menu that changes the Door actions defined in `docs/specs/layout.md`. @@ -47,9 +47,11 @@ Persist only `todo` and the sanitized `notification` (plus `status` for diagnost These do not count as attention: mere visibility, command-mode selection, hover, a Door existing in the baseboard, or reattaching a Door with `d` into command mode. -Attention is lost when the attention timer expires, the app loses focus, the attended Session is minimized or destroyed, or another Session becomes attended. `T_USER_ATTENTION` also acts as the minimum runtime for command-exit alerts. +Attention is lost when the attention timer expires, the app loses focus, the attended Session is minimized or destroyed, or another Session becomes attended. `T_USER_ATTENTION` also acts as the minimum runtime for command-exit alerts: a command that ran for less than the walk-away window was probably watched, so its exit does not ring. -Source of truth: `cfg.alert` in `lib/src/cfg.ts` defines `T_USER_ATTENTION` and the other timer defaults and their purpose. +`T_USER_ATTENTION` is the user-facing **inactivity timeout** (Alarm settings below). It is instance state on the `AlertManager`, not a module constant, and both uses above follow the configured value. Changing it re-arms a live attention timer from that moment, so shortening the window applies immediately rather than after the window already running. + +Source of truth: `cfg.alert` in `lib/src/cfg.ts` defines the shipped default for `T_USER_ATTENTION` and the other timer defaults and their purpose; `AlertManager.setInactivityTimeoutMs` installs the configured override. ## WATCHING Track @@ -127,6 +129,44 @@ Clearing behavior: `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. +## Alarm settings + +A second app-global store sits beside the WATCHING rule set: the alarm settings, edited in one dialog reached from the far right of the baseboard. + +Source of truth: `AlertSettings` in `lib/src/lib/alert-settings.ts` (renderer mirror, persisted at `dormouse:alert-settings`) and `lib/src/lib/alert-settings-host.ts` (multi-renderer coordinator). + +| Field | Meaning | +|---|---| +| `inactivityTimeoutMs` | `T_USER_ATTENTION` — the walk-away window defined under Attention above. | +| `speakEnabled` / `speakDelayMs` | Spoken alarms, below. | +| `pushEnabled` / `pushDelayMs` | Reserved for the push notifications in `## Future`. Persisted and relayed so the field shape does not change when push lands, but the UI renders the whole group disabled and nothing reads them. | + +Rules: + +- Every field is validated and clamped on read *and* on write (`normalizeAlertSettings`), so a hand-edited `localStorage` blob or a hostile message can never install a `NaN` or absurd timer. Unknown keys are dropped and missing keys defaulted, so the blob evolves additively with no version field. The shipped defaults come from `cfg.alert`, keeping `lib/src/cfg.ts` the one place a default is written down. +- Distribution mirrors the WATCHING rule set exactly, and for the same reason — in VS Code the `AlertManager` lives in the shared extension host, and each webview has its own origin and therefore its own `localStorage`. The first renderer seeds the host, an edit replaces the host's copy, and the host broadcasts its canonical snapshot to every webview. The **whole** blob is relayed, not just the field the host consumes, so two webviews cannot disagree about whether alarms speak. The host revalidates everything it receives. +- Single-webview hosts (standalone, browser sidecar, Storybook) own the `AlertManager` in the renderer, so they apply the settings inline and broadcast nothing back. + +### Spoken alarms + +When a Session transitions into `ALERT_RINGING` and is still ringing `speakDelayMs` later, Dormouse says that Pane's name out loud. Source of truth: `lib/src/lib/alert-speech.ts`, armed once by `useAlertSpeech` in `Wall`. + +- Any of the three tracks qualifies. "Not attended" is track-agnostic. +- **Only the derived Pane label is ever spoken** — never `ActivityNotification` title or body. This is a security rule, not a style one: any program can emit `OSC 9`, and speaking its text would let it choose what the machine says out loud. The label comes from `deriveSessionLabel` in `lib/src/lib/session-label.ts` — the one id-keyed label derivation, shared with the dev-server chip — falling back to `terminal`. +- The trigger is a fresh transition into `ALERT_RINGING`, held to the same standard as the bell (WATCHING Track, last bullet). A Session observed for the first time *already* ringing never speaks, which is what keeps a restore or a reconnect replaying a latched ring silent. +- Attending, dismissing, or killing the Pane during the delay cancels the utterance; so does switching the setting off. Both the ring and the setting are re-read when the timer fires rather than captured when it was scheduled. +- One utterance per ring. A Session that rings, is cleared, and rings again speaks twice. Sessions ring and speak independently. +- Renderer-side, via `window.speechSynthesis`. Where that is absent — Tauri on Linux (WebKitGTK ships no speech backend), or a test environment — speaking is a silent no-op rather than an error. `speak()` is the single seam a native host path would replace. +- Desktop shell only: `MobileWall` / Pocket does not arm it and has no settings UI. + +### Settings dialog + +Reached from a control at the far right of the baseboard; placement and the baseboard's right cluster belong to `docs/specs/layout.md`. Source of truth: `lib/src/components/AlertSettingsDialog.tsx`. + +- Lists every watched command with a remove control, and **cannot add one**. WATCHING is keyed on a running command's name, so creating a rule stays a bell click / `a` press in the tab running it; the empty state says so. This dialog and the bell dialog are the two places a rule set on a since-closed Pane can be found and removed — they render the same `WatchedCommandList`, so the list has one implementation. +- Delays are shown in seconds and committed on blur or `Enter`, never per keystroke — typing `3` on the way to `30` must not briefly install a 3-second timer. An out-of-range or empty entry snaps back to whatever the store clamped it to. +- The push group renders inside a disabled `
` with no device name. + ## Workspace union > See `docs/specs/glossary.md` for the Workspace / Window containers and the definitions of the three union fields (`ringing`, `todo`, `count`). @@ -193,10 +233,28 @@ Alert-specific robustness requirements: multiple Sessions ring independently; mi | `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/alert-settings.ts` | Persisted alarm settings, their validation/clamping, and their push to the host | +| `lib/src/lib/alert-settings-host.ts` | First-seed + replace/broadcast coordinator for the settings blob | +| `lib/src/lib/alert-speech.ts` | Fresh-ring detection, the delay, and the spoken Pane label | +| `lib/src/lib/session-label.ts` | `deriveSessionLabel`: the id-keyed Surface label over the live stores | +| `lib/src/components/wall/use-alert-speech.ts` | Arms spoken alarms for the lifetime of the desktop shell | | `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, 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 | | `lib/src/components/TodoAlertDialog.tsx` | TODO + WATCHING-rule switches, notification detail, watched-command list | +| `lib/src/components/AlertSettingsDialog.tsx` | App-global Alarm settings: rule list, inactivity timeout, spoken alarms, disabled push group | +| `lib/src/components/WatchedCommandList.tsx` | The WATCHING rule set with per-rule remove, shared by both dialogs | | `lib/src/components/Door.tsx` | Door bell + TODO display | + +## Future + +**Scope: alarm-push** — deliver an unattended alarm to a phone, so the user learns about it away from the machine. `pushEnabled` / `pushDelayMs` already persist and relay (Alarm settings above) and the dialog already renders the group, disabled, so landing this changes no stored shape. + +Staged in order: + +1. **Device registry.** Reuse the paired-Client identities the Host already holds — `docs/specs/remote-security-model.md` defines the ACL, and a registered Pocket Client is exactly the device a push should reach. The dialog's "Push will be sent to —" line names the chosen device once there is one to name. +2. **Delivery.** A push travels Host -> Server -> Client. The Server relay is the only component that can reach a backgrounded phone, so this needs a wire method beyond the terminal-only protocol-v1 in `docs/specs/remote-api.md`. +3. **Payload rule.** Same rule as spoken alarms: the Pane label only, never `ActivityNotification` text. Terminal output must not be able to choose what a push says. +4. **Cancellation.** Attending on the laptop before `pushDelayMs` cancels, matching the speech path. Whether a delivered push can be recalled once the user attends is undecided. diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 723f286a..d2405432 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -126,6 +126,8 @@ Below the content area is the baseboard (`h-7`, 28px). It is visible by default `Wall` accepts `showBaseboard={false}` for constrained embedders such as the website's mobile Pocket playground, where a separate bottom navigation owns the area below the terminal and door workflows are outside the prototype scope. The main app shell keeps the default `showBaseboard=true`. +The far right of the baseboard is a single flex cluster, right-aligned as a unit: the `N more →` overflow arrow, then the host-supplied `notice` slot (standalone puts the update banner there), then an always-present **Alarm settings** button opening the dialog specified in `docs/specs/alert.md` → Alarm settings. Every baseboard-level button shares one class constant in `Baseboard.tsx`. The cluster's always-present part is measured and subtracted from the door-fitting budget below; the overflow arrow stays out of that measurement because its presence is an *output* of the fit, so measuring it would feed back into its own input. + When a session is minimized, it becomes a **door** on the baseboard. The door displays the same derived terminal label as the pane header, a TODO badge (if set), and an alert bell icon with activity dot. It uses the bottom edge of the window as its bottom border, with left, top, and right borders using the shared terminal top radius from `lib/src/components/design.tsx` — resembling a mouse hole and matching pane rounding. Door dimensions: `min-w-[68px] max-w-[220px] h-6`. ### Door interaction @@ -140,6 +142,7 @@ When a session is minimized, it becomes a **door** on the baseboard. The door di Doors are measured in a hidden off-screen container first: +- Subtract the measured right cluster (notice + alarm settings) and its gap from the available width before fitting anything — that space is never available to doors. - If they all fit, display them all. If there is remaining space, show the keyboard shortcut hint. - If they do not all fit: - Reserve space for a `N more →` button on the right edge @@ -480,7 +483,7 @@ The refill adopts the replacement (`selectPane`) only when the current selection | `lib/src/components/wall/use-session-persistence.ts` | Debounced layout/session save, flush requests, pagehide, PTY exit, file-drop paste routing | | `lib/src/components/wall/use-dor-control.ts` | The `dor` CLI's webview control-plane hook (`useDorControl`): the `dormouse:control-request` handler for `surface.*` methods plus its surface-resolution/param-coercion/command-quoting helpers (`docs/specs/dor-cli.md`) | | `lib/src/components/wall/use-window-focused.ts` | Window focus tracking hook for header and selection overlay dimming | -| `lib/src/components/Baseboard.tsx` | Always-visible bottom strip with door components, overflow arrows, and shortcut hints | +| `lib/src/components/Baseboard.tsx` | Always-visible bottom strip with door components, overflow arrows, shortcut hints, and the right cluster (notice slot + alarm settings button) | | `lib/src/components/Door.tsx` | Individual door element — mouse-hole styled button with alert/TODO indicators | | `lib/src/components/TerminalPane.tsx` | Thin xterm.js mount point — mounts/unmounts persistent session elements | | `lib/src/lib/terminal-registry.ts` | Public facade preserving registry imports | diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 65a3a9a5..542ca5d6 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -100,6 +100,13 @@ Workspace union status (`docs/specs/alert.md`) adds no new message. Standalone c | 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 | `alert:initializeSettings` | `WebviewMessage` | Offer the renderer's persisted alarm settings (`docs/specs/alert.md` -> Alarm settings) as the startup seed. Same first-seed-wins rule as `alert:initializeWatchedCommands`. | +| Webview → host | `alert:updateSettings` | `WebviewMessage` | Replace the host's alarm settings after a user edit. The whole blob travels, including the fields only renderers consume, so every webview agrees. | +| Host → webview | `alert:settings` | `ExtensionMessage` | Canonical settings snapshot broadcast after initialization or update; each renderer replaces and persists its local mirror. | + +Both settings messages carry renderer-supplied numbers that become host timers, so the host **must** revalidate rather than trust them: `AlertSettingsHost` runs every inbound blob through `normalizeAlertSettings`, which drops unknown keys, defaults missing ones, and clamps each delay into range. A webview cannot install a `NaN` or absurd attention window. The two directions share one adapter method — `alertPublishSettings(settings, { seed })` — because the seed/replace distinction only picks a message type; it is not a different payload. + +Note the per-store cost: each app-global store relayed this way spends one `PlatformAdapter` push method plus an on/off listener pair, three message types, and a host coordinator with its own subscribe/unsubscribe. Two stores (WATCHING rules, alarm settings) is worth the directness. A third should instead collapse them into one keyed channel with a host-side key→normalizer registry, rather than paying the tax again. | 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/vscode.md b/docs/specs/vscode.md index afbbc6dc..9eb753f7 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -136,6 +136,15 @@ truth: `WatchedCommandHost` in `lib/src/lib/watched-command-host.ts` and the alert cases in `vscode-ext/src/message-router.ts`. +The alarm settings (`docs/specs/alert.md` → Alarm settings) ride the same shape +for the same reason, through `alert:initializeSettings` / +`alert:updateSettings` / `alert:settings` and `AlertSettingsHost` in +`lib/src/lib/alert-settings-host.ts`. Two details are specific to it: the host +consumes only `inactivityTimeoutMs` (it installs it on the shared +`AlertManager`) yet relays the whole blob so renderer-only fields stay in sync +across webviews, and it revalidates every inbound blob — these are +renderer-supplied numbers that become host timers. + ### 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 ad3bc239..b27b1201 100644 --- a/lib/.storybook/preview.ts +++ b/lib/.storybook/preview.ts @@ -5,6 +5,7 @@ import '../src/theme.css'; import '../src/index.css'; import { initPlatform, type FakePtyAdapter, type FakeScenario } from '../src/lib/platform'; import { + applyAlertSettingsFromHost, clearPrimedActivity, disposeAllSessions, getActivitySnapshot, @@ -15,6 +16,7 @@ import { resetTerminalPaneState, setCommandWatched, type ActivityState, + type AlertSettings, type TerminalPaneState, } from '../src/lib/terminal-registry'; import { computeDynamicPalette } from '../src/lib/themes/dynamic-palette'; @@ -197,6 +199,9 @@ const preview: Preview = { const primedWatchedCommands = context.parameters?.primedWatchedCommands as | readonly string[] | undefined; + const primedAlertSettings = context.parameters?.primedAlertSettings as + | Partial + | undefined; const platform = fakePlatform as FakePtyAdapter; if (scenario) platform.setDefaultScenario(scenario); @@ -207,6 +212,11 @@ const preview: Preview = { const applyPrimedState = () => { applyWatchedCommands(primedWatchedCommands ?? []); + // Alarm settings (`docs/specs/alert.md` -> Alarm settings) leak between + // stories the same way the rule set above does. No merge needed: + // applyAlertSettingsFromHost normalizes, so a partial — or undefined — + // resets every field it does not name. + applyAlertSettingsFromHost(primedAlertSettings); clearPrimedActivity(); for (const id of getTerminalPaneStateSnapshot().keys()) { removeTerminalPaneState(id); @@ -237,6 +247,7 @@ const preview: Preview = { window.cancelAnimationFrame(raf1); window.cancelAnimationFrame(raf2); applyWatchedCommands([]); + applyAlertSettingsFromHost(undefined); clearPrimedActivity(); for (const id of getTerminalPaneStateSnapshot().keys()) { removeTerminalPaneState(id); @@ -244,7 +255,7 @@ const preview: Preview = { platform.clearDefaultScenario(); disposeAllSessions(); }; - }, [platform, primedSessionState, primedTerminalState, primedWatchedCommands]); + }, [platform, primedSessionState, primedTerminalState, primedWatchedCommands, primedAlertSettings]); return createElement(Story); }, diff --git a/lib/src/components/AlertSettingsDialog.tsx b/lib/src/components/AlertSettingsDialog.tsx new file mode 100644 index 00000000..18ef86af --- /dev/null +++ b/lib/src/components/AlertSettingsDialog.tsx @@ -0,0 +1,177 @@ +import { useRef, useState, useSyncExternalStore } from 'react'; +import { + ModalCloseButton, + ModalFrame, + NumericInput, + OnOffSwitch, + Shortcut, + UNDER_SWITCH_INDENT, +} from './design'; +import { WatchedCommandList } from './WatchedCommandList'; +import { + clampAlertDelayMs, + getAlertSettings, + getWatchedCommandsSnapshot, + subscribeToAlertSettings, + subscribeToWatchedCommands, + updateAlertSettings, +} from '../lib/terminal-registry'; + +const TITLE_ID = 'alert-settings-dialog-title'; + +/** + * The app-global Alarm settings (`docs/specs/alert.md` -> Alarm settings), + * opened from the far right of the baseboard. + * + * Rules are removable here but not addable: WATCHING is keyed on a running + * command's name, so a rule is created by pressing `a` in the tab running it. + * This dialog and the bell popover are the two places a rule set on a + * since-closed Pane can be found and removed. + */ +export function AlertSettingsDialog({ onClose }: { onClose: () => void }) { + const watched = useSyncExternalStore(subscribeToWatchedCommands, getWatchedCommandsSnapshot); + const settings = useSyncExternalStore(subscribeToAlertSettings, getAlertSettings); + const closeRef = useRef(null); + + return ( + +
+

+ Alarm settings +

+ +
+ +
+
+ Animation watcher enabled for commands that start with: +
+ {watched.length > 0 ? ( +
+ +
+ ) : ( +
+ Nothing yet. Start a command, then press a in its tab to + alert on every tab running it. +
+ )} +
+ +
+ updateAlertSettings({ inactivityTimeoutMs })} + /> +
+ User has walked away after this much inactivity. +
+
+ +
+ updateAlertSettings({ speakEnabled })} + /> +
+ updateAlertSettings({ speakDelayMs })} + /> +
+
+ + {/* Push is designed but not built — see `docs/specs/alert.md` -> Future. + `disabled` on the fieldset natively disables every control inside, so + these rows need no handlers and no per-control `disabled`. */} +
+ +
+ +
Push will be sent to —
+
+
+
+ ); +} + +function SwitchRow({ + label, + on, + onChange, +}: { + label: string; + on: boolean; + /** Absent inside a disabled fieldset, where the switch can never fire. */ + onChange?: (next: boolean) => void; +}) { + return ( +
+ onChange?.(true)} onDisable={() => onChange?.(false)} label={label} /> + {label} +
+ ); +} + +/** + * A delay expressed in seconds, committed on blur or Enter rather than per + * keystroke: typing "3" on the way to "30" must not briefly install a 3s timer. + * + * `draft === null` means "show the stored value", so committing always clears + * the draft and lets the store win. That covers the snap-back for an empty or + * out-of-range entry — including the case where the clamp makes the store a + * no-op and no change notification arrives. + */ +function SecondsField({ + label, + valueMs, + disabled, + onCommit, +}: { + label: string; + valueMs: number; + disabled?: boolean; + /** Absent inside a disabled fieldset, where the field can never be edited. */ + onCommit?: (ms: number) => void; +}) { + const [draft, setDraft] = useState(null); + + const commit = (): void => { + const seconds = Number(draft ?? ''); + setDraft(null); + if (draft === null || !Number.isFinite(seconds) || seconds <= 0) return; + onCommit?.(clampAlertDelayMs(seconds * 1000)); + }; + + return ( + + ); +} diff --git a/lib/src/components/Baseboard.tsx b/lib/src/components/Baseboard.tsx index 6b677587..31bf7383 100644 --- a/lib/src/components/Baseboard.tsx +++ b/lib/src/components/Baseboard.tsx @@ -1,7 +1,8 @@ -import { useRef, useState, useMemo, useLayoutEffect, useContext, useSyncExternalStore, type ReactNode } from 'react'; -import { CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react'; +import { useEffect, useRef, useState, useMemo, useLayoutEffect, useContext, useSyncExternalStore, type ReactNode } from 'react'; +import { CaretLeftIcon, CaretRightIcon, SlidersHorizontalIcon } from '@phosphor-icons/react'; +import { AlertSettingsDialog } from './AlertSettingsDialog'; import { Door } from './Door'; -import { DoorElementsContext } from './wall/wall-context'; +import { DialogKeyboardContext, DoorElementsContext } from './wall/wall-context'; import type { DooredItem } from './wall/wall-types'; import { IS_MAC } from '../lib/platform'; import { @@ -14,6 +15,10 @@ import { } from '../lib/terminal-registry'; import { createTerminalPaneState, deriveSurfaceLabel, type TerminalPaneState } from '../lib/terminal-state'; +/** Shared look for every baseboard-level button (DESIGN.md -> Navigation). */ +const BASEBOARD_BUTTON_CLASS = + 'flex h-5 shrink-0 items-center gap-1 rounded px-1.5 pb-px text-sm font-medium font-mono text-muted transition-colors hover:bg-surface-raised hover:text-foreground'; + export interface BaseboardProps { items: DooredItem[]; onReattach: (item: DooredItem) => void; @@ -38,7 +43,18 @@ export function Baseboard({ items, onReattach, notice, onDoorDragStart }: Basebo const [startIndex, setStartIndex] = useState(0); const doorWidthsRef = useRef([]); const arrowMeasureEl = useRef(null); + const rightClusterEl = useRef(null); + const [rightClusterWidth, setRightClusterWidth] = useState(0); const layoutMetrics = useRef({ doorGap: 0, arrowWidth: 0 }); + const [settingsOpen, setSettingsOpen] = useState(false); + const setDialogKeyboardActive = useContext(DialogKeyboardContext); + + // Suppress command-mode key dispatch while the settings dialog owns the + // keyboard, so typing a timeout doesn't trigger pane shortcuts. + useEffect(() => { + setDialogKeyboardActive(settingsOpen); + return () => setDialogKeyboardActive(false); + }, [settingsOpen, setDialogKeyboardActive]); useLayoutEffect(() => { const el = containerRef.current; @@ -50,6 +66,20 @@ export function Baseboard({ items, onReattach, notice, onDoorDragStart }: Basebo return () => ro.disconnect(); }, []); + // The right cluster's width is never available to doors, so the fitting budget + // below subtracts it. Observed rather than measured on render: the host's + // `notice` element is referentially stable, so it appears and disappears + // through its own internal state without ever re-rendering this component. + useLayoutEffect(() => { + const el = rightClusterEl.current; + if (!el) return; + const ro = new ResizeObserver(([entry]) => { + setRightClusterWidth(entry.contentRect.width); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + // Measure door widths from hidden elements — re-measures when items change const measureEl = useRef(null); @@ -94,7 +124,9 @@ export function Baseboard({ items, onReattach, notice, onDoorDragStart }: Basebo const widths = doorWidthsRef.current; const { doorGap, arrowWidth } = layoutMetrics.current; const hasLeftOverflow = startIndex > 0; - let budget = availableWidth - (hasLeftOverflow ? arrowWidth : 0); + const budget = availableWidth + - (hasLeftOverflow ? arrowWidth : 0) + - (rightClusterWidth + doorGap); for (let i = startIndex; i < items.length; i++) { const doorW = (widths[i] || 100) + (visibleCount > 0 ? doorGap : 0); @@ -170,7 +202,7 @@ export function Baseboard({ items, onReattach, notice, onDoorDragStart }: Basebo ); })} - @@ -182,7 +214,7 @@ export function Baseboard({ items, onReattach, notice, onDoorDragStart }: Basebo {hiddenLeft > 0 && ( - )} + {/* One right-hand cluster. Previously the overflow arrow and the notice + each carried their own `ml-auto`, which split the free space between + them. The arrow keeps its per-iteration reserve in the fitting loop; + only the always-present part below is measured, so cluster width never + depends on the fitting result it feeds. */} +
+ {hiddenRight > 0 && ( + + )} + +
+ {notice} + + +
+
- {notice &&
{notice}
} + {settingsOpen && setSettingsOpen(false)} />} ); } diff --git a/lib/src/components/TodoAlertDialog.tsx b/lib/src/components/TodoAlertDialog.tsx index cb248153..dade0e9f 100644 --- a/lib/src/components/TodoAlertDialog.tsx +++ b/lib/src/components/TodoAlertDialog.tsx @@ -1,7 +1,8 @@ import { useLayoutEffect, useEffect, useRef, useState, useSyncExternalStore, type CSSProperties } from 'react'; import { createPortal } from 'react-dom'; import { XIcon } from '@phosphor-icons/react'; -import { Shortcut } from './design'; +import { OnOffSwitch, Shortcut } from './design'; +import { WatchedCommandList } from './WatchedCommandList'; import { usePopoverFocusTrap } from './use-popover-focus-trap'; import { clampOverlayPosition, pointInConvexPolygon } from '../lib/ui-geometry'; import { @@ -184,21 +185,7 @@ export function TodoAlertDialog({ {watched.length > 0 && (
Alerting on
-
    - {watched.map((name) => ( -
  • - {name} - -
  • - ))} -
+
)} @@ -224,34 +211,3 @@ export function TodoAlertDialog({ document.body, ); } - -function OnOffSwitch({ - on, - onEnable, - onDisable, - label, -}: { - on: boolean; - onEnable: () => void; - onDisable: () => void; - label: string; -}) { - return ( - - ); -} diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 2f78c95a..ba1fda55 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -70,6 +70,7 @@ import type { WallNav } from './wall/keyboard/types'; import { useWallKeyboard } from './wall/use-wall-keyboard'; import { useSessionPersistence } from './wall/use-session-persistence'; import { useDevServerPortCorrelation } from './wall/use-dev-server-ports'; +import { useAlertSpeech } from './wall/use-alert-speech'; import { useDorControl } from './wall/use-dor-control'; import { useWindowFocused } from './wall/use-window-focused'; import { @@ -732,6 +733,9 @@ export function Wall({ // --- Dev-server port → pane correlation (browser header connection chip) --- useDevServerPortCorrelation({ lath, doorsRef }); + // --- Spoken alarms (`docs/specs/alert.md` -> Alarm settings) --- + useAlertSpeech(); + // --- Reattach --- const handleReattach = useCallback(( diff --git a/lib/src/components/WatchedCommandList.tsx b/lib/src/components/WatchedCommandList.tsx new file mode 100644 index 00000000..11023ed4 --- /dev/null +++ b/lib/src/components/WatchedCommandList.tsx @@ -0,0 +1,42 @@ +import { useSyncExternalStore } from 'react'; +import { XIcon } from '@phosphor-icons/react'; +import { modalIconButton } from './design'; +import { + getWatchedCommandsSnapshot, + setCommandWatched, + subscribeToWatchedCommands, +} from '../lib/terminal-registry'; + +/** + * The app-global WATCHING rule set, with a remove control per rule + * (`docs/specs/alert.md` -> WATCHING Track). + * + * Rendered by both the bell popover and the Alarm settings dialog — the two + * places a rule set on a since-closed Pane can be found and removed. It is one + * list shown twice, so it lives here rather than in either dialog. + * + * Rules are removable but not addable: WATCHING is keyed on a running command's + * name, so creating one stays a bell click in the tab running it. + */ +export function WatchedCommandList() { + const watched = useSyncExternalStore(subscribeToWatchedCommands, getWatchedCommandsSnapshot); + if (watched.length === 0) return null; + + return ( +
    + {watched.map((name) => ( +
  • + {name} + +
  • + ))} +
+ ); +} diff --git a/lib/src/components/design.tsx b/lib/src/components/design.tsx index dd6dd955..4ef9eed3 100644 --- a/lib/src/components/design.tsx +++ b/lib/src/components/design.tsx @@ -2,7 +2,7 @@ import { clsx } from 'clsx'; import { tv, type VariantProps } from 'tailwind-variants'; import { XIcon } from '@phosphor-icons/react'; import { forwardRef, useEffect, useLayoutEffect, useRef, useState } from 'react'; -import type { ButtonHTMLAttributes, CSSProperties, HTMLAttributes, ReactNode, RefObject } from 'react'; +import type { ButtonHTMLAttributes, CSSProperties, HTMLAttributes, InputHTMLAttributes, ReactNode, RefObject } from 'react'; import { stepFocus } from './focus-step'; // App-wide type scale, color strategy, and chrome conventions: see @@ -245,6 +245,86 @@ export const ModalCloseButton = forwardRef (DESIGN.md -> Inputs). + +export type NumericInputProps = Omit, 'onChange' | 'type' | 'value'> & { + value: string; + onChange: (next: string) => void; + /** Max digits the field holds — sizes the box so rows stay compact. */ + chars?: number; +}; + +/** + * A compact underlined number field. Filters non-numeric input at the keystroke + * rather than relying on `type="number"`, whose spinners and locale parsing do + * not fit the app's chrome. + */ +export const NumericInput = forwardRef( + function NumericInput({ value, onChange, chars = 4, className, style, ...props }, ref) { + return ( + onChange(e.target.value.replace(/[^0-9.]/g, ''))} + style={{ width: `calc(${chars}ch + 0.5rem)`, ...style }} + className={clsx( + 'border-0 border-b border-border bg-transparent px-0.5 py-0.5 font-mono text-foreground outline-none focus:border-focus-ring', + className, + )} + {...props} + /> + ); + }, +); + +/** + * Left margin that lines content up under an `OnOffSwitch`'s label rather than + * its pill: the switch's `w-14` plus the usual `gap-3` between them. Lives here + * so it moves with the switch's own geometry. + */ +export const UNDER_SWITCH_INDENT = 'ml-[4.25rem]'; + +/** + * The app's boolean control: a two-position pill reading "on | off". Rendered as + * a `role="switch"` button, so it is disabled natively by a surrounding + * `
`. + */ +export function OnOffSwitch({ + on, + onEnable, + onDisable, + label, +}: { + on: boolean; + onEnable: () => void; + onDisable: () => void; + /** Describes what is being switched; announced with the current position. */ + label: string; +}) { + return ( + + ); +} + export function useMeasuredElementRect(element: HTMLElement | null): ModalRect | null { const [rect, setRect] = useState(null); diff --git a/lib/src/components/wall/AgentBrowserScreenModal.tsx b/lib/src/components/wall/AgentBrowserScreenModal.tsx index 76485215..40b172c5 100644 --- a/lib/src/components/wall/AgentBrowserScreenModal.tsx +++ b/lib/src/components/wall/AgentBrowserScreenModal.tsx @@ -27,7 +27,7 @@ import { LockSimpleIcon, XIcon, } from '@phosphor-icons/react'; -import { ModalCloseButton, ModalFrame, modalActionButton } from '../design'; +import { ModalCloseButton, ModalFrame, NumericInput, modalActionButton } from '../design'; import type { RenderMode, ScreenController, ScreenSnapshot } from './agent-browser-screen'; import { useAgentBrowserScreenSnapshot } from './agent-browser-screen'; @@ -344,15 +344,12 @@ function DimInput({ return ( {label} - onChange(e.target.value.replace(/[^0-9.]/g, ''))} - style={{ width: `calc(${chars}ch + 0.5rem)` }} - className="border-0 border-b border-border bg-transparent px-0.5 py-0.5 font-mono text-foreground outline-none focus:border-focus-ring" /> ); diff --git a/lib/src/components/wall/use-alert-speech.ts b/lib/src/components/wall/use-alert-speech.ts new file mode 100644 index 00000000..b89b8a92 --- /dev/null +++ b/lib/src/components/wall/use-alert-speech.ts @@ -0,0 +1,11 @@ +import { useEffect } from 'react'; +import { startAlertSpeech } from '../../lib/alert-speech'; + +/** + * Arm spoken alarms for the lifetime of the desktop shell. Mounted once by + * `Wall`; the settings that gate it live in the Alarm settings dialog + * (`docs/specs/alert.md` -> Alarm settings). + */ +export function useAlertSpeech(): void { + useEffect(() => startAlertSpeech(), []); +} diff --git a/lib/src/components/wall/use-dev-server-ports.ts b/lib/src/components/wall/use-dev-server-ports.ts index 79019ba9..9cc4eb21 100644 --- a/lib/src/components/wall/use-dev-server-ports.ts +++ b/lib/src/components/wall/use-dev-server-ports.ts @@ -26,8 +26,7 @@ */ import { useEffect } from 'react'; import { getPlatform } from '../../lib/platform'; -import { getActivitySnapshot, getTerminalPaneStateSnapshot } from '../../lib/terminal-registry'; -import { buildAppTitleResolver, deriveSurfaceLabel, DEFAULT_IDLE_TITLE } from '../../lib/terminal-state'; +import { deriveSessionLabel } from '../../lib/session-label'; import { getWantedDevServerPorts, setDevServerResolution, @@ -91,20 +90,6 @@ export function useDevServerPortCorrelation({ // (clears the whole set) or the port leaves "wanted" (navigation). const settled = new Set(); - // Concise pane label (e.g. `pnpm dev`), mirroring buildDorSurfaces; falls - // back to the panel/door title. Works for visible panes and minimized doors - // alike (both keep their pty + terminal state). - const labelForPane = (id: string, fallbackTitle: string | null): string => { - const states = getTerminalPaneStateSnapshot(); - const state = states.get(id); - if (state) { - const appTitleForPane = buildAppTitleResolver(states, getActivitySnapshot()); - const primary = deriveSurfaceLabel(state, [state], appTitleForPane, fallbackTitle); - if (primary && primary !== DEFAULT_IDLE_TITLE) return primary; - } - return fallbackTitle?.trim() || 'terminal'; - }; - const resolveOnce = async (): Promise => { if (cancelled || running) return 'busy'; @@ -173,7 +158,7 @@ export function useDevServerPortCorrelation({ // (e.g. the dev server is still starting up). if (list.length === 1) { settled.add(port); - setDevServerResolution(port, { paneId: list[0], label: labelForPane(list[0], titles.get(list[0]) ?? null) }); + setDevServerResolution(port, { paneId: list[0], label: deriveSessionLabel(list[0], titles.get(list[0]) ?? null) }); } else { setDevServerResolution(port, null); } diff --git a/lib/src/lib/alert-manager.test.ts b/lib/src/lib/alert-manager.test.ts index f6465300..97c007c9 100644 --- a/lib/src/lib/alert-manager.test.ts +++ b/lib/src/lib/alert-manager.test.ts @@ -627,4 +627,86 @@ describe('AlertManager in isolation', () => { notification: { source: 'OSC 9', title: null, body: 'Build finished' }, }); }); + + // --- Configurable inactivity timeout (`docs/specs/alert.md` -> Alarm settings) --- + + describe('setInactivityTimeoutMs', () => { + it('expires attention on the configured window instead of the default 15s', () => { + const id = 'short-window'; + manager.setInactivityTimeoutMs(3_000); + + manager.attend(id); + manager.applyTerminalSemanticEvents(id, [ + { type: 'commandLine', commandLine: 'pnpm build' }, + { type: 'commandStart', source: 'osc633_E', startedAt: Date.now() }, + ]); + + vi.advanceTimersByTime(2_999); + expect(manager.getState(id).status).toBe('WATCHING_DISABLED'); + + vi.advanceTimersByTime(1); + expect(manager.getState(id).status).toBe('COMMAND_EXIT_ARMED'); + }); + + it('gates the command-exit minimum runtime on the same window', () => { + const id = 'short-runtime-gate'; + manager.setInactivityTimeoutMs(3_000); + + manager.attend(id); + manager.applyTerminalSemanticEvents(id, [ + { type: 'commandLine', commandLine: 'git status' }, + { type: 'commandStart', source: 'osc633_E', startedAt: Date.now() }, + ]); + manager.clearAttention(id); + + // Under the default 15s window this runtime would be too short to ring. + vi.advanceTimersByTime(4_000); + manager.applyTerminalSemanticEvents(id, [{ type: 'commandFinish', exitCode: 0 }]); + + expect(manager.getState(id)).toMatchObject({ + status: 'ALERT_RINGING', + notification: { source: 'COMMAND_EXIT', body: 'git status exited 0' }, + }); + }); + + it('re-arms a live attention timer so a shortened window applies immediately', () => { + const id = 're-arm'; + + manager.attend(id); + manager.applyTerminalSemanticEvents(id, [ + { type: 'commandLine', commandLine: 'pnpm build' }, + { type: 'commandStart', source: 'osc633_E', startedAt: Date.now() }, + ]); + + vi.advanceTimersByTime(10_000); + expect(manager.getState(id).status).toBe('WATCHING_DISABLED'); + + // Shortening mid-window restarts the countdown from now rather than + // firing instantly or waiting out the original 15s. + manager.setInactivityTimeoutMs(3_000); + vi.advanceTimersByTime(2_999); + expect(manager.getState(id).status).toBe('WATCHING_DISABLED'); + + vi.advanceTimersByTime(1); + expect(manager.getState(id).status).toBe('COMMAND_EXIT_ARMED'); + }); + + it('ignores a nonsensical value rather than installing a broken timer', () => { + const id = 'bad-value'; + manager.setInactivityTimeoutMs(Number.NaN); + manager.setInactivityTimeoutMs(0); + manager.setInactivityTimeoutMs(-1); + + manager.attend(id); + manager.applyTerminalSemanticEvents(id, [ + { type: 'commandLine', commandLine: 'pnpm build' }, + { type: 'commandStart', source: 'osc633_E', startedAt: Date.now() }, + ]); + + vi.advanceTimersByTime(14_999); + expect(manager.getState(id).status).toBe('WATCHING_DISABLED'); + vi.advanceTimersByTime(1); + expect(manager.getState(id).status).toBe('COMMAND_EXIT_ARMED'); + }); + }); }); diff --git a/lib/src/lib/alert-manager.ts b/lib/src/lib/alert-manager.ts index 62ee1b24..b3d5a2ce 100644 --- a/lib/src/lib/alert-manager.ts +++ b/lib/src/lib/alert-manager.ts @@ -108,8 +108,6 @@ interface AlertEntry { attentionDismissedRing: boolean; } -const T_USER_ATTENTION = cfg.alert.userAttention; - /** * Manages ActivityMonitors, attention tracking, and todo state for PTY sessions. * @@ -123,6 +121,28 @@ export class AlertManager { private listeners = new Set<(id: string, state: AlertState) => void>(); private lastEmitted = new Map(); private watchedCommands = new Set(); + private inactivityTimeoutMs = cfg.alert.userAttention; + + // --- Settings --- + + /** + * The walk-away window (`docs/specs/alert.md` -> Attention): how long + * "looking at this pane" lasts, and — because the same idea gates it — the + * minimum runtime a command needs before its exit is allowed to ring. + * + * `AlertSettingsHost` clamps before this is reached, but the value originates + * in a renderer and ends up in `setTimeout`, so nonsense is rejected here too + * rather than trusted from one caller away. + */ + setInactivityTimeoutMs(ms: number): void { + if (!Number.isFinite(ms) || ms <= 0 || ms === this.inactivityTimeoutMs) return; + this.inactivityTimeoutMs = ms; + // Re-arm from now so a shortened window takes effect immediately instead of + // waiting out the window that was already running. + if (this.attentionTimer !== null && this.attentionId !== null) { + this.setAttention(this.attentionId); + } + } // --- State change subscription --- @@ -387,7 +407,7 @@ export class AlertManager { if (!watch || !wasArmed) return wasArmed; if (this.hasAttention(id)) return true; - if (Date.now() - watch.startedAt < T_USER_ATTENTION) return true; + if (Date.now() - watch.startedAt < this.inactivityTimeoutMs) return true; this.setCommandExitRinging(id, entry, watch, exitCode); return true; @@ -478,7 +498,7 @@ export class AlertManager { } } this.attentionTimer = null; - }, T_USER_ATTENTION); + }, this.inactivityTimeoutMs); } /** Mark that the user is paying attention to this session. */ diff --git a/lib/src/lib/alert-settings-host.ts b/lib/src/lib/alert-settings-host.ts new file mode 100644 index 00000000..e660209b --- /dev/null +++ b/lib/src/lib/alert-settings-host.ts @@ -0,0 +1,60 @@ +import type { AlertManager } from './alert-manager'; +import { + DEFAULT_ALERT_SETTINGS, + normalizeAlertSettings, + type AlertSettings, +} from './alert-settings'; + +type AlertSettingsTarget = Pick; + +/** + * Coordinates one host-authoritative alarm-settings blob across multiple + * renderers, the same way `WatchedCommandHost` does for the WATCHING rule set. + * The first renderer seeds persisted settings after a fresh host start; later + * renderers receive that canonical state instead of replacing it. + * + * Only `inactivityTimeoutMs` means anything to the host's `AlertManager`. The + * renderer-only fields are held purely so every webview reads back the same + * values (`docs/specs/alert.md` -> Alarm settings). + */ +export class AlertSettingsHost { + private initialized = false; + private settings: AlertSettings = DEFAULT_ALERT_SETTINGS; + private listeners = new Set<(settings: AlertSettings) => void>(); + + constructor(private readonly target: AlertSettingsTarget) {} + + /** Offered by every renderer at startup; only the first offer is taken. */ + initialize(value: unknown): void { + if (!this.initialized) { + this.initialized = true; + this.apply(value); + } + this.publish(); + } + + /** An explicit edit from a renderer. Always authoritative. */ + update(value: unknown): void { + this.initialized = true; + this.apply(value); + this.publish(); + } + + subscribe(listener: (settings: AlertSettings) => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private apply(value: unknown): void { + // Renderer input is revalidated here: the host must never install a NaN or + // absurd timer because a webview sent one (`docs/specs/transport.md`). + this.settings = normalizeAlertSettings(value); + this.target.setInactivityTimeoutMs(this.settings.inactivityTimeoutMs); + } + + private publish(): void { + for (const listener of this.listeners) listener(this.settings); + } +} diff --git a/lib/src/lib/alert-settings.test.ts b/lib/src/lib/alert-settings.test.ts new file mode 100644 index 00000000..4f0dd243 --- /dev/null +++ b/lib/src/lib/alert-settings.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const alertPublishSettings = vi.fn(); + +vi.mock('./platform', () => ({ + getPlatform: () => ({ alertPublishSettings }), +})); + +import { cfg } from '../cfg'; +import { + applyAlertSettingsFromHost, + DEFAULT_ALERT_SETTINGS, + getAlertSettings, + MAX_DELAY_MS, + MIN_DELAY_MS, + normalizeAlertSettings, + publishAlertSettings, + subscribeToAlertSettings, + updateAlertSettings, +} from './alert-settings'; + +const STORAGE_KEY = 'dormouse:alert-settings'; + +let store: Map; + +beforeEach(() => { + store = new Map(); + vi.stubGlobal('localStorage', { + getItem: (k: string) => (store.has(k) ? store.get(k)! : null), + setItem: (k: string, v: string) => store.set(k, v), + removeItem: (k: string) => store.delete(k), + }); + applyAlertSettingsFromHost(DEFAULT_ALERT_SETTINGS); + alertPublishSettings.mockClear(); +}); + +afterEach(() => { + applyAlertSettingsFromHost(DEFAULT_ALERT_SETTINGS); + vi.unstubAllGlobals(); +}); + +describe('normalizeAlertSettings', () => { + it('defaults the whole blob from a missing value', () => { + expect(normalizeAlertSettings(null)).toEqual(DEFAULT_ALERT_SETTINGS); + expect(normalizeAlertSettings(undefined)).toEqual(DEFAULT_ALERT_SETTINGS); + expect(normalizeAlertSettings('nonsense')).toEqual(DEFAULT_ALERT_SETTINGS); + }); + + it('seeds the inactivity timeout from cfg so cfg.ts stays the shipped default', () => { + expect(DEFAULT_ALERT_SETTINGS.inactivityTimeoutMs).toBe(cfg.alert.userAttention); + }); + + it('fills in missing keys and drops unknown ones', () => { + const result = normalizeAlertSettings({ speakEnabled: true, bogus: 'x' }); + expect(result).toEqual({ ...DEFAULT_ALERT_SETTINGS, speakEnabled: true }); + expect(result).not.toHaveProperty('bogus'); + }); + + it.each([ + ['NaN', Number.NaN], + ['Infinity', Number.POSITIVE_INFINITY], + ['a string', '5000'], + ['null', null], + ])('replaces a %s delay with the default rather than producing a broken timer', (_label, value) => { + expect(normalizeAlertSettings({ speakDelayMs: value }).speakDelayMs) + .toBe(DEFAULT_ALERT_SETTINGS.speakDelayMs); + }); + + it('clamps delays into range and rounds fractions', () => { + expect(normalizeAlertSettings({ speakDelayMs: 0 }).speakDelayMs).toBe(MIN_DELAY_MS); + expect(normalizeAlertSettings({ speakDelayMs: -5 }).speakDelayMs).toBe(MIN_DELAY_MS); + expect(normalizeAlertSettings({ inactivityTimeoutMs: 10_000_000 }).inactivityTimeoutMs).toBe(MAX_DELAY_MS); + expect(normalizeAlertSettings({ pushDelayMs: 3_000.6 }).pushDelayMs).toBe(3_001); + }); + + it('rejects non-boolean flags', () => { + expect(normalizeAlertSettings({ speakEnabled: 'yes' }).speakEnabled).toBe(false); + expect(normalizeAlertSettings({ speakEnabled: 1 }).speakEnabled).toBe(false); + }); +}); + +describe('updateAlertSettings', () => { + it('merges a patch, persists it, and pushes it to the host', () => { + updateAlertSettings({ speakEnabled: true }); + + expect(getAlertSettings()).toEqual({ ...DEFAULT_ALERT_SETTINGS, speakEnabled: true }); + expect(JSON.parse(store.get(STORAGE_KEY)!)).toEqual(getAlertSettings()); + expect(alertPublishSettings).toHaveBeenCalledWith(getAlertSettings(), { seed: false }); + }); + + it('clamps the patch before storing it', () => { + updateAlertSettings({ inactivityTimeoutMs: 0 }); + expect(getAlertSettings().inactivityTimeoutMs).toBe(MIN_DELAY_MS); + }); + + it('is a no-op when nothing changes, so the host is not spammed', () => { + updateAlertSettings({ speakEnabled: false }); + expect(alertPublishSettings).not.toHaveBeenCalled(); + + updateAlertSettings({ speakEnabled: true }); + alertPublishSettings.mockClear(); + updateAlertSettings({ speakEnabled: true }); + expect(alertPublishSettings).not.toHaveBeenCalled(); + }); + + it('notifies subscribers exactly once per real change', () => { + const listener = vi.fn(); + const unsubscribe = subscribeToAlertSettings(listener); + + updateAlertSettings({ speakDelayMs: 4_000 }); + expect(listener).toHaveBeenCalledTimes(1); + + updateAlertSettings({ speakDelayMs: 4_000 }); + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + updateAlertSettings({ speakDelayMs: 5_000 }); + expect(listener).toHaveBeenCalledTimes(1); + }); +}); + +describe('host canonical snapshot', () => { + it('replaces the local mirror without echoing back to the host', () => { + applyAlertSettingsFromHost({ ...DEFAULT_ALERT_SETTINGS, speakEnabled: true, speakDelayMs: 2_000 }); + + expect(getAlertSettings().speakEnabled).toBe(true); + expect(getAlertSettings().speakDelayMs).toBe(2_000); + expect(alertPublishSettings).not.toHaveBeenCalled(); + }); + + it('normalizes what the host sends', () => { + applyAlertSettingsFromHost({ speakDelayMs: Number.NaN }); + expect(getAlertSettings().speakDelayMs).toBe(DEFAULT_ALERT_SETTINGS.speakDelayMs); + }); + + it('offers the persisted copy as the startup seed', () => { + updateAlertSettings({ speakEnabled: true }); + publishAlertSettings(); + expect(alertPublishSettings).toHaveBeenCalledWith(getAlertSettings(), { seed: true }); + }); +}); diff --git a/lib/src/lib/alert-settings.ts b/lib/src/lib/alert-settings.ts new file mode 100644 index 00000000..89e48ed1 --- /dev/null +++ b/lib/src/lib/alert-settings.ts @@ -0,0 +1,123 @@ +import { cfg } from '../cfg'; +import { loadJson, saveJson } from './local-json-store'; +import { getPlatform } from './platform'; + +/** + * The app-global alarm settings edited by the Alarm settings dialog + * (`docs/specs/alert.md` -> Alarm settings). Like the WATCHING rule set, these + * are a property of the app, not of a Session. + * + * 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 field patches, and the host + * broadcasts its canonical snapshot to every webview. The host needs + * `inactivityTimeoutMs` for its `AlertManager`; it relays the rest untouched so + * two webviews cannot disagree about whether alarms speak. + */ +export interface AlertSettings { + /** ms — how long "looking at this pane" lasts before the user counts as away. */ + inactivityTimeoutMs: number; + /** Speak an unattended alarm out loud after `speakDelayMs`. */ + speakEnabled: boolean; + /** ms after a ring before speaking, if the ring is still unattended. */ + speakDelayMs: number; + /** Reserved — the push UI is rendered disabled. See `docs/specs/alert.md` -> Future. */ + pushEnabled: boolean; + /** Reserved — see `pushEnabled`. */ + pushDelayMs: number; +} + +const STORAGE_KEY = 'dormouse:alert-settings'; + +/** Shared bounds for every delay field. Seconds in the UI, ms on the wire. */ +export const MIN_DELAY_MS = 1_000; +export const MAX_DELAY_MS = 600_000; + +export const DEFAULT_ALERT_SETTINGS: AlertSettings = { + // cfg.ts stays the single source of the shipped default. + inactivityTimeoutMs: cfg.alert.userAttention, + speakEnabled: false, + speakDelayMs: 10_000, + pushEnabled: false, + pushDelayMs: 20_000, +}; + +/** Force a millisecond delay into the shared bounds. The one clamp rule. */ +export function clampAlertDelayMs(ms: number): number { + return Math.min(MAX_DELAY_MS, Math.max(MIN_DELAY_MS, Math.round(ms))); +} + +function clampDelay(value: unknown, fallback: number): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return fallback; + return clampAlertDelayMs(value); +} + +function bool(value: unknown, fallback: boolean): boolean { + return typeof value === 'boolean' ? value : fallback; +} + +/** + * Coerce an arbitrary value into a complete `AlertSettings`. Unknown keys are + * dropped and missing keys defaulted, so the blob evolves additively without a + * version field — and a hand-edited `localStorage` value can never produce a + * `NaN` timer. + */ +export function normalizeAlertSettings(value: unknown): AlertSettings { + const raw = (typeof value === 'object' && value !== null ? value : {}) as Partial>; + return { + inactivityTimeoutMs: clampDelay(raw.inactivityTimeoutMs, DEFAULT_ALERT_SETTINGS.inactivityTimeoutMs), + speakEnabled: bool(raw.speakEnabled, DEFAULT_ALERT_SETTINGS.speakEnabled), + speakDelayMs: clampDelay(raw.speakDelayMs, DEFAULT_ALERT_SETTINGS.speakDelayMs), + pushEnabled: bool(raw.pushEnabled, DEFAULT_ALERT_SETTINGS.pushEnabled), + pushDelayMs: clampDelay(raw.pushDelayMs, DEFAULT_ALERT_SETTINGS.pushDelayMs), + }; +} + +function alertSettingsEqual(a: AlertSettings, b: AlertSettings): boolean { + return a.inactivityTimeoutMs === b.inactivityTimeoutMs + && a.speakEnabled === b.speakEnabled + && a.speakDelayMs === b.speakDelayMs + && a.pushEnabled === b.pushEnabled + && a.pushDelayMs === b.pushDelayMs; +} + +let settings: AlertSettings = normalizeAlertSettings(loadJson(STORAGE_KEY, null)); +const listeners = new Set<() => void>(); + +/** Stable-identity snapshot for `useSyncExternalStore`. */ +export function getAlertSettings(): AlertSettings { + return settings; +} + +export function subscribeToAlertSettings(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** Apply a field patch, persist it, and push the delta to the host. */ +export function updateAlertSettings(patch: Partial): void { + const next = normalizeAlertSettings({ ...settings, ...patch }); + if (alertSettingsEqual(next, settings)) return; + settings = next; + saveJson(STORAGE_KEY, settings); + getPlatform().alertPublishSettings(settings, { seed: false }); + listeners.forEach((listener) => listener()); +} + +/** Replace the renderer mirror with the host's canonical settings. */ +export function applyAlertSettingsFromHost(value: unknown): void { + const next = normalizeAlertSettings(value); + if (alertSettingsEqual(next, settings)) return; + settings = next; + saveJson(STORAGE_KEY, settings); + listeners.forEach((listener) => listener()); +} + +/** + * Offer the renderer's persisted settings 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 publishAlertSettings(): void { + getPlatform().alertPublishSettings(settings, { seed: true }); +} diff --git a/lib/src/lib/alert-speech.test.ts b/lib/src/lib/alert-speech.test.ts new file mode 100644 index 00000000..90c21bf4 --- /dev/null +++ b/lib/src/lib/alert-speech.test.ts @@ -0,0 +1,182 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./platform', () => ({ + getPlatform: () => ({ alertPublishSettings: vi.fn() }), +})); + +import { startAlertSpeech } from './alert-speech'; +import { applyAlertSettingsFromHost, DEFAULT_ALERT_SETTINGS } from './alert-settings'; +import { clearPrimedActivity, primeActivity } from './session-activity-store'; +import type { SessionStatus } from './activity-monitor'; + +const SPEAK_DELAY_MS = 10_000; + +/** Utterances passed to the stubbed Web Speech API, in order. */ +let spoken: string[]; +let stopSpeech: (() => void) | null = null; + +function stubSpeechSynthesis(): void { + spoken = []; + vi.stubGlobal('speechSynthesis', { speak: (u: { text: string }) => spoken.push(u.text) }); + vi.stubGlobal('SpeechSynthesisUtterance', class { + text: string; + constructor(text: string) { this.text = text; } + }); +} + +/** Drive one Session's projected status through the activity store. */ +function setStatus(id: string, status: SessionStatus): void { + primeActivity(id, { status }); +} + +/** + * Ring a Session that the store already knows about. A real pane is in the + * activity store from the moment it is created and only reaches ALERT_RINGING + * later, so a ring is always a transition from some earlier status — that is + * exactly what the watcher keys on. + */ +function ring(id: string): void { + setStatus(id, 'NOTHING_TO_SHOW'); + setStatus(id, 'ALERT_RINGING'); +} + +beforeEach(() => { + vi.useFakeTimers(); + stubSpeechSynthesis(); + clearPrimedActivity(); + applyAlertSettingsFromHost({ ...DEFAULT_ALERT_SETTINGS, speakEnabled: true, speakDelayMs: SPEAK_DELAY_MS }); +}); + +afterEach(() => { + stopSpeech?.(); + stopSpeech = null; + clearPrimedActivity(); + applyAlertSettingsFromHost(DEFAULT_ALERT_SETTINGS); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +/** Start the watcher after any pre-existing state has been staged. */ +function start(): void { + stopSpeech = startAlertSpeech(); +} + +describe('spoken alarms', () => { + it('speaks the pane label once the delay elapses with the ring unattended', () => { + start(); + ring('pty-1'); + + vi.advanceTimersByTime(SPEAK_DELAY_MS - 1); + expect(spoken).toEqual([]); + + vi.advanceTimersByTime(1); + expect(spoken).toEqual(['terminal']); + }); + + it('stays silent when the user attends before the delay elapses', () => { + start(); + ring('pty-1'); + + vi.advanceTimersByTime(SPEAK_DELAY_MS - 1); + setStatus('pty-1', 'NOTHING_TO_SHOW'); + + vi.advanceTimersByTime(60_000); + expect(spoken).toEqual([]); + }); + + it('stays silent when the pane is killed during the delay', () => { + start(); + ring('pty-1'); + + vi.advanceTimersByTime(SPEAK_DELAY_MS - 1); + clearPrimedActivity('pty-1'); + + vi.advanceTimersByTime(60_000); + expect(spoken).toEqual([]); + }); + + it('speaks nothing while the setting is off', () => { + applyAlertSettingsFromHost({ ...DEFAULT_ALERT_SETTINGS, speakEnabled: false }); + start(); + ring('pty-1'); + + vi.advanceTimersByTime(60_000); + expect(spoken).toEqual([]); + }); + + it('drops a scheduled utterance if speech is switched off during the delay', () => { + start(); + ring('pty-1'); + + vi.advanceTimersByTime(SPEAK_DELAY_MS - 1); + applyAlertSettingsFromHost({ ...DEFAULT_ALERT_SETTINGS, speakEnabled: false }); + + vi.advanceTimersByTime(60_000); + expect(spoken).toEqual([]); + }); + + it('speaks exactly once per ring, not once per store notification', () => { + start(); + ring('pty-1'); + // Unrelated churn in the store — a rerender, a TODO toggle, another pane. + primeActivity('pty-1', { status: 'ALERT_RINGING', todo: true }); + setStatus('pty-2', 'BUSY'); + + vi.advanceTimersByTime(60_000); + expect(spoken).toEqual(['terminal']); + }); + + it('speaks again after a ring is cleared and a fresh one arrives', () => { + start(); + ring('pty-1'); + vi.advanceTimersByTime(SPEAK_DELAY_MS); + expect(spoken).toEqual(['terminal']); + + setStatus('pty-1', 'NOTHING_TO_SHOW'); + setStatus('pty-1', 'ALERT_RINGING'); + vi.advanceTimersByTime(SPEAK_DELAY_MS); + expect(spoken).toEqual(['terminal', 'terminal']); + }); + + it('is silent for a Session first seen already ringing (restore / reconnect)', () => { + // `docs/specs/alert.md`: a ring must come from a fresh transition, never + // from a remount or a restored snapshot. + setStatus('restored', 'ALERT_RINGING'); + start(); + + vi.advanceTimersByTime(60_000); + expect(spoken).toEqual([]); + }); + + it('speaks for each ringing Session independently', () => { + start(); + ring('pty-1'); + vi.advanceTimersByTime(4_000); + ring('pty-2'); + + vi.advanceTimersByTime(SPEAK_DELAY_MS - 4_000); + expect(spoken).toHaveLength(1); + + vi.advanceTimersByTime(4_000); + expect(spoken).toHaveLength(2); + }); + + it('no-ops when the host webview has no speech backend', () => { + vi.stubGlobal('speechSynthesis', undefined); + start(); + ring('pty-1'); + + expect(() => vi.advanceTimersByTime(60_000)).not.toThrow(); + }); + + it('cancels every pending utterance when the watcher is disposed', () => { + start(); + ring('pty-1'); + + stopSpeech?.(); + stopSpeech = null; + + vi.advanceTimersByTime(60_000); + expect(spoken).toEqual([]); + }); +}); diff --git a/lib/src/lib/alert-speech.ts b/lib/src/lib/alert-speech.ts new file mode 100644 index 00000000..f6dce9a7 --- /dev/null +++ b/lib/src/lib/alert-speech.ts @@ -0,0 +1,95 @@ +import { getAlertSettings } from './alert-settings'; +import { getActivity, getActivitySnapshot, subscribeToActivity } from './session-activity-store'; +import { deriveSessionLabel } from './session-label'; + +/** + * Spoken alarms (`docs/specs/alert.md` -> Alarm settings). When a Session rings + * and stays unattended for `speakDelayMs`, say its pane name out loud. + * + * Only the derived pane label is ever spoken — never the `ActivityNotification` + * text — so a program that emits `OSC 9` cannot choose what the machine says. + * + * Renderer-side by design: the ring already arrives here as an activity-store + * transition, and the delay timer needs no host round-trip. `speak()` is the + * single seam a future native `PlatformAdapter.speak?()` would slot into for + * hosts whose webview has no speech backend (Tauri on Linux/WebKitGTK). + */ + +function speak(text: string): void { + const synth = globalThis.speechSynthesis; + // Absent in jsdom and in webviews with no speech backend — staying silent is + // the correct degradation, not an error. + if (!synth || typeof globalThis.SpeechSynthesisUtterance !== 'function') return; + try { + synth.speak(new globalThis.SpeechSynthesisUtterance(text)); + } catch { + // A speech engine that refuses the utterance must never break the alert path. + } +} + +/** + * Watch the activity store for fresh rings and speak the unattended ones. + * Returns a disposer that cancels every pending utterance. + */ +export function startAlertSpeech(): () => void { + // Last seen status per Session. A Session missing from this map has never been + // observed, so its first sighting can never count as a transition — that is + // what keeps a restore or reconnect that arrives already ringing silent + // (`docs/specs/alert.md` -> WATCHING Track). + const lastStatus = new Map(); + const pending = new Map>(); + + const cancel = (id: string): void => { + const timer = pending.get(id); + if (timer === undefined) return; + clearTimeout(timer); + pending.delete(id); + }; + + const onActivityChange = (): void => { + const snapshot = getActivitySnapshot(); + + for (const [id, state] of snapshot) { + const previous = lastStatus.get(id); + lastStatus.set(id, state.status); + + if (state.status !== 'ALERT_RINGING') { + // Attended, dismissed, or never ringing — either way nothing to say. + cancel(id); + continue; + } + // Already ringing, or seen for the first time already ringing. + if (previous === 'ALERT_RINGING' || previous === undefined) continue; + + const { speakEnabled, speakDelayMs } = getAlertSettings(); + if (!speakEnabled) continue; + + pending.set(id, setTimeout(() => { + pending.delete(id); + // Re-read rather than trusting the closure: the user may have attended + // or dismissed during the delay, and the setting may have been toggled. + if (getActivity(id).status !== 'ALERT_RINGING') return; + if (!getAlertSettings().speakEnabled) return; + speak(deriveSessionLabel(id)); + }, speakDelayMs)); + } + + // A Session that left the store entirely (pane killed) must not speak. + for (const id of [...lastStatus.keys()]) { + if (snapshot.has(id)) continue; + lastStatus.delete(id); + cancel(id); + } + }; + + // Seed from the current snapshot so nothing already on screen counts as fresh. + onActivityChange(); + const unsubscribe = subscribeToActivity(onActivityChange); + + return () => { + unsubscribe(); + for (const timer of pending.values()) clearTimeout(timer); + pending.clear(); + lastStatus.clear(); + }; +} diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index dbf705ae..48654ace 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -1,5 +1,6 @@ import type { AlertStateDetail, OpenPort, PlatformAdapter, PtyInfo } from './types'; import { AlertManager } from '../alert-manager'; +import type { AlertSettings } from '../alert-settings'; import { normalizeExternalUri } from '../external-links'; import { applyTerminalProtocolEvents, @@ -245,6 +246,7 @@ export class FakePtyAdapter implements PlatformAdapter { alertRemove(id: string): void { this.alertManager.remove(id); } alertSetWatchedCommands(names: string[]): void { this.alertManager.setWatchedCommands(names); } alertSetCommandWatched(name: string, watched: boolean): void { this.alertManager.setCommandWatched(name, watched); } + alertPublishSettings(settings: AlertSettings): void { this.alertManager.setInactivityTimeoutMs(settings.inactivityTimeoutMs); } alertDismiss(id: string): void { this.alertManager.dismissAlert(id); } alertAttend(id: string): void { this.alertManager.attend(id); } alertResize(id: string): void { this.alertManager.onResize(id); } @@ -254,8 +256,12 @@ 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); } + // Single renderer owning the AlertManager, so localStorage is the only store + // and there is no canonical snapshot to broadcast back. onWatchedCommands(_handler: (names: string[]) => void): void {} offWatchedCommands(_handler: (names: string[]) => void): void {} + onAlertSettings(_handler: (settings: AlertSettings) => void): void {} + offAlertSettings(_handler: (settings: AlertSettings) => 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 d643947c..ad836dc9 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -1,4 +1,5 @@ import type { AlertState } from '../alert-manager'; +import type { AlertSettings } from '../alert-settings'; import type { VSCodeWorkbenchCommand } from '../vscode-keybindings'; // Defined in its own dependency-free file so the Node proxy in lib/src/host can // share it without pulling this browser-typed module into a Node tsconfig. @@ -230,6 +231,12 @@ export interface PlatformAdapter { alertSetWatchedCommands(names: string[]): void; /** Mutate one bare-command WATCHING rule without replacing unrelated rules. */ alertSetCommandWatched(name: string, watched: boolean): void; + /** + * Push alarm settings to the host. `seed: true` offers them as the startup + * seed, which a multi-webview host accepts only once; `seed: false` is a user + * edit and always replaces. + */ + alertPublishSettings(settings: AlertSettings, opts: { seed: boolean }): void; alertDismiss(id: string): void; alertAttend(id: string): void; alertResize(id: string): void; @@ -242,6 +249,9 @@ export interface PlatformAdapter { /** Receive the host's canonical WATCHING rule snapshot. */ onWatchedCommands(handler: (names: string[]) => void): void; offWatchedCommands(handler: (names: string[]) => void): void; + /** Receive the host's canonical alarm settings. */ + onAlertSettings(handler: (settings: AlertSettings) => void): void; + offAlertSettings(handler: (settings: AlertSettings) => void): void; // State persistence saveState(state: unknown): void; diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index f7c92806..d93f24cb 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -1,5 +1,6 @@ import type { AgentBrowserCommandResult, AgentBrowserEditOp, AgentBrowserEditResult, AgentBrowserOpenResult, AgentBrowserPopResult, AgentBrowserScreenshotResult, AgentBrowserStreamStatusResult, AlertStateDetail, IframeProxyResult, OpenPort, PlatformAdapter, PtyInfo } from './types'; import { OPEN_PORT_TIMEOUT_MS } from './types'; +import type { AlertSettings } from '../alert-settings'; import { setDefaultShellOpts } from '../shell-defaults'; import { collectTerminalSemanticEvents, @@ -22,6 +23,7 @@ export class VSCodeAdapter implements PlatformAdapter { private flushRequestHandlers = new Set<(detail: { requestId: string }) => void>(); private alertStateHandlers = new Set<(detail: AlertStateDetail) => void>(); private watchedCommandHandlers = new Set<(names: string[]) => void>(); + private alertSettingsHandlers = new Set<(settings: AlertSettings) => void>(); constructor() { this.vscode = acquireVsCodeApi(); @@ -104,6 +106,10 @@ export class VSCodeAdapter implements PlatformAdapter { for (const handler of this.watchedCommandHandlers) { handler(msg.names); } + } else if (msg.type === 'alert:settings') { + for (const handler of this.alertSettingsHandlers) { + handler(msg.settings); + } } else if (msg.type === 'dormouse:newTerminal') { window.dispatchEvent(new CustomEvent('dormouse:new-terminal', { detail: { @@ -403,6 +409,13 @@ export class VSCodeAdapter implements PlatformAdapter { this.vscode.postMessage({ type: 'alert:setCommandWatched', name, watched }); } + alertPublishSettings(settings: AlertSettings, opts: { seed: boolean }): void { + this.vscode.postMessage({ + type: opts.seed ? 'alert:initializeSettings' : 'alert:updateSettings', + settings, + }); + } + alertDismiss(id: string): void { this.vscode.postMessage({ type: 'alert:dismiss', id }); } @@ -447,6 +460,14 @@ export class VSCodeAdapter implements PlatformAdapter { this.watchedCommandHandlers.delete(handler); } + onAlertSettings(handler: (settings: AlertSettings) => void): void { + this.alertSettingsHandlers.add(handler); + } + + offAlertSettings(handler: (settings: AlertSettings) => void): void { + this.alertSettingsHandlers.delete(handler); + } + // --- State persistence --- saveState(state: unknown): void { diff --git a/lib/src/lib/session-activity-store.ts b/lib/src/lib/session-activity-store.ts index 644143eb..02cf8e6f 100644 --- a/lib/src/lib/session-activity-store.ts +++ b/lib/src/lib/session-activity-store.ts @@ -1,5 +1,6 @@ import type { SessionStatus } from './activity-monitor'; import type { AlertStateDetail } from './platform/types'; +import { applyAlertSettingsFromHost, publishAlertSettings } from './alert-settings'; import type { PersistedAlertState, PersistedPane } from './session-types'; import { getPlatform } from './platform'; import { getRunningCommandArgv0 } from './terminal-state-store'; @@ -203,10 +204,14 @@ export function initAlertStateReceiver(): void { } 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. + // No off/on dance: this is a stable module function and handlers live in a + // Set, so re-registering it is a no-op. + platform.onAlertSettings(applyAlertSettingsFromHost); + // The host cannot read renderer localStorage. Offer our persisted copies as + // its startup seed after installing the canonical-snapshot listeners, so a + // second VS Code webview is corrected rather than replacing shared state. publishWatchedCommands(); + publishAlertSettings(); } /** diff --git a/lib/src/lib/session-label.ts b/lib/src/lib/session-label.ts new file mode 100644 index 00000000..91724d12 --- /dev/null +++ b/lib/src/lib/session-label.ts @@ -0,0 +1,26 @@ +import { getActivitySnapshot } from './session-activity-store'; +import { buildAppTitleResolver, deriveSurfaceLabel, DEFAULT_IDLE_TITLE } from './terminal-state'; +import { getTerminalPaneStateSnapshot } from './terminal-state-store'; + +/** + * The concise display label for one Surface id — `pnpm dev`, a cwd basename, an + * app title — mirroring what `buildDorSurfaces` and the pane header show. + * Works for visible Panes and minimized Doors alike; both keep their terminal + * state. + * + * `deriveSurfaceLabel` in `terminal-state.ts` is the pure derivation. This is + * the id-keyed wrapper over the live stores, kept in one place because every + * caller needs the same "an idle pane is called `terminal`" fallback — and one + * of them (spoken alarms) may only ever say a derived label, never terminal + * output (`docs/specs/alert.md` -> Spoken alarms). + */ +export function deriveSessionLabel(id: string, fallbackTitle: string | null = null): string { + const states = getTerminalPaneStateSnapshot(); + const state = states.get(id); + if (state) { + const appTitleForPane = buildAppTitleResolver(states, getActivitySnapshot()); + const primary = deriveSurfaceLabel(state, [state], appTitleForPane, fallbackTitle); + if (primary && primary !== DEFAULT_IDLE_TITLE) return primary; + } + return fallbackTitle?.trim() || 'terminal'; +} diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index e9226ff9..6d9ff96c 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -66,6 +66,17 @@ export { subscribeToWatchedCommands, } from './watched-commands'; +export { + applyAlertSettingsFromHost, + clampAlertDelayMs, + getAlertSettings, + subscribeToAlertSettings, + updateAlertSettings, +} from './alert-settings'; +export type { AlertSettings } from './alert-settings'; + +export { deriveSessionLabel } from './session-label'; + export { applyTerminalSemanticEvents, applyTerminalSemanticEventsByPtyId, diff --git a/lib/src/remote/client/remote-adapter.ts b/lib/src/remote/client/remote-adapter.ts index 4d7c7448..db1825c7 100644 --- a/lib/src/remote/client/remote-adapter.ts +++ b/lib/src/remote/client/remote-adapter.ts @@ -348,6 +348,7 @@ export class RemotePtyAdapter implements PlatformAdapter { alertRemove(): void {} alertSetWatchedCommands(): void {} alertSetCommandWatched(): void {} + alertPublishSettings(): void {} alertDismiss(): void {} alertAttend(): void {} alertResize(): void {} @@ -359,6 +360,8 @@ export class RemotePtyAdapter implements PlatformAdapter { offAlertState(): void {} onWatchedCommands(): void {} offWatchedCommands(): void {} + onAlertSettings(): void {} + offAlertSettings(): void {} saveState(state: unknown): void { this.#savedState = state; diff --git a/lib/src/stories/AlertSettingsDialog.stories.tsx b/lib/src/stories/AlertSettingsDialog.stories.tsx new file mode 100644 index 00000000..46bacf8b --- /dev/null +++ b/lib/src/stories/AlertSettingsDialog.stories.tsx @@ -0,0 +1,90 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { AlertSettingsDialog } from '../components/AlertSettingsDialog'; + +/** + * The app-global Alarm settings, normally opened from the far right of the + * baseboard. Rendering the dialog directly keeps these stories about its own + * content — the rule list, the two live settings, and the disabled push group — + * rather than about the button that opens it (`Baseboard.stories.tsx` covers + * that). Everything here is driven by story `parameters`, since the rule set and + * the settings are app-global stores rather than props. + */ +function DialogStory() { + return {}} />; +} + +const meta: Meta = { + title: 'Modals/AlertSettingsDialog', + component: DialogStory, +}; + +export default meta; +type Story = StoryObj; + +/** + * A fresh install: no rules yet, speech off. The empty state has to explain how + * rules get created, because they cannot be added from this dialog — WATCHING is + * keyed on a running command, so `a` in that tab is the only way in. + */ +export const Default: Story = { + parameters: { + primedWatchedCommands: [], + primedAlertSettings: {}, + }, +}; + +/** The mockup's case: rules accumulated, defaults otherwise. */ +export const WithRules: Story = { + parameters: { + primedWatchedCommands: ['claude', 'codex'], + primedAlertSettings: {}, + }, +}; + +/** + * Speech on. The delay field below it goes from dimmed-and-disabled to live, + * which is the only visual difference between this and `WithRules`. + */ +export const SpeechEnabled: Story = { + parameters: { + primedWatchedCommands: ['claude', 'codex'], + primedAlertSettings: { speakEnabled: true }, + }, +}; + +/** + * Non-default timings, proving both number fields render the stored value rather + * than a hardcoded one. Push stays greyed regardless of what its fields hold. + */ +export const CustomTimings: Story = { + parameters: { + primedWatchedCommands: ['claude'], + primedAlertSettings: { + inactivityTimeoutMs: 45_000, + speakEnabled: true, + speakDelayMs: 5_000, + pushDelayMs: 90_000, + }, + }, +}; + +/** + * A realistic accumulated rule set next to a long command name. argv0 is a + * basename so it is normally short, but nothing enforces that — a pathological + * name must truncate instead of widening the dialog. + */ +export const ManyRules: Story = { + parameters: { + primedWatchedCommands: [ + 'cargo', + 'claude', + 'codex', + 'docker', + 'pnpm', + 'pytest', + 'really-long-generated-integration-test-runner-name.sh', + 'tsc', + ], + primedAlertSettings: { speakEnabled: true }, + }, +}; diff --git a/lib/src/stories/Baseboard.stories.tsx b/lib/src/stories/Baseboard.stories.tsx index 26d1df33..3ddfe17d 100644 --- a/lib/src/stories/Baseboard.stories.tsx +++ b/lib/src/stories/Baseboard.stories.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { Baseboard } from '../components/Baseboard'; import type { DooredItem } from '../components/Wall'; @@ -31,11 +32,12 @@ function userTitleState(title: string, index: number): TerminalPaneState { }); } -function BaseboardStory({ items }: { items: DooredItem[] }) { +function BaseboardStory({ items, notice }: { items: DooredItem[]; notice?: ReactNode }) { return (
console.log('Reattach:', item.id)} />
@@ -144,6 +146,35 @@ export const OverflowWithRingingDoor: Story = { ], }; +/** + * Every right-hand element at once — overflow arrow, host notice, and the alarm + * settings button — in a narrow baseboard. The door-fitting budget subtracts the + * measured cluster, so doors must stop short of it rather than sliding under it. + */ +export const OverflowWithNoticeAndSettings: Story = { + args: { + items: overflowWithRingingDoorItems, + notice: ( + + Update ready + + ), + }, + parameters: withState(overflowWithRingingDoorItems, { + p5: { + status: 'ALERT_RINGING', + todo: false, + }, + }), + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + export const ExtremeTitleWithBothIndicators: Story = { args: { items: extremeTitleWithBothIndicatorsItems, diff --git a/standalone/src/browser-sidecar-adapter.ts b/standalone/src/browser-sidecar-adapter.ts index e8ac700b..d4d7b88e 100644 --- a/standalone/src/browser-sidecar-adapter.ts +++ b/standalone/src/browser-sidecar-adapter.ts @@ -13,6 +13,7 @@ import type { PtyInfo, } from "dormouse-lib/lib/platform/types"; import { AlertManager } from "dormouse-lib/lib/alert-manager"; +import type { AlertSettings } from "dormouse-lib/lib/alert-settings"; import { normalizeExternalUri } from "dormouse-lib/lib/external-links"; import { loadSessionState, saveSessionState } from "dormouse-lib/lib/window-persistence"; import { @@ -207,6 +208,7 @@ export class BrowserSidecarAdapter implements PlatformAdapter { alertRemove(id: string): void { this.alertManager.remove(id); } alertSetWatchedCommands(names: string[]): void { this.alertManager.setWatchedCommands(names); } alertSetCommandWatched(name: string, watched: boolean): void { this.alertManager.setCommandWatched(name, watched); } + alertPublishSettings(settings: AlertSettings): void { this.alertManager.setInactivityTimeoutMs(settings.inactivityTimeoutMs); } alertDismiss(id: string): void { this.alertManager.dismissAlert(id); } alertAttend(id: string): void { this.alertManager.attend(id); } alertResize(id: string): void { this.alertManager.onResize(id); } @@ -216,8 +218,11 @@ 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); } + // See TauriAdapter: single webview, so nothing is broadcast back. onWatchedCommands(_handler: (names: string[]) => void): void {} offWatchedCommands(_handler: (names: string[]) => void): void {} + onAlertSettings(_handler: (settings: AlertSettings) => void): void {} + offAlertSettings(_handler: (settings: AlertSettings) => 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 219e98f8..06842e5e 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -16,6 +16,7 @@ import type { PtyInfo, } from "dormouse-lib/lib/platform/types"; import { AlertManager } from "dormouse-lib/lib/alert-manager"; +import type { AlertSettings } from "dormouse-lib/lib/alert-settings"; import { normalizeExternalUri } from "dormouse-lib/lib/external-links"; import { loadSessionState, saveSessionState } from "dormouse-lib/lib/window-persistence"; import { TauriSessionStore } from "./tauri-session-store"; @@ -461,6 +462,10 @@ export class TauriAdapter implements PlatformAdapter { this.alertManager.setCommandWatched(name, watched); } + alertPublishSettings(settings: AlertSettings): void { + this.alertManager.setInactivityTimeoutMs(settings.inactivityTimeoutMs); + } + alertDismiss(id: string): void { this.alertManager.dismissAlert(id); } @@ -497,10 +502,16 @@ export class TauriAdapter implements PlatformAdapter { this.alertStateHandlers.delete(handler); } + // Single webview owning the AlertManager, so localStorage is the only store + // and there is no canonical snapshot to broadcast back. onWatchedCommands(_handler: (names: string[]) => void): void {} offWatchedCommands(_handler: (names: string[]) => void): void {} + onAlertSettings(_handler: (settings: AlertSettings) => void): void {} + + offAlertSettings(_handler: (settings: AlertSettings) => 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 7466f8ee..93033b38 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import * as ptyManager from './pty-manager'; import { AlertManager } from '../../lib/src/lib/alert-manager'; import { WatchedCommandHost } from '../../lib/src/lib/watched-command-host'; +import { AlertSettingsHost } from '../../lib/src/lib/alert-settings-host'; import { applyTerminalProtocolEvents, collectTerminalSemanticEvents, @@ -44,6 +45,7 @@ const ALLOWED_WORKBENCH_COMMANDS = new Set(VSCODE_WORKBENCH_COMMANDS); // across webview collapse/expand cycles. const alertManager = new AlertManager(); const watchedCommandHost = new WatchedCommandHost(alertManager); +const alertSettingsHost = new AlertSettingsHost(alertManager); const alertProtocolParsers = new Map(); // The extension-host parser has no DOM, so webviews push their resolved terminal @@ -175,6 +177,12 @@ export function attachRouter( names, } satisfies ExtensionMessage); }); + const removeAlertSettingsListener = alertSettingsHost.subscribe((settings) => { + void webview.postMessage({ + type: 'alert:settings', + settings, + } satisfies ExtensionMessage); + }); function claim(id: string): void { ownedPtyIds.add(id); @@ -614,6 +622,14 @@ export function attachRouter( case 'alert:setCommandWatched': watchedCommandHost.setCommandWatched(msg.name, msg.watched); break; + // The host revalidates and clamps: a webview must never be able to install + // a NaN or absurd timer (`docs/specs/transport.md`). + case 'alert:initializeSettings': + alertSettingsHost.initialize(msg.settings); + break; + case 'alert:updateSettings': + alertSettingsHost.update(msg.settings); + break; case 'alert:dismiss': alertManager.dismissAlert(msg.id); break; @@ -647,6 +663,7 @@ export function attachRouter( disposed = true; activeRouters.delete(router); removeWatchedCommandListener(); + removeAlertSettingsListener(); resolveAllFlushRequests(); disconnectWebview?.(); disconnectWebview = null; diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index 087a6ea1..73991e58 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -1,4 +1,5 @@ import type { ActivityNotification, SessionStatus, TodoState } from '../../lib/src/lib/alert-manager'; +import type { AlertSettings } from '../../lib/src/lib/alert-settings'; import type { TerminalSemanticEvent } from '../../lib/src/lib/terminal-state'; import type { TerminalColors } from '../../lib/src/lib/terminal-protocol'; import type { DorControlRequestPayload, DorControlResponsePayload } from '../../dor/src/protocol'; @@ -37,6 +38,8 @@ export type WebviewMessage = | { type: 'alert:remove'; id: string } | { type: 'alert:initializeWatchedCommands'; names: string[] } | { type: 'alert:setCommandWatched'; name: string; watched: boolean } + | { type: 'alert:initializeSettings'; settings: AlertSettings } + | { type: 'alert:updateSettings'; settings: AlertSettings } | { type: 'alert:dismiss'; id: string } | { type: 'alert:attend'; id: string } | { type: 'alert:resize'; id: string } @@ -94,4 +97,5 @@ export type ExtensionMessage = notification: ActivityNotification | null; attentionDismissedRing: boolean; } - | { type: 'alert:watchedCommands'; names: string[] }; + | { type: 'alert:watchedCommands'; names: string[] } + | { type: 'alert:settings'; settings: AlertSettings }; From 5aa940998ff2eb1faf83ee111f513d6ac29d4af0 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 28 Jul 2026 13:09:52 -0700 Subject: [PATCH 2/6] Alerts: drop the receiver's handler bookkeeping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit initAlertStateReceiver() tracked each handler it installed in a module-level `let` so it could deregister on a later call. That was load-bearing for exactly one of the three: the alert-state handler was rebuilt as a fresh closure every call, so without the off/on dance repeat calls — Pocket and the website playground call this from an effect — would stack distinct closures and apply every host update N times. The closure captured nothing from the enclosing scope, so hoisting it to a module-level function removes the reason for the bookkeeping entirely. All three handlers are now stable references and adapters hold handlers in a Set, so re-registration is inherently a no-op and all three `let`s and guards go. Worth noting what the old guard did not do: it read getPlatform() afresh and called off() on the *new* adapter, so it only ever worked when the adapter instance was unchanged. The structural fix has no such precondition. Pins the invariant with a test that fails (3 deliveries, not 1) if a handler is ever made per-call again. Co-Authored-By: Claude Opus 5 (1M context) --- lib/src/lib/session-activity-store.ts | 67 +++++++++++---------- lib/src/lib/terminal-registry.alert.test.ts | 21 +++++++ 2 files changed, 55 insertions(+), 33 deletions(-) diff --git a/lib/src/lib/session-activity-store.ts b/lib/src/lib/session-activity-store.ts index 02cf8e6f..4f12b0c9 100644 --- a/lib/src/lib/session-activity-store.ts +++ b/lib/src/lib/session-activity-store.ts @@ -170,42 +170,43 @@ export function consumePrimedActivity(id: string): Partial | unde return primed; } -let currentAlertHandler: ((detail: AlertStateDetail) => void) | null = null; -let currentWatchedCommandsHandler: ((names: string[]) => void) | null = null; +/** + * Fold one host alert-state update into the registry, or stage it if the PTY's + * entry does not exist yet (the host can report before the terminal is minted). + */ +function handleAlertState(detail: AlertStateDetail): void { + const entry = getEntryByPtyId(detail.id); + if (entry) { + entry.alertStatus = detail.status; + entry.watchingEnabled = detail.watchingEnabled; + entry.todo = detail.todo; + entry.notification = detail.notification; + entry.attentionDismissedRing = detail.attentionDismissedRing; + primedActivityStates.delete(detail.id); + notifyActivityListeners(); + } else { + primeActivity(detail.id, { + status: detail.status, + watchingEnabled: detail.watchingEnabled, + todo: detail.todo, + notification: detail.notification, + }); + } +} +/** + * Subscribe the renderer to the host's alert channels and offer it our + * persisted app-global state. + * + * Safe to call more than once — Pocket and the website playground call it from + * an effect. Every handler here is a stable module-level function and adapters + * hold handlers in a `Set`, so re-registering is a no-op and no deregistration + * bookkeeping is needed. + */ export function initAlertStateReceiver(): void { const platform = getPlatform(); - if (currentAlertHandler) { - platform.offAlertState(currentAlertHandler); - } - - currentAlertHandler = (detail) => { - const entry = getEntryByPtyId(detail.id); - if (entry) { - entry.alertStatus = detail.status; - entry.watchingEnabled = detail.watchingEnabled; - entry.todo = detail.todo; - entry.notification = detail.notification; - entry.attentionDismissedRing = detail.attentionDismissedRing; - primedActivityStates.delete(detail.id); - notifyActivityListeners(); - } else { - primeActivity(detail.id, { - status: detail.status, - watchingEnabled: detail.watchingEnabled, - todo: detail.todo, - notification: detail.notification, - }); - } - }; - platform.onAlertState(currentAlertHandler); - if (currentWatchedCommandsHandler) { - platform.offWatchedCommands(currentWatchedCommandsHandler); - } - currentWatchedCommandsHandler = applyWatchedCommandsFromHost; - platform.onWatchedCommands(currentWatchedCommandsHandler); - // No off/on dance: this is a stable module function and handlers live in a - // Set, so re-registering it is a no-op. + platform.onAlertState(handleAlertState); + platform.onWatchedCommands(applyWatchedCommandsFromHost); platform.onAlertSettings(applyAlertSettingsFromHost); // The host cannot read renderer localStorage. Offer our persisted copies as // its startup seed after installing the canonical-snapshot listeners, so a diff --git a/lib/src/lib/terminal-registry.alert.test.ts b/lib/src/lib/terminal-registry.alert.test.ts index addbc088..771eefac 100644 --- a/lib/src/lib/terminal-registry.alert.test.ts +++ b/lib/src/lib/terminal-registry.alert.test.ts @@ -118,6 +118,7 @@ import { resumeTerminal, restoreTerminal, setPendingShellOpts, + subscribeToActivity, toggleSessionAlert, toggleSessionTodo, } from './terminal-registry'; @@ -306,6 +307,26 @@ describe('terminal-registry alert behavior', () => { expect(isUntouched(id)).toBe(true); }); + /** + * The receiver keeps no deregistration bookkeeping: every handler it installs + * is a stable module-level function and adapters hold handlers in a `Set`, so + * re-registering is a no-op. Pocket and the website playground call it from an + * effect, so a second registration would apply every host update twice. + */ + it('stays singly subscribed when initAlertStateReceiver is called again', () => { + const id = 'double-init'; + const listener = vi.fn(); + + initAlertStateReceiver(); + initAlertStateReceiver(); + const unsubscribe = subscribeToActivity(listener); + platformModule.getPlatform().alertMarkTodo(id); + + expect(listener).toHaveBeenCalledTimes(1); + expect(getActivity(id).todo).toBe(true); + unsubscribe(); + }); + it('marks a session touched on first real terminal input', () => { const id = 'typed-touched'; const entry = createSession(id); From 746ad0dcae05b74640d4b2c52c0be0a79189c793 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 28 Jul 2026 13:33:59 -0700 Subject: [PATCH 3/6] Alerts: drop the unused off* alert subscriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit offAlertState, offWatchedCommands, and offAlertSettings had no production callers. The last one went away when initAlertStateReceiver stopped tracking its handlers for deregistration — its handlers are stable module-level functions registered once for the renderer's lifetime, so nothing ever unsubscribes. Removes them from PlatformAdapter and its five implementations, plus three stale properties in test mock adapters that nothing would have flagged (test files are excluded from tsconfig.app.json). The on* side keeps its Set, which is now load-bearing for idempotent re-registration rather than for removal. This breaks the on/off pairing the PTY listeners have, so the interface says why and what to do if a caller ever does need to unsubscribe. Co-Authored-By: Claude Opus 5 (1M context) --- lib/src/lib/platform/fake-adapter.ts | 3 --- lib/src/lib/platform/types.ts | 8 +++++--- lib/src/lib/platform/vscode-adapter.ts | 12 ------------ lib/src/lib/reconnect.test.ts | 2 -- lib/src/lib/session-restore.test.ts | 2 -- lib/src/lib/session-save.test.ts | 2 -- lib/src/remote/client/remote-adapter.ts | 3 --- standalone/src/browser-sidecar-adapter.ts | 3 --- standalone/src/tauri-adapter.ts | 8 -------- 9 files changed, 5 insertions(+), 38 deletions(-) diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index 48654ace..370ece26 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -255,13 +255,10 @@ export class FakePtyAdapter implements PlatformAdapter { alertMarkTodo(id: string): void { this.alertManager.markTodo(id); } 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); } // Single renderer owning the AlertManager, so localStorage is the only store // and there is no canonical snapshot to broadcast back. onWatchedCommands(_handler: (names: string[]) => void): void {} - offWatchedCommands(_handler: (names: string[]) => void): void {} onAlertSettings(_handler: (settings: AlertSettings) => void): void {} - offAlertSettings(_handler: (settings: AlertSettings) => 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 ad836dc9..0f166881 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -244,14 +244,16 @@ export interface PlatformAdapter { alertToggleTodo(id: string): void; alertMarkTodo(id: string): void; alertClearTodo(id: string): void; + // Alert subscriptions have no `off` counterpart, unlike the PTY listeners + // above: their handlers are stable module-level functions registered once for + // the renderer's lifetime (`initAlertStateReceiver`), so adapters store them + // in a `Set` and re-registration is idempotent. Add the pair back if a + // caller ever needs to unsubscribe. 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; /** Receive the host's canonical alarm settings. */ onAlertSettings(handler: (settings: AlertSettings) => void): void; - offAlertSettings(handler: (settings: AlertSettings) => void): void; // State persistence saveState(state: unknown): void; diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index d93f24cb..df381c96 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -448,26 +448,14 @@ export class VSCodeAdapter implements PlatformAdapter { this.alertStateHandlers.add(handler); } - offAlertState(handler: (detail: AlertStateDetail) => void): void { - this.alertStateHandlers.delete(handler); - } - onWatchedCommands(handler: (names: string[]) => void): void { this.watchedCommandHandlers.add(handler); } - offWatchedCommands(handler: (names: string[]) => void): void { - this.watchedCommandHandlers.delete(handler); - } - onAlertSettings(handler: (settings: AlertSettings) => void): void { this.alertSettingsHandlers.add(handler); } - offAlertSettings(handler: (settings: AlertSettings) => void): void { - this.alertSettingsHandlers.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 d5f98981..e3a830c1 100644 --- a/lib/src/lib/reconnect.test.ts +++ b/lib/src/lib/reconnect.test.ts @@ -74,9 +74,7 @@ function createPlatform(ptys: PtyInfo[], savedState: PersistedSession | null): P alertMarkTodo: vi.fn(), 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-restore.test.ts b/lib/src/lib/session-restore.test.ts index 8ba151dd..d5b7c1fc 100644 --- a/lib/src/lib/session-restore.test.ts +++ b/lib/src/lib/session-restore.test.ts @@ -52,9 +52,7 @@ function createPlatform(savedState: PersistedSession | null): PlatformAdapter { alertMarkTodo: vi.fn(), 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 0e9129a0..ba6d1795 100644 --- a/lib/src/lib/session-save.test.ts +++ b/lib/src/lib/session-save.test.ts @@ -60,9 +60,7 @@ function createPlatform(savedState: PersistedSession | null): PlatformAdapter { alertMarkTodo: () => {}, alertClearTodo: () => {}, onAlertState: () => {}, - offAlertState: () => {}, onWatchedCommands: () => {}, - offWatchedCommands: () => {}, saveState: vi.fn((state: unknown) => { persistedState = state; }), diff --git a/lib/src/remote/client/remote-adapter.ts b/lib/src/remote/client/remote-adapter.ts index db1825c7..7cb7da7c 100644 --- a/lib/src/remote/client/remote-adapter.ts +++ b/lib/src/remote/client/remote-adapter.ts @@ -357,11 +357,8 @@ export class RemotePtyAdapter implements PlatformAdapter { alertMarkTodo(): void {} alertClearTodo(): void {} onAlertState(): void {} - offAlertState(): void {} onWatchedCommands(): void {} - offWatchedCommands(): void {} onAlertSettings(): void {} - offAlertSettings(): void {} saveState(state: unknown): void { this.#savedState = state; diff --git a/standalone/src/browser-sidecar-adapter.ts b/standalone/src/browser-sidecar-adapter.ts index d4d7b88e..e405fb06 100644 --- a/standalone/src/browser-sidecar-adapter.ts +++ b/standalone/src/browser-sidecar-adapter.ts @@ -217,12 +217,9 @@ export class BrowserSidecarAdapter implements PlatformAdapter { alertMarkTodo(id: string): void { this.alertManager.markTodo(id); } 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); } // See TauriAdapter: single webview, so nothing is broadcast back. onWatchedCommands(_handler: (names: string[]) => void): void {} - offWatchedCommands(_handler: (names: string[]) => void): void {} onAlertSettings(_handler: (settings: AlertSettings) => void): void {} - offAlertSettings(_handler: (settings: AlertSettings) => 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 06842e5e..a7338496 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -498,20 +498,12 @@ export class TauriAdapter implements PlatformAdapter { this.alertStateHandlers.add(handler); } - offAlertState(handler: (detail: AlertStateDetail) => void): void { - this.alertStateHandlers.delete(handler); - } - // Single webview owning the AlertManager, so localStorage is the only store // and there is no canonical snapshot to broadcast back. onWatchedCommands(_handler: (names: string[]) => void): void {} - offWatchedCommands(_handler: (names: string[]) => void): void {} - onAlertSettings(_handler: (settings: AlertSettings) => void): void {} - offAlertSettings(_handler: (settings: AlertSettings) => void): void {} - // --- State persistence --- private static STATE_KEY = 'dormouse.session'; From 7667ab945c4a94b2e2528f06dfaa9b2e833f5f39 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 28 Jul 2026 13:41:29 -0700 Subject: [PATCH 4/6] Allow terminal titles in spoken alarms --- docs/specs/alert.md | 6 ++-- lib/src/lib/alert-speech.test.ts | 47 ++++++++++++++++++++++++++++++++ lib/src/lib/alert-speech.ts | 5 ++-- lib/src/lib/session-label.ts | 7 +++-- 4 files changed, 57 insertions(+), 8 deletions(-) diff --git a/docs/specs/alert.md b/docs/specs/alert.md index accbb7e9..b11ea752 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -152,7 +152,7 @@ Rules: When a Session transitions into `ALERT_RINGING` and is still ringing `speakDelayMs` later, Dormouse says that Pane's name out loud. Source of truth: `lib/src/lib/alert-speech.ts`, armed once by `useAlertSpeech` in `Wall`. - Any of the three tracks qualifies. "Not attended" is track-agnostic. -- **Only the derived Pane label is ever spoken** — never `ActivityNotification` title or body. This is a security rule, not a style one: any program can emit `OSC 9`, and speaking its text would let it choose what the machine says out loud. The label comes from `deriveSessionLabel` in `lib/src/lib/session-label.ts` — the one id-keyed label derivation, shared with the dev-server chip — falling back to `terminal`. +- **The derived Pane label is spoken, including terminal-supplied title overrides.** It comes from `deriveSessionLabel` in `lib/src/lib/session-label.ts` — the one id-keyed label derivation shared with the dev-server chip — and falls back to `terminal`. `OSC 0`, `OSC 2`, and legacy `OSC 9` message text can therefore be spoken when that text currently wins the normal Pane-label derivation. This is deliberate: opting into spoken alarms opts into hearing the Pane name Dormouse displays, even when a program supplied that name. A ringing `ActivityNotification` title/body is not itself the speech payload, but an `OSC 9` message body is also an input to normal Pane-label derivation and can be spoken on that basis. - The trigger is a fresh transition into `ALERT_RINGING`, held to the same standard as the bell (WATCHING Track, last bullet). A Session observed for the first time *already* ringing never speaks, which is what keeps a restore or a reconnect replaying a latched ring silent. - Attending, dismissing, or killing the Pane during the delay cancels the utterance; so does switching the setting off. Both the ring and the setting are re-read when the timer fires rather than captured when it was scheduled. - One utterance per ring. A Session that rings, is cleared, and rings again speaks twice. Sessions ring and speak independently. @@ -221,7 +221,7 @@ Notification text is untrusted terminal output. - Strip C0/C1 controls after protocol parsing, collapse whitespace controls to spaces, and trim. - 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, and layout must tolerate long text, CJK, RTL, combining marks, and emoji without pushing fixed controls out of bounds. +- Wherever notification text appears in visible UI or accessible labels, it is plain text, and layout must tolerate long text, CJK, RTL, combining marks, and emoji without pushing fixed controls out of bounds. Sanitized terminal-supplied `OSC 0` / `OSC 2` / `OSC 9` text also participates in normal Pane-label derivation, and the resulting label may be sent to the opt-in speech channel as defined above. 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`. @@ -256,5 +256,5 @@ Staged in order: 1. **Device registry.** Reuse the paired-Client identities the Host already holds — `docs/specs/remote-security-model.md` defines the ACL, and a registered Pocket Client is exactly the device a push should reach. The dialog's "Push will be sent to —" line names the chosen device once there is one to name. 2. **Delivery.** A push travels Host -> Server -> Client. The Server relay is the only component that can reach a backgrounded phone, so this needs a wire method beyond the terminal-only protocol-v1 in `docs/specs/remote-api.md`. -3. **Payload rule.** Same rule as spoken alarms: the Pane label only, never `ActivityNotification` text. Terminal output must not be able to choose what a push says. +3. **Payload rule.** Same rule as spoken alarms: send the derived Pane label rather than the ringing notification's title/body as such. Terminal-supplied `OSC 0` / `OSC 2` / `OSC 9` text can therefore appear when it is the winning Pane label. 4. **Cancellation.** Attending on the laptop before `pushDelayMs` cancels, matching the speech path. Whether a delivered push can be recalled once the user attends is undecided. diff --git a/lib/src/lib/alert-speech.test.ts b/lib/src/lib/alert-speech.test.ts index 90c21bf4..b2d608e6 100644 --- a/lib/src/lib/alert-speech.test.ts +++ b/lib/src/lib/alert-speech.test.ts @@ -8,6 +8,8 @@ import { startAlertSpeech } from './alert-speech'; import { applyAlertSettingsFromHost, DEFAULT_ALERT_SETTINGS } from './alert-settings'; import { clearPrimedActivity, primeActivity } from './session-activity-store'; import type { SessionStatus } from './activity-monitor'; +import { removeTerminalPaneState, resetTerminalPaneState } from './terminal-state-store'; +import type { TerminalTitleSource } from './terminal-state'; const SPEAK_DELAY_MS = 10_000; @@ -50,6 +52,7 @@ beforeEach(() => { afterEach(() => { stopSpeech?.(); stopSpeech = null; + for (const id of ['osc0-title', 'osc2-title', 'osc9-title']) removeTerminalPaneState(id); clearPrimedActivity(); applyAlertSettingsFromHost(DEFAULT_ALERT_SETTINGS); vi.useRealTimers(); @@ -73,6 +76,50 @@ describe('spoken alarms', () => { expect(spoken).toEqual(['terminal']); }); + it('speaks terminal-supplied OSC 0/2/9 titles when they are the pane label', () => { + const sources: TerminalTitleSource[] = ['osc0', 'osc2', 'osc9']; + for (const [index, source] of sources.entries()) { + const id = `${source}-title`; + resetTerminalPaneState(id, { + activity: { kind: 'running' }, + currentCommand: { + id: `cmd-${index}`, + rawCommandLine: 'sleep 60', + displayCommand: 'sleep 60', + cwdAtStart: null, + startedAt: 10, + source: 'osc133_boundaries', + }, + // OSC 0/2 come from terminal semantic state. OSC 9 is exercised below + // through the alert-backed app-title resolver used by the display label. + titleCandidates: source === 'osc9' + ? {} + : { [source]: { title: `program title ${source}`, source, updatedAt: 20 } }, + }); + } + + start(); + for (const source of sources) { + const id = `${source}-title`; + setStatus(id, 'NOTHING_TO_SHOW'); + if (source === 'osc9') { + primeActivity(id, { + status: 'ALERT_RINGING', + notification: { source: 'OSC 9', title: null, body: 'program title osc9' }, + }); + } else { + setStatus(id, 'ALERT_RINGING'); + } + } + vi.advanceTimersByTime(SPEAK_DELAY_MS); + + expect(spoken).toEqual([ + 'program title osc0', + 'program title osc2', + 'program title osc9', + ]); + }); + it('stays silent when the user attends before the delay elapses', () => { start(); ring('pty-1'); diff --git a/lib/src/lib/alert-speech.ts b/lib/src/lib/alert-speech.ts index f6dce9a7..d2ab5485 100644 --- a/lib/src/lib/alert-speech.ts +++ b/lib/src/lib/alert-speech.ts @@ -6,8 +6,9 @@ import { deriveSessionLabel } from './session-label'; * Spoken alarms (`docs/specs/alert.md` -> Alarm settings). When a Session rings * and stays unattended for `speakDelayMs`, say its pane name out loud. * - * Only the derived pane label is ever spoken — never the `ActivityNotification` - * text — so a program that emits `OSC 9` cannot choose what the machine says. + * Speech uses the same derived pane label as the visible UI. That intentionally + * includes terminal-supplied OSC 0/2/9 titles when they win label derivation; + * `ActivityNotification` fields are not chosen as a separate speech payload. * * Renderer-side by design: the ring already arrives here as an activity-store * transition, and the delay timer needs no host round-trip. `speak()` is the diff --git a/lib/src/lib/session-label.ts b/lib/src/lib/session-label.ts index 91724d12..2cf7554d 100644 --- a/lib/src/lib/session-label.ts +++ b/lib/src/lib/session-label.ts @@ -10,9 +10,10 @@ import { getTerminalPaneStateSnapshot } from './terminal-state-store'; * * `deriveSurfaceLabel` in `terminal-state.ts` is the pure derivation. This is * the id-keyed wrapper over the live stores, kept in one place because every - * caller needs the same "an idle pane is called `terminal`" fallback — and one - * of them (spoken alarms) may only ever say a derived label, never terminal - * output (`docs/specs/alert.md` -> Spoken alarms). + * caller needs the same "an idle pane is called `terminal`" fallback. Spoken + * alarms intentionally say this exact derived label, including a terminal- + * supplied OSC 0/2/9 title when it wins the normal display priority + * (`docs/specs/alert.md` -> Spoken alarms). */ export function deriveSessionLabel(id: string, fallbackTitle: string | null = null): string { const states = getTerminalPaneStateSnapshot(); From a3d0428aef55f2e8fdb1cfa76ce7a4c03ea74404 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 28 Jul 2026 20:11:23 -0700 Subject: [PATCH 5/6] Alerts: sanitize the spoken label before handing it to the engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebKit silently drops an utterance containing angle brackets AND leaves the synthesizer wedged, so every later utterance is dropped too until the page reloads. Pane labels carry chrome like ``, so the first spoken alarm in standalone on macOS was silent and permanently disabled every alarm after it. Terminal-supplied OSC 0/2/9 titles also reach speech through normal label derivation, so this was not only a broken feature: any program could permanently disable spoken alarms for the session by putting a `<` in its title. Sanitizing at the speech boundary rather than in `deriveSessionLabel`, because the Pane header legitimately wants `` as a visual placeholder — a label that is safe to render is not automatically safe to speak. `toSpokenText` maps angle brackets, ampersands, and control characters to spaces (spaces rather than deletion, so `a --- docs/specs/alert.md | 3 ++- lib/src/lib/alert-speech.test.ts | 40 +++++++++++++++++++++++++++++++- lib/src/lib/alert-speech.ts | 36 ++++++++++++++++++++++++---- 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/docs/specs/alert.md b/docs/specs/alert.md index b11ea752..5ea2461e 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -153,6 +153,7 @@ When a Session transitions into `ALERT_RINGING` and is still ringing `speakDelay - Any of the three tracks qualifies. "Not attended" is track-agnostic. - **The derived Pane label is spoken, including terminal-supplied title overrides.** It comes from `deriveSessionLabel` in `lib/src/lib/session-label.ts` — the one id-keyed label derivation shared with the dev-server chip — and falls back to `terminal`. `OSC 0`, `OSC 2`, and legacy `OSC 9` message text can therefore be spoken when that text currently wins the normal Pane-label derivation. This is deliberate: opting into spoken alarms opts into hearing the Pane name Dormouse displays, even when a program supplied that name. A ringing `ActivityNotification` title/body is not itself the speech payload, but an `OSC 9` message body is also an input to normal Pane-label derivation and can be spoken on that basis. +- **The label is sanitized before it reaches the engine** (`toSpokenText` in `lib/src/lib/alert-speech.ts`): angle brackets, ampersands, and control characters become spaces, whitespace collapses, the result is capped, and an empty result falls back to `terminal`. This is a robustness *and* security requirement, not tidiness — WebKit silently drops an utterance containing angle brackets **and leaves the synthesizer wedged**, so every later utterance is dropped too until the page reloads. Pane labels carry chrome like ``, and terminal-supplied titles reach speech, so without sanitization any program could permanently disable spoken alarms for the session by putting a `<` in its title. - The trigger is a fresh transition into `ALERT_RINGING`, held to the same standard as the bell (WATCHING Track, last bullet). A Session observed for the first time *already* ringing never speaks, which is what keeps a restore or a reconnect replaying a latched ring silent. - Attending, dismissing, or killing the Pane during the delay cancels the utterance; so does switching the setting off. Both the ring and the setting are re-read when the timer fires rather than captured when it was scheduled. - One utterance per ring. A Session that rings, is cleared, and rings again speaks twice. Sessions ring and speak independently. @@ -221,7 +222,7 @@ Notification text is untrusted terminal output. - Strip C0/C1 controls after protocol parsing, collapse whitespace controls to spaces, and trim. - 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. -- Wherever notification text appears in visible UI or accessible labels, it is plain text, and layout must tolerate long text, CJK, RTL, combining marks, and emoji without pushing fixed controls out of bounds. Sanitized terminal-supplied `OSC 0` / `OSC 2` / `OSC 9` text also participates in normal Pane-label derivation, and the resulting label may be sent to the opt-in speech channel as defined above. +- Wherever notification text appears in visible UI or accessible labels, it is plain text, and layout must tolerate long text, CJK, RTL, combining marks, and emoji without pushing fixed controls out of bounds. Sanitized terminal-supplied `OSC 0` / `OSC 2` / `OSC 9` text also participates in normal Pane-label derivation, and the resulting label may be sent to the opt-in speech channel as defined above — after a second, speech-specific pass, because a label that is safe to *render* is not automatically safe to hand a speech engine. See `toSpokenText` under Spoken alarms. 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`. diff --git a/lib/src/lib/alert-speech.test.ts b/lib/src/lib/alert-speech.test.ts index b2d608e6..cdc7133d 100644 --- a/lib/src/lib/alert-speech.test.ts +++ b/lib/src/lib/alert-speech.test.ts @@ -4,7 +4,7 @@ vi.mock('./platform', () => ({ getPlatform: () => ({ alertPublishSettings: vi.fn() }), })); -import { startAlertSpeech } from './alert-speech'; +import { startAlertSpeech, toSpokenText } from './alert-speech'; import { applyAlertSettingsFromHost, DEFAULT_ALERT_SETTINGS } from './alert-settings'; import { clearPrimedActivity, primeActivity } from './session-activity-store'; import type { SessionStatus } from './activity-monitor'; @@ -64,6 +64,44 @@ function start(): void { stopSpeech = startAlertSpeech(); } +/** + * WebKit drops an utterance containing angle brackets and leaves the + * synthesizer wedged for the rest of the page's life, so every later alarm is + * silent too. Pane labels carry `` chrome, and terminal-supplied titles + * reach speech — so this is a denial-of-service guard, not just tidiness. + */ +describe('toSpokenText', () => { + it('strips the angle brackets that wedge the engine', () => { + expect(toSpokenText(' build finished')).toBe('idle build finished'); + }); + + it('separates rather than joins, so stripped text does not run together', () => { + expect(toSpokenText('ac')).toBe('a b c'); + }); + + it('strips ampersands and control characters from untrusted titles', () => { + expect(toSpokenText('make&test')).toBe('make test'); + expect(toSpokenText('build\u0007done\u001b')).toBe('build done'); + }); + + it('collapses the whitespace its own substitutions create', () => { + expect(toSpokenText(' ')).toBe('a b'); + }); + + it('caps length, since a terminal title has no useful bound', () => { + expect(toSpokenText('x'.repeat(500))).toHaveLength(120); + }); + + it('falls back rather than handing the engine an empty utterance', () => { + expect(toSpokenText('<>')).toBe('terminal'); + expect(toSpokenText(' ')).toBe('terminal'); + }); + + it('leaves an ordinary label alone', () => { + expect(toSpokenText('pnpm test')).toBe('pnpm test'); + }); +}); + describe('spoken alarms', () => { it('speaks the pane label once the delay elapses with the ring unattended', () => { start(); diff --git a/lib/src/lib/alert-speech.ts b/lib/src/lib/alert-speech.ts index d2ab5485..3a17982d 100644 --- a/lib/src/lib/alert-speech.ts +++ b/lib/src/lib/alert-speech.ts @@ -6,9 +6,10 @@ import { deriveSessionLabel } from './session-label'; * Spoken alarms (`docs/specs/alert.md` -> Alarm settings). When a Session rings * and stays unattended for `speakDelayMs`, say its pane name out loud. * - * Speech uses the same derived pane label as the visible UI. That intentionally - * includes terminal-supplied OSC 0/2/9 titles when they win label derivation; - * `ActivityNotification` fields are not chosen as a separate speech payload. + * Speech uses the same derived pane label as the visible UI, passed through + * `toSpokenText`. That intentionally includes terminal-supplied OSC 0/2/9 titles + * when they win label derivation; `ActivityNotification` fields are not chosen + * as a separate speech payload. * * Renderer-side by design: the ring already arrives here as an activity-store * transition, and the delay timer needs no host round-trip. `speak()` is the @@ -16,13 +17,40 @@ import { deriveSessionLabel } from './session-label'; * hosts whose webview has no speech backend (Tauri on Linux/WebKitGTK). */ +/** Longest utterance we will produce. A pane title has no useful upper bound. */ +const SPEECH_LIMIT = 120; + +/** + * Reduce a display label to something safe to hand a speech engine. + * + * WebKit (standalone on macOS) silently drops an utterance containing angle + * brackets **and leaves the synthesizer wedged**, so every later utterance is + * dropped too until the page reloads. Pane labels carry chrome like ``, + * and terminal-supplied OSC 0/2/9 titles reach speech as well — so without this + * any program could permanently disable spoken alarms for the session by + * putting a `<` in its title (`docs/specs/alert.md` -> Text And Security). + * + * Markup metacharacters become spaces rather than being deleted, so `a&]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + return cleaned.slice(0, SPEECH_LIMIT).trim() || 'terminal'; +} + function speak(text: string): void { const synth = globalThis.speechSynthesis; // Absent in jsdom and in webviews with no speech backend — staying silent is // the correct degradation, not an error. if (!synth || typeof globalThis.SpeechSynthesisUtterance !== 'function') return; try { - synth.speak(new globalThis.SpeechSynthesisUtterance(text)); + synth.speak(new globalThis.SpeechSynthesisUtterance(toSpokenText(text))); } catch { // A speech engine that refuses the utterance must never break the alert path. } From e6f3089be86268b85f007a8e50a1706a687687ed Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 28 Jul 2026 20:23:50 -0700 Subject: [PATCH 6/6] Don't imply that we're using eslint when we are not Co-authored-by: dormouse-bot --- lib/src/lib/alert-speech.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/lib/alert-speech.ts b/lib/src/lib/alert-speech.ts index 3a17982d..98f96c5c 100644 --- a/lib/src/lib/alert-speech.ts +++ b/lib/src/lib/alert-speech.ts @@ -36,7 +36,7 @@ const SPEECH_LIMIT = 120; export function toSpokenText(label: string): string { const cleaned = label // Control characters are meaningless aloud and arrive with untrusted - // terminal titles. eslint-disable-next-line no-control-regex + // terminal titles. .replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ') .replace(/[<>&]/g, ' ') .replace(/\s+/g, ' ')