@oh-my-pi/pi-tui 16.4.6 → 16.5.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/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.5.0] - 2026-07-13
6
+
7
+ ### Changed
8
+
9
+ - Improved native scrollback history management by introducing an optional erase-and-replay mechanism to rebuild scrollback when mutated rows (such as finalized tool blocks or collapsed transcripts) diverge. This is now gated behind the `tui.scrollbackRebuild` setting and defaults to off.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed a rendering issue where resizing the terminal during forced renders (such as tool finalization or image reconciliation) caused the entire transcript to visibly replay and flicker. Forced renders are now consolidated into a single paint once the resize settles.
14
+
15
+ ## [16.4.7] - 2026-07-12
16
+
17
+ ### Fixed
18
+
19
+ - Fixed keyboard navigation paying an extra frame of input latency after idle; the queue-drain grace now applies only to Ctrl+C and Escape double-press gestures.
20
+
5
21
  ## [16.4.6] - 2026-07-12
6
22
 
7
23
  ### Added
@@ -280,9 +280,11 @@ export declare function coalesceAdjacentSgr(line: string): string;
280
280
  * source just became declared-final (the block finalized / a barrier
281
281
  * cleared). Hard-scanned in FULL with no tolerance: any content change
282
282
  * (a pending header settling, a preview replaced by its result, a tail
283
- * shifting up after a barrier removal) re-anchors so the final content
284
- * recommits below the frozen snapshot duplication, never loss —
285
- * instead of being committed nowhere and painted nowhere.
283
+ * shifting up after a barrier removal) re-anchors so the engine can
284
+ * erase-and-replay history with the final content exactly once (or, on
285
+ * ED3-unsafe multiplexers, recommit it below the frozen snapshot —
286
+ * duplication, never loss) instead of committing it nowhere and
287
+ * painting it nowhere.
286
288
  * [finalTo, prefix.length) FROZEN visual snapshots of still-live rows —
287
289
  * exempt: their drift is expected (a collapsing preview, a ticking
288
290
  * progress tree) and must never spray re-anchors mid-run.
@@ -335,6 +337,17 @@ export declare class TUI extends Container {
335
337
  * plus a full redraw on the frame after a new image exceeds the cap.
336
338
  */
337
339
  setMaxInlineImages(cap: number): void;
340
+ /**
341
+ * Get whether scrollback divergence rebuild is enabled.
342
+ */
343
+ getScrollbackRebuild(): boolean;
344
+ /**
345
+ * Enable or disable scrollback divergence rebuild (default off).
346
+ * When enabled, the engine will erase and replay the terminal's
347
+ * scrollback (using ED3 / alt buffer / scrollback replay) to avoid
348
+ * duplicate blocks when a block's final form replaces its live preview.
349
+ */
350
+ setScrollbackRebuild(enabled: boolean): void;
338
351
  getShowHardwareCursor(): boolean;
339
352
  setShowHardwareCursor(enabled: boolean): void;
340
353
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "16.4.6",
4
+ "version": "16.5.0",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -37,10 +37,10 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "16.4.6",
41
- "@oh-my-pi/pi-utils": "16.4.6",
42
- "lru-cache": "11.5.1",
43
- "marked": "^18.0.5"
40
+ "@oh-my-pi/pi-natives": "16.5.0",
41
+ "@oh-my-pi/pi-utils": "16.5.0",
42
+ "lru-cache": "11.5.2",
43
+ "marked": "^18.0.6"
44
44
  },
