@cplieger/web-terminal-engine 5.3.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 +1 -1
- package/src/connection.ts +1205 -1676
- package/src/index.ts +20 -100
- package/src/keyboard.ts +56 -133
- package/src/modes.ts +92 -166
- package/src/mouse.ts +380 -413
- package/src/render.ts +1894 -2355
- package/src/scroll.ts +225 -476
- package/src/store.ts +1 -1
- package/src/terminal.ts +135 -0
- package/src/toolbar.ts +29 -43
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 `
|
|
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
|
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;
|