@gajae-code/tui 0.11.10 → 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
@@ -38,6 +38,17 @@ const SEGMENT_RESET = "\x1b[0m";
38
38
  * diffing so `#previousLines` mirrors what was actually written.
39
39
  */
40
40
  const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x07";
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;
44
+
45
+ function stripTerminalControls(text: string): string {
46
+ return Bun.stripANSI(text)
47
+ .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/gu, "")
48
+ .replace(/\x1b[P_^X][\s\S]*?\x1b\\/gu, "")
49
+ .replace(/\x1b(?:\[[0-?]*[ -/]*[@-~]|[@-_])/gu, "")
50
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/gu, "");
51
+ }
41
52
 
42
53
  type InputListenerResult = { consume?: boolean; data?: string } | undefined;
43
54
  type InputListener = (data: string) => InputListenerResult;
@@ -46,7 +57,7 @@ type InputListener = (data: string) => InputListenerResult;
46
57
  * Component interface - all components must implement this
47
58
  */
48
59
  export type MouseEvent = {
49
- kind: "wheel" | "click";
60
+ kind: "wheel" | "click" | "drag" | "release";
50
61
  direction?: -1 | 1;
51
62
  button?: 0;
52
63
  /** Terminal cell coordinates, one-based. */
@@ -66,7 +77,12 @@ type OverlayMouseBounds = {
66
77
  termHeight: number;
67
78
  };
68
79
 
69
- /** Parse xterm SGR mouse reports. Drag and button-release reports are ignored. */
80
+ type MouseSelectionPoint = {
81
+ line: number;
82
+ column: number;
83
+ };
84
+
85
+ /** Parse xterm SGR mouse reports for wheel, left-click, drag, and release events. */
70
86
  export function parseSgrMouseEvent(data: string): MouseEvent | undefined {
71
87
  const match = data.match(/^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/);
72
88
  if (!match) return undefined;
@@ -76,11 +92,17 @@ export function parseSgrMouseEvent(data: string): MouseEvent | undefined {
76
92
  const terminator = match[4];
77
93
  if (![button, x, y].every(Number.isSafeInteger) || x < 1 || y < 1) return undefined;
78
94
 
79
- if (button & 32 || terminator === "m") return undefined;
80
- if (button === 64) return { kind: "wheel", direction: -1, x, y };
81
- if (button === 65) return { kind: "wheel", direction: 1, x, y };
82
- if (button === 0) return { kind: "click", button, x, y };
83
- return undefined;
95
+ const baseButton = button & 3;
96
+ if (button & 64) {
97
+ if (terminator !== "M") return undefined;
98
+ if (baseButton === 0) return { kind: "wheel", direction: -1, x, y };
99
+ if (baseButton === 1) return { kind: "wheel", direction: 1, x, y };
100
+ return undefined;
101
+ }
102
+ if (baseButton !== 0) return undefined;
103
+ if (terminator === "m") return { kind: "release", button: 0, x, y };
104
+ if (button & 32) return { kind: "drag", button: 0, x, y };
105
+ return { kind: "click", button: 0, x, y };
84
106
  }
85
107
 
86
108
  export interface Component {
@@ -170,6 +192,11 @@ export interface ViewportAnchorSource {
170
192
  id: ViewportAnchorId;
171
193
  }
172
194
 
195
+ /** Identity and monotonic revision of the logical output producer. */
196
+ export type ViewportOutputSource = {
197
+ identity: string;
198
+ revision: bigint;
199
+ };
173
200
  export interface ViewportAnchorSourceRenderer extends Component {
174
201
  renderWithViewportAnchorSource(width: number, source: ViewportAnchorSource): ViewportAnchorRender;
175
202
  }
@@ -603,6 +630,10 @@ type TuiRenderCounterSnapshot = {
603
630
  debugRedrawAppendWrites: number;
604
631
  differentialGuardVisibleWidthCalls: number;
605
632
  };
633
+ type RenderCommitWaiter = {
634
+ resolve: (committed: boolean) => void;
635
+ timer: NodeJS.Timeout;
636
+ };
606
637
 
607
638
  /**
608
639
  * TUI - Main class for managing terminal UI with differential rendering
@@ -631,7 +662,28 @@ export class TUI extends Container {
631
662
  /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
632
663
  onDebug?: () => void;
633
664
  #renderRequested = false;
665
+ #nextRenderGeneration = 0;
666
+ #renderRequestedGeneration = 0;
667
+ #committedRenderGeneration = 0;
668
+ #renderCommitWaiters = new Map<number, Set<RenderCommitWaiter>>();
669
+ #lastRenderWriteSucceeded = false;
634
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
+ }
635
687
  #lastRenderAt = 0;
636
688
  static readonly #MIN_RENDER_INTERVAL_MS = 16;
637
689
  // Input-priority scheduling: an input keystroke must never be starved behind a
@@ -642,6 +694,9 @@ export class TUI extends Container {
642
694
  #cursorRow = 0; // Logical cursor row (end of rendered content)
643
695
  #hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
644
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;
645
700
  #manualViewportTop: number | undefined;
646
701
  #viewportAnchorComponent: Component | null = null;
647
702
  #viewportAnchorFrame: ViewportAnchorFrame | null = null;
@@ -671,6 +726,15 @@ export class TUI extends Container {
671
726
  #terminalUnavailable = false;
672
727
  #bottomPinnedComponent: Component | null = null;
673
728
  #pendingTerminalCleanup: Array<{ payload: string; onDelivered?: () => void }> = [];
729
+ #mouseSelectionStart: MouseSelectionPoint | null = null;
730
+ #mouseSelectionEnd: MouseSelectionPoint | null = null;
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;
674
738
 
675
739
  #unsubscribeTabWidthChange?: () => void;
676
740
  static #renderCounters: TuiRenderCounterSnapshot = {
@@ -720,13 +784,26 @@ export class TUI extends Container {
720
784
  constructor(
721
785
  terminal: Terminal,
722
786
  showHardwareCursor?: boolean,
723
- private readonly options: { enableMouse?: boolean } = {},
787
+ private readonly options: {
788
+ enableMouse?: boolean;
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;
797
+ } = {},
724
798
  ) {
725
799
  super();
726
800
  this.terminal = terminal;
727
801
  if (showHardwareCursor !== undefined) {
728
802
  this.#showHardwareCursor = showHardwareCursor;
729
803
  }
804
+ if (options.widthSettleMs !== undefined && Number.isFinite(options.widthSettleMs) && options.widthSettleMs >= 0) {
805
+ this.#widthSettleMs = options.widthSettleMs;
806
+ }
730
807
  this.#imeCursorActive = !this.#showHardwareCursor && this.#useImeBlockCursor;
731
808
  this.#unsubscribeTabWidthChange = onDefaultTabWidthChange(() => {
732
809
  this.#lineTruncationCache.clear();
@@ -787,11 +864,58 @@ export class TUI extends Container {
787
864
  }
788
865
  }
789
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
+
790
888
  setBottomPinnedComponent(component: Component | null): void {
791
889
  this.#bottomPinnedComponent = component;
792
890
  this.requestRender();
793
891
  }
794
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
+
795
919
  /** Register the direct child whose rows are eligible for semantic viewport anchoring. */
796
920
  setViewportAnchorComponent(component: Component | null): void {
797
921
  if (component !== null && !isViewportAnchorProvider(component)) {
@@ -809,6 +933,21 @@ export class TUI extends Container {
809
933
  this.#manualViewportFallbackAnchors = [];
810
934
  this.#reconcileMissingViewportAnchor = false;
811
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;
812
951
  }
813
952
 
814
953
  /** Allow one semantic-neighbor reconciliation after a definitive same-transcript rebuild. */
@@ -821,22 +960,36 @@ export class TUI extends Container {
821
960
  const height = this.terminal.rows;
822
961
  const width = this.terminal.columns;
823
962
  const frame = this.#viewportAnchorFrame;
824
- 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;
825
966
 
826
- 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
+ }
827
976
  const selected = selectedRow < 0 ? null : frame.anchors[selectedRow];
828
977
  if (selected === null) return false;
829
978
 
830
- 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;
831
981
  const targetViewportTop = Math.max(0, frame.startRow + selectedRow - desiredScreenRow);
832
982
  this.#manualViewportAnchor = {
833
983
  id: selected.id,
834
- graphemeIndex: selected.graphemeStart,
835
- 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,
836
989
  desiredScreenRow,
837
990
  };
838
991
  const firstCandidateRow = Math.max(0, targetViewportTop - frame.startRow);
839
- const lastCandidateRow = Math.min(frame.anchors.length, targetViewportTop + height - frame.startRow);
992
+ const lastCandidateRow = Math.min(frame.anchors.length, targetViewportTop + transcriptCapacity - frame.startRow);
840
993
  const fallbacks: ManualViewportAnchor[] = [];
841
994
  for (let row = firstCandidateRow; row < lastCandidateRow; row++) {
842
995
  const anchor = frame.anchors[row];
@@ -860,11 +1013,24 @@ export class TUI extends Container {
860
1013
  return true;
861
1014
  }
862
1015
 
863
- 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 {
864
1023
  const height = this.terminal.rows;
865
1024
  const width = this.terminal.columns;
866
1025
  if (height <= 0 || width <= 0 || this.#previousLines.length === 0) return false;
867
- 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);
868
1034
  let currentViewportTop = Math.max(0, Math.min(maxViewportTop, this.#manualViewportTop ?? this.#viewportTopRow));
869
1035
  const frame = this.#viewportAnchorFrame;
870
1036
  if (this.#manualViewportAnchor !== null) {
@@ -873,16 +1039,29 @@ export class TUI extends Container {
873
1039
  if (resolvedViewportTop === null) return false;
874
1040
  currentViewportTop = Math.max(0, Math.min(maxViewportTop, resolvedViewportTop));
875
1041
  }
876
- const targetViewportTop = Math.max(
877
- 0,
878
- Math.min(maxViewportTop, currentViewportTop + direction * Math.max(1, height - 1)),
879
- );
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
+ }
880
1050
  if (frame !== null) {
881
- 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));
882
1058
  const targetRow = targetViewportTop + desiredScreenRow - frame.startRow;
883
1059
  let selected: { row: number; anchor: ViewportAnchorRow } | undefined;
884
1060
  const firstCandidateRow = Math.max(0, targetViewportTop - frame.startRow);
885
- 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
+ );
886
1065
  for (let row = firstCandidateRow; row < lastCandidateRow; row++) {
887
1066
  const anchor = frame.anchors[row];
888
1067
  if (anchor === null) continue;
@@ -948,26 +1127,63 @@ export class TUI extends Container {
948
1127
  );
949
1128
  }
950
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
+
951
1137
  followLiveViewport(): boolean {
952
1138
  if (this.#manualViewportTop === undefined) return false;
953
1139
  const height = this.terminal.rows;
954
1140
  const width = this.terminal.columns;
955
- 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
+ }
956
1154
  const liveViewportTop = Math.max(0, liveLines.length - height);
957
- this.#manualViewportTop = undefined;
958
- this.#manualViewportAnchor = null;
959
- this.#manualViewportFallbackAnchors = [];
960
- this.#reconcileMissingViewportAnchor = false;
961
- const repainted = this.#repaintViewportFromLines(
1155
+ return this.#repaintViewportFromLines(
962
1156
  liveLines,
963
1157
  width,
964
1158
  height,
965
1159
  liveViewportTop,
966
- this.#lastCursorPosition,
1160
+ liveCursorPosition,
967
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,
968
1186
  );
969
- if (repainted) this.#previousLines = liveLines;
970
- return repainted;
971
1187
  }
972
1188
 
973
1189
  /**
@@ -1071,6 +1287,9 @@ export class TUI extends Container {
1071
1287
  start(): void {
1072
1288
  this.#stopped = false;
1073
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;
1074
1293
  this.terminal.setMouseEnabled?.(this.options.enableMouse === true);
1075
1294
  this.terminal.start(
1076
1295
  data => this.#handleInput(data),
@@ -1086,6 +1305,57 @@ export class TUI extends Container {
1086
1305
  this.requestRender(true);
1087
1306
  }
1088
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
+
1089
1359
  get terminalAvailable(): boolean {
1090
1360
  return !this.#terminalUnavailable && this.terminal.available;
1091
1361
  }
@@ -1094,6 +1364,7 @@ export class TUI extends Container {
1094
1364
  this.#terminalUnavailable = true;
1095
1365
  this.#stopped = true;
1096
1366
  this.#renderRequested = false;
1367
+ this.#settleRenderCommitWaiters(false);
1097
1368
  if (this.#renderTimer) {
1098
1369
  clearTimeout(this.#renderTimer);
1099
1370
  this.#renderTimer = undefined;
@@ -1292,11 +1563,25 @@ export class TUI extends Container {
1292
1563
  this.flushTerminalCleanup();
1293
1564
  this.#clearSixelProbeState();
1294
1565
  this.#stopped = true;
1566
+ this.#settleRenderCommitWaiters(false);
1295
1567
  if (this.#renderTimer) {
1296
1568
  clearTimeout(this.#renderTimer);
1297
1569
  this.#renderTimer = undefined;
1298
1570
  if (renderMetrics.enabled) renderMetrics.setTimerGauge("tui.renderTimer", 0);
1299
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
+ }
1300
1585
  // Move cursor to the end of the content to prevent overwriting/artifacts on exit
1301
1586
  if (this.#previousLines.length > 0) {
1302
1587
  const targetRow = this.#previousLines.length; // Line after the last content
@@ -1357,19 +1642,81 @@ export class TUI extends Container {
1357
1642
  * is a no-op otherwise.
1358
1643
  */
1359
1644
  requestResizeRender(): void {
1360
- const dimensionsChanged =
1361
- this.#previousWidth !== this.terminal.columns || this.#previousHeight !== this.terminal.rows;
1362
- 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?.();
1363
1698
  }
1364
1699
 
1365
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 {
1366
1712
  if (!this.terminalAvailable) {
1367
1713
  this.#markTerminalUnavailable();
1368
1714
  return;
1369
1715
  }
1370
1716
  if (renderMetrics.enabled) renderMetrics.recordRequest(source);
1371
1717
  if (force) {
1372
- const preserveViewportCursor = useViewportRepaintPath(this.terminal);
1718
+ const preserveViewportCursor =
1719
+ useViewportRepaintPath(this.terminal) || shouldPreserveScrollbackOnFullClear(this.terminal);
1373
1720
  // A forced full redraw supersedes any queued input-priority render.
1374
1721
  this.#inputRenderPending = false;
1375
1722
  this.#previousLines = [];
@@ -1396,12 +1743,17 @@ export class TUI extends Container {
1396
1743
  this.#renderRequested = true;
1397
1744
  process.nextTick(() => {
1398
1745
  if (this.#stopped || !this.#renderRequested) {
1746
+ this.#settleRenderCommitWaiters(false, generation);
1399
1747
  return;
1400
1748
  }
1749
+ const requestedGeneration = this.#renderRequestedGeneration;
1750
+ this.#renderRequestedGeneration = 0;
1401
1751
  this.#renderRequested = false;
1402
1752
  this.#lastRenderAt = performance.now();
1753
+ this.#lastRenderWriteSucceeded = false;
1403
1754
  const t0 = renderMetrics.now();
1404
1755
  this.#doRender();
1756
+ this.#commitRenderGeneration(requestedGeneration);
1405
1757
  if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
1406
1758
  });
1407
1759
  return;
@@ -1422,6 +1774,7 @@ export class TUI extends Container {
1422
1774
  if (this.#renderRequested) return;
1423
1775
  this.#renderRequested = true;
1424
1776
  process.nextTick(() => this.#scheduleRender());
1777
+ return;
1425
1778
  }
1426
1779
 
1427
1780
  #scheduleRender(): void {
@@ -1436,10 +1789,14 @@ export class TUI extends Container {
1436
1789
  if (this.#stopped || !this.#renderRequested) {
1437
1790
  return;
1438
1791
  }
1792
+ const requestedGeneration = this.#renderRequestedGeneration;
1793
+ this.#renderRequestedGeneration = 0;
1439
1794
  this.#renderRequested = false;
1440
1795
  this.#lastRenderAt = performance.now();
1796
+ this.#lastRenderWriteSucceeded = false;
1441
1797
  const t0 = renderMetrics.now();
1442
1798
  this.#doRender();
1799
+ this.#commitRenderGeneration(requestedGeneration);
1443
1800
  if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
1444
1801
  if (this.#renderRequested) {
1445
1802
  this.#scheduleRender();
@@ -1462,10 +1819,14 @@ export class TUI extends Container {
1462
1819
  this.#renderTimer = undefined;
1463
1820
  if (renderMetrics.enabled) renderMetrics.setTimerGauge("tui.renderTimer", 0);
1464
1821
  }
1822
+ const requestedGeneration = this.#renderRequestedGeneration;
1823
+ this.#renderRequestedGeneration = 0;
1465
1824
  this.#renderRequested = false;
1466
1825
  this.#lastRenderAt = performance.now();
1826
+ this.#lastRenderWriteSucceeded = false;
1467
1827
  const t0 = renderMetrics.now();
1468
1828
  this.#doRender();
1829
+ this.#commitRenderGeneration(requestedGeneration);
1469
1830
  if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
1470
1831
  }
1471
1832
 
@@ -1491,8 +1852,11 @@ export class TUI extends Container {
1491
1852
  if (mouse) {
1492
1853
  // Coordinates outside the current terminal cannot name a visible cell.
1493
1854
  if (mouse.x > this.terminal.columns || mouse.y > this.terminal.rows) return;
1494
- if (mouse.kind === "wheel") this.scrollViewportPages(mouse.direction!);
1495
- else {
1855
+ if (mouse.kind === "wheel") {
1856
+ this.#clearMouseSelection();
1857
+ this.scrollViewportBy(mouse.direction! * DEFAULT_WHEEL_LINES, { pin: "stable" });
1858
+ } else if (mouse.kind === "click") {
1859
+ this.#beginMouseSelection(mouse);
1496
1860
  const focusedOverlay = this.overlayStack.find(o => o.component === this.#focusedComponent);
1497
1861
  if (focusedOverlay) {
1498
1862
  if (!this.#isOverlayVisible(focusedOverlay)) {
@@ -1518,6 +1882,10 @@ export class TUI extends Container {
1518
1882
  localY: mouse.y - bounds.row,
1519
1883
  });
1520
1884
  } else this.#focusedComponent?.handleMouse?.(mouse);
1885
+ } else if (mouse.kind === "drag") {
1886
+ this.#updateMouseSelection(mouse);
1887
+ } else {
1888
+ this.#finishMouseSelection(mouse);
1521
1889
  }
1522
1890
  this.requestRender(false, "mouse");
1523
1891
  return;
@@ -1562,6 +1930,134 @@ export class TUI extends Container {
1562
1930
  }
1563
1931
  }
1564
1932
 
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 };
1941
+ }
1942
+
1943
+ #beginMouseSelection(mouse: MouseEvent): void {
1944
+ if (!this.options.copySelection) return;
1945
+ const point = this.#mouseSelectionPoint(mouse);
1946
+ if (point === null) {
1947
+ this.#clearMouseSelection();
1948
+ return;
1949
+ }
1950
+ this.#mouseSelectionStart = point;
1951
+ this.#mouseSelectionEnd = point;
1952
+ this.#mouseSelectionDragged = false;
1953
+ }
1954
+
1955
+ #updateMouseSelection(mouse: MouseEvent): void {
1956
+ if (this.#mouseSelectionStart === null) return;
1957
+ const point = this.#mouseSelectionPoint(mouse);
1958
+ if (point === null) return;
1959
+ this.#mouseSelectionEnd = point;
1960
+ this.#mouseSelectionDragged = true;
1961
+ }
1962
+
1963
+ #finishMouseSelection(mouse: MouseEvent): void {
1964
+ if (this.#mouseSelectionStart === null) return;
1965
+ const point = this.#mouseSelectionPoint(mouse);
1966
+ if (point !== null) this.#mouseSelectionEnd = point;
1967
+ if (!this.#mouseSelectionDragged || !this.options.copySelection) {
1968
+ this.#clearMouseSelection();
1969
+ return;
1970
+ }
1971
+ const text = this.#extractMouseSelection();
1972
+ if (!text) {
1973
+ this.#clearMouseSelection();
1974
+ return;
1975
+ }
1976
+ try {
1977
+ const result = this.options.copySelection(text);
1978
+ if (result) void result.catch(() => {});
1979
+ } catch {
1980
+ // Clipboard failures are reported by the host callback and must not break terminal input.
1981
+ }
1982
+ }
1983
+
1984
+ #clearMouseSelection(): void {
1985
+ this.#mouseSelectionStart = null;
1986
+ this.#mouseSelectionEnd = null;
1987
+ this.#mouseSelectionDragged = false;
1988
+ }
1989
+
1990
+ #orderedMouseSelection(): { start: MouseSelectionPoint; end: MouseSelectionPoint } | null {
1991
+ const start = this.#mouseSelectionStart;
1992
+ const end = this.#mouseSelectionEnd;
1993
+ if (start === null || end === null) return null;
1994
+ if (start.line < end.line || (start.line === end.line && start.column <= end.column)) return { start, end };
1995
+ return { start: end, end: start };
1996
+ }
1997
+
1998
+ #mouseSelectionColumns(line: number, text: string): { start: number; end: number } | null {
1999
+ const selection = this.#orderedMouseSelection();
2000
+ if (selection === null || line < selection.start.line || line > selection.end.line) return null;
2001
+ const lineWidth = visibleWidth(text);
2002
+ let start = line === selection.start.line ? selection.start.column : 0;
2003
+ let end = line === selection.end.line ? selection.end.column + 1 : lineWidth;
2004
+ start = Math.max(0, Math.min(lineWidth, start));
2005
+ end = Math.max(0, Math.min(lineWidth, end));
2006
+
2007
+ let column = 0;
2008
+ for (const part of MOUSE_SELECTION_SEGMENTER.segment(text)) {
2009
+ const next = column + Math.max(1, visibleWidth(part.segment));
2010
+ if (column < start && start < next) start = column;
2011
+ if (column < end && end < next) end = next;
2012
+ column = next;
2013
+ }
2014
+ return { start, end };
2015
+ }
2016
+
2017
+ #extractMouseSelection(): string {
2018
+ const selection = this.#orderedMouseSelection();
2019
+ if (selection === null) return "";
2020
+ const selected: string[] = [];
2021
+ for (let lineIndex = selection.start.line; lineIndex <= selection.end.line; lineIndex++) {
2022
+ const line = this.#previousLines[lineIndex];
2023
+ if (line === undefined || TERMINAL.isImageLine(line)) {
2024
+ selected.push("");
2025
+ continue;
2026
+ }
2027
+ const plain = stripTerminalControls(line);
2028
+ const columns = this.#mouseSelectionColumns(lineIndex, plain);
2029
+ if (columns === null || columns.end <= columns.start) {
2030
+ selected.push("");
2031
+ continue;
2032
+ }
2033
+ selected.push(sliceByColumn(plain, columns.start, columns.end - columns.start, false));
2034
+ }
2035
+ return selected.join("\n");
2036
+ }
2037
+
2038
+ #applyMouseSelection(lines: string[]): string[] {
2039
+ if (!this.#mouseSelectionDragged) return lines;
2040
+ const selection = this.#orderedMouseSelection();
2041
+ if (selection === null) return lines;
2042
+ const highlighted = lines;
2043
+ for (let lineIndex = selection.start.line; lineIndex <= selection.end.line; lineIndex++) {
2044
+ const line = highlighted[lineIndex];
2045
+ if (line === undefined || TERMINAL.isImageLine(line)) continue;
2046
+ const plain = stripTerminalControls(line);
2047
+ const width = visibleWidth(plain);
2048
+ const columns = this.#mouseSelectionColumns(lineIndex, plain);
2049
+ if (columns === null || columns.end <= columns.start) continue;
2050
+ const before = sliceByColumn(line, 0, columns.start, false);
2051
+ const selected = sliceByColumn(line, columns.start, columns.end - columns.start, false).replace(
2052
+ /\x1b\[[0-9;]*m/gu,
2053
+ control => `${control}\x1b[7m`,
2054
+ );
2055
+ const after = sliceByColumn(line, columns.end, Math.max(0, width - columns.end), false);
2056
+ highlighted[lineIndex] = `${before}\x1b[7m${selected}\x1b[27m${after}`;
2057
+ }
2058
+ return highlighted;
2059
+ }
2060
+
1565
2061
  #consumeCellSizeResponse(data: string): boolean {
1566
2062
  // Response format: ESC [ 6 ; height ; width t
1567
2063
  const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
@@ -1999,33 +2495,108 @@ export class TUI extends Container {
1999
2495
  return lines;
2000
2496
  }
2001
2497
 
2002
- #padBeforeBottomPinnedComponent(
2003
- lines: string[],
2004
- height: number,
2005
- renderedChildren: Map<Component, string[]>,
2006
- ): string[] {
2007
- const component = this.#bottomPinnedComponent;
2008
- 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
+ }
2009
2503
 
2010
- let pinnedStart = -1;
2011
- for (let i = this.children.length - 1; i >= 0; i--) {
2012
- if (this.children[i] === component) {
2013
- 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;
2014
2509
  break;
2015
2510
  }
2016
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);
2017
2527
  if (pinnedStart < 0) return lines;
2018
2528
 
2019
- let pinnedLineCount = 0;
2020
- for (let i = pinnedStart; i < this.children.length; i++) {
2021
- 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
+ }
2022
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
+ }
2023
2581
 
2024
- const blankRows = height - lines.length;
2025
- const insertAt = Math.max(0, lines.length - pinnedLineCount);
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 };
2589
+ }
2590
+
2591
+ const insertedBlankRows = height - lines.length;
2592
+ const insertionRow = Math.max(0, lines.length - pinnedLineCount);
2026
2593
  const padded = [...lines];
2027
- padded.splice(insertAt, 0, ...Array.from({ length: blankRows }, () => ""));
2028
- 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);
2029
2600
  }
2030
2601
  #resolveManualAnchor(frame: ViewportAnchorFrame): number | null {
2031
2602
  const anchor = this.#manualViewportAnchor;
@@ -2084,9 +2655,19 @@ export class TUI extends Container {
2084
2655
  cursorPos: { row: number; col: number } | null,
2085
2656
  reason: string,
2086
2657
  allowPastLiveBottom = false,
2658
+ onPainted?: () => void,
2659
+ paintLive = false,
2087
2660
  ): boolean {
2661
+ const paintManual = this.#manualViewportTop !== undefined && !paintLive;
2088
2662
  if (height <= 0 || width <= 0) return false;
2089
- 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
+ );
2090
2671
  const nextViewportTop = Math.max(0, Math.min(maxViewportTop, viewportTop));
2091
2672
  const currentScreenRow = Math.max(0, Math.min(height - 1, this.#hardwareCursorRow - this.#viewportTopRow));
2092
2673
  let buffer = "\x1b[?2026h";
@@ -2095,12 +2676,25 @@ export class TUI extends Container {
2095
2676
  }
2096
2677
  buffer += "\r";
2097
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> = [];
2098
2682
  for (let screenRow = 0; screenRow < height; screenRow++) {
2099
2683
  if (screenRow > 0) buffer += "\r\n";
2100
2684
  buffer += "\x1b[2K";
2101
2685
  const lineIndex = nextViewportTop + screenRow;
2102
- if (lineIndex >= lines.length) continue;
2103
- 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
+ );
2104
2698
  const isImage = TERMINAL.isImageLine(line);
2105
2699
  if (!isImage && this.#visibleWidthForDifferentialGuard(line) > width) {
2106
2700
  let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
@@ -2119,19 +2713,24 @@ export class TUI extends Container {
2119
2713
  cursorSeq = cursor.seq;
2120
2714
  cursorToRow = cursor.toRow;
2121
2715
  }
2122
- this.#hardwareCursorRow = cursorToRow;
2123
2716
  buffer += cursorSeq;
2124
2717
  buffer += "\x1b[?2026l";
2125
- 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;
2126
2729
 
2127
2730
  if (this.#debugRedraw) {
2128
2731
  const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (lines=${lines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
2129
2732
  this.#appendDebugRedrawLog(msg);
2130
2733
  }
2131
-
2132
- this.#cursorRow = Math.max(0, lines.length - 1);
2133
- this.#maxLinesRendered = lines.length;
2134
- this.#viewportTopRow = nextViewportTop;
2135
2734
  return true;
2136
2735
  }
2137
2736
 
@@ -2162,13 +2761,30 @@ export class TUI extends Container {
2162
2761
  }
2163
2762
  for (const line of rendered.lines) renderedLines.push(line);
2164
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;
2165
2777
  const anchorRenderFailed = viewportAnchorRenderFailureCount !== anchorRenderFailureCountBefore;
2166
- let newLines = renderedLines;
2778
+ let newLines = this.#constrainPinnedSuffix(renderedLines, height, renderedChildren);
2167
2779
  this.#viewportAnchorFrame = anchorFrame;
2168
2780
  if (renderMetrics.enabled) renderMetrics.recordHelper("renderTree", renderMetrics.now() - renderTreeStart);
2169
2781
 
2170
- if (this.#bottomPinnedComponent !== null && height > 0) {
2171
- 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;
2172
2788
  }
2173
2789
 
2174
2790
  // Composite overlays into the rendered lines (before differential compare)
@@ -2180,6 +2796,8 @@ export class TUI extends Container {
2180
2796
  const cursorPos = this.#extractCursorPosition(newLines, height);
2181
2797
  this.#lastCursorPosition = cursorPos;
2182
2798
 
2799
+ newLines = this.#applyMouseSelection(newLines);
2800
+
2183
2801
  // Terminate every non-image line so #previousLines mirrors emitted bytes
2184
2802
  // (closes SGR + OSC 8 hyperlink state). Must run after cursor extraction
2185
2803
  // because the marker is embedded mid-line, and before any diff/full render
@@ -2188,9 +2806,9 @@ export class TUI extends Container {
2188
2806
  const widthChanged = this.#previousWidth !== 0 && this.#previousWidth !== width;
2189
2807
  const heightChanged = this.#previousHeight !== 0 && this.#previousHeight !== height;
2190
2808
 
2191
- // Normalize/truncate lines for emission. With the opt-in virtual-viewport flag
2192
- // (PI_TUI_VIRTUAL_VIEWPORT) we reuse the previous frame's normalized prefix when the
2193
- // 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
2194
2812
  // short-circuit for cached components), so only the visible window is
2195
2813
  // re-normalized and the diff starts at the window. Output is byte-identical to the
2196
2814
  // full path (reused entries are deterministic normalizations of identical raw lines).
@@ -2240,6 +2858,21 @@ export class TUI extends Container {
2240
2858
  if (usedWindowNormalize) renderMetrics.recordLineCount("offscreenScan", diffStart);
2241
2859
  }
2242
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
+ }
2243
2876
 
2244
2877
  if (this.#manualViewportTop !== undefined) {
2245
2878
  let resolvedAnchorTop = anchorFrame === null ? null : this.#resolveManualAnchor(anchorFrame);
@@ -2295,6 +2928,7 @@ export class TUI extends Container {
2295
2928
  this.#previousWidth === width &&
2296
2929
  this.#previousHeight === height &&
2297
2930
  nextViewportTop === this.#manualViewportTop &&
2931
+ this.#manualOutputNotice === this.#paintedManualOutputNotice &&
2298
2932
  newLines.length === this.#previousLines.length &&
2299
2933
  newLines.every((line, index) => line === this.#previousLines[index])
2300
2934
  ) {
@@ -2316,46 +2950,69 @@ export class TUI extends Container {
2316
2950
  this.#previousLines = newLines;
2317
2951
  this.#previousWidth = width;
2318
2952
  this.#previousHeight = height;
2953
+ this.#paintedManualOutputNotice = this.#manualOutputNotice;
2319
2954
  }
2320
2955
  return;
2321
2956
  }
2322
2957
  // Helper to clear scrollback and viewport and render all new lines
2323
- 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
+ }
2324
2969
  this.#fullRedrawCount += 1;
2325
2970
  if (renderMetrics.enabled) renderMetrics.recordFullRedraw(reason);
2326
2971
  let buffer = "\x1b[?2026h"; // Begin synchronized output
2327
2972
  // Skip clearing scrollback (3J) in hosts where clear/replay can snap the
2328
- // 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).
2329
2977
  if (clear)
2330
- 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";
2331
2982
  for (let i = 0; i < newLines.length; i++) {
2332
2983
  if (i > 0) buffer += "\r\n";
2333
2984
  // Lines were pre-terminated/normalized by #applyLineResets; image
2334
2985
  // lines were left untouched there.
2335
2986
  buffer += newLines[i];
2336
2987
  }
2337
- this.#cursorRow = Math.max(0, newLines.length - 1);
2338
- const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, this.#cursorRow);
2339
- this.#hardwareCursorRow = toRow;
2988
+ const cursorRow = Math.max(0, newLines.length - 1);
2989
+ const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, cursorRow);
2340
2990
  buffer += seq;
2341
2991
  buffer += "\x1b[?2026l"; // End synchronized output
2342
- if (!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length)) return;
2343
- // Reset max lines when clearing, otherwise track growth
2344
- if (clear) {
2345
- this.#maxLinesRendered = newLines.length;
2346
- } else {
2347
- this.#maxLinesRendered = Math.max(this.#maxLinesRendered, newLines.length);
2348
- }
2349
- this.#viewportTopRow = Math.max(0, this.#maxLinesRendered - height);
2350
- this.#previousLines = newLines;
2351
- this.#previousWidth = width;
2352
- 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;
2353
3010
  };
2354
3011
 
2355
- const viewportRepaint = (reason: string): void => {
3012
+ viewportRepaint = (reason: string, targetViewportTop = Math.max(0, newLines.length - height)): void => {
2356
3013
  this.#fullRedrawCount += 1;
2357
3014
  if (renderMetrics.enabled) renderMetrics.recordFullRedraw(reason);
2358
- const nextViewportTop = Math.max(0, newLines.length - height);
3015
+ const nextViewportTop = targetViewportTop;
2359
3016
  const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
2360
3017
  let buffer = "\x1b[?2026h";
2361
3018
  if (currentScreenRow > 0) {
@@ -2386,25 +3043,25 @@ export class TUI extends Container {
2386
3043
  cursorSeq = cursor.seq;
2387
3044
  cursorToRow = cursor.toRow;
2388
3045
  }
2389
- this.#hardwareCursorRow = cursorToRow;
2390
3046
  buffer += cursorSeq;
2391
3047
  buffer += "\x1b[?2026l";
2392
- 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;
2393
3060
 
2394
3061
  if (this.#debugRedraw) {
2395
3062
  const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
2396
3063
  this.#appendDebugRedrawLog(msg);
2397
3064
  }
2398
- // Viewport repaint deliberately prioritizes the live viewport over
2399
- // historical scrollback repair. After offscreen changes, #previousLines
2400
- // tracks the desired logical transcript, not every byte emitted into the
2401
- // terminal scrollback.
2402
- this.#cursorRow = Math.max(0, newLines.length - 1);
2403
- this.#maxLinesRendered = newLines.length;
2404
- this.#viewportTopRow = nextViewportTop;
2405
- this.#previousLines = newLines;
2406
- this.#previousWidth = width;
2407
- this.#previousHeight = height;
2408
3065
  };
2409
3066
 
2410
3067
  const debugRedraw = this.#debugRedraw;
@@ -2423,14 +3080,26 @@ export class TUI extends Container {
2423
3080
 
2424
3081
  // Width changes always need a full re-render because wrapping changes.
2425
3082
  if (widthChanged) {
2426
- logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
2427
- if (useViewportRepaintPath(this.terminal)) {
2428
- // In viewport-repaint sessions a full replay can either pile the transcript
2429
- // back onto scrollback (tmux/screen) or visibly jump to the transcript top
2430
- // (Windows Terminal). Repaint the viewport only, mirroring the height-change
2431
- // 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).
2432
3100
  viewportRepaint(`terminal width changed (${this.#previousWidth} -> ${width})`);
2433
3101
  } else {
3102
+ logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
2434
3103
  fullRender(true, "terminal width changed");
2435
3104
  }
2436
3105
  return;
@@ -2491,7 +3160,7 @@ export class TUI extends Container {
2491
3160
  }
2492
3161
  lastChanged = newLines.length - 1;
2493
3162
  }
2494
- const appendStart = appendedLines && firstChanged === this.#previousLines.length && firstChanged > 0;
3163
+ let appendStart = appendedLines && firstChanged === this.#previousLines.length && firstChanged > 0;
2495
3164
 
2496
3165
  // No changes - but still need to update hardware cursor position if it moved
2497
3166
  if (firstChanged === -1) {
@@ -2505,6 +3174,32 @@ export class TUI extends Container {
2505
3174
  viewportRepaint(`content contraction changed viewport top (${prevViewportTop} -> ${nextLiveViewportTop})`);
2506
3175
  return;
2507
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
+ }
2508
3203
  // All changes are in deleted lines (nothing to render, just clear)
2509
3204
  if (firstChanged >= newLines.length) {
2510
3205
  if (this.#previousLines.length > newLines.length) {
@@ -2538,12 +3233,21 @@ export class TUI extends Container {
2538
3233
  if (moveUp > 0) {
2539
3234
  buffer += `\x1b[${moveUp}A`;
2540
3235
  }
2541
- this.#cursorRow = targetRow;
2542
3236
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, targetRow);
2543
- this.#hardwareCursorRow = toRow;
2544
3237
  buffer += seq;
2545
3238
  buffer += "\x1b[?2026l";
2546
- 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;
2547
3251
  }
2548
3252
  this.#previousLines = newLines;
2549
3253
  this.#previousWidth = width;
@@ -2658,7 +3362,6 @@ export class TUI extends Container {
2658
3362
  }
2659
3363
 
2660
3364
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, finalCursorRow);
2661
- this.#hardwareCursorRow = toRow;
2662
3365
  buffer += seq;
2663
3366
  buffer += "\x1b[?2026l"; // End synchronized output
2664
3367
 
@@ -2692,20 +3395,22 @@ export class TUI extends Container {
2692
3395
  fs.writeFileSync(debugPath, debugData);
2693
3396
  }
2694
3397
 
2695
- // Write entire buffer at once
2696
- if (!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length)) return;
2697
-
2698
- // Track cursor position for next render.
2699
- // cursorRow tracks end of content (for viewport calculation).
2700
- // #hardwareCursorRow was already updated by #cursorControlSequence above.
2701
- this.#cursorRow = Math.max(0, newLines.length - 1);
2702
- // Track content height for viewport calculation
2703
- this.#maxLinesRendered = newLines.length;
2704
- this.#viewportTopRow = Math.max(0, newLines.length - height);
2705
-
2706
- this.#previousLines = newLines;
2707
- this.#previousWidth = width;
2708
- 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;
2709
3414
  }
2710
3415
 
2711
3416
  /**
@@ -2780,6 +3485,7 @@ export class TUI extends Container {
2780
3485
  buffer: string,
2781
3486
  cursorPos: { row: number; col: number } | null,
2782
3487
  totalLines: number,
3488
+ onBufferWritten?: () => void,
2783
3489
  ): boolean {
2784
3490
  const overlay = this.#postRenderEmitter?.();
2785
3491
  if (overlay) {
@@ -2789,8 +3495,14 @@ export class TUI extends Container {
2789
3495
  buffer += `\x1b[?2026h\x1b7${overlay}\x1b8\x1b[?2026l`;
2790
3496
  }
2791
3497
  if (!this.#writeTerminal(buffer)) return false;
2792
- if (!this.#imeCursorActive) return true;
2793
- 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;
2794
3506
  }
2795
3507
 
2796
3508
  /**
@@ -2803,8 +3515,9 @@ export class TUI extends Container {
2803
3515
  return this.#hideCursor();
2804
3516
  }
2805
3517
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, totalLines, this.#hardwareCursorRow);
2806
- this.#hardwareCursorRow = toRow;
2807
3518
  // No \x1b[?2026h/l wrapper: synchronized output flushes terminal state and discards macOS IME composition.
2808
- return this.#writeTerminal(seq);
3519
+ if (!this.#writeTerminal(seq)) return false;
3520
+ this.#hardwareCursorRow = toRow;
3521
+ return true;
2809
3522
  }
2810
3523
  }