@cplieger/web-terminal-engine 2.8.0 → 3.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 +6 -4
- package/jsr.json +1 -1
- package/package.json +2 -1
- package/src/connection.ts +347 -72
- package/src/index.ts +8 -0
- package/src/keyboard.ts +187 -270
- package/src/modes.ts +76 -2
- package/src/mouse.ts +51 -6
- package/src/render.ts +275 -95
- package/src/scroll.ts +25 -0
- package/src/store.ts +11 -3
- package/src/toolbar.ts +270 -0
- package/src/types.ts +49 -2
- package/src/wire-binary.ts +20 -12
- package/src/wire-compatibility.ts +29 -0
- package/src/wire.ts +14 -6
package/src/scroll.ts
CHANGED
|
@@ -136,3 +136,28 @@ export function scrollToBottom(): void {
|
|
|
136
136
|
export function isUserScrolledUp(): boolean {
|
|
137
137
|
return !following;
|
|
138
138
|
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The viewport's current scroll offset, for a consumer keeping per-view
|
|
142
|
+
* scroll memory (a tabbed shell saving the position of the tab it is
|
|
143
|
+
* leaving). Pairs with restoreScrollTop. This module owns the container's
|
|
144
|
+
* scrollTop; consumers read it through this seam rather than the DOM element
|
|
145
|
+
* so an engine-side change to the scroll geometry cannot silently break them.
|
|
146
|
+
*/
|
|
147
|
+
export function currentScrollTop(): number {
|
|
148
|
+
return scrollEl ? scrollEl.scrollTop : 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Restore a previously saved scroll offset (per-view scroll memory: a tabbed
|
|
153
|
+
* shell re-entering a tab whose user had scrolled up to read). The offset is
|
|
154
|
+
* applied verbatim; the follow/hold state then re-derives from the resulting
|
|
155
|
+
* scroll event exactly as it does for a user scroll, so a restored read
|
|
156
|
+
* position holds and a restored at-bottom position re-engages following.
|
|
157
|
+
*/
|
|
158
|
+
export function restoreScrollTop(top: number): void {
|
|
159
|
+
if (!scrollEl) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
scrollEl.scrollTop = top;
|
|
163
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -136,9 +136,17 @@ export class LineStore {
|
|
|
136
136
|
return this.alt;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
/**
|
|
140
|
-
|
|
141
|
-
|
|
139
|
+
/**
|
|
140
|
+
* The ephemeral alt-screen grid rows, as a read-only view of the store's
|
|
141
|
+
* internal arrays (no copy: the pre-2026-07 deep copy allocated the whole
|
|
142
|
+
* grid on every flush while a full-screen TUI was active — measured at real
|
|
143
|
+
* cost in the render bench). Row identity is meaningful: applyScreen
|
|
144
|
+
* replaces exactly the changed rows' arrays and leaves unchanged rows
|
|
145
|
+
* reference-identical, which is what lets the renderer reconcile the alt
|
|
146
|
+
* grid row-by-row instead of rebuilding it (renderAlt).
|
|
147
|
+
*/
|
|
148
|
+
getAltRows(): readonly (readonly WireRun[])[] {
|
|
149
|
+
return this.altRows;
|
|
142
150
|
}
|
|
143
151
|
|
|
144
152
|
/**
|
package/src/toolbar.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// On-screen mobile keyboard toolbar: a DOM widget (button lookup, ARIA
|
|
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.
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
ctrlByteFor,
|
|
17
|
+
kittyCtrlCharSeq,
|
|
18
|
+
kittyDisambiguateActive,
|
|
19
|
+
plainCursorKeySeq,
|
|
20
|
+
plainEscapeSeq,
|
|
21
|
+
} from "./keyboard.js";
|
|
22
|
+
import { isApplicationCursor } from "./modes.js";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Default DOM ids for the on-screen mobile keyboard toolbar buttons,
|
|
26
|
+
* inside the toolbar container element passed to `bindMobileToolbar`.
|
|
27
|
+
* Override individual ids via `BindMobileToolbarOptions.ids`.
|
|
28
|
+
*/
|
|
29
|
+
export const DEFAULT_TOOLBAR_IDS = {
|
|
30
|
+
toggle: "kb-toggle",
|
|
31
|
+
ctrl: "kb-ctrl",
|
|
32
|
+
up: "kb-up",
|
|
33
|
+
down: "kb-down",
|
|
34
|
+
left: "kb-left",
|
|
35
|
+
right: "kb-right",
|
|
36
|
+
tab: "kb-tab",
|
|
37
|
+
enter: "kb-enter",
|
|
38
|
+
esc: "kb-esc",
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
/** Per-button id overrides for `bindMobileToolbar`. */
|
|
42
|
+
export interface MobileToolbarIds {
|
|
43
|
+
readonly toggle?: string;
|
|
44
|
+
readonly ctrl?: string;
|
|
45
|
+
readonly up?: string;
|
|
46
|
+
readonly down?: string;
|
|
47
|
+
readonly left?: string;
|
|
48
|
+
readonly right?: string;
|
|
49
|
+
readonly tab?: string;
|
|
50
|
+
readonly enter?: string;
|
|
51
|
+
readonly esc?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Options for `bindMobileToolbar`. */
|
|
55
|
+
export interface BindMobileToolbarOptions {
|
|
56
|
+
/**
|
|
57
|
+
* Toolbar container element. Buttons are looked up by id within this
|
|
58
|
+
* element via `querySelector('#<id>')`. The same element is the one
|
|
59
|
+
* `kb-toggle` adds/removes the `.collapsed` class on.
|
|
60
|
+
*/
|
|
61
|
+
readonly toolbar: HTMLElement;
|
|
62
|
+
/**
|
|
63
|
+
* Send sink for arrow keys, Tab, Enter, Esc — i.e. for everything
|
|
64
|
+
* the toolbar emits. Sticky-Ctrl input goes through `applyStickyCtrl`
|
|
65
|
+
* on the controller; the consumer is responsible for routing
|
|
66
|
+
* keyboard / paste / IME text through it before handing the result
|
|
67
|
+
* to the same `send`.
|
|
68
|
+
*/
|
|
69
|
+
readonly send: (bytes: string) => void;
|
|
70
|
+
/** Optional id overrides. Missing keys fall back to defaults. */
|
|
71
|
+
readonly ids?: MobileToolbarIds;
|
|
72
|
+
/**
|
|
73
|
+
* Fired whenever the sticky-Ctrl state changes (toolbar press,
|
|
74
|
+
* `setCtrlArmed`, or auto-disarm after applying a Ctrl byte).
|
|
75
|
+
*/
|
|
76
|
+
readonly onCtrlChange?: (armed: boolean) => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Returned from `bindMobileToolbar`; manages sticky-Ctrl state and tear-down. */
|
|
80
|
+
export interface MobileToolbarController {
|
|
81
|
+
/**
|
|
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.
|
|
90
|
+
*/
|
|
91
|
+
applyStickyCtrl(text: string): string;
|
|
92
|
+
/** Programmatically arm/disarm Ctrl. Updates the toolbar button visuals (`.armed` class + `aria-pressed`) + fires `onCtrlChange`. */
|
|
93
|
+
setCtrlArmed(on: boolean): void;
|
|
94
|
+
/** Whether sticky-Ctrl is currently armed. */
|
|
95
|
+
isCtrlArmed(): boolean;
|
|
96
|
+
/** Detach all event listeners and reset the toolbar to a non-armed state. Idempotent. */
|
|
97
|
+
dispose(): void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
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).
|
|
112
|
+
*/
|
|
113
|
+
export function bindMobileToolbar(opts: BindMobileToolbarOptions): MobileToolbarController {
|
|
114
|
+
const ids = { ...DEFAULT_TOOLBAR_IDS, ...opts.ids };
|
|
115
|
+
let armed = false;
|
|
116
|
+
|
|
117
|
+
function findBtn(id: string): HTMLElement | null {
|
|
118
|
+
// IDs are kebab-case ASCII; no CSS escaping needed.
|
|
119
|
+
return opts.toolbar.querySelector<HTMLElement>(`#${id}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const toggleBtn = findBtn(ids.toggle);
|
|
123
|
+
const ctrlBtn = findBtn(ids.ctrl);
|
|
124
|
+
const upBtn = findBtn(ids.up);
|
|
125
|
+
const downBtn = findBtn(ids.down);
|
|
126
|
+
const leftBtn = findBtn(ids.left);
|
|
127
|
+
const rightBtn = findBtn(ids.right);
|
|
128
|
+
const tabBtn = findBtn(ids.tab);
|
|
129
|
+
const enterBtn = findBtn(ids.enter);
|
|
130
|
+
const escBtn = findBtn(ids.esc);
|
|
131
|
+
|
|
132
|
+
function paintCtrlBtn(on: boolean): void {
|
|
133
|
+
if (!ctrlBtn) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
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
|
+
ctrlBtn.setAttribute("aria-pressed", on ? "true" : "false");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function setCtrlArmed(on: boolean): void {
|
|
145
|
+
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.
|
|
149
|
+
paintCtrlBtn(on);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
armed = on;
|
|
153
|
+
paintCtrlBtn(on);
|
|
154
|
+
opts.onCtrlChange?.(on);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Arrow buttons emit exactly what an unmodified physical arrow press emits
|
|
158
|
+
// (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
|
+
function arrowSeq(letter: "A" | "B" | "C" | "D"): string {
|
|
162
|
+
return plainCursorKeySeq(letter, kittyDisambiguateActive(), isApplicationCursor());
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Listener registry so dispose() can detach them all.
|
|
166
|
+
const cleanups: (() => void)[] = [];
|
|
167
|
+
function on(el: HTMLElement | null, handler: (e: PointerEvent) => void): void {
|
|
168
|
+
if (!el) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const wrapped = (e: Event): void => {
|
|
172
|
+
handler(e as PointerEvent);
|
|
173
|
+
};
|
|
174
|
+
el.addEventListener("pointerdown", wrapped);
|
|
175
|
+
cleanups.push(() => {
|
|
176
|
+
el.removeEventListener("pointerdown", wrapped);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
on(toggleBtn, (e) => {
|
|
181
|
+
e.preventDefault();
|
|
182
|
+
// Show/hide the toolbar. Deliberately does NOT clear armed Ctrl —
|
|
183
|
+
// toggling visibility shouldn't lose state.
|
|
184
|
+
opts.toolbar.classList.toggle("collapsed");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
on(ctrlBtn, (e) => {
|
|
188
|
+
e.preventDefault();
|
|
189
|
+
setCtrlArmed(!armed);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
on(upBtn, (e) => {
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
setCtrlArmed(false);
|
|
195
|
+
opts.send(arrowSeq("A"));
|
|
196
|
+
});
|
|
197
|
+
on(downBtn, (e) => {
|
|
198
|
+
e.preventDefault();
|
|
199
|
+
setCtrlArmed(false);
|
|
200
|
+
opts.send(arrowSeq("B"));
|
|
201
|
+
});
|
|
202
|
+
on(rightBtn, (e) => {
|
|
203
|
+
e.preventDefault();
|
|
204
|
+
setCtrlArmed(false);
|
|
205
|
+
opts.send(arrowSeq("C"));
|
|
206
|
+
});
|
|
207
|
+
on(leftBtn, (e) => {
|
|
208
|
+
e.preventDefault();
|
|
209
|
+
setCtrlArmed(false);
|
|
210
|
+
opts.send(arrowSeq("D"));
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
on(tabBtn, (e) => {
|
|
214
|
+
e.preventDefault();
|
|
215
|
+
setCtrlArmed(false);
|
|
216
|
+
opts.send("\t");
|
|
217
|
+
});
|
|
218
|
+
on(enterBtn, (e) => {
|
|
219
|
+
e.preventDefault();
|
|
220
|
+
setCtrlArmed(false);
|
|
221
|
+
opts.send("\r");
|
|
222
|
+
});
|
|
223
|
+
on(escBtn, (e) => {
|
|
224
|
+
e.preventDefault();
|
|
225
|
+
setCtrlArmed(false);
|
|
226
|
+
// The shared logical-Escape encoding (CSI 27 u under kitty disambiguate,
|
|
227
|
+
// bare ESC otherwise) — same single home the physical path uses.
|
|
228
|
+
opts.send(plainEscapeSeq(kittyDisambiguateActive()));
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
function applyStickyCtrl(text: string): string {
|
|
232
|
+
if (!armed) {
|
|
233
|
+
return text;
|
|
234
|
+
}
|
|
235
|
+
if (text.length === 1) {
|
|
236
|
+
setCtrlArmed(false);
|
|
237
|
+
// Under kitty disambiguate, Ctrl+char is the CSI-u form with the
|
|
238
|
+
// spec's UNSHIFTED codepoint (shift folded into the modifier),
|
|
239
|
+
// byte-identical with the physical-keyboard path: Ctrl+':' is 59;6u
|
|
240
|
+
// from both, never 58;5u.
|
|
241
|
+
if (kittyDisambiguateActive()) {
|
|
242
|
+
const seq = kittyCtrlCharSeq(text);
|
|
243
|
+
if (seq !== null) {
|
|
244
|
+
return seq;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const ctrl = ctrlByteFor(text);
|
|
248
|
+
return ctrl ?? text;
|
|
249
|
+
}
|
|
250
|
+
// Multi-char input (paste, IME commit) — applying Ctrl to a string
|
|
251
|
+
// would garble it. Disarm and pass through verbatim.
|
|
252
|
+
setCtrlArmed(false);
|
|
253
|
+
return text;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function dispose(): void {
|
|
257
|
+
setCtrlArmed(false);
|
|
258
|
+
for (const c of cleanups) {
|
|
259
|
+
c();
|
|
260
|
+
}
|
|
261
|
+
cleanups.length = 0;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
applyStickyCtrl,
|
|
266
|
+
setCtrlArmed,
|
|
267
|
+
isCtrlArmed: () => armed,
|
|
268
|
+
dispose,
|
|
269
|
+
};
|
|
270
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -108,6 +108,40 @@ export interface ResumeAckMessage {
|
|
|
108
108
|
* shows a "history trimmed" marker rather than misaligning.
|
|
109
109
|
*/
|
|
110
110
|
oldestIndex?: number;
|
|
111
|
+
/**
|
|
112
|
+
* The server's wire-protocol revision (third length-gated tail, absent on
|
|
113
|
+
* older servers). A value OUTSIDE the client's supported range means one
|
|
114
|
+
* side runs a stale bundle/binary; the connection module warns and fires
|
|
115
|
+
* `onWireVersionMismatch` so the UI can offer a reload. Versions inside
|
|
116
|
+
* the range are normal pairings (a v3 server just means the socket stays
|
|
117
|
+
* in sentinel mode); a value >= 4 additionally triggers the typed-framing
|
|
118
|
+
* upgrade (docs/wire-v4-typed-framing.md).
|
|
119
|
+
*/
|
|
120
|
+
serverWireVersion?: number;
|
|
121
|
+
/**
|
|
122
|
+
* Set when the resume key missed the server's registry while this client
|
|
123
|
+
* claimed sent bytes: the input ledger was reclaimed (idle GC / cap
|
|
124
|
+
* eviction), so the server cannot vouch for any previously sent input.
|
|
125
|
+
* The connection module responds deterministically with its designed loss
|
|
126
|
+
* semantic — drop the outbox and notify (`onServerRestart`) — instead of
|
|
127
|
+
* replaying possibly-duplicate input. Absent on older servers (the
|
|
128
|
+
* `received === 0 && bytesAcked > 0` heuristic remains as their fallback).
|
|
129
|
+
*/
|
|
130
|
+
ledgerLost?: boolean;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Bare input-ack frame. Sent from the server's flush tick when input was
|
|
135
|
+
* applied but no content frame carried the advanced ack within the tick
|
|
136
|
+
* (e.g. typing into a no-echo `read -s`), so outbox trimming never depends
|
|
137
|
+
* on the app producing output. Transport-internal: the connection module
|
|
138
|
+
* consumes it and does not forward it to `onMessage`.
|
|
139
|
+
*/
|
|
140
|
+
export interface AckOnlyMessage {
|
|
141
|
+
/** Discriminator — always `"ackOnly"`. */
|
|
142
|
+
type: "ackOnly";
|
|
143
|
+
/** Server-confirmed bytesReceived for the input ACK protocol. */
|
|
144
|
+
inputAck: number;
|
|
111
145
|
}
|
|
112
146
|
|
|
113
147
|
/**
|
|
@@ -173,7 +207,13 @@ export interface ClipboardMessage {
|
|
|
173
207
|
|
|
174
208
|
/** Discriminated union of all messages the server can send to the client. */
|
|
175
209
|
export type ServerMessage =
|
|
176
|
-
|
|
210
|
+
| ScreenMessage
|
|
211
|
+
| ScrollMessage
|
|
212
|
+
| ResumeAckMessage
|
|
213
|
+
| ModesMessage
|
|
214
|
+
| TitleMessage
|
|
215
|
+
| ClipboardMessage
|
|
216
|
+
| AckOnlyMessage;
|
|
177
217
|
|
|
178
218
|
/**
|
|
179
219
|
* Discriminated union of all control messages the client can send to the
|
|
@@ -188,4 +228,11 @@ export type ControlMessage =
|
|
|
188
228
|
haveThrough: number;
|
|
189
229
|
protocolVersion: number;
|
|
190
230
|
}
|
|
191
|
-
| { type: "ping" }
|
|
231
|
+
| { type: "ping" }
|
|
232
|
+
/**
|
|
233
|
+
* v4 typed-framing transition (docs/wire-v4-typed-framing.md §4): the first
|
|
234
|
+
* TEXT message a client sends after the resumeAck proves the server speaks
|
|
235
|
+
* wire revision >= 4. Receiving it latches the server's connection to typed
|
|
236
|
+
* mode (text = control, binary = full-alphabet PTY input); otherwise a no-op.
|
|
237
|
+
*/
|
|
238
|
+
| { type: "upgrade" };
|
package/src/wire-binary.ts
CHANGED
|
@@ -22,19 +22,8 @@ const MSG_MODES = 3;
|
|
|
22
22
|
const MSG_TITLE = 4;
|
|
23
23
|
const MSG_PONG = 5;
|
|
24
24
|
const MSG_CLIPBOARD = 6;
|
|
25
|
+
const MSG_ACK_ONLY = 7;
|
|
25
26
|
|
|
26
|
-
/**
|
|
27
|
-
* Wire protocol version. Sent by the client in the `resume` control message so
|
|
28
|
-
* the server can detect a client built against a different protocol revision
|
|
29
|
-
* (e.g. a stale cached bundle after a breaking change) and warn rather than
|
|
30
|
-
* silently mis-decode. Bump on any breaking change to the binary frame layout
|
|
31
|
-
* or control-message shape. Mirrors `wireProtocolVersion` in wire_binary.go.
|
|
32
|
-
*
|
|
33
|
-
* v3: the modes frame gained a trailing kbdFlags byte (kitty keyboard
|
|
34
|
-
* protocol). Decoding is tolerant of a pre-v3 server (kbdFlags defaults to 0
|
|
35
|
-
* when the byte is absent), so the bump only drives the mismatch warning.
|
|
36
|
-
*/
|
|
37
|
-
export const WIRE_PROTOCOL_VERSION = 3;
|
|
38
27
|
const MODE_FLAG_BRACKETED_PASTE = 1;
|
|
39
28
|
const MODE_FLAG_APP_CURSOR_KEYS = 2;
|
|
40
29
|
const MODE_FLAG_MOUSE_SGR = 4;
|
|
@@ -154,9 +143,12 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
|
|
|
154
143
|
// Optional tails, length-gated for back-compat with older servers:
|
|
155
144
|
// >= 17 bytes: + serverEpoch (restart detection)
|
|
156
145
|
// >= 33 bytes: + committed + oldestIndex (resume gap detection)
|
|
146
|
+
// >= 35 bytes: + serverWireVersion + ackFlags (bit0 = ledgerLost)
|
|
157
147
|
let serverEpoch: number | undefined;
|
|
158
148
|
let committed: number | undefined;
|
|
159
149
|
let oldestIndex: number | undefined;
|
|
150
|
+
let serverWireVersion: number | undefined;
|
|
151
|
+
let ledgerLost: boolean | undefined;
|
|
160
152
|
if (buf.byteLength >= 17) {
|
|
161
153
|
serverEpoch = c.u64();
|
|
162
154
|
}
|
|
@@ -164,6 +156,11 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
|
|
|
164
156
|
committed = c.u64();
|
|
165
157
|
oldestIndex = c.u64();
|
|
166
158
|
}
|
|
159
|
+
if (buf.byteLength >= 35) {
|
|
160
|
+
serverWireVersion = c.u8();
|
|
161
|
+
const ackFlags = c.u8();
|
|
162
|
+
ledgerLost = (ackFlags & 1) !== 0;
|
|
163
|
+
}
|
|
167
164
|
const msg: ResumeAckMessage = { type: "resumeAck", received: inputAck };
|
|
168
165
|
if (serverEpoch !== undefined) {
|
|
169
166
|
msg.serverEpoch = serverEpoch;
|
|
@@ -174,8 +171,19 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
|
|
|
174
171
|
if (oldestIndex !== undefined) {
|
|
175
172
|
msg.oldestIndex = oldestIndex;
|
|
176
173
|
}
|
|
174
|
+
if (serverWireVersion !== undefined) {
|
|
175
|
+
msg.serverWireVersion = serverWireVersion;
|
|
176
|
+
}
|
|
177
|
+
if (ledgerLost !== undefined) {
|
|
178
|
+
msg.ledgerLost = ledgerLost;
|
|
179
|
+
}
|
|
177
180
|
return msg;
|
|
178
181
|
}
|
|
182
|
+
if (msgType === MSG_ACK_ONLY) {
|
|
183
|
+
// Bare ack: header only (type + inputAck). Applied to the outbox by the
|
|
184
|
+
// connection module and never forwarded to consumers.
|
|
185
|
+
return { type: "ackOnly", inputAck };
|
|
186
|
+
}
|
|
179
187
|
if (msgType === MSG_SCREEN) {
|
|
180
188
|
// Sparse row array: the frame carries only changed rows (indexed by
|
|
181
189
|
// row_idx). The client allocates a full screenHeight-sized array but
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Public wire-compatibility metadata for the independently released browser
|
|
2
|
+
// client. The Go server exposes the complementary directional floor from its
|
|
3
|
+
// terminal package. Keep both current revisions equal; their minimum peer
|
|
4
|
+
// revisions may diverge as each receiver retires old decode paths.
|
|
5
|
+
|
|
6
|
+
/** Current wire revision emitted by this browser client. */
|
|
7
|
+
export const WIRE_PROTOCOL_VERSION = 4;
|
|
8
|
+
|
|
9
|
+
/** Oldest explicitly declared server revision this client accepts. */
|
|
10
|
+
export const MIN_SUPPORTED_SERVER_WIRE_VERSION = 3;
|
|
11
|
+
|
|
12
|
+
/** Definitive private WebSocket close code for an incompatible declared peer. */
|
|
13
|
+
export const WIRE_INCOMPATIBLE_CLOSE_CODE = 4002;
|
|
14
|
+
|
|
15
|
+
/** Machine-readable compatibility metadata shipped in every TS release. */
|
|
16
|
+
export const WIRE_COMPATIBILITY = Object.freeze({
|
|
17
|
+
protocolVersion: WIRE_PROTOCOL_VERSION,
|
|
18
|
+
minimumServerProtocolVersion: MIN_SUPPORTED_SERVER_WIRE_VERSION,
|
|
19
|
+
incompatibleCloseCode: WIRE_INCOMPATIBLE_CLOSE_CODE,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/** Details surfaced when the connection is stopped for wire incompatibility. */
|
|
23
|
+
export interface WireIncompatibility {
|
|
24
|
+
readonly source: "server-version" | "server-close";
|
|
25
|
+
readonly clientVersion: number;
|
|
26
|
+
readonly minimumServerVersion: number;
|
|
27
|
+
readonly serverVersion?: number;
|
|
28
|
+
readonly reason: string;
|
|
29
|
+
}
|
package/src/wire.ts
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
// Pure wire-format helpers for the client → server WebSocket protocol.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// binary frame whose first byte tags the message type:
|
|
3
|
+
// v3 framing (the bootstrap/fallback encoding): every client→server message
|
|
4
|
+
// is a binary frame whose first byte tags the message type:
|
|
5
5
|
//
|
|
6
6
|
// 0x00 control message (JSON-encoded {type, ...} payload)
|
|
7
7
|
// any raw terminal input bytes
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
9
|
+
// Disambiguation rule (amended with the parse-fallback): a solitary [0x00],
|
|
10
|
+
// and any 0x00-leading frame that is not a valid control message, are literal
|
|
11
|
+
// terminal input; 0x00 + valid control JSON is the reserved control channel.
|
|
12
|
+
// The client additionally never emits a multi-byte 0x00-leading input frame
|
|
13
|
+
// in v3 mode (sendInputFrames splits leading NULs into solitary frames), so
|
|
14
|
+
// in practice the reserved channel only ever carries real controls.
|
|
15
|
+
//
|
|
16
|
+
// v4 framing (docs/wire-v4-typed-framing.md) retires the sentinel once a
|
|
17
|
+
// socket upgrades: control messages travel as WebSocket TEXT frames and
|
|
18
|
+
// binary frames are full-alphabet PTY input. This module's controlFrame stays
|
|
19
|
+
// the encoding for the v3 bootstrap (every socket's first resume) and for
|
|
20
|
+
// v3-mode sockets (older servers).
|
|
13
21
|
//
|
|
14
22
|
// Pulled into a dedicated module so unit tests can exercise the framing
|
|
15
23
|
// without spinning up a WebSocket. Pure: no DOM, no WebSocket, no
|