@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 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[];
@@ -9,5 +9,6 @@ export declare class Loader extends Text {
9
9
  render(width: number): string[];
10
10
  start(): void;
11
11
  stop(): void;
12
+ dispose(): void;
12
13
  setMessage(message: string): void;
13
14
  }
@@ -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
  /**
@@ -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
@@ -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.2",
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.2",
42
- "@gajae-code/utils": "0.5.2",
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
  },
@@ -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
@@ -78,6 +78,10 @@ export class Loader extends Text {
78
78
  }
79
79
  }
80
80
 
81
+ dispose(): void {
82
+ this.stop();
83
+ }
84
+
81
85
  setMessage(message: string) {
82
86
  this.message = message;
83
87
  this.#updateDisplay();
@@ -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
- lines.push(this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
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
- lines.push(this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
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
- lines.push(...child.render(width));
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
- const line = lines[i];
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
- newLines = this.#applyLineResetsAndTruncate(newLines, width);
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
- for (let i = 0; i < maxLines; i++) {
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