@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 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. */
@@ -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.2",
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.2",
40
- "@gajae-code/utils": "0.17.2",
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
  },
@@ -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;
@@ -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
- const lines = ["", ...super.render(width)];
71
- for (let i = 0; i < lines.length; i++) {
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
- if (this.#exceedsHighlightCap(code)) return null;
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 });
@@ -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
- if (this.#customBgFn) contentLines.push(applyBackgroundToLine(lineWithMargins, width, this.#customBgFn));
105
- else contentLines.push(lineWithMargins + padding(Math.max(0, width - visibleWidth(lineWithMargins))));
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
- const renderedLines: string[] = [];
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
- : rendered.lines.map(line => [...extractKittyPlacementReferences(line)]);
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
- for (const placement of kittyPlacements[lineIndex] ?? []) {
4648
- placementOwners.set(this.#kittyPlacementKey(placement), owner);
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
- : renderedLines.length;
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 && height > 0 && this.#manualViewportTop === undefined) {
4664
- newLines = this.#padBeforeBottomPinnedComponent(
4665
- newLines,
4666
- height,
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
- // Normalize/truncate lines for emission. The virtual viewport is default-on;
4702
- // PI_TUI_VIRTUAL_VIEWPORT=0 opts out. When enabled, reuse the previous frame's
4703
- // normalized prefix when the off-screen raw prefix is unchanged (raw value equality
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
- if (
4713
- this.#virtualViewport &&
4714
- !widthChanged &&
4715
- this.#latestRaw.length > 0 &&
4716
- this.#latestRenderedLines.length === this.#latestRaw.length
4717
- ) {
4718
- const winTop = Math.max(0, total - height - VIEWPORT_NORMALIZE_OVERSCAN);
4719
- if (winTop <= this.#latestRenderedLines.length && winTop <= this.#latestRaw.length) {
4720
- let stable = true;
4721
- for (let i = 0; i < winTop; i++) {
4722
- if (rawLines[i] !== this.#latestRaw[i]) {
4723
- stable = false;
4724
- break;
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
- if (stable) {
4728
- const windowed = this.#latestRenderedLines.slice(0, winTop);
4729
- for (let i = winTop; i < total; i++) {
4730
- windowed.push(rawLines[i]);
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
- if (!usedWindowNormalize) {
4741
- newLines = this.#applyLineResetsAndTruncate(rawLines.slice(), width);
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);