@gajae-code/tui 0.5.2 → 0.5.4
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 +6 -0
- package/dist/types/components/box.d.ts +5 -0
- package/dist/types/components/loader.d.ts +1 -0
- package/dist/types/components/markdown.d.ts +3 -0
- package/dist/types/metrics.d.ts +7 -0
- package/dist/types/tui.d.ts +13 -0
- package/package.json +3 -3
- package/src/components/box.ts +29 -0
- package/src/components/loader.ts +4 -0
- package/src/components/markdown.ts +62 -27
- package/src/metrics.ts +27 -0
- package/src/tui.ts +114 -11
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.5.3] - 2026-06-16
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Hardened the TUI for very long-running sessions to stop freezes and unbounded memory growth: `Container.render` emits frame lines via a loop instead of `push(...spread)` so huge (~492k-line) frames no longer throw `RangeError`; the component/container/box `dispose()` lifecycle is reuse-safe (`clear()`/`removeChild()` dispose, `detach*()` does not) across tool/loader/oauth/mcp-wizard/extension-ui components, and the reusable editor is detached before transient hook UI is torn down so it is never disposed by accident; markdown gains a per-code-block highlight LRU cache plus a UTF-8 byte/line highlight cap and assistant-message instance reuse. An opt-in viewport-windowed normalize/diff path (`PI_TUI_VIRTUAL_VIEWPORT`, default off) keeps rendering O(rows) at 100k+ lines while staying byte-identical to the golden output (#716).
|
|
10
|
+
|
|
5
11
|
## [0.5.1] - 2026-06-14
|
|
6
12
|
|
|
7
13
|
### Fixed
|
|
@@ -8,7 +8,12 @@ export declare class Box implements Component {
|
|
|
8
8
|
constructor(paddingX?: number, paddingY?: number, bgFn?: (text: string) => string);
|
|
9
9
|
addChild(component: Component): void;
|
|
10
10
|
removeChild(component: Component): void;
|
|
11
|
+
/** Remove a child without disposing it (for detach-then-readd reuse). */
|
|
12
|
+
detachChild(component: Component): void;
|
|
11
13
|
clear(): void;
|
|
14
|
+
/** Remove all children without disposing them (for detach-then-readd reuse). */
|
|
15
|
+
detachAll(): void;
|
|
16
|
+
dispose(): void;
|
|
12
17
|
setBgFn(bgFn?: (text: string) => string): void;
|
|
13
18
|
invalidate(): void;
|
|
14
19
|
render(width: number): string[];
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { SymbolTheme } from "../symbols";
|
|
2
2
|
import type { Component } from "../tui";
|
|
3
|
+
/** Test/diagnostic seam: number of synchronous highlight invocations since the last reset. */
|
|
4
|
+
export declare function getMarkdownHighlightCallCount(): number;
|
|
5
|
+
export declare function resetMarkdownHighlightCallCount(): void;
|
|
3
6
|
/** Drop all L2 cache entries. Call on theme change to prevent stale styled output. */
|
|
4
7
|
export declare function clearRenderCache(): void;
|
|
5
8
|
/**
|
package/dist/types/metrics.d.ts
CHANGED
|
@@ -29,6 +29,10 @@ export interface HelperStat {
|
|
|
29
29
|
totalMs: number;
|
|
30
30
|
meanMs: number;
|
|
31
31
|
}
|
|
32
|
+
export interface LineCountGauge {
|
|
33
|
+
last: number;
|
|
34
|
+
max: number;
|
|
35
|
+
}
|
|
32
36
|
export interface RenderMetricsSnapshot {
|
|
33
37
|
enabled: boolean;
|
|
34
38
|
renderCount: number;
|
|
@@ -43,6 +47,7 @@ export interface RenderMetricsSnapshot {
|
|
|
43
47
|
ownerGauges: Record<string, number>;
|
|
44
48
|
timerGauges: Record<string, number>;
|
|
45
49
|
helperStats: Record<string, HelperStat>;
|
|
50
|
+
lineCounts: Record<string, LineCountGauge>;
|
|
46
51
|
}
|
|
47
52
|
export declare class RenderMetrics {
|
|
48
53
|
#private;
|
|
@@ -66,6 +71,8 @@ export declare class RenderMetrics {
|
|
|
66
71
|
setTimerGauge(name: string, value: number): void;
|
|
67
72
|
/** Accumulate timing/count for a named render helper (e.g. "renderTree"). */
|
|
68
73
|
recordHelper(name: string, durationMs: number): void;
|
|
74
|
+
/** Record a per-render line-count gauge (e.g. "rendered", "normalized", "diffed"). */
|
|
75
|
+
recordLineCount(name: string, value: number): void;
|
|
69
76
|
/**
|
|
70
77
|
* Force a GC when the runtime exposes one and sample RSS as the post-run
|
|
71
78
|
* "return" value used by the memory-leak gate. Callers should drop large
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -29,6 +29,13 @@ export interface Component {
|
|
|
29
29
|
* Called when theme changes or when component needs to re-render from scratch.
|
|
30
30
|
*/
|
|
31
31
|
invalidate(): void;
|
|
32
|
+
/**
|
|
33
|
+
* Optional cleanup hook. Called once when the component is permanently
|
|
34
|
+
* removed from the tree via removeChild/clear/dispose. Implementations MUST
|
|
35
|
+
* be idempotent. Components meant to be re-added should be detached, not
|
|
36
|
+
* removed/cleared.
|
|
37
|
+
*/
|
|
38
|
+
dispose?(): void;
|
|
32
39
|
}
|
|
33
40
|
/**
|
|
34
41
|
* Interface for components that can receive focus and display a hardware cursor.
|
|
@@ -110,10 +117,16 @@ export interface OverlayHandle {
|
|
|
110
117
|
* Container - a component that contains other components
|
|
111
118
|
*/
|
|
112
119
|
export declare class Container implements Component {
|
|
120
|
+
#private;
|
|
113
121
|
children: Component[];
|
|
114
122
|
addChild(component: Component): void;
|
|
115
123
|
removeChild(component: Component): void;
|
|
124
|
+
/** Remove a child without disposing it (for detach-then-readd reuse). */
|
|
125
|
+
detachChild(component: Component): void;
|
|
116
126
|
clear(): void;
|
|
127
|
+
/** Remove all children without disposing them (for detach-then-readd reuse). */
|
|
128
|
+
detachAll(): void;
|
|
129
|
+
dispose(): void;
|
|
117
130
|
invalidate(): void;
|
|
118
131
|
render(width: number): string[];
|
|
119
132
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/tui",
|
|
4
|
-
"version": "0.5.
|
|
4
|
+
"version": "0.5.4",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://gaebal-gajae.dev",
|
|
7
7
|
"author": "Yeachan-Heo",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"fmt": "biome format --write ."
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@gajae-code/natives": "0.5.
|
|
42
|
-
"@gajae-code/utils": "0.5.
|
|
41
|
+
"@gajae-code/natives": "0.5.4",
|
|
42
|
+
"@gajae-code/utils": "0.5.4",
|
|
43
43
|
"lru-cache": "11.3.6",
|
|
44
44
|
"marked": "^18.0.3"
|
|
45
45
|
},
|
package/src/components/box.ts
CHANGED
|
@@ -11,6 +11,7 @@ type Cache = {
|
|
|
11
11
|
*/
|
|
12
12
|
export class Box implements Component {
|
|
13
13
|
children: Component[] = [];
|
|
14
|
+
#disposed = false;
|
|
14
15
|
#paddingX: number;
|
|
15
16
|
#paddingY: number;
|
|
16
17
|
#bgFn?: (text: string) => string;
|
|
@@ -30,6 +31,16 @@ export class Box implements Component {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
removeChild(component: Component): void {
|
|
34
|
+
const index = this.children.indexOf(component);
|
|
35
|
+
if (index !== -1) {
|
|
36
|
+
this.children.splice(index, 1);
|
|
37
|
+
this.#invalidateCache();
|
|
38
|
+
component.dispose?.();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Remove a child without disposing it (for detach-then-readd reuse). */
|
|
43
|
+
detachChild(component: Component): void {
|
|
33
44
|
const index = this.children.indexOf(component);
|
|
34
45
|
if (index !== -1) {
|
|
35
46
|
this.children.splice(index, 1);
|
|
@@ -38,10 +49,28 @@ export class Box implements Component {
|
|
|
38
49
|
}
|
|
39
50
|
|
|
40
51
|
clear(): void {
|
|
52
|
+
for (const child of this.children) {
|
|
53
|
+
child.dispose?.();
|
|
54
|
+
}
|
|
41
55
|
this.children = [];
|
|
42
56
|
this.#invalidateCache();
|
|
43
57
|
}
|
|
44
58
|
|
|
59
|
+
/** Remove all children without disposing them (for detach-then-readd reuse). */
|
|
60
|
+
detachAll(): void {
|
|
61
|
+
this.children = [];
|
|
62
|
+
this.#invalidateCache();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
dispose(): void {
|
|
66
|
+
if (this.#disposed) return;
|
|
67
|
+
this.#disposed = true;
|
|
68
|
+
for (const child of this.children) {
|
|
69
|
+
child.dispose?.();
|
|
70
|
+
}
|
|
71
|
+
this.#invalidateCache();
|
|
72
|
+
}
|
|
73
|
+
|
|
45
74
|
setBgFn(bgFn?: (text: string) => string): void {
|
|
46
75
|
this.#bgFn = bgFn;
|
|
47
76
|
// Don't invalidate here - we'll detect bgFn changes by sampling output
|
package/src/components/loader.ts
CHANGED
|
@@ -43,6 +43,24 @@ const renderCache = new LRUCache<string, { source: string; lines: string[] }>({
|
|
|
43
43
|
const PARSE_CACHE_MAX = 128;
|
|
44
44
|
const parseCache = new LRUCache<string, { source: string; tokens: Token[] }>({ max: PARSE_CACHE_MAX });
|
|
45
45
|
|
|
46
|
+
// Per-code-block highlight cache (F3): keyed by theme + lang + code so streaming
|
|
47
|
+
// appends only highlight new/changed blocks instead of re-highlighting the whole
|
|
48
|
+
// prefix on every chunk. Bounded LRU; cleared on theme change via clearRenderCache().
|
|
49
|
+
const HIGHLIGHT_CACHE_MAX = 512;
|
|
50
|
+
const highlightCache = new LRUCache<string, string[]>({ max: HIGHLIGHT_CACHE_MAX });
|
|
51
|
+
// F18: cap synchronous (Rust FFI) syntax highlighting so a single huge fenced block
|
|
52
|
+
// cannot stall the UI thread; oversized blocks render plain with a sanitized marker.
|
|
53
|
+
const MAX_HIGHLIGHT_BYTES = 200_000;
|
|
54
|
+
const MAX_HIGHLIGHT_LINES = 2000;
|
|
55
|
+
let highlightCallCount = 0;
|
|
56
|
+
/** Test/diagnostic seam: number of synchronous highlight invocations since the last reset. */
|
|
57
|
+
export function getMarkdownHighlightCallCount(): number {
|
|
58
|
+
return highlightCallCount;
|
|
59
|
+
}
|
|
60
|
+
export function resetMarkdownHighlightCallCount(): void {
|
|
61
|
+
highlightCallCount = 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
46
64
|
// Full-content 64-bit wyhash over every byte (no lossy sampling). Cache hits
|
|
47
65
|
// additionally verify entry.source against the normalized text, so even a
|
|
48
66
|
// hash collision can never return another message's render.
|
|
@@ -58,6 +76,7 @@ function wrapTextIfNeeded(line: string, width: number): string[] {
|
|
|
58
76
|
export function clearRenderCache(): void {
|
|
59
77
|
renderCache.clear();
|
|
60
78
|
parseCache.clear();
|
|
79
|
+
highlightCache.clear();
|
|
61
80
|
}
|
|
62
81
|
|
|
63
82
|
// Stable numeric IDs for structural theme/style objects (no ID field on type).
|
|
@@ -186,6 +205,47 @@ export class Markdown implements Component {
|
|
|
186
205
|
this.#cachedLines = undefined;
|
|
187
206
|
}
|
|
188
207
|
|
|
208
|
+
#exceedsHighlightCap(code: string): boolean {
|
|
209
|
+
let newlines = 0;
|
|
210
|
+
for (let i = 0; i < code.length; i++) {
|
|
211
|
+
if (code.charCodeAt(i) === 10) newlines += 1;
|
|
212
|
+
}
|
|
213
|
+
if (newlines + 1 > MAX_HIGHLIGHT_LINES) return true;
|
|
214
|
+
// UTF-8 byte length (not UTF-16 code-unit count) so a non-ASCII block cannot
|
|
215
|
+
// exceed the advertised byte cap and still reach the synchronous highlighter.
|
|
216
|
+
return Buffer.byteLength(code, "utf8") > MAX_HIGHLIGHT_BYTES;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
#highlightCodeBlock(code: string, lang: string): string[] | null {
|
|
220
|
+
if (!this.#theme.highlightCode) return null;
|
|
221
|
+
if (this.#exceedsHighlightCap(code)) return null;
|
|
222
|
+
const key = `${objectId(this.#theme)}\x00${lang}\x00${code}`;
|
|
223
|
+
const cached = highlightCache.get(key);
|
|
224
|
+
if (cached) return cached;
|
|
225
|
+
highlightCallCount += 1;
|
|
226
|
+
const result = this.#theme.highlightCode(code, lang || undefined);
|
|
227
|
+
highlightCache.set(key, result);
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
#emitCodeBlock(lines: string[], code: string, lang: string, codeIndent: string): void {
|
|
232
|
+
lines.push(this.#theme.codeBlockBorder(`\`\`\`${lang}`));
|
|
233
|
+
const highlighted = this.#highlightCodeBlock(code, lang);
|
|
234
|
+
if (highlighted) {
|
|
235
|
+
for (const hlLine of highlighted) {
|
|
236
|
+
lines.push(`${codeIndent}${hlLine}`);
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
if (this.#theme.highlightCode && this.#exceedsHighlightCap(code)) {
|
|
240
|
+
lines.push(`${codeIndent}${this.#theme.codeBlock("[syntax highlighting skipped: code block too large]")}`);
|
|
241
|
+
}
|
|
242
|
+
for (const codeLine of code.split("\n")) {
|
|
243
|
+
lines.push(`${codeIndent}${this.#theme.codeBlock(codeLine)}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
lines.push(this.#theme.codeBlockBorder("```"));
|
|
247
|
+
}
|
|
248
|
+
|
|
189
249
|
render(width: number): string[] {
|
|
190
250
|
// L1: per-instance cache — fastest path for repeated renders of the same
|
|
191
251
|
// instance at the same width (e.g. resize debounce, repeated redraws).
|
|
@@ -442,20 +502,7 @@ export class Markdown implements Component {
|
|
|
442
502
|
}
|
|
443
503
|
|
|
444
504
|
const codeIndent = padding(this.#codeBlockIndent);
|
|
445
|
-
|
|
446
|
-
if (this.#theme.highlightCode) {
|
|
447
|
-
const highlightedLines = this.#theme.highlightCode(token.text, token.lang);
|
|
448
|
-
for (const hlLine of highlightedLines) {
|
|
449
|
-
lines.push(`${codeIndent}${hlLine}`);
|
|
450
|
-
}
|
|
451
|
-
} else {
|
|
452
|
-
// Split code by newlines and style each line
|
|
453
|
-
const codeLines = token.text.split("\n");
|
|
454
|
-
for (const codeLine of codeLines) {
|
|
455
|
-
lines.push(`${codeIndent}${this.#theme.codeBlock(codeLine)}`);
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
lines.push(this.#theme.codeBlockBorder("```"));
|
|
505
|
+
this.#emitCodeBlock(lines, token.text, token.lang || "", codeIndent);
|
|
459
506
|
if (nextTokenType && nextTokenType !== "space") {
|
|
460
507
|
lines.push(""); // Add spacing after code blocks (unless space token follows)
|
|
461
508
|
}
|
|
@@ -725,19 +772,7 @@ export class Markdown implements Component {
|
|
|
725
772
|
} else if (token.type === "code") {
|
|
726
773
|
// Code block in list item
|
|
727
774
|
const codeIndent = padding(this.#codeBlockIndent);
|
|
728
|
-
|
|
729
|
-
if (this.#theme.highlightCode) {
|
|
730
|
-
const highlightedLines = this.#theme.highlightCode(token.text, token.lang);
|
|
731
|
-
for (const hlLine of highlightedLines) {
|
|
732
|
-
lines.push(`${codeIndent}${hlLine}`);
|
|
733
|
-
}
|
|
734
|
-
} else {
|
|
735
|
-
const codeLines = token.text.split("\n");
|
|
736
|
-
for (const codeLine of codeLines) {
|
|
737
|
-
lines.push(`${codeIndent}${this.#theme.codeBlock(codeLine)}`);
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
lines.push(this.#theme.codeBlockBorder("```"));
|
|
775
|
+
this.#emitCodeBlock(lines, token.text, token.lang || "", codeIndent);
|
|
741
776
|
} else {
|
|
742
777
|
// Other token types - try to render as inline
|
|
743
778
|
const text = this.#renderInlineTokens([token], styleContext);
|
package/src/metrics.ts
CHANGED
|
@@ -104,6 +104,11 @@ export interface HelperStat {
|
|
|
104
104
|
meanMs: number;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
export interface LineCountGauge {
|
|
108
|
+
last: number;
|
|
109
|
+
max: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
107
112
|
export interface RenderMetricsSnapshot {
|
|
108
113
|
enabled: boolean;
|
|
109
114
|
renderCount: number;
|
|
@@ -118,6 +123,7 @@ export interface RenderMetricsSnapshot {
|
|
|
118
123
|
ownerGauges: Record<string, number>;
|
|
119
124
|
timerGauges: Record<string, number>;
|
|
120
125
|
helperStats: Record<string, HelperStat>;
|
|
126
|
+
lineCounts: Record<string, LineCountGauge>;
|
|
121
127
|
}
|
|
122
128
|
|
|
123
129
|
function emptyDurationStats(): DurationStats {
|
|
@@ -153,6 +159,7 @@ export class RenderMetrics {
|
|
|
153
159
|
#ownerGauges = new Map<string, number>();
|
|
154
160
|
#timerGauges = new Map<string, number>();
|
|
155
161
|
#helpers = new Map<string, { count: number; totalMs: number }>();
|
|
162
|
+
#lineGauges = new Map<string, LineCountGauge>();
|
|
156
163
|
#rssReturn: number | null = null;
|
|
157
164
|
#heapBaseline: number | null = null;
|
|
158
165
|
#heapReturn: number | null = null;
|
|
@@ -192,6 +199,7 @@ export class RenderMetrics {
|
|
|
192
199
|
this.#ownerGauges.clear();
|
|
193
200
|
this.#timerGauges.clear();
|
|
194
201
|
this.#helpers.clear();
|
|
202
|
+
this.#lineGauges.clear();
|
|
195
203
|
this.#rssReturn = null;
|
|
196
204
|
this.#heapBaseline = null;
|
|
197
205
|
this.#heapReturn = null;
|
|
@@ -278,6 +286,16 @@ export class RenderMetrics {
|
|
|
278
286
|
this.#helpers.set(retained, cur);
|
|
279
287
|
}
|
|
280
288
|
|
|
289
|
+
/** Record a per-render line-count gauge (e.g. "rendered", "normalized", "diffed"). */
|
|
290
|
+
recordLineCount(name: string, value: number): void {
|
|
291
|
+
if (!this.#enabled) return;
|
|
292
|
+
const retained = retainedLabel(this.#lineGauges, name);
|
|
293
|
+
const cur = this.#lineGauges.get(retained) ?? { last: 0, max: 0 };
|
|
294
|
+
cur.last = value;
|
|
295
|
+
if (value > cur.max) cur.max = value;
|
|
296
|
+
this.#lineGauges.set(retained, cur);
|
|
297
|
+
}
|
|
298
|
+
|
|
281
299
|
/**
|
|
282
300
|
* Force a GC when the runtime exposes one and sample RSS as the post-run
|
|
283
301
|
* "return" value used by the memory-leak gate. Callers should drop large
|
|
@@ -319,6 +337,14 @@ export class RenderMetrics {
|
|
|
319
337
|
return out;
|
|
320
338
|
}
|
|
321
339
|
|
|
340
|
+
#lineCountStats(): Record<string, LineCountGauge> {
|
|
341
|
+
const out: Record<string, LineCountGauge> = {};
|
|
342
|
+
for (const [name, v] of this.#lineGauges) {
|
|
343
|
+
out[name] = { last: v.last, max: v.max };
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
|
|
322
348
|
snapshot(): RenderMetricsSnapshot {
|
|
323
349
|
return {
|
|
324
350
|
enabled: this.#enabled,
|
|
@@ -347,6 +373,7 @@ export class RenderMetrics {
|
|
|
347
373
|
ownerGauges: Object.fromEntries(this.#ownerGauges),
|
|
348
374
|
timerGauges: Object.fromEntries(this.#timerGauges),
|
|
349
375
|
helperStats: this.#helperStats(),
|
|
376
|
+
lineCounts: this.#lineCountStats(),
|
|
350
377
|
};
|
|
351
378
|
}
|
|
352
379
|
}
|
package/src/tui.ts
CHANGED
|
@@ -59,6 +59,14 @@ export interface Component {
|
|
|
59
59
|
* Called when theme changes or when component needs to re-render from scratch.
|
|
60
60
|
*/
|
|
61
61
|
invalidate(): void;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Optional cleanup hook. Called once when the component is permanently
|
|
65
|
+
* removed from the tree via removeChild/clear/dispose. Implementations MUST
|
|
66
|
+
* be idempotent. Components meant to be re-added should be detached, not
|
|
67
|
+
* removed/cleared.
|
|
68
|
+
*/
|
|
69
|
+
dispose?(): void;
|
|
62
70
|
}
|
|
63
71
|
|
|
64
72
|
/**
|
|
@@ -196,12 +204,22 @@ export interface OverlayHandle {
|
|
|
196
204
|
*/
|
|
197
205
|
export class Container implements Component {
|
|
198
206
|
children: Component[] = [];
|
|
207
|
+
#disposed = false;
|
|
199
208
|
|
|
200
209
|
addChild(component: Component): void {
|
|
201
210
|
this.children.push(component);
|
|
202
211
|
}
|
|
203
212
|
|
|
204
213
|
removeChild(component: Component): void {
|
|
214
|
+
const index = this.children.indexOf(component);
|
|
215
|
+
if (index !== -1) {
|
|
216
|
+
this.children.splice(index, 1);
|
|
217
|
+
component.dispose?.();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Remove a child without disposing it (for detach-then-readd reuse). */
|
|
222
|
+
detachChild(component: Component): void {
|
|
205
223
|
const index = this.children.indexOf(component);
|
|
206
224
|
if (index !== -1) {
|
|
207
225
|
this.children.splice(index, 1);
|
|
@@ -209,9 +227,25 @@ export class Container implements Component {
|
|
|
209
227
|
}
|
|
210
228
|
|
|
211
229
|
clear(): void {
|
|
230
|
+
for (const child of this.children) {
|
|
231
|
+
child.dispose?.();
|
|
232
|
+
}
|
|
233
|
+
this.children = [];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Remove all children without disposing them (for detach-then-readd reuse). */
|
|
237
|
+
detachAll(): void {
|
|
212
238
|
this.children = [];
|
|
213
239
|
}
|
|
214
240
|
|
|
241
|
+
dispose(): void {
|
|
242
|
+
if (this.#disposed) return;
|
|
243
|
+
this.#disposed = true;
|
|
244
|
+
for (const child of this.children) {
|
|
245
|
+
child.dispose?.();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
215
249
|
invalidate(): void {
|
|
216
250
|
for (const child of this.children) {
|
|
217
251
|
child.invalidate?.();
|
|
@@ -222,7 +256,10 @@ export class Container implements Component {
|
|
|
222
256
|
width = Math.max(1, width);
|
|
223
257
|
const lines: string[] = [];
|
|
224
258
|
for (const child of this.children) {
|
|
225
|
-
|
|
259
|
+
const childLines = child.render(width);
|
|
260
|
+
for (let i = 0; i < childLines.length; i++) {
|
|
261
|
+
lines.push(childLines[i]);
|
|
262
|
+
}
|
|
226
263
|
}
|
|
227
264
|
return lines;
|
|
228
265
|
}
|
|
@@ -239,6 +276,13 @@ type LineNormalizationCacheEntry = {
|
|
|
239
276
|
export class TUI extends Container {
|
|
240
277
|
terminal: Terminal;
|
|
241
278
|
#previousLines: string[] = [];
|
|
279
|
+
/**
|
|
280
|
+
* Raw (pre-normalization) lines from the previous frame, kept only when the
|
|
281
|
+
* virtual-viewport flag is on. Used to detect whether the off-screen prefix is
|
|
282
|
+
* unchanged (by raw value equality, with a fast reference short-circuit when components
|
|
283
|
+
* return stable string instances) so its normalized form can be reused (bounded normalize).
|
|
284
|
+
*/
|
|
285
|
+
#previousRaw: string[] = [];
|
|
242
286
|
#lineNormalizationCache = new Map<string, LineNormalizationCacheEntry>();
|
|
243
287
|
#lineTruncationCache = new Map<string, string>();
|
|
244
288
|
#lineNormalizationCacheLimit = 0;
|
|
@@ -269,6 +313,9 @@ export class TUI extends Container {
|
|
|
269
313
|
#sixelProbeUnsubscribe?: () => void;
|
|
270
314
|
#showHardwareCursor = $flag("PI_HARDWARE_CURSOR");
|
|
271
315
|
#clearOnShrink = $flag("PI_CLEAR_ON_SHRINK"); // Clear empty rows when content shrinks (default: off)
|
|
316
|
+
// Opt-in: reuse the previous normalized off-screen prefix and only normalize/diff the
|
|
317
|
+
// visible window, bounding per-frame work on huge transcripts. Output stays byte-identical.
|
|
318
|
+
#virtualViewport = $flag("PI_TUI_VIRTUAL_VIEWPORT");
|
|
272
319
|
#maxLinesRendered = 0; // Line count from last render, used for viewport calculation
|
|
273
320
|
#fullRedrawCount = 0;
|
|
274
321
|
#stopped = false;
|
|
@@ -663,6 +710,7 @@ export class TUI extends Container {
|
|
|
663
710
|
// focus/listener state is intentionally preserved so input routing survives
|
|
664
711
|
// a resume.
|
|
665
712
|
this.#previousLines = [];
|
|
713
|
+
this.#previousRaw = [];
|
|
666
714
|
this.#lineNormalizationCache.clear();
|
|
667
715
|
this.#lineTruncationCache.clear();
|
|
668
716
|
this.#previousWidth = 0;
|
|
@@ -679,6 +727,7 @@ export class TUI extends Container {
|
|
|
679
727
|
// A forced full redraw supersedes any queued input-priority render.
|
|
680
728
|
this.#inputRenderPending = false;
|
|
681
729
|
this.#previousLines = [];
|
|
730
|
+
this.#previousRaw = [];
|
|
682
731
|
this.#lineNormalizationCache.clear();
|
|
683
732
|
this.#lineTruncationCache.clear();
|
|
684
733
|
this.#previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
|
|
@@ -1204,14 +1253,16 @@ export class TUI extends Container {
|
|
|
1204
1253
|
};
|
|
1205
1254
|
}
|
|
1206
1255
|
|
|
1256
|
+
/** Normalize + width-fit a single line for emission (image lines pass through). */
|
|
1257
|
+
#normalizeLineForEmit(line: string, width: number): string {
|
|
1258
|
+
if (TERMINAL.isImageLine(line)) return line;
|
|
1259
|
+
const { normalized, terminated } = this.#normalizeLineForRender(line);
|
|
1260
|
+
return this.#lineFitsWidth(normalized, width) ? terminated : this.#truncateNormalizedLine(normalized, width);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1207
1263
|
#applyLineResetsAndTruncate(lines: string[], width: number): string[] {
|
|
1208
1264
|
for (let i = 0; i < lines.length; i++) {
|
|
1209
|
-
|
|
1210
|
-
if (TERMINAL.isImageLine(line)) continue;
|
|
1211
|
-
const { normalized, terminated } = this.#normalizeLineForRender(line);
|
|
1212
|
-
lines[i] = this.#lineFitsWidth(normalized, width)
|
|
1213
|
-
? terminated
|
|
1214
|
-
: this.#truncateNormalizedLine(normalized, width);
|
|
1265
|
+
lines[i] = this.#normalizeLineForEmit(lines[i], width);
|
|
1215
1266
|
}
|
|
1216
1267
|
this.#trimLineCachesForRender(lines.length);
|
|
1217
1268
|
return lines;
|
|
@@ -1247,12 +1298,61 @@ export class TUI extends Container {
|
|
|
1247
1298
|
// (closes SGR + OSC 8 hyperlink state). Must run after cursor extraction
|
|
1248
1299
|
// because the marker is embedded mid-line, and before any diff/full render
|
|
1249
1300
|
// path so cache comparisons stay byte-accurate.
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
// Width changed - need full re-render (line wrapping changes)
|
|
1301
|
+
// Width/height change detection (used for both normalization reuse and full-redraw decisions).
|
|
1253
1302
|
const widthChanged = this.#previousWidth !== 0 && this.#previousWidth !== width;
|
|
1254
1303
|
const heightChanged = this.#previousHeight !== 0 && this.#previousHeight !== height;
|
|
1255
1304
|
|
|
1305
|
+
// Normalize/truncate lines for emission. With the opt-in virtual-viewport flag
|
|
1306
|
+
// (PI_TUI_VIRTUAL_VIEWPORT) we reuse the previous frame's normalized prefix when the
|
|
1307
|
+
// off-screen raw prefix is unchanged (raw value equality per line; fast reference
|
|
1308
|
+
// short-circuit for cached components), so only the visible window is
|
|
1309
|
+
// re-normalized and the diff starts at the window. Output is byte-identical to the
|
|
1310
|
+
// full path (reused entries are deterministic normalizations of identical raw lines).
|
|
1311
|
+
const VIEWPORT_NORMALIZE_OVERSCAN = 8;
|
|
1312
|
+
const rawLines = newLines;
|
|
1313
|
+
const total = rawLines.length;
|
|
1314
|
+
let diffStart = 0;
|
|
1315
|
+
let usedWindowNormalize = false;
|
|
1316
|
+
if (
|
|
1317
|
+
this.#virtualViewport &&
|
|
1318
|
+
!widthChanged &&
|
|
1319
|
+
this.#previousRaw.length > 0 &&
|
|
1320
|
+
this.#previousLines.length === this.#previousRaw.length
|
|
1321
|
+
) {
|
|
1322
|
+
const winTop = Math.max(0, total - height - VIEWPORT_NORMALIZE_OVERSCAN);
|
|
1323
|
+
if (winTop <= this.#previousLines.length && winTop <= this.#previousRaw.length) {
|
|
1324
|
+
let stable = true;
|
|
1325
|
+
for (let i = 0; i < winTop; i++) {
|
|
1326
|
+
if (rawLines[i] !== this.#previousRaw[i]) {
|
|
1327
|
+
stable = false;
|
|
1328
|
+
break;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
if (stable) {
|
|
1332
|
+
const windowed = this.#previousLines.slice(0, winTop);
|
|
1333
|
+
for (let i = winTop; i < total; i++) {
|
|
1334
|
+
windowed.push(this.#normalizeLineForEmit(rawLines[i], width));
|
|
1335
|
+
}
|
|
1336
|
+
this.#trimLineCachesForRender(total);
|
|
1337
|
+
newLines = windowed;
|
|
1338
|
+
diffStart = winTop;
|
|
1339
|
+
usedWindowNormalize = true;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
if (!usedWindowNormalize) {
|
|
1344
|
+
newLines = this.#applyLineResetsAndTruncate(this.#virtualViewport ? rawLines.slice() : rawLines, width);
|
|
1345
|
+
}
|
|
1346
|
+
if (this.#virtualViewport) {
|
|
1347
|
+
this.#previousRaw = rawLines;
|
|
1348
|
+
}
|
|
1349
|
+
if (renderMetrics.enabled) {
|
|
1350
|
+
renderMetrics.recordLineCount("rendered", total);
|
|
1351
|
+
renderMetrics.recordLineCount("normalized", total - diffStart);
|
|
1352
|
+
renderMetrics.recordLineCount("measured", total - diffStart);
|
|
1353
|
+
if (usedWindowNormalize) renderMetrics.recordLineCount("offscreenScan", diffStart);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1256
1356
|
// Helper to clear scrollback and viewport and render all new lines
|
|
1257
1357
|
const fullRender = (clear: boolean, reason = "full render"): void => {
|
|
1258
1358
|
this.#fullRedrawCount += 1;
|
|
@@ -1390,7 +1490,10 @@ export class TUI extends Container {
|
|
|
1390
1490
|
let firstChanged = -1;
|
|
1391
1491
|
let lastChanged = -1;
|
|
1392
1492
|
const maxLines = Math.max(newLines.length, this.#previousLines.length);
|
|
1393
|
-
|
|
1493
|
+
if (renderMetrics.enabled) renderMetrics.recordLineCount("diffed", maxLines - diffStart);
|
|
1494
|
+
// When the off-screen prefix was reused (virtual viewport), it is verified
|
|
1495
|
+
// unchanged (raw value equality), so the diff can safely start at the window boundary.
|
|
1496
|
+
for (let i = diffStart; i < maxLines; i++) {
|
|
1394
1497
|
const oldLine = i < this.#previousLines.length ? this.#previousLines[i] : "";
|
|
1395
1498
|
const newLine = i < newLines.length ? newLines[i] : "";
|
|
1396
1499
|
|