@sayknow-cli/tui 0.4.7 → 0.5.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
@@ -4,7 +4,7 @@
4
4
  import * as fs from "node:fs";
5
5
  import * as path from "node:path";
6
6
  import { performance } from "node:perf_hooks";
7
- import { $flag, getDebugLogPath, logger, onDefaultTabWidthChange } from "@sayknow-cli/utils";
7
+ import { $flag, $pickflag, getDebugLogPath, logger, onDefaultTabWidthChange } from "@sayknow-cli/utils";
8
8
  import { getKeybindings } from "./keybindings";
9
9
  import { isKeyRelease } from "./keys";
10
10
  import { renderMetrics } from "./metrics";
@@ -12,13 +12,10 @@ import type { Terminal } from "./terminal";
12
12
  import {
13
13
  ImageProtocol,
14
14
  isImageProtocolForced,
15
- isSixelMultiplexerEnabled,
16
15
  isUnderTerminalMultiplexer,
17
- isUnderTmux,
18
16
  setCellDimensions,
19
17
  setTerminalImageProtocol,
20
18
  TERMINAL,
21
- wrapTmuxPassthrough,
22
19
  } from "./terminal-capabilities";
23
20
  import {
24
21
  Ellipsis,
@@ -41,6 +38,17 @@ const SEGMENT_RESET = "\x1b[0m";
41
38
  * diffing so `#previousLines` mirrors what was actually written.
42
39
  */
43
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
+ }
44
52
 
45
53
  type InputListenerResult = { consume?: boolean; data?: string } | undefined;
46
54
  type InputListener = (data: string) => InputListenerResult;
@@ -49,7 +57,7 @@ type InputListener = (data: string) => InputListenerResult;
49
57
  * Component interface - all components must implement this
50
58
  */
