@cplieger/web-terminal-ui 7.0.5 → 7.0.6

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.5",
3
+ "version": "7.0.6",
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",
@@ -52,12 +52,12 @@
52
52
  "@stryker-mutator/vitest-runner": "10.0.0",
53
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",
57
- "@vitest/coverage-v8": "4.1.11",
55
+ "@vitest/browser": "5.0.0",
56
+ "@vitest/browser-playwright": "5.0.0",
57
+ "@vitest/coverage-v8": "5.0.0",
58
58
  "eslint": "10.9.1",
59
59
  "fast-check": "4.9.0",
60
- "html-validate": "11.12.0",
60
+ "html-validate": "11.14.0",
61
61
  "knip": "6.34.0",
62
62
  "playwright": "1.62.1",
63
63
  "prettier": "3.9.6",
@@ -65,7 +65,7 @@
65
65
  "stylelint-config-standard": "40.0.0",
66
66
  "typescript": "npm:@typescript/typescript6@6.0.2",
67
67
  "typescript-eslint": "8.69.0",
68
- "vitest": "4.1.11"
68
+ "vitest": "5.0.0"
69
69
  },
70
70
  "publishConfig": {
71
71
  "access": "public"
@@ -39,30 +39,38 @@ 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 ".
42
+ // A composition that never ENDS used to stop the terminal accepting input at
43
+ // all. `composing` gates both the kernel's `input` and `keydown` listeners, and
44
+ // nothing cleared it: no blur handler, no timer. So a keyboard that opened a
45
+ // composition and never closed it left every subsequent keystroke, Enter
46
+ // included, dropped on the floor — reported on Android Chrome with SwiftKey as
47
+ // "typing does not work", recoverable only by tapping a word suggestion, which
48
+ // is what finally fired compositionend.
49
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.
50
+ // Not a SwiftKey quirk to special-case. Chromium has a filed defect for
51
+ // compositionend going missing (crbug 446714223, in EditContext rather than a
52
+ // textarea, so it is adjacent evidence and not proof of this exact path), and
53
+ // CodeMirror synthesises the event outright for Safari dead keys, which is a
54
+ // working implementation treating the same absence as real.
54
55
  //
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;
56
+ // The fix is a STALENESS READ rather than a timer. A composition the user is
57
+ // typing into keeps firing compositionupdate, so its clock keeps refreshing; an
58
+ // abandoned one stops and ages out, releasing the gate. UI Events promises one
59
+ // or more compositionupdate events and one whenever the DOM is updated as part
60
+ // of the composition, not strictly one per key, so this is a heuristic about
61
+ // activity rather than a guaranteed per-keystroke signal. Deliberately NOT
62
+ // refreshed by `input` events: in this failure the user IS typing and those
63
+ // events ARE arriving, so refreshing there would defeat the mechanism.
64
+ //
65
+ // Five seconds is the number Slate's android-input-manager uses for its own
66
+ // composition-gone-quiet bound (a DOM flush decision, not an input gate, so the
67
+ // value is borrowed and the job is not the same). It is generous because the
68
+ // residual risk runs the other way: a composition paused longer than this and
69
+ // then resumed can duplicate its first character. That trade is deliberate —
70
+ // the failure being replaced loses ALL input until an unrelated event fires.
71
+ const COMPOSITION_IDLE_MS = 5000;
62
72
 
63
- let directTail = "";
64
- let directTailAt = 0;
65
- let compositionStartedAt: number | null = null;
73
+ let lastCompositionActivity = 0;
66
74
 
67
75
  export function init(opts: {
68
76
  textarea: HTMLTextAreaElement;
@@ -85,61 +93,61 @@ export function init(opts: {
85
93
  textarea.addEventListener("paste", onPaste);
86
94
  }
87
95
 
88
- /** True while an IME composition is in progress. Caller's keydown
89
- * listener must early-return on true to avoid sending raw key bytes
90
- * during composition. */
96
+ /** True while an IME composition is in progress, RECONCILED first: a composition
97
+ * whose clock has not been refreshed for COMPOSITION_IDLE_MS is expired here,
98
+ * clearing the latch, the clock and the overlay, so a keyboard that never fires
99
+ * compositionend cannot strand the terminal or leave a stale panel on screen.
100
+ * A late compositionend still delivers its text — `onEnd` snapshots what it
101
+ * needs and does not require `composing` to have stayed true.
102
+ *
103
+ * `sendingComposition` is exempt: that window is bounded by a setTimeout(0)
104
+ * this module owns. */
91
105
  export function isComposing(): boolean {
92
- return composing || sendingComposition;
93
- }
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;
106
+ if (sendingComposition) {
107
+ return true;
102
108
  }
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 === "") {
109
+ if (!composing) {
114
110
  return false;
115
111
  }
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;
112
+ if (Date.now() - lastCompositionActivity <= COMPOSITION_IDLE_MS) {
113
+ return true;
120
114
  }
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);
115
+ expireComposition();
116
+ return false;
117
+ }
118
+
119
+ /** The RAW latch: is a composition open at all, regardless of whether it has
120
+ * gone quiet. Read before the reconciling `isComposing()` by the keydown guard,
121
+ * because the keyCode 229 signal only means "an IME claimed this key" while a
122
+ * composition is genuinely open — on Android it is the value for nearly every
123
+ * soft-keyboard key. */
124
+ export function isCompositionOpen(): boolean {
125
+ return composing || sendingComposition;
126
+ }
127
+
128
+ /** Drop a composition that has gone quiet: the latch, the clock and the visible
129
+ * overlay together. Without the overlay half the gate would say no composition
130
+ * while `.composition-view.active` still said there was one, leaving a stale
131
+ * panel over the terminal for as long as the missing compositionend never
132
+ * arrived. Does NOT touch the textarea: a late compositionend still reads it. */
133
+ function expireComposition(): void {
134
+ composing = false;
135
+ lastCompositionActivity = 0;
136
+ compositionView.textContent = "";
137
+ compositionView.classList.remove("active");
129
138
  }
130
139
 
131
140
  /** Abort any in-flight composition without sending, and clear the textarea.
132
- * The kernel calls this on a tab switch (detach): committing half-composed IME
133
- * text to either the outgoing or the incoming session is wrong, so we discard
134
- * it (design 5.1, "end any composition ... or cancel"). Clearing
135
- * sendingComposition also neutralizes a just-fired compositionend whose
136
- * deferred send is still pending on the microtask/timeout queue, so it cannot
137
- * land on whoever is active after the switch. */
141
+ * The kernel calls this on a tab switch (detach) and on blur: committing
142
+ * half-composed IME text to either the outgoing or the incoming session is
143
+ * wrong, so we discard it (design 5.1, "end any composition ... or cancel").
144
+ * Clearing sendingComposition also neutralizes a just-fired compositionend
145
+ * whose deferred send is still pending on the microtask/timeout queue, so it
146
+ * cannot land on whoever is active after the switch. */
138
147
  export function cancelComposition(): void {
139
148
  composing = false;
140
149
  sendingComposition = false;
141
- compositionStartedAt = null;
142
- directTail = "";
150
+ lastCompositionActivity = 0;
143
151
  compositionView.textContent = "";
144
152
  compositionView.classList.remove("active");
145
153
  resetToPlaceholder(textarea);
@@ -167,7 +175,7 @@ export function teardown(): void {
167
175
 
168
176
  function onStart(): void {
169
177
  composing = true;
170
- compositionStartedAt = Date.now();
178
+ lastCompositionActivity = Date.now();
171
179
  const start = textarea.selectionStart;
172
180
  const end = textarea.selectionEnd;
173
181
  compositionStart = Math.min(start, end);
@@ -179,6 +187,10 @@ function onStart(): void {
179
187
  }
180
188
 
181
189
  function onUpdate(ev: CompositionEvent): void {
190
+ // Every keystroke of a live composition lands here, which is what keeps its
191
+ // staleness clock fresh (see COMPOSITION_IDLE_MS). An abandoned composition
192
+ // stops firing this and ages out of the gate.
193
+ lastCompositionActivity = Date.now();
182
194
  // \u200E (LTR mark) wrappers + CSS direction:rtl on the view make
183
195
  // long compositions show their trailing edge instead of being
184
196
  // clipped at the start. Pattern from xterm.js.
@@ -189,15 +201,13 @@ function onUpdate(ev: CompositionEvent): void {
189
201
  function onEnd(): void {
190
202
  compositionView.classList.remove("active");
191
203
  composing = false;
204
+ lastCompositionActivity = 0;
192
205
  // The compositionend event fires before the textarea reflects the
193
206
  // final value on most browsers (Chromium especially). Defer one tick
194
207
  // and read the textarea then; xterm.js's pattern.
195
208
  sendingComposition = true;
196
209
  const startSnapshot = compositionStart;
197
210
  const suffixSnapshot = compositionSuffix;
198
- const startedAt = compositionStartedAt;
199
- const endedAt = Date.now();
200
- compositionStartedAt = null;
201
211
  setTimeout(() => {
202
212
  if (!sendingComposition) {
203
213
  return;
@@ -211,10 +221,9 @@ function onEnd(): void {
211
221
  const composed = value
212
222
  .substring(startSnapshot, Math.max(startSnapshot, valueEnd))
213
223
  .replace(/\u00A0/g, " ");
214
- if (composed.length > 0 && !repeatsDirectSends(composed, startedAt, endedAt)) {
224
+ if (composed.length > 0) {
215
225
  send(composed);
216
226
  }
217
- directTail = "";
218
227
  resetToPlaceholder(textarea);
219
228
  }, 0);
220
229
  }
@@ -120,6 +120,16 @@ function normalizeTypedText(text: string): string {
120
120
  return text.replace(/\u00A0/g, " ");
121
121
  }
122
122
 
123
+ /** `KeyboardEvent.keyCode` for a key an IME has claimed. Windows' VK_PROCESS,
124
+ * reported by Blink and WebKit for the key that commits a composition, and by
125
+ * Android for essentially every soft-keyboard key — which is why every read of
126
+ * it is gated on a composition being open.
127
+ *
128
+ * Deprecated and nonetheless the only carrier of this fact: for that commit key
129
+ * both engines report `key` as the ordinary `"Enter"`, and Safari reports
130
+ * `isComposing` false, so neither the modern flag nor `key` distinguishes it. */
131
+ const IME_COMMIT_KEYCODE = 229;
132
+
123
133
  function pick(root: ParentNode, selector: string): HTMLElement {
124
134
  const el = root.querySelector<HTMLElement>(selector);
125
135
  if (!el) {
@@ -390,14 +400,6 @@ function buildTerminal(
390
400
  function sendText(text: string): void {
391
401
  sendBytes(encoder.encode(text));
392
402
  }
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
- }
401
403
  function paste(text: string): void {
402
404
  // The one bracketed-paste + newline-normalize funnel; every feature and the
403
405
  // kernel's own paste paths route through here (paste-jacking defense).
@@ -766,19 +768,43 @@ function buildTerminal(
766
768
  ) {
767
769
  resetToPlaceholder(input);
768
770
  return;
771
+ } else if (inputType === "insertReplacementText") {
772
+ // A spellcheck / autocorrect / autofill REWRITE of text already typed.
773
+ // The bytes it is correcting have already gone to the pty and a terminal
774
+ // cannot retract them: 0x7f means whatever the remote application decides
775
+ // it means, so a pager, vim, a y/n prompt and a no-echo password prompt
776
+ // each read it as their own input. Applying the rewrite forward instead
777
+ // duplicates — measured on xterm.js's value-diff, where `helo` corrected
778
+ // to `hello` puts `helohello` on the wire (its issue #3600). So drop it
779
+ // and leave the typed text standing, which is also what a shell wants:
780
+ // `cd /usr` silently becoming `cd /use` is worse than no autocorrect.
781
+ //
782
+ // The spec's own signal for this, so no length heuristic is needed: a
783
+ // max-one-character rule would break glide typing (a whole word in one
784
+ // event, and SwiftKey's flagship input method) while still admitting
785
+ // CJK's one-for-one in-place replacement. Cancelling is not an option
786
+ // either — preventDefault on beforeinput is a no-op in Chrome, per
787
+ // CodeMirror's own note — but declining to SEND is entirely ours.
788
+ //
789
+ // Reachable on more than Android: WebKit fires this type for a spelling
790
+ // suggestion and for a macOS system text substitution whatever
791
+ // autocorrect="off" says. Dropping is right wherever it fires, because
792
+ // what it carries is always a replacement for bytes the pty already has.
793
+ resetToPlaceholder(input);
794
+ return;
769
795
  } else if (typeof ev.data === "string" && ev.data.length > 0) {
770
796
  if (inputType === "insertFromPaste") {
771
797
  paste(ev.data);
772
798
  } else {
773
799
  // Normalize iOS's NBSP-for-space quirk, then send through the funnel.
774
- sendTyped(ev.data);
800
+ sendText(normalizeTypedText(ev.data));
775
801
  }
776
802
  } else {
777
803
  const v = input.value;
778
804
  if (v.length > INPUT_PLACEHOLDER.length && v.startsWith(INPUT_PLACEHOLDER)) {
779
- sendTyped(v.slice(INPUT_PLACEHOLDER.length));
805
+ sendText(normalizeTypedText(v.slice(INPUT_PLACEHOLDER.length)));
780
806
  } else if (v !== INPUT_PLACEHOLDER && v.length > 0) {
781
- sendTyped(v);
807
+ sendText(normalizeTypedText(v));
782
808
  }
783
809
  }
784
810
  resetToPlaceholder(input);
@@ -796,7 +822,12 @@ function buildTerminal(
796
822
  input.addEventListener(
797
823
  "blur",
798
824
  () => {
799
- resetToPlaceholder(input);
825
+ // cancelComposition, not just resetToPlaceholder: `composing` gates both
826
+ // input listeners, so a composition abandoned by losing focus would
827
+ // otherwise keep the gate shut and the terminal would take no input when
828
+ // focus came back. The staleness read covers a keyboard that strands us
829
+ // while still focused; this covers the case with a definite signal.
830
+ composition.cancelComposition();
800
831
  termWrap.classList.remove("focus");
801
832
  },
802
833
  { signal },
@@ -846,6 +877,33 @@ function buildTerminal(
846
877
  input.addEventListener(
847
878
  "keydown",
848
879
  (ev: KeyboardEvent) => {
880
+ // Three gates, and the order is the design. The spec signal first: UI
881
+ // Events requires a key event inside a composition session to carry
882
+ // isComposing, which covers every engine except one case.
883
+ //
884
+ // That case is the key which COMMITS a composition: Chrome and Safari both
885
+ // report keyCode 229 for it and Safari reports isComposing false (measured
886
+ // in quill PR #3445), so a second signal is needed. It is read against the
887
+ // RAW latch, before the reconciling isComposing() below, and both halves of
888
+ // that matter. Gated, because on Android 229 is the value for nearly every
889
+ // soft-keyboard key, so an unconditional check swallows ordinary Backspace
890
+ // and Enter — found in review after exactly that shipped here. Before the
891
+ // reconcile, because reconciling clears the latch, and a CJK user who left
892
+ // a candidate window open past the idle bound is still committing a real
893
+ // composition: taking this key as an ordinary Enter would send a carriage
894
+ // return before the composed phrase and execute a half-typed line.
895
+ //
896
+ // The consequence to know: in a stranded composition on Android, Enter and
897
+ // Backspace stay blocked until a character arrives (which reconciles the
898
+ // latch and releases them) or focus is lost. That is the deliberate side
899
+ // of the trade, because the alternative costs desktop and iOS correctness.
900
+ if (ev.isComposing) {
901
+ return;
902
+ }
903
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- see above
904
+ if (composition.isCompositionOpen() && ev.keyCode === IME_COMMIT_KEYCODE) {
905
+ return;
906
+ }
849
907
  if (composition.isComposing()) {
850
908
  return;
851
909
  }
@@ -1179,7 +1237,7 @@ function buildTerminal(
1179
1237
  // an uncanceled keydown's insertion target after a listener moved focus
1180
1238
  // mid-dispatch. Chromium does; no specification requires it.
1181
1239
  ev.preventDefault();
1182
- sendTyped(char);
1240
+ sendText(normalizeTypedText(char));
1183
1241
  return;
1184
1242
  }
1185
1243
  applyMappedKey(ev, result);