45
45
  "devDependencies": {
46
46
  "chalk": "^5.6.2",
package/src/tui.ts CHANGED
@@ -5,11 +5,15 @@
5
5
  * immutable — the tape is the terminal's visual record. Whatever scrolls
6
6
  * above the window enters history exactly once, in order: as exact-final
7
7
  * bytes when the component seam (`NativeScrollbackLiveRegion`) declared them
8
- * final, else as a frozen snapshot of what was on screen. ED3 (`CSI 3 J`) is
9
- * emitted only for gesture-driven replays (session replace, resize,
10
- * resetDisplay) where snapping the viewport is acceptable. The engine never
11
- * probes or guesses the terminal's scroll position, and the hot path clamps
12
- * over-wide lines instead of throwing. See `docs/tui-core-renderer.md`.
8
+ * final, else as a frozen snapshot of what was on screen. When recorded
9
+ * history diverges from the frame (a finalized block replacing its
10
+ * scrolled-off live render), the engine erases and replays (ED3, `CSI 3 J`)
11
+ * so history holds the content exactly once the same replay used for
12
+ * gestures (session replace, resize, resetDisplay). Multiplexer panes, where
13
+ * ED3 is unsafe, instead re-anchor and recommit below the stale fragment —
14
+ * duplication, never loss. The engine never probes or guesses the terminal's
15
+ * scroll position, and the hot path clamps over-wide lines instead of
16
+ * throwing. See `docs/tui-core-renderer.md`.
13
17
  */
14
18
  import * as fs from "node:fs";
15
19
  import { performance } from "node:perf_hooks";
@@ -783,9 +787,11 @@ const RESYNC_TAIL_SAMPLES = 8;
783
787
  * source just became declared-final (the block finalized / a barrier
784
788
  * cleared). Hard-scanned in FULL with no tolerance: any content change
785
789
  * (a pending header settling, a preview replaced by its result, a tail
786
- * shifting up after a barrier removal) re-anchors so the final content
787
- * recommits below the frozen snapshot duplication, never loss —
788
- * instead of being committed nowhere and painted nowhere.
790
+ * shifting up after a barrier removal) re-anchors so the engine can
791
+ * erase-and-replay history with the final content exactly once (or, on
792
+ * ED3-unsafe multiplexers, recommit it below the frozen snapshot —
793
+ * duplication, never loss) instead of committing it nowhere and
794
+ * painting it nowhere.
789
795
  * [finalTo, prefix.length) FROZEN visual snapshots of still-live rows —
790
796
  * exempt: their drift is expected (a collapsing preview, a ticking
791
797
  * progress tree) and must never spray re-anchors mid-run.
