@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.
- package/README.md +121 -61
- package/jsr.json +1 -1
- package/package.json +7 -7
- package/src/connection.ts +1205 -1587
- package/src/index.ts +24 -6
- package/src/keyboard.ts +56 -133
- package/src/modes.ts +92 -166
- package/src/mouse.ts +380 -413
- package/src/render.ts +1894 -2342
- package/src/scroll.ts +225 -476
- package/src/status-stream.ts +5 -0
- package/src/store.ts +31 -1
- package/src/terminal.ts +135 -0
- package/src/toolbar.ts +29 -43
package/src/status-stream.ts
CHANGED
|
@@ -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 `
|
|
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
|
package/src/terminal.ts
ADDED
|
@@ -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.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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
|
-
//
|
|
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(
|
|
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;
|