@cplieger/web-terminal-ui 7.3.2 → 8.0.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.
Files changed (48) hide show
  1. package/README.md +114 -18
  2. package/css/30-tabs.css +21 -0
  3. package/css/31-switcher.css +15 -0
  4. package/css/32-split.css +176 -0
  5. package/css/MANIFEST +1 -0
  6. package/css/MANIFEST.tabbed +1 -0
  7. package/package.json +13 -12
  8. package/src/composition.ts +165 -237
  9. package/src/features/activity-monitor.ts +20 -55
  10. package/src/features/animations.ts +12 -30
  11. package/src/features/clipboard.ts +36 -64
  12. package/src/features/connection-banner.ts +10 -19
  13. package/src/features/context-menu.ts +70 -145
  14. package/src/features/dom.ts +44 -54
  15. package/src/features/menu-position.ts +24 -28
  16. package/src/features/mobile-toolbar.ts +32 -62
  17. package/src/features/predictive-echo.ts +15 -49
  18. package/src/features/scroll-to-bottom.ts +12 -24
  19. package/src/features/tabs/index.ts +1398 -1806
  20. package/src/features/tabs/ink-centre.ts +45 -54
  21. package/src/features/tabs/model.ts +301 -383
  22. package/src/features/tabs/strip.ts +45 -97
  23. package/src/features/tabs/switcher.ts +70 -48
  24. package/src/index.ts +13 -13
  25. package/src/kernel/a11y.ts +106 -27
  26. package/src/kernel/attention.ts +157 -0
  27. package/src/kernel/conn-state.ts +28 -22
  28. package/src/kernel/fatal.ts +76 -0
  29. package/src/kernel/feature-host.ts +136 -0
  30. package/src/kernel/gesture.ts +9 -13
  31. package/src/kernel/kernel.ts +57 -2139
  32. package/src/kernel/layout-policy.ts +44 -0
  33. package/src/kernel/loading-status.ts +30 -88
  34. package/src/kernel/navigation.ts +11 -17
  35. package/src/kernel/notify.ts +158 -0
  36. package/src/kernel/pane.ts +1415 -0
  37. package/src/kernel/realm.ts +11 -0
  38. package/src/kernel/regions.ts +8 -11
  39. package/src/kernel/scrollback.ts +88 -168
  40. package/src/kernel/selection.ts +25 -0
  41. package/src/kernel/shell.ts +1434 -0
  42. package/src/kernel/split-handle.ts +280 -0
  43. package/src/kernel/status-share.ts +100 -0
  44. package/src/kernel/types.ts +457 -441
  45. package/src/predict.ts +144 -170
  46. package/src/viewport.ts +124 -197
  47. package/src/features/tabs/attention.ts +0 -250
  48. package/src/features/tabs/notify.ts +0 -265
@@ -1,53 +1,38 @@
1
- /**
2
- * clipboard feature: copy/paste helpers, the desktop Ctrl+Shift+C/V shortcuts,
3
- * and OSC 52 mirroring. Exposes a typed API so contextMenu
4
- * can offer Copy/Paste through it (ctx.use). With this feature absent, inbound
5
- * OSC 52 has no subscriber and is intentionally a no-op.
6
- *
7
- * @module
8
- */
9
-
1
+ import { windowOf } from "../kernel/realm.js";
2
+ import { selectionTextWithin } from "../kernel/selection.js";
10
3
  import type { TerminalFeature } from "../kernel/types.js";
11
4
 
