@cplieger/web-terminal-engine 1.3.0 → 1.4.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 +2 -2
- package/jsr.json +1 -1
- package/package.json +10 -5
- package/src/connection.ts +9 -0
- package/src/modes.ts +11 -0
- package/src/mouse.ts +26 -9
- package/src/render.ts +27 -5
- package/src/store.ts +90 -6
- package/src/types.ts +24 -5
- package/src/wire-binary.ts +22 -4
package/README.md
CHANGED
|
@@ -77,10 +77,10 @@ document.addEventListener("keydown", (ev) => {
|
|
|
77
77
|
|
|
78
78
|
## API
|
|
79
79
|
|
|
80
|
-
- **`render`** — DOM renderer driven by `ScreenMessage` / `ScrollMessage` frames. `init`, `handleScreen`, `handleScroll`, `updateFontMetrics`, `computeSize`, `getCursorPx`, `setPredictedCursor`, `resetScreen`, `resetScrollback`, `
|
|
80
|
+
- **`render`** — DOM renderer driven by `ScreenMessage` / `ScrollMessage` frames. `init`, `handleScreen`, `handleScroll`, `updateFontMetrics`, `computeSize`, `getCursorPx`, `setPredictedCursor`, `resetScreen`, `resetScrollback`, `getHighestIndex`, `noteResumeBounds`, `updateReverseVideo`.
|
|
81
81
|
- **`keyboard`** — Translates `KeyboardEvent` to terminal byte sequences. `mapKeyboardEvent`, `bracketTextForPaste`, `prepareTextForTerminal`, `ctrlByteFor`. Honors `applicationCursor`, `applicationKeypad`, `bracketedPaste`. For touch / mobile UIs, `bindMobileToolbar({toolbar, send, ids?})` wires `pointerdown` handlers for an on-screen Ctrl/arrows/Tab/Enter/Esc toolbar (with sticky-Ctrl semantics and DECCKM-aware arrows), returning a `MobileToolbarController` exposing `applyStickyCtrl`, `setCtrlArmed`, `isCtrlArmed`, and `dispose`.
|
|
82
82
|
- **`mouse`** — SGR 1006 mouse + focus reporting encoder. `init`, `encodeSGR`, `MouseInputHandler`. Auto-gates on `mouseMode > 0`.
|
|
83
|
-
- **`scroll`** — Auto-follow tracker for the scroll container. `init`, `
|
|
83
|
+
- **`scroll`** — Auto-follow tracker for the scroll container. `init`, `stickToBottom`, `scrollToBottom`, `isUserScrolledUp`.
|
|
84
84
|
- **`modes`** — DEC private mode state (synced from server's `ModesMessage`). `setModes`, `isBracketedPaste`, `isApplicationCursor`, `getMouseMode`, `isMouseSGR`, `isFocusReporting`, `isApplicationKeypad`, `isReverseVideo`.
|
|
85
85
|
- **`decodeWireBinary(buf)`** — Top-level decoder for the binary WebSocket frames. Returns a `ServerMessage` or `null` for invalid/truncated frames.
|
|
86
86
|
- **`connection`** — Client → server WebSocket lifecycle: owns the socket, exponential-backoff reconnect, and the resume/inputAck reliability layer (outbox + server-restart detection). `init(callbacks)`, `connect`, `sendBinary(bytes)`, `sendResize`, `reconnectNow`. The callbacks expose `onMessage(ServerMessage)`, `onOpen`/`onClose`/`onConnecting`/`onOutboxFull`/`onServerRestart`, a `computeSize()` provider, and an optional `wsPath` (defaults to `"/ws"`). It decodes frames internally and applies `modes.setModes` for you, so a consumer only needs to dispatch screen/scroll to `render`. Prefer this over wiring `WebSocket` + `decodeWireBinary` by hand unless you need full control.
|
package/jsr.json
CHANGED
package/package.json
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-engine",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"typecheck": "tsgo -p tsconfig.json",
|
|
7
7
|
"typecheck:tests": "tsgo -p tsconfig.test.json",
|
|
8
|
-
"test": "vitest --run"
|
|
8
|
+
"test": "vitest --run",
|
|
9
|
+
"test:e2e": "playwright test"
|
|
9
10
|
},
|
|
10
11
|
"devDependencies": {
|
|
11
12
|
"@eslint/js": "10.0.1",
|
|
13
|
+
"@playwright/test": "1.61.1",
|
|
14
|
+
"@types/pngjs": "6.0.5",
|
|
12
15
|
"@vitest/coverage-v8": "4.1.9",
|
|
16
|
+
"esbuild": "0.28.1",
|
|
13
17
|
"eslint": "10.6.0",
|
|
14
18
|
"fast-check": "4.8.0",
|
|
15
19
|
"happy-dom": "20.10.6",
|
|
16
|
-
"
|
|
20
|
+
"pngjs": "7.0.0",
|
|
21
|
+
"prettier": "3.9.3",
|
|
17
22
|
"typescript": "6.0.3",
|
|
18
|
-
"typescript-eslint": "8.62.
|
|
23
|
+
"typescript-eslint": "8.62.1",
|
|
19
24
|
"vitest": "4.1.9"
|
|
20
25
|
},
|
|
21
26
|
"description": "Browser terminal engine: VT500 screen buffer, DOM renderer, and binary wire protocol",
|
|
@@ -35,7 +40,7 @@
|
|
|
35
40
|
"files": [
|
|
36
41
|
"src/",
|
|
37
42
|
"!src/**/*.test.ts",
|
|
38
|
-
"!src/**/
|
|
43
|
+
"!src/**/test-helpers",
|
|
39
44
|
"!src/**/fc-strict-setup.ts",
|
|
40
45
|
"jsr.json"
|
|
41
46
|
]
|
package/src/connection.ts
CHANGED
|
@@ -373,6 +373,14 @@ export function connect(): void {
|
|
|
373
373
|
/* ignore */
|
|
374
374
|
}
|
|
375
375
|
}
|
|
376
|
+
// Re-entry while a backoff reconnect is pending (e.g. a consumer calling
|
|
377
|
+
// connect() to restore a panel during the 500ms-8s backoff window): clear the
|
|
378
|
+
// scheduled timer so it cannot fire later and spawn a SECOND socket alongside
|
|
379
|
+
// the one created below. The orphaned timer resets connState to disconnected
|
|
380
|
+
// and calls connect() again, while the existing socket's listeners stay bound
|
|
381
|
+
// (its abort never fired) -> a duplicate server connection + double delivery.
|
|
382
|
+
// cancelScheduledReconnect is a no-op in any non-reconnecting state.
|
|
383
|
+
cancelScheduledReconnect();
|
|
376
384
|
|
|
377
385
|
cb?.onConnecting?.();
|
|
378
386
|
|
|
@@ -538,6 +546,7 @@ export function connect(): void {
|
|
|
538
546
|
msg.mouseMode,
|
|
539
547
|
msg.applicationKeypad,
|
|
540
548
|
msg.reverseVideo,
|
|
549
|
+
msg.mousePixels,
|
|
541
550
|
);
|
|
542
551
|
if (typeof msg.inputAck === "number") {
|
|
543
552
|
applyAck(msg.inputAck);
|
package/src/modes.ts
CHANGED
|
@@ -20,6 +20,7 @@ let applicationCursor = false;
|
|
|
20
20
|
let applicationKeypad = false;
|
|
21
21
|
let mouseMode = 0; // 0=off, 1000=normal, 1002=button-event, 1003=any-event
|
|
22
22
|
let mouseSGR = false;
|
|
23
|
+
let mousePixels = false; // DEC 1016: report pixel coords instead of cell coords
|
|
23
24
|
let focusReporting = false;
|
|
24
25
|
let reverseVideo = false;
|
|
25
26
|
|
|
@@ -36,6 +37,7 @@ export function setModes(
|
|
|
36
37
|
mMode?: number,
|
|
37
38
|
appKeypad?: boolean,
|
|
38
39
|
revVideo?: boolean,
|
|
40
|
+
mPixels?: boolean,
|
|
39
41
|
): void {
|
|
40
42
|
bracketedPaste = bracketed;
|
|
41
43
|
applicationCursor = appCursor;
|
|
@@ -54,6 +56,9 @@ export function setModes(
|
|
|
54
56
|
if (revVideo !== undefined) {
|
|
55
57
|
reverseVideo = revVideo;
|
|
56
58
|
}
|
|
59
|
+
if (mPixels !== undefined) {
|
|
60
|
+
mousePixels = mPixels;
|
|
61
|
+
}
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
/** True when the server has DEC 2004 (bracketed paste) enabled. */
|
|
@@ -79,6 +84,12 @@ export function isMouseSGR(): boolean {
|
|
|
79
84
|
return mouseSGR;
|
|
80
85
|
}
|
|
81
86
|
|
|
87
|
+
/** True when the server has DEC 1016 (SGR-pixels mouse) enabled: mouse reports
|
|
88
|
+
* carry pixel coordinates instead of cell coordinates. */
|
|
89
|
+
export function isMousePixels(): boolean {
|
|
90
|
+
return mousePixels;
|
|
91
|
+
}
|
|
92
|
+
|
|
82
93
|
/** True when the server has DEC 1004 (focus event reporting) enabled. */
|
|
83
94
|
export function isFocusReporting(): boolean {
|
|
84
95
|
return focusReporting;
|
package/src/mouse.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// - urxvt encoding (mode 1015): not supported.
|
|
15
15
|
// - DEFAULT encoding (raw bytes): not supported; only SGR 1006.
|
|
16
16
|
|
|
17
|
-
import { getMouseMode, isMouseSGR, isFocusReporting } from "./modes.js";
|
|
17
|
+
import { getMouseMode, isMouseSGR, isMousePixels, isFocusReporting } from "./modes.js";
|
|
18
18
|
|
|
19
19
|
const ESC = "\x1b";
|
|
20
20
|
|
|
@@ -59,12 +59,14 @@ export function encodeSGR(code: number, col: number, row: number, release: boole
|
|
|
59
59
|
|
|
60
60
|
/** Map DOM button index to xterm button number. */
|
|
61
61
|
function domButtonToXterm(button: number): number {
|
|
62
|
-
// DOM: 0=left, 1=middle, 2=right
|
|
63
|
-
// xterm: 0
|
|
62
|
+
// DOM MouseEvent.button: 0=left, 1=middle, 2=right, 3=back, 4=forward.
|
|
63
|
+
// xterm: 0/1/2 for left/middle/right; the "additional buttons" (X11 8-11,
|
|
64
|
+
// reached via DOM back/forward) use the +128 extended-button encoding, so
|
|
65
|
+
// back (DOM 3 = X11 8) → 128 and forward (DOM 4 = X11 9) → 129.
|
|
64
66
|
if (button <= 2) {
|
|
65
67
|
return button;
|
|
66
68
|
}
|
|
67
|
-
return
|
|
69
|
+
return 128 + (button - 3);
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
/**
|
|
@@ -100,12 +102,25 @@ export function init(h: MouseInputHandler): void {
|
|
|
100
102
|
el.addEventListener("focusout", onFocusOut);
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
// pixelToCell returns the coordinate pair to report for a mouse event. Normally
|
|
106
|
+
// that's the 1-based cell column/row; under DEC 1016 (SGR-pixels) it's instead
|
|
107
|
+
// the 1-based pixel offset within the terminal element (same SGR grammar, so
|
|
108
|
+
// encodeSGR is reused unchanged). The {col,row} field names carry whichever the
|
|
109
|
+
// active mode reports.
|
|
103
110
|
function pixelToCell(e: MouseEvent): { col: number; row: number } | null {
|
|
104
111
|
if (!handler) {
|
|
105
112
|
return null;
|
|
106
113
|
}
|
|
107
114
|
const el = handler.termElement();
|
|
108
115
|
const rect = el.getBoundingClientRect();
|
|
116
|
+
if (isMousePixels()) {
|
|
117
|
+
const px = Math.round(e.clientX - rect.left);
|
|
118
|
+
const py = Math.round(e.clientY - rect.top);
|
|
119
|
+
if (px < 0 || py < 0) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
return { col: px + 1, row: py + 1 }; // 1-based pixel offsets
|
|
123
|
+
}
|
|
109
124
|
const { width, height } = handler.cellSize();
|
|
110
125
|
if (width <= 0 || height <= 0) {
|
|
111
126
|
return null;
|
|
@@ -120,7 +135,7 @@ function pixelToCell(e: MouseEvent): { col: number; row: number } | null {
|
|
|
120
135
|
|
|
121
136
|
function onMouseDown(e: MouseEvent): void {
|
|
122
137
|
const mode = getMouseMode();
|
|
123
|
-
if (mode === 0 || !isMouseSGR() || !handler) {
|
|
138
|
+
if (mode === 0 || !(isMouseSGR() || isMousePixels()) || !handler) {
|
|
124
139
|
return;
|
|
125
140
|
}
|
|
126
141
|
const pos = pixelToCell(e);
|
|
@@ -135,7 +150,7 @@ function onMouseDown(e: MouseEvent): void {
|
|
|
135
150
|
|
|
136
151
|
function onMouseUp(e: MouseEvent): void {
|
|
137
152
|
const mode = getMouseMode();
|
|
138
|
-
if (mode === 0 || !isMouseSGR() || !handler) {
|
|
153
|
+
if (mode === 0 || !(isMouseSGR() || isMousePixels()) || !handler) {
|
|
139
154
|
return;
|
|
140
155
|
}
|
|
141
156
|
const pos = pixelToCell(e);
|
|
@@ -151,7 +166,7 @@ function onMouseUp(e: MouseEvent): void {
|
|
|
151
166
|
|
|
152
167
|
function onMouseMove(e: MouseEvent): void {
|
|
153
168
|
const mode = getMouseMode();
|
|
154
|
-
if (mode === 0 || !isMouseSGR() || !handler) {
|
|
169
|
+
if (mode === 0 || !(isMouseSGR() || isMousePixels()) || !handler) {
|
|
155
170
|
return;
|
|
156
171
|
}
|
|
157
172
|
// mode 1000: no motion events
|
|
@@ -167,14 +182,16 @@ function onMouseMove(e: MouseEvent): void {
|
|
|
167
182
|
if (!pos) {
|
|
168
183
|
return;
|
|
169
184
|
}
|
|
170
|
-
|
|
185
|
+
// No button held (bare motion in mode 1003) reports xterm's "no button"
|
|
186
|
+
// code 3, not 0 — code 0 would be read as a left-button drag during hover.
|
|
187
|
+
const btn = e.buttons ? (e.buttons & 1 ? 0 : e.buttons & 4 ? 1 : e.buttons & 2 ? 2 : 0) : 3;
|
|
171
188
|
const code = buttonCode(btn, true, e.shiftKey, e.altKey, e.ctrlKey);
|
|
172
189
|
handler.send(encodeSGR(code, pos.col, pos.row, false));
|
|
173
190
|
}
|
|
174
191
|
|
|
175
192
|
function onWheel(e: WheelEvent): void {
|
|
176
193
|
const mode = getMouseMode();
|
|
177
|
-
if (mode === 0 || !isMouseSGR() || !handler) {
|
|
194
|
+
if (mode === 0 || !(isMouseSGR() || isMousePixels()) || !handler) {
|
|
178
195
|
return;
|
|
179
196
|
}
|
|
180
197
|
const pos = pixelToCell(e);
|
package/src/render.ts
CHANGED
|
@@ -137,6 +137,9 @@ let cursorAbs = -1; // absolute index of the row the cursor is on
|
|
|
137
137
|
let cursorCol = 0;
|
|
138
138
|
let cursorHidden = false;
|
|
139
139
|
let cursorStyleVal = 0; // 0-6: DECSCUSR
|
|
140
|
+
let prevCursorCol = -1;
|
|
141
|
+
let prevCursorHidden = false;
|
|
142
|
+
let prevCursorStyleVal = -1;
|
|
140
143
|
|
|
141
144
|
function cursorClassName(): string {
|
|
142
145
|
// DECSCUSR: 0/1=blinking block, 2=steady block, 3=blinking underline,
|
|
@@ -405,6 +408,12 @@ function buildRowSpans(runs: WireRun[], cursorAt: number): (HTMLSpanElement | HT
|
|
|
405
408
|
prev.style.letterSpacing = `${cellWidth * 2 - w}px`;
|
|
406
409
|
}
|
|
407
410
|
}
|
|
411
|
+
// The spacer occupies the wide char's second cell, so it advances the
|
|
412
|
+
// column exactly like a printed cell. The engine reports cursor_col in
|
|
413
|
+
// true cell coordinates (a wide glyph moves curX by 2), so col must
|
|
414
|
+
// count this cell too — otherwise a visible cursor positioned after a
|
|
415
|
+
// wide char lands one cell too far right per preceding wide char.
|
|
416
|
+
col++;
|
|
408
417
|
continue;
|
|
409
418
|
}
|
|
410
419
|
if (col === cursorAt) {
|
|
@@ -463,7 +472,7 @@ function buildRowSpans(runs: WireRun[], cursorAt: number): (HTMLSpanElement | HT
|
|
|
463
472
|
}
|
|
464
473
|
if (out.length === 0) {
|
|
465
474
|
const span = document.createElement("span");
|
|
466
|
-
span.
|
|
475
|
+
span.textContent = "\u00a0";
|
|
467
476
|
out.push(span);
|
|
468
477
|
}
|
|
469
478
|
return linkifySpans(out);
|
|
@@ -561,15 +570,28 @@ function flushRenderInner(): void {
|
|
|
561
570
|
renderQueue.add(abs);
|
|
562
571
|
}
|
|
563
572
|
|
|
564
|
-
// The cursor rows are built
|
|
565
|
-
//
|
|
566
|
-
// the
|
|
573
|
+
// The cursor rows are built regardless of the budget so the inline cursor span
|
|
574
|
+
// is always current; a huge backlog must never make the caret lag. But skip
|
|
575
|
+
// rebuilding the current cursor row when neither the cursor's visual state nor
|
|
576
|
+
// the row content changed: an unconditional replaceChildren() would discard a
|
|
577
|
+
// text selection on that row, defeating the byte-identical selection-preserving
|
|
578
|
+
// guarantee the store idempotency gives every other row.
|
|
567
579
|
if (prevCursorAbs !== newCursorAbs && prevCursorAbs >= 0) {
|
|
568
580
|
upsertRow(prevCursorAbs);
|
|
569
581
|
renderQueue.delete(prevCursorAbs);
|
|
570
582
|
}
|
|
571
|
-
|
|
583
|
+
const cursorVisualChanged =
|
|
584
|
+
prevCursorAbs !== newCursorAbs ||
|
|
585
|
+
cursorCol !== prevCursorCol ||
|
|
586
|
+
cursorHidden !== prevCursorHidden ||
|
|
587
|
+
cursorStyleVal !== prevCursorStyleVal;
|
|
588
|
+
if (cursorVisualChanged || renderQueue.has(newCursorAbs)) {
|
|
589
|
+
upsertRow(newCursorAbs);
|
|
590
|
+
}
|
|
572
591
|
renderQueue.delete(newCursorAbs);
|
|
592
|
+
prevCursorCol = cursorCol;
|
|
593
|
+
prevCursorHidden = cursorHidden;
|
|
594
|
+
prevCursorStyleVal = cursorStyleVal;
|
|
573
595
|
|
|
574
596
|
// Drain up to MAX_ROWS_PER_FRAME queued rows this frame; the rest carry over
|
|
575
597
|
// to the next frame (scheduled below) so one big burst never blocks paint.
|
package/src/store.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import type { ScreenMessage, ScrollMessage, WireRun } from "./types.js";
|
|
16
16
|
|
|
17
17
|
/** Maximum lines retained client-side. Older lines are evicted from the top. */
|
|
18
|
-
|
|
18
|
+
const MAX_LINES = 5000;
|
|
19
19
|
|
|
20
20
|
/** The live screen window: a fixed `height`-row block at the tail of the buffer. */
|
|
21
21
|
export interface WindowState {
|
|
@@ -183,6 +183,12 @@ export class LineStore {
|
|
|
183
183
|
return;
|
|
184
184
|
}
|
|
185
185
|
this.exitAltIfNeeded();
|
|
186
|
+
if (msg.scrollbackCleared) {
|
|
187
|
+
// ED3 (erase scrollback): the app cleared its saved lines (kiro-cli does
|
|
188
|
+
// this on every resize redraw). Drop our matching history below the live
|
|
189
|
+
// window so the previous frame doesn't accumulate beneath the new one.
|
|
190
|
+
this.dropHistoryBelow(msg.base);
|
|
191
|
+
}
|
|
186
192
|
this.updateWindow(msg);
|
|
187
193
|
for (const y of msg.changed) {
|
|
188
194
|
const row = msg.rows[y];
|
|
@@ -190,6 +196,7 @@ export class LineStore {
|
|
|
190
196
|
this.applyLine(msg.base + y, row);
|
|
191
197
|
}
|
|
192
198
|
}
|
|
199
|
+
this.truncateBelowWindow();
|
|
193
200
|
}
|
|
194
201
|
|
|
195
202
|
/** Apply a decoded scroll/history frame: each line at firstIndex + i. */
|
|
@@ -297,6 +304,70 @@ export class LineStore {
|
|
|
297
304
|
this.enforceCap();
|
|
298
305
|
}
|
|
299
306
|
|
|
307
|
+
// dropHistoryBelow evicts all retained lines with an absolute index below
|
|
308
|
+
// `base` — the scrollback history under the live window. Called when the
|
|
309
|
+
// server signals ED3 (erase scrollback): an inline TUI (kiro-cli) clears its
|
|
310
|
+
// saved lines on every resize redraw, so the client drops its matching
|
|
311
|
+
// history to stop the previous frame accumulating beneath the new one. Window
|
|
312
|
+
// rows (>= base) are kept and refreshed by the frame carrying the signal.
|
|
313
|
+
// everEvictedThrough is left untouched: the app discarded the lines
|
|
314
|
+
// deliberately (not a cap trim), so no "earlier output trimmed" marker fits.
|
|
315
|
+
private dropHistoryBelow(base: number): void {
|
|
316
|
+
if (this.oldest < 0 || this.oldest >= base) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
for (let abs = this.oldest; abs < base; abs++) {
|
|
320
|
+
if (this.lines.delete(abs)) {
|
|
321
|
+
this.evicted.add(abs);
|
|
322
|
+
this.dirty.delete(abs);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (base > this.highest) {
|
|
326
|
+
this.oldest = -1;
|
|
327
|
+
this.highest = -1;
|
|
328
|
+
} else {
|
|
329
|
+
this.oldest = base;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// truncateBelowWindow evicts every retained line past the window's bottom row.
|
|
334
|
+
// The window's bottom row is the most recent line in the terminal, so no line
|
|
335
|
+
// can exist at a higher absolute index. A resize that SHRINKS the screen (the
|
|
336
|
+
// iOS soft keyboard opening) moves the window bottom up while the taller
|
|
337
|
+
// screen's former bottom rows stay in the store at higher indices — stranded
|
|
338
|
+
// below the live window. Cap eviction only trims the top/oldest, so nothing
|
|
339
|
+
// removes them: they linger as phantom blank rows beneath the real content, an
|
|
340
|
+
// "empty" region the user can scroll into and the reason the content never
|
|
341
|
+
// appears to shrink to fit on a short viewport. Evicting them keeps the tail
|
|
342
|
+
// invariant (highest === window bottom); it is the tail-side complement to the
|
|
343
|
+
// top-side cap eviction.
|
|
344
|
+
private truncateBelowWindow(): void {
|
|
345
|
+
if (this.highest < 0) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const windowBottom = this.win.base + this.win.height - 1;
|
|
349
|
+
if (this.highest <= windowBottom) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
for (let abs = windowBottom + 1; abs <= this.highest; abs++) {
|
|
353
|
+
if (this.lines.delete(abs)) {
|
|
354
|
+
this.evicted.add(abs);
|
|
355
|
+
this.dirty.delete(abs);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// New highest is the greatest retained index at or below the window bottom.
|
|
359
|
+
let h = windowBottom;
|
|
360
|
+
while (h >= this.oldest && !this.lines.has(h)) {
|
|
361
|
+
h--;
|
|
362
|
+
}
|
|
363
|
+
if (h >= this.oldest) {
|
|
364
|
+
this.highest = h;
|
|
365
|
+
} else {
|
|
366
|
+
this.highest = -1;
|
|
367
|
+
this.oldest = -1;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
300
371
|
private enforceCap(): void {
|
|
301
372
|
while (this.lines.size > this.maxLines && this.oldest >= 0 && this.oldest < this.highest) {
|
|
302
373
|
const victim = this.oldest;
|
|
@@ -305,12 +376,25 @@ export class LineStore {
|
|
|
305
376
|
this.evicted.add(victim);
|
|
306
377
|
this.dirty.delete(victim);
|
|
307
378
|
}
|
|
308
|
-
// Advance oldest
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
379
|
+
// Advance oldest to the lowest remaining key. After evicting the current
|
|
380
|
+
// oldest, contiguous history (the common case: live scroll-off, resume
|
|
381
|
+
// replay, a cat-bigfile burst) leaves victim + 1 present -- an O(1) probe.
|
|
382
|
+
// Only a hole at the boundary falls back to a full key scan. That scan is
|
|
383
|
+
// bounded by map size (<= maxLines), NOT by the integer gap to the next
|
|
384
|
+
// retained line, so a malformed/compromised frame whose base jumps far
|
|
385
|
+
// ahead of a retained low index cannot make eviction walk billions of
|
|
386
|
+
// indices (an algorithmic-complexity DoS that freezes the tab).
|
|
387
|
+
if (this.lines.has(victim + 1)) {
|
|
388
|
+
this.oldest = victim + 1;
|
|
389
|
+
} else {
|
|
390
|
+
let min = -1;
|
|
391
|
+
for (const k of this.lines.keys()) {
|
|
392
|
+
if (min < 0 || k < min) {
|
|
393
|
+
min = k;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
this.oldest = min;
|
|
312
397
|
}
|
|
313
|
-
this.oldest = next > this.highest && !this.lines.has(this.highest) ? -1 : next;
|
|
314
398
|
if (this.lines.size === 0) {
|
|
315
399
|
this.oldest = -1;
|
|
316
400
|
this.highest = -1;
|
package/src/types.ts
CHANGED
|
@@ -59,6 +59,13 @@ export interface ScreenMessage {
|
|
|
59
59
|
cursorBlink?: boolean;
|
|
60
60
|
/** True if the server emitted a BEL since the last screen update. */
|
|
61
61
|
bell?: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* True when the app issued ED3 (`CSI 3 J`, "erase saved lines") this frame:
|
|
64
|
+
* the client drops its scrollback history (all lines below `base`), matching
|
|
65
|
+
* a real terminal. Inline TUIs (kiro-cli) emit ED3 on every resize redraw to
|
|
66
|
+
* discard the previous frame; honoring it stops stale frames accumulating.
|
|
67
|
+
*/
|
|
68
|
+
scrollbackCleared?: boolean;
|
|
62
69
|
/** Server-confirmed bytesReceived for the input ACK protocol. */
|
|
63
70
|
inputAck?: number;
|
|
64
71
|
}
|
|
@@ -122,6 +129,8 @@ export interface ModesMessage {
|
|
|
122
129
|
focusReporting: boolean;
|
|
123
130
|
/** DEC 5: screen is in reverse-video mode. */
|
|
124
131
|
reverseVideo: boolean;
|
|
132
|
+
/** DEC 1016: mouse reports carry pixel coordinates instead of cell coords. */
|
|
133
|
+
mousePixels: boolean;
|
|
125
134
|
/** Mouse tracking: 0=off, 1000=normal, 1002=button-event, 1003=any-event. */
|
|
126
135
|
mouseMode: number;
|
|
127
136
|
/** Server-confirmed bytesReceived for the input ACK protocol. */
|
|
@@ -141,13 +150,23 @@ export interface TitleMessage {
|
|
|
141
150
|
inputAck?: number;
|
|
142
151
|
}
|
|
143
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Clipboard text an app copied via OSC 52 (`OSC 52 ; c ; <base64> ST`). The
|
|
155
|
+
* client writes it to the system clipboard. Set-only: the query form is
|
|
156
|
+
* refused server-side, so this message never carries a read request.
|
|
157
|
+
*/
|
|
158
|
+
export interface ClipboardMessage {
|
|
159
|
+
/** Discriminator — always `"clipboard"`. */
|
|
160
|
+
type: "clipboard";
|
|
161
|
+
/** Text to place on the system clipboard. */
|
|
162
|
+
text: string;
|
|
163
|
+
/** Server-confirmed bytesReceived for the input ACK protocol. */
|
|
164
|
+
inputAck?: number;
|
|
165
|
+
}
|
|
166
|
+
|
|
144
167
|
/** Discriminated union of all messages the server can send to the client. */
|
|
145
168
|
export type ServerMessage =
|
|
146
|
-
|
|
|
147
|
-
| ScrollMessage
|
|
148
|
-
| ResumeAckMessage
|
|
149
|
-
| ModesMessage
|
|
150
|
-
| TitleMessage;
|
|
169
|
+
ScreenMessage | ScrollMessage | ResumeAckMessage | ModesMessage | TitleMessage | ClipboardMessage;
|
|
151
170
|
|
|
152
171
|
/**
|
|
153
172
|
* Discriminated union of all control messages the client can send to the
|
package/src/wire-binary.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
ResumeAckMessage,
|
|
11
11
|
ModesMessage,
|
|
12
12
|
TitleMessage,
|
|
13
|
+
ClipboardMessage,
|
|
13
14
|
ServerMessage,
|
|
14
15
|
} from "./types.js";
|
|
15
16
|
|
|
@@ -20,6 +21,7 @@ const MSG_RESUME_ACK = 2;
|
|
|
20
21
|
const MSG_MODES = 3;
|
|
21
22
|
const MSG_TITLE = 4;
|
|
22
23
|
const MSG_PONG = 5;
|
|
24
|
+
const MSG_CLIPBOARD = 6;
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
27
|
* Wire protocol version. Sent by the client in the `resume` control message so
|
|
@@ -35,6 +37,7 @@ const MODE_FLAG_MOUSE_SGR = 4;
|
|
|
35
37
|
const MODE_FLAG_FOCUS_REPORTING = 8;
|
|
36
38
|
const MODE_FLAG_APP_KEYPAD = 16;
|
|
37
39
|
const MODE_FLAG_REVERSE_VIDEO = 32;
|
|
40
|
+
const MODE_FLAG_MOUSE_PIXELS = 64;
|
|
38
41
|
|
|
39
42
|
class Cursor {
|
|
40
43
|
view: DataView;
|
|
@@ -60,10 +63,16 @@ class Cursor {
|
|
|
60
63
|
return v;
|
|
61
64
|
}
|
|
62
65
|
u64(): number {
|
|
63
|
-
// Number(v)
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
66
|
+
// Number(v) loses precision above 2^53. That is safe for every u64 this
|
|
67
|
+
// protocol decodes because of how each value is USED, not because they all fit:
|
|
68
|
+
// - base / firstIndex / committed / oldestIndex / inputAck (bytesReceived)
|
|
69
|
+
// stay well under 2^53 for any realistic session, so arithmetic is exact.
|
|
70
|
+
// - serverEpoch is the server's time.Now().UnixNano() (~1.77e18, far ABOVE
|
|
71
|
+
// 2^53), so it DOES drop low bits here. That is harmless: it is only ever
|
|
72
|
+
// equality-compared to detect a restart, and Number() is deterministic, so
|
|
73
|
+
// one epoch always maps to one double and two distinct boots differ by far
|
|
74
|
+
// more than the ~256ns rounding granularity at that magnitude.
|
|
75
|
+
// If a future u64 field needs exact values above 2^53, return BigInt instead.
|
|
67
76
|
const v = this.view.getBigUint64(this.off, true);
|
|
68
77
|
this.off += 8;
|
|
69
78
|
return Number(v);
|
|
@@ -171,6 +180,7 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
|
|
|
171
180
|
const bell = (cursorFlags & 2) !== 0;
|
|
172
181
|
const cursorBlink = (cursorFlags & 4) !== 0;
|
|
173
182
|
const altActive = (cursorFlags & 8) !== 0;
|
|
183
|
+
const scrollbackCleared = (cursorFlags & 16) !== 0;
|
|
174
184
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- pre-allocated array filled below
|
|
175
185
|
const rows: WireRun[][] = new Array(screenHeight);
|
|
176
186
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- pre-allocated array filled below
|
|
@@ -191,6 +201,7 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
|
|
|
191
201
|
cursorHidden,
|
|
192
202
|
cursorBlink,
|
|
193
203
|
bell,
|
|
204
|
+
scrollbackCleared,
|
|
194
205
|
inputAck,
|
|
195
206
|
};
|
|
196
207
|
return msg;
|
|
@@ -217,6 +228,7 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
|
|
|
217
228
|
mouseSGR: (flags & MODE_FLAG_MOUSE_SGR) !== 0,
|
|
218
229
|
focusReporting: (flags & MODE_FLAG_FOCUS_REPORTING) !== 0,
|
|
219
230
|
reverseVideo: (flags & MODE_FLAG_REVERSE_VIDEO) !== 0,
|
|
231
|
+
mousePixels: (flags & MODE_FLAG_MOUSE_PIXELS) !== 0,
|
|
220
232
|
mouseMode,
|
|
221
233
|
inputAck,
|
|
222
234
|
};
|
|
@@ -228,6 +240,12 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
|
|
|
228
240
|
const msg: TitleMessage = { type: "title", title, inputAck };
|
|
229
241
|
return msg;
|
|
230
242
|
}
|
|
243
|
+
if (msgType === MSG_CLIPBOARD) {
|
|
244
|
+
const textLen = c.u16();
|
|
245
|
+
const text = c.utf8(textLen);
|
|
246
|
+
const msg: ClipboardMessage = { type: "clipboard", text, inputAck };
|
|
247
|
+
return msg;
|
|
248
|
+
}
|
|
231
249
|
if (msgType === MSG_PONG) {
|
|
232
250
|
// Liveness pong: no payload to deliver. The message listener already
|
|
233
251
|
// refreshed the activity clock on arrival, so there is nothing to do
|