@cplieger/web-terminal-ui 4.8.0 → 4.8.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": "4.8.0",
3
+ "version": "4.8.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": "GPL-3.0-or-later",
@@ -79,7 +79,8 @@ const TAB_DRAG_TYPE = "application/x-web-terminal-tab";
79
79
  // terminal context menu (formerly two hand-synced copies of the same math).
80
80
 
81
81
  export interface TabsApi {
82
- /** Spawn a fresh session and switch to it. */
82
+ /** Spawn a fresh session and switch to it. Calls made while a create is in
83
+ * flight share that create, so one gesture opens exactly one terminal. */
83
84
  create(): Promise<void>;
84
85
  /** Close a session (kills its process) and drop its tab + cache. */
85
86
  close(id: string): Promise<void>;
@@ -297,7 +298,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
297
298
  // the FIRST tap to blur the input (so "+" only fired create on the second
298
299
  // tap — the reported double-tap). preventDefault on pointerdown fires the
299
300
  // action on the first tap; switchTo then re-focuses the input for the new
300
- // tab on a physical-keyboard device.
301
+ // tab (see focusAfterSwitch). It does NOT make one press one activation —
302
+ // that is create()'s in-flight coalescing, for the opposite failure.
301
303
  btn.addEventListener("pointerdown", (e) => {
302
304
  e.preventDefault();
303
305
  });
@@ -657,8 +659,58 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
657
659
  }
658
660
  }
659
661
 
662
+ const termInput = (): HTMLElement | null =>
663
+ ctx.surface().querySelector<HTMLElement>(".term-input");
660
664
  function focusInput(): void {
661
- ctx.surface().querySelector<HTMLElement>(".term-input")?.focus({ preventScroll: true });
665
+ termInput()?.focus({ preventScroll: true });
666
+ }
667
+
668
+ // Did the terminal input own the keyboard when the pointer press now being
669
+ // handled began? A press on tab chrome focuses the control it landed on
670
+ // (the browser's mousedown default action), so by the time the click
671
+ // handler runs the input is already blurred and only a pointerdown-time
672
+ // snapshot still knows where the keyboard came from. Recorded for the whole
673
+ // chrome by the two pointerdown listeners below (the strip and the
674
+ // switcher), not per control: chips come and go with sessions, and the fact
675
+ // belongs to the gesture rather than to what it hit.
676
+ let inputFocusedAtPress = false;
677
+ function noteChromePress(): void {
678
+ const el = termInput();
679
+ inputFocusedAtPress = el !== null && document.activeElement === el;
680
+ }
681
+ function keyboardParkedOnChrome(): boolean {
682
+ const active = document.activeElement;
683
+ return active !== null && (bar.contains(active) || switcher.contains(active));
684
+ }
685
+
686
+ // focusAfterSwitch applies the tab-switch focus rule. Two independent
687
+ // reasons to hand the keyboard to the terminal input:
688
+ //
689
+ // - A physical keyboard is (likely) present, so a switch should leave you
690
+ // able to type at once, with no extra tap (the iPad + Magic Keyboard
691
+ // ask). Skipped on a keyboard-less touchscreen, or every switch would
692
+ // pop the virtual keyboard (#7).
693
+ // - The press that drove this switch took the keyboard OFF the terminal —
694
+ // it focused the chip (or the x, or a switcher row) it pressed, and the
695
+ // keyboard is still parked there. Handing it back is not NEW focus, so
696
+ // it pops no soft keyboard: it restores what the press displaced. Both
697
+ // halves are required for exactly that reason — a switch with no press
698
+ // behind it (a remote adopt, ensureActive), or a press on a device where
699
+ // the input was not focused to begin with, must not summon a keyboard.
700
+ // Leaving the keyboard parked on a chip is not neutral either: the
701
+ // strip's own keydown handling reads arrows as "switch tab" and Delete
702
+ // as "close tab", so every keystroke meant for the terminal is eaten.
703
+ //
704
+ // Never while a rename field is open: it owns the keyboard until it closes,
705
+ // and a switch (the leading click of a double-click, a remote-driven
706
+ // ensureActive) would otherwise yank the caret out of the field.
707
+ function focusAfterSwitch(): void {
708
+ if (editingId !== null) {
709
+ return;
710
+ }
711
+ if (physicalKeyboardLikely() || (inputFocusedAtPress && keyboardParkedOnChrome())) {
712
+ focusInput();
713
+ }
662
714
  }
663
715
 
664
716
  // paintActive updates the desktop strip's active state. When the ACTIVE
@@ -1249,6 +1301,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1249
1301
 
