@cplieger/web-terminal-ui 7.0.4 → 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.4",
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",
@@ -50,22 +50,22 @@
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
- "@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.10.0",
61
- "knip": "6.33.0",
60
+ "html-validate": "11.14.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",
68
- "vitest": "4.1.11"
67
+ "typescript-eslint": "8.69.0",
68
+ "vitest": "5.0.0"
69
69
  },
70
70
  "publishConfig": {
71
71
  "access": "public"
@@ -39,6 +39,39 @@ let sendingComposition = false;
39
39
  let compositionStart = 0;
40
40
  let compositionSuffix = "";
41
41
 
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
+ //
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.
55
+ //
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;
72
+
73
+ let lastCompositionActivity = 0;
74
+
42
75
  export function init(opts: {
43
76
  textarea: HTMLTextAreaElement;
44
77
  compositionView: HTMLElement;
@@ -60,23 +93,61 @@ export function init(opts: {
60
93
  textarea.addEventListener("paste", onPaste);
61
94
  }
62
95
 
63
- /** True while an IME composition is in progress. Caller's keydown
64
- * listener must early-return on true to avoid sending raw key bytes
65
- * 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. */
66
105
  export function isComposing(): boolean {
106
+ if (sendingComposition) {
107
+ return true;
108
+ }
109
+ if (!composing) {
110
+ return false;
111
+ }
112
+ if (Date.now() - lastCompositionActivity <= COMPOSITION_IDLE_MS) {
113
+ return true;
114
+ }
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 {
67
125
  return composing || sendingComposition;
68
126
  }
69
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");
138
+ }
139
+
70
140
  /** Abort any in-flight composition without sending, and clear the textarea.
71
- * The kernel calls this on a tab switch (detach): committing half-composed IME
72
- * text to either the outgoing or the incoming session is wrong, so we discard
73
- * it (design 5.1, "end any composition ... or cancel"). Clearing
74
- * sendingComposition also neutralizes a just-fired compositionend whose
75
- * deferred send is still pending on the microtask/timeout queue, so it cannot
76
- * 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. */
77
147
  export function cancelComposition(): void {
78
148
  composing = false;
79
149
  sendingComposition = false;
150
+ lastCompositionActivity = 0;
80
151
  compositionView.textContent = "";
81
152
  compositionView.classList.remove("active");
82
153
  resetToPlaceholder(textarea);
@@ -104,6 +175,7 @@ export function teardown(): void {
104
175
 
105
176
  function onStart(): void {
106
177
  composing = true;
178
+ lastCompositionActivity = Date.now();
107
179
  const start = textarea.selectionStart;
108
180
  const end = textarea.selectionEnd;
109
181
  compositionStart = Math.min(start, end);
@@ -115,6 +187,10 @@ function onStart(): void {
115
187
  }
116
188
 
117
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();
118
194
  // \u200E (LTR mark) wrappers + CSS direction:rtl on the view make
119
195
  // long compositions show their trailing edge instead of being
120
196
  // clipped at the start. Pattern from xterm.js.
@@ -125,6 +201,7 @@ function onUpdate(ev: CompositionEvent): void {
125
201
  function onEnd(): void {
126
202
  compositionView.classList.remove("active");
127
203
  composing = false;
204
+ lastCompositionActivity = 0;
128
205
  // The compositionend event fires before the textarea reflects the
129
206
  // final value on most browsers (Chromium especially). Defer one tick
130
207
  // and read the textarea then; xterm.js's pattern.
@@ -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) {
@@ -758,6 +768,30 @@ function buildTerminal(
758
768
  ) {
759
769
  resetToPlaceholder(input);
760
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;
761
795
  } else if (typeof ev.data === "string" && ev.data.length > 0) {
762
796
  if (inputType === "insertFromPaste") {
763
797
  paste(ev.data);
@@ -788,7 +822,12 @@ function buildTerminal(
788
822
  input.addEventListener(
789
823
  "blur",
790
824
  () => {
791
- 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();
792
831
  termWrap.classList.remove("focus");
793
832
  },
794
833
  { signal },
@@ -838,6 +877,33 @@ function buildTerminal(
838
877
  input.addEventListener(
839
878
  "keydown",
840
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
+ }
841
907
  if (composition.isComposing()) {
842
908
  return;
843
909
  }