@cplieger/web-terminal-ui 5.5.0 → 5.6.1

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": "5.5.0",
3
+ "version": "5.6.1",
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",
@@ -57,7 +57,7 @@
57
57
  "fast-check": "4.9.0",
58
58
  "happy-dom": "20.11.2",
59
59
  "html-validate": "11.6.2",
60
- "knip": "6.32.1",
60
+ "knip": "6.32.2",
61
61
  "prettier": "3.9.6",
62
62
  "stylelint": "17.14.1",
63
63
  "stylelint-config-standard": "40.0.0",
@@ -0,0 +1,32 @@
1
+ // Per-test cleanup of DOM state that outlives a test file.
2
+ //
3
+ // Loaded via vitest's `setupFiles` alongside fc-strict-setup, because
4
+ // `vitest.config.ts` deliberately sets `isolate: false`: test FILES share one
5
+ // happy-dom instance inside a worker thread, which is what makes the suite fast
6
+ // and also what lets one file's leftovers reach another's assertions.
7
+ //
8
+ // The document selection is the state that actually bites, for two compounding
9
+ // reasons. Several suites end with a live selection on purpose (the kernel's
10
+ // mouse-selection and type-to-focus suites assert that a gesture PRESERVED one,
11
+ // and contextMenu's dismissal suite clicks a real Select All). And happy-dom does
12
+ // not apply the selection fixup a real browser does, so a range whose nodes were
13
+ // removed by `document.body.replaceChildren()` keeps reporting its old text:
14
+ // measured rangeCount 1, isCollapsed false, and toString() unchanged after the
15
+ // nodes were detached.
16
+ //
17
+ // Left alone, that reached `context-menu.test.ts`'s "omits Copy with nothing
18
+ // selected" case, which found text to copy and failed. Order-dependently, so it
19
+ // passed every local run and every PR check, then failed once on main where the
20
+ // thread layout put the files in a different order. Clearing here rather than in
21
+ // each suite keeps the guarantee independent of which files a worker happens to
22
+ // pair up.
23
+ import { beforeEach } from "vitest";
24
+
25
+ beforeEach(() => {
26
+ // Node-environment files (the default; DOM files opt in per file) have no
27
+ // window, and nothing to clean.
28
+ if (typeof window === "undefined") {
29
+ return;
30
+ }
31
+ window.getSelection()?.removeAllRanges();
32
+ });
@@ -2561,6 +2561,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2561
2561
  if (activeId !== null) {
2562
2562
  return;
2563
2563
  }
2564
+ // ...but only once the bootstrap has adopted its list. Before that the
2565
+ // view is PARTIAL, and deterministically so: tabs subscribes to the status
2566
+ // stream during setup, before resolveInitialSession issues its
2567
+ // GET /api/sessions, so the SSE snapshot's one-event-per-session arrives
2568
+ // first and this ran on a strip holding only whichever session the server
2569
+ // sent first. Activating it both picked a tab on incomplete information and
2570
+ // silently disabled the saved-tab restore: the bootstrap ladder's first act
2571
+ // is to return early when activeId is already set, so localStorage was
2572
+ // never read and every reload landed on the oldest tab. Boot activation
2573
+ // belongs to that ladder alone, which is the only site with the whole list
2574
+ // AND the saved id; this stays the runtime repair it was written to be.
2575
+ if (!started) {
2576
+ return;
2577
+ }
2564
2578
  const first = tabList.find((t) => !isEndedStatus(statusOf(t))) ?? tabList[0];
2565
2579
  if (first) {
2566
2580
  switchTo(first.id);
@@ -4025,10 +4039,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
4025
4039
  }
4026
4040
  // From here on, tabs added at runtime (create / adopt) animate in.
4027
4041
  started = true;
