@cplieger/web-terminal-ui 7.0.7 → 7.1.0

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/README.md CHANGED
@@ -186,7 +186,27 @@ want one, for example Ctrl+L); `reattach()` attaches again to whatever the serve
186
186
  serves now, for a session that has ended (see "When a session ends"); and
187
187
  `destroy()` tears every feature down and releases the kernel.
188
188
 
189
- ### Persisting scrollback across a discard
189
+ ### Mouse and focus
190
+
191
+ A terminal application that turns mouse tracking on receives the clicks, drags
192
+ and wheel gestures a user makes in the browser, as SGR reports. The pointer says
193
+ so while that is happening: at rest the grid carries a text cursor, and while an
194
+ application holds the mouse the grid and any links in it carry an arrow instead,
195
+ because a click is reported to that application rather than selecting text or
196
+ opening the link. Shift-drag still selects and shift-click still opens a link,
197
+ which is the convention xterm set, so nothing becomes unreachable.
198
+
199
+ Mouse input is best-effort, as it is in every terminal: a report says where the
200
+ pointer is at that moment and carries nothing a receiver could use to notice it
201
+ had gone stale, so a report made while the socket is down is dropped rather than
202
+ delivered late against a screen that has since been repainted. Typed input keeps
203
+ its delivery guarantee and is unaffected.
204
+
205
+ Focus reporting is the server's answer rather than this UI's. The kernel tells
206
+ the server whether its own terminal widget holds focus and the server decides
207
+ what the application is told, so several devices attached to one session cannot
208
+ contradict each other. A blur that a press causes and that the same gesture's
209
+ click undoes is not reported, because the terminal did not lose focus.
190
210
 
191
211
  `persistScrollback` takes the storage, not a switch, because storage is a
192
212
  decision this library should not make for you. Chrome's page-lifecycle guidance
@@ -149,6 +149,11 @@
149
149
  kernel focus handling + contextMenu feature). */
150
150
  user-select: text;
151
151
  -webkit-touch-callout: default;
152
+
153
+ /* Stated rather than inherited: the UA default over text is already an I-beam,
154
+ but the tracking rule below has to have something deterministic to override
155
+ (and to hand back to). */
156
+ cursor: text;
152
157
  }
153
158
 
154
159
  /* IME composition view: floating overlay positioned at the cursor
@@ -327,6 +332,18 @@
327
332
  --bg: var(--text-default);
328
333
  }
329
334
 
335
+ /* An application holds the mouse (DEC 1000/1002/1003) and the kernel toggles
336
+ .wt-mouse-app on the scroll container. A plain click is reported to that
337
+ application, so neither the I-beam nor the link's pointer is true any longer:
338
+ both promise something the click no longer does. Deliberately no
339
+ `user-select: none` here, because the engine hands a shifted gesture to the
340
+ browser, so shift+drag selecting and shift+click opening a link are the only
341
+ ways left to reach either and blanketing selection off would remove them. */
342
+ :where(.wt-root) .term.wt-mouse-app .term-output,
343
+ :where(.wt-root) .term.wt-mouse-app .term-link {
344
+ cursor: default;
345
+ }
346
+
330
347
  :where(.wt-root) .term-blink {
331
348
  animation: wt-blink-anim 1s step-end infinite;
332
349
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "7.0.7",
3
+ "version": "7.1.0",
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",
@@ -40,7 +40,7 @@
40
40
  "verify": "bash scripts/verify.sh"
41
41
  },
42
42
  "peerDependencies": {
43
- "@cplieger/web-terminal-engine": "^5.0.3"
43
+ "@cplieger/web-terminal-engine": "^5.1.0"
44
44
  },
