@cplieger/web-terminal-ui 7.0.3 → 7.0.5

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/30-tabs.css CHANGED
@@ -355,27 +355,6 @@
355
355
  color: var(--text);
356
356
  }
357
357
 
358
- /* On an inactive tab the close appears only on hover, kept in the layout via
359
- visibility so the tab width never jumps; on the active tab it is always shown. */
360
- :where(.wt-root) .wt-tab:not(.wt-tab-active) .wt-tab-close {
361
- visibility: hidden;
362
- }
363
-
364
- :where(.wt-root) .wt-tab:not(.wt-tab-active):hover .wt-tab-close {
365
- visibility: visible;
366
- }
367
-
368
- /* Coarse pointer (an iPad using the strip): no hover fires, so an inactive tab's close
369
- would be unreachable without first activating the tab. Reveal every tab's close there,
370
- matching the always-visible mobile switcher closes. No narrow gate needed: on the
371
- narrow-coarse phone the whole strip is display:none (31-switcher.css), so this rule
372
- is moot there. */
373
- @media (pointer: coarse) {
374
- :where(.wt-root) .wt-tab:not(.wt-tab-active) .wt-tab-close {
375
- visibility: visible;
376
- }
377
- }
378
-
379
358
  /* The add-tab (+) button: a fixed bar item OUTSIDE the tab scroller (it never
380
359
  scrolls away with an overflowing list; the shrink-wrapped scroller keeps it
381
360
  visually trailing the last tab while the tabs fit — see .wt-tab-scroll). Its
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "7.0.3",
3
+ "version": "7.0.5",
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,19 +50,21 @@
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.2.0",
53
+ "@types/node": "26.4.1",
54
54
  "@typescript/native": "npm:typescript@7.0.2",
55
+ "@vitest/browser": "4.1.11",
56
+ "@vitest/browser-playwright": "4.1.11",
55
57
  "@vitest/coverage-v8": "4.1.11",
56
- "eslint": "10.8.1",
58
+ "eslint": "10.9.1",
57
59
  "fast-check": "4.9.0",
58
- "happy-dom": "20.11.6",
59
- "html-validate": "11.8.0",
60
- "knip": "6.32.2",
60
+ "html-validate": "11.12.0",
61
+ "knip": "6.34.0",
62
+ "playwright": "1.62.1",
61
63
  "prettier": "3.9.6",
62
64
  "stylelint": "17.14.1",
63
65
  "stylelint-config-standard": "40.0.0",
64
66
  "typescript": "npm:@typescript/typescript6@6.0.2",
65
- "typescript-eslint": "8.67.0",
67
+ "typescript-eslint": "8.69.0",
66
68
  "vitest": "4.1.11"
67
69
  },
68
70
  "publishConfig": {
@@ -39,6 +39,31 @@ let sendingComposition = false;
39
39
  let compositionStart = 0;
40
40
  let compositionSuffix = "";
41
41
 
42
+ // A soft keyboard may announce its composition only at COMMIT time: the
43
+ // per-character `input` events arrive with no composition in progress, the
44
+ // kernel sends each keystroke as typed, and then the keyboard fires
45
+ // compositionstart + compositionend back-to-back on the committing key while
46
+ // writing the WHOLE accumulated word into the textarea. The deferred read below
47
+ // then sends that word a second time — reported on Android Chrome with
48
+ // SwiftKey, where typing "hello" and pressing space produced "hellohello ".
49
+ //
50
+ // The terminal has already been given the literal keystrokes and cannot be
51
+ // asked to retract them, so a commit that only repeats them is a no-op.
52
+ // `noteDirectSend` records what the kernel sent with no composition running;
53
+ // the finaliser drops a commit that matches it.
54
+ //
55
+ // Unreachable on a keyboard that announces its composition BEFORE the
56
+ // keystrokes (every desktop IME, iOS, Android's own keyboard): there the
57
+ // kernel's `input` listener returns early while composing, so nothing is
58
+ // recorded and there is nothing for a commit to match.
59
+ const ECHO_TAIL_MAX = 64;
60
+ const ECHO_WINDOW_MS = 1500;
61
+ const ECHO_MAX_COMPOSE_MS = 60;
62
+
63
+ let directTail = "";
64
+ let directTailAt = 0;
65
+ let compositionStartedAt: number | null = null;
66
+
42
67
  export function init(opts: {
43
68
  textarea: HTMLTextAreaElement;
44
69
  compositionView: HTMLElement;
@@ -67,6 +92,42 @@ export function isComposing(): boolean {
67
92
  return composing || sendingComposition;
68
93
  }
69
94
 
95
+ /** Record text the kernel put on the wire with no composition running, so a
96
+ * keyboard that announces its composition only at commit time cannot have the
97
+ * same keystrokes delivered twice (see the ECHO_* note above). Typed text
98
+ * only — a paste is never re-committed by a keyboard. */
99
+ export function noteDirectSend(text: string): void {
100
+ if (text === "") {
101
+ return;
102
+ }
103
+ const now = Date.now();
104
+ if (now - directTailAt > ECHO_WINDOW_MS) {
105
+ directTail = "";
106
+ }
107
+ directTail = (directTail + text).slice(-ECHO_TAIL_MAX);
108
+ directTailAt = now;
109
+ }
110
+
111
+ /** Whether this commit merely repeats keystrokes already delivered literally. */
112
+ function repeatsDirectSends(composed: string, startedAt: number | null, endedAt: number): boolean {
113
+ if (directTail === "") {
114
+ return false;
115
+ }
116
+ // A composition the user typed INTO spans human time; a commit-time
117
+ // announcement starts and ends inside one keystroke.
118
+ if (startedAt !== null && endedAt - startedAt > ECHO_MAX_COMPOSE_MS) {
119
+ return false;
120
+ }
121
+ // Only the burst still being typed can be a re-commit of itself.
122
+ if (endedAt - directTailAt > ECHO_WINDOW_MS) {
123
+ return false;
124
+ }
125
+ // The committing key (space, tab) is delivered as its own `input` event, so
126
+ // what has to match is the WORD the keyboard is re-committing.
127
+ const word = composed.replace(/[ \t]+$/u, "");
128
+ return word !== "" && directTail.endsWith(word);
129
+ }
130
+
70
131
  /** Abort any in-flight composition without sending, and clear the textarea.
71
132
  * The kernel calls this on a tab switch (detach): committing half-composed IME
72
133
  * text to either the outgoing or the incoming session is wrong, so we discard
@@ -77,6 +138,8 @@ export function isComposing(): boolean {
77
138
  export function cancelComposition(): void {
78
139
  composing = false;
79
140
  sendingComposition = false;
141
+ compositionStartedAt = null;
142
+ directTail = "";
80
143
  compositionView.textContent = "";
81
144
  compositionView.classList.remove("active");
82
145
  resetToPlaceholder(textarea);
@@ -104,6 +167,7 @@ export function teardown(): void {
104
167
 
105
168
  function onStart(): void {
106
169
  composing = true;
170
+ compositionStartedAt = Date.now();
107
171
  const start = textarea.selectionStart;
108
172
  const end = textarea.selectionEnd;
109
173
  compositionStart = Math.min(start, end);
@@ -131,6 +195,9 @@ function onEnd(): void {
131
195
  sendingComposition = true;
132
196
  const startSnapshot = compositionStart;
133
197
  const suffixSnapshot = compositionSuffix;
198
+ const startedAt = compositionStartedAt;
199
+ const endedAt = Date.now();
200
+ compositionStartedAt = null;
134
201
  setTimeout(() => {
135
202
  if (!sendingComposition) {
136
203
  return;
@@ -144,9 +211,10 @@ function onEnd(): void {
144
211
  const composed = value
145
212
  .substring(startSnapshot, Math.max(startSnapshot, valueEnd))
146
213
  .replace(/\u00A0/g, " ");
147
- if (composed.length > 0) {
214
+ if (composed.length > 0 && !repeatsDirectSends(composed, startedAt, endedAt)) {
148
215
  send(composed);
149
216
  }
217
+ directTail = "";
150
218
  resetToPlaceholder(textarea);
151
219
  }, 0);
152
220
  }
@@ -75,6 +75,16 @@ export interface ContextMenuOptions {
75
75
  * opens, so a wrong answer here costs at most a duplicated menu on an unusual
76
76
  * device, never a broken selection and never a lost paste. */
