@cplieger/web-terminal-ui 7.0.4 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "7.0.4",
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,21 +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.4.0",
53
+ "@types/node": "26.4.1",
54
54
  "@typescript/native": "npm:typescript@7.0.2",
55
55
  "@vitest/browser": "4.1.11",
56
56
  "@vitest/browser-playwright": "4.1.11",
57
57
  "@vitest/coverage-v8": "4.1.11",
58
58
  "eslint": "10.9.1",
59
59
  "fast-check": "4.9.0",
60
- "html-validate": "11.10.0",
61
- "knip": "6.33.0",
60
+ "html-validate": "11.12.0",
61
+ "knip": "6.34.0",
62
62
  "playwright": "1.62.1",
63
63
  "prettier": "3.9.6",
64
64
  "stylelint": "17.14.1",
65
65
  "stylelint-config-standard": "40.0.0",
66
66
  "typescript": "npm:@typescript/typescript6@6.0.2",
67
- "typescript-eslint": "8.68.0",
67
+ "typescript-eslint": "8.69.0",
68
68
  "vitest": "4.1.11"
69
69
  },
70
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
  }
@@ -390,6 +390,14 @@ function buildTerminal(
390
390
  function sendText(text: string): void {
391
391
  sendBytes(encoder.encode(text));
392
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
+ }
393
401
  function paste(text: string): void {
394
402
  // The one bracketed-paste + newline-normalize funnel; every feature and the
395
403
  // kernel's own paste paths route through here (paste-jacking defense).
@@ -763,14 +771,14 @@ function buildTerminal(
763
771
  paste(ev.data);
764
772
  } else {
765
773
  // Normalize iOS's NBSP-for-space quirk, then send through the funnel.
766
- sendText(normalizeTypedText(ev.data));
774
+ sendTyped(ev.data);
767
775
  }
768
776
  } else {
769
777
  const v = input.value;
770
778
  if (v.length > INPUT_PLACEHOLDER.length && v.startsWith(INPUT_PLACEHOLDER)) {
771
- sendText(normalizeTypedText(v.slice(INPUT_PLACEHOLDER.length)));
779
+ sendTyped(v.slice(INPUT_PLACEHOLDER.length));
772
780
  } else if (v !== INPUT_PLACEHOLDER && v.length > 0) {
773
- sendText(normalizeTypedText(v));
781
+ sendTyped(v);
774
782
  }
775
783
  }
776
784
  resetToPlaceholder(input);
@@ -1171,7 +1179,7 @@ function buildTerminal(
1171
1179
  // an uncanceled keydown's insertion target after a listener moved focus
1172
1180
  // mid-dispatch. Chromium does; no specification requires it.
1173
1181
  ev.preventDefault();
1174
- sendText(normalizeTypedText(char));
1182
+ sendTyped(char);
1175
1183
  return;
1176
1184
  }
1177
1185
  applyMappedKey(ev, result);