@cplieger/web-terminal-ui 2.1.4 → 3.0.1

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 DELETED
@@ -1,903 +0,0 @@
1
- // @cplieger/web-terminal-ui — reference touch-first browser terminal UI.
2
- //
3
- // mount(root) builds the entire terminal subtree inside a single host-provided
4
- // container element and owns the touch-first input model: a display-only
5
- // terminal output, a hidden <textarea> that owns the keyboard + IME + local
6
- // typing buffer, a mobile key toolbar, a viewport-clamped context menu,
7
- // predictive echo, and viewport/keyboard-inset handling. The consumer's HTML
8
- // is just <head> + one empty root element (+ an optional loading overlay) +
9
- // the importmap; mount creates everything else, so there is no shared
10
- // element-id contract to keep in sync. A consumer that wants a different UI
11
- // builds on the engine directly instead.
12
- //
13
- // Single-instance per page: the module holds the one terminal's DOM refs and
14
- // state, matching a terminal-per-page model. Call mount() exactly once.
15
-
16
- import { render, keyboard, scroll, connection } from "@cplieger/web-terminal-engine";
17
- import * as viewport from "./viewport.js";
18
- import * as composition from "./composition.js";
19
- import * as status from "./status.js";
20
- import * as predict from "./predict.js";
21
- import { INPUT_PLACEHOLDER, resetToPlaceholder } from "./input-placeholder.js";
22
-
23
- const { mapKeyboardEvent, bracketTextForPaste, prepareTextForTerminal } = keyboard;
24
-
25
- export interface MountOptions {
26
- /** WebSocket endpoint path the engine connects to. Default "/ws"
27
- * (vibekit serves the shell at "/api/shell/ws", vibecli at "/ws"). */
28
- wsPath?: string;
29
- /** CSS font shorthand awaited before the first resize is sent, so the
30
- * server is sized against the real web font's cell metrics rather than a
31
- * fallback. Default '14px "MonaspiceNe NFM"'. */
32
- fontReady?: string;
33
- /** Optional pre-JS loading overlay element (lives in the served HTML so it
34
- * paints before this module loads). mount fades it out and removes it once
35
- * the first screen frame renders. The consumer owns the element; mount only
36
- * dismisses it. */
37
- loading?: HTMLElement;
38
- }
39
-
40
- export interface TerminalUI {
41
- /** Focus the terminal input (opens the soft keyboard on touch). */
42
- focus(): void;
43
- }
44
-
45
- const DEFAULT_WS_PATH = "/ws";
46
- const DEFAULT_FONT_READY = '14px "MonaspiceNe NFM"';
47
-
48
- const TAP_MOVEMENT_PX = 10;
49
- const LONG_PRESS_MS = 500;
50
- const LONG_PRESS_MOVE_THRESHOLD_PX = 10;
51
-
52
- const encoder = new TextEncoder();
53
-
54
- // --- DOM refs (picked by class from the subtree mount() builds) ---
55
- let outputEl!: HTMLElement;
56
- let termWrap!: HTMLElement;
57
- let input!: HTMLTextAreaElement;
58
- let ctxMenu!: HTMLElement;
59
-
60
- // --- Mutable UI state ---
61
- // Sticky-Ctrl state + the C0 mapping table + the toolbar button wiring all
62
- // live in the engine's keyboard.bindMobileToolbar; mount() binds it against
63
- // the built .key-toolbar element and keeps the controller so the input handler
64
- // can route typed text through its applyStickyCtrl.
65
- let toolbarCtrl!: keyboard.MobileToolbarController;
66
- let fontsLoaded = false;
67
- let wsOpen = false;
68
- let lastPointerType = "mouse";
69
- let pointerDownX = 0;
70
- let pointerDownY = 0;
71
- let longPressTimer = 0;
72
- let longPressOrigin = { x: 0, y: 0 };
73
-
74
- // iOS Safari shows a system "Paste" permission toast every time
75
- // navigator.clipboard.readText() is called — by design, and unavoidable
76
- // from JavaScript. iOS users get a one-tap paste via the native
77
- // long-press callout (which routes through the textarea's paste event
78
- // handler in composition.ts without ever calling readText), so we omit
79
- // the Paste button from our custom menu on iOS to steer them there.
80
- let isIOS = false;
81
-
82
- // The terminal subtree mount() builds inside the host-provided root. Static,
83
- // trusted markup (no interpolation) parsed once via a <template>. Element refs
84
- // are then picked out by class with scoped queries — no global getElementById,
85
- // no element-id contract the consumer must reproduce. The only ids are on the
86
- // kb-* toolbar buttons, which the engine's bindMobileToolbar resolves with a
87
- // scoped `toolbar.querySelector('#id')` (not a document-global lookup). The
88
- // predicted-cursor overlay is created and owned by the engine renderer; the
89
- // loading overlay stays in the consumer's served HTML (it must paint before
90
- // this module loads).
91
- const TERMINAL_TEMPLATE = `
92
- <div class="term">
93
- <div class="term-output" role="log" aria-live="off" aria-roledescription="Terminal" aria-label="Terminal"></div>
94
- <textarea class="term-input" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false" aria-label="Terminal input" tabindex="-1"></textarea>
95
- <div class="composition-view" aria-hidden="true"></div>
96
- </div>
97
- <div class="conn-banner" role="status" aria-live="polite"></div>
98
- <div class="key-toolbar collapsed no-transition" aria-label="Navigation keys" role="toolbar">
99
- <button type="button" id="kb-toggle" class="kb-toggle" aria-label="Toggle key toolbar"><svg class="icon-hamburger" viewBox="0 0 24 24"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg><svg class="icon-close" viewBox="0 0 24 24"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button>
100
- <button type="button" id="kb-tab" class="kb-key kb-r1c1" aria-label="Tab">TAB</button>
101
- <button type="button" id="kb-esc" class="kb-key kb-r1c2" aria-label="Escape">ESC</button>
102
- <button type="button" id="kb-up" class="kb-key kb-r1c3" aria-label="Up"><svg viewBox="0 0 24 24"><polyline points="6 15 12 9 18 15"/></svg></button>
103
- <button type="button" id="kb-enter" class="kb-key kb-r1c4" aria-label="Enter"><svg viewBox="0 0 24 24"><polyline points="9 10 4 15 9 20"/><polyline points="20 4 20 15 4 15"/></svg></button>
104
- <button type="button" class="kb-scroll-bottom" aria-label="Scroll to bottom"><svg viewBox="0 0 24 24"><path fill="none" d="M7 13l5 5 5-5M7 6l5 5 5-5"/></svg></button>
105
- <button type="button" id="kb-ctrl" class="kb-key kb-r2c1" aria-label="Sticky Ctrl modifier" aria-pressed="false">CTRL</button>
106
- <button type="button" id="kb-left" class="kb-key kb-r2c2" aria-label="Left"><svg viewBox="0 0 24 24"><polyline points="15 6 9 12 15 18"/></svg></button>
107
- <button type="button" id="kb-down" class="kb-key kb-r2c3" aria-label="Down"><svg viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg></button>
108
- <button type="button" id="kb-right" class="kb-key kb-r2c4" aria-label="Right"><svg viewBox="0 0 24 24"><polyline points="9 6 15 12 9 18"/></svg></button>
109
- </div>
110
- <div class="ctx-menu"></div>`;
111
-
112
- // Pick a freshly-built element out of the root by class. Throws if the
113
- // template and the selector ever drift apart (a build-time invariant, not a
114
- // host-scaffold dependency).
115
- function pick(root: ParentNode, selector: string): HTMLElement {
116
- const el = root.querySelector<HTMLElement>(selector);
117
- if (!el) {
118
- throw new Error(`web-terminal-ui: mount() failed to build element ${selector}`);
119
- }
120
- return el;
121
- }
122
-
123
- function send(bytes: string): void {
124
- // Suppress a lone Backspace (DEL) ONLY at the true origin (row 0, col 0):
125
- // there's nothing left to delete on an empty line, and this preserves the
126
- // brake iOS's textarea provided (stops held-Backspace key-repeat when empty).
127
- // The row check matters: predict.applyInput models a col-0 DEL on a WRAPPED
128
- // continuation row (row > 0) by wrapping to the end of the previous row, so a
129
- // continuation-row backspace must cross that wrap and reach the server rather
130
- // than being braked here (braking it would make backspace silently stick
131
- // mid-line on a wrapped command line).
132
- const p = predict.get();
133
- if (bytes === "\x7f" && p.col === 0 && p.row === 0 && p.active) {
134
- return;
135
- }
136
- const buf = encoder.encode(bytes);
137
- // sendBinary buffers while disconnected (resume layer) and returns false ONLY when the
138
- // 1 MiB outbox is full -- the engine has already surfaced that via onOutboxFull ->
139
- // status.closed(). Advancing the prediction for a dropped byte would paint a phantom
140
- // char that never reaches the server, so only advance on accepted input.
141
- if (!connection.sendBinary(buf)) {
142
- return;
143
- }
144
- predict.applyInput(buf);
145
- }
146
-
147
- // Normalize iOS's NBSP-for-space quirk, apply sticky-Ctrl, then send.
148
- function sendTyped(text: string): void {
149
- send(toolbarCtrl.applyStickyCtrl(text.replace(/\u00A0/g, " ")));
150
- }
151
-
152
- // Read the current predicted-cursor state and push it to the renderer.
153
- // Shared by render.init's onCursorMove (server cursor moved) and
154
- // predict.subscribe (prediction changed) so both push identically.
155
- function pushPredictedCursor(): void {
156
- const p = predict.get();
157
- render.setPredictedCursor(p.row, p.col, p.active);
158
- }
159
-
160
- // Clipboard helpers. navigator.clipboard is UNDEFINED outside a secure context
161
- // (plain-HTTP on a non-loopback host — a supported web-terminal-server
162
- // deployment), where a property access such as navigator.clipboard.writeText
163
- // throws a SYNCHRONOUS TypeError before any .then/.catch can guard it. Feature-
164
- // detect first so Copy/Paste surface a "Clipboard unavailable" toast instead of
165
- // throwing out of the handler (on Ctrl+Shift+C an uncaught throw would also skip
166
- // preventDefault and let the browser's native devtools shortcut fire).
167
- function copyToClipboard(text: string): void {
168
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- navigator.clipboard is undefined outside secure contexts (plain-HTTP host)
169
- if (!navigator.clipboard) {
170
- status.toast("Clipboard unavailable");
171
- return;
172
- }
173
- navigator.clipboard
174
- .writeText(text)
175
- .then(() => {
176
- status.toast("Copied");
177
- })
178
- .catch(() => {
179
- status.toast("Copy failed");
180
- });
181
- }
182
-
183
- function pasteFromClipboard(): void {
184
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- navigator.clipboard is undefined outside secure contexts (plain-HTTP host)
185
- if (!navigator.clipboard) {
186
- status.toast("Clipboard unavailable");
187
- return;
188
- }
189
- navigator.clipboard
190
- .readText()
191
- .then((text) => {
192
- send(bracketTextForPaste(prepareTextForTerminal(text)));
193
- })
194
- .catch(() => {
195
- status.toast("Paste blocked");
196
- });
197
- }
198
-
199
- // Keydown handler — attached to the textarea (the single keyboard target,
200
- // desktop and touch alike); .term-output is display-only and never focused.
201
- function handleKeydown(ev: KeyboardEvent): void {
202
- // While composing (IME), let the browser pump composition events;
203
- // keydown bytes during composition would duplicate the composed text.
204
- if (composition.isComposing()) {
205
- return;
206
- }
207
-
208
- // When the context menu is open, Escape dismisses it WITHOUT also sending an
209
- // ESC byte to the PTY -- matching the no-side-effect outside-click dismiss
210
- // (the document-level handler frames this as "parity"). This textarea keydown
211
- // fires before that document-level handler bubbles, so without this guard
212
- // mapKeyboardEvent would send ESC to the shell first (e.g. dropping vim out of
213
- // insert mode) while the menu also closes. The document-level handler still
214
- // covers the case where focus is on a menu button rather than the textarea.
215
- if (ev.key === "Escape" && ctxMenu.classList.contains("visible")) {
216
- ev.preventDefault();
217
- hideCtxMenu();
218
- return;
219
- }
220
-
221
- // Ctrl+Shift+C / Ctrl+Shift+V — desktop clipboard shortcuts. Handled
222
- // before the generic mapper because they take browser-side selection
223
- // and clipboard, not server-bound key sequences.
224
- if (ev.ctrlKey && ev.shiftKey && !ev.altKey && !ev.metaKey) {
225
- if (ev.code === "KeyC") {
226
- const sel = window.getSelection()?.toString();
227
- if (sel) {
228
- copyToClipboard(sel);
229
- }
230
- ev.preventDefault();
231
- return;
232
- }
233
- if (ev.code === "KeyV") {
234
- pasteFromClipboard();
235
- ev.preventDefault();
236
- return;
237
- }
238
- }
239
-
240
- const result = mapKeyboardEvent(ev);
241
- switch (result.kind) {
242
- case "send":
243
- ev.preventDefault();
244
- send(result.bytes);
245
- return;
246
- case "scroll-up": {
247
- ev.preventDefault();
248
- const h = termWrap.clientHeight;
249
- termWrap.scrollTop = Math.max(0, termWrap.scrollTop - h);
250
- return;
251
- }
252
- case "scroll-down": {
253
- ev.preventDefault();
254
- const h = termWrap.clientHeight;
255
- termWrap.scrollTop = Math.min(termWrap.scrollHeight, termWrap.scrollTop + h);
256
- return;
257
- }
258
- case "ignore":
259
- // Defer to the browser; the `input` listener will pick up any
260
- // printable character produced by the keystroke.
261
- return;
262
- }
263
- }
264
-
265
- // Focus the textarea (on page load and after visibility changes).
266
- function focusTerminal(): void {
267
- input.focus({ preventScroll: true });
268
- }
269
-
270
- // Send the first resize only when BOTH fonts are loaded AND the WS is
271
- // open. Either can happen first depending on network/cache conditions.
272
- function maybeSendFirstResize(): void {
273
- if (!fontsLoaded || !wsOpen) {
274
- return;
275
- }
276
- render.updateFontMetrics();
277
- connection.sendResize(); // sends only if size changed
278
- const sz = render.computeSize();
279
- predict.setDimensions(sz.cols, sz.rows);
280
- }
281
-
282
- function hideCtxMenu(): void {
283
- // If focus is inside the menu (a keyboard user activated an item or pressed
284
- // Escape on a focused button), return focus to the terminal input -- otherwise
285
- // removing the focused button drops focus to <body> and the keyboard user can no
286
- // longer type. On a pointer/outside-click dismiss focus is not in the menu, so
287
- // this is a no-op and does not steal focus.
288
- const refocus = ctxMenu.contains(document.activeElement);
289
- ctxMenu.classList.remove("visible");
290
- ctxMenu.replaceChildren();
291
- if (refocus) {
292
- focusTerminal();
293
- }
294
- }
295
-
296
- function showCtxMenu(x: number, y: number): void {
297
- hideCtxMenu();
298
-
299
- const sel = window.getSelection()?.toString();
300
- if (sel) {
301
- const copyBtn = document.createElement("button");
302
- copyBtn.textContent = "Copy";
303
- copyBtn.addEventListener("click", () => {
304
- copyToClipboard(sel);
305
- hideCtxMenu();
306
- });
307
- ctxMenu.appendChild(copyBtn);
308
- }
309
-
310
- const selectAllBtn = document.createElement("button");
311
- selectAllBtn.textContent = "Select All";
312
- selectAllBtn.addEventListener("click", () => {
313
- const s = window.getSelection();
314
- if (s) {
315
- s.selectAllChildren(outputEl);
316
- }
317
- hideCtxMenu();
318
- });
319
- ctxMenu.appendChild(selectAllBtn);
320
-
321
- if (!isIOS || lastPointerType !== "touch") {
322
- const pasteBtn = document.createElement("button");
323
- pasteBtn.textContent = "Paste";
324
- pasteBtn.addEventListener("click", () => {
325
- pasteFromClipboard();
326
- hideCtxMenu();
327
- });
328
- ctxMenu.appendChild(pasteBtn);
329
- }
330
-
331
- // Don't show an empty menu (iOS without selection has nothing to offer).
332
- if (ctxMenu.childElementCount === 0) {
333
- return;
334
- }
335
-
336
- // Make it visible (so it has measurable dimensions) then clamp it
337
- // inside the viewport, so it never opens off-screen near the right or
338
- // bottom edge. position:fixed means x/y are already viewport
339
- // coordinates. Setting left/top after adding the class is flash-free:
340
- // it is all one synchronous task, so the browser paints only the
341
- // clamped position.
342
- ctxMenu.classList.add("visible");
343
- const margin = 8;
344
- const left = Math.max(margin, Math.min(x, window.innerWidth - ctxMenu.offsetWidth - margin));
345
- const top = Math.max(margin, Math.min(y, window.innerHeight - ctxMenu.offsetHeight - margin));
346
- ctxMenu.style.left = `${left}px`;
347
- ctxMenu.style.top = `${top}px`;
348
- }
349
-
350
- export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
351
- const wsPath = opts.wsPath ?? DEFAULT_WS_PATH;
352
- const fontReady = opts.fontReady ?? DEFAULT_FONT_READY;
353
-
354
- // --- Build the terminal subtree inside the host-provided root ---
355
- const tpl = document.createElement("template");
356
- tpl.innerHTML = TERMINAL_TEMPLATE;
357
- root.replaceChildren(tpl.content);
358
-
359
- // --- DOM refs (picked from the just-built subtree by class) ---
360
- termWrap = pick(root, ".term");
361
- outputEl = pick(root, ".term-output");
362
- input = pick(root, ".term-input") as HTMLTextAreaElement;
363
- const compositionViewEl = pick(root, ".composition-view");
364
- ctxMenu = pick(root, ".ctx-menu");
365
- const banner = pick(root, ".conn-banner");
366
- const toolbar = pick(root, ".key-toolbar");
367
-
368
- isIOS =
369
- /iPad|iPhone|iPod/.test(navigator.userAgent) ||
370
- (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
371
-
372
- // markReady fires once: when the first screen frame has rendered AND the web
373
- // font is loaded. It tells the status banner the initial load is over (so it
374
- // may show reconnect state from here on) and fades out the consumer's
375
- // loading overlay if one was passed.
376
- let ready = false;
377
- let firstFrameRendered = false;
378
- let overlayDismissed = false;
379
- // Fade out and remove the consumer-owned loading overlay (if one was passed).
380
- // Extracted from markReady() so status.ts can also drive it via its give-up
381
- // signal: on a socket that never connects no first screen frame ever arrives,
382
- // so markReady never runs and nothing else would remove the opaque
383
- // z-index:200 overlay -- it would occlude the "Offline" banner forever.
384
- // status fires its onGiveUp on EVERY post-limit unloaded close against an
385
- // unbounded reconnect loop, so the overlayDismissed one-shot guard makes the
386
- // fade/remove and its transitionend listener + fallback timer register exactly
387
- // once; later calls early-return rather than piling listeners and timers onto
388
- // the (soon-detached) overlay node.
389
- function dismissLoadingOverlay(): void {
390
- const ld = opts.loading;
391
- if (!ld || overlayDismissed) {
392
- return;
393
- }
394
- overlayDismissed = true;
395
- ld.classList.add("fade");
396
- const removeOverlay = (): void => {
397
- ld.remove();
398
- };
399
- // transitionend fires on the consumer-owned fade, but their overlay CSS may have
400
- // no opacity transition (or it can be interrupted): then transitionend never fires
401
- // and the now opacity:0 overlay -- still inset:0 / z-index:200 with no
402
- // pointer-events:none in the reference scaffold -- keeps swallowing every tap and
403
- // click over the terminal. Remove on a bounded fallback too.
404
- ld.addEventListener("transitionend", removeOverlay, { once: true });
405
- window.setTimeout(removeOverlay, 1500);
406
- }
407
- function markReady(): void {
408
- if (ready) {
409
- return;
410
- }
411
- ready = true;
412
- status.setLoaded();
413
- dismissLoadingOverlay();
414
- }
415
-
416
- // --- Initialize layers ---
417
- status.init({ banner, onGiveUp: dismissLoadingOverlay });
418
-
419
- render.init({
420
- output: outputEl,
421
- termWrap,
422
- // Keep the helper textarea + IME composition view glued to the
423
- // cursor on every render so iOS keyboard focus and IME candidate
424
- // popups target the right area. Also re-render the predicted-cursor
425
- // overlay so its position stays consistent with the just-drawn
426
- // server cursor.
427
- onCursorMove: () => {
428
- composition.positionCompositionView();
429
- pushPredictedCursor();
430
- },
431
- });
432
- render.updateFontMetrics();
433
-
434
- // predict redraws on every change to its predicted-cursor state.
435
- predict.subscribe(pushPredictedCursor);
436
-
437
- composition.init({
438
- textarea: input,
439
- compositionView: compositionViewEl,
440
- getCursorPx: render.getCursorPx,
441
- send,
442
- });
443
-
444
- scroll.init({
445
- scrollEl: termWrap,
446
- onUserScrollChange(scrolledUp) {
447
- // Toggle .scrolled-up on the toolbar so CSS can show/hide the
448
- // scroll-bottom button and grow the pill vertically.
449
- toolbar.classList.toggle("scrolled-up", scrolledUp);
450
- },
451
- });
452
-
453
- connection.init({
454
- computeSize: render.computeSize,
455
- getHaveThrough: render.getHighestIndex,
456
- onResumeBounds: render.noteResumeBounds,
457
- wsPath,
458
- onMessage(msg) {
459
- if (msg.type === "screen") {
460
- render.handleScreen(msg);
461
- firstFrameRendered = true;
462
- if (fontsLoaded) {
463
- markReady();
464
- }
465
- predict.onScreenFrame(msg.cursor[0], msg.cursor[1], msg.cursorHidden);
466
- } else if (msg.type === "scroll") {
467
- render.handleScroll(msg);
468
- } else if (msg.type === "title") {
469
- document.title = msg.title;
470
- } else if (msg.type === "modes") {
471
- render.updateReverseVideo();
472
- } else if (msg.type === "clipboard") {
473
- // An app copied via OSC 52; mirror it to the system clipboard. This is
474
- // an async push (not a user gesture), so Safari/Firefox may reject
475
- // writeText — copyToClipboard surfaces that as a "Copy failed" toast.
476
- copyToClipboard(msg.text);
477
- }
478
- },
479
- onOpen() {
480
- status.open();
481
- // Do NOT call render.resetScreen() here. resetScreen flips the
482
- // firstScreen flag, which causes the next screen frame to wipe
483
- // the entire .term-output DOM (including all scrollback above
484
- // the live viewport). On reconnect (e.g. iPad screen dim/wake)
485
- // that destroys history the user could otherwise scroll up to.
486
- // The server forces a full repaint via builder.Reset() on resume,
487
- // which the client renders as row replacements within the live
488
- // zone — scrollback DOM stays intact. The very first onOpen of
489
- // page load still wipes correctly because firstScreen defaults
490
- // to true at module load (only resetScrollback + resetScreen on
491
- // onServerRestart explicitly trigger a full reset).
492
- wsOpen = true;
493
- maybeSendFirstResize();
494
- },
495
- onConnecting() {
496
- status.reconnecting();
497
- },
498
- onClose() {
499
- status.closed();
500
- },
501
- onOutboxFull() {
502
- // The user kept typing through a long disconnect and we've
503
- // capped the buffer. Surface visibly so they don't keep typing
504
- // into the void.
505
- status.closed();
506
- },
507
- onServerRestart() {
508
- // Wipe the now-stale scrollback DOM and the live viewport so the
509
- // user doesn't see ghost input from the previous server boot;
510
- // the next screen frame from the fresh server populates the
511
- // viewport. Banner explains why.
512
- render.resetScrollback();
513
- render.resetScreen();
514
- predict.reset();
515
- status.restarted();
516
- },
517
- });
518
-
519
- // --- Input handling ---
520
- resetToPlaceholder(input);
521
-
522
- input.addEventListener("input", (e: Event) => {
523
- // While IME composition is in progress, the textarea fires `input`
524
- // events for each composing keystroke. composition.ts owns sending
525
- // the final composed text in compositionend; we must NOT send the
526
- // intermediate input value (it would duplicate the composition).
527
- if (composition.isComposing()) {
528
- return;
529
- }
530
-
531
- const ev = e as InputEvent;
532
- const inputType = ev.inputType;
533
-
534
- if (
535
- inputType === "deleteContentBackward" ||
536
- inputType === "deleteContentForward" ||
537
- inputType === "deleteWordBackward" ||
538
- inputType === "deleteWordForward"
539
- ) {
540
- // Deletion is handled by keydown — just re-pad the placeholder so iOS
541
- // key-repeat keeps firing (it needs content to delete).
542
- resetToPlaceholder(input);
543
- return;
544
- } else if (typeof ev.data === "string" && ev.data.length > 0) {
545
- // insertText / insertFromPaste / insertReplacementText etc. The
546
- // `data` property carries exactly the new content; using it
547
- // sidesteps having to diff against the placeholder.
548
- //
549
- // iOS Safari can deliver U+00A0 (NBSP) instead of U+0020 for the
550
- // spacebar/autocorrect. Normalize so the shell receives an honest
551
- // space byte. (Native paste is bracketed in composition.ts's paste
552
- // handler before this fires; the insertFromPaste data here is a
553
- // fallback for browsers that don't raise a separate paste event.)
554
- if (inputType === "insertFromPaste") {
555
- // Paste arriving through `input` without a preceding `paste` event
556
- // (composition.ts's onPaste handles + preventDefaults the normal
557
- // case). Bracket + sanitize it exactly like the Ctrl+Shift+V,
558
- // context-menu Paste, and composition.onPaste paths so embedded
559
- // newlines / control bytes arrive as an inert bracketed paste
560
- // instead of executing as typed shell commands (paste-jacking).
561
- send(bracketTextForPaste(prepareTextForTerminal(ev.data)));
562
- } else {
563
- sendTyped(ev.data);
564
- }
565
- } else {
566
- // Fallback: anything in the textarea past the placeholder is new
567
- // content. Covers browsers that don't populate inputType / data
568
- // (older WebKit).
569
- const v = input.value;
570
- if (v.length > INPUT_PLACEHOLDER.length && v.startsWith(INPUT_PLACEHOLDER)) {
571
- sendTyped(v.slice(INPUT_PLACEHOLDER.length));
572
- } else if (v !== INPUT_PLACEHOLDER && v.length > 0) {
573
- sendTyped(v);
574
- }
575
- }
576
- resetToPlaceholder(input);
577
- });
578
-
579
- // Focus state on the terminal element, for CSS targeting (e.g. dimming
580
- // the cursor when focus is elsewhere). Pattern from xterm.js.
581
- input.addEventListener("focus", () => {
582
- termWrap.classList.add("focus");
583
- });
584
- input.addEventListener("blur", () => {
585
- // Restore the placeholder so the held-Backspace iOS path stays
586
- // primed for the next focus. Also clears any leftover screen-reader
587
- // text (xterm.js convention).
588
- resetToPlaceholder(input);
589
- termWrap.classList.remove("focus");
590
- });
591
-
592
- // The textarea is the single keyboard target (desktop and touch alike);
593
- // .term-output is display-only and never focused.
594
- input.addEventListener("keydown", handleKeydown);
595
-
596
- // --- Focus strategy ---
597
- // One element, one job. .term-output is display + native selection only
598
- // and is NEVER focused; the textarea owns the keyboard, the local typing
599
- // buffer, and IME. Because the editable element is never the scroll
600
- // content, the first touch-drag scrolls instead of placing a caret and a
601
- // tap on a sparse screen still lands on the full-viewport scroll surface;
602
- // because the display is not re-rendered as editable, a selection
603
- // survives a redraw. Typed text, paste, and IME all arrive through the
604
- // textarea's own input / paste / composition listeners (the latter two
605
- // live in composition.ts).
606
-
607
- // Touch tap: focus the hidden textarea to trigger iOS keyboard.
608
- // On iOS Safari, focusing a hidden textarea from a `click` handler
609
- // only opens the virtual keyboard if the click event arrives within
610
- // iOS's user-gesture window. During heavy DOM mutations (streaming),
611
- // the click event timing slips past that window — symptom: "while the
612
- // agent is thinking I can't open the keyboard; only when the turn ends
613
- // does tap work." `pointerup` fires synchronously with the user's
614
- // finger lift and captures the gesture before iOS layout work can defer
615
- // it, so focus() lands while the gesture is still "live". We track the
616
- // start position from pointerdown and only treat the pointerup as a tap
617
- // (vs scroll) when total movement is sub-threshold.
618
- termWrap.addEventListener(
619
- "pointerdown",
620
- (e) => {
621
- lastPointerType = e.pointerType;
622
- pointerDownX = e.clientX;
623
- pointerDownY = e.clientY;
624
- },
625
- { passive: true },
626
- );
627
- termWrap.addEventListener(
628
- "pointerup",
629
- (e) => {
630
- if (e.pointerType !== "touch") {
631
- return; // mouse/trackpad handled by the click listener below
632
- }
633
- // A tap on a terminal link is opened by the click listener below; don't
634
- // also focus the textarea (which pops the iOS soft keyboard) on the way.
635
- if ((e.target as HTMLElement).closest(".term-link")) {
636
- return;
637
- }
638
- const dx = Math.abs(e.clientX - pointerDownX);
639
- const dy = Math.abs(e.clientY - pointerDownY);
640
- if (dx > TAP_MOVEMENT_PX || dy > TAP_MOVEMENT_PX) {
641
- return; // user was scrolling, not tapping
642
- }
643
- const sel = window.getSelection();
644
- if (sel && sel.toString().length > 0) {
645
- return; // preserve selection (long-tap selected text)
646
- }
647
- // Synchronous focus inside pointerup keeps us inside iOS's
648
- // user-gesture window even when the streaming flush queue is busy.
649
- focusTerminal();
650
- },
651
- { passive: true },
652
- );
653
- // After a touch tap, iOS/WebKit dispatches a synthetic `mousedown` (following
654
- // the `pointerup` above). Its default action moves focus to the nearest
655
- // focusable ancestor of the tap point — but the input-model contract keeps
656
- // `.term-output` and `.term` deliberately non-focusable (display-only) and the
657
- // hidden textarea is `pointer-events: none`, so the tap never lands on a
658
- // focusable node. With nothing focusable under the tap, that default blurs the
659
- // textarea we just focused in `pointerup`, so iOS immediately dismisses the
660
- // soft keyboard the tap had only just opened — the "keyboard flashes open then
661
- // instantly closes" symptom on touch. Cancelling the synthetic mousedown's
662
- // default keeps focus on the textarea, so the keyboard stays up. This is the
663
- // xterm.js focus-preservation pattern, scoped to touch: a real mouse mousedown
664
- // must keep its default so desktop drag-to-select text still works, and iOS
665
- // text selection is long-press-driven (not this tap mousedown), so it is
666
- // unaffected. Not passive — preventDefault must take effect.
667
- termWrap.addEventListener("mousedown", (e) => {
668
- if (lastPointerType === "touch") {
669
- e.preventDefault();
670
- }
671
- });
672
- termWrap.addEventListener("click", (e) => {
673
- // Terminal links: URLs rendered by the engine are wrapped in
674
- // <a class="term-link" target="_blank" rel="noopener noreferrer">. The
675
- // output is display-only (never contenteditable -- see the input-model
676
- // contract), so intercept the click to open with explicit
677
- // noopener/noreferrer and to keep the focus handler below from running
678
- // on a link tap.
679
- const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(".term-link");
680
- if (link) {
681
- e.preventDefault();
682
- window.open(link.href, "_blank", "noopener,noreferrer");
683
- return;
684
- }
685
-
686
- if (lastPointerType === "touch") {
687
- // Touch path is handled by pointerup above. The click listener
688
- // is kept so non-pointer-event environments (very old iOS
689
- // builds without PointerEvent support) still get a focus hook.
690
- return;
691
- }
692
- // Mouse/trackpad: a plain click focuses the textarea so the user can
693
- // type. Never steal focus mid-selection though — a drag-select ends
694
- // with a click, and grabbing focus would collapse the selection the
695
- // user just made and still wants to copy.
696
- const sel = window.getSelection();
697
- if (sel && sel.toString().length > 0) {
698
- return;
699
- }
700
- focusTerminal();
701
- });
702
-
703
- // --- Viewport ---
704
- // Centralized handling of iOS keyboard, window resize, and font-load
705
- // reflows. Whenever the viewport settles, font metrics are remeasured
706
- // and a resize is sent to the server. Snap-back-to-bottom (if the user
707
- // was at the bottom before the transition) is handled inside viewport.ts.
708
- viewport.init({
709
- termWrap,
710
- onSettled() {
711
- render.updateFontMetrics();
712
- // Only send resize if fonts are loaded — otherwise we'd send the
713
- // wrong size (fallback font metrics) which causes the snap.
714
- if (fontsLoaded) {
715
- connection.sendResize();
716
- }
717
- composition.positionCompositionView();
718
- const sz = render.computeSize();
719
- predict.setDimensions(sz.cols, sz.rows);
720
- },
721
- });
722
-
723
- // Only wait for the Regular weight — it determines cell size.
724
- // Bold/Italic load lazily when first used; style pop is barely noticeable.
725
- // A missing/slow font, or a malformed `fontReady` (FontFaceSet.load can throw
726
- // synchronously on a bad shorthand), must NOT leave the terminal permanently
727
- // unsized: degrade to fallback-font metrics so the first resize still fires
728
- // and the loading overlay still dismisses.
729
- const onFontSettled = (): void => {
730
- fontsLoaded = true;
731
- if (firstFrameRendered) {
732
- markReady();
733
- }
734
- requestAnimationFrame(() => {
735
- maybeSendFirstResize();
736
- });
737
- };
738
- try {
739
- void document.fonts
740
- .load(fontReady)
741
- .then(onFontSettled)
742
- .catch((err: unknown) => {
743
- console.warn(
744
- `web-terminal-ui: web font ${fontReady} failed to load; using fallback metrics`,
745
- err,
746
- );
747
- onFontSettled();
748
- });
749
- } catch (err) {
750
- console.warn(`web-terminal-ui: invalid fontReady ${fontReady}; using fallback metrics`, err);
751
- onFontSettled();
752
- }
753
-
754
- // Connect immediately — the WS open triggers maybeSendFirstResize.
755
- render.updateFontMetrics();
756
- composition.positionCompositionView();
757
- connection.connect();
758
- focusTerminal();
759
-
760
- document.addEventListener("visibilitychange", () => {
761
- if (document.visibilityState === "visible") {
762
- connection.reconnectNow();
763
- focusTerminal();
764
- }
765
- });
766
- window.addEventListener("pageshow", () => {
767
- connection.reconnectNow();
768
- focusTerminal();
769
- });
770
- // Network came back (cellular↔wifi handoff, tunnel reconnect): re-establish
771
- // at once rather than waiting out the reconnect backoff. reconnectNow tears
772
- // down any zombie socket first, and resume-by-index backfills what was missed.
773
- window.addEventListener("online", () => {
774
- connection.reconnectNow();
775
- });
776
-
777
- // --- Scroll-to-bottom (inside toolbar grid) ---
778
- const scrollBtn = pick(root, ".kb-scroll-bottom");
779
- // pointerdown (like the other toolbar keys) so touch devices enter
780
- // :active and show press feedback; preventDefault keeps focus on the
781
- // terminal. click is kept for keyboard activation of the desktop FAB.
782
- // scrollToBottom() is idempotent, so the pointerdown+click pair on a
783
- // mouse press is harmless.
784
- scrollBtn.addEventListener("pointerdown", (e) => {
785
- e.preventDefault();
786
- scroll.scrollToBottom();
787
- });
788
- scrollBtn.addEventListener("click", () => {
789
- scroll.scrollToBottom();
790
- });
791
-
792
- // --- Context menu ---
793
- termWrap.addEventListener("contextmenu", (e: MouseEvent) => {
794
- e.preventDefault();
795
- showCtxMenu(e.clientX, e.clientY);
796
- });
797
-
798
- // Long-press to trigger the same menu on touch devices that don't
799
- // already provide a native callout. iOS Safari shows its own
800
- // long-press selection callout (Select / Copy / Paste) which fully
801
- // covers the use case AND would race our custom menu (two stacked
802
- // menus appear if the user long-presses an existing selection), so
803
- // we skip our timer on iOS and let the native callout do its job.
804
- // Android Chrome fires `contextmenu` on long-press release, but the
805
- // timer also helps on browsers that don't, e.g. some Windows touch
806
- // devices.
807
- if (!isIOS) {
808
- termWrap.addEventListener(
809
- "touchstart",
810
- (e: TouchEvent) => {
811
- if (e.touches.length !== 1) {
812
- if (longPressTimer) {
813
- clearTimeout(longPressTimer);
814
- longPressTimer = 0;
815
- }
816
- return;
817
- }
818
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- touches.length checked above
819
- const t = e.touches[0]!;
820
- longPressOrigin = { x: t.clientX, y: t.clientY };
821
- longPressTimer = window.setTimeout(() => {
822
- longPressTimer = 0;
823
- showCtxMenu(longPressOrigin.x, longPressOrigin.y);
824
- }, LONG_PRESS_MS);
825
- },
826
- { passive: true },
827
- );
828
-
829
- termWrap.addEventListener(
830
- "touchmove",
831
- (e: TouchEvent) => {
832
- if (!longPressTimer || e.touches.length !== 1) {
833
- return;
834
- }
835
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- touches.length checked above
836
- const t = e.touches[0]!;
837
- const dx = t.clientX - longPressOrigin.x;
838
- const dy = t.clientY - longPressOrigin.y;
839
- if (dx * dx + dy * dy > LONG_PRESS_MOVE_THRESHOLD_PX * LONG_PRESS_MOVE_THRESHOLD_PX) {
840
- clearTimeout(longPressTimer);
841
- longPressTimer = 0;
842
- }
843
- },
844
- { passive: true },
845
- );
846
-
847
- termWrap.addEventListener(
848
- "touchend",
849
- () => {
850
- if (longPressTimer) {
851
- clearTimeout(longPressTimer);
852
- longPressTimer = 0;
853
- }
854
- },
855
- { passive: true },
856
- );
857
-
858
- termWrap.addEventListener(
859
- "touchcancel",
860
- () => {
861
- if (longPressTimer) {
862
- clearTimeout(longPressTimer);
863
- longPressTimer = 0;
864
- }
865
- },
866
- { passive: true },
867
- );
868
- }
869
-
870
- document.addEventListener("click", () => {
871
- hideCtxMenu();
872
- });
873
-
874
- // Escape dismisses the context menu (keyboard parity with the outside-click
875
- // dismiss; the menu can be opened via the keyboard Menu key, which fires
876
- // `contextmenu`). Without this an AT/keyboard user has no keyboard way to close it.
877
- document.addEventListener("keydown", (e) => {
878
- if (e.key === "Escape" && ctxMenu.classList.contains("visible")) {
879
- hideCtxMenu();
880
- }
881
- });
882
-
883
- // --- Mobile key toolbar ---
884
- // The engine's keyboard.bindMobileToolbar wires the collapse toggle, the
885
- // arrows (DECCKM-aware — SS3 under application-cursor mode, matching
886
- // mapKeyboardEvent), Tab / Enter / Esc, and the sticky-Ctrl button on the
887
- // built toolbar. It returns the controller whose applyStickyCtrl the input
888
- // handler above applies to typed text. The no-transition priming below
889
- // stays local (a UI-specific concern).
890
- toolbarCtrl = keyboard.bindMobileToolbar({ toolbar, send });
891
- requestAnimationFrame(() =>
892
- requestAnimationFrame(() => {
893
- toolbar.classList.remove("no-transition");
894
- }),
895
- );
896
-
897
- // --- Copy feedback toast ---
898
- document.addEventListener("copy", () => {
899
- status.toast("Copied");
900
- });
901
-
902
- return { focus: focusTerminal };
903
- }