@cplieger/web-terminal-engine 3.0.6 → 3.2.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 +5 -2
- package/src/render.ts +128 -37
- package/src/scroll.ts +35 -2
- package/src/status-stream.ts +17 -5
package/jsr.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-engine",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"typecheck": "tsc -p tsconfig.json",
|
|
@@ -21,13 +21,16 @@
|
|
|
21
21
|
"esbuild": "0.28.1",
|
|
22
22
|
"eslint": "10.7.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": {
|
package/src/render.ts
CHANGED
|
@@ -249,7 +249,7 @@ export function init(opts: {
|
|
|
249
249
|
}
|
|
250
250
|
flushDrainedThisPass = 0;
|
|
251
251
|
renderNoProgressStreak = 0;
|
|
252
|
-
|
|
252
|
+
syncCursorBlink();
|
|
253
253
|
}
|
|
254
254
|
|
|
255
255
|
/**
|
|
@@ -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
|
}
|
|
@@ -739,7 +817,11 @@ function flushRenderInner(): void {
|
|
|
739
817
|
cursorCol = win.cursorCol;
|
|
740
818
|
cursorHidden = win.cursorHidden;
|
|
741
819
|
cursorStyleVal = win.cursorStyle;
|
|
742
|
-
|
|
820
|
+
blinkEnabled = win.cursorBlink;
|
|
821
|
+
// Reconcile the blink interval with the fresh cursor state (blink mode and
|
|
822
|
+
// DECTCM visibility may both have changed this frame); no-op when the
|
|
823
|
+
// resulting mode is unchanged.
|
|
824
|
+
syncCursorBlink();
|
|
743
825
|
|
|
744
826
|
// Alt screen: render the ephemeral grid instead of the absolute buffer.
|
|
745
827
|
// Returning here skips the dirtyLines queueing below — safe by invariant:
|
|
@@ -954,8 +1036,20 @@ function stickToBottomIfFollowing(): void {
|
|
|
954
1036
|
|
|
955
1037
|
// --- Cursor blink ---
|
|
956
1038
|
const CURSOR_BLINK_MS = 530;
|
|
1039
|
+
// While the application hides the cursor (DECTCM — e.g. a full-screen TUI
|
|
1040
|
+
// that paints its own cursor cell, like an agent front-end), the fast toggle
|
|
1041
|
+
// would only restyle a display:none overlay, so the interval downshifts to
|
|
1042
|
+
// this slow re-check: still polling for the cursor's return (a self-healing
|
|
1043
|
+
// backstop even if a transition frame were somehow missed — the flush hook is
|
|
1044
|
+
// the primary, instant restart), at a fraction of the 530ms blink's wakeups.
|
|
1045
|
+
// For agent consumers the hidden-cursor state is the session's steady state.
|
|
1046
|
+
const CURSOR_RECHECK_MS = 4000;
|
|
957
1047
|
let blinkInterval: ReturnType<typeof setInterval> | null = null;
|
|
958
1048
|
let blinkEnabled = true;
|
|
1049
|
+
// What the running interval (if any) is configured as. syncCursorBlink()
|
|
1050
|
+
// reconfigures only on mode change, so it is safe to call on every flush.
|
|
1051
|
+
type BlinkMode = "off" | "idle" | "fast";
|
|
1052
|
+
let blinkMode: BlinkMode = "off";
|
|
959
1053
|
|
|
960
1054
|
// hiddenDoc reports whether the page is currently background/hidden. The blink
|
|
961
1055
|
// interval is gated on it: rAF-driven flushes already freeze in a hidden tab,
|
|
@@ -965,25 +1059,42 @@ function hiddenDoc(): boolean {
|
|
|
965
1059
|
return typeof document !== "undefined" && document.visibilityState === "hidden";
|
|
966
1060
|
}
|
|
967
1061
|
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1062
|
+
// desiredBlinkMode folds the three gates into an interval mode: no timer at
|
|
1063
|
+
// all when blinking is disabled or the page is hidden (both have event-driven
|
|
1064
|
+
// restarts — the next mode frame's flush and visibilitychange), the fast
|
|
1065
|
+
// phase toggle when the cursor is visible, and the slow re-check while the
|
|
1066
|
+
// application hides it.
|
|
1067
|
+
function desiredBlinkMode(): BlinkMode {
|
|
1068
|
+
if (!blinkEnabled || hiddenDoc()) {
|
|
1069
|
+
return "off";
|
|
971
1070
|
}
|
|
972
|
-
|
|
973
|
-
// (not inside output), and the `.cursor-blink-off .term-cursor` descendant
|
|
974
|
-
// selector must reach it.
|
|
975
|
-
termWrap.classList.remove("cursor-blink-off");
|
|
976
|
-
blinkInterval = setInterval(() => {
|
|
977
|
-
termWrap.classList.toggle("cursor-blink-off");
|
|
978
|
-
}, CURSOR_BLINK_MS);
|
|
1071
|
+
return cursorHidden ? "idle" : "fast";
|
|
979
1072
|
}
|
|
980
1073
|
|
|
981
|
-
|
|
1074
|
+
// syncCursorBlink reconciles the interval with the current gate state; every
|
|
1075
|
+
// mode change resets the phase to solid. Called from the flush (blink mode +
|
|
1076
|
+
// DECTCM state), the visibilitychange listener, init(), and the idle re-check
|
|
1077
|
+
// itself. The blink class lives on termWrap: the caret overlay is a termWrap
|
|
1078
|
+
// child (not inside output), and the `.cursor-blink-off .term-cursor`
|
|
1079
|
+
// descendant selector must reach it.
|
|
1080
|
+
function syncCursorBlink(): void {
|
|
1081
|
+
const mode = desiredBlinkMode();
|
|
1082
|
+
if (mode === blinkMode) {
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
blinkMode = mode;
|
|
982
1086
|
if (blinkInterval !== null) {
|
|
983
1087
|
clearInterval(blinkInterval);
|
|
984
1088
|
blinkInterval = null;
|
|
985
1089
|
}
|
|
986
1090
|
termWrap.classList.remove("cursor-blink-off");
|
|
1091
|
+
if (mode === "fast") {
|
|
1092
|
+
blinkInterval = setInterval(() => {
|
|
1093
|
+
termWrap.classList.toggle("cursor-blink-off");
|
|
1094
|
+
}, CURSOR_BLINK_MS);
|
|
1095
|
+
} else if (mode === "idle") {
|
|
1096
|
+
blinkInterval = setInterval(syncCursorBlink, CURSOR_RECHECK_MS);
|
|
1097
|
+
}
|
|
987
1098
|
}
|
|
988
1099
|
|
|
989
1100
|
// Pause the interval while hidden; resume (cursor solid, phase reset) when the
|
|
@@ -995,33 +1106,13 @@ if (typeof document !== "undefined") {
|
|
|
995
1106
|
if ((termWrap as HTMLElement | undefined) === undefined) {
|
|
996
1107
|
return;
|
|
997
1108
|
}
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
clearInterval(blinkInterval);
|
|
1003
|
-
blinkInterval = null;
|
|
1004
|
-
}
|
|
1005
|
-
termWrap.classList.remove("cursor-blink-off");
|
|
1006
|
-
return;
|
|
1007
|
-
}
|
|
1008
|
-
startCursorBlink();
|
|
1109
|
+
// blinkEnabled/cursorHidden keep their server-driven state across a
|
|
1110
|
+
// background stint, so foregrounding restores the right mode without
|
|
1111
|
+
// waiting for the next frame.
|
|
1112
|
+
syncCursorBlink();
|
|
1009
1113
|
});
|
|
1010
1114
|
}
|
|
1011
1115
|
|
|
1012
|
-
/** Called from the flush when cursorBlink state changes. */
|
|
1013
|
-
function setCursorBlink(enabled: boolean): void {
|
|
1014
|
-
if (enabled === blinkEnabled) {
|
|
1015
|
-
return;
|
|
1016
|
-
}
|
|
1017
|
-
blinkEnabled = enabled;
|
|
1018
|
-
if (enabled) {
|
|
1019
|
-
startCursorBlink();
|
|
1020
|
-
} else {
|
|
1021
|
-
stopCursorBlink();
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
1116
|
// --- Font metrics & sizing ---
|
|
1026
1117
|
/**
|
|
1027
1118
|
* Re-measure the cell width/height from the rendered DOM. Call after any font
|
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,28 @@ 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
|
readonly status: "working" | "idle" | "input" | "done" | "exited";
|
|
17
23
|
readonly title: string;
|
|
18
|
-
/** the raw client-
|
|
19
|
-
* consumer that treats the program's OSC window title as
|
|
20
|
-
* this instead of `title`. */
|
|
24
|
+
/** the raw client-derived automatic title (before the precedence baked into
|
|
25
|
+
* `title`); a consumer that treats the program's OSC window title as
|
|
26
|
+
* unreliable reads this instead of `title`. */
|
|
21
27
|
readonly clientTitle?: string;
|
|
28
|
+
/** the user's pinned name, set through PUT /api/sessions/{id}/pinned-title and
|
|
29
|
+
* cleared through DELETE on the same path. It outranks every automatic source
|
|
30
|
+
* in `title`; it is carried raw so a UI can tell that a pin EXISTS (to offer
|
|
31
|
+
* the automatic name again) rather than only seeing its effect. Absent or
|
|
32
|
+
* empty means the session has no user-set name. */
|
|
33
|
+
readonly pinnedTitle?: string;
|
|
22
34
|
readonly createdAt: string;
|
|
23
35
|
/** true once the session has emitted a genuine activity signal — OSC 9;4
|
|
24
36
|
* progress (kiro-cli, Claude Code, …) or a classified OSC 9 notification.
|