@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,1434 @@
1
+ import {
2
+ LineStore,
3
+ connectStatusStream,
4
+ type EventSourceLike,
5
+ } from "@cplieger/web-terminal-engine";
6
+ import {
7
+ applyTheme,
8
+ buildPane,
9
+ type PaneKernel,
10
+ type PaneServices,
11
+ type StoreRegistry,
12
+ } from "./pane.js";
13
+ import { createStatusShare } from "./status-share.js";
14
+ import { browserNotifierEnv, createNotifier } from "./notify.js";
15
+ import { attachLoadingStatus, DEFAULT_LOADING_MESSAGES } from "./loading-status.js";
16
+ import {
17
+ createCleanupScope,
18
+ createFeatureHost,
19
+ type CleanupScope,
20
+ type FeatureSetupOutcome,
21
+ } from "./feature-host.js";
22
+ import { createScrollbackKeeper } from "./scrollback.js";
23
+ import { optionalPositiveIntOption } from "./options.js";
24
+ import { clampRatio, isNarrow, isPaneSide, MIN_SPLIT_AREA_PX } from "./layout-policy.js";
25
+ import { fadeOutOverlay, renderFatalStartupInto } from "./fatal.js";
26
+ import { browserAttentionSinks, createAttention, type AttentionSurface } from "./attention.js";
27
+ import { createRegions, type Regions } from "./regions.js";
28
+ import { createAnnouncer, createTablist, type Announcer, type PaneTablist } from "./a11y.js";
29
+ import { createSplitHandle, type SplitHandle } from "./split-handle.js";
30
+ import { windowOf } from "./realm.js";
31
+ import type {
32
+ AttentionOptions,
33
+ AttentionReporter,
34
+ CreateTerminalOptions,
35
+ PaneSide,
36
+ ShellContext,
37
+ SplitController,
38
+ SplitState,
39
+ TerminalContext,
40
+ TerminalEvents,
41
+ TerminalFeature,
42
+ TerminalHandle,
43
+ TerminalStartupFailure,
44
+ Unsubscribe,
45
+ } from "./types.js";
46
+
47
+ // The title, the overlay, the status stream and the notification permission are
48
+ // the DOCUMENT's, so their owner is one per document; the key IS the document.
49
+ const liveShells = new WeakMap<Document, true>();
50
+
51
+ /** Whether `doc` already holds a terminal that has not been destroyed. */
52
+ export function hasLiveShell(doc: Document): boolean {
53
+ return liveShells.has(doc);
54
+ }
55
+
56
+ /** Build the feature list. `features` is a FUNCTION so a preset that throws does
57
+ * so inside createTerminal's failure boundary. */
58
+ function resolveFeatures(
59
+ features: CreateTerminalOptions["features"],
60
+ ): readonly TerminalFeature<unknown>[] {
61
+ return features === undefined ? [] : features();
62
+ }
63
+
64
+ /** The `split` option as an untyped caller (an inline page script) may spell it. */
65
+ function resolveSplitOption(value: unknown): boolean {
66
+ if (value === undefined) {
67
+ return false;
68
+ }
69
+ if (typeof value !== "boolean") {
70
+ throw new Error("web-terminal-ui: split must be true or false");
71
+ }
72
+ return value;
73
+ }
74
+
75
+ function isRatio(value: unknown): value is number {
76
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
77
+ }
78
+
79
+ /** A CSS `<time>` (`0.2s`, `200ms`) in milliseconds; 0 for anything else. */
80
+ function cssTimeMs(value: string): number {
81
+ const m = /^\s*(\d*\.?\d+)(m?s)\s*$/.exec(value);
82
+ if (m === null) {
83
+ return 0;
84
+ }
85
+ const n = Number(m[1]);
86
+ return m[2] === "s" ? n * 1000 : n;
87
+ }
88
+
89
+ /** The controller a terminal built without the split option carries. */
90
+ function disabledSplit(): SplitController {
91
+ const state: SplitState = {
92
+ open: false,
93
+ collapsed: false,
94
+ ratio: 0.5,
95
+ committedRatio: 0.5,
96
+ selected: "left",
97
+ };
98
+ return {
99
+ enabled: false,
100
+ state: () => state,
101
+ isOpen: () => false,
102
+ canOpen: () => false,
103
+ open: () => false,
104
+ close: () => false,
105
+ closeSide: () => false,
106
+ setRatio: () => false,
107
+ onChange: () => () => undefined,
108
+ };
109
+ }
110
+
111
+ const IMPLICIT_STORE_KEY = "\u0000implicit";
112
+ const SESSION_ID_KEYS = ["vterm-session-id", "vterm-session-id:2"] as const;
113
+
114
+ /** One pane's place in the shell: its root, its kernel once built, and the side
115
+ * the shell has assigned it. */
116
+ interface Slot {
117
+ /** 0 for the pane built with the terminal, 1 for the one the first open builds. */
118
+ readonly index: 0 | 1;
119
+ side: PaneSide;
120
+ readonly root: HTMLElement;
121
+ /** Null before the build and after a build that threw. */
122
+ kernel: PaneKernel | null;
123
+ /** The pane's startup failed and its root is the recovery surface. */
124
+ failed: boolean;
125
+ loadingFailed: boolean;
126
+ baseTitle: string;
127
+ connection: PaneKernel["connection"] | null;
128
+ tablist: PaneTablist | null;
129
+ }
130
+
131
+ type PaneFailure =
132
+ | { readonly phase: "kernel-init"; readonly cause: unknown }
133
+ | { readonly phase: "feature-setup"; readonly feature: string; readonly cause: unknown };
134
+
135
+ /** A close whose columns are still sliding: the model is closed already and the
136
+ * display finishes when the transition ends, on the fallback timer, or on
137
+ * `settle()`. */
138
+ interface ClosingTransition {
139
+ /** The ratio the columns slide to: 1 when the left pane survives, 0 when the right does. */
140
+ readonly toward: 0 | 1;
141
+ /** Finish the display now. */
142
+ settle(): void;
143
+ /** Drop the pending finish without running it. */
144
+ abort(): void;
145
+ }
146
+
147
+ /** A shell-scoped feature's registration on every built pane and on every pane
148
+ * built later, released as one. */
149
+ interface PaneRegistration {
150
+ readonly register: (p: PaneKernel) => Unsubscribe;
151
+ readonly offs: Map<PaneKernel, Unsubscribe>;
152
+ }
153
+
154
+ /** Build the terminal in `root`: the shell that owns the document-level facts
155
+ * (title, loading overlay, status stream, notifier, scrollback keeper, the
156
+ * shell-scoped features), its panes, and the feature lifecycle. Without the
157
+ * split option `root` is the one pane's root; with it `root` is the shell root
158
+ * holding a pane root per pane and the shared chrome. Registers the document;
159
+ * `destroy()` releases it. Transactional: a throw releases everything acquired
160
+ * before it, the registration included. */
161
+ export function createShell(root: HTMLElement, opts: CreateTerminalOptions): TerminalHandle {
162
+ // One list of what the shell holds, drained by the throw path and the destroy
163
+ // path alike, so a half-built shell cannot leak or keep its document claimed.
164
+ const held = createCleanupScope((err) => {
165
+ console.error("web-terminal-ui: release failed during teardown", err);
166
+ });
167
+ try {
168
+ return createShellInto(root, opts, held);
169
+ } catch (err) {
170
+ held.drain();
171
+ throw err;
172
+ }
173
+ }
174
+
175
+ function createShellInto(
176
+ root: HTMLElement,
177
+ opts: CreateTerminalOptions,
178
+ held: CleanupScope,
179
+ ): TerminalHandle {
180
+ const featureList = resolveFeatures(opts.features);
181
+ const layoutMode = opts.layout ?? "viewport";
182
+ const splitEnabled = resolveSplitOption(opts.split);
183
+ // Every page service binds to the ROOT's realm, never the importing one, so
184
+ // a terminal in another document owns that document's title and lifecycle.
185
+ const doc = root.ownerDocument;
186
+ const win = windowOf(doc);
187
+ // Read before any pane or attention mark writes the title: a pane built later
188
+ // starts from the served title, not from a title carrying another pane's base
189
+ // and the attention count.
190
+ const servedTitle = doc.title;
191
+ let destroyed = false;
192
+ // A live read for post-await and post-callback checks: a plain variable read is
193
+ // narrowed to always-false by TS CFA, which cannot model destroy() firing
194
+ // during an await or inside a consumer's handler.
195
+ const isDestroyed = (): boolean => destroyed;
196
+
197
+ // At most one feature owns the first connect, whichever registration it uses.
198
+ for (const f of featureList) {
199
+ if (f.sessionOwner !== undefined && f.paneLayoutOwner !== undefined) {
200
+ throw new Error(`web-terminal-ui: ${f.name} registers both sessionOwner and paneLayoutOwner`);
201
+ }
202
+ }
203
+ const owners = featureList.filter(
204
+ (f) => f.sessionOwner !== undefined || f.paneLayoutOwner !== undefined,
205
+ );
206
+ if (owners.length > 1) {
207
+ throw new Error(
208
+ `web-terminal-ui: multiple session-owning features: ${owners.map((f) => f.name).join(", ")}`,
209
+ );
210
+ }
211
+ const owner = owners[0];
212
+ if (splitEnabled && owner?.paneLayoutOwner === undefined) {
213
+ throw new Error("web-terminal-ui: split requires the tabs feature (tabs() or a tabbed preset)");
214
+ }
215
+ const managed = owner !== undefined;
216
+ const scrollbackLines = optionalPositiveIntOption(opts.scrollbackLines, "scrollbackLines");
217
+ // The terminal writes each feature object's `api` and resolves `ctx.use` on
218
+ // its identity, so a pane feature object serves one pane only.
219
+ const usedPaneFeatures = new WeakSet<TerminalFeature<unknown>>();
220
+ for (const f of featureList) {
221
+ if (f.scope !== "shell") {
222
+ usedPaneFeatures.add(f);
223
+ }
224
+ }
225
+
226
+ liveShells.set(doc, true);
227
+ held.push(() => {
228
+ liveShells.delete(doc);
229
+ });
230
+
231
+ const shellRoot = root;
232
+ let shellRegions: Regions | null = null;
233
+ let announcer: Announcer | null = null;
234
+ if (splitEnabled) {
235
+ // Written here AND by each kernel on its pane root: `.wt-root` redeclares
236
+ // every token, so a theme written only here would be reset at the pane.
237
+ applyTheme(shellRoot, opts.theme);
238
+ shellRoot.classList.add(
239
+ "wt-root",
240
+ "wt-split",
241
+ layoutMode === "container" ? "wt-container" : "wt-viewport",
242
+ );
243
+ shellRoot.replaceChildren();
244
+ held.push(() => {
245
+ shellRoot.classList.remove(
246
+ "wt-narrow",
247
+ "wt-split-open",
248
+ "wt-split-collapsed",
249
+ "wt-split-closing",
250
+ );
251
+ shellRoot.style.removeProperty("--wt-split-ratio");
252
+ shellRoot.replaceChildren();
253
+ });
254
+ const regions = createRegions(shellRoot);
255
+ shellRegions = regions;
256
+ held.push(() => {
257
+ regions.destroy();
258
+ });
259
+ const shellAnnouncer = createAnnouncer(shellRoot);
260
+ announcer = shellAnnouncer;
261
+ held.push(() => {
262
+ shellAnnouncer.destroy();
263
+ });
264
+ }
265
+ const paneOpts: CreateTerminalOptions = splitEnabled ? { ...opts, layout: "container" } : opts;
266
+
267
+ const loadingStatus = attachLoadingStatus(opts.loading, {
268
+ ...DEFAULT_LOADING_MESSAGES,
269
+ ...opts.loadingMessages,
270
+ });
271
+ held.push(() => {
272
+ loadingStatus.stop();
273
+ });
274
+ let overlayDismissed = false;
275
+ function dismissLoadingOverlay(): void {
276
+ loadingStatus.stop();
277
+ if (!opts.loading || overlayDismissed) {
278
+ return;
279
+ }
280
+ overlayDismissed = true;
281
+ fadeOutOverlay(opts.loading);
282
+ }
283
+ // Only a pane with a live connect can still deliver a first frame; an empty or
284
+ // hidden one never reports failure, so it must not hold the overlay up.
285
+ function settleLoading(): void {
286
+ const pending = slots.some((s) => !s.loadingFailed && s.kernel?.connectionInitiated() === true);
287
+ if (!pending && slots.some((s) => s.loadingFailed)) {
288
+ dismissLoadingOverlay();
289
+ }
290
+ }
291
+
292
+ const slots: Slot[] = [];
293
+ const slotAt = (side: PaneSide): Slot | undefined => slots.find((s) => s.side === side);
294
+ const paneAt = (side: PaneSide): PaneKernel | undefined => slotAt(side)?.kernel ?? undefined;
295
+ const builtPanes = (): PaneKernel[] => {
296
+ const out: PaneKernel[] = [];
297
+ for (const side of ["left", "right"] as const) {
298
+ const p = paneAt(side);
299
+ if (p) {
300
+ out.push(p);
301
+ }
302
+ }
303
+ return out;
304
+ };
305
+ const connections = (): PaneKernel["connection"][] =>
306
+ slots.flatMap((s) => (s.connection === null ? [] : [s.connection]));
307
+ let selectedSide: PaneSide = "left";
308
+ const selectionListeners = new Set<(side: PaneSide) => void>();
309
+ const panesListeners = new Set<() => void>();
310
+ let closing: ClosingTransition | null = null;
311
+ held.push(() => {
312
+ closing?.abort();
313
+ closing = null;
314
+ });
315
+ const selectedPane = (): PaneKernel => {
316
+ const p = paneAt(selectedSide) ?? slots[0]?.kernel ?? undefined;
317
+ if (!p) {
318
+ throw new Error("web-terminal-ui: the terminal has no pane");
319
+ }
320
+ return p;
321
+ };
322
+ function paintSide(slot: Slot): void {
323
+ slot.root.classList.toggle("wt-side-left", slot.side === "left");
324
+ slot.root.classList.toggle("wt-side-right", slot.side === "right");
325
+ }
326
+ // The side is the model's at once; its grid column follows when no closing
327
+ // transition holds the columns where they were.
328
+ function setSide(slot: Slot, side: PaneSide): void {
329
+ slot.side = side;
330
+ if (closing === null) {
331
+ paintSide(slot);
332
+ }
333
+ }
334
+ function firePanesChange(): void {
335
+ for (const cb of [...panesListeners]) {
336
+ cb();
337
+ }
338
+ }
339
+
340
+ let titleMark = "";
341
+ function paintTitle(): void {
342
+ const next =
343
+ titleMark + (slotAt(selectedSide)?.baseTitle ?? slots[0]?.baseTitle ?? servedTitle);
344
+ // The title doubles as the browser-tab label and the bookmark name, and this
345
+ // runs on every status sweep, so an unchanged assignment must not churn it.
346
+ if (doc.title !== next) {
347
+ doc.title = next;
348
+ }
349
+ }
350
+ const attentionSurfaces = new Set<AttentionSurface>();
351
+ // The badge is OS-level and the icon links outlive the terminal; the base
352
+ // title belongs to the document.
353
+ held.push(() => {
354
+ for (const surface of attentionSurfaces) {
355
+ surface.dispose();
356
+ }
357
+ attentionSurfaces.clear();
358
+ });
359
+ function attention(options: AttentionOptions): AttentionReporter {
360
+ // A setup still running past destroy() gets a reporter that writes nothing.
361
+ if (destroyed) {
362
+ return { report: () => undefined };
363
+ }
364
+ const surface = createAttention(
365
+ browserAttentionSinks(
366
+ (text) => {
367
+ titleMark = text;
368
+ paintTitle();
369
+ },
370
+ options,
371
+ doc,
372
+ ),
373
+ win,
374
+ );
375
+ attentionSurfaces.add(surface);
376
+ return surface;
377
+ }
378
+ // The stream opens on the root's window, not the importing page's.
379
+ const eventSourceIn = (url: string): EventSourceLike => {
380
+ const es = new win.EventSource(url);
381
+ return {
382
+ addEventListener: (type, listener) => {
383
+ es.addEventListener(type, listener as EventListener);
384
+ },
385
+ close: () => {
386
+ es.close();
387
+ },
388
+ get readyState() {
389
+ return es.readyState;
390
+ },
391
+ };
392
+ };
393
+ const statusShare = createStatusShare((path, callbacks) =>
394
+ connectStatusStream(path, callbacks, eventSourceIn),
395
+ );
396
+ held.push(() => {
397
+ statusShare.dispose();
398
+ });
399
+ const notifications = createNotifier(browserNotifierEnv(doc));
400
+
401
+ let splitOpen = false;
402
+ let collapsed = false;
403
+ let committedRatio = 0.5;
404
+ /** The share a drag is previewing, unclamped so a pane can dip under the
405
+ * minimum before the release decides between a close and a snap back. */
406
+ let previewRatio: number | null = null;
407
+ const changeListeners = new Set<(state: SplitState) => void>();
408
+ const rowWidth = (): number => shellRoot.clientWidth;
409
+ // Two ratios: a resize must keep both panes at the minimum without rewriting
410
+ // the share the person chose, which the record holds.
411
+ const effectiveRatio = (): number =>
412
+ splitOpen ? (previewRatio ?? clampRatio(committedRatio, rowWidth())) : 0.5;
413
+ const splitState = (): SplitState => ({
414
+ open: splitOpen,
415
+ collapsed,
416
+ ratio: effectiveRatio(),
417
+ committedRatio,
418
+ selected: selectedSide,
419
+ });
420
+ function applyRatioVar(): void {
421
+ shellRoot.style.setProperty("--wt-split-ratio", String(closing?.toward ?? effectiveRatio()));
422
+ }
423
+ /** How long a close slides, from the same token the stylesheet's transition
424
+ * reads; 0 without `wt-animate`, and the change is then instant. */
425
+ function closingDurationMs(): number {
426
+ if (!shellRoot.classList.contains("wt-animate")) {
427
+ return 0;
428
+ }
429
+ return cssTimeMs(win.getComputedStyle(shellRoot).getPropertyValue("--dur-standard"));
430
+ }
431
+ function beginClosing(toward: 0 | 1, durationMs: number, tail: () => void): void {
432
+ shellRoot.classList.add("wt-split-closing");
433
+ const stop = (): void => {
434
+ win.clearTimeout(timer);
435
+ shellRoot.removeEventListener("transitionend", onEnd);
436
+ closing = null;
437
+ };
438
+ const settle = (): void => {
439
+ stop();
440
+ tail();
441
+ };
442
+ const onEnd = (ev: TransitionEvent): void => {
443
+ if (ev.target === shellRoot && ev.propertyName === "grid-template-columns") {
444
+ settle();
445
+ }
446
+ };
447
+ shellRoot.addEventListener("transitionend", onEnd);
448
+ // An engine that applies a track-list change at once fires no transitionend.
449
+ const timer = win.setTimeout(settle, durationMs);
450
+ closing = { toward, settle, abort: stop };
451
+ }
452
+ function fireSplitChange(): void {
453
+ if (changeListeners.size === 0) {
454
+ return;
455
+ }
456
+ const state = splitState();
457
+ for (const cb of [...changeListeners]) {
458
+ cb(state);
459
+ }
460
+ }
461
+ // Selection never rests on an empty, failed or hidden pane, enforced by `inert`
462
+ // on such a pane root and by the refusal in `select`; an invalid value names no
463
+ // pane. The Tab stop is decided here too, because `inert` alone would leave an
464
+ // empty pane's input reachable on an engine without it.
465
+ function syncFocusReach(slot: Slot): void {
466
+ if (!splitEnabled || slot.kernel === null) {
467
+ return;
468
+ }
469
+ const state = slot.kernel.state();
470
+ slot.root.toggleAttribute(
471
+ "inert",
472
+ state === "empty" ||
473
+ state === "hidden" ||
474
+ (collapsed && state === "shown" && slot.side !== selectedSide),
475
+ );
476
+ slot.kernel.setTabStop(splitOpen && state === "shown");
477
+ }
478
+ function syncSelection(): void {
479
+ for (const slot of slots) {
480
+ if (splitEnabled) {
481
+ const selected = slot.side === selectedSide;
482
+ slot.root.classList.toggle("wt-pane-selected", selected);
483
+ slot.tablist?.setDescription(
484
+ splitOpen
485
+ ? `${slot.side === "left" ? "Left" : "Right"} terminal${selected ? ", selected" : ""}`
486
+ : "",
487
+ );
488
+ }
489
+ syncFocusReach(slot);
490
+ }
491
+ }
492
+ const anyShown = (): boolean => builtPanes().some((p) => p.state() === "shown");
493
+ /** Move selection to `side`, whatever that pane's state: the one transition
494
+ * every indicator, listener and the record follow. */
495
+ function applySelection(side: PaneSide): void {
496
+ if (side === selectedSide) {
497
+ return;
498
+ }
499
+ selectedSide = side;
500
+ paintTitle();
501
+ syncSelection();
502
+ announcer?.announce(side === "left" ? "Left terminal selected" : "Right terminal selected");
503
+ for (const cb of [...selectionListeners]) {
504
+ cb(side);
505
+ }
506
+ fireSplitChange();
507
+ }
508
+ // While no pane shows a tab (both emptied, a replacement on its way), selection
509
+ // may rest on the empty pane the replacement is headed for, so the handle faces
510
+ // where the new tab will land.
511
+ function select(side: PaneSide): boolean {
512
+ if (!isPaneSide(side)) {
513
+ return false;
514
+ }
515
+ const state = paneAt(side)?.state();
516
+ if (state !== "shown" && !(state === "empty" && !anyShown())) {
517
+ return false;
518
+ }
519
+ applySelection(side);
520
+ return true;
521
+ }
522
+ function targetFor(): PaneSide {
523
+ for (const p of builtPanes()) {
524
+ if (p.state() === "empty") {
525
+ return p.side;
526
+ }
527
+ }
528
+ return selectedSide;
529
+ }
530
+ // A focused element that leaves the layout drops focus to the body, so the
531
+ // survivor takes it first.
532
+ function keepFocusOn(survivor: Slot, leaving: (el: Element) => boolean): void {
533
+ const active = doc.activeElement;
534
+ if (active !== null && leaving(active)) {
535
+ survivor.kernel?.focus();
536
+ }
537
+ }
538
+ // Only the handle and the hidden or new root move: a live pane moved in the
539
+ // DOM would lose focus and scroll state, and an empty hidden one has neither.
540
+ function placeAfter(first: Slot, second: Slot): void {
541
+ first.root.after(...(handle === null ? [] : [handle.element]), second.root);
542
+ }
543
+
544
+ /** Every store handed out, held WEAKLY and keyed by session, so a closed tab's
545
+ * store stays collectable. */
546
+ const knownStores = new Map<string, WeakRef<LineStore>>();
547
+ /** Restored scrollbacks not yet confirmed by a resume for THAT session. */
548
+ const unverifiedRestores = new Set<string>();
549
+ function liveStores(): LineStore[] {
550
+ const out: LineStore[] = [];
551
+ for (const [key, ref] of knownStores) {
552
+ const store = ref.deref();
553
+ if (store === undefined) {
554
+ knownStores.delete(key);
555
+ continue;
556
+ }
557
+ out.push(store);
558
+ }
559
+ return out;
560
+ }
561
+ /** The server epoch a pane knew for a session it forgot while the tab lives on
562
+ * (a snap, a close of the split): the next pane to show it is seeded from here,
563
+ * and a save filed after the pane is gone still finds it. */
564
+ const knownEpoch = new Map<string, number>();
565
+ const keeper =
566
+ opts.persistScrollback !== undefined
567
+ ? createScrollbackKeeper(
568
+ opts.persistScrollback,
569
+ scrollbackLines,
570
+ {
571
+ adoptPersistedEpoch(sessionId, epoch) {
572
+ for (const c of connections()) {
573
+ c.adoptPersistedEpoch(sessionId, epoch);
574
+ }
575
+ },
576
+ serverEpochOf(sessionId) {
577
+ for (const c of connections()) {
578
+ const epoch = c.serverEpochOf(sessionId);
579
+ if (epoch !== 0) {
580
+ return epoch;
581
+ }
582
+ }
583
+ return knownEpoch.get(sessionId) ?? 0;
584
+ },
585
+ },
586
+ win,
587
+ )
588
+ : null;
589
+ held.push(() => {
590
+ keeper?.stop();
591
+ knownStores.clear();
592
+ unverifiedRestores.clear();
593
+ knownEpoch.clear();
594
+ });
595
+ const stores: StoreRegistry = {
596
+ persisting: keeper !== null,
597
+ newLineStore(sessionId) {
598
+ const track = (store: LineStore, key: string): LineStore => {
599
+ knownStores.set(key, new WeakRef(store));
600
+ return store;
601
+ };
602
+ if (keeper !== null) {
603
+ if (sessionId !== undefined) {
604
+ const store = keeper.storeFor(sessionId);
605
+ if (store.highestIndex() >= 0) {
606
+ unverifiedRestores.add(sessionId);
607
+ }
608
+ return track(store, sessionId);
609
+ }
610
+ keeper.noteMissingSessionId();
611
+ }
612
+ return track(new LineStore(scrollbackLines), sessionId ?? IMPLICIT_STORE_KEY);
613
+ },
614
+ implicitStore(sessionId, current) {
615
+ if (keeper === null) {
616
+ return null;
617
+ }
618
+ const restored = keeper.storeFor(sessionId);
619
+ if (restored.highestIndex() >= 0) {
620
+ knownStores.set(sessionId, new WeakRef(restored));
621
+ unverifiedRestores.add(sessionId);
622
+ return restored;
623
+ }
624
+ keeper.track(sessionId, current);
625
+ return null;
626
+ },
627
+ verified(sessionId) {
628
+ unverifiedRestores.delete(sessionId);
629
+ },
630
+ discardUnverified(scope, caller) {
631
+ const ids = scope === "page" ? [...unverifiedRestores] : [scope.session];
632
+ for (const id of ids) {
633
+ if (!unverifiedRestores.delete(id)) {
634
+ continue;
635
+ }
636
+ const store = knownStores.get(id)?.deref();
637
+ const holder =
638
+ store === undefined
639
+ ? paneAt(caller)
640
+ : builtPanes().find((p) => p.render.boundStore() === store);
641
+ if (holder !== undefined) {
642
+ holder.reset();
643
+ continue;
644
+ }
645
+ store?.reset();
646
+ }
647
+ },
648
+ backgroundStores(bound) {
649
+ return liveStores().filter((store) => store !== bound);
650
+ },
651
+ flush() {
652
+ keeper?.flush();
653
+ },
654
+ };
655
+ function dropSession(id: string): void {
656
+ for (const c of connections()) {
657
+ c.forgetSession(id);
658
+ }
659
+ knownEpoch.delete(id);
660
+ keeper?.forget(id);
661
+ }
662
+
663
+ const shellKeydown: ((ev: KeyboardEvent) => boolean)[] = [];
664
+ const errorHandlers = new Set<(feature: string, err: unknown) => void>();
665
+ const paneRegistrations = new Set<PaneRegistration>();
666
+ held.push(() => {
667
+ shellKeydown.length = 0;
668
+ errorHandlers.clear();
669
+ selectionListeners.clear();
670
+ panesListeners.clear();
671
+ changeListeners.clear();
672
+ paneRegistrations.clear();
673
+ });
674
+ function reportError(feature: string, err: unknown): void {
675
+ if (errorHandlers.size === 0) {
676
+ console.error(`web-terminal-ui: feature "${feature}" error`, err);
677
+ return;
678
+ }
679
+ for (const fn of [...errorHandlers]) {
680
+ try {
681
+ fn(feature, err);
682
+ } catch (handlerErr) {
683
+ // A reporting failure must not turn the feature failure into an
684
+ // unhandled rejection or stop fatal-startup cleanup.
685
+ console.error("web-terminal-ui: feature error handler failed", handlerErr);
686
+ }
687
+ }
688
+ }
689
+ function onError(fn: (feature: string, err: unknown) => void): Unsubscribe {
690
+ errorHandlers.add(fn);
691
+ return () => errorHandlers.delete(fn);
692
+ }
693
+ function onEveryPane(register: (p: PaneKernel) => Unsubscribe): Unsubscribe {
694
+ const reg: PaneRegistration = { register, offs: new Map() };
695
+ for (const p of builtPanes()) {
696
+ reg.offs.set(p, register(p));
697
+ }
698
+ paneRegistrations.add(reg);
699
+ return () => {
700
+ paneRegistrations.delete(reg);
701
+ for (const off of reg.offs.values()) {
702
+ off();
703
+ }
704
+ reg.offs.clear();
705
+ };
706
+ }
707
+
708
+ const split: SplitController = splitEnabled
709
+ ? {
710
+ enabled: true,
711
+ state: splitState,
712
+ isOpen: () => splitOpen,
713
+ canOpen: () => !splitOpen && !destroyed && rowWidth() >= MIN_SPLIT_AREA_PX,
714
+ open: openSplit,
715
+ close: closeSplit,
716
+ closeSide,
717
+ setRatio,
718
+ onChange(cb) {
719
+ changeListeners.add(cb);
720
+ return () => changeListeners.delete(cb);
721
+ },
722
+ }
723
+ : disabledSplit();
724
+ const shellContext: ShellContext = {
725
+ root: shellRoot,
726
+ pane: (side) => paneAt(side) ?? null,
727
+ panes: builtPanes,
728
+ selected: () => selectedSide,
729
+ select,
730
+ onSelectionChange(cb) {
731
+ selectionListeners.add(cb);
732
+ return () => selectionListeners.delete(cb);
733
+ },
734
+ onPanesChange(cb) {
735
+ panesListeners.add(cb);
736
+ return () => panesListeners.delete(cb);
737
+ },
738
+ targetFor,
739
+ dropSessionExcept(sessionId, side) {
740
+ for (const slot of slots) {
741
+ if (slot.side !== side) {
742
+ slot.connection?.forgetSession(sessionId);
743
+ }
744
+ }
745
+ knownEpoch.delete(sessionId);
746
+ keeper?.forget(sessionId);
747
+ },
748
+ attention,
749
+ subscribeStatus: (path, callbacks) => statusShare.subscribe(path, callbacks),
750
+ notifications,
751
+ split,
752
+ restoreSplit: (committed, selected) =>
753
+ splitEnabled && isPaneSide(selected) && openSplitAt(committed, selected),
754
+ };
755
+ // One row controller over every pane's tabpanel: a chip names the panel of the
756
+ // pane showing it, or the one a click would fill.
757
+ const rowTablist = createTablist(
758
+ (side) => slotAt(side ?? targetFor())?.tablist ?? slots[0]?.tablist ?? null,
759
+ );
760
+ const handle: SplitHandle | null = splitEnabled ? createSplitHandle(shellContext) : null;
761
+ if (handle !== null) {
762
+ held.push(() => {
763
+ handle.dispose();
764
+ });
765
+ }
766
+ const isHandle = (el: Element): boolean => el === handle?.element;
767
+
768
+ function servicesFor(slot: Slot): PaneServices {
769
+ return {
770
+ get side() {
771
+ return slot.side;
772
+ },
773
+ shell: shellContext,
774
+ sessionIdKey: SESSION_ID_KEYS[slot.index],
775
+ managed,
776
+ scrollbackLines,
777
+ narrowProbe: () => isNarrow(shellRoot.clientWidth, slot.root.clientHeight),
778
+ titleBase(text) {
779
+ slot.baseTitle = text;
780
+ paintTitle();
781
+ },
782
+ loading: {
783
+ reason: (message) => {
784
+ loadingStatus.reason(message);
785
+ },
786
+ firstFrame: dismissLoadingOverlay,
787
+ failed() {
788
+ slot.loadingFailed = true;
789
+ settleLoading();
790
+ },
791
+ },
792
+ onSessionEnded() {
793
+ // The host's turn is LAST and a throwing handler cannot take the banner
794
+ // down with it.
795
+ if (opts.onSessionEnded !== undefined) {
796
+ try {
797
+ opts.onSessionEnded();
798
+ } catch (err) {
799
+ console.error("web-terminal-ui: onSessionEnded handler failed", err);
800
+ }
801
+ }
802
+ },
803
+ registerTablist(controller) {
804
+ slot.tablist = controller;
805
+ },
806
+ tablist: () => rowTablist,
807
+ onKeydown(ev) {
808
+ for (const h of shellKeydown) {
809
+ if (h(ev)) {
810
+ return true;
811
+ }
812
+ }
813
+ return false;
814
+ },
815
+ onPointerDown() {
816
+ select(slot.side);
817
+ },
818
+ onFocusIn() {
819
+ // An engine without `inert` still delivers focus into an empty pane.
820
+ if (!select(slot.side)) {
821
+ const selected = paneAt(selectedSide);
822
+ if (selected !== undefined && selected !== slot.kernel) {
823
+ selected.focus();
824
+ }
825
+ }
826
+ },
827
+ onSessionChange() {
828
+ syncFocusReach(slot);
829
+ settleLoading();
830
+ },
831
+ noteEpoch(sessionId, epoch) {
832
+ if (epoch !== 0) {
833
+ knownEpoch.set(sessionId, epoch);
834
+ }
835
+ },
836
+ knownEpoch: (sessionId) => knownEpoch.get(sessionId) ?? 0,
837
+ registerConnection(connection) {
838
+ slot.connection = connection;
839
+ },
840
+ stores,
841
+ dropSession,
842
+ reportError,
843
+ onError,
844
+ };
845
+ }
846
+ function newSlot(index: 0 | 1, side: PaneSide, slotRoot: HTMLElement): Slot {
847
+ const slot: Slot = {
848
+ index,
849
+ side,
850
+ root: slotRoot,
851
+ kernel: null,
852
+ failed: false,
853
+ loadingFailed: false,
854
+ baseTitle: servedTitle,
855
+ connection: null,
856
+ tablist: null,
857
+ };
858
+ if (splitEnabled) {
859
+ slotRoot.classList.add("wt-split-pane");
860
+ setSide(slot, side);
861
+ }
862
+ return slot;
863
+ }
864
+ function buildKernel(slot: Slot): PaneKernel {
865
+ const kernel = buildPane(slot.root, paneOpts, servicesFor(slot));
866
+ slot.kernel = kernel;
867
+ held.push(() => {
868
+ kernel.cleanupRuntime();
869
+ });
870
+ for (const reg of paneRegistrations) {
871
+ reg.offs.set(kernel, reg.register(kernel));
872
+ }
873
+ return kernel;
874
+ }
875
+ // A pane leaving the layout with the only shown tab hands it to an empty live
876
+ // survivor through the layout owner, so the terminal still shows one tab.
877
+ function rehomeShownTab(leaving: Slot, survivor: Slot): void {
878
+ const onlyShown = leaving.kernel?.session.id ?? null;
879
+ if (onlyShown !== null && survivor.kernel?.state() === "empty") {
880
+ owner?.paneLayoutOwner?.showIn(survivor.side, onlyShown);
881
+ }
882
+ }
883
+ /** A second pane that fails while the split is closed has no place on screen,
884
+ * so it is discarded at once and the next open builds a fresh one. When the
885
+ * close kept the failing pane as the single view, the healthy hidden pane
886
+ * returns as the selected single view and takes the shown tab first. */
887
+ function discardClosedPane(slot: Slot, survivor: Slot): void {
888
+ closing?.settle();
889
+ if (survivor.kernel?.state() === "hidden") {
890
+ survivor.root.classList.remove("wt-pane-hidden");
891
+ survivor.kernel.setHidden(false);
892
+ setSide(survivor, "left");
893
+ setSide(slot, "right");
894
+ selectedSide = "left";
895
+ rehomeShownTab(slot, survivor);
896
+ }
897
+ keepFocusOn(survivor, (el) => slot.root.contains(el));
898
+ teardownSlot(slot);
899
+ paintTitle();
900
+ syncSelection();
901
+ }
902
+ /** The second pane's startup failure, which a pane feature can deliver after
903
+ * the pane was shown and selected: the tab must not go down with the kernel,
904
+ * nor typing keep routing to it. While the split is open the pane's root stays
905
+ * in the grid as the recovery surface and the terminal keeps running on the
906
+ * first pane. */
907
+ function failPane(slot: Slot, failure: PaneFailure): void {
908
+ if (destroyed) {
909
+ return;
910
+ }
911
+ slot.failed = true;
912
+ const survivor = slots.find((s) => s !== slot);
913
+ const discarded = !splitOpen && survivor !== undefined;
914
+ if (discarded) {
915
+ discardClosedPane(slot, survivor);
916
+ } else {
917
+ if (survivor !== undefined) {
918
+ rehomeShownTab(slot, survivor);
919
+ }
920
+ slot.kernel?.markFailed();
921
+ slot.kernel?.cleanupRuntime();
922
+ // A build that threw before stamping them still needs the panel's style scope.
923
+ slot.root.classList.add("wt-root", "wt-container");
924
+ slot.root.removeAttribute("inert");
925
+ if (slot.side === selectedSide && survivor !== undefined) {
926
+ applySelection(survivor.side);
927
+ }
928
+ }
929
+ slot.loadingFailed = true;
930
+ settleLoading();
931
+ const surface = discarded ? undefined : slot.root;
932
+ const report: TerminalStartupFailure =
933
+ failure.phase === "kernel-init"
934
+ ? { phase: "kernel-init", cause: failure.cause, surface }
935
+ : { phase: "feature-setup", feature: failure.feature, cause: failure.cause, surface };
936
+ let handled = false;
937
+ try {
938
+ handled = opts.onFatalError?.(report) === true;
939
+ } catch (handlerErr) {
940
+ console.error("web-terminal-ui: onFatalError handler failed", handlerErr);
941
+ }
942
+ // Non-modal: a top-layer dialog would make the tab row inert and the shared
943
+ // split button, this pane's one close path, unreachable.
944
+ if (!handled && !discarded && !isDestroyed()) {
945
+ renderFatalStartupInto(slot.root, { modal: false, idSuffix: "-2" });
946
+ }
947
+ firePanesChange();
948
+ }
949
+ async function setupPaneFeatures(
950
+ slot: Slot,
951
+ features: readonly TerminalFeature<unknown>[],
952
+ ): Promise<void> {
953
+ const kernel = slot.kernel;
954
+ if (kernel === null) {
955
+ return;
956
+ }
957
+ for (const feature of features) {
958
+ if (destroyed || kernel.isDestroyed()) {
959
+ return;
960
+ }
961
+ const outcome = await kernel.setupFeature(feature);
962
+ if (outcome.status === "failed") {
963
+ failPane(slot, { phase: "feature-setup", feature: outcome.feature, cause: outcome.cause });
964
+ return;
965
+ }
966
+ if (outcome.status === "aborted") {
967
+ return;
968
+ }
969
+ }
970
+ }
971
+ function buildSecondary(slot: Slot): void {
972
+ let paneFeatures: TerminalFeature<unknown>[];
973
+ try {
974
+ paneFeatures = resolveFeatures(opts.features).filter((f) => f.scope !== "shell");
975
+ for (const f of paneFeatures) {
976
+ if (usedPaneFeatures.has(f)) {
977
+ throw new Error(
978
+ `web-terminal-ui: features() must return new feature objects for each pane; ${f.name} was already used by another pane`,
979
+ );
980
+ }
981
+ }
982
+ for (const f of paneFeatures) {
983
+ usedPaneFeatures.add(f);
984
+ }
985
+ buildKernel(slot);
986
+ } catch (cause) {
987
+ failPane(slot, { phase: "kernel-init", cause });
988
+ return;
989
+ }
990
+ void setupPaneFeatures(slot, paneFeatures);
991
+ }
992
+ function teardownSlot(slot: Slot): void {
993
+ const kernel = slot.kernel;
994
+ if (kernel !== null) {
995
+ for (const reg of paneRegistrations) {
996
+ reg.offs.get(kernel)?.();
997
+ reg.offs.delete(kernel);
998
+ }
999
+ kernel.destroy();
1000
+ }
1001
+ slot.root.remove();
1002
+ const i = slots.indexOf(slot);
1003
+ if (i >= 0) {
1004
+ slots.splice(i, 1);
1005
+ }
1006
+ }
1007
+
1008
+ const primary = newSlot(0, "left", splitEnabled ? doc.createElement("div") : root);
1009
+ slots.push(primary);
1010
+ if (handle !== null) {
1011
+ shellRoot.prepend(primary.root, handle.element);
1012
+ }
1013
+ const primaryKernel = buildKernel(primary);
1014
+ syncSelection();
1015
+
1016
+ function openSplit(): boolean {
1017
+ return openSplitAt(0.5, "left");
1018
+ }
1019
+ function openSplitAt(ratio: number, selected: PaneSide): boolean {
1020
+ if (splitOpen || destroyed || !isRatio(ratio)) {
1021
+ return false;
1022
+ }
1023
+ closing?.settle();
1024
+ const showing = slotAt("left") ?? primary;
1025
+ let other = slots.find((s) => s !== showing);
1026
+ if (other === undefined) {
1027
+ other = newSlot(1, "right", doc.createElement("div"));
1028
+ slots.push(other);
1029
+ }
1030
+ setSide(showing, "left");
1031
+ setSide(other, "right");
1032
+ selectedSide = "left";
1033
+ placeAfter(showing, other);
1034
+ other.root.classList.remove("wt-pane-hidden");
1035
+ other.kernel?.setHidden(false);
1036
+ splitOpen = true;
1037
+ collapsed = rowWidth() < MIN_SPLIT_AREA_PX;
1038
+ committedRatio = ratio;
1039
+ previewRatio = null;
1040
+ shellRoot.classList.add("wt-split-open");
1041
+ shellRoot.classList.toggle("wt-split-collapsed", collapsed);
1042
+ applyRatioVar();
1043
+ if (other.kernel === null && !other.failed) {
1044
+ buildSecondary(other);
1045
+ }
1046
+ // A restored selection is part of the open, not a change to announce; a
1047
+ // side whose pane failed to build holds nothing to select.
1048
+ if (slotAt(selected)?.failed !== true) {
1049
+ selectedSide = selected;
1050
+ }
1051
+ paintTitle();
1052
+ syncSelection();
1053
+ for (const p of builtPanes()) {
1054
+ p.announceSize();
1055
+ }
1056
+ announcer?.announce("Split open");
1057
+ firePanesChange();
1058
+ fireSplitChange();
1059
+ return true;
1060
+ }
1061
+ /** `survivor` fills the view; `leaving` is emptied and hidden, or torn down when
1062
+ * its startup failed so the next open builds a fresh one. The model closes at
1063
+ * once; with `wt-animate` the columns slide first and the display follows. */
1064
+ function finishClose(survivor: Slot, leaving: Slot): void {
1065
+ keepFocusOn(survivor, (el) => leaving.root.contains(el) || isHandle(el));
1066
+ leaving.kernel?.clearActiveSession();
1067
+ leaving.kernel?.setHidden(true);
1068
+ const durationMs = leaving.failed || collapsed ? 0 : closingDurationMs();
1069
+ const tail = (): void => {
1070
+ if (leaving.failed) {
1071
+ teardownSlot(leaving);
1072
+ } else {
1073
+ leaving.root.classList.add("wt-pane-hidden");
1074
+ placeAfter(survivor, leaving);
1075
+ }
1076
+ for (const slot of slots) {
1077
+ paintSide(slot);
1078
+ }
1079
+ shellRoot.classList.remove("wt-split-open", "wt-split-closing");
1080
+ applyRatioVar();
1081
+ survivor.kernel?.announceSize();
1082
+ };
1083
+ const animated = durationMs > 0;
1084
+ if (animated) {
1085
+ beginClosing(survivor.side === "left" ? 1 : 0, durationMs, tail);
1086
+ }
1087
+ setSide(survivor, "left");
1088
+ setSide(leaving, "right");
1089
+ selectedSide = "left";
1090
+ splitOpen = false;
1091
+ collapsed = false;
1092
+ committedRatio = 0.5;
1093
+ previewRatio = null;
1094
+ shellRoot.classList.remove("wt-split-collapsed");
1095
+ applyRatioVar();
1096
+ paintTitle();
1097
+ syncSelection();
1098
+ if (!animated) {
1099
+ tail();
1100
+ }
1101
+ announcer?.announce("Split closed");
1102
+ firePanesChange();
1103
+ fireSplitChange();
1104
+ }
1105
+ function closeSplit(): boolean {
1106
+ if (!splitOpen || destroyed) {
1107
+ return false;
1108
+ }
1109
+ const survivor = slotAt(selectedSide) ?? primary;
1110
+ const leaving = slots.find((s) => s !== survivor);
1111
+ if (leaving === undefined) {
1112
+ return false;
1113
+ }
1114
+ finishClose(survivor, leaving);
1115
+ return true;
1116
+ }
1117
+ function closeSide(side: PaneSide): boolean {
1118
+ if (!isPaneSide(side) || !splitOpen || destroyed) {
1119
+ return false;
1120
+ }
1121
+ const squeezed = slotAt(side);
1122
+ const survivor = slots.find((s) => s !== squeezed);
1123
+ if (squeezed === undefined || survivor === undefined || survivor.failed) {
1124
+ return false;
1125
+ }
1126
+ rehomeShownTab(squeezed, survivor);
1127
+ finishClose(survivor, squeezed);
1128
+ return true;
1129
+ }
1130
+ function setRatio(ratio: number, commit: boolean): boolean {
1131
+ if (!isRatio(ratio) || !splitOpen || collapsed || destroyed) {
1132
+ return false;
1133
+ }
1134
+ if (commit) {
1135
+ committedRatio = ratio;
1136
+ previewRatio = null;
1137
+ } else {
1138
+ previewRatio = ratio;
1139
+ }
1140
+ applyRatioVar();
1141
+ fireSplitChange();
1142
+ return true;
1143
+ }
1144
+ function paintShellNarrow(): void {
1145
+ shellRoot.classList.toggle(
1146
+ "wt-narrow",
1147
+ isNarrow(shellRoot.clientWidth, shellRoot.clientHeight),
1148
+ );
1149
+ }
1150
+ function onShellResize(): void {
1151
+ if (destroyed) {
1152
+ return;
1153
+ }
1154
+ paintShellNarrow();
1155
+ for (const p of builtPanes()) {
1156
+ p.updateNarrow();
1157
+ }
1158
+ if (!splitOpen) {
1159
+ return;
1160
+ }
1161
+ const wasCollapsed = collapsed;
1162
+ collapsed = rowWidth() < MIN_SPLIT_AREA_PX;
1163
+ shellRoot.classList.toggle("wt-split-collapsed", collapsed);
1164
+ applyRatioVar();
1165
+ if (collapsed !== wasCollapsed) {
1166
+ const selected = slotAt(selectedSide);
1167
+ if (collapsed && selected !== undefined) {
1168
+ keepFocusOn(
1169
+ selected,
1170
+ (el) => isHandle(el) || slots.some((s) => s !== selected && s.root.contains(el)),
1171
+ );
1172
+ }
1173
+ syncSelection();
1174
+ // A collapsed pane shares the visible cell, so the one size it sends is
1175
+ // the full width it will have if the split closes; a `display: none` pane
1176
+ // would send the minimum grid to a live PTY.
1177
+ for (const p of builtPanes()) {
1178
+ p.announceSize();
1179
+ }
1180
+ }
1181
+ fireSplitChange();
1182
+ }
1183
+ if (splitEnabled) {
1184
+ paintShellNarrow();
1185
+ const observer = new win.ResizeObserver(onShellResize);
1186
+ observer.observe(shellRoot);
1187
+ held.push(() => {
1188
+ observer.disconnect();
1189
+ });
1190
+ }
1191
+
1192
+ const shellHost = createFeatureHost(reportError);
1193
+ held.push(() => {
1194
+ shellHost.teardownAll();
1195
+ });
1196
+ const renderFacade: TerminalContext["render"] = {
1197
+ setPredictedCursor: (row, col, active) => {
1198
+ selectedPane().render.setPredictedCursor(row, col, active);
1199
+ },
1200
+ getCursorPx: () => selectedPane().render.getCursorPx(),
1201
+ computeSize: () => selectedPane().render.computeSize(),
1202
+ bind: (store, o) => {
1203
+ selectedPane().render.bind(store, o);
1204
+ },
1205
+ captureViewMemory: () => selectedPane().render.captureViewMemory(),
1206
+ boundStore: () => selectedPane().render.boundStore(),
1207
+ getHighestIndex: () => selectedPane().render.getHighestIndex(),
1208
+ pendingRowCount: () => selectedPane().render.pendingRowCount(),
1209
+ };
1210
+ const scrollFacade: TerminalContext["scroll"] = {
1211
+ scrollToBottom: () => {
1212
+ selectedPane().scroll.scrollToBottom();
1213
+ },
1214
+ isUserScrolledUp: () => selectedPane().scroll.isUserScrolledUp(),
1215
+ currentScrollTop: () => selectedPane().scroll.currentScrollTop(),
1216
+ restoreView: (view) => {
1217
+ selectedPane().scroll.restoreView(view);
1218
+ },
1219
+ };
1220
+ const modesFacade: TerminalContext["modes"] = {
1221
+ isBracketedPaste: () => selectedPane().modes.isBracketedPaste(),
1222
+ isApplicationCursor: () => selectedPane().modes.isApplicationCursor(),
1223
+ getMouseMode: () => selectedPane().modes.getMouseMode(),
1224
+ isMouseSGR: () => selectedPane().modes.isMouseSGR(),
1225
+ isMousePixels: () => selectedPane().modes.isMousePixels(),
1226
+ isFocusReporting: () => selectedPane().modes.isFocusReporting(),
1227
+ isApplicationKeypad: () => selectedPane().modes.isApplicationKeypad(),
1228
+ isReverseVideo: () => selectedPane().modes.isReverseVideo(),
1229
+ getKeyboardFlags: () => selectedPane().modes.getKeyboardFlags(),
1230
+ };
1231
+ const shellLayout = (): { narrow: boolean; coarse: boolean } => ({
1232
+ narrow: isNarrow(shellRoot.clientWidth, shellRoot.clientHeight),
1233
+ coarse: typeof win.matchMedia === "function" && win.matchMedia("(pointer: coarse)").matches,
1234
+ });
1235
+ /** The context a shell-scoped feature receives: every pane-facing member
1236
+ * resolves the SELECTED pane at call time. */
1237
+ function makeShellContext(featureName: string, scope: CleanupScope): TerminalContext {
1238
+ const track = (off: Unsubscribe): Unsubscribe => {
1239
+ scope.push(off);
1240
+ return off;
1241
+ };
1242
+ return {
1243
+ region: (name, slot) =>
1244
+ shellRegions === null ? primaryKernel.region(name, slot) : shellRegions.region(name, slot),
1245
+ surface: () => selectedPane().surface(),
1246
+ send: (bytes) => {
1247
+ selectedPane().send(bytes);
1248
+ },
1249
+ paste: (text) => {
1250
+ selectedPane().paste(text);
1251
+ },
1252
+ registerInputTransform: (fn) => track(onEveryPane((p) => p.addInputTransform(fn))),
1253
+ registerInputObserver: (fn) => track(onEveryPane((p) => p.addInputObserver(fn))),
1254
+ registerKeydown(fn) {
1255
+ shellKeydown.push(fn);
1256
+ return track(() => {
1257
+ const i = shellKeydown.indexOf(fn);
1258
+ if (i >= 0) {
1259
+ shellKeydown.splice(i, 1);
1260
+ }
1261
+ });
1262
+ },
1263
+ render: renderFacade,
1264
+ scroll: scrollFacade,
1265
+ modes: modesFacade,
1266
+ session: {
1267
+ get id() {
1268
+ return selectedPane().session.id;
1269
+ },
1270
+ size: () => selectedPane().session.size(),
1271
+ highestIndex: () => selectedPane().session.highestIndex(),
1272
+ },
1273
+ shell: shellContext,
1274
+ on: <K extends keyof TerminalEvents>(e: K, fn: (p: TerminalEvents[K]) => void) =>
1275
+ track(onEveryPane((p) => p.busOn(featureName, e, fn))),
1276
+ defer(release) {
1277
+ scope.push(release);
1278
+ },
1279
+ use<A>(feature: TerminalFeature<A>): A | undefined {
1280
+ return shellHost.use(feature) as A | undefined;
1281
+ },
1282
+ toast: (message, ms) => {
1283
+ selectedPane().toast(message, ms);
1284
+ },
1285
+ announce: (message, politeness) => {
1286
+ selectedPane().announce(message, politeness);
1287
+ },
1288
+ loadingReason: (message) => {
1289
+ loadingStatus.reason(message);
1290
+ },
1291
+ tablist: () => rowTablist,
1292
+ newLineStore: (sessionId) => stores.newLineStore(sessionId),
1293
+ layout: shellLayout,
1294
+ notifySwitch(session) {
1295
+ // A session is never shown twice: a click on a shown tab selects its pane.
1296
+ const showing = builtPanes().find((p) => p.session.id === session.id);
1297
+ if (showing !== undefined) {
1298
+ select(showing.side);
1299
+ return;
1300
+ }
1301
+ const target = paneAt(targetFor()) ?? primaryKernel;
1302
+ target.notifySwitch(session);
1303
+ select(target.side);
1304
+ },
1305
+ dropSession,
1306
+ onError: (fn) => track(onError(fn)),
1307
+ };
1308
+ }
1309
+
1310
+ let rootReleased = false;
1311
+ function cleanupRuntime(): void {
1312
+ if (destroyed) {
1313
+ return;
1314
+ }
1315
+ destroyed = true;
1316
+ // The tracked stores are about to become unreachable, and the connections
1317
+ // whose epochs the save is filed under go with the drain.
1318
+ keeper?.flush();
1319
+ held.drain();
1320
+ }
1321
+
1322
+ function enterFatalStartup(feature: string, cause: unknown): void {
1323
+ if (destroyed) {
1324
+ return;
1325
+ }
1326
+ primaryKernel.markFailed();
1327
+ cleanupRuntime();
1328
+ dismissLoadingOverlay();
1329
+ let handled = false;
1330
+ try {
1331
+ handled =
1332
+ opts.onFatalError?.({ phase: "feature-setup", feature, cause, surface: root }) === true;
1333
+ } catch (handlerErr) {
1334
+ console.error("web-terminal-ui: onFatalError handler failed", handlerErr);
1335
+ }
1336
+ // A handler may call destroy() while taking over.
1337
+ if (!handled && !rootReleased) {
1338
+ renderFatalStartupInto(root, { modal: layoutMode === "viewport" });
1339
+ }
1340
+ }
1341
+
1342
+ // Features set up in the background; first paint is never gated on them.
1343
+ async function setupAll(): Promise<FeatureSetupOutcome> {
1344
+ for (const feature of featureList) {
1345
+ if (destroyed) {
1346
+ return { status: "aborted" };
1347
+ }
1348
+ const outcome =
1349
+ feature.scope === "shell"
1350
+ ? await shellHost.setup(
1351
+ feature,
1352
+ (scope) => makeShellContext(feature.name, scope),
1353
+ () => destroyed,
1354
+ )
1355
+ : await primaryKernel.setupFeature(feature);
1356
+ if (outcome.status !== "ready") {
1357
+ return outcome;
1358
+ }
1359
+ }
1360
+ return { status: "ready" };
1361
+ }
1362
+
1363
+ // Under an owner the shell drives the first connect: a bare connect to the
1364
+ // wsPath would open a socket a session-gated server 404s. A null or false
1365
+ // resolution means the bootstrap failed; the owner keeps its retry chrome
1366
+ // alive and the overlay comes down so it is visible.
1367
+ void setupAll().then(async (outcome) => {
1368
+ if (outcome.status === "failed") {
1369
+ enterFatalStartup(outcome.feature, outcome.cause);
1370
+ return;
1371
+ }
1372
+ if (outcome.status === "aborted" || owner === undefined || isDestroyed()) {
1373
+ return;
1374
+ }
1375
+ let shown = false;
1376
+ try {
1377
+ if (owner.sessionOwner !== undefined) {
1378
+ const resolved = await owner.sessionOwner.resolveInitialSession();
1379
+ if (resolved && !isDestroyed()) {
1380
+ primaryKernel.notifySwitch(resolved);
1381
+ shown = true;
1382
+ }
1383
+ } else if (owner.paneLayoutOwner !== undefined) {
1384
+ shown = await owner.paneLayoutOwner.resolveInitialLayout();
1385
+ }
1386
+ } catch (err) {
1387
+ reportError(owner.name, err);
1388
+ }
1389
+ if (isDestroyed()) {
1390
+ return;
1391
+ }
1392
+ if (!shown && !builtPanes().some((p) => p.connectionInitiated())) {
1393
+ dismissLoadingOverlay();
1394
+ }
1395
+ });
1396
+
1397
+ return {
1398
+ focus() {
1399
+ selectedPane().focus();
1400
+ },
1401
+ send(bytes) {
1402
+ if (destroyed) {
1403
+ return;
1404
+ }
1405
+ selectedPane().send(bytes);
1406
+ },
1407
+ reset() {
1408
+ if (destroyed) {
1409
+ return;
1410
+ }
1411
+ selectedPane().reset();
1412
+ },
1413
+ reattach() {
1414
+ if (destroyed) {
1415
+ return;
1416
+ }
1417
+ selectedPane().reattach();
1418
+ },
1419
+ destroy() {
1420
+ if (rootReleased) {
1421
+ return;
1422
+ }
1423
+ rootReleased = true;
1424
+ cleanupRuntime();
1425
+ for (const slot of slots) {
1426
+ slot.kernel?.destroy();
1427
+ }
1428
+ if (splitEnabled) {
1429
+ shellRoot.classList.remove("wt-root", "wt-split", "wt-viewport", "wt-container");
1430
+ }
1431
+ },
1432
+ ...(splitEnabled ? { split } : {}),
1433
+ };
1434
+ }