@cplieger/web-terminal-ui 8.3.1 → 8.3.2

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/README.md CHANGED
@@ -97,7 +97,7 @@ The IME module follows the design of [xterm.js](https://github.com/xtermjs/xterm
97
97
 
98
98
  ## Contributing
99
99
 
100
- Issues and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the conventions and how to run the checks locally.
100
+ Issues and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
101
101
 
102
102
  ## Disclaimer
103
103
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "8.3.1",
3
+ "version": "8.3.2",
4
4
  "type": "module",
5
5
  "description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: a composable kernel + feature modules (tabs, activity monitor, mobile toolbar, context menu, IME, predictive echo, viewport handling) and CSS. One createTerminal() call.",
6
6
  "license": "MPL-2.0",
@@ -50,18 +50,18 @@
50
50
  "@stryker-mutator/core": "10.0.0",
51
51
  "@stryker-mutator/typescript-checker": "10.0.0",
52
52
  "@stryker-mutator/vitest-runner": "10.0.0",
53
- "@types/node": "26.6.3",
53
+ "@types/node": "26.6.4",
54
54
  "@typescript/native": "npm:typescript@7.0.2",
55
55
  "@vitest/browser": "5.0.3",
56
56
  "@vitest/browser-playwright": "5.0.3",
57
57
  "@vitest/coverage-v8": "5.0.3",
58
- "eslint": "10.11.0",
58
+ "eslint": "10.12.0",
59
59
  "fast-check": "4.10.2",
60
60
  "html-validate": "11.16.1",
61
61
  "knip": "6.39.0",
62
62
  "playwright": "1.63.0",
63
63
  "prettier": "3.9.9",
64
- "stylelint": "17.15.0",
64
+ "stylelint": "17.16.0",
65
65
  "stylelint-config-standard": "40.0.0",
66
66
  "typescript": "npm:@typescript/typescript6@6.0.2",
67
67
  "typescript-eslint": "8.71.0",
@@ -190,7 +190,7 @@ export function createComposition(opts: CompositionOptions): Composition {
190
190
 
191
191
  return {
192
192
  isComposing,
193
- isCompositionOpen: () => composing || pendingSend !== null,
193
+ isCompositionOpen: () => composing,
194
194
  cancelComposition,
195
195
  positionCompositionView,
196
196
  teardown,
@@ -226,9 +226,6 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
226
226
  pressSelection = selectionTextWithin(surface);
227
227
  };
228
228
  const onTouchMove = (e: TouchEvent): void => {
229
- if (!pressLive) {
230
- return;
231
- }
232
229
  const t = e.touches.length === 1 ? e.touches[0] : undefined;
233
230
  if (!t) {
234
231
  pressLive = false;
@@ -258,11 +255,9 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
258
255
  return; // this press selected text; the OS callout owns it
259
256
  }
260
257
  show(pressX, pressY);
261
- if (menu.classList.contains("visible")) {
262
- // Armed at the release edge, so the trailing click this same gesture
263
- // emits is always inside the window.
264
- swallow.arm();
265
- }
258
+ // Armed at the release edge, so the trailing click this same gesture
259
+ // emits is always inside the window.
260
+ swallow.arm();
266
261
  };
267
262
  const onTouchCancel = (): void => {
268
263
  pressLive = false;
@@ -49,7 +49,6 @@ export function predictiveEcho(): TerminalFeature {
49
49
  predict.reset();
50
50
  },
51
51
  teardown() {
52
- predict.reset();
53
52
  ctx.render.setPredictedCursor(0, 0, false);
54
53
  },
55
54
  };
@@ -545,11 +545,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
545
545
  }
546
546
  // forgetCueSeen drops an acknowledgement, so the session's NEXT input/done
547
547
  // is a fresh cue: called when its status moves off the acknowledged value
548
- // (a new working phase, an exit) and when the session goes away.
548
+ // (a new working phase, an exit) and when the session goes away. Every
549
+ // caller ends in syncChrome, which repaints the out-of-page surfaces.
549
550
  function forgetCueSeen(id: string): void {
550
551
  if (cueSeen.delete(id)) {
551
552
  writeCueSeen(win, cueSeen);
552
- paintAttention();
553
553
  }
554
554
  }
555
555
  // The out-of-page surfaces render the SAME unseen-cue set the switch dot
@@ -600,9 +600,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
600
600
  swSwitchDot.dataset["status"] = switchNotify;
601
601
  swSwitchDot.title = statusPhrase(switchNotify);
602
602
  }
