@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.
Files changed (3) hide show
  1. package/jsr.json +1 -1
  2. package/package.json +4 -4
  3. package/src/render.ts +85 -49
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "3.0.1",
3
+ "version": "3.0.7",
4
4
  "exports": {
5
5
  ".": "./src/index.ts"
6
6
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "3.0.1",
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.10.6",
24
+ "happy-dom": "20.11.0",
25
25
  "pngjs": "7.0.0",
26
- "prettier": "3.9.5",
26
+ "prettier": "3.9.6",
27
27
  "typescript": "npm:@typescript/typescript6@6.0.2",
28
- "typescript-eslint": "8.64.0",
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) or a `cat bigfile` dumps thousands of
129
- // lines in one wire frame; building them all in a single rAF janks or, on a
130
- // constrained device, hangs the tab. The store still ingests the whole burst
131
- // at once (it is cheap, pure data); the renderer drains this queue at most
132
- // MAX_ROWS_PER_FRAME per frame and reschedules until it is empty, so each
133
- // frame stays short and the terminal fills smoothly. The cursor row is always
134
- // built regardless of the budget so the caret never lags.
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
- startCursorBlink();
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
- setCursorBlink(win.cursorBlink);
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; the rest carry over
783
- // to the next frame (scheduled below) so one big burst never blocks paint.
784
- // flushDrainedThisPass doubles as the per-frame budget counter and the
785
- // forward-progress signal the error-path reschedule reads (it was reset to 0
786
- // at entry and nothing between there and here touches it).
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
- function startCursorBlink(): void {
946
- if (blinkInterval !== null || !blinkEnabled || hiddenDoc()) {
947
- return;
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
- // The blink class lives on termWrap: the caret overlay is a termWrap child
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
- function stopCursorBlink(): void {
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
- if (hiddenDoc()) {
976
- // Stop the timer only: blinkEnabled keeps the server-driven state so
977
- // foregrounding can restart without waiting for the next mode frame.
978
- if (blinkInterval !== null) {
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