@gajae-code/tui 0.11.11 → 0.12.0

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
@@ -39,6 +39,8 @@ const SEGMENT_RESET = "\x1b[0m";
39
39
  */
40
40
  const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x07";
41
41
  const MOUSE_SELECTION_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
42
+ /** Discrete mouse-wheel notch size in terminal rows (xterm/less-style). */
43
+ export const DEFAULT_WHEEL_LINES = 3;
42
44
 
43
45
  function stripTerminalControls(text: string): string {
44
46
  return Bun.stripANSI(text)
@@ -190,6 +192,11 @@ export interface ViewportAnchorSource {
190
192
  id: ViewportAnchorId;
191
193
  }
192
194
 
195
+ /** Identity and monotonic revision of the logical output producer. */
196
+ export type ViewportOutputSource = {
197
+ identity: string;
198
+ revision: bigint;
199
+ };
193
200
  export interface ViewportAnchorSourceRenderer extends Component {
194
201
  renderWithViewportAnchorSource(width: number, source: ViewportAnchorSource): ViewportAnchorRender;
195
202
  }
@@ -623,6 +630,10 @@ type TuiRenderCounterSnapshot = {
623
630
  debugRedrawAppendWrites: number;
624
631
  differentialGuardVisibleWidthCalls: number;
625
632
  };
633
+ type RenderCommitWaiter = {
634
+ resolve: (committed: boolean) => void;
635
+ timer: NodeJS.Timeout;
636
+ };
626
637
 
627
638
  /**
628
639
  * TUI - Main class for managing terminal UI with differential rendering
@@ -651,7 +662,28 @@ export class TUI extends Container {
651
662
  /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
652
663
  onDebug?: () => void;
653
664
  #renderRequested = false;
665
+ #nextRenderGeneration = 0;
666
+ #renderRequestedGeneration = 0;
667
+ #committedRenderGeneration = 0;
668
+ #renderCommitWaiters = new Map<number, Set<RenderCommitWaiter>>();
669
+ #lastRenderWriteSucceeded = false;
654
670
  #renderTimer: NodeJS.Timeout | undefined;
671
+ #widthSettleTimer: NodeJS.Timeout | undefined;
672
+ #widthSettleRepairPending = false;
673
+ #lastObservedWidth = 0;
674
+ // Trailing debounce for the settled width repair. Instance-local: taken from
675
+ // options.widthSettleMs when provided (deterministic harnesses pass 0 to
676
+ // disable), otherwise from GJC_TUI_WIDTH_SETTLE_MS / PI_TUI_WIDTH_SETTLE_MS,
677
+ // otherwise 1000. Sampled once at construction.
678
+ #widthSettleMs: number = TUI.#readWidthSettleMs();
679
+ static readonly #WIDTH_SETTLE_MS = 1000;
680
+
681
+ static #readWidthSettleMs(): number {
682
+ const raw = Bun.env.GJC_TUI_WIDTH_SETTLE_MS ?? Bun.env.PI_TUI_WIDTH_SETTLE_MS;
683
+ if (raw === undefined || raw === "") return TUI.#WIDTH_SETTLE_MS;
684
+ const parsed = Number.parseInt(raw, 10);
685
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : TUI.#WIDTH_SETTLE_MS;
686
+ }
655
687
  #lastRenderAt = 0;
656
688
  static readonly #MIN_RENDER_INTERVAL_MS = 16;
657
689
  // Input-priority scheduling: an input keystroke must never be starved behind a
@@ -662,6 +694,9 @@ export class TUI extends Container {
662
694
  #cursorRow = 0; // Logical cursor row (end of rendered content)
663
695
  #hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
664
696
  #viewportTopRow = 0; // Content row currently mapped to screen row 0
697
+ #scrollbackResumeViewportTop: number | undefined; // Reflowed history below this frontier is already committed
698
+ #nativeScrollbackViewportTop = 0;
699
+ #transcriptIdentityResetPending = false;
665
700
  #manualViewportTop: number | undefined;
666
701
  #viewportAnchorComponent: Component | null = null;
667
702
  #viewportAnchorFrame: ViewportAnchorFrame | null = null;
@@ -694,6 +729,12 @@ export class TUI extends Container {
694
729
  #mouseSelectionStart: MouseSelectionPoint | null = null;
695
730
  #mouseSelectionEnd: MouseSelectionPoint | null = null;
696
731
  #mouseSelectionDragged = false;
732
+ #viewportOutputSource: ViewportOutputSource | null = null;
733
+ #manualOutputNotice = false;
734
+ #manualTranscriptLineCount = 0;
735
+ #manualSuffixLineCount = 0;
736
+ #committedTranscriptRows: Array<number | null> = [];
737
+ #paintedManualOutputNotice = false;
697
738
 
698
739
  #unsubscribeTabWidthChange?: () => void;
699
740
  static #renderCounters: TuiRenderCounterSnapshot = {
@@ -746,6 +787,13 @@ export class TUI extends Container {
746
787
  private readonly options: {
747
788
  enableMouse?: boolean;
748
789
  copySelection?: (text: string) => void | Promise<void>;
790
+ /**
791
+ * Trailing debounce for the settled width repair, in ms. `0` disables the
792
+ * settled repair (deterministic harnesses need this — a wall-clock-timed
793
+ * full replay lands at nondeterministic logical positions). Defaults to
794
+ * `GJC_TUI_WIDTH_SETTLE_MS` / `PI_TUI_WIDTH_SETTLE_MS`, then 1000.
795
+ */
796
+ widthSettleMs?: number;
749
797
  } = {},
750
798
  ) {
751
799
  super();
@@ -753,6 +801,9 @@ export class TUI extends Container {
753
801
  if (showHardwareCursor !== undefined) {
754
802
  this.#showHardwareCursor = showHardwareCursor;
755
803
  }
804
+ if (options.widthSettleMs !== undefined && Number.isFinite(options.widthSettleMs) && options.widthSettleMs >= 0) {
805
+ this.#widthSettleMs = options.widthSettleMs;
806
+ }
756
807
  this.#imeCursorActive = !this.#showHardwareCursor && this.#useImeBlockCursor;
757
808
  this.#unsubscribeTabWidthChange = onDefaultTabWidthChange(() => {
758
809
  this.#lineTruncationCache.clear();
@@ -813,11 +864,58 @@ export class TUI extends Container {
813
864
  }
814
865
  }
815
866
 
867
+ override removeChild(component: Component): void {
868
+ this.#invalidateFocusForRemovedTree(component);
869
+ super.removeChild(component);
870
+ }
871
+
872
+ override clear(): void {
873
+ for (const child of this.children) this.#invalidateFocusForRemovedTree(child);
874
+ super.clear();
875
+ }
876
+
877
+ #invalidateFocusForRemovedTree(component: Component): void {
878
+ if (this.#focusedComponent !== null && this.#containsComponent(component, this.#focusedComponent)) {
879
+ this.setFocus(null);
880
+ }
881
+ }
882
+
883
+ #containsComponent(root: Component, target: Component): boolean {
884
+ if (root === target) return true;
885
+ return root instanceof Container && root.children.some(child => this.#containsComponent(child, target));
886
+ }
887
+
816
888
  setBottomPinnedComponent(component: Component | null): void {
817
889
  this.#bottomPinnedComponent = component;
818
890
  this.requestRender();
819
891
  }
820
892
 
