@cplieger/web-terminal-engine 5.3.0 → 6.0.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/src/store.ts CHANGED
@@ -1981,7 +1981,7 @@ export class LineStore {
1981
1981
  // `cursor > winBottom` cannot hold there either. That is the ONLY thing
1982
1982
  // holding when the target is 0, which `new LineStore(0)` produces
1983
1983
  // (`evictionBatch(0)` is 1, so `target = 0 - 1 + 1`; the bare
1984
- // constructor accepts 0, unlike `render.init` and `fromSnapshot`, which
1984
+ // constructor accepts 0, unlike `createRenderer` and `fromSnapshot`, which
1985
1985
  // both require a positive integer) — a reason to leave that conjunct
1986
1986
  // alone.
1987
1987
  // Advance to the lowest remaining key above the victim. Contiguous
@@ -0,0 +1,135 @@
1
+ import { type Connection, type ConnectionCallbacks, createConnection } from "./connection.js";
2
+ import { createModeState, type ModeSnapshot, type ModeState } from "./modes.js";
3
+ import { createMouseController, type MouseController } from "./mouse.js";
4
+ import { createRenderer, type Renderer } from "./render.js";
5
+ import { createScrollController, type ScrollController } from "./scroll.js";
6
+
7
+ /** What `createTerminalEngine` binds to and forwards to its five parts. */
8
+ export interface TerminalEngineOptions {
9
+ /** Inner element that receives the row children. */
10
+ output: HTMLElement;
11
+ /** Outer scroll container; also the mouse listeners' element. */
12
+ termWrap: HTMLElement;
13
+ /**
14
+ * The consumer's connection callbacks. `onMessage` receives every frame after
15
+ * the engine has routed `screen`, `scroll` and `modes` frames to its renderer.
16
+ */
17
+ callbacks: ConnectionCallbacks;
18
+ /** WebSocket endpoint path (default "/ws"). */
19
+ wsPath?: string;
20
+ /** The `sessionStorage` key of the unmanaged session id; distinct per engine on one page. */
21
+ sessionIdKey?: string;
22
+ /** Retained-line cap for the renderer's implicit store; see `RendererOptions.maxLines`. */
23
+ maxLines?: number;
24
+ onCursorMove?: () => void;
25
+ onUserScrollChange?: (scrolledUp: boolean) => void;
26
+ /** Initial mode state (default `POWER_ON_MODES`). */
27
+ initialModes?: Readonly<ModeSnapshot>;
28
+ }
29
+
30
+ /** One terminal: a renderer, a scroll controller, a connection, a mouse controller and a mode state. */
31
+ export interface TerminalEngine {
32
+ readonly renderer: Renderer;
33
+ readonly scroll: ScrollController;
34
+ readonly connection: Connection;
35
+ readonly mouse: MouseController;
36
+ readonly modes: ModeState;
37
+ /** Disposes the five parts in reverse build order; each part is idempotent, so this is too. */
38
+ dispose(): void;
39
+ }
40
+
41
+ /**
42
+ * Composes the five factories into one engine that shares nothing with another
43
+ * engine on the same page. It does not connect and does not measure fonts: call
44
+ * `engine.renderer.updateFontMetrics()` when the font is ready and
45
+ * `engine.connection.connect()` to start. Throws, holding nothing, when a part
46
+ * cannot be built.
47
+ */
48
+ export function createTerminalEngine(opts: TerminalEngineOptions): TerminalEngine {
49
+ // `renderer` and `connection` are assigned below; every closure that reads them
50
+ // runs from a DOM event or a socket message, none of which can fire before this
51
+ // function returns.
52
+ let renderer: Renderer;
53
+ let connection: Connection;
54
+ const built: { dispose(): void }[] = [];
55
+ let modes: ModeState;
56
+ let scroll: ScrollController;
57
+ let mouse: MouseController;
58
+ try {
59
+ modes = createModeState(opts.initialModes);
60
+ built.push(modes);
61
+ scroll = createScrollController({
62
+ scrollEl: opts.termWrap,
63
+ ...(opts.onUserScrollChange === undefined
64
+ ? {}
65
+ : { onUserScrollChange: opts.onUserScrollChange }),
66
+ onScrollPosition: () => {
67
+ renderer.handleScrollPosition();
68
+ },
69
+ });
70
+ built.push(scroll);
71
+ renderer = createRenderer({
72
+ output: opts.output,
73
+ termWrap: opts.termWrap,
74
+ scroll,
75
+ modes,
76
+ ...(opts.onCursorMove === undefined ? {} : { onCursorMove: opts.onCursorMove }),
77
+ ...(opts.maxLines === undefined ? {} : { maxLines: opts.maxLines }),
78
+ requestHistory: (from, max) => connection.requestHistory(from, max),
79
+ historyBudget: () => connection.historyBudget(),
80
+ });
81
+ built.push(renderer);
82
+ mouse = createMouseController({
83
+ modes,
84
+ termElement: () => opts.termWrap,
85
+ gridElement: () => opts.output,
86
+ cellSize: () => renderer.cellSize(),
87
+ gridSize: () => renderer.gridSize(),
88
+ sendReport: (data) => connection.sendEphemeral(data),
89
+ });
90
+ built.push(mouse);
91
+ const consumer = opts.callbacks;
92
+ const routed: ConnectionCallbacks = {
93
+ ...consumer,
94
+ onMessage(msg) {
95
+ if (msg.type === "screen") {
96
+ renderer.handleScreen(msg);
97
+ } else if (msg.type === "scroll") {
98
+ renderer.handleScroll(msg);
99
+ } else if (msg.type === "modes") {
100
+ renderer.updateReverseVideo();
101
+ }
102
+ consumer.onMessage(msg);
103
+ },
104
+ };
105
+ // The last acquisition: nothing after it can throw, so the connection needs
106
+ // no entry in `built`.
107
+ connection = createConnection({
108
+ renderer,
109
+ modes,
110
+ mouse,
111
+ callbacks: routed,
112
+ ...(opts.wsPath === undefined ? {} : { wsPath: opts.wsPath }),
113
+ ...(opts.sessionIdKey === undefined ? {} : { sessionIdKey: opts.sessionIdKey }),
114
+ });
115
+ } catch (err) {
116
+ for (const part of built.reverse()) {
117
+ part.dispose();
118
+ }
119
+ throw err;
120
+ }
121
+ return {
122
+ renderer,
123
+ scroll,
124
+ connection,
125
+ mouse,
126
+ modes,
127
+ dispose(): void {
128
+ connection.dispose();
129
+ mouse.dispose();
130
+ renderer.dispose();
131
+ scroll.dispose();
132
+ modes.dispose();
133
+ },
134
+ };
135
+ }
package/src/toolbar.ts CHANGED
@@ -1,25 +1,18 @@
1
1
  // On-screen mobile keyboard toolbar: a DOM widget (button lookup, ARIA
2
2
  // painting, a listener registry, sticky-Ctrl state) wiring touch buttons to a
3
- // terminal send sink. Split out of keyboard.ts (2026-07): that module is the
4
- // pure key-ENCODING layer (KeyboardEvent -> wire bytes), and a DOM widget
5
- // buried in it made both harder to navigate. The wire encodings themselves
6
- // stay in keyboard.ts — this module consumes its exported logical-key homes
7
- // (plainCursorKeySeq / plainEscapeSeq / kittyCtrlCharSeq / ctrlByteFor), so
8
- // the toolbar cannot drift from the physical-key path (the equivalence
9
- // fixtures in toolbar.test.ts pin the two byte-for-byte).
10
- //
11
- // This was duplicated across vibekit and web-terminal-kiro — same wire
12
- // sequences, same sticky-Ctrl semantics, same DECCKM nuance — so it lives in
13
- // the engine.
3
+ // terminal send sink. The wire encodings live in keyboard.ts; this module
4
+ // consumes its logical-key homes (plainCursorKeySeq, plainEscapeSeq,
5
+ // kittyCtrlCharSeq, ctrlByteFor) so the toolbar cannot drift from the
6
+ // physical-key path, which toolbar.test.ts pins byte-for-byte.
14
7
 
15
8
  import {
16
9
  ctrlByteFor,
10
+ type KeyboardModes,
17
11
  kittyCtrlCharSeq,
18
12
  kittyDisambiguateActive,
19
13
  plainCursorKeySeq,
20
14
  plainEscapeSeq,
21
15
  } from "./keyboard.js";
22
- import { isApplicationCursor } from "./modes.js";
23
16
 
24
17
  /**
25
18
  * Default DOM ids for the on-screen mobile keyboard toolbar buttons,
@@ -67,6 +60,11 @@ export interface BindMobileToolbarOptions {
67
60
  * to the same `send`.
68
61
  */
69
62
  readonly send: (bytes: string) => void;
63
+ /**
64
+ * The mode state of the terminal the toolbar types into, read at each press:
65
+ * the arrow and Escape bytes depend on its kitty and DECCKM bits.
66
+ */
67
+ readonly modes: KeyboardModes;
70
68
  /** Optional id overrides. Missing keys fall back to defaults. */
71
69
  readonly ids?: MobileToolbarIds;
72
70
  /**
@@ -79,14 +77,11 @@ export interface BindMobileToolbarOptions {
79
77
  /** Returned from `bindMobileToolbar`; manages sticky-Ctrl state and tear-down. */
80
78
  export interface MobileToolbarController {
81
79
  /**
82
- * Apply sticky-Ctrl to a piece of input text.
83
- * - When NOT armed: returns `text` unchanged.
84
- * - Armed AND `text.length === 1`: returns the matching Ctrl byte
85
- * (`ctrlByteFor(text)`), or the original char when no mapping
86
- * exists. Always disarms.
87
- * - Armed AND longer (e.g. paste, IME commit): returns `text`
88
- * unchanged and disarms — applying Ctrl to a multi-char string
89
- * would garble it.
80
+ * Apply sticky-Ctrl to a piece of input text. Not armed: `text` unchanged.
81
+ * Armed and one character: the matching Ctrl byte (`ctrlByteFor`), or the
82
+ * character when none exists. Armed and longer (paste, IME commit): `text`
83
+ * unchanged, since Ctrl over a multi-character string would garble it.
84
+ * Every armed call disarms.
90
85
  */
91
86
  applyStickyCtrl(text: string): string;
92
87
  /** Programmatically arm/disarm Ctrl. Updates the toolbar button visuals (`.armed` class + `aria-pressed`) + fires `onCtrlChange`. */
@@ -98,17 +93,12 @@ export interface MobileToolbarController {
98
93
  }
99
94
 
100
95
  /**
101
- * Wire a mobile / touch toolbar of on-screen keyboard buttons (Ctrl,
102
- * arrows, Tab, Enter, Esc, plus a collapse toggle) to a vterm send
103
- * sink.
104
- *
105
- * Each button's `pointerdown` is intercepted with `preventDefault()`
106
- * so the press never fires a focus change or scroll on the host page.
107
- *
108
- * Arrow keys and Escape emit exactly what an unmodified physical key press
109
- * emits: the shared logical-key encodings live in keyboard.ts
110
- * (plainCursorKeySeq honors kitty disambiguate over DECCKM, then DECCKM's
111
- * SS3 form; plainEscapeSeq is CSI 27 u under kitty, bare ESC otherwise).
96
+ * Wire a mobile / touch toolbar of on-screen keyboard buttons (Ctrl, arrows,
97
+ * Tab, Enter, Esc, plus a collapse toggle) to a vterm send sink. Each button's
98
+ * `pointerdown` is intercepted with `preventDefault()` so the press never
99
+ * fires a focus change or scroll on the host page. Arrow keys and Escape emit
100
+ * exactly what an unmodified physical key press emits, through the shared
101
+ * encodings in keyboard.ts.
112
102
  */
113
103
  export function bindMobileToolbar(opts: BindMobileToolbarOptions): MobileToolbarController {
114
104
  const ids = { ...DEFAULT_TOOLBAR_IDS, ...opts.ids };
@@ -134,18 +124,12 @@ export function bindMobileToolbar(opts: BindMobileToolbarOptions): MobileToolbar
134
124
  return;
135
125
  }
136
126
  ctrlBtn.classList.toggle("armed", on);
137
- // Keep the ARIA toggle state in sync with the visual `.armed` class so
138
- // assistive tech announces the sticky-Ctrl button as pressed/unpressed.
139
- // The scaffold ships kb-ctrl as `aria-pressed="false"`; consumers used to
140
- // own this in their own setCtrlArmed before delegating the toolbar here.
141
127
  ctrlBtn.setAttribute("aria-pressed", on ? "true" : "false");
142
128
  }
143
129
 
144
130
  function setCtrlArmed(on: boolean): void {
145
131
  if (armed === on) {
146
- // Still update visuals defensively (e.g. after dispose was called
147
- // and someone re-armed via setCtrlArmed) — but skip the change
148
- // notification to keep onCtrlChange edge-triggered.
132
+ // Repaint without notifying, so onCtrlChange stays edge-triggered.
149
133
  paintCtrlBtn(on);
150
134
  return;
151
135
  }
@@ -156,10 +140,12 @@ export function bindMobileToolbar(opts: BindMobileToolbarOptions): MobileToolbar
156
140
 
157
141
  // Arrow buttons emit exactly what an unmodified physical arrow press emits
158
142
  // (the toolbar.test.ts equivalence fixtures pin the two paths together).
159
- // Mode bits come from the module-global state — the toolbar's source until
160
- // P3 threads per-session modes through it.
161
143
  function arrowSeq(letter: "A" | "B" | "C" | "D"): string {
162
- return plainCursorKeySeq(letter, kittyDisambiguateActive(), isApplicationCursor());
144
+ return plainCursorKeySeq(
145
+ letter,
146
+ kittyDisambiguateActive(opts.modes),
147
+ opts.modes.isApplicationCursor(),
148
+ );
163
149
  }
164
150
 
165
151
  // Listener registry so dispose() can detach them all.
@@ -225,7 +211,7 @@ export function bindMobileToolbar(opts: BindMobileToolbarOptions): MobileToolbar
225
211
  setCtrlArmed(false);
226
212
  // The shared logical-Escape encoding (CSI 27 u under kitty disambiguate,
227
213
  // bare ESC otherwise) — same single home the physical path uses.
228
- opts.send(plainEscapeSeq(kittyDisambiguateActive()));
214
+ opts.send(plainEscapeSeq(kittyDisambiguateActive(opts.modes)));
229
215
  });
230
216
 
231
217
  function applyStickyCtrl(text: string): string {
@@ -238,7 +224,7 @@ export function bindMobileToolbar(opts: BindMobileToolbarOptions): MobileToolbar
238
224
  // spec's UNSHIFTED codepoint (shift folded into the modifier),
239
225
  // byte-identical with the physical-keyboard path: Ctrl+':' is 59;6u
240
226
  // from both, never 58;5u.
241
- if (kittyDisambiguateActive()) {
227
+ if (kittyDisambiguateActive(opts.modes)) {
242
228
  const seq = kittyCtrlCharSeq(text);
243
229
  if (seq !== null) {
244
230
  return seq;