603
- // The out-of-page surfaces answer to the same set, so they repaint
604
- // wherever this does: a raise, and every clear through clearSwitchNotify.
605
- paintAttention();
606
603
  }
607
604
  function clearSwitchNotify(): void {
608
605
  switchNotify = "";
@@ -931,7 +928,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
931
928
  }
932
929
  function keyboardParkedOnChrome(): boolean {
933
930
  const active = doc.activeElement;
934
- return active !== null && (bar.contains(active) || switcher.contains(active));
931
+ return bar.contains(active) || switcher.contains(active);
935
932
  }
936
933
  function keyboardInPaneInput(): boolean {
937
934
  const active = doc.activeElement;
@@ -1254,8 +1251,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1254
1251
  for (const tab of tabList) {
1255
1252
  scroller.appendChild(tab.el);
1256
1253
  }
1257
- paintActive();
1258
- syncMobile();
1259
1254
  }
1260
1255
 
1261
1256
  // closeKeyGrid closes the mobile key grid (if a keyboardToggle is wired and
@@ -1298,9 +1293,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1298
1293
  }
1299
1294
  collapseClearTimer = win.setTimeout(() => {
1300
1295
  collapseClearTimer = null;
1301
- if (!expanded) {
1302
- clearRows();
1303
- }
1296
+ clearRows();
1304
1297
  }, 260);
1305
1298
  }
1306
1299
  }
@@ -1414,10 +1407,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1414
1407
  activityCount: tab.activityCount,
1415
1408
  }),
1416
1409
  );