4028
- // The SSE snapshot may have raced this bootstrap and already activated
4029
- // a tab (ensureActive during the await above). The switch is then
4030
- // already in flight — return null; the kernel sees connectionInitiated
4031
- // and leaves the loading overlay to the normal ready path.
4042
+ // Something already activated a tab while the list was in flight. The
4043
+ // chrome mounts synchronously, so a user can tap a chip (or a delivered
4044
+ // notification can be clicked) before this resolves, and an explicit
4045
+ // gesture outranks the saved id — return null and leave that switch alone;
4046
+ // the kernel sees connectionInitiated and leaves the loading overlay to
4047
+ // the normal ready path. ensureActive is deliberately NOT one of these
4048
+ // callers during boot (see its `started` gate): when it was, the SSE
4049
+ // snapshot won this race on every load and the ladder below never ran.
4032
4050
  if (activeId !== null) {
4033
4051
  return null;
4034
4052
  }
@@ -91,6 +91,34 @@ const CORE_TEMPLATE = `
91
91
  <div class="composition-view" aria-hidden="true"></div>
92
92
  </div>`;
93
93
 
94
+ /**
95
+ * The single character this key types, or null when it types none. Used to tell
96
+ * a character the engine's encoder deferred to the `input` event apart from a
97
+ * key whose `key` is a NAME rather than text.
98
+ *
99
+ * `codePointAt` consumes a surrogate pair whole, so a key whose first scalar is
100
+ * the whole string is exactly one character; "Shift", "Dead", "F5" and "Process"
101
+ * are not. Code points rather than grapheme clusters on purpose: a character key
102
+ * carries one code point, and a segmenter would also accept a multi-scalar
103
+ * cluster. UI Events does allow a key "associated with multiple characters"
104
+ * (macros, some dead-key sequences); those return null here and are simply not
105
+ * recovered, which is a stated limitation rather than a claim about key names.
106
+ */
107
+ function typedChar(ev: KeyboardEvent): string | null {
108
+ const first = ev.key.codePointAt(0);
109
+ return first !== undefined && String.fromCodePoint(first) === ev.key ? ev.key : null;
110
+ }
111
+
112
+ /**
113
+ * Normalizes text on its way to the PTY: iOS sends U+00A0 where a space was
114
+ * typed. Shared by every send path so one physical key cannot produce two byte
115
+ * sequences depending on which listener caught it (the `input` event on the
116
+ * focused textarea, or the document-level type-to-focus fallback).
117
+ */
118
+ function normalizeTypedText(text: string): string {
119
+ return text.replace(/\u00A0/g, " ");
120
+ }
121
+
94
122
  function pick(root: ParentNode, selector: string): HTMLElement {
95
123
  const el = root.querySelector<HTMLElement>(selector);
96
124
  if (!el) {
@@ -712,14 +740,14 @@ function buildTerminal(
712
740
  paste(ev.data);
713
741
  } else {
714
742
  // Normalize iOS's NBSP-for-space quirk, then send through the funnel.
715
- sendText(ev.data.replace(/\u00A0/g, " "));
743
+ sendText(normalizeTypedText(ev.data));
716
744
  }
717
745
  } else {
718
746
  const v = input.value;
719
747
  if (v.length > INPUT_PLACEHOLDER.length && v.startsWith(INPUT_PLACEHOLDER)) {
720
- sendText(v.slice(INPUT_PLACEHOLDER.length).replace(/\u00A0/g, " "));
748
+ sendText(normalizeTypedText(v.slice(INPUT_PLACEHOLDER.length)));
721
749
  } else if (v !== INPUT_PLACEHOLDER && v.length > 0) {
722
- sendText(v.replace(/\u00A0/g, " "));
750
+ sendText(normalizeTypedText(v));
723
751
  }
724
752
  }
725
753
  resetToPlaceholder(input);
@@ -743,6 +771,42 @@ function buildTerminal(
743
771
  { signal },
744
772
  );
745
773
 
774
+ /**
775
+ * Applies the part of a `mapKeyboardEvent` result that BOTH keydown paths (the
776
+ * focused textarea below, and the document-level type-to-focus fallback in the
777
+ * focus-strategy section) handle identically. Returns false only for "ignore",
778
+ * the one arm the two callers resolve differently: the textarea lets its own
779
+ * `input` event deliver the character, while the fallback has no `input` event
780
+ * coming and must send the character itself.
781
+ *
782
+ * Switching over the whole `KeyboardResult` union in one place is what keeps
783
+ * the two paths from drifting — handling only "send" in the fallback silently
784
+ * dropped Shift+PageUp, which is exactly what a reader who just selected some
785
+ * scrollback presses next.
786
+ */
787
+ function applyMappedKey(ev: KeyboardEvent, result: keyboard.KeyboardResult): boolean {
788
+ switch (result.kind) {
789
+ case "send":
790
+ ev.preventDefault();
791
+ sendText(result.bytes);
792
+ return true;
793
+ case "scroll-up": {
794
+ ev.preventDefault();
795
+ const h = termWrap.clientHeight;
796
+ termWrap.scrollTop = Math.max(0, termWrap.scrollTop - h);
797
+ return true;
798
+ }
799
+ case "scroll-down": {
800
+ ev.preventDefault();
801
+ const h = termWrap.clientHeight;
802
+ termWrap.scrollTop = Math.min(termWrap.scrollHeight, termWrap.scrollTop + h);
803
+ return true;
804
+ }
805
+ case "ignore":
806
+ return false;
807
+ }
808
+ }
809
+
746
810
  input.addEventListener(
747
811
  "keydown",
748
812
  (ev: KeyboardEvent) => {
@@ -755,27 +819,10 @@ function buildTerminal(
755
819
  return;
756
820
  }
757
821
  }
758
- const result = mapKeyboardEvent(ev, modes);
759
- switch (result.kind) {
760
- case "send":
761
- ev.preventDefault();
762
- sendText(result.bytes);
763
- return;
764
- case "scroll-up": {
765
- ev.preventDefault();
766
- const h = termWrap.clientHeight;
767
- termWrap.scrollTop = Math.max(0, termWrap.scrollTop - h);
768
- return;
769
- }
770
- case "scroll-down": {
771
- ev.preventDefault();
772
- const h = termWrap.clientHeight;
773
- termWrap.scrollTop = Math.min(termWrap.scrollHeight, termWrap.scrollTop + h);
774
- return;
775
- }
776
- case "ignore":
777
- return;
778
- }
822
+ // A false return ("ignore") needs nothing here: the `input` listener above
823
+ // delivers the character, which is also how IME and dead-key composition
824
+ // output reaches the terminal.
825
+ applyMappedKey(ev, mapKeyboardEvent(ev, modes));
779
826
  },
780
827
  { signal },
781
828
  );