893
+ /** Report the logical output producer revision without coupling TUI to message types. */
894
+ setViewportOutputSource(source: ViewportOutputSource | null): void {
895
+ const previous = this.#viewportOutputSource;
896
+ if (
897
+ (source === null && previous === null) ||
898
+ (source !== null &&
899
+ previous !== null &&
900
+ source.identity === previous.identity &&
901
+ source.revision === previous.revision)
902
+ ) {
903
+ renderMetrics.recordStructuralCounter("viewportOutputSourceEqualNoops");
904
+ return;
905
+ }
906
+ const identityReset = source === null || previous === null || previous.identity !== source.identity;
907
+ // Same-identity revisions are a high-water mark. A delayed stale observation
908
+ // must not lower it, because observing that revision again would otherwise
909
+ // look like fresh output while the user owns the manual viewport.
910
+ if (!identityReset && source.revision < previous.revision) return;
911
+ if (!identityReset && source.revision > previous.revision && this.#manualViewportTop !== undefined) {
912
+ this.#manualOutputNotice = true;
913
+ }
914
+ if (identityReset || this.#manualViewportTop === undefined) this.#manualOutputNotice = false;
915
+ this.#viewportOutputSource = source;
916
+ this.requestRender();
917
+ }
918
+
821
919
  /** Register the direct child whose rows are eligible for semantic viewport anchoring. */
822
920
  setViewportAnchorComponent(component: Component | null): void {
823
921
  if (component !== null && !isViewportAnchorProvider(component)) {
@@ -835,6 +933,21 @@ export class TUI extends Container {
835
933
  this.#manualViewportFallbackAnchors = [];
836
934
  this.#reconcileMissingViewportAnchor = false;
837
935
  this.#viewportAnchorFrame = null;
936
+ this.#scrollbackResumeViewportTop = undefined;
937
+ this.#nativeScrollbackViewportTop = 0;
938
+ this.#transcriptIdentityResetPending = true;
939
+ this.#manualOutputNotice = false;
940
+ this.#paintedManualOutputNotice = false;
941
+ this.#committedTranscriptRows = [];
942
+ // The old transcript identity is being replaced wholesale, which supersedes
943
+ // any stale old-width artifact a deferred settle repair would have fixed.
944
+ // Cancel both the armed timer and a pending deferred repair so an unrelated
945
+ // later render cannot trigger an out-of-window full clear+replay.
946
+ if (this.#widthSettleTimer) {
947
+ clearTimeout(this.#widthSettleTimer);
948
+ this.#widthSettleTimer = undefined;
949
+ }
950
+ this.#widthSettleRepairPending = false;
838
951
  }
839
952
 
840
953
  /** Allow one semantic-neighbor reconciliation after a definitive same-transcript rebuild. */
@@ -847,22 +960,36 @@ export class TUI extends Container {
847
960
  const height = this.terminal.rows;
848
961
  const width = this.terminal.columns;
849
962
  const frame = this.#viewportAnchorFrame;
850
- if (height <= 0 || width <= 0 || this.#previousLines.length === 0 || frame === null) return false;
963
+ const transcriptCapacity = this.#manualTranscriptCapacity(height);
964
+ if (height <= 0 || width <= 0 || transcriptCapacity === 0 || this.#previousLines.length === 0 || frame === null)
965
+ return false;
851
966
 
852
- const selectedRow = frame.anchors.findIndex(anchor => anchor?.id === id);
967
+ let selectedRow = frame.anchors.findIndex(anchor => anchor?.id === id);
968
+ if (alignment === "bottom") {
969
+ for (let row = frame.anchors.length - 1; row >= 0; row--) {
970
+ if (frame.anchors[row]?.id === id) {
971
+ selectedRow = row;
972
+ break;
973
+ }
974
+ }
975
+ }
853
976
  const selected = selectedRow < 0 ? null : frame.anchors[selectedRow];
854
977
  if (selected === null) return false;
855
978
 
856
- const desiredScreenRow = alignment === "top" ? 0 : alignment === "center" ? Math.floor(height / 2) : height - 1;
979
+ const desiredScreenRow =
980
+ alignment === "top" ? 0 : alignment === "center" ? Math.floor(transcriptCapacity / 2) : transcriptCapacity - 1;
857
981
  const targetViewportTop = Math.max(0, frame.startRow + selectedRow - desiredScreenRow);
858
982
  this.#manualViewportAnchor = {
859
983
  id: selected.id,
860
- graphemeIndex: selected.graphemeStart,
861
- cellOffset: selected.cellStart,
984
+ graphemeIndex:
985
+ alignment === "bottom"
986
+ ? Math.max(selected.graphemeStart, selected.graphemeEnd - 1)
987
+ : selected.graphemeStart,
988
+ cellOffset: alignment === "bottom" ? Math.max(selected.cellStart, selected.cellEnd - 1) : selected.cellStart,
862
989
  desiredScreenRow,
863
990
  };
864
991
  const firstCandidateRow = Math.max(0, targetViewportTop - frame.startRow);
865
- const lastCandidateRow = Math.min(frame.anchors.length, targetViewportTop + height - frame.startRow);
992
+ const lastCandidateRow = Math.min(frame.anchors.length, targetViewportTop + transcriptCapacity - frame.startRow);
866
993
  const fallbacks: ManualViewportAnchor[] = [];
867
994
  for (let row = firstCandidateRow; row < lastCandidateRow; row++) {
868
995
  const anchor = frame.anchors[row];
@@ -886,11 +1013,24 @@ export class TUI extends Container {
886
1013
  return true;
887
1014
  }
888
1015
 
889
- scrollViewportPages(direction: -1 | 1): boolean {
1016
+ scrollViewportBy(
1017
+ deltaRows: number,
1018
+ options?: {
1019
+ /** edge: PageUp/PageDown pin; stable: preserve/center pin for fine wheel motion */
1020
+ pin?: "edge" | "stable";
1021
+ },
1022
+ ): boolean {
890
1023
  const height = this.terminal.rows;
891
1024
  const width = this.terminal.columns;
892
1025
  if (height <= 0 || width <= 0 || this.#previousLines.length === 0) return false;
893
- const maxViewportTop = Math.max(0, this.#previousLines.length - height);
1026
+ if (!Number.isFinite(deltaRows)) return false;
1027
+ const delta = Math.trunc(deltaRows);
1028
+ if (delta === 0) return false;
1029
+
1030
+ const direction: -1 | 1 = delta < 0 ? -1 : 1;
1031
+ const pin = options?.pin ?? "stable";
1032
+ const transcriptCapacity = this.#manualTranscriptCapacity(height);
1033
+ const maxViewportTop = Math.max(0, this.#manualTranscriptLineCount - transcriptCapacity);
894
1034
  let currentViewportTop = Math.max(0, Math.min(maxViewportTop, this.#manualViewportTop ?? this.#viewportTopRow));
895
1035
  const frame = this.#viewportAnchorFrame;
896
1036
  if (this.#manualViewportAnchor !== null) {
@@ -899,16 +1039,29 @@ export class TUI extends Container {
899
1039
  if (resolvedViewportTop === null) return false;
900
1040
  currentViewportTop = Math.max(0, Math.min(maxViewportTop, resolvedViewportTop));
901
1041
  }
902
- const targetViewportTop = Math.max(
903
- 0,
904
- Math.min(maxViewportTop, currentViewportTop + direction * Math.max(1, height - 1)),
905
- );
1042
+ const targetViewportTop = Math.max(0, Math.min(maxViewportTop, currentViewportTop + delta));
1043
+ // Downward input at an already-live bottom is a no-op; it must not silently
1044
+ // acquire manual ownership and freeze the next semantic output. Manual owners
1045
+ // that reach the same boundary transition through the existing live transaction.
1046
+ if (direction > 0 && targetViewportTop === maxViewportTop) {
1047
+ if (this.#manualViewportTop === undefined && currentViewportTop === maxViewportTop) return true;
1048
+ if (this.#manualViewportTop !== undefined) return this.followLiveViewport();
1049
+ }
906
1050
  if (frame !== null) {
907
- const desiredScreenRow = this.#manualViewportAnchor?.desiredScreenRow ?? (direction < 0 ? 0 : height - 1);
1051
+ const desiredScreenRow =
1052
+ this.#manualViewportAnchor?.desiredScreenRow ??
1053
+ (pin === "edge"
1054
+ ? direction < 0
1055
+ ? 0
1056
+ : Math.max(0, transcriptCapacity - 1)
1057
+ : Math.floor(transcriptCapacity / 2));
908
1058
  const targetRow = targetViewportTop + desiredScreenRow - frame.startRow;
909
1059
  let selected: { row: number; anchor: ViewportAnchorRow } | undefined;
910
1060
  const firstCandidateRow = Math.max(0, targetViewportTop - frame.startRow);
911
- const lastCandidateRow = Math.min(frame.anchors.length, targetViewportTop + height - frame.startRow);
1061
+ const lastCandidateRow = Math.min(
1062
+ frame.anchors.length,
1063
+ targetViewportTop + transcriptCapacity - frame.startRow,
1064
+ );
912
1065
  for (let row = firstCandidateRow; row < lastCandidateRow; row++) {
913
1066
  const anchor = frame.anchors[row];
914
1067
  if (anchor === null) continue;
@@ -974,26 +1127,63 @@ export class TUI extends Container {
974
1127
  );
975
1128
  }
976
1129
 
1130
+ scrollViewportPages(direction: -1 | 1): boolean {
1131
+ const height = this.terminal.rows;
1132
+ return this.scrollViewportBy(direction * Math.max(1, this.#manualTranscriptCapacity(height) - 1), {
1133
+ pin: "edge",
1134
+ });
1135
+ }
1136
+
977
1137
  followLiveViewport(): boolean {
978
1138
  if (this.#manualViewportTop === undefined) return false;
979
1139
  const height = this.terminal.rows;
980
1140
  const width = this.terminal.columns;
981
- const liveLines = this.#latestRenderedLines;
1141
+ const paddedLiveLines = this.#padBeforeBottomPinnedComponent(
1142
+ this.#latestRenderedLines,
1143
+ height,
1144
+ this.#manualSuffixLineCount,
1145
+ );
1146
+ const liveLines = paddedLiveLines.lines;
1147
+ let liveCursorPosition = this.#lastCursorPosition;
1148
+ if (liveCursorPosition !== null && liveCursorPosition.row >= paddedLiveLines.insertionRow) {
1149
+ liveCursorPosition = {
1150
+ ...liveCursorPosition,
1151
+ row: liveCursorPosition.row + paddedLiveLines.insertedBlankRows,
1152
+ };
1153
+ }
982
1154
  const liveViewportTop = Math.max(0, liveLines.length - height);
983
- this.#manualViewportTop = undefined;
984
- this.#manualViewportAnchor = null;
985
- this.#manualViewportFallbackAnchors = [];
986
- this.#reconcileMissingViewportAnchor = false;
987
- const repainted = this.#repaintViewportFromLines(
1155
+ return this.#repaintViewportFromLines(
988
1156
  liveLines,
989
1157
  width,
990
1158
  height,
991
1159
  liveViewportTop,
992
- this.#lastCursorPosition,
1160
+ liveCursorPosition,
993
1161
  "manual viewport follow live",
1162
+ false,
1163
+ () => {
1164
+ this.#manualViewportTop = undefined;
1165
+ this.#manualViewportAnchor = null;
1166
+ this.#manualViewportFallbackAnchors = [];
1167
+ this.#reconcileMissingViewportAnchor = false;
1168
+ this.#manualOutputNotice = false;
1169
+ this.#committedTranscriptRows = [];
1170
+ this.#paintedManualOutputNotice = false;
1171
+ this.#lastCursorPosition = liveCursorPosition;
1172
+ this.#previousLines = liveLines;
1173
+ if (this.#scrollbackResumeViewportTop === undefined) {
1174
+ this.#nativeScrollbackViewportTop = liveViewportTop;
1175
+ }
1176
+ // A settled width repair was deferred while the user was reading
1177
+ // scrollback (repainting mid-read would have destroyed their
1178
+ // position). The transactional live repaint above has committed, so
1179
+ // manual state is safely released — now schedule the deferred full
1180
+ // clear+replay that repairs old-width wrapping in history.
1181
+ if (this.#widthSettleRepairPending) {
1182
+ this.requestRender(true, "resize.width-settled.deferred");
1183
+ }
1184
+ },
1185
+ true,
994
1186
  );
995
- if (repainted) this.#previousLines = liveLines;
996
- return repainted;
997
1187
  }
998
1188
 
999
1189
  /**
@@ -1097,6 +1287,9 @@ export class TUI extends Container {
1097
1287
  start(): void {
1098
1288
  this.#stopped = false;
1099
1289
  this.#terminalUnavailable = false;
1290
+ // Seed the observed width so a spurious post-start resize event (iTerm2 tab
1291
+ // activation, the self-sent SIGWINCH after resume) is not read as a reflow.
1292
+ this.#lastObservedWidth = this.terminal.columns;
1100
1293
  this.terminal.setMouseEnabled?.(this.options.enableMouse === true);
1101
1294
  this.terminal.start(
1102
1295
  data => this.#handleInput(data),
@@ -1112,6 +1305,57 @@ export class TUI extends Container {
1112
1305
  this.requestRender(true);
1113
1306
  }
1114
1307
 
1308
+ /**
1309
+ * Wait for a specific render request generation to be written successfully.
1310
+ *
1311
+ * Render requests are coalesced, so committing a newer generation also commits
1312
+ * every older generation represented by that frame. A stopped or unavailable
1313
+ * terminal resolves waiters false so UI callers can fail open instead of
1314
+ * holding a session operation behind a dead renderer.
1315
+ */
1316
+ waitForRenderCommit(generation: number, timeoutMs = 250): Promise<boolean> {
1317
+ if (generation <= 0 || generation <= this.#committedRenderGeneration) return Promise.resolve(true);
1318
+ if (this.#stopped || !this.terminalAvailable) return Promise.resolve(false);
1319
+ return new Promise<boolean>(resolve => {
1320
+ const waiter: RenderCommitWaiter = {
1321
+ resolve,
1322
+ timer: setTimeout(
1323
+ () => {
1324
+ const waiters = this.#renderCommitWaiters.get(generation);
1325
+ if (waiters) {
1326
+ waiters.delete(waiter);
1327
+ if (waiters.size === 0) this.#renderCommitWaiters.delete(generation);
1328
+ }
1329
+ resolve(false);
1330
+ },
1331
+ Math.max(0, timeoutMs),
1332
+ ),
1333
+ };
1334
+ waiter.timer.unref?.();
1335
+ const waiters = this.#renderCommitWaiters.get(generation) ?? new Set();
1336
+ waiters.add(waiter);
1337
+ this.#renderCommitWaiters.set(generation, waiters);
1338
+ });
1339
+ }
1340
+
1341
+ #settleRenderCommitWaiters(committed: boolean, generation = Number.POSITIVE_INFINITY): void {
1342
+ if (committed) this.#committedRenderGeneration = Math.max(this.#committedRenderGeneration, generation);
1343
+ for (const [waiterGeneration, waiters] of this.#renderCommitWaiters) {
1344
+ if (committed && waiterGeneration > generation) continue;
1345
+ this.#renderCommitWaiters.delete(waiterGeneration);
1346
+ for (const waiter of waiters) {
1347
+ clearTimeout(waiter.timer);
1348
+ waiter.resolve(committed);
1349
+ }
1350
+ }
1351
+ }
1352
+
1353
+ #commitRenderGeneration(generation: number): void {
1354
+ if (generation <= 0) return;
1355
+ if (this.#lastRenderWriteSucceeded) this.#settleRenderCommitWaiters(true, generation);
1356
+ else if (this.#stopped || !this.terminalAvailable) this.#settleRenderCommitWaiters(false, generation);
1357
+ }
1358
+
1115
1359
  get terminalAvailable(): boolean {
1116
1360
  return !this.#terminalUnavailable && this.terminal.available;
1117
1361
  }
@@ -1120,6 +1364,7 @@ export class TUI extends Container {
1120
1364
  this.#terminalUnavailable = true;
1121
1365
  this.#stopped = true;
1122
1366
  this.#renderRequested = false;
1367
+ this.#settleRenderCommitWaiters(false);
1123
1368
  if (this.#renderTimer) {
1124
1369
  clearTimeout(this.#renderTimer);
1125
1370
  this.#renderTimer = undefined;
@@ -1318,11 +1563,25 @@ export class TUI extends Container {
1318
1563
  this.flushTerminalCleanup();
1319
1564
  this.#clearSixelProbeState();
1320
1565
  this.#stopped = true;
1566
+ this.#settleRenderCommitWaiters(false);
1321
1567
  if (this.#renderTimer) {
1322
1568
  clearTimeout(this.#renderTimer);
1323
1569
  this.#renderTimer = undefined;
1324
1570
  if (renderMetrics.enabled) renderMetrics.setTimerGauge("tui.renderTimer", 0);
1325
1571
  }
1572
+ if (this.#widthSettleTimer) {
1573
+ clearTimeout(this.#widthSettleTimer);
1574
+ this.#widthSettleTimer = undefined;
1575
+ }
1576
+ // An armed TIMER dies with the session, but an already-deferred repair
1577
+ // (deadline passed while the user was reading scrollback) must survive a
1578
+ // temporary stop/start (Ctrl-Z resume, external editor): manual viewport
1579
+ // ownership survives restart, so followLiveViewport() still needs the flag
1580
+ // to run the deferred repair. Without manual ownership the flag is moot —
1581
+ // start() issues a forced full render that repairs everything anyway.
1582
+ if (this.#manualViewportTop === undefined) {
1583
+ this.#widthSettleRepairPending = false;
1584
+ }
1326
1585
  // Move cursor to the end of the content to prevent overwriting/artifacts on exit
1327
1586
  if (this.#previousLines.length > 0) {
1328
1587
  const targetRow = this.#previousLines.length; // Line after the last content
@@ -1383,19 +1642,81 @@ export class TUI extends Container {
1383
1642
  * is a no-op otherwise.
1384
1643
  */
1385
1644
  requestResizeRender(): void {
1386
- const dimensionsChanged =
1387
- this.#previousWidth !== this.terminal.columns || this.#previousHeight !== this.terminal.rows;
1388
- this.requestRender(dimensionsChanged && !useViewportRepaintPath(this.terminal), "resize");
1645
+ // Width is tracked against the last OBSERVED terminal width, not against
1646
+ // #previousWidth (the last committed frame). Those diverge whenever resize
1647
+ // events coalesce inside one frame budget: a 100->90->100 burst would leave
1648
+ // #previousWidth at 100 the whole time, so a commit-keyed debounce would
1649
+ // never see the second transition and could skip the only repair frame.
1650
+ const observedWidth = this.terminal.columns;
1651
+ const widthChanged = observedWidth !== this.#lastObservedWidth;
1652
+ this.#lastObservedWidth = observedWidth;
1653
+ const heightChanged = this.#previousHeight !== this.terminal.rows;
1654
+ if (widthChanged) this.#scheduleWidthSettleRedraw();
1655
+ this.requestRender(heightChanged && !useViewportRepaintPath(this.terminal), "resize");
1656
+ }
1657
+
1658
+ /**
1659
+ * Width reflow leaves artifacts that the immediate resize frame does not always
1660
+ * repair: lines wrapped at the old column count can survive as stale bands — in
1661
+ * the live viewport and in scrollback history. The immediate frame is unchanged
1662
+ * by this timer — `#doRender` still promotes a real width change to
1663
+ * `fullRender`/`viewportRepaint` on the spot. What this adds is a single
1664
+ * trailing repair #WIDTH_SETTLE_MS after the last observed width change.
1665
+ *
1666
+ * The settled repair is a FULL transcript replay on every host, including
1667
+ * viewport-repaint hosts (tmux/screen/zellij, Windows Terminal, process
1668
+ * terminals) where per-SIGWINCH forced redraws are normally suppressed. That
1669
+ * per-event replay is the storm `resize-replay-storm.test.ts` pins against;
1670
+ * the debounce is what makes the full replay safe here — it happens once per
1671
+ * settled width sequence, so scrollback artifacts are repaired without
1672
+ * replaying the transcript on every resize event.
1673
+ *
1674
+ * Every observed width sequence gets exactly one repair, including one that
1675
+ * ends back at its starting width. Skipping the drag-and-return case would
1676
+ * require proving that a frame committed at the final geometry *after* the
1677
+ * final resize event, which the render pipeline does not guarantee; one extra
1678
+ * repaint is cheaper than a missed repair.
1679
+ *
1680
+ * Height-only changes are unaffected: they reflow nothing and keep their
1681
+ * existing behavior.
1682
+ */
1683
+ #scheduleWidthSettleRedraw(): void {
1684
+ if (this.#widthSettleMs <= 0) return;
1685
+ if (this.#widthSettleTimer) clearTimeout(this.#widthSettleTimer);
1686
+ this.#widthSettleTimer = setTimeout(() => {
1687
+ this.#widthSettleTimer = undefined;
1688
+ if (this.#stopped) return;
1689
+ this.#widthSettleRepairPending = true;
1690
+ // While the user is reading scrollback (manual viewport), a forced
1691
+ // clear+replay would rip them out of history mid-read. Keep the flag
1692
+ // armed instead; followLiveViewport() runs the deferred repair the
1693
+ // moment they return to live.
1694
+ if (this.#manualViewportTop !== undefined) return;
1695
+ this.requestRender(true, "resize.width-settled");
1696
+ }, this.#widthSettleMs);
1697
+ this.#widthSettleTimer.unref?.();
1389
1698
  }
1390
1699
 
1391
1700
  requestRender(force = false, source = "unknown"): void {
1701
+ this.requestRenderWithGeneration(force, source);
1702
+ }
1703
+
1704
+ requestRenderWithGeneration(force = false, source = "unknown"): number {
1705
+ const generation = ++this.#nextRenderGeneration;
1706
+ this.#renderRequestedGeneration = Math.max(this.#renderRequestedGeneration, generation);
1707
+ this.#requestRenderCore(force, source, generation);
1708
+ return generation;
1709
+ }
1710
+
1711
+ #requestRenderCore(force: boolean, source: string, generation: number): void {
1392
1712
  if (!this.terminalAvailable) {
1393
1713
  this.#markTerminalUnavailable();
1394
1714
  return;
1395
1715
  }
1396
1716
  if (renderMetrics.enabled) renderMetrics.recordRequest(source);
1397
1717
  if (force) {
1398
- const preserveViewportCursor = useViewportRepaintPath(this.terminal);
1718
+ const preserveViewportCursor =
1719
+ useViewportRepaintPath(this.terminal) || shouldPreserveScrollbackOnFullClear(this.terminal);
1399
1720
  // A forced full redraw supersedes any queued input-priority render.
1400
1721
  this.#inputRenderPending = false;
1401
1722
  this.#previousLines = [];
@@ -1422,12 +1743,17 @@ export class TUI extends Container {
1422
1743
  this.#renderRequested = true;
1423
1744
  process.nextTick(() => {
1424
1745
  if (this.#stopped || !this.#renderRequested) {
1746
+ this.#settleRenderCommitWaiters(false, generation);
1425
1747
  return;
1426
1748
  }
1749
+ const requestedGeneration = this.#renderRequestedGeneration;
1750
+ this.#renderRequestedGeneration = 0;
1427
1751
  this.#renderRequested = false;
1428
1752
  this.#lastRenderAt = performance.now();
1753
+ this.#lastRenderWriteSucceeded = false;
1429
1754
  const t0 = renderMetrics.now();
1430
1755
  this.#doRender();
1756
+ this.#commitRenderGeneration(requestedGeneration);
1431
1757
  if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
1432
1758
  });
1433
1759
  return;
@@ -1448,6 +1774,7 @@ export class TUI extends Container {
1448
1774
  if (this.#renderRequested) return;
1449
1775
  this.#renderRequested = true;
1450
1776
  process.nextTick(() => this.#scheduleRender());
1777
+ return;
1451
1778
  }
1452
1779
 
1453
1780
  #scheduleRender(): void {
@@ -1462,10 +1789,14 @@ export class TUI extends Container {
1462
1789
  if (this.#stopped || !this.#renderRequested) {
1463
1790
  return;
1464
1791
  }
1792
+ const requestedGeneration = this.#renderRequestedGeneration;
1793
+ this.#renderRequestedGeneration = 0;
1465
1794
  this.#renderRequested = false;
1466
1795
  this.#lastRenderAt = performance.now();
1796
+ this.#lastRenderWriteSucceeded = false;
1467
1797
  const t0 = renderMetrics.now();
1468
1798
  this.#doRender();
1799
+ this.#commitRenderGeneration(requestedGeneration);
1469
1800
  if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
1470
1801
  if (this.#renderRequested) {
1471
1802
  this.#scheduleRender();
@@ -1488,10 +1819,14 @@ export class TUI extends Container {
1488
1819
  this.#renderTimer = undefined;
1489
1820
  if (renderMetrics.enabled) renderMetrics.setTimerGauge("tui.renderTimer", 0);
1490
1821
  }
1822
+ const requestedGeneration = this.#renderRequestedGeneration;
1823
+ this.#renderRequestedGeneration = 0;
1491
1824
  this.#renderRequested = false;
1492
1825
  this.#lastRenderAt = performance.now();
1826
+ this.#lastRenderWriteSucceeded = false;
1493
1827
  const t0 = renderMetrics.now();
1494
1828
  this.#doRender();
1829
+ this.#commitRenderGeneration(requestedGeneration);
1495
1830
  if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
1496
1831
  }
1497
1832
 
@@ -1519,7 +1854,7 @@ export class TUI extends Container {
1519
1854
  if (mouse.x > this.terminal.columns || mouse.y > this.terminal.rows) return;
1520
1855
  if (mouse.kind === "wheel") {
1521
1856
  this.#clearMouseSelection();
1522
- this.scrollViewportPages(mouse.direction!);
1857
+ this.scrollViewportBy(mouse.direction! * DEFAULT_WHEEL_LINES, { pin: "stable" });
1523
1858
  } else if (mouse.kind === "click") {
1524
1859
  this.#beginMouseSelection(mouse);
1525
1860
  const focusedOverlay = this.overlayStack.find(o => o.component === this.#focusedComponent);
@@ -1595,16 +1930,23 @@ export class TUI extends Container {
1595
1930
  }
1596
1931
  }
1597
1932
 
1598
- #mouseSelectionPoint(mouse: MouseEvent): MouseSelectionPoint {
1599
- return {
1600
- line: this.#viewportTopRow + mouse.y - 1,
1601
- column: mouse.x - 1,
1602
- };
1933
+ #mouseSelectionPoint(mouse: MouseEvent): MouseSelectionPoint | null {
1934
+ if (this.#manualViewportTop === undefined) {
1935
+ return { line: this.#viewportTopRow + mouse.y - 1, column: mouse.x - 1 };
1936
+ }
1937
+ const line = this.#committedTranscriptRows[mouse.y - 1];
1938
+ return line === null || line === undefined || line < 0 || line >= this.#manualTranscriptLineCount
1939
+ ? null
1940
+ : { line, column: mouse.x - 1 };
1603
1941
  }
1604
1942
 
1605
1943
  #beginMouseSelection(mouse: MouseEvent): void {
1606
1944
  if (!this.options.copySelection) return;
1607
1945
  const point = this.#mouseSelectionPoint(mouse);
1946
+ if (point === null) {
1947
+ this.#clearMouseSelection();
1948
+ return;
1949
+ }
1608
1950
  this.#mouseSelectionStart = point;
1609
1951
  this.#mouseSelectionEnd = point;
1610
1952
  this.#mouseSelectionDragged = false;
@@ -1612,13 +1954,16 @@ export class TUI extends Container {
1612
1954
 
1613
1955
  #updateMouseSelection(mouse: MouseEvent): void {
1614
1956
  if (this.#mouseSelectionStart === null) return;
1615
- this.#mouseSelectionEnd = this.#mouseSelectionPoint(mouse);
1957
+ const point = this.#mouseSelectionPoint(mouse);
1958
+ if (point === null) return;
1959
+ this.#mouseSelectionEnd = point;
1616
1960
  this.#mouseSelectionDragged = true;
1617
1961
  }
1618
1962
 
1619
1963
  #finishMouseSelection(mouse: MouseEvent): void {
1620
1964
  if (this.#mouseSelectionStart === null) return;
1621
- this.#mouseSelectionEnd = this.#mouseSelectionPoint(mouse);
1965
+ const point = this.#mouseSelectionPoint(mouse);
1966
+ if (point !== null) this.#mouseSelectionEnd = point;
1622
1967
  if (!this.#mouseSelectionDragged || !this.options.copySelection) {
1623
1968
  this.#clearMouseSelection();
1624
1969
  return;
@@ -2150,33 +2495,108 @@ export class TUI extends Container {
2150
2495
  return lines;
2151
2496
  }
2152
2497
 
2153
- #padBeforeBottomPinnedComponent(
2154
- lines: string[],
2155
- height: number,
2156
- renderedChildren: Map<Component, string[]>,
2157
- ): string[] {
2158
- const component = this.#bottomPinnedComponent;
2159
- if (component === null || lines.length >= height) return lines;
2498
+ #pinnedChildLines(component: Component, renderedChildren: Map<Component, string[]>): string[] {
2499
+ const lines = renderedChildren.get(component);
2500
+ if (lines === undefined) throw new Error("Missing rendered direct child for pinned suffix");
2501
+ return lines;
2502
+ }
2160
2503
 
2161
- let pinnedStart = -1;
2162
- for (let i = this.children.length - 1; i >= 0; i--) {
2163
- if (this.children[i] === component) {
2164
- pinnedStart = i;
2504
+ #constrainedPinnedChildLines(lines: string[], remaining: number): string[] {
2505
+ let cursorLine = -1;
2506
+ for (let index = 0; index < lines.length; index++) {
2507
+ if (lines[index].includes(CURSOR_MARKER)) {
2508
+ cursorLine = index;
2165
2509
  break;
2166
2510
  }
2167
2511
  }
2512
+ if (cursorLine < 0) return lines.slice(-remaining);
2513
+ const start = Math.max(0, Math.min(cursorLine, lines.length - remaining));
2514
+ return lines.slice(start, start + remaining);
2515
+ }
2516
+
2517
+ #componentContains(root: Component, target: Component | null): boolean {
2518
+ if (target === null) return false;
2519
+ if (root === target) return true;
2520
+ return root instanceof Container && root.children.some(child => this.#componentContains(child, target));
2521
+ }
2522
+
2523
+ #constrainPinnedSuffix(lines: string[], height: number, renderedChildren: Map<Component, string[]>): string[] {
2524
+ const component = this.#bottomPinnedComponent;
2525
+ if (component === null || height <= 0) return lines;
2526
+ const pinnedStart = this.children.indexOf(component);
2168
2527
  if (pinnedStart < 0) return lines;
2169
2528
 
2170
- let pinnedLineCount = 0;
2171
- for (let i = pinnedStart; i < this.children.length; i++) {
2172
- pinnedLineCount += (renderedChildren.get(this.children[i]) ?? []).length;
2529
+ let suffixRowCount = 0;
2530
+ for (let index = pinnedStart; index < this.children.length; index++) {
2531
+ suffixRowCount += this.#pinnedChildLines(this.children[index], renderedChildren).length;
2532
+ }
2533
+ if (suffixRowCount <= height) return lines;
2534
+
2535
+ renderMetrics.recordStructuralCounter("pinnedSuffixOverflowFrames");
2536
+ let focusedChild: Component | null = null;
2537
+ for (let index = pinnedStart; index < this.children.length; index++) {
2538
+ const child = this.children[index];
2539
+ if (this.#componentContains(child, this.#focusedComponent)) {
2540
+ focusedChild = child;
2541
+ break;
2542
+ }
2543
+ }
2544
+ const selectedRowCounts = new Map<Component, number>();
2545
+ let remaining = height;
2546
+ const allocate = (child: Component, maximumRows?: number): void => {
2547
+ if (remaining === 0) return;
2548
+ const rows = this.#pinnedChildLines(child, renderedChildren);
2549
+ const alreadySelected = selectedRowCounts.get(child) ?? 0;
2550
+ const count = Math.min(rows.length - alreadySelected, maximumRows ?? rows.length, remaining);
2551
+ if (count === 0) return;
2552
+ selectedRowCounts.set(child, alreadySelected + count);
2553
+ remaining -= count;
2554
+ };
2555
+
2556
+ // Reserve the focused cursor row before the status boundary, then let later
2557
+ // decorative children compete in reverse order. A deferred allocation lets the
2558
+ // focused child retain adjacent rows only after those priorities are satisfied.
2559
+ if (focusedChild !== null) allocate(focusedChild, 1);
2560
+ if (component !== focusedChild) allocate(component);
2561
+ for (let index = this.children.length - 1; index >= pinnedStart; index--) {
2562
+ const child = this.children[index];
2563
+ if (child !== focusedChild && child !== component) allocate(child);
2564
+ }
2565
+ if (focusedChild !== null) allocate(focusedChild);
2566
+
2567
+ const transcriptEnd = lines.length - suffixRowCount;
2568
+ lines.length = transcriptEnd;
2569
+ let selectedRows = 0;
2570
+ for (let index = pinnedStart; index < this.children.length; index++) {
2571
+ const child = this.children[index];
2572
+ const count = selectedRowCounts.get(child);
2573
+ if (count === undefined) continue;
2574
+ const constrained = this.#constrainedPinnedChildLines(this.#pinnedChildLines(child, renderedChildren), count);
2575
+ selectedRows += constrained.length;
2576
+ for (const row of constrained) lines.push(row);
2577
+ }
2578
+ renderMetrics.recordStructuralCounter("pinnedSuffixSelectedRows", selectedRows);
2579
+ return lines;
2580
+ }
2581
+
2582
+ #padBeforeBottomPinnedComponent(
2583
+ lines: string[],
2584
+ height: number,
2585
+ pinnedLineCount: number,
2586
+ ): { lines: string[]; insertionRow: number; insertedBlankRows: number } {
2587
+ if (pinnedLineCount <= 0 || lines.length >= height) {
2588
+ return { lines, insertionRow: lines.length, insertedBlankRows: 0 };
2173
2589
  }
2174
2590
 
2175
- const blankRows = height - lines.length;
2176
- const insertAt = Math.max(0, lines.length - pinnedLineCount);
2591
+ const insertedBlankRows = height - lines.length;
2592
+ const insertionRow = Math.max(0, lines.length - pinnedLineCount);
2177
2593
  const padded = [...lines];
2178
- padded.splice(insertAt, 0, ...Array.from({ length: blankRows }, () => ""));
2179
- return padded;
2594
+ padded.splice(insertionRow, 0, ...Array.from({ length: insertedBlankRows }, () => ""));
2595
+ return { lines: padded, insertionRow, insertedBlankRows };
2596
+ }
2597
+ #manualTranscriptCapacity(height: number): number {
2598
+ const noticeRows = this.#manualOutputNotice && height > this.#manualSuffixLineCount ? 1 : 0;
2599
+ return Math.max(0, height - this.#manualSuffixLineCount - noticeRows);
2180
2600
  }
2181
2601
  #resolveManualAnchor(frame: ViewportAnchorFrame): number | null {
2182
2602
  const anchor = this.#manualViewportAnchor;
@@ -2235,9 +2655,19 @@ export class TUI extends Container {
2235
2655
  cursorPos: { row: number; col: number } | null,
2236
2656
  reason: string,
2237
2657
  allowPastLiveBottom = false,
2658
+ onPainted?: () => void,
2659
+ paintLive = false,
2238
2660
  ): boolean {
2661
+ const paintManual = this.#manualViewportTop !== undefined && !paintLive;
2239
2662
  if (height <= 0 || width <= 0) return false;
2240
- const maxViewportTop = Math.max(0, lines.length - (allowPastLiveBottom ? 1 : height));
2663
+ const maxViewportTop = Math.max(
2664
+ 0,
2665
+ !paintManual
2666
+ ? lines.length - (allowPastLiveBottom ? 1 : height)
2667
+ : allowPastLiveBottom
2668
+ ? lines.length - 1
2669
+ : this.#manualTranscriptLineCount - this.#manualTranscriptCapacity(height),
2670
+ );
2241
2671
  const nextViewportTop = Math.max(0, Math.min(maxViewportTop, viewportTop));
2242
2672
  const currentScreenRow = Math.max(0, Math.min(height - 1, this.#hardwareCursorRow - this.#viewportTopRow));
2243
2673
  let buffer = "\x1b[?2026h";
@@ -2246,12 +2676,25 @@ export class TUI extends Container {
2246
2676
  }
2247
2677
  buffer += "\r";
2248
2678
 
2679
+ const transcriptCapacity = paintManual ? this.#manualTranscriptCapacity(height) : height;
2680
+ const noticeRows = paintManual && this.#manualOutputNotice && height > this.#manualSuffixLineCount ? 1 : 0;
2681
+ const committedTranscriptRows: Array<number | null> = [];
2249
2682
  for (let screenRow = 0; screenRow < height; screenRow++) {
2250
2683
  if (screenRow > 0) buffer += "\r\n";
2251
2684
  buffer += "\x1b[2K";
2252
2685
  const lineIndex = nextViewportTop + screenRow;
2253
- if (lineIndex >= lines.length) continue;
2254
- const line = lines[lineIndex];
2686
+ const suffixRow = screenRow - transcriptCapacity - noticeRows;
2687
+ const line =
2688
+ paintManual && screenRow === transcriptCapacity && noticeRows > 0
2689
+ ? "New output — type to follow"
2690
+ : paintManual && suffixRow >= 0
2691
+ ? (lines[this.#manualTranscriptLineCount + suffixRow] ?? "")
2692
+ : paintManual && lineIndex >= this.#manualTranscriptLineCount
2693
+ ? ""
2694
+ : (lines[lineIndex] ?? "");
2695
+ committedTranscriptRows.push(
2696
+ screenRow < transcriptCapacity && lineIndex < this.#manualTranscriptLineCount ? lineIndex : null,
2697
+ );
2255
2698
  const isImage = TERMINAL.isImageLine(line);
2256
2699
  if (!isImage && this.#visibleWidthForDifferentialGuard(line) > width) {
2257
2700
  let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
@@ -2270,19 +2713,24 @@ export class TUI extends Container {
2270
2713
  cursorSeq = cursor.seq;
2271
2714
  cursorToRow = cursor.toRow;
2272
2715
  }
2273
- this.#hardwareCursorRow = cursorToRow;
2274
2716
  buffer += cursorSeq;
2275
2717
  buffer += "\x1b[?2026l";
2276
- if (!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, lines.length)) return false;
2718
+ if (
2719
+ !this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, lines.length, () => {
2720
+ this.#hardwareCursorRow = cursorToRow;
2721
+ this.#committedTranscriptRows = committedTranscriptRows;
2722
+ this.#cursorRow = Math.max(0, lines.length - 1);
2723
+ this.#maxLinesRendered = lines.length;
2724
+ this.#viewportTopRow = nextViewportTop;
2725
+ onPainted?.();
2726
+ })
2727
+ )
2728
+ return false;
2277
2729
 
2278
2730
  if (this.#debugRedraw) {
2279
2731
  const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (lines=${lines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
2280
2732
  this.#appendDebugRedrawLog(msg);
2281
2733
  }
2282
-
2283
- this.#cursorRow = Math.max(0, lines.length - 1);
2284
- this.#maxLinesRendered = lines.length;
2285
- this.#viewportTopRow = nextViewportTop;
2286
2734
  return true;
2287
2735
  }
2288
2736
 
@@ -2313,13 +2761,30 @@ export class TUI extends Container {
2313
2761
  }
2314
2762
  for (const line of rendered.lines) renderedLines.push(line);
2315
2763
  }
2764
+ let pinnedChildIndex = -1;
2765
+ for (let index = 0; index < this.children.length; index++) {
2766
+ if (this.children[index] === this.#bottomPinnedComponent) {
2767
+ pinnedChildIndex = index;
2768
+ break;
2769
+ }
2770
+ }
2771
+ const hasStickySuffix = pinnedChildIndex >= 0;
2772
+ const sourceTranscriptLineCount = hasStickySuffix
2773
+ ? this.children
2774
+ .slice(0, pinnedChildIndex)
2775
+ .reduce((count, child) => count + this.#pinnedChildLines(child, renderedChildren).length, 0)
2776
+ : renderedLines.length;
2316
2777
  const anchorRenderFailed = viewportAnchorRenderFailureCount !== anchorRenderFailureCountBefore;
2317
- let newLines = renderedLines;
2778
+ let newLines = this.#constrainPinnedSuffix(renderedLines, height, renderedChildren);
2318
2779
  this.#viewportAnchorFrame = anchorFrame;
2319
2780
  if (renderMetrics.enabled) renderMetrics.recordHelper("renderTree", renderMetrics.now() - renderTreeStart);
2320
2781
 
2321
- if (this.#bottomPinnedComponent !== null && height > 0) {
2322
- newLines = this.#padBeforeBottomPinnedComponent(newLines, height, renderedChildren);
2782
+ if (hasStickySuffix && height > 0 && this.#manualViewportTop === undefined) {
2783
+ newLines = this.#padBeforeBottomPinnedComponent(
2784
+ newLines,
2785
+ height,
2786
+ newLines.length - sourceTranscriptLineCount,
2787
+ ).lines;
2323
2788
  }
2324
2789
 
2325
2790
  // Composite overlays into the rendered lines (before differential compare)
@@ -2341,9 +2806,9 @@ export class TUI extends Container {
2341
2806
  const widthChanged = this.#previousWidth !== 0 && this.#previousWidth !== width;
2342
2807
  const heightChanged = this.#previousHeight !== 0 && this.#previousHeight !== height;
2343
2808
 
2344
- // Normalize/truncate lines for emission. With the opt-in virtual-viewport flag
2345
- // (PI_TUI_VIRTUAL_VIEWPORT) we reuse the previous frame's normalized prefix when the
2346
- // off-screen raw prefix is unchanged (raw value equality per line; fast reference
2809
+ // Normalize/truncate lines for emission. The virtual viewport is default-on;
2810
+ // PI_TUI_VIRTUAL_VIEWPORT=0 opts out. When enabled, reuse the previous frame's
2811
+ // normalized prefix when the off-screen raw prefix is unchanged (raw value equality
2347
2812
  // short-circuit for cached components), so only the visible window is
2348
2813
  // re-normalized and the diff starts at the window. Output is byte-identical to the
2349
2814
  // full path (reused entries are deterministic normalizations of identical raw lines).
@@ -2393,6 +2858,21 @@ export class TUI extends Container {
2393
2858
  if (usedWindowNormalize) renderMetrics.recordLineCount("offscreenScan", diffStart);
2394
2859
  }
2395
2860
  this.#latestRenderedLines = newLines;
2861
+ this.#manualTranscriptLineCount = sourceTranscriptLineCount;
2862
+ this.#manualSuffixLineCount = Math.max(0, newLines.length - sourceTranscriptLineCount);
2863
+ const naturalViewportTop = Math.max(0, newLines.length - height);
2864
+ const priorLogicalLineCount = Math.max(this.#previousLines.length, this.#maxLinesRendered);
2865
+ if (this.#transcriptIdentityResetPending) {
2866
+ this.#transcriptIdentityResetPending = false;
2867
+ } else if (
2868
+ newLines.length < priorLogicalLineCount &&
2869
+ (naturalViewportTop < prevViewportTop || this.#manualViewportTop !== undefined)
2870
+ ) {
2871
+ this.#scrollbackResumeViewportTop = Math.max(
2872
+ this.#scrollbackResumeViewportTop ?? 0,
2873
+ this.#nativeScrollbackViewportTop,
2874
+ );
2875
+ }
2396
2876
 
2397
2877
  if (this.#manualViewportTop !== undefined) {
2398
2878
  let resolvedAnchorTop = anchorFrame === null ? null : this.#resolveManualAnchor(anchorFrame);
@@ -2448,6 +2928,7 @@ export class TUI extends Container {
2448
2928
  this.#previousWidth === width &&
2449
2929
  this.#previousHeight === height &&
2450
2930
  nextViewportTop === this.#manualViewportTop &&
2931
+ this.#manualOutputNotice === this.#paintedManualOutputNotice &&
2451
2932
  newLines.length === this.#previousLines.length &&
2452
2933
  newLines.every((line, index) => line === this.#previousLines[index])
2453
2934
  ) {
@@ -2469,46 +2950,69 @@ export class TUI extends Container {
2469
2950
  this.#previousLines = newLines;
2470
2951
  this.#previousWidth = width;
2471
2952
  this.#previousHeight = height;
2953
+ this.#paintedManualOutputNotice = this.#manualOutputNotice;
2472
2954
  }
2473
2955
  return;
2474
2956
  }
2475
2957
  // Helper to clear scrollback and viewport and render all new lines
2476
- const fullRender = (clear: boolean, reason = "full render"): void => {
2958
+ let viewportRepaint: (reason: string, targetViewportTop?: number) => void;
2959
+ const fullRender = (clear: boolean, reason = "full render", forceScrollbackClear = false): void => {
2960
+ if (
2961
+ clear &&
2962
+ !forceScrollbackClear &&
2963
+ shouldPreserveScrollbackOnFullClear(this.terminal) &&
2964
+ this.#scrollbackResumeViewportTop !== undefined
2965
+ ) {
2966
+ viewportRepaint(`preserving full replay blocked after scrollback-unsafe contraction: ${reason}`);
2967
+ return;
2968
+ }
2477
2969
  this.#fullRedrawCount += 1;
2478
2970
  if (renderMetrics.enabled) renderMetrics.recordFullRedraw(reason);
2479
2971
  let buffer = "\x1b[?2026h"; // Begin synchronized output
2480
2972
  // Skip clearing scrollback (3J) in hosts where clear/replay can snap the
2481
- // native viewport away from the live prompt (tmux/screen, Windows ConPTY).
2973
+ // native viewport away from the live prompt (tmux/screen, Windows ConPTY) —
2974
+ // unless the caller explicitly needs history erased (the settled width
2975
+ // repair, where a replay WITHOUT 3J would stack the new transcript on top
2976
+ // of the stale-width copy instead of replacing it).
2482
2977
  if (clear)
2483
- buffer += shouldPreserveScrollbackOnFullClear(this.terminal) ? "\x1b[2J\x1b[H" : "\x1b[2J\x1b[H\x1b[3J";
2978
+ buffer +=
2979
+ !forceScrollbackClear && shouldPreserveScrollbackOnFullClear(this.terminal)
2980
+ ? "\x1b[2J\x1b[H"
2981
+ : "\x1b[2J\x1b[H\x1b[3J";
2484
2982
  for (let i = 0; i < newLines.length; i++) {
2485
2983
  if (i > 0) buffer += "\r\n";
2486
2984
  // Lines were pre-terminated/normalized by #applyLineResets; image
2487
2985
  // lines were left untouched there.
2488
2986
  buffer += newLines[i];
2489
2987
  }
2490
- this.#cursorRow = Math.max(0, newLines.length - 1);
2491
- const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, this.#cursorRow);
2492
- this.#hardwareCursorRow = toRow;
2988
+ const cursorRow = Math.max(0, newLines.length - 1);
2989
+ const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, cursorRow);
2493
2990
  buffer += seq;
2494
2991
  buffer += "\x1b[?2026l"; // End synchronized output
2495
- if (!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length)) return;
2496
- // Reset max lines when clearing, otherwise track growth
2497
- if (clear) {
2498
- this.#maxLinesRendered = newLines.length;
2499
- } else {
2500
- this.#maxLinesRendered = Math.max(this.#maxLinesRendered, newLines.length);
2501
- }
2502
- this.#viewportTopRow = Math.max(0, this.#maxLinesRendered - height);
2503
- this.#previousLines = newLines;
2504
- this.#previousWidth = width;
2505
- this.#previousHeight = height;
2992
+ if (
2993
+ !this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length, () => {
2994
+ this.#cursorRow = cursorRow;
2995
+ this.#hardwareCursorRow = toRow;
2996
+ this.#maxLinesRendered = clear ? newLines.length : Math.max(this.#maxLinesRendered, newLines.length);
2997
+ this.#viewportTopRow = Math.max(0, this.#maxLinesRendered - height);
2998
+ this.#nativeScrollbackViewportTop = clear
2999
+ ? this.#viewportTopRow
3000
+ : Math.max(this.#nativeScrollbackViewportTop, this.#viewportTopRow);
3001
+ if (clear && (forceScrollbackClear || !shouldPreserveScrollbackOnFullClear(this.terminal))) {
3002
+ this.#scrollbackResumeViewportTop = undefined;
3003
+ }
3004
+ this.#previousLines = newLines;
3005
+ this.#previousWidth = width;
3006
+ this.#previousHeight = height;
3007
+ })
3008
+ )
3009
+ return;
2506
3010
  };
2507
3011
 
2508
- const viewportRepaint = (reason: string): void => {
3012
+ viewportRepaint = (reason: string, targetViewportTop = Math.max(0, newLines.length - height)): void => {
2509
3013
  this.#fullRedrawCount += 1;
2510
3014
  if (renderMetrics.enabled) renderMetrics.recordFullRedraw(reason);
2511
- const nextViewportTop = Math.max(0, newLines.length - height);
3015
+ const nextViewportTop = targetViewportTop;
2512
3016
  const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
2513
3017
  let buffer = "\x1b[?2026h";
2514
3018
  if (currentScreenRow > 0) {
@@ -2539,25 +3043,25 @@ export class TUI extends Container {
2539
3043
  cursorSeq = cursor.seq;
2540
3044
  cursorToRow = cursor.toRow;
2541
3045
  }
2542
- this.#hardwareCursorRow = cursorToRow;
2543
3046
  buffer += cursorSeq;
2544
3047
  buffer += "\x1b[?2026l";
2545
- if (!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length)) return;
3048
+ if (
3049
+ !this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length, () => {
3050
+ this.#hardwareCursorRow = cursorToRow;
3051
+ this.#cursorRow = Math.max(0, newLines.length - 1);
3052
+ this.#maxLinesRendered = newLines.length;
3053
+ this.#viewportTopRow = nextViewportTop;
3054
+ this.#previousLines = newLines;
3055
+ this.#previousWidth = width;
3056
+ this.#previousHeight = height;
3057
+ })
3058
+ )
3059
+ return;
2546
3060
 
2547
3061
  if (this.#debugRedraw) {
2548
3062
  const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
2549
3063
  this.#appendDebugRedrawLog(msg);
2550
3064
  }
2551
- // Viewport repaint deliberately prioritizes the live viewport over
2552
- // historical scrollback repair. After offscreen changes, #previousLines
2553
- // tracks the desired logical transcript, not every byte emitted into the
2554
- // terminal scrollback.
2555
- this.#cursorRow = Math.max(0, newLines.length - 1);
2556
- this.#maxLinesRendered = newLines.length;
2557
- this.#viewportTopRow = nextViewportTop;
2558
- this.#previousLines = newLines;
2559
- this.#previousWidth = width;
2560
- this.#previousHeight = height;
2561
3065
  };
2562
3066
 
2563
3067
  const debugRedraw = this.#debugRedraw;
@@ -2576,14 +3080,26 @@ export class TUI extends Container {
2576
3080
 
2577
3081
  // Width changes always need a full re-render because wrapping changes.
2578
3082
  if (widthChanged) {
2579
- logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
2580
- if (useViewportRepaintPath(this.terminal)) {
2581
- // In viewport-repaint sessions a full replay can either pile the transcript
2582
- // back onto scrollback (tmux/screen) or visibly jump to the transcript top
2583
- // (Windows Terminal). Repaint the viewport only, mirroring the height-change
2584
- // branch and neutralizing fake width changes from requestRender(true).
3083
+ if (this.#widthSettleRepairPending) {
3084
+ logRedraw(`width settled (${this.#previousWidth} -> ${width})`);
3085
+ // The one debounced post-resize repair: a full clear+replay so stale
3086
+ // old-width wrapping is repaired in scrollback history too, not just
3087
+ // the live viewport. forceScrollbackClear erases the stale-width
3088
+ // history instead of stacking the replay on top of it. Safe against
3089
+ // the replay storm because it runs once per settled width sequence,
3090
+ // never once per SIGWINCH.
3091
+ this.#widthSettleRepairPending = false;
3092
+ fullRender(true, "width settled", true);
3093
+ } else if (useViewportRepaintPath(this.terminal)) {
3094
+ logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
3095
+ // In viewport-repaint sessions a per-event full replay can either pile
3096
+ // the transcript back onto scrollback (tmux/screen) or visibly jump to
3097
+ // the transcript top (Windows Terminal). Repaint the viewport only,
3098
+ // mirroring the height-change branch and neutralizing fake width
3099
+ // changes from requestRender(true).
2585
3100
  viewportRepaint(`terminal width changed (${this.#previousWidth} -> ${width})`);
2586
3101
  } else {
3102
+ logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
2587
3103
  fullRender(true, "terminal width changed");
2588
3104
  }
2589
3105
  return;
@@ -2644,7 +3160,7 @@ export class TUI extends Container {
2644
3160
  }
2645
3161
  lastChanged = newLines.length - 1;
2646
3162
  }
2647
- const appendStart = appendedLines && firstChanged === this.#previousLines.length && firstChanged > 0;
3163
+ let appendStart = appendedLines && firstChanged === this.#previousLines.length && firstChanged > 0;
2648
3164
 
2649
3165
  // No changes - but still need to update hardware cursor position if it moved
2650
3166
  if (firstChanged === -1) {
@@ -2658,6 +3174,32 @@ export class TUI extends Container {
2658
3174
  viewportRepaint(`content contraction changed viewport top (${prevViewportTop} -> ${nextLiveViewportTop})`);
2659
3175
  return;
2660
3176
  }
3177
+ if (appendedLines && this.#scrollbackResumeViewportTop !== undefined && nextLiveViewportTop > prevViewportTop) {
3178
+ const resumeViewportTop = this.#scrollbackResumeViewportTop;
3179
+ if (nextLiveViewportTop <= resumeViewportTop) {
3180
+ viewportRepaint(
3181
+ `content expansion below committed scrollback frontier (${prevViewportTop} -> ${nextLiveViewportTop}, frontier=${resumeViewportTop})`,
3182
+ );
3183
+ return;
3184
+ }
3185
+
3186
+ const previousLines = this.#previousLines;
3187
+ const previousWidth = this.#previousWidth;
3188
+ const previousHeight = this.#previousHeight;
3189
+ viewportRepaint(
3190
+ `staging committed scrollback frontier before resumed admission (${prevViewportTop} -> ${resumeViewportTop} -> ${nextLiveViewportTop})`,
3191
+ resumeViewportTop,
3192
+ );
3193
+ this.#previousLines = previousLines;
3194
+ this.#previousWidth = previousWidth;
3195
+ this.#previousHeight = previousHeight;
3196
+ prevViewportTop = resumeViewportTop;
3197
+ viewportTop = resumeViewportTop;
3198
+ hardwareCursorRow = this.#hardwareCursorRow;
3199
+ firstChanged = resumeViewportTop;
3200
+ appendStart = false;
3201
+ this.#scrollbackResumeViewportTop = undefined;
3202
+ }
2661
3203
  // All changes are in deleted lines (nothing to render, just clear)
2662
3204
  if (firstChanged >= newLines.length) {
2663
3205
  if (this.#previousLines.length > newLines.length) {
@@ -2691,12 +3233,21 @@ export class TUI extends Container {
2691
3233
  if (moveUp > 0) {
2692
3234
  buffer += `\x1b[${moveUp}A`;
2693
3235
  }
2694
- this.#cursorRow = targetRow;
2695
3236
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, targetRow);
2696
- this.#hardwareCursorRow = toRow;
2697
3237
  buffer += seq;
2698
3238
  buffer += "\x1b[?2026l";
2699
- if (!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length)) return;
3239
+ if (
3240
+ !this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length, () => {
3241
+ this.#cursorRow = targetRow;
3242
+ this.#hardwareCursorRow = toRow;
3243
+ this.#previousLines = newLines;
3244
+ this.#previousWidth = width;
3245
+ this.#previousHeight = height;
3246
+ this.#maxLinesRendered = newLines.length;
3247
+ this.#viewportTopRow = Math.max(0, newLines.length - height);
3248
+ })
3249
+ )
3250
+ return;
2700
3251
  }
2701
3252
  this.#previousLines = newLines;
2702
3253
  this.#previousWidth = width;
@@ -2811,7 +3362,6 @@ export class TUI extends Container {
2811
3362
  }
2812
3363
 
2813
3364
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, finalCursorRow);
2814
- this.#hardwareCursorRow = toRow;
2815
3365
  buffer += seq;
2816
3366
  buffer += "\x1b[?2026l"; // End synchronized output
2817
3367
 
@@ -2845,20 +3395,22 @@ export class TUI extends Container {
2845
3395
  fs.writeFileSync(debugPath, debugData);
2846
3396
  }
2847
3397
 
2848
- // Write entire buffer at once
2849
- if (!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length)) return;
2850
-
2851
- // Track cursor position for next render.
2852
- // cursorRow tracks end of content (for viewport calculation).
2853
- // #hardwareCursorRow was already updated by #cursorControlSequence above.
2854
- this.#cursorRow = Math.max(0, newLines.length - 1);
2855
- // Track content height for viewport calculation
2856
- this.#maxLinesRendered = newLines.length;
2857
- this.#viewportTopRow = Math.max(0, newLines.length - height);
2858
-
2859
- this.#previousLines = newLines;
2860
- this.#previousWidth = width;
2861
- this.#previousHeight = height;
3398
+ // Write entire buffer at once. Once those bytes are accepted, the painted
3399
+ // frame and geometry are authoritative even when the optional IME cursor
3400
+ // write subsequently detaches the terminal.
3401
+ if (
3402
+ !this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length, () => {
3403
+ this.#hardwareCursorRow = toRow;
3404
+ this.#cursorRow = Math.max(0, newLines.length - 1);
3405
+ this.#maxLinesRendered = newLines.length;
3406
+ this.#viewportTopRow = Math.max(0, newLines.length - height);
3407
+ this.#nativeScrollbackViewportTop = Math.max(this.#nativeScrollbackViewportTop, this.#viewportTopRow);
3408
+ this.#previousLines = newLines;
3409
+ this.#previousWidth = width;
3410
+ this.#previousHeight = height;
3411
+ })
3412
+ )
3413
+ return;
2862
3414
  }
2863
3415
 
2864
3416
  /**
@@ -2933,6 +3485,7 @@ export class TUI extends Container {
2933
3485
  buffer: string,
2934
3486
  cursorPos: { row: number; col: number } | null,
2935
3487
  totalLines: number,
3488
+ onBufferWritten?: () => void,
2936
3489
  ): boolean {
2937
3490
  const overlay = this.#postRenderEmitter?.();
2938
3491
  if (overlay) {
@@ -2942,8 +3495,14 @@ export class TUI extends Container {
2942
3495
  buffer += `\x1b[?2026h\x1b7${overlay}\x1b8\x1b[?2026l`;
2943
3496
  }
2944
3497
  if (!this.#writeTerminal(buffer)) return false;
2945
- if (!this.#imeCursorActive) return true;
2946
- return this.#writeCursorPosition(cursorPos, totalLines);
3498
+ onBufferWritten?.();
3499
+ if (!this.#imeCursorActive) {
3500
+ this.#lastRenderWriteSucceeded = true;
3501
+ return true;
3502
+ }
3503
+ const cursorWritten = this.#writeCursorPosition(cursorPos, totalLines);
3504
+ if (cursorWritten) this.#lastRenderWriteSucceeded = true;
3505
+ return cursorWritten;
2947
3506
  }
2948
3507
 
2949
3508
  /**
@@ -2956,8 +3515,9 @@ export class TUI extends Container {
2956
3515
  return this.#hideCursor();
2957
3516
  }
2958
3517
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, totalLines, this.#hardwareCursorRow);
2959
- this.#hardwareCursorRow = toRow;
2960
3518
  // No \x1b[?2026h/l wrapper: synchronized output flushes terminal state and discards macOS IME composition.
2961
- return this.#writeTerminal(seq);
3519
+ if (!this.#writeTerminal(seq)) return false;
3520
+ this.#hardwareCursorRow = toRow;
3521
+ return true;
2962
3522
  }
2963
3523
  }