@cplieger/web-terminal-ui 1.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.
package/src/mount.ts ADDED
@@ -0,0 +1,798 @@
1
+ // @cplieger/web-terminal-ui — reference touch-first browser terminal UI.
2
+ //
3
+ // mount() wires the @cplieger/web-terminal-engine engine to a fixed DOM scaffold
4
+ // (see scaffold/index.html for the required element ids) and owns the
5
+ // touch-first input model: a display-only #term-output, a hidden <textarea>
6
+ // that owns the keyboard + IME + local typing buffer, a mobile key toolbar,
7
+ // a viewport-clamped context menu, predictive echo, and viewport/keyboard-
8
+ // inset handling. This is the full default UI extracted from vibecli; a
9
+ // consumer either mounts it as-is on the scaffold or builds its own UI on the
10
+ // engine directly.
11
+ //
12
+ // Single-instance per page: the module holds the one terminal's DOM refs and
13
+ // state, matching a terminal-per-page model. Call mount() exactly once.
14
+
15
+ import { render, keyboard, scroll, connection } from "@cplieger/web-terminal-engine";
16
+ import * as viewport from "./viewport.js";
17
+ import * as composition from "./composition.js";
18
+ import * as status from "./status.js";
19
+ import * as predict from "./predict.js";
20
+
21
+ const { mapKeyboardEvent, bracketTextForPaste, prepareTextForTerminal } = keyboard;
22
+
23
+ export interface MountOptions {
24
+ /** WebSocket endpoint path the engine connects to. Default "/ws"
25
+ * (vibekit serves the shell at "/api/shell/ws", vibecli at "/ws"). */
26
+ wsPath?: string;
27
+ /** CSS font shorthand awaited before the first resize is sent, so the
28
+ * server is sized against the real web font's cell metrics rather than a
29
+ * fallback. Default '14px "MonaspiceNe NFM"'. */
30
+ fontReady?: string;
31
+ }
32
+
33
+ export interface TerminalUI {
34
+ /** Focus the terminal input (opens the soft keyboard on touch). */
35
+ focus(): void;
36
+ }
37
+
38
+ const DEFAULT_WS_PATH = "/ws";
39
+ const DEFAULT_FONT_READY = '14px "MonaspiceNe NFM"';
40
+
41
+ // Single-character placeholder kept in the hidden textarea so iOS soft
42
+ // keyboards have something to "delete" when the user holds Backspace.
43
+ // iOS only fires repeating `input` events with
44
+ // inputType="deleteContentBackward" when the textarea has content to
45
+ // delete; with a perpetually empty textarea, holding Backspace deletes
46
+ // one char and stops because iOS sees nothing more to remove. The
47
+ // placeholder itself is invisible (textarea has opacity:0) and we strip
48
+ // it out of every send. NBSP chosen specifically so screen-reader
49
+ // announcement of the input state stays empty-ish rather than "space".
50
+ const INPUT_PLACEHOLDER = "\u00A0";
51
+
52
+ const TAP_MOVEMENT_PX = 10;
53
+ const LONG_PRESS_MS = 500;
54
+ const LONG_PRESS_MOVE_THRESHOLD_PX = 10;
55
+
56
+ const encoder = new TextEncoder();
57
+
58
+ // --- DOM refs (assigned in mount() from the scaffold) ---
59
+ let outputEl!: HTMLElement;
60
+ let termWrap!: HTMLElement;
61
+ let input!: HTMLTextAreaElement;
62
+ let compositionViewEl!: HTMLElement;
63
+ let ctxMenu!: HTMLElement;
64
+ let ctrlBtn: HTMLElement | null = null;
65
+
66
+ // --- Mutable UI state ---
67
+ let ctrlArmed = false;
68
+ let fontsLoaded = false;
69
+ let wsOpen = false;
70
+ let lastPointerType = "mouse";
71
+ let pointerDownX = 0;
72
+ let pointerDownY = 0;
73
+ let longPressTimer = 0;
74
+ let longPressOrigin = { x: 0, y: 0 };
75
+
76
+ // iOS Safari shows a system "Paste" permission toast every time
77
+ // navigator.clipboard.readText() is called — by design, and unavoidable
78
+ // from JavaScript. iOS users get a one-tap paste via the native
79
+ // long-press callout (which routes through the textarea's paste event
80
+ // handler in composition.ts without ever calling readText), so we omit
81
+ // the Paste button from our custom menu on iOS to steer them there.
82
+ let isIOS = false;
83
+
84
+ function getRequired(id: string): HTMLElement {
85
+ const el = document.getElementById(id);
86
+ if (!el) {
87
+ throw new Error(`web-terminal-ui: missing required scaffold element #${id}`);
88
+ }
89
+ return el;
90
+ }
91
+
92
+ function send(bytes: string): void {
93
+ // Suppress backspace (DEL) when the predicted cursor is at column 0
94
+ // — there's nothing left to delete. This mimics the natural brake
95
+ // that iOS's textarea provided (stops key-repeat when empty).
96
+ if (bytes === "\x7f" && predict.get().col === 0 && predict.get().active) {
97
+ return;
98
+ }
99
+ const buf = encoder.encode(bytes);
100
+ predict.applyInput(buf);
101
+ if (!connection.sendBinary(buf)) {
102
+ /* connection not ready — drop silently */
103
+ }
104
+ }
105
+
106
+ // --- Sticky Ctrl modifier ---
107
+ // The iOS virtual keyboard has no Ctrl key, so control sequences
108
+ // (Ctrl+C, Ctrl+L = clear screen, Ctrl+X, ...) are otherwise
109
+ // unreachable on touch. The toolbar's Ctrl button arms a one-shot
110
+ // modifier: tap it, then tap a letter on the virtual keyboard and that
111
+ // keystroke is sent as its C0 control byte. Auto-disarms after one
112
+ // printable character.
113
+ function setCtrlArmed(on: boolean): void {
114
+ ctrlArmed = on;
115
+ ctrlBtn?.classList.toggle("armed", on);
116
+ ctrlBtn?.setAttribute("aria-pressed", on ? "true" : "false");
117
+ }
118
+
119
+ // Map one printable character to its Ctrl+<char> C0 control byte,
120
+ // mirroring the Ctrl handling in @cplieger/web-terminal-engine's keyboard mapper.
121
+ function ctrlByteFor(ch: string): string | null {
122
+ const code = ch.toLowerCase().charCodeAt(0);
123
+ if (code >= 97 && code <= 122) {
124
+ return String.fromCharCode(code - 96); // a–z → 0x01–0x1a
125
+ }
126
+ switch (ch) {
127
+ case " ":
128
+ case "@":
129
+ return "\x00";
130
+ case "[":
131
+ return "\x1b";
132
+ case "\\":
133
+ return "\x1c";
134
+ case "]":
135
+ return "\x1d";
136
+ case "^":
137
+ return "\x1e";
138
+ case "_":
139
+ return "\x1f";
140
+ case "?":
141
+ return "\x7f";
142
+ default:
143
+ return null;
144
+ }
145
+ }
146
+
147
+ // Apply a one-shot armed Ctrl to freshly-typed text: a single printable
148
+ // character becomes its control byte; longer input (paste) just disarms
149
+ // and passes through unchanged.
150
+ function applyStickyCtrl(data: string): string {
151
+ if (!ctrlArmed) {
152
+ return data;
153
+ }
154
+ setCtrlArmed(false);
155
+ if (data.length === 1) {
156
+ return ctrlByteFor(data) ?? data;
157
+ }
158
+ return data;
159
+ }
160
+
161
+ function resetInputPlaceholder(): void {
162
+ input.value = INPUT_PLACEHOLDER;
163
+ // Cursor at the end so the next typed char appends after the
164
+ // placeholder rather than before.
165
+ try {
166
+ input.setSelectionRange(INPUT_PLACEHOLDER.length, INPUT_PLACEHOLDER.length);
167
+ } catch {
168
+ // Ignore browsers that throw on setSelectionRange against a hidden
169
+ // input (some older WebKit builds).
170
+ }
171
+ }
172
+
173
+ // Keydown handler — attached to the textarea (the single keyboard target,
174
+ // desktop and touch alike); #term-output is display-only and never focused.
175
+ function handleKeydown(ev: KeyboardEvent): void {
176
+ // While composing (IME), let the browser pump composition events;
177
+ // keydown bytes during composition would duplicate the composed text.
178
+ if (composition.isComposing()) {
179
+ return;
180
+ }
181
+
182
+ // Ctrl+Shift+C / Ctrl+Shift+V — desktop clipboard shortcuts. Handled
183
+ // before the generic mapper because they take browser-side selection
184
+ // and clipboard, not server-bound key sequences.
185
+ if (ev.ctrlKey && ev.shiftKey && !ev.altKey && !ev.metaKey) {
186
+ if (ev.code === "KeyC") {
187
+ const sel = window.getSelection()?.toString();
188
+ if (sel) {
189
+ void navigator.clipboard.writeText(sel).catch(() => {
190
+ /* ignore */
191
+ });
192
+ }
193
+ ev.preventDefault();
194
+ return;
195
+ }
196
+ if (ev.code === "KeyV") {
197
+ navigator.clipboard
198
+ .readText()
199
+ .then((text) => {
200
+ send(bracketTextForPaste(prepareTextForTerminal(text)));
201
+ })
202
+ .catch(() => {
203
+ /* ignore */
204
+ });
205
+ ev.preventDefault();
206
+ return;
207
+ }
208
+ }
209
+
210
+ const result = mapKeyboardEvent(ev);
211
+ switch (result.kind) {
212
+ case "send":
213
+ ev.preventDefault();
214
+ send(result.bytes);
215
+ return;
216
+ case "scroll-up": {
217
+ ev.preventDefault();
218
+ const h = termWrap.clientHeight;
219
+ termWrap.scrollTop = Math.max(0, termWrap.scrollTop - h);
220
+ return;
221
+ }
222
+ case "scroll-down": {
223
+ ev.preventDefault();
224
+ const h = termWrap.clientHeight;
225
+ termWrap.scrollTop = Math.min(termWrap.scrollHeight, termWrap.scrollTop + h);
226
+ return;
227
+ }
228
+ case "ignore":
229
+ // Defer to the browser; the `input` listener will pick up any
230
+ // printable character produced by the keystroke.
231
+ return;
232
+ }
233
+ }
234
+
235
+ // Focus the textarea (on page load and after visibility changes).
236
+ function focusTerminal(): void {
237
+ input.focus({ preventScroll: true });
238
+ }
239
+
240
+ // Send the first resize only when BOTH fonts are loaded AND the WS is
241
+ // open. Either can happen first depending on network/cache conditions.
242
+ function maybeSendFirstResize(): void {
243
+ if (!fontsLoaded || !wsOpen) {
244
+ return;
245
+ }
246
+ render.updateFontMetrics();
247
+ connection.sendResize(); // sends only if size changed
248
+ const sz = render.computeSize();
249
+ predict.setDimensions(sz.cols, sz.rows);
250
+ }
251
+
252
+ function hideCtxMenu(): void {
253
+ ctxMenu.classList.remove("visible");
254
+ ctxMenu.innerHTML = "";
255
+ }
256
+
257
+ function showCtxMenu(x: number, y: number): void {
258
+ hideCtxMenu();
259
+
260
+ const sel = window.getSelection()?.toString();
261
+ if (sel) {
262
+ const copyBtn = document.createElement("button");
263
+ copyBtn.textContent = "Copy";
264
+ copyBtn.addEventListener("click", () => {
265
+ void navigator.clipboard.writeText(sel).catch(() => {
266
+ /* ignore */
267
+ });
268
+ hideCtxMenu();
269
+ });
270
+ ctxMenu.appendChild(copyBtn);
271
+ }
272
+
273
+ const selectAllBtn = document.createElement("button");
274
+ selectAllBtn.textContent = "Select All";
275
+ selectAllBtn.addEventListener("click", () => {
276
+ const s = window.getSelection();
277
+ if (s) {
278
+ s.selectAllChildren(outputEl);
279
+ }
280
+ hideCtxMenu();
281
+ });
282
+ ctxMenu.appendChild(selectAllBtn);
283
+
284
+ if (!isIOS || lastPointerType !== "touch") {
285
+ const pasteBtn = document.createElement("button");
286
+ pasteBtn.textContent = "Paste";
287
+ pasteBtn.addEventListener("click", () => {
288
+ navigator.clipboard
289
+ .readText()
290
+ .then((text) => {
291
+ send(bracketTextForPaste(prepareTextForTerminal(text)));
292
+ })
293
+ .catch(() => {
294
+ /* ignore */
295
+ });
296
+ hideCtxMenu();
297
+ });
298
+ ctxMenu.appendChild(pasteBtn);
299
+ }
300
+
301
+ // Don't show an empty menu (iOS without selection has nothing to offer).
302
+ if (ctxMenu.childElementCount === 0) {
303
+ return;
304
+ }
305
+
306
+ // Make it visible (so it has measurable dimensions) then clamp it
307
+ // inside the viewport, so it never opens off-screen near the right or
308
+ // bottom edge. position:fixed means x/y are already viewport
309
+ // coordinates. Setting left/top after adding the class is flash-free:
310
+ // it is all one synchronous task, so the browser paints only the
311
+ // clamped position.
312
+ ctxMenu.classList.add("visible");
313
+ const margin = 8;
314
+ const left = Math.max(margin, Math.min(x, window.innerWidth - ctxMenu.offsetWidth - margin));
315
+ const top = Math.max(margin, Math.min(y, window.innerHeight - ctxMenu.offsetHeight - margin));
316
+ ctxMenu.style.left = `${left}px`;
317
+ ctxMenu.style.top = `${top}px`;
318
+ }
319
+
320
+ export function mount(opts: MountOptions = {}): TerminalUI {
321
+ const wsPath = opts.wsPath ?? DEFAULT_WS_PATH;
322
+ const fontReady = opts.fontReady ?? DEFAULT_FONT_READY;
323
+
324
+ // --- DOM refs ---
325
+ outputEl = getRequired("term-output");
326
+ termWrap = getRequired("term");
327
+ input = getRequired("term-input") as HTMLTextAreaElement;
328
+ compositionViewEl = getRequired("composition-view");
329
+ ctxMenu = getRequired("ctx-menu");
330
+ ctrlBtn = document.getElementById("kb-ctrl");
331
+
332
+ isIOS =
333
+ /iPad|iPhone|iPod/.test(navigator.userAgent) ||
334
+ (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
335
+
336
+ // --- Initialize layers ---
337
+ status.init();
338
+
339
+ render.init({
340
+ output: outputEl,
341
+ termWrap,
342
+ // Keep the helper textarea + IME composition view glued to the
343
+ // cursor on every render so iOS keyboard focus and IME candidate
344
+ // popups target the right area. Also re-render the predicted-cursor
345
+ // overlay so its position stays consistent with the just-drawn
346
+ // server cursor.
347
+ onCursorMove: () => {
348
+ composition.positionCompositionView();
349
+ const p = predict.get();
350
+ render.setPredictedCursor(p.row, p.col, p.active);
351
+ },
352
+ });
353
+ render.updateFontMetrics();
354
+
355
+ // predict redraws on every change to its predicted-cursor state.
356
+ predict.subscribe(() => {
357
+ const p = predict.get();
358
+ render.setPredictedCursor(p.row, p.col, p.active);
359
+ });
360
+
361
+ composition.init({
362
+ textarea: input,
363
+ compositionView: compositionViewEl,
364
+ getCursorPx: render.getCursorPx,
365
+ send,
366
+ });
367
+
368
+ scroll.init({
369
+ scrollEl: termWrap,
370
+ onUserScrollChange(scrolledUp) {
371
+ // Toggle .scrolled-up on the toolbar so CSS can show/hide the
372
+ // scroll-bottom button and grow the pill vertically.
373
+ const toolbar = document.getElementById("key-toolbar");
374
+ if (toolbar) {
375
+ toolbar.classList.toggle("scrolled-up", scrolledUp);
376
+ }
377
+ },
378
+ });
379
+
380
+ connection.init({
381
+ computeSize: render.computeSize,
382
+ getHaveThrough: render.getHighestIndex,
383
+ onResumeBounds: render.noteResumeBounds,
384
+ wsPath,
385
+ onMessage(msg) {
386
+ if (msg.type === "screen") {
387
+ render.handleScreen(msg);
388
+ if (fontsLoaded) {
389
+ const ld = document.getElementById("loading");
390
+ if (ld) {
391
+ ld.classList.add("fade");
392
+ ld.addEventListener("transitionend", () => {
393
+ ld.remove();
394
+ });
395
+ }
396
+ }
397
+ predict.onScreenFrame(msg.cursor[0], msg.cursor[1], msg.cursorHidden);
398
+ } else if (msg.type === "scroll") {
399
+ render.handleScroll(msg);
400
+ } else if (msg.type === "title") {
401
+ document.title = msg.title;
402
+ } else if (msg.type === "modes") {
403
+ render.updateReverseVideo();
404
+ }
405
+ },
406
+ onOpen() {
407
+ status.open();
408
+ // Do NOT call render.resetScreen() here. resetScreen flips the
409
+ // firstScreen flag, which causes the next screen frame to wipe
410
+ // the entire #term-output DOM (including all scrollback above
411
+ // the live viewport). On reconnect (e.g. iPad screen dim/wake)
412
+ // that destroys history the user could otherwise scroll up to.
413
+ // The server forces a full repaint via builder.Reset() on resume,
414
+ // which the client renders as row replacements within the live
415
+ // zone — scrollback DOM stays intact. The very first onOpen of
416
+ // page load still wipes correctly because firstScreen defaults
417
+ // to true at module load (only resetScrollback + resetScreen on
418
+ // onServerRestart explicitly trigger a full reset).
419
+ wsOpen = true;
420
+ maybeSendFirstResize();
421
+ },
422
+ onConnecting() {
423
+ status.reconnecting();
424
+ },
425
+ onClose() {
426
+ status.closed();
427
+ },
428
+ onOutboxFull() {
429
+ // The user kept typing through a long disconnect and we've
430
+ // capped the buffer. Surface visibly so they don't keep typing
431
+ // into the void.
432
+ status.closed();
433
+ },
434
+ onServerRestart() {
435
+ // Wipe the now-stale scrollback DOM and the live viewport so the
436
+ // user doesn't see ghost input from the previous server boot;
437
+ // the next screen frame from the fresh server populates the
438
+ // viewport. Banner explains why.
439
+ render.resetScrollback();
440
+ render.resetScreen();
441
+ predict.reset();
442
+ status.restarted();
443
+ },
444
+ });
445
+
446
+ // --- Input handling ---
447
+ resetInputPlaceholder();
448
+
449
+ input.addEventListener("input", (e: Event) => {
450
+ // While IME composition is in progress, the textarea fires `input`
451
+ // events for each composing keystroke. composition.ts owns sending
452
+ // the final composed text in compositionend; we must NOT send the
453
+ // intermediate input value (it would duplicate the composition).
454
+ if (composition.isComposing()) {
455
+ return;
456
+ }
457
+
458
+ const ev = e as InputEvent;
459
+ const inputType = ev.inputType;
460
+
461
+ if (inputType === "deleteContentBackward") {
462
+ // Handled by keydown — just re-pad the placeholder so iOS
463
+ // key-repeat keeps firing (it needs content to delete).
464
+ resetInputPlaceholder();
465
+ return;
466
+ } else if (inputType === "deleteContentForward") {
467
+ resetInputPlaceholder();
468
+ return;
469
+ } else if (inputType === "deleteWordBackward") {
470
+ resetInputPlaceholder();
471
+ return;
472
+ } else if (inputType === "deleteWordForward") {
473
+ resetInputPlaceholder();
474
+ return;
475
+ } else if (typeof ev.data === "string" && ev.data.length > 0) {
476
+ // insertText / insertFromPaste / insertReplacementText etc. The
477
+ // `data` property carries exactly the new content; using it
478
+ // sidesteps having to diff against the placeholder.
479
+ //
480
+ // iOS Safari can deliver U+00A0 (NBSP) instead of U+0020 for the
481
+ // spacebar/autocorrect. Normalize so the shell receives an honest
482
+ // space byte. (Native paste is bracketed in composition.ts's paste
483
+ // handler before this fires; the insertFromPaste data here is a
484
+ // fallback for browsers that don't raise a separate paste event.)
485
+ send(applyStickyCtrl(ev.data.replace(/\u00A0/g, " ")));
486
+ } else {
487
+ // Fallback: anything in the textarea past the placeholder is new
488
+ // content. Covers browsers that don't populate inputType / data
489
+ // (older WebKit).
490
+ const v = input.value;
491
+ if (v.length > INPUT_PLACEHOLDER.length && v.startsWith(INPUT_PLACEHOLDER)) {
492
+ send(applyStickyCtrl(v.slice(INPUT_PLACEHOLDER.length).replace(/\u00A0/g, " ")));
493
+ } else if (v !== INPUT_PLACEHOLDER && v.length > 0) {
494
+ send(applyStickyCtrl(v.replace(/\u00A0/g, " ")));
495
+ }
496
+ }
497
+ resetInputPlaceholder();
498
+ });
499
+
500
+ // Focus state on the terminal element, for CSS targeting (e.g. dimming
501
+ // the cursor when focus is elsewhere). Pattern from xterm.js.
502
+ input.addEventListener("focus", () => {
503
+ termWrap.classList.add("focus");
504
+ });
505
+ input.addEventListener("blur", () => {
506
+ // Restore the placeholder so the held-Backspace iOS path stays
507
+ // primed for the next focus. Also clears any leftover screen-reader
508
+ // text (xterm.js convention).
509
+ resetInputPlaceholder();
510
+ termWrap.classList.remove("focus");
511
+ });
512
+
513
+ // The textarea is the single keyboard target (desktop and touch alike);
514
+ // #term-output is display-only and never focused.
515
+ input.addEventListener("keydown", handleKeydown);
516
+
517
+ // --- Focus strategy ---
518
+ // One element, one job. #term-output is display + native selection only
519
+ // and is NEVER focused; the textarea owns the keyboard, the local typing
520
+ // buffer, and IME. Because the editable element is never the scroll
521
+ // content, the first touch-drag scrolls instead of placing a caret and a
522
+ // tap on a sparse screen still lands on the full-viewport scroll surface;
523
+ // because the display is not re-rendered as editable, a selection
524
+ // survives a redraw. Typed text, paste, and IME all arrive through the
525
+ // textarea's own input / paste / composition listeners (the latter two
526
+ // live in composition.ts).
527
+
528
+ // Touch tap: focus the hidden textarea to trigger iOS keyboard.
529
+ // On iOS Safari, focusing a hidden textarea from a `click` handler
530
+ // only opens the virtual keyboard if the click event arrives within
531
+ // iOS's user-gesture window. During heavy DOM mutations (streaming),
532
+ // the click event timing slips past that window — symptom: "while the
533
+ // agent is thinking I can't open the keyboard; only when the turn ends
534
+ // does tap work." `pointerup` fires synchronously with the user's
535
+ // finger lift and captures the gesture before iOS layout work can defer
536
+ // it, so focus() lands while the gesture is still "live". We track the
537
+ // start position from pointerdown and only treat the pointerup as a tap
538
+ // (vs scroll) when total movement is sub-threshold.
539
+ termWrap.addEventListener(
540
+ "pointerdown",
541
+ (e) => {
542
+ lastPointerType = e.pointerType;
543
+ pointerDownX = e.clientX;
544
+ pointerDownY = e.clientY;
545
+ },
546
+ { passive: true },
547
+ );
548
+ termWrap.addEventListener(
549
+ "pointerup",
550
+ (e) => {
551
+ if (e.pointerType !== "touch") {
552
+ return; // mouse/trackpad handled by the click listener below
553
+ }
554
+ const dx = Math.abs(e.clientX - pointerDownX);
555
+ const dy = Math.abs(e.clientY - pointerDownY);
556
+ if (dx > TAP_MOVEMENT_PX || dy > TAP_MOVEMENT_PX) {
557
+ return; // user was scrolling, not tapping
558
+ }
559
+ const sel = window.getSelection();
560
+ if (sel && sel.toString().length > 0) {
561
+ return; // preserve selection (long-tap selected text)
562
+ }
563
+ // Synchronous focus inside pointerup keeps us inside iOS's
564
+ // user-gesture window even when the streaming flush queue is busy.
565
+ input.focus({ preventScroll: true });
566
+ },
567
+ { passive: true },
568
+ );
569
+ termWrap.addEventListener("click", (e) => {
570
+ // Terminal links: URLs detected by render.ts's linkifySpans are wrapped
571
+ // in <a class="term-link" target="_blank">. In a contenteditable element
572
+ // the browser treats link clicks as cursor-placement, not navigation.
573
+ // Intercept and open explicitly.
574
+ const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(".term-link");
575
+ if (link) {
576
+ e.preventDefault();
577
+ window.open(link.href, "_blank", "noopener,noreferrer");
578
+ return;
579
+ }
580
+
581
+ if (lastPointerType === "touch") {
582
+ // Touch path is handled by pointerup above. The click listener
583
+ // is kept so non-pointer-event environments (very old iOS
584
+ // builds without PointerEvent support) still get a focus hook.
585
+ return;
586
+ }
587
+ // Mouse/trackpad: a plain click focuses the textarea so the user can
588
+ // type. Never steal focus mid-selection though — a drag-select ends
589
+ // with a click, and grabbing focus would collapse the selection the
590
+ // user just made and still wants to copy.
591
+ const sel = window.getSelection();
592
+ if (sel && sel.toString().length > 0) {
593
+ return;
594
+ }
595
+ focusTerminal();
596
+ });
597
+
598
+ // --- Viewport ---
599
+ // Centralized handling of iOS keyboard, window resize, and font-load
600
+ // reflows. Whenever the viewport settles, font metrics are remeasured
601
+ // and a resize is sent to the server. Snap-back-to-bottom (if the user
602
+ // was at the bottom before the transition) is handled inside viewport.ts.
603
+ viewport.init({
604
+ termWrap,
605
+ onSettled() {
606
+ render.updateFontMetrics();
607
+ // Only send resize if fonts are loaded — otherwise we'd send the
608
+ // wrong size (fallback font metrics) which causes the snap.
609
+ if (fontsLoaded) {
610
+ connection.sendResize();
611
+ }
612
+ composition.positionCompositionView();
613
+ const sz = render.computeSize();
614
+ predict.setDimensions(sz.cols, sz.rows);
615
+ },
616
+ });
617
+
618
+ // Only wait for the Regular weight — it determines cell size.
619
+ // Bold/Italic load lazily when first used; style pop is barely noticeable.
620
+ const regularFont = document.fonts.load(fontReady).then(() => {
621
+ /* discard result */
622
+ });
623
+ void regularFont.then(() => {
624
+ fontsLoaded = true;
625
+ requestAnimationFrame(() => {
626
+ maybeSendFirstResize();
627
+ });
628
+ });
629
+
630
+ // Connect immediately — the WS open triggers maybeSendFirstResize.
631
+ render.updateFontMetrics();
632
+ composition.positionCompositionView();
633
+ connection.connect();
634
+ focusTerminal();
635
+
636
+ document.addEventListener("visibilitychange", () => {
637
+ if (document.visibilityState === "visible") {
638
+ connection.reconnectNow();
639
+ focusTerminal();
640
+ }
641
+ });
642
+ window.addEventListener("pageshow", () => {
643
+ connection.reconnectNow();
644
+ focusTerminal();
645
+ });
646
+ // Network came back (cellular↔wifi handoff, tunnel reconnect): re-establish
647
+ // at once rather than waiting out the reconnect backoff. reconnectNow tears
648
+ // down any zombie socket first, and resume-by-index backfills what was missed.
649
+ window.addEventListener("online", () => {
650
+ connection.reconnectNow();
651
+ });
652
+
653
+ // --- Scroll-to-bottom (inside toolbar grid) ---
654
+ const scrollBtn = document.getElementById("scroll-bottom");
655
+ if (scrollBtn) {
656
+ // pointerdown (like the other toolbar keys) so touch devices enter
657
+ // :active and show press feedback; preventDefault keeps focus on the
658
+ // terminal. click is kept for keyboard activation of the desktop FAB.
659
+ // scrollToBottom() is idempotent, so the pointerdown+click pair on a
660
+ // mouse press is harmless.
661
+ scrollBtn.addEventListener("pointerdown", (e) => {
662
+ e.preventDefault();
663
+ scroll.scrollToBottom();
664
+ });
665
+ scrollBtn.addEventListener("click", () => {
666
+ scroll.scrollToBottom();
667
+ });
668
+ }
669
+
670
+ // --- Context menu ---
671
+ termWrap.addEventListener("contextmenu", (e: MouseEvent) => {
672
+ e.preventDefault();
673
+ showCtxMenu(e.clientX, e.clientY);
674
+ });
675
+
676
+ // Long-press to trigger the same menu on touch devices that don't
677
+ // already provide a native callout. iOS Safari shows its own
678
+ // long-press selection callout (Select / Copy / Paste) which fully
679
+ // covers the use case AND would race our custom menu (two stacked
680
+ // menus appear if the user long-presses an existing selection), so
681
+ // we skip our timer on iOS and let the native callout do its job.
682
+ // Android Chrome fires `contextmenu` on long-press release, but the
683
+ // timer also helps on browsers that don't, e.g. some Windows touch
684
+ // devices.
685
+ if (!isIOS) {
686
+ termWrap.addEventListener(
687
+ "touchstart",
688
+ (e: TouchEvent) => {
689
+ if (e.touches.length !== 1) {
690
+ if (longPressTimer) {
691
+ clearTimeout(longPressTimer);
692
+ longPressTimer = 0;
693
+ }
694
+ return;
695
+ }
696
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- touches.length checked above
697
+ const t = e.touches[0]!;
698
+ longPressOrigin = { x: t.clientX, y: t.clientY };
699
+ longPressTimer = window.setTimeout(() => {
700
+ longPressTimer = 0;
701
+ showCtxMenu(longPressOrigin.x, longPressOrigin.y);
702
+ }, LONG_PRESS_MS);
703
+ },
704
+ { passive: true },
705
+ );
706
+
707
+ termWrap.addEventListener(
708
+ "touchmove",
709
+ (e: TouchEvent) => {
710
+ if (!longPressTimer || e.touches.length !== 1) {
711
+ return;
712
+ }
713
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- touches.length checked above
714
+ const t = e.touches[0]!;
715
+ const dx = t.clientX - longPressOrigin.x;
716
+ const dy = t.clientY - longPressOrigin.y;
717
+ if (dx * dx + dy * dy > LONG_PRESS_MOVE_THRESHOLD_PX * LONG_PRESS_MOVE_THRESHOLD_PX) {
718
+ clearTimeout(longPressTimer);
719
+ longPressTimer = 0;
720
+ }
721
+ },
722
+ { passive: true },
723
+ );
724
+
725
+ termWrap.addEventListener(
726
+ "touchend",
727
+ () => {
728
+ if (longPressTimer) {
729
+ clearTimeout(longPressTimer);
730
+ longPressTimer = 0;
731
+ }
732
+ },
733
+ { passive: true },
734
+ );
735
+
736
+ termWrap.addEventListener(
737
+ "touchcancel",
738
+ () => {
739
+ if (longPressTimer) {
740
+ clearTimeout(longPressTimer);
741
+ longPressTimer = 0;
742
+ }
743
+ },
744
+ { passive: true },
745
+ );
746
+ }
747
+
748
+ document.addEventListener("click", () => {
749
+ hideCtxMenu();
750
+ });
751
+
752
+ // --- Mobile key toolbar ---
753
+ const keyToolbar = document.getElementById("key-toolbar");
754
+ if (keyToolbar) {
755
+ requestAnimationFrame(() =>
756
+ requestAnimationFrame(() => {
757
+ keyToolbar.classList.remove("no-transition");
758
+ }),
759
+ );
760
+ const toggleBtn = document.getElementById("kb-toggle");
761
+ toggleBtn?.addEventListener("pointerdown", (e) => {
762
+ e.preventDefault();
763
+ keyToolbar.classList.toggle("collapsed");
764
+ });
765
+
766
+ const keyMap: Record<string, string> = {
767
+ "kb-up": "\x1b[A",
768
+ "kb-down": "\x1b[B",
769
+ "kb-left": "\x1b[D",
770
+ "kb-right": "\x1b[C",
771
+ "kb-esc": "\x1b",
772
+ "kb-tab": "\t",
773
+ "kb-enter": "\r",
774
+ };
775
+
776
+ for (const [id, seq] of Object.entries(keyMap)) {
777
+ document.getElementById(id)?.addEventListener("pointerdown", (e) => {
778
+ e.preventDefault();
779
+ setCtrlArmed(false);
780
+ send(seq);
781
+ });
782
+ }
783
+
784
+ // Sticky Ctrl: tap to arm/disarm. preventDefault keeps focus on the
785
+ // terminal so the iOS virtual keyboard stays up for the next tap.
786
+ ctrlBtn?.addEventListener("pointerdown", (e) => {
787
+ e.preventDefault();
788
+ setCtrlArmed(!ctrlArmed);
789
+ });
790
+ }
791
+
792
+ // --- Copy feedback toast ---
793
+ document.addEventListener("copy", () => {
794
+ status.toast("Copied");
795
+ });
796
+
797
+ return { focus: focusTerminal };
798
+ }