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