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