@cplieger/web-terminal-ui 7.3.1 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +114 -18
  2. package/css/30-tabs.css +52 -13
  3. package/css/31-switcher.css +15 -0
  4. package/css/32-split.css +176 -0
  5. package/css/MANIFEST +1 -0
  6. package/css/MANIFEST.tabbed +1 -0
  7. package/package.json +13 -12
  8. package/src/composition.ts +165 -237
  9. package/src/features/activity-monitor.ts +20 -55
  10. package/src/features/animations.ts +12 -30
  11. package/src/features/clipboard.ts +36 -64
  12. package/src/features/connection-banner.ts +10 -19
  13. package/src/features/context-menu.ts +70 -145
  14. package/src/features/dom.ts +44 -54
  15. package/src/features/menu-position.ts +24 -28
  16. package/src/features/mobile-toolbar.ts +32 -62
  17. package/src/features/predictive-echo.ts +15 -49
  18. package/src/features/scroll-to-bottom.ts +12 -24
  19. package/src/features/tabs/index.ts +1398 -1806
  20. package/src/features/tabs/ink-centre.ts +45 -54
  21. package/src/features/tabs/model.ts +301 -383
  22. package/src/features/tabs/strip.ts +45 -97
  23. package/src/features/tabs/switcher.ts +70 -48
  24. package/src/index.ts +13 -13
  25. package/src/kernel/a11y.ts +106 -27
  26. package/src/kernel/attention.ts +157 -0
  27. package/src/kernel/conn-state.ts +28 -22
  28. package/src/kernel/fatal.ts +76 -0
  29. package/src/kernel/feature-host.ts +136 -0
  30. package/src/kernel/gesture.ts +9 -13
  31. package/src/kernel/kernel.ts +57 -2139
  32. package/src/kernel/layout-policy.ts +44 -0
  33. package/src/kernel/loading-status.ts +30 -88
  34. package/src/kernel/navigation.ts +11 -17
  35. package/src/kernel/notify.ts +158 -0
  36. package/src/kernel/pane.ts +1415 -0
  37. package/src/kernel/realm.ts +11 -0
  38. package/src/kernel/regions.ts +8 -11
  39. package/src/kernel/scrollback.ts +88 -168
  40. package/src/kernel/selection.ts +25 -0
  41. package/src/kernel/shell.ts +1434 -0
  42. package/src/kernel/split-handle.ts +280 -0
  43. package/src/kernel/status-share.ts +100 -0
  44. package/src/kernel/types.ts +457 -441
  45. package/src/predict.ts +144 -170
  46. package/src/viewport.ts +124 -197
  47. package/src/features/tabs/attention.ts +0 -250
  48. package/src/features/tabs/notify.ts +0 -265
@@ -1,78 +1,24 @@
1
- // IME / composition support.
2
- //
3
- // Mirrors xterm.js's CompositionHelper. The browser fires
4
- // compositionstart → compositionupdate(s) → compositionend when an IME
5
- // (Japanese / Korean / Chinese / Vietnamese / dictation / autocorrect
6
- // suggestion bar) is active. Without a handler, the user sees nothing
7
- // while typing because keydown/input fire only when the composed
8
- // phrase is finalised.
9
- //
10
- // What this module does:
11
- // 1. On compositionstart, mark composing=true and capture textarea
12
- // selection bounds. Show the floating compositionView at the
13
- // cursor with the in-progress text.
14
- // 2. On compositionupdate, mirror the in-progress text into
15
- // compositionView (\u200E LTR markers, RTL CSS direction so long
16
- // compositions track their tail).
17
- // 3. On compositionend, set a setTimeout(0) finaliser. The
18
- // compositionend.data property is unreliable on Chromium (Korean
19
- // ending consonant moves to next char on vowel input, etc.); the
20
- // textarea value at next tick has the truth.
21
- // 4. While composing, callers' keydown handler must early-return
22
- // (handled via isComposing()). xterm.js takes the same approach.
23
- //
24
- // Positioning: the compositionView is absolutely positioned inside
25
- // `termWrap`. The IME UI (browser-rendered popup with candidate words)
26
- // uses the textarea as its anchor; we move the textarea to the cursor
27
- // for the same reason.
1
+ // IME / composition support, after xterm.js's CompositionHelper: the browser
2
+ // fires compositionstart, compositionupdate(s) and compositionend while an IME
3
+ // (CJK, Vietnamese, dictation, an autocorrect bar) is active, and without a
4
+ // handler the user sees nothing until the phrase is finalised. The in-progress
5
+ // text is mirrored into a floating view at the cursor; the textarea is moved
6
+ // there too because the IME's candidate popup anchors on it. compositionend's
7
+ // `data` is unreliable on Chromium, so the textarea value is read one tick later.
28
8
 