@@ -935,6 +982,175 @@ function buildTerminal(
935
982
  { signal },
936
983
  );
937
984
 
985
+ // --- Type-to-focus: recovering a keystroke after a mouse selection ---
986
+ // A mouse gesture over `.term-output` leaves the browser no focusable target,
987
+ // so the key target becomes the document body (UI Events: "If no suitable
988
+ // element is in focus, the event target will be the HTML body element") and
989
+ // the hidden textarea stops receiving keys. The click handler above cannot fix
990
+ // that, because focusing the textarea COLLAPSES the selection the user just
991
+ // made, so it deliberately declines while one exists. Net effect without this
992
+ // listener: select a single cell with the mouse and typing silently does
993
+ // nothing, and the two shortcuts that ride the keydown chain go with it
994
+ // (clipboard's Ctrl+Shift+C, contextMenu's Escape).
995
+ //
996
+ // Native terminals have no such competition: a selection is app state and the
997
+ // keyboard belongs to the window, so VTE grabs widget focus on the very press
998
+ // that starts a selection. xterm.js sidesteps it by preventDefaulting mousedown
999
+ // and painting its own selection, which is not open to us — native touch
1000
+ // selection is this UI's mobile selection UX (css/02-terminal.css). So the
1001
+ // browser's model stands and typing takes the keyboard back instead, which is
1002
+ // also what Windows Terminal does ("most key input clears the selection and
1003
+ // passes the key event directly to the underlying shell").
1004
+ const doc = root.ownerDocument;
1005
+ function ownsSelection(): boolean {
1006
+ // EVERY range, and both ends of each: a range dragged from the terminal out
1007
+ // into the host page's own content never arms this in either drag direction,
1008
+ // and neither does a Firefox Ctrl+drag multi-range selection with one range
1009
+ // outside (the mousedown policy above deliberately preserves those, and
1010
+ // removeAllRanges below would take the host's range with the terminal's).
1011
+ // Range ends are in document order whichever way the user dragged, so
1012
+ // direction costs nothing here. Selected TEXT rather than isCollapsed, so a
1013
+ // bare caret does not arm, matching the click handler's own test. Scoped to
1014
+ // `.term-output`, so a selection in the chrome does not arm. Known
1015
+ // limitation: a terminal inside a host's shadow root never arms, because
1016
+ // getSelection() does not reach into shadow trees.
1017
+ const sel = doc.getSelection();
1018
+ if (sel === null || sel.toString().length === 0) {
1019
+ return false;
1020
+ }
1021
+ for (let i = 0; i < sel.rangeCount; i++) {
1022
+ const range = sel.getRangeAt(i);
1023
+ if (!outputEl.contains(range.startContainer) || !outputEl.contains(range.endContainer)) {
1024
+ return false;
1025
+ }
1026
+ }
1027
+ return true;
1028
+ }
1029
+ doc.addEventListener(
1030
+ "keydown",
1031
+ (ev: KeyboardEvent) => {
1032
+ // Two groups, in this order. The ARMING gates immediately below decide
1033
+ // whether this listener is responsible for the keystroke at all; the
1034
+ // TERMINAL-KEY gates after the feature chain decide whether the terminal
1035
+ // wants this particular key. Both are preconditions rather than a
1036
+ // classification: nothing runs unless every gate passes, so no keystroke
1037
+ // falls between them.
1038
+ if (destroyed) {
1039
+ // Reachable in one window only: cleanupRuntime() tears features down
1040
+ // BEFORE it aborts the signal that removes this listener, so a feature
1041
+ // teardown that synchronously dispatched a key would otherwise be served
1042
+ // by a terminal that is already gone. (The textarea listener needs no
1043
+ // such gate: it only fires while the textarea holds focus, and a teardown
1044
+ // does not focus it.)
1045
+ return;
1046
+ }
1047
+ // A host that already handled this key marked it, so leave it alone. This
1048
+ // is the platform's own convention for composing keyboard handlers, and it
1049
+ // is the reason this listener needs no opt-out option: an embedder with a
1050
+ // document-level keymap cancels the events it owns.
1051
+ if (ev.defaultPrevented) {
1052
+ return;
1053
+ }
1054
+ // Nobody owns the keyboard. This one test replaces a blocklist of
1055
+ // interactive tag names and is strictly more complete: focus inside an open
1056
+ // shadow root reports the HOST, an open <dialog> reports the dialog (or its
1057
+ // focused child), and a tabindex="0" ARIA widget reports itself. All
1058
+ // non-body, so all bail. It also subsumes the double-send guard: a focused
1059
+ // textarea is not the body, so a key the handler above already took can
1060
+ // never reach here. Residual: HTML's focus fixup rule can park focus back
1061
+ // on the body while a modal dialog is still open (its focused child was
1062
+ // removed), which the focus-landed check below is what actually covers.
1063
+ if (doc.activeElement !== null && doc.activeElement !== doc.body) {
1064
+ return;
1065
+ }
1066
+ // Composition first, matching the textarea path, and on the composition
1067
+ // module's clock rather than ev.isComposing: that clock stays true through
1068
+ // the deferred compositionend read (the documented Chromium workaround).
1069
+ if (composition.isComposing()) {
1070
+ return;
1071
+ }
1072
+ if (!ownsSelection()) {
1073
+ return;
1074
+ }
1075
+ // Features next, while the selection is STILL INTACT, and BEFORE the
1076
+ // modifier gate below. Both orderings are load-bearing: clipboard's
1077
+ // Ctrl+Shift+C is a MODIFIED key that reads the selection, so a modifier
1078
+ // gate placed first would deny the chain the one shortcut this listener
1079
+ // exists to restore (caught by its own test). A claimed key never focuses,
1080
+ // so the selection survives it and a second copy is still possible.
1081
+ for (const h of keydownHandlers) {
1082
+ if (h(ev)) {
1083
+ return;
1084
+ }
1085
+ }
1086
+ // What this key DOES is decided before taking focus, because a keystroke
1087
+ // that delivers nothing must leave the selection and the focus untouched.
1088
+ // A bare modifier press is the case that proves it: Shift on its way to
1089
+ // Shift+click, or to Ctrl+Shift+C, would otherwise clear the very selection
1090
+ // the chord is about to act on.
1091
+ //
1092
+ // The character is computed ONLY for an "ignore" result, so the encoder
1093
+ // always wins a key it maps. Under the kitty protocol a MODIFIED character
1094
+ // key encodes to a CSI-u sequence rather than to its text, and the focused
1095
+ // path sends that sequence; this path has to agree with it.
1096
+ const result = mapKeyboardEvent(ev, modes);
1097
+ const char = result.kind === "ignore" ? typedChar(ev) : null;
1098
+ if (result.kind === "ignore" && char === null) {
1099
+ // Nothing to deliver: a bare modifier or lock key, a dead key, an IME
1100
+ // start, a multi-character or unknown key name.
1101
+ return;
1102
+ }
1103
+ // Modified keys stay with the browser, with one exception: an AltGr
1104
+ // CHARACTER, because on the layouts that need AltGr that is how the
1105
+ // character is typed. The exception is deliberately limited to a character
1106
+ // rather than granted to any AltGraph event, because AltGraph is not a
1107
+ // reliable signal on its own: Firefox reports it for plain Option on macOS
1108
+ // and for ordinary Ctrl+Alt on Windows, so admitting every AltGraph event
1109
+ // would hand the terminal Option+ArrowLeft, which is browser back.
1110
+ // (xterm.js carries the mirror-image heuristic, ctrl+alt meaning AltGr, and
1111
+ // scopes it to Windows.)
1112
+ //
1113
+ // Nothing else modified is recovered, and the asymmetry decides that: not
1114
+ // recovering Ctrl+ArrowLeft costs one keypress in a state that ends on the
1115
+ // next character typed, while stealing Ctrl+C costs the user the selection
1116
+ // they just made, and Alt+ArrowLeft is browser back.
1117
+ const altGraphChar = char !== null && ev.getModifierState("AltGraph");
1118
+ if ((ev.ctrlKey || ev.metaKey || ev.altKey) && !altGraphChar) {
1119
+ return;
1120
+ }
1121
+ // Tab stays with the browser: this only ever runs on a body-focused page,
1122
+ // where Tab is how a keyboard user moves on (web-terminal-kiro's
1123
+ // focusable-inventory test pins that expectation).
1124
+ if (ev.key === "Tab") {
1125
+ return;
1126
+ }
1127
+ // Take the keyboard. Collapsing explicitly rather than leaning on focus()
1128
+ // to do it keeps the behaviour identical wherever focus() would not.
1129
+ doc.getSelection()?.removeAllRanges();
1130
+ focusTerminal();
1131
+ // Fail closed. Focus can fail to land (an inert subtree under an open modal
1132
+ // dialog, a released root), and bytes whose effect the user cannot see do
1133
+ // not belong in a shell.
1134
+ if (doc.activeElement !== input) {
1135
+ return;
1136
+ }
1137
+ if (char !== null) {
1138
+ // The encoder defers a plain character to the `input` event, which the
1139
+ // focused path delivers. No input event is coming for a key that targeted
1140
+ // the body, so send it here. preventDefault makes that exactly once BY
1141
+ // SPEC ("If this event is canceled, the associated event types MUST NOT be
1142
+ // dispatched"), rather than by depending on whether an engine re-resolves
1143
+ // an uncanceled keydown's insertion target after a listener moved focus
1144
+ // mid-dispatch. Chromium does; no specification requires it.
1145
+ ev.preventDefault();
1146
+ sendText(normalizeTypedText(char));
1147
+ return;
1148
+ }
1149
+ applyMappedKey(ev, result);
1150
+ },
1151
+ { signal },
1152
+ );
1153
+
938
1154
  // --- Viewport ---
939
1155
  viewport.init({
940
1156
  termWrap,