77
77
  function isAppleTouchDevice(): boolean {
78
+ // Dead in every runtime that reaches this, and left in place deliberately.
79
+ // Measured on this repo's Node (v24.18.0, the version ts-ci pins): `typeof
80
+ // navigator` is "object", with userAgent "Node.js/24", platform
81
+ // "Linux x86_64" and maxTouchPoints undefined — so the fall-through already
82
+ // answers false there, by the same route a browser on a non-Apple device
83
+ // takes. A Web Worker has a WorkerNavigator, so it does not reach this
84
+ // either. What the guard still buys is totality: without it the bare
85
+ // `navigator` reads below would throw a ReferenceError in a runtime that has
86
+ // no navigator at all. It answers the same `false` the fall-through would, so
87
+ // nothing depends on reaching it, and no test can reach it without stubbing.
78
88
  if (typeof navigator === "undefined") {
79
89
  return false;
80
90
  }
@@ -19,10 +19,25 @@
19
19
  // the switcher's aggregate dot about what wants the user; the fold and the raise
20
20
  // share isUnseenCue (model.ts).
21
21
  //
22
- // DOM-free and global-free by construction, like notify.ts: every capability
23
- // arrives through AttentionEnv, so the decisions are unit-testable with no
24
- // document, no navigator and no icon assets, and browserAttentionEnv() below is
25
- // the one place that touches globals.
22
+ // DOM-free by construction, like notify.ts: every capability arrives through
23
+ // AttentionEnv, so the fold, the idempotence and the degradation paths are driven
24
+ // in the tests by plain recorder functions, and browserAttentionEnv() below is the
25
+ // only function here that reads a global (globalThis.navigator, document).
26
+ //
27
+ // That last clause is a reading of the source, and nothing enforces it — on
28
+ // purpose, not by omission. An ambient-absence test would assert the RUNTIME
29
+ // rather than this module: the only importer is tabs/index.ts, the node project
30
+ // holds one file and it reads stylesheets off disk, so nothing here loads this
31
+ // module outside a browser and no test can observe an absent `document` without
32
+ // passing for a third wrong reason (.kiro/steering/testing-ts.md,
33
+ // "Capability-absence is NOT a project selection"). The half that does ship IS
34
+ // enforced, and at the site: attention.test.ts shadows the whole `navigator`
35
+ // object and drives the real browserAttentionEnv through it. The mechanism that
36
+ // could enforce the load-time half is an AST scan of this file's top-level
37
+ // statements — vibekit carries one for its ported copy, where a node-project test
38
+ // statically imports the module and a module-scope global read really does break
39
+ // something. Copy it here the day anything needs this module to load without a
40
+ // document.
26
41
 
27
42
  import { type CueStatus, cueIconName, isUnseenCue, worseCue } from "./model.js";
28
43
 
@@ -1007,8 +1007,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1007
1007
  // sync, so a user browsing a scrolled strip is never yanked back by an
1008
1008
  // unrelated repaint), the newly active chip is brought into view in the
1009
1009
  // overflowed scroller; inline: "nearest" is a no-op when it is already
1010
- // visible. The typeof guard covers happy-dom, which lacks
1011
- // scrollIntoView.
1010
+ // visible. The typeof guard covers an environment without scrollIntoView.
1012
1011
  let lastRevealedActive = "";
1013
1012
  function paintActive(): void {
1014
1013
  for (const t of tabList) {
@@ -2247,7 +2246,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2247
2246
  }
2248
2247
  }
2249
2248
  if (invert.size === 0) {
2250
- return; // nothing moved on screen (a zero-rect DOM: happy-dom)
2249
+ return; // nothing moved on screen (every box measured zero)
2251
2250
  }
2252
2251
  applyShift(invert, "none");
2253
2252
  // The read forces the reflow that COMMITS the from-state. Without it the
@@ -2537,8 +2536,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2537
2536
  // Reordering re-appends every chip, and reparenting the focused field blurs
2538
2537
  // it — so an open edit would be committed as a side effect of moving some
2539
2538
  // OTHER tab, via a blur the user never performed. Resolve it here instead,
2540
- // so the outcome is chosen and testable rather than emergent (happy-dom
2541
- // does not reproduce the reparent-blur, so no test could catch it).
2539
+ // so the outcome is chosen rather than emergent: a blur that arrives as a
2540
+ // side effect of reparenting is not a decision anyone made.
2542
2541
  resolveEdit("blur", false);
2543
2542
  const from = tabList.findIndex((t) => t.id === id);
2544
2543
  const to = from + delta;
@@ -249,9 +249,9 @@ export function centreChipLabels(
249
249
  };
250
250
 
251
251
  remeasure(false);
252
- // The DOM lib types document.fonts as always present; happy-dom ships no
253
- // FontFaceSet at all, and neither does any engine without the CSS Font
254
- // Loading API, so the honest type is the optional one.
252
+ // The DOM lib types document.fonts as always present; an engine without the CSS
253
+ // Font Loading API ships no FontFaceSet at all, so the honest type is the
254
+ // optional one.
255
255
  const fonts = document.fonts as FontFaceSet | undefined;
256
256
  // `ready` settles the initial load (including a failed one); `loadingdone`
257
257
  // covers a face the host page adds afterwards, which `ready` never reports.
@@ -48,7 +48,7 @@ export type NotificationCtorLike = new (
48
48
  * not recognise must degrade rather than be asserted. */
49
49
  export interface NotifierEnv {
50
50
  /** The Notification constructor, or undefined where the API does not exist
51
- * (iOS Safari outside an installed PWA, a non-secure context, happy-dom). */
51
+ * (iOS Safari outside an installed PWA, a non-secure context). */
52
52
  ctor: NotificationCtorLike | undefined;
53
53
  /** Current permission: "default" | "granted" | "denied" (or anything else). */
54
54
  permission: () => string;
@@ -10,7 +10,7 @@
10
10
  // on the 360 ms fallback timer instead of the animation's own end, and every test
11
11
  // still passes because the tests dispatch the JS constant.
12
12
  //
13
- // Living here rather than inside the feature is what lets `css-contract.test.ts`
13
+ // Living here rather than inside the feature is what lets `css-contract.node.test.ts`
14
14
  // import it and assert the CSS agrees. Same reason `input-placeholder.ts` and
15
15
  // `kernel/gesture.ts` exist: a constant two places must keep in lockstep gets its
16
16
  // own module. NOT part of the package's `exports` map, so it is internal and can
@@ -40,6 +40,7 @@ import { createConnState } from "./conn-state.js";
40
40
  import { createScrollbackKeeper } from "./scrollback.js";
41
41
  import { STARTUP_FAILURE_COPY } from "./startup-copy.js";
42
42
  import { attachLoadingStatus, DEFAULT_LOADING_MESSAGES } from "./loading-status.js";
43
+ import { reloadPage } from "./navigation.js";
43
44
  import type {
44
45
  CreateTerminalOptions,
45
46
  FeatureInstance,
@@ -184,7 +185,7 @@ function renderFatalStartupInto(
184
185
  reloadButton.type = "button";
185
186
  reloadButton.textContent = STARTUP_FAILURE_COPY.reloadLabel;
186
187
  reloadButton.addEventListener("click", () => {
187
- window.location.reload();
188
+ reloadPage();
188
189
  });
189
190
  card.append(title, messageEl, reloadButton);
190
191
  surface.appendChild(card);
@@ -389,6 +390,14 @@ function buildTerminal(
389
390
  function sendText(text: string): void {
390
391
  sendBytes(encoder.encode(text));
391
392
  }
393
+ /** A keystroke the user typed with no composition running. Recorded so a
394
+ * keyboard that announces its composition only at commit time cannot have
395
+ * the same characters delivered twice (composition.ts, the ECHO_* note). */
396
+ function sendTyped(text: string): void {
397
+ const out = normalizeTypedText(text);
398
+ sendText(out);
399
+ composition.noteDirectSend(out);
400
+ }
392
401
  function paste(text: string): void {
393
402
  // The one bracketed-paste + newline-normalize funnel; every feature and the
394
403
  // kernel's own paste paths route through here (paste-jacking defense).
@@ -762,14 +771,14 @@ function buildTerminal(
762
771
  paste(ev.data);
763
772
  } else {
764
773
  // Normalize iOS's NBSP-for-space quirk, then send through the funnel.
765
- sendText(normalizeTypedText(ev.data));
774
+ sendTyped(ev.data);
766
775
  }
767
776
  } else {
768
777
  const v = input.value;
769
778
  if (v.length > INPUT_PLACEHOLDER.length && v.startsWith(INPUT_PLACEHOLDER)) {
770
- sendText(normalizeTypedText(v.slice(INPUT_PLACEHOLDER.length)));
779
+ sendTyped(v.slice(INPUT_PLACEHOLDER.length));
771
780
  } else if (v !== INPUT_PLACEHOLDER && v.length > 0) {
772
- sendText(normalizeTypedText(v));
781
+ sendTyped(v);
773
782
  }
774
783
  }
775
784
  resetToPlaceholder(input);
@@ -1170,7 +1179,7 @@ function buildTerminal(
1170
1179
  // an uncanceled keydown's insertion target after a listener moved focus
1171
1180
  // mid-dispatch. Chromium does; no specification requires it.
1172
1181
  ev.preventDefault();
1173
- sendText(normalizeTypedText(char));
1182
+ sendTyped(char);
1174
1183
  return;
1175
1184
  }
1176
1185
  applyMappedKey(ev, result);
@@ -0,0 +1,19 @@
1
+ // navigation.ts — the library's one page-navigation call, behind its own module.
2
+ //
3
+ // `window.location.reload` cannot be substituted in a real browser: `reload` is
4
+ // an own, sealed property of the location instance (`configurable: false`,
5
+ // `writable: false`), `Location.prototype` carries no `reload` to spy on, and
6
+ // `window`, `globalThis.window` and `globalThis.location` are all
7
+ // non-configurable. So a test cannot redefine it, assign over it, or stub the
8
+ // global, and letting the real call run navigates the test runner's own frame
9
+ // and takes the whole run down with it.
10
+ //
11
+ // A separate module is the seam that works: a caller's import is a live binding
12
+ // the test framework can replace, which an intra-module call is not. Keeping the
13
+ // call here also means there is exactly one place in the library that navigates,
14
+ // so the next one has to be added deliberately.
15
+
16
+ /** Reload the current page. The only navigation this library performs. */
17
+ export function reloadPage(): void {
18
+ window.location.reload();
19
+ }
@@ -25,7 +25,7 @@
25
25
  // These lists are HAND-WRITTEN on purpose. They are the public contract — a
26
26
  // decision about what this library promises to support — not a fact derived
27
27
  // from the stylesheets. What IS derived from the stylesheets is the
28
- // verification that the promise holds: `src/css-contract.test.ts` generates the
28
+ // verification that the promise holds: `src/css-contract.node.test.ts` generates the
29
29
  // declared/referenced token inventory from the CSS itself and fails if any
30
30
  // token named here is not both DECLARED and READ by a rule. A token that is
31
31
  // declared but never read is the silent-no-op case above, reached from the
@@ -10,6 +10,18 @@ import { activityMonitor } from "../features/activity-monitor.js";
10
10
  import { animations } from "../features/animations.js";
11
11
  import { presetSingle } from "./single.js";
12
12
 
13
+ /** Options shared by the two tabbed presets, passed through to the tabs feature.
14
+ * A preset that takes arguments is called as `features: () =>
15
+ * presetTabbed({...})`, which keeps the call inside createTerminal's failure
16
+ * boundary (see CreateTerminalOptions.features). */
17
+ export interface TabbedPresetOptions {
18
+ /** Swap the page's icon links to a status variant while a background session
19
+ * wants the user. Off by default, and enabling it is a promise that the
20
+ * variant assets are served — see TabsOptions.attentionIcons for the naming
21
+ * contract and .kiro/scripts/gen-attention-icons.py, which writes them. */
22
+ attentionIcons?: boolean;
23
+ }
24
+
13
25
  /** buildTabbed composes the tabbed UI: the single-pane touch features, an
14
26
  * externally-driven mobile toolbar (its grid is opened from a keyboard button
15
27
  * in the tab bar, not its own toggle), the activity monitor (the status-SSE
@@ -27,18 +39,6 @@ import { presetSingle } from "./single.js";
27
39
  * foreground-process/cwd inference — and both presets render what it reports. A
28
40
  * browser that re-derived any of that could only disagree with the server and
29
41
  * with every other client attached to the same session. */
30
- /** Options shared by the two tabbed presets, passed through to the tabs feature.
31
- * A preset that takes arguments is called as `features: () =>
32
- * presetTabbed({...})`, which keeps the call inside createTerminal's failure
33
- * boundary (see CreateTerminalOptions.features). */
34
- export interface TabbedPresetOptions {
35
- /** Swap the page's icon links to a status variant while a background session
36
- * wants the user. Off by default, and enabling it is a promise that the
37
- * variant assets are served — see TabsOptions.attentionIcons for the naming
38
- * contract and .kiro/scripts/gen-attention-icons.py, which writes them. */
39
- attentionIcons?: boolean;
40
- }
41
-
42
42
  export function buildTabbed(
43
43
  agentShell: boolean,
44
44
  opts: TabbedPresetOptions = {},
@@ -59,12 +59,8 @@ export function buildTabbed(
59
59
  ];
60
60
  }
61
61
 
62
- /** Tabbed UI: the touch features, tabs, the mobile keyboard bar, the activity
63
- * monitor, and animations. Each tab's title follows the process OSC 0/2 window
64
- * title when the program sets one, else the last submitted line; each tab's
65
- * activity dot stays hidden until its session reports activity via OSC 9;4
66
- * progress (kiro-cli, Claude Code, …), so a plain bash/sh keeps clean,
67
- * label-only tabs. */
62
+ /** Tabbed UI: buildTabbed with agent activity presumed off (see buildTabbed for
63
+ * title and activity-dot sourcing). */
68
64
  export function presetTabbed(opts: TabbedPresetOptions = {}): TerminalFeature<unknown>[] {
69
65
  return buildTabbed(false, opts);
70
66
  }