51
59
  export type MouseEvent = {
52
- kind: "wheel" | "click";
60
+ kind: "wheel" | "click" | "drag" | "release";
53
61
  direction?: -1 | 1;
54
62
  button?: 0;
55
63
  /** Terminal cell coordinates, one-based. */
@@ -69,7 +77,12 @@ type OverlayMouseBounds = {
69
77
  termHeight: number;
70
78
  };
71
79
 
72
- /** 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. */
73
86
  export function parseSgrMouseEvent(data: string): MouseEvent | undefined {
74
87
  const match = data.match(/^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/);
75
88
  if (!match) return undefined;
@@ -79,11 +92,17 @@ export function parseSgrMouseEvent(data: string): MouseEvent | undefined {
79
92
  const terminator = match[4];
80
93
  if (![button, x, y].every(Number.isSafeInteger) || x < 1 || y < 1) return undefined;
81
94
 
82
- if (button & 32 || terminator === "m") return undefined;
83
- if (button === 64) return { kind: "wheel", direction: -1, x, y };
84
- if (button === 65) return { kind: "wheel", direction: 1, x, y };
85
- if (button === 0) return { kind: "click", button, x, y };
86
- 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 };
87
106
  }
88
107
 
89
108
  export interface Component {
@@ -173,6 +192,11 @@ export interface ViewportAnchorSource {
173
192
  id: ViewportAnchorId;
174
193
  }
175
194
 
195
+ /** Identity and monotonic revision of the logical output producer. */
196
+ export type ViewportOutputSource = {
197
+ identity: string;
198
+ revision: bigint;
199
+ };
176
200
  export interface ViewportAnchorSourceRenderer extends Component {
177
201
  renderWithViewportAnchorSource(width: number, source: ViewportAnchorSource): ViewportAnchorRender;
178
202
  }
@@ -299,10 +323,11 @@ function isMultiplexerSession(env: Record<string, string | undefined> = Bun.env)
299
323
  * Startup sixel capability probe policy (pure; exported for tests):
300
324
  * - Never probe when PI_FORCE_IMAGE_PROTOCOL is set — an explicit
301
325
  * configuration (including "off") is authoritative.
302
- * - Under tmux, probe when the sixel-multiplexer path is enabled (default): the
303
- * query is DCS-passthrough wrapped so the OUTER terminal answers, not tmux's
304
- * unreliable compile-time DA1, making a positive reply genuine end-to-end
305
- * evidence. screen/zellij have no passthrough envelope, so graphics stay off.
326
+ * - Never probe inside a terminal multiplexer: tmux advertises DA1 ";4"
327
+ * whenever it was compiled with sixel support, regardless of whether the
328
+ * attached client terminal can render sixel, so a positive reply is not
329
+ * end-to-end evidence. Graphics under a multiplexer are strictly opt-in
330
+ * via PI_FORCE_IMAGE_PROTOCOL=sixel.
306
331
  * - Probe Windows Terminal (>=1.22 renders sixel but exposes no env marker).
307
332
  */
308
333
  export function shouldProbeSixelCapability(
@@ -310,7 +335,7 @@ export function shouldProbeSixelCapability(
310
335
  platform: NodeJS.Platform = process.platform,
311
336
  ): boolean {
312
337
  if (isImageProtocolForced()) return false;
313
- if (isUnderTerminalMultiplexer(env)) return isUnderTmux(env) && isSixelMultiplexerEnabled(env);
338
+ if (isUnderTerminalMultiplexer(env)) return false;
314
339
  return platform === "win32" && Boolean(env.WT_SESSION?.trim());
315
340
  }
316
341
 
@@ -605,6 +630,10 @@ type TuiRenderCounterSnapshot = {
605
630
  debugRedrawAppendWrites: number;
606
631
  differentialGuardVisibleWidthCalls: number;
607
632
  };
633
+ type RenderCommitWaiter = {
634
+ resolve: (committed: boolean) => void;
635
+ timer: NodeJS.Timeout;
636
+ };
608
637
 
609
638
  /**
610
639
  * TUI - Main class for managing terminal UI with differential rendering
@@ -633,7 +662,28 @@ export class TUI extends Container {
633
662
  /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
634
663
  onDebug?: () => void;
635
664
  #renderRequested = false;
665
+ #nextRenderGeneration = 0;
666
+ #renderRequestedGeneration = 0;
667
+ #committedRenderGeneration = 0;
668
+ #renderCommitWaiters = new Map<number, Set<RenderCommitWaiter>>();
669
+ #lastRenderWriteSucceeded = false;
636
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 SKC_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.SKC_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
+ }
637
687
  #lastRenderAt = 0;
638
688
  static readonly #MIN_RENDER_INTERVAL_MS = 16;
639
689
  // Input-priority scheduling: an input keystroke must never be starved behind a
@@ -644,6 +694,9 @@ export class TUI extends Container {
644
694
  #cursorRow = 0; // Logical cursor row (end of rendered content)
645
695
  #hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
646
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;
647
700
  #manualViewportTop: number | undefined;
648
701
  #viewportAnchorComponent: Component | null = null;
649
702
  #viewportAnchorFrame: ViewportAnchorFrame | null = null;
@@ -656,13 +709,13 @@ export class TUI extends Container {
656
709
  #sixelProbeBuffer = "";
657
710
  #sixelProbeTimeout?: NodeJS.Timeout;
658
711
  #sixelProbeUnsubscribe?: () => void;
659
- #showHardwareCursor = $flag("PI_HARDWARE_CURSOR");
712
+ #showHardwareCursor = $pickflag("SKC_HARDWARE_CURSOR", "PI_HARDWARE_CURSOR");
660
713
  #debugRedraw = TUI.#readDebugRedrawFlag();
661
714
  // macOS: steady-block cursor anchors CJK IME overlays; disable with SKC_TUI_IME_CURSOR=0.
662
715
  readonly #useImeBlockCursor = $flag("SKC_TUI_IME_CURSOR", process.platform === "darwin");
663
716
  // showHardwareCursor=false but cursor is shown for IME anchoring (macOS).
664
717
  #imeCursorActive = false;
665
- #clearOnShrink = $flag("PI_CLEAR_ON_SHRINK"); // Clear empty rows when content shrinks (default: off)
718
+ #clearOnShrink = $pickflag("SKC_CLEAR_ON_SHRINK", "PI_CLEAR_ON_SHRINK"); // Clear empty rows when content shrinks (default: off)
666
719
  // Default-on: reuse the previous normalized off-screen prefix and only normalize/diff the
667
720
  // visible window, bounding per-frame work on huge transcripts. Output stays byte-identical;
668
721
  // set PI_TUI_VIRTUAL_VIEWPORT=0 to restore legacy full-transcript normalization.
@@ -673,6 +726,15 @@ export class TUI extends Container {
673
726
  #terminalUnavailable = false;
674
727
  #bottomPinnedComponent: Component | null = null;
675
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;
676
738
 
677
739
  #unsubscribeTabWidthChange?: () => void;
678
740
  static #renderCounters: TuiRenderCounterSnapshot = {
@@ -695,7 +757,7 @@ export class TUI extends Container {
695
757
 
696
758
  static #readDebugRedrawFlag(): boolean {
697
759
  TUI.#renderCounters.debugRedrawEnvReads += 1;
698
- return $flag("PI_DEBUG_REDRAW");
760
+ return $pickflag("SKC_DEBUG_REDRAW", "PI_DEBUG_REDRAW");
699
761
  }
700
762
 
701
763
  #appendDebugRedrawLog(message: string): void {
@@ -722,13 +784,26 @@ export class TUI extends Container {
722
784
  constructor(
723
785
  terminal: Terminal,
724
786
  showHardwareCursor?: boolean,
725
- 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
+ * `SKC_TUI_WIDTH_SETTLE_MS` / `PI_TUI_WIDTH_SETTLE_MS`, then 1000.
795
+ */
796
+ widthSettleMs?: number;
797
+ } = {},
726
798
  ) {
727
799
  super();
728
800
  this.terminal = terminal;
729
801
  if (showHardwareCursor !== undefined) {
730
802
  this.#showHardwareCursor = showHardwareCursor;
731
803
  }
804
+ if (options.widthSettleMs !== undefined && Number.isFinite(options.widthSettleMs) && options.widthSettleMs >= 0) {
805
+ this.#widthSettleMs = options.widthSettleMs;
806
+ }
732
807
  this.#imeCursorActive = !this.#showHardwareCursor && this.#useImeBlockCursor;
733
808
  this.#unsubscribeTabWidthChange = onDefaultTabWidthChange(() => {
734
809
  this.#lineTruncationCache.clear();
@@ -789,11 +864,58 @@ export class TUI extends Container {
789
864
  }
790
865
  }
791
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
+
792
888
  setBottomPinnedComponent(component: Component | null): void {
793
889
  this.#bottomPinnedComponent = component;
794
890
  this.requestRender();
795
891
  }
796
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
+
797
919
  /** Register the direct child whose rows are eligible for semantic viewport anchoring. */
798
920
  setViewportAnchorComponent(component: Component | null): void {
799
921
  if (component !== null && !isViewportAnchorProvider(component)) {
@@ -811,6 +933,21 @@ export class TUI extends Container {
811
933
  this.#manualViewportFallbackAnchors = [];
812
934
  this.#reconcileMissingViewportAnchor = false;
813
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;
814
951
  }
815
952
 
816
953
  /** Allow one semantic-neighbor reconciliation after a definitive same-transcript rebuild. */
@@ -823,22 +960,36 @@ export class TUI extends Container {
823
960
  const height = this.terminal.rows;
824
961
  const width = this.terminal.columns;
825
962
  const frame = this.#viewportAnchorFrame;
826
- 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;
827
966
 
828
- 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
+ }
829
976
  const selected = selectedRow < 0 ? null : frame.anchors[selectedRow];
830
977
  if (selected === null) return false;
831
978
 
832
- 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;
833
981
  const targetViewportTop = Math.max(0, frame.startRow + selectedRow - desiredScreenRow);
834
982
  this.#manualViewportAnchor = {
835
983
  id: selected.id,
836
- graphemeIndex: selected.graphemeStart,
837
- 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,
838
989
  desiredScreenRow,
839
990
  };
840
991
  const firstCandidateRow = Math.max(0, targetViewportTop - frame.startRow);
841
- const lastCandidateRow = Math.min(frame.anchors.length, targetViewportTop + height - frame.startRow);
992
+ const lastCandidateRow = Math.min(frame.anchors.length, targetViewportTop + transcriptCapacity - frame.startRow);
842
993
  const fallbacks: ManualViewportAnchor[] = [];
843
994
  for (let row = firstCandidateRow; row < lastCandidateRow; row++) {
844
995
  const anchor = frame.anchors[row];
@@ -862,11 +1013,24 @@ export class TUI extends Container {
862
1013
  return true;
863
1014
  }
864
1015
 
865
- 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 {
866
1023
  const height = this.terminal.rows;
867
1024
  const width = this.terminal.columns;
868
1025
  if (height <= 0 || width <= 0 || this.#previousLines.length === 0) return false;
869
- 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);
870
1034
  let currentViewportTop = Math.max(0, Math.min(maxViewportTop, this.#manualViewportTop ?? this.#viewportTopRow));
871
1035
  const frame = this.#viewportAnchorFrame;
872
1036
  if (this.#manualViewportAnchor !== null) {
@@ -875,16 +1039,29 @@ export class TUI extends Container {
875
1039
  if (resolvedViewportTop === null) return false;
876
1040
  currentViewportTop = Math.max(0, Math.min(maxViewportTop, resolvedViewportTop));
877
1041
  }
878
- const targetViewportTop = Math.max(
879
- 0,
880
- Math.min(maxViewportTop, currentViewportTop + direction * Math.max(1, height - 1)),
881
- );
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
+ }
882
1050
  if (frame !== null) {
883
- 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));
884
1058
  const targetRow = targetViewportTop + desiredScreenRow - frame.startRow;
885
1059
  let selected: { row: number; anchor: ViewportAnchorRow } | undefined;
886
1060
  const firstCandidateRow = Math.max(0, targetViewportTop - frame.startRow);
887
- 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
+ );
888
1065
  for (let row = firstCandidateRow; row < lastCandidateRow; row++) {
889
1066
  const anchor = frame.anchors[row];
890
1067
  if (anchor === null) continue;
@@ -897,7 +1074,14 @@ export class TUI extends Container {
897
1074
  selected = { row, anchor };
898
1075
  }
899
1076
  if (selected === undefined) {
900
- if (this.#manualViewportAnchor !== null) return false;
1077
+ // A page can consist entirely of non-semantic rows such as tool output,
1078
+ // transient panels, or pinned chrome. Fall back to numeric viewport
1079
+ // ownership so PageUp/PageDown can continue through those rows instead
1080
+ // of becoming an intermittent no-op. A later page with an eligible row
1081
+ // will establish a fresh semantic anchor.
1082
+ this.#manualViewportAnchor = null;
1083
+ this.#manualViewportFallbackAnchors = [];
1084
+ this.#reconcileMissingViewportAnchor = false;
901
1085
  } else {
902
1086
  this.#manualViewportAnchor = {
903
1087
  id: selected.anchor.id,
@@ -943,26 +1127,63 @@ export class TUI extends Container {
943
1127
  );
944
1128
  }
945
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
+
946
1137
  followLiveViewport(): boolean {
947
1138
  if (this.#manualViewportTop === undefined) return false;
948
1139
  const height = this.terminal.rows;
949
1140
  const width = this.terminal.columns;
950
- 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
+ }
951
1154
  const liveViewportTop = Math.max(0, liveLines.length - height);
952
- this.#manualViewportTop = undefined;
953
- this.#manualViewportAnchor = null;
954
- this.#manualViewportFallbackAnchors = [];
955
- this.#reconcileMissingViewportAnchor = false;
956
- const repainted = this.#repaintViewportFromLines(
1155
+ return this.#repaintViewportFromLines(
957
1156
  liveLines,
958
1157
  width,
959
1158
  height,
960
1159
  liveViewportTop,
961
- this.#lastCursorPosition,
1160
+ liveCursorPosition,
962
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,
963
1186
  );
964
- if (repainted) this.#previousLines = liveLines;
965
- return repainted;
966
1187
  }
967
1188
 
968
1189
  /**
@@ -1066,6 +1287,9 @@ export class TUI extends Container {
1066
1287
  start(): void {
1067
1288
  this.#stopped = false;
1068
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;
1069
1293
  this.terminal.setMouseEnabled?.(this.options.enableMouse === true);
1070
1294
  this.terminal.start(
1071
1295
  data => this.#handleInput(data),
@@ -1081,6 +1305,57 @@ export class TUI extends Container {
1081
1305
  this.requestRender(true);
1082
1306
  }
1083
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
+
1084
1359
  get terminalAvailable(): boolean {
1085
1360
  return !this.#terminalUnavailable && this.terminal.available;
1086
1361
  }
@@ -1089,6 +1364,7 @@ export class TUI extends Container {
1089
1364
  this.#terminalUnavailable = true;
1090
1365
  this.#stopped = true;
1091
1366
  this.#renderRequested = false;
1367
+ this.#settleRenderCommitWaiters(false);
1092
1368
  if (this.#renderTimer) {
1093
1369
  clearTimeout(this.#renderTimer);
1094
1370
  this.#renderTimer = undefined;
@@ -1147,16 +1423,11 @@ export class TUI extends Container {
1147
1423
  this.#sixelProbePendingDa = true;
1148
1424
  this.#sixelProbePendingGraphics = true;
1149
1425
  this.#sixelProbeUnsubscribe = this.addInputListener(data => this.#handleSixelProbeInput(data));
1150
- // Under tmux, wrap the DA1 + XTSMGRAPHICS queries in the DCS passthrough
1151
- // envelope so the outer terminal answers instead of tmux itself. Round-trip
1152
- // through the outer terminal is slower, so allow a longer settle window.
1153
- if (!this.#writeTerminal(wrapTmuxPassthrough("\x1b[c\x1b[?2;1;0S"))) return;
1154
- this.#sixelProbeTimeout = setTimeout(
1155
- () => {
1156
- this.#finishSixelProbe(false);
1157
- },
1158
- isUnderTmux() ? 600 : 250,
1159
- );
1426
+ if (!this.#writeTerminal("\x1b[c")) return;
1427
+ if (!this.#writeTerminal("\x1b[?2;1;0S")) return;
1428
+ this.#sixelProbeTimeout = setTimeout(() => {
1429
+ this.#finishSixelProbe(false);
1430
+ }, 250);
1160
1431
  }
1161
1432
 
1162
1433
  #isSixelProbeCandidate(): boolean {
@@ -1283,23 +1554,34 @@ export class TUI extends Container {
1283
1554
  if (!TERMINAL.imageProtocol) {
1284
1555
  return;
1285
1556
  }
1286
- // Query terminal for cell size in pixels: CSI 16 t → CSI 6 ; height ; width t.
1287
- // Under tmux, wrap it in the DCS passthrough envelope so the OUTER terminal
1288
- // reports its real cell size; tmux otherwise answers with a wrong value,
1289
- // which oversizes the sixel pet and pushes it out of bounds (freezing its
1290
- // animation because the overflowing position resolves to null).
1291
- this.#writeTerminal(wrapTmuxPassthrough("\x1b[16t"));
1557
+ // Query terminal for cell size in pixels: CSI 16 t
1558
+ // Response format: CSI 6 ; height ; width t
1559
+ this.#writeTerminal("\x1b[16t");
1292
1560
  }
1293
1561
 
1294
1562
  stop(): void {
1295
1563
  this.flushTerminalCleanup();
1296
1564
  this.#clearSixelProbeState();
1297
1565
  this.#stopped = true;
1566
+ this.#settleRenderCommitWaiters(false);
1298
1567
  if (this.#renderTimer) {
1299
1568
  clearTimeout(this.#renderTimer);
1300
1569
  this.#renderTimer = undefined;
1301
1570
  if (renderMetrics.enabled) renderMetrics.setTimerGauge("tui.renderTimer", 0);
1302
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
+ }
1303
1585
  // Move cursor to the end of the content to prevent overwriting/artifacts on exit
1304
1586
  if (this.#previousLines.length > 0) {
1305
1587
  const targetRow = this.#previousLines.length; // Line after the last content
@@ -1349,19 +1631,92 @@ export class TUI extends Container {
1349
1631
  * the transcript top during streaming redraws, so viewport-repaint sessions
1350
1632
  * keep force off and let `#doRender` repaint only the live viewport. Set
1351
1633
  * `PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER=1` to restore the legacy tmux redraw.
1634
+ *
1635
+ * Spurious resize events (SIGWINCH with unchanged dimensions — iTerm2 tab
1636
+ * switches and window focus changes, the self-sent SIGWINCH after resume)
1637
+ * must not force either: on hosts still using the `fullRender` path (legacy
1638
+ * multiplexer opt-in, non-process terminals) the forced redraw clears
1639
+ * scrollback (`2J`/`H`/`3J`) and replays the whole transcript, which can
1640
+ * park the native viewport at the transcript top. Only force when the grid
1641
+ * size actually changed since the last committed frame; a plain diff render
1642
+ * is a no-op otherwise.
1352
1643
  */
1353
1644
  requestResizeRender(): void {
1354
- this.requestRender(!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?.();
1355
1698
  }
1356
1699
 
1357
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 {
1358
1712
  if (!this.terminalAvailable) {
1359
1713
  this.#markTerminalUnavailable();
1360
1714
  return;
1361
1715
  }
1362
1716
  if (renderMetrics.enabled) renderMetrics.recordRequest(source);
1363
1717
  if (force) {
1364
- const preserveViewportCursor = useViewportRepaintPath(this.terminal);
1718
+ const preserveViewportCursor =
1719
+ useViewportRepaintPath(this.terminal) || shouldPreserveScrollbackOnFullClear(this.terminal);
1365
1720
  // A forced full redraw supersedes any queued input-priority render.
1366
1721
  this.#inputRenderPending = false;
1367
1722
  this.#previousLines = [];
@@ -1388,12 +1743,17 @@ export class TUI extends Container {
1388
1743
  this.#renderRequested = true;
1389
1744
  process.nextTick(() => {
1390
1745
  if (this.#stopped || !this.#renderRequested) {
1746
+ this.#settleRenderCommitWaiters(false, generation);
1391
1747
  return;
1392
1748
  }
1749
+ const requestedGeneration = this.#renderRequestedGeneration;
1750
+ this.#renderRequestedGeneration = 0;
1393
1751
  this.#renderRequested = false;
1394
1752
  this.#lastRenderAt = performance.now();
1753
+ this.#lastRenderWriteSucceeded = false;
1395
1754
  const t0 = renderMetrics.now();
1396
1755
  this.#doRender();
1756
+ this.#commitRenderGeneration(requestedGeneration);
1397
1757
  if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
1398
1758
  });
1399
1759
  return;
@@ -1414,6 +1774,7 @@ export class TUI extends Container {
1414
1774
  if (this.#renderRequested) return;
1415
1775
  this.#renderRequested = true;
1416
1776
  process.nextTick(() => this.#scheduleRender());
1777
+ return;
1417
1778
  }
1418
1779
 
1419
1780
  #scheduleRender(): void {
@@ -1428,10 +1789,14 @@ export class TUI extends Container {
1428
1789
  if (this.#stopped || !this.#renderRequested) {
1429
1790
  return;
1430
1791
  }
1792
+ const requestedGeneration = this.#renderRequestedGeneration;
1793
+ this.#renderRequestedGeneration = 0;
1431
1794
  this.#renderRequested = false;
1432
1795
  this.#lastRenderAt = performance.now();
1796
+ this.#lastRenderWriteSucceeded = false;
1433
1797
  const t0 = renderMetrics.now();
1434
1798
  this.#doRender();
1799
+ this.#commitRenderGeneration(requestedGeneration);
1435
1800
  if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
1436
1801
  if (this.#renderRequested) {
1437
1802
  this.#scheduleRender();
@@ -1454,10 +1819,14 @@ export class TUI extends Container {
1454
1819
  this.#renderTimer = undefined;
1455
1820
  if (renderMetrics.enabled) renderMetrics.setTimerGauge("tui.renderTimer", 0);
1456
1821
  }
1822
+ const requestedGeneration = this.#renderRequestedGeneration;
1823
+ this.#renderRequestedGeneration = 0;
1457
1824
  this.#renderRequested = false;
1458
1825
  this.#lastRenderAt = performance.now();
1826
+ this.#lastRenderWriteSucceeded = false;
1459
1827
  const t0 = renderMetrics.now();
1460
1828
  this.#doRender();
1829
+ this.#commitRenderGeneration(requestedGeneration);
1461
1830
  if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
1462
1831
  }
1463
1832
 
@@ -1483,8 +1852,11 @@ export class TUI extends Container {
1483
1852
  if (mouse) {
1484
1853
  // Coordinates outside the current terminal cannot name a visible cell.
1485
1854
  if (mouse.x > this.terminal.columns || mouse.y > this.terminal.rows) return;
1486
- if (mouse.kind === "wheel") this.scrollViewportPages(mouse.direction!);
1487
- 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);
1488
1860
  const focusedOverlay = this.overlayStack.find(o => o.component === this.#focusedComponent);
1489
1861
  if (focusedOverlay) {
1490
1862
  if (!this.#isOverlayVisible(focusedOverlay)) {
@@ -1510,6 +1882,10 @@ export class TUI extends Container {
1510
1882
  localY: mouse.y - bounds.row,
1511
1883
  });
1512
1884
  } else this.#focusedComponent?.handleMouse?.(mouse);
1885
+ } else if (mouse.kind === "drag") {
1886
+ this.#updateMouseSelection(mouse);
1887
+ } else {
1888
+ this.#finishMouseSelection(mouse);
1513
1889
  }
1514
1890
  this.requestRender(false, "mouse");
1515
1891
  return;
@@ -1554,6 +1930,134 @@ export class TUI extends Container {
1554
1930
  }
1555
1931
  }
1556
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
+
1557
2061
  #consumeCellSizeResponse(data: string): boolean {
1558
2062
  // Response format: ESC [ 6 ; height ; width t
1559
2063
  const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
@@ -1991,33 +2495,108 @@ export class TUI extends Container {
1991
2495
  return lines;
1992
2496
  }
1993
2497
 
1994
- #padBeforeBottomPinnedComponent(
1995
- lines: string[],
1996
- height: number,
1997
- renderedChildren: Map<Component, string[]>,
1998
- ): string[] {
1999
- const component = this.#bottomPinnedComponent;
2000
- 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
+ }
2001
2503
 
2002
- let pinnedStart = -1;
2003
- for (let i = this.children.length - 1; i >= 0; i--) {
2004
- if (this.children[i] === component) {
2005
- 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;
2006
2509
  break;
2007
2510
  }
2008
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);
2009
2527
  if (pinnedStart < 0) return lines;
2010
2528
 
2011
- let pinnedLineCount = 0;
2012
- for (let i = pinnedStart; i < this.children.length; i++) {
2013
- pinnedLineCount += (renderedChildren.get(this.children[i]) ?? []).length;
2529
+ let suffixRowCount = 0;
2530
+ for (let index = pinnedStart; index < this.children.length; index++) {
2531
+ suffixRowCount += this.#pinnedChildLines(this.children[index], renderedChildren).length;
2532
+ }
2533
+ if (suffixRowCount <= height) return lines;
2534
+
2535
+ renderMetrics.recordStructuralCounter("pinnedSuffixOverflowFrames");
2536
+ let focusedChild: Component | null = null;
2537
+ for (let index = pinnedStart; index < this.children.length; index++) {
2538
+ const child = this.children[index];
2539
+ if (this.#componentContains(child, this.#focusedComponent)) {
2540
+ focusedChild = child;
2541
+ break;
2542
+ }
2543
+ }
2544
+ const selectedRowCounts = new Map<Component, number>();
2545
+ let remaining = height;
2546
+ const allocate = (child: Component, maximumRows?: number): void => {
2547
+ if (remaining === 0) return;
2548
+ const rows = this.#pinnedChildLines(child, renderedChildren);
2549
+ const alreadySelected = selectedRowCounts.get(child) ?? 0;
2550
+ const count = Math.min(rows.length - alreadySelected, maximumRows ?? rows.length, remaining);
2551
+ if (count === 0) return;
2552
+ selectedRowCounts.set(child, alreadySelected + count);
2553
+ remaining -= count;
2554
+ };
2555
+
2556
+ // Reserve the focused cursor row before the status boundary, then let later
2557
+ // decorative children compete in reverse order. A deferred allocation lets the
2558
+ // focused child retain adjacent rows only after those priorities are satisfied.
2559
+ if (focusedChild !== null) allocate(focusedChild, 1);
2560
+ if (component !== focusedChild) allocate(component);
2561
+ for (let index = this.children.length - 1; index >= pinnedStart; index--) {
2562
+ const child = this.children[index];
2563
+ if (child !== focusedChild && child !== component) allocate(child);
2564
+ }
2565
+ if (focusedChild !== null) allocate(focusedChild);
2566
+
2567
+ const transcriptEnd = lines.length - suffixRowCount;
2568
+ lines.length = transcriptEnd;
2569
+ let selectedRows = 0;
2570
+ for (let index = pinnedStart; index < this.children.length; index++) {
2571
+ const child = this.children[index];
2572
+ const count = selectedRowCounts.get(child);
2573
+ if (count === undefined) continue;
2574
+ const constrained = this.#constrainedPinnedChildLines(this.#pinnedChildLines(child, renderedChildren), count);
2575
+ selectedRows += constrained.length;
2576
+ for (const row of constrained) lines.push(row);
2577
+ }
2578
+ renderMetrics.recordStructuralCounter("pinnedSuffixSelectedRows", selectedRows);
2579
+ return lines;
2580
+ }
2581
+
2582
+ #padBeforeBottomPinnedComponent(
2583
+ lines: string[],
2584
+ height: number,
2585
+ pinnedLineCount: number,
2586
+ ): { lines: string[]; insertionRow: number; insertedBlankRows: number } {
2587
+ if (pinnedLineCount <= 0 || lines.length >= height) {
2588
+ return { lines, insertionRow: lines.length, insertedBlankRows: 0 };
2014
2589
  }
2015
2590
 
2016
- const blankRows = height - lines.length;
2017
- const insertAt = Math.max(0, lines.length - pinnedLineCount);
2591
+ const insertedBlankRows = height - lines.length;
2592
+ const insertionRow = Math.max(0, lines.length - pinnedLineCount);
2018
2593
  const padded = [...lines];
2019
- padded.splice(insertAt, 0, ...Array.from({ length: blankRows }, () => ""));
2020
- 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);
2021
2600
  }
2022
2601
  #resolveManualAnchor(frame: ViewportAnchorFrame): number | null {
2023
2602
  const anchor = this.#manualViewportAnchor;
@@ -2076,9 +2655,19 @@ export class TUI extends Container {
2076
2655
  cursorPos: { row: number; col: number } | null,
2077
2656
  reason: string,
2078
2657
  allowPastLiveBottom = false,
2658
+ onPainted?: () => void,
2659
+ paintLive = false,
2079
2660
  ): boolean {
2661
+ const paintManual = this.#manualViewportTop !== undefined && !paintLive;
2080
2662
  if (height <= 0 || width <= 0) return false;
2081
- 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
+ );
2082
2671
  const nextViewportTop = Math.max(0, Math.min(maxViewportTop, viewportTop));
2083
2672
  const currentScreenRow = Math.max(0, Math.min(height - 1, this.#hardwareCursorRow - this.#viewportTopRow));
2084
2673
  let buffer = "\x1b[?2026h";
@@ -2087,12 +2676,25 @@ export class TUI extends Container {
2087
2676
  }
2088
2677
  buffer += "\r";
2089
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> = [];
2090
2682
  for (let screenRow = 0; screenRow < height; screenRow++) {
2091
2683
  if (screenRow > 0) buffer += "\r\n";
2092
2684
  buffer += "\x1b[2K";
2093
2685
  const lineIndex = nextViewportTop + screenRow;
2094
- if (lineIndex >= lines.length) continue;
2095
- 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
+ );
2096
2698
  const isImage = TERMINAL.isImageLine(line);
2097
2699
  if (!isImage && this.#visibleWidthForDifferentialGuard(line) > width) {
2098
2700
  let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
@@ -2111,19 +2713,24 @@ export class TUI extends Container {
2111
2713
  cursorSeq = cursor.seq;
2112
2714
  cursorToRow = cursor.toRow;
2113
2715
  }
2114
- this.#hardwareCursorRow = cursorToRow;
2115
2716
  buffer += cursorSeq;
2116
2717
  buffer += "\x1b[?2026l";
2117
- 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;
2118
2729
 
2119
2730
  if (this.#debugRedraw) {
2120
2731
  const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (lines=${lines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
2121
2732
  this.#appendDebugRedrawLog(msg);
2122
2733
  }
2123
-
2124
- this.#cursorRow = Math.max(0, lines.length - 1);
2125
- this.#maxLinesRendered = lines.length;
2126
- this.#viewportTopRow = nextViewportTop;
2127
2734
  return true;
2128
2735
  }
2129
2736
 
@@ -2154,13 +2761,30 @@ export class TUI extends Container {
2154
2761
  }
2155
2762
  for (const line of rendered.lines) renderedLines.push(line);
2156
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;
2157
2777
  const anchorRenderFailed = viewportAnchorRenderFailureCount !== anchorRenderFailureCountBefore;
2158
- let newLines = renderedLines;
2778
+ let newLines = this.#constrainPinnedSuffix(renderedLines, height, renderedChildren);
2159
2779
  this.#viewportAnchorFrame = anchorFrame;
2160
2780
  if (renderMetrics.enabled) renderMetrics.recordHelper("renderTree", renderMetrics.now() - renderTreeStart);
2161
2781
 
2162
- if (this.#bottomPinnedComponent !== null && height > 0) {
2163
- 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;
2164
2788
  }
2165
2789
 
2166
2790
  // Composite overlays into the rendered lines (before differential compare)
@@ -2172,6 +2796,8 @@ export class TUI extends Container {
2172
2796
  const cursorPos = this.#extractCursorPosition(newLines, height);
2173
2797
  this.#lastCursorPosition = cursorPos;
2174
2798
 
2799
+ newLines = this.#applyMouseSelection(newLines);
2800
+
2175
2801
  // Terminate every non-image line so #previousLines mirrors emitted bytes
2176
2802
  // (closes SGR + OSC 8 hyperlink state). Must run after cursor extraction
2177
2803
  // because the marker is embedded mid-line, and before any diff/full render
@@ -2180,9 +2806,9 @@ export class TUI extends Container {
2180
2806
  const widthChanged = this.#previousWidth !== 0 && this.#previousWidth !== width;
2181
2807
  const heightChanged = this.#previousHeight !== 0 && this.#previousHeight !== height;
2182
2808
 
2183
- // Normalize/truncate lines for emission. With the opt-in virtual-viewport flag
2184
- // (PI_TUI_VIRTUAL_VIEWPORT) we reuse the previous frame's normalized prefix when the
2185
- // 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
2186
2812
  // short-circuit for cached components), so only the visible window is
2187
2813
  // re-normalized and the diff starts at the window. Output is byte-identical to the
2188
2814
  // full path (reused entries are deterministic normalizations of identical raw lines).
@@ -2232,6 +2858,21 @@ export class TUI extends Container {
2232
2858
  if (usedWindowNormalize) renderMetrics.recordLineCount("offscreenScan", diffStart);
2233
2859
  }
2234
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
+ }
2235
2876
 
2236
2877
  if (this.#manualViewportTop !== undefined) {
2237
2878
  let resolvedAnchorTop = anchorFrame === null ? null : this.#resolveManualAnchor(anchorFrame);
@@ -2287,6 +2928,7 @@ export class TUI extends Container {
2287
2928
  this.#previousWidth === width &&
2288
2929
  this.#previousHeight === height &&
2289
2930
  nextViewportTop === this.#manualViewportTop &&
2931
+ this.#manualOutputNotice === this.#paintedManualOutputNotice &&
2290
2932
  newLines.length === this.#previousLines.length &&
2291
2933
  newLines.every((line, index) => line === this.#previousLines[index])
2292
2934
  ) {
@@ -2308,61 +2950,75 @@ export class TUI extends Container {
2308
2950
  this.#previousLines = newLines;
2309
2951
  this.#previousWidth = width;
2310
2952
  this.#previousHeight = height;
2953
+ this.#paintedManualOutputNotice = this.#manualOutputNotice;
2311
2954
  }
2312
2955
  return;
2313
2956
  }
2314
2957
  // Helper to clear scrollback and viewport and render all new lines
2315
- 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
+ }
2316
2969
  this.#fullRedrawCount += 1;
2317
2970
  if (renderMetrics.enabled) renderMetrics.recordFullRedraw(reason);
2318
2971
  let buffer = "\x1b[?2026h"; // Begin synchronized output
2319
2972
  // Skip clearing scrollback (3J) in hosts where clear/replay can snap the
2320
- // 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).
2321
2977
  if (clear)
2322
- 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";
2323
2982
  for (let i = 0; i < newLines.length; i++) {
2324
2983
  if (i > 0) buffer += "\r\n";
2325
2984
  // Lines were pre-terminated/normalized by #applyLineResets; image
2326
2985
  // lines were left untouched there.
2327
2986
  buffer += newLines[i];
2328
2987
  }
2329
- this.#cursorRow = Math.max(0, newLines.length - 1);
2330
- const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, this.#cursorRow);
2331
- this.#hardwareCursorRow = toRow;
2988
+ const cursorRow = Math.max(0, newLines.length - 1);
2989
+ const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, cursorRow);
2332
2990
  buffer += seq;
2333
2991
  buffer += "\x1b[?2026l"; // End synchronized output
2334
- if (!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length)) return;
2335
- // Reset max lines when clearing, otherwise track growth
2336
- if (clear) {
2337
- this.#maxLinesRendered = newLines.length;
2338
- } else {
2339
- this.#maxLinesRendered = Math.max(this.#maxLinesRendered, newLines.length);
2340
- }
2341
- this.#viewportTopRow = Math.max(0, this.#maxLinesRendered - height);
2342
- this.#previousLines = newLines;
2343
- this.#previousWidth = width;
2344
- 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;
2345
3010
  };
2346
3011
 
2347
- const viewportRepaint = (reason: string, absoluteClear = false): void => {
3012
+ viewportRepaint = (reason: string, targetViewportTop = Math.max(0, newLines.length - height)): void => {
2348
3013
  this.#fullRedrawCount += 1;
2349
3014
  if (renderMetrics.enabled) renderMetrics.recordFullRedraw(reason);
2350
- const nextViewportTop = Math.max(0, newLines.length - height);
3015
+ const nextViewportTop = targetViewportTop;
3016
+ const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
2351
3017
  let buffer = "\x1b[?2026h";
2352
- if (absoluteClear) {
2353
- // A width reflow under a terminal multiplexer re-wraps the on-screen rows to
2354
- // the new width before SIGWINCH fires, so cursor-relative row math (from the
2355
- // old width) lands on the wrong physical row and corrupts the repaint. Clear
2356
- // the visible screen and repaint from absolute home; skip 3J so scrollback
2357
- // history is preserved.
2358
- buffer += "\x1b[2J\x1b[H";
2359
- } else {
2360
- const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
2361
- if (currentScreenRow > 0) {
2362
- buffer += `\x1b[${currentScreenRow}A`;
2363
- }
2364
- buffer += "\r";
3018
+ if (currentScreenRow > 0) {
3019
+ buffer += `\x1b[${currentScreenRow}A`;
2365
3020
  }
3021
+ buffer += "\r";
2366
3022
  for (let screenRow = 0; screenRow < height; screenRow++) {
2367
3023
  if (screenRow > 0) buffer += "\r\n";
2368
3024
  buffer += "\x1b[2K";
@@ -2387,25 +3043,25 @@ export class TUI extends Container {
2387
3043
  cursorSeq = cursor.seq;
2388
3044
  cursorToRow = cursor.toRow;
2389
3045
  }
2390
- this.#hardwareCursorRow = cursorToRow;
2391
3046
  buffer += cursorSeq;
2392
3047
  buffer += "\x1b[?2026l";
2393
- 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;
2394
3060
 
2395
3061
  if (this.#debugRedraw) {
2396
3062
  const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
2397
3063
  this.#appendDebugRedrawLog(msg);
2398
3064
  }
2399
- // Viewport repaint deliberately prioritizes the live viewport over
2400
- // historical scrollback repair. After offscreen changes, #previousLines
2401
- // tracks the desired logical transcript, not every byte emitted into the
2402
- // terminal scrollback.
2403
- this.#cursorRow = Math.max(0, newLines.length - 1);
2404
- this.#maxLinesRendered = newLines.length;
2405
- this.#viewportTopRow = nextViewportTop;
2406
- this.#previousLines = newLines;
2407
- this.#previousWidth = width;
2408
- this.#previousHeight = height;
2409
3065
  };
2410
3066
 
2411
3067
  const debugRedraw = this.#debugRedraw;
@@ -2424,23 +3080,26 @@ export class TUI extends Container {
2424
3080
 
2425
3081
  // Width changes always need a full re-render because wrapping changes.
2426
3082
  if (widthChanged) {
2427
- // A forced render (requestRender(true)) resets #previousWidth to -1: that is a
2428
- // *fake* width change (the terminal never reflowed), so it keeps the cheap
2429
- // cursor-relative repaint. A *real* width change (a valid prior width that
2430
- // differs) means the multiplexer already re-wrapped the on-screen rows,
2431
- // invalidating cursor-relative row math, so it needs the absolute clear.
2432
- const realWidthChange = this.#previousWidth > 0 && this.#previousWidth !== width;
2433
- logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
2434
- if (useViewportRepaintPath(this.terminal)) {
2435
- // In viewport-repaint sessions a full replay can either pile the transcript
2436
- // back onto scrollback (tmux/screen) or visibly jump to the transcript top
2437
- // (Windows Terminal). For a real width change the reflow invalidates
2438
- // cursor-relative math, so clear the visible screen and repaint the viewport
2439
- // from absolute home (absoluteClear) fixing the corruption without replaying
2440
- // the whole transcript (no scrollback storm). A fake (force) width change
2441
- // keeps the in-place relative repaint.
2442
- viewportRepaint(`terminal width changed (${this.#previousWidth} -> ${width})`, realWidthChange);
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).
3100
+ viewportRepaint(`terminal width changed (${this.#previousWidth} -> ${width})`);
2443
3101
  } else {
3102
+ logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
2444
3103
  fullRender(true, "terminal width changed");
2445
3104
  }
2446
3105
  return;
@@ -2461,7 +3120,7 @@ export class TUI extends Container {
2461
3120
 
2462
3121
  // Content shrunk below the previous render and no overlays - re-render to clear empty rows
2463
3122
  // (overlays need the padding, so only do this when no overlays are active)
2464
- // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
3123
+ // Configurable via setClearOnShrink() or SKC_CLEAR_ON_SHRINK=0 env var
2465
3124
  if (this.#clearOnShrink && newLines.length < this.#previousLines.length && this.overlayStack.length === 0) {
2466
3125
  logRedraw(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
2467
3126
  if (
@@ -2501,7 +3160,7 @@ export class TUI extends Container {
2501
3160
  }
2502
3161
  lastChanged = newLines.length - 1;
2503
3162
  }
2504
- const appendStart = appendedLines && firstChanged === this.#previousLines.length && firstChanged > 0;
3163
+ let appendStart = appendedLines && firstChanged === this.#previousLines.length && firstChanged > 0;
2505
3164
 
2506
3165
  // No changes - but still need to update hardware cursor position if it moved
2507
3166
  if (firstChanged === -1) {
@@ -2515,6 +3174,32 @@ export class TUI extends Container {
2515
3174
  viewportRepaint(`content contraction changed viewport top (${prevViewportTop} -> ${nextLiveViewportTop})`);
2516
3175
  return;
2517
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
+ }
2518
3203
  // All changes are in deleted lines (nothing to render, just clear)
2519
3204
  if (firstChanged >= newLines.length) {
2520
3205
  if (this.#previousLines.length > newLines.length) {
@@ -2548,12 +3233,21 @@ export class TUI extends Container {
2548
3233
  if (moveUp > 0) {
2549
3234
  buffer += `\x1b[${moveUp}A`;
2550
3235
  }
2551
- this.#cursorRow = targetRow;
2552
3236
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, targetRow);
2553
- this.#hardwareCursorRow = toRow;
2554
3237
  buffer += seq;
2555
3238
  buffer += "\x1b[?2026l";
2556
- 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;
2557
3251
  }
2558
3252
  this.#previousLines = newLines;
2559
3253
  this.#previousWidth = width;
@@ -2668,11 +3362,10 @@ export class TUI extends Container {
2668
3362
  }
2669
3363
 
2670
3364
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, finalCursorRow);
2671
- this.#hardwareCursorRow = toRow;
2672
3365
  buffer += seq;
2673
3366
  buffer += "\x1b[?2026l"; // End synchronized output
2674
3367
 
2675
- if ($flag("PI_TUI_DEBUG")) {
3368
+ if ($pickflag("SKC_TUI_DEBUG", "PI_TUI_DEBUG")) {
2676
3369
  const debugDir = "/tmp/tui";
2677
3370
  fs.mkdirSync(debugDir, { recursive: true });
2678
3371
  const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
@@ -2702,20 +3395,22 @@ export class TUI extends Container {
2702
3395
  fs.writeFileSync(debugPath, debugData);
2703
3396
  }
2704
3397
 
2705
- // Write entire buffer at once
2706
- if (!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length)) return;
2707
-
2708
- // Track cursor position for next render.
2709
- // cursorRow tracks end of content (for viewport calculation).
2710
- // #hardwareCursorRow was already updated by #cursorControlSequence above.
2711
- this.#cursorRow = Math.max(0, newLines.length - 1);
2712
- // Track content height for viewport calculation
2713
- this.#maxLinesRendered = newLines.length;
2714
- this.#viewportTopRow = Math.max(0, newLines.length - height);
2715
-
2716
- this.#previousLines = newLines;
2717
- this.#previousWidth = width;
2718
- 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;
2719
3414
  }
2720
3415
 
2721
3416
  /**
@@ -2790,6 +3485,7 @@ export class TUI extends Container {
2790
3485
  buffer: string,
2791
3486
  cursorPos: { row: number; col: number } | null,
2792
3487
  totalLines: number,
3488
+ onBufferWritten?: () => void,
2793
3489
  ): boolean {
2794
3490
  const overlay = this.#postRenderEmitter?.();
2795
3491
  if (overlay) {
@@ -2799,8 +3495,14 @@ export class TUI extends Container {
2799
3495
  buffer += `\x1b[?2026h\x1b7${overlay}\x1b8\x1b[?2026l`;
2800
3496
  }
2801
3497
  if (!this.#writeTerminal(buffer)) return false;
2802
- if (!this.#imeCursorActive) return true;
2803
- 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;
2804
3506
  }
2805
3507
 
2806
3508
  /**
@@ -2813,8 +3515,9 @@ export class TUI extends Container {
2813
3515
  return this.#hideCursor();
2814
3516
  }
2815
3517
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, totalLines, this.#hardwareCursorRow);
2816
- this.#hardwareCursorRow = toRow;
2817
3518
  // No \x1b[?2026h/l wrapper: synchronized output flushes terminal state and discards macOS IME composition.
2818
- return this.#writeTerminal(seq);
3519
+ if (!this.#writeTerminal(seq)) return false;
3520
+ this.#hardwareCursorRow = toRow;
3521
+ return true;
2819
3522
  }
2820
3523
  }