@cplieger/web-terminal-engine 1.3.2 → 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/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "1.3.2",
3
+ "version": "1.4.0",
4
4
  "exports": {
5
5
  ".": "./src/index.ts"
6
6
  },
package/package.json CHANGED
@@ -1,21 +1,26 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "1.3.2",
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
- "prettier": "3.8.5",
20
+ "pngjs": "7.0.0",
21
+ "prettier": "3.9.3",
17
22
  "typescript": "6.0.3",
18
- "typescript-eslint": "8.62.0",
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/**/__test-helpers__",
43
+ "!src/**/test-helpers",
39
44
  "!src/**/fc-strict-setup.ts",
40
45
  "jsr.json"
41
46
  ]
package/src/connection.ts CHANGED
@@ -546,6 +546,7 @@ export function connect(): void {
546
546
  msg.mouseMode,
547
547
  msg.applicationKeypad,
548
548
  msg.reverseVideo,
549
+ msg.mousePixels,
549
550
  );
550
551
  if (typeof msg.inputAck === "number") {
551
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=left, 1=middle, 2=right
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 0; // fallback
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
- const btn = e.buttons ? (e.buttons & 1 ? 0 : e.buttons & 4 ? 1 : e.buttons & 2 ? 2 : 0) : 0;
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
@@ -408,6 +408,12 @@ function buildRowSpans(runs: WireRun[], cursorAt: number): (HTMLSpanElement | HT
408
408
  prev.style.letterSpacing = `${cellWidth * 2 - w}px`;
409
409
  }
410
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++;
411
417
  continue;
412
418
  }
413
419
  if (col === cursorAt) {
package/src/store.ts CHANGED
@@ -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;
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
- | ScreenMessage
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
@@ -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;
@@ -177,6 +180,7 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
177
180
  const bell = (cursorFlags & 2) !== 0;
178
181
  const cursorBlink = (cursorFlags & 4) !== 0;
179
182
  const altActive = (cursorFlags & 8) !== 0;
183
+ const scrollbackCleared = (cursorFlags & 16) !== 0;
180
184
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- pre-allocated array filled below
181
185
  const rows: WireRun[][] = new Array(screenHeight);
182
186
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- pre-allocated array filled below
@@ -197,6 +201,7 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
197
201
  cursorHidden,
198
202
  cursorBlink,
199
203
  bell,
204
+ scrollbackCleared,
200
205
  inputAck,
201
206
  };
202
207
  return msg;
@@ -223,6 +228,7 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
223
228
  mouseSGR: (flags & MODE_FLAG_MOUSE_SGR) !== 0,
224
229
  focusReporting: (flags & MODE_FLAG_FOCUS_REPORTING) !== 0,
225
230
  reverseVideo: (flags & MODE_FLAG_REVERSE_VIDEO) !== 0,
231
+ mousePixels: (flags & MODE_FLAG_MOUSE_PIXELS) !== 0,
226
232
  mouseMode,
227
233
  inputAck,
228
234
  };
@@ -234,6 +240,12 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
234
240
  const msg: TitleMessage = { type: "title", title, inputAck };
235
241
  return msg;
236
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
+ }
237
249
  if (msgType === MSG_PONG) {
238
250
  // Liveness pong: no payload to deliver. The message listener already
239
251
  // refreshed the activity clock on arrival, so there is nothing to do