@gajae-code/tui 0.11.11 → 0.12.1

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
@@ -10,9 +10,12 @@ import { isKeyRelease } from "./keys";
10
10
  import { renderMetrics } from "./metrics";
11
11
  import type { Terminal } from "./terminal";
12
12
  import {
13
+ encodeKittyPlacementDelete,
14
+ extractKittyPlacementReferences,
13
15
  ImageProtocol,
14
16
  isImageProtocolForced,
15
17
  isUnderTerminalMultiplexer,
18
+ type KittyPlacementReference,
16
19
  setCellDimensions,
17
20
  setTerminalImageProtocol,
18
21
  TERMINAL,
@@ -39,6 +42,11 @@ const SEGMENT_RESET = "\x1b[0m";
39
42
  */
40
43
  const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x07";
41
44
  const MOUSE_SELECTION_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
45
+ /** Discrete mouse-wheel notch size in terminal rows (xterm/less-style). */
46
+ export const DEFAULT_WHEEL_LINES = 3;
47
+
48
+ /** DA1 (`CSI ? … c`) and XTSMGRAPHICS (`CSI ? … S`) replies to the sixel probe. */
49
+ const DEVICE_REPORT_PATTERN = /^\x1b\[\?[\d;]*[cS]$/u;
42
50
 
43
51
  function stripTerminalControls(text: string): string {
44
52
  return Bun.stripANSI(text)
@@ -190,6 +198,11 @@ export interface ViewportAnchorSource {
190
198
  id: ViewportAnchorId;
191
199
  }
192
200
 
201
+ /** Identity and monotonic revision of the logical output producer. */
202
+ export type ViewportOutputSource = {
203
+ identity: string;
204
+ revision: bigint;
205
+ };
193
206
  export interface ViewportAnchorSourceRenderer extends Component {
194
207
  renderWithViewportAnchorSource(width: number, source: ViewportAnchorSource): ViewportAnchorRender;
195
208
  }
@@ -623,6 +636,27 @@ type TuiRenderCounterSnapshot = {
623
636
  debugRedrawAppendWrites: number;
624
637
  differentialGuardVisibleWidthCalls: number;
625
638
  };
639
+ type RenderCommitWaiter = {
640
+ resolve: (committed: boolean) => void;
641
+ timer: NodeJS.Timeout;
642
+ };
643
+
644
+ type KittyPlacementOwner = "transcript" | "suffix" | "overlay";
645
+
646
+ type KittyPlacementSpan = KittyPlacementReference & {
647
+ row: number;
648
+ owner: KittyPlacementOwner;
649
+ };
650
+
651
+ type KittyPlacementRegion = {
652
+ top: number;
653
+ bottom: number;
654
+ };
655
+
656
+ type KittyPlacementDeletePlan = {
657
+ deletedKeys: Set<string>;
658
+ output: string;
659
+ };
626
660
 
627
661
  /**
628
662
  * TUI - Main class for managing terminal UI with differential rendering
@@ -631,6 +665,9 @@ export class TUI extends Container {
631
665
  terminal: Terminal;
632
666
  #previousLines: string[] = [];
633
667
  #latestRenderedLines: string[] = [];
668
+ #latestRenderedTranscriptLineCount = 0;
669
+ #latestRenderedSuffixLineCount = 0;
670
+ #latestRenderedPlacementOwners = new Map<string, KittyPlacementOwner>();
634
671
  /**
635
672
  * Raw (pre-normalization) lines from the previous frame, kept only when the
636
673
  * virtual-viewport flag is on. Used to detect whether the off-screen prefix is
@@ -638,6 +675,7 @@ export class TUI extends Container {
638
675
  * return stable string instances) so its normalized form can be reused (bounded normalize).
639
676
  */
640
677
  #previousRaw: string[] = [];
678
+ #kittyPlacementSpans: KittyPlacementSpan[] = [];
641
679
  #lineNormalizationCache = new Map<string, LineNormalizationCacheEntry>();
642
680
  #lineEmitWidthCache = new Map<string, number>();
643
681
  #lineTruncationCache = new Map<string, string>();
@@ -651,7 +689,28 @@ export class TUI extends Container {
651
689
  /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
652
690
  onDebug?: () => void;
653
691
  #renderRequested = false;
692
+ #nextRenderGeneration = 0;
693
+ #renderRequestedGeneration = 0;
694
+ #committedRenderGeneration = 0;
695
+ #renderCommitWaiters = new Map<number, Set<RenderCommitWaiter>>();
696
+ #lastRenderWriteSucceeded = false;
654
697
  #renderTimer: NodeJS.Timeout | undefined;
698
+ #widthSettleTimer: NodeJS.Timeout | undefined;
699
+ #widthSettleRepairPending = false;
700
+ #lastObservedWidth = 0;
701
+ // Trailing debounce for the settled width repair. Instance-local: taken from
702
+ // options.widthSettleMs when provided (deterministic harnesses pass 0 to
703
+ // disable), otherwise from GJC_TUI_WIDTH_SETTLE_MS / PI_TUI_WIDTH_SETTLE_MS,
704
+ // otherwise 1000. Sampled once at construction.
705
+ #widthSettleMs: number = TUI.#readWidthSettleMs();
706
+ static readonly #WIDTH_SETTLE_MS = 1000;
707
+
708
+ static #readWidthSettleMs(): number {
709
+ const raw = Bun.env.GJC_TUI_WIDTH_SETTLE_MS ?? Bun.env.PI_TUI_WIDTH_SETTLE_MS;
710
+ if (raw === undefined || raw === "") return TUI.#WIDTH_SETTLE_MS;
711
+ const parsed = Number.parseInt(raw, 10);
712
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : TUI.#WIDTH_SETTLE_MS;
713
+ }
655
714
  #lastRenderAt = 0;
656
715
  static readonly #MIN_RENDER_INTERVAL_MS = 16;
657
716
  // Input-priority scheduling: an input keystroke must never be starved behind a
@@ -662,6 +721,9 @@ export class TUI extends Container {
662
721
  #cursorRow = 0; // Logical cursor row (end of rendered content)
663
722
  #hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
664
723
  #viewportTopRow = 0; // Content row currently mapped to screen row 0
724
+ #scrollbackResumeViewportTop: number | undefined; // Reflowed history below this frontier is already committed
725
+ #nativeScrollbackViewportTop = 0;
726
+ #transcriptIdentityResetPending = false;
665
727
  #manualViewportTop: number | undefined;
666
728
  #viewportAnchorComponent: Component | null = null;
667
729
  #viewportAnchorFrame: ViewportAnchorFrame | null = null;
@@ -694,6 +756,12 @@ export class TUI extends Container {
694
756
  #mouseSelectionStart: MouseSelectionPoint | null = null;
695
757
  #mouseSelectionEnd: MouseSelectionPoint | null = null;
696
758
  #mouseSelectionDragged = false;
759
+ #viewportOutputSource: ViewportOutputSource | null = null;
760
+ #manualOutputNotice = false;
761
+ #manualTranscriptLineCount = 0;
762
+ #manualSuffixLineCount = 0;
763
+ #committedTranscriptRows: Array<number | null> = [];
764
+ #paintedManualOutputNotice = false;
697
765
 
698
766
  #unsubscribeTabWidthChange?: () => void;
699
767
  static #renderCounters: TuiRenderCounterSnapshot = {
@@ -746,6 +814,13 @@ export class TUI extends Container {
746
814
  private readonly options: {
747
815
  enableMouse?: boolean;
748
816
  copySelection?: (text: string) => void | Promise<void>;
817
+ /**
818
+ * Trailing debounce for the settled width repair, in ms. `0` disables the
819
+ * settled repair (deterministic harnesses need this — a wall-clock-timed
820
+ * full replay lands at nondeterministic logical positions). Defaults to
821
+ * `GJC_TUI_WIDTH_SETTLE_MS` / `PI_TUI_WIDTH_SETTLE_MS`, then 1000.
822
+ */
823
+ widthSettleMs?: number;
749
824
  } = {},
750
825
  ) {
751
826
  super();
@@ -753,6 +828,9 @@ export class TUI extends Container {
753
828
  if (showHardwareCursor !== undefined) {
754
829
  this.#showHardwareCursor = showHardwareCursor;
755
830
  }
831
+ if (options.widthSettleMs !== undefined && Number.isFinite(options.widthSettleMs) && options.widthSettleMs >= 0) {
832
+ this.#widthSettleMs = options.widthSettleMs;
833
+ }
756
834
  this.#imeCursorActive = !this.#showHardwareCursor && this.#useImeBlockCursor;
757
835
  this.#unsubscribeTabWidthChange = onDefaultTabWidthChange(() => {
758
836
  this.#lineTruncationCache.clear();
@@ -813,11 +891,58 @@ export class TUI extends Container {
813
891
  }
814
892
  }
815
893
 
894
+ override removeChild(component: Component): void {
895
+ this.#invalidateFocusForRemovedTree(component);
896
+ super.removeChild(component);
897
+ }
898
+
899
+ override clear(): void {
900
+ for (const child of this.children) this.#invalidateFocusForRemovedTree(child);
901
+ super.clear();
902
+ }
903
+
904
+ #invalidateFocusForRemovedTree(component: Component): void {
905
+ if (this.#focusedComponent !== null && this.#containsComponent(component, this.#focusedComponent)) {
906
+ this.setFocus(null);
907
+ }
908
+ }
909
+
910
+ #containsComponent(root: Component, target: Component): boolean {
911
+ if (root === target) return true;
912
+ return root instanceof Container && root.children.some(child => this.#containsComponent(child, target));
913
+ }
914
+
816
915
  setBottomPinnedComponent(component: Component | null): void {
817
916
  this.#bottomPinnedComponent = component;
818
917
  this.requestRender();
819
918
  }
820
919
 
920
+ /** Report the logical output producer revision without coupling TUI to message types. */
921
+ setViewportOutputSource(source: ViewportOutputSource | null): void {
922
+ const previous = this.#viewportOutputSource;
923
+ if (
924
+ (source === null && previous === null) ||
925
+ (source !== null &&
926
+ previous !== null &&
927
+ source.identity === previous.identity &&
928
+ source.revision === previous.revision)
929
+ ) {
930
+ renderMetrics.recordStructuralCounter("viewportOutputSourceEqualNoops");
931
+ return;
932
+ }
933
+ const identityReset = source === null || previous === null || previous.identity !== source.identity;
934
+ // Same-identity revisions are a high-water mark. A delayed stale observation
935
+ // must not lower it, because observing that revision again would otherwise
936
+ // look like fresh output while the user owns the manual viewport.
937
+ if (!identityReset && source.revision < previous.revision) return;
938
+ if (!identityReset && source.revision > previous.revision && this.#manualViewportTop !== undefined) {
939
+ this.#manualOutputNotice = true;
940
+ }
941
+ if (identityReset || this.#manualViewportTop === undefined) this.#manualOutputNotice = false;
942
+ this.#viewportOutputSource = source;
943
+ this.requestRender();
944
+ }
945
+
821
946
  /** Register the direct child whose rows are eligible for semantic viewport anchoring. */
822
947
  setViewportAnchorComponent(component: Component | null): void {
823
948
  if (component !== null && !isViewportAnchorProvider(component)) {
@@ -835,6 +960,21 @@ export class TUI extends Container {
835
960
  this.#manualViewportFallbackAnchors = [];
836
961
  this.#reconcileMissingViewportAnchor = false;
837
962
  this.#viewportAnchorFrame = null;
963
+ this.#scrollbackResumeViewportTop = undefined;
964
+ this.#nativeScrollbackViewportTop = 0;
965
+ this.#transcriptIdentityResetPending = true;
966
+ this.#manualOutputNotice = false;
967
+ this.#paintedManualOutputNotice = false;
968
+ this.#committedTranscriptRows = [];
969
+ // The old transcript identity is being replaced wholesale, which supersedes
970
+ // any stale old-width artifact a deferred settle repair would have fixed.
971
+ // Cancel both the armed timer and a pending deferred repair so an unrelated
972
+ // later render cannot trigger an out-of-window full clear+replay.
973
+ if (this.#widthSettleTimer) {
974
+ clearTimeout(this.#widthSettleTimer);
975
+ this.#widthSettleTimer = undefined;
976
+ }
977
+ this.#widthSettleRepairPending = false;
838
978
  }
839
979
 
840
980
  /** Allow one semantic-neighbor reconciliation after a definitive same-transcript rebuild. */
@@ -847,22 +987,36 @@ export class TUI extends Container {
847
987
  const height = this.terminal.rows;
848
988
  const width = this.terminal.columns;
849
989
  const frame = this.#viewportAnchorFrame;
850
- if (height <= 0 || width <= 0 || this.#previousLines.length === 0 || frame === null) return false;
990
+ const transcriptCapacity = this.#manualTranscriptCapacity(height);
991
+ if (height <= 0 || width <= 0 || transcriptCapacity === 0 || this.#previousLines.length === 0 || frame === null)
992
+ return false;
851
993
 
852
- const selectedRow = frame.anchors.findIndex(anchor => anchor?.id === id);
994
+ let selectedRow = frame.anchors.findIndex(anchor => anchor?.id === id);
995
+ if (alignment === "bottom") {
996
+ for (let row = frame.anchors.length - 1; row >= 0; row--) {
997
+ if (frame.anchors[row]?.id === id) {
998
+ selectedRow = row;
999
+ break;
1000
+ }
1001
+ }
1002
+ }
853
1003
  const selected = selectedRow < 0 ? null : frame.anchors[selectedRow];
854
1004
  if (selected === null) return false;
855
1005
 
856
- const desiredScreenRow = alignment === "top" ? 0 : alignment === "center" ? Math.floor(height / 2) : height - 1;
1006
+ const desiredScreenRow =
1007
+ alignment === "top" ? 0 : alignment === "center" ? Math.floor(transcriptCapacity / 2) : transcriptCapacity - 1;
857
1008
  const targetViewportTop = Math.max(0, frame.startRow + selectedRow - desiredScreenRow);
858
1009
  this.#manualViewportAnchor = {
859
1010
  id: selected.id,
860
- graphemeIndex: selected.graphemeStart,
861
- cellOffset: selected.cellStart,
1011
+ graphemeIndex:
1012
+ alignment === "bottom"
1013
+ ? Math.max(selected.graphemeStart, selected.graphemeEnd - 1)
1014
+ : selected.graphemeStart,
1015
+ cellOffset: alignment === "bottom" ? Math.max(selected.cellStart, selected.cellEnd - 1) : selected.cellStart,
862
1016
  desiredScreenRow,
863
1017
  };
864
1018
  const firstCandidateRow = Math.max(0, targetViewportTop - frame.startRow);
865
- const lastCandidateRow = Math.min(frame.anchors.length, targetViewportTop + height - frame.startRow);
1019
+ const lastCandidateRow = Math.min(frame.anchors.length, targetViewportTop + transcriptCapacity - frame.startRow);
866
1020
  const fallbacks: ManualViewportAnchor[] = [];
867
1021
  for (let row = firstCandidateRow; row < lastCandidateRow; row++) {
868
1022
  const anchor = frame.anchors[row];
@@ -886,11 +1040,28 @@ export class TUI extends Container {
886
1040
  return true;
887
1041
  }
888
1042
 
889
- scrollViewportPages(direction: -1 | 1): boolean {
1043
+ scrollViewportBy(
1044
+ deltaRows: number,
1045
+ options?: {
1046
+ /** edge: PageUp/PageDown pin; stable: preserve/center pin for fine wheel motion */
1047
+ pin?: "edge" | "stable";
1048
+ },
1049
+ ): boolean {
890
1050
  const height = this.terminal.rows;
891
1051
  const width = this.terminal.columns;
892
1052
  if (height <= 0 || width <= 0 || this.#previousLines.length === 0) return false;
893
- const maxViewportTop = Math.max(0, this.#previousLines.length - height);
1053
+ if (!Number.isFinite(deltaRows)) return false;
1054
+ const delta = Math.trunc(deltaRows);
1055
+ if (delta === 0) return false;
1056
+ const previousManualViewportTop = this.#manualViewportTop;
1057
+ const previousManualViewportAnchor = this.#manualViewportAnchor;
1058
+ const previousManualViewportFallbackAnchors = this.#manualViewportFallbackAnchors;
1059
+ const previousReconcileMissingViewportAnchor = this.#reconcileMissingViewportAnchor;
1060
+
1061
+ const direction: -1 | 1 = delta < 0 ? -1 : 1;
1062
+ const pin = options?.pin ?? "stable";
1063
+ const transcriptCapacity = this.#manualTranscriptCapacity(height);
1064
+ const maxViewportTop = Math.max(0, this.#manualTranscriptLineCount - transcriptCapacity);
894
1065
  let currentViewportTop = Math.max(0, Math.min(maxViewportTop, this.#manualViewportTop ?? this.#viewportTopRow));
895
1066
  const frame = this.#viewportAnchorFrame;
896
1067
  if (this.#manualViewportAnchor !== null) {
@@ -899,16 +1070,29 @@ export class TUI extends Container {
899
1070
  if (resolvedViewportTop === null) return false;
900
1071
  currentViewportTop = Math.max(0, Math.min(maxViewportTop, resolvedViewportTop));
901
1072
  }
902
- const targetViewportTop = Math.max(
903
- 0,
904
- Math.min(maxViewportTop, currentViewportTop + direction * Math.max(1, height - 1)),
905
- );
1073
+ const targetViewportTop = Math.max(0, Math.min(maxViewportTop, currentViewportTop + delta));
1074
+ // Downward input at an already-live bottom is a no-op; it must not silently
1075
+ // acquire manual ownership and freeze the next semantic output. Manual owners
1076
+ // that reach the same boundary transition through the existing live transaction.
1077
+ if (direction > 0 && targetViewportTop === maxViewportTop) {
1078
+ if (this.#manualViewportTop === undefined && currentViewportTop === maxViewportTop) return true;
1079
+ if (this.#manualViewportTop !== undefined) return this.followLiveViewport();
1080
+ }
906
1081
  if (frame !== null) {
907
- const desiredScreenRow = this.#manualViewportAnchor?.desiredScreenRow ?? (direction < 0 ? 0 : height - 1);
1082
+ const desiredScreenRow =
1083
+ this.#manualViewportAnchor?.desiredScreenRow ??
1084
+ (pin === "edge"
1085
+ ? direction < 0
1086
+ ? 0
1087
+ : Math.max(0, transcriptCapacity - 1)
1088
+ : Math.floor(transcriptCapacity / 2));
908
1089
  const targetRow = targetViewportTop + desiredScreenRow - frame.startRow;
909
1090
  let selected: { row: number; anchor: ViewportAnchorRow } | undefined;
910
1091
  const firstCandidateRow = Math.max(0, targetViewportTop - frame.startRow);
911
- const lastCandidateRow = Math.min(frame.anchors.length, targetViewportTop + height - frame.startRow);
1092
+ const lastCandidateRow = Math.min(
1093
+ frame.anchors.length,
1094
+ targetViewportTop + transcriptCapacity - frame.startRow,
1095
+ );
912
1096
  for (let row = firstCandidateRow; row < lastCandidateRow; row++) {
913
1097
  const anchor = frame.anchors[row];
914
1098
  if (anchor === null) continue;
@@ -963,7 +1147,8 @@ export class TUI extends Container {
963
1147
  }
964
1148
  }
965
1149
  this.#manualViewportTop = targetViewportTop;
966
- return this.#repaintViewportFromLines(
1150
+ let contentPainted = false;
1151
+ const painted = this.#repaintViewportFromLines(
967
1152
  this.#previousLines,
968
1153
  width,
969
1154
  height,
@@ -971,29 +1156,93 @@ export class TUI extends Container {
971
1156
  null,
972
1157
  "manual viewport scroll",
973
1158
  this.#manualViewportAnchor !== null,
1159
+ () => {
1160
+ contentPainted = true;
1161
+ this.#manualTranscriptLineCount = this.#latestRenderedTranscriptLineCount;
1162
+ this.#manualSuffixLineCount = this.#latestRenderedSuffixLineCount;
1163
+ },
1164
+ false,
1165
+ this.#kittyPlacementSpans,
1166
+ this.#kittyPlacementSpansForLines(this.#previousLines, this.#latestRenderedPlacementOwners),
1167
+ {
1168
+ transcriptLineCount: this.#latestRenderedTranscriptLineCount,
1169
+ suffixLineCount: this.#latestRenderedSuffixLineCount,
1170
+ },
974
1171
  );
1172
+ if (!contentPainted) {
1173
+ this.#manualViewportTop = previousManualViewportTop;
1174
+ this.#manualViewportAnchor = previousManualViewportAnchor;
1175
+ this.#manualViewportFallbackAnchors = previousManualViewportFallbackAnchors;
1176
+ this.#reconcileMissingViewportAnchor = previousReconcileMissingViewportAnchor;
1177
+ }
1178
+ return painted;
1179
+ }
1180
+
1181
+ scrollViewportPages(direction: -1 | 1): boolean {
1182
+ const height = this.terminal.rows;
1183
+ return this.scrollViewportBy(direction * Math.max(1, this.#manualTranscriptCapacity(height) - 1), {
1184
+ pin: "edge",
1185
+ });
975
1186
  }
976
1187
 
977
1188
  followLiveViewport(): boolean {
978
1189
  if (this.#manualViewportTop === undefined) return false;
979
1190
  const height = this.terminal.rows;
980
1191
  const width = this.terminal.columns;
981
- const liveLines = this.#latestRenderedLines;
1192
+ const paddedLiveLines = this.#padBeforeBottomPinnedComponent(
1193
+ this.#latestRenderedLines,
1194
+ height,
1195
+ this.#latestRenderedSuffixLineCount,
1196
+ );
1197
+ const liveLines = paddedLiveLines.lines;
1198
+ const liveTranscriptLineCount = this.#latestRenderedTranscriptLineCount;
1199
+ const liveSuffixLineCount = this.#latestRenderedSuffixLineCount + paddedLiveLines.insertedBlankRows;
1200
+ const liveKittyPlacementSpans = this.#kittyPlacementSpansForLines(liveLines, this.#latestRenderedPlacementOwners);
1201
+ let liveCursorPosition = this.#lastCursorPosition;
1202
+ if (liveCursorPosition !== null && liveCursorPosition.row >= paddedLiveLines.insertionRow) {
1203
+ liveCursorPosition = {
1204
+ ...liveCursorPosition,
1205
+ row: liveCursorPosition.row + paddedLiveLines.insertedBlankRows,
1206
+ };
1207
+ }
982
1208
  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(
1209
+ return this.#repaintViewportFromLines(
988
1210
  liveLines,
989
1211
  width,
990
1212
  height,
991
1213
  liveViewportTop,
992
- this.#lastCursorPosition,
1214
+ liveCursorPosition,
993
1215
  "manual viewport follow live",
1216
+ false,
1217
+ () => {
1218
+ this.#manualViewportTop = undefined;
1219
+ this.#manualViewportAnchor = null;
1220
+ this.#manualViewportFallbackAnchors = [];
1221
+ this.#reconcileMissingViewportAnchor = false;
1222
+ this.#manualOutputNotice = false;
1223
+ this.#committedTranscriptRows = [];
1224
+ this.#paintedManualOutputNotice = false;
1225
+ this.#lastCursorPosition = liveCursorPosition;
1226
+ this.#previousLines = liveLines;
1227
+ this.#manualTranscriptLineCount = liveTranscriptLineCount;
1228
+ this.#manualSuffixLineCount = liveSuffixLineCount;
1229
+ if (this.#scrollbackResumeViewportTop === undefined) {
1230
+ this.#nativeScrollbackViewportTop = liveViewportTop;
1231
+ }
1232
+ // A settled width repair was deferred while the user was reading
1233
+ // scrollback (repainting mid-read would have destroyed their
1234
+ // position). The transactional live repaint above has committed, so
1235
+ // manual state is safely released — now schedule the deferred full
1236
+ // clear+replay that repairs old-width wrapping in history.
1237
+ if (this.#widthSettleRepairPending) {
1238
+ this.requestRender(true, "resize.width-settled.deferred");
1239
+ }
1240
+ },
1241
+ true,
1242
+ this.#kittyPlacementSpans,
1243
+ liveKittyPlacementSpans,
1244
+ { transcriptLineCount: liveTranscriptLineCount, suffixLineCount: liveSuffixLineCount },
994
1245
  );
995
- if (repainted) this.#previousLines = liveLines;
996
- return repainted;
997
1246
  }
998
1247
 
999
1248
  /**
@@ -1097,6 +1346,9 @@ export class TUI extends Container {
1097
1346
  start(): void {
1098
1347
  this.#stopped = false;
1099
1348
  this.#terminalUnavailable = false;
1349
+ // Seed the observed width so a spurious post-start resize event (iTerm2 tab
1350
+ // activation, the self-sent SIGWINCH after resume) is not read as a reflow.
1351
+ this.#lastObservedWidth = this.terminal.columns;
1100
1352
  this.terminal.setMouseEnabled?.(this.options.enableMouse === true);
1101
1353
  this.terminal.start(
1102
1354
  data => this.#handleInput(data),
@@ -1112,6 +1364,57 @@ export class TUI extends Container {
1112
1364
  this.requestRender(true);
1113
1365
  }
1114
1366
 
1367
+ /**
1368
+ * Wait for a specific render request generation to be written successfully.
1369
+ *
1370
+ * Render requests are coalesced, so committing a newer generation also commits
1371
+ * every older generation represented by that frame. A stopped or unavailable
1372
+ * terminal resolves waiters false so UI callers can fail open instead of
1373
+ * holding a session operation behind a dead renderer.
1374
+ */
1375
+ waitForRenderCommit(generation: number, timeoutMs = 250): Promise<boolean> {
1376
+ if (generation <= 0 || generation <= this.#committedRenderGeneration) return Promise.resolve(true);
1377
+ if (this.#stopped || !this.terminalAvailable) return Promise.resolve(false);
1378
+ return new Promise<boolean>(resolve => {
1379
+ const waiter: RenderCommitWaiter = {
1380
+ resolve,
1381
+ timer: setTimeout(
1382
+ () => {
1383
+ const waiters = this.#renderCommitWaiters.get(generation);
1384
+ if (waiters) {
1385
+ waiters.delete(waiter);
1386
+ if (waiters.size === 0) this.#renderCommitWaiters.delete(generation);
1387
+ }
1388
+ resolve(false);
1389
+ },
1390
+ Math.max(0, timeoutMs),
1391
+ ),
1392
+ };
1393
+ waiter.timer.unref?.();
1394
+ const waiters = this.#renderCommitWaiters.get(generation) ?? new Set();
1395
+ waiters.add(waiter);
1396
+ this.#renderCommitWaiters.set(generation, waiters);
1397
+ });
1398
+ }
1399
+
1400
+ #settleRenderCommitWaiters(committed: boolean, generation = Number.POSITIVE_INFINITY): void {
1401
+ if (committed) this.#committedRenderGeneration = Math.max(this.#committedRenderGeneration, generation);
1402
+ for (const [waiterGeneration, waiters] of this.#renderCommitWaiters) {
1403
+ if (committed && waiterGeneration > generation) continue;
1404
+ this.#renderCommitWaiters.delete(waiterGeneration);
1405
+ for (const waiter of waiters) {
1406
+ clearTimeout(waiter.timer);
1407
+ waiter.resolve(committed);
1408
+ }
1409
+ }
1410
+ }
1411
+
1412
+ #commitRenderGeneration(generation: number): void {
1413
+ if (generation <= 0) return;
1414
+ if (this.#lastRenderWriteSucceeded) this.#settleRenderCommitWaiters(true, generation);
1415
+ else if (this.#stopped || !this.terminalAvailable) this.#settleRenderCommitWaiters(false, generation);
1416
+ }
1417
+
1115
1418
  get terminalAvailable(): boolean {
1116
1419
  return !this.#terminalUnavailable && this.terminal.available;
1117
1420
  }
@@ -1120,6 +1423,7 @@ export class TUI extends Container {
1120
1423
  this.#terminalUnavailable = true;
1121
1424
  this.#stopped = true;
1122
1425
  this.#renderRequested = false;
1426
+ this.#settleRenderCommitWaiters(false);
1123
1427
  if (this.#renderTimer) {
1124
1428
  clearTimeout(this.#renderTimer);
1125
1429
  this.#renderTimer = undefined;
@@ -1316,13 +1620,29 @@ export class TUI extends Container {
1316
1620
 
1317
1621
  stop(): void {
1318
1622
  this.flushTerminalCleanup();
1623
+ const placementCleanup = this.#kittyPlacementDeletePlan(this.#kittyPlacementSpans, [], [], true).output;
1624
+ if (placementCleanup.length > 0 && this.#writeTerminal(placementCleanup)) this.#kittyPlacementSpans = [];
1319
1625
  this.#clearSixelProbeState();
1320
1626
  this.#stopped = true;
1627
+ this.#settleRenderCommitWaiters(false);
1321
1628
  if (this.#renderTimer) {
1322
1629
  clearTimeout(this.#renderTimer);
1323
1630
  this.#renderTimer = undefined;
1324
1631
  if (renderMetrics.enabled) renderMetrics.setTimerGauge("tui.renderTimer", 0);
1325
1632
  }
1633
+ if (this.#widthSettleTimer) {
1634
+ clearTimeout(this.#widthSettleTimer);
1635
+ this.#widthSettleTimer = undefined;
1636
+ }
1637
+ // An armed TIMER dies with the session, but an already-deferred repair
1638
+ // (deadline passed while the user was reading scrollback) must survive a
1639
+ // temporary stop/start (Ctrl-Z resume, external editor): manual viewport
1640
+ // ownership survives restart, so followLiveViewport() still needs the flag
1641
+ // to run the deferred repair. Without manual ownership the flag is moot —
1642
+ // start() issues a forced full render that repairs everything anyway.
1643
+ if (this.#manualViewportTop === undefined) {
1644
+ this.#widthSettleRepairPending = false;
1645
+ }
1326
1646
  // Move cursor to the end of the content to prevent overwriting/artifacts on exit
1327
1647
  if (this.#previousLines.length > 0) {
1328
1648
  const targetRow = this.#previousLines.length; // Line after the last content
@@ -1353,6 +1673,7 @@ export class TUI extends Container {
1353
1673
  this.#previousLines = [];
1354
1674
  this.#latestRenderedLines = [];
1355
1675
  this.#previousRaw = [];
1676
+ this.#kittyPlacementSpans = [];
1356
1677
  this.#lineNormalizationCache.clear();
1357
1678
  this.#lineTruncationCache.clear();
1358
1679
  this.#lineEmitWidthCache.clear();
@@ -1383,19 +1704,79 @@ export class TUI extends Container {
1383
1704
  * is a no-op otherwise.
1384
1705
  */
1385
1706
  requestResizeRender(): void {
1386
- const dimensionsChanged =
1387
- this.#previousWidth !== this.terminal.columns || this.#previousHeight !== this.terminal.rows;
1388
- this.requestRender(dimensionsChanged && !useViewportRepaintPath(this.terminal), "resize");
1707
+ // Width is tracked against the last OBSERVED terminal width, not against
1708
+ // #previousWidth (the last committed frame). Those diverge whenever resize
1709
+ // events coalesce inside one frame budget: a 100->90->100 burst would leave
1710
+ // #previousWidth at 100 the whole time, so a commit-keyed debounce would
1711
+ // never see the second transition and could skip the only repair frame.
1712
+ const observedWidth = this.terminal.columns;
1713
+ const widthChanged = observedWidth !== this.#lastObservedWidth;
1714
+ this.#lastObservedWidth = observedWidth;
1715
+ const heightChanged = this.#previousHeight !== this.terminal.rows;
1716
+ if (widthChanged) this.#scheduleWidthSettleRedraw();
1717
+ this.requestRender(heightChanged && !useViewportRepaintPath(this.terminal), "resize");
1718
+ }
1719
+
1720
+ /**
1721
+ * Width reflow leaves artifacts that the immediate resize frame does not always
1722
+ * repair: lines wrapped at the old column count can survive as stale bands — in
1723
+ * the live viewport and in scrollback history. The immediate frame is unchanged
1724
+ * by this timer — `#doRender` still promotes a real width change to
1725
+ * `fullRender`/`viewportRepaint` on the spot. What this adds is a single
1726
+ * trailing repair #WIDTH_SETTLE_MS after the last observed width change.
1727
+ *
1728
+ * The settled repair is a FULL transcript replay on every host, including
1729
+ * viewport-repaint hosts (tmux/screen/zellij, Windows Terminal, process
1730
+ * terminals) where per-SIGWINCH forced redraws are normally suppressed. That
1731
+ * per-event replay is the storm `resize-replay-storm.test.ts` pins against;
1732
+ * the debounce is what makes the full replay safe here — it happens once per
1733
+ * settled width sequence, so scrollback artifacts are repaired without
1734
+ * replaying the transcript on every resize event.
1735
+ *
1736
+ * Every observed width sequence gets exactly one repair, including one that
1737
+ * ends back at its starting width. Skipping the drag-and-return case would
1738
+ * require proving that a frame committed at the final geometry *after* the
1739
+ * final resize event, which the render pipeline does not guarantee; one extra
1740
+ * repaint is cheaper than a missed repair.
1741
+ *
1742
+ * Height-only changes are unaffected: they reflow nothing and keep their
1743
+ * existing behavior.
1744
+ */
1745
+ #scheduleWidthSettleRedraw(): void {
1746
+ if (this.#widthSettleMs <= 0) return;
1747
+ if (this.#widthSettleTimer) clearTimeout(this.#widthSettleTimer);
1748
+ this.#widthSettleTimer = setTimeout(() => {
1749
+ this.#widthSettleTimer = undefined;
1750
+ if (this.#stopped) return;
1751
+ this.#widthSettleRepairPending = true;
1752
+ // While the user is reading scrollback (manual viewport), a forced
1753
+ // clear+replay would rip them out of history mid-read. Keep the flag
1754
+ // armed instead; followLiveViewport() runs the deferred repair the
1755
+ // moment they return to live.
1756
+ if (this.#manualViewportTop !== undefined) return;
1757
+ this.requestRender(true, "resize.width-settled");
1758
+ }, this.#widthSettleMs);
1759
+ this.#widthSettleTimer.unref?.();
1389
1760
  }
1390
1761
 
1391
1762
  requestRender(force = false, source = "unknown"): void {
1763
+ this.requestRenderWithGeneration(force, source);
1764
+ }
1765
+
1766
+ requestRenderWithGeneration(force = false, source = "unknown"): number {
1767
+ const generation = ++this.#nextRenderGeneration;
1768
+ this.#renderRequestedGeneration = Math.max(this.#renderRequestedGeneration, generation);
1769
+ this.#requestRenderCore(force, source, generation);
1770
+ return generation;
1771
+ }
1772
+
1773
+ #requestRenderCore(force: boolean, source: string, generation: number): void {
1392
1774
  if (!this.terminalAvailable) {
1393
1775
  this.#markTerminalUnavailable();
1394
1776
  return;
1395
1777
  }
1396
1778
  if (renderMetrics.enabled) renderMetrics.recordRequest(source);
1397
1779
  if (force) {
1398
- const preserveViewportCursor = useViewportRepaintPath(this.terminal);
1399
1780
  // A forced full redraw supersedes any queued input-priority render.
1400
1781
  this.#inputRenderPending = false;
1401
1782
  this.#previousLines = [];
@@ -1408,12 +1789,6 @@ export class TUI extends Container {
1408
1789
  this.#previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
1409
1790
  this.#lineNormalizationCacheLimit = 0;
1410
1791
  this.#lineTruncationCacheLimit = 0;
1411
- if (!preserveViewportCursor) {
1412
- this.#cursorRow = 0;
1413
- this.#hardwareCursorRow = 0;
1414
- this.#viewportTopRow = 0;
1415
- this.#maxLinesRendered = 0;
1416
- }
1417
1792
  if (this.#renderTimer) {
1418
1793
  clearTimeout(this.#renderTimer);
1419
1794
  this.#renderTimer = undefined;
@@ -1422,12 +1797,17 @@ export class TUI extends Container {
1422
1797
  this.#renderRequested = true;
1423
1798
  process.nextTick(() => {
1424
1799
  if (this.#stopped || !this.#renderRequested) {
1800
+ this.#settleRenderCommitWaiters(false, generation);
1425
1801
  return;
1426
1802
  }
1803
+ const requestedGeneration = this.#renderRequestedGeneration;
1804
+ this.#renderRequestedGeneration = 0;
1427
1805
  this.#renderRequested = false;
1428
1806
  this.#lastRenderAt = performance.now();
1807
+ this.#lastRenderWriteSucceeded = false;
1429
1808
  const t0 = renderMetrics.now();
1430
1809
  this.#doRender();
1810
+ this.#commitRenderGeneration(requestedGeneration);
1431
1811
  if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
1432
1812
  });
1433
1813
  return;
@@ -1448,6 +1828,7 @@ export class TUI extends Container {
1448
1828
  if (this.#renderRequested) return;
1449
1829
  this.#renderRequested = true;
1450
1830
  process.nextTick(() => this.#scheduleRender());
1831
+ return;
1451
1832
  }
1452
1833
 
1453
1834
  #scheduleRender(): void {
@@ -1462,10 +1843,14 @@ export class TUI extends Container {
1462
1843
  if (this.#stopped || !this.#renderRequested) {
1463
1844
  return;
1464
1845
  }
1846
+ const requestedGeneration = this.#renderRequestedGeneration;
1847
+ this.#renderRequestedGeneration = 0;
1465
1848
  this.#renderRequested = false;
1466
1849
  this.#lastRenderAt = performance.now();
1850
+ this.#lastRenderWriteSucceeded = false;
1467
1851
  const t0 = renderMetrics.now();
1468
1852
  this.#doRender();
1853
+ this.#commitRenderGeneration(requestedGeneration);
1469
1854
  if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
1470
1855
  if (this.#renderRequested) {
1471
1856
  this.#scheduleRender();
@@ -1488,10 +1873,14 @@ export class TUI extends Container {
1488
1873
  this.#renderTimer = undefined;
1489
1874
  if (renderMetrics.enabled) renderMetrics.setTimerGauge("tui.renderTimer", 0);
1490
1875
  }
1876
+ const requestedGeneration = this.#renderRequestedGeneration;
1877
+ this.#renderRequestedGeneration = 0;
1491
1878
  this.#renderRequested = false;
1492
1879
  this.#lastRenderAt = performance.now();
1880
+ this.#lastRenderWriteSucceeded = false;
1493
1881
  const t0 = renderMetrics.now();
1494
1882
  this.#doRender();
1883
+ this.#commitRenderGeneration(requestedGeneration);
1495
1884
  if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
1496
1885
  }
1497
1886
 
@@ -1519,7 +1908,7 @@ export class TUI extends Container {
1519
1908
  if (mouse.x > this.terminal.columns || mouse.y > this.terminal.rows) return;
1520
1909
  if (mouse.kind === "wheel") {
1521
1910
  this.#clearMouseSelection();
1522
- this.scrollViewportPages(mouse.direction!);
1911
+ this.scrollViewportBy(mouse.direction! * DEFAULT_WHEEL_LINES, { pin: "stable" });
1523
1912
  } else if (mouse.kind === "click") {
1524
1913
  this.#beginMouseSelection(mouse);
1525
1914
  const focusedOverlay = this.overlayStack.find(o => o.component === this.#focusedComponent);
@@ -1563,6 +1952,15 @@ export class TUI extends Container {
1563
1952
  return;
1564
1953
  }
1565
1954
 
1955
+ // DA1 and XTSMGRAPHICS replies belong to the sixel probe, whose listener runs
1956
+ // above. Reaching this point means the probe already finished, timed out, or
1957
+ // was cleared by a stop()/start() cycle while the terminal still owed the
1958
+ // reply. These are terminal-to-host reports, never user input, so drop them
1959
+ // instead of typing them into the focused component.
1960
+ if (DEVICE_REPORT_PATTERN.test(data)) {
1961
+ return;
1962
+ }
1963
+
1566
1964
  // Global debug key handler (registry: tui.global.debug, default Shift+Ctrl+D)
1567
1965
  if (getKeybindings().matches(data, "tui.global.debug") && this.onDebug) {
1568
1966
  this.onDebug();
@@ -1595,16 +1993,23 @@ export class TUI extends Container {
1595
1993
  }
1596
1994
  }
1597
1995
 
1598
- #mouseSelectionPoint(mouse: MouseEvent): MouseSelectionPoint {
1599
- return {
1600
- line: this.#viewportTopRow + mouse.y - 1,
1601
- column: mouse.x - 1,
1602
- };
1996
+ #mouseSelectionPoint(mouse: MouseEvent): MouseSelectionPoint | null {
1997
+ if (this.#manualViewportTop === undefined) {
1998
+ return { line: this.#viewportTopRow + mouse.y - 1, column: mouse.x - 1 };
1999
+ }
2000
+ const line = this.#committedTranscriptRows[mouse.y - 1];
2001
+ return line === null || line === undefined || line < 0 || line >= this.#manualTranscriptLineCount
2002
+ ? null
2003
+ : { line, column: mouse.x - 1 };
1603
2004
  }
1604
2005
 
1605
2006
  #beginMouseSelection(mouse: MouseEvent): void {
1606
2007
  if (!this.options.copySelection) return;
1607
2008
  const point = this.#mouseSelectionPoint(mouse);
2009
+ if (point === null) {
2010
+ this.#clearMouseSelection();
2011
+ return;
2012
+ }
1608
2013
  this.#mouseSelectionStart = point;
1609
2014
  this.#mouseSelectionEnd = point;
1610
2015
  this.#mouseSelectionDragged = false;
@@ -1612,13 +2017,16 @@ export class TUI extends Container {
1612
2017
 
1613
2018
  #updateMouseSelection(mouse: MouseEvent): void {
1614
2019
  if (this.#mouseSelectionStart === null) return;
1615
- this.#mouseSelectionEnd = this.#mouseSelectionPoint(mouse);
2020
+ const point = this.#mouseSelectionPoint(mouse);
2021
+ if (point === null) return;
2022
+ this.#mouseSelectionEnd = point;
1616
2023
  this.#mouseSelectionDragged = true;
1617
2024
  }
1618
2025
 
1619
2026
  #finishMouseSelection(mouse: MouseEvent): void {
1620
2027
  if (this.#mouseSelectionStart === null) return;
1621
- this.#mouseSelectionEnd = this.#mouseSelectionPoint(mouse);
2028
+ const point = this.#mouseSelectionPoint(mouse);
2029
+ if (point !== null) this.#mouseSelectionEnd = point;
1622
2030
  if (!this.#mouseSelectionDragged || !this.options.copySelection) {
1623
2031
  this.#clearMouseSelection();
1624
2032
  return;
@@ -1872,7 +2280,12 @@ export class TUI extends Container {
1872
2280
  }
1873
2281
 
1874
2282
  /** Composite all overlays into content lines (in stack order, later = on top). */
1875
- #compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] {
2283
+ #compositeOverlays(
2284
+ lines: string[],
2285
+ termWidth: number,
2286
+ termHeight: number,
2287
+ placementOwners?: Map<string, KittyPlacementOwner>,
2288
+ ): string[] {
1876
2289
  if (this.overlayStack.length === 0) return lines;
1877
2290
  const result = [...lines];
1878
2291
  for (const entry of this.overlayStack) entry.mouseBounds = undefined;
@@ -1933,6 +2346,11 @@ export class TUI extends Container {
1933
2346
  // (components should already respect width, but this ensures it)
1934
2347
  const truncatedOverlayLine =
1935
2348
  visibleWidth(overlayLines[i]) > w ? sliceByColumn(overlayLines[i], 0, w, true) : overlayLines[i];
2349
+ if (placementOwners !== undefined) {
2350
+ for (const placement of extractKittyPlacementReferences(truncatedOverlayLine)) {
2351
+ placementOwners.set(this.#kittyPlacementKey(placement), "overlay");
2352
+ }
2353
+ }
1936
2354
  result[idx] = this.#compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth);
1937
2355
  modifiedLines.add(idx);
1938
2356
  }
@@ -2150,33 +2568,194 @@ export class TUI extends Container {
2150
2568
  return lines;
2151
2569
  }
2152
2570
 
2153
- #padBeforeBottomPinnedComponent(
2571
+ #kittyPlacementKey(reference: KittyPlacementReference): string {
2572
+ return `${reference.imageId}:${reference.placementId}`;
2573
+ }
2574
+
2575
+ #kittyPlacementSpansForLines(
2154
2576
  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;
2577
+ owners: ReadonlyMap<string, KittyPlacementOwner>,
2578
+ ): KittyPlacementSpan[] {
2579
+ const placements: KittyPlacementSpan[] = [];
2580
+ for (let row = 0; row < lines.length; row++) {
2581
+ for (const placement of extractKittyPlacementReferences(lines[row])) {
2582
+ placements.push({
2583
+ ...placement,
2584
+ row,
2585
+ owner: owners.get(this.#kittyPlacementKey(placement)) ?? "transcript",
2586
+ });
2587
+ }
2588
+ }
2589
+ return placements;
2590
+ }
2591
+
2592
+ #kittyPlacementIntersectsRegion(placement: KittyPlacementSpan, region: KittyPlacementRegion): boolean {
2593
+ return placement.row < region.bottom && placement.row + placement.rows > region.top;
2594
+ }
2595
+
2596
+ #kittyPlacementDeletePlan(
2597
+ previous: KittyPlacementSpan[],
2598
+ next: KittyPlacementSpan[],
2599
+ overwrittenRegions: KittyPlacementRegion[],
2600
+ deleteAll = false,
2601
+ overwrittenOwners: KittyPlacementOwner[] = [],
2602
+ ): KittyPlacementDeletePlan {
2603
+ const deletedKeys = new Set<string>();
2604
+ if (TERMINAL.imageProtocol !== ImageProtocol.Kitty) return { deletedKeys, output: "" };
2605
+ const nextByKey = new Map(next.map(placement => [this.#kittyPlacementKey(placement), placement]));
2606
+ let output = "";
2607
+ for (const placement of previous) {
2608
+ const key = this.#kittyPlacementKey(placement);
2609
+ if (deletedKeys.has(key)) continue;
2610
+ const candidate = nextByKey.get(key);
2611
+ const changed =
2612
+ candidate === undefined || candidate.row !== placement.row || candidate.rows !== placement.rows;
2613
+ const overwritten =
2614
+ deleteAll ||
2615
+ overwrittenOwners.includes(placement.owner) ||
2616
+ overwrittenRegions.some(region => this.#kittyPlacementIntersectsRegion(placement, region));
2617
+ if (!changed && !overwritten) continue;
2618
+ deletedKeys.add(key);
2619
+ output += encodeKittyPlacementDelete(placement);
2620
+ }
2621
+ return { deletedKeys, output };
2622
+ }
2623
+
2624
+ #kittyCommittedPlacementsAfterPaint(
2625
+ previous: KittyPlacementSpan[],
2626
+ next: KittyPlacementSpan[],
2627
+ deletePlan: KittyPlacementDeletePlan,
2628
+ emittedRegions: KittyPlacementRegion[],
2629
+ ): KittyPlacementSpan[] {
2630
+ const committed = new Map<string, KittyPlacementSpan>();
2631
+ for (const placement of previous) {
2632
+ const key = this.#kittyPlacementKey(placement);
2633
+ if (!deletePlan.deletedKeys.has(key)) committed.set(key, placement);
2634
+ }
2635
+ for (const placement of next) {
2636
+ if (!emittedRegions.some(region => placement.row >= region.top && placement.row < region.bottom)) continue;
2637
+ committed.set(this.#kittyPlacementKey(placement), placement);
2638
+ }
2639
+ return [...committed.values()];
2640
+ }
2641
+
2642
+ #kittyViewportTopIncludingPlacementAnchors(viewportTop: number, placements: KittyPlacementSpan[]): number {
2643
+ let resolvedTop = viewportTop;
2644
+ let changed: boolean;
2645
+ do {
2646
+ const priorTop = resolvedTop;
2647
+ for (const placement of placements) {
2648
+ if (placement.row < resolvedTop && placement.row + placement.rows > resolvedTop) {
2649
+ resolvedTop = placement.row;
2650
+ }
2651
+ }
2652
+ changed = resolvedTop !== priorTop;
2653
+ } while (changed);
2654
+ return resolvedTop;
2655
+ }
2160
2656
 
2161
- let pinnedStart = -1;
2162
- for (let i = this.children.length - 1; i >= 0; i--) {
2163
- if (this.children[i] === component) {
2164
- pinnedStart = i;
2657
+ #pinnedChildLines(component: Component, renderedChildren: Map<Component, string[]>): string[] {
2658
+ const lines = renderedChildren.get(component);
2659
+ if (lines === undefined) throw new Error("Missing rendered direct child for pinned suffix");
2660
+ return lines;
2661
+ }
2662
+
2663
+ #constrainedPinnedChildLines(lines: string[], remaining: number): string[] {
2664
+ let cursorLine = -1;
2665
+ for (let index = 0; index < lines.length; index++) {
2666
+ if (lines[index].includes(CURSOR_MARKER)) {
2667
+ cursorLine = index;
2165
2668
  break;
2166
2669
  }
2167
2670
  }
2671
+ if (cursorLine < 0) return lines.slice(-remaining);
2672
+ const start = Math.max(0, Math.min(cursorLine, lines.length - remaining));
2673
+ return lines.slice(start, start + remaining);
2674
+ }
2675
+
2676
+ #componentContains(root: Component, target: Component | null): boolean {
2677
+ if (target === null) return false;
2678
+ if (root === target) return true;
2679
+ return root instanceof Container && root.children.some(child => this.#componentContains(child, target));
2680
+ }
2681
+
2682
+ #constrainPinnedSuffix(lines: string[], height: number, renderedChildren: Map<Component, string[]>): string[] {
2683
+ const component = this.#bottomPinnedComponent;
2684
+ if (component === null || height <= 0) return lines;
2685
+ const pinnedStart = this.children.indexOf(component);
2168
2686
  if (pinnedStart < 0) return lines;
2169
2687
 
2170
- let pinnedLineCount = 0;
2171
- for (let i = pinnedStart; i < this.children.length; i++) {
2172
- pinnedLineCount += (renderedChildren.get(this.children[i]) ?? []).length;
2688
+ let suffixRowCount = 0;
2689
+ for (let index = pinnedStart; index < this.children.length; index++) {
2690
+ suffixRowCount += this.#pinnedChildLines(this.children[index], renderedChildren).length;
2691
+ }
2692
+ if (suffixRowCount <= height) return lines;
2693
+
2694
+ renderMetrics.recordStructuralCounter("pinnedSuffixOverflowFrames");
2695
+ let focusedChild: Component | null = null;
2696
+ for (let index = pinnedStart; index < this.children.length; index++) {
2697
+ const child = this.children[index];
2698
+ if (this.#componentContains(child, this.#focusedComponent)) {
2699
+ focusedChild = child;
2700
+ break;
2701
+ }
2702
+ }
2703
+ const selectedRowCounts = new Map<Component, number>();
2704
+ let remaining = height;
2705
+ const allocate = (child: Component, maximumRows?: number): void => {
2706
+ if (remaining === 0) return;
2707
+ const rows = this.#pinnedChildLines(child, renderedChildren);
2708
+ const alreadySelected = selectedRowCounts.get(child) ?? 0;
2709
+ const count = Math.min(rows.length - alreadySelected, maximumRows ?? rows.length, remaining);
2710
+ if (count === 0) return;
2711
+ selectedRowCounts.set(child, alreadySelected + count);
2712
+ remaining -= count;
2713
+ };
2714
+
2715
+ // Reserve the focused cursor row before the status boundary, then let later
2716
+ // decorative children compete in reverse order. A deferred allocation lets the
2717
+ // focused child retain adjacent rows only after those priorities are satisfied.
2718
+ if (focusedChild !== null) allocate(focusedChild, 1);
2719
+ if (component !== focusedChild) allocate(component);
2720
+ for (let index = this.children.length - 1; index >= pinnedStart; index--) {
2721
+ const child = this.children[index];
2722
+ if (child !== focusedChild && child !== component) allocate(child);
2723
+ }
2724
+ if (focusedChild !== null) allocate(focusedChild);
2725
+
2726
+ const transcriptEnd = lines.length - suffixRowCount;
2727
+ lines.length = transcriptEnd;
2728
+ let selectedRows = 0;
2729
+ for (let index = pinnedStart; index < this.children.length; index++) {
2730
+ const child = this.children[index];
2731
+ const count = selectedRowCounts.get(child);
2732
+ if (count === undefined) continue;
2733
+ const constrained = this.#constrainedPinnedChildLines(this.#pinnedChildLines(child, renderedChildren), count);
2734
+ selectedRows += constrained.length;
2735
+ for (const row of constrained) lines.push(row);
2736
+ }
2737
+ renderMetrics.recordStructuralCounter("pinnedSuffixSelectedRows", selectedRows);
2738
+ return lines;
2739
+ }
2740
+
2741
+ #padBeforeBottomPinnedComponent(
2742
+ lines: string[],
2743
+ height: number,
2744
+ pinnedLineCount: number,
2745
+ ): { lines: string[]; insertionRow: number; insertedBlankRows: number } {
2746
+ if (pinnedLineCount <= 0 || lines.length >= height) {
2747
+ return { lines, insertionRow: lines.length, insertedBlankRows: 0 };
2173
2748
  }
2174
2749
 
2175
- const blankRows = height - lines.length;
2176
- const insertAt = Math.max(0, lines.length - pinnedLineCount);
2750
+ const insertedBlankRows = height - lines.length;
2751
+ const insertionRow = Math.max(0, lines.length - pinnedLineCount);
2177
2752
  const padded = [...lines];
2178
- padded.splice(insertAt, 0, ...Array.from({ length: blankRows }, () => ""));
2179
- return padded;
2753
+ padded.splice(insertionRow, 0, ...Array.from({ length: insertedBlankRows }, () => ""));
2754
+ return { lines: padded, insertionRow, insertedBlankRows };
2755
+ }
2756
+ #manualTranscriptCapacity(height: number, suffixLineCount = this.#manualSuffixLineCount): number {
2757
+ const noticeRows = this.#manualOutputNotice && height > suffixLineCount ? 1 : 0;
2758
+ return Math.max(0, height - suffixLineCount - noticeRows);
2180
2759
  }
2181
2760
  #resolveManualAnchor(frame: ViewportAnchorFrame): number | null {
2182
2761
  const anchor = this.#manualViewportAnchor;
@@ -2235,23 +2814,65 @@ export class TUI extends Container {
2235
2814
  cursorPos: { row: number; col: number } | null,
2236
2815
  reason: string,
2237
2816
  allowPastLiveBottom = false,
2817
+ onPainted?: () => void,
2818
+ paintLive = false,
2819
+ placementsToClear: KittyPlacementSpan[] = this.#kittyPlacementSpans,
2820
+ placementsToPaint: KittyPlacementSpan[] = placementsToClear,
2821
+ geometry?: { transcriptLineCount: number; suffixLineCount: number },
2238
2822
  ): boolean {
2823
+ const paintManual = this.#manualViewportTop !== undefined && !paintLive;
2824
+ const transcriptLineCount = geometry?.transcriptLineCount ?? this.#manualTranscriptLineCount;
2825
+ const suffixLineCount = geometry?.suffixLineCount ?? this.#manualSuffixLineCount;
2239
2826
  if (height <= 0 || width <= 0) return false;
2240
- const maxViewportTop = Math.max(0, lines.length - (allowPastLiveBottom ? 1 : height));
2241
- const nextViewportTop = Math.max(0, Math.min(maxViewportTop, viewportTop));
2827
+ const maxViewportTop = Math.max(
2828
+ 0,
2829
+ !paintManual
2830
+ ? lines.length - (allowPastLiveBottom ? 1 : height)
2831
+ : allowPastLiveBottom
2832
+ ? lines.length - 1
2833
+ : transcriptLineCount - this.#manualTranscriptCapacity(height, suffixLineCount),
2834
+ );
2835
+ let nextViewportTop = Math.max(0, Math.min(maxViewportTop, viewportTop));
2836
+ if (paintManual)
2837
+ nextViewportTop = this.#kittyViewportTopIncludingPlacementAnchors(nextViewportTop, placementsToPaint);
2242
2838
  const currentScreenRow = Math.max(0, Math.min(height - 1, this.#hardwareCursorRow - this.#viewportTopRow));
2243
- let buffer = "\x1b[?2026h";
2839
+ const transcriptCapacity = paintManual ? this.#manualTranscriptCapacity(height, suffixLineCount) : height;
2840
+ const noticeRows = paintManual && this.#manualOutputNotice && height > suffixLineCount ? 1 : 0;
2841
+ const deletePlan = this.#kittyPlacementDeletePlan(
2842
+ placementsToClear,
2843
+ placementsToPaint,
2844
+ [{ top: this.#viewportTopRow, bottom: this.#viewportTopRow + height }],
2845
+ false,
2846
+ paintManual ? ["suffix", "overlay"] : [],
2847
+ );
2848
+ const emittedRegions: KittyPlacementRegion[] = paintManual
2849
+ ? [
2850
+ { top: nextViewportTop, bottom: nextViewportTop + transcriptCapacity },
2851
+ { top: transcriptLineCount, bottom: transcriptLineCount + suffixLineCount },
2852
+ ]
2853
+ : [{ top: nextViewportTop, bottom: nextViewportTop + height }];
2854
+ let buffer = `\x1b[?2026h${deletePlan.output}`;
2244
2855
  if (currentScreenRow > 0) {
2245
2856
  buffer += `\x1b[${currentScreenRow}A`;
2246
2857
  }
2247
2858
  buffer += "\r";
2248
-
2859
+ const committedTranscriptRows: Array<number | null> = [];
2249
2860
  for (let screenRow = 0; screenRow < height; screenRow++) {
2250
2861
  if (screenRow > 0) buffer += "\r\n";
2251
2862
  buffer += "\x1b[2K";
2252
2863
  const lineIndex = nextViewportTop + screenRow;
2253
- if (lineIndex >= lines.length) continue;
2254
- const line = lines[lineIndex];
2864
+ const suffixRow = screenRow - transcriptCapacity - noticeRows;
2865
+ const line =
2866
+ paintManual && screenRow === transcriptCapacity && noticeRows > 0
2867
+ ? "New output — type to follow"
2868
+ : paintManual && suffixRow >= 0
2869
+ ? (lines[transcriptLineCount + suffixRow] ?? "")
2870
+ : paintManual && lineIndex >= transcriptLineCount
2871
+ ? ""
2872
+ : (lines[lineIndex] ?? "");
2873
+ committedTranscriptRows.push(
2874
+ screenRow < transcriptCapacity && lineIndex < transcriptLineCount ? lineIndex : null,
2875
+ );
2255
2876
  const isImage = TERMINAL.isImageLine(line);
2256
2877
  if (!isImage && this.#visibleWidthForDifferentialGuard(line) > width) {
2257
2878
  let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
@@ -2270,20 +2891,32 @@ export class TUI extends Container {
2270
2891
  cursorSeq = cursor.seq;
2271
2892
  cursorToRow = cursor.toRow;
2272
2893
  }
2273
- this.#hardwareCursorRow = cursorToRow;
2274
2894
  buffer += cursorSeq;
2275
2895
  buffer += "\x1b[?2026l";
2276
- if (!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, lines.length)) return false;
2896
+ let contentWritten = false;
2897
+ const writeSucceeded = this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, lines.length, () => {
2898
+ contentWritten = true;
2899
+ this.#hardwareCursorRow = cursorToRow;
2900
+ this.#committedTranscriptRows = committedTranscriptRows;
2901
+ this.#cursorRow = Math.max(0, lines.length - 1);
2902
+ this.#maxLinesRendered = lines.length;
2903
+ this.#viewportTopRow = nextViewportTop;
2904
+ if (paintManual) this.#manualViewportTop = nextViewportTop;
2905
+ this.#kittyPlacementSpans = this.#kittyCommittedPlacementsAfterPaint(
2906
+ placementsToClear,
2907
+ placementsToPaint,
2908
+ deletePlan,
2909
+ emittedRegions,
2910
+ );
2911
+ onPainted?.();
2912
+ });
2913
+ if (!contentWritten) return false;
2277
2914
 
2278
2915
  if (this.#debugRedraw) {
2279
2916
  const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (lines=${lines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
2280
2917
  this.#appendDebugRedrawLog(msg);
2281
2918
  }
2282
-
2283
- this.#cursorRow = Math.max(0, lines.length - 1);
2284
- this.#maxLinesRendered = lines.length;
2285
- this.#viewportTopRow = nextViewportTop;
2286
- return true;
2919
+ return writeSucceeded;
2287
2920
  }
2288
2921
 
2289
2922
  #doRender(): void {
@@ -2304,27 +2937,50 @@ export class TUI extends Container {
2304
2937
  const renderedLines: string[] = [];
2305
2938
  const renderedChildren = new Map<Component, string[]>();
2306
2939
  let anchorFrame: ViewportAnchorFrame | null = null;
2940
+ let previousKittyPlacementSpans = this.#kittyPlacementSpans;
2941
+ const placementOwners = new Map<string, KittyPlacementOwner>();
2942
+ const pinnedChildIndex =
2943
+ this.#bottomPinnedComponent === null ? -1 : this.children.indexOf(this.#bottomPinnedComponent);
2944
+ const hasStickySuffix = pinnedChildIndex >= 0;
2307
2945
  const anchorRenderFailureCountBefore = viewportAnchorRenderFailureCount;
2308
- for (const child of this.children) {
2946
+ for (let childIndex = 0; childIndex < this.children.length; childIndex++) {
2947
+ const child = this.children[childIndex];
2309
2948
  const rendered = safeRenderComponentWithViewportAnchors(child, width, "tui-child");
2310
2949
  renderedChildren.set(child, rendered.lines);
2311
2950
  if (child === this.#viewportAnchorComponent && rendered.anchors.some(anchor => anchor !== null)) {
2312
2951
  anchorFrame = { startRow: renderedLines.length, anchors: rendered.anchors };
2313
2952
  }
2314
- for (const line of rendered.lines) renderedLines.push(line);
2953
+ const owner: KittyPlacementOwner = hasStickySuffix && childIndex >= pinnedChildIndex ? "suffix" : "transcript";
2954
+ for (const line of rendered.lines) {
2955
+ for (const placement of extractKittyPlacementReferences(line)) {
2956
+ placementOwners.set(this.#kittyPlacementKey(placement), owner);
2957
+ }
2958
+ renderedLines.push(line);
2959
+ }
2315
2960
  }
2961
+ const sourceTranscriptLineCount = hasStickySuffix
2962
+ ? this.children
2963
+ .slice(0, pinnedChildIndex)
2964
+ .reduce((count, child) => count + this.#pinnedChildLines(child, renderedChildren).length, 0)
2965
+ : renderedLines.length;
2316
2966
  const anchorRenderFailed = viewportAnchorRenderFailureCount !== anchorRenderFailureCountBefore;
2317
- let newLines = renderedLines;
2967
+ let newLines = this.#constrainPinnedSuffix(renderedLines, height, renderedChildren);
2318
2968
  this.#viewportAnchorFrame = anchorFrame;
2319
2969
  if (renderMetrics.enabled) renderMetrics.recordHelper("renderTree", renderMetrics.now() - renderTreeStart);
2320
2970
 
2321
- if (this.#bottomPinnedComponent !== null && height > 0) {
2322
- newLines = this.#padBeforeBottomPinnedComponent(newLines, height, renderedChildren);
2971
+ if (hasStickySuffix && height > 0 && this.#manualViewportTop === undefined) {
2972
+ newLines = this.#padBeforeBottomPinnedComponent(
2973
+ newLines,
2974
+ height,
2975
+ newLines.length - sourceTranscriptLineCount,
2976
+ ).lines;
2323
2977
  }
2978
+ const nextTranscriptLineCount = sourceTranscriptLineCount;
2979
+ const nextSuffixLineCount = hasStickySuffix ? Math.max(0, newLines.length - nextTranscriptLineCount) : 0;
2324
2980
 
2325
2981
  // Composite overlays into the rendered lines (before differential compare)
2326
2982
  if (this.overlayStack.length > 0) {
2327
- newLines = this.#compositeOverlays(newLines, width, height);
2983
+ newLines = this.#compositeOverlays(newLines, width, height, placementOwners);
2328
2984
  }
2329
2985
 
2330
2986
  // Extract cursor position (marker must be found before diff comparison)
@@ -2341,9 +2997,9 @@ export class TUI extends Container {
2341
2997
  const widthChanged = this.#previousWidth !== 0 && this.#previousWidth !== width;
2342
2998
  const heightChanged = this.#previousHeight !== 0 && this.#previousHeight !== height;
2343
2999
 
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
3000
+ // Normalize/truncate lines for emission. The virtual viewport is default-on;
3001
+ // PI_TUI_VIRTUAL_VIEWPORT=0 opts out. When enabled, reuse the previous frame's
3002
+ // normalized prefix when the off-screen raw prefix is unchanged (raw value equality
2347
3003
  // short-circuit for cached components), so only the visible window is
2348
3004
  // re-normalized and the diff starts at the window. Output is byte-identical to the
2349
3005
  // full path (reused entries are deterministic normalizations of identical raw lines).
@@ -2392,9 +3048,37 @@ export class TUI extends Container {
2392
3048
  renderMetrics.recordLineCount("measured", total - diffStart);
2393
3049
  if (usedWindowNormalize) renderMetrics.recordLineCount("offscreenScan", diffStart);
2394
3050
  }
3051
+ const nextKittyPlacementSpans = this.#kittyPlacementSpansForLines(newLines, placementOwners);
2395
3052
  this.#latestRenderedLines = newLines;
3053
+ this.#latestRenderedTranscriptLineCount = nextTranscriptLineCount;
3054
+ this.#latestRenderedSuffixLineCount = nextSuffixLineCount;
3055
+ this.#latestRenderedPlacementOwners = placementOwners;
3056
+ const naturalViewportTop = Math.max(0, newLines.length - height);
3057
+ const priorLogicalLineCount = Math.max(this.#previousLines.length, this.#maxLinesRendered);
3058
+ if (this.#transcriptIdentityResetPending) {
3059
+ this.#transcriptIdentityResetPending = false;
3060
+ } else if (
3061
+ newLines.length < priorLogicalLineCount &&
3062
+ (naturalViewportTop < prevViewportTop || this.#manualViewportTop !== undefined)
3063
+ ) {
3064
+ this.#scrollbackResumeViewportTop = Math.max(
3065
+ this.#scrollbackResumeViewportTop ?? 0,
3066
+ this.#nativeScrollbackViewportTop,
3067
+ );
3068
+ }
2396
3069
 
2397
3070
  if (this.#manualViewportTop !== undefined) {
3071
+ const committedManualViewportTop = this.#manualViewportTop;
3072
+ const committedManualViewportAnchor = this.#manualViewportAnchor;
3073
+ const committedManualViewportFallbackAnchors = this.#manualViewportFallbackAnchors;
3074
+ const committedReconcileMissingViewportAnchor = this.#reconcileMissingViewportAnchor;
3075
+ const restoreManualIntent = (): void => {
3076
+ this.#manualViewportTop = committedManualViewportTop;
3077
+ this.#manualViewportAnchor = committedManualViewportAnchor;
3078
+ this.#manualViewportFallbackAnchors = committedManualViewportFallbackAnchors;
3079
+ this.#reconcileMissingViewportAnchor = committedReconcileMissingViewportAnchor;
3080
+ };
3081
+ let contentPainted = false;
2398
3082
  let resolvedAnchorTop = anchorFrame === null ? null : this.#resolveManualAnchor(anchorFrame);
2399
3083
  if (
2400
3084
  this.#manualViewportAnchor !== null &&
@@ -2412,6 +3096,7 @@ export class TUI extends Container {
2412
3096
  if (anchorRenderFailed) {
2413
3097
  // Keep semantic intent armed for recovery, but render the diagnostic frame
2414
3098
  // instead of masking a provider failure behind stale transcript content.
3099
+ contentPainted = false;
2415
3100
  this.#repaintViewportFromLines(
2416
3101
  newLines,
2417
3102
  width,
@@ -2420,16 +3105,27 @@ export class TUI extends Container {
2420
3105
  null,
2421
3106
  "failed semantic viewport render",
2422
3107
  true,
3108
+ () => {
3109
+ contentPainted = true;
3110
+ this.#previousLines = newLines;
3111
+ this.#previousWidth = width;
3112
+ this.#previousHeight = height;
3113
+ this.#manualTranscriptLineCount = nextTranscriptLineCount;
3114
+ this.#manualSuffixLineCount = nextSuffixLineCount;
3115
+ },
3116
+ false,
3117
+ previousKittyPlacementSpans,
3118
+ nextKittyPlacementSpans,
3119
+ { transcriptLineCount: nextTranscriptLineCount, suffixLineCount: nextSuffixLineCount },
2423
3120
  );
2424
- this.#previousLines = newLines;
2425
- this.#previousWidth = width;
2426
- this.#previousHeight = height;
3121
+ if (!contentPainted) restoreManualIntent();
2427
3122
  return;
2428
3123
  }
2429
3124
  // A formerly valid semantic target is temporarily absent (provider removal,
2430
3125
  // replacement, eviction, or object deletion). Keep the last resolved frame
2431
3126
  // instead of silently reinterpreting manual intent as a numeric viewport.
2432
3127
  const retainedLines = this.#previousLines.length > 0 ? this.#previousLines : newLines;
3128
+ contentPainted = false;
2433
3129
  this.#repaintViewportFromLines(
2434
3130
  retainedLines,
2435
3131
  width,
@@ -2438,9 +3134,15 @@ export class TUI extends Container {
2438
3134
  null,
2439
3135
  "unresolved semantic viewport render",
2440
3136
  true,
3137
+ () => {
3138
+ contentPainted = true;
3139
+ this.#previousWidth = width;
3140
+ this.#previousHeight = height;
3141
+ },
3142
+ false,
3143
+ previousKittyPlacementSpans,
2441
3144
  );
2442
- this.#previousWidth = width;
2443
- this.#previousHeight = height;
3145
+ if (!contentPainted) restoreManualIntent();
2444
3146
  return;
2445
3147
  }
2446
3148
  const nextViewportTop = resolvedAnchorTop ?? this.#manualViewportTop;
@@ -2448,6 +3150,7 @@ export class TUI extends Container {
2448
3150
  this.#previousWidth === width &&
2449
3151
  this.#previousHeight === height &&
2450
3152
  nextViewportTop === this.#manualViewportTop &&
3153
+ this.#manualOutputNotice === this.#paintedManualOutputNotice &&
2451
3154
  newLines.length === this.#previousLines.length &&
2452
3155
  newLines.every((line, index) => line === this.#previousLines[index])
2453
3156
  ) {
@@ -2455,62 +3158,110 @@ export class TUI extends Container {
2455
3158
  }
2456
3159
  this.#manualViewportTop = nextViewportTop;
2457
3160
  this.#reconcileMissingViewportAnchor = false;
2458
- if (
2459
- this.#repaintViewportFromLines(
2460
- newLines,
2461
- width,
2462
- height,
2463
- nextViewportTop,
2464
- null,
2465
- "manual viewport render",
2466
- this.#manualViewportAnchor !== null,
2467
- )
2468
- ) {
2469
- this.#previousLines = newLines;
2470
- this.#previousWidth = width;
2471
- this.#previousHeight = height;
2472
- }
3161
+ contentPainted = false;
3162
+ this.#repaintViewportFromLines(
3163
+ newLines,
3164
+ width,
3165
+ height,
3166
+ nextViewportTop,
3167
+ null,
3168
+ "manual viewport render",
3169
+ this.#manualViewportAnchor !== null,
3170
+ () => {
3171
+ contentPainted = true;
3172
+ this.#previousLines = newLines;
3173
+ this.#previousWidth = width;
3174
+ this.#previousHeight = height;
3175
+ this.#paintedManualOutputNotice = this.#manualOutputNotice;
3176
+ this.#manualTranscriptLineCount = nextTranscriptLineCount;
3177
+ this.#manualSuffixLineCount = nextSuffixLineCount;
3178
+ },
3179
+ false,
3180
+ previousKittyPlacementSpans,
3181
+ nextKittyPlacementSpans,
3182
+ { transcriptLineCount: nextTranscriptLineCount, suffixLineCount: nextSuffixLineCount },
3183
+ );
3184
+ if (!contentPainted) restoreManualIntent();
2473
3185
  return;
2474
3186
  }
2475
3187
  // Helper to clear scrollback and viewport and render all new lines
2476
- const fullRender = (clear: boolean, reason = "full render"): void => {
3188
+ let viewportRepaint: (reason: string, targetViewportTop?: number) => boolean;
3189
+ const fullRender = (clear: boolean, reason = "full render", forceScrollbackClear = false): void => {
3190
+ if (
3191
+ clear &&
3192
+ !forceScrollbackClear &&
3193
+ shouldPreserveScrollbackOnFullClear(this.terminal) &&
3194
+ this.#scrollbackResumeViewportTop !== undefined
3195
+ ) {
3196
+ viewportRepaint(`preserving full replay blocked after scrollback-unsafe contraction: ${reason}`);
3197
+ return;
3198
+ }
2477
3199
  this.#fullRedrawCount += 1;
2478
3200
  if (renderMetrics.enabled) renderMetrics.recordFullRedraw(reason);
2479
- let buffer = "\x1b[?2026h"; // Begin synchronized output
3201
+ const deletePlan = this.#kittyPlacementDeletePlan(
3202
+ previousKittyPlacementSpans,
3203
+ nextKittyPlacementSpans,
3204
+ [],
3205
+ clear,
3206
+ );
3207
+ let buffer = `\x1b[?2026h${deletePlan.output}`; // Begin synchronized output
2480
3208
  // Skip clearing scrollback (3J) in hosts where clear/replay can snap the
2481
- // native viewport away from the live prompt (tmux/screen, Windows ConPTY).
3209
+ // native viewport away from the live prompt (tmux/screen, Windows ConPTY) —
3210
+ // unless the caller explicitly needs history erased (the settled width
3211
+ // repair, where a replay WITHOUT 3J would stack the new transcript on top
3212
+ // of the stale-width copy instead of replacing it).
2482
3213
  if (clear)
2483
- buffer += shouldPreserveScrollbackOnFullClear(this.terminal) ? "\x1b[2J\x1b[H" : "\x1b[2J\x1b[H\x1b[3J";
3214
+ buffer +=
3215
+ !forceScrollbackClear && shouldPreserveScrollbackOnFullClear(this.terminal)
3216
+ ? "\x1b[2J\x1b[H"
3217
+ : "\x1b[2J\x1b[H\x1b[3J";
2484
3218
  for (let i = 0; i < newLines.length; i++) {
2485
3219
  if (i > 0) buffer += "\r\n";
2486
3220
  // Lines were pre-terminated/normalized by #applyLineResets; image
2487
3221
  // lines were left untouched there.
2488
3222
  buffer += newLines[i];
2489
3223
  }
2490
- this.#cursorRow = Math.max(0, newLines.length - 1);
2491
- const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, this.#cursorRow);
2492
- this.#hardwareCursorRow = toRow;
3224
+ const cursorRow = Math.max(0, newLines.length - 1);
3225
+ const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, cursorRow);
2493
3226
  buffer += seq;
2494
3227
  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;
3228
+ if (
3229
+ !this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length, () => {
3230
+ this.#cursorRow = cursorRow;
3231
+ this.#hardwareCursorRow = toRow;
3232
+ this.#maxLinesRendered = clear ? newLines.length : Math.max(this.#maxLinesRendered, newLines.length);
3233
+ this.#viewportTopRow = Math.max(0, this.#maxLinesRendered - height);
3234
+ this.#nativeScrollbackViewportTop = clear
3235
+ ? this.#viewportTopRow
3236
+ : Math.max(this.#nativeScrollbackViewportTop, this.#viewportTopRow);
3237
+ if (clear && (forceScrollbackClear || !shouldPreserveScrollbackOnFullClear(this.terminal))) {
3238
+ this.#scrollbackResumeViewportTop = undefined;
3239
+ }
3240
+ this.#previousLines = newLines;
3241
+ this.#previousWidth = width;
3242
+ this.#previousHeight = height;
3243
+ this.#kittyPlacementSpans = this.#kittyCommittedPlacementsAfterPaint(
3244
+ previousKittyPlacementSpans,
3245
+ nextKittyPlacementSpans,
3246
+ deletePlan,
3247
+ [{ top: Number.NEGATIVE_INFINITY, bottom: Number.POSITIVE_INFINITY }],
3248
+ );
3249
+ this.#manualTranscriptLineCount = nextTranscriptLineCount;
3250
+ this.#manualSuffixLineCount = nextSuffixLineCount;
3251
+ })
3252
+ )
3253
+ return;
2506
3254
  };
2507
3255
 
2508
- const viewportRepaint = (reason: string): void => {
3256
+ viewportRepaint = (reason: string, targetViewportTop = Math.max(0, newLines.length - height)): boolean => {
2509
3257
  this.#fullRedrawCount += 1;
2510
3258
  if (renderMetrics.enabled) renderMetrics.recordFullRedraw(reason);
2511
- const nextViewportTop = Math.max(0, newLines.length - height);
3259
+ const nextViewportTop = targetViewportTop;
2512
3260
  const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
2513
- let buffer = "\x1b[?2026h";
3261
+ const deletePlan = this.#kittyPlacementDeletePlan(previousKittyPlacementSpans, nextKittyPlacementSpans, [
3262
+ { top: prevViewportTop, bottom: prevViewportTop + height },
3263
+ ]);
3264
+ let buffer = `\x1b[?2026h${deletePlan.output}`;
2514
3265
  if (currentScreenRow > 0) {
2515
3266
  buffer += `\x1b[${currentScreenRow}A`;
2516
3267
  }
@@ -2539,25 +3290,34 @@ export class TUI extends Container {
2539
3290
  cursorSeq = cursor.seq;
2540
3291
  cursorToRow = cursor.toRow;
2541
3292
  }
2542
- this.#hardwareCursorRow = cursorToRow;
2543
3293
  buffer += cursorSeq;
2544
3294
  buffer += "\x1b[?2026l";
2545
- if (!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length)) return;
3295
+ let contentWritten = false;
3296
+ this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length, () => {
3297
+ contentWritten = true;
3298
+ this.#hardwareCursorRow = cursorToRow;
3299
+ this.#cursorRow = Math.max(0, newLines.length - 1);
3300
+ this.#maxLinesRendered = newLines.length;
3301
+ this.#viewportTopRow = nextViewportTop;
3302
+ this.#previousLines = newLines;
3303
+ this.#previousWidth = width;
3304
+ this.#previousHeight = height;
3305
+ this.#kittyPlacementSpans = this.#kittyCommittedPlacementsAfterPaint(
3306
+ previousKittyPlacementSpans,
3307
+ nextKittyPlacementSpans,
3308
+ deletePlan,
3309
+ [{ top: nextViewportTop, bottom: nextViewportTop + height }],
3310
+ );
3311
+ this.#manualTranscriptLineCount = nextTranscriptLineCount;
3312
+ this.#manualSuffixLineCount = nextSuffixLineCount;
3313
+ });
3314
+ if (!contentWritten) return false;
2546
3315
 
2547
3316
  if (this.#debugRedraw) {
2548
3317
  const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
2549
3318
  this.#appendDebugRedrawLog(msg);
2550
3319
  }
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;
3320
+ return true;
2561
3321
  };
2562
3322
 
2563
3323
  const debugRedraw = this.#debugRedraw;
@@ -2576,14 +3336,26 @@ export class TUI extends Container {
2576
3336
 
2577
3337
  // Width changes always need a full re-render because wrapping changes.
2578
3338
  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).
3339
+ if (this.#widthSettleRepairPending) {
3340
+ logRedraw(`width settled (${this.#previousWidth} -> ${width})`);
3341
+ // The one debounced post-resize repair: a full clear+replay so stale
3342
+ // old-width wrapping is repaired in scrollback history too, not just
3343
+ // the live viewport. forceScrollbackClear erases the stale-width
3344
+ // history instead of stacking the replay on top of it. Safe against
3345
+ // the replay storm because it runs once per settled width sequence,
3346
+ // never once per SIGWINCH.
3347
+ this.#widthSettleRepairPending = false;
3348
+ fullRender(true, "width settled", true);
3349
+ } else if (useViewportRepaintPath(this.terminal)) {
3350
+ logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
3351
+ // In viewport-repaint sessions a per-event full replay can either pile
3352
+ // the transcript back onto scrollback (tmux/screen) or visibly jump to
3353
+ // the transcript top (Windows Terminal). Repaint the viewport only,
3354
+ // mirroring the height-change branch and neutralizing fake width
3355
+ // changes from requestRender(true).
2585
3356
  viewportRepaint(`terminal width changed (${this.#previousWidth} -> ${width})`);
2586
3357
  } else {
3358
+ logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
2587
3359
  fullRender(true, "terminal width changed");
2588
3360
  }
2589
3361
  return;
@@ -2644,7 +3416,23 @@ export class TUI extends Container {
2644
3416
  }
2645
3417
  lastChanged = newLines.length - 1;
2646
3418
  }
2647
- const appendStart = appendedLines && firstChanged === this.#previousLines.length && firstChanged > 0;
3419
+ let appendStart = appendedLines && firstChanged === this.#previousLines.length && firstChanged > 0;
3420
+ if (firstChanged >= 0) {
3421
+ const changedTop = firstChanged;
3422
+ let expanded: boolean;
3423
+ do {
3424
+ const priorTop = firstChanged;
3425
+ const priorBottom = lastChanged + 1;
3426
+ for (const placement of previousKittyPlacementSpans) {
3427
+ const placementBottom = placement.row + placement.rows;
3428
+ if (placement.row >= priorBottom || placementBottom <= priorTop) continue;
3429
+ firstChanged = Math.min(firstChanged, placement.row);
3430
+ lastChanged = Math.max(lastChanged, placementBottom - 1);
3431
+ }
3432
+ expanded = firstChanged !== priorTop || lastChanged + 1 !== priorBottom;
3433
+ } while (expanded);
3434
+ if (firstChanged !== changedTop) appendStart = false;
3435
+ }
2648
3436
 
2649
3437
  // No changes - but still need to update hardware cursor position if it moved
2650
3438
  if (firstChanged === -1) {
@@ -2658,10 +3446,59 @@ export class TUI extends Container {
2658
3446
  viewportRepaint(`content contraction changed viewport top (${prevViewportTop} -> ${nextLiveViewportTop})`);
2659
3447
  return;
2660
3448
  }
3449
+ if (
3450
+ appendedLines &&
3451
+ nextLiveViewportTop > prevViewportTop &&
3452
+ previousKittyPlacementSpans.some(placement =>
3453
+ this.#kittyPlacementIntersectsRegion(placement, {
3454
+ top: prevViewportTop,
3455
+ bottom: prevViewportTop + height,
3456
+ }),
3457
+ )
3458
+ ) {
3459
+ viewportRepaint(
3460
+ `content append moved a Kitty placement viewport (${prevViewportTop} -> ${nextLiveViewportTop})`,
3461
+ );
3462
+ return;
3463
+ }
3464
+ if (appendedLines && this.#scrollbackResumeViewportTop !== undefined && nextLiveViewportTop > prevViewportTop) {
3465
+ const resumeViewportTop = this.#scrollbackResumeViewportTop;
3466
+ if (nextLiveViewportTop <= resumeViewportTop) {
3467
+ viewportRepaint(
3468
+ `content expansion below committed scrollback frontier (${prevViewportTop} -> ${nextLiveViewportTop}, frontier=${resumeViewportTop})`,
3469
+ );
3470
+ return;
3471
+ }
3472
+
3473
+ const previousLines = this.#previousLines;
3474
+ const previousWidth = this.#previousWidth;
3475
+ const previousHeight = this.#previousHeight;
3476
+ if (
3477
+ !viewportRepaint(
3478
+ `staging committed scrollback frontier before resumed admission (${prevViewportTop} -> ${resumeViewportTop} -> ${nextLiveViewportTop})`,
3479
+ resumeViewportTop,
3480
+ )
3481
+ ) {
3482
+ return;
3483
+ }
3484
+ previousKittyPlacementSpans = this.#kittyPlacementSpans;
3485
+ this.#previousLines = previousLines;
3486
+ this.#previousWidth = previousWidth;
3487
+ this.#previousHeight = previousHeight;
3488
+ prevViewportTop = resumeViewportTop;
3489
+ viewportTop = resumeViewportTop;
3490
+ hardwareCursorRow = this.#hardwareCursorRow;
3491
+ firstChanged = resumeViewportTop;
3492
+ appendStart = false;
3493
+ this.#scrollbackResumeViewportTop = undefined;
3494
+ }
2661
3495
  // All changes are in deleted lines (nothing to render, just clear)
2662
3496
  if (firstChanged >= newLines.length) {
2663
3497
  if (this.#previousLines.length > newLines.length) {
2664
- let buffer = "\x1b[?2026h";
3498
+ const deletePlan = this.#kittyPlacementDeletePlan(previousKittyPlacementSpans, nextKittyPlacementSpans, [
3499
+ { top: firstChanged, bottom: lastChanged + 1 },
3500
+ ]);
3501
+ let buffer = `\x1b[?2026h${deletePlan.output}`;
2665
3502
  // Move to end of new content (clamp to 0 for empty content)
2666
3503
  const targetRow = Math.max(0, newLines.length - 1);
2667
3504
  const lineDiff = computeLineDiff(targetRow);
@@ -2691,18 +3528,37 @@ export class TUI extends Container {
2691
3528
  if (moveUp > 0) {
2692
3529
  buffer += `\x1b[${moveUp}A`;
2693
3530
  }
2694
- this.#cursorRow = targetRow;
2695
3531
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, targetRow);
2696
- this.#hardwareCursorRow = toRow;
2697
3532
  buffer += seq;
2698
3533
  buffer += "\x1b[?2026l";
2699
- if (!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length)) return;
3534
+ if (
3535
+ !this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length, () => {
3536
+ this.#cursorRow = targetRow;
3537
+ this.#hardwareCursorRow = toRow;
3538
+ this.#previousLines = newLines;
3539
+ this.#previousWidth = width;
3540
+ this.#previousHeight = height;
3541
+ this.#maxLinesRendered = newLines.length;
3542
+ this.#viewportTopRow = Math.max(0, newLines.length - height);
3543
+ this.#kittyPlacementSpans = this.#kittyCommittedPlacementsAfterPaint(
3544
+ previousKittyPlacementSpans,
3545
+ nextKittyPlacementSpans,
3546
+ deletePlan,
3547
+ [],
3548
+ );
3549
+ this.#manualTranscriptLineCount = nextTranscriptLineCount;
3550
+ this.#manualSuffixLineCount = nextSuffixLineCount;
3551
+ })
3552
+ )
3553
+ return;
2700
3554
  }
2701
3555
  this.#previousLines = newLines;
2702
3556
  this.#previousWidth = width;
2703
3557
  this.#previousHeight = height;
2704
3558
  this.#maxLinesRendered = newLines.length;
2705
3559
  this.#viewportTopRow = Math.max(0, newLines.length - height);
3560
+ this.#manualTranscriptLineCount = nextTranscriptLineCount;
3561
+ this.#manualSuffixLineCount = nextSuffixLineCount;
2706
3562
  return;
2707
3563
  }
2708
3564
 
@@ -2729,7 +3585,10 @@ export class TUI extends Container {
2729
3585
 
2730
3586
  // Render from first changed line to end
2731
3587
  // Build buffer with all updates wrapped in synchronized output
2732
- let buffer = "\x1b[?2026h"; // Begin synchronized output
3588
+ const deletePlan = this.#kittyPlacementDeletePlan(previousKittyPlacementSpans, nextKittyPlacementSpans, [
3589
+ { top: firstChanged, bottom: lastChanged + 1 },
3590
+ ]);
3591
+ let buffer = `\x1b[?2026h${deletePlan.output}`; // Begin synchronized output
2733
3592
  const prevViewportBottom = prevViewportTop + height - 1;
2734
3593
  const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
2735
3594
  if (moveTargetRow > prevViewportBottom) {
@@ -2811,7 +3670,6 @@ export class TUI extends Container {
2811
3670
  }
2812
3671
 
2813
3672
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, finalCursorRow);
2814
- this.#hardwareCursorRow = toRow;
2815
3673
  buffer += seq;
2816
3674
  buffer += "\x1b[?2026l"; // End synchronized output
2817
3675
 
@@ -2845,20 +3703,30 @@ export class TUI extends Container {
2845
3703
  fs.writeFileSync(debugPath, debugData);
2846
3704
  }
2847
3705
 
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;
3706
+ // Write entire buffer at once. Once those bytes are accepted, the painted
3707
+ // frame and geometry are authoritative even when the optional IME cursor
3708
+ // write subsequently detaches the terminal.
3709
+ if (
3710
+ !this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length, () => {
3711
+ this.#hardwareCursorRow = toRow;
3712
+ this.#cursorRow = Math.max(0, newLines.length - 1);
3713
+ this.#maxLinesRendered = newLines.length;
3714
+ this.#viewportTopRow = Math.max(0, newLines.length - height);
3715
+ this.#nativeScrollbackViewportTop = Math.max(this.#nativeScrollbackViewportTop, this.#viewportTopRow);
3716
+ this.#previousLines = newLines;
3717
+ this.#previousWidth = width;
3718
+ this.#previousHeight = height;
3719
+ this.#kittyPlacementSpans = this.#kittyCommittedPlacementsAfterPaint(
3720
+ previousKittyPlacementSpans,
3721
+ nextKittyPlacementSpans,
3722
+ deletePlan,
3723
+ [{ top: firstChanged, bottom: renderEnd + 1 }],
3724
+ );
3725
+ this.#manualTranscriptLineCount = nextTranscriptLineCount;
3726
+ this.#manualSuffixLineCount = nextSuffixLineCount;
3727
+ })
3728
+ )
3729
+ return;
2862
3730
  }
2863
3731
 
2864
3732
  /**
@@ -2933,6 +3801,7 @@ export class TUI extends Container {
2933
3801
  buffer: string,
2934
3802
  cursorPos: { row: number; col: number } | null,
2935
3803
  totalLines: number,
3804
+ onBufferWritten?: () => void,
2936
3805
  ): boolean {
2937
3806
  const overlay = this.#postRenderEmitter?.();
2938
3807
  if (overlay) {
@@ -2942,8 +3811,14 @@ export class TUI extends Container {
2942
3811
  buffer += `\x1b[?2026h\x1b7${overlay}\x1b8\x1b[?2026l`;
2943
3812
  }
2944
3813
  if (!this.#writeTerminal(buffer)) return false;
2945
- if (!this.#imeCursorActive) return true;
2946
- return this.#writeCursorPosition(cursorPos, totalLines);
3814
+ onBufferWritten?.();
3815
+ if (!this.#imeCursorActive) {
3816
+ this.#lastRenderWriteSucceeded = true;
3817
+ return true;
3818
+ }
3819
+ const cursorWritten = this.#writeCursorPosition(cursorPos, totalLines);
3820
+ if (cursorWritten) this.#lastRenderWriteSucceeded = true;
3821
+ return cursorWritten;
2947
3822
  }
2948
3823
 
2949
3824
  /**
@@ -2956,8 +3831,9 @@ export class TUI extends Container {
2956
3831
  return this.#hideCursor();
2957
3832
  }
2958
3833
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, totalLines, this.#hardwareCursorRow);
2959
- this.#hardwareCursorRow = toRow;
2960
3834
  // No \x1b[?2026h/l wrapper: synchronized output flushes terminal state and discards macOS IME composition.
2961
- return this.#writeTerminal(seq);
3835
+ if (!this.#writeTerminal(seq)) return false;
3836
+ this.#hardwareCursorRow = toRow;
3837
+ return true;
2962
3838
  }
2963
3839
  }