1417
- el.addEventListener("click", (e) => {
1418
- if ((e.target as HTMLElement).closest(".wt-tab-close")) {
1419
- return; // handled by the close button
1420
- }
1410
+ el.addEventListener("click", () => {
1421
1411
  if (editingId === tab.id) {
1422
1412
  return; // the rename field owns this chip
1423
1413
  }
@@ -1983,10 +1973,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1983
1973
  // would ignore an inline transform and refuse to move. `translate` composes
1984
1974
  // with it instead, so the two can own one chip at the same time.
1985
1975
  function applyShift(px: ReadonlyMap<HTMLElement, number>, trans: string): void {
1986
- if (shiftTimer !== null) {
1987
- win.clearTimeout(shiftTimer);
1988
- shiftTimer = null;
1989
- }
1990
1976
  for (const [el, dx] of px) {
1991
1977
  el.style.transition = trans;
1992
1978
  el.style.translate = `${String(Math.round(dx))}px`;
@@ -2105,7 +2091,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2105
2091
  if (before !== null && before.parentNode !== scroller) {
2106
2092
  return;
2107
2093
  }
2108
- if (before === dragged || before === dragged.nextElementSibling) {
2094
+ if (before === dragged.nextElementSibling) {
2109
2095
  return;
2110
2096
  }
2111
2097
  flipTo(() => {
@@ -2162,16 +2148,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2162
2148
  return [...scroller.querySelectorAll<HTMLElement>(".wt-tab")].indexOf(el) + 1;
2163
2149
  }
2164
2150
  function announceTarget(el: HTMLElement): void {
2165
- const at = slotPosition(el);
2166
- if (at > 0) {
2167
- ctx.announce(`Drop position ${String(at)}`);
2168
- }
2151
+ ctx.announce(`Drop position ${String(slotPosition(el))}`);
2169
2152
  }
2170
2153
  function announceMoved(el: HTMLElement): void {
2171
2154
  const tab = tabList.find((t) => t.el === el);
2172
- const at = slotPosition(el);
2173
- if (tab && at > 0) {
2174
- ctx.announce(`Moved ${tab.display} to position ${String(at)}`);
2155
+ if (tab) {
2156
+ ctx.announce(`Moved ${tab.display} to position ${String(slotPosition(el))}`);
2175
2157
  }
2176
2158
  }
2177
2159
 
@@ -2222,10 +2204,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2222
2204
  if (removed !== draggingEl) {
2223
2205
  return;
2224
2206
  }
2225
- removed.classList.remove("wt-tab-dragging");
2226
2207
  draggingEl = null;
2227
- dropped = false;
2228
- endReorderPreview();
2229
2208
  endShift();
2230
2209
  clearDragGhost();
2231
2210
  }
@@ -2238,14 +2217,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2238
2217
  order.push(t);
2239
2218
  }
2240
2219
  }
2241
- if (order.length === tabList.length) {
2242
- tabList.length = 0;
2243
- tabList.push(...order);
2244
- // Before syncChrome: it ends in applyServerOrder, which would undo this
2245
- // move while the tabs still carry their old positions.
2246
- publishOrder();
2247
- syncChrome();
2248
- }
2220
+ tabList.length = 0;
2221
+ tabList.push(...order);
2222
+ // Before syncChrome: it ends in applyServerOrder, which would undo this
2223
+ // move while the tabs still carry their old positions.
2224
+ publishOrder();
2225
+ syncChrome();
2249
2226
  }
2250
2227
 
2251
2228
  // The write half of tab-order sync, called only from the paths that COMMIT
@@ -2254,9 +2231,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2254
2231
  // the strip straight back. A 409 means this client's list is stale, so the
2255
2232
  // server's word is taken; any other failure leaves the arrangement local.
2256
2233
  function publishOrder(): void {
2257
- if (tabList.length === 0) {
2258
- return;
2259
- }
2260
2234
  tabList.forEach((tab, i) => {
2261
2235
  tab.order = i;
2262
2236
  });
@@ -2486,7 +2460,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2486
2460
  // far left while the create POST is in flight. The replacement lands as any
2487
2461
  // new tab does, and dropping the old tab is then an ordinary non-last
2488
2462
  // close, so this intercept does not re-fire. A user close only.
2489
- if (remote && tabList.length === 1 && tabList[0]?.id === id) {
2463
+ if (remote && tabList.length === 1) {
2490
2464
  await create();
2491
2465
  // A failed create adds nothing (and toasts): keep the existing tab
2492
2466
  // rather than stranding the user on an empty strip.
@@ -2687,10 +2661,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2687
2661
  // Recorded at entry because the exit paths cannot tell a keyboard-started
2688
2662
  // edit (focus returns to the chip) from a pointer one (to the terminal).
2689
2663
  let editFrom: "keyboard" | "pointer" = "pointer";
2690
- // Pinned-name requests in flight, COUNTED per id: two renames of one tab can
2691
- // be out at once, and a bare Set let the first completion clear the marker
2692
- // while the second's PUT was still open, so that rename failed silently.
2693
- const namesInFlight = new Map<string, number>();
2694
2664
 
2695
2665
  /** restoreFocusAfterEdit sends focus where the entry path implies. The
2696
2666
  * terminal branch is gated exactly like switchTo's focus-on-switch: on a
@@ -2745,8 +2715,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2745
2715
  }
2746
2716
  // Optimistic: paint it now, guarded by a monotonic counter AND the tab's
2747
2717
  // birth epoch so a slow response cannot roll back a newer rename, a later
2748
- // clear, a remote update, or — if this id were ever reused by a fresh
2749
- // session — a different tab entirely.
2718
+ // clear, or — if this id were ever reused by a fresh session — a
2719
+ // different tab entirely.
2750
2720
  const seq = ++t.nameSeq;
2751
2721
  const born = t.born;
2752
2722
  t.pinnedTitle = name;
@@ -2761,28 +2731,15 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2761
2731
  ctx.announce(fallback ? "Custom name removed" : `Using automatic name: ${text}`);
2762
2732
  }
2763
2733
  const request = name === "" ? api.clearPinnedTitle(id) : api.setPinnedTitle(id, name);
2764
- // Counted only once the request exists: an api implementation that throws
2765
- // instead of rejecting would otherwise leave a count behind that no
2766
- // .finally ever releases, and the id would read as in flight forever.
2767
- namesInFlight.set(id, (namesInFlight.get(id) ?? 0) + 1);
2768
- void request
2769
- .catch(() => {
2770
- const cur = tabList.find((x) => x.id === id);
2771
- if (cur?.born !== born || cur.nameSeq !== seq) {
2772
- return; // gone, reused, or superseded: the newer state stands
2773
- }
2774
- cur.pinnedTitle = before;
2775
- syncChrome();
2776
- ctx.toast("Could not save the terminal name");
2777
- })
2778
- .finally(() => {
2779
- const left = (namesInFlight.get(id) ?? 0) - 1;
2780
- if (left > 0) {
2781
- namesInFlight.set(id, left);
2782
- } else {
2783
- namesInFlight.delete(id);
2784
- }
2785
- });
2734
+ void request.catch(() => {
2735
+ const cur = tabList.find((x) => x.id === id);
2736
+ if (cur?.born !== born || cur.nameSeq !== seq) {
2737
+ return; // gone, reused, or superseded: the newer state stands
2738
+ }
2739
+ cur.pinnedTitle = before;
2740
+ syncChrome();
2741
+ ctx.toast("Could not save the terminal name");
2742
+ });
2786
2743
  return true;
2787
2744
  }
2788
2745
 
@@ -2973,7 +2930,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2973
2930
  hideTabMenu();
2974
2931
  const idx = tabList.findIndex((t) => t.id === id);
2975
2932
  const target = tabList[idx];
2976
- if (idx < 0 || !target) {
2933
+ if (!target) {
2977
2934
  return;
2978
2935
  }
2979
2936
  const n = tabList.length;
@@ -3134,17 +3091,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3134
3091
  t.title = rec.title;
3135
3092
  }
3136
3093
  // "" is meaningful here: it is how a clear made in ANOTHER browser reaches
3137
- // this one. The bump is gated on a DIFFERING value because the wire echoes
3138
- // pinnedTitle on every event, and an unconditional bump would mark an
3139
- // in-flight local rename as superseded by its own echo.
3140
- if (rec.pinnedTitle !== undefined && rec.pinnedTitle !== (t.pinnedTitle ?? "")) {
3094
+ // this one. It never bumps nameSeq: SSE delivery and REST mutation are not
3095
+ // one total order, so a record arriving during a pending request may
3096
+ // predate our own PUT, and a bump would suppress that request's rollback.
3097
+ if (rec.pinnedTitle !== undefined) {
3141
3098
  t.pinnedTitle = rec.pinnedTitle;
3142
- // SSE delivery and REST mutation are not one total order: during a
3143
- // pending request the record may predate our own PUT, and bumping would
3144
- // suppress that request's rollback and its failure toast.
3145
- if (!namesInFlight.has(rec.id)) {
3146
- t.nameSeq++;
3147
- }
3148
3099
  }
3149
3100
  }
3150
3101
 
@@ -3350,20 +3301,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3350
3301
  if (!draggingEl) {
3351
3302
  return;
3352
3303
  }
3353
- e.preventDefault();
3354
3304
  if (e.dataTransfer) {
3355
3305
  e.dataTransfer.dropEffect = "move";
3356
3306
  }
3357
3307
  trackRest(e.clientX);
3358
3308
  });
3359
- // The preventDefault is load-bearing: WebKit's default for an uncancelled
3360
- // drop is to LOAD the payload as a URL, so dropping a tab on iPadOS
3361
- // navigated the page to /<session-id>.
3309
+ // onDocTabDrop cancels both events here too, as they bubble.
3362
3310
  bar.addEventListener("drop", (e) => {
3363
- if (!draggingEl) {
3364
- return;
3365
- }
3366
- e.preventDefault();
3367
3311
  dropOnStrip(e.clientX);
3368
3312
  });
3369
3313
  function dropOnStrip(clientX: number): void {
@@ -3388,7 +3332,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3388
3332
  return;
3389
3333
  }
3390
3334
  const next = e.relatedTarget;
3391
- if (next === null || !bar.contains(next as Node)) {
3335
+ if (!bar.contains(next as Node | null)) {
3392
3336
  endRestNet();
3393
3337
  }
3394
3338
  });
@@ -3412,10 +3356,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3412
3356
  ctx.shell.root.classList.toggle("wt-drop-left", half === "left");
3413
3357
  ctx.shell.root.classList.toggle("wt-drop-right", half === "right");
3414
3358
  }
3415
- // A tab released anywhere OTHER than the strip must be inert, not a
3416
- // navigation, so the whole document is a drop target for the life of a tab
3417
- // drag; over a half of the pane row it is a snap. Gated on draggingEl so a
3418
- // file dropped on the page is the browser's.
3359
+ // A tab released anywhere must be inert, not a navigation: WebKit's default
3360
+ // for an uncancelled drop is to LOAD the payload as a URL, which sent iPadOS
3361
+ // to /<session-id>. So the whole document, the strip included, is a drop
3362
+ // target for the life of a tab drag; over a half of the pane row it is a
3363
+ // snap. Gated on draggingEl so a file dropped on the page is the browser's.
3419
3364
  const onDocTabDrop = (e: DragEvent): void => {
3420
3365
  if (!draggingEl) {
3421
3366
  return;
@@ -3499,7 +3444,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3499
3444
  const rule = pointerDragActivation(e.pointerType);
3500
3445
  const el =
3501
3446
  e.target instanceof win.Element ? e.target.closest<HTMLElement>(".wt-tab") : null;
3502
- if (rule === null || !e.isPrimary || press !== null || el?.parentNode !== scroller) {
3447
+ if (rule === null || !e.isPrimary || el?.parentNode !== scroller) {
3503
3448
  return;
3504
3449
  }
3505
3450
  const tab = tabList.find((t) => t.el === el);
@@ -3529,7 +3474,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3529
3474
  win.addEventListener("pointermove", onPressMove, opts);
3530
3475
  win.addEventListener("pointerup", onPressUp, opts);
3531
3476
  win.addEventListener("pointercancel", onPressCancel, opts);
3532
- win.addEventListener("pointerdown", onPressSecond, opts);
3477
+ // Registered after this press's own pointerdown has passed the window, so
3478
+ // any pointerdown it sees is another pointer.
3479
+ win.addEventListener("pointerdown", cancelPointerPress, opts);
3533
3480
  win.addEventListener("keydown", onPressKey, opts);
3534
3481
  win.addEventListener("blur", cancelPointerPress, { signal: off.signal });
3535
3482
  doc.addEventListener("visibilitychange", onPressVisibility, { signal: off.signal });
@@ -3608,11 +3555,6 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3608
3555
  cancelPointerPress();
3609
3556
  }
3610
3557
  }
3611
- function onPressSecond(e: PointerEvent): void {
3612
- if (press !== null && press.id !== e.pointerId) {
3613
- cancelPointerPress();
3614
- }
3615
- }
3616
3558
  // lostpointercapture bubbles, and the chip's implicit touch capture ending
3617
3559
  // when the bar takes it over is not a loss.
3618
3560
  function onPressLostCapture(e: PointerEvent): void {
@@ -3657,7 +3599,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3657
3599
  return;
3658
3600
  }
3659
3601
  const target = doc.elementFromPoint(x, y);
3660
- if (target !== null && bar.contains(target)) {
3602
+ if (bar.contains(target)) {
3661
3603
  paintDropHalf(null);
3662
3604
  if (release) {
3663
3605
  dropOnStrip(x);
@@ -293,7 +293,7 @@ export const MAX_PERSISTED_CUE_SEEN = 200;
293
293
  * lost acknowledgement only re-lights a dot the user can dismiss again. */
294
294
  export function parseCueSeen(raw: string | null): Map<string, CueStatus> {
295
295
  const out = new Map<string, CueStatus>();
296
- if (raw === null || raw === "") {
296
+ if (raw === null) {
297
297
  return out;
298
298
  }
299
299
  let data: unknown;
@@ -608,9 +608,6 @@ function parseRetryAfter(header: string | null, now: number): number | undefined
608
608
  return undefined;
609
609
  }
610
610
  const raw = header.trim();
611
- if (raw === "") {
612
- return undefined;
613
- }
614
611
  if (/^\d+$/.test(raw)) {
615
612
  return Math.min(Number(raw) * 1000, RETRY_AFTER_MAX_MS);
616
613
  }
@@ -627,9 +624,6 @@ function parseRetryAfter(header: string | null, now: number): number | undefined
627
624
  async function readServerMessage(r: Response): Promise<string | undefined> {
628
625
  try {
629
626
  const body: unknown = await r.json();
630
- if (typeof body !== "object" || body === null) {
631
- return undefined;
632
- }
633
627
  const fields = body as { error?: unknown; message?: unknown };
634
628
  for (const candidate of [fields.error, fields.message]) {
635
629
  if (typeof candidate === "string" && candidate.trim() !== "") {
@@ -29,7 +29,15 @@ export interface Announcer {
29
29
  /** Delay before a cleared live region is re-set with the new message (ms).
30
30
  * Long enough for assistive tech to register the clear and the set as two
31
31
  * distinct mutations (a sub-frame gap is not). */
32
- const REANNOUNCE_DELAY_MS = 100;
32
+ let REANNOUNCE_DELAY_MS = 100;
33
+
34
+ /** @internal Test seam: re-set every later announcement after `ms`; returns the
35
+ * previous value so the caller can restore it. */
36
+ export function setReannounceDelayMs(ms: number): number {
37
+ const previous = REANNOUNCE_DELAY_MS;
38
+ REANNOUNCE_DELAY_MS = ms;
39
+ return previous;
40
+ }
33
41
 
34
42
  /** Build the single pair of live regions inside root. */
35
43
  export function createAnnouncer(root: HTMLElement): Announcer {
@@ -58,10 +66,7 @@ export function createAnnouncer(root: HTMLElement): Announcer {
58
66
  // distinct mutations — the same rationale ui-primitives' announce()
59
67
  // documents for its shared region.
60
68
  el.textContent = "";
61
- const pending = timers.get(el);
62
- if (pending !== undefined) {
63
- win.clearTimeout(pending);
64
- }
69
+ win.clearTimeout(timers.get(el));
65
70
  timers.set(
66
71
  el,
67
72
  win.setTimeout(() => {
@@ -62,12 +62,10 @@ export function createConnState(opts: {
62
62
  // Suppress transient states until the initial load is over (the loading
63
63
  // overlay owns the screen). Terminal states ("ended" and "incompatible")
64
64
  // must pass through because no later frame or reconnect will explain the
65
- // failure; "offline" also passes once the initial-failure limit is reached.
65
+ // failure; offline, which before load is reached only at the initial-failure
66
+ // limit (closed() returns before it until then), passes for the same reason.
66
67
  // connectionBanner renders "open" as hidden.
67
- const passesLoadingGate =
68
- state === "ended" ||
69
- state === "incompatible" ||
70
- (state === "offline" && consecutiveFailures >= INITIAL_FAILURE_LIMIT);
68
+ const passesLoadingGate = state === "ended" || state === "incompatible" || state === "offline";
71
69
  if (!loaded && !passesLoadingGate) {
72
70
  opts.onState("open"); // nothing to show yet
73
71
  return;
@@ -140,10 +138,6 @@ export function createConnState(opts: {
140
138
  },
141
139
  idle(): void {
142
140
  consecutiveFailures = 0;
143
- if (restartedTimer !== null) {
144
- timers.clearTimeout(restartedTimer);
145
- restartedTimer = null;
146
- }
147
141
  setState("idle", 0);
148
142
  },
149
143
  setLoaded(): void {
@@ -165,10 +165,7 @@ export function attachLoadingStatus(
165
165
  }
166
166
  current = text;
167
167
  pinned = true;
168
- if (rotation !== undefined) {
169
- win.clearInterval(rotation);
170
- rotation = undefined;
171
- }
168
+ win.clearInterval(rotation);
172
169
  show(text);
173
170
  announce(text);
174
171
  },
@@ -179,16 +176,11 @@ export function attachLoadingStatus(
179
176
  // cancellable callback, can run, and it is the one reader of `stopped`.
180
177
  // show() and announce() do not re-test it; a timer armed outside later()
181
178
  // is what the "stop() leaves no timer armed" test catches.
182
- if (stopped) {
183
- return;
184
- }
185
179
  stopped = true;
186
180
  for (const t of timers) {
187
181
  win.clearTimeout(t);
188
182
  }
189
- if (rotation !== undefined) {
190
- win.clearInterval(rotation);
191
- }
183
+ win.clearInterval(rotation);
192
184
  visible.remove();
193
185
  live.remove();
194
186
  },
@@ -220,7 +220,8 @@ export interface PaneKernel extends PaneHandle {
220
220
  * gets when the closing transition ends. */
221
221
  setHidden(value: boolean): void;
222
222
  /** The shell is about to slide this pane's box: nothing it measures from now
223
- * until its viewport settles is final, and the settle sends the size. */
223
+ * until its viewport settles is final, and the settle sends the size. Live
224
+ * panes only: a failed pane is torn down before the split next opens. */
224
225
  holdGeometry(): void;
225
226
  /** Whether this pane's input is a stop in the document's Tab order. On while
226
227
  * the split is open and the pane shows a tab, so Tab runs left input, divider,
@@ -437,15 +438,10 @@ function buildPaneInto(
437
438
  let hidden = false;
438
439
  let rootReleased = false;
439
440
 
440
- let ready = false;
441
441
  let firstFrameRendered = false;
442
442
  let fontsLoaded = false;
443
443
  let wsOpen = false;
444
444
  function markReady(): void {
445
- if (ready) {
446
- return;
447
- }
448
- ready = true;
449
445
  connState.setLoaded();
450
446
  services.loading.firstFrame();
451
447
  }
@@ -981,7 +977,7 @@ function buildPaneInto(
981
977
  }
982
978
  // Nobody owns the keyboard: focus in a shadow root, a dialog or an ARIA
983
979
  // widget all report non-body and all bail.
984
- if (doc.activeElement !== null && doc.activeElement !== doc.body) {
980
+ if (doc.activeElement !== doc.body) {
985
981
  return;
986
982
  }
987
983
  if (composition.isComposing()) {
@@ -1514,9 +1510,7 @@ function buildPaneInto(
1514
1510
  hidden = value;
1515
1511
  },
1516
1512
  holdGeometry() {
1517
- if (!destroyed) {
1518
- viewport.beginTransition();
1519
- }
1513
+ viewport.beginTransition();
1520
1514
  },
1521
1515
  setTabStop(value) {
1522
1516
  input.tabIndex = value ? 0 : -1;
@@ -214,7 +214,6 @@ export function localScrollbackStorage(
214
214
  function sweep(): void {
215
215
  const now = Date.now();
216
216
  const live: { key: string; savedAt: number; chars: number }[] = [];
217
- let total = 0;
218
217
  for (const entry of ownEntries()) {
219
218
  // An unreadable timestamp is treated as expired: the kernel would refuse
220
219
  // the entry anyway, so keeping it only consumes quota.
@@ -223,10 +222,6 @@ export function localScrollbackStorage(
223
222
  continue;
224
223
  }
225
224
  live.push({ key: entry.key, savedAt: entry.savedAt, chars: entry.chars });
226
- total += entry.chars;
227
- }
228
- if (total <= maxBytes) {
229
- return;
230
225
  }
231
226
  // Newest first, then keep entries while they fit and drop the rest: an entry
232
227
  // the user last saw a minute ago is worth more than one from this morning.
@@ -12,9 +12,6 @@ export function selectionTextWithin(container: Element): string {
12
12
  return "";
13
13
  }
14
14
  const text = sel.toString();
15
- if (text.length === 0) {
16
- return "";
17
- }
18
15
  for (let i = 0; i < sel.rangeCount; i++) {
19
16
  const range = sel.getRangeAt(i);
20
17
  if (!container.contains(range.startContainer) || !container.contains(range.endContainer)) {
@@ -549,9 +549,6 @@ function createShellInto(
549
549
  applyRatioVar();
550
550
  }
551
551
  function fireSplitChange(): void {
552
- if (changeListeners.size === 0) {
553
- return;
554
- }
555
552
  const state = splitState();
556
553
  for (const cb of [...changeListeners]) {
557
554
  cb(state);
@@ -608,9 +605,6 @@ function createShellInto(
608
605
  // may rest on the empty pane the replacement is headed for, so the handle faces
609
606
  // where the new tab will land.
610
607
  function select(side: PaneSide): boolean {
611
- if (!isPaneSide(side)) {
612
- return false;
613
- }
614
608
  const state = paneAt(side)?.state();
615
609
  if (state !== "shown" && !(state === "empty" && !anyShown())) {
616
610
  return false;
@@ -1064,7 +1058,7 @@ function createShellInto(
1064
1058
  return;
1065
1059
  }
1066
1060
  for (const feature of features) {
1067
- if (destroyed || kernel.isDestroyed()) {
1061
+ if (kernel.isDestroyed()) {
1068
1062
  return;
1069
1063
  }
1070
1064
  const outcome = await kernel.setupFeature(feature);
@@ -1072,9 +1066,6 @@ function createShellInto(
1072
1066
  failPane(slot, { phase: "feature-setup", feature: outcome.feature, cause: outcome.cause });
1073
1067
  return;
1074
1068
  }
1075
- if (outcome.status === "aborted") {
1076
- return;
1077
- }
1078
1069
  }
1079
1070
  }
1080
1071
  function buildSecondary(slot: Slot): void {
@@ -1231,7 +1222,7 @@ function createShellInto(
1231
1222
  return true;
1232
1223
  }
1233
1224
  function closeSide(side: PaneSide): boolean {
1234
- if (!isPaneSide(side) || !splitOpen || destroyed) {
1225
+ if (!splitOpen || destroyed) {
1235
1226
  return false;
1236
1227
  }
1237
1228
  const squeezed = slotAt(side);
@@ -1266,9 +1257,6 @@ function createShellInto(
1266
1257
  );
1267
1258
  }
1268
1259
  function onShellResize(): void {
1269
- if (destroyed) {
1270
- return;
1271
- }
1272
1260
  paintShellNarrow();
1273
1261
  for (const p of builtPanes()) {
1274
1262
  p.updateNarrow();
@@ -1427,9 +1415,6 @@ function createShellInto(
1427
1415
 
1428
1416
  let rootReleased = false;
1429
1417
  function cleanupRuntime(): void {
1430
- if (destroyed) {
1431
- return;
1432
- }
1433
1418
  destroyed = true;
1434
1419
  // The tracked stores are about to become unreachable, and the connections
1435
1420
  // whose epochs the save is filed under go with the drain.
@@ -1441,7 +1426,6 @@ function createShellInto(
1441
1426
  if (destroyed) {
1442
1427
  return;
1443
1428
  }
1444
- primaryKernel.markFailed();
1445
1429
  cleanupRuntime();
1446
1430
  dismissLoadingOverlay();
1447
1431
  let handled = false;
@@ -1517,21 +1501,12 @@ function createShellInto(
1517
1501
  selectedPane().focus();
1518
1502
  },
1519
1503
  send(bytes) {
1520
- if (destroyed) {
1521
- return;
1522
- }
1523
1504
  selectedPane().send(bytes);
1524
1505
  },
1525
1506
  reset() {
1526
- if (destroyed) {
1527
- return;
1528
- }
1529
1507
  selectedPane().reset();
1530
1508
  },
1531
1509
  reattach() {
1532
- if (destroyed) {
1533
- return;
1534
- }
1535
1510
  selectedPane().reattach();
1536
1511
  },
1537
1512
  destroy() {
@@ -237,9 +237,6 @@ export function createSplitHandle(shell: SplitHandleShell): SplitHandle {
237
237
  return;
238
238
  }
239
239
  const state = split.state();
240
- if (!state.open || state.collapsed) {
241
- return;
242
- }
243
240
  ev.preventDefault();
244
241
  const span = root.clientWidth - SPLIT_GUTTER_PX;
245
242
  const { min, max } = bounds();
package/src/predict.ts CHANGED
@@ -31,7 +31,6 @@ export function createPredictor(): Predictor {
31
31
  let predCol = 0;
32
32
  let predActive = false;
33
33
  let predPendingWrap = false;
34
- let predFrozen = false;
35
34
  let cols = 80;
36
35
  let rows = 30;
37
36
  let onChange: (() => void) | null = null;
@@ -40,8 +39,6 @@ export function createPredictor(): Predictor {
40
39
  predRow = 0;
41
40
  predCol = 0;
42
41
  predActive = false;
43
- predPendingWrap = false;
44
- predFrozen = false;
45
42
  onChange?.();
46
43
  }
47
44
 
@@ -73,23 +70,17 @@ export function createPredictor(): Predictor {
73
70
  predRow = serverRow;
74
71
  predCol = serverCol;
75
72
  predPendingWrap = false;
76
- predFrozen = cursorHidden ?? false;
77
- predActive = !predFrozen;
73
+ predActive = !(cursorHidden ?? false);
78
74
  onChange?.();
79
75
  },
80
76
  applyInput(bytes) {
81
- if (!predActive || predFrozen) {
77
+ if (!predActive) {
82
78
  return;
83
79
  }
84
80
  let i = 0;
85
81
  while (i < bytes.length) {
86
82
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- index checked by loop condition
87
83
  const b = bytes[i]!;
88
- if (b === 0x1b) {
89
- predActive = false;
90
- onChange?.();
91
- return;
92
- }
93
84
  if (b === 0x08 || b === 0x7f) {
94
85
  predPendingWrap = false;
95
86
  if (predCol > 0) {
package/src/viewport.ts CHANGED
@@ -9,7 +9,15 @@ import { windowOf } from "./kernel/realm.js";
9
9
 
10
10
  // Long enough to bridge the iOS keyboard slide (~250ms) with margin for fonts
11
11
  // and reflow.
12
- const SETTLE_MS = 350;
12
+ let SETTLE_MS = 350;
13
+
14
+ /** @internal Test seam: arm every settle started from now on at `ms`; returns the
15
+ * previous value so the caller can restore it. */
16
+ export function setSettleMs(ms: number): number {
17
+ const previous = SETTLE_MS;
18
+ SETTLE_MS = ms;
19
+ return previous;
20
+ }
13
21
 
14
22
  /** The visual viewport against the layout viewport, in whole CSS px. */
15
23
  interface KeyboardGeometry {