@gajae-code/tui 0.12.7 → 0.12.8

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/src/tui.ts CHANGED
@@ -38,7 +38,7 @@ const SEGMENT_RESET = "\x1b[0m";
38
38
  * Per-line terminator written at the end of every non-image line. Closes both
39
39
  * SGR state and any in-flight OSC 8 hyperlink so styles/links cannot bleed
40
40
  * across lines in scrollback. Applied by {@link TUI.#applyLineResets} before
41
- * diffing so `#previousLines` mirrors what was actually written.
41
+ * diffing so the latest frame mirrors emitted bytes.
42
42
  */
43
43
  const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x07";
44
44
  const MOUSE_SELECTION_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
@@ -55,7 +55,59 @@ function stripTerminalControls(text: string): string {
55
55
  .replace(/\x1b(?:\[[0-?]*[ -/]*[@-~]|[@-_])/gu, "")
56
56
  .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/gu, "");
57
57
  }
58
+ const CSI_PARAMETER = (value: number): boolean => value >= 0x30 && value <= 0x3f;
59
+ const CSI_INTERMEDIATE = (value: number): boolean => value >= 0x20 && value <= 0x2f;
60
+ const CSI_FINAL = (value: number): boolean => value >= 0x40 && value <= 0x7e;
58
61
 
62
+ function csiEnd(bytes: string, start: number): number | undefined {
63
+ for (let index = start; index < bytes.length; index += 1) {
64
+ const value = bytes.charCodeAt(index);
65
+ if (CSI_FINAL(value)) return index;
66
+ if (!CSI_PARAMETER(value) && !CSI_INTERMEDIATE(value)) return undefined;
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ /**
72
+ * Remove component-owned erase controls and incomplete CSI fragments before
73
+ * persistent bytes enter a shared render transaction. Erase controls cannot
74
+ * repair native scrollback; dropping only the control preserves surrounding text
75
+ * and keeps later frames renderable.
76
+ */
77
+ function stripTerminalEraseControls(bytes: string): string {
78
+ let sanitized = "";
79
+ for (let index = 0; index < bytes.length; index += 1) {
80
+ const value = bytes.charCodeAt(index);
81
+ const isEscapeCsi = value === 0x1b && bytes.charCodeAt(index + 1) === 0x5b;
82
+ const isEightBitCsi = value === 0x9b;
83
+ if (value === 0x1b && index === bytes.length - 1) break;
84
+ if (!isEscapeCsi && !isEightBitCsi) {
85
+ sanitized += bytes[index];
86
+ continue;
87
+ }
88
+
89
+ const start = isEightBitCsi ? index + 1 : index + 2;
90
+ const end = csiEnd(bytes, start);
91
+ if (end === undefined) {
92
+ // Drop the CSI introducer and its complete parameter/intermediate prefix.
93
+ // If an invalid delimiter follows, revisit it as ordinary text.
94
+ let next = start;
95
+ while (next < bytes.length) {
96
+ const nextValue = bytes.charCodeAt(next);
97
+ if (!CSI_PARAMETER(nextValue) && !CSI_INTERMEDIATE(nextValue)) break;
98
+ next += 1;
99
+ }
100
+ index = next - 1;
101
+ continue;
102
+ }
103
+ const final = bytes.charCodeAt(end);
104
+ if (final !== 0x4a && final !== 0x4b) {
105
+ sanitized += bytes.slice(index, end + 1);
106
+ }
107
+ index = end;
108
+ }
109
+ return sanitized;
110
+ }
59
111
  type InputListenerResult = { consume?: boolean; data?: string } | undefined;
60
112
  type InputListener = (data: string) => InputListenerResult;
61
113
 
@@ -306,23 +358,12 @@ function parseSizeValue(value: SizeValue | undefined, referenceSize: number): nu
306
358
  return undefined;
307
359
  }
308
360
 
309
- function isTermuxSession(env: Record<string, string | undefined> = Bun.env): boolean {
310
- return Boolean(env.TERMUX_VERSION);
311
- }
312
-
313
361
  const DISABLED_ENV_VALUES = new Set(["0", "false", "off", "no"]);
314
- const TRUTHY_ENV_VALUES = new Set(["1", "true", "yes", "on", "y"]);
315
362
 
316
363
  function envIsEnabled(value: string | undefined): boolean {
317
364
  const normalized = value?.trim().toLowerCase();
318
365
  return normalized !== undefined && normalized.length > 0 && !DISABLED_ENV_VALUES.has(normalized);
319
366
  }
320
-
321
- function envFlagEnabled(value: string | undefined): boolean {
322
- const normalized = value?.trim().toLowerCase();
323
- return normalized !== undefined && TRUTHY_ENV_VALUES.has(normalized);
324
- }
325
-
326
367
  function isWindowsTerminalSession(env: Record<string, string | undefined> = Bun.env): boolean {
327
368
  return envIsEnabled(env.WT_SESSION) || env.TERM_PROGRAM === "Windows_Terminal";
328
369
  }
@@ -357,22 +398,20 @@ export function shouldProbeSixelCapability(
357
398
  return platform === "win32" && Boolean(env.WT_SESSION?.trim());
358
399
  }
359
400
 
360
- function useLegacyMultiplexerFullRender(env: Record<string, string | undefined> = Bun.env): boolean {
361
- return envFlagEnabled(env.PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER);
362
- }
363
-
364
401
  function isViewportSensitiveHost(
365
402
  env: Record<string, string | undefined>,
366
403
  platform: NodeJS.Platform,
367
404
  includeNativeWindows: boolean,
368
405
  includeProcessTerminal: boolean,
369
406
  ): boolean {
370
- return (
371
- isMultiplexerSession(env) ||
372
- isWindowsTerminalSession(env) ||
373
- includeProcessTerminal ||
374
- (includeNativeWindows && platform === "win32")
375
- );
407
+ const underMultiplexer = isMultiplexerSession(env);
408
+ if (underMultiplexer) {
409
+ // Preserve the documented opt-in for the legacy clear/replay path. This
410
+ // must take precedence over the process-terminal capability because tmux
411
+ // and screen sessions commonly expose a real process terminal as well.
412
+ return !envIsEnabled(env.PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER);
413
+ }
414
+ return isWindowsTerminalSession(env) || includeProcessTerminal || (includeNativeWindows && platform === "win32");
376
415
  }
377
416
  /**
378
417
  * True when repainting only the live viewport is safer than clearing/replaying
@@ -386,37 +425,9 @@ export function shouldUseViewportRepaintForHost(
386
425
  platform: NodeJS.Platform = process.platform,
387
426
  options: { includeNativeWindows?: boolean; includeProcessTerminal?: boolean } = {},
388
427
  ): boolean {
389
- const multiplexed = isMultiplexerSession(env);
390
428
  const includeNativeWindows = options.includeNativeWindows ?? true;
391
429
  const includeProcessTerminal = options.includeProcessTerminal ?? false;
392
- return (
393
- isViewportSensitiveHost(env, platform, includeNativeWindows, includeProcessTerminal) &&
394
- !(multiplexed && useLegacyMultiplexerFullRender(env))
395
- );
396
- }
397
-
398
- function useViewportRepaintPath(terminal: Terminal): boolean {
399
- if (terminal.isProcessTerminal !== true) return false;
400
- return shouldUseViewportRepaintForHost(Bun.env, process.platform, {
401
- includeNativeWindows: true,
402
- includeProcessTerminal: true,
403
- });
404
- }
405
-
406
- function allowsHostNeutralOverflowRepaint(
407
- terminal: Terminal,
408
- env: Record<string, string | undefined> = Bun.env,
409
- ): boolean {
410
- return (
411
- terminal.isProcessTerminal === true &&
412
- !isTermuxSession(env) &&
413
- !(isMultiplexerSession(env) && useLegacyMultiplexerFullRender(env))
414
- );
415
- }
416
-
417
- function shouldPreserveScrollbackOnFullClear(terminal: Terminal): boolean {
418
- if (terminal.isProcessTerminal !== true) return false;
419
- return isViewportSensitiveHost(Bun.env, process.platform, true, true);
430
+ return isViewportSensitiveHost(env, platform, includeNativeWindows, includeProcessTerminal);
420
431
  }
421
432
 
422
433
  /**
@@ -590,7 +601,7 @@ let viewportAnchorRenderFailureCount = 0;
590
601
 
591
602
  function safeRenderComponent(component: Component, width: number, where: string): string[] {
592
603
  try {
593
- return component.render(width);
604
+ return component.render(width).map(stripTerminalEraseControls);
594
605
  } catch (err) {
595
606
  return renderFailure(component, where, err);
596
607
  }
@@ -670,24 +681,151 @@ type KittyPlacementDeletePlan = {
670
681
  output: string;
671
682
  };
672
683
 
684
+ function reflowBoundaryText(line: string): string {
685
+ return Bun.stripANSI(line).replace(/\s+/g, "");
686
+ }
687
+
688
+ function findSafeReflowSuffixStart(previousFrameLines: string[], nextFrameLines: string[]): number {
689
+ const previousVisibleRows = previousFrameLines.map(line => Bun.stripANSI(line).replace(/[ \t]+$/g, ""));
690
+ const nextVisibleRows = nextFrameLines.map(line => Bun.stripANSI(line).replace(/[ \t]+$/g, ""));
691
+ for (let index = 0; index < Math.min(previousVisibleRows.length, nextVisibleRows.length); index++) {
692
+ if (
693
+ reflowBoundaryText(previousFrameLines[index]) === reflowBoundaryText(nextFrameLines[index]) &&
694
+ previousVisibleRows[index] !== nextVisibleRows[index]
695
+ ) {
696
+ return -1;
697
+ }
698
+ }
699
+ if (previousFrameLines.length === 0) return 0;
700
+
701
+ const previousRows = previousFrameLines.map(reflowBoundaryText);
702
+ let lastContentRow = previousRows.length - 1;
703
+ while (lastContentRow >= 0 && previousRows[lastContentRow].length === 0) lastContentRow -= 1;
704
+ if (lastContentRow < 0) {
705
+ for (let index = 0; index < previousFrameLines.length; index++) {
706
+ if (reflowBoundaryText(nextFrameLines[index] ?? "").length > 0) return 0;
707
+ }
708
+ return previousFrameLines.length;
709
+ }
710
+ const trailingEmptyRows = previousRows.length - lastContentRow - 1;
711
+ const hasMeaningfulWhitespace = previousVisibleRows.slice(0, lastContentRow + 1).some(line => /\s/.test(line));
712
+ const previousMeaningfulText = previousVisibleRows.slice(0, lastContentRow + 1).join("");
713
+ const previousText = previousRows.join("");
714
+ // Wrapping can insert physical rows between characters from the prior frame;
715
+ // compare the non-whitespace content as an ordered subsequence.
716
+
717
+ const completionBoundaries: number[] = [];
718
+ let boundary = 0;
719
+ for (const row of previousRows) {
720
+ if (row.length === 0) continue;
721
+ boundary += row.length;
722
+ completionBoundaries.push(boundary);
723
+ }
724
+
725
+ let matchedLength = 0;
726
+ let matchStartedAt = -1;
727
+ const completedAt: number[] = [];
728
+ for (let index = 0; index < nextFrameLines.length; index++) {
729
+ const rowText = reflowBoundaryText(nextFrameLines[index]);
730
+ const matchedBeforeRow = matchedLength;
731
+ for (let rowOffset = 0; rowOffset < rowText.length && matchedLength < previousText.length; rowOffset++) {
732
+ if (rowText[rowOffset] === previousText[matchedLength]) {
733
+ if (matchedLength === 0) matchStartedAt = index;
734
+ matchedLength += 1;
735
+ }
736
+ }
737
+ while (
738
+ completedAt.length < completionBoundaries.length &&
739
+ matchedLength >= completionBoundaries[completedAt.length]
740
+ ) {
741
+ completedAt.push(index);
742
+ }
743
+ // A row that partially matches an old row but includes extra content is an
744
+ // in-place mutation, not a reflow continuation. Completely unmatched
745
+ // nonempty rows before the old frame is consumed are ambiguous too.
746
+ const matchedInRow = matchedLength - matchedBeforeRow;
747
+ if (
748
+ matchedLength < previousText.length &&
749
+ ((matchedInRow > 0 && matchedInRow !== rowText.length) ||
750
+ (matchedInRow === 0 && rowText.length > 0 && matchStartedAt < 0))
751
+ )
752
+ return -1;
753
+ if (matchedLength === previousText.length) {
754
+ if (
755
+ (hasMeaningfulWhitespace || nextVisibleRows.slice(0, index + 1).some(line => /\s/.test(line))) &&
756
+ nextVisibleRows.slice(0, index + 1).join("") !== previousMeaningfulText
757
+ )
758
+ return -1;
759
+ // A match that starts after the prior logical frame is necessarily
760
+ // sourced from the appended suffix. It cannot prove that the old
761
+ // frame reflowed; repaint the frame conservatively instead.
762
+ if (matchStartedAt < 0 || matchStartedAt >= previousFrameLines.length) return -1;
763
+ // Completing the final old row before the current row ends is ambiguous:
764
+ // the row may have been mutated in place and wrapped below it.
765
+ if (completedAt.length === completionBoundaries.length && rowText.length > matchedInRow) return -1;
766
+ let trailingMatched = 0;
767
+ while (
768
+ trailingMatched < trailingEmptyRows &&
769
+ index + 1 + trailingMatched < nextFrameLines.length &&
770
+ reflowBoundaryText(nextFrameLines[index + 1 + trailingMatched]).length === 0
771
+ ) {
772
+ trailingMatched += 1;
773
+ }
774
+ if (trailingMatched !== trailingEmptyRows) return -1;
775
+
776
+ // If the final old row was completed by a continuation row, the
777
+ // completion index already includes that reflow row. Do not advance
778
+ // past it again: the first appended row may itself be wrapped.
779
+ // A trailing final-row exclusion requires direct evidence that the final
780
+ // row itself wrapped. Earlier completion gaps cannot establish that.
781
+ return Math.min(nextFrameLines.length, index + 1 + trailingMatched);
782
+ }
783
+ }
784
+ return -1;
785
+ }
786
+ function findStableLogicalAppendBoundary(previousFrameLines: string[], nextFrameLines: string[]): number {
787
+ if (previousFrameLines.length === 0 || nextFrameLines.length <= previousFrameLines.length) return -1;
788
+ for (let index = 0; index < previousFrameLines.length; index++) {
789
+ if (nextFrameLines[index] !== previousFrameLines[index]) return -1;
790
+ }
791
+ return previousFrameLines.length;
792
+ }
793
+ function hasDistinctPostContractionRows(
794
+ latestFrameLines: string[],
795
+ nextFrameLines: string[],
796
+ durableFrameLines: string[],
797
+ nextRawLines: string[],
798
+ durableRawLines: string[],
799
+ ): boolean {
800
+ if (nextFrameLines.length <= latestFrameLines.length || nextFrameLines.length > durableFrameLines.length)
801
+ return false;
802
+ for (let index = latestFrameLines.length; index < nextFrameLines.length; index += 1) {
803
+ if (nextRawLines[index] !== durableRawLines[index]) return true;
804
+ }
805
+ return false;
806
+ }
807
+
673
808
  /**
674
809
  * TUI - Main class for managing terminal UI with differential rendering
675
810
  */
676
811
  export class TUI extends Container {
677
812
  terminal: Terminal;
678
813
  #previousLines: string[] = [];
814
+ // Latest logical frame, including rows shown only by a transient viewport paint.
679
815
  #latestRenderedLines: string[] = [];
680
816
  #latestRenderedTranscriptLineCount = 0;
681
817
  #latestRenderedSuffixLineCount = 0;
682
818
  #latestRenderedPlacementOwners = new Map<string, KittyPlacementOwner>();
683
- /**
684
- * Raw (pre-normalization) lines from the previous frame, kept only when the
685
- * virtual-viewport flag is on. Used to detect whether the off-screen prefix is
686
- * unchanged (by raw value equality, with a fast reference short-circuit when components
687
- * return stable string instances) so its normalized form can be reused (bounded normalize).
688
- */
689
- #previousRaw: string[] = [];
690
819
  #kittyPlacementSpans: KittyPlacementSpan[] = [];
820
+ #latestRaw: string[] = [];
821
+ #durableLineCount = 0;
822
+ #durableRenderedLines: string[] = [];
823
+ #durableRawLines: string[] = [];
824
+ #restartDurableLineCount = 0;
825
+ #restartDurableRenderedLines: string[] = [];
826
+ #restartDurableRawLines: string[] = [];
827
+ #restartDurableWidth = 0;
828
+ #transcriptIdentityReplaced = false;
691
829
  #lineNormalizationCache = new Map<string, LineNormalizationCacheEntry>();
692
830
  #lineEmitWidthCache = new Map<string, number>();
693
831
  #lineTruncationCache = new Map<string, string>();
@@ -706,9 +844,16 @@ export class TUI extends Container {
706
844
  #committedRenderGeneration = 0;
707
845
  #renderCommitWaiters = new Map<number, Set<RenderCommitWaiter>>();
708
846
  #lastRenderWriteSucceeded = false;
847
+ #resizeRenderQueued = false;
848
+ #resizeRenderMutationQueued = false;
849
+ #renderMutationQueued = false;
709
850
  #renderTimer: NodeJS.Timeout | undefined;
710
851
  #widthSettleTimer: NodeJS.Timeout | undefined;
711
852
  #widthSettleRepairPending = false;
853
+ #widthSettleRenderQueued = false;
854
+ #tabWidthRepairPending = false;
855
+ #forcedRenderQueued = false;
856
+ #restartViewportRepaintPending = false;
712
857
  #lastObservedWidth = 0;
713
858
  // Trailing debounce for the settled width repair. Instance-local: taken from
714
859
  // options.widthSettleMs when provided (deterministic harnesses pass 0 to
@@ -735,6 +880,7 @@ export class TUI extends Container {
735
880
  #viewportTopRow = 0; // Content row currently mapped to screen row 0
736
881
  #scrollbackResumeViewportTop: number | undefined; // Reflowed history below this frontier is already committed
737
882
  #nativeScrollbackViewportTop = 0;
883
+ #nativeScrollbackAdmissionPending = false;
738
884
  #transcriptIdentityResetPending = false;
739
885
  #manualViewportTop: number | undefined;
740
886
  #viewportAnchorComponent: Component | null = null;
@@ -751,11 +897,13 @@ export class TUI extends Container {
751
897
  #sixelProbeUnsubscribe?: () => void;
752
898
  #showHardwareCursor = $pickflag("GJC_HARDWARE_CURSOR", "PI_HARDWARE_CURSOR");
753
899
  #debugRedraw = TUI.#readDebugRedrawFlag();
900
+ #legacyMultiplexerFullRender = false;
754
901
  // macOS: steady-block cursor anchors CJK IME overlays; disable with GJC_TUI_IME_CURSOR=0.
755
902
  readonly #useImeBlockCursor = $flag("GJC_TUI_IME_CURSOR", process.platform === "darwin");
756
903
  // showHardwareCursor=false but cursor is shown for IME anchoring (macOS).
757
904
  #imeCursorActive = false;
758
- #clearOnShrink = $pickflag("GJC_CLEAR_ON_SHRINK", "PI_CLEAR_ON_SHRINK"); // Clear empty rows when content shrinks (default: off)
905
+ #clearOnShrink = $pickflag("GJC_CLEAR_ON_SHRINK", "PI_CLEAR_ON_SHRINK");
906
+
759
907
  // Default-on: reuse the previous normalized off-screen prefix and only normalize/diff the
760
908
  // visible window, bounding per-frame work on huge transcripts. Output stays byte-identical;
761
909
  // set PI_TUI_VIRTUAL_VIEWPORT=0 to restore legacy full-transcript normalization.
@@ -811,6 +959,15 @@ export class TUI extends Container {
811
959
  TUI.#renderCounters.differentialGuardVisibleWidthCalls += 1;
812
960
  return visibleWidth(line);
813
961
  }
962
+ #recordDurableLines(lines: string[], rawLines: string[], start: number, end: number): void {
963
+ const retainedLength = Math.max(this.#durableRenderedLines.length, this.#durableLineCount);
964
+ if (this.#durableRenderedLines.length < retainedLength) this.#durableRenderedLines.length = retainedLength;
965
+ if (this.#durableRawLines.length < retainedLength) this.#durableRawLines.length = retainedLength;
966
+ for (let index = start; index <= end && index < lines.length; index += 1) {
967
+ this.#durableRenderedLines[index] = lines[index]!;
968
+ this.#durableRawLines[index] = rawLines[index] ?? lines[index]!;
969
+ }
970
+ }
814
971
 
815
972
  // Overlay stack for modal components rendered on top of base content
816
973
  overlayStack: {
@@ -838,6 +995,8 @@ export class TUI extends Container {
838
995
  ) {
839
996
  super();
840
997
  this.terminal = terminal;
998
+ this.#legacyMultiplexerFullRender =
999
+ isMultiplexerSession(Bun.env) && envIsEnabled(Bun.env.PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER);
841
1000
  if (showHardwareCursor !== undefined) {
842
1001
  this.#showHardwareCursor = showHardwareCursor;
843
1002
  }
@@ -849,6 +1008,7 @@ export class TUI extends Container {
849
1008
  this.#lineTruncationCache.clear();
850
1009
  this.#lineNormalizationCache.clear();
851
1010
  this.#lineEmitWidthCache.clear();
1011
+ this.#tabWidthRepairPending = true;
852
1012
  this.requestRender(true, "tab-width-change");
853
1013
  });
854
1014
  }
@@ -876,16 +1036,10 @@ export class TUI extends Container {
876
1036
  }
877
1037
  this.requestRender();
878
1038
  }
879
-
880
1039
  getClearOnShrink(): boolean {
881
1040
  return this.#clearOnShrink;
882
1041
  }
883
1042
 
884
- /**
885
- * Set whether to trigger full re-render when content shrinks.
886
- * When true (default), empty rows are cleared when content shrinks.
887
- * When false, empty rows remain (reduces redraws on slower terminals).
888
- */
889
1043
  setClearOnShrink(enabled: boolean): void {
890
1044
  this.#clearOnShrink = enabled;
891
1045
  }
@@ -1025,7 +1179,7 @@ export class TUI extends Container {
1025
1179
  return this.#viewportAnchorComponent;
1026
1180
  }
1027
1181
 
1028
- /** Clear manual viewport ownership before replacing the transcript identity namespace. */
1182
+ /** Clear manual viewport ownership and durable history before replacing the transcript identity. */
1029
1183
  resetViewportAnchorIntent(): void {
1030
1184
  this.#manualViewportTop = undefined;
1031
1185
  this.#manualViewportAnchor = null;
@@ -1034,6 +1188,7 @@ export class TUI extends Container {
1034
1188
  this.#viewportAnchorFrame = null;
1035
1189
  this.#scrollbackResumeViewportTop = undefined;
1036
1190
  this.#nativeScrollbackViewportTop = 0;
1191
+ this.#nativeScrollbackAdmissionPending = false;
1037
1192
  this.#transcriptIdentityResetPending = true;
1038
1193
  this.#manualOutputNotice = false;
1039
1194
  this.#paintedManualOutputNotice = false;
@@ -1047,6 +1202,11 @@ export class TUI extends Container {
1047
1202
  this.#widthSettleTimer = undefined;
1048
1203
  }
1049
1204
  this.#widthSettleRepairPending = false;
1205
+ // Replacing the transcript identity starts a new durable history namespace.
1206
+ this.#durableLineCount = 0;
1207
+ this.#durableRenderedLines.length = 0;
1208
+ this.#durableRawLines.length = 0;
1209
+ this.#transcriptIdentityReplaced = true;
1050
1210
  }
1051
1211
 
1052
1212
  /** Allow one semantic-neighbor reconciliation after a definitive same-transcript rebuild. */
@@ -1240,6 +1400,7 @@ export class TUI extends Container {
1240
1400
  transcriptLineCount: this.#latestRenderedTranscriptLineCount,
1241
1401
  suffixLineCount: this.#latestRenderedSuffixLineCount,
1242
1402
  },
1403
+ true,
1243
1404
  );
1244
1405
  if (!contentPainted) {
1245
1406
  this.#manualViewportTop = previousManualViewportTop;
@@ -1298,16 +1459,18 @@ export class TUI extends Container {
1298
1459
  this.#previousLines = liveLines;
1299
1460
  this.#manualTranscriptLineCount = liveTranscriptLineCount;
1300
1461
  this.#manualSuffixLineCount = liveSuffixLineCount;
1462
+ this.#latestRenderedLines = liveLines.slice();
1301
1463
  if (this.#scrollbackResumeViewportTop === undefined) {
1302
1464
  this.#nativeScrollbackViewportTop = liveViewportTop;
1303
1465
  }
1304
- // A settled width repair was deferred while the user was reading
1305
- // scrollback (repainting mid-read would have destroyed their
1306
- // position). The transactional live repaint above has committed, so
1307
- // manual state is safely released — now schedule the deferred full
1308
- // clear+replay that repairs old-width wrapping in history.
1309
- if (this.#widthSettleRepairPending) {
1310
- this.requestRender(true, "resize.width-settled.deferred");
1466
+ this.#nativeScrollbackAdmissionPending = liveLines.length > this.#durableLineCount;
1467
+ // Repairs deferred while the user was reading scrollback run only
1468
+ // after the transactional live repaint has committed.
1469
+ if (this.#widthSettleRepairPending || this.#tabWidthRepairPending) {
1470
+ this.requestRender(
1471
+ true,
1472
+ this.#tabWidthRepairPending ? "tab-width-change.deferred" : "resize.width-settled.deferred",
1473
+ );
1311
1474
  }
1312
1475
  },
1313
1476
  true,
@@ -1491,11 +1654,11 @@ export class TUI extends Container {
1491
1654
  return !this.#terminalUnavailable && this.terminal.available;
1492
1655
  }
1493
1656
 
1494
- #markTerminalUnavailable(): void {
1657
+ #markTerminalUnavailable(settleRenderWaiters = true): void {
1495
1658
  this.#terminalUnavailable = true;
1496
1659
  this.#stopped = true;
1497
1660
  this.#renderRequested = false;
1498
- this.#settleRenderCommitWaiters(false);
1661
+ if (settleRenderWaiters) this.#settleRenderCommitWaiters(false);
1499
1662
  if (this.#renderTimer) {
1500
1663
  clearTimeout(this.#renderTimer);
1501
1664
  this.#renderTimer = undefined;
@@ -1504,8 +1667,8 @@ export class TUI extends Container {
1504
1667
  this.#clearSixelProbeState();
1505
1668
  }
1506
1669
 
1507
- #writeTerminal(data: string): boolean {
1508
- return this.#guardTerminalOperation(() => this.terminal.write(data));
1670
+ #writeTerminal(data: string, deferRenderFailure = false): boolean {
1671
+ return this.#guardTerminalOperation(() => this.terminal.write(data), !deferRenderFailure);
1509
1672
  }
1510
1673
 
1511
1674
  #hideCursor(): boolean {
@@ -1516,19 +1679,19 @@ export class TUI extends Container {
1516
1679
  return this.#guardTerminalOperation(() => this.terminal.showCursor());
1517
1680
  }
1518
1681
 
1519
- #guardTerminalOperation(operation: () => void): boolean {
1682
+ #guardTerminalOperation(operation: () => void, settleRenderWaiters = true): boolean {
1520
1683
  if (!this.terminalAvailable) {
1521
- this.#markTerminalUnavailable();
1684
+ this.#markTerminalUnavailable(settleRenderWaiters);
1522
1685
  return false;
1523
1686
  }
1524
1687
  try {
1525
1688
  operation();
1526
1689
  } catch {
1527
- this.#markTerminalUnavailable();
1690
+ this.#markTerminalUnavailable(settleRenderWaiters);
1528
1691
  return false;
1529
1692
  }
1530
1693
  if (!this.terminal.available) {
1531
- this.#markTerminalUnavailable();
1694
+ this.#markTerminalUnavailable(settleRenderWaiters);
1532
1695
  return false;
1533
1696
  }
1534
1697
  return true;
@@ -1706,18 +1869,21 @@ export class TUI extends Container {
1706
1869
  clearTimeout(this.#widthSettleTimer);
1707
1870
  this.#widthSettleTimer = undefined;
1708
1871
  }
1709
- // An armed TIMER dies with the session, but an already-deferred repair
1710
- // (deadline passed while the user was reading scrollback) must survive a
1711
- // temporary stop/start (Ctrl-Z resume, external editor): manual viewport
1712
- // ownership survives restart, so followLiveViewport() still needs the flag
1713
- // to run the deferred repair. Without manual ownership the flag is moot —
1714
- // start() issues a forced full render that repairs everything anyway.
1872
+ // An armed TIMER dies with the session, but a repair already deferred while
1873
+ // the user was reading scrollback must survive a temporary stop/start
1874
+ // (Ctrl-Z resume, external editor): manual viewport ownership survives
1875
+ // restart, so followLiveViewport() still needs the pending repair. Without
1876
+ // manual ownership the flags are moot — start() issues a forced full render.
1715
1877
  if (this.#manualViewportTop === undefined) {
1716
1878
  this.#widthSettleRepairPending = false;
1717
- }
1718
- // Move cursor to the end of the content to prevent overwriting/artifacts on exit
1719
- if (this.#previousLines.length > 0) {
1720
- const targetRow = this.#previousLines.length; // Line after the last content
1879
+ this.#tabWidthRepairPending = false;
1880
+ }
1881
+ // Move the cursor after the frame actually displayed to prevent
1882
+ // overwriting/artifacts on exit. The latest logical frame can differ while
1883
+ // a semantic viewport retains the previously painted frame.
1884
+ const displayedFrameLines = this.#previousLines.length || this.#latestRenderedLines.length;
1885
+ if (displayedFrameLines > 0) {
1886
+ const targetRow = displayedFrameLines; // Line after the last content
1721
1887
  const lineDiff = targetRow - this.#hardwareCursorRow;
1722
1888
  if (lineDiff > 0) {
1723
1889
  this.#writeTerminal(`\x1b[${lineDiff}B`);
@@ -1736,44 +1902,54 @@ export class TUI extends Container {
1736
1902
  } catch {
1737
1903
  this.#markTerminalUnavailable();
1738
1904
  }
1739
- // Teardown: release the retained rendered transcript so a stopped TUI does
1740
- // not pin a flat copy of every emitted line for the process lifetime.
1741
- // Safe across temporary stop/start (Ctrl-Z resume, external editor): start()
1742
- // issues a forced render that rebuilds this state and fully redraws, and
1743
- // focus/listener state is intentionally preserved so input routing survives
1744
- // a resume.
1745
- this.#previousLines = [];
1905
+ // Teardown normally releases the retained rendered transcript. A temporary
1906
+ // non-manual restart keeps only the durable baseline until its first render:
1907
+ // that render can admit a raw-prefix-proven append without replaying history.
1908
+ this.#restartViewportRepaintPending =
1909
+ this.#manualViewportTop === undefined && (this.#previousLines.length > 0 || this.#maxLinesRendered > 0);
1910
+ if (this.#restartViewportRepaintPending) {
1911
+ this.#restartDurableLineCount = this.#durableLineCount;
1912
+ this.#restartDurableRenderedLines = this.#durableRenderedLines.slice();
1913
+ this.#restartDurableRawLines = this.#durableRawLines.slice();
1914
+ this.#restartDurableWidth = this.#previousWidth;
1915
+ } else {
1916
+ this.#restartDurableLineCount = 0;
1917
+ this.#restartDurableRenderedLines = [];
1918
+ this.#restartDurableRawLines = [];
1919
+ this.#restartDurableWidth = 0;
1920
+ }
1746
1921
  this.#latestRenderedLines = [];
1747
- this.#previousRaw = [];
1748
1922
  this.#kittyPlacementSpans = [];
1923
+ this.#latestRaw = [];
1924
+ this.#durableLineCount = 0;
1925
+ this.#nativeScrollbackAdmissionPending = false;
1926
+ this.#durableRenderedLines.length = 0;
1927
+ this.#durableRawLines.length = 0;
1928
+ this.#previousLines = [];
1929
+ this.#transcriptIdentityReplaced = false;
1749
1930
  this.#lineNormalizationCache.clear();
1750
1931
  this.#lineTruncationCache.clear();
1751
1932
  this.#lineEmitWidthCache.clear();
1752
1933
  this.#previousWidth = 0;
1753
1934
  this.#previousHeight = 0;
1935
+ this.#resizeRenderQueued = false;
1936
+ this.#resizeRenderMutationQueued = false;
1937
+ this.#renderMutationQueued = false;
1938
+ this.#widthSettleRenderQueued = false;
1939
+ this.#forcedRenderQueued = false;
1754
1940
  }
1755
1941
 
1756
1942
  /**
1757
1943
  * Viewport-repaint-aware resize render request.
1758
1944
  *
1759
- * A forced full redraw (`requestRender(true)`) resets `#previousWidth`/`#previousHeight`
1760
- * to -1, which makes `#doRender` treat the frame as a width change and fall into the
1761
- * `fullRender` path. In terminal multiplexers that path skips the scrollback-clearing
1762
- * `3J` escape (users navigate scrollback history), so replaying every transcript line
1763
- * piles it back on top of scrollback — the "top of screen scrolls down to the prompt at
1764
- * high speed" resize storm. Windows Terminal/ConPTY can also visibly jump to
1765
- * the transcript top during streaming redraws, so viewport-repaint sessions
1766
- * keep force off and let `#doRender` repaint only the live viewport. Set
1767
- * `PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER=1` to restore the legacy tmux redraw.
1945
+ * A forced repaint resets `#previousWidth`/`#previousHeight` to -1, which makes
1946
+ * `#doRender` treat the frame as a dimension change. Repaints stay anchored to
1947
+ * the live viewport so native scrollback is never replayed or erased.
1768
1948
  *
1769
1949
  * Spurious resize events (SIGWINCH with unchanged dimensions — iTerm2 tab
1770
1950
  * switches and window focus changes, the self-sent SIGWINCH after resume)
1771
- * must not force either: on hosts still using the `fullRender` path (legacy
1772
- * multiplexer opt-in, non-process terminals) the forced redraw clears
1773
- * scrollback (`2J`/`H`/`3J`) and replays the whole transcript, which can
1774
- * park the native viewport at the transcript top. Only force when the grid
1775
- * size actually changed since the last committed frame; a plain diff render
1776
- * is a no-op otherwise.
1951
+ * must not force either: only force when the grid size actually changed since
1952
+ * the last committed frame.
1777
1953
  */
1778
1954
  requestResizeRender(): void {
1779
1955
  // Width is tracked against the last OBSERVED terminal width, not against
@@ -1786,7 +1962,13 @@ export class TUI extends Container {
1786
1962
  this.#lastObservedWidth = observedWidth;
1787
1963
  const heightChanged = this.#previousHeight !== this.terminal.rows;
1788
1964
  if (widthChanged) this.#scheduleWidthSettleRedraw();
1789
- this.requestRender(heightChanged && !useViewportRepaintPath(this.terminal), "resize");
1965
+ this.requestRender(
1966
+ heightChanged &&
1967
+ !shouldUseViewportRepaintForHost(Bun.env, process.platform, {
1968
+ includeProcessTerminal: this.terminal.isProcessTerminal === true,
1969
+ }),
1970
+ "resize",
1971
+ );
1790
1972
  }
1791
1973
 
1792
1974
  /**
@@ -1848,19 +2030,43 @@ export class TUI extends Container {
1848
2030
  return;
1849
2031
  }
1850
2032
  if (renderMetrics.enabled) renderMetrics.recordRequest(source);
2033
+ const widthSettleRequest = source.startsWith("resize.width-settled");
2034
+ const mutationRequest = source !== "resize" && !widthSettleRequest;
2035
+ if (source === "resize") {
2036
+ this.#resizeRenderQueued = true;
2037
+ if (this.#renderRequested && this.#renderMutationQueued) {
2038
+ // A resize request coalesced into an already pending mutation means
2039
+ // the component changed before that frame settled. Preserve this
2040
+ // bit regardless of which request arrived first.
2041
+ this.#resizeRenderMutationQueued = true;
2042
+ }
2043
+ } else if (mutationRequest) {
2044
+ if (this.#resizeRenderQueued && this.#renderRequested) {
2045
+ // A mutation request coalesced into the pending resize frame means
2046
+ // the component changed before that frame settled. Keep this bit
2047
+ // separate from the terminal resize itself so a resize-only repaint
2048
+ // remains transient.
2049
+ this.#resizeRenderMutationQueued = true;
2050
+ }
2051
+ this.#renderMutationQueued = true;
2052
+ }
2053
+ if (widthSettleRequest) this.#widthSettleRenderQueued = true;
1851
2054
  if (force) {
1852
2055
  // A forced full redraw supersedes any queued input-priority render.
1853
2056
  this.#inputRenderPending = false;
1854
- this.#previousLines = [];
1855
- this.#latestRenderedLines = [];
1856
- this.#previousRaw = [];
1857
- this.#lineNormalizationCache.clear();
1858
- this.#lineTruncationCache.clear();
1859
- this.#lineEmitWidthCache.clear();
1860
- this.#previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
1861
- this.#previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
2057
+ if (!widthSettleRequest) this.#forcedRenderQueued = true;
2058
+ if (!widthSettleRequest) {
2059
+ this.#previousWidth = -1; // -1 triggers widthChanged
2060
+ this.#previousHeight = -1; // -1 triggers heightChanged
2061
+ }
1862
2062
  this.#lineNormalizationCacheLimit = 0;
1863
2063
  this.#lineTruncationCacheLimit = 0;
2064
+ if (this.#latestRenderedLines.length === 0) {
2065
+ this.#cursorRow = 0;
2066
+ this.#hardwareCursorRow = 0;
2067
+ this.#viewportTopRow = 0;
2068
+ this.#maxLinesRendered = 0;
2069
+ }
1864
2070
  if (this.#renderTimer) {
1865
2071
  clearTimeout(this.#renderTimer);
1866
2072
  this.#renderTimer = undefined;
@@ -2032,7 +2238,6 @@ export class TUI extends Container {
2032
2238
  if (DEVICE_REPORT_PATTERN.test(data)) {
2033
2239
  return;
2034
2240
  }
2035
-
2036
2241
  // Global debug key handler (registry: tui.global.debug, default Shift+Ctrl+D)
2037
2242
  if (getKeybindings().matches(data, "tui.global.debug") && this.onDebug) {
2038
2243
  this.onDebug();
@@ -2153,8 +2358,9 @@ export class TUI extends Container {
2153
2358
  const selection = this.#orderedMouseSelection();
2154
2359
  if (selection === null) return "";
2155
2360
  const selected: string[] = [];
2361
+ const selectionLines = this.#manualViewportTop === undefined ? this.#latestRenderedLines : this.#previousLines;
2156
2362
  for (let lineIndex = selection.start.line; lineIndex <= selection.end.line; lineIndex++) {
2157
- const line = this.#previousLines[lineIndex];
2363
+ const line = selectionLines[lineIndex];
2158
2364
  if (line === undefined || TERMINAL.isImageLine(line)) {
2159
2365
  selected.push("");
2160
2366
  continue;
@@ -2612,7 +2818,7 @@ export class TUI extends Container {
2612
2818
  const key = `${width}\0${normalized}`;
2613
2819
  const cached = this.#lineTruncationCache.get(key);
2614
2820
  if (cached !== undefined) {
2615
- this.#lineEmitWidthCache.set(cached, width);
2821
+ this.#lineEmitWidthCache.set(cached, visibleWidth(cached));
2616
2822
  lines[lineIndex] = cached;
2617
2823
  continue;
2618
2824
  }
@@ -2627,7 +2833,7 @@ export class TUI extends Container {
2627
2833
  const truncatedLine = truncated[i] ?? "";
2628
2834
  const terminated = truncatedLine + (truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
2629
2835
  this.#lineTruncationCache.set(`${width}\0${normalized}`, terminated);
2630
- this.#lineEmitWidthCache.set(terminated, width);
2836
+ this.#lineEmitWidthCache.set(terminated, visibleWidth(truncatedLine));
2631
2837
  lines[lineIndex] = terminated;
2632
2838
  }
2633
2839
 
@@ -2640,6 +2846,12 @@ export class TUI extends Container {
2640
2846
  return lines;
2641
2847
  }
2642
2848
 
2849
+ #padLineToWidth(line: string, width: number): string {
2850
+ if (TERMINAL.isImageLine(line)) return line;
2851
+ const lineWidth = this.#visibleWidthForDifferentialGuard(line);
2852
+ return lineWidth >= width ? line : line + " ".repeat(width - lineWidth);
2853
+ }
2854
+
2643
2855
  #kittyPlacementKey(reference: KittyPlacementReference): string {
2644
2856
  return `${reference.imageId}:${reference.placementId}`;
2645
2857
  }
@@ -2891,6 +3103,7 @@ export class TUI extends Container {
2891
3103
  placementsToClear: KittyPlacementSpan[] = this.#kittyPlacementSpans,
2892
3104
  placementsToPaint: KittyPlacementSpan[] = placementsToClear,
2893
3105
  geometry?: { transcriptLineCount: number; suffixLineCount: number },
3106
+ avoidScrollback = true,
2894
3107
  ): boolean {
2895
3108
  const paintManual = this.#manualViewportTop !== undefined && !paintLive;
2896
3109
  const transcriptLineCount = geometry?.transcriptLineCount ?? this.#manualTranscriptLineCount;
@@ -2907,7 +3120,6 @@ export class TUI extends Container {
2907
3120
  let nextViewportTop = Math.max(0, Math.min(maxViewportTop, viewportTop));
2908
3121
  if (paintManual)
2909
3122
  nextViewportTop = this.#kittyViewportTopIncludingPlacementAnchors(nextViewportTop, placementsToPaint);
2910
- const currentScreenRow = Math.max(0, Math.min(height - 1, this.#hardwareCursorRow - this.#viewportTopRow));
2911
3123
  const transcriptCapacity = paintManual ? this.#manualTranscriptCapacity(height, suffixLineCount) : height;
2912
3124
  const noticeRows = paintManual && this.#manualOutputNotice && height > suffixLineCount ? 1 : 0;
2913
3125
  const deletePlan = this.#kittyPlacementDeletePlan(
@@ -2924,14 +3136,10 @@ export class TUI extends Container {
2924
3136
  ]
2925
3137
  : [{ top: nextViewportTop, bottom: nextViewportTop + height }];
2926
3138
  let buffer = `\x1b[?2026h${deletePlan.output}`;
2927
- if (currentScreenRow > 0) {
2928
- buffer += `\x1b[${currentScreenRow}A`;
2929
- }
2930
- buffer += "\r";
3139
+ buffer += "\x1b[H";
2931
3140
  const committedTranscriptRows: Array<number | null> = [];
2932
3141
  for (let screenRow = 0; screenRow < height; screenRow++) {
2933
- if (screenRow > 0) buffer += "\r\n";
2934
- buffer += "\x1b[2K";
3142
+ if (screenRow > 0) buffer += avoidScrollback ? "\r\x1b[1B" : "\r\n";
2935
3143
  const lineIndex = nextViewportTop + screenRow;
2936
3144
  const suffixRow = screenRow - transcriptCapacity - noticeRows;
2937
3145
  const line =
@@ -2946,14 +3154,17 @@ export class TUI extends Container {
2946
3154
  screenRow < transcriptCapacity && lineIndex < transcriptLineCount ? lineIndex : null,
2947
3155
  );
2948
3156
  const isImage = TERMINAL.isImageLine(line);
3157
+ if (avoidScrollback && isImage) buffer += "\x1b7\x1b[2K";
2949
3158
  if (!isImage && this.#visibleWidthForDifferentialGuard(line) > width) {
2950
3159
  let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
2951
3160
  truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
2952
- buffer += truncatedLine;
3161
+ buffer += this.#padLineToWidth(truncatedLine, width);
2953
3162
  } else {
2954
- buffer += line;
3163
+ buffer += this.#padLineToWidth(line, width);
2955
3164
  }
3165
+ if (avoidScrollback && isImage) buffer += "\x1b8";
2956
3166
  }
3167
+ if (avoidScrollback) buffer += "\r";
2957
3168
 
2958
3169
  const finalPhysicalRow = nextViewportTop + Math.max(0, height - 1);
2959
3170
  let cursorSeq = "\x1b[?25l";
@@ -3052,6 +3263,18 @@ export class TUI extends Container {
3052
3263
 
3053
3264
  #doRender(): void {
3054
3265
  if (this.#stopped || !this.terminalAvailable) return;
3266
+ const transcriptIdentityReplaced = this.#transcriptIdentityReplaced;
3267
+ const restartViewportRepaintPending = this.#restartViewportRepaintPending;
3268
+ const resizeRenderMutationQueued = this.#resizeRenderMutationQueued;
3269
+ const widthSettleRenderQueued = this.#widthSettleRenderQueued;
3270
+ const tabWidthRepairPending = this.#tabWidthRepairPending;
3271
+ const forcedRenderQueued = this.#forcedRenderQueued;
3272
+ this.#resizeRenderQueued = false;
3273
+ this.#resizeRenderMutationQueued = false;
3274
+ this.#renderMutationQueued = false;
3275
+ this.#widthSettleRenderQueued = false;
3276
+ this.#tabWidthRepairPending = tabWidthRepairPending && this.#manualViewportTop !== undefined;
3277
+ this.#forcedRenderQueued = false;
3055
3278
  const width = this.terminal.columns;
3056
3279
  const height = this.terminal.rows;
3057
3280
  let viewportTop = Math.max(0, this.#maxLinesRendered - height);
@@ -3063,7 +3286,6 @@ export class TUI extends Container {
3063
3286
  return targetScreenRow - currentScreenRow;
3064
3287
  };
3065
3288
 
3066
- // Render direct children once so the registered transcript component retains row ownership.
3067
3289
  const renderTreeStart = renderMetrics.now();
3068
3290
  const renderedLines: string[] = [];
3069
3291
  const renderedChildren = new Map<Component, string[]>();
@@ -3077,16 +3299,19 @@ export class TUI extends Container {
3077
3299
  for (let childIndex = 0; childIndex < this.children.length; childIndex++) {
3078
3300
  const child = this.children[childIndex];
3079
3301
  const rendered = safeRenderComponentWithViewportAnchors(child, width, "tui-child");
3080
- renderedChildren.set(child, rendered.lines);
3302
+ const safeLines = rendered.lines.map(stripTerminalEraseControls);
3303
+ renderedChildren.set(child, safeLines);
3304
+ const childStart = renderedLines.length;
3081
3305
  if (child === this.#viewportAnchorComponent && rendered.anchors.some(anchor => anchor !== null)) {
3082
- anchorFrame = { startRow: renderedLines.length, anchors: rendered.anchors };
3306
+ anchorFrame = { startRow: childStart, anchors: rendered.anchors };
3083
3307
  }
3084
3308
  const owner: KittyPlacementOwner = hasStickySuffix && childIndex >= pinnedChildIndex ? "suffix" : "transcript";
3085
- for (const line of rendered.lines) {
3309
+ for (let lineIndex = 0; lineIndex < rendered.lines.length; lineIndex++) {
3310
+ const line = rendered.lines[lineIndex]!;
3086
3311
  for (const placement of extractKittyPlacementReferences(line)) {
3087
3312
  placementOwners.set(this.#kittyPlacementKey(placement), owner);
3088
3313
  }
3089
- renderedLines.push(line);
3314
+ renderedLines.push(safeLines[lineIndex] ?? line);
3090
3315
  }
3091
3316
  }
3092
3317
  const sourceTranscriptLineCount = hasStickySuffix
@@ -3120,13 +3345,22 @@ export class TUI extends Container {
3120
3345
 
3121
3346
  newLines = this.#applyMouseSelection(newLines);
3122
3347
 
3123
- // Terminate every non-image line so #previousLines mirrors emitted bytes
3348
+ // Terminate every non-image line so the latest frame mirrors emitted bytes
3124
3349
  // (closes SGR + OSC 8 hyperlink state). Must run after cursor extraction
3125
3350
  // because the marker is embedded mid-line, and before any diff/full render
3126
3351
  // path so cache comparisons stay byte-accurate.
3127
- // Width/height change detection (used for both normalization reuse and full-redraw decisions).
3352
+ // Width/height change detection (used for normalization reuse and repaint decisions).
3128
3353
  const widthChanged = this.#previousWidth !== 0 && this.#previousWidth !== width;
3354
+ const widthMetadataChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
3355
+ if (widthMetadataChanged) {
3356
+ // Emitted widths are viewport-dependent for truncated rows. The no-repair
3357
+ // resize path reuses the latest frame during repaint, so discard carried
3358
+ // width metadata before any differential guard reads it.
3359
+ this.#lineEmitWidthCache.clear();
3360
+ }
3129
3361
  const heightChanged = this.#previousHeight !== 0 && this.#previousHeight !== height;
3362
+ const initialRender = this.#previousLines.length === 0 && this.#maxLinesRendered === 0;
3363
+ let coalescedWidthAppend = false;
3130
3364
 
3131
3365
  // Normalize/truncate lines for emission. The virtual viewport is default-on;
3132
3366
  // PI_TUI_VIRTUAL_VIEWPORT=0 opts out. When enabled, reuse the previous frame's
@@ -3135,27 +3369,27 @@ export class TUI extends Container {
3135
3369
  // re-normalized and the diff starts at the window. Output is byte-identical to the
3136
3370
  // full path (reused entries are deterministic normalizations of identical raw lines).
3137
3371
  const VIEWPORT_NORMALIZE_OVERSCAN = 8;
3138
- const rawLines = newLines;
3372
+ const rawLines = newLines.slice();
3139
3373
  const total = rawLines.length;
3140
3374
  let diffStart = 0;
3141
3375
  let usedWindowNormalize = false;
3142
3376
  if (
3143
3377
  this.#virtualViewport &&
3144
3378
  !widthChanged &&
3145
- this.#previousRaw.length > 0 &&
3146
- this.#previousLines.length === this.#previousRaw.length
3379
+ this.#latestRaw.length > 0 &&
3380
+ this.#latestRenderedLines.length === this.#latestRaw.length
3147
3381
  ) {
3148
3382
  const winTop = Math.max(0, total - height - VIEWPORT_NORMALIZE_OVERSCAN);
3149
- if (winTop <= this.#previousLines.length && winTop <= this.#previousRaw.length) {
3383
+ if (winTop <= this.#latestRenderedLines.length && winTop <= this.#latestRaw.length) {
3150
3384
  let stable = true;
3151
3385
  for (let i = 0; i < winTop; i++) {
3152
- if (rawLines[i] !== this.#previousRaw[i]) {
3386
+ if (rawLines[i] !== this.#latestRaw[i]) {
3153
3387
  stable = false;
3154
3388
  break;
3155
3389
  }
3156
3390
  }
3157
3391
  if (stable) {
3158
- const windowed = this.#previousLines.slice(0, winTop);
3392
+ const windowed = this.#latestRenderedLines.slice(0, winTop);
3159
3393
  for (let i = winTop; i < total; i++) {
3160
3394
  windowed.push(rawLines[i]);
3161
3395
  }
@@ -3168,10 +3402,7 @@ export class TUI extends Container {
3168
3402
  }
3169
3403
  }
3170
3404
  if (!usedWindowNormalize) {
3171
- newLines = this.#applyLineResetsAndTruncate(this.#virtualViewport ? rawLines.slice() : rawLines, width);
3172
- }
3173
- if (this.#virtualViewport) {
3174
- this.#previousRaw = rawLines;
3405
+ newLines = this.#applyLineResetsAndTruncate(rawLines.slice(), width);
3175
3406
  }
3176
3407
  if (renderMetrics.enabled) {
3177
3408
  renderMetrics.recordLineCount("rendered", total);
@@ -3180,6 +3411,9 @@ export class TUI extends Container {
3180
3411
  if (usedWindowNormalize) renderMetrics.recordLineCount("offscreenScan", diffStart);
3181
3412
  }
3182
3413
  const nextKittyPlacementSpans = this.#kittyPlacementSpansForLines(newLines, placementOwners);
3414
+ const previousLogicalFrame = this.#latestRenderedLines.slice();
3415
+ const previousRawFrame = this.#latestRaw.slice();
3416
+ const previousRenderedLength = previousLogicalFrame.length;
3183
3417
  this.#latestRenderedLines = newLines;
3184
3418
  this.#latestRenderedTranscriptLineCount = nextTranscriptLineCount;
3185
3419
  this.#latestRenderedSuffixLineCount = nextSuffixLineCount;
@@ -3248,14 +3482,25 @@ export class TUI extends Container {
3248
3482
  previousKittyPlacementSpans,
3249
3483
  nextKittyPlacementSpans,
3250
3484
  { transcriptLineCount: nextTranscriptLineCount, suffixLineCount: nextSuffixLineCount },
3485
+ true,
3251
3486
  );
3252
- if (!contentPainted) restoreManualIntent();
3487
+ if (contentPainted) {
3488
+ this.#latestRenderedLines = newLines;
3489
+ if (this.#virtualViewport) this.#latestRaw = rawLines;
3490
+ } else restoreManualIntent();
3253
3491
  return;
3254
3492
  }
3255
3493
  // A formerly valid semantic target is temporarily absent (provider removal,
3256
3494
  // replacement, eviction, or object deletion). Keep the last resolved frame
3257
3495
  // instead of silently reinterpreting manual intent as a numeric viewport.
3258
- const retainedLines = this.#previousLines.length > 0 ? this.#previousLines : newLines;
3496
+ // Keep the committed physical baseline first: #latestRenderedLines may already
3497
+ // reflect source changes that are intentionally hidden until the anchor recovers.
3498
+ const retainedLines =
3499
+ this.#previousLines.length > 0
3500
+ ? this.#previousLines
3501
+ : previousLogicalFrame.length > 0
3502
+ ? previousLogicalFrame
3503
+ : newLines;
3259
3504
  contentPainted = false;
3260
3505
  this.#repaintViewportFromLines(
3261
3506
  retainedLines,
@@ -3283,6 +3528,8 @@ export class TUI extends Container {
3283
3528
  this.#previousHeight === height &&
3284
3529
  nextViewportTop === this.#manualViewportTop &&
3285
3530
  this.#manualOutputNotice === this.#paintedManualOutputNotice &&
3531
+ this.#latestRenderedLines.length === newLines.length &&
3532
+ this.#latestRenderedLines.every((line, index) => line === newLines[index]) &&
3286
3533
  newLines.length === this.#previousLines.length &&
3287
3534
  newLines.every((line, index) => line === this.#previousLines[index])
3288
3535
  ) {
@@ -3317,12 +3564,20 @@ export class TUI extends Container {
3317
3564
  return;
3318
3565
  }
3319
3566
  // Helper to clear scrollback and viewport and render all new lines
3320
- let viewportRepaint: (reason: string, targetViewportTop?: number) => boolean;
3567
+ const shouldPreserveScrollbackOnFullClear =
3568
+ shouldUseViewportRepaintForHost(Bun.env, process.platform, {
3569
+ includeProcessTerminal: this.terminal.isProcessTerminal === true,
3570
+ }) || this.#legacyMultiplexerFullRender;
3571
+ let viewportRepaint: (
3572
+ reason: string,
3573
+ targetViewportTopOrAllowPastLiveBottom?: number | boolean,
3574
+ allowPastLiveBottom?: boolean,
3575
+ ) => boolean;
3321
3576
  const fullRender = (clear: boolean, reason = "full render", forceScrollbackClear = false): void => {
3322
3577
  if (
3323
3578
  clear &&
3324
3579
  !forceScrollbackClear &&
3325
- shouldPreserveScrollbackOnFullClear(this.terminal) &&
3580
+ shouldPreserveScrollbackOnFullClear &&
3326
3581
  this.#scrollbackResumeViewportTop !== undefined
3327
3582
  ) {
3328
3583
  viewportRepaint(`preserving full replay blocked after scrollback-unsafe contraction: ${reason}`);
@@ -3344,9 +3599,7 @@ export class TUI extends Container {
3344
3599
  // of the stale-width copy instead of replacing it).
3345
3600
  if (clear)
3346
3601
  buffer +=
3347
- !forceScrollbackClear && shouldPreserveScrollbackOnFullClear(this.terminal)
3348
- ? "\x1b[2J\x1b[H"
3349
- : "\x1b[2J\x1b[H\x1b[3J";
3602
+ !forceScrollbackClear && shouldPreserveScrollbackOnFullClear ? "\x1b[2J\x1b[H" : "\x1b[2J\x1b[H\x1b[3J";
3350
3603
  for (let i = 0; i < newLines.length; i++) {
3351
3604
  if (i > 0) buffer += "\r\n";
3352
3605
  // Lines were pre-terminated/normalized by #applyLineResets; image
@@ -3366,7 +3619,7 @@ export class TUI extends Container {
3366
3619
  this.#nativeScrollbackViewportTop = clear
3367
3620
  ? this.#viewportTopRow
3368
3621
  : Math.max(this.#nativeScrollbackViewportTop, this.#viewportTopRow);
3369
- if (clear && (forceScrollbackClear || !shouldPreserveScrollbackOnFullClear(this.terminal))) {
3622
+ if (clear && (forceScrollbackClear || !shouldPreserveScrollbackOnFullClear)) {
3370
3623
  this.#scrollbackResumeViewportTop = undefined;
3371
3624
  }
3372
3625
  this.#previousLines = newLines;
@@ -3381,106 +3634,235 @@ export class TUI extends Container {
3381
3634
  this.#manualTranscriptLineCount = nextTranscriptLineCount;
3382
3635
  this.#manualSuffixLineCount = nextSuffixLineCount;
3383
3636
  this.#refreshPaintedLiveViewportObservation(height);
3637
+ this.#durableLineCount = newLines.length;
3638
+ this.#durableRenderedLines = newLines.slice();
3639
+ this.#durableRawLines = rawLines.slice();
3640
+ this.#transcriptIdentityReplaced = false;
3384
3641
  })
3385
3642
  )
3386
3643
  return;
3644
+ if (this.#virtualViewport) this.#latestRaw = rawLines;
3387
3645
  };
3388
3646
 
3389
- viewportRepaint = (reason: string, targetViewportTop = Math.max(0, newLines.length - height)): boolean => {
3390
- this.#fullRedrawCount += 1;
3391
- if (renderMetrics.enabled) renderMetrics.recordFullRedraw(reason);
3392
- const nextViewportTop = targetViewportTop;
3393
- const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
3394
- const deletePlan = this.#kittyPlacementDeletePlan(previousKittyPlacementSpans, nextKittyPlacementSpans, [
3395
- { top: prevViewportTop, bottom: prevViewportTop + height },
3396
- ]);
3397
- let buffer = `\x1b[?2026h${deletePlan.output}`;
3398
- if (currentScreenRow > 0) {
3399
- buffer += `\x1b[${currentScreenRow}A`;
3400
- }
3401
- buffer += "\r";
3402
- for (let screenRow = 0; screenRow < height; screenRow++) {
3403
- if (screenRow > 0) buffer += "\r\n";
3404
- buffer += "\x1b[2K";
3405
- const lineIndex = nextViewportTop + screenRow;
3406
- if (lineIndex >= newLines.length) continue;
3407
- const line = newLines[lineIndex];
3408
- const isImage = TERMINAL.isImageLine(line);
3409
- if (!isImage && this.#visibleWidthForDifferentialGuard(line) > width) {
3410
- let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
3411
- truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
3412
- buffer += truncatedLine;
3413
- } else {
3414
- buffer += line;
3647
+ viewportRepaint = (
3648
+ reason: string,
3649
+ targetViewportTopOrAllowPastLiveBottom: number | boolean = Math.max(0, newLines.length - height),
3650
+ allowPastLiveBottom = false,
3651
+ ): boolean => {
3652
+ const targetViewportTop =
3653
+ typeof targetViewportTopOrAllowPastLiveBottom === "number"
3654
+ ? targetViewportTopOrAllowPastLiveBottom
3655
+ : Math.max(0, newLines.length - height);
3656
+ const paintPastLiveBottom =
3657
+ typeof targetViewportTopOrAllowPastLiveBottom === "boolean"
3658
+ ? targetViewportTopOrAllowPastLiveBottom
3659
+ : allowPastLiveBottom;
3660
+ return this.#repaintViewportFromLines(
3661
+ newLines,
3662
+ width,
3663
+ height,
3664
+ targetViewportTop,
3665
+ cursorPos,
3666
+ reason,
3667
+ paintPastLiveBottom,
3668
+ () => {
3669
+ this.#previousLines = newLines;
3670
+ this.#previousWidth = width;
3671
+ this.#previousHeight = height;
3672
+ this.#manualTranscriptLineCount = nextTranscriptLineCount;
3673
+ this.#manualSuffixLineCount = nextSuffixLineCount;
3674
+ this.#refreshPaintedLiveViewportObservation(height);
3675
+ this.#latestRenderedLines = newLines.slice();
3676
+ if (this.#virtualViewport) this.#latestRaw = rawLines.slice();
3677
+ },
3678
+ false,
3679
+ previousKittyPlacementSpans,
3680
+ nextKittyPlacementSpans,
3681
+ { transcriptLineCount: nextTranscriptLineCount, suffixLineCount: nextSuffixLineCount },
3682
+ true,
3683
+ );
3684
+ };
3685
+ if (transcriptIdentityReplaced && !initialRender) {
3686
+ fullRender(true, "transcript identity replaced", true);
3687
+ return;
3688
+ }
3689
+ if (tabWidthRepairPending && !initialRender) {
3690
+ fullRender(true, "tab width changed", true);
3691
+ return;
3692
+ }
3693
+ // A width change may only use the durable append path when the current raw
3694
+ // frame proves that the previous raw frame is an unchanged prefix. Otherwise
3695
+ // component reflow (including row-count growth) is indistinguishable from an
3696
+ // append, so repaint the live viewport without replaying durable history.
3697
+ // Resize-only frames must never enter this path: a row-count increase caused
3698
+ // solely by reflow is not durable content and must remain a viewport repaint.
3699
+ let retainedLength = -1;
3700
+ if (widthChanged && !initialRender) {
3701
+ // Raw-prefix equality only proves a durable row prefix when every retained
3702
+ // raw row fits at both widths. Otherwise find the conservative physical
3703
+ // reflow boundary before appending the mutation suffix.
3704
+ let rawPrefixProven = false;
3705
+ if (this.#virtualViewport && rawLines.length > previousRawFrame.length) {
3706
+ const previousWidth = this.#previousWidth;
3707
+ if (previousWidth > 0) {
3708
+ rawPrefixProven = true;
3709
+ const durableWidth = Math.min(previousWidth, width);
3710
+ for (let i = 0; i < previousRawFrame.length; i++) {
3711
+ if (rawLines[i] !== previousRawFrame[i] || visibleWidth(rawLines[i]) > durableWidth) {
3712
+ rawPrefixProven = false;
3713
+ break;
3714
+ }
3715
+ }
3716
+ if (rawPrefixProven) retainedLength = previousRawFrame.length;
3415
3717
  }
3416
3718
  }
3417
-
3418
- const finalPhysicalRow = nextViewportTop + Math.max(0, height - 1);
3419
- let cursorSeq = "\x1b[?25l";
3420
- let cursorToRow = finalPhysicalRow;
3421
- if (cursorPos && cursorPos.row >= nextViewportTop && cursorPos.row < nextViewportTop + height) {
3422
- const cursor = this.#cursorControlSequence(cursorPos, newLines.length, finalPhysicalRow);
3423
- cursorSeq = cursor.seq;
3424
- cursorToRow = cursor.toRow;
3425
- }
3426
- buffer += cursorSeq;
3427
- buffer += "\x1b[?2026l";
3428
- let contentWritten = false;
3429
- this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length, () => {
3430
- contentWritten = true;
3431
- this.#hardwareCursorRow = cursorToRow;
3432
- this.#cursorRow = Math.max(0, newLines.length - 1);
3433
- this.#maxLinesRendered = newLines.length;
3434
- this.#viewportTopRow = nextViewportTop;
3435
- this.#previousLines = newLines;
3436
- this.#previousWidth = width;
3437
- this.#previousHeight = height;
3438
- this.#kittyPlacementSpans = this.#kittyCommittedPlacementsAfterPaint(
3439
- previousKittyPlacementSpans,
3440
- nextKittyPlacementSpans,
3441
- deletePlan,
3442
- [{ top: nextViewportTop, bottom: nextViewportTop + height }],
3443
- );
3444
- this.#manualTranscriptLineCount = nextTranscriptLineCount;
3445
- this.#manualSuffixLineCount = nextSuffixLineCount;
3446
- this.#refreshPaintedLiveViewportObservation(height);
3447
- });
3448
- if (!contentWritten) return false;
3449
-
3450
- if (this.#debugRedraw) {
3451
- const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
3452
- this.#appendDebugRedrawLog(msg);
3719
+ if (rawPrefixProven) {
3720
+ // Raw rows can expand into a different number of physical rows at
3721
+ // the new width (components may also expose width-sensitive rows).
3722
+ // Derive the retained boundary from rendered frames so reflow
3723
+ // continuations are not committed as durable output.
3724
+ retainedLength = findSafeReflowSuffixStart(previousLogicalFrame, newLines);
3725
+ if (retainedLength < 0) rawPrefixProven = false;
3453
3726
  }
3454
- return true;
3455
- };
3727
+ if (!rawPrefixProven) {
3728
+ // A raw row that exceeded the old/new width may have been truncated in
3729
+ // the previous frame. Match the rendered frame so a resize cannot
3730
+ // mistake that truncation for a stable durable boundary.
3731
+ const previousFrameLines = previousLogicalFrame;
3732
+ const hasPresentationMetadata =
3733
+ previousRawFrame.some(line => !TERMINAL.isImageLine(line) && Bun.stripANSI(line) !== line) ||
3734
+ rawLines.some(line => !TERMINAL.isImageLine(line) && Bun.stripANSI(line) !== line);
3735
+ const stableLogicalBoundary = hasPresentationMetadata
3736
+ ? -1
3737
+ : findStableLogicalAppendBoundary(previousFrameLines, rawLines);
3738
+ retainedLength =
3739
+ stableLogicalBoundary >= 0
3740
+ ? stableLogicalBoundary
3741
+ : hasPresentationMetadata
3742
+ ? -1
3743
+ : findSafeReflowSuffixStart(previousFrameLines, rawLines);
3744
+ }
3745
+ }
3746
+ const distinctPostContractionRows = hasDistinctPostContractionRows(
3747
+ previousLogicalFrame,
3748
+ newLines,
3749
+ this.#durableRenderedLines,
3750
+ rawLines,
3751
+ this.#durableRawLines,
3752
+ );
3753
+ const durableAppend = newLines.length > this.#durableLineCount || distinctPostContractionRows;
3754
+ // A stale durable frontier can sit behind a transient reflow frame. Coalesced
3755
+ // resize/mutation output is an append only when the desired frame also grew
3756
+ // beyond that frame; otherwise CRLF would commit reflow rows a second time.
3757
+ const logicalAppend = newLines.length > previousRenderedLength;
3758
+ if (widthSettleRenderQueued && this.#widthSettleRepairPending && !initialRender) {
3759
+ // The debounced repair is the only permitted full clear/replay after a
3760
+ // resize storm. It must run before resize-only admission so old-width
3761
+ // wraps are replaced in native scrollback as well as the live viewport.
3762
+ this.#widthSettleRepairPending = false;
3763
+ fullRender(true, "width settled", true);
3764
+ return;
3765
+ }
3766
+ const useViewportRepaintPath = shouldUseViewportRepaintForHost(Bun.env, process.platform, {
3767
+ includeProcessTerminal: this.terminal.isProcessTerminal === true,
3768
+ });
3769
+ const widthReflowRequired =
3770
+ this.#previousWidth > 0 &&
3771
+ rawLines.some(
3772
+ line => !TERMINAL.isImageLine(line) && visibleWidth(line) > Math.min(this.#previousWidth, width),
3773
+ );
3774
+ if (
3775
+ widthChanged &&
3776
+ !this.#legacyMultiplexerFullRender &&
3777
+ !initialRender &&
3778
+ (!resizeRenderMutationQueued ||
3779
+ !durableAppend ||
3780
+ !logicalAppend ||
3781
+ retainedLength < 0 ||
3782
+ retainedLength >= newLines.length) &&
3783
+ useViewportRepaintPath
3784
+ ) {
3785
+ // Resize-only frames, and frames without a proven append suffix, repaint
3786
+ // the live viewport without replaying durable history. Only on viewport-
3787
+ // repaint hosts (multiplexers, Windows Terminal, process terminals); plain
3788
+ // terminals fall through to fullRender so the whole frame is replayed.
3789
+ if (forcedRenderQueued) this.#fullRedrawCount += 1;
3790
+ viewportRepaint(`terminal width changed (${this.#previousWidth} -> ${width})`, true, true);
3791
+ return;
3792
+ }
3793
+ if (widthChanged && !initialRender && resizeRenderMutationQueued) {
3794
+ this.#latestRenderedLines = newLines.slice(0, retainedLength);
3795
+ if (this.#virtualViewport) this.#latestRaw = rawLines.slice(0, retainedLength);
3796
+ coalescedWidthAppend = true;
3797
+ }
3456
3798
 
3457
3799
  const debugRedraw = this.#debugRedraw;
3458
3800
  const logRedraw = (reason: string): void => {
3459
3801
  if (!debugRedraw) return;
3460
- const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height})\n`;
3802
+ const msg = `[${new Date().toISOString()}] fullRender: ${reason} (new=${newLines.length}, height=${height})\n`;
3461
3803
  this.#appendDebugRedrawLog(msg);
3462
3804
  };
3463
3805
 
3806
+ if (restartViewportRepaintPending && initialRender) {
3807
+ const restartAppendProven =
3808
+ width === this.#restartDurableWidth &&
3809
+ rawLines.length >= this.#restartDurableLineCount &&
3810
+ rawLines
3811
+ .slice(0, this.#restartDurableLineCount)
3812
+ .every((line, index) => line === this.#restartDurableRawLines[index]);
3813
+ if (restartAppendProven && rawLines.length > this.#restartDurableLineCount) {
3814
+ const appendBuffer = `\x1b[?2026h${newLines.slice(this.#restartDurableLineCount).join("\r\n")}\x1b[?2026l`;
3815
+ if (!this.#writeTerminal(appendBuffer)) return;
3816
+ // The append already reached native scrollback. Advance both the live
3817
+ // frontier and the retained restart baseline before the viewport write:
3818
+ // a subsequent terminal failure must not re-admit this suffix on restart.
3819
+ this.#durableLineCount = newLines.length;
3820
+ this.#durableRenderedLines = newLines.slice();
3821
+ this.#durableRawLines = rawLines.slice();
3822
+ this.#restartDurableLineCount = newLines.length;
3823
+ this.#restartDurableRenderedLines = newLines.slice();
3824
+ this.#restartDurableRawLines = rawLines.slice();
3825
+ this.#restartDurableWidth = width;
3826
+ }
3827
+ if (viewportRepaint("restart after temporary stop")) {
3828
+ if (restartAppendProven) {
3829
+ this.#durableLineCount = newLines.length;
3830
+ this.#durableRenderedLines = newLines.slice();
3831
+ this.#durableRawLines = rawLines.slice();
3832
+ } else {
3833
+ this.#durableLineCount = this.#restartDurableLineCount;
3834
+ this.#durableRenderedLines = this.#restartDurableRenderedLines.slice();
3835
+ this.#durableRawLines = this.#restartDurableRawLines.slice();
3836
+ }
3837
+ this.#restartDurableLineCount = 0;
3838
+ this.#restartDurableRenderedLines = [];
3839
+ this.#restartDurableRawLines = [];
3840
+ this.#restartDurableWidth = 0;
3841
+ this.#restartViewportRepaintPending = false;
3842
+ }
3843
+ return;
3844
+ }
3464
3845
  // First render - just output everything without clearing (assumes clean screen)
3465
- if (this.#previousLines.length === 0 && !widthChanged && !heightChanged) {
3846
+ if (initialRender) {
3466
3847
  logRedraw("first render");
3467
3848
  fullRender(false, "first render");
3468
3849
  return;
3469
3850
  }
3470
3851
 
3471
- // Width changes always need a full re-render because wrapping changes.
3472
- if (widthChanged) {
3473
- if (this.#widthSettleRepairPending) {
3474
- logRedraw(`width settled (${this.#previousWidth} -> ${width})`);
3475
- // The one debounced post-resize repair: a full clear+replay so stale
3476
- // old-width wrapping is repaired in scrollback history too, not just
3477
- // the live viewport. forceScrollbackClear erases the stale-width
3478
- // history instead of stacking the replay on top of it. Safe against
3479
- // the replay storm because it runs once per settled width sequence,
3480
- // never once per SIGWINCH.
3852
+ // Width changes always need a full re-render because wrapping changes, unless
3853
+ // a proven coalesced append is continuing through the durable append path.
3854
+ if (widthChanged && !coalescedWidthAppend) {
3855
+ if (!widthReflowRequired) {
3481
3856
  this.#widthSettleRepairPending = false;
3482
- fullRender(true, "width settled", true);
3483
- } else if (useViewportRepaintPath(this.terminal)) {
3857
+ logRedraw(`terminal width changed without reflow (${this.#previousWidth} -> ${width})`);
3858
+ if (useViewportRepaintPath) {
3859
+ viewportRepaint(`terminal width changed without reflow (${this.#previousWidth} -> ${width})`);
3860
+ } else {
3861
+ fullRender(true, "terminal width changed without reflow");
3862
+ }
3863
+ return;
3864
+ }
3865
+ if (useViewportRepaintPath) {
3484
3866
  logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
3485
3867
  // In viewport-repaint sessions a per-event full replay can either pile
3486
3868
  // the transcript back onto scrollback (tmux/screen) or visibly jump to
@@ -3499,7 +3881,7 @@ export class TUI extends Container {
3499
3881
  // but Termux changes height when the software keyboard shows or hides.
3500
3882
  // In that environment, a full redraw causes the entire history to replay on every toggle.
3501
3883
  if (heightChanged) {
3502
- if (useViewportRepaintPath(this.terminal)) {
3884
+ if (useViewportRepaintPath) {
3503
3885
  viewportRepaint(`terminal height changed (${this.#previousHeight} -> ${height})`);
3504
3886
  return;
3505
3887
  }
@@ -3513,11 +3895,7 @@ export class TUI extends Container {
3513
3895
  // Configurable via setClearOnShrink() or GJC_CLEAR_ON_SHRINK=0 env var
3514
3896
  if (this.#clearOnShrink && newLines.length < this.#previousLines.length && this.overlayStack.length === 0) {
3515
3897
  logRedraw(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
3516
- if (
3517
- useViewportRepaintPath(this.terminal) ||
3518
- ((this.#previousLines.length > height || newLines.length > height) &&
3519
- allowsHostNeutralOverflowRepaint(this.terminal))
3520
- ) {
3898
+ if (useViewportRepaintPath) {
3521
3899
  viewportRepaint(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
3522
3900
  } else {
3523
3901
  fullRender(true, "clearOnShrink");
@@ -3543,14 +3921,55 @@ export class TUI extends Container {
3543
3921
  lastChanged = i;
3544
3922
  }
3545
3923
  }
3546
- const appendedLines = newLines.length > this.#previousLines.length;
3924
+ // Regrowth entirely within rows already committed before a contraction is
3925
+ // a viewport repaint, never a new scrollback append.
3926
+ if (
3927
+ !initialRender &&
3928
+ !coalescedWidthAppend &&
3929
+ newLines.length > this.#previousLines.length &&
3930
+ newLines.length <= this.#durableLineCount &&
3931
+ !distinctPostContractionRows
3932
+ ) {
3933
+ viewportRepaint("content regrowth within durable history", true, true);
3934
+ return;
3935
+ }
3936
+ const appendedLines = newLines.length > this.#previousLines.length || durableAppend;
3547
3937
  if (appendedLines) {
3548
- if (firstChanged === -1) {
3549
- firstChanged = this.#previousLines.length;
3938
+ if (
3939
+ this.#nativeScrollbackAdmissionPending &&
3940
+ this.#durableLineCount <= firstChanged &&
3941
+ previousLogicalFrame.length < newLines.length &&
3942
+ firstChanged >= previousLogicalFrame.length
3943
+ ) {
3944
+ // Following a manual viewport repaints the live frame in place, so its
3945
+ // newest rows are not yet in native scrollback. Advance by a newline
3946
+ // before emitting the new frontier; the terminal admits the existing
3947
+ // bottom row without replaying its bytes.
3948
+ firstChanged = previousLogicalFrame.length;
3949
+ } else if (coalescedWidthAppend && retainedLength >= 0) {
3950
+ // The terminal reflows the retained prefix during a resize. Emit only
3951
+ // the proven durable suffix; replaying the reflowed prefix would append
3952
+ // historical rows to native scrollback a second time.
3953
+ firstChanged = retainedLength;
3954
+ } else if (firstChanged === -1 || (durableAppend && firstChanged === previousLogicalFrame.length)) {
3955
+ // A resize repaint updates #latestRenderedLines without committing the
3956
+ // reflowed viewport rows to scrollback. Never rewind the suffix start
3957
+ // below that repaint boundary: doing so re-emits reflow rows on the
3958
+ // following real append. A contraction can still retain a higher
3959
+ // durable boundary, hence the maximum.
3960
+ firstChanged =
3961
+ durableAppend && !coalescedWidthAppend && !distinctPostContractionRows
3962
+ ? Math.max(this.#durableLineCount, previousLogicalFrame.length)
3963
+ : previousLogicalFrame.length;
3550
3964
  }
3551
3965
  lastChanged = newLines.length - 1;
3552
3966
  }
3553
- let appendStart = appendedLines && firstChanged === this.#previousLines.length && firstChanged > 0;
3967
+ let appendStart =
3968
+ appendedLines &&
3969
+ firstChanged > 0 &&
3970
+ (firstChanged === this.#previousLines.length ||
3971
+ firstChanged === previousLogicalFrame.length ||
3972
+ firstChanged === this.#durableLineCount);
3554
3973
  if (firstChanged >= 0) {
3555
3974
  const changedTop = firstChanged;
3556
3975
  let expanded: boolean;
@@ -3595,7 +4014,13 @@ export class TUI extends Container {
3595
4014
  );
3596
4015
  return;
3597
4016
  }
3598
- if (appendedLines && this.#scrollbackResumeViewportTop !== undefined && nextLiveViewportTop > prevViewportTop) {
4017
+ if (distinctPostContractionRows) this.#scrollbackResumeViewportTop = undefined;
4018
+ if (
4019
+ appendedLines &&
4020
+ this.#scrollbackResumeViewportTop !== undefined &&
4021
+ nextLiveViewportTop > prevViewportTop &&
4022
+ !distinctPostContractionRows
4023
+ ) {
3599
4024
  const resumeViewportTop = this.#scrollbackResumeViewportTop;
3600
4025
  if (nextLiveViewportTop <= resumeViewportTop) {
3601
4026
  viewportRepaint(
@@ -3643,11 +4068,7 @@ export class TUI extends Container {
3643
4068
  const extraLines = this.#previousLines.length - newLines.length;
3644
4069
  if (extraLines > height) {
3645
4070
  logRedraw(`extraLines > height (${extraLines} > ${height})`);
3646
- if (useViewportRepaintPath(this.terminal)) {
3647
- viewportRepaint(`extraLines > height (${extraLines} > ${height})`);
3648
- } else {
3649
- fullRender(true, "extraLines > height");
3650
- }
4071
+ viewportRepaint(`extraLines > height (${extraLines} > ${height})`);
3651
4072
  return;
3652
4073
  }
3653
4074
  const clearStartOffset = newLines.length > 0 && extraLines > 0 ? 1 : 0;
@@ -3655,7 +4076,7 @@ export class TUI extends Container {
3655
4076
  buffer += `\x1b[${clearStartOffset}B`;
3656
4077
  }
3657
4078
  for (let i = 0; i < extraLines; i++) {
3658
- buffer += "\r\x1b[2K";
4079
+ buffer += `\r${" ".repeat(width)}`;
3659
4080
  if (i < extraLines - 1) buffer += "\x1b[1B";
3660
4081
  }
3661
4082
  const moveUp = extraLines - 1 + clearStartOffset;
@@ -3686,8 +4107,13 @@ export class TUI extends Container {
3686
4107
  })
3687
4108
  )
3688
4109
  return;
4110
+ this.#latestRenderedLines = newLines;
4111
+ if (this.#virtualViewport) this.#latestRaw = rawLines;
4112
+ this.#transcriptIdentityReplaced = false;
3689
4113
  }
3690
- this.#previousLines = newLines;
4114
+ this.#latestRenderedLines = newLines;
4115
+ if (this.#virtualViewport) this.#latestRaw = rawLines;
4116
+ this.#durableLineCount = Math.max(this.#durableLineCount, newLines.length);
3691
4117
  this.#previousWidth = width;
3692
4118
  this.#previousHeight = height;
3693
4119
  this.#maxLinesRendered = newLines.length;
@@ -3701,24 +4127,73 @@ export class TUI extends Container {
3701
4127
  // Differential rendering can only touch what was actually visible. If a
3702
4128
  // streaming status/header line changes above a live-following viewport, keep
3703
4129
  // the terminal pinned by diffing from the visible top instead of clearing and
3704
- // replaying the transcript. If the user paged away, keep the historical
3705
- // full-redraw behavior so scrollback is repaired rather than snapping them
3706
- // back to live.
3707
- if (firstChanged < prevViewportTop) {
3708
- logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
3709
- if (
3710
- useViewportRepaintPath(this.terminal) ||
3711
- (newLines.length <= this.#previousLines.length &&
3712
- (this.#previousLines.length > height || newLines.length > height) &&
3713
- allowsHostNeutralOverflowRepaint(this.terminal))
3714
- ) {
3715
- viewportRepaint(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
4130
+ // replaying the transcript. Historical mutations repaint the viewport so
4131
+ // native scrollback is never replayed or repaired.
4132
+ // When a historical mutation is accompanied by growth, commit only the
4133
+ // changed visible suffix. This advances native scrollback without replaying
4134
+ // the mutated off-screen prefix; the latest frame is updated below so each
4135
+ // appended row is emitted exactly once.
4136
+ if (firstChanged < prevViewportTop && appendedLines) {
4137
+ // A same-length substitution above the viewport (a streaming status line,
4138
+ // say) leaves every later row at its original index, so the visible suffix
4139
+ // can still be committed. Growth *inside* the off-screen prefix instead
4140
+ // shifts committed content down across the scrollback frontier: the rows
4141
+ // this frame would commit already sit in native scrollback under their old
4142
+ // index, so emitting them appends a second copy — a pending tool block
4143
+ // stranded above its own completed copy, with the rows between duplicated.
4144
+ // The last committed row changing is the observable signal of that shift.
4145
+ const committedBoundary = prevViewportTop - 1;
4146
+ const committedBoundaryShifted =
4147
+ committedBoundary >= diffStart &&
4148
+ (this.#previousLines[committedBoundary] ?? "") !== (newLines[committedBoundary] ?? "");
4149
+ if (committedBoundaryShifted) {
4150
+ const reason = `offscreen growth shifted committed rows (${firstChanged} < ${prevViewportTop})`;
4151
+ logRedraw(reason);
4152
+ if (useViewportRepaintPath) viewportRepaint(reason);
4153
+ else fullRender(true, reason);
3716
4154
  return;
3717
4155
  }
3718
- fullRender(true, "firstChanged < viewportTop");
4156
+ let suffixStart = -1;
4157
+ for (let i = Math.max(diffStart, prevViewportTop); i < maxLines; i++) {
4158
+ const oldLine = i < this.#previousLines.length ? this.#previousLines[i] : "";
4159
+ const newLine = i < newLines.length ? newLines[i] : "";
4160
+ if (oldLine !== newLine) {
4161
+ suffixStart = i;
4162
+ break;
4163
+ }
4164
+ }
4165
+ if (suffixStart >= 0) {
4166
+ firstChanged = suffixStart;
4167
+ appendStart =
4168
+ firstChanged > 0 &&
4169
+ (firstChanged === previousLogicalFrame.length || firstChanged === this.#durableLineCount);
4170
+ }
4171
+ }
4172
+ // A transient width-reflow repaint can leave the latest logical frame
4173
+ // above the durable boundary: those reflowed rows are visible, but were
4174
+ // intentionally not committed to native scrollback. If a later update
4175
+ // has the same row count and changes only that off-screen prefix,
4176
+ // `durableAppend` is true solely because the boundary is stale. Treat it
4177
+ // as a viewport repaint rather than moving through or replaying history.
4178
+ if (
4179
+ firstChanged < prevViewportTop &&
4180
+ newLines.length === previousLogicalFrame.length &&
4181
+ this.#latestRenderedLines.length > this.#durableLineCount
4182
+ ) {
4183
+ logRedraw("offscreen mutation after transient reflow");
4184
+ viewportRepaint("offscreen mutation after transient reflow");
3719
4185
  return;
3720
4186
  }
3721
4187
 
4188
+ if (firstChanged < prevViewportTop && !durableAppend) {
4189
+ logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
4190
+ if (useViewportRepaintPath) {
4191
+ viewportRepaint(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
4192
+ } else {
4193
+ fullRender(true, "firstChanged < viewportTop");
4194
+ }
4195
+ return;
4196
+ }
3722
4197
  // Render from first changed line to end
3723
4198
  // Build buffer with all updates wrapped in synchronized output
3724
4199
  const deletePlan = this.#kittyPlacementDeletePlan(previousKittyPlacementSpans, nextKittyPlacementSpans, [
@@ -3726,15 +4201,47 @@ export class TUI extends Container {
3726
4201
  ]);
3727
4202
  let buffer = `\x1b[?2026h${deletePlan.output}`; // Begin synchronized output
3728
4203
  const prevViewportBottom = prevViewportTop + height - 1;
3729
- const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
4204
+ const nativeScrollbackAdmission =
4205
+ appendedLines &&
4206
+ this.#nativeScrollbackAdmissionPending &&
4207
+ this.#durableLineCount <= firstChanged &&
4208
+ previousLogicalFrame.length < newLines.length &&
4209
+ firstChanged >= previousLogicalFrame.length &&
4210
+ appendStart;
4211
+ if (nativeScrollbackAdmission) {
4212
+ // A live repaint can leave the hardware cursor one row beyond the
4213
+ // logical frontier when the bottom row wrapped at terminal width.
4214
+ // Let the geometry branch perform the native scroll from that row;
4215
+ // moving back first would only advance a blank row.
4216
+ appendStart = false;
4217
+ }
4218
+ const moveTargetRow = coalescedWidthAppend
4219
+ ? newLines.length - 1
4220
+ : nativeScrollbackAdmission
4221
+ ? firstChanged
4222
+ : appendStart
4223
+ ? firstChanged - 1
4224
+ : firstChanged;
3730
4225
  if (moveTargetRow > prevViewportBottom) {
3731
- const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
3732
- const moveToBottom = height - 1 - currentScreenRow;
3733
- if (moveToBottom > 0) {
3734
- buffer += `\x1b[${moveToBottom}B`;
4226
+ if (nativeScrollbackAdmission) {
4227
+ // The logical cursor row can be one row ahead of the physical xterm
4228
+ // cursor after a live viewport repaint (a full-width row leaves a
4229
+ // pending wrap). CUD is bounded by the terminal's scroll margin, so
4230
+ // moving by one viewport height reliably reaches the physical bottom
4231
+ // without replaying any transcript bytes.
4232
+ buffer += `\x1b[${Math.max(1, height)}B`;
4233
+ } else {
4234
+ const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
4235
+ const moveToBottom = height - 1 - currentScreenRow;
4236
+ if (moveToBottom > 0) {
4237
+ buffer += `\x1b[${moveToBottom}B`;
4238
+ }
3735
4239
  }
3736
4240
  const scroll = moveTargetRow - prevViewportBottom;
3737
- buffer += "\r\n".repeat(scroll);
4241
+ // Native admission follows a repaint at the live bottom. Use IND rather
4242
+ // than LF so the terminal performs one unambiguous scroll without
4243
+ // reinterpreting a pending wrapped row.
4244
+ buffer += (nativeScrollbackAdmission ? "\r\x1bD" : "\r\n").repeat(scroll);
3738
4245
  prevViewportTop += scroll;
3739
4246
  viewportTop += scroll;
3740
4247
  hardwareCursorRow = moveTargetRow;
@@ -3755,7 +4262,7 @@ export class TUI extends Container {
3755
4262
  const renderEnd = Math.min(lastChanged, newLines.length - 1);
3756
4263
  for (let i = firstChanged; i <= renderEnd; i++) {
3757
4264
  if (i > firstChanged) buffer += "\r\n";
3758
- buffer += "\x1b[2K"; // Clear current line
4265
+ buffer += "\x1b[2K";
3759
4266
  const line = newLines[i];
3760
4267
  let truncatedLine = line;
3761
4268
  const isImage = TERMINAL.isImageLine(line);
@@ -3783,7 +4290,7 @@ export class TUI extends Container {
3783
4290
  }
3784
4291
  // Non-image lines are pre-terminated/normalized by #applyLineResets;
3785
4292
  // truncated lines re-append LINE_TERMINATOR above.
3786
- buffer += truncatedLine;
4293
+ buffer += this.#padLineToWidth(truncatedLine, width);
3787
4294
  }
3788
4295
 
3789
4296
  // Track where cursor ended up after rendering
@@ -3799,7 +4306,7 @@ export class TUI extends Container {
3799
4306
  }
3800
4307
  const extraLines = this.#previousLines.length - newLines.length;
3801
4308
  for (let i = newLines.length; i < this.#previousLines.length; i++) {
3802
- buffer += "\r\n\x1b[2K";
4309
+ buffer += `\r\n${" ".repeat(width)}`;
3803
4310
  }
3804
4311
  // Move cursor back to end of new content
3805
4312
  buffer += `\x1b[${extraLines}A`;
@@ -3825,13 +4332,13 @@ export class TUI extends Container {
3825
4332
  `finalCursorRow: ${finalCursorRow}`,
3826
4333
  `cursorPos: ${JSON.stringify(cursorPos)}`,
3827
4334
  `newLines.length: ${newLines.length}`,
3828
- `previousLines.length: ${this.#previousLines.length}`,
4335
+ `latestRenderedLines.length: ${this.#latestRenderedLines.length}`,
3829
4336
  "",
3830
4337
  "=== newLines ===",
3831
4338
  JSON.stringify(newLines, null, 2),
3832
4339
  "",
3833
- "=== previousLines ===",
3834
- JSON.stringify(this.#previousLines, null, 2),
4340
+ "=== latestRenderedLines ===",
4341
+ JSON.stringify(this.#latestRenderedLines, null, 2),
3835
4342
  "",
3836
4343
  "=== buffer ===",
3837
4344
  JSON.stringify(buffer),
@@ -3864,6 +4371,12 @@ export class TUI extends Container {
3864
4371
  })
3865
4372
  )
3866
4373
  return;
4374
+ this.#latestRenderedLines = newLines;
4375
+ if (this.#virtualViewport) this.#latestRaw = rawLines;
4376
+ this.#durableLineCount = Math.max(this.#durableLineCount, newLines.length);
4377
+ this.#recordDurableLines(newLines, rawLines, firstChanged, renderEnd);
4378
+ this.#nativeScrollbackAdmissionPending = false;
4379
+ this.#transcriptIdentityReplaced = false;
3867
4380
  }
3868
4381
 
3869
4382
  /**
@@ -3923,10 +4436,9 @@ export class TUI extends Container {
3923
4436
  }
3924
4437
 
3925
4438
  /**
3926
- * Register an emitter whose escape payload is appended to every render
3927
- * write (inside its own synchronized-output block, cursor saved/restored).
3928
- * Used for absolute-positioned overlays such as pixel-image pets that live
3929
- * outside the line-based component model. Return null to emit nothing.
4439
+ * Register an emitter whose payload is delivered after each shared render
4440
+ * transaction. The emitter is an exempt physical overlay: its bytes are
4441
+ * deliberately kept out of the shared transcript write.
3930
4442
  */
3931
4443
  setPostRenderEmitter(emitter: (() => string | null) | undefined): void {
3932
4444
  this.#postRenderEmitter = emitter;
@@ -3940,21 +4452,31 @@ export class TUI extends Container {
3940
4452
  totalLines: number,
3941
4453
  onBufferWritten?: () => void,
3942
4454
  ): boolean {
4455
+ if (!this.#writeTerminal(buffer)) {
4456
+ return false;
4457
+ }
4458
+ onBufferWritten?.();
4459
+ this.#lastRenderWriteSucceeded = true;
4460
+
3943
4461
  const overlay = this.#postRenderEmitter?.();
3944
4462
  if (overlay) {
3945
4463
  // DECSC/DECRC keep the hardware cursor stable; the dedicated
3946
4464
  // synchronized block prevents visible tearing while the overlay
3947
4465
  // area is cleared and redrawn.
3948
- buffer += `\x1b[?2026h\x1b7${overlay}\x1b8\x1b[?2026l`;
3949
- }
3950
- if (!this.#writeTerminal(buffer)) return false;
3951
- onBufferWritten?.();
3952
- if (!this.#imeCursorActive) {
3953
- this.#lastRenderWriteSucceeded = true;
3954
- return true;
4466
+ const overlayBuffer = `\x1b[?2026h\x1b7${overlay}\x1b8\x1b[?2026l`;
4467
+ // Overlay delivery is outside shared transcript ownership. The
4468
+ // shared write has already committed even when this exempt write
4469
+ // fails, so do not make callers retry the shared bytes.
4470
+ if (!this.#writeTerminal(overlayBuffer, true)) {
4471
+ return true;
4472
+ }
3955
4473
  }
3956
- const cursorWritten = this.#writeCursorPosition(cursorPos, totalLines);
3957
- if (cursorWritten) this.#lastRenderWriteSucceeded = true;
4474
+ if (!this.#imeCursorActive) return true;
4475
+ // Cursor positioning is outside shared transcript ownership. A failure still
4476
+ // makes the terminal unavailable, but cannot uncommit the shared frame. The
4477
+ // onBufferWritten callback has already run; the return value propagates
4478
+ // terminal availability so callers can detect the detach.
4479
+ const cursorWritten = this.#writeCursorPosition(cursorPos, totalLines, true);
3958
4480
  return cursorWritten;
3959
4481
  }
3960
4482
 
@@ -3963,13 +4485,21 @@ export class TUI extends Container {
3963
4485
  * synchronized output block. Use when there is no surrounding render buffer
3964
4486
  * to embed the sequences into.
3965
4487
  */
3966
- #writeCursorPosition(cursorPos: { row: number; col: number } | null, totalLines: number): boolean {
4488
+ #writeCursorPosition(
4489
+ cursorPos: { row: number; col: number } | null,
4490
+ totalLines: number,
4491
+ deferRenderFailure = false,
4492
+ ): boolean {
3967
4493
  if (!cursorPos || totalLines <= 0) {
3968
- return this.#hideCursor();
4494
+ return deferRenderFailure
4495
+ ? this.#guardTerminalOperation(() => this.terminal.hideCursor(), false)
4496
+ : this.#hideCursor();
3969
4497
  }
3970
4498
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, totalLines, this.#hardwareCursorRow);
3971
4499
  // No \x1b[?2026h/l wrapper: synchronized output flushes terminal state and discards macOS IME composition.
3972
- if (!this.#writeTerminal(seq)) return false;
4500
+ if (!this.#writeTerminal(seq, deferRenderFailure)) {
4501
+ return false;
4502
+ }
3973
4503
  this.#hardwareCursorRow = toRow;
3974
4504
  return true;
3975
4505
  }