@cplieger/web-terminal-engine 1.3.2 → 2.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/jsr.json +1 -1
- package/package.json +10 -5
- package/src/connection.ts +169 -72
- package/src/index.ts +10 -0
- package/src/keyboard.ts +24 -8
- package/src/modes.ts +11 -0
- package/src/mouse.ts +26 -9
- package/src/render.ts +74 -1
- package/src/status-stream.ts +95 -0
- package/src/store.ts +71 -0
- package/src/types.ts +24 -5
- package/src/wire-binary.ts +12 -0
package/jsr.json
CHANGED
package/package.json
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-engine",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.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.4",
|
|
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
|
@@ -40,20 +40,58 @@ let lastSentCols = 0;
|
|
|
40
40
|
let lastSentRows = 0;
|
|
41
41
|
let wsPath = "/ws";
|
|
42
42
|
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
43
|
+
// --- Per-session resume state (the switching cache's connection half) ---
|
|
44
|
+
//
|
|
45
|
+
// Each server session carries its own reliable-input accounting: an outbox of
|
|
46
|
+
// unacked bytes, byte counters, and the last server boot-epoch it saw. Scoping
|
|
47
|
+
// this per session (rather than one module-global set) is what lets a tab switch
|
|
48
|
+
// reconnect to a different session without replaying the previous tab's unacked
|
|
49
|
+
// bytes onto it and without firing a false server-restart reset, because each
|
|
50
|
+
// session's epoch is compared only against its own bootEpoch (design section 8).
|
|
51
|
+
interface ResumeState {
|
|
52
|
+
id: string; // server session id: both the routing id (?session=) and the resume id
|
|
53
|
+
bytesSent: number; // total bytes ever passed to sendBinary for this session
|
|
54
|
+
bytesAcked: number; // confirmed by server inputAck/resumeAck
|
|
55
|
+
outbox: Uint8Array[]; // unacked chunks (sum of lengths = bytesSent - bytesAcked)
|
|
56
|
+
outboxBytes: number; // running sum of outbox chunk lengths; keeps applyAck O(n) not O(n²)
|
|
57
|
+
lastServerEpoch: number | null; // process-start nanos last seen for this session
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const sessions = new Map<string, ResumeState>();
|
|
61
|
+
// The session the live socket currently serves. null until the first connect or
|
|
62
|
+
// setSession; the unmanaged single-terminal path lazily creates a default
|
|
63
|
+
// session with a sessionStorage-backed id.
|
|
64
|
+
let activeId: string | null = null;
|
|
65
|
+
// managed = a consumer selected sessions explicitly via setSession, so the WS URL
|
|
66
|
+
// carries ?session=<id>. Unmanaged keeps the bare wsPath and a sessionStorage id,
|
|
67
|
+
// preserving the original single-terminal behavior and its iOS-resume semantics
|
|
68
|
+
// (sessionStorage survives iOS tab-suspend/BFCache, so an unmanaged reload
|
|
69
|
+
// resumes rather than orphaning its outbox). A tabbed shell is managed; it
|
|
70
|
+
// rebuilds tabs from GET /api/sessions on reload (section 17), so it needs no
|
|
71
|
+
// client-side id persistence.
|
|
72
|
+
let managed = false;
|
|
73
|
+
|
|
50
74
|
const SESSION_ID_KEY = "vterm-session-id";
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
75
|
+
|
|
76
|
+
function newResumeState(id: string): ResumeState {
|
|
77
|
+
return { id, bytesSent: 0, bytesAcked: 0, outbox: [], outboxBytes: 0, lastServerEpoch: null };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function ensureState(id: string): ResumeState {
|
|
81
|
+
let s = sessions.get(id);
|
|
82
|
+
if (s === undefined) {
|
|
83
|
+
s = newResumeState(id);
|
|
84
|
+
sessions.set(id, s);
|
|
85
|
+
}
|
|
86
|
+
return s;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// activeState returns the ResumeState the live socket serves, lazily creating
|
|
90
|
+
// the default (unmanaged) session from a sessionStorage-backed id on first use.
|
|
91
|
+
function activeState(): ResumeState {
|
|
92
|
+
activeId ??= loadOrCreateSessionId();
|
|
93
|
+
return ensureState(activeId);
|
|
94
|
+
}
|
|
57
95
|
|
|
58
96
|
// --- Client-side liveness (bug 2 defense-in-depth) ---
|
|
59
97
|
//
|
|
@@ -181,17 +219,18 @@ export function init(callbacks: Callbacks): void {
|
|
|
181
219
|
* buffer reuse.
|
|
182
220
|
*/
|
|
183
221
|
export function sendBinary(data: Uint8Array): boolean {
|
|
184
|
-
|
|
222
|
+
const st = activeState();
|
|
223
|
+
if (st.outboxBytes + data.length > MAX_OUTBOX_BYTES) {
|
|
185
224
|
cb?.onOutboxFull?.();
|
|
186
225
|
return false;
|
|
187
226
|
}
|
|
188
|
-
// Always go through the outbox. Bytes leave it only when the
|
|
189
|
-
// explicitly acks them — guarantees correct retransmission after a
|
|
227
|
+
// Always go through the active session's outbox. Bytes leave it only when the
|
|
228
|
+
// server explicitly acks them — guarantees correct retransmission after a
|
|
190
229
|
// network blip even if ws.send() reported success.
|
|
191
230
|
const copy = new Uint8Array(data); // defensive copy (caller may reuse buffer)
|
|
192
|
-
outbox.push(copy);
|
|
193
|
-
outboxBytes += copy.length;
|
|
194
|
-
bytesSent += copy.length;
|
|
231
|
+
st.outbox.push(copy);
|
|
232
|
+
st.outboxBytes += copy.length;
|
|
233
|
+
st.bytesSent += copy.length;
|
|
195
234
|
if (connState.status === "connected") {
|
|
196
235
|
connState.sock.send(copy.buffer.slice(copy.byteOffset, copy.byteOffset + copy.byteLength));
|
|
197
236
|
}
|
|
@@ -218,40 +257,37 @@ export function sendResize(): void {
|
|
|
218
257
|
sendControl({ type: "resize", cols, rows });
|
|
219
258
|
}
|
|
220
259
|
|
|
221
|
-
// applyAck drops chunks from the front of the outbox until the
|
|
222
|
-
// running total of unacked bytes matches (bytesSent - newAck). Runs
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
function applyAck(received: number): void {
|
|
226
|
-
if (received <= bytesAcked) {
|
|
260
|
+
// applyAck drops chunks from the front of the session's outbox until the
|
|
261
|
+
// running total of unacked bytes matches (bytesSent - newAck). Runs in
|
|
262
|
+
// O(chunks_dropped) by tracking outboxBytes incrementally rather than
|
|
263
|
+
// re-summing on every loop iteration.
|
|
264
|
+
function applyAck(st: ResumeState, received: number): void {
|
|
265
|
+
if (received <= st.bytesAcked) {
|
|
227
266
|
return;
|
|
228
267
|
}
|
|
229
|
-
bytesAcked = Math.min(received, bytesSent);
|
|
230
|
-
const targetUnacked = bytesSent - bytesAcked;
|
|
231
|
-
while (outbox.length > 0 && outboxBytes > targetUnacked) {
|
|
268
|
+
st.bytesAcked = Math.min(received, st.bytesSent);
|
|
269
|
+
const targetUnacked = st.bytesSent - st.bytesAcked;
|
|
270
|
+
while (st.outbox.length > 0 && st.outboxBytes > targetUnacked) {
|
|
232
271
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- length checked above
|
|
233
|
-
const head = outbox[0]!;
|
|
234
|
-
const dropFromHead = outboxBytes - targetUnacked;
|
|
272
|
+
const head = st.outbox[0]!;
|
|
273
|
+
const dropFromHead = st.outboxBytes - targetUnacked;
|
|
235
274
|
if (head.length <= dropFromHead) {
|
|
236
|
-
outbox.shift();
|
|
237
|
-
outboxBytes -= head.length;
|
|
275
|
+
st.outbox.shift();
|
|
276
|
+
st.outboxBytes -= head.length;
|
|
238
277
|
} else {
|
|
239
|
-
outbox[0] = head.subarray(dropFromHead);
|
|
240
|
-
outboxBytes -= dropFromHead;
|
|
278
|
+
st.outbox[0] = head.subarray(dropFromHead);
|
|
279
|
+
st.outboxBytes -= dropFromHead;
|
|
241
280
|
break;
|
|
242
281
|
}
|
|
243
282
|
}
|
|
244
283
|
}
|
|
245
284
|
|
|
246
|
-
// On reconnect, after sending the resume control message and getting
|
|
247
|
-
//
|
|
248
|
-
// adjusted bytesAcked already — only unacked bytes remain.
|
|
249
|
-
function retransmitOutbox(): void {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
for (const chunk of outbox) {
|
|
254
|
-
connState.sock.send(
|
|
285
|
+
// On reconnect, after sending the resume control message and getting resumeAck,
|
|
286
|
+
// replay anything still in the session's outbox over its (now open) socket. The
|
|
287
|
+
// server has adjusted bytesAcked already — only unacked bytes remain.
|
|
288
|
+
function retransmitOutbox(sock: WebSocket, st: ResumeState): void {
|
|
289
|
+
for (const chunk of st.outbox) {
|
|
290
|
+
sock.send(
|
|
255
291
|
chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength) as ArrayBuffer,
|
|
256
292
|
);
|
|
257
293
|
}
|
|
@@ -330,17 +366,11 @@ function heartbeatTick(): void {
|
|
|
330
366
|
}
|
|
331
367
|
}
|
|
332
368
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
// skipped the reconnect, which is exactly why content printed during sleep
|
|
339
|
-
// stayed missing until a manual refresh (bug 2). So we never trust the
|
|
340
|
-
// current state on a wake: abort + close whatever socket exists and
|
|
341
|
-
// reconnect. The resume protocol (by absolute index) then backfills exactly
|
|
342
|
-
// the missed lines, so a reconnect over a still-healthy socket is a cheap,
|
|
343
|
-
// duplicate-free no-op rather than a risk.
|
|
369
|
+
// teardown aborts and closes the live socket (if any) and stops the heartbeat
|
|
370
|
+
// and any scheduled reconnect, leaving the module disconnected. It never touches
|
|
371
|
+
// per-session resume state, so a later connect() resumes cleanly. Shared by
|
|
372
|
+
// reconnectNow (reconnects after) and disconnect/forgetSession (do not).
|
|
373
|
+
function teardown(): void {
|
|
344
374
|
if (connState.status === "connecting" || connState.status === "connected") {
|
|
345
375
|
// Abort BEFORE close: aborting detaches all listeners on the existing
|
|
346
376
|
// sock, so frames arriving between close() and the close handshake aren't
|
|
@@ -355,9 +385,64 @@ export function reconnectNow(): void {
|
|
|
355
385
|
stopHeartbeat();
|
|
356
386
|
cancelScheduledReconnect();
|
|
357
387
|
connState = { status: "disconnected" };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function reconnectNow(): void {
|
|
391
|
+
// Unconditional teardown. On iOS wake (visibilitychange + pageshow), the
|
|
392
|
+
// socket frequently reads OPEN/"connected" for a while but is actually a
|
|
393
|
+
// zombie — the OS froze it during sleep and frames printed meanwhile never
|
|
394
|
+
// arrive. The old early-return on "connected" trusted that stale state and
|
|
395
|
+
// skipped the reconnect, which is exactly why content printed during sleep
|
|
396
|
+
// stayed missing until a manual refresh (bug 2). So we never trust the
|
|
397
|
+
// current state on a wake: abort + close whatever socket exists and
|
|
398
|
+
// reconnect. The resume protocol (by absolute index) then backfills exactly
|
|
399
|
+
// the missed lines, so a reconnect over a still-healthy socket is a cheap,
|
|
400
|
+
// duplicate-free no-op rather than a risk.
|
|
401
|
+
teardown();
|
|
358
402
|
connect();
|
|
359
403
|
}
|
|
360
404
|
|
|
405
|
+
/**
|
|
406
|
+
* setSession switches the live socket to a different server session, keeping
|
|
407
|
+
* every session's resume state intact (design section 5, the switch). The
|
|
408
|
+
* current socket is torn down (its outbox and byte counters preserved for a
|
|
409
|
+
* later switch back) and a fresh socket connects to `id` with its own resume
|
|
410
|
+
* state, sending `?session=id`. Calling this marks the module "managed": the WS
|
|
411
|
+
* URL then carries the session id. A no-op when `id` is already the active,
|
|
412
|
+
* connected session.
|
|
413
|
+
*/
|
|
414
|
+
export function setSession(id: string): void {
|
|
415
|
+
managed = true;
|
|
416
|
+
ensureState(id);
|
|
417
|
+
if (id === activeId && (connState.status === "connected" || connState.status === "connecting")) {
|
|
418
|
+
return; // already serving this session
|
|
419
|
+
}
|
|
420
|
+
activeId = id;
|
|
421
|
+
reconnectNow();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* forgetSession drops a session's resume state (on tab close, design section 17).
|
|
426
|
+
* If it was the active session, the live socket is torn down without
|
|
427
|
+
* reconnecting; the shell then selects another tab via setSession.
|
|
428
|
+
*/
|
|
429
|
+
export function forgetSession(id: string): void {
|
|
430
|
+
sessions.delete(id);
|
|
431
|
+
if (id === activeId) {
|
|
432
|
+
activeId = null;
|
|
433
|
+
teardown();
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* disconnect tears down the live socket without reconnecting. Per-session resume
|
|
439
|
+
* state is kept, so a later setSession/connect resumes cleanly. Used when the
|
|
440
|
+
* shell has no active tab to show (e.g. the last tab closed).
|
|
441
|
+
*/
|
|
442
|
+
export function disconnect(): void {
|
|
443
|
+
teardown();
|
|
444
|
+
}
|
|
445
|
+
|
|
361
446
|
export function connect(): void {
|
|
362
447
|
// Guard against double-call: a stray invocation while a previous
|
|
363
448
|
// socket is still CONNECTING/OPEN would orphan it (its handlers
|
|
@@ -384,7 +469,18 @@ export function connect(): void {
|
|
|
384
469
|
|
|
385
470
|
cb?.onConnecting?.();
|
|
386
471
|
|
|
387
|
-
|
|
472
|
+
// The resume state this socket serves, captured for the socket's lifetime.
|
|
473
|
+
// A switch aborts this socket's listeners, so even a late frame is handled
|
|
474
|
+
// against the session it was opened for, never whoever is active now.
|
|
475
|
+
const st = activeState();
|
|
476
|
+
let url = wsURL(location.protocol, location.host, wsPath);
|
|
477
|
+
if (managed) {
|
|
478
|
+
// Route the socket to this session (SessionManager's WebSocketHandler
|
|
479
|
+
// dispatches on ?session=). The unmanaged single-terminal path keeps the
|
|
480
|
+
// bare wsPath, matching resume purely by the resume frame's sessionId.
|
|
481
|
+
url += (url.includes("?") ? "&" : "?") + "session=" + encodeURIComponent(st.id);
|
|
482
|
+
}
|
|
483
|
+
const sock = new WebSocket(url);
|
|
388
484
|
sock.binaryType = "arraybuffer";
|
|
389
485
|
|
|
390
486
|
// One AbortController governs the lifetime of THIS sock's listeners.
|
|
@@ -427,8 +523,8 @@ export function connect(): void {
|
|
|
427
523
|
sock.send(
|
|
428
524
|
controlFrame({
|
|
429
525
|
type: "resume",
|
|
430
|
-
sessionId,
|
|
431
|
-
sentBytes: bytesSent,
|
|
526
|
+
sessionId: st.id,
|
|
527
|
+
sentBytes: st.bytesSent,
|
|
432
528
|
// Highest absolute line index the client holds (-1 if none). The
|
|
433
529
|
// server replays everything after it, so lines printed while the
|
|
434
530
|
// device slept are backfilled exactly on wake (bug 2), with no
|
|
@@ -499,14 +595,14 @@ export function connect(): void {
|
|
|
499
595
|
// of the previous boot's input). Reset state and notify the UI.
|
|
500
596
|
const epoch = msg.serverEpoch;
|
|
501
597
|
if (epoch !== undefined && epoch !== 0) {
|
|
502
|
-
if (lastServerEpoch !== null && lastServerEpoch !== epoch) {
|
|
503
|
-
bytesSent = 0;
|
|
504
|
-
bytesAcked = 0;
|
|
505
|
-
outbox.length = 0;
|
|
506
|
-
outboxBytes = 0;
|
|
598
|
+
if (st.lastServerEpoch !== null && st.lastServerEpoch !== epoch) {
|
|
599
|
+
st.bytesSent = 0;
|
|
600
|
+
st.bytesAcked = 0;
|
|
601
|
+
st.outbox.length = 0;
|
|
602
|
+
st.outboxBytes = 0;
|
|
507
603
|
cb?.onServerRestart?.();
|
|
508
604
|
}
|
|
509
|
-
lastServerEpoch = epoch;
|
|
605
|
+
st.lastServerEpoch = epoch;
|
|
510
606
|
}
|
|
511
607
|
// Resync guard 8.2.2: hand the server's retained-history bounds to the
|
|
512
608
|
// consumer so it can surface a trim marker when history the client was
|
|
@@ -525,16 +621,16 @@ export function connect(): void {
|
|
|
525
621
|
// since the last successful ack is irrecoverable but at least
|
|
526
622
|
// not duplicated. Skip this branch when bytesSent = 0 (genuine
|
|
527
623
|
// first-connect; received=0 is correct).
|
|
528
|
-
if (msg.received === 0 && bytesAcked > 0) {
|
|
529
|
-
bytesSent = 0;
|
|
530
|
-
bytesAcked = 0;
|
|
531
|
-
outbox.length = 0;
|
|
532
|
-
outboxBytes = 0;
|
|
624
|
+
if (msg.received === 0 && st.bytesAcked > 0) {
|
|
625
|
+
st.bytesSent = 0;
|
|
626
|
+
st.bytesAcked = 0;
|
|
627
|
+
st.outbox.length = 0;
|
|
628
|
+
st.outboxBytes = 0;
|
|
533
629
|
cb?.onServerRestart?.();
|
|
534
630
|
return;
|
|
535
631
|
}
|
|
536
|
-
applyAck(msg.received);
|
|
537
|
-
retransmitOutbox();
|
|
632
|
+
applyAck(st, msg.received);
|
|
633
|
+
retransmitOutbox(sock, st);
|
|
538
634
|
return;
|
|
539
635
|
}
|
|
540
636
|
if (msg.type === "modes") {
|
|
@@ -546,9 +642,10 @@ export function connect(): void {
|
|
|
546
642
|
msg.mouseMode,
|
|
547
643
|
msg.applicationKeypad,
|
|
548
644
|
msg.reverseVideo,
|
|
645
|
+
msg.mousePixels,
|
|
549
646
|
);
|
|
550
647
|
if (typeof msg.inputAck === "number") {
|
|
551
|
-
applyAck(msg.inputAck);
|
|
648
|
+
applyAck(st, msg.inputAck);
|
|
552
649
|
}
|
|
553
650
|
// Notify the UI so it can react to mode changes (e.g. clear
|
|
554
651
|
// scrollback on alt-screen entry — handled by the caller).
|
|
@@ -556,7 +653,7 @@ export function connect(): void {
|
|
|
556
653
|
return;
|
|
557
654
|
}
|
|
558
655
|
if (typeof msg.inputAck === "number") {
|
|
559
|
-
applyAck(msg.inputAck);
|
|
656
|
+
applyAck(st, msg.inputAck);
|
|
560
657
|
}
|
|
561
658
|
cb?.onMessage(msg);
|
|
562
659
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export * from "./types.js";
|
|
2
|
+
export { LineStore } from "./store.js";
|
|
3
|
+
export type { WindowState, StoreChanges } from "./store.js";
|
|
2
4
|
export * as render from "./render.js";
|
|
3
5
|
export * as keyboard from "./keyboard.js";
|
|
4
6
|
export * as scroll from "./scroll.js";
|
|
@@ -8,3 +10,11 @@ export * as connection from "./connection.js";
|
|
|
8
10
|
export { decodeWireBinary } from "./wire-binary.js";
|
|
9
11
|
export { controlFrame, CONTROL_FRAME_PREFIX } from "./wire.js";
|
|
10
12
|
export { wsURL } from "./wsurl.js";
|
|
13
|
+
export { connectStatusStream } from "./status-stream.js";
|
|
14
|
+
export type {
|
|
15
|
+
SessionStatus,
|
|
16
|
+
StatusStream,
|
|
17
|
+
StatusStreamCallbacks,
|
|
18
|
+
EventSourceFactory,
|
|
19
|
+
EventSourceLike,
|
|
20
|
+
} from "./status-stream.js";
|
package/src/keyboard.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// vt screen tracks both bracketed paste (?2004) and DECCKM (?1) and
|
|
15
15
|
// emits a modes frame whenever they change.
|
|
16
16
|
|
|
17
|
-
import { isApplicationCursor,
|
|
17
|
+
import { isApplicationCursor, isBracketedPaste } from "./modes.js";
|
|
18
18
|
|
|
19
19
|
/** Result of mapping a keyboard event. */
|
|
20
20
|
export type KeyboardResult =
|
|
@@ -23,6 +23,18 @@ export type KeyboardResult =
|
|
|
23
23
|
| { kind: "scroll-down" } // Shift+PageDown — handled locally
|
|
24
24
|
| { kind: "ignore" }; // Modifier-only press, etc.
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* KeyboardModes is the mode state `mapKeyboardEvent` reads: DECCKM (application
|
|
28
|
+
* cursor keys) and DECKPAM (application keypad). Passed explicitly so the shared
|
|
29
|
+
* input maps against the active tab's modes and so the mapping is testable
|
|
30
|
+
* without mutating global state. The `modes` module namespace satisfies this
|
|
31
|
+
* structurally; a tabbed shell passes its active session's modes.
|
|
32
|
+
*/
|
|
33
|
+
export interface KeyboardModes {
|
|
34
|
+
isApplicationCursor: () => boolean;
|
|
35
|
+
isApplicationKeypad: () => boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
26
38
|
const ESC = "\x1b";
|
|
27
39
|
const DEL = "\x7f";
|
|
28
40
|
|
|
@@ -42,11 +54,11 @@ function modifiersDigit(ev: KeyboardEvent): number {
|
|
|
42
54
|
// When the application has set DECCKM, the modifier-less form switches
|
|
43
55
|
// from CSI to SS3 (ESC O letter); modifier-bearing forms stay on CSI
|
|
44
56
|
// because they have no SS3 equivalent.
|
|
45
|
-
function csiLetter(letter: string, ev: KeyboardEvent): string {
|
|
57
|
+
function csiLetter(letter: string, ev: KeyboardEvent, modes: KeyboardModes): string {
|
|
46
58
|
const m = modifiersDigit(ev);
|
|
47
59
|
if (m === 1) {
|
|
48
60
|
if (
|
|
49
|
-
isApplicationCursor() &&
|
|
61
|
+
modes.isApplicationCursor() &&
|
|
50
62
|
(letter === "A" ||
|
|
51
63
|
letter === "B" ||
|
|
52
64
|
letter === "C" ||
|
|
@@ -123,8 +135,12 @@ const KEYPAD_SS3: Record<string, string | undefined> = {
|
|
|
123
135
|
* Caller is responsible for ev.preventDefault() when the result is
|
|
124
136
|
* "send" or "scroll-*"; we don't call it here so the function stays
|
|
125
137
|
* pure and testable.
|
|
138
|
+
*
|
|
139
|
+
* `modes` supplies the active session's DECCKM/DECKPAM state; pass the `modes`
|
|
140
|
+
* module namespace for the single-terminal case or the active tab's modes in a
|
|
141
|
+
* tabbed shell.
|
|
126
142
|
*/
|
|
127
|
-
export function mapKeyboardEvent(ev: KeyboardEvent): KeyboardResult {
|
|
143
|
+
export function mapKeyboardEvent(ev: KeyboardEvent, modes: KeyboardModes): KeyboardResult {
|
|
128
144
|
// Modifier-only presses (Shift, Ctrl, Alt, Meta) — no-op.
|
|
129
145
|
if (ev.key === "Shift" || ev.key === "Control" || ev.key === "Alt" || ev.key === "Meta") {
|
|
130
146
|
return { kind: "ignore" };
|
|
@@ -139,7 +155,7 @@ export function mapKeyboardEvent(ev: KeyboardEvent): KeyboardResult {
|
|
|
139
155
|
// We detect numpad keys via ev.code (Numpad0-9, NumpadDecimal,
|
|
140
156
|
// NumpadSubtract, NumpadAdd, NumpadMultiply, NumpadDivide, NumpadEnter).
|
|
141
157
|
if (
|
|
142
|
-
isApplicationKeypad() &&
|
|
158
|
+
modes.isApplicationKeypad() &&
|
|
143
159
|
!ev.ctrlKey &&
|
|
144
160
|
!ev.altKey &&
|
|
145
161
|
!ev.metaKey &&
|
|
@@ -175,15 +191,15 @@ export function mapKeyboardEvent(ev: KeyboardEvent): KeyboardResult {
|
|
|
175
191
|
// Cursor keys — CSI form with optional modifiers (CSI 1;{m}{letter}).
|
|
176
192
|
const arrow = ARROW_LETTER[ev.key];
|
|
177
193
|
if (arrow !== undefined) {
|
|
178
|
-
return { kind: "send", bytes: csiLetter(arrow, ev) };
|
|
194
|
+
return { kind: "send", bytes: csiLetter(arrow, ev, modes) };
|
|
179
195
|
}
|
|
180
196
|
|
|
181
197
|
// Home / End — CSI {H,F} with optional modifiers.
|
|
182
198
|
if (ev.key === "Home") {
|
|
183
|
-
return { kind: "send", bytes: csiLetter("H", ev) };
|
|
199
|
+
return { kind: "send", bytes: csiLetter("H", ev, modes) };
|
|
184
200
|
}
|
|
185
201
|
if (ev.key === "End") {
|
|
186
|
-
return { kind: "send", bytes: csiLetter("F", ev) };
|
|
202
|
+
return { kind: "send", bytes: csiLetter("F", ev, modes) };
|
|
187
203
|
}
|
|
188
204
|
|
|
189
205
|
// Insert / Delete / PageUp / PageDown (no Shift) — CSI tilde forms.
|
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
|
@@ -110,7 +110,11 @@ function measureCellWidth(): number {
|
|
|
110
110
|
let output: HTMLElement;
|
|
111
111
|
let termWrap: HTMLElement;
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
// The store the renderer reflects. Module-private by default (a consumer that
|
|
114
|
+
// never calls bind() gets one implicit store, the original single-terminal
|
|
115
|
+
// behavior). The tabs feature keeps one LineStore per tab and calls bind() on
|
|
116
|
+
// switch to point the renderer at the active tab's cache (design section 6).
|
|
117
|
+
let store = new LineStore();
|
|
114
118
|
// abs index -> its row element. The DOM children of `output` are these
|
|
115
119
|
// elements, kept in ascending data-abs order.
|
|
116
120
|
const rowEls = new Map<number, HTMLDivElement>();
|
|
@@ -215,6 +219,69 @@ export function resetScrollback(): void {
|
|
|
215
219
|
scheduleFlush();
|
|
216
220
|
}
|
|
217
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Bind the renderer to a different store and rebuild the surface from it. The
|
|
224
|
+
* tabs feature calls this on every switch to point the one renderer at the
|
|
225
|
+
* active tab's cached LineStore (design sections 5, 6, 8). The DOM is wiped and
|
|
226
|
+
* repainted viewport-first from the new store; this is local, so the last-known
|
|
227
|
+
* screen paints without a network round-trip.
|
|
228
|
+
*/
|
|
229
|
+
export function bind(next: LineStore): void {
|
|
230
|
+
store = next;
|
|
231
|
+
rebuild();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The store the renderer is currently bound to. Exposed so the shell can feed
|
|
236
|
+
* decoded frames into the active tab's store and read its resume bounds.
|
|
237
|
+
*/
|
|
238
|
+
export function boundStore(): LineStore {
|
|
239
|
+
return store;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Wipe the DOM and rebuild it from the current store, viewport-first and
|
|
244
|
+
* budgeted. The live window (what the user sees) builds first, then scrollback
|
|
245
|
+
* from newest to oldest so rows adjacent to the viewport fill in before deep
|
|
246
|
+
* history; the existing per-frame budget spreads a large backlog across frames
|
|
247
|
+
* so the switch never janks. Used by bind(); also safe to call directly to
|
|
248
|
+
* force a full repaint of the current store.
|
|
249
|
+
*/
|
|
250
|
+
export function rebuild(): void {
|
|
251
|
+
output.replaceChildren();
|
|
252
|
+
rowEls.clear();
|
|
253
|
+
renderQueue.clear();
|
|
254
|
+
trimMarkerEl = null;
|
|
255
|
+
cursorAbs = -1;
|
|
256
|
+
altRendered = false;
|
|
257
|
+
prevCursorCol = -1;
|
|
258
|
+
prevCursorHidden = false;
|
|
259
|
+
prevCursorStyleVal = -1;
|
|
260
|
+
// Alt screen paints from the ephemeral grid in the flush, so it needs no
|
|
261
|
+
// absolute-index queueing here.
|
|
262
|
+
if (!store.isAlt()) {
|
|
263
|
+
const oldest = store.oldestIndex();
|
|
264
|
+
const highest = store.highestIndex();
|
|
265
|
+
if (highest >= 0) {
|
|
266
|
+
const winStart = Math.max(store.getWindow().base, oldest);
|
|
267
|
+
// Live window first (viewport), ascending.
|
|
268
|
+
for (let abs = winStart; abs <= highest; abs++) {
|
|
269
|
+
if (store.getLine(abs) !== undefined) {
|
|
270
|
+
renderQueue.add(abs);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// Then scrollback, newest to oldest, so rows nearest the viewport fill in
|
|
274
|
+
// before deep history.
|
|
275
|
+
for (let abs = winStart - 1; abs >= oldest; abs--) {
|
|
276
|
+
if (store.getLine(abs) !== undefined) {
|
|
277
|
+
renderQueue.add(abs);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
scheduleFlush();
|
|
283
|
+
}
|
|
284
|
+
|
|
218
285
|
/**
|
|
219
286
|
* Highest absolute line index the client holds, or -1 if empty. This is the
|
|
220
287
|
* resume `haveThrough` value (it replaces the old DOM-row count). Exposed so
|
|
@@ -408,6 +475,12 @@ function buildRowSpans(runs: WireRun[], cursorAt: number): (HTMLSpanElement | HT
|
|
|
408
475
|
prev.style.letterSpacing = `${cellWidth * 2 - w}px`;
|
|
409
476
|
}
|
|
410
477
|
}
|
|
478
|
+
// The spacer occupies the wide char's second cell, so it advances the
|
|
479
|
+
// column exactly like a printed cell. The engine reports cursor_col in
|
|
480
|
+
// true cell coordinates (a wide glyph moves curX by 2), so col must
|
|
481
|
+
// count this cell too — otherwise a visible cursor positioned after a
|
|
482
|
+
// wide char lands one cell too far right per preceding wide char.
|
|
483
|
+
col++;
|
|
411
484
|
continue;
|
|
412
485
|
}
|
|
413
486
|
if (col === cursorAt) {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Client for the server's session status stream (Server-Sent Events at
|
|
2
|
+
// /api/sessions/events). A thin EventSource wrapper: it parses each status event
|
|
3
|
+
// and fans it out to a callback. The reconnect-resync policy (re-fetching the
|
|
4
|
+
// session list after a gap) is the consumer's job; onOpen fires on every
|
|
5
|
+
// (re)open so the consumer can trigger it. Pairs with the Go
|
|
6
|
+
// terminal.SessionManager EventsHandler.
|
|
7
|
+
|
|
8
|
+
/** SessionStatus is one session's current status as carried on the stream. It
|
|
9
|
+
* mirrors the server's status event. */
|
|
10
|
+
export interface SessionStatus {
|
|
11
|
+
readonly id: string;
|
|
12
|
+
readonly status: "working" | "idle" | "input" | "exited";
|
|
13
|
+
readonly title: string;
|
|
14
|
+
readonly createdAt: string;
|
|
15
|
+
/** true when the session is gone (closed or reaped); the consumer drops it. */
|
|
16
|
+
readonly removed?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** StatusStreamCallbacks are the consumer's hooks. Declared as function-typed
|
|
20
|
+
* properties (not method shorthand) so destructuring them is safe. */
|
|
21
|
+
export interface StatusStreamCallbacks {
|
|
22
|
+
/** Called for each status event. */
|
|
23
|
+
onStatus: (status: SessionStatus) => void;
|
|
24
|
+
/** Called on every (re)open, including auto-reconnects, so the consumer can
|
|
25
|
+
* resync after a gap (the stream only carries future changes). */
|
|
26
|
+
onOpen?: () => void;
|
|
27
|
+
/** Called when the stream errors; the EventSource then auto-reconnects. */
|
|
28
|
+
onError?: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** EventSourceLike is the minimal surface used here, so the stream can be tested
|
|
32
|
+
* without a DOM EventSource (the default factory adapts a real one). */
|
|
33
|
+
export interface EventSourceLike {
|
|
34
|
+
addEventListener: (type: string, listener: (event: MessageEvent) => void) => void;
|
|
35
|
+
close: () => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** EventSourceFactory builds an EventSourceLike for a URL. */
|
|
39
|
+
export type EventSourceFactory = (url: string) => EventSourceLike;
|
|
40
|
+
|
|
41
|
+
export interface StatusStream {
|
|
42
|
+
/** Closes the stream and stops reconnection. */
|
|
43
|
+
close: () => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const defaultFactory: EventSourceFactory = (url) => {
|
|
47
|
+
const es = new EventSource(url);
|
|
48
|
+
return {
|
|
49
|
+
addEventListener: (type, listener) => {
|
|
50
|
+
es.addEventListener(type, listener as unknown as EventListener);
|
|
51
|
+
},
|
|
52
|
+
close: () => {
|
|
53
|
+
es.close();
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** connectStatusStream opens the status stream at path and fans events out to
|
|
59
|
+
* cb. make defaults to a real EventSource; tests inject a fake. */
|
|
60
|
+
export function connectStatusStream(
|
|
61
|
+
path: string,
|
|
62
|
+
cb: StatusStreamCallbacks,
|
|
63
|
+
make: EventSourceFactory = defaultFactory,
|
|
64
|
+
): StatusStream {
|
|
65
|
+
const es = make(path);
|
|
66
|
+
const { onOpen, onError } = cb;
|
|
67
|
+
if (onOpen) {
|
|
68
|
+
es.addEventListener("open", () => {
|
|
69
|
+
onOpen();
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
es.addEventListener("message", (event) => {
|
|
73
|
+
const raw: unknown = event.data;
|
|
74
|
+
if (typeof raw !== "string") {
|
|
75
|
+
return; // non-text frame, ignore
|
|
76
|
+
}
|
|
77
|
+
let status: SessionStatus;
|
|
78
|
+
try {
|
|
79
|
+
status = JSON.parse(raw) as SessionStatus;
|
|
80
|
+
} catch {
|
|
81
|
+
return; // skip a malformed frame, keep the stream
|
|
82
|
+
}
|
|
83
|
+
cb.onStatus(status);
|
|
84
|
+
});
|
|
85
|
+
if (onError) {
|
|
86
|
+
es.addEventListener("error", () => {
|
|
87
|
+
onError();
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
close: () => {
|
|
92
|
+
es.close();
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
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
|
-
|
|
|
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;
|
|
@@ -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
|