@cplieger/web-terminal-engine 5.2.0 → 6.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.
@@ -139,6 +139,11 @@ export interface EventSourceLike {
139
139
  /** EventSourceFactory builds an EventSourceLike for a URL. */
140
140
  export type EventSourceFactory = (url: string) => EventSourceLike;
141
141
 
142
+ /** StatusStream is the handle connectStatusStream returns: the consumer's only
143
+ * hold on a live stream, and close is the whole of it — everything else the
144
+ * stream does is reported through the callbacks. Closing is final and there is
145
+ * no reopen; it cancels a scheduled reconnect as well as the current stream, so
146
+ * a consumer that wants status back calls connectStatusStream again. */
142
147
  export interface StatusStream {
143
148
  /** Closes the stream and stops reconnection. */
144
149
  close: () => void;
package/src/store.ts CHANGED
@@ -309,6 +309,36 @@ function rowEqual(a: WireRun[], b: WireRun[]): boolean {
309
309
  return true;
310
310
  }
311
311
 
312
+ /**
313
+ * The client's model of one terminal: lines keyed by absolute index, the live
314
+ * screen window sliding along the tail, and the change set a renderer drains each
315
+ * frame. Holds no DOM and reads no globals, so a consumer may own several (one
316
+ * per tab) and bind whichever is visible.
317
+ *
318
+ * Applying is idempotent by absolute index, which is what makes re-delivery —
319
+ * resume replay, a doubled frame from a zombie socket — incapable of duplicating
320
+ * a row. The apply methods enforce the store's invariants themselves and refuse
321
+ * rather than throw: a frame that violates one is dropped, so a malformed or
322
+ * misordered stream degrades to missing content, never to an exception in the
323
+ * middle of a render. A drop is not reported either, so a caller that needs to
324
+ * know what the store holds asks it (oldestIndex, highestIndex, retainedRanges)
325
+ * instead of tracking what it sent.
326
+ *
327
+ * What the caller must hold up:
328
+ *
329
+ * - Feed the store every frame the server sends, in arrival order, and pass
330
+ * `viewportAbs`/`following` where a method asks for them. The store never
331
+ * infers where the reader is; the renderer is the only layer that knows.
332
+ * - Drain with drainChanges once per frame and apply the result. Nothing is
333
+ * re-reported, so a skipped drain loses the notification, not the content.
334
+ * - Treat every reader (getLine, forEachLine, getAltRows) as a view onto live
335
+ * state: the runs are the store's own, valid until the next apply, and are not
336
+ * to be mutated.
337
+ *
338
+ * Residency is a HISTORY budget floored at the live window, so a small cap keeps
339
+ * the full screen and simply retains no scrollback; the constructor documents the
340
+ * difference between supplying one and letting the engine choose.
341
+ */
312
342
  export class LineStore {
313
343
  private lines = new Map<number, WireRun[]>();
314
344
  private oldest = -1; // lowest retained absolute index (-1 = empty)
@@ -1951,7 +1981,7 @@ export class LineStore {
1951
1981
  // `cursor > winBottom` cannot hold there either. That is the ONLY thing
1952
1982
  // holding when the target is 0, which `new LineStore(0)` produces
1953
1983
  // (`evictionBatch(0)` is 1, so `target = 0 - 1 + 1`; the bare
1954
- // constructor accepts 0, unlike `render.init` and `fromSnapshot`, which
1984
+ // constructor accepts 0, unlike `createRenderer` and `fromSnapshot`, which
1955
1985
  // both require a positive integer) — a reason to leave that conjunct
1956
1986
  // alone.
1957
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;