@cplieger/web-terminal-ui 7.3.2 → 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 +21 -0
  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
@@ -0,0 +1,1415 @@
1
+ import {
2
+ createTerminalEngine,
3
+ keyboard,
4
+ LineStore,
5
+ type Connection,
6
+ type TerminalEngine,
7
+ } from "@cplieger/web-terminal-engine";
8
+ import { createComposition } from "../composition.js";
9
+ import { createViewport } from "../viewport.js";
10
+ import { INPUT_PLACEHOLDER, resetToPlaceholder } from "../input-placeholder.js";
11
+ import { createBus } from "./bus.js";
12
+ import { TAP_MAX_MS, TAP_MOVEMENT_PX, isLinkTarget } from "./gesture.js";
13
+ import { createRegions } from "./regions.js";
14
+ import { createAnnouncer, createPaneTablist, type PaneTablist } from "./a11y.js";
15
+ import { createConnState } from "./conn-state.js";
16
+ import { selectionTextWithin } from "./selection.js";
17
+ import { windowOf } from "./realm.js";
18
+ import {
19
+ createCleanupScope,
20
+ createFeatureHost,
21
+ type CleanupScope,
22
+ type FeatureSetupOutcome,
23
+ } from "./feature-host.js";
24
+ import type {
25
+ CreateTerminalOptions,
26
+ PaneHandle,
27
+ PaneSide,
28
+ SessionRef,
29
+ ShellContext,
30
+ TablistController,
31
+ TerminalContext,
32
+ TerminalEvents,
33
+ TerminalFeature,
34
+ Unsubscribe,
35
+ } from "./types.js";
36
+
37
+ const { mapKeyboardEvent, bracketTextForPaste, prepareTextForTerminal } = keyboard;
38
+
39
+ const DEFAULT_WS_PATH = "/ws";
40
+ // The family that carries the CELL METRICS, alone, never --font-mono's list:
41
+ // WebKit settles load() over a list as soon as the FIRST family covering the
42
+ // sample has loaded, so naming the tiling overlay (11 KB, no "M" glyph) would
43
+ // open the gate while the companion the metrics come from is still loading.
44
+ const DEFAULT_FONT_READY = '14px "Monaspace Neon NF"';
45
+ // Neither document.fonts.load nor document.fonts.ready carries a deadline, and a
46
+ // response held open leaves both pending. 3s is the block period font-display:
47
+ // block buys; once the engine has painted the fallback, the cell on screen IS
48
+ // the fallback cell.
49
+ const FONT_READY_TIMEOUT_MS = 3000;
50
+ const TOAST_MS = 3000;
51
+ // Paged-in history is disposable by construction (recovery is one fetch), so it
52
+ // is evicted by INACTIVITY, never eagerly, to keep rapid scrolling instant.
53
+ const BROWSE_CACHE_TTL_MS = 5 * 60_000;
54
+
55
+ // Kernel-owned core subtree: the display-only output, the hidden textarea (the
56
+ // single keyboard target), and the IME composition view. Static, trusted markup.
57
+ const CORE_TEMPLATE = `
58
+ <div class="term">
59
+ <div class="term-output" role="log" aria-live="off" aria-roledescription="Terminal" aria-label="Terminal"></div>
60
+ <textarea class="term-input" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false" aria-label="Terminal input" tabindex="-1"></textarea>
61
+ <div class="composition-view" aria-hidden="true"></div>
62
+ </div>`;
63
+
64
+ /**
65
+ * The single character this key types, or null when it types none. `codePointAt`
66
+ * consumes a surrogate pair whole, so a key whose first scalar is the whole
67
+ * string is exactly one character; "Shift", "Dead", "F5" and "Process" are not.
68
+ * A key "associated with multiple characters" (macros, some dead-key sequences)
69
+ * returns null and is not recovered, a stated limitation.
70
+ */
71
+ function typedChar(ev: KeyboardEvent): string | null {
72
+ const first = ev.key.codePointAt(0);
73
+ return first !== undefined && String.fromCodePoint(first) === ev.key ? ev.key : null;
74
+ }
75
+
76
+ /** iOS sends U+00A0 where a space was typed; shared by every send path so one
77
+ * physical key cannot produce two byte sequences. */
78
+ function normalizeTypedText(text: string): string {
79
+ return text.replace(/\u00A0/g, " ");
80
+ }
81
+
82
+ /** `KeyboardEvent.keyCode` for a key an IME has claimed (VK_PROCESS). Deprecated
83
+ * and the only carrier of this fact: for the key that commits a composition both
84
+ * Blink and WebKit report `key` as the ordinary "Enter", and Safari reports
85
+ * `isComposing` false. Read only while a composition is open, because Android
86
+ * reports 229 for nearly every soft-keyboard key. */
87
+ const IME_COMMIT_KEYCODE = 229;
88
+
89
+ function pick(root: ParentNode, selector: string): HTMLElement {
90
+ const el = root.querySelector<HTMLElement>(selector);
91
+ if (!el) {
92
+ throw new Error(`web-terminal-ui: createTerminal failed to build ${selector}`);
93
+ }
94
+ return el;
95
+ }
96
+
97
+ /** The overlay service the shell gives each pane. */
98
+ interface PaneLoading {
99
+ reason(message: string): void;
100
+ /** The first frame from ANY pane dismisses the overlay. */
101
+ firstFrame(): void;
102
+ /** Dismisses the overlay once every built pane has failed. */
103
+ failed(): void;
104
+ }
105
+
106
+ /** The shell-owned store registry every pane's stores go through: a tab's store
107
+ * belongs to the tab and follows it into whichever pane shows it. */
108
+ export interface StoreRegistry {
109
+ /** Whether the consumer supplied scrollback persistence. */
110
+ readonly persisting: boolean;
111
+ /** `ctx.newLineStore`: a store hydrated from persistence when one exists,
112
+ * tracked for saving and for the browse-cache sweep. */
113
+ newLineStore(sessionId: string | undefined): LineStore;
114
+ /** The unmanaged pane's implicit store: the hydrated store when persistence
115
+ * holds a usable entry (registered under `sessionId`), else null after
116
+ * tracking `current` under that id. */
117
+ implicitStore(sessionId: string, current: LineStore): LineStore | null;
118
+ /** A resume answered under a known epoch verified this session's restore. */
119
+ verified(sessionId: string): void;
120
+ /** Drop restored scrollback that will never be confirmed: one session's (a 4001
121
+ * says one process is gone) or every unverified one (a wire refusal). The
122
+ * renderer holding the store is reconciled; a background store is reset; an
123
+ * unknown id is the calling pane's implicit store. */
124
+ discardUnverified(scope: { session: string } | "page", caller: PaneSide): void;
125
+ /** Every live tracked store except `bound`. */
126
+ backgroundStores(bound: LineStore): LineStore[];
127
+ /** Save every tracked store whose content advanced (the page-lifecycle hooks). */
128
+ flush(): void;
129
+ }
130
+
131
+ /** What the shell hands `buildPane` beyond the consumer's options: the members a
132
+ * feature must not see. */
133
+ export interface PaneServices {
134
+ readonly side: PaneSide;
135
+ readonly shell: ShellContext;
136
+ /** The `sessionStorage` key of this pane's unmanaged session id. */
137
+ readonly sessionIdKey: string;
138
+ /** A session owner or a pane layout owner exists, so no bare startup connect. */
139
+ readonly managed: boolean;
140
+ /** The resolved retained-line cap (`CreateTerminalOptions.scrollbackLines`),
141
+ * honored by the renderer's implicit store and every `ctx.newLineStore()`. */
142
+ readonly scrollbackLines: number | undefined;
143
+ /** The narrow classification for this pane (the shell root's width, this
144
+ * pane's height). */
145
+ narrowProbe(): boolean;
146
+ /** This pane's base title (the served `<title>`, or its program's OSC 0/2). */
147
+ titleBase(text: string): void;
148
+ readonly loading: PaneLoading;
149
+ onSessionEnded(): void;
150
+ /** This pane's tabpanel half of the ARIA seam, as soon as the panel exists. */
151
+ registerTablist(controller: PaneTablist): void;
152
+ /** The tab row's half, for `ctx.tablist()`. */
153
+ tablist(): TablistController;
154
+ /** The shell's keydown chain, run before this pane's own. */
155
+ onKeydown(ev: KeyboardEvent): boolean;
156
+ /** The two selection drivers. */
157
+ onPointerDown(): void;
158
+ onFocusIn(): void;
159
+ /** This pane attached a session or became empty. */
160
+ onSessionChange(): void;
161
+ /** The server epoch this pane's connection knew for a session it is about to
162
+ * forget, so the pane that shows the session next can be seeded with it. */
163
+ noteEpoch(sessionId: string, epoch: number): void;
164
+ /** The epoch a pane recorded for `sessionId` before forgetting it; 0 when none. */
165
+ knownEpoch(sessionId: string): number;
166
+ /** This pane's connection, registered as soon as the engine exists so the
167
+ * shell's store registry can seed it before the pane's first connect. */
168
+ registerConnection(connection: PaneKernel["connection"]): void;
169
+ readonly stores: StoreRegistry;
170
+ /** `ctx.dropSession`: forget the session on every pane and in the keeper. */
171
+ dropSession(id: string): void;
172
+ /** The terminal's one feature-error channel. */
173
+ reportError(feature: string, err: unknown): void;
174
+ onError(fn: (feature: string, err: unknown) => void): Unsubscribe;
175
+ }
176
+
177
+ /** The pane as the shell drives it: the feature-facing handle plus the members
178
+ * only the shell calls. */
179
+ export interface PaneKernel extends PaneHandle {
180
+ readonly connection: Pick<
181
+ Connection,
182
+ "forgetSession" | "serverEpochOf" | "adoptPersistedEpoch" | "currentSessionId"
183
+ >;
184
+ setupFeature(feature: TerminalFeature<unknown>): Promise<FeatureSetupOutcome>;
185
+ /** `PaneHandle.on` with the feature the handler belongs to, for error attribution. */
186
+ busOn<K extends keyof TerminalEvents>(
187
+ featureName: string,
188
+ e: K,
189
+ fn: (p: TerminalEvents[K]) => void,
190
+ ): Unsubscribe;
191
+ connectionInitiated(): boolean;
192
+ isDestroyed(): boolean;
193
+ layout(): { narrow: boolean; coarse: boolean };
194
+ toast(message: string, ms?: number): void;
195
+ announce(message: string, politeness?: "polite" | "assertive"): void;
196
+ paste(text: string): void;
197
+ addInputTransform(fn: (bytes: Uint8Array) => Uint8Array): Unsubscribe;
198
+ addInputObserver(fn: (bytes: Uint8Array) => void): Unsubscribe;
199
+ updateNarrow(): void;
200
+ /** Drop the LOCAL display: the client-side scrollback and screen. */
201
+ reset(): void;
202
+ reattach(): void;
203
+ /** Release the live runtime, the engine included, leaving the root's boundary
204
+ * classes for a recovery surface. */
205
+ cleanupRuntime(): void;
206
+ /** Record that this pane's startup failed, so `state()` reads `failed`. */
207
+ markFailed(): void;
208
+ /** Record whether a closed split hides this pane, so `state()` reads `hidden`
209
+ * from the moment the split closes rather than from the class the display
210
+ * gets when the closing transition ends. */
211
+ setHidden(value: boolean): void;
212
+ /** Whether this pane's input is a stop in the document's Tab order. On while
213
+ * the split is open and the pane shows a tab, so Tab runs left input, divider,
214
+ * right input, chrome; off otherwise, where typing or a click enters the one
215
+ * pane and Tab from outside lands on the chrome. */
216
+ setTabStop(value: boolean): void;
217
+ /** `cleanupRuntime()` plus the root's classes and children. */
218
+ destroy(): void;
219
+ }
220
+
221
+ /** The consumer's theme as custom properties on `root`, so the whole subtree
222
+ * inherits them over the token defaults of css/00-tokens.css. */
223
+ export function applyTheme(root: HTMLElement, theme: CreateTerminalOptions["theme"]): void {
224
+ if (!theme) {
225
+ return;
226
+ }
227
+ for (const [key, value] of Object.entries(theme)) {
228
+ if (key.startsWith("--")) {
229
+ root.style.setProperty(key, value);
230
+ }
231
+ }
232
+ }
233
+
234
+ /** Build one pane kernel inside `root`: the output surface, the hidden textarea
235
+ * that owns the keyboard, one engine instance, and the pane's regions and
236
+ * primitives; everything visible above the raw terminal is an opt-in feature.
237
+ * The pane REPORTS focus and blur to the transport and never writes DEC 1004
238
+ * bytes itself, because the server derives the answer from every attached
239
+ * client's report plus its own hold. Synchronous and transactional: every
240
+ * acquisition pushes its release onto one disposer stack, which a throw drains
241
+ * before it propagates and `cleanupRuntime()` drains on the way out. */
242
+ export function buildPane(
243
+ root: HTMLElement,
244
+ opts: CreateTerminalOptions,
245
+ services: PaneServices,
246
+ ): PaneKernel {
247
+ // One list of what this pane holds, drained by the throw path and the destroy
248
+ // path alike, so a half-built pane cannot leak.
249
+ const held = createCleanupScope((err) => {
250
+ console.error("web-terminal-ui: release failed during teardown", err);
251
+ });
252
+ try {
253
+ return buildPaneInto(root, opts, services, held);
254
+ } catch (err) {
255
+ held.drain();
256
+ throw err;
257
+ }
258
+ }
259
+
260
+ function buildPaneInto(
261
+ root: HTMLElement,
262
+ opts: CreateTerminalOptions,
263
+ services: PaneServices,
264
+ held: CleanupScope,
265
+ ): PaneKernel {
266
+ const acquire = (release: () => void): void => {
267
+ held.push(release);
268
+ };
269
+ const wsPath = opts.wsPath ?? DEFAULT_WS_PATH;
270
+ const fontReady = opts.fontReady ?? DEFAULT_FONT_READY;
271
+ const scrollbackLines = services.scrollbackLines;
272
+ const encoder = new TextEncoder();
273
+ // The root's realm, never the importing one: a pane in another document reads
274
+ // and listens to that document.
275
+ const doc = root.ownerDocument;
276
+ const win = windowOf(doc);
277
+ // `destroyed` means the runtime is no longer usable, whether through destroy()
278
+ // or a rollback; `rootReleased` (below) is narrower: the boundary classes are
279
+ // gone too. The flag is the stack's own, so a callback the build already armed
280
+ // (the fonts wait) finds it set after a throw as after destroy().
281
+ let destroyed = false;
282
+ acquire(() => {
283
+ destroyed = true;
284
+ });
285
+ const kernelAbort = new AbortController();
286
+ const { signal } = kernelAbort;
287
+ acquire(() => {
288
+ kernelAbort.abort();
289
+ });
290
+
291
+ applyTheme(root, opts.theme);
292
+
293
+ // .wt-root scopes every library token and style rule to this subtree; the
294
+ // layout-mode class decides how the root claims space (the full viewport, or
295
+ // the parent element). All chrome positions against the root, never the page.
296
+ const layoutMode = opts.layout ?? "viewport";
297
+ root.classList.add("wt-root", layoutMode === "container" ? "wt-container" : "wt-viewport");
298
+
299
+ const tpl = doc.createElement("template");
300
+ tpl.innerHTML = CORE_TEMPLATE;
301
+ root.replaceChildren(tpl.content);
302
+ acquire(() => {
303
+ root.classList.remove("wt-narrow");
304
+ root.replaceChildren();
305
+ });
306
+ const termWrap = pick(root, ".term");
307
+ const outputEl = pick(root, ".term-output");
308
+ const input = pick(root, ".term-input") as HTMLTextAreaElement;
309
+ const compositionViewEl = pick(root, ".composition-view");
310
+
311
+ const regions = createRegions(root);
312
+ acquire(() => {
313
+ regions.destroy();
314
+ });
315
+ const bus = createBus();
316
+ acquire(() => {
317
+ bus.clear();
318
+ });
319
+ const announcer = createAnnouncer(root);
320
+ acquire(() => {
321
+ announcer.destroy();
322
+ });
323
+ services.registerTablist(createPaneTablist(outputEl));
324
+
325
+ // Narrow = compact in EITHER dimension: skinny (a portrait phone, a narrow
326
+ // panel) or short (a landscape phone). ResizeObserver is a hard requirement
327
+ // of this library, so it is constructed with no guard.
328
+ function updateNarrow(): void {
329
+ root.classList.toggle("wt-narrow", services.narrowProbe());
330
+ }
331
+ updateNarrow();
332
+ const narrowObserver = new win.ResizeObserver(updateNarrow);
333
+ narrowObserver.observe(root);
334
+ acquire(() => {
335
+ narrowObserver.disconnect();
336
+ });
337
+
338
+ const toastEl = doc.createElement("div");
339
+ toastEl.className = "wt-toast";
340
+ toastEl.setAttribute("role", "status");
341
+ regions.region("banner", "toast").appendChild(toastEl);
342
+ let toastTimer: number | null = null;
343
+ function toast(message: string, ms = TOAST_MS): void {
344
+ toastEl.textContent = message;
345
+ toastEl.classList.add("visible");
346
+ if (toastTimer !== null) {
347
+ win.clearTimeout(toastTimer);
348
+ }
349
+ toastTimer = win.setTimeout(() => {
350
+ toastTimer = null;
351
+ toastEl.classList.remove("visible");
352
+ toastEl.textContent = "";
353
+ }, ms);
354
+ }
355
+ acquire(() => {
356
+ if (toastTimer !== null) {
357
+ win.clearTimeout(toastTimer);
358
+ toastTimer = null;
359
+ }
360
+ });
361
+
362
+ const reportError = (feature: string, err: unknown): void => {
363
+ services.reportError(feature, err);
364
+ };
365
+
366
+ const inputTransforms: ((b: Uint8Array) => Uint8Array)[] = [];
367
+ const inputObservers: ((b: Uint8Array) => void)[] = [];
368
+ const keydownHandlers: ((ev: KeyboardEvent) => boolean)[] = [];
369
+ acquire(() => {
370
+ inputTransforms.length = 0;
371
+ inputObservers.length = 0;
372
+ keydownHandlers.length = 0;
373
+ });
374
+ function removeFrom<T>(list: T[], item: T): () => void {
375
+ return () => {
376
+ const i = list.indexOf(item);
377
+ if (i >= 0) {
378
+ list.splice(i, 1);
379
+ }
380
+ };
381
+ }
382
+
383
+ function sendBytes(bytes: Uint8Array): void {
384
+ // A managed pane with no session (before its first attach, or emptied) sends
385
+ // nothing: the connection would mint an unmanaged session id and park the
386
+ // bytes in its outbox for whatever attaches next.
387
+ if (services.managed && activeSession === null) {
388
+ return;
389
+ }
390
+ let out = bytes;
391
+ for (const t of inputTransforms) {
392
+ out = t(out);
393
+ if (out.length === 0) {
394
+ return;
395
+ }
396
+ }
397
+ // sendBinary buffers while disconnected and returns false only when the
398
+ // outbox is full; observers see accepted input only, so predictive echo never
399
+ // paints a character that never reached the server.
400
+ if (!engine.connection.sendBinary(out)) {
401
+ return;
402
+ }
403
+ // User input re-engages follow and snaps to the bottom (GNOME/xterm), and is
404
+ // the ONLY thing that scrolls a held view down; mouse reports bypass this
405
+ // funnel or the view would snap on every motion report.
406
+ engine.scroll.scrollToBottom();
407
+ for (const obs of inputObservers) {
408
+ obs(out);
409
+ }
410
+ }
411
+ function sendText(text: string): void {
412
+ sendBytes(encoder.encode(text));
413
+ }
414
+ function paste(text: string): void {
415
+ sendText(bracketTextForPaste(prepareTextForTerminal(text), engine.modes));
416
+ }
417
+
418
+ let activeSession: SessionRef | null = null;
419
+ // The wake handlers (visibilitychange/pageshow/online) must not open a socket
420
+ // before the first connect: a bare wsPath on a session-gated server 404s, and
421
+ // pageshow fires on the initial load.
422
+ let connectionInitiated = false;
423
+ let failed = false;
424
+ let hidden = false;
425
+ let rootReleased = false;
426
+
427
+ let ready = false;
428
+ let firstFrameRendered = false;
429
+ let fontsLoaded = false;
430
+ let wsOpen = false;
431
+ function markReady(): void {
432
+ if (ready) {
433
+ return;
434
+ }
435
+ ready = true;
436
+ connState.setLoaded();
437
+ services.loading.firstFrame();
438
+ }
439
+
440
+ const connState = createConnState({
441
+ onState: (s) => {
442
+ bus.emit("connection:state", s);
443
+ },
444
+ onGiveUp: () => {
445
+ services.loading.failed();
446
+ },
447
+ timers: win,
448
+ });
449
+ acquire(() => {
450
+ connState.destroy();
451
+ });
452
+
453
+ // The functions above and below read `engine` from a DOM event, a socket
454
+ // message or a timer, none of which fires before createTerminalEngine returns.
455
+ function updateMousePointer(): void {
456
+ termWrap.classList.toggle("wt-mouse-app", engine.modes.getMouseMode() !== 0);
457
+ }
458
+ function measurableSize(): { cols: number; rows: number } | null {
459
+ if (!fontsLoaded || viewport.isInTransition()) {
460
+ return null;
461
+ }
462
+ engine.renderer.updateFontMetrics();
463
+ return engine.renderer.computeSize();
464
+ }
465
+ function maybeSendFirstResize(): void {
466
+ if (!wsOpen || measurableSize() === null) {
467
+ return;
468
+ }
469
+ engine.connection.sendResize();
470
+ }
471
+
472
+ const engine: TerminalEngine = createTerminalEngine({
473
+ output: outputEl,
474
+ termWrap,
475
+ wsPath,
476
+ sessionIdKey: services.sessionIdKey,
477
+ ...(scrollbackLines !== undefined ? { maxLines: scrollbackLines } : {}),
478
+ onCursorMove: () => {
479
+ composition.positionCompositionView();
480
+ bus.emit("render:cursor", undefined);
481
+ },
482
+ onUserScrollChange: (scrolledUp) => {
483
+ bus.emit("scroll:state", { scrolledUp });
484
+ },
485
+ callbacks: {
486
+ computeSize: () => engine.renderer.computeSize(),
487
+ getReplayMax: () => engine.renderer.replayMaxForResume(),
488
+ onResumeBounds() {
489
+ // A resume VERIFIES the restored store of the socket's OWN session: the
490
+ // server answered under a known epoch. One ack cannot vouch for a tab it
491
+ // never talked to.
492
+ services.stores.verified(engine.connection.currentSessionId());
493
+ },
494
+ initialSize: measurableSize,
495
+ onMessage(msg) {
496
+ if (msg.type === "screen") {
497
+ firstFrameRendered = true;
498
+ if (fontsLoaded) {
499
+ markReady();
500
+ }
501
+ bus.emit("wire:screen", msg);
502
+ } else if (msg.type === "title") {
503
+ // A shell clears its window title when it redraws its prompt after
504
+ // idling, so a blank OSC 0/2 keeps the last good title.
505
+ if (msg.title.trim() !== "") {
506
+ services.titleBase(msg.title);
507
+ }
508
+ bus.emit("wire:title", { session: activeSession?.id ?? "", title: msg.title });
509
+ } else if (msg.type === "modes") {
510
+ updateMousePointer();
511
+ bus.emit("wire:modes", msg);
512
+ } else if (msg.type === "clipboard") {
513
+ bus.emit("wire:clipboard", msg.text);
514
+ }
515
+ },
516
+ onOpen() {
517
+ connState.open();
518
+ wsOpen = true;
519
+ maybeSendFirstResize();
520
+ },
521
+ onConnecting() {
522
+ connState.reconnecting();
523
+ },
524
+ onClose() {
525
+ connState.closed();
526
+ },
527
+ onOutboxFull() {
528
+ connState.closed();
529
+ },
530
+ onProcessExit() {
531
+ // The definitive 4001 close. markReady() first, so an exit that lands
532
+ // before any screen frame still lowers the overlay; then the end state
533
+ // (no reconnect is coming); the host's turn LAST, so a throwing handler
534
+ // cannot leave the page without its banner.
535
+ services.stores.discardUnverified(
536
+ { session: engine.connection.currentSessionId() },
537
+ services.side,
538
+ );
539
+ markReady();
540
+ connState.ended();
541
+ services.onSessionEnded();
542
+ },
543
+ onWireIncompatible() {
544
+ services.stores.discardUnverified("page", services.side);
545
+ markReady();
546
+ connState.incompatible();
547
+ },
548
+ onServerRestart() {
549
+ engine.renderer.resetScrollback();
550
+ engine.renderer.resetScreen();
551
+ connState.restarted();
552
+ },
553
+ },
554
+ });
555
+ acquire(() => {
556
+ engine.dispose();
557
+ });
558
+ services.registerConnection(engine.connection);
559
+ engine.renderer.updateFontMetrics();
560
+ // Derived at build as well as from the two triggers, so a pane created over a
561
+ // mode state that already tracks the mouse shows the right pointer at once.
562
+ updateMousePointer();
563
+
564
+ // A press over the display-only output takes focus off the hidden textarea and
565
+ // the click handler puts it back; that blur/focus pair is an artifact of this
566
+ // input model, not a focus change the application should be told about (xterm.js
567
+ // never emits one). So a blur raised inside a press is withheld and the
568
+ // gesture's REAL end state is reported once it resolves.
569
+ let pressHoldsFocus = false;
570
+ let focusResolve: number | null = null;
571
+ function reportFocusNow(): void {
572
+ engine.connection.setClientFocus(
573
+ doc.activeElement !== null && termWrap.contains(doc.activeElement),
574
+ );
575
+ }
576
+ // The focus restore happens in the `click` handler, dispatched after pointerup
577
+ // within the same task, so a task boundary is the first settled point.
578
+ function endPressFocusGesture(): void {
579
+ if (focusResolve !== null) {
580
+ win.clearTimeout(focusResolve);
581
+ }
582
+ focusResolve = win.setTimeout(() => {
583
+ focusResolve = null;
584
+ pressHoldsFocus = false;
585
+ reportFocusNow();
586
+ }, 0);
587
+ }
588
+ acquire(() => {
589
+ if (focusResolve !== null) {
590
+ win.clearTimeout(focusResolve);
591
+ focusResolve = null;
592
+ }
593
+ });
594
+ termWrap.addEventListener(
595
+ "focusin",
596
+ () => {
597
+ engine.connection.setClientFocus(true);
598
+ },
599
+ { signal },
600
+ );
601
+ // Any focus move into the pane selects it, a control in the pane's own regions
602
+ // included, not only the textarea.
603
+ root.addEventListener(
604
+ "focusin",
605
+ () => {
606
+ services.onFocusIn();
607
+ },
608
+ { signal },
609
+ );
610
+ termWrap.addEventListener(
611
+ "focusout",
612
+ (ev) => {
613
+ if (ev.relatedTarget instanceof win.Node && termWrap.contains(ev.relatedTarget)) {
614
+ return;
615
+ }
616
+ if (pressHoldsFocus) {
617
+ return;
618
+ }
619
+ engine.connection.setClientFocus(false);
620
+ },
621
+ { signal },
622
+ );
623
+ reportFocusNow();
624
+
625
+ const composition = createComposition({
626
+ textarea: input,
627
+ compositionView: compositionViewEl,
628
+ getCursorPx: () => engine.renderer.getCursorPx(),
629
+ send: sendText,
630
+ paste,
631
+ });
632
+ acquire(() => {
633
+ composition.teardown();
634
+ });
635
+
636
+ resetToPlaceholder(input);
637
+ input.addEventListener(
638
+ "input",
639
+ (e: Event) => {
640
+ if (composition.isComposing()) {
641
+ return;
642
+ }
643
+ const ev = e as InputEvent;
644
+ const inputType = ev.inputType;
645
+ if (
646
+ inputType === "deleteContentBackward" ||
647
+ inputType === "deleteContentForward" ||
648
+ inputType === "deleteWordBackward" ||
649
+ inputType === "deleteWordForward"
650
+ ) {
651
+ resetToPlaceholder(input);
652
+ return;
653
+ } else if (inputType === "insertReplacementText") {
654
+ // A spellcheck, autocorrect or system-substitution REWRITE of text the pty
655
+ // already has. A terminal cannot retract bytes, and applying the rewrite
656
+ // forward duplicates (xterm.js #3600: `helo` corrected to `hello` puts
657
+ // `helohello` on the wire), so drop it and leave the typed text standing.
658
+ resetToPlaceholder(input);
659
+ return;
660
+ } else if (typeof ev.data === "string" && ev.data.length > 0) {
661
+ if (inputType === "insertFromPaste") {
662
+ paste(ev.data);
663
+ } else {
664
+ sendText(normalizeTypedText(ev.data));
665
+ }
666
+ } else {
667
+ const v = input.value;
668
+ if (v.length > INPUT_PLACEHOLDER.length && v.startsWith(INPUT_PLACEHOLDER)) {
669
+ sendText(normalizeTypedText(v.slice(INPUT_PLACEHOLDER.length)));
670
+ } else if (v !== INPUT_PLACEHOLDER && v.length > 0) {
671
+ sendText(normalizeTypedText(v));
672
+ }
673
+ }
674
+ resetToPlaceholder(input);
675
+ },
676
+ { signal },
677
+ );
678
+ input.addEventListener(
679
+ "focus",
680
+ () => {
681
+ termWrap.classList.add("focus");
682
+ },
683
+ { signal },
684
+ );
685
+ input.addEventListener(
686
+ "blur",
687
+ () => {
688
+ // cancelComposition, not just resetToPlaceholder: `composing` gates both
689
+ // input listeners, so a composition abandoned by losing focus would keep
690
+ // the gate shut when focus came back.
691
+ composition.cancelComposition();
692
+ termWrap.classList.remove("focus");
693
+ },
694
+ { signal },
695
+ );
696
+
697
+ /** Applies a `mapKeyboardEvent` result identically on BOTH keydown paths (the
698
+ * focused textarea and the document-level type-to-focus fallback), so the two
699
+ * cannot drift: handling only "send" in the fallback silently dropped
700
+ * Shift+PageUp, which is what a reader who just selected scrollback presses. */
701
+ function applyMappedKey(ev: KeyboardEvent, result: keyboard.KeyboardResult): void {
702
+ switch (result.kind) {
703
+ case "send":
704
+ ev.preventDefault();
705
+ sendText(result.bytes);
706
+ return;
707
+ case "scroll-up": {
708
+ ev.preventDefault();
709
+ const h = termWrap.clientHeight;
710
+ termWrap.scrollTop = Math.max(0, termWrap.scrollTop - h);
711
+ return;
712
+ }
713
+ case "scroll-down": {
714
+ ev.preventDefault();
715
+ const h = termWrap.clientHeight;
716
+ // The maximum offset, not scrollHeight, which is one clientHeight past the
717
+ // end; WebKit does not reliably clamp an out-of-range offset.
718
+ const max = Math.max(0, termWrap.scrollHeight - h);
719
+ termWrap.scrollTop = Math.min(max, termWrap.scrollTop + h);
720
+ return;
721
+ }
722
+ case "ignore":
723
+ return;
724
+ }
725
+ }
726
+ /** The shell's chain first, then this pane's features'. */
727
+ function runKeydownChain(ev: KeyboardEvent): boolean {
728
+ if (services.onKeydown(ev)) {
729
+ return true;
730
+ }
731
+ for (const h of keydownHandlers) {
732
+ if (h(ev)) {
733
+ return true;
734
+ }
735
+ }
736
+ return false;
737
+ }
738
+
739
+ input.addEventListener(
740
+ "keydown",
741
+ (ev: KeyboardEvent) => {
742
+ // Three gates, in this order. `isComposing` covers every engine except the
743
+ // key that COMMITS a composition (keyCode 229, Safari reports isComposing
744
+ // false), read against the RAW latch before the reconciling read clears it:
745
+ // a CJK user past the idle bound is still committing a real composition.
746
+ if (ev.isComposing) {
747
+ return;
748
+ }
749
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- the only carrier of the IME-commit fact; see IME_COMMIT_KEYCODE
750
+ if (composition.isCompositionOpen() && ev.keyCode === IME_COMMIT_KEYCODE) {
751
+ return;
752
+ }
753
+ if (composition.isComposing()) {
754
+ return;
755
+ }
756
+ if (runKeydownChain(ev)) {
757
+ return;
758
+ }
759
+ // An "ignore" needs nothing more: the `input` listener delivers the character.
760
+ applyMappedKey(ev, mapKeyboardEvent(ev, engine.modes));
761
+ },
762
+ { signal },
763
+ );
764
+
765
+ // On touch the output is the native text-selection surface, so this does the
766
+ // MINIMUM: it opens the keyboard on a clean tap and otherwise gets out of the
767
+ // browser's way. `any-pointer: fine` rather than pointerType because iPadOS
768
+ // reports a COARSE primary pointer even with a trackpad attached.
769
+ const hasFinePointer = (): boolean =>
770
+ typeof win.matchMedia === "function" && win.matchMedia("(any-pointer: fine)").matches;
771
+ let lastPointerType = "mouse";
772
+ let pointerDownX = 0;
773
+ let pointerDownY = 0;
774
+ let pointerDownTime = 0;
775
+ function focusTerminal(): void {
776
+ if (destroyed) {
777
+ return;
778
+ }
779
+ input.focus({ preventScroll: true });
780
+ }
781
+ // A passive listener on the pane root: a touch that starts a native selection
782
+ // never focuses the textarea, so focus alone would leave the touched pane
783
+ // unselected.
784
+ root.addEventListener(
785
+ "pointerdown",
786
+ () => {
787
+ services.onPointerDown();
788
+ },
789
+ { passive: true, signal },
790
+ );
791
+ termWrap.addEventListener(
792
+ "pointerdown",
793
+ (e) => {
794
+ lastPointerType = e.pointerType;
795
+ pointerDownX = e.clientX;
796
+ pointerDownY = e.clientY;
797
+ pointerDownTime = e.timeStamp;
798
+ pressHoldsFocus = true;
799
+ },
800
+ { passive: true, signal },
801
+ );
802
+ for (const end of ["pointerup", "pointercancel", "lostpointercapture"] as const) {
803
+ termWrap.addEventListener(end, endPressFocusGesture, { passive: true, signal });
804
+ }
805
+ termWrap.addEventListener(
806
+ "pointerup",
807
+ (e) => {
808
+ if (e.pointerType !== "touch") {
809
+ return;
810
+ }
811
+ if (isLinkTarget(win, e.target)) {
812
+ return;
813
+ }
814
+ const dx = Math.abs(e.clientX - pointerDownX);
815
+ const dy = Math.abs(e.clientY - pointerDownY);
816
+ // A drag or a long-press is not a tap-to-focus.
817
+ if (dx > TAP_MOVEMENT_PX || dy > TAP_MOVEMENT_PX) {
818
+ return;
819
+ }
820
+ if (e.timeStamp - pointerDownTime > TAP_MAX_MS) {
821
+ return;
822
+ }
823
+ // A clean tap while text is selected means "done selecting": iOS otherwise
824
+ // leaves the selection stuck, because the synthetic mousedown cancelled
825
+ // below also suppresses the platform's own tap-to-deselect. A deselect tap
826
+ // must not pop the soft keyboard, unless a hardware keyboard is present.
827
+ const sel = doc.getSelection();
828
+ if (sel && !sel.isCollapsed) {
829
+ sel.removeAllRanges();
830
+ if (hasFinePointer()) {
831
+ focusTerminal();
832
+ }
833
+ return;
834
+ }
835
+ focusTerminal();
836
+ },
837
+ { passive: true, signal },
838
+ );
839
+ termWrap.addEventListener(
840
+ "mousedown",
841
+ (e) => {
842
+ if (lastPointerType === "touch") {
843
+ // Cancel the synthetic mousedown after a touch tap so iOS keeps the
844
+ // keyboard up, except with a fine pointer, where suppressing it defeated
845
+ // the native focus.
846
+ if (!hasFinePointer()) {
847
+ e.preventDefault();
848
+ }
849
+ return;
850
+ }
851
+ // A bare left press INSIDE the current selection starts a native
852
+ // drag-and-drop of the selected text in Blink and Gecko, and a real mouse
853
+ // always moves a pixel, so the selection would be stuck. Collapsing it
854
+ // before the browser resolves the gesture keeps it on the select path; a
855
+ // modified or non-left press keeps the selection for its own purpose.
856
+ if (e.button !== 0 || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) {
857
+ return;
858
+ }
859
+ const pressSel = doc.getSelection();
860
+ if (pressSel && !pressSel.isCollapsed) {
861
+ pressSel.removeAllRanges();
862
+ }
863
+ },
864
+ { signal },
865
+ );
866
+ termWrap.addEventListener(
867
+ "click",
868
+ (e) => {
869
+ const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(".term-link");
870
+ if (link) {
871
+ e.preventDefault();
872
+ win.open(link.href, "_blank", "noopener,noreferrer");
873
+ return;
874
+ }
875
+ if (lastPointerType === "touch" && !hasFinePointer()) {
876
+ return;
877
+ }
878
+ const sel = doc.getSelection();
879
+ if (sel && sel.toString().length > 0) {
880
+ return;
881
+ }
882
+ focusTerminal();
883
+ },
884
+ { signal },
885
+ );
886
+
887
+ // A mouse gesture over `.term-output` leaves focus on the body, so typing
888
+ // silently does nothing (and the two shortcuts on the keydown chain with it).
889
+ // Focusing on the press would collapse the selection, so the browser's model
890
+ // stands and typing takes the keyboard back, as Windows Terminal does.
891
+ const ownsSelection = (): boolean => selectionTextWithin(outputEl) !== "";
892
+ doc.addEventListener(
893
+ "keydown",
894
+ (ev: KeyboardEvent) => {
895
+ // Reachable in one window: the feature teardowns run before the abort that
896
+ // removes this listener.
897
+ if (destroyed) {
898
+ return;
899
+ }
900
+ // A host that handled the key marked it; the platform's own convention.
901
+ if (ev.defaultPrevented) {
902
+ return;
903
+ }
904
+ // Nobody owns the keyboard: focus in a shadow root, a dialog or an ARIA
905
+ // widget all report non-body and all bail.
906
+ if (doc.activeElement !== null && doc.activeElement !== doc.body) {
907
+ return;
908
+ }
909
+ if (composition.isComposing()) {
910
+ return;
911
+ }
912
+ if (!ownsSelection()) {
913
+ return;
914
+ }
915
+ // Features while the selection is STILL INTACT and before the modifier
916
+ // gate: clipboard's Ctrl+Shift+C is a modified key that reads the selection.
917
+ if (runKeydownChain(ev)) {
918
+ return;
919
+ }
920
+ // Decided before taking focus, because a bare modifier press (Shift on its
921
+ // way to Shift+click) must leave the selection alone. Computed only for an
922
+ // "ignore", so the encoder always wins a key it maps.
923
+ const result = mapKeyboardEvent(ev, engine.modes);
924
+ const char = result.kind === "ignore" ? typedChar(ev) : null;
925
+ if (result.kind === "ignore" && char === null) {
926
+ return;
927
+ }
928
+ // Modified keys stay with the browser except an AltGr CHARACTER, the way
929
+ // some layouts type it; AltGraph alone is not a reliable signal (Firefox
930
+ // reports it for plain Option on macOS), and Alt+ArrowLeft is browser back.
931
+ const altGraphChar = char !== null && ev.getModifierState("AltGraph");
932
+ if ((ev.ctrlKey || ev.metaKey || ev.altKey) && !altGraphChar) {
933
+ return;
934
+ }
935
+ if (ev.key === "Tab") {
936
+ return;
937
+ }
938
+ doc.getSelection()?.removeAllRanges();
939
+ focusTerminal();
940
+ // Fail closed: bytes whose effect the user cannot see do not belong in a
941
+ // shell.
942
+ if (doc.activeElement !== input) {
943
+ return;
944
+ }
945
+ if (char !== null) {
946
+ // No `input` event is coming for a key that targeted the body, so send it
947
+ // here; preventDefault makes that exactly once BY SPEC.
948
+ ev.preventDefault();
949
+ sendText(normalizeTypedText(char));
950
+ return;
951
+ }
952
+ applyMappedKey(ev, result);
953
+ },
954
+ { signal },
955
+ );
956
+
957
+ const viewport = createViewport({
958
+ termWrap,
959
+ root,
960
+ scroll: engine.scroll,
961
+ suppressKeyboardInset: hasFinePointer,
962
+ onSettled() {
963
+ // The settle is the one resize a keyboard slide or rotation should cost;
964
+ // sendResize deduplicates.
965
+ if (measurableSize() !== null) {
966
+ engine.connection.sendResize();
967
+ }
968
+ composition.positionCompositionView();
969
+ },
970
+ });
971
+ acquire(() => {
972
+ viewport.teardown();
973
+ });
974
+
975
+ // Both handles are owned so a pane destroyed inside the font wait fires
976
+ // nothing afterwards: the settled callback would fade the consumer's overlay
977
+ // and measure a disposed renderer.
978
+ let fontDeadline: number | null = null;
979
+ let firstResizeFrame: number | null = null;
980
+ acquire(() => {
981
+ if (fontDeadline !== null) {
982
+ win.clearTimeout(fontDeadline);
983
+ fontDeadline = null;
984
+ }
985
+ if (firstResizeFrame !== null) {
986
+ win.cancelAnimationFrame(firstResizeFrame);
987
+ firstResizeFrame = null;
988
+ }
989
+ });
990
+ const onFontSettled = (): void => {
991
+ if (destroyed) {
992
+ return;
993
+ }
994
+ fontsLoaded = true;
995
+ if (firstFrameRendered) {
996
+ markReady();
997
+ }
998
+ firstResizeFrame = win.requestAnimationFrame(() => {
999
+ firstResizeFrame = null;
1000
+ maybeSendFirstResize();
1001
+ });
1002
+ };
1003
+ try {
1004
+ // The race covers the whole wait: a stalled request leaves load() itself
1005
+ // pending, so a deadline armed on the rejection alone would bound one of the
1006
+ // ways a font can hang.
1007
+ const landed: Promise<boolean> = doc.fonts.load(fontReady).then(
1008
+ () => false,
1009
+ (err: unknown) => {
1010
+ console.warn(`web-terminal-ui: web font ${fontReady} failed to load`, err);
1011
+ // One load() over a stack rejects as a UNIT, so the families that ARE
1012
+ // served still land before the first resize is measured.
1013
+ return doc.fonts.ready.then(() => false);
1014
+ },
1015
+ );
1016
+ const deadline = new Promise<boolean>((resolve) => {
1017
+ fontDeadline = win.setTimeout(() => {
1018
+ fontDeadline = null;
1019
+ resolve(true);
1020
+ }, FONT_READY_TIMEOUT_MS);
1021
+ });
1022
+ void Promise.race([landed, deadline]).then((timedOut) => {
1023
+ onFontSettled();
1024
+ if (destroyed || !timedOut) {
1025
+ return;
1026
+ }
1027
+ // The gate opened on fallback metrics and the swap period is infinite, so
1028
+ // one corrective announce once the bytes land; sendResize deduplicates.
1029
+ void doc.fonts.ready.then(() => {
1030
+ if (destroyed || measurableSize() === null) {
1031
+ return;
1032
+ }
1033
+ engine.connection.sendResize();
1034
+ });
1035
+ }, onFontSettled);
1036
+ } catch (err) {
1037
+ console.warn(`web-terminal-ui: invalid fontReady ${fontReady}`, err);
1038
+ onFontSettled();
1039
+ }
1040
+
1041
+ const browseSweep = win.setInterval(() => {
1042
+ // The BOUND store has a reader, so its drop is conditional and goes through
1043
+ // the renderer; every other store belongs to a background tab. The engine
1044
+ // stamps browse activity with its own module's Date.now, so the age is read
1045
+ // off that clock and not the mounted window's.
1046
+ const bound = engine.renderer.boundStore();
1047
+ if (
1048
+ engine.renderer.browseCacheSize() > 0 &&
1049
+ Date.now() - engine.renderer.lastBrowseActivityMs() >= BROWSE_CACHE_TTL_MS
1050
+ ) {
1051
+ engine.renderer.dropBrowseCache(doc.visibilityState === "visible");
1052
+ }
1053
+ for (const store of services.stores.backgroundStores(bound)) {
1054
+ if (
1055
+ store.browseCacheSize() > 0 &&
1056
+ Date.now() - store.lastBrowseActivityMs() >= BROWSE_CACHE_TTL_MS
1057
+ ) {
1058
+ store.dropBrowseCache(-1, false);
1059
+ }
1060
+ }
1061
+ }, 60_000);
1062
+ acquire(() => {
1063
+ win.clearInterval(browseSweep);
1064
+ });
1065
+ // Unconditional, for the page about to stop executing: a frozen page runs no
1066
+ // code, so its caches would stay resident for the whole freeze. Wrong on the
1067
+ // return transition, where a reader is arriving.
1068
+ const dropEveryBrowseCache = (): void => {
1069
+ const bound = engine.renderer.boundStore();
1070
+ engine.renderer.dropBrowseCache(false);
1071
+ for (const store of services.stores.backgroundStores(bound)) {
1072
+ store.dropBrowseCache(-1, false);
1073
+ }
1074
+ };
1075
+ // `freeze` is Chrome's Page Lifecycle signal; `pagehide` with `persisted` is
1076
+ // the Safari bfcache path. Either can fire without the other.
1077
+ doc.addEventListener(
1078
+ "freeze",
1079
+ () => {
1080
+ // Persist BEFORE dropping: a hidden page's socket keeps delivering, and
1081
+ // `freeze` is the last code that runs.
1082
+ services.stores.flush();
1083
+ dropEveryBrowseCache();
1084
+ },
1085
+ { signal },
1086
+ );
1087
+
1088
+ doc.addEventListener(
1089
+ "visibilitychange",
1090
+ () => {
1091
+ if (doc.visibilityState === "visible") {
1092
+ if (connectionInitiated) {
1093
+ engine.connection.reconnectNow();
1094
+ }
1095
+ if (services.shell.selected() === services.side) {
1096
+ focusTerminal();
1097
+ }
1098
+ return;
1099
+ }
1100
+ services.stores.flush();
1101
+ },
1102
+ { signal },
1103
+ );
1104
+ win.addEventListener(
1105
+ "pageshow",
1106
+ () => {
1107
+ if (connectionInitiated) {
1108
+ engine.connection.reconnectNow();
1109
+ }
1110
+ if (services.shell.selected() === services.side) {
1111
+ focusTerminal();
1112
+ }
1113
+ },
1114
+ { signal },
1115
+ );
1116
+ win.addEventListener(
1117
+ "pagehide",
1118
+ (event) => {
1119
+ services.stores.flush();
1120
+ if (event.persisted) {
1121
+ dropEveryBrowseCache();
1122
+ }
1123
+ },
1124
+ { signal },
1125
+ );
1126
+ win.addEventListener(
1127
+ "online",
1128
+ () => {
1129
+ if (connectionInitiated) {
1130
+ engine.connection.reconnectNow();
1131
+ }
1132
+ },
1133
+ { signal },
1134
+ );
1135
+
1136
+ const host = createFeatureHost(reportError);
1137
+ acquire(() => {
1138
+ host.teardownAll();
1139
+ });
1140
+
1141
+ function detachSession(): void {
1142
+ // Make input safe before the socket is re-pointed: end any in-flight IME
1143
+ // composition, clear the textarea, forget a held mouse gesture (the incoming
1144
+ // session's application never saw the press), and let every feature disarm
1145
+ // latched input state (mobileToolbar's sticky-Ctrl).
1146
+ composition.cancelComposition();
1147
+ resetToPlaceholder(input);
1148
+ engine.mouse.disarmGesture();
1149
+ for (const instance of host.instances()) {
1150
+ instance.onDetach?.();
1151
+ }
1152
+ }
1153
+
1154
+ function performSwitch(session: SessionRef): void {
1155
+ // A feature's un-cancelled async can request a switch after destroy().
1156
+ if (destroyed) {
1157
+ return;
1158
+ }
1159
+ detachSession();
1160
+ activeSession = session;
1161
+ // A session another pane forgot arrives with the epoch that pane knew, or the
1162
+ // first resumeAck has nothing to compare against and a server restart between
1163
+ // the two attaches goes undetected.
1164
+ const epoch = services.knownEpoch(session.id);
1165
+ if (epoch !== 0) {
1166
+ engine.connection.adoptPersistedEpoch(session.id, epoch);
1167
+ }
1168
+ engine.connection.setSession(session.id);
1169
+ // setSession restores the incoming session's mode mirror synchronously and
1170
+ // delivers no modes frame.
1171
+ updateMousePointer();
1172
+ connectionInitiated = true;
1173
+ for (const instance of host.instances()) {
1174
+ instance.onSwitch?.(session);
1175
+ }
1176
+ bus.emit("session:switch", session);
1177
+ services.onSessionChange();
1178
+ }
1179
+
1180
+ function clearActiveSession(): void {
1181
+ if (destroyed || activeSession === null) {
1182
+ return;
1183
+ }
1184
+ const outgoing = activeSession.id;
1185
+ detachSession();
1186
+ services.noteEpoch(outgoing, engine.connection.serverEpochOf(outgoing));
1187
+ engine.connection.forgetSession(outgoing);
1188
+ activeSession = null;
1189
+ // `connectionInitiated` must go false here or the wake handlers reconnect a
1190
+ // pane that holds no session, under a fresh id the server has never seen.
1191
+ connectionInitiated = false;
1192
+ engine.renderer.bind(new LineStore(scrollbackLines));
1193
+ engine.renderer.resetScreen();
1194
+ connState.idle();
1195
+ services.onSessionChange();
1196
+ }
1197
+
1198
+ const modes = engine.modes;
1199
+ const session = {
1200
+ get id() {
1201
+ return activeSession?.id ?? null;
1202
+ },
1203
+ size: () => engine.renderer.computeSize(),
1204
+ highestIndex: () => engine.renderer.getHighestIndex(),
1205
+ };
1206
+ const layout = (): { narrow: boolean; coarse: boolean } => ({
1207
+ narrow: services.narrowProbe(),
1208
+ coarse: typeof win.matchMedia === "function" && win.matchMedia("(pointer: coarse)").matches,
1209
+ });
1210
+ function busOn<K extends keyof TerminalEvents>(
1211
+ featureName: string,
1212
+ e: K,
1213
+ fn: (p: TerminalEvents[K]) => void,
1214
+ ): Unsubscribe {
1215
+ // Wrapped so a throwing feature handler is isolated and attributed.
1216
+ return bus.on(e, (p) => {
1217
+ try {
1218
+ fn(p);
1219
+ } catch (err) {
1220
+ reportError(featureName, err);
1221
+ }
1222
+ });
1223
+ }
1224
+
1225
+ function makeContext(featureName: string, scope: CleanupScope): TerminalContext {
1226
+ const track = (off: Unsubscribe): Unsubscribe => {
1227
+ scope.push(off);
1228
+ return off;
1229
+ };
1230
+ return {
1231
+ region: (name, slot) => regions.region(name, slot),
1232
+ surface: () => termWrap,
1233
+ send: sendBytes,
1234
+ paste,
1235
+ registerInputTransform(fn) {
1236
+ inputTransforms.push(fn);
1237
+ return track(removeFrom(inputTransforms, fn));
1238
+ },
1239
+ registerInputObserver(fn) {
1240
+ inputObservers.push(fn);
1241
+ return track(removeFrom(inputObservers, fn));
1242
+ },
1243
+ registerKeydown(fn) {
1244
+ keydownHandlers.push(fn);
1245
+ return track(removeFrom(keydownHandlers, fn));
1246
+ },
1247
+ render: engine.renderer,
1248
+ scroll: engine.scroll,
1249
+ modes,
1250
+ session,
1251
+ shell: services.shell,
1252
+ on: (e, fn) => track(busOn(featureName, e, fn)),
1253
+ defer(release) {
1254
+ scope.push(release);
1255
+ },
1256
+ use<A>(feature: TerminalFeature<A>): A | undefined {
1257
+ return host.use(feature) as A | undefined;
1258
+ },
1259
+ toast,
1260
+ announce: (message, politeness) => {
1261
+ announcer.announce(message, politeness);
1262
+ },
1263
+ loadingReason: (message) => {
1264
+ services.loading.reason(message);
1265
+ },
1266
+ tablist: () => services.tablist(),
1267
+ newLineStore: (sessionId) => services.stores.newLineStore(sessionId),
1268
+ layout,
1269
+ notifySwitch(s) {
1270
+ performSwitch(s);
1271
+ },
1272
+ dropSession(id) {
1273
+ services.dropSession(id);
1274
+ },
1275
+ onError: (fn) => track(services.onError(fn)),
1276
+ };
1277
+ }
1278
+
1279
+ function cleanupRuntime(): void {
1280
+ if (destroyed) {
1281
+ return;
1282
+ }
1283
+ destroyed = true;
1284
+ // A pane torn down while showing a tab hands the tab's epoch on as an emptied
1285
+ // one does: the tab lives on, and the next pane to show it is seeded from it.
1286
+ if (activeSession !== null) {
1287
+ services.noteEpoch(activeSession.id, engine.connection.serverEpochOf(activeSession.id));
1288
+ }
1289
+ activeSession = null;
1290
+ held.drain();
1291
+ }
1292
+
1293
+ engine.renderer.updateFontMetrics();
1294
+ composition.positionCompositionView();
1295
+ if (!services.managed) {
1296
+ // A single unmanaged pane has no feature to own its store, so the shell
1297
+ // hydrates the renderer's implicit one here, before connect(), because the
1298
+ // resume announces what this client already holds. Restored content does not
1299
+ // dismiss the overlay: it is last session's output until the resume confirms
1300
+ // it.
1301
+ if (services.stores.persisting) {
1302
+ const sessionId = engine.connection.currentSessionId();
1303
+ const restored = services.stores.implicitStore(sessionId, engine.renderer.boundStore());
1304
+ if (restored !== null) {
1305
+ engine.renderer.bind(restored);
1306
+ }
1307
+ }
1308
+ engine.connection.connect();
1309
+ connectionInitiated = true;
1310
+ }
1311
+ if (services.shell.selected() === services.side) {
1312
+ focusTerminal();
1313
+ }
1314
+
1315
+ return {
1316
+ get side() {
1317
+ return services.side;
1318
+ },
1319
+ root,
1320
+ surface: () => termWrap,
1321
+ render: engine.renderer,
1322
+ scroll: engine.scroll,
1323
+ modes,
1324
+ session,
1325
+ state() {
1326
+ if (failed) {
1327
+ return "failed";
1328
+ }
1329
+ if (hidden) {
1330
+ return "hidden";
1331
+ }
1332
+ return activeSession === null ? "empty" : "shown";
1333
+ },
1334
+ region: (name, slot) => regions.region(name, slot),
1335
+ on: (e, fn) => busOn("shell", e, fn),
1336
+ busOn,
1337
+ notifySwitch: performSwitch,
1338
+ clearActiveSession,
1339
+ announceSize() {
1340
+ if (!destroyed && fontsLoaded) {
1341
+ engine.connection.sendResize();
1342
+ }
1343
+ },
1344
+ focus: focusTerminal,
1345
+ send(bytes) {
1346
+ if (destroyed) {
1347
+ return;
1348
+ }
1349
+ sendBytes(bytes);
1350
+ },
1351
+ connection: engine.connection,
1352
+ setupFeature: (feature) =>
1353
+ host.setup(
1354
+ feature,
1355
+ (scope) => makeContext(feature.name, scope),
1356
+ () => destroyed,
1357
+ ),
1358
+ connectionInitiated: () => connectionInitiated,
1359
+ isDestroyed: () => destroyed,
1360
+ layout,
1361
+ toast,
1362
+ announce: (message, politeness) => {
1363
+ announcer.announce(message, politeness);
1364
+ },
1365
+ paste,
1366
+ addInputTransform(fn) {
1367
+ inputTransforms.push(fn);
1368
+ return removeFrom(inputTransforms, fn);
1369
+ },
1370
+ addInputObserver(fn) {
1371
+ inputObservers.push(fn);
1372
+ return removeFrom(inputObservers, fn);
1373
+ },
1374
+ updateNarrow,
1375
+ reset() {
1376
+ if (destroyed) {
1377
+ return;
1378
+ }
1379
+ engine.renderer.resetScrollback();
1380
+ engine.renderer.resetScreen();
1381
+ },
1382
+ reattach() {
1383
+ if (destroyed) {
1384
+ return;
1385
+ }
1386
+ // The old session's content goes first: holding it would put a
1387
+ // `haveThrough` on the resume that claims lines the replacement has never
1388
+ // reached. Off `ended`, or the banner would contradict the blanked screen.
1389
+ engine.renderer.resetScrollback();
1390
+ engine.renderer.resetScreen();
1391
+ connState.reconnecting();
1392
+ engine.connection.reconnectNow();
1393
+ },
1394
+ cleanupRuntime,
1395
+ markFailed() {
1396
+ failed = true;
1397
+ },
1398
+ setHidden(value) {
1399
+ hidden = value;
1400
+ },
1401
+ setTabStop(value) {
1402
+ input.tabIndex = value ? 0 : -1;
1403
+ },
1404
+ destroy() {
1405
+ if (rootReleased) {
1406
+ return;
1407
+ }
1408
+ rootReleased = true;
1409
+ cleanupRuntime();
1410
+ root.classList.remove("wt-root", "wt-viewport", "wt-container", "wt-narrow");
1411
+ // A fatal handler may have rendered replacement UI after the drain.
1412
+ root.replaceChildren();
1413
+ },
1414
+ };
1415
+ }