@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.
package/css/06-mobile.css CHANGED
@@ -1,17 +1,20 @@
1
1
  /* ===== Mobile tab chrome (tabs feature, section 12) =====
2
2
  The bottom bar (switcher) is the mobile tab surface, the externally-driven key
3
3
  grid docks above it, and the overview sheet lists every tab. The desktop strip
4
- is hidden on a coarse pointer and these appear instead; all motion (bar number
5
- fade, sheet slide) lives in 07-animations.css. */
6
-
7
- /* The switcher is hidden on a fine pointer, where the top strip is used instead;
8
- the top strip is correspondingly hidden on a coarse pointer so tabs live in
9
- the thumb zone. */
4
+ is hidden only on a narrow coarse-pointer device (a phone) and these appear
5
+ instead; all motion (bar number fade, sheet slide) lives in 07-animations.css. */
6
+
7
+ /* The switcher is used only on a NARROW coarse-pointer device (a phone), where
8
+ the top strip is hidden so tabs live in the thumb zone. A wide touchscreen (an
9
+ iPad) and any fine-pointer device keep the multi-tab top strip — a single
10
+ giant tab wastes a big screen. This media query MUST stay in lockstep with
11
+ SWITCHER_MEDIA in src/features/tabs.ts and the complementary strip clearance
12
+ in css/05-tabs.css. */
10
13
  .wt-switcher {
11
14
  display: none;
12
15
  }
13
16
 