45
45
  "overrides": {
46
46
  "qs": "^6.15.2"
@@ -50,15 +50,15 @@
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.1",
53
+ "@types/node": "26.5.0",
54
54
  "@typescript/native": "npm:typescript@7.0.2",
55
55
  "@vitest/browser": "5.0.0",
56
56
  "@vitest/browser-playwright": "5.0.0",
57
57
  "@vitest/coverage-v8": "5.0.0",
58
58
  "eslint": "10.10.0",
59
59
  "fast-check": "4.9.0",
60
- "html-validate": "11.14.0",
61
- "knip": "6.34.0",
60
+ "html-validate": "11.15.0",
61
+ "knip": "6.35.1",
62
62
  "playwright": "1.63.0",
63
63
  "prettier": "3.9.6",
64
64
  "stylelint": "17.15.0",
@@ -13,12 +13,11 @@
13
13
  // still single-instance, so createTerminal is called once per page (tabs
14
14
  // multiplex sessions over the one kernel, section 22.5).
15
15
  //
16
- // Mouse tracking (engine `mouse` module) is intentionally NOT wired here yet:
17
- // the design adds it (section 22.3), but wiring it also brings DEC 1004 focus
18
- // emission, which must be suppressed under the keep-unfocused model (section
19
- // 7.2). Not wiring it keeps the client emitting no focus bytes (the safe
20
- // default the previous UI already had); adding mouse tracking with focus
21
- // suppression is a tracked follow-up.
16
+ // The kernel owns the terminal widget's DOM, so it owns the widget's focus: it
17
+ // REPORTS focus and blur to the transport (`connection.setClientFocus`) and
18
+ // never writes DEC 1004 bytes itself. The server derives the answer for the
19
+ // session from every attached client's report plus its own hold, which is the
20
+ // only place the question can be answered once a second device attaches.
22
21
 
23
22
  import {
24
23
  render,
@@ -26,6 +25,7 @@ import {
26
25
  connection,
27
26
  keyboard,
28
27
  modes,
28
+ mouse,
29
29
  LineStore,
30
30
  } from "@cplieger/web-terminal-engine";
31
31
  import * as composition from "../composition.js";
@@ -400,9 +400,9 @@ function buildTerminal(
400
400
  // output never does (the scroll controller's follow/hold job is unchanged),
401
401
  // so scrolling up to read while output streams is preserved. A no-op on the
402
402
  // alt screen (no scrollback) and when already following. NOTE: every caller
403
- // of sendBytes today is genuine user input (typed keys, paste, mobile
404
- // toolbar); if mouse tracking is ever wired through ctx.send, its motion
405
- // bytes must NOT come through here or the view would snap on every move.
403
+ // of sendBytes is genuine user input (typed keys, paste, mobile toolbar);
404
+ // mouse reports deliberately bypass this funnel and go out as ephemeral
405
+ // input, or the view would snap on every motion report.
406
406
  scroll.scrollToBottom();
407
407
  for (const obs of inputObservers) {
408
408
  obs(out);
@@ -575,6 +575,100 @@ function buildTerminal(
575
575
  });
576
576
  render.updateFontMetrics();
577
577
 
578
+ // A mouse report is BEST-EFFORT and never enters the reliable outbox: it
579
+ // carries no sequence number, so one retransmitted after a resume describes a
580
+ // screen since repainted, and `sendEphemeral` refuses rather than queues. Not
581
+ // sendBytes either: that funnel snaps the view to the bottom on every accepted
582
+ // byte, so a motion report would jump the viewport. The listeners belong on the
583
+ // scroll container (a click in its padding or over the reserved scrollbar
584
+ // gutter never reaches a row, and the wheel has to be cancellable there); the
585
+ // coordinate frame is the row container, whose box IS the grid.
586
+ const disposeMouse = mouse.init({
587
+ sendReport: (data) => connection.sendEphemeral(data),
588
+ cellSize: render.cellSize,
589
+ termElement: () => termWrap,
590
+ gridElement: () => outputEl,
591
+ // The RENDERED grid, not computeSize(): the row hit test anchors on its
592
+ // height, and this client's own measurement disagrees with the negotiated
593
+ // screen whenever a second device is attached or a local resize has not been
594
+ // answered yet, which would shift every reported row.
595
+ gridSize: render.gridSize,
596
+ });
597
+
598
+ // A press over the display-only output takes focus off the hidden textarea and
599
+ // the click handler below puts it back, so that blur/focus pair is an artifact
600
+ // of this input model, not a focus change the application should be told about.
601
+ // xterm.js never emits one because its textarea keeps focus through a click on
602
+ // the terminal. So a blur raised inside a press is withheld and the gesture's
603
+ // REAL end state is reported once it resolves: nothing when focus came back,
604
+ // one report when it did not (a live selection, a link and a bare touch tap all
605
+ // decline to restore it). Measured before this: 3600 focus reports in 90s of
606
+ // clicking, which reaches an application as FocusLost/FocusGained per click.
607
+ let pressHoldsFocus = false;
608
+ let focusResolve: ReturnType<typeof setTimeout> | null = null;
609
+
610
+ function reportFocusNow(): void {
611
+ connection.setClientFocus(
612
+ document.activeElement !== null && termWrap.contains(document.activeElement),
613
+ );
614
+ }
615
+
616
+ // Scheduled rather than read at pointerup: the focus restore happens in the
617
+ // `click` handler, which the browser dispatches after pointerup within the same
618
+ // task, so a task boundary is the first point where the end state is settled.
619
+ function endPressFocusGesture(): void {
620
+ if (focusResolve !== null) {
621
+ clearTimeout(focusResolve);
622
+ }
623
+ focusResolve = setTimeout(() => {
624
+ focusResolve = null;
625
+ pressHoldsFocus = false;
626
+ reportFocusNow();
627
+ }, 0);
628
+ }
629
+
630
+ // The keyboard target is the hidden textarea INSIDE termWrap, so the widget's
631
+ // own focus events bubble to here. What goes out is the widget's STATE, not
632
+ // DEC 1004 bytes: the server derives the answer from this report plus every
633
+ // other attached client's plus its own hold.
634
+ termWrap.addEventListener(
635
+ "focusin",
636
+ () => {
637
+ connection.setClientFocus(true);
638
+ },
639
+ { signal },
640
+ );
641
+ termWrap.addEventListener(
642
+ "focusout",
643
+ (ev) => {
644
+ if (ev.relatedTarget instanceof Node && termWrap.contains(ev.relatedTarget)) {
645
+ return; // focus moving WITHIN the terminal is not a blur
646
+ }
647
+ if (pressHoldsFocus) {
648
+ return; // this gesture's own doing; endPressFocusGesture reports the truth
649
+ }
650
+ connection.setClientFocus(false);
651
+ },
652
+ { signal },
653
+ );
654
+ // Seeded once, so a terminal mounted with its textarea already focused does not
655
+ // report blurred until the first real focusin.
656
+ reportFocusNow();
657
+
658
+ // The kernel is the toggler, and the class goes on the scroll container rather
659
+ // than on the rows: `.term-output` is replaced wholesale by the renderer, and
660
+ // `.term` is the element that survives every rebuild. What the shape MEANS is
661
+ // at the CSS rule it drives (css/02-terminal.css).
662
+ function updateMousePointer(): void {
663
+ termWrap.classList.toggle("wt-mouse-app", modes.getMouseMode() !== 0);
664
+ }
665
+
666
+ // Derived once here as well as from the two triggers below: the modes module is
667
+ // a singleton that outlives a terminal, so a re-created terminal on a page
668
+ // whose mirror already holds a tracking mode would otherwise show the resting
669
+ // pointer until the next frame.
670
+ updateMousePointer();
671
+
578
672
  composition.init({
579
673
  textarea: input,
580
674
  compositionView: compositionViewEl,
@@ -698,6 +792,7 @@ function buildTerminal(
698
792
  bus.emit("wire:title", { session: activeSession?.id ?? "", title: msg.title });
699
793
  } else if (msg.type === "modes") {
700
794
  render.updateReverseVideo();
795
+ updateMousePointer();
701
796
  bus.emit("wire:modes", msg);
702
797
  } else if (msg.type === "clipboard") {
703
798
  // Inbound OSC 52. With no clipboard feature subscribed this is a no-op
@@ -969,9 +1064,13 @@ function buildTerminal(
969
1064
  pointerDownX = e.clientX;
970
1065
  pointerDownY = e.clientY;
971
1066
  pointerDownTime = e.timeStamp;
1067
+ pressHoldsFocus = true;
972
1068
  },
973
1069
  { passive: true, signal },
974
1070
  );
1071
+ for (const end of ["pointerup", "pointercancel", "lostpointercapture"] as const) {
1072
+ termWrap.addEventListener(end, endPressFocusGesture, { passive: true, signal });
1073
+ }
975
1074
  termWrap.addEventListener(
976
1075
  "pointerup",
977
1076
  (e) => {
@@ -1476,6 +1575,11 @@ function buildTerminal(
1476
1575
  // detach here and the onSwitch attach below.
1477
1576
  composition.cancelComposition();
1478
1577
  resetToPlaceholder(input);
1578
+ // A held mouse gesture is latched input too, and the mouse module is per
1579
+ // TERMINAL while sessions multiplex over it: a record kept across the switch
1580
+ // would let the incoming session's first open synthesize a release for a
1581
+ // press its application never saw.
1582
+ mouse.disarmGesture();
1479
1583
  for (const { instance } of instances) {
1480
1584
  instance.onDetach?.();
1481
1585
  }
@@ -1483,6 +1587,9 @@ function buildTerminal(
1483
1587
  // Reconnect the terminal WS to this session using its per-tab resume
1484
1588
  // state; the renderer was already pointed at its store by the owner.
1485
1589
  connection.setSession(session.id);
1590
+ // setSession restores the incoming session's mode mirror synchronously and
1591
+ // delivers no modes frame, so nothing else re-derives the pointer here.
1592
+ updateMousePointer();
1486
1593
  // The owned first connect has happened (session id is now on the WS URL);
1487
1594
  // wake-reconnect handlers may fire from here on.
1488
1595
  connectionInitiated = true;
@@ -1671,12 +1778,17 @@ function buildTerminal(
1671
1778
  // Reset the composition singleton: a failure during an IME cycle must not
1672
1779
  // leave module state or a deferred send alive for a later terminal mount.
1673
1780
  composition.teardown();
1781
+ disposeMouse();
1674
1782
  connection.disconnect();
1675
1783
  connState.destroy();
1676
1784
  if (toastTimer !== null) {
1677
1785
  clearTimeout(toastTimer);
1678
1786
  toastTimer = null;
1679
1787
  }
1788
+ if (focusResolve !== null) {
1789
+ clearTimeout(focusResolve);
1790
+ focusResolve = null;
1791
+ }
1680
1792
  for (const off of subscriptions) {
1681
1793
  off();
1682
1794
  }