@gajae-code/tui 0.17.2 → 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 +27 -0
- package/dist/types/components/editor.d.ts +2 -0
- package/dist/types/components/markdown.d.ts +1 -0
- package/dist/types/tui.d.ts +11 -0
- package/package.json +3 -3
- package/src/components/editor.ts +36 -0
- package/src/components/loader.ts +2 -9
- package/src/components/markdown.ts +7 -1
- package/src/components/text.ts +9 -2
- package/src/tui.ts +270 -66
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
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
|
+
|
|
17
|
+
## [0.17.4] - 2026-09-23
|
|
18
|
+
|
|
19
|
+
## [0.17.3] - 2026-09-22
|
|
20
|
+
|
|
21
|
+
### Performance
|
|
22
|
+
|
|
23
|
+
- Avoid unsupported Kitty placement extraction on ordinary non-Kitty input frames.
|
|
24
|
+
- Reuse unchanged editor logical-line layouts, including keyboard shrink/join paths that previously retained deleted-line cache entries.
|
|
25
|
+
- Reuse the byte/line admission decision for exact cached Markdown highlights instead of rescanning unchanged fenced code.
|
|
26
|
+
- Add a native-highlight input-to-synchronized-write benchmark with same-frame visibility checks. Scheduling, preparation, force precedence, and output revisions are unchanged.
|
|
27
|
+
|
|
28
|
+
- Avoid allocating fallback arrays while attributing Kitty placements during frame assembly. Emitted terminal bytes are unchanged.
|
|
29
|
+
|
|
30
|
+
- Hold the raw frame by reference instead of copying every transcript line during frame assembly. Emitted terminal bytes are unchanged.
|
|
31
|
+
|
|
5
32
|
## [0.17.2] - 2026-09-18
|
|
6
33
|
|
|
7
34
|
## [0.17.1] - 2026-09-17
|
|
@@ -98,6 +98,8 @@ export declare class Editor implements Component, Focusable {
|
|
|
98
98
|
invalidate(): void;
|
|
99
99
|
render(width: number): string[];
|
|
100
100
|
handleInput(data: string): void;
|
|
101
|
+
/** Test-only seam: retained per-logical-line layout entries. */
|
|
102
|
+
get logicalLayoutCacheSize(): number;
|
|
101
103
|
/** Test-only seam: current wrap-cache entry count (memory-bound assertions). */
|
|
102
104
|
get wrappedLineCacheSize(): number;
|
|
103
105
|
getText(): string;
|
|
@@ -21,6 +21,7 @@ export declare function resetMarkdownHighlightCallCount(): void;
|
|
|
21
21
|
export declare const __markdownPerfCounters: {
|
|
22
22
|
lexerInvocations: number;
|
|
23
23
|
lexedBytes: number;
|
|
24
|
+
highlightAdmissionScans: number;
|
|
24
25
|
reset(): void;
|
|
25
26
|
};
|
|
26
27
|
/** Drop all L2 cache entries. Call on theme change to prevent stale styled output. */
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -364,6 +364,16 @@ type TuiRenderCounterSnapshot = {
|
|
|
364
364
|
widthReflowScanRows: number;
|
|
365
365
|
widthReflowVisibleWidthCalls: number;
|
|
366
366
|
kittyPlacementScanRows: number;
|
|
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;
|
|
367
377
|
};
|
|
368
378
|
/**
|
|
369
379
|
* TUI - Main class for managing terminal UI with differential rendering
|
|
@@ -376,6 +386,7 @@ export declare class TUI extends Container {
|
|
|
376
386
|
onDebug?: () => void;
|
|
377
387
|
static resetRenderCountersForTest(): void;
|
|
378
388
|
static getRenderCountersForTest(): TuiRenderCounterSnapshot;
|
|
389
|
+
getRenderedLineForTest(index: number): string | undefined;
|
|
379
390
|
getRenderPreparationStateForTest(): {
|
|
380
391
|
pending: number;
|
|
381
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/editor.ts
CHANGED
|
@@ -443,6 +443,7 @@ export class Editor implements Component, Focusable {
|
|
|
443
443
|
#wrappedLineCache: CachedWrappedLine[] = [];
|
|
444
444
|
#docVersion = 0;
|
|
445
445
|
#layoutCache: LayoutCache | undefined;
|
|
446
|
+
#logicalLayoutCache: Array<{ text: string; width: number; cursorCol: number | undefined; lines: LayoutLine[] }> = [];
|
|
446
447
|
|
|
447
448
|
// Emacs-style kill ring
|
|
448
449
|
#killRing = new KillRing();
|
|
@@ -754,11 +755,20 @@ export class Editor implements Component, Focusable {
|
|
|
754
755
|
}
|
|
755
756
|
|
|
756
757
|
invalidate(): void {
|
|
758
|
+
this.#logicalLayoutCache.length = 0;
|
|
757
759
|
this.#wrappedLineCache.length = 0;
|
|
758
760
|
this.#layoutCache = undefined;
|
|
759
761
|
}
|
|
760
762
|
|
|
763
|
+
#trimLogicalLayoutCache(): void {
|
|
764
|
+
// Retain at most one layout per current logical line, never deleted tails.
|
|
765
|
+
if (this.#logicalLayoutCache.length > this.#state.lines.length) {
|
|
766
|
+
this.#logicalLayoutCache.length = this.#state.lines.length;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
761
770
|
#bumpDocumentVersion(): void {
|
|
771
|
+
this.#trimLogicalLayoutCache();
|
|
762
772
|
this.#docVersion += 1;
|
|
763
773
|
this.#layoutCache = undefined;
|
|
764
774
|
}
|
|
@@ -1163,6 +1173,16 @@ export class Editor implements Component, Focusable {
|
|
|
1163
1173
|
}
|
|
1164
1174
|
|
|
1165
1175
|
handleInput(data: string): void {
|
|
1176
|
+
try {
|
|
1177
|
+
this.#handleInput(data);
|
|
1178
|
+
} finally {
|
|
1179
|
+
// Undo snapshots invalidate before mutation; trim after all input paths,
|
|
1180
|
+
// including early returns and nested bracketed-paste handling.
|
|
1181
|
+
this.#trimLogicalLayoutCache();
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
#handleInput(data: string): void {
|
|
1166
1186
|
const kb = getKeybindings();
|
|
1167
1187
|
|
|
1168
1188
|
// Handle character jump mode (awaiting next character to jump to)
|
|
@@ -1585,6 +1605,11 @@ export class Editor implements Component, Focusable {
|
|
|
1585
1605
|
return wrapped;
|
|
1586
1606
|
}
|
|
1587
1607
|
|
|
1608
|
+
/** Test-only seam: retained per-logical-line layout entries. */
|
|
1609
|
+
get logicalLayoutCacheSize(): number {
|
|
1610
|
+
return this.#logicalLayoutCache.length;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1588
1613
|
/** Test-only seam: current wrap-cache entry count (memory-bound assertions). */
|
|
1589
1614
|
get wrappedLineCacheSize(): number {
|
|
1590
1615
|
return this.#wrappedLineCache.length;
|
|
@@ -1624,6 +1649,16 @@ export class Editor implements Component, Focusable {
|
|
|
1624
1649
|
}
|
|
1625
1650
|
|
|
1626
1651
|
#layoutLogicalLine(lineIndex: number, contentWidth: number): LayoutLine[] {
|
|
1652
|
+
const text = this.#state.lines[lineIndex] || "";
|
|
1653
|
+
const cursorCol = lineIndex === this.#state.cursorLine ? this.#state.cursorCol : undefined;
|
|
1654
|
+
const cached = this.#logicalLayoutCache[lineIndex];
|
|
1655
|
+
if (cached?.text === text && cached.width === contentWidth && cached.cursorCol === cursorCol) return cached.lines;
|
|
1656
|
+
const lines = this.#buildLogicalLine(lineIndex, contentWidth);
|
|
1657
|
+
this.#logicalLayoutCache[lineIndex] = { text, width: contentWidth, cursorCol, lines };
|
|
1658
|
+
return lines;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
#buildLogicalLine(lineIndex: number, contentWidth: number): LayoutLine[] {
|
|
1627
1662
|
__editorPerfCounters.layoutLogicalLinesProcessed += 1;
|
|
1628
1663
|
const line = this.#state.lines[lineIndex] || "";
|
|
1629
1664
|
const isCurrentLine = lineIndex === this.#state.cursorLine;
|
|
@@ -1722,6 +1757,7 @@ export class Editor implements Component, Focusable {
|
|
|
1722
1757
|
}
|
|
1723
1758
|
|
|
1724
1759
|
#layoutText(contentWidth: number): LayoutLine[] {
|
|
1760
|
+
this.#trimLogicalLayoutCache();
|
|
1725
1761
|
__editorPerfCounters.layoutTextInvocations += 1;
|
|
1726
1762
|
const key = this.#makeLayoutCacheKey(contentWidth);
|
|
1727
1763
|
const cached = this.#layoutCache;
|
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() {
|
|
@@ -214,9 +214,11 @@ export function resetMarkdownHighlightCallCount(): void {
|
|
|
214
214
|
export const __markdownPerfCounters = {
|
|
215
215
|
lexerInvocations: 0,
|
|
216
216
|
lexedBytes: 0,
|
|
217
|
+
highlightAdmissionScans: 0,
|
|
217
218
|
reset(): void {
|
|
218
219
|
this.lexerInvocations = 0;
|
|
219
220
|
this.lexedBytes = 0;
|
|
221
|
+
this.highlightAdmissionScans = 0;
|
|
220
222
|
},
|
|
221
223
|
};
|
|
222
224
|
|
|
@@ -452,6 +454,7 @@ export class Markdown implements Component {
|
|
|
452
454
|
}
|
|
453
455
|
|
|
454
456
|
#exceedsHighlightCap(code: string): boolean {
|
|
457
|
+
__markdownPerfCounters.highlightAdmissionScans += 1;
|
|
455
458
|
// UTF-8 requires at least one byte per UTF-16 code unit, including lone
|
|
456
459
|
// surrogates. Reject obviously oversized input without scanning or hashing it.
|
|
457
460
|
if (code.length > MAX_HIGHLIGHT_BYTES) return true;
|
|
@@ -467,10 +470,13 @@ export class Markdown implements Component {
|
|
|
467
470
|
|
|
468
471
|
#highlightCodeBlock(code: string, lang: string): string[] | null {
|
|
469
472
|
if (!this.#theme.highlightCode) return null;
|
|
470
|
-
|
|
473
|
+
// Avoid building/hashing keys for obviously oversized input. A cache hit
|
|
474
|
+
// already passed the immutable byte/line caps; only misses need a scan.
|
|
475
|
+
if (code.length > MAX_HIGHLIGHT_BYTES) return null;
|
|
471
476
|
const key = `${objectId(this.#theme)}\x00${lang}\x00${code}`;
|
|
472
477
|
const cached = highlightCache.get(key);
|
|
473
478
|
if (cached?.lang === lang && cached.code === code) return cached.lines;
|
|
479
|
+
if (this.#exceedsHighlightCap(code)) return null;
|
|
474
480
|
highlightCallCount += 1;
|
|
475
481
|
const result = this.#theme.highlightCode(code, lang || undefined);
|
|
476
482
|
highlightCache.set(key, { lang, code, lines: result });
|
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
|
@@ -821,6 +821,16 @@ type TuiRenderCounterSnapshot = {
|
|
|
821
821
|
widthReflowScanRows: number;
|
|
822
822
|
widthReflowVisibleWidthCalls: number;
|
|
823
823
|
kittyPlacementScanRows: number;
|
|
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;
|
|
824
834
|
};
|
|
825
835
|
type RenderCommitWaiter = {
|
|
826
836
|
resolve: (committed: boolean) => void;
|
|
@@ -995,6 +1005,12 @@ export class TUI extends Container {
|
|
|
995
1005
|
#latestRenderedPlacementOwners = new Map<string, KittyPlacementOwner>();
|
|
996
1006
|
#kittyPlacementSpans: KittyPlacementSpan[] = [];
|
|
997
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;
|
|
998
1014
|
#durableLineCount = 0;
|
|
999
1015
|
#durableRenderedLines: string[] = [];
|
|
1000
1016
|
#durableRawLines: string[] = [];
|
|
@@ -1187,6 +1203,10 @@ export class TUI extends Container {
|
|
|
1187
1203
|
widthReflowScanRows: 0,
|
|
1188
1204
|
widthReflowVisibleWidthCalls: 0,
|
|
1189
1205
|
kittyPlacementScanRows: 0,
|
|
1206
|
+
kittyPlacementReferenceRows: 0,
|
|
1207
|
+
layoutAssemblyRows: 0,
|
|
1208
|
+
layoutOffscreenPrefixCompares: 0,
|
|
1209
|
+
layoutPrefixLineCopies: 0,
|
|
1190
1210
|
};
|
|
1191
1211
|
|
|
1192
1212
|
static resetRenderCountersForTest(): void {
|
|
@@ -1197,6 +1217,10 @@ export class TUI extends Container {
|
|
|
1197
1217
|
widthReflowScanRows: 0,
|
|
1198
1218
|
widthReflowVisibleWidthCalls: 0,
|
|
1199
1219
|
kittyPlacementScanRows: 0,
|
|
1220
|
+
kittyPlacementReferenceRows: 0,
|
|
1221
|
+
layoutAssemblyRows: 0,
|
|
1222
|
+
layoutOffscreenPrefixCompares: 0,
|
|
1223
|
+
layoutPrefixLineCopies: 0,
|
|
1200
1224
|
};
|
|
1201
1225
|
}
|
|
1202
1226
|
|
|
@@ -1204,6 +1228,10 @@ export class TUI extends Container {
|
|
|
1204
1228
|
return { ...TUI.#renderCounters };
|
|
1205
1229
|
}
|
|
1206
1230
|
|
|
1231
|
+
getRenderedLineForTest(index: number): string | undefined {
|
|
1232
|
+
return this.#latestRenderedLines[index];
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1207
1235
|
getRenderPreparationStateForTest(): { pending: number; holes: number; failedRanges: number } {
|
|
1208
1236
|
return {
|
|
1209
1237
|
pending: this.#preparationBlocked.size,
|
|
@@ -2858,6 +2886,9 @@ export class TUI extends Container {
|
|
|
2858
2886
|
this.#latestRenderedLines = [];
|
|
2859
2887
|
this.#kittyPlacementSpans = [];
|
|
2860
2888
|
this.#latestRaw = [];
|
|
2889
|
+
this.#layoutSpareRaw = null;
|
|
2890
|
+
this.#layoutSpareRendered = null;
|
|
2891
|
+
this.#layoutSpareValid = false;
|
|
2861
2892
|
this.#durableLineCount = 0;
|
|
2862
2893
|
this.#nativeScrollbackAdmissionPending = false;
|
|
2863
2894
|
this.#durableRenderedLines.length = 0;
|
|
@@ -4567,8 +4598,97 @@ export class TUI extends Container {
|
|
|
4567
4598
|
this.#recordPaintedViewportObservation(this.#viewportTopRow, height, false);
|
|
4568
4599
|
}
|
|
4569
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
|
+
|
|
4570
4688
|
#doRender(): void {
|
|
4571
4689
|
if (this.#stopped || !this.terminalAvailable) return;
|
|
4690
|
+
const layoutSpareValid = this.#layoutSpareValid;
|
|
4691
|
+
this.#layoutSpareValid = false;
|
|
4572
4692
|
const transcriptIdentityReplaced = this.#transcriptIdentityReplaced;
|
|
4573
4693
|
const restartViewportRepaintPending = this.#restartViewportRepaintPending;
|
|
4574
4694
|
const resizeRenderMutationQueued = this.#resizeRenderMutationQueued;
|
|
@@ -4595,9 +4715,22 @@ export class TUI extends Container {
|
|
|
4595
4715
|
const renderTreeStart = renderMetrics.now();
|
|
4596
4716
|
const renderScope = this.#renderScope;
|
|
4597
4717
|
this.#renderScope = "full";
|
|
4598
|
-
|
|
4718
|
+
let renderedLines: string[] = [];
|
|
4599
4719
|
const renderedChildren = new Map<Component, string[]>();
|
|
4600
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;
|
|
4601
4734
|
let previousKittyPlacementSpans = this.#kittyPlacementSpans;
|
|
4602
4735
|
const placementOwners = new Map<string, KittyPlacementOwner>();
|
|
4603
4736
|
const pinnedChildIndex =
|
|
@@ -4624,7 +4757,12 @@ export class TUI extends Container {
|
|
|
4624
4757
|
const safeLines = reuseCached ? cached.safeLines : rendered.lines.map(stripTerminalEraseControls);
|
|
4625
4758
|
const kittyPlacements = reuseCached
|
|
4626
4759
|
? cached.kittyPlacements
|
|
4627
|
-
:
|
|
4760
|
+
: TERMINAL.imageProtocol === ImageProtocol.Kitty
|
|
4761
|
+
? rendered.lines.map(line => {
|
|
4762
|
+
TUI.#renderCounters.kittyPlacementReferenceRows++;
|
|
4763
|
+
return [...extractKittyPlacementReferences(line)];
|
|
4764
|
+
})
|
|
4765
|
+
: [];
|
|
4628
4766
|
if (!reuseCached && componentRevision !== undefined && source !== null) {
|
|
4629
4767
|
this.#viewportAnchorRenderCache = {
|
|
4630
4768
|
component: child,
|
|
@@ -4643,48 +4781,49 @@ export class TUI extends Container {
|
|
|
4643
4781
|
anchorFrame = { startRow: childStart, anchors: rendered.anchors };
|
|
4644
4782
|
}
|
|
4645
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
|
+
}
|
|
4646
4798
|
for (let lineIndex = 0; lineIndex < rendered.lines.length; lineIndex++) {
|
|
4647
|
-
|
|
4648
|
-
|
|
4799
|
+
const placements = kittyPlacements[lineIndex];
|
|
4800
|
+
if (placements !== undefined) {
|
|
4801
|
+
for (const placement of placements) {
|
|
4802
|
+
placementOwners.set(this.#kittyPlacementKey(placement), owner);
|
|
4803
|
+
}
|
|
4649
4804
|
}
|
|
4650
4805
|
renderedLines.push(safeLines[lineIndex] ?? rendered.lines[lineIndex]!);
|
|
4806
|
+
if (renderScope === "layout") TUI.#renderCounters.layoutAssemblyRows += 1;
|
|
4651
4807
|
}
|
|
4652
4808
|
}
|
|
4809
|
+
const frameLineCount = renderedLines.length + (reusedAnchor?.lines.length ?? 0);
|
|
4653
4810
|
const sourceTranscriptLineCount = hasStickySuffix
|
|
4654
4811
|
? this.children
|
|
4655
4812
|
.slice(0, pinnedChildIndex)
|
|
4656
4813
|
.reduce((count, child) => count + this.#pinnedChildLines(child, renderedChildren).length, 0)
|
|
4657
|
-
:
|
|
4814
|
+
: frameLineCount;
|
|
4658
4815
|
const anchorRenderFailed = viewportAnchorRenderFailureCount !== anchorRenderFailureCountBefore;
|
|
4659
|
-
let newLines = this.#constrainPinnedSuffix(renderedLines, height, renderedChildren);
|
|
4660
4816
|
this.#viewportAnchorFrame = anchorFrame;
|
|
4661
4817
|
if (renderMetrics.enabled) renderMetrics.recordHelper("renderTree", renderMetrics.now() - renderTreeStart);
|
|
4662
|
-
|
|
4663
|
-
if (hasStickySuffix
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
|
|
4667
|
-
newLines.length - sourceTranscriptLineCount,
|
|
4668
|
-
).lines;
|
|
4669
|
-
}
|
|
4670
|
-
const nextTranscriptLineCount = sourceTranscriptLineCount;
|
|
4671
|
-
const nextSuffixLineCount = hasStickySuffix ? Math.max(0, newLines.length - nextTranscriptLineCount) : 0;
|
|
4672
|
-
|
|
4673
|
-
// Composite overlays into the rendered lines (before differential compare)
|
|
4674
|
-
if (this.overlayStack.length > 0) {
|
|
4675
|
-
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
|
+
}
|
|
4676
4823
|
}
|
|
4824
|
+
const layoutPrefixBlocked =
|
|
4825
|
+
hasStickySuffix && (suffixRowCount > height || (height > 0 && frameLineCount < height));
|
|
4677
4826
|
|
|
4678
|
-
// Extract cursor position (marker must be found before diff comparison)
|
|
4679
|
-
const cursorPos = this.#extractCursorPosition(newLines, height);
|
|
4680
|
-
this.#lastCursorPosition = cursorPos;
|
|
4681
|
-
|
|
4682
|
-
newLines = this.#applyMouseSelection(newLines);
|
|
4683
|
-
|
|
4684
|
-
// Terminate every non-image line so the latest frame mirrors emitted bytes
|
|
4685
|
-
// (closes SGR + OSC 8 hyperlink state). Must run after cursor extraction
|
|
4686
|
-
// because the marker is embedded mid-line, and before any diff/full render
|
|
4687
|
-
// path so cache comparisons stay byte-accurate.
|
|
4688
4827
|
// Width/height change detection (used for normalization reuse and repaint decisions).
|
|
4689
4828
|
const widthChanged = this.#previousWidth !== 0 && this.#previousWidth !== width;
|
|
4690
4829
|
const widthMetadataChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
|
|
@@ -4697,49 +4836,114 @@ export class TUI extends Container {
|
|
|
4697
4836
|
const heightChanged = this.#previousHeight !== 0 && this.#previousHeight !== height;
|
|
4698
4837
|
const initialRender = this.#previousLines.length === 0 && this.#maxLinesRendered === 0;
|
|
4699
4838
|
let coalescedWidthAppend = false;
|
|
4700
|
-
|
|
4701
|
-
|
|
4702
|
-
|
|
4703
|
-
|
|
4704
|
-
// short-circuit for cached components), so only the visible window is
|
|
4705
|
-
// re-normalized and the diff starts at the window. Output is byte-identical to the
|
|
4706
|
-
// full path (reused entries are deterministic normalizations of identical raw lines).
|
|
4707
|
-
const VIEWPORT_NORMALIZE_OVERSCAN = 8;
|
|
4708
|
-
const rawLines = newLines.slice();
|
|
4709
|
-
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;
|
|
4710
4843
|
let diffStart = 0;
|
|
4711
4844
|
let usedWindowNormalize = false;
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
|
|
4716
|
-
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
|
|
4720
|
-
|
|
4721
|
-
|
|
4722
|
-
|
|
4723
|
-
|
|
4724
|
-
|
|
4725
|
-
|
|
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]!;
|
|
4726
4874
|
}
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
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]!;
|
|
4878
|
+
}
|
|
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;
|
|
4731
4936
|
}
|
|
4732
|
-
this.#normalizeLinesForEmit(windowed, width, winTop);
|
|
4733
|
-
this.#trimLineCachesForRender(total);
|
|
4734
|
-
newLines = windowed;
|
|
4735
|
-
diffStart = winTop;
|
|
4736
|
-
usedWindowNormalize = true;
|
|
4737
4937
|
}
|
|
4738
4938
|
}
|
|
4939
|
+
if (!usedWindowNormalize) {
|
|
4940
|
+
newLines = this.#applyLineResetsAndTruncate(rawLines.slice(), width);
|
|
4941
|
+
}
|
|
4739
4942
|
}
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
|
|
4943
|
+
const nextTranscriptLineCount = sourceTranscriptLineCount;
|
|
4944
|
+
const nextSuffixLineCount = hasStickySuffix ? Math.max(0, newLines.length - nextTranscriptLineCount) : 0;
|
|
4945
|
+
this.#lastCursorPosition = cursorPos;
|
|
4946
|
+
const total = rawLines.length;
|
|
4743
4947
|
if (renderMetrics.enabled) {
|
|
4744
4948
|
renderMetrics.recordLineCount("rendered", total);
|
|
4745
4949
|
renderMetrics.recordLineCount("normalized", total - diffStart);
|