12
- /** The value a peer feature reads through `ctx.use(clipboard())` — contextMenu's
13
- * Copy and Paste items are this API behind a button.
14
- *
15
- * Both members are fire-and-forget: the underlying Clipboard API calls are async
16
- * and their outcome is reported to the user as a toast rather than returned, so a
17
- * caller cannot await or branch on success. Both require a secure context
18
- * (`navigator.clipboard` is absent on a plain-HTTP non-loopback host, a supported
19
- * web-terminal-server deployment), where they toast "Clipboard unavailable" and
20
- * do nothing. */
5
+ /** The value a peer reads through `ctx.use(clipboard())`. Both members are
6
+ * fire-and-forget: the outcome reaches the user as a toast, never the caller.
7
+ * Outside a secure context (`navigator.clipboard` absent) they toast
8
+ * "Clipboard unavailable" and do nothing. */
21
9
  export interface ClipboardApi {
22
- /** Write text to the system clipboard (surfaces a toast on success/failure). */
10
+ /** Write text to the system clipboard. */
23
11
  copy(text: string): void;
24
12
  /** Read the system clipboard and paste it through the sanitizing funnel. */
25
13
  paste(): void;
26
14
  }
27
15
 
28
- /** Build the clipboard feature.
29
- *
30
- * It claims three things for the lifetime of the terminal: a kernel keydown
31
- * intercept (Ctrl+Shift+C copies the browser selection, Ctrl+Shift+V pastes, and
32
- * a plain Ctrl+V is consumed WITHOUT preventDefault so the browser's own paste
33
- * event reaches the hidden textarea instead of being mapped to `\x16`), the
34
- * `wire:clipboard` event that mirrors an application's OSC 52 copy to the system
35
- * clipboard, and a document `copy` listener that toasts "Copied" for a selection
36
- * inside the terminal surface only. Teardown releases all three. */
16
+ /** Build the clipboard feature: Ctrl+Shift+C copies the browser selection,
17
+ * Ctrl+Shift+V pastes, a plain Ctrl+V is consumed WITHOUT preventDefault so the
18
+ * browser's own paste event reaches the hidden textarea, an application's OSC 52
19
+ * copy is mirrored to the system clipboard, and a native copy of a selection
20
+ * inside the terminal toasts "Copied". Absent, inbound OSC 52 is a no-op. */
37
21
  export function clipboard(): TerminalFeature<ClipboardApi> {
38
22
  return {
39
23
  name: "clipboard",
40
24
  setup(ctx) {
25
+ const surface = ctx.surface();
26
+ const doc = ctx.shell.root.ownerDocument;
27
+ const win = windowOf(doc);
41
28
  function copy(text: string): void {
42
- // navigator.clipboard is undefined outside a secure context (plain-HTTP
43
- // non-loopback host, a supported web-terminal-server deployment), where a
44
- // property access on it throws synchronously. Feature-detect first.
29
+ // A property access on navigator.clipboard throws outside a secure context.
45
30
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- undefined outside secure contexts
46
- if (!navigator.clipboard) {
31
+ if (!win.navigator.clipboard) {
47
32
  ctx.toast("Clipboard unavailable");
48
33
  return;
49
34
  }
50
- navigator.clipboard
35
+ win.navigator.clipboard
51
36
  .writeText(text)
52
37
  .then(() => {
53
38
  ctx.toast("Copied");
@@ -59,11 +44,11 @@ export function clipboard(): TerminalFeature<ClipboardApi> {
59
44
 
60
45
  function paste(): void {
61
46
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- undefined outside secure contexts
62
- if (!navigator.clipboard) {
47
+ if (!win.navigator.clipboard) {
63
48
  ctx.toast("Clipboard unavailable");
64
49
  return;
65
50
  }
66
- navigator.clipboard
51
+ win.navigator.clipboard
67
52
  .readText()
68
53
  .then((text) => {
69
54
  ctx.paste(text);
@@ -73,23 +58,17 @@ export function clipboard(): TerminalFeature<ClipboardApi> {
73
58
  });
74
59
  }
75
60
 
76
- // Desktop clipboard shortcuts, handled before the kernel's key mapping so
77
- // they take the browser selection/clipboard rather than server-bound bytes.
78
- const offKey = ctx.registerKeydown((ev) => {
79
- // Plain Ctrl+V: let the browser's NATIVE paste event flow into the
80
- // hidden textarea (the kernel's insertFromPaste path sends it through the
81
- // sanitizing funnel). Consuming the key WITHOUT preventDefault stops the
82
- // kernel mapping Ctrl+V to \x16 while leaving the native paste intact — so
83
- // no navigator.clipboard.readText(), hence no Firefox clipboard-read
84
- // popup. (Cmd+V on macOS already pastes natively and never reached the
85
- // \x16 mapping.)
61
+ ctx.registerKeydown((ev) => {
62
+ // Consuming plain Ctrl+V without preventDefault stops the kernel mapping
63
+ // it to \x16 while the native paste still flows into the textarea, so no
64
+ // clipboard read and no Firefox clipboard-read popup.
86
65
  if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey && ev.code === "KeyV") {
87
- return true; // do NOT preventDefault — the browser pastes natively
66
+ return true;
88
67
  }
89
68
  if (ev.ctrlKey && ev.shiftKey && !ev.altKey && !ev.metaKey) {
90
69
  if (ev.code === "KeyC") {
91
- const sel = window.getSelection()?.toString();
92
- if (sel) {
70
+ const sel = selectionTextWithin(surface);
71
+ if (sel !== "") {
93
72
  copy(sel);
94
73
  }
95
74
  ev.preventDefault();
@@ -104,32 +83,25 @@ export function clipboard(): TerminalFeature<ClipboardApi> {
104
83
  return false;
105
84
  });
106
85
 
107
- // Inbound OSC 52: an app copied; mirror it to the system clipboard. This
108
- // is an async push (not a user gesture), so some browsers reject it and
109
- // copy() surfaces "Copy failed".
110
- const offClip = ctx.on("wire:clipboard", (text) => {
86
+ // An OSC 52 copy is an async push, not a user gesture, so some browsers
87
+ // reject it and copy() toasts "Copy failed".
88
+ ctx.on("wire:clipboard", (text) => {
111
89
  copy(text);
112
90
  });
113
91
 
114
- // Native copy (e.g. Cmd/Ctrl+C on a selection) feedback toast, scoped to a
115
- // selection inside the terminal surface so an embedding host's copies of
116
- // its own (non-terminal) content do not raise a spurious "Copied".
117
- const surface = ctx.surface();
118
92
  const onCopy = (): void => {
119
- const node = window.getSelection()?.anchorNode ?? null;
120
- if (node && surface.contains(node)) {
93
+ if (selectionTextWithin(surface) !== "") {
121
94
  ctx.toast("Copied");
122
95
  }
123
96
  };
124
- document.addEventListener("copy", onCopy);
97
+ doc.addEventListener("copy", onCopy);
98
+ ctx.defer(() => {
99
+ doc.removeEventListener("copy", onCopy);
100
+ });
125
101
 
126
102
  return {
127
103
  api: { copy, paste },
128
- teardown() {
129
- offKey();
130
- offClip();
131
- document.removeEventListener("copy", onCopy);
132
- },
104
+ teardown: () => undefined,
133
105
  };
134
106
  },
135
107
  };
@@ -1,12 +1,6 @@
1
- /**
2
- * connectionBanner feature: renders the kernel's connection state as a visible
3
- * banner. The state machine, the loading
4
- * lifecycle, and the give-up hook are kernel-owned; this feature is a pure
5
- * renderer subscribing to connection:state. It uses the kernel's single
6
- * announcer (ctx.announce) rather than spawning its own aria-live region.
7
- *
8
- * @module
9
- */
1
+ // The state machine, the loading lifecycle and the give-up hook are the kernel's;
2
+ // this feature only renders connection:state, and announces through the kernel's
3
+ // one announcer rather than an aria-live region of its own.
10
4
 
11
5
  import type { ConnState, TerminalFeature } from "../kernel/types.js";
12
6
 
@@ -21,21 +15,18 @@ const TEXT: Partial<Record<ConnState, string>> = {
21
15
  incompatible: "Terminal protocol mismatch; update the server or reload this page",
22
16
  };
23
17
 
24
- /** Build the connectionBanner feature. Exposes no API — it owns one element in
25
- * the kernel's "banner" region and nothing else reads from it.
26
- *
27
- * Only the states with copy above are shown; `connected` and any other state
28
- * hides the banner, so it is a transient surface rather than a permanent status
29
- * line. `restarted`, `ended` and `incompatible` are the terminal ones a user
30
- * cannot wait out. Each change is announced once through `ctx.announce`, so a
31
- * flaky link re-entering the same state does not repeat itself to a screen
32
- * reader. Teardown unsubscribes and removes the element. */
18
+ /** Build the connectionBanner feature. Exposes no API: it owns one element in
19
+ * the "banner" region. Only the states with copy above are shown, so it is a
20
+ * transient surface, not a status line; `restarted`, `ended` and
21
+ * `incompatible` are the terminal ones a user cannot wait out. Each change is
22
+ * announced once, so a flaky link re-entering a state does not repeat itself
23
+ * to a screen reader. Teardown unsubscribes and removes the element. */
33
24
  export function connectionBanner(): TerminalFeature {
34
25
  return {
35
26
  name: "connectionBanner",
36
27
  setup(ctx) {
37
28
  const slot = ctx.region("banner", "status");
38
- const banner = document.createElement("div");
29
+ const banner = ctx.shell.root.ownerDocument.createElement("div");
39
30
  banner.className = "wt-conn-banner";
40
31
  slot.appendChild(banner);
41
32
 
@@ -1,151 +1,69 @@
1
- /**
2
- * contextMenu feature: the Copy / Select All / Paste menu for the terminal
3
- * surface, rendered into the overlay region. Copy and Paste
4
- * need the clipboard feature (ctx.use), and Paste routes through the kernel's
5
- * sanitizing funnel. Escape-to-close goes through the kernel keydown intercept;
6
- * outside-click is a document gesture.
7
- *
8
- * WHY THIS MENU EXISTS
9
- * The terminal output is real DOM text, so the browser already does the hard
10
- * part: drag-select with a mouse, long-press word-select plus the OS copy callout
11
- * on touch. What no platform can offer is PASTE — the keyboard target is a 1x1
12
- * pointer-events:none textarea, so there is no editable surface under the pointer
13
- * for a native paste item to attach to. This menu is the paste path. It carries
14
- * Copy and Select All because a menu appearing where the platform's would have
15
- * appeared should not be missing them.
16
- *
17
- * THE MODEL: one owner per gesture, and nothing decided mid-gesture.
18
- *
19
- * Mouse / pen — `contextmenu` is an already-classified request for a menu:
20
- * preventDefault and open at the pointer. No timers, no heuristics, no
21
- * platform branches.
22
- *
23
- * Touch — the platform owns the press while the finger is down. We run no hold
24
- * timer against it, open nothing before it finishes, and never cancel its
25
- * gesture on WebKit. We classify ONCE, on `touchend`, when every fact is
26
- * settled: a single-finger, stationary press held past the tap ceiling
27
- * (kernel/gesture.ts, shared with the kernel's tap-to-focus so the two cannot
28
- * both claim one press) that selected nothing and did not start on a link is a
29
- * press the platform declined — so it is ours, and it is the paste path. A
30
- * press that produced a selection belongs to the OS callout; a press on a link
31
- * belongs to the platform's link preview; a shorter press is the kernel's tap.
32
- *
33
- * WHY AT RELEASE RATHER THAN DURING THE HOLD
34
- * Opening from a ~550ms hold timer put this feature in a race it cannot win: the
35
- * timer has to beat the platform's own long-press threshold and then GUESS
36
- * whether a selection is still coming (iOS 26 registers a word selection well
37
- * after 550ms). Four mechanisms existed only to referee that guess — a hit test
38
- * for glyphs under the finger, a selectionchange watch to retract a menu that
39
- * opened too early, a device sniff, and per-platform contextmenu branching,
40
- * because WebKit reads preventDefault on a touch contextmenu as "cancel every
41
- * remaining default of this gesture", the not-yet-registered selection included.
42
- * Deciding at release deletes all four: the outcome is observed, not predicted.
43
- *
44
- * It also fixes the symptom that prompted the rewrite. A touch long-press emits a
45
- * trailing click on release, and the swallow window that covers that click was
46
- * armed when the menu OPENED — ~550ms into the press, expiring 350ms later — so
47
- * holding a beat longer meant the release click landed as an outside click and
48
- * dismissed the menu the instant the finger lifted. Opened BY the release, the
49
- * window can only ever start at the release edge.
50
- *
51
- * @module
52
- */
1
+ // Exists for PASTE: the keyboard target is a 1x1 pointer-events:none textarea, so
2
+ // no platform can attach a native paste item under the pointer. A touch press is
3
+ // classified ONCE, at touchend, when every fact is settled (a stationary
4
+ // single-finger press past the tap ceiling that selected nothing and did not start
5
+ // on a link is one the platform declined), and deciding at release is what lets
6
+ // the trailing-click swallow start at the release edge.
53
7
 
8
+ import { windowOf } from "../kernel/realm.js";
9
+ import { selectionTextWithin } from "../kernel/selection.js";
54
10
  import type { TerminalFeature } from "../kernel/types.js";
55
11
  import { TAP_MAX_MS, TAP_MOVEMENT_PX, isLinkTarget } from "../kernel/gesture.js";
56
12
  import type { ClipboardApi } from "./clipboard.js";
57
13
  import { createClickSwallow, placeMenuAt } from "./menu-position.js";
58
14
 
59
- // Viewport clamping, the flip-above-the-fingertip gap, and the trailing-click
60
- // swallow all live in the shared point-anchored menu module (menu-position.ts),
61
- // shared with the tab menu.
62
-
63
- /** Options for the contextMenu feature.
64
- *
65
- * Pass the SAME clipboard feature value the composition includes, not a second
66
- * `clipboard()` call: `ctx.use` resolves a value to the instance the kernel set
67
- * up, so a value that is not in the feature list resolves to nothing and the
68
- * menu silently loses Copy and Paste. `presetSingle` shows the shape. */
15
+ /** Options for the contextMenu feature. Pass the SAME clipboard feature value
16
+ * the composition includes, not a second `clipboard()` call: `ctx.use` resolves
17
+ * a value that is not in the feature list to nothing, and the menu silently
18
+ * loses Copy and Paste. */
69
19
  export interface ContextMenuOptions {
70
- /** The clipboard feature value, so the menu can offer Copy/Paste through its
71
- * API (ctx.use). Omitted: the menu shows only Select All. */
20
+ /** The clipboard feature value. Omitted: the menu shows only Select All. */
72
21
  clipboard?: TerminalFeature<ClipboardApi>;
73
22
  }
74
23
 
75
- /** iPhone/iPad/iPod, including iPadOS Safari's default "desktop mode" (platform
76
- * MacIntel with a touch screen).
77
- *
78
- * This decides exactly one thing: whether a touch `contextmenu` may be
79
- * cancelled. WebKit reads preventDefault there as "cancel every remaining
80
- * default action of this gesture", which takes the platform's own
81
- * not-yet-registered word selection with it — that asymmetry is what once left
82
- * an iPad unable to select text at all while an iPhone (which fires no
83
- * contextmenu) could. Everywhere else, cancelling is how we stop the platform's
84
- * menu appearing alongside ours. It no longer decides whether or when our menu
85
- * opens, so a wrong answer here costs at most a duplicated menu on an unusual
86
- * device, never a broken selection and never a lost paste. */
87
- function isAppleTouchDevice(): boolean {
88
- // Dead in every runtime that reaches this, and left in place deliberately.
89
- // Measured on this repo's Node (v24.18.0, the version ts-ci pins): `typeof
90
- // navigator` is "object", with userAgent "Node.js/24", platform
91
- // "Linux x86_64" and maxTouchPoints undefined — so the fall-through already
92
- // answers false there, by the same route a browser on a non-Apple device
93
- // takes. A Web Worker has a WorkerNavigator, so it does not reach this
94
- // either. What the guard still buys is totality: without it the bare
95
- // `navigator` reads below would throw a ReferenceError in a runtime that has
96
- // no navigator at all. It answers the same `false` the fall-through would, so
97
- // nothing depends on reaching it, and no test can reach it without stubbing.
98
- if (typeof navigator === "undefined") {
99
- return false;
100
- }
101
- const ua = navigator.userAgent || "";
102
- const platform = navigator.platform || "";
24
+ /** Whether a touch `contextmenu` may be cancelled. WebKit reads preventDefault
25
+ * there as "cancel every remaining default of this gesture", the platform's
26
+ * not-yet-registered word selection included, which once left an iPad unable to
27
+ * select text; everywhere else cancelling keeps the platform's menu from
28
+ * appearing beside ours. */
29
+ function isAppleTouchDevice(nav: Navigator): boolean {
30
+ const ua = nav.userAgent || "";
31
+ const platform = nav.platform || "";
103
32
  if (/iP(hone|ad|od)/.test(ua) || /iP(hone|ad|od)/.test(platform)) {
104
33
  return true;
105
34
  }
106
- // iPadOS 13+ Safari defaults to desktop mode: platform "MacIntel" but a touch
107
- // screen (maxTouchPoints > 1). A real trackpad Mac reports maxTouchPoints 0.
108
- return platform === "MacIntel" && navigator.maxTouchPoints > 1;
35
+ // iPadOS Safari's desktop mode reports MacIntel with a touch screen; a
36
+ // trackpad Mac reports maxTouchPoints 0.
37
+ return platform === "MacIntel" && nav.maxTouchPoints > 1;
109
38
  }
110
39
 
111
- /** The current selection as text ("" when nothing is selected). A collapsed
112
- * selection stringifies to "", so this is the whole test. */
113
- function selectionText(): string {
114
- return window.getSelection()?.toString() ?? "";
115
- }
116
-
117
- /** True when an event target sits inside a hyperlink. A long-press on a link
118
- * raises the platform's own link preview/menu without ever making a selection,
119
- * so the selection test alone would not keep us out of its way. */
120
- const onLink = isLinkTarget;
121
-
122
- /** Build the contextMenu feature. Exposes no API — the menu is opened by the
123
- * gestures described above, never programmatically by a peer.
124
- *
125
- * Hand it the clipboard value the composition also includes (see
126
- * ContextMenuOptions) or it degrades to a Select All-only menu; its own position in
127
- * the feature list is otherwise free, since nothing reads it through `ctx.use`. Its
128
- * items act on the browser selection inside the terminal surface and on the
129
- * kernel's paste funnel, so it holds no session state and survives a tab switch
130
- * unchanged. Teardown removes the menu element and releases the keydown intercept
131
- * along with every surface and document listener. */
40
+ /** Build the contextMenu feature. Exposes no API. Without the clipboard value of
41
+ * ContextMenuOptions it degrades to a Select All-only menu. Its items act on the
42
+ * browser selection inside the surface and on the kernel's paste funnel, so it
43
+ * holds no session state. */
132
44
  export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
133
45
  return {
134
46
  name: "contextMenu",
135
47
  setup(ctx) {
136
48
  const surface = ctx.surface();
137
- const menu = document.createElement("div");
49
+ const doc = ctx.shell.root.ownerDocument;
50
+ const win = windowOf(doc);
51
+ const menu = doc.createElement("div");
138
52
  menu.className = "wt-ctx-menu";
139
53
  ctx.region("overlay", "menu").appendChild(menu);
54
+ // A long-press on a link raises the platform's own preview without ever
55
+ // making a selection, so the selection test alone would not keep us out of
56
+ // its way.
57
+ const onLink = (target: EventTarget | null): boolean => isLinkTarget(win, target);
140
58
 
141
59
  // The device that started the current interaction: `contextmenu` fires on a
142
60
  // desktop right-click AND on an Android long-press, so its handler branches
143
61
  // on this to tell them apart.
144
62
  let lastPointerType = "mouse";
145
63
  // Computed once: may a touch contextmenu be cancelled (see isAppleTouchDevice).
146
- const appleTouch = isAppleTouchDevice();
64
+ const appleTouch = isAppleTouchDevice(win.navigator);
147
65
  // Swallows the trailing click a touch long-press emits on release.
148
- const swallow = createClickSwallow();
66
+ const swallow = createClickSwallow(win);
149
67
 
150
68
  // The in-flight single-finger press, all of it read at `touchend`.
151
69
  // pressLive goes false the moment the gesture stops being a candidate (a
@@ -169,7 +87,7 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
169
87
  }
170
88
 
171
89
  function hide(refocus = true): void {
172
- const shouldRefocus = refocus && menu.contains(document.activeElement);
90
+ const shouldRefocus = refocus && menu.contains(doc.activeElement);
173
91
  menu.classList.remove("visible");
174
92
  menu.replaceChildren();
175
93
  if (shouldRefocus) {
@@ -181,7 +99,7 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
181
99
  // NOT refocus the textarea, or Firefox collapses the just-made selection
182
100
  // when focus leaves the output.
183
101
  function addButton(label: string, onClick: () => void, refocus = true): void {
184
- const b = document.createElement("button");
102
+ const b = doc.createElement("button");
185
103
  b.type = "button";
186
104
  b.textContent = label;
187
105
  b.addEventListener("click", () => {
@@ -196,7 +114,7 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
196
114
  function show(x: number, y: number): void {
197
115
  hide();
198
116
  const clipboard = clip();
199
- const sel = selectionText();
117
+ const sel = selectionTextWithin(surface);
200
118
  if (clipboard && sel) {
201
119
  addButton("Copy", () => {
202
120
  clipboard.copy(sel);
@@ -205,7 +123,7 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
205
123
  addButton(
206
124
  "Select All",
207
125
  () => {
208
- const s = window.getSelection();
126
+ const s = doc.getSelection();
209
127
  const output = surface.querySelector(".term-output");
210
128
  if (s && output) {
211
129
  s.selectAllChildren(output);
@@ -229,6 +147,9 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
229
147
  lastPointerType = e.pointerType;
230
148
  };
231
149
  surface.addEventListener("pointerdown", onPointerDown, { passive: true });
150
+ ctx.defer(() => {
151
+ surface.removeEventListener("pointerdown", onPointerDown);
152
+ });
232
153
 
233
154
  const onContextMenu = (e: MouseEvent): void => {
234
155
  if (lastPointerType !== "touch") {
@@ -236,20 +157,21 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
236
157
  show(e.clientX, e.clientY);
237
158
  return;
238
159
  }
239
- // Touch: our menu never opens from here — the touchend classifier owns
240
- // that. The only question left is whether the PLATFORM's menu may
241
- // proceed. On WebKit it must (cancelling here cancels the whole gesture's
242
- // remaining defaults, the word selection included). Elsewhere (Android
243
- // fires contextmenu mid-press) cancel it when the press has nothing of
244
- // the platform's own to show, so its menu and ours cannot both appear.
245
- if (!appleTouch && !onLink(e.target) && selectionText() === "") {
160
+ // Touch: the touchend classifier owns our menu; the question here is whether
161
+ // the PLATFORM's may proceed. WebKit must keep it (cancelling cancels the
162
+ // gesture's remaining defaults, the word selection included); elsewhere it is
163
+ // cancelled when the press has nothing of the platform's to show.
164
+ if (!appleTouch && !onLink(e.target) && selectionTextWithin(surface) === "") {
246
165
  e.preventDefault();
247
166
  }
248
167
  };
249
168
  surface.addEventListener("contextmenu", onContextMenu);
169
+ ctx.defer(() => {
170
+ surface.removeEventListener("contextmenu", onContextMenu);
171
+ });
250
172
 
251
173
  // Escape closes the menu without also sending ESC to the PTY.
252
- const offKey = ctx.registerKeydown((ev) => {
174
+ ctx.registerKeydown((ev) => {
253
175
  if (ev.key === "Escape" && menu.classList.contains("visible")) {
254
176
  ev.preventDefault();
255
177
  hide();
@@ -263,7 +185,7 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
263
185
  // menu with the correct refocus behaviour (Select All must not refocus
264
186
  // the input, or Firefox collapses the selection it just made). Reaching
265
187
  // hide() from here as well would override that choice.
266
- if (e.target instanceof Node && menu.contains(e.target)) {
188
+ if (e.target instanceof win.Node && menu.contains(e.target)) {
267
189
  return;
268
190
  }
269
191
  // The release click of the long-press that just opened the menu is that
@@ -273,7 +195,10 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
273
195
  }
274
196
  hide();
275
197
  };
276
- document.addEventListener("click", onDocClick);
198
+ doc.addEventListener("click", onDocClick);
199
+ ctx.defer(() => {
200
+ doc.removeEventListener("click", onDocClick);
201
+ });
277
202
  // A right-click outside the terminal surface (a tab, its menu, elsewhere,
278
203
  // or a native browser menu) dismisses this menu. A right-click on the
279
204
  // surface is handled by onContextMenu (which reopens it) and fires first.
@@ -282,7 +207,10 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
282
207
  hide();
283
208
  }
284
209
  };
285
- document.addEventListener("contextmenu", onDocContextMenu);
210
+ doc.addEventListener("contextmenu", onDocContextMenu);
211
+ ctx.defer(() => {
212
+ doc.removeEventListener("contextmenu", onDocContextMenu);
213
+ });
286
214
 
287
215
  const onTouchStart = (e: TouchEvent): void => {
288
216
  const t = e.touches.length === 1 ? e.touches[0] : undefined;
@@ -295,7 +223,7 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
295
223
  pressX = t.clientX;
296
224
  pressY = t.clientY;
297
225
  pressOnLink = onLink(e.target);
298
- pressSelection = selectionText();
226
+ pressSelection = selectionTextWithin(surface);
299
227
  };
300
228
  const onTouchMove = (e: TouchEvent): void => {
301
229
  if (!pressLive) {
@@ -325,7 +253,7 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
325
253
  if (e.timeStamp - pressStart <= TAP_MAX_MS) {
326
254
  return; // a tap: the kernel focuses the input / clears the selection
327
255
  }
328
- const sel = selectionText();
256
+ const sel = selectionTextWithin(surface);
329
257
  if (sel !== "" && sel !== pressSelection) {
330
258
  return; // this press selected text; the OS callout owns it
331
259
  }
@@ -343,18 +271,15 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
343
271
  surface.addEventListener("touchmove", onTouchMove, { passive: true });
344
272
  surface.addEventListener("touchend", onTouchEnd, { passive: true });
345
273
  surface.addEventListener("touchcancel", onTouchCancel, { passive: true });
274
+ ctx.defer(() => {
275
+ surface.removeEventListener("touchstart", onTouchStart);
276
+ surface.removeEventListener("touchmove", onTouchMove);
277
+ surface.removeEventListener("touchend", onTouchEnd);
278
+ surface.removeEventListener("touchcancel", onTouchCancel);
279
+ });
346
280
 
347
281
  return {
348
282
  teardown() {
349
- offKey();
350
- surface.removeEventListener("pointerdown", onPointerDown);
351
- surface.removeEventListener("contextmenu", onContextMenu);
352
- document.removeEventListener("click", onDocClick);
353
- document.removeEventListener("contextmenu", onDocContextMenu);
354
- surface.removeEventListener("touchstart", onTouchStart);
355
- surface.removeEventListener("touchmove", onTouchMove);
356
- surface.removeEventListener("touchend", onTouchEnd);
357
- surface.removeEventListener("touchcancel", onTouchCancel);
358
283
  menu.remove();
359
284
  },
360
285
  };