@cplieger/web-terminal-engine 1.4.0 → 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 +2 -2
- package/src/connection.ts +168 -72
- package/src/index.ts +10 -0
- package/src/keyboard.ts +24 -8
- package/src/render.ts +68 -1
- package/src/status-stream.ts +95 -0
package/jsr.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"fast-check": "4.8.0",
|
|
19
19
|
"happy-dom": "20.10.6",
|
|
20
20
|
"pngjs": "7.0.0",
|
|
21
|
-
"prettier": "3.9.
|
|
21
|
+
"prettier": "3.9.4",
|
|
22
22
|
"typescript": "6.0.3",
|
|
23
23
|
"typescript-eslint": "8.62.1",
|
|
24
24
|
"vitest": "4.1.9"
|
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") {
|
|
@@ -549,7 +645,7 @@ export function connect(): void {
|
|
|
549
645
|
msg.mousePixels,
|
|
550
646
|
);
|
|
551
647
|
if (typeof msg.inputAck === "number") {
|
|
552
|
-
applyAck(msg.inputAck);
|
|
648
|
+
applyAck(st, msg.inputAck);
|
|
553
649
|
}
|
|
554
650
|
// Notify the UI so it can react to mode changes (e.g. clear
|
|
555
651
|
// scrollback on alt-screen entry — handled by the caller).
|
|
@@ -557,7 +653,7 @@ export function connect(): void {
|
|
|
557
653
|
return;
|
|
558
654
|
}
|
|
559
655
|
if (typeof msg.inputAck === "number") {
|
|
560
|
-
applyAck(msg.inputAck);
|
|
656
|
+
applyAck(st, msg.inputAck);
|
|
561
657
|
}
|
|
562
658
|
cb?.onMessage(msg);
|
|
563
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/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
|
|
@@ -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
|
+
}
|