@gajae-code/tui 0.11.10 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -1
- package/README.md +8 -0
- package/dist/types/components/select-list.d.ts +1 -0
- package/dist/types/metrics.d.ts +3 -0
- package/dist/types/tui.d.ts +35 -2
- package/package.json +3 -3
- package/src/components/select-list.ts +16 -0
- package/src/metrics.ts +15 -0
- package/src/terminal.ts +10 -5
- package/src/tui.ts +843 -130
package/src/tui.ts
CHANGED
|
@@ -38,6 +38,17 @@ const SEGMENT_RESET = "\x1b[0m";
|
|
|
38
38
|
* diffing so `#previousLines` mirrors what was actually written.
|
|
39
39
|
*/
|
|
40
40
|
const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x07";
|
|
41
|
+
const MOUSE_SELECTION_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
42
|
+
/** Discrete mouse-wheel notch size in terminal rows (xterm/less-style). */
|
|
43
|
+
export const DEFAULT_WHEEL_LINES = 3;
|
|
44
|
+
|
|
45
|
+
function stripTerminalControls(text: string): string {
|
|
46
|
+
return Bun.stripANSI(text)
|
|
47
|
+
.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/gu, "")
|
|
48
|
+
.replace(/\x1b[P_^X][\s\S]*?\x1b\\/gu, "")
|
|
49
|
+
.replace(/\x1b(?:\[[0-?]*[ -/]*[@-~]|[@-_])/gu, "")
|
|
50
|
+
.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/gu, "");
|
|
51
|
+
}
|
|
41
52
|
|
|
42
53
|
type InputListenerResult = { consume?: boolean; data?: string } | undefined;
|
|
43
54
|
type InputListener = (data: string) => InputListenerResult;
|
|
@@ -46,7 +57,7 @@ type InputListener = (data: string) => InputListenerResult;
|
|
|
46
57
|
* Component interface - all components must implement this
|
|
47
58
|
*/
|
|
48
59
|
export type MouseEvent = {
|
|
49
|
-
kind: "wheel" | "click";
|
|
60
|
+
kind: "wheel" | "click" | "drag" | "release";
|
|
50
61
|
direction?: -1 | 1;
|
|
51
62
|
button?: 0;
|
|
52
63
|
/** Terminal cell coordinates, one-based. */
|
|
@@ -66,7 +77,12 @@ type OverlayMouseBounds = {
|
|
|
66
77
|
termHeight: number;
|
|
67
78
|
};
|
|
68
79
|
|
|
69
|
-
|
|
80
|
+
type MouseSelectionPoint = {
|
|
81
|
+
line: number;
|
|
82
|
+
column: number;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Parse xterm SGR mouse reports for wheel, left-click, drag, and release events. */
|
|
70
86
|
export function parseSgrMouseEvent(data: string): MouseEvent | undefined {
|
|
71
87
|
const match = data.match(/^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/);
|
|
72
88
|
if (!match) return undefined;
|
|
@@ -76,11 +92,17 @@ export function parseSgrMouseEvent(data: string): MouseEvent | undefined {
|
|
|
76
92
|
const terminator = match[4];
|
|
77
93
|
if (![button, x, y].every(Number.isSafeInteger) || x < 1 || y < 1) return undefined;
|
|
78
94
|
|
|
79
|
-
|
|
80
|
-
if (button
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
95
|
+
const baseButton = button & 3;
|
|
96
|
+
if (button & 64) {
|
|
97
|
+
if (terminator !== "M") return undefined;
|
|
98
|
+
if (baseButton === 0) return { kind: "wheel", direction: -1, x, y };
|
|
99
|
+
if (baseButton === 1) return { kind: "wheel", direction: 1, x, y };
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
if (baseButton !== 0) return undefined;
|
|
103
|
+
if (terminator === "m") return { kind: "release", button: 0, x, y };
|
|
104
|
+
if (button & 32) return { kind: "drag", button: 0, x, y };
|
|
105
|
+
return { kind: "click", button: 0, x, y };
|
|
84
106
|
}
|
|
85
107
|
|
|
86
108
|
export interface Component {
|
|
@@ -170,6 +192,11 @@ export interface ViewportAnchorSource {
|
|
|
170
192
|
id: ViewportAnchorId;
|
|
171
193
|
}
|
|
172
194
|
|
|
195
|
+
/** Identity and monotonic revision of the logical output producer. */
|
|
196
|
+
export type ViewportOutputSource = {
|
|
197
|
+
identity: string;
|
|
198
|
+
revision: bigint;
|
|
199
|
+
};
|
|
173
200
|
export interface ViewportAnchorSourceRenderer extends Component {
|
|
174
201
|
renderWithViewportAnchorSource(width: number, source: ViewportAnchorSource): ViewportAnchorRender;
|
|
175
202
|
}
|
|
@@ -603,6 +630,10 @@ type TuiRenderCounterSnapshot = {
|
|
|
603
630
|
debugRedrawAppendWrites: number;
|
|
604
631
|
differentialGuardVisibleWidthCalls: number;
|
|
605
632
|
};
|
|
633
|
+
type RenderCommitWaiter = {
|
|
634
|
+
resolve: (committed: boolean) => void;
|
|
635
|
+
timer: NodeJS.Timeout;
|
|
636
|
+
};
|
|
606
637
|
|
|
607
638
|
/**
|
|
608
639
|
* TUI - Main class for managing terminal UI with differential rendering
|
|
@@ -631,7 +662,28 @@ export class TUI extends Container {
|
|
|
631
662
|
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
|
|
632
663
|
onDebug?: () => void;
|
|
633
664
|
#renderRequested = false;
|
|
665
|
+
#nextRenderGeneration = 0;
|
|
666
|
+
#renderRequestedGeneration = 0;
|
|
667
|
+
#committedRenderGeneration = 0;
|
|
668
|
+
#renderCommitWaiters = new Map<number, Set<RenderCommitWaiter>>();
|
|
669
|
+
#lastRenderWriteSucceeded = false;
|
|
634
670
|
#renderTimer: NodeJS.Timeout | undefined;
|
|
671
|
+
#widthSettleTimer: NodeJS.Timeout | undefined;
|
|
672
|
+
#widthSettleRepairPending = false;
|
|
673
|
+
#lastObservedWidth = 0;
|
|
674
|
+
// Trailing debounce for the settled width repair. Instance-local: taken from
|
|
675
|
+
// options.widthSettleMs when provided (deterministic harnesses pass 0 to
|
|
676
|
+
// disable), otherwise from GJC_TUI_WIDTH_SETTLE_MS / PI_TUI_WIDTH_SETTLE_MS,
|
|
677
|
+
// otherwise 1000. Sampled once at construction.
|
|
678
|
+
#widthSettleMs: number = TUI.#readWidthSettleMs();
|
|
679
|
+
static readonly #WIDTH_SETTLE_MS = 1000;
|
|
680
|
+
|
|
681
|
+
static #readWidthSettleMs(): number {
|
|
682
|
+
const raw = Bun.env.GJC_TUI_WIDTH_SETTLE_MS ?? Bun.env.PI_TUI_WIDTH_SETTLE_MS;
|
|
683
|
+
if (raw === undefined || raw === "") return TUI.#WIDTH_SETTLE_MS;
|
|
684
|
+
const parsed = Number.parseInt(raw, 10);
|
|
685
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : TUI.#WIDTH_SETTLE_MS;
|
|
686
|
+
}
|
|
635
687
|
#lastRenderAt = 0;
|
|
636
688
|
static readonly #MIN_RENDER_INTERVAL_MS = 16;
|
|
637
689
|
// Input-priority scheduling: an input keystroke must never be starved behind a
|
|
@@ -642,6 +694,9 @@ export class TUI extends Container {
|
|
|
642
694
|
#cursorRow = 0; // Logical cursor row (end of rendered content)
|
|
643
695
|
#hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
|
|
644
696
|
#viewportTopRow = 0; // Content row currently mapped to screen row 0
|
|
697
|
+
#scrollbackResumeViewportTop: number | undefined; // Reflowed history below this frontier is already committed
|
|
698
|
+
#nativeScrollbackViewportTop = 0;
|
|
699
|
+
#transcriptIdentityResetPending = false;
|
|
645
700
|
#manualViewportTop: number | undefined;
|
|
646
701
|
#viewportAnchorComponent: Component | null = null;
|
|
647
702
|
#viewportAnchorFrame: ViewportAnchorFrame | null = null;
|
|
@@ -671,6 +726,15 @@ export class TUI extends Container {
|
|
|
671
726
|
#terminalUnavailable = false;
|
|
672
727
|
#bottomPinnedComponent: Component | null = null;
|
|
673
728
|
#pendingTerminalCleanup: Array<{ payload: string; onDelivered?: () => void }> = [];
|
|
729
|
+
#mouseSelectionStart: MouseSelectionPoint | null = null;
|
|
730
|
+
#mouseSelectionEnd: MouseSelectionPoint | null = null;
|
|
731
|
+
#mouseSelectionDragged = false;
|
|
732
|
+
#viewportOutputSource: ViewportOutputSource | null = null;
|
|
733
|
+
#manualOutputNotice = false;
|
|
734
|
+
#manualTranscriptLineCount = 0;
|
|
735
|
+
#manualSuffixLineCount = 0;
|
|
736
|
+
#committedTranscriptRows: Array<number | null> = [];
|
|
737
|
+
#paintedManualOutputNotice = false;
|
|
674
738
|
|
|
675
739
|
#unsubscribeTabWidthChange?: () => void;
|
|
676
740
|
static #renderCounters: TuiRenderCounterSnapshot = {
|
|
@@ -720,13 +784,26 @@ export class TUI extends Container {
|
|
|
720
784
|
constructor(
|
|
721
785
|
terminal: Terminal,
|
|
722
786
|
showHardwareCursor?: boolean,
|
|
723
|
-
private readonly options: {
|
|
787
|
+
private readonly options: {
|
|
788
|
+
enableMouse?: boolean;
|
|
789
|
+
copySelection?: (text: string) => void | Promise<void>;
|
|
790
|
+
/**
|
|
791
|
+
* Trailing debounce for the settled width repair, in ms. `0` disables the
|
|
792
|
+
* settled repair (deterministic harnesses need this — a wall-clock-timed
|
|
793
|
+
* full replay lands at nondeterministic logical positions). Defaults to
|
|
794
|
+
* `GJC_TUI_WIDTH_SETTLE_MS` / `PI_TUI_WIDTH_SETTLE_MS`, then 1000.
|
|
795
|
+
*/
|
|
796
|
+
widthSettleMs?: number;
|
|
797
|
+
} = {},
|
|
724
798
|
) {
|
|
725
799
|
super();
|
|
726
800
|
this.terminal = terminal;
|
|
727
801
|
if (showHardwareCursor !== undefined) {
|
|
728
802
|
this.#showHardwareCursor = showHardwareCursor;
|
|
729
803
|
}
|
|
804
|
+
if (options.widthSettleMs !== undefined && Number.isFinite(options.widthSettleMs) && options.widthSettleMs >= 0) {
|
|
805
|
+
this.#widthSettleMs = options.widthSettleMs;
|
|
806
|
+
}
|
|
730
807
|
this.#imeCursorActive = !this.#showHardwareCursor && this.#useImeBlockCursor;
|
|
731
808
|
this.#unsubscribeTabWidthChange = onDefaultTabWidthChange(() => {
|
|
732
809
|
this.#lineTruncationCache.clear();
|
|
@@ -787,11 +864,58 @@ export class TUI extends Container {
|
|
|
787
864
|
}
|
|
788
865
|
}
|
|
789
866
|
|
|
867
|
+
override removeChild(component: Component): void {
|
|
868
|
+
this.#invalidateFocusForRemovedTree(component);
|
|
869
|
+
super.removeChild(component);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
override clear(): void {
|
|
873
|
+
for (const child of this.children) this.#invalidateFocusForRemovedTree(child);
|
|
874
|
+
super.clear();
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
#invalidateFocusForRemovedTree(component: Component): void {
|
|
878
|
+
if (this.#focusedComponent !== null && this.#containsComponent(component, this.#focusedComponent)) {
|
|
879
|
+
this.setFocus(null);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
#containsComponent(root: Component, target: Component): boolean {
|
|
884
|
+
if (root === target) return true;
|
|
885
|
+
return root instanceof Container && root.children.some(child => this.#containsComponent(child, target));
|
|
886
|
+
}
|
|
887
|
+
|
|
790
888
|
setBottomPinnedComponent(component: Component | null): void {
|
|
791
889
|
this.#bottomPinnedComponent = component;
|
|
792
890
|
this.requestRender();
|
|
793
891
|
}
|
|
794
892
|
|
|
893
|
+
/** Report the logical output producer revision without coupling TUI to message types. */
|
|
894
|
+
setViewportOutputSource(source: ViewportOutputSource | null): void {
|
|
895
|
+
const previous = this.#viewportOutputSource;
|
|
896
|
+
if (
|
|
897
|
+
(source === null && previous === null) ||
|
|
898
|
+
(source !== null &&
|
|
899
|
+
previous !== null &&
|
|
900
|
+
source.identity === previous.identity &&
|
|
901
|
+
source.revision === previous.revision)
|
|
902
|
+
) {
|
|
903
|
+
renderMetrics.recordStructuralCounter("viewportOutputSourceEqualNoops");
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
const identityReset = source === null || previous === null || previous.identity !== source.identity;
|
|
907
|
+
// Same-identity revisions are a high-water mark. A delayed stale observation
|
|
908
|
+
// must not lower it, because observing that revision again would otherwise
|
|
909
|
+
// look like fresh output while the user owns the manual viewport.
|
|
910
|
+
if (!identityReset && source.revision < previous.revision) return;
|
|
911
|
+
if (!identityReset && source.revision > previous.revision && this.#manualViewportTop !== undefined) {
|
|
912
|
+
this.#manualOutputNotice = true;
|
|
913
|
+
}
|
|
914
|
+
if (identityReset || this.#manualViewportTop === undefined) this.#manualOutputNotice = false;
|
|
915
|
+
this.#viewportOutputSource = source;
|
|
916
|
+
this.requestRender();
|
|
917
|
+
}
|
|
918
|
+
|
|
795
919
|
/** Register the direct child whose rows are eligible for semantic viewport anchoring. */
|
|
796
920
|
setViewportAnchorComponent(component: Component | null): void {
|
|
797
921
|
if (component !== null && !isViewportAnchorProvider(component)) {
|
|
@@ -809,6 +933,21 @@ export class TUI extends Container {
|
|
|
809
933
|
this.#manualViewportFallbackAnchors = [];
|
|
810
934
|
this.#reconcileMissingViewportAnchor = false;
|
|
811
935
|
this.#viewportAnchorFrame = null;
|
|
936
|
+
this.#scrollbackResumeViewportTop = undefined;
|
|
937
|
+
this.#nativeScrollbackViewportTop = 0;
|
|
938
|
+
this.#transcriptIdentityResetPending = true;
|
|
939
|
+
this.#manualOutputNotice = false;
|
|
940
|
+
this.#paintedManualOutputNotice = false;
|
|
941
|
+
this.#committedTranscriptRows = [];
|
|
942
|
+
// The old transcript identity is being replaced wholesale, which supersedes
|
|
943
|
+
// any stale old-width artifact a deferred settle repair would have fixed.
|
|
944
|
+
// Cancel both the armed timer and a pending deferred repair so an unrelated
|
|
945
|
+
// later render cannot trigger an out-of-window full clear+replay.
|
|
946
|
+
if (this.#widthSettleTimer) {
|
|
947
|
+
clearTimeout(this.#widthSettleTimer);
|
|
948
|
+
this.#widthSettleTimer = undefined;
|
|
949
|
+
}
|
|
950
|
+
this.#widthSettleRepairPending = false;
|
|
812
951
|
}
|
|
813
952
|
|
|
814
953
|
/** Allow one semantic-neighbor reconciliation after a definitive same-transcript rebuild. */
|
|
@@ -821,22 +960,36 @@ export class TUI extends Container {
|
|
|
821
960
|
const height = this.terminal.rows;
|
|
822
961
|
const width = this.terminal.columns;
|
|
823
962
|
const frame = this.#viewportAnchorFrame;
|
|
824
|
-
|
|
963
|
+
const transcriptCapacity = this.#manualTranscriptCapacity(height);
|
|
964
|
+
if (height <= 0 || width <= 0 || transcriptCapacity === 0 || this.#previousLines.length === 0 || frame === null)
|
|
965
|
+
return false;
|
|
825
966
|
|
|
826
|
-
|
|
967
|
+
let selectedRow = frame.anchors.findIndex(anchor => anchor?.id === id);
|
|
968
|
+
if (alignment === "bottom") {
|
|
969
|
+
for (let row = frame.anchors.length - 1; row >= 0; row--) {
|
|
970
|
+
if (frame.anchors[row]?.id === id) {
|
|
971
|
+
selectedRow = row;
|
|
972
|
+
break;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
827
976
|
const selected = selectedRow < 0 ? null : frame.anchors[selectedRow];
|
|
828
977
|
if (selected === null) return false;
|
|
829
978
|
|
|
830
|
-
const desiredScreenRow =
|
|
979
|
+
const desiredScreenRow =
|
|
980
|
+
alignment === "top" ? 0 : alignment === "center" ? Math.floor(transcriptCapacity / 2) : transcriptCapacity - 1;
|
|
831
981
|
const targetViewportTop = Math.max(0, frame.startRow + selectedRow - desiredScreenRow);
|
|
832
982
|
this.#manualViewportAnchor = {
|
|
833
983
|
id: selected.id,
|
|
834
|
-
graphemeIndex:
|
|
835
|
-
|
|
984
|
+
graphemeIndex:
|
|
985
|
+
alignment === "bottom"
|
|
986
|
+
? Math.max(selected.graphemeStart, selected.graphemeEnd - 1)
|
|
987
|
+
: selected.graphemeStart,
|
|
988
|
+
cellOffset: alignment === "bottom" ? Math.max(selected.cellStart, selected.cellEnd - 1) : selected.cellStart,
|
|
836
989
|
desiredScreenRow,
|
|
837
990
|
};
|
|
838
991
|
const firstCandidateRow = Math.max(0, targetViewportTop - frame.startRow);
|
|
839
|
-
const lastCandidateRow = Math.min(frame.anchors.length, targetViewportTop +
|
|
992
|
+
const lastCandidateRow = Math.min(frame.anchors.length, targetViewportTop + transcriptCapacity - frame.startRow);
|
|
840
993
|
const fallbacks: ManualViewportAnchor[] = [];
|
|
841
994
|
for (let row = firstCandidateRow; row < lastCandidateRow; row++) {
|
|
842
995
|
const anchor = frame.anchors[row];
|
|
@@ -860,11 +1013,24 @@ export class TUI extends Container {
|
|
|
860
1013
|
return true;
|
|
861
1014
|
}
|
|
862
1015
|
|
|
863
|
-
|
|
1016
|
+
scrollViewportBy(
|
|
1017
|
+
deltaRows: number,
|
|
1018
|
+
options?: {
|
|
1019
|
+
/** edge: PageUp/PageDown pin; stable: preserve/center pin for fine wheel motion */
|
|
1020
|
+
pin?: "edge" | "stable";
|
|
1021
|
+
},
|
|
1022
|
+
): boolean {
|
|
864
1023
|
const height = this.terminal.rows;
|
|
865
1024
|
const width = this.terminal.columns;
|
|
866
1025
|
if (height <= 0 || width <= 0 || this.#previousLines.length === 0) return false;
|
|
867
|
-
|
|
1026
|
+
if (!Number.isFinite(deltaRows)) return false;
|
|
1027
|
+
const delta = Math.trunc(deltaRows);
|
|
1028
|
+
if (delta === 0) return false;
|
|
1029
|
+
|
|
1030
|
+
const direction: -1 | 1 = delta < 0 ? -1 : 1;
|
|
1031
|
+
const pin = options?.pin ?? "stable";
|
|
1032
|
+
const transcriptCapacity = this.#manualTranscriptCapacity(height);
|
|
1033
|
+
const maxViewportTop = Math.max(0, this.#manualTranscriptLineCount - transcriptCapacity);
|
|
868
1034
|
let currentViewportTop = Math.max(0, Math.min(maxViewportTop, this.#manualViewportTop ?? this.#viewportTopRow));
|
|
869
1035
|
const frame = this.#viewportAnchorFrame;
|
|
870
1036
|
if (this.#manualViewportAnchor !== null) {
|
|
@@ -873,16 +1039,29 @@ export class TUI extends Container {
|
|
|
873
1039
|
if (resolvedViewportTop === null) return false;
|
|
874
1040
|
currentViewportTop = Math.max(0, Math.min(maxViewportTop, resolvedViewportTop));
|
|
875
1041
|
}
|
|
876
|
-
const targetViewportTop = Math.max(
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
1042
|
+
const targetViewportTop = Math.max(0, Math.min(maxViewportTop, currentViewportTop + delta));
|
|
1043
|
+
// Downward input at an already-live bottom is a no-op; it must not silently
|
|
1044
|
+
// acquire manual ownership and freeze the next semantic output. Manual owners
|
|
1045
|
+
// that reach the same boundary transition through the existing live transaction.
|
|
1046
|
+
if (direction > 0 && targetViewportTop === maxViewportTop) {
|
|
1047
|
+
if (this.#manualViewportTop === undefined && currentViewportTop === maxViewportTop) return true;
|
|
1048
|
+
if (this.#manualViewportTop !== undefined) return this.followLiveViewport();
|
|
1049
|
+
}
|
|
880
1050
|
if (frame !== null) {
|
|
881
|
-
const desiredScreenRow =
|
|
1051
|
+
const desiredScreenRow =
|
|
1052
|
+
this.#manualViewportAnchor?.desiredScreenRow ??
|
|
1053
|
+
(pin === "edge"
|
|
1054
|
+
? direction < 0
|
|
1055
|
+
? 0
|
|
1056
|
+
: Math.max(0, transcriptCapacity - 1)
|
|
1057
|
+
: Math.floor(transcriptCapacity / 2));
|
|
882
1058
|
const targetRow = targetViewportTop + desiredScreenRow - frame.startRow;
|
|
883
1059
|
let selected: { row: number; anchor: ViewportAnchorRow } | undefined;
|
|
884
1060
|
const firstCandidateRow = Math.max(0, targetViewportTop - frame.startRow);
|
|
885
|
-
const lastCandidateRow = Math.min(
|
|
1061
|
+
const lastCandidateRow = Math.min(
|
|
1062
|
+
frame.anchors.length,
|
|
1063
|
+
targetViewportTop + transcriptCapacity - frame.startRow,
|
|
1064
|
+
);
|
|
886
1065
|
for (let row = firstCandidateRow; row < lastCandidateRow; row++) {
|
|
887
1066
|
const anchor = frame.anchors[row];
|
|
888
1067
|
if (anchor === null) continue;
|
|
@@ -948,26 +1127,63 @@ export class TUI extends Container {
|
|
|
948
1127
|
);
|
|
949
1128
|
}
|
|
950
1129
|
|
|
1130
|
+
scrollViewportPages(direction: -1 | 1): boolean {
|
|
1131
|
+
const height = this.terminal.rows;
|
|
1132
|
+
return this.scrollViewportBy(direction * Math.max(1, this.#manualTranscriptCapacity(height) - 1), {
|
|
1133
|
+
pin: "edge",
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
|
|
951
1137
|
followLiveViewport(): boolean {
|
|
952
1138
|
if (this.#manualViewportTop === undefined) return false;
|
|
953
1139
|
const height = this.terminal.rows;
|
|
954
1140
|
const width = this.terminal.columns;
|
|
955
|
-
const
|
|
1141
|
+
const paddedLiveLines = this.#padBeforeBottomPinnedComponent(
|
|
1142
|
+
this.#latestRenderedLines,
|
|
1143
|
+
height,
|
|
1144
|
+
this.#manualSuffixLineCount,
|
|
1145
|
+
);
|
|
1146
|
+
const liveLines = paddedLiveLines.lines;
|
|
1147
|
+
let liveCursorPosition = this.#lastCursorPosition;
|
|
1148
|
+
if (liveCursorPosition !== null && liveCursorPosition.row >= paddedLiveLines.insertionRow) {
|
|
1149
|
+
liveCursorPosition = {
|
|
1150
|
+
...liveCursorPosition,
|
|
1151
|
+
row: liveCursorPosition.row + paddedLiveLines.insertedBlankRows,
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
956
1154
|
const liveViewportTop = Math.max(0, liveLines.length - height);
|
|
957
|
-
this.#
|
|
958
|
-
this.#manualViewportAnchor = null;
|
|
959
|
-
this.#manualViewportFallbackAnchors = [];
|
|
960
|
-
this.#reconcileMissingViewportAnchor = false;
|
|
961
|
-
const repainted = this.#repaintViewportFromLines(
|
|
1155
|
+
return this.#repaintViewportFromLines(
|
|
962
1156
|
liveLines,
|
|
963
1157
|
width,
|
|
964
1158
|
height,
|
|
965
1159
|
liveViewportTop,
|
|
966
|
-
|
|
1160
|
+
liveCursorPosition,
|
|
967
1161
|
"manual viewport follow live",
|
|
1162
|
+
false,
|
|
1163
|
+
() => {
|
|
1164
|
+
this.#manualViewportTop = undefined;
|
|
1165
|
+
this.#manualViewportAnchor = null;
|
|
1166
|
+
this.#manualViewportFallbackAnchors = [];
|
|
1167
|
+
this.#reconcileMissingViewportAnchor = false;
|
|
1168
|
+
this.#manualOutputNotice = false;
|
|
1169
|
+
this.#committedTranscriptRows = [];
|
|
1170
|
+
this.#paintedManualOutputNotice = false;
|
|
1171
|
+
this.#lastCursorPosition = liveCursorPosition;
|
|
1172
|
+
this.#previousLines = liveLines;
|
|
1173
|
+
if (this.#scrollbackResumeViewportTop === undefined) {
|
|
1174
|
+
this.#nativeScrollbackViewportTop = liveViewportTop;
|
|
1175
|
+
}
|
|
1176
|
+
// A settled width repair was deferred while the user was reading
|
|
1177
|
+
// scrollback (repainting mid-read would have destroyed their
|
|
1178
|
+
// position). The transactional live repaint above has committed, so
|
|
1179
|
+
// manual state is safely released — now schedule the deferred full
|
|
1180
|
+
// clear+replay that repairs old-width wrapping in history.
|
|
1181
|
+
if (this.#widthSettleRepairPending) {
|
|
1182
|
+
this.requestRender(true, "resize.width-settled.deferred");
|
|
1183
|
+
}
|
|
1184
|
+
},
|
|
1185
|
+
true,
|
|
968
1186
|
);
|
|
969
|
-
if (repainted) this.#previousLines = liveLines;
|
|
970
|
-
return repainted;
|
|
971
1187
|
}
|
|
972
1188
|
|
|
973
1189
|
/**
|
|
@@ -1071,6 +1287,9 @@ export class TUI extends Container {
|
|
|
1071
1287
|
start(): void {
|
|
1072
1288
|
this.#stopped = false;
|
|
1073
1289
|
this.#terminalUnavailable = false;
|
|
1290
|
+
// Seed the observed width so a spurious post-start resize event (iTerm2 tab
|
|
1291
|
+
// activation, the self-sent SIGWINCH after resume) is not read as a reflow.
|
|
1292
|
+
this.#lastObservedWidth = this.terminal.columns;
|
|
1074
1293
|
this.terminal.setMouseEnabled?.(this.options.enableMouse === true);
|
|
1075
1294
|
this.terminal.start(
|
|
1076
1295
|
data => this.#handleInput(data),
|
|
@@ -1086,6 +1305,57 @@ export class TUI extends Container {
|
|
|
1086
1305
|
this.requestRender(true);
|
|
1087
1306
|
}
|
|
1088
1307
|
|
|
1308
|
+
/**
|
|
1309
|
+
* Wait for a specific render request generation to be written successfully.
|
|
1310
|
+
*
|
|
1311
|
+
* Render requests are coalesced, so committing a newer generation also commits
|
|
1312
|
+
* every older generation represented by that frame. A stopped or unavailable
|
|
1313
|
+
* terminal resolves waiters false so UI callers can fail open instead of
|
|
1314
|
+
* holding a session operation behind a dead renderer.
|
|
1315
|
+
*/
|
|
1316
|
+
waitForRenderCommit(generation: number, timeoutMs = 250): Promise<boolean> {
|
|
1317
|
+
if (generation <= 0 || generation <= this.#committedRenderGeneration) return Promise.resolve(true);
|
|
1318
|
+
if (this.#stopped || !this.terminalAvailable) return Promise.resolve(false);
|
|
1319
|
+
return new Promise<boolean>(resolve => {
|
|
1320
|
+
const waiter: RenderCommitWaiter = {
|
|
1321
|
+
resolve,
|
|
1322
|
+
timer: setTimeout(
|
|
1323
|
+
() => {
|
|
1324
|
+
const waiters = this.#renderCommitWaiters.get(generation);
|
|
1325
|
+
if (waiters) {
|
|
1326
|
+
waiters.delete(waiter);
|
|
1327
|
+
if (waiters.size === 0) this.#renderCommitWaiters.delete(generation);
|
|
1328
|
+
}
|
|
1329
|
+
resolve(false);
|
|
1330
|
+
},
|
|
1331
|
+
Math.max(0, timeoutMs),
|
|
1332
|
+
),
|
|
1333
|
+
};
|
|
1334
|
+
waiter.timer.unref?.();
|
|
1335
|
+
const waiters = this.#renderCommitWaiters.get(generation) ?? new Set();
|
|
1336
|
+
waiters.add(waiter);
|
|
1337
|
+
this.#renderCommitWaiters.set(generation, waiters);
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
#settleRenderCommitWaiters(committed: boolean, generation = Number.POSITIVE_INFINITY): void {
|
|
1342
|
+
if (committed) this.#committedRenderGeneration = Math.max(this.#committedRenderGeneration, generation);
|
|
1343
|
+
for (const [waiterGeneration, waiters] of this.#renderCommitWaiters) {
|
|
1344
|
+
if (committed && waiterGeneration > generation) continue;
|
|
1345
|
+
this.#renderCommitWaiters.delete(waiterGeneration);
|
|
1346
|
+
for (const waiter of waiters) {
|
|
1347
|
+
clearTimeout(waiter.timer);
|
|
1348
|
+
waiter.resolve(committed);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
#commitRenderGeneration(generation: number): void {
|
|
1354
|
+
if (generation <= 0) return;
|
|
1355
|
+
if (this.#lastRenderWriteSucceeded) this.#settleRenderCommitWaiters(true, generation);
|
|
1356
|
+
else if (this.#stopped || !this.terminalAvailable) this.#settleRenderCommitWaiters(false, generation);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1089
1359
|
get terminalAvailable(): boolean {
|
|
1090
1360
|
return !this.#terminalUnavailable && this.terminal.available;
|
|
1091
1361
|
}
|
|
@@ -1094,6 +1364,7 @@ export class TUI extends Container {
|
|
|
1094
1364
|
this.#terminalUnavailable = true;
|
|
1095
1365
|
this.#stopped = true;
|
|
1096
1366
|
this.#renderRequested = false;
|
|
1367
|
+
this.#settleRenderCommitWaiters(false);
|
|
1097
1368
|
if (this.#renderTimer) {
|
|
1098
1369
|
clearTimeout(this.#renderTimer);
|
|
1099
1370
|
this.#renderTimer = undefined;
|
|
@@ -1292,11 +1563,25 @@ export class TUI extends Container {
|
|
|
1292
1563
|
this.flushTerminalCleanup();
|
|
1293
1564
|
this.#clearSixelProbeState();
|
|
1294
1565
|
this.#stopped = true;
|
|
1566
|
+
this.#settleRenderCommitWaiters(false);
|
|
1295
1567
|
if (this.#renderTimer) {
|
|
1296
1568
|
clearTimeout(this.#renderTimer);
|
|
1297
1569
|
this.#renderTimer = undefined;
|
|
1298
1570
|
if (renderMetrics.enabled) renderMetrics.setTimerGauge("tui.renderTimer", 0);
|
|
1299
1571
|
}
|
|
1572
|
+
if (this.#widthSettleTimer) {
|
|
1573
|
+
clearTimeout(this.#widthSettleTimer);
|
|
1574
|
+
this.#widthSettleTimer = undefined;
|
|
1575
|
+
}
|
|
1576
|
+
// An armed TIMER dies with the session, but an already-deferred repair
|
|
1577
|
+
// (deadline passed while the user was reading scrollback) must survive a
|
|
1578
|
+
// temporary stop/start (Ctrl-Z resume, external editor): manual viewport
|
|
1579
|
+
// ownership survives restart, so followLiveViewport() still needs the flag
|
|
1580
|
+
// to run the deferred repair. Without manual ownership the flag is moot —
|
|
1581
|
+
// start() issues a forced full render that repairs everything anyway.
|
|
1582
|
+
if (this.#manualViewportTop === undefined) {
|
|
1583
|
+
this.#widthSettleRepairPending = false;
|
|
1584
|
+
}
|
|
1300
1585
|
// Move cursor to the end of the content to prevent overwriting/artifacts on exit
|
|
1301
1586
|
if (this.#previousLines.length > 0) {
|
|
1302
1587
|
const targetRow = this.#previousLines.length; // Line after the last content
|
|
@@ -1357,19 +1642,81 @@ export class TUI extends Container {
|
|
|
1357
1642
|
* is a no-op otherwise.
|
|
1358
1643
|
*/
|
|
1359
1644
|
requestResizeRender(): void {
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1645
|
+
// Width is tracked against the last OBSERVED terminal width, not against
|
|
1646
|
+
// #previousWidth (the last committed frame). Those diverge whenever resize
|
|
1647
|
+
// events coalesce inside one frame budget: a 100->90->100 burst would leave
|
|
1648
|
+
// #previousWidth at 100 the whole time, so a commit-keyed debounce would
|
|
1649
|
+
// never see the second transition and could skip the only repair frame.
|
|
1650
|
+
const observedWidth = this.terminal.columns;
|
|
1651
|
+
const widthChanged = observedWidth !== this.#lastObservedWidth;
|
|
1652
|
+
this.#lastObservedWidth = observedWidth;
|
|
1653
|
+
const heightChanged = this.#previousHeight !== this.terminal.rows;
|
|
1654
|
+
if (widthChanged) this.#scheduleWidthSettleRedraw();
|
|
1655
|
+
this.requestRender(heightChanged && !useViewportRepaintPath(this.terminal), "resize");
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
/**
|
|
1659
|
+
* Width reflow leaves artifacts that the immediate resize frame does not always
|
|
1660
|
+
* repair: lines wrapped at the old column count can survive as stale bands — in
|
|
1661
|
+
* the live viewport and in scrollback history. The immediate frame is unchanged
|
|
1662
|
+
* by this timer — `#doRender` still promotes a real width change to
|
|
1663
|
+
* `fullRender`/`viewportRepaint` on the spot. What this adds is a single
|
|
1664
|
+
* trailing repair #WIDTH_SETTLE_MS after the last observed width change.
|
|
1665
|
+
*
|
|
1666
|
+
* The settled repair is a FULL transcript replay on every host, including
|
|
1667
|
+
* viewport-repaint hosts (tmux/screen/zellij, Windows Terminal, process
|
|
1668
|
+
* terminals) where per-SIGWINCH forced redraws are normally suppressed. That
|
|
1669
|
+
* per-event replay is the storm `resize-replay-storm.test.ts` pins against;
|
|
1670
|
+
* the debounce is what makes the full replay safe here — it happens once per
|
|
1671
|
+
* settled width sequence, so scrollback artifacts are repaired without
|
|
1672
|
+
* replaying the transcript on every resize event.
|
|
1673
|
+
*
|
|
1674
|
+
* Every observed width sequence gets exactly one repair, including one that
|
|
1675
|
+
* ends back at its starting width. Skipping the drag-and-return case would
|
|
1676
|
+
* require proving that a frame committed at the final geometry *after* the
|
|
1677
|
+
* final resize event, which the render pipeline does not guarantee; one extra
|
|
1678
|
+
* repaint is cheaper than a missed repair.
|
|
1679
|
+
*
|
|
1680
|
+
* Height-only changes are unaffected: they reflow nothing and keep their
|
|
1681
|
+
* existing behavior.
|
|
1682
|
+
*/
|
|
1683
|
+
#scheduleWidthSettleRedraw(): void {
|
|
1684
|
+
if (this.#widthSettleMs <= 0) return;
|
|
1685
|
+
if (this.#widthSettleTimer) clearTimeout(this.#widthSettleTimer);
|
|
1686
|
+
this.#widthSettleTimer = setTimeout(() => {
|
|
1687
|
+
this.#widthSettleTimer = undefined;
|
|
1688
|
+
if (this.#stopped) return;
|
|
1689
|
+
this.#widthSettleRepairPending = true;
|
|
1690
|
+
// While the user is reading scrollback (manual viewport), a forced
|
|
1691
|
+
// clear+replay would rip them out of history mid-read. Keep the flag
|
|
1692
|
+
// armed instead; followLiveViewport() runs the deferred repair the
|
|
1693
|
+
// moment they return to live.
|
|
1694
|
+
if (this.#manualViewportTop !== undefined) return;
|
|
1695
|
+
this.requestRender(true, "resize.width-settled");
|
|
1696
|
+
}, this.#widthSettleMs);
|
|
1697
|
+
this.#widthSettleTimer.unref?.();
|
|
1363
1698
|
}
|
|
1364
1699
|
|
|
1365
1700
|
requestRender(force = false, source = "unknown"): void {
|
|
1701
|
+
this.requestRenderWithGeneration(force, source);
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
requestRenderWithGeneration(force = false, source = "unknown"): number {
|
|
1705
|
+
const generation = ++this.#nextRenderGeneration;
|
|
1706
|
+
this.#renderRequestedGeneration = Math.max(this.#renderRequestedGeneration, generation);
|
|
1707
|
+
this.#requestRenderCore(force, source, generation);
|
|
1708
|
+
return generation;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
#requestRenderCore(force: boolean, source: string, generation: number): void {
|
|
1366
1712
|
if (!this.terminalAvailable) {
|
|
1367
1713
|
this.#markTerminalUnavailable();
|
|
1368
1714
|
return;
|
|
1369
1715
|
}
|
|
1370
1716
|
if (renderMetrics.enabled) renderMetrics.recordRequest(source);
|
|
1371
1717
|
if (force) {
|
|
1372
|
-
const preserveViewportCursor =
|
|
1718
|
+
const preserveViewportCursor =
|
|
1719
|
+
useViewportRepaintPath(this.terminal) || shouldPreserveScrollbackOnFullClear(this.terminal);
|
|
1373
1720
|
// A forced full redraw supersedes any queued input-priority render.
|
|
1374
1721
|
this.#inputRenderPending = false;
|
|
1375
1722
|
this.#previousLines = [];
|
|
@@ -1396,12 +1743,17 @@ export class TUI extends Container {
|
|
|
1396
1743
|
this.#renderRequested = true;
|
|
1397
1744
|
process.nextTick(() => {
|
|
1398
1745
|
if (this.#stopped || !this.#renderRequested) {
|
|
1746
|
+
this.#settleRenderCommitWaiters(false, generation);
|
|
1399
1747
|
return;
|
|
1400
1748
|
}
|
|
1749
|
+
const requestedGeneration = this.#renderRequestedGeneration;
|
|
1750
|
+
this.#renderRequestedGeneration = 0;
|
|
1401
1751
|
this.#renderRequested = false;
|
|
1402
1752
|
this.#lastRenderAt = performance.now();
|
|
1753
|
+
this.#lastRenderWriteSucceeded = false;
|
|
1403
1754
|
const t0 = renderMetrics.now();
|
|
1404
1755
|
this.#doRender();
|
|
1756
|
+
this.#commitRenderGeneration(requestedGeneration);
|
|
1405
1757
|
if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
|
|
1406
1758
|
});
|
|
1407
1759
|
return;
|
|
@@ -1422,6 +1774,7 @@ export class TUI extends Container {
|
|
|
1422
1774
|
if (this.#renderRequested) return;
|
|
1423
1775
|
this.#renderRequested = true;
|
|
1424
1776
|
process.nextTick(() => this.#scheduleRender());
|
|
1777
|
+
return;
|
|
1425
1778
|
}
|
|
1426
1779
|
|
|
1427
1780
|
#scheduleRender(): void {
|
|
@@ -1436,10 +1789,14 @@ export class TUI extends Container {
|
|
|
1436
1789
|
if (this.#stopped || !this.#renderRequested) {
|
|
1437
1790
|
return;
|
|
1438
1791
|
}
|
|
1792
|
+
const requestedGeneration = this.#renderRequestedGeneration;
|
|
1793
|
+
this.#renderRequestedGeneration = 0;
|
|
1439
1794
|
this.#renderRequested = false;
|
|
1440
1795
|
this.#lastRenderAt = performance.now();
|
|
1796
|
+
this.#lastRenderWriteSucceeded = false;
|
|
1441
1797
|
const t0 = renderMetrics.now();
|
|
1442
1798
|
this.#doRender();
|
|
1799
|
+
this.#commitRenderGeneration(requestedGeneration);
|
|
1443
1800
|
if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
|
|
1444
1801
|
if (this.#renderRequested) {
|
|
1445
1802
|
this.#scheduleRender();
|
|
@@ -1462,10 +1819,14 @@ export class TUI extends Container {
|
|
|
1462
1819
|
this.#renderTimer = undefined;
|
|
1463
1820
|
if (renderMetrics.enabled) renderMetrics.setTimerGauge("tui.renderTimer", 0);
|
|
1464
1821
|
}
|
|
1822
|
+
const requestedGeneration = this.#renderRequestedGeneration;
|
|
1823
|
+
this.#renderRequestedGeneration = 0;
|
|
1465
1824
|
this.#renderRequested = false;
|
|
1466
1825
|
this.#lastRenderAt = performance.now();
|
|
1826
|
+
this.#lastRenderWriteSucceeded = false;
|
|
1467
1827
|
const t0 = renderMetrics.now();
|
|
1468
1828
|
this.#doRender();
|
|
1829
|
+
this.#commitRenderGeneration(requestedGeneration);
|
|
1469
1830
|
if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
|
|
1470
1831
|
}
|
|
1471
1832
|
|
|
@@ -1491,8 +1852,11 @@ export class TUI extends Container {
|
|
|
1491
1852
|
if (mouse) {
|
|
1492
1853
|
// Coordinates outside the current terminal cannot name a visible cell.
|
|
1493
1854
|
if (mouse.x > this.terminal.columns || mouse.y > this.terminal.rows) return;
|
|
1494
|
-
if (mouse.kind === "wheel")
|
|
1495
|
-
|
|
1855
|
+
if (mouse.kind === "wheel") {
|
|
1856
|
+
this.#clearMouseSelection();
|
|
1857
|
+
this.scrollViewportBy(mouse.direction! * DEFAULT_WHEEL_LINES, { pin: "stable" });
|
|
1858
|
+
} else if (mouse.kind === "click") {
|
|
1859
|
+
this.#beginMouseSelection(mouse);
|
|
1496
1860
|
const focusedOverlay = this.overlayStack.find(o => o.component === this.#focusedComponent);
|
|
1497
1861
|
if (focusedOverlay) {
|
|
1498
1862
|
if (!this.#isOverlayVisible(focusedOverlay)) {
|
|
@@ -1518,6 +1882,10 @@ export class TUI extends Container {
|
|
|
1518
1882
|
localY: mouse.y - bounds.row,
|
|
1519
1883
|
});
|
|
1520
1884
|
} else this.#focusedComponent?.handleMouse?.(mouse);
|
|
1885
|
+
} else if (mouse.kind === "drag") {
|
|
1886
|
+
this.#updateMouseSelection(mouse);
|
|
1887
|
+
} else {
|
|
1888
|
+
this.#finishMouseSelection(mouse);
|
|
1521
1889
|
}
|
|
1522
1890
|
this.requestRender(false, "mouse");
|
|
1523
1891
|
return;
|
|
@@ -1562,6 +1930,134 @@ export class TUI extends Container {
|
|
|
1562
1930
|
}
|
|
1563
1931
|
}
|
|
1564
1932
|
|
|
1933
|
+
#mouseSelectionPoint(mouse: MouseEvent): MouseSelectionPoint | null {
|
|
1934
|
+
if (this.#manualViewportTop === undefined) {
|
|
1935
|
+
return { line: this.#viewportTopRow + mouse.y - 1, column: mouse.x - 1 };
|
|
1936
|
+
}
|
|
1937
|
+
const line = this.#committedTranscriptRows[mouse.y - 1];
|
|
1938
|
+
return line === null || line === undefined || line < 0 || line >= this.#manualTranscriptLineCount
|
|
1939
|
+
? null
|
|
1940
|
+
: { line, column: mouse.x - 1 };
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
#beginMouseSelection(mouse: MouseEvent): void {
|
|
1944
|
+
if (!this.options.copySelection) return;
|
|
1945
|
+
const point = this.#mouseSelectionPoint(mouse);
|
|
1946
|
+
if (point === null) {
|
|
1947
|
+
this.#clearMouseSelection();
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
this.#mouseSelectionStart = point;
|
|
1951
|
+
this.#mouseSelectionEnd = point;
|
|
1952
|
+
this.#mouseSelectionDragged = false;
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
#updateMouseSelection(mouse: MouseEvent): void {
|
|
1956
|
+
if (this.#mouseSelectionStart === null) return;
|
|
1957
|
+
const point = this.#mouseSelectionPoint(mouse);
|
|
1958
|
+
if (point === null) return;
|
|
1959
|
+
this.#mouseSelectionEnd = point;
|
|
1960
|
+
this.#mouseSelectionDragged = true;
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
#finishMouseSelection(mouse: MouseEvent): void {
|
|
1964
|
+
if (this.#mouseSelectionStart === null) return;
|
|
1965
|
+
const point = this.#mouseSelectionPoint(mouse);
|
|
1966
|
+
if (point !== null) this.#mouseSelectionEnd = point;
|
|
1967
|
+
if (!this.#mouseSelectionDragged || !this.options.copySelection) {
|
|
1968
|
+
this.#clearMouseSelection();
|
|
1969
|
+
return;
|
|
1970
|
+
}
|
|
1971
|
+
const text = this.#extractMouseSelection();
|
|
1972
|
+
if (!text) {
|
|
1973
|
+
this.#clearMouseSelection();
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
try {
|
|
1977
|
+
const result = this.options.copySelection(text);
|
|
1978
|
+
if (result) void result.catch(() => {});
|
|
1979
|
+
} catch {
|
|
1980
|
+
// Clipboard failures are reported by the host callback and must not break terminal input.
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
#clearMouseSelection(): void {
|
|
1985
|
+
this.#mouseSelectionStart = null;
|
|
1986
|
+
this.#mouseSelectionEnd = null;
|
|
1987
|
+
this.#mouseSelectionDragged = false;
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
#orderedMouseSelection(): { start: MouseSelectionPoint; end: MouseSelectionPoint } | null {
|
|
1991
|
+
const start = this.#mouseSelectionStart;
|
|
1992
|
+
const end = this.#mouseSelectionEnd;
|
|
1993
|
+
if (start === null || end === null) return null;
|
|
1994
|
+
if (start.line < end.line || (start.line === end.line && start.column <= end.column)) return { start, end };
|
|
1995
|
+
return { start: end, end: start };
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
#mouseSelectionColumns(line: number, text: string): { start: number; end: number } | null {
|
|
1999
|
+
const selection = this.#orderedMouseSelection();
|
|
2000
|
+
if (selection === null || line < selection.start.line || line > selection.end.line) return null;
|
|
2001
|
+
const lineWidth = visibleWidth(text);
|
|
2002
|
+
let start = line === selection.start.line ? selection.start.column : 0;
|
|
2003
|
+
let end = line === selection.end.line ? selection.end.column + 1 : lineWidth;
|
|
2004
|
+
start = Math.max(0, Math.min(lineWidth, start));
|
|
2005
|
+
end = Math.max(0, Math.min(lineWidth, end));
|
|
2006
|
+
|
|
2007
|
+
let column = 0;
|
|
2008
|
+
for (const part of MOUSE_SELECTION_SEGMENTER.segment(text)) {
|
|
2009
|
+
const next = column + Math.max(1, visibleWidth(part.segment));
|
|
2010
|
+
if (column < start && start < next) start = column;
|
|
2011
|
+
if (column < end && end < next) end = next;
|
|
2012
|
+
column = next;
|
|
2013
|
+
}
|
|
2014
|
+
return { start, end };
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
#extractMouseSelection(): string {
|
|
2018
|
+
const selection = this.#orderedMouseSelection();
|
|
2019
|
+
if (selection === null) return "";
|
|
2020
|
+
const selected: string[] = [];
|
|
2021
|
+
for (let lineIndex = selection.start.line; lineIndex <= selection.end.line; lineIndex++) {
|
|
2022
|
+
const line = this.#previousLines[lineIndex];
|
|
2023
|
+
if (line === undefined || TERMINAL.isImageLine(line)) {
|
|
2024
|
+
selected.push("");
|
|
2025
|
+
continue;
|
|
2026
|
+
}
|
|
2027
|
+
const plain = stripTerminalControls(line);
|
|
2028
|
+
const columns = this.#mouseSelectionColumns(lineIndex, plain);
|
|
2029
|
+
if (columns === null || columns.end <= columns.start) {
|
|
2030
|
+
selected.push("");
|
|
2031
|
+
continue;
|
|
2032
|
+
}
|
|
2033
|
+
selected.push(sliceByColumn(plain, columns.start, columns.end - columns.start, false));
|
|
2034
|
+
}
|
|
2035
|
+
return selected.join("\n");
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
#applyMouseSelection(lines: string[]): string[] {
|
|
2039
|
+
if (!this.#mouseSelectionDragged) return lines;
|
|
2040
|
+
const selection = this.#orderedMouseSelection();
|
|
2041
|
+
if (selection === null) return lines;
|
|
2042
|
+
const highlighted = lines;
|
|
2043
|
+
for (let lineIndex = selection.start.line; lineIndex <= selection.end.line; lineIndex++) {
|
|
2044
|
+
const line = highlighted[lineIndex];
|
|
2045
|
+
if (line === undefined || TERMINAL.isImageLine(line)) continue;
|
|
2046
|
+
const plain = stripTerminalControls(line);
|
|
2047
|
+
const width = visibleWidth(plain);
|
|
2048
|
+
const columns = this.#mouseSelectionColumns(lineIndex, plain);
|
|
2049
|
+
if (columns === null || columns.end <= columns.start) continue;
|
|
2050
|
+
const before = sliceByColumn(line, 0, columns.start, false);
|
|
2051
|
+
const selected = sliceByColumn(line, columns.start, columns.end - columns.start, false).replace(
|
|
2052
|
+
/\x1b\[[0-9;]*m/gu,
|
|
2053
|
+
control => `${control}\x1b[7m`,
|
|
2054
|
+
);
|
|
2055
|
+
const after = sliceByColumn(line, columns.end, Math.max(0, width - columns.end), false);
|
|
2056
|
+
highlighted[lineIndex] = `${before}\x1b[7m${selected}\x1b[27m${after}`;
|
|
2057
|
+
}
|
|
2058
|
+
return highlighted;
|
|
2059
|
+
}
|
|
2060
|
+
|
|
1565
2061
|
#consumeCellSizeResponse(data: string): boolean {
|
|
1566
2062
|
// Response format: ESC [ 6 ; height ; width t
|
|
1567
2063
|
const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
|
|
@@ -1999,33 +2495,108 @@ export class TUI extends Container {
|
|
|
1999
2495
|
return lines;
|
|
2000
2496
|
}
|
|
2001
2497
|
|
|
2002
|
-
#
|
|
2003
|
-
lines
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
const component = this.#bottomPinnedComponent;
|
|
2008
|
-
if (component === null || lines.length >= height) return lines;
|
|
2498
|
+
#pinnedChildLines(component: Component, renderedChildren: Map<Component, string[]>): string[] {
|
|
2499
|
+
const lines = renderedChildren.get(component);
|
|
2500
|
+
if (lines === undefined) throw new Error("Missing rendered direct child for pinned suffix");
|
|
2501
|
+
return lines;
|
|
2502
|
+
}
|
|
2009
2503
|
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2504
|
+
#constrainedPinnedChildLines(lines: string[], remaining: number): string[] {
|
|
2505
|
+
let cursorLine = -1;
|
|
2506
|
+
for (let index = 0; index < lines.length; index++) {
|
|
2507
|
+
if (lines[index].includes(CURSOR_MARKER)) {
|
|
2508
|
+
cursorLine = index;
|
|
2014
2509
|
break;
|
|
2015
2510
|
}
|
|
2016
2511
|
}
|
|
2512
|
+
if (cursorLine < 0) return lines.slice(-remaining);
|
|
2513
|
+
const start = Math.max(0, Math.min(cursorLine, lines.length - remaining));
|
|
2514
|
+
return lines.slice(start, start + remaining);
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
#componentContains(root: Component, target: Component | null): boolean {
|
|
2518
|
+
if (target === null) return false;
|
|
2519
|
+
if (root === target) return true;
|
|
2520
|
+
return root instanceof Container && root.children.some(child => this.#componentContains(child, target));
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
#constrainPinnedSuffix(lines: string[], height: number, renderedChildren: Map<Component, string[]>): string[] {
|
|
2524
|
+
const component = this.#bottomPinnedComponent;
|
|
2525
|
+
if (component === null || height <= 0) return lines;
|
|
2526
|
+
const pinnedStart = this.children.indexOf(component);
|
|
2017
2527
|
if (pinnedStart < 0) return lines;
|
|
2018
2528
|
|
|
2019
|
-
let
|
|
2020
|
-
for (let
|
|
2021
|
-
|
|
2529
|
+
let suffixRowCount = 0;
|
|
2530
|
+
for (let index = pinnedStart; index < this.children.length; index++) {
|
|
2531
|
+
suffixRowCount += this.#pinnedChildLines(this.children[index], renderedChildren).length;
|
|
2532
|
+
}
|
|
2533
|
+
if (suffixRowCount <= height) return lines;
|
|
2534
|
+
|
|
2535
|
+
renderMetrics.recordStructuralCounter("pinnedSuffixOverflowFrames");
|
|
2536
|
+
let focusedChild: Component | null = null;
|
|
2537
|
+
for (let index = pinnedStart; index < this.children.length; index++) {
|
|
2538
|
+
const child = this.children[index];
|
|
2539
|
+
if (this.#componentContains(child, this.#focusedComponent)) {
|
|
2540
|
+
focusedChild = child;
|
|
2541
|
+
break;
|
|
2542
|
+
}
|
|
2022
2543
|
}
|
|
2544
|
+
const selectedRowCounts = new Map<Component, number>();
|
|
2545
|
+
let remaining = height;
|
|
2546
|
+
const allocate = (child: Component, maximumRows?: number): void => {
|
|
2547
|
+
if (remaining === 0) return;
|
|
2548
|
+
const rows = this.#pinnedChildLines(child, renderedChildren);
|
|
2549
|
+
const alreadySelected = selectedRowCounts.get(child) ?? 0;
|
|
2550
|
+
const count = Math.min(rows.length - alreadySelected, maximumRows ?? rows.length, remaining);
|
|
2551
|
+
if (count === 0) return;
|
|
2552
|
+
selectedRowCounts.set(child, alreadySelected + count);
|
|
2553
|
+
remaining -= count;
|
|
2554
|
+
};
|
|
2555
|
+
|
|
2556
|
+
// Reserve the focused cursor row before the status boundary, then let later
|
|
2557
|
+
// decorative children compete in reverse order. A deferred allocation lets the
|
|
2558
|
+
// focused child retain adjacent rows only after those priorities are satisfied.
|
|
2559
|
+
if (focusedChild !== null) allocate(focusedChild, 1);
|
|
2560
|
+
if (component !== focusedChild) allocate(component);
|
|
2561
|
+
for (let index = this.children.length - 1; index >= pinnedStart; index--) {
|
|
2562
|
+
const child = this.children[index];
|
|
2563
|
+
if (child !== focusedChild && child !== component) allocate(child);
|
|
2564
|
+
}
|
|
2565
|
+
if (focusedChild !== null) allocate(focusedChild);
|
|
2566
|
+
|
|
2567
|
+
const transcriptEnd = lines.length - suffixRowCount;
|
|
2568
|
+
lines.length = transcriptEnd;
|
|
2569
|
+
let selectedRows = 0;
|
|
2570
|
+
for (let index = pinnedStart; index < this.children.length; index++) {
|
|
2571
|
+
const child = this.children[index];
|
|
2572
|
+
const count = selectedRowCounts.get(child);
|
|
2573
|
+
if (count === undefined) continue;
|
|
2574
|
+
const constrained = this.#constrainedPinnedChildLines(this.#pinnedChildLines(child, renderedChildren), count);
|
|
2575
|
+
selectedRows += constrained.length;
|
|
2576
|
+
for (const row of constrained) lines.push(row);
|
|
2577
|
+
}
|
|
2578
|
+
renderMetrics.recordStructuralCounter("pinnedSuffixSelectedRows", selectedRows);
|
|
2579
|
+
return lines;
|
|
2580
|
+
}
|
|
2023
2581
|
|
|
2024
|
-
|
|
2025
|
-
|
|
2582
|
+
#padBeforeBottomPinnedComponent(
|
|
2583
|
+
lines: string[],
|
|
2584
|
+
height: number,
|
|
2585
|
+
pinnedLineCount: number,
|
|
2586
|
+
): { lines: string[]; insertionRow: number; insertedBlankRows: number } {
|
|
2587
|
+
if (pinnedLineCount <= 0 || lines.length >= height) {
|
|
2588
|
+
return { lines, insertionRow: lines.length, insertedBlankRows: 0 };
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
const insertedBlankRows = height - lines.length;
|
|
2592
|
+
const insertionRow = Math.max(0, lines.length - pinnedLineCount);
|
|
2026
2593
|
const padded = [...lines];
|
|
2027
|
-
padded.splice(
|
|
2028
|
-
return padded;
|
|
2594
|
+
padded.splice(insertionRow, 0, ...Array.from({ length: insertedBlankRows }, () => ""));
|
|
2595
|
+
return { lines: padded, insertionRow, insertedBlankRows };
|
|
2596
|
+
}
|
|
2597
|
+
#manualTranscriptCapacity(height: number): number {
|
|
2598
|
+
const noticeRows = this.#manualOutputNotice && height > this.#manualSuffixLineCount ? 1 : 0;
|
|
2599
|
+
return Math.max(0, height - this.#manualSuffixLineCount - noticeRows);
|
|
2029
2600
|
}
|
|
2030
2601
|
#resolveManualAnchor(frame: ViewportAnchorFrame): number | null {
|
|
2031
2602
|
const anchor = this.#manualViewportAnchor;
|
|
@@ -2084,9 +2655,19 @@ export class TUI extends Container {
|
|
|
2084
2655
|
cursorPos: { row: number; col: number } | null,
|
|
2085
2656
|
reason: string,
|
|
2086
2657
|
allowPastLiveBottom = false,
|
|
2658
|
+
onPainted?: () => void,
|
|
2659
|
+
paintLive = false,
|
|
2087
2660
|
): boolean {
|
|
2661
|
+
const paintManual = this.#manualViewportTop !== undefined && !paintLive;
|
|
2088
2662
|
if (height <= 0 || width <= 0) return false;
|
|
2089
|
-
const maxViewportTop = Math.max(
|
|
2663
|
+
const maxViewportTop = Math.max(
|
|
2664
|
+
0,
|
|
2665
|
+
!paintManual
|
|
2666
|
+
? lines.length - (allowPastLiveBottom ? 1 : height)
|
|
2667
|
+
: allowPastLiveBottom
|
|
2668
|
+
? lines.length - 1
|
|
2669
|
+
: this.#manualTranscriptLineCount - this.#manualTranscriptCapacity(height),
|
|
2670
|
+
);
|
|
2090
2671
|
const nextViewportTop = Math.max(0, Math.min(maxViewportTop, viewportTop));
|
|
2091
2672
|
const currentScreenRow = Math.max(0, Math.min(height - 1, this.#hardwareCursorRow - this.#viewportTopRow));
|
|
2092
2673
|
let buffer = "\x1b[?2026h";
|
|
@@ -2095,12 +2676,25 @@ export class TUI extends Container {
|
|
|
2095
2676
|
}
|
|
2096
2677
|
buffer += "\r";
|
|
2097
2678
|
|
|
2679
|
+
const transcriptCapacity = paintManual ? this.#manualTranscriptCapacity(height) : height;
|
|
2680
|
+
const noticeRows = paintManual && this.#manualOutputNotice && height > this.#manualSuffixLineCount ? 1 : 0;
|
|
2681
|
+
const committedTranscriptRows: Array<number | null> = [];
|
|
2098
2682
|
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
2099
2683
|
if (screenRow > 0) buffer += "\r\n";
|
|
2100
2684
|
buffer += "\x1b[2K";
|
|
2101
2685
|
const lineIndex = nextViewportTop + screenRow;
|
|
2102
|
-
|
|
2103
|
-
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
|
+
);
|
|
2104
2698
|
const isImage = TERMINAL.isImageLine(line);
|
|
2105
2699
|
if (!isImage && this.#visibleWidthForDifferentialGuard(line) > width) {
|
|
2106
2700
|
let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
|
|
@@ -2119,19 +2713,24 @@ export class TUI extends Container {
|
|
|
2119
2713
|
cursorSeq = cursor.seq;
|
|
2120
2714
|
cursorToRow = cursor.toRow;
|
|
2121
2715
|
}
|
|
2122
|
-
this.#hardwareCursorRow = cursorToRow;
|
|
2123
2716
|
buffer += cursorSeq;
|
|
2124
2717
|
buffer += "\x1b[?2026l";
|
|
2125
|
-
if (
|
|
2718
|
+
if (
|
|
2719
|
+
!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, lines.length, () => {
|
|
2720
|
+
this.#hardwareCursorRow = cursorToRow;
|
|
2721
|
+
this.#committedTranscriptRows = committedTranscriptRows;
|
|
2722
|
+
this.#cursorRow = Math.max(0, lines.length - 1);
|
|
2723
|
+
this.#maxLinesRendered = lines.length;
|
|
2724
|
+
this.#viewportTopRow = nextViewportTop;
|
|
2725
|
+
onPainted?.();
|
|
2726
|
+
})
|
|
2727
|
+
)
|
|
2728
|
+
return false;
|
|
2126
2729
|
|
|
2127
2730
|
if (this.#debugRedraw) {
|
|
2128
2731
|
const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (lines=${lines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
|
|
2129
2732
|
this.#appendDebugRedrawLog(msg);
|
|
2130
2733
|
}
|
|
2131
|
-
|
|
2132
|
-
this.#cursorRow = Math.max(0, lines.length - 1);
|
|
2133
|
-
this.#maxLinesRendered = lines.length;
|
|
2134
|
-
this.#viewportTopRow = nextViewportTop;
|
|
2135
2734
|
return true;
|
|
2136
2735
|
}
|
|
2137
2736
|
|
|
@@ -2162,13 +2761,30 @@ export class TUI extends Container {
|
|
|
2162
2761
|
}
|
|
2163
2762
|
for (const line of rendered.lines) renderedLines.push(line);
|
|
2164
2763
|
}
|
|
2764
|
+
let pinnedChildIndex = -1;
|
|
2765
|
+
for (let index = 0; index < this.children.length; index++) {
|
|
2766
|
+
if (this.children[index] === this.#bottomPinnedComponent) {
|
|
2767
|
+
pinnedChildIndex = index;
|
|
2768
|
+
break;
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
const hasStickySuffix = pinnedChildIndex >= 0;
|
|
2772
|
+
const sourceTranscriptLineCount = hasStickySuffix
|
|
2773
|
+
? this.children
|
|
2774
|
+
.slice(0, pinnedChildIndex)
|
|
2775
|
+
.reduce((count, child) => count + this.#pinnedChildLines(child, renderedChildren).length, 0)
|
|
2776
|
+
: renderedLines.length;
|
|
2165
2777
|
const anchorRenderFailed = viewportAnchorRenderFailureCount !== anchorRenderFailureCountBefore;
|
|
2166
|
-
let newLines = renderedLines;
|
|
2778
|
+
let newLines = this.#constrainPinnedSuffix(renderedLines, height, renderedChildren);
|
|
2167
2779
|
this.#viewportAnchorFrame = anchorFrame;
|
|
2168
2780
|
if (renderMetrics.enabled) renderMetrics.recordHelper("renderTree", renderMetrics.now() - renderTreeStart);
|
|
2169
2781
|
|
|
2170
|
-
if (
|
|
2171
|
-
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;
|
|
2172
2788
|
}
|
|
2173
2789
|
|
|
2174
2790
|
// Composite overlays into the rendered lines (before differential compare)
|
|
@@ -2180,6 +2796,8 @@ export class TUI extends Container {
|
|
|
2180
2796
|
const cursorPos = this.#extractCursorPosition(newLines, height);
|
|
2181
2797
|
this.#lastCursorPosition = cursorPos;
|
|
2182
2798
|
|
|
2799
|
+
newLines = this.#applyMouseSelection(newLines);
|
|
2800
|
+
|
|
2183
2801
|
// Terminate every non-image line so #previousLines mirrors emitted bytes
|
|
2184
2802
|
// (closes SGR + OSC 8 hyperlink state). Must run after cursor extraction
|
|
2185
2803
|
// because the marker is embedded mid-line, and before any diff/full render
|
|
@@ -2188,9 +2806,9 @@ export class TUI extends Container {
|
|
|
2188
2806
|
const widthChanged = this.#previousWidth !== 0 && this.#previousWidth !== width;
|
|
2189
2807
|
const heightChanged = this.#previousHeight !== 0 && this.#previousHeight !== height;
|
|
2190
2808
|
|
|
2191
|
-
// Normalize/truncate lines for emission.
|
|
2192
|
-
//
|
|
2193
|
-
// 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
|
|
2194
2812
|
// short-circuit for cached components), so only the visible window is
|
|
2195
2813
|
// re-normalized and the diff starts at the window. Output is byte-identical to the
|
|
2196
2814
|
// full path (reused entries are deterministic normalizations of identical raw lines).
|
|
@@ -2240,6 +2858,21 @@ export class TUI extends Container {
|
|
|
2240
2858
|
if (usedWindowNormalize) renderMetrics.recordLineCount("offscreenScan", diffStart);
|
|
2241
2859
|
}
|
|
2242
2860
|
this.#latestRenderedLines = newLines;
|
|
2861
|
+
this.#manualTranscriptLineCount = sourceTranscriptLineCount;
|
|
2862
|
+
this.#manualSuffixLineCount = Math.max(0, newLines.length - sourceTranscriptLineCount);
|
|
2863
|
+
const naturalViewportTop = Math.max(0, newLines.length - height);
|
|
2864
|
+
const priorLogicalLineCount = Math.max(this.#previousLines.length, this.#maxLinesRendered);
|
|
2865
|
+
if (this.#transcriptIdentityResetPending) {
|
|
2866
|
+
this.#transcriptIdentityResetPending = false;
|
|
2867
|
+
} else if (
|
|
2868
|
+
newLines.length < priorLogicalLineCount &&
|
|
2869
|
+
(naturalViewportTop < prevViewportTop || this.#manualViewportTop !== undefined)
|
|
2870
|
+
) {
|
|
2871
|
+
this.#scrollbackResumeViewportTop = Math.max(
|
|
2872
|
+
this.#scrollbackResumeViewportTop ?? 0,
|
|
2873
|
+
this.#nativeScrollbackViewportTop,
|
|
2874
|
+
);
|
|
2875
|
+
}
|
|
2243
2876
|
|
|
2244
2877
|
if (this.#manualViewportTop !== undefined) {
|
|
2245
2878
|
let resolvedAnchorTop = anchorFrame === null ? null : this.#resolveManualAnchor(anchorFrame);
|
|
@@ -2295,6 +2928,7 @@ export class TUI extends Container {
|
|
|
2295
2928
|
this.#previousWidth === width &&
|
|
2296
2929
|
this.#previousHeight === height &&
|
|
2297
2930
|
nextViewportTop === this.#manualViewportTop &&
|
|
2931
|
+
this.#manualOutputNotice === this.#paintedManualOutputNotice &&
|
|
2298
2932
|
newLines.length === this.#previousLines.length &&
|
|
2299
2933
|
newLines.every((line, index) => line === this.#previousLines[index])
|
|
2300
2934
|
) {
|
|
@@ -2316,46 +2950,69 @@ export class TUI extends Container {
|
|
|
2316
2950
|
this.#previousLines = newLines;
|
|
2317
2951
|
this.#previousWidth = width;
|
|
2318
2952
|
this.#previousHeight = height;
|
|
2953
|
+
this.#paintedManualOutputNotice = this.#manualOutputNotice;
|
|
2319
2954
|
}
|
|
2320
2955
|
return;
|
|
2321
2956
|
}
|
|
2322
2957
|
// Helper to clear scrollback and viewport and render all new lines
|
|
2323
|
-
|
|
2958
|
+
let viewportRepaint: (reason: string, targetViewportTop?: number) => void;
|
|
2959
|
+
const fullRender = (clear: boolean, reason = "full render", forceScrollbackClear = false): void => {
|
|
2960
|
+
if (
|
|
2961
|
+
clear &&
|
|
2962
|
+
!forceScrollbackClear &&
|
|
2963
|
+
shouldPreserveScrollbackOnFullClear(this.terminal) &&
|
|
2964
|
+
this.#scrollbackResumeViewportTop !== undefined
|
|
2965
|
+
) {
|
|
2966
|
+
viewportRepaint(`preserving full replay blocked after scrollback-unsafe contraction: ${reason}`);
|
|
2967
|
+
return;
|
|
2968
|
+
}
|
|
2324
2969
|
this.#fullRedrawCount += 1;
|
|
2325
2970
|
if (renderMetrics.enabled) renderMetrics.recordFullRedraw(reason);
|
|
2326
2971
|
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
|
2327
2972
|
// Skip clearing scrollback (3J) in hosts where clear/replay can snap the
|
|
2328
|
-
// native viewport away from the live prompt (tmux/screen, Windows ConPTY)
|
|
2973
|
+
// native viewport away from the live prompt (tmux/screen, Windows ConPTY) —
|
|
2974
|
+
// unless the caller explicitly needs history erased (the settled width
|
|
2975
|
+
// repair, where a replay WITHOUT 3J would stack the new transcript on top
|
|
2976
|
+
// of the stale-width copy instead of replacing it).
|
|
2329
2977
|
if (clear)
|
|
2330
|
-
buffer +=
|
|
2978
|
+
buffer +=
|
|
2979
|
+
!forceScrollbackClear && shouldPreserveScrollbackOnFullClear(this.terminal)
|
|
2980
|
+
? "\x1b[2J\x1b[H"
|
|
2981
|
+
: "\x1b[2J\x1b[H\x1b[3J";
|
|
2331
2982
|
for (let i = 0; i < newLines.length; i++) {
|
|
2332
2983
|
if (i > 0) buffer += "\r\n";
|
|
2333
2984
|
// Lines were pre-terminated/normalized by #applyLineResets; image
|
|
2334
2985
|
// lines were left untouched there.
|
|
2335
2986
|
buffer += newLines[i];
|
|
2336
2987
|
}
|
|
2337
|
-
|
|
2338
|
-
const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length,
|
|
2339
|
-
this.#hardwareCursorRow = toRow;
|
|
2988
|
+
const cursorRow = Math.max(0, newLines.length - 1);
|
|
2989
|
+
const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, cursorRow);
|
|
2340
2990
|
buffer += seq;
|
|
2341
2991
|
buffer += "\x1b[?2026l"; // End synchronized output
|
|
2342
|
-
if (
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2992
|
+
if (
|
|
2993
|
+
!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length, () => {
|
|
2994
|
+
this.#cursorRow = cursorRow;
|
|
2995
|
+
this.#hardwareCursorRow = toRow;
|
|
2996
|
+
this.#maxLinesRendered = clear ? newLines.length : Math.max(this.#maxLinesRendered, newLines.length);
|
|
2997
|
+
this.#viewportTopRow = Math.max(0, this.#maxLinesRendered - height);
|
|
2998
|
+
this.#nativeScrollbackViewportTop = clear
|
|
2999
|
+
? this.#viewportTopRow
|
|
3000
|
+
: Math.max(this.#nativeScrollbackViewportTop, this.#viewportTopRow);
|
|
3001
|
+
if (clear && (forceScrollbackClear || !shouldPreserveScrollbackOnFullClear(this.terminal))) {
|
|
3002
|
+
this.#scrollbackResumeViewportTop = undefined;
|
|
3003
|
+
}
|
|
3004
|
+
this.#previousLines = newLines;
|
|
3005
|
+
this.#previousWidth = width;
|
|
3006
|
+
this.#previousHeight = height;
|
|
3007
|
+
})
|
|
3008
|
+
)
|
|
3009
|
+
return;
|
|
2353
3010
|
};
|
|
2354
3011
|
|
|
2355
|
-
|
|
3012
|
+
viewportRepaint = (reason: string, targetViewportTop = Math.max(0, newLines.length - height)): void => {
|
|
2356
3013
|
this.#fullRedrawCount += 1;
|
|
2357
3014
|
if (renderMetrics.enabled) renderMetrics.recordFullRedraw(reason);
|
|
2358
|
-
const nextViewportTop =
|
|
3015
|
+
const nextViewportTop = targetViewportTop;
|
|
2359
3016
|
const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
|
|
2360
3017
|
let buffer = "\x1b[?2026h";
|
|
2361
3018
|
if (currentScreenRow > 0) {
|
|
@@ -2386,25 +3043,25 @@ export class TUI extends Container {
|
|
|
2386
3043
|
cursorSeq = cursor.seq;
|
|
2387
3044
|
cursorToRow = cursor.toRow;
|
|
2388
3045
|
}
|
|
2389
|
-
this.#hardwareCursorRow = cursorToRow;
|
|
2390
3046
|
buffer += cursorSeq;
|
|
2391
3047
|
buffer += "\x1b[?2026l";
|
|
2392
|
-
if (
|
|
3048
|
+
if (
|
|
3049
|
+
!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length, () => {
|
|
3050
|
+
this.#hardwareCursorRow = cursorToRow;
|
|
3051
|
+
this.#cursorRow = Math.max(0, newLines.length - 1);
|
|
3052
|
+
this.#maxLinesRendered = newLines.length;
|
|
3053
|
+
this.#viewportTopRow = nextViewportTop;
|
|
3054
|
+
this.#previousLines = newLines;
|
|
3055
|
+
this.#previousWidth = width;
|
|
3056
|
+
this.#previousHeight = height;
|
|
3057
|
+
})
|
|
3058
|
+
)
|
|
3059
|
+
return;
|
|
2393
3060
|
|
|
2394
3061
|
if (this.#debugRedraw) {
|
|
2395
3062
|
const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
|
|
2396
3063
|
this.#appendDebugRedrawLog(msg);
|
|
2397
3064
|
}
|
|
2398
|
-
// Viewport repaint deliberately prioritizes the live viewport over
|
|
2399
|
-
// historical scrollback repair. After offscreen changes, #previousLines
|
|
2400
|
-
// tracks the desired logical transcript, not every byte emitted into the
|
|
2401
|
-
// terminal scrollback.
|
|
2402
|
-
this.#cursorRow = Math.max(0, newLines.length - 1);
|
|
2403
|
-
this.#maxLinesRendered = newLines.length;
|
|
2404
|
-
this.#viewportTopRow = nextViewportTop;
|
|
2405
|
-
this.#previousLines = newLines;
|
|
2406
|
-
this.#previousWidth = width;
|
|
2407
|
-
this.#previousHeight = height;
|
|
2408
3065
|
};
|
|
2409
3066
|
|
|
2410
3067
|
const debugRedraw = this.#debugRedraw;
|
|
@@ -2423,14 +3080,26 @@ export class TUI extends Container {
|
|
|
2423
3080
|
|
|
2424
3081
|
// Width changes always need a full re-render because wrapping changes.
|
|
2425
3082
|
if (widthChanged) {
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
//
|
|
2429
|
-
//
|
|
2430
|
-
//
|
|
2431
|
-
//
|
|
3083
|
+
if (this.#widthSettleRepairPending) {
|
|
3084
|
+
logRedraw(`width settled (${this.#previousWidth} -> ${width})`);
|
|
3085
|
+
// The one debounced post-resize repair: a full clear+replay so stale
|
|
3086
|
+
// old-width wrapping is repaired in scrollback history too, not just
|
|
3087
|
+
// the live viewport. forceScrollbackClear erases the stale-width
|
|
3088
|
+
// history instead of stacking the replay on top of it. Safe against
|
|
3089
|
+
// the replay storm because it runs once per settled width sequence,
|
|
3090
|
+
// never once per SIGWINCH.
|
|
3091
|
+
this.#widthSettleRepairPending = false;
|
|
3092
|
+
fullRender(true, "width settled", true);
|
|
3093
|
+
} else if (useViewportRepaintPath(this.terminal)) {
|
|
3094
|
+
logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
|
|
3095
|
+
// In viewport-repaint sessions a per-event full replay can either pile
|
|
3096
|
+
// the transcript back onto scrollback (tmux/screen) or visibly jump to
|
|
3097
|
+
// the transcript top (Windows Terminal). Repaint the viewport only,
|
|
3098
|
+
// mirroring the height-change branch and neutralizing fake width
|
|
3099
|
+
// changes from requestRender(true).
|
|
2432
3100
|
viewportRepaint(`terminal width changed (${this.#previousWidth} -> ${width})`);
|
|
2433
3101
|
} else {
|
|
3102
|
+
logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
|
|
2434
3103
|
fullRender(true, "terminal width changed");
|
|
2435
3104
|
}
|
|
2436
3105
|
return;
|
|
@@ -2491,7 +3160,7 @@ export class TUI extends Container {
|
|
|
2491
3160
|
}
|
|
2492
3161
|
lastChanged = newLines.length - 1;
|
|
2493
3162
|
}
|
|
2494
|
-
|
|
3163
|
+
let appendStart = appendedLines && firstChanged === this.#previousLines.length && firstChanged > 0;
|
|
2495
3164
|
|
|
2496
3165
|
// No changes - but still need to update hardware cursor position if it moved
|
|
2497
3166
|
if (firstChanged === -1) {
|
|
@@ -2505,6 +3174,32 @@ export class TUI extends Container {
|
|
|
2505
3174
|
viewportRepaint(`content contraction changed viewport top (${prevViewportTop} -> ${nextLiveViewportTop})`);
|
|
2506
3175
|
return;
|
|
2507
3176
|
}
|
|
3177
|
+
if (appendedLines && this.#scrollbackResumeViewportTop !== undefined && nextLiveViewportTop > prevViewportTop) {
|
|
3178
|
+
const resumeViewportTop = this.#scrollbackResumeViewportTop;
|
|
3179
|
+
if (nextLiveViewportTop <= resumeViewportTop) {
|
|
3180
|
+
viewportRepaint(
|
|
3181
|
+
`content expansion below committed scrollback frontier (${prevViewportTop} -> ${nextLiveViewportTop}, frontier=${resumeViewportTop})`,
|
|
3182
|
+
);
|
|
3183
|
+
return;
|
|
3184
|
+
}
|
|
3185
|
+
|
|
3186
|
+
const previousLines = this.#previousLines;
|
|
3187
|
+
const previousWidth = this.#previousWidth;
|
|
3188
|
+
const previousHeight = this.#previousHeight;
|
|
3189
|
+
viewportRepaint(
|
|
3190
|
+
`staging committed scrollback frontier before resumed admission (${prevViewportTop} -> ${resumeViewportTop} -> ${nextLiveViewportTop})`,
|
|
3191
|
+
resumeViewportTop,
|
|
3192
|
+
);
|
|
3193
|
+
this.#previousLines = previousLines;
|
|
3194
|
+
this.#previousWidth = previousWidth;
|
|
3195
|
+
this.#previousHeight = previousHeight;
|
|
3196
|
+
prevViewportTop = resumeViewportTop;
|
|
3197
|
+
viewportTop = resumeViewportTop;
|
|
3198
|
+
hardwareCursorRow = this.#hardwareCursorRow;
|
|
3199
|
+
firstChanged = resumeViewportTop;
|
|
3200
|
+
appendStart = false;
|
|
3201
|
+
this.#scrollbackResumeViewportTop = undefined;
|
|
3202
|
+
}
|
|
2508
3203
|
// All changes are in deleted lines (nothing to render, just clear)
|
|
2509
3204
|
if (firstChanged >= newLines.length) {
|
|
2510
3205
|
if (this.#previousLines.length > newLines.length) {
|
|
@@ -2538,12 +3233,21 @@ export class TUI extends Container {
|
|
|
2538
3233
|
if (moveUp > 0) {
|
|
2539
3234
|
buffer += `\x1b[${moveUp}A`;
|
|
2540
3235
|
}
|
|
2541
|
-
this.#cursorRow = targetRow;
|
|
2542
3236
|
const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, targetRow);
|
|
2543
|
-
this.#hardwareCursorRow = toRow;
|
|
2544
3237
|
buffer += seq;
|
|
2545
3238
|
buffer += "\x1b[?2026l";
|
|
2546
|
-
if (
|
|
3239
|
+
if (
|
|
3240
|
+
!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length, () => {
|
|
3241
|
+
this.#cursorRow = targetRow;
|
|
3242
|
+
this.#hardwareCursorRow = toRow;
|
|
3243
|
+
this.#previousLines = newLines;
|
|
3244
|
+
this.#previousWidth = width;
|
|
3245
|
+
this.#previousHeight = height;
|
|
3246
|
+
this.#maxLinesRendered = newLines.length;
|
|
3247
|
+
this.#viewportTopRow = Math.max(0, newLines.length - height);
|
|
3248
|
+
})
|
|
3249
|
+
)
|
|
3250
|
+
return;
|
|
2547
3251
|
}
|
|
2548
3252
|
this.#previousLines = newLines;
|
|
2549
3253
|
this.#previousWidth = width;
|
|
@@ -2658,7 +3362,6 @@ export class TUI extends Container {
|
|
|
2658
3362
|
}
|
|
2659
3363
|
|
|
2660
3364
|
const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, finalCursorRow);
|
|
2661
|
-
this.#hardwareCursorRow = toRow;
|
|
2662
3365
|
buffer += seq;
|
|
2663
3366
|
buffer += "\x1b[?2026l"; // End synchronized output
|
|
2664
3367
|
|
|
@@ -2692,20 +3395,22 @@ export class TUI extends Container {
|
|
|
2692
3395
|
fs.writeFileSync(debugPath, debugData);
|
|
2693
3396
|
}
|
|
2694
3397
|
|
|
2695
|
-
// Write entire buffer at once
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
3398
|
+
// Write entire buffer at once. Once those bytes are accepted, the painted
|
|
3399
|
+
// frame and geometry are authoritative even when the optional IME cursor
|
|
3400
|
+
// write subsequently detaches the terminal.
|
|
3401
|
+
if (
|
|
3402
|
+
!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length, () => {
|
|
3403
|
+
this.#hardwareCursorRow = toRow;
|
|
3404
|
+
this.#cursorRow = Math.max(0, newLines.length - 1);
|
|
3405
|
+
this.#maxLinesRendered = newLines.length;
|
|
3406
|
+
this.#viewportTopRow = Math.max(0, newLines.length - height);
|
|
3407
|
+
this.#nativeScrollbackViewportTop = Math.max(this.#nativeScrollbackViewportTop, this.#viewportTopRow);
|
|
3408
|
+
this.#previousLines = newLines;
|
|
3409
|
+
this.#previousWidth = width;
|
|
3410
|
+
this.#previousHeight = height;
|
|
3411
|
+
})
|
|
3412
|
+
)
|
|
3413
|
+
return;
|
|
2709
3414
|
}
|
|
2710
3415
|
|
|
2711
3416
|
/**
|
|
@@ -2780,6 +3485,7 @@ export class TUI extends Container {
|
|
|
2780
3485
|
buffer: string,
|
|
2781
3486
|
cursorPos: { row: number; col: number } | null,
|
|
2782
3487
|
totalLines: number,
|
|
3488
|
+
onBufferWritten?: () => void,
|
|
2783
3489
|
): boolean {
|
|
2784
3490
|
const overlay = this.#postRenderEmitter?.();
|
|
2785
3491
|
if (overlay) {
|
|
@@ -2789,8 +3495,14 @@ export class TUI extends Container {
|
|
|
2789
3495
|
buffer += `\x1b[?2026h\x1b7${overlay}\x1b8\x1b[?2026l`;
|
|
2790
3496
|
}
|
|
2791
3497
|
if (!this.#writeTerminal(buffer)) return false;
|
|
2792
|
-
|
|
2793
|
-
|
|
3498
|
+
onBufferWritten?.();
|
|
3499
|
+
if (!this.#imeCursorActive) {
|
|
3500
|
+
this.#lastRenderWriteSucceeded = true;
|
|
3501
|
+
return true;
|
|
3502
|
+
}
|
|
3503
|
+
const cursorWritten = this.#writeCursorPosition(cursorPos, totalLines);
|
|
3504
|
+
if (cursorWritten) this.#lastRenderWriteSucceeded = true;
|
|
3505
|
+
return cursorWritten;
|
|
2794
3506
|
}
|
|
2795
3507
|
|
|
2796
3508
|
/**
|
|
@@ -2803,8 +3515,9 @@ export class TUI extends Container {
|
|
|
2803
3515
|
return this.#hideCursor();
|
|
2804
3516
|
}
|
|
2805
3517
|
const { seq, toRow } = this.#cursorControlSequence(cursorPos, totalLines, this.#hardwareCursorRow);
|
|
2806
|
-
this.#hardwareCursorRow = toRow;
|
|
2807
3518
|
// No \x1b[?2026h/l wrapper: synchronized output flushes terminal state and discards macOS IME composition.
|
|
2808
|
-
|
|
3519
|
+
if (!this.#writeTerminal(seq)) return false;
|
|
3520
|
+
this.#hardwareCursorRow = toRow;
|
|
3521
|
+
return true;
|
|
2809
3522
|
}
|
|
2810
3523
|
}
|