@cplieger/web-terminal-ui 3.3.1 → 3.4.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.
@@ -1,5 +1,5 @@
1
1
  // Single source of truth for the hidden-textarea placeholder shared by
2
- // mount.ts and composition.ts.
2
+ // kernel.ts and composition.ts.
3
3
  //
4
4
  // A single-character placeholder is kept in the hidden textarea so iOS soft
5
5
  // keyboards have something to "delete" when the user holds Backspace. iOS only
@@ -11,7 +11,7 @@
11
11
  // announcement of the input state stays empty-ish rather than "space".
12
12
  //
13
13
  // The constant AND the reset routine must stay in lockstep across the two
14
- // modules (mount.ts seeds + re-pads it; composition.ts restores it on
14
+ // modules (kernel.ts seeds + re-pads it; composition.ts restores it on
15
15
  // compositionend / paste) or the iOS held-Backspace key-repeat silently breaks
16
16
  // with no compile error — which is why they live here in one place.
17
17
  export const INPUT_PLACEHOLDER = "\u00A0";
@@ -248,6 +248,7 @@ export function createTerminal(
248
248
  compositionView: compositionViewEl,
249
249
  getCursorPx: render.getCursorPx,
250
250
  send: sendText,
251
+ paste,
251
252
  });
252
253
 
