@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
@@ -1,2079 +1,11 @@
1
- // The v3 kernel: createTerminal (design section 22.3, 22.6).
2
- //
3
- // The kernel is the irreducible core that makes it a terminal at all: the
4
- // display-only output surface and the hidden textarea that owns the keyboard,
5
- // IME/composition, the engine wiring (render/connection/scroll), viewport and
6
- // keyboard-inset handling, the single sanitizing input funnel, the
7
- // connection-state machine + loading lifecycle, the shared primitives (toast,
8
- // announce, tablist), and the named layout regions. Everything visible above
9
- // the raw terminal (toolbar, menu, banner text, tabs) is an opt-in feature.
10
- //
11
- // State is closure-scoped per createTerminal call (not module singletons), so
12
- // destroy() can tear it down; the engine's render/connection/scroll modules are
13
- // still single-instance, so createTerminal is called once per page (tabs
14
- // multiplex sessions over the one kernel, section 22.5).
15
- //
16
- // The kernel owns the terminal widget's DOM, so it owns the widget's focus: it
17
- // REPORTS focus and blur to the transport (`connection.setClientFocus`) and
18
- // never writes DEC 1004 bytes itself. The server derives the answer for the
19
- // session from every attached client's report plus its own hold, which is the
20
- // only place the question can be answered once a second device attaches.
21
-
22
- import {
23
- render,
24
- scroll,
25
- connection,
26
- keyboard,
27
- modes,
28
- mouse,
29
- LineStore,
30
- } from "@cplieger/web-terminal-engine";
31
- import * as composition from "../composition.js";
32
- import * as viewport from "../viewport.js";
33
- import { INPUT_PLACEHOLDER, resetToPlaceholder } from "../input-placeholder.js";
34
- import { createBus } from "./bus.js";
35
- import { optionalPositiveIntOption } from "./options.js";
36
- import { TAP_MAX_MS, TAP_MOVEMENT_PX, isLinkTarget } from "./gesture.js";
37
- import { createRegions } from "./regions.js";
38
- import { createAnnouncer, createTablist } from "./a11y.js";
39
- import { createConnState } from "./conn-state.js";
40
- import { createScrollbackKeeper } from "./scrollback.js";
41
- import { STARTUP_FAILURE_COPY } from "./startup-copy.js";
42
- import { attachLoadingStatus, DEFAULT_LOADING_MESSAGES } from "./loading-status.js";
43
- import { reloadPage } from "./navigation.js";
44
- import type {
45
- CreateTerminalOptions,
46
- FeatureInstance,
47
- SessionRef,
48
- TerminalContext,
49
- TerminalFeature,
50
- TerminalHandle,
51
- Unsubscribe,
52
- } from "./types.js";
53
-
54
- const { mapKeyboardEvent, bracketTextForPaste, prepareTextForTerminal } = keyboard;
55
-
56
- const DEFAULT_WS_PATH = "/ws";
57
- // The family that carries the CELL METRICS, alone: deliberately not --font-mono's
58
- // list, which leads with the tiling overlay. WebKit resolves load() over a list as
59
- // soon as the FIRST family whose descriptor covers the sample has loaded
60
- // (CSSFontFaceSet::matchingFacesExcludingPreinstalledFonts, `if (found) break;`),
61
- // so naming the overlay would settle the gate on its 11 KB while the companion is
62
- // still loading — and the overlay copies the companion's metrics and carries no
63
- // "M", the glyph render.ts measures, so waiting on it defends no number anyway.
64
- const DEFAULT_FONT_READY = '14px "Monaspace Neon NF"';
65
- // The deadline on the WHOLE gate below, because neither document.fonts.load nor
66
- // document.fonts.ready carries one: a response held open leaves both pending for
67
- // as long as that request lives, and a gate that never settles reports no size at
68
- // all. The budget is the block period the faces themselves buy with font-display:
69
- // block (css/page.css declares it on every one; 3s is what CSS Fonts 4 recommends
70
- // for `block`) — how long an engine withholds glyphs before it paints the
71
- // fallback. Once it has, the cell on screen IS the fallback cell, so a longer wait
72
- // defends metrics nobody is looking at.
73
- const FONT_READY_TIMEOUT_MS = 3000;
74
-
75
- const TOAST_MS = 3000;
76
- // A touch that focuses the input (opens the soft keyboard) must be a genuine
77
- // tap: short and low-movement. A longer hold is a long-press, which belongs to
78
- // native text selection / the context menu — so tap-to-focus bows out above
79
- // this duration and never steals a long-press or a selection. The thresholds
80
- // live in gesture.ts because the contextMenu feature classifies the other side
81
- // of the same boundary from them.
82
- // The narrow-layout breakpoints, in ROOT pixels. The single source of the
83
- // numbers: the kernel mirrors the resulting fact into the .wt-narrow root
84
- // class for CSS (paired there with pointer-coarseness media queries where
85
- // touch matters) and features read the same fact via ctx.layout(). Root
86
- // dimensions — not viewport dimensions — so an embedded terminal in a narrow
87
- // panel counts as narrow. For every full-page consumer root size equals
88
- // viewport size, so the width half matches the old (max-width: 600px) media
89
- // queries exactly.
90
- const NARROW_MAX_PX = 600;
91
- // A root can be compact by HEIGHT as well as width: a large phone rotated to
92
- // landscape (e.g. an iPhone 14 Pro Max at 932x430) is wider than
93
- // NARROW_MAX_PX, but its short viewport is still a phone in the hand — the
94
- // thumb-reach switcher bar is the right UX and the desktop strip wastes
95
- // scarce rows. Tablets are never this short (the smallest iPad is 744 CSS px
96
- // tall in landscape), so height <= 500 cleanly separates "landscape phone"
97
- // from "tablet/desktop" with margin on both sides (the tallest current
98
- // phones are ~440 in landscape, less with browser chrome).
99
- const SHORT_MAX_PX = 500;
100
-
101
- // Kernel-owned core subtree: the display-only output, the hidden textarea (the
102
- // single keyboard target), and the IME composition view. No chrome; features
103
- // build that into regions. Static, trusted markup parsed once via a template.
104
- const CORE_TEMPLATE = `
105
- <div class="term">
106
- <div class="term-output" role="log" aria-live="off" aria-roledescription="Terminal" aria-label="Terminal"></div>
107
- <textarea class="term-input" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false" aria-label="Terminal input" tabindex="-1"></textarea>
108
- <div class="composition-view" aria-hidden="true"></div>
109
- </div>`;
110
-
111
- /**
112
- * The single character this key types, or null when it types none. Used to tell
113
- * a character the engine's encoder deferred to the `input` event apart from a
114
- * key whose `key` is a NAME rather than text.
115
- *
116
- * `codePointAt` consumes a surrogate pair whole, so a key whose first scalar is
117
- * the whole string is exactly one character; "Shift", "Dead", "F5" and "Process"
118
- * are not. Code points rather than grapheme clusters on purpose: a character key
119
- * carries one code point, and a segmenter would also accept a multi-scalar
120
- * cluster. UI Events does allow a key "associated with multiple characters"
121
- * (macros, some dead-key sequences); those return null here and are simply not
122
- * recovered, which is a stated limitation rather than a claim about key names.
123
- */
124
- function typedChar(ev: KeyboardEvent): string | null {
125
- const first = ev.key.codePointAt(0);
126
- return first !== undefined && String.fromCodePoint(first) === ev.key ? ev.key : null;
127
- }
128
-
129
- /**
130
- * Normalizes text on its way to the PTY: iOS sends U+00A0 where a space was
131
- * typed. Shared by every send path so one physical key cannot produce two byte
132
- * sequences depending on which listener caught it (the `input` event on the
133
- * focused textarea, or the document-level type-to-focus fallback).
134
- */
135
- function normalizeTypedText(text: string): string {
136
- return text.replace(/\u00A0/g, " ");
137
- }
138
-
139
- /** `KeyboardEvent.keyCode` for a key an IME has claimed. Windows' VK_PROCESS,
140
- * reported by Blink and WebKit for the key that commits a composition, and by
141
- * Android for essentially every soft-keyboard key — which is why every read of
142
- * it is gated on a composition being open.
143
- *
144
- * Deprecated and nonetheless the only carrier of this fact: for that commit key
145
- * both engines report `key` as the ordinary `"Enter"`, and Safari reports
146
- * `isComposing` false, so neither the modern flag nor `key` distinguishes it. */
147
- const IME_COMMIT_KEYCODE = 229;
148
-
149
- function pick(root: ParentNode, selector: string): HTMLElement {
150
- const el = root.querySelector<HTMLElement>(selector);
151
- if (!el) {
152
- throw new Error(`web-terminal-ui: createTerminal failed to build ${selector}`);
153
- }
154
- return el;
155
- }
156
-
157
- /** Fade out and remove a consumer-supplied loading overlay.
158
- *
159
- * Module scope because BOTH startup-failure phases need it and they live on
160
- * opposite sides of createTerminal's closure: the async `feature-setup` path
161
- * reaches it through dismissLoadingOverlay (which adds the once-only guard),
162
- * the synchronous `kernel-init` path calls it directly from the catch, where
163
- * no kernel state exists yet. */
164
- function fadeOutOverlay(ld: HTMLElement | undefined): void {
165
- if (!ld) {
166
- return;
167
- }
168
- ld.classList.add("fade");
169
- const removeOverlay = (): void => {
170
- ld.remove();
171
- };
172
- ld.addEventListener("transitionend", removeOverlay, { once: true });
173
- window.setTimeout(removeOverlay, 1500);
174
- }
175
-
176
- /** Render the kernel-owned "Terminal failed to start" recovery surface into root.
177
- *
178
- * Module scope for the same reason as fadeOutOverlay: it is the ONE
179
- * implementation of this surface, shared by the async `feature-setup` phase and
180
- * the synchronous `kernel-init` phase, so the two can never drift apart. It
181
- * depends on nothing but its two arguments. */
182
- function renderFatalStartupInto(
183
- root: HTMLElement,
184
- layoutMode: "viewport" | "container",
185
- message: string = STARTUP_FAILURE_COPY.message,
186
- ): void {
187
- const surface = document.createElement("dialog");
188
- surface.className = "wt-fatal";
189
- // alertdialog over the implicit `dialog` role: an urgent message to act on. No
190
- // `aria-modal` — showModal() conveys modality; an authored value can contradict the UA.
191
- surface.setAttribute("role", "alertdialog");
192
- surface.setAttribute("aria-labelledby", "wt-fatal-title");
193
- surface.setAttribute("aria-describedby", "wt-fatal-message");
194
- // showModal() makes Escape a close request, and the kernel has already cleared
195
- // the root: nothing is behind this panel, so reload is the only recovery.
196
- surface.addEventListener("cancel", (ev) => {
197
- ev.preventDefault();
198
- });
199
-
200
- const card = document.createElement("div");
201
- card.className = "wt-fatal-card";
202
- const title = document.createElement("h2");
203
- title.id = "wt-fatal-title";
204
- title.className = "wt-fatal-title";
205
- title.textContent = STARTUP_FAILURE_COPY.title;
206
- const messageEl = document.createElement("p");
207
- messageEl.id = "wt-fatal-message";
208
- messageEl.className = "wt-fatal-message";
209
- messageEl.textContent = message;
210
- const reloadButton = document.createElement("button");
211
- reloadButton.className = "wt-btn wt-fatal-reload";
212
- reloadButton.type = "button";
213
- reloadButton.textContent = STARTUP_FAILURE_COPY.reloadLabel;
214
- reloadButton.addEventListener("click", () => {
215
- reloadPage();
216
- });
217
- card.append(title, messageEl, reloadButton);
218
- surface.appendChild(card);
219
- root.replaceChildren(surface);
220
-
221
- // Viewport: the terminal IS the page, so the top layer and an inert document
222
- // are both accurate; container gets the bare `open` attribute, which is neither
223
- // and is what an embedded panel wants. `isConnected` is showModal()'s own
224
- // precondition and a detached root arrives here from a typed call, where a
225
- // throw would bury the real startup cause under a second error out of the catch.
226
- if (layoutMode === "viewport" && surface.isConnected) {
227
- surface.showModal();
228
- } else {
229
- surface.open = true;
230
- }
231
-
232
- // The terminal input held focus before setup failed; move it to the only
233
- // recovery action. showModal() lands there itself, the non-modal half does not.
234
- reloadButton.focus();
235
- }
236
-
237
- /** Build the terminal. See createTerminal, which wraps this to give a
238
- * SYNCHRONOUS failure the same recovery surface an async one already gets. */
239
- function buildTerminal(
240
- root: HTMLElement,
241
- opts: CreateTerminalOptions,
242
- featureList: readonly TerminalFeature<unknown>[],
243
- ): TerminalHandle {
244
- const wsPath = opts.wsPath ?? DEFAULT_WS_PATH;
245
- const fontReady = opts.fontReady ?? DEFAULT_FONT_READY;
246
- // The one resolved retained-line cap: the engine renderer's implicit store
247
- // and every ctx.newLineStore() (the tabs switching cache) both honor it, so
248
- // a consumer sets its memory budget in exactly one place.
249
- // Invalid reads as OMITTED, so the engine's own default applies — and the engine
250
- // additionally floors the cap at the live screen, so even a tiny valid value
251
- // cannot truncate the visible window.
252
- const scrollbackLines = optionalPositiveIntOption(opts.scrollbackLines, "scrollbackLines");
253
- // Scrollback persistence (off unless the consumer supplied storage). One
254
- // keeper serves the whole terminal: the kernel's implicit store below and
255
- // every per-session store a session-owning feature creates through
256
- // ctx.newLineStore(sessionId).
257
- const scrollbackKeeper =
258
- opts.persistScrollback !== undefined
259
- ? createScrollbackKeeper(opts.persistScrollback, scrollbackLines)
260
- : null;
261
- // At most one feature owns session selection (fail fast, before any DOM
262
- // work): the kernel drives the first connect through its registration.
263
- const owners = featureList.filter((f) => f.sessionOwner !== undefined);
264
- if (owners.length > 1) {
265
- throw new Error(
266
- `web-terminal-ui: multiple session-owning features: ${owners.map((f) => f.name).join(", ")}`,
267
- );
268
- }
269
- const sessionOwner = owners[0]?.sessionOwner;
270
- const sessionOwnerName = owners[0]?.name ?? "";
271
- const encoder = new TextEncoder();
272
- const kernelAbort = new AbortController();
273
- const { signal } = kernelAbort;
274
-
275
- // Apply consumer theme overrides as CSS custom properties on the root, so the
276
- // whole terminal subtree inherits them. The library ships the token defaults
277
- // (css/00-tokens.css); these override them for this instance. Only custom
278
- // properties (leading "--") are set.
279
- if (opts.theme) {
280
- for (const [key, value] of Object.entries(opts.theme)) {
281
- if (key.startsWith("--")) {
282
- root.style.setProperty(key, value);
283
- }
284
- }
285
- }
286
-
287
- // Stamp the boundary classes: .wt-root scopes every library token and style
288
- // rule to this subtree, and the layout-mode class decides how the root claims
289
- // space — wt-viewport pins the root itself to the full viewport (the
290
- // full-page product), wt-container fills the parent element (the embedded
291
- // case). All chrome positions against the root, never the page. destroy()
292
- // removes them.
293
- const layoutMode = opts.layout ?? "viewport";
294
- root.classList.add("wt-root", layoutMode === "container" ? "wt-container" : "wt-viewport");
295
-
296
- // --- Build the core subtree ---
297
- const tpl = document.createElement("template");
298
- tpl.innerHTML = CORE_TEMPLATE;
299
- root.replaceChildren(tpl.content);
300
- const termWrap = pick(root, ".term");
301
- const outputEl = pick(root, ".term-output");
302
- const input = pick(root, ".term-input") as HTMLTextAreaElement;
303
- const compositionViewEl = pick(root, ".composition-view");
304
-
305
- // --- Regions + shared primitives ---
306
- const regions = createRegions(root);
307
- const bus = createBus();
308
- const announcer = createAnnouncer(root);
309
- const tablistController = createTablist(outputEl);
310
-
311
- // --- Narrow-layout driver (.wt-narrow mirrors root size for CSS) ---
312
- // Narrow = compact in EITHER dimension: skinny (a phone in portrait, a
313
- // narrow embedded panel) or short (a phone in landscape). The one
314
- // ResizeObserver below fires on both width and height changes.
315
- //
316
- // Constructed unconditionally: ResizeObserver is a hard requirement of this
317
- // library, not an enhancement. viewport.init() and the tabs strip both
318
- // construct one with no guard, and the same buildTerminal builds a WeakRef
319
- // (whose browser floor is strictly higher), so a `typeof ResizeObserver ===
320
- // "function"` guard here could only ever protect a browser that crashes
321
- // moments later. It used to be here, and it was measured dead.
322
- const isNarrow = (): boolean =>
323
- root.clientWidth <= NARROW_MAX_PX || root.clientHeight <= SHORT_MAX_PX;
324
- function updateNarrow(): void {
325
- root.classList.toggle("wt-narrow", isNarrow());
326
- }
327
- updateNarrow();
328
- let narrowObserver: ResizeObserver | null = new ResizeObserver(updateNarrow);
329
- narrowObserver.observe(root);
330
-
331
- // --- Document title (kernel-owned, composed from two inputs) ---
332
- //
333
- // The page has ONE title and two things want to write it: a program's OSC 0/2
334
- // window title, and a feature's attention prefix (tabs' unseen-cue count). So
335
- // the kernel holds both and is the only assigner. Two direct writers was the
336
- // alternative and it cannot work: whichever ran last won, so a shell or an
337
- // editor emitting OSC 2 silently erased the prefix, and there is no ordering
338
- // that fixes it because both inputs change on their own schedule.
339
- //
340
- // The base starts as whatever the served document declared, so a consumer that
341
- // never receives an OSC title (web-terminal-kiro sets kiro-cli's
342
- // chat.terminalTitle=false) keeps its own <title> as the base rather than
343
- // needing to declare it twice.
344
- let baseTitle = document.title;
345
- let titlePrefixText = "";
346
- function paintTitle(): void {
347
- const next = titlePrefixText + baseTitle;
348
- // Idempotent: the title doubles as the browser-tab label and the bookmark
349
- // name, and this runs on every status sweep, so an unchanged assignment must
350
- // not churn it.
351
- if (document.title !== next) {
352
- document.title = next;
353
- }
354
- }
355
-
356
- // Toast surface (kernel-owned shared primitive, section 22.3).
357
- const toastEl = document.createElement("div");
358
- toastEl.className = "wt-toast";
359
- toastEl.setAttribute("role", "status");
360
- regions.region("banner", "toast").appendChild(toastEl);
361
- let toastTimer: ReturnType<typeof setTimeout> | null = null;
362
- function toast(message: string, ms = TOAST_MS): void {
363
- toastEl.textContent = message;
364
- toastEl.classList.add("visible");
365
- if (toastTimer !== null) {
366
- clearTimeout(toastTimer);
367
- }
368
- toastTimer = setTimeout(() => {
369
- toastTimer = null;
370
- toastEl.classList.remove("visible");
371
- toastEl.textContent = "";
372
- }, ms);
373
- }
374
-
375
- // --- Feature error routing ---
376
- const errorHandlers = new Set<(feature: string, err: unknown) => void>();
377
- function reportError(feature: string, err: unknown): void {
378
- if (errorHandlers.size === 0) {
379
- console.error(`web-terminal-ui: feature "${feature}" error`, err);
380
- return;
381
- }
382
- for (const fn of [...errorHandlers]) {
383
- try {
384
- fn(feature, err);
385
- } catch (handlerErr) {
386
- // Error reporting must never turn the original feature failure into an
387
- // unhandled rejection or prevent fatal-startup cleanup from running.
388
- console.error("web-terminal-ui: feature error handler failed", handlerErr);
389
- }
390
- }
391
- }
392
-
393
- // --- Input funnel (the single sanitizing, session-routed send path) ---
394
- const inputTransforms: ((b: Uint8Array) => Uint8Array)[] = [];
395
- const inputObservers: ((b: Uint8Array) => void)[] = [];
396
-
397
- function sendBytes(bytes: Uint8Array): void {
398
- let out = bytes;
399
- for (const t of inputTransforms) {
400
- out = t(out);
401
- if (out.length === 0) {
402
- return; // a transform dropped it (e.g. the col-0 backspace brake)
403
- }
404
- }
405
- // sendBinary buffers while disconnected (resume layer) and returns false
406
- // only when the outbox is full (already surfaced via onOutboxFull). Only
407
- // notify observers on accepted input, so predictive echo never paints a
408
- // phantom char that never reached the server.
409
- if (!connection.sendBinary(out)) {
410
- return;
411
- }
412
- // Classic-terminal behavior (GNOME/xterm): user input re-engages follow and
413
- // snaps to the bottom, so typing while scrolled up jumps to the input line.
414
- // Instant (not the jump-button's smooth scroll), so it isn't janky per
415
- // keystroke. This is the ONLY thing that scrolls a held view down — program
416
- // output never does (the scroll controller's follow/hold job is unchanged),
417
- // so scrolling up to read while output streams is preserved. A no-op on the
418
- // alt screen (no scrollback) and when already following. NOTE: every caller
419
- // of sendBytes is genuine user input (typed keys, paste, mobile toolbar);
420
- // mouse reports deliberately bypass this funnel and go out as ephemeral
421
- // input, or the view would snap on every motion report.
422
- scroll.scrollToBottom();
423
- for (const obs of inputObservers) {
424
- obs(out);
425
- }
426
- }
427
- function sendText(text: string): void {
428
- sendBytes(encoder.encode(text));
429
- }
430
- function paste(text: string): void {
431
- // The one bracketed-paste + newline-normalize funnel; every feature and the
432
- // kernel's own paste paths route through here (paste-jacking defense).
433
- sendText(bracketTextForPaste(prepareTextForTerminal(text)));
434
- }
435
-
436
- // --- Keydown intercept chain (clipboard shortcuts, contextMenu Escape) ---
437
- const keydownHandlers: ((ev: KeyboardEvent) => boolean)[] = [];
438
-
439
- // --- Active session (SessionView + onSwitch fan-out) ---
440
- let activeSession: SessionRef | null = null;
441
- // Whether the first connection has been kicked off. The wake-reconnect
442
- // handlers (visibilitychange/pageshow/online) must not open a socket before
443
- // it has: under a session owner the kernel drives the first connect only
444
- // once resolveInitialSession() returns a session id, and connecting to the
445
- // bare wsPath before then hits a session-gated endpoint (the SessionManager
446
- // 404s a /ws with no ?session=). pageshow fires on the initial load, so
447
- // without this gate a slow session list lets pageshow's reconnectNow open a
448
- // bare /ws that 404s (seen in Firefox, where the list loses the race). Flips
449
- // true on the startup connect (no owner) or the first setSession (owned).
450
- let connectionInitiated = false;
451
-
452
- // --- Loading lifecycle ---
453
- let ready = false;
454
- /**
455
- * Every LineStore this kernel hands out, held WEAKLY and keyed by session. A
456
- * closed tab's store must stay collectable, and a weak registry cannot leak or
457
- * go stale the way a strong map paired with an unregister call can — the store
458
- * outlives the tab record in more than one teardown order.
459
- *
460
- * Keyed rather than a bare set because two features need to reach a SPECIFIC
461
- * session's store, not just "all of them": the restore guard below, and the
462
- * browse-cache sweep. The implicit store (a bare kernel with no session owner)
463
- * has no id, so it takes a reserved key no session can collide with.
464
- */
465
- const knownStores = new Map<string, WeakRef<LineStore>>();
466
- const IMPLICIT_STORE_KEY = "\u0000implicit";
467
- /** The live stores, pruning entries whose store has been collected. */
468
- function liveStores(): LineStore[] {
469
- const out: LineStore[] = [];
470
- for (const [key, ref] of knownStores) {
471
- const store = ref.deref();
472
- if (store === undefined) {
473
- knownStores.delete(key); // the tab closed and its store was collected
474
- continue;
475
- }
476
- out.push(store);
477
- }
478
- return out;
479
- }
480
-
481
- // Which restored scrollbacks have not yet been confirmed against the live
482
- // server. A session enters when its snapshot is hydrated and leaves on the
483
- // first resumeAck for THAT session; in between, the content is last session's —
484
- // plausible but unconfirmed.
485
- //
486
- // Per SESSION, and that is the whole point. Two booleans stood here, and under
487
- // the tabs preset (which every reference app uses) they could not do the job:
488
- // N sessions are hydrated at boot and all set the same flag, the active
489
- // session's first ack cleared it for everyone, and the reset reached only the
490
- // renderer-BOUND store. So tabs 2..N were neither verified nor discardable for
491
- // the life of the page, and the exact failure this guard exists to prevent —
492
- // the previous run's output under a "Session ended" banner — stayed reachable
493
- // by switching tabs. The claimed invariant ("only ever discards a restore,
494
- // never live content") also did not hold: a background tab's restore
495
- // authorised a reset of the bound tab's store.
496
- const unverifiedRestores = new Set<string>();
497
- /** Drop restored scrollback that will never be confirmed.
498
- *
499
- * The epoch check makes a stale restore self-correcting ONLY when a resumeAck
500
- * arrives: that is what compares the seeded epoch and fires the restart reset.
501
- * Two closes reach `markReady` without one — a process-exited 4001 (the session
502
- * is gone, which is what a container restart leaves behind) and a wire-revision
503
- * refusal (a cached client one revision behind, which is what an image update
504
- * leaves behind). Both are exactly the restart case, and without this the
505
- * overlay lifts over the PREVIOUS run's output under a "Session ended" banner —
506
- * the one path where the design's stated worst case was reachable.
507
- *
508
- * SCOPE matters because the two triggers differ: a 4001 says one session's
509
- * process is gone, so only that session's restore is condemned; a wire refusal
510
- * says this client cannot talk to this server at all, so every unverified
511
- * restore is. Discarding page-wide on a 4001 would wipe restores for sessions
512
- * that are perfectly alive.
513
- *
514
- * Only ever discards a restore, never live content: a session leaves the set
515
- * the moment its resume is served, and a session that was never hydrated never
516
- * enters it. */
517
- function discardUnverifiedRestore(scope: { session: string } | "page"): void {
518
- const ids = scope === "page" ? [...unverifiedRestores] : [scope.session];
519
- const bound = render.boundStore();
520
- for (const id of ids) {
521
- if (!unverifiedRestores.delete(id)) {
522
- continue; // never hydrated, or already verified
523
- }
524
- const store = knownStores.get(id)?.deref();
525
- if (store === undefined || store === bound) {
526
- // The BOUND store goes through the renderer so the DOM is reconciled;
527
- // an unknown id can only be the implicit store, which is also bound.
528
- render.resetScrollback();
529
- render.resetScreen();
530
- continue;
531
- }
532
- // A background store has no DOM: resetting it is enough, and the next
533
- // bind rebuilds from whatever it then holds.
534
- store.reset();
535
- }
536
- }
537
- let firstFrameRendered = false;
538
- let fontsLoaded = false;
539
- let wsOpen = false;
540
- let overlayDismissed = false;
541
- // Progressive status text on the consumer's overlay. Attached unconditionally
542
- // (inert when no overlay was supplied) and torn down by the same function that
543
- // lowers the overlay, so there is exactly one place where the loading screen's
544
- // life ends and no timer can outlive the thing it was writing into.
545
- const loadingStatus = attachLoadingStatus(opts.loading, {
546
- ...DEFAULT_LOADING_MESSAGES,
547
- ...opts.loadingMessages,
548
- });
549
- function dismissLoadingOverlay(): void {
550
- loadingStatus.stop();
551
- const ld = opts.loading;
552
- if (!ld || overlayDismissed) {
553
- return;
554
- }
555
- overlayDismissed = true;
556
- fadeOutOverlay(ld);
557
- }
558
- function markReady(): void {
559
- if (ready) {
560
- return;
561
- }
562
- ready = true;
563
- connState.setLoaded();
564
- dismissLoadingOverlay();
565
- }
566
-
567
- // --- Connection-state machine ---
568
- const connState = createConnState({
569
- onState: (s) => {
570
- bus.emit("connection:state", s);
571
- },
572
- onGiveUp: dismissLoadingOverlay,
573
- });
574
-
575
- // --- Engine wiring ---
576
- render.init({
577
- output: outputEl,
578
- termWrap,
579
- onCursorMove: () => {
580
- composition.positionCompositionView();
581
- bus.emit("render:cursor", undefined);
582
- },
583
- // Demand-paged scrollback (engine docs/paged-scrollback.md §5.4). The
584
- // renderer owns the DECISION (which gap the reader is approaching, and how
585
- // much to ask for); the transport owns the request. Wrapped rather than
586
- // passed by reference so neither module's identity leaks into the other's
587
- // options object.
588
- requestHistory: (fromAbs, maxLines) => connection.requestHistory(fromAbs, maxLines),
589
- historyBudget: () => connection.historyBudget(),
590
- ...(scrollbackLines !== undefined ? { maxLines: scrollbackLines } : {}),
591
- });
592
- render.updateFontMetrics();
593
-
594
- // A mouse report is BEST-EFFORT and never enters the reliable outbox: it
595
- // carries no sequence number, so one retransmitted after a resume describes a
596
- // screen since repainted, and `sendEphemeral` refuses rather than queues. Not
597
- // sendBytes either: that funnel snaps the view to the bottom on every accepted
598
- // byte, so a motion report would jump the viewport. The listeners belong on the
599
- // scroll container (a click in its padding or over the reserved scrollbar
600
- // gutter never reaches a row, and the wheel has to be cancellable there); the
601
- // coordinate frame is the row container, whose box IS the grid.
602
- const disposeMouse = mouse.init({
603
- sendReport: (data) => connection.sendEphemeral(data),
604
- cellSize: render.cellSize,
605
- termElement: () => termWrap,
606
- gridElement: () => outputEl,
607
- // The RENDERED grid, not computeSize(): the row hit test anchors on its
608
- // height, and this client's own measurement disagrees with the negotiated
609
- // screen whenever a second device is attached or a local resize has not been
610
- // answered yet, which would shift every reported row.
611
- gridSize: render.gridSize,
612
- });
613
-
614
- // A press over the display-only output takes focus off the hidden textarea and
615
- // the click handler below puts it back, so that blur/focus pair is an artifact
616
- // of this input model, not a focus change the application should be told about.
617
- // xterm.js never emits one because its textarea keeps focus through a click on
618
- // the terminal. So a blur raised inside a press is withheld and the gesture's
619
- // REAL end state is reported once it resolves: nothing when focus came back,
620
- // one report when it did not (a live selection, a link and a bare touch tap all
621
- // decline to restore it). Measured before this: 3600 focus reports in 90s of
622
- // clicking, which reaches an application as FocusLost/FocusGained per click.
623
- let pressHoldsFocus = false;
624
- let focusResolve: ReturnType<typeof setTimeout> | null = null;
625
-
626
- function reportFocusNow(): void {
627
- connection.setClientFocus(
628
- document.activeElement !== null && termWrap.contains(document.activeElement),
629
- );
630
- }
631
-
632
- // Scheduled rather than read at pointerup: the focus restore happens in the
633
- // `click` handler, which the browser dispatches after pointerup within the same
634
- // task, so a task boundary is the first point where the end state is settled.
635
- function endPressFocusGesture(): void {
636
- if (focusResolve !== null) {
637
- clearTimeout(focusResolve);
638
- }
639
- focusResolve = setTimeout(() => {
640
- focusResolve = null;
641
- pressHoldsFocus = false;
642
- reportFocusNow();
643
- }, 0);
644
- }
645
-
646
- // The keyboard target is the hidden textarea INSIDE termWrap, so the widget's
647
- // own focus events bubble to here. What goes out is the widget's STATE, not
648
- // DEC 1004 bytes: the server derives the answer from this report plus every
649
- // other attached client's plus its own hold.
650
- termWrap.addEventListener(
651
- "focusin",
652
- () => {
653
- connection.setClientFocus(true);
654
- },
655
- { signal },
656
- );
657
- termWrap.addEventListener(
658
- "focusout",
659
- (ev) => {
660
- if (ev.relatedTarget instanceof Node && termWrap.contains(ev.relatedTarget)) {
661
- return; // focus moving WITHIN the terminal is not a blur
662
- }
663
- if (pressHoldsFocus) {
664
- return; // this gesture's own doing; endPressFocusGesture reports the truth
665
- }
666
- connection.setClientFocus(false);
667
- },
668
- { signal },
669
- );
670
- // Seeded once, so a terminal mounted with its textarea already focused does not
671
- // report blurred until the first real focusin.
672
- reportFocusNow();
673
-
674
- // The kernel is the toggler, and the class goes on the scroll container rather
675
- // than on the rows: `.term-output` is replaced wholesale by the renderer, and
676
- // `.term` is the element that survives every rebuild. What the shape MEANS is
677
- // at the CSS rule it drives (css/02-terminal.css).
678
- function updateMousePointer(): void {
679
- termWrap.classList.toggle("wt-mouse-app", modes.getMouseMode() !== 0);
680
- }
681
-
682
- // Derived once here as well as from the two triggers below: the modes module is
683
- // a singleton that outlives a terminal, so a re-created terminal on a page
684
- // whose mirror already holds a tracking mode would otherwise show the resting
685
- // pointer until the next frame.
686
- updateMousePointer();
687
-
688
- composition.init({
689
- textarea: input,
690
- compositionView: compositionViewEl,
691
- getCursorPx: render.getCursorPx,
692
- send: sendText,
693
- paste,
694
- });
695
-
696
- scroll.init({
697
- scrollEl: termWrap,
698
- onUserScrollChange(scrolledUp) {
699
- bus.emit("scroll:state", { scrolledUp });
700
- },
701
- // Every position change, not only a follow/hold toggle: a reader scrolling
702
- // WITHIN history never toggles follow, and that is exactly when paging has
703
- // to work. The trigger re-evaluates its own guards, so firing for the
704
- // browser's own clamps as well as gestures costs nothing.
705
- //
706
- // ONE hook, deliberately. The renderer's scroll-position contract is two jobs
707
- // (re-evaluate the paging trigger, and finish a drain that stopped with rows
708
- // queued) and it owns the ordering and the guards for both. A consumer wiring
709
- // them as two calls is a consumer that can wire one.
710
- onScrollPosition: render.handleScrollPosition,
711
- });
712
-
713
- // The size this client would announce right now, or null when it cannot
714
- // measure trustworthily yet. Both the resume-time announce (the engine's
715
- // Callbacks.initialSize) and the open-time send below read it, so there is one
716
- // definition of "measurable" rather than two that can disagree.
717
- //
718
- // Unmeasurable means either web fonts are still loading (cell metrics would be
719
- // wrong, so cols/rows would be) or a viewport transition is in flight (an iOS
720
- // keyboard slide or rotation, whose intermediate geometry is provisional). In
721
- // both cases announcing costs a second resize once the real size is known, and
722
- // therefore a second redraw from any program that repaints on SIGWINCH.
723
- function measurableSize(): { cols: number; rows: number } | null {
724
- if (!fontsLoaded || viewport.isInTransition()) {
725
- return null;
726
- }
727
- render.updateFontMetrics();
728
- return render.computeSize();
729
- }
730
-
731
- function maybeSendFirstResize(): void {
732
- if (!wsOpen || measurableSize() === null) {
733
- return;
734
- }
735
- connection.sendResize();
736
- }
737
-
738
- connection.init({
739
- computeSize: render.computeSize,
740
- // getHaveThrough is deliberately NOT supplied. The engine defaults it to its
741
- // own renderer (engine >= 5.0.3), which is the only party that knows which
742
- // store is currently bound and which of its rows the server has confirmed.
743
- // Supplying it from here is what claimed rows the application had merely
744
- // DRAWN — `render.getHighestIndex` is the window's bottom row — and left a
745
- // frozen copy of the composer parked in scrollback on almost every reattach.
746
- // An explicit member wins over the default, so re-adding one reopens that.
747
- // --- Demand-paged scrollback (engine docs/paged-scrollback.md §4-5) ---
748
- // The transport is store-blind and viewport-blind by design, so every one of
749
- // these forwards a decision only the renderer can make. Without them the
750
- // engine's paging machinery is inert: the server still declares the
751
- // capability and still bounds its replay, but nothing ever asks for a page.
752
- //
753
- // How much replay this client wants on attach: no more than it intends to
754
- // keep resident. The server bounds the replay regardless, and the client
755
- // sends the same number, so its replay-jump prediction and the server's
756
- // actual start agree.
757
- getReplayMax: render.replayMaxForResume,
758
- onHistoryReply: render.handleHistoryReply,
759
- // ONE transition per ack, carrying the values this socket SENT. It must not
760
- // be split into separate callbacks: the store's five steps (epoch reset,
761
- // bounds, cap flip, replay-jump prediction, budget pass) are ordered against
762
- // each other, and an implementer with five hooks can interleave them.
763
- onResumeTransition: render.applyResumeTransition,
764
- // The solicited window: the store's permission to admit lines below its
765
- // stale-re-send watermark, opened for exactly the range in flight and closed
766
- // when the reply lands.
767
- noteSolicited: render.noteSolicited,
768
- clearSolicited: render.clearSolicited,
769
- // A denied request (paced by the token bucket, or a data timeout) re-runs the
770
- // FULL trigger rather than replaying the denied range: by the time the bucket
771
- // refills the gap may have healed, or the session may have entered alt.
772
- onHistoryRetry: () => {
773
- render.maybeFetchHistory();
774
- },
775
- onResumeBounds(committed, oldest) {
776
- // A resume VERIFIES the restored store of the session it belongs to: the
777
- // server has answered under a known epoch, so that content is confirmed
778
- // rather than merely plausible. Recorded here because onResumeBounds rides
779
- // every resumeAck, which is also where the epoch comparison happens. Scoped
780
- // to the socket's OWN session — one ack cannot vouch for a tab it never
781
- // talked to.
782
- unverifiedRestores.delete(connection.currentSessionId());
783
- render.noteResumeBounds(committed, oldest);
784
- },
785
- // Announced BEFORE the resume so the server's snapshot and history replay
786
- // come back at this client's geometry (engine Callbacks.initialSize).
787
- initialSize: measurableSize,
788
- wsPath,
789
- onMessage(msg) {
790
- if (msg.type === "screen") {
791
- render.handleScreen(msg);
792
- firstFrameRendered = true;
793
- if (fontsLoaded) {
794
- markReady();
795
- }
796
- bus.emit("wire:screen", msg);
797
- } else if (msg.type === "scroll") {
798
- render.handleScroll(msg);
799
- } else if (msg.type === "title") {
800
- // Match the app's own title policy (tabs.ts wire:title / applyStatus,
801
- // which ignore a blank OSC 0/2 title): a shell clears its window title
802
- // when it redraws its prompt after idling, so hold the last-good browser
803
- // title instead of flickering blank.
804
- if (msg.title.trim() !== "") {
805
- baseTitle = msg.title;
806
- paintTitle();
807
- }
808
- bus.emit("wire:title", { session: activeSession?.id ?? "", title: msg.title });
809
- } else if (msg.type === "modes") {
810
- render.updateReverseVideo();
811
- updateMousePointer();
812
- bus.emit("wire:modes", msg);
813
- } else if (msg.type === "clipboard") {
814
- // Inbound OSC 52. With no clipboard feature subscribed this is a no-op
815
- // (section 22.4).
816
- bus.emit("wire:clipboard", msg.text);
817
- }
818
- },
819
- onOpen() {
820
- connState.open();
821
- wsOpen = true;
822
- maybeSendFirstResize();
823
- },
824
- onConnecting() {
825
- connState.reconnecting();
826
- },
827
- onClose() {
828
- connState.closed();
829
- },
830
- onOutboxFull() {
831
- connState.closed();
832
- },
833
- onProcessExit() {
834
- // The engine's definitive 4001 close: the session's process has exited
835
- // and the engine will not reconnect it. Three jobs here. markReady()
836
- // guarantees the page is usable even when the exit lands before any
837
- // screen frame (attaching to an already-dead session on a server that
838
- // races the replay) — without it the loading overlay would sit on top of
839
- // the tabs chrome forever, which is exactly the reported stuck-loading
840
- // wedge. Then surface the end state: "Session ended", not a flapping
841
- // "Reconnecting…", since no reconnect is coming. The final screen (when
842
- // the server delivered it) stays rendered behind the banner.
843
- discardUnverifiedRestore({ session: connection.currentSessionId() });
844
- markReady();
845
- connState.ended();
846
- // The host's turn, and LAST: everything above is unconditional, so a
847
- // handler that throws cannot leave the page without its overlay lowered
848
- // or its banner shown. The host is told because the kernel's own answer
849
- // stops here — only the host knows whether its endpoint yields a new
850
- // session on the next connect, which is what reattach() is for.
851
- if (opts.onSessionEnded !== undefined) {
852
- try {
853
- opts.onSessionEnded();
854
- } catch (err) {
855
- console.error("web-terminal-ui: onSessionEnded handler failed", err);
856
- }
857
- }
858
- },
859
- onWireIncompatible() {
860
- // Compatibility refusal is terminal for this page instance. Lower the
861
- // pre-JS overlay even if no screen frame arrived, then leave the
862
- // actionable banner visible while the engine suppresses reconnects.
863
- discardUnverifiedRestore("page");
864
- markReady();
865
- connState.incompatible();
866
- },
867
- onServerRestart() {
868
- render.resetScrollback();
869
- render.resetScreen();
870
- connState.restarted();
871
- },
872
- });
873
-
874
- // --- Input handling ---
875
- resetToPlaceholder(input);
876
-
877
- input.addEventListener(
878
- "input",
879
- (e: Event) => {
880
- if (composition.isComposing()) {
881
- return;
882
- }
883
- const ev = e as InputEvent;
884
- const inputType = ev.inputType;
885
- if (
886
- inputType === "deleteContentBackward" ||
887
- inputType === "deleteContentForward" ||
888
- inputType === "deleteWordBackward" ||
889
- inputType === "deleteWordForward"
890
- ) {
891
- resetToPlaceholder(input);
892
- return;
893
- } else if (inputType === "insertReplacementText") {
894
- // A spellcheck / autocorrect / autofill REWRITE of text already typed.
895
- // The bytes it is correcting have already gone to the pty and a terminal
896
- // cannot retract them: 0x7f means whatever the remote application decides
897
- // it means, so a pager, vim, a y/n prompt and a no-echo password prompt
898
- // each read it as their own input. Applying the rewrite forward instead
899
- // duplicates — measured on xterm.js's value-diff, where `helo` corrected
900
- // to `hello` puts `helohello` on the wire (its issue #3600). So drop it
901
- // and leave the typed text standing, which is also what a shell wants:
902
- // `cd /usr` silently becoming `cd /use` is worse than no autocorrect.
903
- //
904
- // The spec's own signal for this, so no length heuristic is needed: a
905
- // max-one-character rule would break glide typing (a whole word in one
906
- // event, and SwiftKey's flagship input method) while still admitting
907
- // CJK's one-for-one in-place replacement. Cancelling is not an option
908
- // either — preventDefault on beforeinput is a no-op in Chrome, per
909
- // CodeMirror's own note — but declining to SEND is entirely ours.
910
- //
911
- // Reachable on more than Android: WebKit fires this type for a spelling
912
- // suggestion and for a macOS system text substitution whatever
913
- // autocorrect="off" says. Dropping is right wherever it fires, because
914
- // what it carries is always a replacement for bytes the pty already has.
915
- resetToPlaceholder(input);
916
- return;
917
- } else if (typeof ev.data === "string" && ev.data.length > 0) {
918
- if (inputType === "insertFromPaste") {
919
- paste(ev.data);
920
- } else {
921
- // Normalize iOS's NBSP-for-space quirk, then send through the funnel.
922
- sendText(normalizeTypedText(ev.data));
923
- }
924
- } else {
925
- const v = input.value;
926
- if (v.length > INPUT_PLACEHOLDER.length && v.startsWith(INPUT_PLACEHOLDER)) {
927
- sendText(normalizeTypedText(v.slice(INPUT_PLACEHOLDER.length)));
928
- } else if (v !== INPUT_PLACEHOLDER && v.length > 0) {
929
- sendText(normalizeTypedText(v));
930
- }
931
- }
932
- resetToPlaceholder(input);
933
- },
934
- { signal },
935
- );
936
-
937
- input.addEventListener(
938
- "focus",
939
- () => {
940
- termWrap.classList.add("focus");
941
- },
942
- { signal },
943
- );
944
- input.addEventListener(
945
- "blur",
946
- () => {
947
- // cancelComposition, not just resetToPlaceholder: `composing` gates both
948
- // input listeners, so a composition abandoned by losing focus would
949
- // otherwise keep the gate shut and the terminal would take no input when
950
- // focus came back. The staleness read covers a keyboard that strands us
951
- // while still focused; this covers the case with a definite signal.
952
- composition.cancelComposition();
953
- termWrap.classList.remove("focus");
954
- },
955
- { signal },
956
- );
957
-
958
- /**
959
- * Applies a `mapKeyboardEvent` result. BOTH keydown paths — the focused textarea
960
- * below, and the document-level type-to-focus fallback in the focus-strategy
961
- * section — handle every arm identically, including "ignore": the textarea lets
962
- * its own `input` event deliver the character, and the fallback has already
963
- * resolved that case before it gets here, computing the typed character itself
964
- * because no `input` event is coming for a key that targeted the body.
965
- *
966
- * Switching over the whole `KeyboardResult` union in one place is what keeps the
967
- * two paths from drifting — handling only "send" in the fallback silently dropped
968
- * Shift+PageUp, which is exactly what a reader who just selected some scrollback
969
- * presses next.
970
- */
971
- function applyMappedKey(ev: KeyboardEvent, result: keyboard.KeyboardResult): void {
972
- switch (result.kind) {
973
- case "send":
974
- ev.preventDefault();
975
- sendText(result.bytes);
976
- return;
977
- case "scroll-up": {
978
- ev.preventDefault();
979
- const h = termWrap.clientHeight;
980
- termWrap.scrollTop = Math.max(0, termWrap.scrollTop - h);
981
- return;
982
- }
983
- case "scroll-down": {
984
- ev.preventDefault();
985
- const h = termWrap.clientHeight;
986
- // The ceiling is the maximum offset, not scrollHeight: the latter is one
987
- // clientHeight past the end, so a page-down near the bottom handed the
988
- // container an out-of-range offset and relied on it to clamp. Same
989
- // reasoning as the engine's bottom writes.
990
- const max = Math.max(0, termWrap.scrollHeight - h);
991
- termWrap.scrollTop = Math.min(max, termWrap.scrollTop + h);
992
- return;
993
- }
994
- case "ignore":
995
- return;
996
- }
997
- }
998
-
999
- input.addEventListener(
1000
- "keydown",
1001
- (ev: KeyboardEvent) => {
1002
- // Three gates, and the order is the design. The spec signal first: UI
1003
- // Events requires a key event inside a composition session to carry
1004
- // isComposing, which covers every engine except one case.
1005
- //
1006
- // That case is the key which COMMITS a composition: Chrome and Safari both
1007
- // report keyCode 229 for it and Safari reports isComposing false (measured
1008
- // in quill PR #3445), so a second signal is needed. It is read against the
1009
- // RAW latch, before the reconciling isComposing() below, and both halves of
1010
- // that matter. Gated, because on Android 229 is the value for nearly every
1011
- // soft-keyboard key, so an unconditional check swallows ordinary Backspace
1012
- // and Enter — found in review after exactly that shipped here. Before the
1013
- // reconcile, because reconciling clears the latch, and a CJK user who left
1014
- // a candidate window open past the idle bound is still committing a real
1015
- // composition: taking this key as an ordinary Enter would send a carriage
1016
- // return before the composed phrase and execute a half-typed line.
1017
- //
1018
- // The consequence to know: in a stranded composition on Android, Enter and
1019
- // Backspace stay blocked until a character arrives (which reconciles the
1020
- // latch and releases them) or focus is lost. That is the deliberate side
1021
- // of the trade, because the alternative costs desktop and iOS correctness.
1022
- if (ev.isComposing) {
1023
- return;
1024
- }
1025
- // eslint-disable-next-line @typescript-eslint/no-deprecated -- see above
1026
- if (composition.isCompositionOpen() && ev.keyCode === IME_COMMIT_KEYCODE) {
1027
- return;
1028
- }
1029
- if (composition.isComposing()) {
1030
- return;
1031
- }
1032
- // Features intercept first (clipboard Ctrl+Shift+C/V, contextMenu Escape).
1033
- for (const h of keydownHandlers) {
1034
- if (h(ev)) {
1035
- return;
1036
- }
1037
- }
1038
- // Nothing more is needed for an "ignore": the `input` listener above delivers
1039
- // the character, which is also how IME and dead-key composition output
1040
- // reaches the terminal.
1041
- applyMappedKey(ev, mapKeyboardEvent(ev, modes));
1042
- },
1043
- { signal },
1044
- );
1045
-
1046
- // --- Focus strategy (the touch focus dance; see the input-model contract) ---
1047
- // On touch, the terminal output is the native text-selection surface, so this
1048
- // handler deliberately does the MINIMUM: it opens the keyboard on a clean tap
1049
- // and otherwise gets out of the browser's way. It never preventDefaults a
1050
- // touch and never clears a selection — a long-press to select a word, the OS
1051
- // copy/paste callout, and drag-to-scroll are all left to the platform (the
1052
- // research consensus: over text, allow the default and emit nothing).
1053
- // A fine pointer (mouse / trackpad) is available: a desktop, or a tablet with
1054
- // a trackpad / Magic Keyboard. Such a device has a hardware keyboard, so there
1055
- // is no soft keyboard to protect — focus should be eager and land in one tap.
1056
- // The bare-touch guards below bow out of focusing (to avoid popping the soft
1057
- // keyboard or stealing a selection); they are relaxed when a fine pointer
1058
- // exists. This is keyed off `any-pointer: fine` rather than the event's
1059
- // pointerType because iPadOS reports a COARSE primary pointer even with a
1060
- // trackpad attached, and its trackpad taps arrive inconsistently as "mouse" or
1061
- // "touch" — so pointerType alone made the terminal take several taps to focus.
1062
- const hasFinePointer = (): boolean =>
1063
- typeof window.matchMedia === "function" && window.matchMedia("(any-pointer: fine)").matches;
1064
- let lastPointerType = "mouse";
1065
- let pointerDownX = 0;
1066
- let pointerDownY = 0;
1067
- let pointerDownTime = 0;
1068
- function focusTerminal(): void {
1069
- // The public handle can outlive a fatal startup or explicit destroy. Never
1070
- // focus the detached textarea after the runtime has been released.
1071
- if (destroyed) {
1072
- return;
1073
- }
1074
- input.focus({ preventScroll: true });
1075
- }
1076
- termWrap.addEventListener(
1077
- "pointerdown",
1078
- (e) => {
1079
- lastPointerType = e.pointerType;
1080
- pointerDownX = e.clientX;
1081
- pointerDownY = e.clientY;
1082
- pointerDownTime = e.timeStamp;
1083
- pressHoldsFocus = true;
1084
- },
1085
- { passive: true, signal },
1086
- );
1087
- for (const end of ["pointerup", "pointercancel", "lostpointercapture"] as const) {
1088
- termWrap.addEventListener(end, endPressFocusGesture, { passive: true, signal });
1089
- }
1090
- termWrap.addEventListener(
1091
- "pointerup",
1092
- (e) => {
1093
- if (e.pointerType !== "touch") {
1094
- return;
1095
- }
1096
- if (isLinkTarget(e.target)) {
1097
- return;
1098
- }
1099
- const dx = Math.abs(e.clientX - pointerDownX);
1100
- const dy = Math.abs(e.clientY - pointerDownY);
1101
- // A drag (scroll / selection-extend) or a long-press (native word-select /
1102
- // context menu) is not a tap-to-focus: bow out and let the browser own it.
1103
- if (dx > TAP_MOVEMENT_PX || dy > TAP_MOVEMENT_PX) {
1104
- return;
1105
- }
1106
- if (e.timeStamp - pointerDownTime > TAP_MAX_MS) {
1107
- return;
1108
- }
1109
- // A clean tap while text is selected means "done selecting": clear the
1110
- // selection (and dismiss the OS callout with it). This is our deselect —
1111
- // iOS otherwise leaves the selection stuck, because the synthetic mousedown
1112
- // we preventDefault to preserve the keyboard also suppresses the platform's
1113
- // own tap-to-deselect. Do NOT also focus: a deselect tap should not pop the
1114
- // keyboard; the next clean tap (nothing selected) opens it. The long-press
1115
- // that MADE the selection is filtered out above by the duration/movement
1116
- // guards, so only a deliberate later tap reaches here.
1117
- const sel = window.getSelection();
1118
- if (sel && !sel.isCollapsed) {
1119
- sel.removeAllRanges();
1120
- // On a bare touchscreen a deselect tap must NOT also focus: that would
1121
- // pop the soft keyboard right after a copy. But with a fine pointer
1122
- // (a hardware keyboard is present) there is no soft keyboard to pop, so
1123
- // focus in the same tap rather than forcing the user to tap again — this
1124
- // is a large part of the "2-3 taps to focus" on an iPad + Magic Keyboard.
1125
- if (hasFinePointer()) {
1126
- focusTerminal();
1127
- }
1128
- return;
1129
- }
1130
- // A clean tap with nothing selected focuses the input, synchronously (an
1131
- // async focus would not raise the iOS soft keyboard).
1132
- focusTerminal();
1133
- },
1134
- { passive: true, signal },
1135
- );
1136
- termWrap.addEventListener(
1137
- "mousedown",
1138
- (e) => {
1139
- // Touch is the platform's press, start to finish: its own selection UI
1140
- // (long-press word select, the drag handles, the OS callout) owns a press
1141
- // that lands on a selection, and it never uses HTML5 drag-and-drop. So a
1142
- // touch press gets nothing here but the keyboard preservation below, and
1143
- // the selection policy that follows is deliberately mouse-and-pen only.
1144
- if (lastPointerType === "touch") {
1145
- // Cancel the synthetic mousedown after a touch tap so iOS keeps the
1146
- // keyboard up (xterm.js focus-preservation pattern, scoped to touch).
1147
- // Skip it when a fine pointer is present (iPad + trackpad / Magic
1148
- // Keyboard): there is no soft keyboard to protect, and suppressing the
1149
- // mousedown was defeating the native focus, so the terminal needed
1150
- // several taps to focus.
1151
- if (!hasFinePointer()) {
1152
- e.preventDefault();
1153
- }
1154
- return;
1155
- }
1156
- // Terminal text is display-only, never draggable content — but a browser
1157
- // does not know that, and a bare left press that lands INSIDE the current
1158
- // selection is the one gesture where it matters: Blink and Gecko both read
1159
- // it as the start of a native drag-and-drop of the selected text, so the
1160
- // press neither collapses the selection nor begins a new one, and the
1161
- // release drops nothing (there is no drop target). Drag over text you just
1162
- // selected and the selection is stuck: no new selection, and not even a
1163
- // click clears it, because a real mouse always moves a pixel or two and
1164
- // that is enough to re-enter the drag path. Collapsing the selection here,
1165
- // before the browser resolves the gesture, is what keeps it out: with
1166
- // nothing selected under the press both engines take their ordinary select
1167
- // path and the drag is never considered. A press OUTSIDE the selection
1168
- // collapses it natively anyway, so this only ever pre-empts the browser,
1169
- // never contradicts it — no hit test against the selection needed.
1170
- // Non-left and modified presses are left alone: a right-click has to keep
1171
- // the selection for the context menu's Copy, a middle-click for the
1172
- // primary selection it pastes, Shift-click extends a selection, and
1173
- // Ctrl-drag adds a second range in Firefox.
1174
- if (e.button !== 0 || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) {
1175
- return;
1176
- }
1177
- const pressSel = window.getSelection();
1178
- if (pressSel && !pressSel.isCollapsed) {
1179
- pressSel.removeAllRanges();
1180
- }
1181
- },
1182
- { signal },
1183
- );
1184
- termWrap.addEventListener(
1185
- "click",
1186
- (e) => {
1187
- const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(".term-link");
1188
- if (link) {
1189
- e.preventDefault();
1190
- window.open(link.href, "_blank", "noopener,noreferrer");
1191
- return;
1192
- }
1193
- if (lastPointerType === "touch" && !hasFinePointer()) {
1194
- return;
1195
- }
1196
- const sel = window.getSelection();
1197
- if (sel && sel.toString().length > 0) {
1198
- return;
1199
- }
1200
- focusTerminal();
1201
- },
1202
- { signal },
1203
- );
1204
-
1205
- // --- Type-to-focus: recovering a keystroke after a mouse selection ---
1206
- // A mouse gesture over `.term-output` leaves the browser no focusable target,
1207
- // so the key target becomes the document body (UI Events: "If no suitable
1208
- // element is in focus, the event target will be the HTML body element") and
1209
- // the hidden textarea stops receiving keys. The click handler above cannot fix
1210
- // that, because focusing the textarea COLLAPSES the selection the user just
1211
- // made, so it deliberately declines while one exists. Net effect without this
1212
- // listener: select a single cell with the mouse and typing silently does
1213
- // nothing, and the two shortcuts that ride the keydown chain go with it
1214
- // (clipboard's Ctrl+Shift+C, contextMenu's Escape).
1215
- //
1216
- // Native terminals have no such competition: a selection is app state and the
1217
- // keyboard belongs to the window, so VTE grabs widget focus on the very press
1218
- // that starts a selection. xterm.js sidesteps it by preventDefaulting mousedown
1219
- // and painting its own selection, which is not open to us — native touch
1220
- // selection is this UI's mobile selection UX (css/02-terminal.css). So the
1221
- // browser's model stands and typing takes the keyboard back instead, which is
1222
- // also what Windows Terminal does ("most key input clears the selection and
1223
- // passes the key event directly to the underlying shell").
1224
- const doc = root.ownerDocument;
1225
- function ownsSelection(): boolean {
1226
- // EVERY range, and both ends of each: a range dragged from the terminal out
1227
- // into the host page's own content never arms this in either drag direction,
1228
- // and neither does a Firefox Ctrl+drag multi-range selection with one range
1229
- // outside (the mousedown policy above deliberately preserves those, and
1230
- // removeAllRanges below would take the host's range with the terminal's).
1231
- // Range ends are in document order whichever way the user dragged, so
1232
- // direction costs nothing here. Selected TEXT rather than isCollapsed, so a
1233
- // bare caret does not arm, matching the click handler's own test. Scoped to
1234
- // `.term-output`, so a selection in the chrome does not arm. Known
1235
- // limitation: a terminal inside a host's shadow root never arms, because
1236
- // getSelection() does not reach into shadow trees.
1237
- const sel = doc.getSelection();
1238
- if (sel === null || sel.toString().length === 0) {
1239
- return false;
1240
- }
1241
- for (let i = 0; i < sel.rangeCount; i++) {
1242
- const range = sel.getRangeAt(i);
1243
- if (!outputEl.contains(range.startContainer) || !outputEl.contains(range.endContainer)) {
1244
- return false;
1245
- }
1246
- }
1247
- return true;
1248
- }
1249
- doc.addEventListener(
1250
- "keydown",
1251
- (ev: KeyboardEvent) => {
1252
- // Two groups, in this order. The ARMING gates immediately below decide
1253
- // whether this listener is responsible for the keystroke at all; the
1254
- // TERMINAL-KEY gates after the feature chain decide whether the terminal
1255
- // wants this particular key. Both are preconditions rather than a
1256
- // classification: nothing runs unless every gate passes, so no keystroke
1257
- // falls between them.
1258
- if (destroyed) {
1259
- // Reachable in one window only: cleanupRuntime() tears features down
1260
- // BEFORE it aborts the signal that removes this listener, so a feature
1261
- // teardown that synchronously dispatched a key would otherwise be served
1262
- // by a terminal that is already gone. (The textarea listener needs no
1263
- // such gate: it only fires while the textarea holds focus, and a teardown
1264
- // does not focus it.)
1265
- return;
1266
- }
1267
- // A host that already handled this key marked it, so leave it alone. This
1268
- // is the platform's own convention for composing keyboard handlers, and it
1269
- // is the reason this listener needs no opt-out option: an embedder with a
1270
- // document-level keymap cancels the events it owns.
1271
- if (ev.defaultPrevented) {
1272
- return;
1273
- }
1274
- // Nobody owns the keyboard. This one test replaces a blocklist of
1275
- // interactive tag names and is strictly more complete: focus inside an open
1276
- // shadow root reports the HOST, an open <dialog> reports the dialog (or its
1277
- // focused child), and a tabindex="0" ARIA widget reports itself. All
1278
- // non-body, so all bail. It also subsumes the double-send guard: a focused
1279
- // textarea is not the body, so a key the handler above already took can
1280
- // never reach here. Residual: HTML's focus fixup rule can park focus back
1281
- // on the body while a modal dialog is still open (its focused child was
1282
- // removed), which the focus-landed check below is what actually covers.
1283
- if (doc.activeElement !== null && doc.activeElement !== doc.body) {
1284
- return;
1285
- }
1286
- // Composition first, matching the textarea path, and on the composition
1287
- // module's clock rather than ev.isComposing: that clock stays true through
1288
- // the deferred compositionend read (the documented Chromium workaround).
1289
- if (composition.isComposing()) {
1290
- return;
1291
- }
1292
- if (!ownsSelection()) {
1293
- return;
1294
- }
1295
- // Features next, while the selection is STILL INTACT, and BEFORE the
1296
- // modifier gate below. Both orderings are load-bearing: clipboard's
1297
- // Ctrl+Shift+C is a MODIFIED key that reads the selection, so a modifier
1298
- // gate placed first would deny the chain the one shortcut this listener
1299
- // exists to restore (caught by its own test). A claimed key never focuses,
1300
- // so the selection survives it and a second copy is still possible.
1301
- for (const h of keydownHandlers) {
1302
- if (h(ev)) {
1303
- return;
1304
- }
1305
- }
1306
- // What this key DOES is decided before taking focus, because a keystroke
1307
- // that delivers nothing must leave the selection and the focus untouched.
1308
- // A bare modifier press is the case that proves it: Shift on its way to
1309
- // Shift+click, or to Ctrl+Shift+C, would otherwise clear the very selection
1310
- // the chord is about to act on.
1311
- //
1312
- // The character is computed ONLY for an "ignore" result, so the encoder
1313
- // always wins a key it maps. Under the kitty protocol a MODIFIED character
1314
- // key encodes to a CSI-u sequence rather than to its text, and the focused
1315
- // path sends that sequence; this path has to agree with it.
1316
- const result = mapKeyboardEvent(ev, modes);
1317
- const char = result.kind === "ignore" ? typedChar(ev) : null;
1318
- if (result.kind === "ignore" && char === null) {
1319
- // Nothing to deliver: a bare modifier or lock key, a dead key, an IME
1320
- // start, a multi-character or unknown key name.
1321
- return;
1322
- }
1323
- // Modified keys stay with the browser, with one exception: an AltGr
1324
- // CHARACTER, because on the layouts that need AltGr that is how the
1325
- // character is typed. The exception is deliberately limited to a character
1326
- // rather than granted to any AltGraph event, because AltGraph is not a
1327
- // reliable signal on its own: Firefox reports it for plain Option on macOS
1328
- // and for ordinary Ctrl+Alt on Windows, so admitting every AltGraph event
1329
- // would hand the terminal Option+ArrowLeft, which is browser back.
1330
- // (xterm.js carries the mirror-image heuristic, ctrl+alt meaning AltGr, and
1331
- // scopes it to Windows.)
1332
- //
1333
- // Nothing else modified is recovered, and the asymmetry decides that: not
1334
- // recovering Ctrl+ArrowLeft costs one keypress in a state that ends on the
1335
- // next character typed, while stealing Ctrl+C costs the user the selection
1336
- // they just made, and Alt+ArrowLeft is browser back.
1337
- const altGraphChar = char !== null && ev.getModifierState("AltGraph");
1338
- if ((ev.ctrlKey || ev.metaKey || ev.altKey) && !altGraphChar) {
1339
- return;
1340
- }
1341
- // Tab stays with the browser: this only ever runs on a body-focused page,
1342
- // where Tab is how a keyboard user moves on (web-terminal-kiro's
1343
- // focusable-inventory test pins that expectation).
1344
- if (ev.key === "Tab") {
1345
- return;
1346
- }
1347
- // Take the keyboard. Collapsing explicitly rather than leaning on focus()
1348
- // to do it keeps the behaviour identical wherever focus() would not.
1349
- doc.getSelection()?.removeAllRanges();
1350
- focusTerminal();
1351
- // Fail closed. Focus can fail to land (an inert subtree under an open modal
1352
- // dialog, a released root), and bytes whose effect the user cannot see do
1353
- // not belong in a shell.
1354
- if (doc.activeElement !== input) {
1355
- return;
1356
- }
1357
- if (char !== null) {
1358
- // The encoder defers a plain character to the `input` event, which the
1359
- // focused path delivers. No input event is coming for a key that targeted
1360
- // the body, so send it here. preventDefault makes that exactly once BY
1361
- // SPEC ("If this event is canceled, the associated event types MUST NOT be
1362
- // dispatched"), rather than by depending on whether an engine re-resolves
1363
- // an uncanceled keydown's insertion target after a listener moved focus
1364
- // mid-dispatch. Chromium does; no specification requires it.
1365
- ev.preventDefault();
1366
- sendText(normalizeTypedText(char));
1367
- return;
1368
- }
1369
- applyMappedKey(ev, result);
1370
- },
1371
- { signal },
1372
- );
1373
-
1374
- // --- Viewport ---
1375
- viewport.init({
1376
- termWrap,
1377
- root,
1378
- suppressKeyboardInset: hasFinePointer,
1379
- onSettled() {
1380
- // The settle is the authoritative size for this geometry: measurableSize
1381
- // re-measures the font metrics and now reports a viewport that has stopped
1382
- // moving, so this is the one resize a keyboard slide or rotation should
1383
- // cost. sendResize deduplicates, so a settle that changed nothing is free.
1384
- if (measurableSize() !== null) {
1385
- connection.sendResize();
1386
- }
1387
- composition.positionCompositionView();
1388
- },
1389
- });
1390
-
1391
- // --- Fonts ---
1392
- const onFontSettled = (): void => {
1393
- fontsLoaded = true;
1394
- if (firstFrameRendered) {
1395
- markReady();
1396
- }
1397
- requestAnimationFrame(() => {
1398
- maybeSendFirstResize();
1399
- });
1400
- };
1401
- try {
1402
- // false once the fonts have landed, true when the deadline opened the gate
1403
- // without them. The race covers the whole wait, not just the rejection arm: a
1404
- // request that stalls with nothing rejecting leaves load() ITSELF pending, so a
1405
- // deadline armed on the rejection would bound one of the ways a font can hang.
1406
- const landed: Promise<boolean> = document.fonts.load(fontReady).then(
1407
- () => false,
1408
- (err: unknown) => {
1409
- console.warn(`web-terminal-ui: web font ${fontReady} failed to load`, err);
1410
- // One load() over a comma-separated stack rejects as a UNIT, so settling
1411
- // here would let an unavailable OPTIONAL family abandon the gate for the
1412
- // REQUIRED one and announce a size measured on fallback metrics. `ready`
1413
- // settles the initial load INCLUDING a failed one (the same property
1414
- // features/tabs/ink-centre.ts relies on), so the families that ARE served
1415
- // still land their bytes before the first resize is measured.
1416
- return document.fonts.ready.then(() => false);
1417
- },
1418
- );
1419
- const deadline = new Promise<boolean>((resolve) => {
1420
- window.setTimeout(() => {
1421
- resolve(true);
1422
- }, FONT_READY_TIMEOUT_MS);
1423
- });
1424
- // Whichever settles first opens the gate exactly once; the loser is ignored.
1425
- void Promise.race([landed, deadline]).then((timedOut) => {
1426
- onFontSettled();
1427
- if (!timedOut) {
1428
- return;
1429
- }
1430
- // The gate opened on fallback metrics, and the swap period is infinite: bytes
1431
- // that land after this change the cell width with fontsLoaded already true,
1432
- // and nothing re-measures until the next viewport transition. So one
1433
- // corrective announce, through the path the viewport settle uses.
1434
- // sendResize deduplicates, so a font that never lands costs nothing.
1435
- void document.fonts.ready.then(() => {
1436
- // The bytes can arrive arbitrarily late, so this is the one arm of the gate
1437
- // that outlives its terminal; destroy() wins, as it does for a feature's
1438
- // un-cancelled async.
1439
- if (isDestroyed() || measurableSize() === null) {
1440
- return;
1441
- }
1442
- connection.sendResize();
1443
- });
1444
- }, onFontSettled);
1445
- } catch (err) {
1446
- console.warn(`web-terminal-ui: invalid fontReady ${fontReady}`, err);
1447
- onFontSettled();
1448
- }
1449
-
1450
- // --- Browse-cache TTL (engine docs/paged-scrollback.md §5.6) ---
1451
- //
1452
- // Paged-in history is disposable by construction: recovery is one fetch. The
1453
- // ENGINE provides the mechanism and this layer owns the clock, because the
1454
- // engine has no notion of a page or a tab. Eviction is by INACTIVITY, never
1455
- // eagerly, so that rapid scrolling in any direction stays instant.
1456
- //
1457
- // The visible-page drop is CONDITIONAL inside the store: a reader parked on
1458
- // cached rows is inactive while looking straight at them, so the store skips
1459
- // and this timer retries. A hidden page has no reader, so its drop is
1460
- // unconditional.
1461
- const BROWSE_CACHE_TTL_MS = 5 * 60_000;
1462
- /**
1463
- * Every store EXCEPT the renderer-bound one, pruning refs whose store has been
1464
- * collected. A background tab has no reader, which is what lets both callers
1465
- * below drop its cache without a position to protect.
1466
- */
1467
- const backgroundStores = (bound: LineStore): LineStore[] => {
1468
- const out: LineStore[] = [];
1469
- for (const store of liveStores()) {
1470
- if (store !== bound) {
1471
- out.push(store);
1472
- }
1473
- }
1474
- return out;
1475
- };
1476
- const browseSweep = window.setInterval(() => {
1477
- // The BOUND store is the one with a reader, so its drop is conditional and
1478
- // goes through the renderer — the only layer that knows where that reader is.
1479
- const bound = render.boundStore();
1480
- if (
1481
- render.browseCacheSize() > 0 &&
1482
- Date.now() - render.lastBrowseActivityMs() >= BROWSE_CACHE_TTL_MS
1483
- ) {
1484
- render.dropBrowseCache(document.visibilityState === "visible");
1485
- }
1486
- // Every OTHER store belongs to a background tab. Without this pass the sweep
1487
- // only ever reached the visible tab and every background tab's cache was
1488
- // immortal for the life of the page — up to the engine's whole cache budget
1489
- // per tab, on the phone this feature exists for.
1490
- for (const store of backgroundStores(bound)) {
1491
- if (
1492
- store.browseCacheSize() > 0 &&
1493
- Date.now() - store.lastBrowseActivityMs() >= BROWSE_CACHE_TTL_MS
1494
- ) {
1495
- store.dropBrowseCache(-1, false); // no reader: no position to exempt
1496
- }
1497
- }
1498
- }, 60_000);
1499
-
1500
- /**
1501
- * Drop every cache unconditionally, TTL ignored. The LAST CHANCE handler: it
1502
- * runs when the page is about to stop executing entirely, which is the one
1503
- * state the sweep above cannot cover — a frozen page runs no code at all, so
1504
- * without this its caches stay resident for as long as the freeze lasts, and a
1505
- * discard then throws them away unread. Ten to twenty megabytes (estimate), for
1506
- * an unbounded time, for nobody.
1507
- *
1508
- * Unconditional is right HERE and wrong on the return transition, and the
1509
- * difference is which way the reader is walking. `visibilitychange` to visible
1510
- * fires as a reader ARRIVES, so dropping there deletes the rows they are about
1511
- * to look at (that drop existed and was removed). `freeze` fires as the page
1512
- * stops being a running program, with no reader and none imminent — and the
1513
- * cache is disposable by construction, so the cost of being wrong is one fetch
1514
- * on a return that may never come.
1515
- */
1516
- const dropEveryBrowseCache = (): void => {
1517
- const bound = render.boundStore();
1518
- render.dropBrowseCache(false); // through the renderer: it schedules the reconcile
1519
- for (const store of backgroundStores(bound)) {
1520
- store.dropBrowseCache(-1, false);
1521
- }
1522
- };
1523
- // Two hooks for one state, because no single one covers both engines: `freeze`
1524
- // is Chrome's Page Lifecycle signal, and `pagehide` with `persisted` is entry
1525
- // into the back/forward cache — the Safari path, on the platform this feature
1526
- // is for. Either can fire without the other.
1527
- document.addEventListener(
1528
- "freeze",
1529
- () => {
1530
- // Persist BEFORE dropping. The keeper already wrote on `visibilitychange`
1531
- // to hidden, but a hidden page's socket keeps delivering, so the tail can
1532
- // have advanced since — and `freeze` is the last code that runs, so a write
1533
- // skipped here is a write that never happens. The order matters only in that
1534
- // direction: the snapshot excludes browse cache by classification, so
1535
- // flushing first costs nothing and dropping first could lose a page of live
1536
- // tail on a page that never wakes.
1537
- scrollbackKeeper?.flush();
1538
- dropEveryBrowseCache();
1539
- },
1540
- { signal },
1541
- );
1542
- signal.addEventListener("abort", () => {
1543
- clearInterval(browseSweep);
1544
- });
1545
-
1546
- // --- Reconnect-on-wake ---
1547
- document.addEventListener(
1548
- "visibilitychange",
1549
- () => {
1550
- if (document.visibilityState === "visible") {
1551
- // No cache drop on the RETURN transition. An earlier version enforced the
1552
- // TTL the throttled hidden period owed, with hidden-page semantics
1553
- // (unconditional) — which deleted the rows the returning reader was
1554
- // parked on, in the one moment they are certain to look at them. The page
1555
- // is visible again the instant this fires, so the visible-page rule
1556
- // applies, and that is exactly what the periodic sweep does within its
1557
- // next tick. There is nothing left for this branch to add.
1558
- if (connectionInitiated) {
1559
- connection.reconnectNow();
1560
- }
1561
- focusTerminal();
1562
- return;
1563
- }
1564
- // Hidden. This and pagehide are the last callbacks that reliably run
1565
- // before a discard, and a discard is the case scrollback persistence
1566
- // exists for, so write now rather than waiting for the background pass.
1567
- scrollbackKeeper?.flush();
1568
- },
1569
- { signal },
1570
- );
1571
- window.addEventListener(
1572
- "pageshow",
1573
- () => {
1574
- if (connectionInitiated) {
1575
- connection.reconnectNow();
1576
- }
1577
- focusTerminal();
1578
- },
1579
- { signal },
1580
- );
1581
- window.addEventListener(
1582
- "pagehide",
1583
- (event) => {
1584
- // Both halves of the last-chance write: visibilitychange fires when the
1585
- // page is backgrounded, pagehide when it is unloaded or frozen into
1586
- // bfcache. Chrome's page-lifecycle guidance is explicit that pagehide is
1587
- // not guaranteed to run, which is why the keeper also saves on a timer.
1588
- scrollbackKeeper?.flush();
1589
- if (event.persisted) {
1590
- // Into the back/forward cache: the page is frozen, so this is the last
1591
- // code that runs until it is restored — if it ever is.
1592
- dropEveryBrowseCache();
1593
- }
1594
- },
1595
- { signal },
1596
- );
1597
- window.addEventListener(
1598
- "online",
1599
- () => {
1600
- if (connectionInitiated) {
1601
- connection.reconnectNow();
1602
- }
1603
- },
1604
- { signal },
1605
- );
1606
-
1607
- // --- Feature context ---
1608
- const subscriptions: Unsubscribe[] = [];
1609
- const apiMap = new Map<TerminalFeature<unknown>, unknown>();
1610
- const instances: { feature: TerminalFeature<unknown>; instance: FeatureInstance<unknown> }[] = [];
1611
-
1612
- // The one switch path: ctx.notifySwitch (tab switches) and the kernel's own
1613
- // owner-resolved first connect both land here.
1614
- function performSwitch(session: SessionRef): void {
1615
- // A feature's un-cancelled async (tabs create()/pollOnce()) can resolve
1616
- // after destroy() and request a switch; ignore it so a torn-down terminal
1617
- // never re-points or reopens the socket (connection.setSession below).
1618
- if (isDestroyed()) {
1619
- return;
1620
- }
1621
- // Detach (design 5.1): make input safe before the socket is re-pointed.
1622
- // End any in-flight IME composition and clear the textarea so
1623
- // half-composed text is not delivered to either session, and let every
1624
- // feature disarm latched input state (mobileToolbar's sticky-Ctrl) so it
1625
- // cannot fire against the incoming session. This all runs before
1626
- // setSession, and the switch is synchronous, so input is inert between
1627
- // detach here and the onSwitch attach below.
1628
- composition.cancelComposition();
1629
- resetToPlaceholder(input);
1630
- // A held mouse gesture is latched input too, and the mouse module is per
1631
- // TERMINAL while sessions multiplex over it: a record kept across the switch
1632
- // would let the incoming session's first open synthesize a release for a
1633
- // press its application never saw.
1634
- mouse.disarmGesture();
1635
- for (const { instance } of instances) {
1636
- instance.onDetach?.();
1637
- }
1638
- activeSession = session;
1639
- // Reconnect the terminal WS to this session using its per-tab resume
1640
- // state; the renderer was already pointed at its store by the owner.
1641
- connection.setSession(session.id);
1642
- // setSession restores the incoming session's mode mirror synchronously and
1643
- // delivers no modes frame, so nothing else re-derives the pointer here.
1644
- updateMousePointer();
1645
- // The owned first connect has happened (session id is now on the WS URL);
1646
- // wake-reconnect handlers may fire from here on.
1647
- connectionInitiated = true;
1648
- for (const { instance } of instances) {
1649
- if (instance.onSwitch) {
1650
- instance.onSwitch(session);
1651
- }
1652
- }
1653
- bus.emit("session:switch", session);
1654
- }
1655
-
1656
- function makeContext(featureName: string): TerminalContext {
1657
- return {
1658
- region: (name, slot) => regions.region(name, slot),
1659
- surface: () => termWrap,
1660
- send: sendBytes,
1661
- paste,
1662
- registerInputTransform(fn) {
1663
- inputTransforms.push(fn);
1664
- return () => {
1665
- const i = inputTransforms.indexOf(fn);
1666
- if (i >= 0) {
1667
- inputTransforms.splice(i, 1);
1668
- }
1669
- };
1670
- },
1671
- registerInputObserver(fn) {
1672
- inputObservers.push(fn);
1673
- return () => {
1674
- const i = inputObservers.indexOf(fn);
1675
- if (i >= 0) {
1676
- inputObservers.splice(i, 1);
1677
- }
1678
- };
1679
- },
1680
- registerKeydown(fn) {
1681
- keydownHandlers.push(fn);
1682
- return () => {
1683
- const i = keydownHandlers.indexOf(fn);
1684
- if (i >= 0) {
1685
- keydownHandlers.splice(i, 1);
1686
- }
1687
- };
1688
- },
1689
- render,
1690
- scroll,
1691
- session: {
1692
- get id() {
1693
- return activeSession?.id ?? null;
1694
- },
1695
- size: () => render.computeSize(),
1696
- highestIndex: () => render.getHighestIndex(),
1697
- },
1698
- on(e, fn) {
1699
- // Wrap so a throwing feature handler is isolated and attributed.
1700
- const wrapped = (p: Parameters<typeof fn>[0]): void => {
1701
- try {
1702
- fn(p);
1703
- } catch (err) {
1704
- reportError(featureName, err);
1705
- }
1706
- };
1707
- const off = bus.on(e, wrapped);
1708
- subscriptions.push(off);
1709
- return off;
1710
- },
1711
- use<A>(feature: TerminalFeature<A>): A | undefined {
1712
- return apiMap.get(feature) as A | undefined;
1713
- },
1714
- toast,
1715
- announce: (message, politeness) => {
1716
- announcer.announce(message, politeness);
1717
- },
1718
- loadingReason: (message) => {
1719
- // No overlayDismissed guard needed: loadingStatus.stop() runs in
1720
- // dismissLoadingOverlay and every method is inert afterwards, so the
1721
- // "a late retry cannot resurrect the overlay" contract holds in ONE
1722
- // place rather than at each caller.
1723
- loadingStatus.reason(message);
1724
- },
1725
- titlePrefix: (text) => {
1726
- titlePrefixText = text;
1727
- paintTitle();
1728
- },
1729
- tablist: () => tablistController,
1730
- newLineStore: (sessionId) => {
1731
- // Registered on EVERY path, because this factory is the only way a store
1732
- // enters the app and the browse-cache sweep has to be able to reach a
1733
- // background tab's store (the renderer only ever sees the bound one).
1734
- const track = (store: LineStore, key: string): LineStore => {
1735
- knownStores.set(key, new WeakRef(store));
1736
- return store;
1737
- };
1738
- if (scrollbackKeeper !== null) {
1739
- if (sessionId !== undefined) {
1740
- // Hydrated from storage when a usable entry exists, and tracked for
1741
- // saving either way. Necessarily before this session connects: the
1742
- // caller is building the tab, and the kernel's switch (which opens
1743
- // the socket) happens afterwards.
1744
- const store = scrollbackKeeper.storeFor(sessionId);
1745
- if (store.highestIndex() >= 0) {
1746
- unverifiedRestores.add(sessionId);
1747
- }
1748
- return track(store, sessionId);
1749
- }
1750
- scrollbackKeeper.noteMissingSessionId();
1751
- }
1752
- return track(new LineStore(scrollbackLines), sessionId ?? IMPLICIT_STORE_KEY);
1753
- },
1754
- layout: () => ({
1755
- narrow: isNarrow(),
1756
- coarse:
1757
- typeof window.matchMedia === "function" && window.matchMedia("(pointer: coarse)").matches,
1758
- }),
1759
- notifySwitch(session) {
1760
- performSwitch(session);
1761
- },
1762
- dropSession(id) {
1763
- connection.forgetSession(id);
1764
- // A closed tab's scrollback should not outlive it. This is also the only
1765
- // collection path that runs promptly; the keeper's age bound covers the
1766
- // case this one cannot see, which is a tab the browser discarded without
1767
- // ever running a close.
1768
- scrollbackKeeper?.forget(id);
1769
- },
1770
- onError(fn) {
1771
- errorHandlers.add(fn);
1772
- return () => errorHandlers.delete(fn);
1773
- },
1774
- };
1775
- }
1776
-
1777
- // --- Feature lifecycle ---
1778
- // `destroyed` means the terminal runtime is no longer usable, whether that
1779
- // happened through the public destroy() handle or a fatal startup rollback.
1780
- // `rootReleased` is narrower: destroy() has also removed the recovery surface
1781
- // and boundary classes. Keeping the two facts separate lets a fatal terminal
1782
- // remain visible and still be explicitly destroyed later.
1783
- let destroyed = false;
1784
- let rootReleased = false;
1785
- let runtimeCleaned = false;
1786
- // Live read of `destroyed` for post-await re-checks: a plain variable read is
1787
- // narrowed to always-false by TS CFA because it cannot model destroy() firing
1788
- // while feature setup or session resolution is awaiting.
1789
- const isDestroyed = (): boolean => destroyed;
1790
-
1791
- function teardownFeatures(): void {
1792
- for (let i = instances.length - 1; i >= 0; i--) {
1793
- const entry = instances[i];
1794
- if (!entry) {
1795
- continue;
1796
- }
1797
- try {
1798
- entry.instance.teardown();
1799
- } catch (err) {
1800
- reportError(entry.feature.name, err);
1801
- }
1802
- }
1803
- instances.length = 0;
1804
- }
1805
-
1806
- // Release the complete live runtime while leaving the root boundary classes
1807
- // intact. Fatal startup uses the root for its recovery surface; destroy()
1808
- // follows this cleanup by removing the classes and any recovery UI.
1809
- function cleanupRuntime(): void {
1810
- if (runtimeCleaned) {
1811
- return;
1812
- }
1813
- runtimeCleaned = true;
1814
- // Before anything is torn down: a deliberate teardown (a host closing an
1815
- // embedded panel, a reload path calling destroy()) is still a page the user
1816
- // may come back to, and the tracked stores are about to become unreachable.
1817
- scrollbackKeeper?.flush();
1818
- scrollbackKeeper?.stop();
1819
- teardownFeatures();
1820
- // A destroyed terminal must not leave its attention prefix on the page: the
1821
- // tab it counted is gone, and the title outlives this runtime (an embedded
1822
- // panel closing, a reload path). The base is left alone -- it belongs to the
1823
- // document, not to us.
1824
- titlePrefixText = "";
1825
- paintTitle();
1826
- kernelAbort.abort();
1827
- narrowObserver?.disconnect();
1828
- narrowObserver = null;
1829
- viewport.teardown();
1830
- // Reset the composition singleton: a failure during an IME cycle must not
1831
- // leave module state or a deferred send alive for a later terminal mount.
1832
- composition.teardown();
1833
- disposeMouse();
1834
- connection.disconnect();
1835
- connState.destroy();
1836
- if (toastTimer !== null) {
1837
- clearTimeout(toastTimer);
1838
- toastTimer = null;
1839
- }
1840
- if (focusResolve !== null) {
1841
- clearTimeout(focusResolve);
1842
- focusResolve = null;
1843
- }
1844
- for (const off of subscriptions) {
1845
- off();
1846
- }
1847
- subscriptions.length = 0;
1848
- inputTransforms.length = 0;
1849
- inputObservers.length = 0;
1850
- keydownHandlers.length = 0;
1851
- errorHandlers.clear();
1852
- apiMap.clear();
1853
- activeSession = null;
1854
- bus.clear();
1855
- announcer.destroy();
1856
- regions.destroy();
1857
- root.classList.remove("wt-narrow");
1858
- root.replaceChildren();
1859
- }
1860
-
1861
- function renderFatalStartup(): void {
1862
- renderFatalStartupInto(root, layoutMode);
1863
- }
1864
-
1865
- function enterFatalStartup(feature: string, cause: unknown): void {
1866
- if (destroyed) {
1867
- return;
1868
- }
1869
- destroyed = true;
1870
- cleanupRuntime();
1871
- dismissLoadingOverlay();
1872
-
1873
- let handled = false;
1874
- try {
1875
- handled =
1876
- opts.onFatalError?.({ phase: "feature-setup", feature, cause, surface: root }) === true;
1877
- } catch (handlerErr) {
1878
- console.error("web-terminal-ui: onFatalError handler failed", handlerErr);
1879
- }
1880
- // A handler may call destroy() while taking over, so do not recreate the
1881
- // default surface after the root has explicitly been released.
1882
- if (!handled && !rootReleased) {
1883
- renderFatalStartup();
1884
- }
1885
- }
1886
-
1887
- type FeatureSetupOutcome =
1888
- | { readonly status: "ready" }
1889
- | { readonly status: "aborted" }
1890
- | {
1891
- readonly status: "failed";
1892
- readonly feature: string;
1893
- readonly cause: unknown;
1894
- };
1895
-
1896
- // Distinguish intentional cancellation from a fatal feature failure. A
1897
- // boolean collapsed both into the same "not ready" outcome, which is why a
1898
- // failed setup previously left a half-live terminal and only wrote a log.
1899
- async function setupFeatures(): Promise<FeatureSetupOutcome> {
1900
- for (const feature of featureList) {
1901
- if (destroyed) {
1902
- return { status: "aborted" };
1903
- }
1904
- try {
1905
- const instance = await feature.setup(makeContext(feature.name));
1906
- // destroy() may have run during the await above; cleanupRuntime() has
1907
- // already swept `instances`, and destroy() is one-shot, so a straight
1908
- // push here would leave this instance's listeners/timers/observers alive
1909
- // forever. Tear it down instead of registering it.
1910
- if (isDestroyed()) {
1911
- try {
1912
- instance.teardown();
1913
- } catch (err) {
1914
- reportError(feature.name, err);
1915
- }
1916
- return { status: "aborted" };
1917
- }
1918
- instances.push({ feature, instance });
1919
- // Populate the feature value's readonly api (consumer pattern:
1920
- // tabs.api?.create()) via a narrow cast, and the ctx.use lookup map.
1921
- (feature as { api?: unknown }).api = instance.api;
1922
- apiMap.set(feature, instance.api);
1923
- } catch (cause) {
1924
- if (isDestroyed()) {
1925
- return { status: "aborted" };
1926
- }
1927
- reportError(feature.name, cause);
1928
- console.error(`web-terminal-ui: feature "${feature.name}" setup failed`, cause);
1929
- if (isDestroyed()) {
1930
- return { status: "aborted" };
1931
- }
1932
- return { status: "failed", feature: feature.name, cause };
1933
- }
1934
- }
1935
- return { status: "ready" };
1936
- }
1937
-
1938
- // Features set up in the background; first paint is never gated on them.
1939
- // Under a session owner the kernel then drives the first connect itself:
1940
- // await the owner's resolveInitialSession() and switch to the session it
1941
- // returns through the same path a tab switch uses. Connecting to the bare
1942
- // wsPath first would open a /ws that a SessionManager 404s (no ?session=),
1943
- // flashing a disconnect banner and churning the reconnect backoff — hence no
1944
- // startup connect below when an owner is registered. A null (or thrown)
1945
- // resolution means the bootstrap failed: the owner keeps its retry chrome
1946
- // alive, and the kernel — which now SEES the failure instead of inferring it
1947
- // from a missing side effect — dismisses the loading overlay so that chrome
1948
- // is visible instead of an eternal spinner.
1949
- void setupFeatures().then(async (outcome) => {
1950
- if (outcome.status === "failed") {
1951
- enterFatalStartup(outcome.feature, outcome.cause);
1952
- return;
1953
- }
1954
- if (outcome.status === "aborted") {
1955
- return;
1956
- }
1957
- if (!sessionOwner || isDestroyed()) {
1958
- return;
1959
- }
1960
- let resolved: SessionRef | null = null;
1961
- try {
1962
- resolved = await sessionOwner.resolveInitialSession();
1963
- } catch (err) {
1964
- reportError(sessionOwnerName, err);
1965
- }
1966
- if (isDestroyed()) {
1967
- return;
1968
- }
1969
- if (resolved) {
1970
- performSwitch(resolved);
1971
- } else if (!connectionInitiated) {
1972
- dismissLoadingOverlay();
1973
- }
1974
- });
1975
-
1976
- // --- Connect + focus ---
1977
- render.updateFontMetrics();
1978
- composition.positionCompositionView();
1979
- if (!sessionOwner) {
1980
- // A single unmanaged terminal has no feature to own its store, so the kernel
1981
- // hydrates the renderer's implicit one here — before connect(), because the
1982
- // resume announces what this client already holds (Callbacks.getHaveThrough)
1983
- // and a store hydrated afterwards has already asked for everything.
1984
- //
1985
- // The session id comes from the engine rather than from this library: an
1986
- // unmanaged terminal's identity is a per-tab, sessionStorage-backed id minted
1987
- // inside the connection layer, with exactly the semantics a persistence key
1988
- // needs (stable across a reload and an iOS tab restore, fresh in a genuinely
1989
- // new tab). Under a session owner the same work happens per session, through
1990
- // ctx.newLineStore(sessionId).
1991
- //
1992
- // Note what is deliberately NOT done: restored content does not dismiss the
1993
- // loading overlay. It is last session's output until the resume confirms it,
1994
- // and showing it as live is the same mistake the persisted epoch exists to
1995
- // prevent. The overlay still lifts on the first real frame — which now
1996
- // arrives sooner, because the replay behind it is a delta instead of a
1997
- // refill.
1998
- if (scrollbackKeeper !== null) {
1999
- const sessionId = connection.currentSessionId();
2000
- const restored = scrollbackKeeper.storeFor(sessionId);
2001
- if (restored.highestIndex() >= 0) {
2002
- render.bind(restored);
2003
- // Registered under the SAME id the resumeAck will report, so the guard
2004
- // can both verify and discard it. This store does not come through
2005
- // `newLineStore` — an unmanaged terminal has no feature to own it.
2006
- knownStores.set(sessionId, new WeakRef(restored));
2007
- unverifiedRestores.add(sessionId);
2008
- } else {
2009
- // Nothing usable was stored. Keep the renderer's own store and track
2010
- // that one instead of swapping in an empty replacement, so this path
2011
- // changes nothing about a terminal with no snapshot to restore.
2012
- scrollbackKeeper.track(sessionId, render.boundStore());
2013
- }
2014
- }
2015
- connection.connect();
2016
- connectionInitiated = true;
2017
- }
2018
- focusTerminal();
2019
-
2020
- return {
2021
- focus: focusTerminal,
2022
- send(bytes) {
2023
- if (destroyed) {
2024
- return;
2025
- }
2026
- sendBytes(bytes);
2027
- },
2028
- reset() {
2029
- if (destroyed) {
2030
- return;
2031
- }
2032
- render.resetScrollback();
2033
- render.resetScreen();
2034
- },
2035
- reattach() {
2036
- if (destroyed) {
2037
- return;
2038
- }
2039
- // The old session's content goes first: it belongs to a process that is
2040
- // gone, and holding it would also put a `haveThrough` on the resume that
2041
- // claims lines above anything the replacement has committed.
2042
- render.resetScrollback();
2043
- render.resetScreen();
2044
- // Off `ended`, or the banner would keep saying "Session ended" over a
2045
- // screen this call just blanked. `reconnecting` carries a grace delay, so a
2046
- // connect that lands promptly shows no banner at all and a slow one reads
2047
- // as what it is.
2048
- connState.reconnecting();
2049
- connection.reconnectNow();
2050
- },
2051
- destroy() {
2052
- if (rootReleased) {
2053
- return;
2054
- }
2055
- rootReleased = true;
2056
- destroyed = true;
2057
- cleanupRuntime();
2058
- root.classList.remove("wt-root", "wt-viewport", "wt-container", "wt-narrow");
2059
- // cleanupRuntime() removed the live subtree. A fatal handler may have
2060
- // rendered replacement UI afterward, so destroy() clears once more.
2061
- root.replaceChildren();
2062
- },
2063
- };
2064
- }
1
+ import { fadeOutOverlay, renderFatalStartupInto } from "./fatal.js";
2
+ import { createShell, hasLiveShell } from "./shell.js";
3
+ import type { CreateTerminalOptions, TerminalHandle } from "./types.js";
2065
4
 
