@cplieger/web-terminal-ui 2.1.3 → 3.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.
@@ -0,0 +1,688 @@
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
+ // Mouse tracking (engine `mouse` module) is intentionally NOT wired here yet:
17
+ // the design adds it (section 22.3), but wiring it also brings DEC 1004 focus
18
+ // emission, which must be suppressed under the keep-unfocused model (section
19
+ // 7.2). Not wiring it keeps the client emitting no focus bytes (the safe
20
+ // default the previous UI already had); adding mouse tracking with focus
21
+ // suppression is a tracked follow-up.
22
+
23
+ import { render, scroll, connection, keyboard, modes } from "@cplieger/web-terminal-engine";
24
+ import * as composition from "../composition.js";
25
+ import * as viewport from "../viewport.js";
26
+ import { INPUT_PLACEHOLDER, resetToPlaceholder } from "../input-placeholder.js";
27
+ import { createBus } from "./bus.js";
28
+ import { createRegions } from "./regions.js";
29
+ import { createAnnouncer, createTablist } from "./a11y.js";
30
+ import { createConnState } from "./conn-state.js";
31
+ import type {
32
+ CreateTerminalOptions,
33
+ FeatureInstance,
34
+ SessionRef,
35
+ TerminalContext,
36
+ TerminalFeature,
37
+ TerminalHandle,
38
+ Unsubscribe,
39
+ } from "./types.js";
40
+
41
+ const { mapKeyboardEvent, bracketTextForPaste, prepareTextForTerminal } = keyboard;
42
+
43
+ const DEFAULT_WS_PATH = "/ws";
44
+ const DEFAULT_FONT_READY = '14px "MonaspiceNe NFM"';
45
+ const TOAST_MS = 3000;
46
+ const TAP_MOVEMENT_PX = 10;
47
+
48
+ // Kernel-owned core subtree: the display-only output, the hidden textarea (the
49
+ // single keyboard target), and the IME composition view. No chrome; features
50
+ // build that into regions. Static, trusted markup parsed once via a template.
51
+ const CORE_TEMPLATE = `
52
+ <div class="term">
53
+ <div class="term-output" role="log" aria-live="off" aria-roledescription="Terminal" aria-label="Terminal"></div>
54
+ <textarea class="term-input" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false" aria-label="Terminal input" tabindex="-1"></textarea>
55
+ <div class="composition-view" aria-hidden="true"></div>
56
+ </div>`;
57
+
58
+ function pick(root: ParentNode, selector: string): HTMLElement {
59
+ const el = root.querySelector<HTMLElement>(selector);
60
+ if (!el) {
61
+ throw new Error(`web-terminal-ui: createTerminal failed to build ${selector}`);
62
+ }
63
+ return el;
64
+ }
65
+
66
+ export function createTerminal(
67
+ root: HTMLElement,
68
+ opts: CreateTerminalOptions = {},
69
+ ): TerminalHandle {
70
+ const wsPath = opts.wsPath ?? DEFAULT_WS_PATH;
71
+ const fontReady = opts.fontReady ?? DEFAULT_FONT_READY;
72
+ const featureList = opts.features ?? [];
73
+ const encoder = new TextEncoder();
74
+ const kernelAbort = new AbortController();
75
+ const { signal } = kernelAbort;
76
+
77
+ // --- Build the core subtree ---
78
+ const tpl = document.createElement("template");
79
+ tpl.innerHTML = CORE_TEMPLATE;
80
+ root.replaceChildren(tpl.content);
81
+ const termWrap = pick(root, ".term");
82
+ const outputEl = pick(root, ".term-output");
83
+ const input = pick(root, ".term-input") as HTMLTextAreaElement;
84
+ const compositionViewEl = pick(root, ".composition-view");
85
+
86
+ // --- Regions + shared primitives ---
87
+ const regions = createRegions(root);
88
+ const bus = createBus();
89
+ const announcer = createAnnouncer(root);
90
+ const tablistController = createTablist(outputEl);
91
+
92
+ // Toast surface (kernel-owned shared primitive, section 22.3).
93
+ const toastEl = document.createElement("div");
94
+ toastEl.className = "wt-toast";
95
+ toastEl.setAttribute("role", "status");
96
+ regions.region("banner", "toast").appendChild(toastEl);
97
+ let toastTimer: ReturnType<typeof setTimeout> | null = null;
98
+ function toast(message: string, ms = TOAST_MS): void {
99
+ toastEl.textContent = message;
100
+ toastEl.classList.add("visible");
101
+ if (toastTimer !== null) {
102
+ clearTimeout(toastTimer);
103
+ }
104
+ toastTimer = setTimeout(() => {
105
+ toastTimer = null;
106
+ toastEl.classList.remove("visible");
107
+ toastEl.textContent = "";
108
+ }, ms);
109
+ }
110
+
111
+ // --- Feature error routing ---
112
+ const errorHandlers = new Set<(feature: string, err: unknown) => void>();
113
+ function reportError(feature: string, err: unknown): void {
114
+ if (errorHandlers.size === 0) {
115
+ console.error(`web-terminal-ui: feature "${feature}" error`, err);
116
+ return;
117
+ }
118
+ for (const fn of [...errorHandlers]) {
119
+ fn(feature, err);
120
+ }
121
+ }
122
+
123
+ // --- Input funnel (the single sanitizing, session-routed send path) ---
124
+ const inputTransforms: ((b: Uint8Array) => Uint8Array)[] = [];
125
+ const inputObservers: ((b: Uint8Array) => void)[] = [];
126
+
127
+ function sendBytes(bytes: Uint8Array): void {
128
+ let out = bytes;
129
+ for (const t of inputTransforms) {
130
+ out = t(out);
131
+ if (out.length === 0) {
132
+ return; // a transform dropped it (e.g. the col-0 backspace brake)
133
+ }
134
+ }
135
+ // sendBinary buffers while disconnected (resume layer) and returns false
136
+ // only when the outbox is full (already surfaced via onOutboxFull). Only
137
+ // notify observers on accepted input, so predictive echo never paints a
138
+ // phantom char that never reached the server.
139
+ if (!connection.sendBinary(out)) {
140
+ return;
141
+ }
142
+ for (const obs of inputObservers) {
143
+ obs(out);
144
+ }
145
+ }
146
+ function sendText(text: string): void {
147
+ sendBytes(encoder.encode(text));
148
+ }
149
+ function paste(text: string): void {
150
+ // The one bracketed-paste + newline-normalize funnel; every feature and the
151
+ // kernel's own paste paths route through here (paste-jacking defense).
152
+ sendText(bracketTextForPaste(prepareTextForTerminal(text)));
153
+ }
154
+
155
+ // --- Keydown intercept chain (clipboard shortcuts, contextMenu Escape) ---
156
+ const keydownHandlers: ((ev: KeyboardEvent) => boolean)[] = [];
157
+
158
+ // --- Active session (SessionView + onSwitch fan-out) ---
159
+ let activeSession: SessionRef | null = null;
160
+
161
+ // --- Loading lifecycle ---
162
+ let ready = false;
163
+ let firstFrameRendered = false;
164
+ let fontsLoaded = false;
165
+ let wsOpen = false;
166
+ let overlayDismissed = false;
167
+ function dismissLoadingOverlay(): void {
168
+ const ld = opts.loading;
169
+ if (!ld || overlayDismissed) {
170
+ return;
171
+ }
172
+ overlayDismissed = true;
173
+ ld.classList.add("fade");
174
+ const removeOverlay = (): void => {
175
+ ld.remove();
176
+ };
177
+ ld.addEventListener("transitionend", removeOverlay, { once: true });
178
+ window.setTimeout(removeOverlay, 1500);
179
+ }
180
+ function markReady(): void {
181
+ if (ready) {
182
+ return;
183
+ }
184
+ ready = true;
185
+ connState.setLoaded();
186
+ dismissLoadingOverlay();
187
+ }
188
+
189
+ // --- Connection-state machine ---
190
+ const connState = createConnState({
191
+ onState: (s) => {
192
+ bus.emit("connection:state", s);
193
+ },
194
+ onGiveUp: dismissLoadingOverlay,
195
+ });
196
+
197
+ // --- Engine wiring ---
198
+ render.init({
199
+ output: outputEl,
200
+ termWrap,
201
+ onCursorMove: () => {
202
+ composition.positionCompositionView();
203
+ bus.emit("render:cursor", undefined);
204
+ },
205
+ });
206
+ render.updateFontMetrics();
207
+
208
+ composition.init({
209
+ textarea: input,
210
+ compositionView: compositionViewEl,
211
+ getCursorPx: render.getCursorPx,
212
+ send: sendText,
213
+ });
214
+
215
+ scroll.init({
216
+ scrollEl: termWrap,
217
+ onUserScrollChange(scrolledUp) {
218
+ bus.emit("scroll:state", { scrolledUp });
219
+ },
220
+ });
221
+
222
+ function maybeSendFirstResize(): void {
223
+ if (!fontsLoaded || !wsOpen) {
224
+ return;
225
+ }
226
+ render.updateFontMetrics();
227
+ connection.sendResize();
228
+ }
229
+
230
+ connection.init({
231
+ computeSize: render.computeSize,
232
+ getHaveThrough: render.getHighestIndex,
233
+ onResumeBounds: render.noteResumeBounds,
234
+ wsPath,
235
+ onMessage(msg) {
236
+ if (msg.type === "screen") {
237
+ render.handleScreen(msg);
238
+ firstFrameRendered = true;
239
+ if (fontsLoaded) {
240
+ markReady();
241
+ }
242
+ bus.emit("wire:screen", msg);
243
+ } else if (msg.type === "scroll") {
244
+ render.handleScroll(msg);
245
+ } else if (msg.type === "title") {
246
+ document.title = msg.title;
247
+ bus.emit("wire:title", { session: activeSession?.id ?? "", title: msg.title });
248
+ } else if (msg.type === "modes") {
249
+ render.updateReverseVideo();
250
+ bus.emit("wire:modes", msg);
251
+ } else if (msg.type === "clipboard") {
252
+ // Inbound OSC 52. With no clipboard feature subscribed this is a no-op
253
+ // (section 22.4).
254
+ bus.emit("wire:clipboard", msg.text);
255
+ }
256
+ },
257
+ onOpen() {
258
+ connState.open();
259
+ wsOpen = true;
260
+ maybeSendFirstResize();
261
+ },
262
+ onConnecting() {
263
+ connState.reconnecting();
264
+ },
265
+ onClose() {
266
+ connState.closed();
267
+ },
268
+ onOutboxFull() {
269
+ connState.closed();
270
+ },
271
+ onServerRestart() {
272
+ render.resetScrollback();
273
+ render.resetScreen();
274
+ connState.restarted();
275
+ },
276
+ });
277
+
278
+ // --- Input handling ---
279
+ resetToPlaceholder(input);
280
+
281
+ input.addEventListener(
282
+ "input",
283
+ (e: Event) => {
284
+ if (composition.isComposing()) {
285
+ return;
286
+ }
287
+ const ev = e as InputEvent;
288
+ const inputType = ev.inputType;
289
+ if (
290
+ inputType === "deleteContentBackward" ||
291
+ inputType === "deleteContentForward" ||
292
+ inputType === "deleteWordBackward" ||
293
+ inputType === "deleteWordForward"
294
+ ) {
295
+ resetToPlaceholder(input);
296
+ return;
297
+ } else if (typeof ev.data === "string" && ev.data.length > 0) {
298
+ if (inputType === "insertFromPaste") {
299
+ paste(ev.data);
300
+ } else {
301
+ // Normalize iOS's NBSP-for-space quirk, then send through the funnel.
302
+ sendText(ev.data.replace(/\u00A0/g, " "));
303
+ }
304
+ } else {
305
+ const v = input.value;
306
+ if (v.length > INPUT_PLACEHOLDER.length && v.startsWith(INPUT_PLACEHOLDER)) {
307
+ sendText(v.slice(INPUT_PLACEHOLDER.length).replace(/\u00A0/g, " "));
308
+ } else if (v !== INPUT_PLACEHOLDER && v.length > 0) {
309
+ sendText(v.replace(/\u00A0/g, " "));
310
+ }
311
+ }
312
+ resetToPlaceholder(input);
313
+ },
314
+ { signal },
315
+ );
316
+
317
+ input.addEventListener(
318
+ "focus",
319
+ () => {
320
+ termWrap.classList.add("focus");
321
+ },
322
+ { signal },
323
+ );
324
+ input.addEventListener(
325
+ "blur",
326
+ () => {
327
+ resetToPlaceholder(input);
328
+ termWrap.classList.remove("focus");
329
+ },
330
+ { signal },
331
+ );
332
+
333
+ input.addEventListener(
334
+ "keydown",
335
+ (ev: KeyboardEvent) => {
336
+ if (composition.isComposing()) {
337
+ return;
338
+ }
339
+ // Features intercept first (clipboard Ctrl+Shift+C/V, contextMenu Escape).
340
+ for (const h of keydownHandlers) {
341
+ if (h(ev)) {
342
+ return;
343
+ }
344
+ }
345
+ const result = mapKeyboardEvent(ev, modes);
346
+ switch (result.kind) {
347
+ case "send":
348
+ ev.preventDefault();
349
+ sendText(result.bytes);
350
+ return;
351
+ case "scroll-up": {
352
+ ev.preventDefault();
353
+ const h = termWrap.clientHeight;
354
+ termWrap.scrollTop = Math.max(0, termWrap.scrollTop - h);
355
+ return;
356
+ }
357
+ case "scroll-down": {
358
+ ev.preventDefault();
359
+ const h = termWrap.clientHeight;
360
+ termWrap.scrollTop = Math.min(termWrap.scrollHeight, termWrap.scrollTop + h);
361
+ return;
362
+ }
363
+ case "ignore":
364
+ return;
365
+ }
366
+ },
367
+ { signal },
368
+ );
369
+
370
+ // --- Focus strategy (the touch focus dance; see the input-model contract) ---
371
+ let lastPointerType = "mouse";
372
+ let pointerDownX = 0;
373
+ let pointerDownY = 0;
374
+ function focusTerminal(): void {
375
+ input.focus({ preventScroll: true });
376
+ }
377
+ termWrap.addEventListener(
378
+ "pointerdown",
379
+ (e) => {
380
+ lastPointerType = e.pointerType;
381
+ pointerDownX = e.clientX;
382
+ pointerDownY = e.clientY;
383
+ },
384
+ { passive: true, signal },
385
+ );
386
+ termWrap.addEventListener(
387
+ "pointerup",
388
+ (e) => {
389
+ if (e.pointerType !== "touch") {
390
+ return;
391
+ }
392
+ if ((e.target as HTMLElement).closest(".term-link")) {
393
+ return;
394
+ }
395
+ const dx = Math.abs(e.clientX - pointerDownX);
396
+ const dy = Math.abs(e.clientY - pointerDownY);
397
+ if (dx > TAP_MOVEMENT_PX || dy > TAP_MOVEMENT_PX) {
398
+ return;
399
+ }
400
+ const sel = window.getSelection();
401
+ if (sel && sel.toString().length > 0) {
402
+ return;
403
+ }
404
+ focusTerminal();
405
+ },
406
+ { passive: true, signal },
407
+ );
408
+ termWrap.addEventListener(
409
+ "mousedown",
410
+ (e) => {
411
+ // Cancel the synthetic mousedown after a touch tap so iOS keeps the
412
+ // keyboard up (xterm.js focus-preservation pattern, scoped to touch).
413
+ if (lastPointerType === "touch") {
414
+ e.preventDefault();
415
+ }
416
+ },
417
+ { signal },
418
+ );
419
+ termWrap.addEventListener(
420
+ "click",
421
+ (e) => {
422
+ const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(".term-link");
423
+ if (link) {
424
+ e.preventDefault();
425
+ window.open(link.href, "_blank", "noopener,noreferrer");
426
+ return;
427
+ }
428
+ if (lastPointerType === "touch") {
429
+ return;
430
+ }
431
+ const sel = window.getSelection();
432
+ if (sel && sel.toString().length > 0) {
433
+ return;
434
+ }
435
+ focusTerminal();
436
+ },
437
+ { signal },
438
+ );
439
+
440
+ // --- Viewport ---
441
+ viewport.init({
442
+ termWrap,
443
+ onSettled() {
444
+ render.updateFontMetrics();
445
+ if (fontsLoaded) {
446
+ connection.sendResize();
447
+ }
448
+ composition.positionCompositionView();
449
+ },
450
+ });
451
+
452
+ // --- Fonts ---
453
+ const onFontSettled = (): void => {
454
+ fontsLoaded = true;
455
+ if (firstFrameRendered) {
456
+ markReady();
457
+ }
458
+ requestAnimationFrame(() => {
459
+ maybeSendFirstResize();
460
+ });
461
+ };
462
+ try {
463
+ void document.fonts
464
+ .load(fontReady)
465
+ .then(onFontSettled)
466
+ .catch((err: unknown) => {
467
+ console.warn(`web-terminal-ui: web font ${fontReady} failed to load`, err);
468
+ onFontSettled();
469
+ });
470
+ } catch (err) {
471
+ console.warn(`web-terminal-ui: invalid fontReady ${fontReady}`, err);
472
+ onFontSettled();
473
+ }
474
+
475
+ // --- Reconnect-on-wake ---
476
+ document.addEventListener(
477
+ "visibilitychange",
478
+ () => {
479
+ if (document.visibilityState === "visible") {
480
+ connection.reconnectNow();
481
+ focusTerminal();
482
+ }
483
+ },
484
+ { signal },
485
+ );
486
+ window.addEventListener(
487
+ "pageshow",
488
+ () => {
489
+ connection.reconnectNow();
490
+ focusTerminal();
491
+ },
492
+ { signal },
493
+ );
494
+ window.addEventListener(
495
+ "online",
496
+ () => {
497
+ connection.reconnectNow();
498
+ },
499
+ { signal },
500
+ );
501
+
502
+ // --- Feature context ---
503
+ const subscriptions: Unsubscribe[] = [];
504
+ const apiMap = new Map<TerminalFeature<unknown>, unknown>();
505
+ const instances: { feature: TerminalFeature<unknown>; instance: FeatureInstance<unknown> }[] = [];
506
+
507
+ function makeContext(featureName: string): TerminalContext {
508
+ return {
509
+ region: (name, slot) => regions.region(name, slot),
510
+ surface: () => termWrap,
511
+ send: sendBytes,
512
+ paste,
513
+ registerInputTransform(fn) {
514
+ inputTransforms.push(fn);
515
+ return () => {
516
+ const i = inputTransforms.indexOf(fn);
517
+ if (i >= 0) {
518
+ inputTransforms.splice(i, 1);
519
+ }
520
+ };
521
+ },
522
+ registerInputObserver(fn) {
523
+ inputObservers.push(fn);
524
+ return () => {
525
+ const i = inputObservers.indexOf(fn);
526
+ if (i >= 0) {
527
+ inputObservers.splice(i, 1);
528
+ }
529
+ };
530
+ },
531
+ registerKeydown(fn) {
532
+ keydownHandlers.push(fn);
533
+ return () => {
534
+ const i = keydownHandlers.indexOf(fn);
535
+ if (i >= 0) {
536
+ keydownHandlers.splice(i, 1);
537
+ }
538
+ };
539
+ },
540
+ render,
541
+ scroll,
542
+ session: {
543
+ get id() {
544
+ return activeSession?.id ?? null;
545
+ },
546
+ size: () => render.computeSize(),
547
+ highestIndex: () => render.getHighestIndex(),
548
+ },
549
+ on(e, fn) {
550
+ // Wrap so a throwing feature handler is isolated and attributed.
551
+ const wrapped = (p: Parameters<typeof fn>[0]): void => {
552
+ try {
553
+ fn(p);
554
+ } catch (err) {
555
+ reportError(featureName, err);
556
+ }
557
+ };
558
+ const off = bus.on(e, wrapped);
559
+ subscriptions.push(off);
560
+ return off;
561
+ },
562
+ use<A>(feature: TerminalFeature<A>): A | undefined {
563
+ return apiMap.get(feature) as A | undefined;
564
+ },
565
+ toast,
566
+ announce: (message, politeness) => {
567
+ announcer.announce(message, politeness);
568
+ },
569
+ tablist: () => tablistController,
570
+ notifySwitch(session) {
571
+ // Detach (design 5.1): make input safe before the socket is re-pointed.
572
+ // End any in-flight IME composition and clear the textarea so
573
+ // half-composed text is not delivered to either session, and let every
574
+ // feature disarm latched input state (mobileToolbar's sticky-Ctrl) so it
575
+ // cannot fire against the incoming session. This all runs before
576
+ // setSession, and the switch is synchronous, so input is inert between
577
+ // detach here and the onSwitch attach below.
578
+ composition.cancelComposition();
579
+ resetToPlaceholder(input);
580
+ for (const { instance } of instances) {
581
+ instance.onDetach?.();
582
+ }
583
+ activeSession = session;
584
+ // Reconnect the terminal WS to this session using its per-tab resume
585
+ // state; the renderer was already pointed at its store by tabs.
586
+ connection.setSession(session.id);
587
+ for (const { instance } of instances) {
588
+ if (instance.onSwitch) {
589
+ instance.onSwitch(session);
590
+ }
591
+ }
592
+ bus.emit("session:switch", session);
593
+ },
594
+ dropSession(id) {
595
+ connection.forgetSession(id);
596
+ },
597
+ onError(fn) {
598
+ errorHandlers.add(fn);
599
+ return () => errorHandlers.delete(fn);
600
+ },
601
+ };
602
+ }
603
+
604
+ // --- Feature lifecycle ---
605
+ let destroyed = false;
606
+ function teardownFeatures(): void {
607
+ for (let i = instances.length - 1; i >= 0; i--) {
608
+ const entry = instances[i];
609
+ if (!entry) {
610
+ continue;
611
+ }
612
+ try {
613
+ entry.instance.teardown();
614
+ } catch (err) {
615
+ reportError(entry.feature.name, err);
616
+ }
617
+ }
618
+ instances.length = 0;
619
+ }
620
+
621
+ async function setupFeatures(): Promise<void> {
622
+ for (const feature of featureList) {
623
+ if (destroyed) {
624
+ return;
625
+ }
626
+ try {
627
+ const instance = await feature.setup(makeContext(feature.name));
628
+ instances.push({ feature, instance });
629
+ // Populate the feature value's readonly api (consumer pattern:
630
+ // tabs.api?.create()) via a narrow cast, and the ctx.use lookup map.
631
+ (feature as { api?: unknown }).api = instance.api;
632
+ apiMap.set(feature, instance.api);
633
+ } catch (err) {
634
+ // Fail fast: roll back the already-set-up features in reverse and
635
+ // surface a composed, named error (section 22.4).
636
+ reportError(feature.name, err);
637
+ teardownFeatures();
638
+ console.error(
639
+ `web-terminal-ui: feature "${feature.name}" setup failed; terminal has no chrome`,
640
+ err,
641
+ );
642
+ return;
643
+ }
644
+ }
645
+ }
646
+ // Features set up in the background; first paint is never gated on them.
647
+ void setupFeatures();
648
+
649
+ // --- Connect + focus ---
650
+ render.updateFontMetrics();
651
+ composition.positionCompositionView();
652
+ // A session-managing feature (tabs) drives the first connect itself once it
653
+ // has resolved a session id (ctx.notifySwitch -> connection.setSession, which
654
+ // adds ?session=<id>). Connecting here first would open a bare /ws that a
655
+ // SessionManager 404s (no ?session=), flashing a disconnect banner and
656
+ // churning the reconnect backoff until the feature sets a session. So skip the
657
+ // startup connect when managed; the single-terminal presets leave it to us.
658
+ const sessionManaged = featureList.some((f) => f.managesSessions === true);
659
+ if (!sessionManaged) {
660
+ connection.connect();
661
+ }
662
+ focusTerminal();
663
+
664
+ return {
665
+ focus: focusTerminal,
666
+ destroy() {
667
+ if (destroyed) {
668
+ return;
669
+ }
670
+ destroyed = true;
671
+ teardownFeatures();
672
+ kernelAbort.abort();
673
+ connection.disconnect();
674
+ connState.destroy();
675
+ if (toastTimer !== null) {
676
+ clearTimeout(toastTimer);
677
+ }
678
+ for (const off of subscriptions) {
679
+ off();
680
+ }
681
+ subscriptions.length = 0;
682
+ bus.clear();
683
+ announcer.destroy();
684
+ regions.destroy();
685
+ root.replaceChildren();
686
+ },
687
+ };
688
+ }