@cplieger/web-terminal-ui 7.3.1 → 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 +52 -13
  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
@@ -4,12 +4,14 @@
4
4
  // share. No interpolation ever passes through here, so it carries no injection
5
5
  // surface.
6
6
 
7
- /** Parse a static HTML string and return its first element. */
8
- export function fromHTML(html: string): HTMLElement {
9
- const tpl = document.createElement("template");
7
+ import { windowOf } from "../kernel/realm.js";
8
+
9
+ /** Parse a static HTML string in `doc` and return its first element. */
10
+ export function fromHTML(doc: Document, html: string): HTMLElement {
11
+ const tpl = doc.createElement("template");
10
12
  tpl.innerHTML = html.trim();
11
13
  const el = tpl.content.firstElementChild;
12
- if (!(el instanceof HTMLElement)) {
14
+ if (!(el instanceof windowOf(doc).HTMLElement)) {
13
15
  throw new Error("web-terminal-ui: fromHTML produced no element");
14
16
  }
15
17
  return el;
@@ -21,55 +23,43 @@ export function fromHTML(html: string): HTMLElement {
21
23
  * Firefox. */
22
24
  export const PRESSED_CLASS = "wt-pressed";
23
25
 
24
- /** holdFocusOnPress wires a chrome button that must NOT take the keyboard off
25
- * the terminal, and paints its own press state because of it.
26
- *
27
- * Focus: the hidden terminal textarea stays focused. A bar button needs no
28
- * focus of its own, and letting the press shift focus makes iOS/iPadOS consume
29
- * the FIRST tap to blur the input (so "+" only fired on the second tap — the
30
- * reported double-tap), while on a desktop it parks the keyboard on the button,
31
- * where the strip's own keydown handling eats arrows as "switch tab" and Delete
32
- * as "close tab". Cancelling pointerdown's default prevents both.
33
- *
34
- * Press feedback: cancelling that default is also what suppresses the browser's
35
- * OWN activation state — Firefox ties `:active` to the mousedown default action,
36
- * and preventDefault on pointerdown suppresses the compatibility mousedown
37
- * entirely (measured on Firefox 140: no mousedown event, `matches(":active")`
38
- * false, background unchanged for as long as the button is held; Chromium and
39
- * WebKit still apply it). So these buttons — and ONLY these — carry the press
40
- * class, and their CSS press rules match either. That is why the two jobs live
41
- * in one helper: a caller that cancels the default without painting the class
42
- * ships a control with no press feedback in one engine, which is exactly the
43
- * defect this replaced.
44
- *
45
- * The class is dropped on release, on cancel, and when the pointer leaves while
46
- * held (a press dragged off the button is not an activation), mirroring what
47
- * `:active` does on its own. A non-primary button press paints nothing, also
48
- * matching it. The release listeners live on `window` for the life of the press
49
- * (an `AbortController`, the pattern the switcher gesture uses) rather than on
50
- * the button: `.wt-scroll-bottom` acts on the press and hides itself, so by the
51
- * time the release lands the button is `display: none` and receives no
52
- * `pointerup` at all — bound to the button, the class would stay painted and the
53
- * control would come back already lit. */
54
- export function holdFocusOnPress(btn: HTMLElement): void {
26
+ /** Wire a chrome button that must NOT take the keyboard off the terminal, and
27
+ * paint its own press state because of it: cancelling pointerdown's default
28
+ * keeps the hidden textarea focused (iOS otherwise spends the first tap on a
29
+ * blur; a desktop parks the arrows on the button) and suppresses Firefox's
30
+ * `:active` (tied to the mousedown default, measured on Firefox 140), so the
31
+ * press class replaces it. The release listeners live on the window because
32
+ * `.wt-scroll-bottom` hides itself on press and never sees its own `pointerup`.
33
+ * Returns the release that unwires the button and ends a held press. */
34
+ export function holdFocusOnPress(btn: HTMLElement): () => void {
35
+ const wired = new AbortController();
55
36
  let release: (() => void) | null = null;
56
- btn.addEventListener("pointerdown", (e) => {
57
- e.preventDefault();
58
- if (e.button !== 0 || release !== null) {
59
- return;
60
- }
61
- btn.classList.add(PRESSED_CLASS);
62
- const held = new AbortController();
63
- release = () => {
64
- held.abort();
65
- release = null;
66
- btn.classList.remove(PRESSED_CLASS);
67
- };
68
- const end = (): void => {
69
- release?.();
70
- };
71
- window.addEventListener("pointerup", end, { signal: held.signal });
72
- window.addEventListener("pointercancel", end, { signal: held.signal });
73
- btn.addEventListener("pointerleave", end, { signal: held.signal });
74
- });
37
+ btn.addEventListener(
38
+ "pointerdown",
39
+ (e) => {
40
+ e.preventDefault();
41
+ if (e.button !== 0 || release !== null) {
42
+ return;
43
+ }
44
+ btn.classList.add(PRESSED_CLASS);
45
+ const held = new AbortController();
46
+ release = () => {
47
+ held.abort();
48
+ release = null;
49
+ btn.classList.remove(PRESSED_CLASS);
50
+ };
51
+ const end = (): void => {
52
+ release?.();
53
+ };
54
+ const win = windowOf(btn.ownerDocument);
55
+ win.addEventListener("pointerup", end, { signal: held.signal });
56
+ win.addEventListener("pointercancel", end, { signal: held.signal });
57
+ btn.addEventListener("pointerleave", end, { signal: held.signal });
58
+ },
59
+ { signal: wired.signal },
60
+ );
61
+ return () => {
62
+ wired.abort();
63
+ release?.();
64
+ };
75
65
  }
@@ -1,11 +1,9 @@
1
- // menu-position.ts — shared point-anchored placement for the fixed-position
2
- // menus (the terminal context menu and the tab-strip menu), which previously
3
- // carried two hand-synced copies of this exact clamp/flip math.
4
- //
5
1
  // position:fixed means x/y are viewport coordinates; the VISUAL viewport
6
2
  // (when present) is the area above the on-screen keyboard, so clamping to it
7
3
  // keeps a menu off the keyboard too.
8
4
 
5
+ import { windowOf } from "../kernel/realm.js";
6
+
9
7
  /** Clamp margin from the visible viewport edges (px). */
10
8
  const EDGE_MARGIN = 8;
11
9
 
@@ -20,31 +18,28 @@ const FLIP_GAP = 16;
20
18
  * the release without eating a deliberate follow-up tap. */
21
19
  const SWALLOW_MS = 350;
22
20
 
23
- /** createClickSwallow returns the two halves a point-anchored menu needs to
24
- * survive the contextmenu-then-touchend race: `arm()` and `swallowing()` as the
25
- * first guard in the outside-click dismiss.
26
- *
27
- * **Arm on the RELEASE edge, not when the menu opens.** The window is a fixed
28
- * 350ms from `arm()`, so arming it while the finger is still down only covers
29
- * the release if the user lifts within 350ms. Both menus shipped that way and
30
- * both dismissed themselves on release whenever the press was held a beat
31
- * longer: the terminal menu opened from a 550ms hold timer, so anything past
32
- * ~900ms of hold lost its guard, and the tab menu armed from a mid-press
33
- * `contextmenu`. The terminal menu now opens from `touchend` itself, so its
34
- * arm() is on the release by construction; the tab menu opens mid-press and
35
- * re-arms when the opening gesture's pointer goes up.
36
- *
37
- * It lives here, beside the shared placement math, because both point-anchored
38
- * menus need it and a hand-synced second copy is what this module was extracted
39
- * to stop. Arm it only for touch: a mouse right-click emits no trailing click,
40
- * and swallowing there would eat a genuine dismiss. */
41
- export function createClickSwallow(): { arm: () => void; swallowing: () => boolean } {
21
+ /** The two halves a point-anchored menu needs to survive the
22
+ * contextmenu-then-touchend race. */
23
+ interface ClickSwallow {
24
+ /** Start the swallow window. Call on the RELEASE edge of the opening touch,
25
+ * not when the menu opens: the window is a fixed 350 ms from here, so armed
26
+ * mid-press it only covers a release that comes within 350 ms. Touch only: a
27
+ * mouse right-click emits no trailing click, and swallowing there would eat a
28
+ * genuine dismiss. */
29
+ arm: () => void;
30
+ /** Whether a click arriving now is the opening gesture's own release; the
31
+ * first guard in the outside-click dismiss. */
32
+ swallowing: () => boolean;
33
+ }
34
+
35
+ /** Build a swallow on `win`'s clock, the clock the menu's document runs on. */
36
+ export function createClickSwallow(win: Pick<Window, "performance">): ClickSwallow {
42
37
  let until = 0;
43
38
  return {
44
39
  arm: (): void => {
45
- until = performance.now() + SWALLOW_MS;
40
+ until = win.performance.now() + SWALLOW_MS;
46
41
  },
47
- swallowing: (): boolean => performance.now() < until,
42
+ swallowing: (): boolean => win.performance.now() < until,
48
43
  };
49
44
  }
50
45
 
@@ -57,11 +52,12 @@ export function createClickSwallow(): { arm: () => void; swallowing: () => boole
57
52
  * (wt-container). The menu must be measurable (rendered, not display:none)
58
53
  * when called. */
59
54
  export function placeMenuAt(menu: HTMLElement, x: number, y: number): void {
60
- const vv = window.visualViewport;
55
+ const win = windowOf(menu.ownerDocument);
56
+ const vv = win.visualViewport;
61
57
  const viewLeft = vv ? vv.offsetLeft : 0;
62
58
  const viewTop = vv ? vv.offsetTop : 0;
63
- const viewWidth = vv ? vv.width : window.innerWidth;
64
- const viewHeight = vv ? vv.height : window.innerHeight;
59
+ const viewWidth = vv ? vv.width : win.innerWidth;
60
+ const viewHeight = vv ? vv.height : win.innerHeight;
65
61
  const menuW = menu.offsetWidth;
66
62
  const menuH = menu.offsetHeight;
67
63
  const left = Math.max(
@@ -1,48 +1,30 @@
1
- /**
2
- * mobileToolbar feature: the on-screen key toolbar (Tab/Esc/arrows/Enter + a
3
- * collapse toggle and sticky-Ctrl) in the thumb-zone region.
4
- * The engine's toolbar.bindMobileToolbar wires the buttons (DECCKM-aware
5
- * arrows, sticky-Ctrl state; engine >= v3, where the widget moved out of the
6
- * keyboard module); this feature builds the toolbar chrome, routes its
7
- * output through the kernel funnel, and registers sticky-Ctrl as an input
8
- * transform so a typed character is rewritten to its Ctrl byte when armed.
9
- *
10
- * @module
11
- */
12
-
13
1
  import { toolbar } from "@cplieger/web-terminal-engine";
2
+ import { windowOf } from "../kernel/realm.js";
14
3
  import type { TerminalFeature } from "../kernel/types.js";
15
4
  import { fromHTML } from "./dom.js";
16
5
 
17
6
  const { bindMobileToolbar } = toolbar;
18
7
 
19
- /** mobileToolbar's public API, so a peer feature (tabs) can drive the key grid
20
- * from a button it owns instead of the toolbar's own toggle. */
8
+ /** mobileToolbar's API, so a peer feature (tabs) can drive the key grid from a
9
+ * button it owns instead of the toolbar's own toggle. */
21
10
  export interface MobileToolbarApi {
22
- /** Show/hide the key grid (flips the toolbar's collapsed state). */
11
+ /** Show/hide the key grid. */
23
12
  toggle(): void;
24
- /** Whether the key grid is currently open. */
25
13
  isOpen(): boolean;
26
- /** Whether sticky-Ctrl is currently armed (a pending Ctrl press). */
14
+ /** Whether sticky-Ctrl is armed (a pending Ctrl press). */
27
15
  isCtrlArmed(): boolean;
28
- /** Subscribe to sticky-Ctrl arm/disarm; returns an unsubscribe. Lets a peer
29
- * (the tab bar's keyboard button) surface the pending modifier even while the
30
- * key grid — and its own Ctrl button — is closed. */
16
+ /** Sticky-Ctrl arm/disarm, so a peer can show the pending modifier while the
17
+ * key grid and its own Ctrl button are closed. */
31
18
  onCtrlArmedChange(fn: (armed: boolean) => void): () => void;
32
19
  }
33
20
 
34
21
  /** Options for the mobileToolbar feature. */
35
22
  export interface MobileToolbarOptions {
36
- /** When true, the toolbar hides its own top-right toggle button and positions
37
- * the key grid to open above the mobile tab bar. The grid is then driven
38
- * externally through the returned API (tabs renders a keyboard button in the
39
- * switcher bar and calls toggle()). The tabbed presets set this; presetTouch
40
- * leaves it off and keeps the self-contained top-right toggle. */
23
+ /** Hide the toolbar's own toggle and open the grid above the mobile tab bar;
24
+ * the grid is then driven through the returned API. The tabbed presets set it. */
41
25
  externalToggle?: boolean;
42
26
  }
43
27
 
44
- // The key grid, without the scroll-to-bottom button (that is the scrollToBottom
45
- // feature, composed into the same region's "scroll" slot).
46
28
  const TOOLBAR_HTML = `
47
29
  <div class="key-toolbar collapsed no-transition" aria-label="Navigation keys" role="toolbar">
48
30
  <button type="button" id="kb-toggle" class="kb-toggle" aria-label="Toggle key toolbar"><svg class="icon-hamburger" viewBox="0 0 24 24"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg><svg class="icon-close" viewBox="0 0 24 24"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button>
@@ -56,53 +38,46 @@ const TOOLBAR_HTML = `
56
38
  <button type="button" id="kb-right" class="kb-key kb-r2c4" aria-label="Right"><svg viewBox="0 0 24 24"><polyline points="9 6 15 12 9 18"/></svg></button>
57
39
  </div>`;
58
40
 
59
- /** Build the mobileToolbar feature.
60
- *
61
- * The chrome is always built; whether it is VISIBLE is a CSS decision — the keys
62
- * and the toggle are `display: none` and only a coarse pointer restores them
63
- * (`css/23-toolbar.css`) — so a desktop consumer including this feature pays the
64
- * DOM and sees nothing. Order it before `tabs` when `externalToggle` is set, since
65
- * tabs reads the returned API through `ctx.use` to drive the grid from the tab
66
- * bar's keyboard button.
67
- *
68
- * Sticky-Ctrl is registered as a kernel INPUT TRANSFORM rather than handled in
69
- * the toolbar's own click path, so it rewrites a character typed on the soft
70
- * keyboard as well as one produced by these buttons. Teardown removes the
71
- * toolbar, drops the transform, and clears every subscriber. */
41
+ /** Build the mobileToolbar feature. The chrome is always built; only a coarse
42
+ * pointer makes it visible (`css/23-toolbar.css`). Order it before `tabs` when
43
+ * `externalToggle` is set, since tabs reads the API through `ctx.use`.
44
+ * Sticky-Ctrl is a kernel INPUT TRANSFORM rather than a toolbar click path, so
45
+ * it rewrites a character typed on the soft keyboard as well as one from these
46
+ * buttons. */
72
47
  export function mobileToolbar(opts: MobileToolbarOptions = {}): TerminalFeature<MobileToolbarApi> {
73
48
  return {
74
49
  name: "mobileToolbar",
50
+ scope: "shell",
75
51
  setup(ctx) {
76
52
  const encoder = new TextEncoder();
77
53
  const decoder = new TextDecoder();
78
- const toolbar = fromHTML(TOOLBAR_HTML);
79
- // Externally-driven mode (the tabbed presets): the toolbar hides its own
80
- // toggle and opens the grid above the mobile tab bar; tabs drives it via
81
- // the returned API. The class gates both behaviors in CSS.
54
+ const win = windowOf(ctx.shell.root.ownerDocument);
55
+ const toolbar = fromHTML(ctx.shell.root.ownerDocument, TOOLBAR_HTML);
82
56
  if (opts.externalToggle) {
83
57
  toolbar.classList.add("wt-toolbar-external");
84
58
  }
85
59
  ctx.region("bottom-inset-end", "keys").appendChild(toolbar);
86
60
 
87
- // Fan sticky-Ctrl arm/disarm out to subscribers (the tab bar's keyboard
88
- // button mirrors it, so a pending Ctrl is visible with the grid closed).
89
61
  const armedListeners = new Set<(armed: boolean) => void>();
90
62
  const ctrl = bindMobileToolbar({
91
63
  toolbar,
92
64
  send: (bytes) => {
93
65
  ctx.send(encoder.encode(bytes));
94
66
  },
67
+ modes: ctx.modes,
95
68
  onCtrlChange: (armed) => {
96
69
  for (const fn of [...armedListeners]) {
97
70
  fn(armed);
98
71
  }
99
72
  },
100
73
  });
74
+ ctx.defer(() => {
75
+ ctrl.dispose();
76
+ });
101
77
 
102
- // Sticky-Ctrl as an outbound-byte transform: when armed, a single typed
103
- // character is rewritten to its Ctrl byte (and disarms). The toolbar's own
104
- // arrow/Tab/Enter/Esc sends disarm before sending, so they pass through.
105
- const offTransform = ctx.registerInputTransform((bytes) => {
78
+ // The toolbar's own arrow/Tab/Enter/Esc sends disarm before sending, so
79
+ // they pass through untouched.
80
+ ctx.registerInputTransform((bytes) => {
106
81
  if (!ctrl.isCtrlArmed()) {
107
82
  return bytes;
108
83
  }
@@ -111,18 +86,17 @@ export function mobileToolbar(opts: MobileToolbarOptions = {}): TerminalFeature<
111
86
  return mapped === text ? bytes : encoder.encode(mapped);
112
87
  });
113
88
 
114
- // Prime the slide transition off for the first paint (no initial flash),
115
- // then enable it after two frames.
116
- requestAnimationFrame(() => {
117
- requestAnimationFrame(() => {
89
+ // Two frames without the slide transition, so the first paint cannot flash.
90
+ let settleFrame = win.requestAnimationFrame(() => {
91
+ settleFrame = win.requestAnimationFrame(() => {
118
92
  toolbar.classList.remove("no-transition");
119
93
  });
120
94
  });
95
+ ctx.defer(() => {
96
+ win.cancelAnimationFrame(settleFrame);
97
+ });
121
98
 
122
99
  return {
123
- // Expose grid open/close so a peer (tabs) can drive it from the tab bar.
124
- // Toggling the same `.collapsed` class the engine's own kb-toggle flips
125
- // keeps a single source of truth for the grid's visibility.
126
100
  api: {
127
101
  toggle() {
128
102
  toolbar.classList.toggle("collapsed");
@@ -139,17 +113,13 @@ export function mobileToolbar(opts: MobileToolbarOptions = {}): TerminalFeature<
139
113
  },
140
114
  },
141
115
  onDetach() {
142
- // A tab switch disarms a latched sticky-Ctrl so a pending Ctrl does
143
- // not fire against the incoming session (e.g. an accidental Ctrl+C to
144
- // the wrong agent, design 5.1 / ui-ux review D).
116
+ // A pending Ctrl must not fire against the incoming session.
145
117
  if (ctrl.isCtrlArmed()) {
146
118
  ctrl.setCtrlArmed(false);
147
119
  }
148
120
  },
149
121
  teardown() {
150
- offTransform();
151
122
  armedListeners.clear();
152
- ctrl.dispose();
153
123
  toolbar.remove();
154
124
  },
155
125
  };
@@ -1,88 +1,54 @@
1
- /**
2
- * predictiveEcho feature: Mosh-style local echo. Wires the
3
- * predict.ts mini-VT to the kernel input funnel: it advances a predicted cursor
4
- * on accepted input (an input observer) and owns the col-0 backspace brake (an
5
- * input transform that drops a lone DEL at the true origin, since the predicted
6
- * cursor is the authoritative position for that decision). It re-pushes the
7
- * predicted-cursor overlay on prediction changes and after each render flush.
8
- *
9
- * @module
10
- */
11
-
12
- import * as predict from "../predict.js";
1
+ import { createPredictor } from "../predict.js";
13
2
  import type { TerminalFeature } from "../kernel/types.js";
14
3
 
15
- /** Build the predictiveEcho feature. Exposes no API — its output is the engine
16
- * renderer's predicted-cursor overlay.
17
- *
18
- * The prediction state is MODULE-scoped (`../predict.js`), not per instance, so two
19
- * terminals in one page that both include this feature would drive one predicted
20
- * cursor between them. That is within the kernel's existing "call createTerminal
21
- * exactly once" contract, but it is the reason this feature cannot be made
22
- * per-instance by composing it twice.
23
- *
24
- * Predictions are advisory: `predict` suspends itself on any byte it cannot
25
- * model, because a wrong prediction is worse than a missing one. The feature
26
- * resets it on a server restart and on a tab switch (`onDetach`), so the outgoing
27
- * session's ghost cursor never paints over the incoming session's screen; the
28
- * next server frame re-arms it. Teardown drops every listener, hides the overlay,
29
- * and clears the prediction. */
4
+ /** Build the predictiveEcho feature: Mosh-style local echo through one predictor
5
+ * per pane, drawn as the renderer's predicted-cursor overlay. The predictor
6
+ * suspends itself on any byte it cannot model, because a wrong prediction is
7
+ * worse than a missing one; a server restart and a tab switch reset it so the
8
+ * outgoing session's ghost cursor never paints over the incoming screen. */
30
9
  export function predictiveEcho(): TerminalFeature {
31
10
  return {
32
11
  name: "predictiveEcho",
33
12
  setup(ctx) {
13
+ const predict = createPredictor();
14
+ ctx.defer(() => {
15
+ predict.dispose();
16
+ });
34
17
  function push(): void {
35
18
  const p = predict.get();
36
19
  ctx.render.setPredictedCursor(p.row, p.col, p.active);
37
20
  }
38
21
  predict.subscribe(push);
39
22
 
40
- // Col-0 backspace brake: suppress a lone DEL at the true origin (row 0,
41
- // col 0) so held-Backspace on an empty line does not flood the server.
42
- // Owned here because the predicted cursor is the position it keys off.
43
- const offTransform = ctx.registerInputTransform((bytes) => {
23
+ ctx.registerInputTransform((bytes) => {
44
24
  const p = predict.get();
45
25
  if (bytes.length === 1 && bytes[0] === 0x7f && p.active && p.row === 0 && p.col === 0) {
46
26
  return new Uint8Array(0);
47
27
  }
48
28
  return bytes;
49
29
  });
50
- const offObserver = ctx.registerInputObserver((bytes) => {
30
+ ctx.registerInputObserver((bytes) => {
51
31
  predict.applyInput(bytes);
52
32
  });
53
-
54
- const offCursor = ctx.on("render:cursor", () => {
33
+ ctx.on("render:cursor", () => {
55
34
  push();
56
35
  });
57
- const offScreen = ctx.on("wire:screen", (msg) => {
36
+ ctx.on("wire:screen", (msg) => {
58
37
  const sz = ctx.session.size();
59
38
  predict.setDimensions(sz.cols, sz.rows);
60
39
  predict.onScreenFrame(msg.cursor[0], msg.cursor[1], msg.cursorHidden);
61
40
  });
62
- const offState = ctx.on("connection:state", (s) => {
41
+ ctx.on("connection:state", (s) => {
63
42
  if (s === "restarted") {
64
43
  predict.reset();
65
44
  }
66
45
  });
67
46
 
68
47
  return {
69
- // A tab switch (kernel notifySwitch -> onDetach) must drop the predicted
70
- // cursor, so the outgoing session's ghost cursor does not paint on the
71
- // incoming session's freshly-bound screen until its first server frame
72
- // re-arms prediction. Mirrors composition.cancelComposition on the same
73
- // detach: both local-echo paths reset on switch. predict.reset() hides the
74
- // overlay (its onChange -> push -> setPredictedCursor(0,0,false)); the next
75
- // wire:screen re-arms via onScreenFrame.
76
48
  onDetach() {
77
49
  predict.reset();
78
50
  },
79
51
  teardown() {
80
- offTransform();
81
- offObserver();
82
- offCursor();
83
- offScreen();
84
- offState();
85
- // Hide the overlay and drop stale prediction state.
86
52
  predict.reset();
87
53
  ctx.render.setPredictedCursor(0, 0, false);
88
54
  },
@@ -1,13 +1,4 @@
1
- /**
2
- * scrollToBottom feature: a scroll-to-bottom control in the thumb-zone region,
3
- * shown only while the user has scrolled up. The
4
- * scroll-state signal comes from the kernel's scroll:state event (the kernel
5
- * owns scroll.init); this feature just renders the affordance and drives
6
- * scroll.scrollToBottom.
7
- *
8
- * @module
9
- */
10
-
1
+ import { windowOf } from "../kernel/realm.js";
11
2
  import type { TerminalFeature } from "../kernel/types.js";
12
3
  import { fromHTML, holdFocusOnPress } from "./dom.js";
13
4
 
@@ -16,22 +7,19 @@ const BUTTON_HTML = `
16
7
  <svg viewBox="0 0 24 24" aria-hidden="true"><path fill="none" d="M7 13l5 5 5-5M7 6l5 5 5-5"/></svg>
17
8
  </button>`;
18
9
 
19
- /** Build the scrollToBottom feature. Exposes no API — the button is the whole
20
- * surface, and a peer that wants the same effect calls `ctx.scroll.scrollToBottom`
21
- * directly.
22
- *
23
- * Its job is resuming FOLLOW, not just moving the viewport, which is why it also
24
- * acts when the reader is already at the bottom but holding (an ED3 clamp leaves
25
- * exactly that state). The jump is smooth unless the user asked for reduced
26
- * motion, and it is idempotent, so binding both `pointerdown` (touch presses feel
27
- * immediate) and `click` (keyboard activation) is safe. Teardown unsubscribes and
28
- * removes the button. */
10
+ /** Build the scrollToBottom feature. Exposes no API: the button is the whole
11
+ * surface, and a peer that wants the effect calls `ctx.scroll.scrollToBottom`.
12
+ * Its job is resuming FOLLOW, not just moving the viewport, so it also acts
13
+ * when the reader is at the bottom but holding (an ED3 clamp leaves that
14
+ * state). The jump is idempotent, so `pointerdown` (touch feels immediate) and
15
+ * `click` (keyboard) are both bound. Teardown unsubscribes and removes it. */
29
16
  export function scrollToBottom(): TerminalFeature {
30
17
  return {
31
18
  name: "scrollToBottom",
32
19
  setup(ctx) {
20
+ const win = windowOf(ctx.shell.root.ownerDocument);
33
21
  const slot = ctx.region("bottom-inset-end", "scroll");
34
- const btn = fromHTML(BUTTON_HTML);
22
+ const btn = fromHTML(ctx.shell.root.ownerDocument, BUTTON_HTML);
35
23
  slot.appendChild(btn);
36
24
 
37
25
  // Jump to the bottom. With motion allowed, smooth-scroll the surface so
@@ -43,8 +31,8 @@ export function scrollToBottom(): TerminalFeature {
43
31
  // scrollToBottom (which re-engages following synchronously).
44
32
  const jump = (): void => {
45
33
  const reduce =
46
- typeof window.matchMedia === "function" &&
47
- window.matchMedia("(prefers-reduced-motion: reduce)").matches;
34
+ typeof win.matchMedia === "function" &&
35
+ win.matchMedia("(prefers-reduced-motion: reduce)").matches;
48
36
  if (reduce) {
49
37
  ctx.scroll.scrollToBottom();
50
38
  return;
@@ -78,7 +66,7 @@ export function scrollToBottom(): TerminalFeature {
78
66
  // jump is idempotent, so the pair is safe. holdFocusOnPress keeps the
79
67
  // keyboard on the terminal across the press and paints the press class the
80
68
  // cancelled default costs this button.
81
- holdFocusOnPress(btn);
69
+ ctx.defer(holdFocusOnPress(btn));
82
70
  btn.addEventListener("pointerdown", jump);
83
71
  btn.addEventListener("click", jump);
84
72