@gajae-code/tui 0.17.4 → 0.17.5
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 +12 -0
- package/dist/types/tui.d.ts +10 -0
- package/package.json +3 -3
- package/src/components/loader.ts +2 -9
- package/src/components/text.ts +9 -2
- package/src/tui.ts +256 -64
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.17.5] - 2026-09-24
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `Text` rows, including custom-background rows, no longer exceed the viewport width.
|
|
10
|
+
|
|
11
|
+
### Performance
|
|
12
|
+
|
|
13
|
+
- Layout-only frames reuse a cached transcript prefix instead of copying and re-normalizing every row. Off-screen prefix identity is still checked by reference, and only the viewport window is normalized. Emitted bytes stay the same.
|
|
14
|
+
|
|
15
|
+
- `Text` measures each row once and slices overflow instead of measuring again. `Loader` no longer re-clamps its rows.
|
|
16
|
+
|
|
5
17
|
## [0.17.4] - 2026-09-23
|
|
6
18
|
|
|
7
19
|
## [0.17.3] - 2026-09-22
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -365,6 +365,15 @@ type TuiRenderCounterSnapshot = {
|
|
|
365
365
|
widthReflowVisibleWidthCalls: number;
|
|
366
366
|
kittyPlacementScanRows: number;
|
|
367
367
|
kittyPlacementReferenceRows: number;
|
|
368
|
+
/** Rows copied into the frame while the render scope is layout-only. */
|
|
369
|
+
layoutAssemblyRows: number;
|
|
370
|
+
/** Off-screen prefix comparisons performed by a layout-only frame. */
|
|
371
|
+
layoutOffscreenPrefixCompares: number;
|
|
372
|
+
/**
|
|
373
|
+
* Stable prefix rows copied into a fresh layout frame. Zero when the spare prefix is reused.
|
|
374
|
+
* The fresh slice also copies the viewport window, which is rewritten immediately.
|
|
375
|
+
*/
|
|
376
|
+
layoutPrefixLineCopies: number;
|
|
368
377
|
};
|
|
369
378
|
/**
|
|
370
379
|
* TUI - Main class for managing terminal UI with differential rendering
|
|
@@ -377,6 +386,7 @@ export declare class TUI extends Container {
|
|
|
377
386
|
onDebug?: () => void;
|
|
378
387
|
static resetRenderCountersForTest(): void;
|
|
379
388
|
static getRenderCountersForTest(): TuiRenderCounterSnapshot;
|
|
389
|
+
getRenderedLineForTest(index: number): string | undefined;
|
|
380
390
|
getRenderPreparationStateForTest(): {
|
|
381
391
|
pending: number;
|
|
382
392
|
holes: number;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/tui",
|
|
4
|
-
"version": "0.17.
|
|
4
|
+
"version": "0.17.5",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://gajae-code.com",
|
|
7
7
|
"author": "Yeachan-Heo and Gajae Code Contributors",
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"fmt": "biome format --write ."
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@gajae-code/natives": "0.17.
|
|
40
|
-
"@gajae-code/utils": "0.17.
|
|
39
|
+
"@gajae-code/natives": "0.17.5",
|
|
40
|
+
"@gajae-code/utils": "0.17.5",
|
|
41
41
|
"lru-cache": "11.3.6",
|
|
42
42
|
"marked": "18.0.6"
|
|
43
43
|
},
|
package/src/components/loader.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { type AnimationRegistration, registerAnimationCallback } from "../animation-scheduler";
|
|
2
2
|
import { isRemoteTerminalSession, isUnderTerminalMultiplexer } from "../terminal-capabilities";
|
|
3
3
|
import type { TUI } from "../tui";
|
|
4
|
-
import { sliceByColumn, visibleWidth } from "../utils";
|
|
5
4
|
import { Text } from "./text";
|
|
6
5
|
|
|
7
6
|
const SPINNER_ADVANCE_MS = 80;
|
|
@@ -67,14 +66,8 @@ export class Loader extends Text {
|
|
|
67
66
|
}
|
|
68
67
|
|
|
69
68
|
render(width: number): string[] {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const line = lines[i];
|
|
73
|
-
if (visibleWidth(line) > width) {
|
|
74
|
-
lines[i] = sliceByColumn(line, 0, width, true);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
return lines;
|
|
69
|
+
// Leading blank is the spacer; Text already fits each row to `width`.
|
|
70
|
+
return ["", ...super.render(width)];
|
|
78
71
|
}
|
|
79
72
|
|
|
80
73
|
start() {
|
package/src/components/text.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
extractViewportAnchorRows,
|
|
7
7
|
padding,
|
|
8
8
|
replaceTabs,
|
|
9
|
+
sliceByColumn,
|
|
9
10
|
type ViewportAnchorSpan,
|
|
10
11
|
visibleWidth,
|
|
11
12
|
wrapTextWithAnsi,
|
|
@@ -101,8 +102,14 @@ export class Text implements Component {
|
|
|
101
102
|
const contentLines: string[] = [];
|
|
102
103
|
for (const line of wrappedLines) {
|
|
103
104
|
const lineWithMargins = leftMargin + line + rightMargin;
|
|
104
|
-
|
|
105
|
-
|
|
105
|
+
// A wrapped grapheme can still be wider than the viewport. Measure once,
|
|
106
|
+
// then slice instead of padding. Background functions do not change width.
|
|
107
|
+
const measured = visibleWidth(lineWithMargins);
|
|
108
|
+
const fitted =
|
|
109
|
+
measured > width
|
|
110
|
+
? sliceByColumn(lineWithMargins, 0, width, true)
|
|
111
|
+
: lineWithMargins + padding(width - measured);
|
|
112
|
+
contentLines.push(this.#customBgFn ? this.#customBgFn(fitted) : fitted);
|
|
106
113
|
}
|
|
107
114
|
const emptyLine = padding(width);
|
|
108
115
|
const emptyLines = Array.from({ length: this.#paddingY }, () =>
|
package/src/tui.ts
CHANGED
|
@@ -822,6 +822,15 @@ type TuiRenderCounterSnapshot = {
|
|
|
822
822
|
widthReflowVisibleWidthCalls: number;
|
|
823
823
|
kittyPlacementScanRows: number;
|
|
824
824
|
kittyPlacementReferenceRows: number;
|
|
825
|
+
/** Rows copied into the frame while the render scope is layout-only. */
|
|
826
|
+
layoutAssemblyRows: number;
|
|
827
|
+
/** Off-screen prefix comparisons performed by a layout-only frame. */
|
|
828
|
+
layoutOffscreenPrefixCompares: number;
|
|
829
|
+
/**
|
|
830
|
+
* Stable prefix rows copied into a fresh layout frame. Zero when the spare prefix is reused.
|
|
831
|
+
* The fresh slice also copies the viewport window, which is rewritten immediately.
|
|
832
|
+
*/
|
|
833
|
+
layoutPrefixLineCopies: number;
|
|
825
834
|
};
|
|
826
835
|
type RenderCommitWaiter = {
|
|
827
836
|
resolve: (committed: boolean) => void;
|
|
@@ -996,6 +1005,12 @@ export class TUI extends Container {
|
|
|
996
1005
|
#latestRenderedPlacementOwners = new Map<string, KittyPlacementOwner>();
|
|
997
1006
|
#kittyPlacementSpans: KittyPlacementSpan[] = [];
|
|
998
1007
|
#latestRaw: string[] = [];
|
|
1008
|
+
// Spare frame for a repeated layout tick. Its off-screen prefix is the prefix of the
|
|
1009
|
+
// frame that last took this path, so only the viewport window is rewritten.
|
|
1010
|
+
#layoutSpareRaw: string[] | null = null;
|
|
1011
|
+
#layoutSpareRendered: string[] | null = null;
|
|
1012
|
+
#layoutSpareValid = false;
|
|
1013
|
+
static #viewportNormalizeOverscan = 8;
|
|
999
1014
|
#durableLineCount = 0;
|
|
1000
1015
|
#durableRenderedLines: string[] = [];
|
|
1001
1016
|
#durableRawLines: string[] = [];
|
|
@@ -1189,6 +1204,9 @@ export class TUI extends Container {
|
|
|
1189
1204
|
widthReflowVisibleWidthCalls: 0,
|
|
1190
1205
|
kittyPlacementScanRows: 0,
|
|
1191
1206
|
kittyPlacementReferenceRows: 0,
|
|
1207
|
+
layoutAssemblyRows: 0,
|
|
1208
|
+
layoutOffscreenPrefixCompares: 0,
|
|
1209
|
+
layoutPrefixLineCopies: 0,
|
|
1192
1210
|
};
|
|
1193
1211
|
|
|
1194
1212
|
static resetRenderCountersForTest(): void {
|
|
@@ -1200,6 +1218,9 @@ export class TUI extends Container {
|
|
|
1200
1218
|
widthReflowVisibleWidthCalls: 0,
|
|
1201
1219
|
kittyPlacementScanRows: 0,
|
|
1202
1220
|
kittyPlacementReferenceRows: 0,
|
|
1221
|
+
layoutAssemblyRows: 0,
|
|
1222
|
+
layoutOffscreenPrefixCompares: 0,
|
|
1223
|
+
layoutPrefixLineCopies: 0,
|
|
1203
1224
|
};
|
|
1204
1225
|
}
|
|
1205
1226
|
|
|
@@ -1207,6 +1228,10 @@ export class TUI extends Container {
|
|
|
1207
1228
|
return { ...TUI.#renderCounters };
|
|
1208
1229
|
}
|
|
1209
1230
|
|
|
1231
|
+
getRenderedLineForTest(index: number): string | undefined {
|
|
1232
|
+
return this.#latestRenderedLines[index];
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1210
1235
|
getRenderPreparationStateForTest(): { pending: number; holes: number; failedRanges: number } {
|
|
1211
1236
|
return {
|
|
1212
1237
|
pending: this.#preparationBlocked.size,
|
|
@@ -2861,6 +2886,9 @@ export class TUI extends Container {
|
|
|
2861
2886
|
this.#latestRenderedLines = [];
|
|
2862
2887
|
this.#kittyPlacementSpans = [];
|
|
2863
2888
|
this.#latestRaw = [];
|
|
2889
|
+
this.#layoutSpareRaw = null;
|
|
2890
|
+
this.#layoutSpareRendered = null;
|
|
2891
|
+
this.#layoutSpareValid = false;
|
|
2864
2892
|
this.#durableLineCount = 0;
|
|
2865
2893
|
this.#nativeScrollbackAdmissionPending = false;
|
|
2866
2894
|
this.#durableRenderedLines.length = 0;
|
|
@@ -4570,8 +4598,97 @@ export class TUI extends Container {
|
|
|
4570
4598
|
this.#recordPaintedViewportObservation(this.#viewportTopRow, height, false);
|
|
4571
4599
|
}
|
|
4572
4600
|
|
|
4601
|
+
/**
|
|
4602
|
+
* Reuse the committed off-screen prefix when a layout tick's cached transcript still
|
|
4603
|
+
* matches it by reference. Only the viewport window is copied and normalized.
|
|
4604
|
+
* Returns null when that prefix changed so the caller keeps the full-frame path.
|
|
4605
|
+
*/
|
|
4606
|
+
#reuseCachedLayoutPrefix(
|
|
4607
|
+
width: number,
|
|
4608
|
+
height: number,
|
|
4609
|
+
beforeLines: string[],
|
|
4610
|
+
anchorLines: string[],
|
|
4611
|
+
afterLines: string[],
|
|
4612
|
+
spareValid: boolean,
|
|
4613
|
+
): {
|
|
4614
|
+
newLines: string[];
|
|
4615
|
+
rawLines: string[];
|
|
4616
|
+
diffStart: number;
|
|
4617
|
+
cursorPos: { row: number; col: number } | null;
|
|
4618
|
+
} | null {
|
|
4619
|
+
const total = beforeLines.length + anchorLines.length + afterLines.length;
|
|
4620
|
+
if (total === 0 || this.#latestRaw.length !== total || this.#latestRenderedLines.length !== total) return null;
|
|
4621
|
+
const winTop = Math.max(0, total - height - TUI.#viewportNormalizeOverscan);
|
|
4622
|
+
const prefixLength = beforeLines.length + anchorLines.length;
|
|
4623
|
+
const anchorOverrides = new Map<number, string>();
|
|
4624
|
+
const rawAt = (index: number): string => {
|
|
4625
|
+
if (index < beforeLines.length) return beforeLines[index] ?? "";
|
|
4626
|
+
if (index < prefixLength) {
|
|
4627
|
+
const anchorIndex = index - beforeLines.length;
|
|
4628
|
+
return anchorOverrides.get(anchorIndex) ?? anchorLines[anchorIndex] ?? "";
|
|
4629
|
+
}
|
|
4630
|
+
return afterLines[index - prefixLength] ?? "";
|
|
4631
|
+
};
|
|
4632
|
+
const scanStart = Math.max(0, total - height);
|
|
4633
|
+
const bottom: string[] = [];
|
|
4634
|
+
for (let row = scanStart; row < total; row++) bottom.push(rawAt(row));
|
|
4635
|
+
const extracted = this.#extractCursorPosition(bottom, bottom.length);
|
|
4636
|
+
for (let index = 0; index < bottom.length; index++) {
|
|
4637
|
+
const row = scanStart + index;
|
|
4638
|
+
const updated = bottom[index];
|
|
4639
|
+
if (updated === undefined || updated === rawAt(row)) continue;
|
|
4640
|
+
if (row < beforeLines.length) beforeLines[row] = updated;
|
|
4641
|
+
else if (row < prefixLength) anchorOverrides.set(row - beforeLines.length, updated);
|
|
4642
|
+
else afterLines[row - prefixLength] = updated;
|
|
4643
|
+
}
|
|
4644
|
+
let stable = winTop <= this.#latestRaw.length;
|
|
4645
|
+
let compared = 0;
|
|
4646
|
+
for (let index = 0; stable && index < winTop; index++) {
|
|
4647
|
+
compared += 1;
|
|
4648
|
+
if (rawAt(index) !== this.#latestRaw[index]) stable = false;
|
|
4649
|
+
}
|
|
4650
|
+
if (!stable) return null;
|
|
4651
|
+
TUI.#renderCounters.layoutOffscreenPrefixCompares += compared;
|
|
4652
|
+
|
|
4653
|
+
const spareRaw = this.#layoutSpareRaw;
|
|
4654
|
+
const spareRendered = this.#layoutSpareRendered;
|
|
4655
|
+
let nextRaw: string[];
|
|
4656
|
+
let nextRendered: string[];
|
|
4657
|
+
if (
|
|
4658
|
+
spareValid &&
|
|
4659
|
+
spareRaw !== null &&
|
|
4660
|
+
spareRendered !== null &&
|
|
4661
|
+
spareRaw.length === total &&
|
|
4662
|
+
spareRendered.length === total &&
|
|
4663
|
+
spareRaw !== this.#latestRaw &&
|
|
4664
|
+
spareRendered !== this.#latestRenderedLines &&
|
|
4665
|
+
spareRendered !== this.#previousLines
|
|
4666
|
+
) {
|
|
4667
|
+
nextRaw = spareRaw;
|
|
4668
|
+
nextRendered = spareRendered;
|
|
4669
|
+
} else {
|
|
4670
|
+
nextRaw = this.#latestRaw.slice();
|
|
4671
|
+
nextRendered = this.#latestRenderedLines.slice();
|
|
4672
|
+
TUI.#renderCounters.layoutPrefixLineCopies += winTop;
|
|
4673
|
+
}
|
|
4674
|
+
for (let index = winTop; index < total; index++) {
|
|
4675
|
+
const rawLine = rawAt(index);
|
|
4676
|
+
nextRaw[index] = rawLine;
|
|
4677
|
+
nextRendered[index] = rawLine;
|
|
4678
|
+
}
|
|
4679
|
+
this.#normalizeLinesForEmit(nextRendered, width, winTop);
|
|
4680
|
+
this.#trimLineCachesForRender(total);
|
|
4681
|
+
this.#layoutSpareRaw = this.#latestRaw;
|
|
4682
|
+
this.#layoutSpareRendered = this.#latestRenderedLines;
|
|
4683
|
+
this.#layoutSpareValid = true;
|
|
4684
|
+
const cursorPos = extracted === null ? null : { row: extracted.row + scanStart, col: extracted.col };
|
|
4685
|
+
return { newLines: nextRendered, rawLines: nextRaw, diffStart: winTop, cursorPos };
|
|
4686
|
+
}
|
|
4687
|
+
|
|
4573
4688
|
#doRender(): void {
|
|
4574
4689
|
if (this.#stopped || !this.terminalAvailable) return;
|
|
4690
|
+
const layoutSpareValid = this.#layoutSpareValid;
|
|
4691
|
+
this.#layoutSpareValid = false;
|
|
4575
4692
|
const transcriptIdentityReplaced = this.#transcriptIdentityReplaced;
|
|
4576
4693
|
const restartViewportRepaintPending = this.#restartViewportRepaintPending;
|
|
4577
4694
|
const resizeRenderMutationQueued = this.#resizeRenderMutationQueued;
|
|
@@ -4598,9 +4715,22 @@ export class TUI extends Container {
|
|
|
4598
4715
|
const renderTreeStart = renderMetrics.now();
|
|
4599
4716
|
const renderScope = this.#renderScope;
|
|
4600
4717
|
this.#renderScope = "full";
|
|
4601
|
-
|
|
4718
|
+
let renderedLines: string[] = [];
|
|
4602
4719
|
const renderedChildren = new Map<Component, string[]>();
|
|
4603
4720
|
let anchorFrame: ViewportAnchorFrame | null = null;
|
|
4721
|
+
let reusedAnchor: { start: number; lines: string[] } | null = null;
|
|
4722
|
+
const layoutPrefixEligible =
|
|
4723
|
+
renderScope === "layout" &&
|
|
4724
|
+
this.#virtualViewport &&
|
|
4725
|
+
this.#previousWidth === width &&
|
|
4726
|
+
this.#previousWidth !== 0 &&
|
|
4727
|
+
this.#previousHeight === height &&
|
|
4728
|
+
this.overlayStack.length === 0 &&
|
|
4729
|
+
!this.#mouseSelectionActive &&
|
|
4730
|
+
this.#manualViewportTop === undefined &&
|
|
4731
|
+
this.#latestRaw.length > 0 &&
|
|
4732
|
+
this.#latestRenderedLines.length === this.#latestRaw.length &&
|
|
4733
|
+
this.#previousLines === this.#latestRenderedLines;
|
|
4604
4734
|
let previousKittyPlacementSpans = this.#kittyPlacementSpans;
|
|
4605
4735
|
const placementOwners = new Map<string, KittyPlacementOwner>();
|
|
4606
4736
|
const pinnedChildIndex =
|
|
@@ -4651,6 +4781,20 @@ export class TUI extends Container {
|
|
|
4651
4781
|
anchorFrame = { startRow: childStart, anchors: rendered.anchors };
|
|
4652
4782
|
}
|
|
4653
4783
|
const owner: KittyPlacementOwner = hasStickySuffix && childIndex >= pinnedChildIndex ? "suffix" : "transcript";
|
|
4784
|
+
if (layoutPrefixEligible && reuseCached) {
|
|
4785
|
+
reusedAnchor = { start: childStart, lines: safeLines };
|
|
4786
|
+
if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
|
|
4787
|
+
for (let lineIndex = 0; lineIndex < rendered.lines.length; lineIndex++) {
|
|
4788
|
+
const placements = kittyPlacements[lineIndex];
|
|
4789
|
+
if (placements !== undefined) {
|
|
4790
|
+
for (const placement of placements) {
|
|
4791
|
+
placementOwners.set(this.#kittyPlacementKey(placement), owner);
|
|
4792
|
+
}
|
|
4793
|
+
}
|
|
4794
|
+
}
|
|
4795
|
+
}
|
|
4796
|
+
continue;
|
|
4797
|
+
}
|
|
4654
4798
|
for (let lineIndex = 0; lineIndex < rendered.lines.length; lineIndex++) {
|
|
4655
4799
|
const placements = kittyPlacements[lineIndex];
|
|
4656
4800
|
if (placements !== undefined) {
|
|
@@ -4659,43 +4803,27 @@ export class TUI extends Container {
|
|
|
4659
4803
|
}
|
|
4660
4804
|
}
|
|
4661
4805
|
renderedLines.push(safeLines[lineIndex] ?? rendered.lines[lineIndex]!);
|
|
4806
|
+
if (renderScope === "layout") TUI.#renderCounters.layoutAssemblyRows += 1;
|
|
4662
4807
|
}
|
|
4663
4808
|
}
|
|
4809
|
+
const frameLineCount = renderedLines.length + (reusedAnchor?.lines.length ?? 0);
|
|
4664
4810
|
const sourceTranscriptLineCount = hasStickySuffix
|
|
4665
4811
|
? this.children
|
|
4666
4812
|
.slice(0, pinnedChildIndex)
|
|
4667
4813
|
.reduce((count, child) => count + this.#pinnedChildLines(child, renderedChildren).length, 0)
|
|
4668
|
-
:
|
|
4814
|
+
: frameLineCount;
|
|
4669
4815
|
const anchorRenderFailed = viewportAnchorRenderFailureCount !== anchorRenderFailureCountBefore;
|
|
4670
|
-
let newLines = this.#constrainPinnedSuffix(renderedLines, height, renderedChildren);
|
|
4671
4816
|
this.#viewportAnchorFrame = anchorFrame;
|
|
4672
4817
|
if (renderMetrics.enabled) renderMetrics.recordHelper("renderTree", renderMetrics.now() - renderTreeStart);
|
|
4673
|
-
|
|
4674
|
-
if (hasStickySuffix
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
newLines.length - sourceTranscriptLineCount,
|
|
4679
|
-
).lines;
|
|
4680
|
-
}
|
|
4681
|
-
const nextTranscriptLineCount = sourceTranscriptLineCount;
|
|
4682
|
-
const nextSuffixLineCount = hasStickySuffix ? Math.max(0, newLines.length - nextTranscriptLineCount) : 0;
|
|
4683
|
-
|
|
4684
|
-
// Composite overlays into the rendered lines (before differential compare)
|
|
4685
|
-
if (this.overlayStack.length > 0) {
|
|
4686
|
-
newLines = this.#compositeOverlays(newLines, width, height, placementOwners);
|
|
4818
|
+
let suffixRowCount = 0;
|
|
4819
|
+
if (hasStickySuffix) {
|
|
4820
|
+
for (let index = pinnedChildIndex; index < this.children.length; index++) {
|
|
4821
|
+
suffixRowCount += this.#pinnedChildLines(this.children[index]!, renderedChildren).length;
|
|
4822
|
+
}
|
|
4687
4823
|
}
|
|
4824
|
+
const layoutPrefixBlocked =
|
|
4825
|
+
hasStickySuffix && (suffixRowCount > height || (height > 0 && frameLineCount < height));
|
|
4688
4826
|
|
|
4689
|
-
// Extract cursor position (marker must be found before diff comparison)
|
|
4690
|
-
const cursorPos = this.#extractCursorPosition(newLines, height);
|
|
4691
|
-
this.#lastCursorPosition = cursorPos;
|
|
4692
|
-
|
|
4693
|
-
newLines = this.#applyMouseSelection(newLines);
|
|
4694
|
-
|
|
4695
|
-
// Terminate every non-image line so the latest frame mirrors emitted bytes
|
|
4696
|
-
// (closes SGR + OSC 8 hyperlink state). Must run after cursor extraction
|
|
4697
|
-
// because the marker is embedded mid-line, and before any diff/full render
|
|
4698
|
-
// path so cache comparisons stay byte-accurate.
|
|
4699
4827
|
// Width/height change detection (used for normalization reuse and repaint decisions).
|
|
4700
4828
|
const widthChanged = this.#previousWidth !== 0 && this.#previousWidth !== width;
|
|
4701
4829
|
const widthMetadataChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
|
|
@@ -4708,50 +4836,114 @@ export class TUI extends Container {
|
|
|
4708
4836
|
const heightChanged = this.#previousHeight !== 0 && this.#previousHeight !== height;
|
|
4709
4837
|
const initialRender = this.#previousLines.length === 0 && this.#maxLinesRendered === 0;
|
|
4710
4838
|
let coalescedWidthAppend = false;
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
// short-circuit for cached components), so only the visible window is
|
|
4716
|
-
// re-normalized and the diff starts at the window. Output is byte-identical to the
|
|
4717
|
-
// full path (reused entries are deterministic normalizations of identical raw lines).
|
|
4718
|
-
const VIEWPORT_NORMALIZE_OVERSCAN = 8;
|
|
4719
|
-
// Reassigned below, never mutated in place -- a reference is the snapshot.
|
|
4720
|
-
const rawLines = newLines;
|
|
4721
|
-
const total = rawLines.length;
|
|
4839
|
+
const VIEWPORT_NORMALIZE_OVERSCAN = TUI.#viewportNormalizeOverscan;
|
|
4840
|
+
let newLines = renderedLines;
|
|
4841
|
+
let rawLines = renderedLines;
|
|
4842
|
+
let cursorPos: { row: number; col: number } | null = null;
|
|
4722
4843
|
let diffStart = 0;
|
|
4723
4844
|
let usedWindowNormalize = false;
|
|
4724
|
-
|
|
4725
|
-
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
|
|
4845
|
+
let stitched = false;
|
|
4846
|
+
if (reusedAnchor !== null && layoutPrefixEligible && !layoutPrefixBlocked) {
|
|
4847
|
+
const reused = this.#reuseCachedLayoutPrefix(
|
|
4848
|
+
width,
|
|
4849
|
+
height,
|
|
4850
|
+
renderedLines.slice(0, reusedAnchor.start),
|
|
4851
|
+
reusedAnchor.lines,
|
|
4852
|
+
renderedLines.slice(reusedAnchor.start),
|
|
4853
|
+
layoutSpareValid,
|
|
4854
|
+
);
|
|
4855
|
+
if (reused !== null) {
|
|
4856
|
+
stitched = true;
|
|
4857
|
+
newLines = reused.newLines;
|
|
4858
|
+
rawLines = reused.rawLines;
|
|
4859
|
+
cursorPos = reused.cursorPos;
|
|
4860
|
+
diffStart = reused.diffStart;
|
|
4861
|
+
usedWindowNormalize = true;
|
|
4862
|
+
}
|
|
4863
|
+
}
|
|
4864
|
+
if (!stitched) {
|
|
4865
|
+
this.#layoutSpareRaw = null;
|
|
4866
|
+
this.#layoutSpareRendered = null;
|
|
4867
|
+
if (reusedAnchor !== null) {
|
|
4868
|
+
if (renderScope === "layout") TUI.#renderCounters.layoutAssemblyRows += reusedAnchor.lines.length;
|
|
4869
|
+
const anchorLineCount = reusedAnchor.lines.length;
|
|
4870
|
+
const merged = new Array<string>(renderedLines.length + anchorLineCount);
|
|
4871
|
+
for (let index = 0; index < reusedAnchor.start; index++) merged[index] = renderedLines[index]!;
|
|
4872
|
+
for (let index = 0; index < anchorLineCount; index++) {
|
|
4873
|
+
merged[reusedAnchor.start + index] = reusedAnchor.lines[index]!;
|
|
4874
|
+
}
|
|
4875
|
+
const afterCount = renderedLines.length - reusedAnchor.start;
|
|
4876
|
+
for (let index = 0; index < afterCount; index++) {
|
|
4877
|
+
merged[reusedAnchor.start + anchorLineCount + index] = renderedLines[reusedAnchor.start + index]!;
|
|
4738
4878
|
}
|
|
4739
|
-
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
|
|
4879
|
+
renderedLines = merged;
|
|
4880
|
+
}
|
|
4881
|
+
newLines = this.#constrainPinnedSuffix(renderedLines, height, renderedChildren);
|
|
4882
|
+
if (hasStickySuffix && height > 0 && this.#manualViewportTop === undefined) {
|
|
4883
|
+
newLines = this.#padBeforeBottomPinnedComponent(
|
|
4884
|
+
newLines,
|
|
4885
|
+
height,
|
|
4886
|
+
newLines.length - sourceTranscriptLineCount,
|
|
4887
|
+
).lines;
|
|
4888
|
+
}
|
|
4889
|
+
// Composite overlays into the rendered lines (before differential compare)
|
|
4890
|
+
if (this.overlayStack.length > 0) {
|
|
4891
|
+
newLines = this.#compositeOverlays(newLines, width, height, placementOwners);
|
|
4892
|
+
}
|
|
4893
|
+
// Extract cursor position (marker must be found before diff comparison)
|
|
4894
|
+
cursorPos = this.#extractCursorPosition(newLines, height);
|
|
4895
|
+
newLines = this.#applyMouseSelection(newLines);
|
|
4896
|
+
// Terminate every non-image line so the latest frame mirrors emitted bytes
|
|
4897
|
+
// (closes SGR + OSC 8 hyperlink state). Must run after cursor extraction
|
|
4898
|
+
// because the marker is embedded mid-line, and before any diff/full render
|
|
4899
|
+
// path so cache comparisons stay byte-accurate.
|
|
4900
|
+
// Reassigned below, never mutated in place -- a reference is the snapshot.
|
|
4901
|
+
rawLines = newLines;
|
|
4902
|
+
diffStart = 0;
|
|
4903
|
+
usedWindowNormalize = false;
|
|
4904
|
+
// Normalize/truncate lines for emission. The virtual viewport is default-on;
|
|
4905
|
+
// PI_TUI_VIRTUAL_VIEWPORT=0 opts out. When enabled, reuse the previous frame's
|
|
4906
|
+
// normalized prefix when the off-screen raw prefix is unchanged (raw value equality
|
|
4907
|
+
// short-circuit for cached components), so only the visible window is
|
|
4908
|
+
// re-normalized and the diff starts at the window. Output is byte-identical to the
|
|
4909
|
+
// full path (reused entries are deterministic normalizations of identical raw lines).
|
|
4910
|
+
if (
|
|
4911
|
+
this.#virtualViewport &&
|
|
4912
|
+
!widthChanged &&
|
|
4913
|
+
this.#latestRaw.length > 0 &&
|
|
4914
|
+
this.#latestRenderedLines.length === this.#latestRaw.length
|
|
4915
|
+
) {
|
|
4916
|
+
const winTop = Math.max(0, rawLines.length - height - VIEWPORT_NORMALIZE_OVERSCAN);
|
|
4917
|
+
if (winTop <= this.#latestRenderedLines.length && winTop <= this.#latestRaw.length) {
|
|
4918
|
+
let stable = true;
|
|
4919
|
+
for (let i = 0; i < winTop; i++) {
|
|
4920
|
+
if (renderScope === "layout") TUI.#renderCounters.layoutOffscreenPrefixCompares += 1;
|
|
4921
|
+
if (rawLines[i] !== this.#latestRaw[i]) {
|
|
4922
|
+
stable = false;
|
|
4923
|
+
break;
|
|
4924
|
+
}
|
|
4925
|
+
}
|
|
4926
|
+
if (stable) {
|
|
4927
|
+
const windowed = this.#latestRenderedLines.slice(0, winTop);
|
|
4928
|
+
for (let i = winTop; i < rawLines.length; i++) {
|
|
4929
|
+
windowed.push(rawLines[i]!);
|
|
4930
|
+
}
|
|
4931
|
+
this.#normalizeLinesForEmit(windowed, width, winTop);
|
|
4932
|
+
this.#trimLineCachesForRender(rawLines.length);
|
|
4933
|
+
newLines = windowed;
|
|
4934
|
+
diffStart = winTop;
|
|
4935
|
+
usedWindowNormalize = true;
|
|
4743
4936
|
}
|
|
4744
|
-
this.#normalizeLinesForEmit(windowed, width, winTop);
|
|
4745
|
-
this.#trimLineCachesForRender(total);
|
|
4746
|
-
newLines = windowed;
|
|
4747
|
-
diffStart = winTop;
|
|
4748
|
-
usedWindowNormalize = true;
|
|
4749
4937
|
}
|
|
4750
4938
|
}
|
|
4939
|
+
if (!usedWindowNormalize) {
|
|
4940
|
+
newLines = this.#applyLineResetsAndTruncate(rawLines.slice(), width);
|
|
4941
|
+
}
|
|
4751
4942
|
}
|
|
4752
|
-
|
|
4753
|
-
|
|
4754
|
-
|
|
4943
|
+
const nextTranscriptLineCount = sourceTranscriptLineCount;
|
|
4944
|
+
const nextSuffixLineCount = hasStickySuffix ? Math.max(0, newLines.length - nextTranscriptLineCount) : 0;
|
|
4945
|
+
this.#lastCursorPosition = cursorPos;
|
|
4946
|
+
const total = rawLines.length;
|
|
4755
4947
|
if (renderMetrics.enabled) {
|
|
4756
4948
|
renderMetrics.recordLineCount("rendered", total);
|
|
4757
4949
|
renderMetrics.recordLineCount("normalized", total - diffStart);
|