14
- @media (pointer: coarse) {
17
+ @media (pointer: coarse) and (width <= 600px) {
15
18
  .wt-tab-bar,
16
19
  .wt-tab-new {
17
20
  display: none;
@@ -57,7 +60,11 @@
57
60
  padding: 0 var(--sp-2);
58
61
  display: flex;
59
62
  flex-direction: column;
60
- gap: 2px;
63
+ /* A touch more room than the desktop strip's --sp-1: vertical touch rows read
64
+ better with a bit more separation, and this equals the bar's --sp-2 bottom
65
+ padding so the active-tab -> first-row seam matches the inter-row gap (see
66
+ the expanded-list padding-top: 0 below). */
67
+ gap: var(--sp-2);
61
68
  max-height: 0;
62
69
  overflow: hidden;
63
70
  /* max-height caps the whole box (incl. the expanded padding) so an interactive
@@ -67,9 +74,14 @@
67
74
  }
68
75
 
69
76
  .wt-switcher-expanded .wt-switcher-list {
70
- max-height: 50vh;
77
+ /* Cap against the DYNAMIC viewport so a shown mobile browser toolbar can't push the list
78
+ (and the bar above it) off the top; the switcher's own bottom: kb-inset already lifts it
79
+ clear of the soft keyboard. */
80
+ max-height: var(--wt-list-h, 50dvh);
71
81
  overflow-y: auto;
72
- padding-top: var(--sp-1);
82
+ /* No top padding: the bar's --sp-2 bottom padding already sits between the
83
+ active tab and the first row, so the seam equals the --sp-2 inter-row gap. */
84
+ padding-top: 0;
73
85
  padding-bottom: var(--sp-1);
74
86
  }
75
87
 
@@ -102,7 +114,7 @@
102
114
  fill/foreground the desktop strip uses (themeable; purple in vibecli). */
103
115
  background: var(--tab-active-bg);
104
116
  color: var(--tab-active-fg);
105
- border: none;
117
+ border: 1px solid var(--tab-active-border);
106
118
  border-radius: var(--radius-inner);
107
119
  font-family: var(--font-ui);
108
120
  font-size: 0.875rem;
@@ -167,6 +179,13 @@
167
179
  button collapses to zero width and fades out (the key grid is closed too, in
168
180
  JS), and the active row flex-grows to fill the freed space. */
169
181
  .wt-switcher-kb {
182
+ /* Rigid 44px box like the "+" and switch buttons (flex: none), so a long
183
+ active-tab title that overflows the bar is absorbed entirely by the
184
+ ellipsizing active-tab field, not shared with this button. It is otherwise
185
+ the only bar control with flex-shrink, so it squished (≈28px, non-square) on
186
+ long-title tabs while staying square on short ones. The `width` below still
187
+ drives the expand-collapse animation; flex: none honours it. */
188
+ flex: none;
170
189
  width: var(--touch-target);
171
190
  /* Override .wt-btn's min-width (the 44px tap floor). min-width is NOT
172
191
  transitioned, so on collapse it would revert to 44px instantly and clamp the
@@ -206,6 +225,61 @@
206
225
  pointer-events: none;
207
226
  }
208
227
 
228
+ /* Tab-switcher button: toggles the list open/closed, sitting between the
229
+ keyboard and "+" buttons. position: relative anchors its notification dot,
230
+ absolutely placed in the top-right corner. It only earns its place with ≥2
231
+ tabs (a single tab has nothing to switch to), so it is COLLAPSED to zero width
232
+ by default and revealed by .wt-switcher-multi (the tabs feature sets it on tab
233
+ count); the appear/collapse is animated in 07-animations.css and the active
234
+ chip shrinks/grows to fill the freed space in lockstep (flex layout). */
235
+ .wt-switcher-switch {
236
+ position: relative;
237
+ flex: none;
238
+ width: 0;
239
+ min-width: 0;
240
+ padding: 0;
241
+ opacity: 0;
242
+ overflow: hidden;
243
+ pointer-events: none;
244
+ /* Absorb the flex gap the zero-width button would otherwise leave between the
245
+ active row and the "+". */
246
+ margin-inline-start: calc(-1 * var(--sp-1));
247
+ }
248
+
249
+ /* Revealed once a second tab exists: full 44px box, interactive, no gap eat. */
250
+ .wt-switcher-multi .wt-switcher-switch {
251
+ width: var(--touch-target);
252
+ opacity: 1;
253
+ pointer-events: auto;
254
+ margin-inline-start: 0;
255
+ }
256
+
257
+ @media (hover: hover) {
258
+ .wt-switcher-switch:hover {
259
+ background: var(--tab-hover-bg);
260
+ }
261
+ }
262
+
263
+ /* Latest-wins background-tab notification dot on the switch button — the moved
264
+ attention cue. Amber = a background terminal needs input, green = a background
265
+ turn finished. Reuses the exact .wt-status-dot colours (05-tabs.css) so it
266
+ matches the per-tab dots. Hidden until the tabs feature sets data-status;
267
+ cleared when the list opens (expandSwitcher). */
268
+ .wt-switcher-switch-dot {
269
+ position: absolute;
270
+ top: 6px;
271
+ right: 6px;
272
+ }
273
+
274
+ /* The dot carries the .wt-status-dot class, so its size, shape, and colour come
275
+ straight from the shared .wt-status-dot[data-status="input"|"done"] rules
276
+ (05-tabs.css) — the SAME dot colours every tab already uses, defined once.
277
+ .wt-status-dot is display:none by default; force it visible only while a
278
+ notification is set (data-status present). */
279
+ .wt-switcher-switch-dot[data-status] {
280
+ display: block;
281
+ }
282
+
209
283
  /* Mobile "+": the primary action, carried in the accent colour so it reads as
210
284
  permanent and prominent. Hover fill is gated to real hover pointers: on touch,
211
285
  :hover sticks after a tap (no pointer-leave), so the purple would linger — the
@@ -225,31 +299,12 @@
225
299
  background: var(--tab-hover-bg);
226
300
  }
227
301
 
228
- /* Aggregate needs-input attention now rides the active surface (the separate
229
- overview button is gone): a background tab is blocked on you, and a tap /
230
- swipe-up here opens the overview to resolve it. Shape + color (an inset ring
231
- plus a corner glyph), never hue alone (WCAG 1.4.1). */
232
- .wt-switcher-current[data-attention="input"] {
233
- box-shadow: inset 0 0 0 2px oklch(70% 0.18 25deg / 45%);
234
- }
235
-
236
- .wt-switcher-current[data-attention="input"]::after {
237
- content: "";
238
- position: absolute;
239
- top: 4px;
240
- right: 4px;
241
- width: 8px;
242
- height: 8px;
243
- border-radius: 50%;
244
- background: oklch(70% 0.18 25deg);
245
- }
246
-
247
302
  /* ===== Externally-driven key grid (tabbed presets, WS3) =====
248
303
  In the tabbed presets the mobile toolbar hides its own toggle (the tab bar's
249
304
  keyboard button drives it) and the grid docks above the bar on the right, to
250
305
  the left of where scroll-to-bottom sits. It collapses to fully hidden, since
251
306
  there is no in-grid toggle to keep visible. */
252
- @media (pointer: coarse) {
307
+ @media (pointer: coarse) and (width <= 600px) {
253
308
  .wt-toolbar-external .kb-toggle {
254
309
  display: none;
255
310
  }
@@ -268,6 +323,36 @@
268
323
  }
269
324
  }
270
325
 
326
+ /* Desktop tab strip on a WIDE touchscreen (an iPad): the mobile switcher is
327
+ gone, so the desktop strip carries its OWN keyboard button (.wt-tab-kb, built
328
+ by the same factory as the switcher's — see css/05-tabs.css + tabs.ts) to open
329
+ the key grid. So, exactly like switcher mode, the toolbar's own toggle is
330
+ hidden and the collapsed grid fully hidden — the tab-strip keyboard button is
331
+ the sole control (this replaces the old CSS-only workaround that un-hid the
332
+ toolbar's own toggle here). The grid still docks bottom-right ABOVE the strip:
333
+ above the tabs (never overlapping them, even when many are open and the strip
334
+ scrolls) and to the left of the scroll-to-bottom FAB (same right offset as
335
+ switcher mode). Complements the strip-shown breakpoint in css/05-tabs.css;
336
+ keep in lockstep with SWITCHER_MEDIA in src/features/tabs.ts. */
337
+ @media (pointer: coarse) and (width >= 601px) {
338
+ .wt-toolbar-external .kb-toggle {
339
+ display: none;
340
+ }
341
+
342
+ .key-toolbar.wt-toolbar-external {
343
+ top: auto;
344
+ right: calc(var(--sp-3) + var(--touch-target) + var(--sp-2));
345
+ bottom: calc(
346
+ var(--wt-tabbar-h, 44px) + var(--safe-bottom, 0px) + var(--kb-inset, 0px) + var(--sp-2)
347
+ );
348
+ grid-template-columns: repeat(4, var(--touch-target));
349
+ }
350
+
351
+ .key-toolbar.wt-toolbar-external.collapsed {
352
+ display: none;
353
+ }
354
+ }
355
+
271
356
  /* ===== Expanded tab-list rows (tabs feature) =====
272
357
  One row per OTHER tab in the expanded list: a stretched select target (dot +
273
358
  label) with the close (x) laid inside at the right, mirroring the desktop
@@ -285,9 +370,9 @@
285
370
  min-height: var(--touch-target);
286
371
  /* Right padding reserves room for the close (x) overlaid inside the box. */
287
372
  padding: 0 var(--touch-target) 0 var(--sp-2);
288
- background: transparent;
373
+ background: var(--tab-bg);
289
374
  color: var(--text);
290
- border: none;
375
+ border: 1px solid var(--border);
291
376
  border-radius: var(--radius-inner);
292
377
  font-family: var(--font-ui);
293
378
  font-size: 0.875rem;
@@ -298,15 +383,16 @@
298
383
 
299
384
  @media (hover: hover) {
300
385
  .wt-switcher-row-select:hover {
301
- background: var(--tab-hover-bg);
386
+ background: color-mix(in oklch, var(--tab-bg), var(--text) 15%);
302
387
  }
303
388
  }
304
389
 
305
- /* Press / touch-hold feedback on a listed (inactive) tab: the same subtle fill
306
- the "+" button uses (--tab-hover-bg), NOT the stronger active-tab fill, so an
307
- inactive row never looks like the active one. */
390
+ /* Press / touch-hold feedback on a listed (inactive) tab: a solid lift over the
391
+ chip (the same 15% --text lift the desktop tabs use), not the translucent
392
+ --tab-hover-bg wash that would erase the chip fill. The active tab is set apart
393
+ by its accent border, so a lit inactive row is never mistaken for it. */
308
394
  .wt-switcher-row-select:active {
309
- background: var(--tab-hover-bg);
395
+ background: color-mix(in oklch, var(--tab-bg), var(--text) 15%);
310
396
  }
311
397
 
312
398
  .wt-switcher-row-label {
@@ -1,18 +1,27 @@
1
1
  /* ===== Animations (animations feature) =====
2
- Every bit of motion in the UI lives here, gated on the root .wt-animate class
3
- that the animations feature sets only when the OS is not in reduced-motion.
4
- Without the feature (or under prefers-reduced-motion) the UI is instant: base
5
- rules in 03-06 carry the resting state, and these rules add the transitions
6
- and keyframes on top. Keeping motion in one file is the design-system seam for
7
- "does this UI animate, and how". */
2
+ Chrome-surface motion is centralized here, gated on the root .wt-animate class
3
+ that the animations feature sets only when the OS is not in reduced-motion:
4
+ base rules in 03-06 carry the resting state, and these rules add the
5
+ transitions and keyframes on top. A few pieces of motion are intentionally NOT
6
+ gated on .wt-animate and animate unconditionally: terminal-core motion (cursor
7
+ and SGR blink) and the mobile key toolbar open/close in 02-app.css, plus the
8
+ working-status-dot pulse in 05-tabs.css (motion is its only non-hue cue, per
9
+ WCAG 1.4.1). prefers-reduced-motion still defeats all of it via the reset in
10
+ 01-reset.css. Keeping the chrome-surface motion in one file is the
11
+ design-system seam for "does this chrome animate, and how". */
8
12
 
9
- /* Fade transient surfaces in/out (display allow-discrete plays the fade before
10
- the discrete display:none on hide). */
11
- .wt-animate .wt-toast,
12
- .wt-animate .wt-conn-banner,
13
- .wt-animate .wt-scroll-bottom,
14
- .wt-animate .wt-ctx-menu,
15
- .wt-animate .wt-tab-menu {
13
+ /* Fade the toast OUT on hide (display allow-discrete holds display:block for
14
+ the fade, then flips to display:none). It appears INSTANTLY on show: a
15
+ transition out of display:none does not animate without a matching
16
+ @starting-style rule (none is defined in css/), so opacity snaps 0->1 on
17
+ show. Only the toast sets opacity in its base and
18
+ .visible rules, so only it can interpolate. The other transient surfaces
19
+ (connection banner, scroll-to-bottom, context menu, tab menu) toggle `display`
20
+ only and never set opacity, so they are intentionally not listed here: a
21
+ shared opacity transition would be inert while the `display allow-discrete`
22
+ half held them fully visible for --dur-standard before popping out on hide.
23
+ They show and hide instantly. */
24
+ .wt-animate .wt-toast {
16
25
  transition:
17
26
  opacity var(--dur-standard) var(--ease-standard),
18
27
  display var(--dur-standard) allow-discrete;
@@ -65,6 +74,17 @@
65
74
  opacity var(--dur-standard) var(--ease-standard);
66
75
  }
67
76
 
77
+ /* The dedicated switch button appears when a second tab opens and collapses back
78
+ at one tab (.wt-switcher-multi, tabs feature). Animate its width + margin +
79
+ opacity so it slides in from zero width — the active-tab chip shrinks to make
80
+ room in lockstep (flex) — rather than popping. Mirrors the keyboard button. */
81
+ .wt-animate .wt-switcher-switch {
82
+ transition:
83
+ width var(--dur-enter) var(--ease-standard),
84
+ margin var(--dur-enter) var(--ease-standard),
85
+ opacity var(--dur-standard) var(--ease-standard);
86
+ }
87
+
68
88
  /* Switch motion: a subtle content fade on switch, so the cache-first repaint
69
89
  reads as a change rather than a flicker. A swipe additionally slides the
70
90
  content in from the swipe's direction (next from the right, prev from the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "3.3.1",
3
+ "version": "3.4.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",
@@ -26,14 +26,13 @@
26
26
  // uses the textarea as its anchor; we move the textarea to the cursor
27
27
  // for the same reason.
28
28
 
29
- import { keyboard } from "@cplieger/web-terminal-engine";
30
29
  import { resetToPlaceholder } from "./input-placeholder.js";
31
- const { bracketTextForPaste, prepareTextForTerminal } = keyboard;
32
30
 
33
31
  let textarea: HTMLTextAreaElement;
34
32
  let compositionView: HTMLElement;
35
33
  let getCursorPx: () => { left: number; top: number; cellH: number };
36
34
  let send: (bytes: string) => void;
35
+ let paste: (text: string) => void;
37
36
 
38
37
  let composing = false;
39
38
  let sendingComposition = false;
@@ -45,11 +44,15 @@ export function init(opts: {
45
44
  compositionView: HTMLElement;
46
45
  getCursorPx: () => { left: number; top: number; cellH: number };
47
46
  send: (bytes: string) => void;
47
+ /** The kernel's single bracketed-paste+normalize funnel; the native iOS-callout
48
+ * paste routes through it rather than re-composing the funnel here. */
49
+ paste: (text: string) => void;
48
50
  }): void {
49
51
  textarea = opts.textarea;
50
52
  compositionView = opts.compositionView;
51
53
  getCursorPx = opts.getCursorPx;
52
54
  send = opts.send;
55
+ paste = opts.paste;
53
56
 
54
57
  textarea.addEventListener("compositionstart", onStart);
55
58
  textarea.addEventListener("compositionupdate", onUpdate);
@@ -79,6 +82,26 @@ export function cancelComposition(): void {
79
82
  resetToPlaceholder(textarea);
80
83
  }
81
84
 
85
+ /** Release init()-attached listeners and drop the captured kernel closures on kernel
86
+ * destroy(), so a destroy without a remount does not retain the old textarea + the
87
+ * kernel send/paste closure graph. Mirrors viewport.teardown(). */
88
+ export function teardown(): void {
89
+ cancelComposition(); // neutralize a pending finalizer + reset composing state
90
+ textarea.removeEventListener("compositionstart", onStart);
91
+ textarea.removeEventListener("compositionupdate", onUpdate);
92
+ textarea.removeEventListener("compositionend", onEnd);
93
+ textarea.removeEventListener("paste", onPaste);
94
+ // Drop references to the old kernel instance so its closure graph is GC-eligible
95
+ // (the detached textarea/compositionView are overwritten on the next init()).
96
+ send = () => {
97
+ /* no-op after teardown */
98
+ };
99
+ paste = () => {
100
+ /* no-op after teardown */
101
+ };
102
+ getCursorPx = () => ({ left: 0, top: 0, cellH: 0 });
103
+ }
104
+
82
105
  function onStart(): void {
83
106
  composing = true;
84
107
  const start = textarea.selectionStart;
@@ -118,7 +141,9 @@ function onEnd(): void {
118
141
  suffixSnapshot.length > 0 && value.endsWith(suffixSnapshot)
119
142
  ? value.length - suffixSnapshot.length
120
143
  : value.length;
121
- const composed = value.substring(startSnapshot, Math.max(startSnapshot, valueEnd));
144
+ const composed = value
145
+ .substring(startSnapshot, Math.max(startSnapshot, valueEnd))
146
+ .replace(/\u00A0/g, " ");
122
147
  if (composed.length > 0) {
123
148
  send(composed);
124
149
  }
@@ -139,7 +164,7 @@ function onPaste(ev: ClipboardEvent): void {
139
164
  }
140
165
  ev.preventDefault();
141
166
  ev.stopPropagation();
142
- send(bracketTextForPaste(prepareTextForTerminal(raw)));
167
+ paste(raw);
143
168
  // Restore the placeholder so the subsequent `input` event (some
144
169
  // browsers fire it after paste) and held-Backspace auto-repeat keep
145
170
  // working.
@@ -38,7 +38,14 @@ export function activityMonitor(
38
38
  statuses.set(s.id, s);
39
39
  }
40
40
  for (const cb of [...subs]) {
41
- cb(s);
41
+ try {
42
+ cb(s);
43
+ } catch (err) {
44
+ // Isolate a throwing subscriber so it neither skips the
45
+ // remaining subscribers nor propagates into the engine's
46
+ // SSE reader (mirrors the kernel bus's ctx.on wrapping).
47
+ console.error("web-terminal-ui: activityMonitor subscriber threw", err);
48
+ }
42
49
  }
43
50
  },
44
51
  });
@@ -1,8 +1,13 @@
1
- // animations feature: opt-in motion (design sections 13, 22.4). Without it the
2
- // UI is instant (no transitions); with it, and when the user has not asked for
3
- // reduced motion, it enables the shared animation vocabulary by marking the
4
- // terminal root, which the feature stylesheets key their transitions off. It
5
- // tracks prefers-reduced-motion live so toggling the OS setting takes effect.
1
+ // animations feature: opt-in motion for the chrome surfaces (design sections
2
+ // 13, 22.4). Without it the chrome surfaces are instant (no transitions); with
3
+ // it, and when the user has not asked for reduced motion, it enables the shared
4
+ // animation vocabulary by marking the terminal root, which the feature
5
+ // stylesheets key their transitions off. A few pieces of motion animate
6
+ // unconditionally regardless of this feature (terminal-core cursor/SGR blink and
7
+ // the mobile key toolbar open/close in 02-app.css, plus the working-status-dot
8
+ // pulse in 05-tabs.css, its WCAG non-hue cue), though prefers-reduced-motion
9
+ // still defeats all of it. It tracks prefers-reduced-motion live so toggling the
10
+ // OS setting takes effect.
6
11
 
7
12
  import type { TerminalFeature } from "../kernel/types.js";
8
13
 
@@ -89,9 +89,15 @@ export function clipboard(): TerminalFeature<ClipboardApi> {
89
89
  copy(text);
90
90
  });
91
91
 
92
- // Native copy (e.g. Cmd/Ctrl+C on a selection) feedback toast.
92
+ // Native copy (e.g. Cmd/Ctrl+C on a selection) feedback toast, scoped to a
93
+ // selection inside the terminal surface so an embedding host's copies of
94
+ // its own (non-terminal) content do not raise a spurious "Copied".
95
+ const surface = ctx.surface();
93
96
  const onCopy = (): void => {
94
- ctx.toast("Copied");
97
+ const node = window.getSelection()?.anchorNode ?? null;
98
+ if (node && surface.contains(node)) {
99
+ ctx.toast("Copied");
100
+ }
95
101
  };
96
102
  document.addEventListener("copy", onCopy);
97
103
 
@@ -20,7 +20,6 @@ export function connectionBanner(): TerminalFeature {
20
20
  const slot = ctx.region("banner", "status");
21
21
  const banner = document.createElement("div");
22
22
  banner.className = "wt-conn-banner";
23
- banner.setAttribute("role", "status");
24
23
  slot.appendChild(banner);
25
24
 
26
25
  let last: ConnState | null = null;
@@ -53,6 +53,28 @@ export interface ContextMenuOptions {
53
53
  clipboard?: TerminalFeature<ClipboardApi>;
54
54
  }
55
55
 
56
+ /** iPhone/iPad/iPod, including iPadOS Safari's default "desktop mode" (which
57
+ * reports platform MacIntel but with a touch screen). On these, a long-press on
58
+ * text fires `contextmenu` slightly BEFORE the native word-selection registers,
59
+ * so hasNativeSelection() is still false at that instant and the Android
60
+ * empty-space branch would wrongly open our menu and preventDefault the native
61
+ * selection. Deferring on any Apple touch device lets iOS/iPadOS run its own
62
+ * text selection + drag handles (bug: an iPad long-press opened the custom menu
63
+ * instead of native selection, leaving Select All the only way to select). */
64
+ function isAppleTouchDevice(): boolean {
65
+ if (typeof navigator === "undefined") {
66
+ return false;
67
+ }
68
+ const ua = navigator.userAgent || "";
69
+ const platform = navigator.platform || "";
70
+ if (/iP(hone|ad|od)/.test(ua) || /iP(hone|ad|od)/.test(platform)) {
71
+ return true;
72
+ }
73
+ // iPadOS 13+ Safari defaults to desktop mode: platform "MacIntel" but a touch
74
+ // screen (maxTouchPoints > 1). A real trackpad Mac reports maxTouchPoints 0.
75
+ return platform === "MacIntel" && navigator.maxTouchPoints > 1;
76
+ }
77
+
56
78
  export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
57
79
  return {
58
80
  name: "contextMenu",
@@ -68,6 +90,9 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
68
90
  // desktop right-click AND on Android long-press, so its handler branches
69
91
  // on this to tell them apart.
70
92
  let lastPointerType = "mouse";
93
+ // Computed once: on an Apple touch device, defer a touch contextmenu to
94
+ // native text selection (see isAppleTouchDevice).
95
+ const appleTouch = isAppleTouchDevice();
71
96
  // Timestamp until which a document click is swallowed (see SWALLOW_MS).
72
97
  let swallowUntil = 0;
73
98
  // A native selection was made during the current touch press (tracked via
@@ -220,6 +245,13 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
220
245
  if (hasNativeSelection()) {
221
246
  return; // native selection toolbar owns Copy over text
222
247
  }
248
+ if (appleTouch) {
249
+ // iOS/iPadOS fires contextmenu a beat before the native word-select
250
+ // registers, so hasNativeSelection() can still be false here. Defer
251
+ // anyway so the platform's own selection wins instead of our
252
+ // empty-space menu (which would preventDefault and suppress it).
253
+ return;
254
+ }
223
255
  e.preventDefault();
224
256
  clearLongPress();
225
257
  show(e.clientX, e.clientY, true);
@@ -47,6 +47,16 @@ export function predictiveEcho(): TerminalFeature {
47
47
  });
48
48
 
49
49
  return {
50
+ // A tab switch (kernel notifySwitch -> onDetach) must drop the predicted
51
+ // cursor, so the outgoing session's ghost cursor does not paint on the
52
+ // incoming session's freshly-bound screen until its first server frame
53
+ // re-arms prediction. Mirrors composition.cancelComposition on the same
54
+ // detach: both local-echo paths reset on switch. predict.reset() hides the
55
+ // overlay (its onChange -> push -> setPredictedCursor(0,0,false)); the next
56
+ // wire:screen re-arms via onScreenFrame.
57
+ onDetach() {
58
+ predict.reset();
59
+ },
50
60
  teardown() {
51
61
  offTransform();
52
62
  offObserver();