29
9
  import { resetToPlaceholder } from "./input-placeholder.js";
30
-
31
- let textarea: HTMLTextAreaElement;
32
- let compositionView: HTMLElement;
33
- let getCursorPx: () => { left: number; top: number; cellH: number };
34
- let send: (bytes: string) => void;
35
- let paste: (text: string) => void;
36
-
37
- let composing = false;
38
- let sendingComposition = false;
39
- let compositionStart = 0;
40
- let compositionSuffix = "";
41
-
42
- // A composition that never ENDS used to stop the terminal accepting input at
43
- // all. `composing` gates both the kernel's `input` and `keydown` listeners, and
44
- // nothing cleared it: no blur handler, no timer. So a keyboard that opened a
45
- // composition and never closed it left every subsequent keystroke, Enter
46
- // included, dropped on the floor — reported on Android Chrome with SwiftKey as
47
- // "typing does not work", recoverable only by tapping a word suggestion, which
48
- // is what finally fired compositionend.
49
- //
50
- // Not a SwiftKey quirk to special-case. Chromium has a filed defect for
51
- // compositionend going missing (crbug 446714223, in EditContext rather than a
52
- // textarea, so it is adjacent evidence and not proof of this exact path), and
53
- // CodeMirror synthesises the event outright for Safari dead keys, which is a
54
- // working implementation treating the same absence as real.
55
- //
56
- // The fix is a STALENESS READ rather than a timer. A composition the user is
57
- // typing into keeps firing compositionupdate, so its clock keeps refreshing; an
58
- // abandoned one stops and ages out, releasing the gate. UI Events promises one
59
- // or more compositionupdate events and one whenever the DOM is updated as part
60
- // of the composition, not strictly one per key, so this is a heuristic about
61
- // activity rather than a guaranteed per-keystroke signal. Deliberately NOT
62
- // refreshed by `input` events: in this failure the user IS typing and those
63
- // events ARE arriving, so refreshing there would defeat the mechanism.
64
- //
65
- // Five seconds is the number Slate's android-input-manager uses for its own
66
- // composition-gone-quiet bound (a DOM flush decision, not an input gate, so the
67
- // value is borrowed and the job is not the same). It is generous because the
68
- // residual risk runs the other way: a composition paused longer than this and
69
- // then resumed can duplicate its first character. That trade is deliberate —
70
- // the failure being replaced loses ALL input until an unrelated event fires.
10
+ import { windowOf } from "./kernel/realm.js";
11
+
12
+ /** A composition that never ends would gate every later keystroke out, and it
13
+ * happens: Android Chrome with SwiftKey fires no compositionend until a word
14
+ * suggestion is tapped (crbug 446714223 is adjacent evidence). So the latch is
15
+ * read against a STALENESS clock refreshed by compositionupdate alone, never by
16
+ * `input` (in this failure the user IS typing). Five seconds is Slate's
17
+ * composition-gone-quiet bound; the residual risk is one duplicated character
18
+ * after a pause, against losing all input until an unrelated event fires. */
71
19
  const COMPOSITION_IDLE_MS = 5000;
72
20
 
