@cplieger/web-terminal-ui 2.0.0 → 2.1.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/css/00-tokens.css CHANGED
@@ -30,4 +30,5 @@
30
30
  --ease-exit: cubic-bezier(0.4, 0, 1, 1);
31
31
  --blur: blur(8px);
32
32
  --safe-top: env(safe-area-inset-top, 0px);
33
+ --safe-bottom: env(safe-area-inset-bottom, 0px);
33
34
  }
package/css/02-app.css CHANGED
@@ -48,11 +48,10 @@
48
48
  client-side cap or a server-reported resume gap). A quiet, centered,
49
49
  non-selectable divider so the user knows scrollback doesn't go further up. */
50
50
  .term-trim-marker {
51
- color: color-mix(in oklch, var(--text) 45%, transparent);
51
+ color: color-mix(in oklch, var(--text) 60%, transparent);
52
52
  font-size: 0.6875rem;
53
53
  text-align: center;
54
54
  padding: 2px 0;
55
- opacity: 0.7;
56
55
  user-select: none;
57
56
  font-family: var(--font-ui);
58
57
  }
@@ -141,10 +140,11 @@
141
140
  display: block;
142
141
  }
143
142
 
144
- /* Connection-state banner: floats top-center with a subtle backdrop
145
- blur. Hidden until status.ts sets `.visible`. Distinct from the
146
- `.pill` family because it wants to span across the full top center
147
- visually rather than dock to a corner. */
143
+ /* Connection-state banner: bottom-center on touch (lifted above the
144
+ virtual keyboard via --kb-inset), top-center on desktop. Subtle
145
+ backdrop blur. Hidden until status.ts sets `.visible`. Distinct from
146
+ the `.pill` family because it wants to span the full width centrally
147
+ rather than dock to a corner. */
148
148
  .conn-banner {
149
149
  position: fixed;
150
150
  bottom: var(--sp-3);
@@ -208,6 +208,18 @@
208
208
  }
209
209
  }
210
210
 
211
+ /* Desktop (pointer: fine): move the banner to top-center so it no longer
212
+ shares the bottom-center anchor with the scroll-to-bottom FAB
213
+ (.kb-scroll-bottom). Sharing it let the banner (pointer-events:auto,
214
+ z-index:20) render over the FAB and intercept its click. Touch keeps
215
+ the bottom placement and the --kb-inset keyboard lift above. */
216
+ @media (pointer: fine) {
217
+ .conn-banner {
218
+ top: var(--sp-3);
219
+ bottom: auto;
220
+ }
221
+ }
222
+
211
223
  /* Clickable URLs */
212
224
  .term-link {
213
225
  color: inherit;
@@ -430,7 +442,7 @@
430
442
  z-index: 10;
431
443
  transition:
432
444
  opacity var(--dur-standard) var(--ease-standard),
433
- background-color 150ms var(--ease-standard),
445
+ background-color var(--dur-micro) var(--ease-standard),
434
446
  display var(--dur-standard) allow-discrete;
435
447
  }
436
448
 
@@ -696,9 +708,9 @@
696
708
  opacity: 0;
697
709
  scale: 0.95;
698
710
  transition:
699
- opacity 0.15s ease,
700
- scale 0.15s ease,
701
- display 0.15s allow-discrete;
711
+ opacity var(--dur-exit) var(--ease-standard),
712
+ scale var(--dur-exit) var(--ease-standard),
713
+ display var(--dur-exit) allow-discrete;
702
714
  }
703
715
 
