@cplieger/web-terminal-ui 7.0.1 → 7.0.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "7.0.1",
3
+ "version": "7.0.3",
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",
@@ -55,7 +55,7 @@
55
55
  "@vitest/coverage-v8": "4.1.11",
56
56
  "eslint": "10.8.1",
57
57
  "fast-check": "4.9.0",
58
- "happy-dom": "20.11.2",
58
+ "happy-dom": "20.11.6",
59
59
  "html-validate": "11.8.0",
60
60
  "knip": "6.32.2",
61
61
  "prettier": "3.9.6",
@@ -2873,13 +2873,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2873
2873
  // still be), to the terminal otherwise. Recorded at entry because the exit
2874
2874
  // paths cannot tell them apart.
2875
2875
  let editFrom: "keyboard" | "pointer" = "pointer";
2876
- // Tab ids with a pinned-name request in flight. While an id is here, a status
2877
- // record carrying a DIFFERENT value is applied for display but does not
2878
- // supersede the request: SSE delivery order and REST mutation order are not
2879
- // one total order, so a record sampled BEFORE our PUT can arrive after it,
2880
- // and treating it as newer authority would suppress the request's own
2881
- // rollback and its failure toast.
2882
- const namesInFlight = new Set<string>();
2876
+ // Tab ids with pinned-name requests in flight, COUNTED per id. While a count
2877
+ // is held, a status record carrying a DIFFERENT value is applied for display
2878
+ // but does not supersede the request: SSE delivery order and REST mutation
2879
+ // order are not one total order, so a record sampled BEFORE our PUT can
2880
+ // arrive after it, and treating it as newer authority would suppress the
2881
+ // request's own rollback and its failure toast.
2882
+ // Counted rather than a Set of ids, because two renames of one tab can be out
2883
+ // at once and the invariant is "no request of OURS is in flight", not "no id
2884
+ // is marked": a bare Set let the first request's completion clear the marker
2885
+ // while the second's PUT was still open, and the second rename then failed
2886
+ // silently. A per-request token would carry more information, but the only
2887
+ // reader asks whether ANY request is out for this id, and the failure handler
2888
+ // already distinguishes its own supersession by (born, nameSeq).
2889
+ const namesInFlight = new Map<string, number>();
2883
2890
 
