@cplieger/web-terminal-engine 2.7.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/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
+ }
@@ -7,9 +7,11 @@
7
7
 
8
8
  import { nextBackoffDelay } from "./reconnect.js";
9
9
 
10
- /** SessionStatus is one session's current status as carried on the stream. It
11
- * mirrors the server's status event. */
12
- export interface SessionStatus {
10
+ /** SessionInfo is one session's wire shape: the JSON object the session REST
11
+ * API (GET/POST /api/sessions) returns per session. Mirrors the Go
12
+ * terminal.SessionInfo — the two are kept in lockstep by hand (single 6-field
13
+ * type; flip to wiregen if this surface grows). */
14
+ export interface SessionInfo {
13
15
  readonly id: string;
14
16
  readonly status: "working" | "idle" | "input" | "done" | "exited";
15
17
  readonly title: string;
@@ -18,8 +20,6 @@ export interface SessionStatus {
18
20
  * this instead of `title`. */
19
21
  readonly clientTitle?: string;
20
22
  readonly createdAt: string;
21
- /** true when the session is gone (closed or reaped); the consumer drops it. */
22
- readonly removed?: boolean;
23
23
  /** true once the session has emitted a genuine activity signal — OSC 9;4
24
24
  * progress (kiro-cli, Claude Code, …) or a classified OSC 9 notification.
25
25
  * Sticky for the session's life. Consumers reveal the per-tab activity dot
@@ -28,6 +28,14 @@ export interface SessionStatus {
28
28
  readonly reportsActivity?: boolean;
29
29
  }
30
30
 
31
+ /** SessionStatus is one session's current status as carried on the status
32
+ * stream (SSE): the REST wire shape plus the stream-only removal marker. It
33
+ * mirrors the server's status event. */
34
+ export interface SessionStatus extends SessionInfo {
35
+ /** true when the session is gone (closed or reaped); the consumer drops it. */
36
+ readonly removed?: boolean;
37
+ }
38
+
31
39
  /** StatusStreamCallbacks are the consumer's hooks. Declared as function-typed
32
40
  * properties (not method shorthand) so destructuring them is safe. */
33
41
  export interface StatusStreamCallbacks {
package/src/store.ts CHANGED
@@ -136,9 +136,17 @@ export class LineStore {
136
136
  return this.alt;
137
137
  }
138
138
 
139
- /** A copy of the ephemeral alt-screen grid rows. */
140
- getAltRows(): WireRun[][] {
141
- return this.altRows.map((r) => r.slice());
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
- ScreenMessage | ScrollMessage | ResumeAckMessage | ModesMessage | TitleMessage | ClipboardMessage;
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" };
@@ -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
- // Protocol: every WebSocket message the client sends to the server is a
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
- // The 0x00 prefix is the only framing convention; raw input flows
10
- // through the rest of the byte space, because terminal input never
11
- // starts a write with NUL. See the Go terminal package's frame parser
12
- // for the receiving end.
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