1250
1302
  function switchTo(id: string, dir?: "next" | "prev"): void {
1251
1303
  if (id === activeId) {
1304
+ // Already active, so there is no switch to perform — but the FOCUS rule
1305
+ // still owes an answer, because the press that delivered this click has
1306
+ // already moved the keyboard onto the chip. Two pointer paths land here:
1307
+ // clicking the tab that is already active, and the SECOND activation of
1308
+ // one press on another tab (see create() for the evidence that this
1309
+ // device delivers those). The first activation switches and focuses the
1310
+ // terminal; the second one's mousedown re-focuses the chip and then fell
1311
+ // out here with the keyboard stranded on it — the reported "the
1312
+ // invisible input loses focus when I click another tab", and the same
1313
+ // hole a plain mouse click on the active tab hit on every platform.
1314
+ focusAfterSwitch();
1252
1315
  return;
1253
1316
  }
1254
1317
  const next = tabList.find((t) => t.id === id);
@@ -1339,16 +1402,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1339
1402
  }
1340
1403
  });
1341
1404
  ctx.announce(`Switched to ${next.display}`);
1342
- if (physicalKeyboardLikely() && editingId === null) {
1343
- // A physical keyboard is (likely) present, so focus the input on
1344
- // switch — switch and type immediately (the iPad + Magic Keyboard
1345
- // ask). On a keyboard-less touchscreen this is skipped, or every
1346
- // switch would pop the virtual keyboard (#7). Skipped entirely while a
1347
- // rename field is open: a switch (e.g. the leading click of a
1348
- // double-click, or a remote-driven ensureActive) would otherwise yank
1349
- // the caret out of the field the user is typing in.
1350
- focusInput();
1351
- }
1405
+ focusAfterSwitch();
1352
1406
  }
1353
1407
 
1354
1408
  // armCatchup shows the "catching up" cue only if the resume delta has not
@@ -1590,7 +1644,28 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1590
1644
  }
1591
1645
  }
1592
1646
 
1593
- async function create(): Promise<void> {
1647
+ // create is the coalesced door: while one create is in flight, every caller
1648
+ // asking for another gets THAT one, so a single gesture can only ever open
1649
+ // one terminal. Spawning a session is expensive and non-idempotent, and one
1650
+ // press does not reliably arrive as one activation — the web-terminal-kiro
1651
+ // server logged two POST /api/sessions 0-3ms apart (three times in one
1652
+ // afternoon, both answered 201) for single "+" taps from an iPad + Magic
1653
+ // Keyboard, so the user got two terminals and had to close one. Sharing the
1654
+ // in-flight promise needs no threshold to make that decision, unlike a
1655
+ // click-level debounce: a duplicate activation lands while the POST is still
1656
+ // open and collapses into it, while a deliberate second tap (the POST
1657
+ // resolves in milliseconds) still opens a second terminal. It also stops "+"
1658
+ // mashing from queueing sessions across the server's 503 install window,
1659
+ // where createSessionHonouringRetry legitimately waits minutes.
1660
+ let creating: Promise<void> | null = null;
1661
+ function create(): Promise<void> {
1662
+ creating ??= openNewSession().finally(() => {
1663
+ creating = null;
1664
+ });
1665
+ return creating;
1666
+ }
1667
+
1668
+ async function openNewSession(): Promise<void> {
1594
1669
  let info: SessionInfo;
1595
1670
  try {
1596
1671
  info = await createSessionHonouringRetry(api, ctx, () => tornDown);
@@ -2041,15 +2116,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2041
2116
  const menuSwallow = createClickSwallow();
2042
2117
  let menuTouch = false;
2043
2118
  // One listener on the bar rather than one per chip: chips are created and
2044
- // destroyed as sessions come and go, and the pointer type belongs to the
2045
- // gesture, not to a chip.
2119
+ // destroyed as sessions come and go, and both facts recorded here (the
2120
+ // gesture's pointer type, and whether it took the keyboard off the
2121
+ // terminal) belong to the gesture, not to a chip.
2046
2122
  bar.addEventListener(
2047
2123
  "pointerdown",
2048
2124
  (e) => {
2049
2125
  menuTouch = e.pointerType === "touch";
2126
+ noteChromePress();
2050
2127
  },
2051
2128
  { passive: true },
2052
2129
  );
2130
+ // The switcher's half of that snapshot (its rows and the x are buttons, so
2131
+ // pressing one focuses it and blurs the terminal exactly as a chip does).
2132
+ switcher.addEventListener("pointerdown", noteChromePress, { passive: true });
2053
2133
  function hideTabMenu(): void {
2054
2134
  tabMenu.classList.remove("visible");
2055
2135
  tabMenu.replaceChildren();