@cplieger/web-terminal-engine 1.2.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 ADDED
@@ -0,0 +1,103 @@
1
+ // Scroll controller: the single owner of the scroll container's scrollTop.
2
+ //
3
+ // One piece of state: `following`. The user is "following" when the viewport is
4
+ // at (or within a small tolerance of) the bottom; otherwise they have scrolled
5
+ // up to read and are "holding". The state is derived purely from scroll
6
+ // position on every scroll event — there is no debounce window, no suppress
7
+ // timer, and no programmatic-vs-user flag. That heuristic soup (a 100px
8
+ // tolerance, a 150ms debounce, a 60-second touch window) was the source of the
9
+ // view-jumping and scroll-interruption bugs (the legacy heuristic-soup
10
+ // failure mode; see the #web-terminal-engine steering doc, "Design rationale").
11
+ //
12
+ // The renderer calls stickToBottom() once after each flush: if following, pin
13
+ // to the new bottom; if holding, do nothing and let native scroll anchoring
14
+ // (overflow-anchor) hold the reading position when history is inserted above.
15
+ // Appending content at the bottom does not fire a scroll event, so following
16
+ // stays true across new output and the post-flush pin lands correctly. Pinning
17
+ // to the bottom produces a scroll event whose recomputation yields
18
+ // following=true again (no churn). Scrolling up past the tolerance flips to
19
+ // holding; scrolling back flips to following.
20
+
21
+ const BOTTOM_TOLERANCE_PX = 24;
22
+
23
+ let scrollEl: HTMLElement | null = null;
24
+ let following = true;
25
+ let onFollowChange: ((scrolledUp: boolean) => void) | null = null;
26
+ let scrollHandler: (() => void) | null = null;
27
+
28
+ function distanceFromBottom(): number {
29
+ if (!scrollEl) {
30
+ return 0;
31
+ }
32
+ return scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight;
33
+ }
34
+
35
+ function atBottom(): boolean {
36
+ return distanceFromBottom() <= BOTTOM_TOLERANCE_PX;
37
+ }
38
+
39
+ function setFollowing(next: boolean): void {
40
+ if (next === following) {
41
+ return;
42
+ }
43
+ following = next;
44
+ if (onFollowChange) {
45
+ onFollowChange(!following);
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Initialize the scroll controller on the scroll container. The optional
51
+ * callback fires whenever the follow state toggles (its argument is true when
52
+ * the user has scrolled up / disengaged auto-follow).
53
+ *
54
+ * @param opts.scrollEl Element whose scroll position is observed and owned.
55
+ * @param opts.onUserScrollChange Optional callback fired on follow/hold toggle.
56
+ */
57
+ export function init(opts: {
58
+ scrollEl: HTMLElement;
59
+ onUserScrollChange?: (scrolledUp: boolean) => void;
60
+ }): void {
61
+ // Detach any prior listener (re-init in tests / re-mount).
62
+ if (scrollEl && scrollHandler) {
63
+ scrollEl.removeEventListener("scroll", scrollHandler);
64
+ }
65
+ scrollEl = opts.scrollEl;
66
+ onFollowChange = opts.onUserScrollChange ?? null;
67
+ following = true;
68
+ scrollHandler = () => {
69
+ setFollowing(atBottom());
70
+ };
71
+ scrollEl.addEventListener("scroll", scrollHandler, { passive: true });
72
+ }
73
+
74
+ /**
75
+ * Pin the viewport to the bottom iff the user is following. Called by the
76
+ * renderer after each flush. A no-op when holding (scrolled up) or already at
77
+ * the bottom, so it never fights the user and never scrolls redundantly.
78
+ */
79
+ export function stickToBottom(): void {
80
+ if (!scrollEl || !following) {
81
+ return;
82
+ }
83
+ if (distanceFromBottom() > 0) {
84
+ scrollEl.scrollTop = scrollEl.scrollHeight;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Force scroll to the bottom and re-engage following. Used by the explicit
90
+ * jump-to-bottom control.
91
+ */
92
+ export function scrollToBottom(): void {
93
+ if (!scrollEl) {
94
+ return;
95
+ }
96
+ scrollEl.scrollTop = scrollEl.scrollHeight;
97
+ setFollowing(true);
98
+ }
99
+
100
+ /** Whether the user has scrolled away from the bottom (auto-follow disengaged). */
101
+ export function isUserScrolledUp(): boolean {
102
+ return !following;
103
+ }
package/src/store.ts ADDED
@@ -0,0 +1,389 @@
1
+ // Absolute-index line store: the client's authoritative model of the
2
+ // terminal. One buffer keyed by absolute line index, with the live screen
3
+ // window sliding along it. This is the data model that resolves the
4
+ // live/history split (see the #web-terminal-engine steering doc, "Design
5
+ // rationale"): there is no separate
6
+ // "live zone" and "scrollback" here, only lines addressed by absolute index,
7
+ // the last `height` of which happen to still be changing.
8
+ //
9
+ // The store is pure (no DOM). The renderer (render.ts) reads from it and
10
+ // reflects changes to the DOM; the connection layer feeds decoded wire
11
+ // messages into it. Applying a line is idempotent by absolute index, which is
12
+ // what makes re-delivery (resume replay, fast-burst re-send, a doubled frame
13
+ // from a zombie socket) incapable of duplicating a row.
14
+
15
+ import type { ScreenMessage, ScrollMessage, WireRun } from "./types.js";
16
+
17
+ /** Maximum lines retained client-side. Older lines are evicted from the top. */
18
+ export const MAX_LINES = 5000;
19
+
20
+ /** The live screen window: a fixed `height`-row block at the tail of the buffer. */
21
+ export interface WindowState {
22
+ /** Absolute index of window row 0. */
23
+ base: number;
24
+ /** Number of rows in the window (terminal height). */
25
+ height: number;
26
+ /** Cursor row within the window (0..height-1). */
27
+ cursorRow: number;
28
+ /** Cursor column within the window. */
29
+ cursorCol: number;
30
+ /** DECSCUSR cursor style (0-6). */
31
+ cursorStyle: number;
32
+ /** Cursor hidden (DECTCEM off). */
33
+ cursorHidden: boolean;
34
+ /** Cursor blinking. */
35
+ cursorBlink: boolean;
36
+ }
37
+
38
+ /** What changed since the last drain, for the renderer to apply to the DOM. */
39
+ export interface StoreChanges {
40
+ /** Absolute indices whose content changed (need a DOM row build/update). */
41
+ dirtyLines: number[];
42
+ /** Absolute indices removed from the store (need their DOM row dropped). */
43
+ evictedLines: number[];
44
+ /** The window descriptor or cursor changed. */
45
+ windowChanged: boolean;
46
+ /** The alternate-screen grid or its active state changed. */
47
+ altChanged: boolean;
48
+ /** A full reset happened (server restart): the renderer must wipe all rows. */
49
+ fullReset: boolean;
50
+ }
51
+
52
+ function emptyWindow(): WindowState {
53
+ return {
54
+ base: 0,
55
+ height: 0,
56
+ cursorRow: 0,
57
+ cursorCol: 0,
58
+ cursorStyle: 0,
59
+ cursorHidden: false,
60
+ cursorBlink: false,
61
+ };
62
+ }
63
+
64
+ /** Deep-equality for two style runs (all wire fields). */
65
+ function runEqual(a: WireRun, b: WireRun): boolean {
66
+ return a.t === b.t && a.f === b.f && a.b === b.b && a.a === b.a && a.uc === b.uc && a.u === b.u;
67
+ }
68
+
69
+ /** Deep-equality for two rows of runs. */
70
+ function rowEqual(a: WireRun[], b: WireRun[]): boolean {
71
+ if (a.length !== b.length) {
72
+ return false;
73
+ }
74
+ for (let i = 0; i < a.length; i++) {
75
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- index < length
76
+ if (!runEqual(a[i]!, b[i]!)) {
77
+ return false;
78
+ }
79
+ }
80
+ return true;
81
+ }
82
+
83
+ export class LineStore {
84
+ private lines = new Map<number, WireRun[]>();
85
+ private oldest = -1; // lowest retained absolute index (-1 = empty)
86
+ private highest = -1; // highest retained absolute index (-1 = empty)
87
+ private everEvictedThrough = -1; // highest absolute index ever evicted; lines <= this are stale
88
+ private serverOldest = -1; // oldest index the server still retains (from resumeAck); for trim marker
89
+
90
+ private win: WindowState = emptyWindow();
91
+ private alt = false;
92
+ private altRows: WireRun[][] = [];
93
+
94
+ // Change tracking, drained by the renderer each frame.
95
+ private dirty = new Set<number>();
96
+ private evicted = new Set<number>();
97
+ private windowDirty = false;
98
+ private altDirty = false;
99
+ private resetPending = false;
100
+
101
+ /**
102
+ * @param maxLines retained-line cap; defaults to MAX_LINES. Injectable so
103
+ * eviction is testable without allocating thousands of rows.
104
+ */
105
+ constructor(private readonly maxLines: number = MAX_LINES) {}
106
+
107
+ /** Highest absolute index held, or -1 if empty. Used as resume `haveThrough`. */
108
+ highestIndex(): number {
109
+ return this.highest;
110
+ }
111
+
112
+ /** Lowest absolute index held, or -1 if empty. */
113
+ oldestIndex(): number {
114
+ return this.oldest;
115
+ }
116
+
117
+ /**
118
+ * True if history older than what the store holds was trimmed (evicted
119
+ * client-side, or the server reported it no longer retains it). The
120
+ * renderer shows a "history trimmed" marker at the top in this case.
121
+ */
122
+ hasTrimmedHistory(): boolean {
123
+ if (this.oldest > 0 && this.everEvictedThrough >= 0) {
124
+ return true; // we evicted the oldest lines ourselves
125
+ }
126
+ return this.serverOldest > 0 && this.oldest >= this.serverOldest;
127
+ }
128
+
129
+ /** Current live-window descriptor (cursor, base, height). */
130
+ getWindow(): WindowState {
131
+ return { ...this.win };
132
+ }
133
+
134
+ /** Whether the alternate screen is active. */
135
+ isAlt(): boolean {
136
+ return this.alt;
137
+ }
138
+
139
+ /** A copy of the ephemeral alt-screen grid rows. */
140
+ getAltRows(): WireRun[][] {
141
+ return this.altRows.map((r) => r.slice());
142
+ }
143
+
144
+ /**
145
+ * Iterate retained lines from oldest to highest in absolute-index order,
146
+ * skipping holes. The renderer uses this to build and order DOM rows; a hole
147
+ * (a jump in abs between consecutive lines) is a trimmed-history gap the
148
+ * renderer marks.
149
+ */
150
+ forEachLine(cb: (abs: number, runs: WireRun[]) => void): void {
151
+ if (this.oldest < 0) {
152
+ return;
153
+ }
154
+ for (let abs = this.oldest; abs <= this.highest; abs++) {
155
+ const runs = this.lines.get(abs);
156
+ if (runs !== undefined) {
157
+ cb(abs, runs);
158
+ }
159
+ }
160
+ }
161
+
162
+ /** Read a single retained line by absolute index, or undefined. */
163
+ getLine(abs: number): WireRun[] | undefined {
164
+ return this.lines.get(abs);
165
+ }
166
+
167
+ /**
168
+ * Apply a decoded screen frame: update the window descriptor and cursor,
169
+ * route to the alt grid when the alternate screen is active, and apply each
170
+ * changed window row at its absolute index (base + y).
171
+ */
172
+ applyScreen(msg: ScreenMessage): void {
173
+ if (msg.altActive) {
174
+ this.enterAltIfNeeded(msg.rows.length);
175
+ for (const y of msg.changed) {
176
+ const row = msg.rows[y];
177
+ if (y >= 0 && y < this.altRows.length && row !== undefined) {
178
+ this.altRows[y] = row;
179
+ this.altDirty = true;
180
+ }
181
+ }
182
+ this.updateWindowCursor(msg);
183
+ return;
184
+ }
185
+ this.exitAltIfNeeded();
186
+ this.updateWindow(msg);
187
+ for (const y of msg.changed) {
188
+ const row = msg.rows[y];
189
+ if (row !== undefined) {
190
+ this.applyLine(msg.base + y, row);
191
+ }
192
+ }
193
+ }
194
+
195
+ /** Apply a decoded scroll/history frame: each line at firstIndex + i. */
196
+ applyScroll(msg: ScrollMessage): void {
197
+ if (this.alt) {
198
+ // Protocol invariant: the server does not emit scroll frames while the
199
+ // alternate screen is active. Drop rather than corrupt the abs store.
200
+ return;
201
+ }
202
+ for (let i = 0; i < msg.lines.length; i++) {
203
+ const row = msg.lines[i];
204
+ if (row !== undefined) {
205
+ this.applyLine(msg.firstIndex + i, row);
206
+ }
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Record the server's retained-history bounds from a resumeAck so the
212
+ * renderer can tell a genuine trim from a still-loading state.
213
+ */
214
+ noteResumeBounds(_committed: number, oldestIndex: number): void {
215
+ if (Number.isInteger(oldestIndex) && oldestIndex >= 0) {
216
+ this.serverOldest = oldestIndex;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Full reset: drop all lines and window state. Used on server restart (a new
222
+ * boot epoch), where absolute indices start over from 0 and any retained
223
+ * content is stale. The renderer wipes all DOM on the next drain.
224
+ */
225
+ reset(): void {
226
+ this.lines.clear();
227
+ this.oldest = -1;
228
+ this.highest = -1;
229
+ this.everEvictedThrough = -1;
230
+ this.serverOldest = -1;
231
+ this.win = emptyWindow();
232
+ this.alt = false;
233
+ this.altRows = [];
234
+ this.dirty.clear();
235
+ this.evicted.clear();
236
+ this.windowDirty = true;
237
+ this.altDirty = true;
238
+ this.resetPending = true;
239
+ }
240
+
241
+ /** Drain accumulated changes for the renderer and clear the tracking sets. */
242
+ drainChanges(): StoreChanges {
243
+ const out: StoreChanges = {
244
+ dirtyLines: [...this.dirty],
245
+ evictedLines: [...this.evicted],
246
+ windowChanged: this.windowDirty,
247
+ altChanged: this.altDirty,
248
+ fullReset: this.resetPending,
249
+ };
250
+ this.dirty.clear();
251
+ this.evicted.clear();
252
+ this.windowDirty = false;
253
+ this.altDirty = false;
254
+ this.resetPending = false;
255
+ return out;
256
+ }
257
+
258
+ // --- internals ---
259
+
260
+ /**
261
+ * applyLine is the guarded core. It enforces the apply-line guard set
262
+ * (see the #web-terminal-engine steering doc, "Design rationale"):
263
+ * valid index, not stale, idempotent, and
264
+ * cap-bounded. Returns nothing; effects are recorded in the dirty/evicted
265
+ * sets for the next drain.
266
+ */
267
+ private applyLine(abs: number, runs: WireRun[]): void {
268
+ // Guard 1: a valid, non-negative integer index.
269
+ if (!Number.isInteger(abs) || abs < 0) {
270
+ return;
271
+ }
272
+ // Guard 2: not below what we have permanently evicted (stale re-send).
273
+ if (abs <= this.everEvictedThrough) {
274
+ return;
275
+ }
276
+ // Guard 3: a well-formed run array.
277
+ if (!Array.isArray(runs)) {
278
+ return;
279
+ }
280
+ // Guard 5: idempotent — identical content is a no-op (no DOM churn, no
281
+ // selection disturbance). Guards 4/6/8/9 (gap, alt-consistency, cell
282
+ // width, row-element integrity) live at the callers and the renderer.
283
+ const existing = this.lines.get(abs);
284
+ if (existing !== undefined && rowEqual(existing, runs)) {
285
+ return;
286
+ }
287
+ this.lines.set(abs, runs);
288
+ this.evicted.delete(abs);
289
+ this.dirty.add(abs);
290
+ if (this.oldest < 0 || abs < this.oldest) {
291
+ this.oldest = abs;
292
+ }
293
+ if (abs > this.highest) {
294
+ this.highest = abs;
295
+ }
296
+ // Guard 10: enforce the cap by evicting from the oldest end.
297
+ this.enforceCap();
298
+ }
299
+
300
+ private enforceCap(): void {
301
+ while (this.lines.size > this.maxLines && this.oldest >= 0 && this.oldest < this.highest) {
302
+ const victim = this.oldest;
303
+ if (this.lines.delete(victim)) {
304
+ this.everEvictedThrough = Math.max(this.everEvictedThrough, victim);
305
+ this.evicted.add(victim);
306
+ this.dirty.delete(victim);
307
+ }
308
+ // Advance oldest past the evicted line and any hole.
309
+ let next = victim + 1;
310
+ while (next <= this.highest && !this.lines.has(next)) {
311
+ next++;
312
+ }
313
+ this.oldest = next > this.highest && !this.lines.has(this.highest) ? -1 : next;
314
+ if (this.lines.size === 0) {
315
+ this.oldest = -1;
316
+ this.highest = -1;
317
+ break;
318
+ }
319
+ }
320
+ }
321
+
322
+ private updateWindow(msg: ScreenMessage): void {
323
+ const next: WindowState = {
324
+ base: msg.base,
325
+ height: msg.rows.length,
326
+ cursorRow: msg.cursor[0],
327
+ cursorCol: msg.cursor[1],
328
+ cursorStyle: msg.cursorStyle ?? 0,
329
+ cursorHidden: msg.cursorHidden ?? false,
330
+ cursorBlink: msg.cursorBlink ?? false,
331
+ };
332
+ if (!windowEqual(this.win, next)) {
333
+ this.win = next;
334
+ this.windowDirty = true;
335
+ }
336
+ }
337
+
338
+ private updateWindowCursor(msg: ScreenMessage): void {
339
+ // In the alt screen the base is frozen but the cursor still moves.
340
+ const next: WindowState = {
341
+ ...this.win,
342
+ height: msg.rows.length,
343
+ cursorRow: msg.cursor[0],
344
+ cursorCol: msg.cursor[1],
345
+ cursorStyle: msg.cursorStyle ?? 0,
346
+ cursorHidden: msg.cursorHidden ?? false,
347
+ cursorBlink: msg.cursorBlink ?? false,
348
+ };
349
+ if (!windowEqual(this.win, next)) {
350
+ this.win = next;
351
+ this.windowDirty = true;
352
+ }
353
+ }
354
+
355
+ private enterAltIfNeeded(height: number): void {
356
+ if (!this.alt) {
357
+ this.alt = true;
358
+ this.altDirty = true;
359
+ }
360
+ if (this.altRows.length !== height) {
361
+ const next: WireRun[][] = new Array<WireRun[]>(height);
362
+ for (let i = 0; i < height; i++) {
363
+ next[i] = this.altRows[i] ?? [];
364
+ }
365
+ this.altRows = next;
366
+ this.altDirty = true;
367
+ }
368
+ }
369
+
370
+ private exitAltIfNeeded(): void {
371
+ if (this.alt) {
372
+ this.alt = false;
373
+ this.altRows = [];
374
+ this.altDirty = true;
375
+ }
376
+ }
377
+ }
378
+
379
+ function windowEqual(a: WindowState, b: WindowState): boolean {
380
+ return (
381
+ a.base === b.base &&
382
+ a.height === b.height &&
383
+ a.cursorRow === b.cursorRow &&
384
+ a.cursorCol === b.cursorCol &&
385
+ a.cursorStyle === b.cursorStyle &&
386
+ a.cursorHidden === b.cursorHidden &&
387
+ a.cursorBlink === b.cursorBlink
388
+ );
389
+ }
package/src/types.ts ADDED
@@ -0,0 +1,165 @@
1
+ // Wire types matching the Go vt package's wire format.
2
+
3
+ /**
4
+ * A single styled run of text within a terminal row. The Go server emits these
5
+ * as flat arrays per row; the renderer reconstructs cells from them.
6
+ */
7
+ export interface WireRun {
8
+ /** UTF-8 text content of this run. */
9
+ t: string;
10
+ /** Foreground color (palette index or RGB packed); -1 means default. */
11
+ f?: number;
12
+ /** Background color (palette index or RGB packed); -1 means default. */
13
+ b?: number;
14
+ /** Underline color (palette index or RGB packed); -1 means default. */
15
+ uc?: number;
16
+ /**
17
+ * Style attribute bitflags:
18
+ * 1=bold, 2=italic, 4=underline, 8=inverse, 16=strike, 32=dim, 64=hidden,
19
+ * 128=blink, 256=overline, 512=double-underline.
20
+ */
21
+ a?: number;
22
+ /** OSC 8 hyperlink URI (empty/absent means no link). */
23
+ u?: string;
24
+ }
25
+
26
+ /**
27
+ * Full or incremental screen update from the server. `changed` lists row
28
+ * indices in `rows` that actually changed since the last frame; the renderer
29
+ * only re-paints those rows.
30
+ */
31
+ export interface ScreenMessage {
32
+ /** Discriminator — always `"screen"`. */
33
+ type: "screen";
34
+ /** All rows of the visible screen (length = screen height). */
35
+ rows: WireRun[][];
36
+ /**
37
+ * Absolute line index of the top screen row (row 0). A changed row at
38
+ * window index `y` has absolute index `base + y`. The client stores
39
+ * every line by absolute index, so applying a row is idempotent and
40
+ * never duplicates (see the #web-terminal-engine steering doc,
41
+ * "Design rationale").
42
+ */
43
+ base: number;
44
+ /** Cursor position as [row, col], zero-indexed within the window. */
45
+ cursor: [number, number];
46
+ /** Indices into `rows` that changed; an empty array means cursor-only update. */
47
+ changed: number[];
48
+ /**
49
+ * True while the alternate screen is active. Alt-screen content is
50
+ * ephemeral (no history accrual): the client renders it as an overlay
51
+ * and restores the main buffer on exit. `base` is frozen while alt.
52
+ */
53
+ altActive?: boolean;
54
+ /** DECSCUSR cursor style (0-6); 0 = default block. */
55
+ cursorStyle?: number;
56
+ /** True if the cursor is currently hidden (DECTCEM off). */
57
+ cursorHidden?: boolean;
58
+ /** True if the cursor is currently blinking. */
59
+ cursorBlink?: boolean;
60
+ /** True if the server emitted a BEL since the last screen update. */
61
+ bell?: boolean;
62
+ /** Server-confirmed bytesReceived for the input ACK protocol. */
63
+ inputAck?: number;
64
+ }
65
+
66
+ /**
67
+ * A batch of committed history lines, addressed by absolute index. Used both
68
+ * for lines that scrolled off the live window and for resume replay.
69
+ */
70
+ export interface ScrollMessage {
71
+ /** Discriminator — always `"scroll"`. */
72
+ type: "scroll";
73
+ /** Absolute line index of `lines[0]`; line `i` has index `firstIndex + i`. */
74
+ firstIndex: number;
75
+ /** Lines in order (oldest to newest), applied by absolute index. */
76
+ lines: WireRun[][];
77
+ /** Server-confirmed bytesReceived for the input ACK protocol. */
78
+ inputAck?: number;
79
+ }
80
+
81
+ /**
82
+ * Server acknowledgement of a client `resume` control message. Carries the
83
+ * input-ack count (to trim the outbox) plus the absolute-index bounds of
84
+ * retained history (to detect an eviction gap on resume).
85
+ */
86
+ export interface ResumeAckMessage {
87
+ /** Discriminator — always `"resumeAck"`. */
88
+ type: "resumeAck";
89
+ /** Bytes the server received from this session before the resume. */
90
+ received: number;
91
+ /**
92
+ * Server boot-time nanoseconds since unix epoch. Optional for back-compat
93
+ * with pre-CONN-01 server builds (which omit it).
94
+ */
95
+ serverEpoch?: number;
96
+ /** Absolute index of the next line to commit (one past the newest retained). */
97
+ committed?: number;
98
+ /**
99
+ * Absolute index of the oldest retained line. If this exceeds the client's
100
+ * highest-held index + 1, history between them was evicted: the client
101
+ * shows a "history trimmed" marker rather than misaligning.
102
+ */
103
+ oldestIndex?: number;
104
+ }
105
+
106
+ /**
107
+ * Snapshot of the server's terminal mode flags. The client updates its input
108
+ * encoding behavior (keyboard, mouse, paste) based on these.
109
+ */
110
+ export interface ModesMessage {
111
+ /** Discriminator — always `"modes"`. */
112
+ type: "modes";
113
+ /** DEC 2004: paste content is wrapped with ESC[200~ ... ESC[201~. */
114
+ bracketedPaste: boolean;
115
+ /** DECCKM: cursor keys send ESC O instead of ESC [. */
116
+ applicationCursor: boolean;
117
+ /** DECKPAM: keypad keys send application-mode sequences. */
118
+ applicationKeypad: boolean;
119
+ /** DEC 1006: mouse coordinates are encoded in SGR (CSI <) form. */
120
+ mouseSGR: boolean;
121
+ /** DEC 1004: focus in/out events are reported as ESC[I / ESC[O. */
122
+ focusReporting: boolean;
123
+ /** DEC 5: screen is in reverse-video mode. */
124
+ reverseVideo: boolean;
125
+ /** Mouse tracking: 0=off, 1000=normal, 1002=button-event, 1003=any-event. */
126
+ mouseMode: number;
127
+ /** Server-confirmed bytesReceived for the input ACK protocol. */
128
+ inputAck?: number;
129
+ }
130
+
131
+ /**
132
+ * Window title set by the server (OSC 0/1/2). The client typically reflects
133
+ * this into `document.title`.
134
+ */
135
+ export interface TitleMessage {
136
+ /** Discriminator — always `"title"`. */
137
+ type: "title";
138
+ /** New window title text. */
139
+ title: string;
140
+ /** Server-confirmed bytesReceived for the input ACK protocol. */
141
+ inputAck?: number;
142
+ }
143
+
144
+ /** Discriminated union of all messages the server can send to the client. */
145
+ export type ServerMessage =
146
+ | ScreenMessage
147
+ | ScrollMessage
148
+ | ResumeAckMessage
149
+ | ModesMessage
150
+ | TitleMessage;
151
+
152
+ /**
153
+ * Discriminated union of all control messages the client can send to the
154
+ * server (multiplexed alongside raw input bytes; see the wire protocol notes).
155
+ */
156
+ export type ControlMessage =
157
+ | { type: "resize"; cols: number; rows: number }
158
+ | {
159
+ type: "resume";
160
+ sessionId: string;
161
+ sentBytes: number;
162
+ haveThrough: number;
163
+ protocolVersion: number;
164
+ }
165
+ | { type: "ping" };