@cplieger/web-terminal-ui 1.0.1 → 2.1.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 CHANGED
@@ -1,13 +1,14 @@
1
1
  // @cplieger/web-terminal-ui — reference touch-first browser terminal UI.
2
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.
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.
11
12
  //
12
13
  // Single-instance per page: the module holds the one terminal's DOM refs and
13
14
  // state, matching a terminal-per-page model. Call mount() exactly once.
@@ -17,6 +18,7 @@ import * as viewport from "./viewport.js";
17
18
  import * as composition from "./composition.js";
18
19
  import * as status from "./status.js";
19
20
  import * as predict from "./predict.js";
21
+ import { INPUT_PLACEHOLDER, resetToPlaceholder } from "./input-placeholder.js";
20
22
 
21
23
  const { mapKeyboardEvent, bracketTextForPaste, prepareTextForTerminal } = keyboard;
22
24
 
@@ -28,6 +30,11 @@ export interface MountOptions {
28
30
  * server is sized against the real web font's cell metrics rather than a
29
31
  * fallback. Default '14px "MonaspiceNe NFM"'. */
30
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;
31
38
  }
32
39
 
33
40
  export interface TerminalUI {
@@ -38,33 +45,24 @@ export interface TerminalUI {
38
45
  const DEFAULT_WS_PATH = "/ws";
39
46
  const DEFAULT_FONT_READY = '14px "MonaspiceNe NFM"';
40
47
 
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
48
  const TAP_MOVEMENT_PX = 10;
53
49
  const LONG_PRESS_MS = 500;
54
50
  const LONG_PRESS_MOVE_THRESHOLD_PX = 10;
55
51
 
56
52
  const encoder = new TextEncoder();
57
53
 
58
- // --- DOM refs (assigned in mount() from the scaffold) ---
54
+ // --- DOM refs (picked by class from the subtree mount() builds) ---
59
55
  let outputEl!: HTMLElement;
60
56
  let termWrap!: HTMLElement;
61
57
  let input!: HTMLTextAreaElement;
62
- let compositionViewEl!: HTMLElement;
63
58
  let ctxMenu!: HTMLElement;
64
- let ctrlBtn: HTMLElement | null = null;
65
59
 
66
60
  // --- Mutable UI state ---
67
- let ctrlArmed = false;
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;
68
66
  let fontsLoaded = false;
69
67
  let wsOpen = false;
70
68
  let lastPointerType = "mouse";
@@ -81,97 +79,125 @@ let longPressOrigin = { x: 0, y: 0 };
81
79
  // the Paste button from our custom menu on iOS to steer them there.
82
80
  let isIOS = false;
83
81
 
84
- function getRequired(id: string): HTMLElement {
85
- const el = document.getElementById(id);
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);
86
117
  if (!el) {
87
- throw new Error(`web-terminal-ui: missing required scaffold element #${id}`);
118
+ throw new Error(`web-terminal-ui: mount() failed to build element ${selector}`);
88
119
  }
89
120
  return el;
90
121
  }
91
122
 
92
123
  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) {
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) {
97
134
  return;
98
135
  }
99
136
  const buf = encoder.encode(bytes);
100
- predict.applyInput(buf);
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.
101
141
  if (!connection.sendBinary(buf)) {
102
- /* connection not ready — drop silently */
142
+ return;
103
143
  }
144
+ predict.applyInput(buf);
104
145
  }
105
146
 
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");
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, " ")));
117
150
  }
118
151
 
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
- }
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);
145
158
  }
146
159
 
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;
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;
157
172
  }
158
- return data;
173
+ navigator.clipboard
174
+ .writeText(text)
175
+ .then(() => {
176
+ status.toast("Copied");
177
+ })
178
+ .catch(() => {
179
+ status.toast("Copy failed");
180
+ });
159
181
  }
160
182
 
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).
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;
170
188
  }
189
+ navigator.clipboard
190
+ .readText()
191
+ .then((text) => {
192
+ send(bracketTextForPaste(prepareTextForTerminal(text)));
193
+ })
194
+ .catch(() => {
195
+ status.toast("Paste blocked");
196
+ });
171
197
  }
