@cplieger/web-terminal-engine 3.0.7 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -1
- package/jsr.json +3 -2
- package/package.json +11 -6
- package/src/connection.ts +54 -16
- package/src/render.ts +78 -0
- package/src/scroll.ts +35 -2
- package/src/status-stream.ts +44 -6
- package/src/types.ts +5 -3
- package/src/wire-manifest.ts +77 -0
- package/wire-compatibility.json +9 -0
package/README.md
CHANGED
|
@@ -84,12 +84,50 @@ document.addEventListener("keydown", (ev) => {
|
|
|
84
84
|
- **`scroll`** — Auto-follow tracker for the scroll container. `init`, `stickToBottom`, `scrollToBottom`, `isUserScrolledUp`.
|
|
85
85
|
- **`modes`** — DEC private mode state (synced from server's `ModesMessage`). `setModes`, `isBracketedPaste`, `isApplicationCursor`, `getMouseMode`, `isMouseSGR`, `isFocusReporting`, `isApplicationKeypad`, `isReverseVideo`.
|
|
86
86
|
- **`decodeWireBinary(buf)`**: Top-level decoder for the binary WebSocket frames. Returns a `ServerMessage` or `null` for invalid/truncated frames.
|
|
87
|
-
- **Wire compatibility metadata**: `WIRE_PROTOCOL_VERSION`, `MIN_SUPPORTED_SERVER_WIRE_VERSION`, `WIRE_INCOMPATIBLE_CLOSE_CODE`, and `WIRE_COMPATIBILITY` publish this client release's directional contract.
|
|
87
|
+
- **Wire compatibility metadata**: `WIRE_PROTOCOL_VERSION`, `MIN_SUPPORTED_SERVER_WIRE_VERSION`, `WIRE_INCOMPATIBLE_CLOSE_CODE`, and `WIRE_COMPATIBILITY` publish this client release's directional contract. The same values ship as a language-neutral JSON artifact for non-TypeScript consumers — see [Wire compatibility manifest](#wire-compatibility-manifest).
|
|
88
88
|
- **`connection`**: Client → server WebSocket lifecycle: owns the socket, exponential-backoff reconnect, and the resume/inputAck reliability layer (outbox + server-restart detection). `init(callbacks)`, `connect`, `sendBinary(bytes)`, `sendResize`, `reconnectNow`. The callbacks expose `onMessage(ServerMessage)`, `onOpen`/`onClose`/`onConnecting`/`onOutboxFull`/`onServerRestart`, `onWireVersionMismatch`, and the definitive `onWireIncompatible`; a `computeSize()` provider; and an optional `wsPath` (defaults to `"/ws"`). An explicit below-floor server revision or close code 4002 stops automatic reconnects until `disconnect()` or a page reload. Version-silent and future-revision servers remain tolerated. The module decodes frames internally and applies `modes.setModes`, so a consumer only needs to dispatch screen/scroll to `render`. Prefer this over wiring `WebSocket` + `decodeWireBinary` by hand unless you need full control.
|
|
89
89
|
- **`controlFrame(msg)` / `wsURL(proto, host, path?)`** — Low-level helpers for the client → server protocol (0x00-prefixed JSON control frames, WebSocket URL building). Used internally by `connection`; exported for advanced consumers.
|
|
90
90
|
|
|
91
91
|
Wire types (`WireRun`, `ScreenMessage`, `ScrollMessage`, `ModesMessage`, `TitleMessage`, `ResumeAckMessage`, `ServerMessage`, `ControlMessage`) are re-exported from the package root and match the Go server's wire format byte-for-byte.
|
|
92
92
|
|
|
93
|
+
## Wire compatibility manifest
|
|
94
|
+
|
|
95
|
+
The same compatibility numbers are also published as a language-neutral JSON artifact, so a consumer that cannot import TypeScript — a Dockerfile, a shell release gate, a CI script in any language — can read them without scraping source:
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
# npm consumer
|
|
99
|
+
jq -r .wireCompatibility.protocolVersion \
|
|
100
|
+
node_modules/@cplieger/web-terminal-engine/wire-compatibility.json
|
|
101
|
+
|
|
102
|
+
# JSR consumer (published as an included file; JSR exports are modules only)
|
|
103
|
+
curl -fsSL https://jsr.io/@cplieger/web-terminal-engine/<version>/wire-compatibility.json
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"schemaVersion": 1,
|
|
109
|
+
"generatedBy": "web/src/wire-manifest.ts",
|
|
110
|
+
"wireCompatibility": {
|
|
111
|
+
"protocolVersion": 4,
|
|
112
|
+
"minimumServerProtocolVersion": 3,
|
|
113
|
+
"incompatibleCloseCode": 4002
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
It is generated from `WIRE_COMPATIBILITY` (there is no second copy of the numbers), checked into the repo because the publish pipeline runs no build step, and guarded by a regenerate-and-diff test so it cannot go stale. Its values are pinned to both the TypeScript constants and the Go `terminal` constants, so the three surfaces cannot diverge.
|
|
119
|
+
|
|
120
|
+
**What you may rely on** (semver-governed public artifact, versioned by the package version):
|
|
121
|
+
|
|
122
|
+
- The file is present at the package root of every npm tarball and JSR publish, at path `wire-compatibility.json`, and is importable from npm as `@cplieger/web-terminal-engine/wire-compatibility.json`.
|
|
123
|
+
- `schemaVersion` is an integer describing this file's LAYOUT, not the wire protocol. Read it first and reject a value you do not understand.
|
|
124
|
+
- Within a `schemaVersion`, the fields under `wireCompatibility` keep their names, types and meanings, and equal the correspondingly named `WIRE_COMPATIBILITY` members of the same release.
|
|
125
|
+
- New fields may be added under either object in a MINOR release, so parse permissively (ignore unknown keys).
|
|
126
|
+
|
|
127
|
+
**What is not part of the contract**: `generatedBy` is an informational provenance note. The manifest deliberately carries no package version — the publish step injects that into `package.json` / `jsr.json` after checkout, so a version here would ship frozen at the repo placeholder; read the package version from those files.
|
|
128
|
+
|
|
129
|
+
**Breaking changes to the manifest** (MAJOR, and always release-noted): removing or renaming a field, changing a field's type or meaning, moving the file, or bumping `schemaVersion`. A change to the wire revision NUMBERS is not a manifest break — reporting them is what the file is for.
|
|
130
|
+
|
|
93
131
|
## Browser-only
|
|
94
132
|
|
|
95
133
|
This package depends on `document`, `HTMLElement`, `MessageChannel`, and other DOM APIs, so it only runs in browser-like environments. The companion Go server runs anywhere Go does.
|
package/jsr.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-engine",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"exports": {
|
|
5
5
|
".": "./src/index.ts"
|
|
6
6
|
},
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"include": [
|
|
9
9
|
"LICENSE",
|
|
10
10
|
"README.md",
|
|
11
|
-
"src/**/*.ts"
|
|
11
|
+
"src/**/*.ts",
|
|
12
|
+
"wire-compatibility.json"
|
|
12
13
|
],
|
|
13
14
|
"exclude": [
|
|
14
15
|
"src/**/*.test.ts",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-engine",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"typecheck": "tsc -p tsconfig.json",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"devDependencies": {
|
|
12
12
|
"@cplieger/web-terminal-engine-v3": "npm:@cplieger/web-terminal-engine@2.8.0",
|
|
13
13
|
"@eslint/js": "10.0.1",
|
|
14
|
-
"@playwright/test": "1.
|
|
14
|
+
"@playwright/test": "1.62.0",
|
|
15
15
|
"@stryker-mutator/core": "9.6.1",
|
|
16
16
|
"@stryker-mutator/typescript-checker": "9.6.1",
|
|
17
17
|
"@stryker-mutator/vitest-runner": "9.6.1",
|
|
@@ -19,19 +19,23 @@
|
|
|
19
19
|
"@typescript/native": "npm:typescript@7.0.2",
|
|
20
20
|
"@vitest/coverage-v8": "4.1.10",
|
|
21
21
|
"esbuild": "0.28.1",
|
|
22
|
-
"eslint": "10.
|
|
22
|
+
"eslint": "10.8.0",
|
|
23
23
|
"fast-check": "4.9.0",
|
|
24
|
-
"happy-dom": "20.11.
|
|
24
|
+
"happy-dom": "20.11.1",
|
|
25
25
|
"pngjs": "7.0.0",
|
|
26
26
|
"prettier": "3.9.6",
|
|
27
27
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
28
28
|
"typescript-eslint": "8.65.0",
|
|
29
29
|
"vitest": "4.1.10"
|
|
30
30
|
},
|
|
31
|
+
"overrides": {
|
|
32
|
+
"qs": "^6.15.2"
|
|
33
|
+
},
|
|
31
34
|
"description": "Browser terminal engine: VT500 screen buffer, DOM renderer, and binary wire protocol",
|
|
32
35
|
"license": "GPL-3.0-or-later",
|
|
33
36
|
"exports": {
|
|
34
|
-
".": "./src/index.ts"
|
|
37
|
+
".": "./src/index.ts",
|
|
38
|
+
"./wire-compatibility.json": "./wire-compatibility.json"
|
|
35
39
|
},
|
|
36
40
|
"types": "./src/index.ts",
|
|
37
41
|
"publishConfig": {
|
|
@@ -47,6 +51,7 @@
|
|
|
47
51
|
"!src/**/*.test.ts",
|
|
48
52
|
"!src/**/test-helpers",
|
|
49
53
|
"!src/**/fc-strict-setup.ts",
|
|
50
|
-
"jsr.json"
|
|
54
|
+
"jsr.json",
|
|
55
|
+
"wire-compatibility.json"
|
|
51
56
|
]
|
|
52
57
|
}
|
package/src/connection.ts
CHANGED
|
@@ -278,12 +278,16 @@ export interface Callbacks {
|
|
|
278
278
|
* transient treatment (onClose + backoff reconnect), so existing
|
|
279
279
|
* consumers are unaffected until they opt in. */
|
|
280
280
|
onProcessExit?(): void;
|
|
281
|
-
/** Fired when the
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
281
|
+
/** Fired when the client's queued input can no longer be trusted, which
|
|
282
|
+
* happens two ways: the server's boot-epoch in resumeAck differs from the
|
|
283
|
+
* one observed on a previous connection (the server restarted), or the
|
|
284
|
+
* server no longer holds this session's input ledger AND the outbox still
|
|
285
|
+
* held unacked bytes. A forgotten ledger whose every byte was already acked
|
|
286
|
+
* does NOT fire this — nothing was lost, and reporting it made a phone
|
|
287
|
+
* waking from sleep announce a restart that never happened. By the time this
|
|
288
|
+
* fires, the connection module has already reset bytesSent/bytesAcked/outbox
|
|
289
|
+
* so subsequent input starts from zero. UI should surface a banner so the
|
|
290
|
+
* user knows recent input may have been lost. */
|
|
287
291
|
onServerRestart?(): void;
|
|
288
292
|
/** Fired when a resumeAck carries an explicit server revision outside
|
|
289
293
|
* [MIN_SUPPORTED_SERVER_WIRE_VERSION, WIRE_PROTOCOL_VERSION]. A newer
|
|
@@ -415,18 +419,51 @@ export function sendResize(): void {
|
|
|
415
419
|
sendControl({ type: "resize", cols, rows });
|
|
416
420
|
}
|
|
417
421
|
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
// the
|
|
421
|
-
//
|
|
422
|
-
|
|
422
|
+
// resetLedger drops a session's reliable-input accounting. Called when the
|
|
423
|
+
// server's boot epoch changes (restart) or when the server no longer recognizes
|
|
424
|
+
// the session -- both invalidate the local bytesSent/bytesAcked/outbox state.
|
|
425
|
+
// The three entry points below differ only in whether the UI hears about it.
|
|
426
|
+
// resetLedger drops a session's reliable-input accounting. Called whenever the
|
|
427
|
+
// server no longer holds the ledger these counters were keyed to (a restart, an
|
|
428
|
+
// idle-GC'd resume key, a session the server does not recognize): its
|
|
429
|
+
// replacement counts from zero, so keeping non-zero counters would make every
|
|
430
|
+
// later ack read as stale in applyAck and the outbox would never trim again.
|
|
431
|
+
function resetLedger(st: ResumeState): void {
|
|
423
432
|
st.bytesSent = 0;
|
|
424
433
|
st.bytesAcked = 0;
|
|
425
434
|
st.outbox.length = 0;
|
|
426
435
|
st.outboxBytes = 0;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// resetSessionAfterRestart resets the ledger and tells the UI the server
|
|
439
|
+
// restarted. Reserved for a boot-epoch change, which is the one cause where
|
|
440
|
+
// that sentence is literally true.
|
|
441
|
+
function resetSessionAfterRestart(st: ResumeState): void {
|
|
442
|
+
resetLedger(st);
|
|
427
443
|
cb?.onServerRestart?.();
|
|
428
444
|
}
|
|
429
445
|
|
|
446
|
+
// resetForgottenLedger handles a ledger the server no longer holds (the
|
|
447
|
+
// explicit ledgerLost flag, or the old-server received=0 heuristic). The server
|
|
448
|
+
// forgetting a ledger is not a restart and is not by itself a loss: only the
|
|
449
|
+
// UNACKED remainder was ever at risk. So the UI is notified only when there was
|
|
450
|
+
// one, and a session whose every sent byte was already acked resets silently —
|
|
451
|
+
// nothing can be replayed and nothing was lost.
|
|
452
|
+
//
|
|
453
|
+
// The silent case is the common one, and reporting it was a real defect: an
|
|
454
|
+
// iPad whose screen slept for half an hour woke, reconnected, and announced a
|
|
455
|
+
// server restart that never happened. Server-side, the ledger it claimed had
|
|
456
|
+
// received EXACTLY as many bytes as the client claimed to have sent (10531 ==
|
|
457
|
+
// 10531 in the incident logs), i.e. an empty outbox and a fully-applied
|
|
458
|
+
// session, and the banner still fired.
|
|
459
|
+
function resetForgottenLedger(st: ResumeState): void {
|
|
460
|
+
const lostUnacked = st.bytesSent > st.bytesAcked;
|
|
461
|
+
resetLedger(st);
|
|
462
|
+
if (lostUnacked) {
|
|
463
|
+
cb?.onServerRestart?.();
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
430
467
|
// applyAck drops chunks from the front of the session's outbox until the
|
|
431
468
|
// running total of unacked bytes matches (bytesSent - newAck). Runs in
|
|
432
469
|
// O(chunks_dropped) by tracking outboxBytes incrementally rather than
|
|
@@ -901,11 +938,12 @@ export function connect(): void {
|
|
|
901
938
|
// tail): the resume key missed the server's registry while we claimed
|
|
902
939
|
// sent bytes — our ledger was reclaimed (idle GC / cap eviction). The
|
|
903
940
|
// server cannot vouch for ANY previously sent input, so replaying the
|
|
904
|
-
// outbox risks duplicate execution. Deterministic drop
|
|
941
|
+
// outbox risks duplicate execution. Deterministic drop, plus a notify
|
|
942
|
+
// only when unacked bytes were actually dropped (resetForgottenLedger),
|
|
905
943
|
// covering the bytesAcked === 0 case the heuristic below cannot see
|
|
906
944
|
// (acks that never reached us before the disconnect).
|
|
907
945
|
if (msg.ledgerLost) {
|
|
908
|
-
|
|
946
|
+
resetForgottenLedger(st);
|
|
909
947
|
return;
|
|
910
948
|
}
|
|
911
949
|
// Server-doesn't-recognize-this-session safeguard (old-server
|
|
@@ -914,12 +952,12 @@ export function connect(): void {
|
|
|
914
952
|
// our session (idle GC kicked in, or sessionId persistence failed and
|
|
915
953
|
// a reload created a new one). Replaying the outbox would deliver
|
|
916
954
|
// every queued chunk again, causing the iOS tab-suspend
|
|
917
|
-
// duplicate-resend bug. Drop the outbox and notify
|
|
918
|
-
//
|
|
955
|
+
// duplicate-resend bug. Drop the outbox, and notify only if it held
|
|
956
|
+
// unacked bytes — input since the last successful ack is
|
|
919
957
|
// irrecoverable but at least not duplicated. Skip this branch when
|
|
920
958
|
// bytesSent = 0 (genuine first-connect; received=0 is correct).
|
|
921
959
|
if (msg.received === 0 && st.bytesAcked > 0) {
|
|
922
|
-
|
|
960
|
+
resetForgottenLedger(st);
|
|
923
961
|
return;
|
|
924
962
|
}
|
|
925
963
|
applyAck(st, msg.received);
|
package/src/render.ts
CHANGED
|
@@ -669,8 +669,80 @@ function scheduleFlush(): void {
|
|
|
669
669
|
pendingFrame = requestAnimationFrame(flushRender);
|
|
670
670
|
}
|
|
671
671
|
|
|
672
|
+
// --- Read-position anchoring (manual scroll anchoring) ---
|
|
673
|
+
//
|
|
674
|
+
// A flush can change the content height ABOVE the reading position: rows evicted
|
|
675
|
+
// from the top of history once the retention cap is reached, and the trim marker
|
|
676
|
+
// appearing. Every such change moves whatever the user is reading, unless the
|
|
677
|
+
// viewport is shifted by the same amount.
|
|
678
|
+
//
|
|
679
|
+
// Chrome and Firefox do that natively (scroll anchoring, `overflow-anchor`).
|
|
680
|
+
// WebKit does not implement it at all, so on Safari — including the iPad, where
|
|
681
|
+
// this UI mostly runs — a user scrolled up to read had their position dragged
|
|
682
|
+
// one line per evicted row for as long as output kept arriving.
|
|
683
|
+
//
|
|
684
|
+
// The anchor is the first row at or below the viewport top, found by binary
|
|
685
|
+
// search over the container's children: they are in document order, so their
|
|
686
|
+
// offsetTop is monotonic. ~13 reads for a full 5000-row buffer, and only while
|
|
687
|
+
// the user is actually scrolled up — a following viewport takes this path not at
|
|
688
|
+
// all, because the bottom pin owns its position.
|
|
689
|
+
interface ReadAnchor {
|
|
690
|
+
el: HTMLElement;
|
|
691
|
+
/** Where the row sat ON SCREEN: offsetTop minus the scroll offset. Measuring
|
|
692
|
+
* the screen position rather than the container offset is what makes the
|
|
693
|
+
* correction idempotent — see restoreReadAnchor. */
|
|
694
|
+
screenTop: number;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function captureReadAnchor(): ReadAnchor | null {
|
|
698
|
+
if (!scroll.isUserScrolledUp()) {
|
|
699
|
+
return null; // following: stickToBottom owns the position
|
|
700
|
+
}
|
|
701
|
+
const kids = output.children;
|
|
702
|
+
if (kids.length === 0) {
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
const offset = scroll.currentScrollTop();
|
|
706
|
+
let lo = 0;
|
|
707
|
+
let hi = kids.length - 1;
|
|
708
|
+
let found: HTMLElement | null = null;
|
|
709
|
+
while (lo <= hi) {
|
|
710
|
+
const mid = (lo + hi) >> 1;
|
|
711
|
+
const el = kids[mid] as HTMLElement;
|
|
712
|
+
if (el.offsetTop >= offset) {
|
|
713
|
+
found = el;
|
|
714
|
+
hi = mid - 1;
|
|
715
|
+
} else {
|
|
716
|
+
lo = mid + 1;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
// Everything is above the viewport top (a shrink already clamped past the
|
|
720
|
+
// last row): anchor on the last row rather than giving up.
|
|
721
|
+
const el = found ?? (kids[kids.length - 1] as HTMLElement);
|
|
722
|
+
return { el, screenTop: el.offsetTop - offset };
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function restoreReadAnchor(anchor: ReadAnchor | null): void {
|
|
726
|
+
if (anchor?.el.parentElement !== output) {
|
|
727
|
+
return; // not holding, or the anchor row was itself evicted (mass trim)
|
|
728
|
+
}
|
|
729
|
+
// Correct by how far the row DRIFTED ON SCREEN, not by how much the content
|
|
730
|
+
// above it changed. The two differ exactly when the browser already did this
|
|
731
|
+
// itself: Chrome and Firefox have native scroll anchoring, so their offsetTop
|
|
732
|
+
// change comes with a matching scrollTop change and the screen position is
|
|
733
|
+
// already right — this then measures zero drift and does nothing. Correcting
|
|
734
|
+
// the content delta instead would double-compensate there and throw the view
|
|
735
|
+
// the other way. On Safari, which has no native anchoring, the drift is the
|
|
736
|
+
// whole content delta and this is the only thing that fixes it.
|
|
737
|
+
const drift = anchor.el.offsetTop - scroll.currentScrollTop() - anchor.screenTop;
|
|
738
|
+
scroll.adjustForContentShift(drift);
|
|
739
|
+
}
|
|
740
|
+
|
|
672
741
|
function flushRender(): void {
|
|
673
742
|
pendingFrame = undefined;
|
|
743
|
+
// Measured BEFORE any DOM mutation, so the post-mutation read below reveals
|
|
744
|
+
// exactly how much the content above the reading position moved.
|
|
745
|
+
const anchor = captureReadAnchor();
|
|
674
746
|
try {
|
|
675
747
|
flushRenderInner();
|
|
676
748
|
// A clean pass means the error condition (if any) has cleared, so give a
|
|
@@ -704,6 +776,12 @@ function flushRender(): void {
|
|
|
704
776
|
console.error("vterm: giving up render retry after repeated no-progress errors");
|
|
705
777
|
}
|
|
706
778
|
}
|
|
779
|
+
// Two position invariants, applied after every DOM mutation and in this order:
|
|
780
|
+
// hold the reader's line against content that shifted above it, then (only if
|
|
781
|
+
// following) pin to the new bottom. They are mutually exclusive by
|
|
782
|
+
// construction — the anchor is null while following — so the order is for
|
|
783
|
+
// readability, not correctness.
|
|
784
|
+
restoreReadAnchor(anchor);
|
|
707
785
|
// Single auto-follow invariant, applied after every DOM mutation.
|
|
708
786
|
stickToBottomIfFollowing();
|
|
709
787
|
}
|
package/src/scroll.ts
CHANGED
|
@@ -10,8 +10,13 @@
|
|
|
10
10
|
// failure mode; see the #web-terminal-engine steering doc, "Design rationale").
|
|
11
11
|
//
|
|
12
12
|
// The renderer calls stickToBottom() once after each flush: if following, pin
|
|
13
|
-
// to the new bottom; if holding, do nothing
|
|
14
|
-
//
|
|
13
|
+
// to the new bottom; if holding, do nothing. Holding the reading position when
|
|
14
|
+
// content ABOVE it changes height (top-of-history eviction at the retention cap,
|
|
15
|
+
// the trim marker appearing) is adjustForContentShift's job, called by the
|
|
16
|
+
// renderer around its DOM mutations. That used to be left to native scroll
|
|
17
|
+
// anchoring (overflow-anchor), which WebKit has never shipped — so on Safari and
|
|
18
|
+
// iPadOS the read position slid up one line per evicted row while the user was
|
|
19
|
+
// scrolled up reading.
|
|
15
20
|
// Appending content at the bottom does not fire a scroll event, so following
|
|
16
21
|
// stays true across new output and the post-flush pin lands correctly. Pinning
|
|
17
22
|
// to the bottom produces a scroll event whose recomputation yields
|
|
@@ -148,6 +153,34 @@ export function currentScrollTop(): number {
|
|
|
148
153
|
return scrollEl ? scrollEl.scrollTop : 0;
|
|
149
154
|
}
|
|
150
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Shift the viewport by a content-height change that happened ABOVE the reading
|
|
158
|
+
* position, so the user keeps looking at the same line. A no-op while following
|
|
159
|
+
* (the bottom pin owns the position then) and when the delta is zero.
|
|
160
|
+
*
|
|
161
|
+
* This is scroll anchoring, done by hand. Chrome and Firefox do it natively
|
|
162
|
+
* (`overflow-anchor`), and this module's design leaned on that: "if holding, do
|
|
163
|
+
* nothing and let native scroll anchoring hold the reading position when history
|
|
164
|
+
* is inserted above". WebKit has never shipped scroll anchoring, so on Safari
|
|
165
|
+
* — including iPadOS, where this UI mostly lives — nothing compensated, and
|
|
166
|
+
* every row evicted from the top of history while the user was scrolled up
|
|
167
|
+
* reading slid their reading position one line further up. Over a streaming
|
|
168
|
+
* agent session at the retention cap that reads as "the view scrolls itself
|
|
169
|
+
* while I am trying to read".
|
|
170
|
+
*
|
|
171
|
+
* `following` is deliberately NOT touched, and neither is `lastScrollTop`: the
|
|
172
|
+
* state stays derived purely from the resulting scroll event, exactly as it is
|
|
173
|
+
* for the bottom pin. Both directions recompute to holding (an upward shift
|
|
174
|
+
* leaves a gap below; a downward one is not at the bottom), so there is no churn
|
|
175
|
+
* and no follow flip.
|
|
176
|
+
*/
|
|
177
|
+
export function adjustForContentShift(deltaPx: number): void {
|
|
178
|
+
if (!scrollEl || following || deltaPx === 0) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
scrollEl.scrollTop += deltaPx;
|
|
182
|
+
}
|
|
183
|
+
|
|
151
184
|
/**
|
|
152
185
|
* Restore a previously saved scroll offset (per-view scroll memory: a tabbed
|
|
153
186
|
* shell re-entering a tab whose user had scrolled up to read). The offset is
|
package/src/status-stream.ts
CHANGED
|
@@ -9,16 +9,42 @@ import { nextBackoffDelay } from "./reconnect.js";
|
|
|
9
9
|
|
|
10
10
|
/** SessionInfo is one session's wire shape: the JSON object the session REST
|
|
11
11
|
* API (GET/POST /api/sessions) returns per session. Mirrors the Go
|
|
12
|
-
* terminal.SessionInfo — the two are kept in lockstep by hand (single
|
|
13
|
-
* type; flip to wiregen if this surface grows).
|
|
12
|
+
* terminal.SessionInfo — the two are kept in lockstep by hand (single 7-field
|
|
13
|
+
* type; flip to wiregen if this surface grows).
|
|
14
|
+
*
|
|
15
|
+
* Three of the fields are title-shaped and they are not interchangeable:
|
|
16
|
+
* `title` is the RESOLVED display title, `pinnedTitle` is the USER's name (set
|
|
17
|
+
* and cleared only by a human action, outranking everything else), and
|
|
18
|
+
* `clientTitle` is a CLIENT-DERIVED automatic title a client asked the server to
|
|
19
|
+
* remember. */
|
|
14
20
|
export interface SessionInfo {
|
|
15
21
|
readonly id: string;
|
|
16
|
-
|
|
22
|
+
/** the session's current status. "working"/"idle"/"exited"/"crashed" are
|
|
23
|
+
* derived by the server; "input"/"done" are latched from a classified OSC 9
|
|
24
|
+
* notification; "failed" is OSC 9;4 progress state 2 and "warning" is state 4
|
|
25
|
+
* (iTerm2 semantics), both of which PERSIST until the program changes the
|
|
26
|
+
* progress state. "exited" and "crashed" split the session's end: "exited" is
|
|
27
|
+
* an ordinary one (status 0, or any exit the server itself caused — a closed
|
|
28
|
+
* session, the idle reaper, a server shutdown), "crashed" is a non-zero exit
|
|
29
|
+
* status or a terminating signal the program was not asked for, so a routine
|
|
30
|
+
* restart never renders as a failure. Neither is a progress state: they
|
|
31
|
+
* outrank everything, and nothing clears them. A consumer must tolerate an
|
|
32
|
+
* UNKNOWN status string from a newer server: the stream is parsed, not
|
|
33
|
+
* validated, and an unrecognised value is forwarded rather than dropped
|
|
34
|
+
* (forward compatibility across the wire floor). */
|
|
35
|
+
readonly status:
|
|
36
|
+
"working" | "idle" | "input" | "done" | "exited" | "crashed" | "failed" | "warning";
|
|
17
37
|
readonly title: string;
|
|
18
|
-
/** the raw client-
|
|
19
|
-
* consumer that treats the program's OSC window title as
|
|
20
|
-
* this instead of `title`. */
|
|
38
|
+
/** the raw client-derived automatic title (before the precedence baked into
|
|
39
|
+
* `title`); a consumer that treats the program's OSC window title as
|
|
40
|
+
* unreliable reads this instead of `title`. */
|
|
21
41
|
readonly clientTitle?: string;
|
|
42
|
+
/** the user's pinned name, set through PUT /api/sessions/{id}/pinned-title and
|
|
43
|
+
* cleared through DELETE on the same path. It outranks every automatic source
|
|
44
|
+
* in `title`; it is carried raw so a UI can tell that a pin EXISTS (to offer
|
|
45
|
+
* the automatic name again) rather than only seeing its effect. Absent or
|
|
46
|
+
* empty means the session has no user-set name. */
|
|
47
|
+
readonly pinnedTitle?: string;
|
|
22
48
|
readonly createdAt: string;
|
|
23
49
|
/** true once the session has emitted a genuine activity signal — OSC 9;4
|
|
24
50
|
* progress (kiro-cli, Claude Code, …) or a classified OSC 9 notification.
|
|
@@ -34,6 +60,18 @@ export interface SessionInfo {
|
|
|
34
60
|
export interface SessionStatus extends SessionInfo {
|
|
35
61
|
/** true when the session is gone (closed or reaped); the consumer drops it. */
|
|
36
62
|
readonly removed?: boolean;
|
|
63
|
+
/** the OSC 9;4 percentage: -1 when absent or unknown (no sequence seen, or a
|
|
64
|
+
* state that carries none — clear/indeterminate), else 0-100. Paired with
|
|
65
|
+
* `status` to render a determinate bar. */
|
|
66
|
+
readonly progressValue?: number;
|
|
67
|
+
/** the OSC 9 notification message, delivered to EVERY subscriber whether or
|
|
68
|
+
* not the server has a status classifier installed. A notification is an
|
|
69
|
+
* EVENT, not a state: it does not latch a status, so a consumer that wants a
|
|
70
|
+
* status out of it decides that itself. */
|
|
71
|
+
readonly notification?: string;
|
|
72
|
+
/** the notification's sequence number (monotonic per session), so a repeated
|
|
73
|
+
* message is still recognised as a new event. */
|
|
74
|
+
readonly notificationSeq?: number;
|
|
37
75
|
}
|
|
38
76
|
|
|
39
77
|
/** StatusStreamCallbacks are the consumer's hooks. Declared as function-typed
|
package/src/types.ts
CHANGED
|
@@ -123,9 +123,11 @@ export interface ResumeAckMessage {
|
|
|
123
123
|
* claimed sent bytes: the input ledger was reclaimed (idle GC / cap
|
|
124
124
|
* eviction), so the server cannot vouch for any previously sent input.
|
|
125
125
|
* The connection module responds deterministically with its designed loss
|
|
126
|
-
* semantic — drop the outbox
|
|
127
|
-
*
|
|
128
|
-
*
|
|
126
|
+
* semantic — drop the outbox rather than replay possibly-duplicate input —
|
|
127
|
+
* and notifies (`onServerRestart`) only when that outbox actually held
|
|
128
|
+
* unacked bytes; a fully-acked session lost nothing and resets silently.
|
|
129
|
+
* Absent on older servers (the `received === 0 && bytesAcked > 0` heuristic
|
|
130
|
+
* remains as their fallback).
|
|
129
131
|
*/
|
|
130
132
|
ledgerLost?: boolean;
|
|
131
133
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Generator for the published, language-neutral wire-compatibility manifest
|
|
2
|
+
// (`web/wire-compatibility.json`).
|
|
3
|
+
//
|
|
4
|
+
// Why it exists: the wire revision and directional floor are published as
|
|
5
|
+
// TypeScript constants (wire-compatibility.ts) and as Go constants
|
|
6
|
+
// (terminal.WireProtocolVersion / MinSupportedClientWireVersion). A consumer
|
|
7
|
+
// that is neither — a Dockerfile, a shell release gate, a Python CI script —
|
|
8
|
+
// had no machine-readable surface to read them from and resorted to scraping
|
|
9
|
+
// the TypeScript source with `sed`, which breaks silently on any reformat.
|
|
10
|
+
//
|
|
11
|
+
// This module renders that surface FROM the existing WIRE_COMPATIBILITY export,
|
|
12
|
+
// so the numbers keep exactly one source of truth in the TypeScript half. The
|
|
13
|
+
// rendered text is checked into the repo (see the drift guard in
|
|
14
|
+
// wire-manifest.test.ts) because the publish pipeline runs no build step: npm
|
|
15
|
+
// and JSR publish the working tree as-is, so a manifest generated at publish
|
|
16
|
+
// time would never exist in the tarball.
|
|
17
|
+
//
|
|
18
|
+
// The manifest deliberately does NOT carry the package version. `npm pkg set
|
|
19
|
+
// version` injects that at publish time, after checkout, with no regeneration
|
|
20
|
+
// step — an embedded version would be frozen at the repo's placeholder (0.1.0)
|
|
21
|
+
// in every published artifact. Consumers read the package version from
|
|
22
|
+
// package.json / jsr.json, which the pipeline does rewrite.
|
|
23
|
+
//
|
|
24
|
+
// Public-artifact contract: see the "Wire compatibility manifest" section of
|
|
25
|
+
// web/README.md for what a consumer may rely on and what counts as a breaking
|
|
26
|
+
// change.
|
|
27
|
+
|
|
28
|
+
import { WIRE_COMPATIBILITY, type WireCompatibility } from "./wire-compatibility.js";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Schema revision of the manifest FILE, independent of the wire protocol
|
|
32
|
+
* revision it reports. A consumer must reject a `schemaVersion` it does not
|
|
33
|
+
* understand rather than guess at the shape. Bumped only for a breaking
|
|
34
|
+
* change to the manifest's own layout (a removed or repurposed field).
|
|
35
|
+
*/
|
|
36
|
+
export const WIRE_MANIFEST_SCHEMA_VERSION = 1;
|
|
37
|
+
|
|
38
|
+
/** Path of the checked-in artifact this module renders, relative to `web/`. */
|
|
39
|
+
export const WIRE_MANIFEST_PATH = "wire-compatibility.json";
|
|
40
|
+
|
|
41
|
+
/** Shape of the generated wire-compatibility manifest. */
|
|
42
|
+
export interface WireManifest {
|
|
43
|
+
/** Schema revision of this file's layout (see WIRE_MANIFEST_SCHEMA_VERSION). */
|
|
44
|
+
readonly schemaVersion: number;
|
|
45
|
+
/** Informational provenance note; not part of the parsed contract. */
|
|
46
|
+
readonly generatedBy: string;
|
|
47
|
+
/** The same values the TypeScript `WIRE_COMPATIBILITY` export carries. */
|
|
48
|
+
readonly wireCompatibility: WireCompatibility;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Builds the manifest object from the live TypeScript constants. Nesting the
|
|
53
|
+
* protocol values under `wireCompatibility` keeps file-level metadata
|
|
54
|
+
* (`schemaVersion`) in a separate namespace from the payload, so a future
|
|
55
|
+
* metadata field can never collide with a protocol field name.
|
|
56
|
+
*/
|
|
57
|
+
export function buildWireManifest(): WireManifest {
|
|
58
|
+
return {
|
|
59
|
+
schemaVersion: WIRE_MANIFEST_SCHEMA_VERSION,
|
|
60
|
+
generatedBy: "web/src/wire-manifest.ts",
|
|
61
|
+
wireCompatibility: {
|
|
62
|
+
protocolVersion: WIRE_COMPATIBILITY.protocolVersion,
|
|
63
|
+
minimumServerProtocolVersion: WIRE_COMPATIBILITY.minimumServerProtocolVersion,
|
|
64
|
+
incompatibleCloseCode: WIRE_COMPATIBILITY.incompatibleCloseCode,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Renders the exact bytes of the checked-in manifest: two-space indentation and
|
|
71
|
+
* a trailing newline, which is what `prettier --check` requires of a JSON file
|
|
72
|
+
* in this repo. Comparing rendered text (not parsed objects) is what makes the
|
|
73
|
+
* drift guard able to fail on a hand edit that happens to parse the same.
|
|
74
|
+
*/
|
|
75
|
+
export function renderWireManifest(): string {
|
|
76
|
+
return `${JSON.stringify(buildWireManifest(), null, 2)}\n`;
|
|
77
|
+
}
|