2884
2891
  /** restoreFocusAfterEdit sends focus where the entry path implies. The
2885
2892
  * terminal branch is gated exactly like switchTo's focus-on-switch: on a
@@ -2949,8 +2956,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2949
2956
  const { text, fallback } = baseLabel(t);
2950
2957
  ctx.announce(fallback ? "Custom name removed" : `Using automatic name: ${text}`);
2951
2958
  }
2952
- namesInFlight.add(id);
2953
2959
  const request = name === "" ? api.clearPinnedTitle(id) : api.setPinnedTitle(id, name);
2960
+ // Counted only once the request exists: an api implementation that throws
2961
+ // instead of rejecting would otherwise leave a count behind that no
2962
+ // .finally ever releases, and the id would read as in flight forever.
2963
+ namesInFlight.set(id, (namesInFlight.get(id) ?? 0) + 1);
2954
2964
  void request
2955
2965
  .catch(() => {
2956
2966
  const cur = tabList.find((x) => x.id === id);
@@ -2962,7 +2972,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2962
2972
  ctx.toast("Couldn't save the terminal name");
2963
2973
  })
2964
2974
  .finally(() => {
2965
- namesInFlight.delete(id);
2975
+ const left = (namesInFlight.get(id) ?? 0) - 1;
2976
+ if (left > 0) {
2977
+ namesInFlight.set(id, left);
2978
+ } else {
2979
+ namesInFlight.delete(id);
2980
+ }
2966
2981
  });
2967
2982
  return true;
2968
2983
  }
@@ -274,17 +274,21 @@ function buildTerminal(
274
274
  // Narrow = compact in EITHER dimension: skinny (a phone in portrait, a
275
275
  // narrow embedded panel) or short (a phone in landscape). The one
276
276
  // ResizeObserver below fires on both width and height changes.
277
+ //
278
+ // Constructed unconditionally: ResizeObserver is a hard requirement of this
279
+ // library, not an enhancement. viewport.init() and the tabs strip both
280
+ // construct one with no guard, and the same buildTerminal builds a WeakRef
281
+ // (whose browser floor is strictly higher), so a `typeof ResizeObserver ===
282
+ // "function"` guard here could only ever protect a browser that crashes
283
+ // moments later. It used to be here, and it was measured dead.
277
284
  const isNarrow = (): boolean =>
278
285
  root.clientWidth <= NARROW_MAX_PX || root.clientHeight <= SHORT_MAX_PX;
279
286
  function updateNarrow(): void {
280
287
  root.classList.toggle("wt-narrow", isNarrow());
281
288
  }
282
289
  updateNarrow();
283
- let narrowObserver: ResizeObserver | null = null;
284
- if (typeof ResizeObserver === "function") {
285
- narrowObserver = new ResizeObserver(updateNarrow);
286
- narrowObserver.observe(root);
287
- }
290
+ let narrowObserver: ResizeObserver | null = new ResizeObserver(updateNarrow);
291
+ narrowObserver.observe(root);
288
292
 
289
293
  // --- Document title (kernel-owned, composed from two inputs) ---
290
294
  //
@@ -135,10 +135,10 @@ export function attachLoadingStatus(
135
135
  // Swap the visible text through a fade so a change reads as deliberate rather
136
136
  // than as a glitch. The first write skips the fade (there is nothing to fade
137
137
  // out) so the line does not arrive half-transparent.
138
+ //
139
+ // No `stopped` check, here or in the deferred half below, and that is
140
+ // deliberate rather than an omission: see stop(), which owns the invariant.
138
141
  const show = (text: string): void => {
139
- if (stopped) {
140
- return;
141
- }
142
142
  // First write has nothing to fade out, so it must not arrive half-transparent.
143
143
  if (visible.textContent === "") {
144
144
  visible.textContent = text;
@@ -146,18 +146,13 @@ export function attachLoadingStatus(
146
146
  }
147
147
  visible.classList.add("wt-loading-text-out");
148
148
  later(() => {
149
- if (stopped) {
150
- return;
151
- }
152
149
  visible.textContent = text;
153
150
  visible.classList.remove("wt-loading-text-out");
154
151
  }, SWAP_FADE_MS);
155
152
  };
156
153
 
157
154
  const announce = (text: string): void => {
158
- if (!stopped) {
159
- live.textContent = text;
160
- }
155
+ live.textContent = text;
161
156
  };
162
157
 
163
158
  later(() => {
@@ -170,7 +165,13 @@ export function attachLoadingStatus(
170
165
 
171
166
  later(() => {
172
167
  const waiting = messages.waiting;
173
- if (pinned || stopped || waiting.length === 0) {
168
+ // `pinned` only. Not `stopped`: this callback is held by `timers`, so stop()
169
+ // has cancelled it before it could read the flag. That disjunct was here and
170
+ // was as dead as the ones deleted above — and invisible to the mutation
171
+ // report, because a mutator works on binary-expression nodes and never
172
+ // isolates a bare identifier operand, so `pinned || stopped` is mutated as a
173
+ // pair and the pair is killable through `pinned`.
174
+ if (pinned || waiting.length === 0) {
174
175
  return;
175
176
  }
176
177
  // showAt reads through an undefined check rather than an index assertion:
@@ -219,6 +220,23 @@ export function attachLoadingStatus(
219
220
  announce(text);
220
221
  },
221
222
  stop(): void {
223
+ // THE OWNER of "nothing further happens", and the only one.
224
+ //
225
+ // It discharges that by cancelling every callback this controller armed:
226
+ // `later()` is the sole way to schedule here, everything it schedules is in
227
+ // `timers`, and the rotation interval is the one handle held separately.
228
+ // So the only call that can arrive after this line is one from OUTSIDE a
229
+ // cancellable callback — which is `reason()`, and `reason()` is where
230
+ // `stopped` is read.
231
+ //
232
+ // `show()` and `announce()` used to re-test it as well, and the mutation
233
+ // pass measured what that was worth: five permanently unkillable mutants,
234
+ // because no input could reach either function with `stopped` true. They
235
+ // were not even protecting anything if they had — the two nodes are
236
+ // detached three lines below, so a late write goes nowhere. The invariant
237
+ // is pinned by a test instead ("stop() leaves no timer armed"), which,
238
+ // unlike a guard, fails for the change that would actually break it: a
239
+ // future timer armed outside `later()`.
222
240
  if (stopped) {
223
241
  return;
224
242
  }
@@ -28,7 +28,6 @@
28
28
  // caps rather than only writing.
29
29
 
30
30
  import type { ScrollbackPersistence } from "./types.js";
31
- import type { StoreSnapshot } from "@cplieger/web-terminal-engine";
32
31
  import { DEFAULT_MAX_AGE_MS } from "./scrollback.js";
33
32
  import { positiveIntOption } from "./options.js";
34
33
 
@@ -259,11 +258,16 @@ export function localScrollbackStorage(
259
258
  return null;
260
259
  }
261
260
  try {
262
- // The snapshot is returned unvalidated on purpose: the kernel validates
263
- // every field against the engine's snapshot contract, and duplicating that
264
- // here would give two places to disagree about what a usable entry is. A
265
- // parse failure is the one thing this layer must handle, since it throws.
266
- return { savedAt, snapshot: JSON.parse(raw.slice(raw.indexOf("\n") + 1)) as StoreSnapshot };
261
+ // The snapshot is returned unparsed on purpose: the kernel parses every
262
+ // field against the engine's snapshot contract, and duplicating that here
263
+ // would give two places to disagree about what a usable entry is. Typed
264
+ // `unknown` rather than asserted `as StoreSnapshot`, which is what this
265
+ // line used to do — and that cast was laundering `JSON.parse`'s `any` past
266
+ // no-unsafe-assignment while telling the compiler the value was verified,
267
+ // leaving the keeper to verify it anyway. That is how the keeper's
268
+ // boundary came to check the same thing twice.
269
+ const snapshot: unknown = JSON.parse(raw.slice(raw.indexOf("\n") + 1));
270
+ return { savedAt, snapshot };
267
271
  } catch {
268
272
  remove(keyFor(sessionId));
269
273
  return null;
@@ -18,7 +18,7 @@
18
18
 
19
19
  import { LineStore, connection } from "@cplieger/web-terminal-engine";
20
20
  import { positiveIntOption } from "./options.js";
21
- import type { PersistedScrollback, ScrollbackPersistence } from "./types.js";
21
+ import type { ScrollbackPersistence } from "./types.js";
22
22
 
23
23
  /**
24
24
  * Newest lines persisted per session when the consumer names no bound.
@@ -69,33 +69,72 @@ export interface ScrollbackKeeper {
69
69
  stop(): void;
70
70
  }
71
71
 
72
- /** Read a stored entry's timestamp defensively. The value has been outside this
73
- * program's memory, so its type is an assumption until checked. */
74
- function savedAtOf(entry: unknown): number | null {
75
- if (typeof entry !== "object" || entry === null) {
76
- return null;
77
- }
78
- const at = (entry as Record<string, unknown>)["savedAt"];
79
- return typeof at === "number" && Number.isFinite(at) ? at : null;
72
+ /** A stored entry that has been read and proven: every field is what it says, so
73
+ * nothing downstream re-checks it. `readStoredEntry` is the only constructor,
74
+ * which is what makes that promise keepable. */
75
+ interface StoredEntry {
76
+ /** `Date.now()` at the write. Finite. */
77
+ readonly savedAt: number;
78
+ /** The snapshot's server epoch, or null when the entry names no server
79
+ * process.
80
+ *
81
+ * Stricter than "a finite number", because this value is adopted as a
82
+ * session's IDENTITY: the server reports a process-start timestamp in
83
+ * nanoseconds, so a negative or fractional value cannot have come from one
84
+ * and is a corrupt or hand-edited entry wearing the shape of a real one.
85
+ *
86
+ * NOT 0-for-unknown. The engine spells "unknown" as 0 on its own surface and
87
+ * that is its contract, but inheriting the spelling here put the rejection
88
+ * value inside the range of accepted values, so `epoch > 0` and `epoch >= 0`
89
+ * could not differ: at `epoch === 0` both yielded 0 and both were rejected
90
+ * downstream. Out-of-band restores the distinction, and 0 goes back to being
91
+ * a value the boundary refuses rather than the way it says no. */
92
+ readonly serverEpoch: number | null;
93
+ /** Still the engine's to parse. `LineStore.fromSnapshot` takes `unknown` and
94
+ * is the authority on a snapshot's contents (engine store.ts:1584), so a
95
+ * shape check here would be a second owner of one obligation. */
96
+ readonly snapshot: unknown;
80
97
  }
81
98
 
82
- /** Read a stored snapshot's server epoch defensively, or 0 for "unknown".
99
+ /**
100
+ * Read a stored entry, or null when it cannot be used.
83
101
  *
84
- * Stricter than "a finite number", because this value is adopted as a session's
85
- * IDENTITY: the server reports a process-start timestamp in nanoseconds, so a
86
- * negative or fractional value cannot have come from one and is a corrupt or
87
- * hand-edited entry wearing the shape of a real one. 0 is the library's existing
88
- * "unknown", and every rejection lands there. */
89
- function epochOf(entry: unknown): number {
90
- if (typeof entry !== "object" || entry === null) {
91
- return 0;
92
- }
93
- const snap = (entry as Record<string, unknown>)["snapshot"];
94
- if (typeof snap !== "object" || snap === null) {
95
- return 0;
102
+ * `unknown` in, because the value has been outside this program's memory and
103
+ * `ScrollbackPersistence.load`'s declared return type is an assumption about a
104
+ * consumer's storage rather than a fact about its contents — the library's own
105
+ * implementation produces one by `JSON.parse`. A `StoredEntry` out, so every
106
+ * later line reads fields instead of re-establishing them.
107
+ *
108
+ * The parameter type carries the caller's proof: `hydrate` must separate "no
109
+ * entry" from "bad entry" before calling, because those two have different
110
+ * handling, so non-nullishness is already established. That is why there is no
111
+ * object test here and why there must not be one — a property read on any other
112
+ * primitive yields undefined and the field checks below reject it, so a `typeof
113
+ * entry !== "object"` test would be a second statement of a thing the signature
114
+ * already guarantees. This function had two of them, one per field it read, and
115
+ * neither could be observed: 15 of the file's 19 surviving mutants were those
116
+ * two guards and their bodies.
117
+ */
118
+ function readStoredEntry(entry: NonNullable<unknown>): StoredEntry | null {
119
+ const rec = entry as Record<string, unknown>;
120
+ const savedAt = rec["savedAt"];
121
+ // Finite, not merely a number: the age comparison below is a subtraction, and
122
+ // `Math.abs(Date.now() - NaN) > maxAgeMs` is false, so a NaN timestamp would
123
+ // read as freshly written forever.
124
+ if (typeof savedAt !== "number" || !Number.isFinite(savedAt)) {
125
+ return null;
96
126
  }
97
- const epoch = (snap as Record<string, unknown>)["serverEpoch"];
98
- return typeof epoch === "number" && Number.isSafeInteger(epoch) && epoch > 0 ? epoch : 0;
127
+ // Read through a nullable cast rather than a typeof test: `?.` is safe on a
128
+ // primitive as well as on null, so one operator covers every shape a stored
129
+ // value can have, and `fromSnapshot` owns what a usable snapshot contains.
130
+ const snapshot = rec["snapshot"] as Record<string, unknown> | null | undefined;
131
+ const epoch = snapshot?.["serverEpoch"];
132
+ return {
133
+ savedAt,
134
+ serverEpoch:
135
+ typeof epoch === "number" && Number.isSafeInteger(epoch) && epoch > 0 ? epoch : null,
136
+ snapshot,
137
+ };
99
138
  }
100
139
 
101
140
  export function createScrollbackKeeper(
@@ -143,22 +182,41 @@ export function createScrollbackKeeper(
143
182
  * Every rejection below has that same correct handling, which is why none of
144
183
  * them throws or reports: the worst outcome of starting empty is one slow
145
184
  * restore, and the worst outcome of hydrating something unsound is a terminal
146
- * that shows content from a session that no longer exists. */
185
+ * that shows content from a session that no longer exists.
186
+ *
187
+ * This function owns exactly two decisions and delegates the rest, which is
188
+ * the point of its shape. It decides whether there is an entry at all, and
189
+ * whether an entry that parsed may be USED (age, and verifiability). What an
190
+ * entry's envelope must look like is `readStoredEntry`'s; what a snapshot must
191
+ * contain is `LineStore.fromSnapshot`'s. Three owners, three depths, no
192
+ * overlap — the arrangement this module did not have when its two readers each
193
+ * re-established that the entry was an object. */
147
194
  function hydrate(sessionId: string): LineStore | null {
148
- let entry: PersistedScrollback | null | undefined;
195
+ let raw: unknown;
149
196
  try {
150
- entry = cfg.load(sessionId);
197
+ raw = cfg.load(sessionId);
151
198
  } catch {
199
+ // Deliberately empty: a seam that threw has told us there is no entry, and
200
+ // `raw` is still undefined, which is exactly what the check below concludes.
201
+ // A `return null` here would be a second owner of "there is no entry".
202
+ }
203
+ // "No entry" is silent and is not a rejection: nothing is dropped, because
204
+ // there is nothing to drop. Every path below this line has an entry to
205
+ // account for. This is also what lets `readStoredEntry` take a non-nullish
206
+ // parameter and therefore need no object test.
207
+ if (raw === null || raw === undefined) {
152
208
  return null;
153
209
  }
154
- if (entry === null || entry === undefined) {
210
+
211
+ const entry = readStoredEntry(raw);
212
+ if (entry === null) {
213
+ drop(sessionId);
155
214
  return null;
156
215
  }
157
216
 
158
- const savedAt = savedAtOf(entry);
159
217
  // Distance in either direction: a clock that moved backwards would otherwise
160
218
  // leave an entry whose age never reaches the bound.
161
- if (savedAt === null || Math.abs(Date.now() - savedAt) > maxAgeMs) {
219
+ if (Math.abs(Date.now() - entry.savedAt) > maxAgeMs) {
162
220
  drop(sessionId);
163
221
  return null;
164
222
  }
@@ -170,19 +228,22 @@ export function createScrollbackKeeper(
170
228
  }
171
229
 
172
230
  // Structure checked; now the one rejection that is a real decision rather
173
- // than a validation. An entry with no server epoch cannot be verified
174
- // against the server it is about to be shown next to, and absolute line
175
- // indices only mean anything within one server process: a restarted server
176
- // begins again near 0, so a hydrated store whose epoch cannot be compared
177
- // would present old content as live AND then refuse the new session's
178
- // output, because those low indices fall below what the hydrated store
179
- // believes it evicted. A terminal that fills in slowly is a far better
180
- // failure than one that is wrong and then permanently blank, so an
181
- // unverifiable entry is discarded. In practice this never fires: an epoch is
182
- // missing only from a server that reports none at all, in which case restart
183
- // detection was never available to anyone.
184
- const epoch = epochOf(entry);
185
- if (epoch === 0) {
231
+ // than a validation, and the reason the epoch is read at the boundary but
232
+ // JUDGED here. An entry with no server epoch cannot be verified against the
233
+ // server it is about to be shown next to, and absolute line indices only
234
+ // mean anything within one server process: a restarted server begins again
235
+ // near 0, so a hydrated store whose epoch cannot be compared would present
236
+ // old content as live AND then refuse the new session's output, because
237
+ // those low indices fall below what the hydrated store believes it evicted.
238
+ // A terminal that fills in slowly is a far better failure than one that is
239
+ // wrong and then permanently blank, so an unverifiable entry is discarded.
240
+ // In practice this never fires: an epoch is missing only from a server that
241
+ // reports none at all, in which case restart detection was never available
242
+ // to anyone.
243
+ //
244
+ // AFTER fromSnapshot, deliberately: reading it earlier would fire the warning
245
+ // below for a snapshot the engine was going to reject anyway.
246
+ if (entry.serverEpoch === null) {
186
247
  if (!warnedNoEpoch) {
187
248
  warnedNoEpoch = true;
188
249
  console.warn(
@@ -196,7 +257,7 @@ export function createScrollbackKeeper(
196
257
  // The epoch must reach the connection layer BEFORE this session's resume, or
197
258
  // the first resumeAck records it with nothing to compare against and a
198
259
  // restart goes undetected for exactly the content that needed checking.
199
- connection.adoptPersistedEpoch(sessionId, epoch);
260
+ connection.adoptPersistedEpoch(sessionId, entry.serverEpoch);
200
261
  return store;
201
262
  }
202
263
 
@@ -498,9 +498,28 @@ export interface PersistedScrollback {
498
498
  * treats any failure as "nothing was restored" and takes a full resume, exactly
499
499
  * as if persistence were switched off. */
500
500
  export interface ScrollbackPersistence {
501
- /** Return the stored entry for a session, or null/undefined when there is
502
- * none. Called once per session, before that session connects. */
503
- load(sessionId: string): PersistedScrollback | null | undefined;
501
+ /** Return whatever was stored for a session, or null/undefined when there is
502
+ * nothing. Called once per session, before that session connects.
503
+ *
504
+ * `unknown`, not `PersistedScrollback`, and the asymmetry with `save` below is
505
+ * the point: an entry going OUT was produced by the engine and is proven, while
506
+ * an entry coming BACK has been outside this program's memory and is a claim.
507
+ * Declaring the proven type on both sides told the compiler the returned value
508
+ * was verified — which no implementation of this seam can promise, this
509
+ * library's own `localScrollbackStorage` least of all, since it hands back a
510
+ * `JSON.parse` result. The kernel parsed it anyway, and because the type said
511
+ * otherwise it did so in helpers that each re-established the shape from
512
+ * scratch: measured 2026-08, 15 of `scrollback.ts`'s 19 permanently unkillable
513
+ * mutants were those duplicate checks.
514
+ *
515
+ * Nothing is lost by an implementation: a `load` annotated
516
+ * `PersistedScrollback | null` still satisfies this, so a consumer that holds
517
+ * real entries in memory keeps its own type checking. What goes is only the
518
+ * false guarantee at the boundary that cannot honour it.
519
+ *
520
+ * `PersistedScrollback` remains the shape to store and the shape you get back
521
+ * when storage is intact; see `save`. */
522
+ load(sessionId: string): unknown;
504
523
  /** Store an entry, replacing any previous one for the session.
505
524
  *
506
525
  * THROW when nothing was persisted (over quota, storage revoked, an entry the