@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 +16 -0
- package/dist/types/tui.d.ts +16 -3
- package/package.json +5 -5
- package/src/tui.ts +85 -40
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
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -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
|
|
284
|
-
*
|
|
285
|
-
*
|
|
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
|
+
"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.
|
|
41
|
-
"@oh-my-pi/pi-utils": "16.
|
|
42
|
-
"lru-cache": "11.5.
|
|
43
|
-
"marked": "^18.0.
|
|
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.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
787
|
-
*
|
|
788
|
-
*
|
|
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
|
-
//
|
|
2243
|
-
//
|
|
2244
|
-
//
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
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
|
|
2700
|
-
//
|
|
2701
|
-
//
|
|
2702
|
-
//
|
|
2703
|
-
//
|
|
2704
|
-
//
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
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
|
|
2781
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
2868
|
-
//
|
|
2869
|
-
//
|
|
2870
|
-
//
|
|
2871
|
-
//
|
|
2872
|
-
//
|
|
2873
|
-
//
|
|
2874
|
-
//
|
|
2875
|
-
//
|
|
2876
|
-
//
|
|
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
|
-
? {
|
|
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
|
|