@@ -1001,6 +1007,8 @@ export class TUI extends Container {
1001
1007
  #clearScrollbackOnNextRender = false;
1002
1008
  #forceViewportRepaintOnNextRender = false;
1003
1009
  #hasEverRendered = false;
1010
+ #scrollbackRebuildEnabled =
1011
+ Bun.env.PI_TUI_SCROLLBACK_REBUILD === "1" || Bun.env.PI_TUI_SCROLLBACK_REBUILD === "true";
1004
1012
  // Set by the terminal resize callback; consumed by the next render. A resize
1005
1013
  // event invalidates the committed screen even when the dimensions net out
1006
1014
  // unchanged by render time (e.g. a 6→4→6 round trip coalesced into one frame
@@ -1290,6 +1298,23 @@ export class TUI extends Container {
1290
1298
  this.#imageBudget.setCap(cap);
1291
1299
  }
1292
1300
 
1301
+ /**
1302
+ * Get whether scrollback divergence rebuild is enabled.
1303
+ */
1304
+ getScrollbackRebuild(): boolean {
1305
+ return this.#scrollbackRebuildEnabled;
1306
+ }
1307
+
1308
+ /**
1309
+ * Enable or disable scrollback divergence rebuild (default off).
1310
+ * When enabled, the engine will erase and replay the terminal's
1311
+ * scrollback (using ED3 / alt buffer / scrollback replay) to avoid
1312
+ * duplicate blocks when a block's final form replaces its live preview.
1313
+ */
1314
+ setScrollbackRebuild(enabled: boolean): void {
1315
+ this.#scrollbackRebuildEnabled = enabled;
1316
+ }
1317
+
1293
1318
  getShowHardwareCursor(): boolean {
1294
1319
  return this.#showHardwareCursor;
1295
1320
  }
@@ -2239,12 +2264,12 @@ export class TUI extends Container {
2239
2264
  }
2240
2265
 
2241
2266
  #handleInput(data: string): void {
2242
- // Raw-mode Ctrl+C/Esc arrive as stdin data, not process signals. If the
2243
- // first key in a double-key gesture schedules an immediate slow repaint,
2244
- // the queued second key can sit behind that repaint long enough for the
2245
- // app-level double-press window to expire. Give the input queue one frame
2246
- // before ordinary paints; forced repaints still bypass this path.
2247
- this.#inputRenderGraceUntilMs = this.#renderScheduler.now() + TUI.#INPUT_RENDER_GRACE_MS;
2267
+ // Ctrl+C/Esc use app-level double-press windows. Give those gestures one
2268
+ // frame to drain queued input before an ordinary repaint; delaying every
2269
+ // key would make idle navigation pay a full frame of latency.
2270
+ if (matchesKey(data, "ctrl+c") || matchesKey(data, "escape")) {
2271
+ this.#inputRenderGraceUntilMs = this.#renderScheduler.now() + TUI.#INPUT_RENDER_GRACE_MS;
2272
+ }
2248
2273
  if (this.#inputListeners.size > 0) {
2249
2274
  let current = data;
2250
2275
  for (const listener of this.#inputListeners) {
@@ -2696,18 +2721,19 @@ export class TUI extends Container {
2696
2721
  // alternate screen to repaint the whole transcript on the normal
2697
2722
  // screen — then the next SIGWINCH re-enters the alt screen and paints
2698
2723
  // only the tail, so the block flashes in for one frame and vanishes.
2699
- // A forced render (tool finalization, reset, image reconciliation) must
2700
- // still preempt: it set #forceViewportRepaintOnNextRender via
2701
- // #prepareForcedRender and owns the next authoritative paint, so it falls
2702
- // through. A visible overlay composites over the transcript and needs the
2703
- // whole window, so it also falls through (overlay resizes are not on the
2704
- // drag-cost hot path).
2705
- if (
2706
- this.#resizeViewportActive &&
2707
- !this.#forceViewportRepaintOnNextRender &&
2708
- this.#hasEverRendered &&
2709
- this.#getTopmostVisibleOverlay() === undefined
2710
- ) {
2724
+ // A FORCED render mid-drag (tool finalization, resetDisplay, image
2725
+ // reconciliation) also stays on the fast path: preempting would leave
2726
+ // the borrowed alternate screen and run the geometry-rebuild full paint
2727
+ // on the normal screen ED3 plus an O(history) replay that visibly
2728
+ // scrolls the whole transcript through the viewport, once per forced
2729
+ // render and once more at settle. The forced intent is not lost: the
2730
+ // fast path consumes neither #forceViewportRepaintOnNextRender nor
2731
+ // #clearScrollbackOnNextRender, and the settle's authoritative
2732
+ // requestRender(true) honors both — same fold-into-the-settle contract
2733
+ // as the multiplexer resize debounce. A visible overlay composites over
2734
+ // the transcript and needs the whole window, so it falls through
2735
+ // (overlay resizes are not on the drag-cost hot path).
2736
+ if (this.#resizeViewportActive && this.#hasEverRendered && this.#getTopmostVisibleOverlay() === undefined) {
2711
2737
  this.#componentRenderTargets.clear();
2712
2738
  this.#renderResizeViewport(width, height);
2713
2739
  return;
@@ -2777,8 +2803,9 @@ export class TUI extends Container {
2777
2803
  // verified once (a pending header settling, a barrier clearing above a
2778
2804
  // shifted tail); rows past the boundary are still-live frozen snapshots,
2779
2805
  // exempt so a collapsing preview can never spray re-anchors mid-run. A
2780
- // divergence re-anchors and recommits duplication, never loss
2781
- // instead of silently skipping rows (committed nowhere, painted
2806
+ // divergence re-anchors — feeding the divergenceRebuild erase-and-replay
2807
+ // below (mux fallback: recommit below the stale copy; duplication, never
2808
+ // loss) — instead of silently skipping rows (committed nowhere, painted
2782
2809
  // nowhere). Skipped on geometry frames (a rewrap legitimately reflows
2783
2810
  // every row), and skipped when the composed frame's stable prefix
2784
2811
  // covers every verified row and no rows newly became final.
@@ -2851,7 +2878,21 @@ export class TUI extends Container {
2851
2878
  const firstPaint = !this.#hasEverRendered;
2852
2879
  const replaceRequested = this.#clearScrollbackOnNextRender;
2853
2880
  const geometryRebuild = geometryChanged && !resizeRepaintsInPlace();
2854
- const fullPaint = firstPaint || replaceRequested || geometryRebuild;
2881
+ // Committed history no longer matches the frame: a finalized block
2882
+ // replaced its scrolled-off live render, or the frame collapsed into
2883
+ // recorded rows. Native scrollback is a render cache, not a court
2884
+ // record — erase and replay so history holds the content exactly once,
2885
+ // instead of recommitting the final form below the stale fragment
2886
+ // (a visibly duplicated block). Multiplexer panes cannot ED3 safely
2887
+ // and keep the repair-below fallback in the branches under this one.
2888
+ const divergenceRebuild =
2889
+ this.#scrollbackRebuildEnabled &&
2890
+ !firstPaint &&
2891
+ !replaceRequested &&
2892
+ !geometryChanged &&
2893
+ !isMultiplexerSession() &&
2894
+ (committedRowsResynced || frameLength <= this.#committedRows);
2895
+ const fullPaint = firstPaint || replaceRequested || geometryRebuild || divergenceRebuild;
2855
2896
  let windowTop: number;
2856
2897
  let chunkTo: number;
2857
2898
  if (fullPaint) {
@@ -2864,16 +2905,17 @@ export class TUI extends Container {
2864
2905
  frameLength - this.#committedRows < height &&
2865
2906
  cursorMarkers.some(marker => marker.row >= this.#committedRows))
2866
2907
  ) {
2867
- // Either the frame shrank into the committed prefix, or a
2868
- // committed-prefix resync left a focused cursor tail shorter than the
2869
- // viewport. The latter happens when a streaming/live block had an
2870
- // append-only prefix committed, then collapses on abort/finalize:
2871
- // the audit re-anchors #committedRows at the first divergent row, but
2872
- // flooring windowTop there would pin the editor near the top and
2873
- // leave blank rows underneath. Re-show the frame tail instead. The
2874
- // stale committed copy stays in native history; duplicating a few rows
2875
- // is preferable to a live editor gap and matches the existing
2876
- // "duplication, never loss" resync contract.
2908
+ // Multiplexer fallback (a direct terminal takes the divergenceRebuild
2909
+ // full paint above): either the frame shrank into the committed
2910
+ // prefix, or a committed-prefix resync left a focused cursor tail
2911
+ // shorter than the viewport. The latter happens when a streaming/live
2912
+ // block had an append-only prefix committed, then collapses on
2913
+ // abort/finalize: the audit re-anchors #committedRows at the first
2914
+ // divergent row, but flooring windowTop there would pin the editor
2915
+ // near the top and leave blank rows underneath. Re-show the frame
2916
+ // tail instead. The stale committed copy stays in native history;
2917
+ // duplicating a few rows is preferable to a live editor gap —
2918
+ // "duplication, never loss" is the ED3-unsafe fallback contract.
2877
2919
  committedPrefixResliced = true;
2878
2920
  windowTop = Math.max(0, frameLength - height);
2879
2921
  chunkTo = windowTop;
@@ -2926,7 +2968,10 @@ export class TUI extends Container {
2926
2968
  const cursorTrackingLineCount = hasVisibleOverlay ? Math.max(frame.length, windowTop + height) : frame.length;
2927
2969
 
2928
2970
  const intent: RenderIntent = fullPaint
2929
- ? { kind: "fullPaint", clearScrollback: replaceRequested || geometryRebuild ? !isMultiplexerSession() : false }
2971
+ ? {
2972
+ kind: "fullPaint",
2973
+ clearScrollback: divergenceRebuild || ((replaceRequested || geometryRebuild) && !isMultiplexerSession()),
2974
+ }
2930
2975
  : { kind: "update", chunkTo, windowTop };
2931
2976
  this.#logRedraw(intent, frameLength, height);
2932
2977