253
254
  scroll.init({
@@ -281,7 +282,13 @@ export function createTerminal(
281
282
  } else if (msg.type === "scroll") {
282
283
  render.handleScroll(msg);
283
284
  } else if (msg.type === "title") {
284
- document.title = msg.title;
285
+ // Match the app's own title policy (tabs.ts wire:title / applyStatus,
286
+ // which ignore a blank OSC 0/2 title): a shell clears its window title
287
+ // when it redraws its prompt after idling, so hold the last-good browser
288
+ // title instead of flickering blank.
289
+ if (msg.title.trim() !== "") {
290
+ document.title = msg.title;
291
+ }
285
292
  bus.emit("wire:title", { session: activeSession?.id ?? "", title: msg.title });
286
293
  } else if (msg.type === "modes") {
287
294
  render.updateReverseVideo();
@@ -412,6 +419,17 @@ export function createTerminal(
412
419
  // touch and never clears a selection — a long-press to select a word, the OS
413
420
  // copy/paste callout, and drag-to-scroll are all left to the platform (the
414
421
  // research consensus: over text, allow the default and emit nothing).
422
+ // A fine pointer (mouse / trackpad) is available: a desktop, or a tablet with
423
+ // a trackpad / Magic Keyboard. Such a device has a hardware keyboard, so there
424
+ // is no soft keyboard to protect — focus should be eager and land in one tap.
425
+ // The bare-touch guards below bow out of focusing (to avoid popping the soft
426
+ // keyboard or stealing a selection); they are relaxed when a fine pointer
427
+ // exists. This is keyed off `any-pointer: fine` rather than the event's
428
+ // pointerType because iPadOS reports a COARSE primary pointer even with a
429
+ // trackpad attached, and its trackpad taps arrive inconsistently as "mouse" or
430
+ // "touch" — so pointerType alone made the terminal take several taps to focus.
431
+ const hasFinePointer = (): boolean =>
432
+ typeof window.matchMedia === "function" && window.matchMedia("(any-pointer: fine)").matches;
415
433
  let lastPointerType = "mouse";
416
434
  let pointerDownX = 0;
417
435
  let pointerDownY = 0;
@@ -459,6 +477,14 @@ export function createTerminal(
459
477
  const sel = window.getSelection();
460
478
  if (sel && !sel.isCollapsed) {
461
479
  sel.removeAllRanges();
480
+ // On a bare touchscreen a deselect tap must NOT also focus: that would
481
+ // pop the soft keyboard right after a copy. But with a fine pointer
482
+ // (a hardware keyboard is present) there is no soft keyboard to pop, so
483
+ // focus in the same tap rather than forcing the user to tap again — this
484
+ // is a large part of the "2-3 taps to focus" on an iPad + Magic Keyboard.
485
+ if (hasFinePointer()) {
486
+ focusTerminal();
487
+ }
462
488
  return;
463
489
  }
464
490
  // A clean tap with nothing selected focuses the input, synchronously (an
@@ -471,8 +497,11 @@ export function createTerminal(
471
497
  "mousedown",
472
498
  (e) => {
473
499
  // Cancel the synthetic mousedown after a touch tap so iOS keeps the
474
- // keyboard up (xterm.js focus-preservation pattern, scoped to touch).
475
- if (lastPointerType === "touch") {
500
+ // keyboard up (xterm.js focus-preservation pattern, scoped to touch). Skip
501
+ // it when a fine pointer is present (iPad + trackpad / Magic Keyboard):
502
+ // there is no soft keyboard to protect, and suppressing the mousedown was
503
+ // defeating the native focus, so the terminal needed several taps to focus.
504
+ if (lastPointerType === "touch" && !hasFinePointer()) {
476
505
  e.preventDefault();
477
506
  }
478
507
  },
@@ -487,7 +516,7 @@ export function createTerminal(
487
516
  window.open(link.href, "_blank", "noopener,noreferrer");
488
517
  return;
489
518
  }
490
- if (lastPointerType === "touch") {
519
+ if (lastPointerType === "touch" && !hasFinePointer()) {
491
520
  return;
492
521
  }
493
522
  const sel = window.getSelection();
@@ -502,6 +531,7 @@ export function createTerminal(
502
531
  // --- Viewport ---
503
532
  viewport.init({
504
533
  termWrap,
534
+ suppressKeyboardInset: hasFinePointer,
505
535
  onSettled() {
506
536
  render.updateFontMetrics();
507
537
  if (fontsLoaded) {
@@ -636,6 +666,12 @@ export function createTerminal(
636
666
  },
637
667
  tablist: () => tablistController,
638
668
  notifySwitch(session) {
669
+ // A feature's un-cancelled async (tabs bootstrap/create()/pollOnce()) can
670
+ // resolve after destroy() and call this; ignore it so a torn-down terminal
671
+ // never re-points or reopens the socket (connection.setSession below).
672
+ if (isDestroyed()) {
673
+ return;
674
+ }
639
675
  // Detach (design 5.1): make input safe before the socket is re-pointed.
640
676
  // End any in-flight IME composition and clear the textarea so
641
677
  // half-composed text is not delivered to either session, and let every
@@ -674,6 +710,12 @@ export function createTerminal(
674
710
 
675
711
  // --- Feature lifecycle ---
676
712
  let destroyed = false;
713
+ // Live read of `destroyed` for the post-await re-check in setupFeatures():
714
+ // a plain `if (destroyed)` there is narrowed to always-false by TS CFA
715
+ // (it cannot model destroy() firing during the await), tripping
716
+ // @typescript-eslint/no-unnecessary-condition. A call defeats the stale
717
+ // narrowing with identical runtime behavior.
718
+ const isDestroyed = (): boolean => destroyed;
677
719
  function teardownFeatures(): void {
678
720
  for (let i = instances.length - 1; i >= 0; i--) {
679
721
  const entry = instances[i];
@@ -696,6 +738,19 @@ export function createTerminal(
696
738
  }
697
739
  try {
698
740
  const instance = await feature.setup(makeContext(feature.name));
741
+ // destroy() may have run during the await above; teardownFeatures() has already
742
+ // swept `instances`, and destroy() is one-shot, so a straight push here would
743
+ // leave this instance's listeners/timers/observers (tabs' SSE + poll + window/
744
+ // document listeners + ResizeObservers) alive forever. Tear it down instead of
745
+ // registering it.
746
+ if (isDestroyed()) {
747
+ try {
748
+ instance.teardown();
749
+ } catch (err) {
750
+ reportError(feature.name, err);
751
+ }
752
+ return;
753
+ }
699
754
  instances.push({ feature, instance });
700
755
  // Populate the feature value's readonly api (consumer pattern:
701
756
  // tabs.api?.create()) via a narrow cast, and the ctx.use lookup map.
@@ -714,12 +769,6 @@ export function createTerminal(
714
769
  }
715
770
  }
716
771
  }
717
- // Features set up in the background; first paint is never gated on them.
718
- void setupFeatures();
719
-
720
- // --- Connect + focus ---
721
- render.updateFontMetrics();
722
- composition.positionCompositionView();
723
772
  // A session-managing feature (tabs) drives the first connect itself once it
724
773
  // has resolved a session id (ctx.notifySwitch -> connection.setSession, which
725
774
  // adds ?session=<id>). Connecting here first would open a bare /ws that a
@@ -727,6 +776,23 @@ export function createTerminal(
727
776
  // churning the reconnect backoff until the feature sets a session. So skip the
728
777
  // startup connect when managed; the single-terminal presets leave it to us.
729
778
  const sessionManaged = featureList.some((f) => f.managesSessions === true);
779
+
780
+ // Features set up in the background; first paint is never gated on them.
781
+ void setupFeatures().then(() => {
782
+ // A session-managing feature that finished setup WITHOUT initiating a
783
+ // connection means its bootstrap failed (no session could be listed or
784
+ // spawned) or its setup threw. markReady (needs a screen frame) and onGiveUp
785
+ // (needs the connect to be attempted and fail) then never fire, so the
786
+ // loading overlay would stay up forever, hiding the retry chrome the feature
787
+ // keeps alive (tabs' "+"). Dismiss it so the page shows its real state.
788
+ if (sessionManaged && !connectionInitiated && !destroyed) {
789
+ dismissLoadingOverlay();
790
+ }
791
+ });
792
+
793
+ // --- Connect + focus ---
794
+ render.updateFontMetrics();
795
+ composition.positionCompositionView();
730
796
  if (!sessionManaged) {
731
797
  connection.connect();
732
798
  connectionInitiated = true;
@@ -742,6 +808,12 @@ export function createTerminal(
742
808
  destroyed = true;
743
809
  teardownFeatures();
744
810
  kernelAbort.abort();
811
+ viewport.teardown();
812
+ // Reset the composition singleton (kernel-driven, no feature teardown): a
813
+ // destroy() mid-IME-composition otherwise leaves module-level `composing`
814
+ // stuck true, so a remounted terminal swallows every keydown until an IME
815
+ // cycle resets it; also neutralizes a pending compositionend setTimeout.
816
+ composition.teardown();
745
817
  connection.disconnect();
746
818
  connState.destroy();
747
819
  if (toastTimer !== null) {
package/src/presets.ts CHANGED
@@ -4,13 +4,14 @@
4
4
  // lists (convenient, full UI); a consumer wanting a minimal footprint imports
5
5
  // individual features instead.
6
6
  //
7
- // The tabbed presets (presetTabbed / presetAgentTabbed) are now identical: both
8
- // include the activity monitor, because the per-tab activity dot reveals itself
9
- // only when a session reports activity (OSC 9;4 progress), so a plain shell
10
- // simply never shows one. The generic-vs-agent distinction is now purely
11
- // server-side — an agent server sets a status classifier that maps OSC 9
12
- // notifications to done/needs-input. presetAgentTabbed is kept as an alias for
13
- // its consumers (vibecli).
7
+ // The tabbed presets (presetTabbed / presetAgentTabbed) share the same feature
8
+ // set (both include the activity monitor, whose per-tab dot reveals itself only
9
+ // on OSC 9;4 progress, so a plain shell never shows one). They differ in the
10
+ // title source: presetAgentTabbed sets preferInputTitle (the agent's program
11
+ // emits a useless OSC title, so the label follows the latest submitted line),
12
+ // while presetTabbed is OSC-first. The generic-vs-agent STATUS distinction stays
13
+ // server-side — an agent server sets a classifier mapping OSC 9 notifications to
14
+ // done/needs-input.
14
15
 
15
16
  import type { TerminalFeature } from "./kernel/types.js";
16
17
  import { clipboard } from "./features/clipboard.js";
@@ -50,14 +51,14 @@ export function presetTouch(): TerminalFeature<unknown>[] {
50
51
  // per tab only when a session reports activity (OSC 9;4), so the monitor is
51
52
  // always included — a plain shell just never reveals a dot. The toolbar and
52
53
  // monitor are ordered before tabs because tabs reads their APIs via ctx.use.
53
- function buildTabbed(): TerminalFeature<unknown>[] {
54
+ function buildTabbed(preferInputTitle: boolean): TerminalFeature<unknown>[] {
54
55
  const kb = mobileToolbar({ externalToggle: true });
55
56
  const monitor = activityMonitor();
56
57
  return [
57
58
  ...presetSingle(),
58
59
  kb,
59
60
  monitor,
60
- tabs({ keyboardToggle: kb, activityMonitor: monitor }),
61
+ tabs({ keyboardToggle: kb, activityMonitor: monitor, preferInputTitle }),
61
62
  animations(),
62
63
  ];
63
64
  }
@@ -71,13 +72,15 @@ function buildTabbed(): TerminalFeature<unknown>[] {
71
72
  * reports activity via OSC 9;4 progress (kiro-cli, Claude Code, …), so a plain
72
73
  * bash/sh keeps clean, label-only tabs. */
73
74
  export function presetTabbed(): TerminalFeature<unknown>[] {
74
- return buildTabbed();
75
+ return buildTabbed(false);
75
76
  }
76
77
 
77
- /** Alias of presetTabbed, kept for the agent-shell consumer (vibecli). The UI is
78
- * identical — the generic-vs-agent distinction is now purely server-side: an
79
- * agent server sets a status classifier (mapping OSC 9 notifications to
80
- * done/needs-input), and each tab's dot reveals itself from OSC 9;4 either way. */
78
+ /** Tabbed UI for an agent shell (vibecli). Same features as presetTabbed, but
79
+ * with `preferInputTitle`: the agent's program (kiro-cli) emits a non-empty but
80
+ * useless OSC 0/2 title, so each tab's label follows the latest submitted line
81
+ * (persisted server-side and recovered on reload) and the OSC title is ignored.
82
+ * The agent-vs-generic status distinction remains server-side (a status
83
+ * classifier mapping OSC 9 notifications to done/needs-input). */
81
84
  export function presetAgentTabbed(): TerminalFeature<unknown>[] {
82
- return buildTabbed();
85
+ return buildTabbed(true);
83
86
  }
package/src/viewport.ts CHANGED
@@ -24,6 +24,15 @@ let onSettled: ((wasAtBottom: boolean) => void) | null = null;
24
24
  let inTransition = false;
25
25
  let wasAtBottomAtStart = true;
26
26
  let settleTimer: ReturnType<typeof setTimeout> | null = null;
27
+ // When true, ignore the visualViewport keyboard geometry entirely (a hardware
28
+ // keyboard/trackpad is present, so the soft keyboard never opens). Set at init
29
+ // from the kernel's hasFinePointer; see the onChange note below.
30
+ let suppressKeyboardInset: () => boolean = () => false;
31
+ // Removals for every listener/observer init() attaches, so teardown() can
32
+ // release them on kernel destroy(). Without it the window/visualViewport/
33
+ // screen.orientation listeners and the ResizeObserver survive destroy() and a
34
+ // re-created terminal double-binds them.
35
+ let cleanup: (() => void)[] = [];
27
36
 
28
37
  function startTransition(): void {
29
38
  // Capture pre-transition scroll state at the *start* of the transition,
@@ -51,9 +60,14 @@ function startTransition(): void {
51
60
  export function init(opts: {
52
61
  termWrap: HTMLElement;
53
62
  onSettled: (wasAtBottom: boolean) => void;
63
+ /** Ignore the visualViewport keyboard geometry (a hardware-keyboard device
64
+ * has no soft keyboard to accommodate); only reserved bottom chrome insets
65
+ * the terminal. The kernel passes its hasFinePointer here. */
66
+ suppressKeyboardInset?: () => boolean;
54
67
  }): void {
55
68
  termWrap = opts.termWrap;
56
69
  onSettled = opts.onSettled;
70
+ suppressKeyboardInset = opts.suppressKeyboardInset ?? (() => false);
57
71
 
58
72
  // iOS soft keyboard. interactive-widget=resizes-content makes the
59
73
  // layout viewport shrink; we still apply a manual bottom inset as
@@ -72,15 +86,26 @@ export function init(opts: {
72
86
  // scroll" symptom). Driving top AND bottom from visualViewport keeps the
73
87
  // terminal exactly over the visible area in every browser, so nothing is
74
88
  // left behind the keyboard for iOS to scroll to.
75
- const offsetTop = Math.max(0, Math.round(vv.offsetTop));
76
- const bottomInset = Math.max(0, Math.round(window.innerHeight - vv.offsetTop - vv.height));
89
+ // suppressKeyboardInset (set at init from the kernel's hasFinePointer): on
90
+ // a device with a hardware keyboard/trackpad (a desktop, or an iPad with a
91
+ // Magic Keyboard) the on-screen keyboard never opens, so there is no
92
+ // keyboard geometry to accommodate. iPadOS has been seen to briefly report
93
+ // a keyboard-sized visualViewport shrink with no keyboard shown, which this
94
+ // handler then pinned as a bottom inset and left stuck (the terminal "moved
95
+ // up ~50%", black below, surviving tab switches because nothing recomputes
96
+ // it — only a reload cleared it). Ignore the keyboard geometry there and
97
+ // keep the terminal full-height; only the reserved bottom chrome applies.
98
+ const offsetTop = suppressKeyboardInset() ? 0 : Math.max(0, Math.round(vv.offsetTop));
99
+ const bottomInset = suppressKeyboardInset()
100
+ ? 0
101
+ : Math.max(0, Math.round(window.innerHeight - vv.offsetTop - vv.height));
77
102
  // Reserved bottom chrome (a bottom tab bar) the content must clear, on top
78
103
  // of the keyboard inset. A feature sets --wt-reserve-bottom (px) on the
79
104
  // root, 0 when none; viewport owns the terminal's fixed-box geometry, so it
80
105
  // folds the reserve into the bottom offset here. The reserve excludes the
81
106
  // keyboard (it is measured with the keyboard closed), so adding it to
82
107
  // bottomInset does not double-count.
83
- const reserve = Math.max(
108
+ const rawReserve = Math.max(
84
109
  0,
85
110
  Math.round(
86
111
  parseFloat(
@@ -88,6 +113,12 @@ export function init(opts: {
88
113
  ) || 0,
89
114
  ),
90
115
  );
116
+ // Sanity cap: the reserve is bottom chrome (a tab bar), tens of px. A value
117
+ // near half the screen is a bad measurement — e.g. the switcher bar
118
+ // measured while a phantom keyboard inset had lifted it — that would
119
+ // otherwise strand the lower half of the terminal black. Never let it
120
+ // exceed a third of the viewport height.
121
+ const reserve = Math.min(rawReserve, Math.round(window.innerHeight / 3));
91
122
  const bottom = bottomInset + reserve;
92
123
  termWrap.style.top = offsetTop > 0 ? `${offsetTop}px` : "";
93
124
  termWrap.style.bottom = bottom > 0 ? `${bottom}px` : "";
@@ -102,6 +133,20 @@ export function init(opts: {
102
133
  };
103
134
  vv.addEventListener("resize", onChange);
104
135
  vv.addEventListener("scroll", onChange);
136
+ // Self-heal a stuck inset (see the fine-pointer note above): recompute when
137
+ // the window regains focus or is restored from the bfcache, so a one-off bad
138
+ // visualViewport reading that got pinned clears on the next natural
139
+ // interaction instead of surviving until a full page reload.
140
+ window.addEventListener("focus", onChange);
141
+ window.addEventListener("pageshow", onChange);
142
+ cleanup.push(() => {
143
+ window.removeEventListener("focus", onChange);
144
+ window.removeEventListener("pageshow", onChange);
145
+ });
146
+ cleanup.push(() => {
147
+ vv.removeEventListener("resize", onChange);
148
+ vv.removeEventListener("scroll", onChange);
149
+ });
105
150
  onChange();
106
151
  }
107
152
 
@@ -109,6 +154,10 @@ export function init(opts: {
109
154
  const ro = new ResizeObserver(startTransition);
110
155
  ro.observe(termWrap);
111
156
  window.addEventListener("resize", startTransition);
157
+ cleanup.push(() => {
158
+ ro.disconnect();
159
+ window.removeEventListener("resize", startTransition);
160
+ });
112
161
 
113
162
  // Orientation change on mobile. iOS Safari often emits the
114
163
  // window.resize event late or not at all on rotation while
@@ -120,7 +169,35 @@ export function init(opts: {
120
169
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard for older Safari without screen.orientation
121
170
  if (orientation) {
122
171
  orientation.addEventListener("change", startTransition);
172
+ cleanup.push(() => {
173
+ orientation.removeEventListener("change", startTransition);
174
+ });
123
175
  } else if ("onorientationchange" in window) {
124
176
  window.addEventListener("orientationchange", startTransition);
177
+ cleanup.push(() => {
178
+ window.removeEventListener("orientationchange", startTransition);
179
+ });
125
180
  }
126
181
  }
182
+
183
+ // Release every listener/observer init() attached and stop the settle timer.
184
+ // The kernel calls this from destroy() so a create->destroy->create remount
185
+ // does not leave stale global listeners double-bound.
186
+ export function teardown(): void {
187
+ for (const fn of cleanup) {
188
+ fn();
189
+ }
190
+ cleanup = [];
191
+ if (settleTimer !== null) {
192
+ clearTimeout(settleTimer);
193
+ settleTimer = null;
194
+ }
195
+ // Clear the global root CSS vars onChange published, so a destroy without
196
+ // a remount (which would recompute them) leaves no stale inset on :root.
197
+ const rootStyle = document.documentElement.style;
198
+ rootStyle.removeProperty("--kb-inset");
199
+ rootStyle.removeProperty("--vv-top");
200
+ inTransition = false;
201
+ onSettled = null;
202
+ suppressKeyboardInset = () => false;
203
+ }