704
716
  .ctx-menu.visible {
@@ -728,3 +740,15 @@
728
740
  .ctx-menu button:active {
729
741
  background: var(--text-active);
730
742
  }
743
+
744
+ /* Touch (long-press) menu: bump the rows to the 44px --touch-target
745
+ standard the rest of the touch UI uses (the key-toolbar grid is
746
+ repeat(4, var(--touch-target))). Desktop right-click keeps the
747
+ compact 24px menu. padding-block re-centres the label in the taller box. */
748
+ @media (pointer: coarse) {
749
+ .ctx-menu button {
750
+ min-height: var(--touch-target);
751
+ padding-block: var(--sp-2);
752
+ touch-action: manipulation;
753
+ }
754
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
4
4
  "type": "module",
5
5
  "description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: textarea input model, mobile key toolbar, context menu, IME, predictive echo, viewport/keyboard-inset handling, and CSS. One mount() call.",
6
6
  "license": "GPL-3.0-or-later",
@@ -31,8 +31,8 @@
31
31
  "fast-check": "4.8.0",
32
32
  "happy-dom": "20.10.6",
33
33
  "html-validate": "11.5.3",
34
- "knip": "6.21.0",
35
- "prettier": "3.8.5",
34
+ "knip": "6.23.0",
35
+ "prettier": "3.9.1",
36
36
  "stylelint": "17.14.0",
37
37
  "stylelint-config-standard": "40.0.0",
38
38
  "typescript": "6.0.3",
@@ -27,14 +27,9 @@
27
27
  // for the same reason.
28
28
 
29
29
  import { keyboard } from "@cplieger/web-terminal-engine";
30
+ import { resetToPlaceholder } from "./input-placeholder.js";
30
31
  const { bracketTextForPaste, prepareTextForTerminal } = keyboard;
31
32
 
32
- // Single-character invisible placeholder used by app.ts to keep the
33
- // iOS soft keyboard's held-Backspace auto-repeat firing. Composition
34
- // finalisation must restore the placeholder (and not empty the
35
- // textarea) for the same reason.
36
- const INPUT_PLACEHOLDER = "\u00A0";
37
-
38
33
  let textarea: HTMLTextAreaElement;
39
34
  let compositionView: HTMLElement;
40
35
  let getCursorPx: () => { left: number; top: number; cellH: number };
@@ -43,7 +38,6 @@ let send: (bytes: string) => void;
43
38
  let composing = false;
44
39
  let sendingComposition = false;
45
40
  let compositionStart = 0;
46
- let compositionEnd = 0;
47
41
  let compositionSuffix = "";
48
42
 
49
43
  export function init(opts: {
@@ -75,7 +69,7 @@ function onStart(): void {
75
69
  const start = textarea.selectionStart;
76
70
  const end = textarea.selectionEnd;
77
71
  compositionStart = Math.min(start, end);
78
- compositionEnd = Math.max(start, end);
72
+ const compositionEnd = Math.max(start, end);
79
73
  compositionSuffix = textarea.value.substring(compositionEnd);
80
74
  compositionView.textContent = "";
81
75
  compositionView.classList.add("active");
@@ -88,12 +82,6 @@ function onUpdate(ev: CompositionEvent): void {
88
82
  // clipped at the start. Pattern from xterm.js.
89
83
  compositionView.textContent = `\u200E${ev.data}\u200E`;
90
84
  positionCompositionView();
91
- // Schedule a microtask to refresh the end position after the
92
- // textarea selection settles.
93
- setTimeout(() => {
94
- const end = textarea.selectionEnd;
95
- compositionEnd = Math.max(compositionStart, end);
96
- }, 0);
97
85
  }
98
86
 
99
87
  function onEnd(): void {
@@ -119,7 +107,7 @@ function onEnd(): void {
119
107
  if (composed.length > 0) {
120
108
  send(composed);
121
109
  }
122
- resetTextareaToPlaceholder();
110
+ resetToPlaceholder(textarea);
123
111
  }, 0);
124
112
  }
125
113
 
@@ -140,17 +128,7 @@ function onPaste(ev: ClipboardEvent): void {
140
128
  // Restore the placeholder so the subsequent `input` event (some
141
129
  // browsers fire it after paste) and held-Backspace auto-repeat keep
142
130
  // working.
143
- resetTextareaToPlaceholder();
144
- }
145
-
146
- function resetTextareaToPlaceholder(): void {
147
- textarea.value = INPUT_PLACEHOLDER;
148
- try {
149
- textarea.setSelectionRange(INPUT_PLACEHOLDER.length, INPUT_PLACEHOLDER.length);
150
- } catch {
151
- // Some older WebKit builds throw on setSelectionRange against a
152
- // visually-hidden textarea; ignore.
153
- }
131
+ resetToPlaceholder(textarea);
154
132
  }
155
133
 
156
134
  /** Position the composition view (and the helper textarea, which
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // @cplieger/web-terminal-ui — the reference touch-first browser UI built on
2
- // the @cplieger/web-terminal-engine engine. The public surface is a single mount()
3
- // call against the shipped scaffold (scaffold/index.html) + CSS (css/).
2
+ // the @cplieger/web-terminal-engine engine. The public surface is a single
3
+ // mount(root) call that builds the whole terminal subtree inside a
4
+ // host-provided element; scaffold/index.html + css/ are a reference page to copy.
4
5
  //
5
6
  // Consumers who want a different UI should depend on @cplieger/web-terminal-engine
6
7
  // directly and wire the engine's render/scroll/connection/keyboard modules
@@ -0,0 +1,29 @@
1
+ // Single source of truth for the hidden-textarea placeholder shared by
2
+ // mount.ts and composition.ts.
3
+ //
4
+ // A single-character placeholder is kept in the hidden textarea so iOS soft
5
+ // keyboards have something to "delete" when the user holds Backspace. iOS only
6
+ // fires repeating `input` events with inputType="deleteContentBackward" when
7
+ // the textarea has content to delete; with a perpetually empty textarea,
8
+ // holding Backspace deletes one char and stops because iOS sees nothing more to
9
+ // remove. The placeholder itself is invisible (textarea has opacity:0) and is
10
+ // stripped out of every send. NBSP is chosen specifically so screen-reader
11
+ // announcement of the input state stays empty-ish rather than "space".
12
+ //
13
+ // The constant AND the reset routine must stay in lockstep across the two
14
+ // modules (mount.ts seeds + re-pads it; composition.ts restores it on
15
+ // compositionend / paste) or the iOS held-Backspace key-repeat silently breaks
16
+ // with no compile error — which is why they live here in one place.
17
+ export const INPUT_PLACEHOLDER = "\u00A0";
18
+
19
+ // Restore the placeholder and put the cursor at the end, so the next typed
20
+ // character appends after it rather than before.
21
+ export function resetToPlaceholder(textarea: HTMLTextAreaElement): void {
22
+ textarea.value = INPUT_PLACEHOLDER;
23
+ try {
24
+ textarea.setSelectionRange(INPUT_PLACEHOLDER.length, INPUT_PLACEHOLDER.length);
25
+ } catch {
26
+ // Some older WebKit builds throw on setSelectionRange against a
27
+ // visually-hidden textarea; ignore.
28
+ }
29
+ }
package/src/mount.ts CHANGED
@@ -18,6 +18,7 @@ import * as viewport from "./viewport.js";
18
18
  import * as composition from "./composition.js";
19
19
  import * as status from "./status.js";
20
20
  import * as predict from "./predict.js";
21
+ import { INPUT_PLACEHOLDER, resetToPlaceholder } from "./input-placeholder.js";
21
22
 
22
23
  const { mapKeyboardEvent, bracketTextForPaste, prepareTextForTerminal } = keyboard;
23
24
 
@@ -44,34 +45,22 @@ export interface TerminalUI {
44
45
  const DEFAULT_WS_PATH = "/ws";
45
46
  const DEFAULT_FONT_READY = '14px "MonaspiceNe NFM"';
46
47
 
47
- // Single-character placeholder kept in the hidden textarea so iOS soft
48
- // keyboards have something to "delete" when the user holds Backspace.
49
- // iOS only fires repeating `input` events with
50
- // inputType="deleteContentBackward" when the textarea has content to
51
- // delete; with a perpetually empty textarea, holding Backspace deletes
52
- // one char and stops because iOS sees nothing more to remove. The
53
- // placeholder itself is invisible (textarea has opacity:0) and we strip
54
- // it out of every send. NBSP chosen specifically so screen-reader
55
- // announcement of the input state stays empty-ish rather than "space".
56
- const INPUT_PLACEHOLDER = "\u00A0";
57
-
58
48
  const TAP_MOVEMENT_PX = 10;
59
49
  const LONG_PRESS_MS = 500;
60
50
  const LONG_PRESS_MOVE_THRESHOLD_PX = 10;
61
51
 
62
52
  const encoder = new TextEncoder();
63
53
 
64
- // --- DOM refs (assigned in mount() from the scaffold) ---
54
+ // --- DOM refs (picked by class from the subtree mount() builds) ---
65
55
  let outputEl!: HTMLElement;
66
56
  let termWrap!: HTMLElement;
67
57
  let input!: HTMLTextAreaElement;
68
- let compositionViewEl!: HTMLElement;
69
58
  let ctxMenu!: HTMLElement;
70
59
 
71
60
  // --- Mutable UI state ---
72
61
  // Sticky-Ctrl state + the C0 mapping table + the toolbar button wiring all
73
62
  // live in the engine's keyboard.bindMobileToolbar; mount() binds it against
74
- // the required #key-toolbar and keeps the controller so the input handler
63
+ // the built .key-toolbar element and keeps the controller so the input handler
75
64
  // can route typed text through its applyStickyCtrl.
76
65
  let toolbarCtrl!: keyboard.MobileToolbarController;
77
66
  let fontsLoaded = false;
@@ -132,33 +121,83 @@ function pick(root: ParentNode, selector: string): HTMLElement {
132
121
  }
133
122
 
134
123
  function send(bytes: string): void {
135
- // Suppress backspace (DEL) when the predicted cursor is at column 0
136
- // — there's nothing left to delete. This mimics the natural brake
137
- // that iOS's textarea provided (stops key-repeat when empty).
138
- 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) {
139
134
  return;
140
135
  }
141
136
  const buf = encoder.encode(bytes);
142
- 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.
143
141
  if (!connection.sendBinary(buf)) {
144
- /* connection not ready — drop silently */
142
+ return;
145
143
  }
144
+ predict.applyInput(buf);
146
145
  }
147
146
 
148
- function resetInputPlaceholder(): void {
149
- input.value = INPUT_PLACEHOLDER;
150
- // Cursor at the end so the next typed char appends after the
151
- // placeholder rather than before.
152
- try {
153
- input.setSelectionRange(INPUT_PLACEHOLDER.length, INPUT_PLACEHOLDER.length);
154
- } catch {
155
- // Ignore browsers that throw on setSelectionRange against a hidden
156
- // input (some older WebKit builds).
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;
157
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
+ });
158
197
  }
159
198
 
160
199
  // Keydown handler — attached to the textarea (the single keyboard target,
161
- // 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.
162
201
  function handleKeydown(ev: KeyboardEvent): void {
163
202
  // While composing (IME), let the browser pump composition events;
164
203
  // keydown bytes during composition would duplicate the composed text.
@@ -166,6 +205,19 @@ function handleKeydown(ev: KeyboardEvent): void {
166
205
  return;
167
206
  }
168
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
+
169
221
  // Ctrl+Shift+C / Ctrl+Shift+V — desktop clipboard shortcuts. Handled
170
222
  // before the generic mapper because they take browser-side selection
171
223
  // and clipboard, not server-bound key sequences.
@@ -173,22 +225,13 @@ function handleKeydown(ev: KeyboardEvent): void {
173
225
  if (ev.code === "KeyC") {
174
226
  const sel = window.getSelection()?.toString();
175
227
  if (sel) {
176
- void navigator.clipboard.writeText(sel).catch(() => {
177
- /* ignore */
178
- });
228
+ copyToClipboard(sel);
179
229
  }
180
230
  ev.preventDefault();
181
231
  return;
182
232
  }
183
233
  if (ev.code === "KeyV") {
184
- navigator.clipboard
185
- .readText()
186
- .then((text) => {
187
- send(bracketTextForPaste(prepareTextForTerminal(text)));
188
- })
189
- .catch(() => {
190
- /* ignore */
191
- });
234
+ pasteFromClipboard();
192
235
  ev.preventDefault();
193
236
  return;
194
237
  }
@@ -237,8 +280,17 @@ function maybeSendFirstResize(): void {
237
280
  }
238
281
 
239
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);
240
289
  ctxMenu.classList.remove("visible");
241
- ctxMenu.innerHTML = "";
290
+ ctxMenu.replaceChildren();
291
+ if (refocus) {
292
+ focusTerminal();
293
+ }
242
294
  }
243
295
 
244
296
  function showCtxMenu(x: number, y: number): void {
@@ -249,9 +301,7 @@ function showCtxMenu(x: number, y: number): void {
249
301
  const copyBtn = document.createElement("button");
250
302
  copyBtn.textContent = "Copy";
251
303
  copyBtn.addEventListener("click", () => {
252
- void navigator.clipboard.writeText(sel).catch(() => {
253
- /* ignore */
254
- });
304
+ copyToClipboard(sel);
255
305
  hideCtxMenu();
256
306
  });
257
307
  ctxMenu.appendChild(copyBtn);
@@ -272,14 +322,7 @@ function showCtxMenu(x: number, y: number): void {
272
322
  const pasteBtn = document.createElement("button");
273
323
  pasteBtn.textContent = "Paste";
274
324
  pasteBtn.addEventListener("click", () => {
275
- navigator.clipboard
276
- .readText()
277
- .then((text) => {
278
- send(bracketTextForPaste(prepareTextForTerminal(text)));
279
- })
280
- .catch(() => {
281
- /* ignore */
282
- });
325
+ pasteFromClipboard();
283
326
  hideCtxMenu();
284
327
  });
285
328
  ctxMenu.appendChild(pasteBtn);
@@ -317,7 +360,7 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
317
360
  termWrap = pick(root, ".term");
318
361
  outputEl = pick(root, ".term-output");
319
362
  input = pick(root, ".term-input") as HTMLTextAreaElement;
320
- compositionViewEl = pick(root, ".composition-view");
363
+ const compositionViewEl = pick(root, ".composition-view");
321
364
  ctxMenu = pick(root, ".ctx-menu");
322
365
  const banner = pick(root, ".conn-banner");
323
366
  const toolbar = pick(root, ".key-toolbar");
@@ -331,23 +374,47 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
331
374
  // may show reconnect state from here on) and fades out the consumer's
332
375
  // loading overlay if one was passed.
333
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
+ }
334
407
  function markReady(): void {
335
408
  if (ready) {
336
409
  return;
337
410
  }
338
411
  ready = true;
339
412
  status.setLoaded();
340
- const ld = opts.loading;
341
- if (ld) {
342
- ld.classList.add("fade");
343
- ld.addEventListener("transitionend", () => {
344
- ld.remove();
345
- });
346
- }
413
+ dismissLoadingOverlay();
347
414
  }
348
415
 
349
416
  // --- Initialize layers ---
350
- status.init({ banner });
417
+ status.init({ banner, onGiveUp: dismissLoadingOverlay });
351
418
 
352
419
  render.init({
353
420
  output: outputEl,
@@ -359,17 +426,13 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
359
426
  // server cursor.
360
427
  onCursorMove: () => {
361
428
  composition.positionCompositionView();
362
- const p = predict.get();
363
- render.setPredictedCursor(p.row, p.col, p.active);
429
+ pushPredictedCursor();
364
430
  },
365
431
  });
366
432
  render.updateFontMetrics();
367
433
 
368
434
  // predict redraws on every change to its predicted-cursor state.
369
- predict.subscribe(() => {
370
- const p = predict.get();
371
- render.setPredictedCursor(p.row, p.col, p.active);
372
- });
435
+ predict.subscribe(pushPredictedCursor);
373
436
 
374
437
  composition.init({
375
438
  textarea: input,
@@ -395,6 +458,7 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
395
458
  onMessage(msg) {
396
459
  if (msg.type === "screen") {
397
460
  render.handleScreen(msg);
461
+ firstFrameRendered = true;
398
462
  if (fontsLoaded) {
399
463
  markReady();
400
464
  }
@@ -411,7 +475,7 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
411
475
  status.open();
412
476
  // Do NOT call render.resetScreen() here. resetScreen flips the
413
477
  // firstScreen flag, which causes the next screen frame to wipe
414
- // the entire #term-output DOM (including all scrollback above
478
+ // the entire .term-output DOM (including all scrollback above
415
479
  // the live viewport). On reconnect (e.g. iPad screen dim/wake)
416
480
  // that destroys history the user could otherwise scroll up to.
417
481
  // The server forces a full repaint via builder.Reset() on resume,
@@ -448,7 +512,7 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
448
512
  });
449
513
 
450
514
  // --- Input handling ---
451
- resetInputPlaceholder();
515
+ resetToPlaceholder(input);
452
516
 
453
517
  input.addEventListener("input", (e: Event) => {
454
518
  // While IME composition is in progress, the textarea fires `input`
@@ -462,19 +526,15 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
462
526
  const ev = e as InputEvent;
463
527
  const inputType = ev.inputType;
464
528
 
465
- if (inputType === "deleteContentBackward") {
466
- // 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
467
536
  // key-repeat keeps firing (it needs content to delete).
468
- resetInputPlaceholder();
469
- return;
470
- } else if (inputType === "deleteContentForward") {
471
- resetInputPlaceholder();
472
- return;
473
- } else if (inputType === "deleteWordBackward") {
474
- resetInputPlaceholder();
475
- return;
476
- } else if (inputType === "deleteWordForward") {
477
- resetInputPlaceholder();
537
+ resetToPlaceholder(input);
478
538
  return;
479
539
  } else if (typeof ev.data === "string" && ev.data.length > 0) {
480
540
  // insertText / insertFromPaste / insertReplacementText etc. The
@@ -486,21 +546,29 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
486
546
  // space byte. (Native paste is bracketed in composition.ts's paste
487
547
  // handler before this fires; the insertFromPaste data here is a
488
548
  // fallback for browsers that don't raise a separate paste event.)
489
- send(toolbarCtrl.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
+ }
490
560
  } else {
491
561
  // Fallback: anything in the textarea past the placeholder is new
492
562
  // content. Covers browsers that don't populate inputType / data
493
563
  // (older WebKit).
494
564
  const v = input.value;
495
565
  if (v.length > INPUT_PLACEHOLDER.length && v.startsWith(INPUT_PLACEHOLDER)) {
496
- send(
497
- toolbarCtrl.applyStickyCtrl(v.slice(INPUT_PLACEHOLDER.length).replace(/\u00A0/g, " ")),
498
- );
566
+ sendTyped(v.slice(INPUT_PLACEHOLDER.length));
499
567
  } else if (v !== INPUT_PLACEHOLDER && v.length > 0) {
500
- send(toolbarCtrl.applyStickyCtrl(v.replace(/\u00A0/g, " ")));
568
+ sendTyped(v);
501
569
  }
502
570
  }
503
- resetInputPlaceholder();
571
+ resetToPlaceholder(input);
504
572
  });
505
573
 
506
574
  // Focus state on the terminal element, for CSS targeting (e.g. dimming
@@ -512,16 +580,16 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
512
580
  // Restore the placeholder so the held-Backspace iOS path stays
513
581
  // primed for the next focus. Also clears any leftover screen-reader
514
582
  // text (xterm.js convention).
515
- resetInputPlaceholder();
583
+ resetToPlaceholder(input);
516
584
  termWrap.classList.remove("focus");
517
585
  });
518
586
 
519
587
  // The textarea is the single keyboard target (desktop and touch alike);
520
- // #term-output is display-only and never focused.
588
+ // .term-output is display-only and never focused.
521
589
  input.addEventListener("keydown", handleKeydown);
522
590
 
523
591
  // --- Focus strategy ---
524
- // One element, one job. #term-output is display + native selection only
592
+ // One element, one job. .term-output is display + native selection only
525
593
  // and is NEVER focused; the textarea owns the keyboard, the local typing
526
594
  // buffer, and IME. Because the editable element is never the scroll
527
595
  // content, the first touch-drag scrolls instead of placing a caret and a
@@ -557,6 +625,11 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
557
625
  if (e.pointerType !== "touch") {
558
626
  return; // mouse/trackpad handled by the click listener below
559
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
+ }
560
633
  const dx = Math.abs(e.clientX - pointerDownX);
561
634
  const dy = Math.abs(e.clientY - pointerDownY);
562
635
  if (dx > TAP_MOVEMENT_PX || dy > TAP_MOVEMENT_PX) {
@@ -568,15 +641,36 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
568
641
  }
569
642
  // Synchronous focus inside pointerup keeps us inside iOS's
570
643
  // user-gesture window even when the streaming flush queue is busy.
571
- input.focus({ preventScroll: true });
644
+ focusTerminal();
572
645
  },
573
646
  { passive: true },
574
647
  );
648
+ // After a touch tap, iOS/WebKit dispatches a synthetic `mousedown` (following
649
+ // the `pointerup` above). Its default action moves focus to the nearest
650
+ // focusable ancestor of the tap point — but the input-model contract keeps
651
+ // `.term-output` and `.term` deliberately non-focusable (display-only) and the
652
+ // hidden textarea is `pointer-events: none`, so the tap never lands on a
653
+ // focusable node. With nothing focusable under the tap, that default blurs the
654
+ // textarea we just focused in `pointerup`, so iOS immediately dismisses the
655
+ // soft keyboard the tap had only just opened — the "keyboard flashes open then
656
+ // instantly closes" symptom on touch. Cancelling the synthetic mousedown's
657
+ // default keeps focus on the textarea, so the keyboard stays up. This is the
658
+ // xterm.js focus-preservation pattern, scoped to touch: a real mouse mousedown
659
+ // must keep its default so desktop drag-to-select text still works, and iOS
660
+ // text selection is long-press-driven (not this tap mousedown), so it is
661
+ // unaffected. Not passive — preventDefault must take effect.
662
+ termWrap.addEventListener("mousedown", (e) => {
663
+ if (lastPointerType === "touch") {
664
+ e.preventDefault();
665
+ }
666
+ });
575
667
  termWrap.addEventListener("click", (e) => {
576
- // Terminal links: URLs detected by render.ts's linkifySpans are wrapped
577
- // in <a class="term-link" target="_blank">. In a contenteditable element
578
- // the browser treats link clicks as cursor-placement, not navigation.
579
- // Intercept and open explicitly.
668
+ // Terminal links: URLs rendered by the engine are wrapped in
669
+ // <a class="term-link" target="_blank" rel="noopener noreferrer">. The
670
+ // output is display-only (never contenteditable -- see the input-model
671
+ // contract), so intercept the click to open with explicit
672
+ // noopener/noreferrer and to keep the focus handler below from running
673
+ // on a link tap.
580
674
  const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(".term-link");
581
675
  if (link) {
582
676
  e.preventDefault();
@@ -623,15 +717,34 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
623
717
 
624
718
  // Only wait for the Regular weight — it determines cell size.
625
719
  // Bold/Italic load lazily when first used; style pop is barely noticeable.
626
- const regularFont = document.fonts.load(fontReady).then(() => {
627
- /* discard result */
628
- });
629
- void regularFont.then(() => {
720
+ // A missing/slow font, or a malformed `fontReady` (FontFaceSet.load can throw
721
+ // synchronously on a bad shorthand), must NOT leave the terminal permanently
722
+ // unsized: degrade to fallback-font metrics so the first resize still fires
723
+ // and the loading overlay still dismisses.
724
+ const onFontSettled = (): void => {
630
725
  fontsLoaded = true;
726
+ if (firstFrameRendered) {
727
+ markReady();
728
+ }
631
729
  requestAnimationFrame(() => {
632
730
  maybeSendFirstResize();
633
731
  });
634
- });
732
+ };
733
+ try {
734
+ void document.fonts
735
+ .load(fontReady)
736
+ .then(onFontSettled)
737
+ .catch((err: unknown) => {
738
+ console.warn(
739
+ `web-terminal-ui: web font ${fontReady} failed to load; using fallback metrics`,
740
+ err,
741
+ );
742
+ onFontSettled();
743
+ });
744
+ } catch (err) {
745
+ console.warn(`web-terminal-ui: invalid fontReady ${fontReady}; using fallback metrics`, err);
746
+ onFontSettled();
747
+ }
635
748
 
636
749
  // Connect immediately — the WS open triggers maybeSendFirstResize.
637
750
  render.updateFontMetrics();
@@ -753,6 +866,15 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
753
866
  hideCtxMenu();
754
867
  });
755
868
 
869
+ // Escape dismisses the context menu (keyboard parity with the outside-click
870
+ // dismiss; the menu can be opened via the keyboard Menu key, which fires
871
+ // `contextmenu`). Without this an AT/keyboard user has no keyboard way to close it.
872
+ document.addEventListener("keydown", (e) => {
873
+ if (e.key === "Escape" && ctxMenu.classList.contains("visible")) {
874
+ hideCtxMenu();
875
+ }
876
+ });
877
+
756
878
  // --- Mobile key toolbar ---
757
879
  // The engine's keyboard.bindMobileToolbar wires the collapse toggle, the
758
880
  // arrows (DECCKM-aware — SS3 under application-cursor mode, matching
package/src/predict.ts CHANGED
@@ -48,6 +48,8 @@ export function setDimensions(c: number, r: number): void {
48
48
  if (r > 0) {
49
49
  rows = r;
50
50
  }
51
+ const prevCol = predCol;
52
+ const prevRow = predRow;
51
53
  // Clamp predicted position to new bounds so a resize mid-typing
52
54
  // doesn't leave predCol/predRow past the edge.
53
55
  if (predCol >= cols) {
@@ -56,6 +58,12 @@ export function setDimensions(c: number, r: number): void {
56
58
  if (predRow >= rows) {
57
59
  predRow = rows - 1;
58
60
  }
61
+ // Notify subscribers when the clamp moved the predicted cursor, so the
62
+ // renderer's overlay doesn't show a stale (possibly off-screen) position
63
+ // until the next server frame arrives.
64
+ if (predCol !== prevCol || predRow !== prevRow) {
65
+ onChange?.();
66
+ }
59
67
  }
60
68
 
61
69
  /** Reset all prediction state. Called on server restart to avoid
@@ -66,9 +74,7 @@ export function reset(): void {
66
74
  predActive = false;
67
75
  predPendingWrap = false;
68
76
  predFrozen = false;
69
- if (onChange) {
70
- onChange();
71
- }
77
+ onChange?.();
72
78
  }
73
79
 
74
80
  /** Reset prediction to the server's reported cursor and re-arm.
@@ -81,9 +87,7 @@ export function onScreenFrame(serverRow: number, serverCol: number, cursorHidden
81
87
  predPendingWrap = false;
82
88
  predFrozen = cursorHidden ?? false;
83
89
  predActive = !predFrozen;
84
- if (onChange) {
85
- onChange();
86
- }
90
+ onChange?.();
87
91
  }
88
92
 
89
93
  /** Apply locally-typed input bytes to the predicted cursor. Bails out
@@ -100,9 +104,7 @@ export function applyInput(bytes: Uint8Array): void {
100
104
  if (b === 0x1b) {
101
105
  // ESC — start of an escape sequence. Don't try to model it.
102
106
  predActive = false;
103
- if (onChange) {
104
- onChange();
105
- }
107
+ onChange?.();
106
108
  return;
107
109
  }
108
110
  if (b === 0x08 || b === 0x7f) {
@@ -137,9 +139,7 @@ export function applyInput(bytes: Uint8Array): void {
137
139
  if (b < 0x20) {
138
140
  // Other C0 — probably interpreted by the application; bail.
139
141
  predActive = false;
140
- if (onChange) {
141
- onChange();
142
- }
142
+ onChange?.();
143
143
  return;
144
144
  }
145
145
  // Printable ASCII or UTF-8 lead byte. Detect the codepoint length
@@ -169,9 +169,7 @@ export function applyInput(bytes: Uint8Array): void {
169
169
  predCol++;
170
170
  }
171
171
  }
172
- if (onChange) {
173
- onChange();
174
- }
172
+ onChange?.();
175
173
  }
176
174
 
177
175
  /** Current predicted cursor state. `active` is false when prediction
package/src/status.ts CHANGED
@@ -1,20 +1,31 @@
1
- // Connection-state banner. Shows "Reconnecting…" / "Offline" / "Slow
2
- // connection" feedback so users on flaky links understand why typing
3
- // is unresponsive. Hidden by default; appears only when the
4
- // connection state diverges from "open" for more than a brief window
5
- // (debounced to avoid flicker on healthy networks).
1
+ // Connection-state banner. Shows "Reconnecting…" / "Offline" / "Server
2
+ // restarted" feedback so users on flaky links understand why typing is
3
+ // unresponsive. Hidden by default; appears only when the connection
4
+ // state diverges from "open" for more than a brief window (debounced to
5
+ // avoid flicker on healthy networks). A "slow connection" indicator is
6
+ // intentionally not implemented (see the state machine below).
6
7
  //
7
- // State machine:
8
- // open → no banner.
9
- // closed → "Reconnecting…" after CONNECTING_GRACE_MS, "Offline"
10
- // after several backoff failures.
11
- // reconnecting (during a connect retry) → "Reconnecting…".
12
- // slow → "Slow connection" overlaid on top of "open" when the
13
- // server's pingstat reports a capped RTO. (Not wired
14
- // yet; placeholder for a future ping-RTT feedback path.)
8
+ // State machine (matches the `State` union below):
9
+ // open → no banner.
10
+ // connecting → "Reconnecting…" (initial connect, before first open).
11
+ // reconnecting → "Reconnecting…" (after a close, during retry);
12
+ // escalates to offline after >3 consecutive failures.
13
+ // offline → "Offline".
14
+ // restarted → "Server restarted — recent input may have been lost".
15
+ // A "slow connection" indicator is intentionally not implemented: it would
16
+ // need an RTT signal the engine does not currently expose to consumers.
15
17
 
16
18
  const CONNECTING_GRACE_MS = 600;
17
19
 
20
+ // Consecutive failed *initial* connection attempts (before the first screen
21
+ // frame, i.e. while `loaded` is still false) after which the "Offline" banner
22
+ // is allowed to render anyway. Below this we stay silent and defer to the
23
+ // consumer's loading overlay, so a merely-slow first connect raises no false
24
+ // alarm; this many failures in a row means the connection genuinely isn't
25
+ // coming up and an indefinite silent "Loading…" would be worse than "Offline".
26
+ // Aligned with the post-load escalation threshold (consecutiveFailures > 3).
27
+ const INITIAL_FAILURE_LIMIT = 4;
28
+
18
29
  type State = "open" | "connecting" | "reconnecting" | "offline" | "restarted";
19
30
 
20
31
  let banner: HTMLElement | null = null;
@@ -26,6 +37,13 @@ let consecutiveFailures = 0;
26
37
  // reconnect/offline banner so the two don't stack — this replaces the old
27
38
  // `document.getElementById("loading")` probe.
28
39
  let loaded = false;
40
+ // Optional callback supplied by init(): invoked once the initial-connect
41
+ // failure limit is reached while still !loaded, so mount can fade its opaque
42
+ // loading overlay and let the "Offline" banner underneath become visible.
43
+ // mount always supplies this (its dismissLoadingOverlay self-guards when no
44
+ // overlay was passed), so onGiveUp is null only when init() is called without
45
+ // the callback (e.g. the status unit tests).
46
+ let onGiveUp: (() => void) | null = null;
29
47
 
30
48
  /** Mark the connection as open. Hides the banner. */
31
49
  export function open(): void {
@@ -51,9 +69,21 @@ export function reconnecting(): void {
51
69
  /** Mark the connection as closed; shows banner after grace period. */
52
70
  export function closed(): void {
53
71
  consecutiveFailures++;
54
- if (!loaded) {
72
+ // Before the first frame, stay suppressed (the loading overlay owns the
73
+ // screen) UNTIL we've failed to connect INITIAL_FAILURE_LIMIT times in a
74
+ // row — at that point the connection clearly isn't coming up, so let the
75
+ // "Offline" banner through rather than leave a silent "Loading…" forever.
76
+ if (!loaded && consecutiveFailures < INITIAL_FAILURE_LIMIT) {
55
77
  return;
56
78
  }
79
+ // Past the initial-failure limit while the loading overlay is still up: tell
80
+ // mount to fade it so the "Offline" banner below becomes visible rather than
81
+ // staying occluded behind the opaque overlay (which markReady would otherwise
82
+ // never remove on a never-connecting socket). Idempotent, so firing on each
83
+ // subsequent !loaded close is safe.
84
+ if (!loaded) {
85
+ onGiveUp?.();
86
+ }
57
87
  setState(consecutiveFailures > 3 ? "offline" : "reconnecting", CONNECTING_GRACE_MS);
58
88
  }
59
89
 
@@ -116,15 +146,17 @@ function dismissToast(): void {
116
146
  if (!banner) {
117
147
  return;
118
148
  }
119
- // Only clear if still showing a toast (not overridden by connection state).
120
- if (state === "open" && banner.dataset["state"] === "toast") {
121
- banner.classList.remove("visible");
122
- banner.textContent = "";
149
+ // Restore the live connection state. applyState() hides the banner when
150
+ // "open" (identical to the old clear) and re-renders "Offline"/"Reconnecting…"
151
+ // when a toast was shown over a non-open status, so the status is never stranded.
152
+ if (banner.dataset["state"] === "toast") {
153
+ applyState();
123
154
  }
124
155
  }
125
156
 
126
- export function init(opts: { banner: HTMLElement }): void {
157
+ export function init(opts: { banner: HTMLElement; onGiveUp?: () => void }): void {
127
158
  banner = opts.banner;
159
+ onGiveUp = opts.onGiveUp ?? null;
128
160
  applyState();
129
161
 
130
162
  // Hover-pause: stop the auto-dismiss timer while the user hovers
@@ -160,6 +192,17 @@ function applyState(): void {
160
192
  if (!banner) {
161
193
  return;
162
194
  }
195
+ // Stay fully suppressed until the initial load is over (matches the
196
+ // `loaded` guard in reconnecting()/closed()): the loading overlay, when
197
+ // present, owns the screen until then and a bare page has nothing to show.
198
+ // Exception: once INITIAL_FAILURE_LIMIT consecutive initial connects have
199
+ // failed, let "Offline" through even while !loaded so a never-connecting
200
+ // page doesn't sit on a silent "Loading…" indefinitely.
201
+ if (!loaded && !(state === "offline" && consecutiveFailures >= INITIAL_FAILURE_LIMIT)) {
202
+ banner.classList.remove("visible");
203
+ banner.textContent = "";
204
+ return;
205
+ }
163
206
  switch (state) {
164
207
  case "open":
165
208
  banner.classList.remove("visible");