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