2066
5
  /** Resolve the mount target. A selector is looked up here, INSIDE
2067
- * createTerminal's try, which is the whole point: a consumer that passed
2068
- * `document.getElementById("terminal")` had to null-check it first (tsc forces
2069
- * that), and the only thing it could do in the null branch was hand-build a
2070
- * startup-failure surface this library already owns. Taking the selector moves
2071
- * that failure inside the boundary and deletes the consumer's branch.
2072
- *
2073
- * An element is still accepted, because an embedder that CREATED the element
2074
- * already holds a non-null reference and asking it to invent a selector for its
2075
- * own div would be worse. The trap was never "passing an element", it was
2076
- * "passing the result of a lookup". */
6
+ * createTerminal's try, so a missing element is a startup failure this library
7
+ * reports rather than a null-check every consumer restates. An element is still
8
+ * accepted, because an embedder that CREATED the element already holds it. */
2077
9
  function resolveRoot(target: HTMLElement | string): HTMLElement {
2078
10
  if (typeof target !== "string") {
2079
11
  return target;
@@ -2083,35 +15,16 @@ function resolveRoot(target: HTMLElement | string): HTMLElement {
2083
15
  throw new Error(`web-terminal-ui: no element matches the mount selector ${target}`);
2084
16
  }
2085
17
  if (!(found instanceof HTMLElement)) {
2086
- // An SVG or MathML element can match a selector but cannot host the terminal
2087
- // (no style/classList contract this kernel relies on). Fail with the reason
2088
- // rather than crashing later on a missing property.
18
+ // An SVG or MathML element can match a selector but has no style/classList
19
+ // contract to host the terminal.
2089
20
  throw new Error(`web-terminal-ui: the mount selector ${target} matched a non-HTML element`);
2090
21
  }
2091
22
  return found;
2092
23
  }
2093
24
 
2094
- /** Build the feature list. `features` is a FUNCTION so a preset that throws does
2095
- * so inside createTerminal's try. As an eagerly-evaluated array argument
2096
- * (`features: presetTabbed()`) the throw happened at the CALL SITE, before this
2097
- * function's boundary existed, which is why a consumer had to wrap the preset
2098
- * call in its own try/catch and render its own surface. One character of
2099
- * laziness moves that failure inside. */
2100
- function resolveFeatures(
2101
- features: CreateTerminalOptions["features"],
2102
- ): readonly TerminalFeature<unknown>[] {
2103
- return features === undefined ? [] : features();
2104
- }
2105
-
2106
- /** Create the host element the recovery surface uses when the mount target could
2107
- * not be resolved at all.
2108
- *
2109
- * It is a NEW element appended to the body, never the body itself: .wt-root
2110
- * carries the token defaults, box-sizing, color-scheme and scrollbar treatment,
2111
- * and .wt-viewport carries `position: fixed; inset: 0` — stamping those on a
2112
- * host application's own body would reformat a page that is otherwise perfectly
2113
- * healthy. A dedicated element gets the styling boundary the surface needs
2114
- * without touching anything the consumer owns. */
25
+ /** The host for the recovery surface when the mount target could not be
26
+ * resolved: a NEW element, never the body, whose styling a .wt-root would
27
+ * reformat. */
2115
28
  function createFallbackHost(): HTMLElement {
2116
29
  const host = document.createElement("div");
2117
30
  host.className = "wt-root wt-viewport";
@@ -2119,61 +32,66 @@ function createFallbackHost(): HTMLElement {
2119
32
  return host;
2120
33
  }
2121
34
 
2122
- /** Build the terminal UI inside `target`. Call exactly once. Wraps buildTerminal so
2123
- * a SYNCHRONOUS startup failure gets the same recovery surface an async one does
2124
- * and still propagates; that surface is modal in viewport layout, so
2125
- * `onFatalError` returning `true` is the path that leaves the page interactive. */
35
+ function reportFatal(
36
+ opts: CreateTerminalOptions,
37
+ cause: unknown,
38
+ surface: HTMLElement | undefined,
39
+ ): boolean {
40
+ try {
41
+ return opts.onFatalError?.({ phase: "kernel-init", cause, surface }) === true;
42
+ } catch (handlerErr) {
43
+ // A reporting failure must not leave the page blank.
44
+ console.error("web-terminal-ui: onFatalError handler failed", handlerErr);
45
+ return false;
46
+ }
47
+ }
48
+
49
+ /** Build the terminal UI inside `target`. Call at most once per document while
50
+ * the previous terminal is alive: a second call reports `kernel-init` with no
51
+ * surface and throws, touching nothing. A synchronous startup failure renders
52
+ * the recovery surface and still propagates; that surface is modal in viewport
53
+ * layout, so `onFatalError` returning `true` is the path that leaves the page
54
+ * interactive. */
2126
55
  export function createTerminal(
2127
56
  target: HTMLElement | string,
2128
57
  opts: CreateTerminalOptions = {},
2129
58
  ): TerminalHandle {
2130
59
  const layoutMode = opts.layout ?? "viewport";
2131
- // Declared outside the try so the catch can tell "we never resolved a root"
2132
- // from "we had one and the build failed". The old code read the root straight
2133
- // from its parameter, so a caller who passed a null root (reachable from any
2134
- // untyped call site — an inline <script type="module"> never sees tsc) threw a
2135
- // SECOND error out of the catch itself, losing the real cause and rendering no
2136
- // surface at all. There is no null root to pass now, and the unresolved case
2137
- // has an explicit branch below.
60
+ // Declared outside the try so the catch can tell "never resolved a root" from
61
+ // "had one and the build failed".
2138
62
  let root: HTMLElement | undefined;
63
+ let refused = false;
2139
64
  try {
2140
65
  root = resolveRoot(target);
2141
- return buildTerminal(root, opts, resolveFeatures(opts.features));
66
+ // A second terminal must not draw on a root the first one may own, so this
67
+ // failure has no surface and no overlay fade.
68
+ if (hasLiveShell(root.ownerDocument)) {
69
+ refused = true;
70
+ const cause = new Error(
71
+ "web-terminal-ui: a terminal already exists in this document; destroy() it first, or use the split option for a second pane",
72
+ );
73
+ reportFatal(opts, cause, undefined);
74
+ throw cause;
75
+ }
76
+ return createShell(root, opts);
2142
77
  } catch (cause) {
2143
- // Container mode declines the fallback on purpose. An embedded terminal is
2144
- // one panel inside someone else's working application; if its mount target
2145
- // is missing, claiming the viewport to say so would break a page that is
2146
- // otherwise fine — strictly worse than the host app reporting the failure in
2147
- // the space it owns. The failure is still delivered and still rethrown, so an
2148
- // embedder is informed, just not overruled. A full-page consumer is the
2149
- // opposite case: the page IS the terminal, so a blank page with nothing but a
2150
- // console error is the worst outcome available.
78
+ if (refused) {
79
+ throw cause;
80
+ }
81
+ // Container mode declines the fallback host on purpose: an embedded terminal
82
+ // is one panel in someone else's working application, and claiming the
83
+ // viewport to say its mount target is missing would break a page that is
84
+ // otherwise fine. The failure is still delivered and rethrown.
2151
85
  const surface = root ?? (layoutMode === "viewport" ? createFallbackHost() : undefined);
2152
86
  if (surface !== undefined) {
2153
- // Stamp the boundary classes before rendering. buildTerminal normally does
2154
- // this early, but a throw can precede it (the multiple-session-owner guard
2155
- // fires before any DOM work by design), and every .wt-fatal rule is scoped
2156
- // :where(.wt-root) -- so without this the recovery surface would render
2157
- // completely unstyled exactly when it matters most. Idempotent when
2158
- // buildTerminal already stamped them, and when createFallbackHost did.
87
+ // Every .wt-fatal rule is scoped :where(.wt-root), and a throw can precede
88
+ // the stamp (the owner guard fires before any DOM work).
2159
89
  surface.classList.add("wt-root", layoutMode === "container" ? "wt-container" : "wt-viewport");
2160
90
  }
2161
- // The overlay must come down even though nothing was built: it is the
2162
- // consumer's pre-JS spinner, and leaving it up hides the surface below it.
2163
- // This runs even when no surface is rendered — a spinner over an embedder's
2164
- // broken panel is a lie either way.
91
+ // The consumer's pre-JS spinner must come down even though nothing was built.
2165
92
  fadeOutOverlay(opts.loading);
2166
-
2167
- let handled = false;
2168
- try {
2169
- handled = opts.onFatalError?.({ phase: "kernel-init", cause, surface }) === true;
2170
- } catch (handlerErr) {
2171
- // Same posture as the async phase: a reporting failure must not leave the
2172
- // page blank, so fall through to the built-in surface.
2173
- console.error("web-terminal-ui: onFatalError handler failed", handlerErr);
2174
- }
2175
- if (!handled && surface !== undefined) {
2176
- renderFatalStartupInto(surface, layoutMode);
93
+ if (!reportFatal(opts, cause, surface) && surface !== undefined) {
94
+ renderFatalStartupInto(surface, { modal: layoutMode === "viewport" });
2177
95
  }
2178
96
  throw cause;
2179
97
  }