172
198
 
173
199
  // Keydown handler — attached to the textarea (the single keyboard target,
174
- // desktop and touch alike); #term-output is display-only and never focused.
200
+ // desktop and touch alike); .term-output is display-only and never focused.
175
201
  function handleKeydown(ev: KeyboardEvent): void {
176
202
  // While composing (IME), let the browser pump composition events;
177
203
  // keydown bytes during composition would duplicate the composed text.
@@ -179,6 +205,19 @@ function handleKeydown(ev: KeyboardEvent): void {
179
205
  return;
180
206
  }
181
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
+
182
221
  // Ctrl+Shift+C / Ctrl+Shift+V — desktop clipboard shortcuts. Handled
183
222
  // before the generic mapper because they take browser-side selection
184
223
  // and clipboard, not server-bound key sequences.
@@ -186,22 +225,13 @@ function handleKeydown(ev: KeyboardEvent): void {
186
225
  if (ev.code === "KeyC") {
187
226
  const sel = window.getSelection()?.toString();
188
227
  if (sel) {
189
- void navigator.clipboard.writeText(sel).catch(() => {
190
- /* ignore */
191
- });
228
+ copyToClipboard(sel);
192
229
  }
193
230
  ev.preventDefault();
194
231
  return;
195
232
  }
196
233
  if (ev.code === "KeyV") {
197
- navigator.clipboard
198
- .readText()
199
- .then((text) => {
200
- send(bracketTextForPaste(prepareTextForTerminal(text)));
201
- })
202
- .catch(() => {
203
- /* ignore */
204
- });
234
+ pasteFromClipboard();
205
235
  ev.preventDefault();
206
236
  return;
207
237
  }
@@ -250,8 +280,17 @@ function maybeSendFirstResize(): void {
250
280
  }
251
281
 
252
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);
253
289
  ctxMenu.classList.remove("visible");
254
- ctxMenu.innerHTML = "";
290
+ ctxMenu.replaceChildren();
291
+ if (refocus) {
292
+ focusTerminal();
293
+ }
255
294
  }
256
295
 
