@gajae-code/tui 0.17.4 → 0.17.5

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