Skip to content

Commit 020490a

Browse files
authored
Support iTerm2 OSC notifications and terminal bells (#57)
2 parents 7b92c93 + 39e290c commit 020490a

31 files changed

Lines changed: 1954 additions & 143 deletions

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ The primary job of a spec is to be an accurate reference for the current state o
3333
- **`docs/specs/ontology.md`** — Canonical vocabulary for Session states, layers (Process / Registry / View / Link / Activity / Snapshot), transition verbs, and the Liskov contract on Registry APIs. Read this first. Other specs defer to it when naming a state or a verb.
3434
- **`docs/specs/layout.md`** — Tiling layout, pane/door containers, dockview configuration, modes (passthrough/command), keyboard shortcuts, selection overlay, spatial navigation, minimize/reattach, inline rename, session lifecycle, session persistence, and theming. Read this when touching: `Wall.tsx`, `Baseboard.tsx`, `Door.tsx`, `TerminalPane.tsx`, `spatial-nav.ts`, `layout-snapshot.ts`, `terminal-registry.ts`, `session-save.ts`, `session-restore.ts`, `reconnect.ts`, `index.css`, `theme.css`, or any keyboard/navigation/mode behavior.
3535
- **`docs/specs/alert.md`** — Activity monitoring state machine, alert trigger/clearing rules, attention model, TODO lifecycle (soft/hard), bell button visual states and interaction, door alert indicators, and hardening (a11y, motion, i18n, overflow). Read this when touching: `activity-monitor.ts`, `alert-manager.ts`, the alert bell or TODO pill in `Wall.tsx` (TerminalPaneHeader), alert indicators in `Door.tsx`, or the `a`/`t` keyboard shortcuts. Layout.md defers to this spec for all alert/TODO behavior.
36+
- **`docs/specs/iTerm2.md`** — iTerm2-compatible identity, terminal notification protocols (`OSC 9`, `OSC 99`, `OSC 777`), and `OSC 9;4` progress arming, including how protocol signals force or cock the alert/TODO system. Read this when touching PTY environment identity, terminal device/version reports, OSC parsing, `AlertManager` protocol notification/progress paths, `ActivityState` metadata, or TODO notification preview UI.
3637
- **`docs/specs/vscode.md`** — VS Code extension architecture: hosting modes (WebviewView + WebviewPanel), PTY lifecycle and buffering, message protocol between webview and extension host, session persistence flow, reconnection protocol, theme integration, CSP, build pipeline, and invariants (save-before-kill ordering, PTY ownership, alert state merging). Read this when touching: `extension.ts`, `webview-view-provider.ts`, `message-router.ts`, `message-types.ts`, `pty-manager.ts`, `pty-host.js`, `session-state.ts`, `webview-html.ts`, `vscode-adapter.ts`, or `pty-core.js`.
3738
- **`docs/specs/tutorial.md`** — Playground tutorial on the website: 3-pane layout, interactive `tut` TUI runner with three sections (keyboard navigation, alerts/TODOs, copy/paste), per-item detection wired to `WallEvent` / activity store / mouse-selection store, single-key `mouseterm-tut-v3` localStorage scheme, theme picker, and FakePtyAdapter extensions (`sendOutput`, `pumpActivity`, `setInputHandler`). Read this when touching: `website/src/pages/Playground.tsx`, `website/src/lib/tut-runner.ts`, `website/src/lib/tut-detector.ts`, `website/src/lib/tutorial-state.ts`, `website/src/lib/tut-items.ts`, `website/src/lib/tutorial-shell.ts`, `lib/src/components/ThemePicker.tsx`, `lib/src/lib/themes/`, `lib/src/lib/platform/fake-scenarios.ts` (tutorial scenarios), the `WallEvent` union, or the `onApiReady`/`onEvent`/`initialPaneIds` props on Wall.
3839
- **`docs/specs/theme.md`** — Theme system: two-layer CSS variable strategy, theme data model, conversion pipeline, bundled themes, localStorage store, shared ThemePicker component, standalone AppBar picker, runtime OpenVSX installer. Read this when touching: `lib/src/lib/themes/`, `lib/src/components/ThemePicker.tsx`, `lib/src/theme.css`, `lib/scripts/bundle-themes.mjs`, `standalone/src/AppBar.tsx` (theme picker), `standalone/src/main.tsx` (theme restore), or `website/src/components/SiteHeader.tsx` (themeAware mode).

docs/specs/alert.md

Lines changed: 98 additions & 17 deletions
Large diffs are not rendered by default.

docs/specs/iTerm2.md

Lines changed: 415 additions & 0 deletions
Large diffs are not rendered by default.

docs/specs/layout.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
55
## Conceptual model
66

7-
A **Session** is a single PTY instance — a running shell process with its scrollback, environment, and working directory. Sessions are managed by the terminal registry and persist independently of how they are displayed. Each session also carries Activity state (alert status from the activity monitor, optional TODO flag).
7+
A **Session** is a single PTY instance — a running shell process with its scrollback, environment, and working directory. Sessions are managed by the terminal registry and persist independently of how they are displayed. Each session also carries Activity state (projected alert status, optional TODO flag, and optional protocol notification detail).
88

99
A Session's **View** state places it in one of two containers:
1010

@@ -281,7 +281,7 @@ On startup, recovery is priority-based:
281281

282282
### Activity state
283283

284-
Each session carries `ActivityState` with `status: SessionStatus` and `todo: TodoState`. These are synced to React via `useSyncExternalStore`. State that arrives from the platform before a registry entry exists (resume scenario) is held as "primed state" and applied when the registry entry is created.
284+
Each session carries `ActivityState` with `status: SessionStatus`, `todo: TodoState`, and `notification: ActivityNotification | null`. `status` is the projected public status from the timer-based visual track plus the terminal-report protocol track described in `docs/specs/alert.md`; it may be `OSC_NOTIF_BUSY` when OSC progress has cocked the bell. These are synced to React via `useSyncExternalStore`. State that arrives from the platform before a registry entry exists (resume scenario) is held as "primed state" and applied when the registry entry is created.
285285

286286
## Theme
287287

docs/specs/ontology.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ A **Session** is the tuple of its `SessionId` plus one state per layer. `Session
6363

6464
Keep the existing state machine (see `docs/specs/alert.md` for transition rules):
6565

66-
`ALERT_DISABLED` · `NOTHING_TO_SHOW` · `MIGHT_BE_BUSY` · `BUSY` · `MIGHT_NEED_ATTENTION` · `ALERT_RINGING`
66+
`ALERT_DISABLED` · `NOTHING_TO_SHOW` · `MIGHT_BE_BUSY` · `BUSY` · `OSC_NOTIF_BUSY` · `MIGHT_NEED_ATTENTION` · `ALERT_RINGING`
6767

6868
### Snapshot
6969

docs/specs/tutorial.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ The detector subscribes to `subscribeToActivity()` and tracks per-id `(status, t
6060
| ID | Title | Detection |
6161
|---|---|---|
6262
| `al-enable` | Enable alerts on a pane (click bell or `a`) | status transitions away from `ALERT_DISABLED` |
63-
| `al-busy` | Watch the bell tilt while a task runs | status enters `BUSY` or `MIGHT_BE_BUSY` |
63+
| `al-busy` | Watch the bell tilt while a task runs | status enters `BUSY`, `MIGHT_BE_BUSY`, or `OSC_NOTIF_BUSY` |
6464
| `al-ring` | Bell rings on completion | status enters `ALERT_RINGING` |
6565
| `al-todo-auto` | TODO appears when you dismiss the ringing alert | `todo` transitions `false → true` while previous status was `ALERT_RINGING` |
6666
| `al-todo-clear` | Press passthrough Enter to clear the TODO | `todo` transitions `true → false` |

docs/specs/vscode.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ All types defined in `message-types.ts`. Webview-side handling in `vscode-adapte
190190
| `pty:shells` | Available shells list response (matched by requestId) |
191191
| `mouseterm:flushSessionSave` | Request webview to save state now (deactivate trigger, matched by requestId) |
192192
| `mouseterm:openThemeDebugger` | Command-triggered request to open the shared theme debugger dialog |
193-
| `alert:state` | Alert state change (status, todo, attentionDismissedRing) |
193+
| `alert:state` | Alert state change (projected status, todo, notification, attentionDismissedRing) |
194194

195195
### Serialization and restore
196196

@@ -222,6 +222,7 @@ interface PersistedPane {
222222
interface PersistedAlertState {
223223
status: SessionStatus;
224224
todo: boolean;
225+
notification?: ActivityNotification | null;
225226
}
226227

227228
interface PersistedDoor {

lib/src/components/TodoAlertDialog.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,19 @@ export function TodoAlertDialog({
206206
/>
207207
</div>
208208

209+
{activity.notification && (
210+
<div className="mb-3 max-w-80 border-t border-border pt-2 text-sm leading-relaxed text-foreground">
211+
{activity.notification.title && (
212+
<div className="font-medium break-words">{activity.notification.title}</div>
213+
)}
214+
{activity.notification.body && (
215+
<div className="mt-1 max-h-32 overflow-auto whitespace-pre-wrap break-words text-muted">
216+
{activity.notification.body}
217+
</div>
218+
)}
219+
</div>
220+
)}
221+
209222
<div className="border-t border-border pt-2 text-sm leading-relaxed text-muted">
210223
When a tab with a ringing alert is selected,<br />
211224
the alert is cleared and the tab gets a TODO.<br />

lib/src/components/bell-icon-class.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export function bellIconClass(status: SessionStatus): string {
66
return [
77
'transition-transform',
88
status === 'MIGHT_BE_BUSY' && '-rotate-[22.5deg]',
9-
status === 'BUSY' && 'rotate-45',
9+
(status === 'BUSY' || status === 'OSC_NOTIF_BUSY') && 'rotate-45',
1010
status === 'MIGHT_NEED_ATTENTION' && 'rotate-[60deg]',
1111
status === 'ALERT_RINGING' && (
1212
cfg.alert.ringingPaused

lib/src/components/wall/TerminalPaneHeader.tsx

Lines changed: 112 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { useCallback, useContext, useEffect, useRef, useState, useSyncExternalStore } from 'react';
1+
import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type CSSProperties } from 'react';
2+
import { createPortal } from 'react-dom';
23
import type { IDockviewPanelHeaderProps } from 'dockview-react';
34
import { tv } from 'tailwind-variants';
45
import {
@@ -53,6 +54,19 @@ const tabVariant = tv({
5354

5455
type HeaderTier = 'full' | 'compact' | 'minimal';
5556

57+
const ALERT_BUTTON_ENABLED = { aria: 'Disable alert', tooltip: '[a] Disable alerts' };
58+
const ALERT_BUTTON_LABELS: Record<SessionStatus, { aria: string; tooltip: string }> = {
59+
ALERT_DISABLED: { aria: 'Enable alert', tooltip: '[a] Enable alerts' },
60+
NOTHING_TO_SHOW: ALERT_BUTTON_ENABLED,
61+
MIGHT_BE_BUSY: ALERT_BUTTON_ENABLED,
62+
BUSY: ALERT_BUTTON_ENABLED,
63+
MIGHT_NEED_ATTENTION: ALERT_BUTTON_ENABLED,
64+
ALERT_RINGING: { aria: 'Alert ringing', tooltip: 'Alert ringing' },
65+
OSC_NOTIF_BUSY: { aria: 'Progress active', tooltip: 'Progress active' },
66+
};
67+
const TODO_PREVIEW_GAP = 6;
68+
const TODO_PREVIEW_MARGIN = 8;
69+
5670
export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) {
5771
const mode = useContext(ModeContext);
5872
const selectedId = useContext(SelectedIdContext);
@@ -80,23 +94,24 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) {
8094
const suppressAlertClickRef = useRef(false);
8195
const [tier, setTier] = useState<HeaderTier>('full');
8296
const [dialogTriggerRect, setDialogTriggerRect] = useState<DOMRect | null>(null);
97+
const [todoPreviewRect, setTodoPreviewRect] = useState<DOMRect | null>(null);
8398
const todoPill = useTodoPillContent(activity.todo);
8499
const showTodoPill = todoPill.visible && tier !== 'minimal';
85-
const alertButtonAriaLabel = activity.status === 'ALERT_RINGING'
86-
? 'Alert ringing'
87-
: activity.status === 'ALERT_DISABLED'
88-
? 'Enable alert'
89-
: 'Disable alert';
90-
const alertButtonTooltip = activity.status === 'ALERT_RINGING'
91-
? 'Alert ringing'
92-
: activity.status === 'ALERT_DISABLED'
93-
? '[a] Enable alerts'
94-
: '[a] Disable alerts';
100+
const alertButtonLabels = ALERT_BUTTON_LABELS[activity.status];
101+
const alertButtonAriaLabel = alertButtonLabels.aria;
102+
const alertButtonTooltip = alertButtonLabels.tooltip;
95103
const alertButtonTooltipDetail = activity.status === 'ALERT_RINGING'
96104
? 'Click to dismiss and show options'
97105
: 'Right-click for options';
106+
const todoNotificationPreview = formatNotificationPreview(activity.notification);
107+
const todoPreviewId = `todo-notification-preview-${api.id}`;
98108

99109
const closeDialog = useCallback(() => setDialogTriggerRect(null), []);
110+
const closeTodoPreview = useCallback(() => setTodoPreviewRect(null), []);
111+
const openTodoPreview = useCallback((button: HTMLButtonElement) => {
112+
if (!activity.notification) return;
113+
setTodoPreviewRect(button.getBoundingClientRect());
114+
}, [activity.notification]);
100115

101116
const triggerAlertButtonAction = useCallback((displayedStatus: SessionStatus, button: HTMLButtonElement) => {
102117
const result = actions.onAlertButton(api.id, displayedStatus);
@@ -118,6 +133,10 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) {
118133
return () => ro.disconnect();
119134
}, []);
120135

136+
useEffect(() => {
137+
if (!activity.notification) setTodoPreviewRect(null);
138+
}, [activity.notification]);
139+
121140
return (
122141
<div
123142
ref={tabRef}
@@ -194,12 +213,18 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) {
194213
type="button"
195214
data-session-todo-for={api.id}
196215
data-flourishing={todoPill.flourishing ? 'true' : 'false'}
197-
className={`todo-pill-shell shrink-0 rounded border border-current px-1.5 py-px text-xs font-semibold ${TODO_PILL_TRACKING_CLASS} transition-colors hover:bg-current/10`}
198-
aria-label="Dismiss TODO"
216+
className={`todo-pill-shell shrink-0 rounded border border-current px-1.5 py-px text-xs font-semibold ${TODO_PILL_TRACKING_CLASS} transition-colors hover:bg-current/10 focus:outline-none`}
217+
aria-label={todoNotificationPreview ? `Dismiss TODO: ${todoNotificationPreview}` : 'Dismiss TODO'}
218+
aria-describedby={todoPreviewRect && activity.notification ? todoPreviewId : undefined}
199219
aria-hidden={todoPill.flourishing ? true : undefined}
200220
onMouseDown={(e) => e.stopPropagation()}
221+
onMouseEnter={(e) => openTodoPreview(e.currentTarget)}
222+
onMouseLeave={closeTodoPreview}
223+
onFocus={(e) => openTodoPreview(e.currentTarget)}
224+
onBlur={closeTodoPreview}
201225
onClick={(e) => {
202226
e.stopPropagation();
227+
closeTodoPreview();
203228
clearSessionTodo(api.id);
204229
}}
205230
>
@@ -277,6 +302,80 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) {
277302
onKeyboardActiveChange={setDialogKeyboardActive}
278303
/>
279304
)}
305+
{todoPreviewRect && activity.notification && !dialogTriggerRect && (
306+
<TodoNotificationPreview
307+
id={todoPreviewId}
308+
notification={activity.notification}
309+
anchorRect={todoPreviewRect}
310+
/>
311+
)}
280312
</div>
281313
);
282314
}
315+
316+
function TodoNotificationPreview({
317+
id,
318+
notification,
319+
anchorRect,
320+
}: {
321+
id: string;
322+
notification: { title: string | null; body: string | null };
323+
anchorRect: DOMRect;
324+
}) {
325+
const ref = useRef<HTMLDivElement>(null);
326+
const [style, setStyle] = useState<CSSProperties>({
327+
position: 'fixed',
328+
left: anchorRect.left,
329+
top: anchorRect.bottom + TODO_PREVIEW_GAP,
330+
});
331+
332+
useLayoutEffect(() => {
333+
const el = ref.current;
334+
if (!el) return;
335+
const rect = el.getBoundingClientRect();
336+
const top = anchorRect.bottom + TODO_PREVIEW_GAP;
337+
const maxLeft = Math.max(TODO_PREVIEW_MARGIN, window.innerWidth - rect.width - TODO_PREVIEW_MARGIN);
338+
setStyle({
339+
position: 'fixed',
340+
left: Math.min(Math.max(anchorRect.left, TODO_PREVIEW_MARGIN), maxLeft),
341+
top,
342+
maxHeight: Math.max(48, window.innerHeight - top - TODO_PREVIEW_MARGIN),
343+
});
344+
}, [anchorRect]);
345+
346+
return createPortal(
347+
<div
348+
ref={ref}
349+
id={id}
350+
role="tooltip"
351+
className="z-[1000] max-w-80 rounded border border-border bg-surface-raised px-2.5 py-2 font-mono text-sm leading-snug text-foreground shadow-md"
352+
style={style}
353+
>
354+
{notification.title && (
355+
<div className="font-medium break-words">{notification.title}</div>
356+
)}
357+
{notification.body && (
358+
<div
359+
className="mt-1 whitespace-pre-wrap break-words text-muted"
360+
style={{
361+
display: '-webkit-box',
362+
WebkitBoxOrient: 'vertical',
363+
WebkitLineClamp: 3,
364+
overflow: 'hidden',
365+
}}
366+
>
367+
{notification.body}
368+
</div>
369+
)}
370+
</div>,
371+
document.body,
372+
);
373+
}
374+
375+
function formatNotificationPreview(notification: { title: string | null; body: string | null } | null): string | undefined {
376+
if (!notification) return undefined;
377+
const parts = [notification.title, notification.body].filter((part): part is string => !!part);
378+
if (parts.length === 0) return undefined;
379+
const preview = parts.join('\n');
380+
return preview.length > 512 ? `${preview.slice(0, 509)}...` : preview;
381+
}

0 commit comments

Comments
 (0)