257
296
  function showCtxMenu(x: number, y: number): void {
@@ -262,9 +301,7 @@ function showCtxMenu(x: number, y: number): void {
262
301
  const copyBtn = document.createElement("button");
263
302
  copyBtn.textContent = "Copy";
264
303
  copyBtn.addEventListener("click", () => {
265
- void navigator.clipboard.writeText(sel).catch(() => {
266
- /* ignore */
267
- });
304
+ copyToClipboard(sel);
268
305
  hideCtxMenu();
269
306
  });
270
307
  ctxMenu.appendChild(copyBtn);
@@ -285,14 +322,7 @@ function showCtxMenu(x: number, y: number): void {
285
322
  const pasteBtn = document.createElement("button");
286
323
  pasteBtn.textContent = "Paste";
287
324
  pasteBtn.addEventListener("click", () => {
288
- navigator.clipboard
289
- .readText()
290
- .then((text) => {
291
- send(bracketTextForPaste(prepareTextForTerminal(text)));
292
- })
293
- .catch(() => {
294
- /* ignore */
295
- });
325
+ pasteFromClipboard();
296
326
  hideCtxMenu();
297
327
  });
298
328
  ctxMenu.appendChild(pasteBtn);
@@ -317,24 +347,74 @@ function showCtxMenu(x: number, y: number): void {
317
347
  ctxMenu.style.top = `${top}px`;
318
348
  }
319
349
 
320
- export function mount(opts: MountOptions = {}): TerminalUI {
350
+ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
321
351
  const wsPath = opts.wsPath ?? DEFAULT_WS_PATH;
322
352
  const fontReady = opts.fontReady ?? DEFAULT_FONT_READY;
323
353
 
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");
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");
331
367
 
332
368
  isIOS =
333
369
  /iPad|iPhone|iPod/.test(navigator.userAgent) ||
334
370
  (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
335
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
+
336
416
  // --- Initialize layers ---
337
- status.init();
417
+ status.init({ banner, onGiveUp: dismissLoadingOverlay });
338
418
 
339
419
  render.init({
340
420
  output: outputEl,
@@ -346,17 +426,13 @@ export function mount(opts: MountOptions = {}): TerminalUI {
346
426
  // server cursor.
347
427
  onCursorMove: () => {
348
428
  composition.positionCompositionView();
349
- const p = predict.get();
350
- render.setPredictedCursor(p.row, p.col, p.active);
429
+ pushPredictedCursor();
351
430
  },
352
431
  });
353
432
  render.updateFontMetrics();
354
433
 
355
434
  // 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
- });
435
+ predict.subscribe(pushPredictedCursor);
360
436
 
361
437
  composition.init({
362
438
  textarea: input,
@@ -370,10 +446,7 @@ export function mount(opts: MountOptions = {}): TerminalUI {
370
446
  onUserScrollChange(scrolledUp) {
371
447
  // Toggle .scrolled-up on the toolbar so CSS can show/hide the
372
448
  // 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
- }
449
+ toolbar.classList.toggle("scrolled-up", scrolledUp);
377
450
  },
378
451
  });
379
452
 
@@ -385,14 +458,9 @@ export function mount(opts: MountOptions = {}): TerminalUI {
385
458
  onMessage(msg) {
386
459
  if (msg.type === "screen") {
387
460
  render.handleScreen(msg);
461
+ firstFrameRendered = true;
388
462
  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
- }
463
+ markReady();
396
464
  }
397
465
  predict.onScreenFrame(msg.cursor[0], msg.cursor[1], msg.cursorHidden);
398
466
  } else if (msg.type === "scroll") {
@@ -407,7 +475,7 @@ export function mount(opts: MountOptions = {}): TerminalUI {
407
475
  status.open();
408
476
  // Do NOT call render.resetScreen() here. resetScreen flips the
409
477
  // firstScreen flag, which causes the next screen frame to wipe
410
- // the entire #term-output DOM (including all scrollback above
478
+ // the entire .term-output DOM (including all scrollback above
411
479
  // the live viewport). On reconnect (e.g. iPad screen dim/wake)
412
480
  // that destroys history the user could otherwise scroll up to.
413
481
  // The server forces a full repaint via builder.Reset() on resume,
@@ -444,7 +512,7 @@ export function mount(opts: MountOptions = {}): TerminalUI {
444
512
  });
445
513
 
446
514
  // --- Input handling ---
447
- resetInputPlaceholder();
515
+ resetToPlaceholder(input);
448
516
 
449
517
  input.addEventListener("input", (e: Event) => {
450
518
  // While IME composition is in progress, the textarea fires `input`
@@ -458,19 +526,15 @@ export function mount(opts: MountOptions = {}): TerminalUI {
458
526
  const ev = e as InputEvent;
459
527
  const inputType = ev.inputType;
460
528
 
461
- if (inputType === "deleteContentBackward") {
462
- // Handled by keydown — just re-pad the placeholder so iOS
529
+ if (
530
+ inputType === "deleteContentBackward" ||
531
+ inputType === "deleteContentForward" ||
532
+ inputType === "deleteWordBackward" ||
533
+ inputType === "deleteWordForward"
534
+ ) {
535
+ // Deletion is handled by keydown — just re-pad the placeholder so iOS
463
536
  // 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();
537
+ resetToPlaceholder(input);
474
538
  return;
475
539
  } else if (typeof ev.data === "string" && ev.data.length > 0) {
476
540
  // insertText / insertFromPaste / insertReplacementText etc. The
@@ -482,19 +546,29 @@ export function mount(opts: MountOptions = {}): TerminalUI {
482
546
  // space byte. (Native paste is bracketed in composition.ts's paste
483
547
  // handler before this fires; the insertFromPaste data here is a
484
548
  // fallback for browsers that don't raise a separate paste event.)
485
- send(applyStickyCtrl(ev.data.replace(/\u00A0/g, " ")));
549
+ if (inputType === "insertFromPaste") {
550
+ // Paste arriving through `input` without a preceding `paste` event
551
+ // (composition.ts's onPaste handles + preventDefaults the normal
552
+ // case). Bracket + sanitize it exactly like the Ctrl+Shift+V,
553
+ // context-menu Paste, and composition.onPaste paths so embedded
554
+ // newlines / control bytes arrive as an inert bracketed paste
555
+ // instead of executing as typed shell commands (paste-jacking).
556
+ send(bracketTextForPaste(prepareTextForTerminal(ev.data)));
557
+ } else {
558
+ sendTyped(ev.data);
559
+ }
486
560
  } else {
487
561
  // Fallback: anything in the textarea past the placeholder is new
488
562
  // content. Covers browsers that don't populate inputType / data
489
563
  // (older WebKit).
490
564
  const v = input.value;
491
565
  if (v.length > INPUT_PLACEHOLDER.length && v.startsWith(INPUT_PLACEHOLDER)) {
492
- send(applyStickyCtrl(v.slice(INPUT_PLACEHOLDER.length).replace(/\u00A0/g, " ")));
566
+ sendTyped(v.slice(INPUT_PLACEHOLDER.length));
493
567
  } else if (v !== INPUT_PLACEHOLDER && v.length > 0) {
494
- send(applyStickyCtrl(v.replace(/\u00A0/g, " ")));
568
+ sendTyped(v);
495
569
  }
496
570
  }
497
- resetInputPlaceholder();
571
+ resetToPlaceholder(input);
498
572
  });
499
573
 
500
574
  // Focus state on the terminal element, for CSS targeting (e.g. dimming
@@ -506,16 +580,16 @@ export function mount(opts: MountOptions = {}): TerminalUI {
506
580
  // Restore the placeholder so the held-Backspace iOS path stays
507
581
  // primed for the next focus. Also clears any leftover screen-reader
508
582
  // text (xterm.js convention).
509
- resetInputPlaceholder();
583
+ resetToPlaceholder(input);
510
584
  termWrap.classList.remove("focus");
511
585
  });
512
586
 
513
587
  // The textarea is the single keyboard target (desktop and touch alike);
514
- // #term-output is display-only and never focused.
588
+ // .term-output is display-only and never focused.
515
589
  input.addEventListener("keydown", handleKeydown);
516
590
 
517
591
  // --- Focus strategy ---
518
- // One element, one job. #term-output is display + native selection only
592
+ // One element, one job. .term-output is display + native selection only
519
593
  // and is NEVER focused; the textarea owns the keyboard, the local typing
520
594
  // buffer, and IME. Because the editable element is never the scroll
521
595
  // content, the first touch-drag scrolls instead of placing a caret and a
@@ -551,6 +625,11 @@ export function mount(opts: MountOptions = {}): TerminalUI {
551
625
  if (e.pointerType !== "touch") {
552
626
  return; // mouse/trackpad handled by the click listener below
553
627
  }
628
+ // A tap on a terminal link is opened by the click listener below; don't
629
+ // also focus the textarea (which pops the iOS soft keyboard) on the way.
630
+ if ((e.target as HTMLElement).closest(".term-link")) {
631
+ return;
632
+ }
554
633
  const dx = Math.abs(e.clientX - pointerDownX);
555
634
  const dy = Math.abs(e.clientY - pointerDownY);
556
635
  if (dx > TAP_MOVEMENT_PX || dy > TAP_MOVEMENT_PX) {
@@ -562,15 +641,17 @@ export function mount(opts: MountOptions = {}): TerminalUI {
562
641
  }
563
642
  // Synchronous focus inside pointerup keeps us inside iOS's
564
643
  // user-gesture window even when the streaming flush queue is busy.
565
- input.focus({ preventScroll: true });
644
+ focusTerminal();
566
645
  },
567
646
  { passive: true },
568
647
  );
569
648
  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.
649
+ // Terminal links: URLs rendered by the engine are wrapped in
650
+ // <a class="term-link" target="_blank" rel="noopener noreferrer">. The
651
+ // output is display-only (never contenteditable -- see the input-model
652
+ // contract), so intercept the click to open with explicit
653
+ // noopener/noreferrer and to keep the focus handler below from running
654
+ // on a link tap.
574
655
  const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(".term-link");
575
656
  if (link) {
576
657
  e.preventDefault();
@@ -617,15 +698,34 @@ export function mount(opts: MountOptions = {}): TerminalUI {
617
698
 
618
699
  // Only wait for the Regular weight — it determines cell size.
619
700
  // 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(() => {
701
+ // A missing/slow font, or a malformed `fontReady` (FontFaceSet.load can throw
702
+ // synchronously on a bad shorthand), must NOT leave the terminal permanently
703
+ // unsized: degrade to fallback-font metrics so the first resize still fires
704
+ // and the loading overlay still dismisses.
705
+ const onFontSettled = (): void => {
624
706
  fontsLoaded = true;
707
+ if (firstFrameRendered) {
708
+ markReady();
709
+ }
625
710
  requestAnimationFrame(() => {
626
711
  maybeSendFirstResize();
627
712
  });
628
- });
713
+ };
714
+ try {
715
+ void document.fonts
716
+ .load(fontReady)
717
+ .then(onFontSettled)
718
+ .catch((err: unknown) => {
719
+ console.warn(
720
+ `web-terminal-ui: web font ${fontReady} failed to load; using fallback metrics`,
721
+ err,
722
+ );
723
+ onFontSettled();
724
+ });
725
+ } catch (err) {
726
+ console.warn(`web-terminal-ui: invalid fontReady ${fontReady}; using fallback metrics`, err);
727
+ onFontSettled();
728
+ }
629
729
 
630
730
  // Connect immediately — the WS open triggers maybeSendFirstResize.
631
731
  render.updateFontMetrics();
@@ -651,21 +751,19 @@ export function mount(opts: MountOptions = {}): TerminalUI {
651
751
  });
652
752
 
653
753
  // --- 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
- }
754
+ const scrollBtn = pick(root, ".kb-scroll-bottom");
755
+ // pointerdown (like the other toolbar keys) so touch devices enter
756
+ // :active and show press feedback; preventDefault keeps focus on the
757
+ // terminal. click is kept for keyboard activation of the desktop FAB.
758
+ // scrollToBottom() is idempotent, so the pointerdown+click pair on a
759
+ // mouse press is harmless.
760
+ scrollBtn.addEventListener("pointerdown", (e) => {
761
+ e.preventDefault();
762
+ scroll.scrollToBottom();
763
+ });
764
+ scrollBtn.addEventListener("click", () => {
765
+ scroll.scrollToBottom();
766
+ });
669
767
 
670
768
  // --- Context menu ---
671
769
  termWrap.addEventListener("contextmenu", (e: MouseEvent) => {
@@ -749,45 +847,28 @@ export function mount(opts: MountOptions = {}): TerminalUI {
749
847
  hideCtxMenu();
750
848
  });
751
849
 
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
- });
850
+ // Escape dismisses the context menu (keyboard parity with the outside-click
851
+ // dismiss; the menu can be opened via the keyboard Menu key, which fires
852
+ // `contextmenu`). Without this an AT/keyboard user has no keyboard way to close it.
853
+ document.addEventListener("keydown", (e) => {
854
+ if (e.key === "Escape" && ctxMenu.classList.contains("visible")) {
855
+ hideCtxMenu();
782
856
  }
857
+ });
783
858
 
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
- }
859
+ // --- Mobile key toolbar ---
860
+ // The engine's keyboard.bindMobileToolbar wires the collapse toggle, the
861
+ // arrows (DECCKM-aware — SS3 under application-cursor mode, matching
862
+ // mapKeyboardEvent), Tab / Enter / Esc, and the sticky-Ctrl button on the
863
+ // built toolbar. It returns the controller whose applyStickyCtrl the input
864
+ // handler above applies to typed text. The no-transition priming below
865
+ // stays local (a UI-specific concern).
866
+ toolbarCtrl = keyboard.bindMobileToolbar({ toolbar, send });
867
+ requestAnimationFrame(() =>
868
+ requestAnimationFrame(() => {
869
+ toolbar.classList.remove("no-transition");
870
+ }),
871
+ );
791
872
 
792
873
  // --- Copy feedback toast ---
793
874
  document.addEventListener("copy", () => {