@cplieger/web-terminal-engine 3.0.1 → 3.0.7
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 +4 -4
- package/src/render.ts +85 -49
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.0.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"typecheck": "tsc -p tsconfig.json",
|
|
@@ -21,11 +21,11 @@
|
|
|
21
21
|
"esbuild": "0.28.1",
|
|
22
22
|
"eslint": "10.7.0",
|
|
23
23
|
"fast-check": "4.9.0",
|
|
24
|
-
"happy-dom": "20.
|
|
24
|
+
"happy-dom": "20.11.0",
|
|
25
25
|
"pngjs": "7.0.0",
|
|
26
|
-
"prettier": "3.9.
|
|
26
|
+
"prettier": "3.9.6",
|
|
27
27
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
28
|
-
"typescript-eslint": "8.
|
|
28
|
+
"typescript-eslint": "8.65.0",
|
|
29
29
|
"vitest": "4.1.10"
|
|
30
30
|
},
|
|
31
31
|
"description": "Browser terminal engine: VT500 screen buffer, DOM renderer, and binary wire protocol",
|
package/src/render.ts
CHANGED
|
@@ -125,13 +125,17 @@ const rowEls = new Map<number, HTMLDivElement>();
|
|
|
125
125
|
let trimMarkerEl: HTMLDivElement | null = null;
|
|
126
126
|
|
|
127
127
|
// Rows awaiting a DOM (re)build, processed in budgeted batches across frames.
|
|
128
|
-
// A session restore (kiro-cli's /chat)
|
|
129
|
-
// lines in one wire frame; building them all
|
|
130
|
-
// constrained device, hangs the tab. The store
|
|
131
|
-
// at once (it is cheap, pure data); the renderer
|
|
132
|
-
// MAX_ROWS_PER_FRAME per frame and reschedules until
|
|
133
|
-
//
|
|
134
|
-
//
|
|
128
|
+
// A session restore (kiro-cli's /chat), a post-resize transcript reprint, or a
|
|
129
|
+
// `cat bigfile` dumps thousands of lines in one wire frame; building them all
|
|
130
|
+
// in a single rAF janks or, on a constrained device, hangs the tab. The store
|
|
131
|
+
// still ingests the whole burst at once (it is cheap, pure data); the renderer
|
|
132
|
+
// drains this queue at most MAX_ROWS_PER_FRAME per frame and reschedules until
|
|
133
|
+
// it is empty, so each frame stays short. The drain is VIEWPORT-FIRST each
|
|
134
|
+
// frame (see flushRenderInner): live-window rows build before any scrollback
|
|
135
|
+
// backlog, and scrollback drains newest->oldest so the backlog fills upward,
|
|
136
|
+
// above the bottom-pinned viewport — offscreen — instead of visibly streaming
|
|
137
|
+
// through it. The cursor row is always built regardless of the budget so the
|
|
138
|
+
// caret never lags.
|
|
135
139
|
const renderQueue = new Set<number>();
|
|
136
140
|
const MAX_ROWS_PER_FRAME = 300;
|
|
137
141
|
|
|
@@ -245,7 +249,7 @@ export function init(opts: {
|
|
|
245
249
|
}
|
|
246
250
|
flushDrainedThisPass = 0;
|
|
247
251
|
renderNoProgressStreak = 0;
|
|
248
|
-
|
|
252
|
+
syncCursorBlink();
|
|
249
253
|
}
|
|
250
254
|
|
|
251
255
|
/**
|
|
@@ -735,7 +739,11 @@ function flushRenderInner(): void {
|
|
|
735
739
|
cursorCol = win.cursorCol;
|
|
736
740
|
cursorHidden = win.cursorHidden;
|
|
737
741
|
cursorStyleVal = win.cursorStyle;
|
|
738
|
-
|
|
742
|
+
blinkEnabled = win.cursorBlink;
|
|
743
|
+
// Reconcile the blink interval with the fresh cursor state (blink mode and
|
|
744
|
+
// DECTCM visibility may both have changed this frame); no-op when the
|
|
745
|
+
// resulting mode is unchanged.
|
|
746
|
+
syncCursorBlink();
|
|
739
747
|
|
|
740
748
|
// Alt screen: render the ephemeral grid instead of the absolute buffer.
|
|
741
749
|
// Returning here skips the dirtyLines queueing below — safe by invariant:
|
|
@@ -779,12 +787,31 @@ function flushRenderInner(): void {
|
|
|
779
787
|
renderQueue.delete(newCursorAbs);
|
|
780
788
|
}
|
|
781
789
|
|
|
782
|
-
// Drain up to MAX_ROWS_PER_FRAME queued rows this frame;
|
|
783
|
-
// to the next frame (scheduled below) so one big burst
|
|
784
|
-
//
|
|
785
|
-
//
|
|
786
|
-
//
|
|
790
|
+
// Drain up to MAX_ROWS_PER_FRAME queued rows this frame, viewport-first;
|
|
791
|
+
// the rest carry over to the next frame (scheduled below) so one big burst
|
|
792
|
+
// never blocks paint. Live-window rows build first (ascending), then the
|
|
793
|
+
// scrollback backlog newest->oldest, so the backlog fills upward above the
|
|
794
|
+
// bottom-pinned viewport — offscreen. A Set's insertion order did the
|
|
795
|
+
// opposite under load: a multi-thousand-row backlog (a resume replay,
|
|
796
|
+
// kiro-cli's post-resize transcript reprint, `cat bigfile`) queued ahead of
|
|
797
|
+
// freshly-dirtied window rows, so the visible screen either churned through
|
|
798
|
+
// history or froze (only the force-built cursor row moving) for seconds on
|
|
799
|
+
// a slow device while the backlog drained. flushDrainedThisPass doubles as
|
|
800
|
+
// the per-frame budget counter and the forward-progress signal the
|
|
801
|
+
// error-path reschedule reads (it was reset to 0 at entry and nothing
|
|
802
|
+
// between there and here touches it).
|
|
803
|
+
const inWindow: number[] = [];
|
|
804
|
+
const belowWindow: number[] = [];
|
|
787
805
|
for (const abs of renderQueue) {
|
|
806
|
+
if (abs >= win.base) {
|
|
807
|
+
inWindow.push(abs);
|
|
808
|
+
} else {
|
|
809
|
+
belowWindow.push(abs);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
inWindow.sort((a, b) => a - b);
|
|
813
|
+
belowWindow.sort((a, b) => b - a);
|
|
814
|
+
for (const abs of [...inWindow, ...belowWindow]) {
|
|
788
815
|
if (flushDrainedThisPass >= MAX_ROWS_PER_FRAME) {
|
|
789
816
|
break;
|
|
790
817
|
}
|
|
@@ -931,8 +958,20 @@ function stickToBottomIfFollowing(): void {
|
|
|
931
958
|
|
|
932
959
|
// --- Cursor blink ---
|
|
933
960
|
const CURSOR_BLINK_MS = 530;
|
|
961
|
+
// While the application hides the cursor (DECTCM — e.g. a full-screen TUI
|
|
962
|
+
// that paints its own cursor cell, like an agent front-end), the fast toggle
|
|
963
|
+
// would only restyle a display:none overlay, so the interval downshifts to
|
|
964
|
+
// this slow re-check: still polling for the cursor's return (a self-healing
|
|
965
|
+
// backstop even if a transition frame were somehow missed — the flush hook is
|
|
966
|
+
// the primary, instant restart), at a fraction of the 530ms blink's wakeups.
|
|
967
|
+
// For agent consumers the hidden-cursor state is the session's steady state.
|
|
968
|
+
const CURSOR_RECHECK_MS = 4000;
|
|
934
969
|
let blinkInterval: ReturnType<typeof setInterval> | null = null;
|
|
935
970
|
let blinkEnabled = true;
|
|
971
|
+
// What the running interval (if any) is configured as. syncCursorBlink()
|
|
972
|
+
// reconfigures only on mode change, so it is safe to call on every flush.
|
|
973
|
+
type BlinkMode = "off" | "idle" | "fast";
|
|
974
|
+
let blinkMode: BlinkMode = "off";
|
|
936
975
|
|
|
937
976
|
// hiddenDoc reports whether the page is currently background/hidden. The blink
|
|
938
977
|
// interval is gated on it: rAF-driven flushes already freeze in a hidden tab,
|
|
@@ -942,25 +981,42 @@ function hiddenDoc(): boolean {
|
|
|
942
981
|
return typeof document !== "undefined" && document.visibilityState === "hidden";
|
|
943
982
|
}
|
|
944
983
|
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
984
|
+
// desiredBlinkMode folds the three gates into an interval mode: no timer at
|
|
985
|
+
// all when blinking is disabled or the page is hidden (both have event-driven
|
|
986
|
+
// restarts — the next mode frame's flush and visibilitychange), the fast
|
|
987
|
+
// phase toggle when the cursor is visible, and the slow re-check while the
|
|
988
|
+
// application hides it.
|
|
989
|
+
function desiredBlinkMode(): BlinkMode {
|
|
990
|
+
if (!blinkEnabled || hiddenDoc()) {
|
|
991
|
+
return "off";
|
|
948
992
|
}
|
|
949
|
-
|
|
950
|
-
// (not inside output), and the `.cursor-blink-off .term-cursor` descendant
|
|
951
|
-
// selector must reach it.
|
|
952
|
-
termWrap.classList.remove("cursor-blink-off");
|
|
953
|
-
blinkInterval = setInterval(() => {
|
|
954
|
-
termWrap.classList.toggle("cursor-blink-off");
|
|
955
|
-
}, CURSOR_BLINK_MS);
|
|
993
|
+
return cursorHidden ? "idle" : "fast";
|
|
956
994
|
}
|
|
957
995
|
|
|
958
|
-
|
|
996
|
+
// syncCursorBlink reconciles the interval with the current gate state; every
|
|
997
|
+
// mode change resets the phase to solid. Called from the flush (blink mode +
|
|
998
|
+
// DECTCM state), the visibilitychange listener, init(), and the idle re-check
|
|
999
|
+
// itself. The blink class lives on termWrap: the caret overlay is a termWrap
|
|
1000
|
+
// child (not inside output), and the `.cursor-blink-off .term-cursor`
|
|
1001
|
+
// descendant selector must reach it.
|
|
1002
|
+
function syncCursorBlink(): void {
|
|
1003
|
+
const mode = desiredBlinkMode();
|
|
1004
|
+
if (mode === blinkMode) {
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
blinkMode = mode;
|
|
959
1008
|
if (blinkInterval !== null) {
|
|
960
1009
|
clearInterval(blinkInterval);
|
|
961
1010
|
blinkInterval = null;
|
|
962
1011
|
}
|
|
963
1012
|
termWrap.classList.remove("cursor-blink-off");
|
|
1013
|
+
if (mode === "fast") {
|
|
1014
|
+
blinkInterval = setInterval(() => {
|
|
1015
|
+
termWrap.classList.toggle("cursor-blink-off");
|
|
1016
|
+
}, CURSOR_BLINK_MS);
|
|
1017
|
+
} else if (mode === "idle") {
|
|
1018
|
+
blinkInterval = setInterval(syncCursorBlink, CURSOR_RECHECK_MS);
|
|
1019
|
+
}
|
|
964
1020
|
}
|
|
965
1021
|
|
|
966
1022
|
// Pause the interval while hidden; resume (cursor solid, phase reset) when the
|
|
@@ -972,33 +1028,13 @@ if (typeof document !== "undefined") {
|
|
|
972
1028
|
if ((termWrap as HTMLElement | undefined) === undefined) {
|
|
973
1029
|
return;
|
|
974
1030
|
}
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
clearInterval(blinkInterval);
|
|
980
|
-
blinkInterval = null;
|
|
981
|
-
}
|
|
982
|
-
termWrap.classList.remove("cursor-blink-off");
|
|
983
|
-
return;
|
|
984
|
-
}
|
|
985
|
-
startCursorBlink();
|
|
1031
|
+
// blinkEnabled/cursorHidden keep their server-driven state across a
|
|
1032
|
+
// background stint, so foregrounding restores the right mode without
|
|
1033
|
+
// waiting for the next frame.
|
|
1034
|
+
syncCursorBlink();
|
|
986
1035
|
});
|
|
987
1036
|
}
|
|
988
1037
|
|
|
989
|
-
/** Called from the flush when cursorBlink state changes. */
|
|
990
|
-
function setCursorBlink(enabled: boolean): void {
|
|
991
|
-
if (enabled === blinkEnabled) {
|
|
992
|
-
return;
|
|
993
|
-
}
|
|
994
|
-
blinkEnabled = enabled;
|
|
995
|
-
if (enabled) {
|
|
996
|
-
startCursorBlink();
|
|
997
|
-
} else {
|
|
998
|
-
stopCursorBlink();
|
|
999
|
-
}
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
1038
|
// --- Font metrics & sizing ---
|
|
1003
1039
|
/**
|
|
1004
1040
|
* Re-measure the cell width/height from the rendered DOM. Call after any font
|