73
- let lastCompositionActivity = 0;
74
-
75
- export function init(opts: {
21
+ export interface CompositionOptions {
76
22
  textarea: HTMLTextAreaElement;
77
23
  compositionView: HTMLElement;
78
24
  getCursorPx: () => { left: number; top: number; cellH: number };
@@ -80,191 +26,173 @@ export function init(opts: {
80
26
  /** The kernel's single bracketed-paste+normalize funnel; the native iOS-callout
81
27
  * paste routes through it rather than re-composing the funnel here. */
82
28
  paste: (text: string) => void;
83
- }): void {
84
- textarea = opts.textarea;
85
- compositionView = opts.compositionView;
86
- getCursorPx = opts.getCursorPx;
87
- send = opts.send;
88
- paste = opts.paste;
29
+ }
89
30
 
90
- textarea.addEventListener("compositionstart", onStart);
91
- textarea.addEventListener("compositionupdate", onUpdate);
92
- textarea.addEventListener("compositionend", onEnd);
93
- textarea.addEventListener("paste", onPaste);
31
+ /** One pane's IME state: the latch every input listener gates on, the floating
32
+ * view, and the textarea placement. */
33
+ export interface Composition {
34
+ /** True while an IME composition is in progress, reconciled first: a
35
+ * composition quiet for COMPOSITION_IDLE_MS is expired here. */
36
+ isComposing(): boolean;
37
+ /** The RAW latch: is a composition open at all, regardless of staleness. Read
38
+ * before `isComposing()` by the keydown guard, because keyCode 229 means "an
39
+ * IME claimed this key" only while a composition is genuinely open. */
40
+ isCompositionOpen(): boolean;
41
+ /** Abort any in-flight composition without sending, and clear the textarea.
42
+ * Also neutralizes a just-fired compositionend whose deferred send is still
43
+ * pending, so it cannot land on whoever is active after a switch. */
44
+ cancelComposition(): void;
45
+ /** Place the view and the textarea at the terminal cursor. */
46
+ positionCompositionView(): void;
47
+ /** Release the listeners and any pending send. */
48
+ teardown(): void;
94
49
  }
95
50
 
96
- /** True while an IME composition is in progress, RECONCILED first: a composition
97
- * whose clock has not been refreshed for COMPOSITION_IDLE_MS is expired here,
98
- * clearing the latch, the clock and the overlay, so a keyboard that never fires
99
- * compositionend cannot strand the terminal or leave a stale panel on screen.
100
- * A late compositionend still delivers its text — `onEnd` snapshots what it
101
- * needs and does not require `composing` to have stayed true.
102
- *
103
- * `sendingComposition` is exempt: that window is bounded by a setTimeout(0)
104
- * this module owns. */
105
- export function isComposing(): boolean {
106
- if (sendingComposition) {
107
- return true;
51
+ export function createComposition(opts: CompositionOptions): Composition {
52
+ const { textarea, compositionView, getCursorPx, send, paste } = opts;
53
+ // The textarea's own clock and timers: the deferred send belongs to the
54
+ // document the terminal is mounted in, not the importing page.
55
+ const win = windowOf(textarea.ownerDocument);
56
+ let composing = false;
57
+ let compositionStart = 0;
58
+ let compositionSuffix = "";
59
+ let lastCompositionActivity = 0;
60
+ let pendingSend: number | null = null;
61
+
62
+ function expireComposition(): void {
63
+ composing = false;
64
+ lastCompositionActivity = 0;
65
+ compositionView.textContent = "";
66
+ compositionView.classList.remove("active");
108
67
  }
109
- if (!composing) {
68
+
69
+ function isComposing(): boolean {
70
+ if (pendingSend !== null) {
71
+ return true;
72
+ }
73
+ if (!composing) {
74
+ return false;
75
+ }
76
+ if (win.Date.now() - lastCompositionActivity <= COMPOSITION_IDLE_MS) {
77
+ return true;
78
+ }
79
+ expireComposition();
110
80
  return false;
111
81
  }
112
- if (Date.now() - lastCompositionActivity <= COMPOSITION_IDLE_MS) {
113
- return true;
114
- }
115
- expireComposition();
116
- return false;
117
- }
118
82
 
119
- /** The RAW latch: is a composition open at all, regardless of whether it has
120
- * gone quiet. Read before the reconciling `isComposing()` by the keydown guard,
121
- * because the keyCode 229 signal only means "an IME claimed this key" while a
122
- * composition is genuinely open — on Android it is the value for nearly every
123
- * soft-keyboard key. */
124
- export function isCompositionOpen(): boolean {
125
- return composing || sendingComposition;
126
- }
83
+ function clearPendingSend(): void {
84
+ if (pendingSend !== null) {
85
+ win.clearTimeout(pendingSend);
86
+ pendingSend = null;
87
+ }
88
+ }
127
89
 
128
- /** Drop a composition that has gone quiet: the latch, the clock and the visible
129
- * overlay together. Without the overlay half the gate would say no composition
130
- * while `.composition-view.active` still said there was one, leaving a stale
131
- * panel over the terminal for as long as the missing compositionend never
132
- * arrived. Does NOT touch the textarea: a late compositionend still reads it. */
133
- function expireComposition(): void {
134
- composing = false;
135
- lastCompositionActivity = 0;
136
- compositionView.textContent = "";
137
- compositionView.classList.remove("active");
138
- }
90
+ function cancelComposition(): void {
91
+ composing = false;
92
+ clearPendingSend();
93
+ lastCompositionActivity = 0;
94
+ compositionView.textContent = "";
95
+ compositionView.classList.remove("active");
96
+ resetToPlaceholder(textarea);
97
+ }
139
98
 
140
- /** Abort any in-flight composition without sending, and clear the textarea.
141
- * The kernel calls this on a tab switch (detach) and on blur: committing
142
- * half-composed IME text to either the outgoing or the incoming session is
143
- * wrong, so we discard it (design 5.1, "end any composition ... or cancel").
144
- * Clearing sendingComposition also neutralizes a just-fired compositionend
145
- * whose deferred send is still pending on the microtask/timeout queue, so it
146
- * cannot land on whoever is active after the switch. */
147
- export function cancelComposition(): void {
148
- composing = false;
149
- sendingComposition = false;
150
- lastCompositionActivity = 0;
151
- compositionView.textContent = "";
152
- compositionView.classList.remove("active");
153
- resetToPlaceholder(textarea);
154
- }
99
+ function positionCompositionView(): void {
100
+ const { left, top, cellH } = getCursorPx();
101
+ compositionView.style.left = `${left}px`;
102
+ compositionView.style.top = `${top}px`;
103
+ compositionView.style.height = `${cellH}px`;
104
+ compositionView.style.lineHeight = `${cellH}px`;
105
+ // The textarea shares the view's content coordinates and .term is pinned to
106
+ // the visual viewport, so this keeps the focused input at the VISIBLE cursor,
107
+ // above the keyboard, where iOS wants it and does not scroll the page.
108
+ textarea.style.left = `${left}px`;
109
+ textarea.style.top = `${top}px`;
110
+ textarea.style.height = `${cellH}px`;
111
+ }
155
112
 
156
- /** Release init()-attached listeners and drop the captured kernel closures on kernel
157
- * destroy(), so a destroy without a remount does not retain the old textarea + the
158
- * kernel send/paste closure graph. Mirrors viewport.teardown(). */
159
- export function teardown(): void {
160
- cancelComposition(); // neutralize a pending finalizer + reset composing state
161
- textarea.removeEventListener("compositionstart", onStart);
162
- textarea.removeEventListener("compositionupdate", onUpdate);
163
- textarea.removeEventListener("compositionend", onEnd);
164
- textarea.removeEventListener("paste", onPaste);
165
- // Drop references to the old kernel instance so its closure graph is GC-eligible
166
- // (the detached textarea/compositionView are overwritten on the next init()).
167
- send = () => {
168
- /* no-op after teardown */
169
- };
170
- paste = () => {
171
- /* no-op after teardown */
172
- };
173
- getCursorPx = () => ({ left: 0, top: 0, cellH: 0 });
174
- }
113
+ function onStart(): void {
114
+ composing = true;
115
+ lastCompositionActivity = win.Date.now();
116
+ const start = textarea.selectionStart;
117
+ const end = textarea.selectionEnd;
118
+ compositionStart = Math.min(start, end);
119
+ const compositionEnd = Math.max(start, end);
120
+ compositionSuffix = textarea.value.substring(compositionEnd);
121
+ compositionView.textContent = "";
122
+ compositionView.classList.add("active");
123
+ positionCompositionView();
124
+ }
175
125
 
176
- function onStart(): void {
177
- composing = true;
178
- lastCompositionActivity = Date.now();
179
- const start = textarea.selectionStart;
180
- const end = textarea.selectionEnd;
181
- compositionStart = Math.min(start, end);
182
- const compositionEnd = Math.max(start, end);
183
- compositionSuffix = textarea.value.substring(compositionEnd);
184
- compositionView.textContent = "";
185
- compositionView.classList.add("active");
186
- positionCompositionView();
187
- }
126
+ function onUpdate(ev: CompositionEvent): void {
127
+ lastCompositionActivity = win.Date.now();
128
+ // LTR marks plus direction:rtl on the view show a long composition's tail
129
+ // instead of clipping its start (xterm.js's pattern).
130
+ compositionView.textContent = `\u200E${ev.data}\u200E`;
131
+ positionCompositionView();
132
+ }
188
133
 
189
- function onUpdate(ev: CompositionEvent): void {
190
- // Every keystroke of a live composition lands here, which is what keeps its
191
- // staleness clock fresh (see COMPOSITION_IDLE_MS). An abandoned composition
192
- // stops firing this and ages out of the gate.
193
- lastCompositionActivity = Date.now();
194
- // \u200E (LTR mark) wrappers + CSS direction:rtl on the view make
195
- // long compositions show their trailing edge instead of being
196
- // clipped at the start. Pattern from xterm.js.
197
- compositionView.textContent = `\u200E${ev.data}\u200E`;
198
- positionCompositionView();
199
- }
134
+ function onEnd(): void {
135
+ compositionView.classList.remove("active");
136
+ composing = false;
137
+ lastCompositionActivity = 0;
138
+ clearPendingSend();
139
+ const startSnapshot = compositionStart;
140
+ const suffixSnapshot = compositionSuffix;
141
+ pendingSend = win.setTimeout(() => {
142
+ pendingSend = null;
143
+ const value = textarea.value;
144
+ const valueEnd =
145
+ suffixSnapshot.length > 0 && value.endsWith(suffixSnapshot)
146
+ ? value.length - suffixSnapshot.length
147
+ : value.length;
148
+ const composed = value
149
+ .substring(startSnapshot, Math.max(startSnapshot, valueEnd))
150
+ .replace(/\u00A0/g, " ");
151
+ if (composed.length > 0) {
152
+ send(composed);
153
+ }
154
+ resetToPlaceholder(textarea);
155
+ }, 0);
156
+ }
200
157
 
201
- function onEnd(): void {
202
- compositionView.classList.remove("active");
203
- composing = false;
204
- lastCompositionActivity = 0;
205
- // The compositionend event fires before the textarea reflects the
206
- // final value on most browsers (Chromium especially). Defer one tick
207
- // and read the textarea then; xterm.js's pattern.
208
- sendingComposition = true;
209
- const startSnapshot = compositionStart;
210
- const suffixSnapshot = compositionSuffix;
211
- setTimeout(() => {
212
- if (!sendingComposition) {
158
+ function onPaste(ev: ClipboardEvent): void {
159
+ // iOS has no Ctrl+Shift+V; its callout menu fires this on the focused textarea.
160
+ if (!ev.clipboardData) {
213
161
  return;
214
162
  }
215
- sendingComposition = false;
216
- const value = textarea.value;
217
- const valueEnd =
218
- suffixSnapshot.length > 0 && value.endsWith(suffixSnapshot)
219
- ? value.length - suffixSnapshot.length
220
- : value.length;
221
- const composed = value
222
- .substring(startSnapshot, Math.max(startSnapshot, valueEnd))
223
- .replace(/\u00A0/g, " ");
224
- if (composed.length > 0) {
225
- send(composed);
163
+ const raw = ev.clipboardData.getData("text/plain");
164
+ if (raw === "") {
165
+ return;
226
166
  }
167
+ ev.preventDefault();
168
+ ev.stopPropagation();
169
+ paste(raw);
227
170
  resetToPlaceholder(textarea);
228
- }, 0);
229
- }
171
+ }
230
172
 
231
- function onPaste(ev: ClipboardEvent): void {
232
- // Native `paste` event handler. Required for iOS where Ctrl+Shift+V
233
- // is unavailable; users invoke paste from the iOS callout menu and
234
- // it fires this event on the focused textarea.
235
- if (!ev.clipboardData) {
236
- return;
173
+ // One signal over every listener, so a registration that throws leaves the
174
+ // ones before it released with it.
175
+ const wired = new AbortController();
176
+ const { signal } = wired;
177
+ function teardown(): void {
178
+ cancelComposition();
179
+ wired.abort();
237
180
  }
238
- const raw = ev.clipboardData.getData("text/plain");
239
- if (raw === "") {
240
- return;
181
+ try {
182
+ textarea.addEventListener("compositionstart", onStart, { signal });
183
+ textarea.addEventListener("compositionupdate", onUpdate, { signal });
184
+ textarea.addEventListener("compositionend", onEnd, { signal });
185
+ textarea.addEventListener("paste", onPaste, { signal });
186
+ } catch (err) {
187
+ teardown();
188
+ throw err;
241
189
  }
242
- ev.preventDefault();
243
- ev.stopPropagation();
244
- paste(raw);
245
- // Restore the placeholder so the subsequent `input` event (some
246
- // browsers fire it after paste) and held-Backspace auto-repeat keep
247
- // working.
248
- resetToPlaceholder(textarea);
249
- }
250
190
 
251
- /** Position the composition view (and the helper textarea, which
252
- * anchors the IME popup) at the current terminal cursor pixel
253
- * position. iOS keyboards open without scrolling the layout when
254
- * the focused textarea is on screen at a sane location. */
255
- export function positionCompositionView(): void {
256
- const { left, top, cellH } = getCursorPx();
257
- compositionView.style.left = `${left}px`;
258
- compositionView.style.top = `${top}px`;
259
- compositionView.style.height = `${cellH}px`;
260
- compositionView.style.lineHeight = `${cellH}px`;
261
- // The textarea is position:absolute in the same scroll/content space as the
262
- // composition view (see the .term-input CSS note), so the cursor's content
263
- // coordinates place it at the cursor row directly. Because .term is pinned to
264
- // the visual viewport (viewport.ts), that keeps the textarea at the VISIBLE
265
- // cursor, above the keyboard — where iOS wants the focused input so it doesn't
266
- // scroll the page to reveal it.
267
- textarea.style.left = `${left}px`;
268
- textarea.style.top = `${top}px`;
269
- textarea.style.height = `${cellH}px`;
191
+ return {
192
+ isComposing,
193
+ isCompositionOpen: () => composing || pendingSend !== null,
194
+ cancelComposition,
195
+ positionCompositionView,
196
+ teardown,
197
+ };
270
198
  }
@@ -1,77 +1,45 @@
1
- /**
2
- * activityMonitor feature: subscribes to the server's status SSE
3
- * (/api/sessions/events via the engine's connectStatusStream) and exposes each
4
- * session's live status through its API. It is a
5
- * pure data source with no chrome of its own; the tabs feature consumes it via
6
- * ctx.use to render per-tab activity dots and drop exited/removed tabs. The
7
- * server pushes an initial snapshot on every (re)open — but the snapshot
8
- * carries no tombstones for sessions a REPLACEMENT server never knew (manager
9
- * restart), so the stream-open signal is surfaced via onStreamOpen and the
10
- * consumer (tabs) runs a one-shot GET /api/sessions reconcile there to drop
11
- * zombie tabs.
12
- *
13
- * @module
14
- */
1
+ // The server's snapshot on a (re)open carries no tombstones for sessions a
2
+ // REPLACEMENT server never knew, so the open is surfaced through onStreamOpen
3
+ // for a consumer to reconcile on.
15
4
 
16
- import { connectStatusStream } from "@cplieger/web-terminal-engine";
17
5
  import type { SessionStatus } from "@cplieger/web-terminal-engine";
18
6
  import type { TerminalFeature, Unsubscribe } from "../kernel/types.js";
19
7
 
20
8
  const DEFAULT_EVENTS_PATH = "/api/sessions/events";
21
9
 
22
- /** The value a peer feature reads through `ctx.use(activityMonitor(...))`: the
23
- * live status of every session the server knows about.
24
- *
25
- * Read-only — nothing here starts, stops or mutates a session. `current` answers
26
- * from the last event seen rather than from the network, so it is undefined for
27
- * a session no event has covered yet (the initial snapshot arrives
28
- * asynchronously after setup) and for one the stream has since reported
29
- * `removed`. A caller that needs "eventually" rather than "now" subscribes. */
10
+ /** The live status of every session the server knows about. `current` answers
11
+ * from the last event seen, so it is undefined for a session no event has
12
+ * covered yet and for one reported `removed`; a caller that needs "eventually"
13
+ * subscribes. */
30
14
  export interface ActivityMonitorApi {
31
- /** Subscribe to every status update (idle/working/warning/failed/input/done/
32
- * exited/crashed, plus removed), including the OSC 9;4 percentage and any OSC
33
- * 9 notification the event carries. Fired for the initial snapshot and each
34
- * change. */
15
+ /** Every status update, the initial snapshot included. */
35
16
  onStatus(cb: (s: SessionStatus) => void): Unsubscribe;
36
17
  /** The last known status for a session, or undefined. */
37
18
  current(id: string): SessionStatus | undefined;
38
- /** Subscribe to stream (re)open events — fired on the initial SSE connect
39
- * and every reconnect (including the reopen against a restarted server,
40
- * whose fresh snapshot cannot tombstone sessions it never knew). A
41
- * subscriber attaching after an open has already happened is called
42
- * immediately (catch-up), so a late consumer never misses the initial
43
- * open. Optional so alternative monitor implementations remain valid. */
19
+ /** Every stream (re)open; a subscriber attaching after one has happened is
20
+ * called at once. Optional so an alternative monitor stays valid. */
44
21
  onStreamOpen?(cb: () => void): Unsubscribe;
45
22
  }
46
23
 
47
- /** Build the activityMonitor feature.
48
- *
49
- * Requires a server that serves the status SSE endpoint; on a server without one
50
- * the stream never opens, every `current` stays undefined and no subscriber ever
51
- * fires, which is why `tabs` treats the monitor as optional and polls
52
- * `GET /api/sessions` instead when it is absent.
53
- *
54
- * `eventsPath` overrides the endpoint (default `/api/sessions/events`). Order it
55
- * BEFORE any feature that reads its API through `ctx.use`, since a feature's
56
- * setup can only see peers already set up. Teardown closes the stream and drops
57
- * every subscriber and cached status.
58
- *
59
- * A throwing subscriber is caught and logged rather than propagated: it must not
60
- * skip the remaining subscribers, and it must not reach the engine's SSE reader
61
- * and kill the stream for everyone. */
24
+ /** Build the activityMonitor feature. On a server without the SSE endpoint the
25
+ * stream never opens and no subscriber fires, which is why `tabs` treats the
26
+ * monitor as optional. Order it BEFORE any feature that reads it through
27
+ * `ctx.use`. A throwing subscriber is logged, never propagated: it must not
28
+ * skip the remaining subscribers or kill the stream for everyone. */
62
29
  export function activityMonitor(
63
30
  opts: { eventsPath?: string } = {},
64
31
  ): TerminalFeature<ActivityMonitorApi> {
65
32
  return {
66
33
  name: "activityMonitor",
67
- setup() {
34
+ scope: "shell",
35
+ setup(ctx) {
68
36
  const path = opts.eventsPath ?? DEFAULT_EVENTS_PATH;
69
37
  const statuses = new Map<string, SessionStatus>();
70
38
  const subs = new Set<(s: SessionStatus) => void>();
71
39
  const openSubs = new Set<() => void>();
72
40
  let opens = 0;
73
41
 
74
- const stream = connectStatusStream(path, {
42
+ const offStream = ctx.shell.subscribeStatus(path, {
75
43
  onOpen() {
76
44
  opens++;
77
45
  for (const cb of [...openSubs]) {
@@ -92,14 +60,12 @@ export function activityMonitor(
92
60
  try {
93
61
  cb(s);
94
62
  } catch (err) {
95
- // Isolate a throwing subscriber so it neither skips the
96
- // remaining subscribers nor propagates into the engine's
97
- // SSE reader (mirrors the kernel bus's ctx.on wrapping).
98
63
  console.error("web-terminal-ui: activityMonitor subscriber threw", err);
99
64
  }
100
65
  }
101
66
  },
102
67
  });
68
+ ctx.defer(offStream);
103
69
 
104
70
  return {
105
71
  api: {
@@ -113,13 +79,12 @@ export function activityMonitor(
113
79
  onStreamOpen(cb) {
114
80
  openSubs.add(cb);
115
81
  if (opens > 0) {
116
- cb(); // catch-up: the stream opened before this subscriber attached
82
+ cb();
117
83
  }
118
84
  return () => openSubs.delete(cb);
119
85
  },
120
86
  },
121
87
  teardown() {
122
- stream.close();
123
88
  subs.clear();
124
89
  openSubs.clear();
125
90
  statuses.clear();
@@ -1,45 +1,27 @@
1
- /**
2
- * animations feature: opt-in motion for the chrome surfaces.
3
- * Without it the chrome surfaces are instant (no transitions); with
4
- * it, and when the user has not asked for reduced motion, it enables the shared
5
- * animation vocabulary by marking the terminal root, which the feature
6
- * stylesheets key their transitions off. A few pieces of motion animate
7
- * unconditionally regardless of this feature (terminal-core cursor/SGR blink and
8
- * the mobile key toolbar open/close in 02-app.css, plus the working-status-dot
9
- * pulse in 05-tabs.css, its WCAG non-hue cue), though prefers-reduced-motion
10
- * still defeats all of it. It tracks prefers-reduced-motion live so toggling the
11
- * OS setting takes effect.
12
- *
13
- * @module
14
- */
15
-
1
+ import { windowOf } from "../kernel/realm.js";
16
2
  import type { TerminalFeature } from "../kernel/types.js";
17
3
 
18
- /** Build the animations feature. Exposes no API — its whole effect is the
19
- * `wt-animate` class on the terminal root, which the shipped stylesheets gate
20
- * their transitions on, so a composition either includes it or gets instant
21
- * chrome.
22
- *
23
- * Position in the feature list does not matter (nothing reads it through
24
- * `ctx.use`). The class is applied during setup, re-evaluated whenever the
25
- * `prefers-reduced-motion` media query changes, and removed on teardown along
26
- * with the listener. */
4
+ /** Build the animations feature. Exposes no API; its whole effect is the
5
+ * `wt-animate` class on the shell root, which the shipped stylesheets gate their
6
+ * transitions on, tracking prefers-reduced-motion live. */
27
7
  export function animations(): TerminalFeature {
28
8
  return {
29
9
  name: "animations",
10
+ scope: "shell",
30
11
  setup(ctx) {
31
- // The root is the surface's parent (regions + surface are its children).
32
- const root = ctx.surface().parentElement;
33
- const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
12
+ const root = ctx.shell.root;
13
+ const mq = windowOf(root.ownerDocument).matchMedia("(prefers-reduced-motion: reduce)");
34
14
  const apply = (): void => {
35
- root?.classList.toggle("wt-animate", !mq.matches);
15
+ root.classList.toggle("wt-animate", !mq.matches);
36
16
  };
37
17
  apply();
38
18
  mq.addEventListener("change", apply);
19
+ ctx.defer(() => {
20
+ mq.removeEventListener("change", apply);
21
+ });
39
22
  return {
40
23
  teardown() {
41
- mq.removeEventListener("change", apply);
42
- root?.classList.remove("wt-animate");
24
+ root.classList.remove("wt-animate");
43
25
  },
44
26
  };
45
27
  },