@gajae-code/tui 0.12.7 → 0.12.8
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 +10 -0
- package/dist/types/components/editor.d.ts +1 -1
- package/dist/types/tui.d.ts +9 -25
- package/package.json +3 -3
- package/src/components/editor.ts +6 -1
- package/src/tui.ts +829 -299
package/src/tui.ts
CHANGED
|
@@ -38,7 +38,7 @@ const SEGMENT_RESET = "\x1b[0m";
|
|
|
38
38
|
* Per-line terminator written at the end of every non-image line. Closes both
|
|
39
39
|
* SGR state and any in-flight OSC 8 hyperlink so styles/links cannot bleed
|
|
40
40
|
* across lines in scrollback. Applied by {@link TUI.#applyLineResets} before
|
|
41
|
-
* diffing so
|
|
41
|
+
* diffing so the latest frame mirrors emitted bytes.
|
|
42
42
|
*/
|
|
43
43
|
const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x07";
|
|
44
44
|
const MOUSE_SELECTION_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
@@ -55,7 +55,59 @@ function stripTerminalControls(text: string): string {
|
|
|
55
55
|
.replace(/\x1b(?:\[[0-?]*[ -/]*[@-~]|[@-_])/gu, "")
|
|
56
56
|
.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/gu, "");
|
|
57
57
|
}
|
|
58
|
+
const CSI_PARAMETER = (value: number): boolean => value >= 0x30 && value <= 0x3f;
|
|
59
|
+
const CSI_INTERMEDIATE = (value: number): boolean => value >= 0x20 && value <= 0x2f;
|
|
60
|
+
const CSI_FINAL = (value: number): boolean => value >= 0x40 && value <= 0x7e;
|
|
58
61
|
|
|
62
|
+
function csiEnd(bytes: string, start: number): number | undefined {
|
|
63
|
+
for (let index = start; index < bytes.length; index += 1) {
|
|
64
|
+
const value = bytes.charCodeAt(index);
|
|
65
|
+
if (CSI_FINAL(value)) return index;
|
|
66
|
+
if (!CSI_PARAMETER(value) && !CSI_INTERMEDIATE(value)) return undefined;
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Remove component-owned erase controls and incomplete CSI fragments before
|
|
73
|
+
* persistent bytes enter a shared render transaction. Erase controls cannot
|
|
74
|
+
* repair native scrollback; dropping only the control preserves surrounding text
|
|
75
|
+
* and keeps later frames renderable.
|
|
76
|
+
*/
|
|
77
|
+
function stripTerminalEraseControls(bytes: string): string {
|
|
78
|
+
let sanitized = "";
|
|
79
|
+
for (let index = 0; index < bytes.length; index += 1) {
|
|
80
|
+
const value = bytes.charCodeAt(index);
|
|
81
|
+
const isEscapeCsi = value === 0x1b && bytes.charCodeAt(index + 1) === 0x5b;
|
|
82
|
+
const isEightBitCsi = value === 0x9b;
|
|
83
|
+
if (value === 0x1b && index === bytes.length - 1) break;
|
|
84
|
+
if (!isEscapeCsi && !isEightBitCsi) {
|
|
85
|
+
sanitized += bytes[index];
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const start = isEightBitCsi ? index + 1 : index + 2;
|
|
90
|
+
const end = csiEnd(bytes, start);
|
|
91
|
+
if (end === undefined) {
|
|
92
|
+
// Drop the CSI introducer and its complete parameter/intermediate prefix.
|
|
93
|
+
// If an invalid delimiter follows, revisit it as ordinary text.
|
|
94
|
+
let next = start;
|
|
95
|
+
while (next < bytes.length) {
|
|
96
|
+
const nextValue = bytes.charCodeAt(next);
|
|
97
|
+
if (!CSI_PARAMETER(nextValue) && !CSI_INTERMEDIATE(nextValue)) break;
|
|
98
|
+
next += 1;
|
|
99
|
+
}
|
|
100
|
+
index = next - 1;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const final = bytes.charCodeAt(end);
|
|
104
|
+
if (final !== 0x4a && final !== 0x4b) {
|
|
105
|
+
sanitized += bytes.slice(index, end + 1);
|
|
106
|
+
}
|
|
107
|
+
index = end;
|
|
108
|
+
}
|
|
109
|
+
return sanitized;
|
|
110
|
+
}
|
|
59
111
|
type InputListenerResult = { consume?: boolean; data?: string } | undefined;
|
|
60
112
|
type InputListener = (data: string) => InputListenerResult;
|
|
61
113
|
|
|
@@ -306,23 +358,12 @@ function parseSizeValue(value: SizeValue | undefined, referenceSize: number): nu
|
|
|
306
358
|
return undefined;
|
|
307
359
|
}
|
|
308
360
|
|
|
309
|
-
function isTermuxSession(env: Record<string, string | undefined> = Bun.env): boolean {
|
|
310
|
-
return Boolean(env.TERMUX_VERSION);
|
|
311
|
-
}
|
|
312
|
-
|
|
313
361
|
const DISABLED_ENV_VALUES = new Set(["0", "false", "off", "no"]);
|
|
314
|
-
const TRUTHY_ENV_VALUES = new Set(["1", "true", "yes", "on", "y"]);
|
|
315
362
|
|
|
316
363
|
function envIsEnabled(value: string | undefined): boolean {
|
|
317
364
|
const normalized = value?.trim().toLowerCase();
|
|
318
365
|
return normalized !== undefined && normalized.length > 0 && !DISABLED_ENV_VALUES.has(normalized);
|
|
319
366
|
}
|
|
320
|
-
|
|
321
|
-
function envFlagEnabled(value: string | undefined): boolean {
|
|
322
|
-
const normalized = value?.trim().toLowerCase();
|
|
323
|
-
return normalized !== undefined && TRUTHY_ENV_VALUES.has(normalized);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
367
|
function isWindowsTerminalSession(env: Record<string, string | undefined> = Bun.env): boolean {
|
|
327
368
|
return envIsEnabled(env.WT_SESSION) || env.TERM_PROGRAM === "Windows_Terminal";
|
|
328
369
|
}
|
|
@@ -357,22 +398,20 @@ export function shouldProbeSixelCapability(
|
|
|
357
398
|
return platform === "win32" && Boolean(env.WT_SESSION?.trim());
|
|
358
399
|
}
|
|
359
400
|
|
|
360
|
-
function useLegacyMultiplexerFullRender(env: Record<string, string | undefined> = Bun.env): boolean {
|
|
361
|
-
return envFlagEnabled(env.PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER);
|
|
362
|
-
}
|
|
363
|
-
|
|
364
401
|
function isViewportSensitiveHost(
|
|
365
402
|
env: Record<string, string | undefined>,
|
|
366
403
|
platform: NodeJS.Platform,
|
|
367
404
|
includeNativeWindows: boolean,
|
|
368
405
|
includeProcessTerminal: boolean,
|
|
369
406
|
): boolean {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
407
|
+
const underMultiplexer = isMultiplexerSession(env);
|
|
408
|
+
if (underMultiplexer) {
|
|
409
|
+
// Preserve the documented opt-in for the legacy clear/replay path. This
|
|
410
|
+
// must take precedence over the process-terminal capability because tmux
|
|
411
|
+
// and screen sessions commonly expose a real process terminal as well.
|
|
412
|
+
return !envIsEnabled(env.PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER);
|
|
413
|
+
}
|
|
414
|
+
return isWindowsTerminalSession(env) || includeProcessTerminal || (includeNativeWindows && platform === "win32");
|
|
376
415
|
}
|
|
377
416
|
/**
|
|
378
417
|
* True when repainting only the live viewport is safer than clearing/replaying
|
|
@@ -386,37 +425,9 @@ export function shouldUseViewportRepaintForHost(
|
|
|
386
425
|
platform: NodeJS.Platform = process.platform,
|
|
387
426
|
options: { includeNativeWindows?: boolean; includeProcessTerminal?: boolean } = {},
|
|
388
427
|
): boolean {
|
|
389
|
-
const multiplexed = isMultiplexerSession(env);
|
|
390
428
|
const includeNativeWindows = options.includeNativeWindows ?? true;
|
|
391
429
|
const includeProcessTerminal = options.includeProcessTerminal ?? false;
|
|
392
|
-
return (
|
|
393
|
-
isViewportSensitiveHost(env, platform, includeNativeWindows, includeProcessTerminal) &&
|
|
394
|
-
!(multiplexed && useLegacyMultiplexerFullRender(env))
|
|
395
|
-
);
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
function useViewportRepaintPath(terminal: Terminal): boolean {
|
|
399
|
-
if (terminal.isProcessTerminal !== true) return false;
|
|
400
|
-
return shouldUseViewportRepaintForHost(Bun.env, process.platform, {
|
|
401
|
-
includeNativeWindows: true,
|
|
402
|
-
includeProcessTerminal: true,
|
|
403
|
-
});
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
function allowsHostNeutralOverflowRepaint(
|
|
407
|
-
terminal: Terminal,
|
|
408
|
-
env: Record<string, string | undefined> = Bun.env,
|
|
409
|
-
): boolean {
|
|
410
|
-
return (
|
|
411
|
-
terminal.isProcessTerminal === true &&
|
|
412
|
-
!isTermuxSession(env) &&
|
|
413
|
-
!(isMultiplexerSession(env) && useLegacyMultiplexerFullRender(env))
|
|
414
|
-
);
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
function shouldPreserveScrollbackOnFullClear(terminal: Terminal): boolean {
|
|
418
|
-
if (terminal.isProcessTerminal !== true) return false;
|
|
419
|
-
return isViewportSensitiveHost(Bun.env, process.platform, true, true);
|
|
430
|
+
return isViewportSensitiveHost(env, platform, includeNativeWindows, includeProcessTerminal);
|
|
420
431
|
}
|
|
421
432
|
|
|
422
433
|
/**
|
|
@@ -590,7 +601,7 @@ let viewportAnchorRenderFailureCount = 0;
|
|
|
590
601
|
|
|
591
602
|
function safeRenderComponent(component: Component, width: number, where: string): string[] {
|
|
592
603
|
try {
|
|
593
|
-
return component.render(width);
|
|
604
|
+
return component.render(width).map(stripTerminalEraseControls);
|
|
594
605
|
} catch (err) {
|
|
595
606
|
return renderFailure(component, where, err);
|
|
596
607
|
}
|
|
@@ -670,24 +681,151 @@ type KittyPlacementDeletePlan = {
|
|
|
670
681
|
output: string;
|
|
671
682
|
};
|
|
672
683
|
|
|
684
|
+
function reflowBoundaryText(line: string): string {
|
|
685
|
+
return Bun.stripANSI(line).replace(/\s+/g, "");
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function findSafeReflowSuffixStart(previousFrameLines: string[], nextFrameLines: string[]): number {
|
|
689
|
+
const previousVisibleRows = previousFrameLines.map(line => Bun.stripANSI(line).replace(/[ \t]+$/g, ""));
|
|
690
|
+
const nextVisibleRows = nextFrameLines.map(line => Bun.stripANSI(line).replace(/[ \t]+$/g, ""));
|
|
691
|
+
for (let index = 0; index < Math.min(previousVisibleRows.length, nextVisibleRows.length); index++) {
|
|
692
|
+
if (
|
|
693
|
+
reflowBoundaryText(previousFrameLines[index]) === reflowBoundaryText(nextFrameLines[index]) &&
|
|
694
|
+
previousVisibleRows[index] !== nextVisibleRows[index]
|
|
695
|
+
) {
|
|
696
|
+
return -1;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
if (previousFrameLines.length === 0) return 0;
|
|
700
|
+
|
|
701
|
+
const previousRows = previousFrameLines.map(reflowBoundaryText);
|
|
702
|
+
let lastContentRow = previousRows.length - 1;
|
|
703
|
+
while (lastContentRow >= 0 && previousRows[lastContentRow].length === 0) lastContentRow -= 1;
|
|
704
|
+
if (lastContentRow < 0) {
|
|
705
|
+
for (let index = 0; index < previousFrameLines.length; index++) {
|
|
706
|
+
if (reflowBoundaryText(nextFrameLines[index] ?? "").length > 0) return 0;
|
|
707
|
+
}
|
|
708
|
+
return previousFrameLines.length;
|
|
709
|
+
}
|
|
710
|
+
const trailingEmptyRows = previousRows.length - lastContentRow - 1;
|
|
711
|
+
const hasMeaningfulWhitespace = previousVisibleRows.slice(0, lastContentRow + 1).some(line => /\s/.test(line));
|
|
712
|
+
const previousMeaningfulText = previousVisibleRows.slice(0, lastContentRow + 1).join("");
|
|
713
|
+
const previousText = previousRows.join("");
|
|
714
|
+
// Wrapping can insert physical rows between characters from the prior frame;
|
|
715
|
+
// compare the non-whitespace content as an ordered subsequence.
|
|
716
|
+
|
|
717
|
+
const completionBoundaries: number[] = [];
|
|
718
|
+
let boundary = 0;
|
|
719
|
+
for (const row of previousRows) {
|
|
720
|
+
if (row.length === 0) continue;
|
|
721
|
+
boundary += row.length;
|
|
722
|
+
completionBoundaries.push(boundary);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
let matchedLength = 0;
|
|
726
|
+
let matchStartedAt = -1;
|
|
727
|
+
const completedAt: number[] = [];
|
|
728
|
+
for (let index = 0; index < nextFrameLines.length; index++) {
|
|
729
|
+
const rowText = reflowBoundaryText(nextFrameLines[index]);
|
|
730
|
+
const matchedBeforeRow = matchedLength;
|
|
731
|
+
for (let rowOffset = 0; rowOffset < rowText.length && matchedLength < previousText.length; rowOffset++) {
|
|
732
|
+
if (rowText[rowOffset] === previousText[matchedLength]) {
|
|
733
|
+
if (matchedLength === 0) matchStartedAt = index;
|
|
734
|
+
matchedLength += 1;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
while (
|
|
738
|
+
completedAt.length < completionBoundaries.length &&
|
|
739
|
+
matchedLength >= completionBoundaries[completedAt.length]
|
|
740
|
+
) {
|
|
741
|
+
completedAt.push(index);
|
|
742
|
+
}
|
|
743
|
+
// A row that partially matches an old row but includes extra content is an
|
|
744
|
+
// in-place mutation, not a reflow continuation. Completely unmatched
|
|
745
|
+
// nonempty rows before the old frame is consumed are ambiguous too.
|
|
746
|
+
const matchedInRow = matchedLength - matchedBeforeRow;
|
|
747
|
+
if (
|
|
748
|
+
matchedLength < previousText.length &&
|
|
749
|
+
((matchedInRow > 0 && matchedInRow !== rowText.length) ||
|
|
750
|
+
(matchedInRow === 0 && rowText.length > 0 && matchStartedAt < 0))
|
|
751
|
+
)
|
|
752
|
+
return -1;
|
|
753
|
+
if (matchedLength === previousText.length) {
|
|
754
|
+
if (
|
|
755
|
+
(hasMeaningfulWhitespace || nextVisibleRows.slice(0, index + 1).some(line => /\s/.test(line))) &&
|
|
756
|
+
nextVisibleRows.slice(0, index + 1).join("") !== previousMeaningfulText
|
|
757
|
+
)
|
|
758
|
+
return -1;
|
|
759
|
+
// A match that starts after the prior logical frame is necessarily
|
|
760
|
+
// sourced from the appended suffix. It cannot prove that the old
|
|
761
|
+
// frame reflowed; repaint the frame conservatively instead.
|
|
762
|
+
if (matchStartedAt < 0 || matchStartedAt >= previousFrameLines.length) return -1;
|
|
763
|
+
// Completing the final old row before the current row ends is ambiguous:
|
|
764
|
+
// the row may have been mutated in place and wrapped below it.
|
|
765
|
+
if (completedAt.length === completionBoundaries.length && rowText.length > matchedInRow) return -1;
|
|
766
|
+
let trailingMatched = 0;
|
|
767
|
+
while (
|
|
768
|
+
trailingMatched < trailingEmptyRows &&
|
|
769
|
+
index + 1 + trailingMatched < nextFrameLines.length &&
|
|
770
|
+
reflowBoundaryText(nextFrameLines[index + 1 + trailingMatched]).length === 0
|
|
771
|
+
) {
|
|
772
|
+
trailingMatched += 1;
|
|
773
|
+
}
|
|
774
|
+
if (trailingMatched !== trailingEmptyRows) return -1;
|
|
775
|
+
|
|
776
|
+
// If the final old row was completed by a continuation row, the
|
|
777
|
+
// completion index already includes that reflow row. Do not advance
|
|
778
|
+
// past it again: the first appended row may itself be wrapped.
|
|
779
|
+
// A trailing final-row exclusion requires direct evidence that the final
|
|
780
|
+
// row itself wrapped. Earlier completion gaps cannot establish that.
|
|
781
|
+
return Math.min(nextFrameLines.length, index + 1 + trailingMatched);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return -1;
|
|
785
|
+
}
|
|
786
|
+
function findStableLogicalAppendBoundary(previousFrameLines: string[], nextFrameLines: string[]): number {
|
|
787
|
+
if (previousFrameLines.length === 0 || nextFrameLines.length <= previousFrameLines.length) return -1;
|
|
788
|
+
for (let index = 0; index < previousFrameLines.length; index++) {
|
|
789
|
+
if (nextFrameLines[index] !== previousFrameLines[index]) return -1;
|
|
790
|
+
}
|
|
791
|
+
return previousFrameLines.length;
|
|
792
|
+
}
|
|
793
|
+
function hasDistinctPostContractionRows(
|
|
794
|
+
latestFrameLines: string[],
|
|
795
|
+
nextFrameLines: string[],
|
|
796
|
+
durableFrameLines: string[],
|
|
797
|
+
nextRawLines: string[],
|
|
798
|
+
durableRawLines: string[],
|
|
799
|
+
): boolean {
|
|
800
|
+
if (nextFrameLines.length <= latestFrameLines.length || nextFrameLines.length > durableFrameLines.length)
|
|
801
|
+
return false;
|
|
802
|
+
for (let index = latestFrameLines.length; index < nextFrameLines.length; index += 1) {
|
|
803
|
+
if (nextRawLines[index] !== durableRawLines[index]) return true;
|
|
804
|
+
}
|
|
805
|
+
return false;
|
|
806
|
+
}
|
|
807
|
+
|
|
673
808
|
/**
|
|
674
809
|
* TUI - Main class for managing terminal UI with differential rendering
|
|
675
810
|
*/
|
|
676
811
|
export class TUI extends Container {
|
|
677
812
|
terminal: Terminal;
|
|
678
813
|
#previousLines: string[] = [];
|
|
814
|
+
// Latest logical frame, including rows shown only by a transient viewport paint.
|
|
679
815
|
#latestRenderedLines: string[] = [];
|
|
680
816
|
#latestRenderedTranscriptLineCount = 0;
|
|
681
817
|
#latestRenderedSuffixLineCount = 0;
|
|
682
818
|
#latestRenderedPlacementOwners = new Map<string, KittyPlacementOwner>();
|
|
683
|
-
/**
|
|
684
|
-
* Raw (pre-normalization) lines from the previous frame, kept only when the
|
|
685
|
-
* virtual-viewport flag is on. Used to detect whether the off-screen prefix is
|
|
686
|
-
* unchanged (by raw value equality, with a fast reference short-circuit when components
|
|
687
|
-
* return stable string instances) so its normalized form can be reused (bounded normalize).
|
|
688
|
-
*/
|
|
689
|
-
#previousRaw: string[] = [];
|
|
690
819
|
#kittyPlacementSpans: KittyPlacementSpan[] = [];
|
|
820
|
+
#latestRaw: string[] = [];
|
|
821
|
+
#durableLineCount = 0;
|
|
822
|
+
#durableRenderedLines: string[] = [];
|
|
823
|
+
#durableRawLines: string[] = [];
|
|
824
|
+
#restartDurableLineCount = 0;
|
|
825
|
+
#restartDurableRenderedLines: string[] = [];
|
|
826
|
+
#restartDurableRawLines: string[] = [];
|
|
827
|
+
#restartDurableWidth = 0;
|
|
828
|
+
#transcriptIdentityReplaced = false;
|
|
691
829
|
#lineNormalizationCache = new Map<string, LineNormalizationCacheEntry>();
|
|
692
830
|
#lineEmitWidthCache = new Map<string, number>();
|
|
693
831
|
#lineTruncationCache = new Map<string, string>();
|
|
@@ -706,9 +844,16 @@ export class TUI extends Container {
|
|
|
706
844
|
#committedRenderGeneration = 0;
|
|
707
845
|
#renderCommitWaiters = new Map<number, Set<RenderCommitWaiter>>();
|
|
708
846
|
#lastRenderWriteSucceeded = false;
|
|
847
|
+
#resizeRenderQueued = false;
|
|
848
|
+
#resizeRenderMutationQueued = false;
|
|
849
|
+
#renderMutationQueued = false;
|
|
709
850
|
#renderTimer: NodeJS.Timeout | undefined;
|
|
710
851
|
#widthSettleTimer: NodeJS.Timeout | undefined;
|
|
711
852
|
#widthSettleRepairPending = false;
|
|
853
|
+
#widthSettleRenderQueued = false;
|
|
854
|
+
#tabWidthRepairPending = false;
|
|
855
|
+
#forcedRenderQueued = false;
|
|
856
|
+
#restartViewportRepaintPending = false;
|
|
712
857
|
#lastObservedWidth = 0;
|
|
713
858
|
// Trailing debounce for the settled width repair. Instance-local: taken from
|
|
714
859
|
// options.widthSettleMs when provided (deterministic harnesses pass 0 to
|
|
@@ -735,6 +880,7 @@ export class TUI extends Container {
|
|
|
735
880
|
#viewportTopRow = 0; // Content row currently mapped to screen row 0
|
|
736
881
|
#scrollbackResumeViewportTop: number | undefined; // Reflowed history below this frontier is already committed
|
|
737
882
|
#nativeScrollbackViewportTop = 0;
|
|
883
|
+
#nativeScrollbackAdmissionPending = false;
|
|
738
884
|
#transcriptIdentityResetPending = false;
|
|
739
885
|
#manualViewportTop: number | undefined;
|
|
740
886
|
#viewportAnchorComponent: Component | null = null;
|
|
@@ -751,11 +897,13 @@ export class TUI extends Container {
|
|
|
751
897
|
#sixelProbeUnsubscribe?: () => void;
|
|
752
898
|
#showHardwareCursor = $pickflag("GJC_HARDWARE_CURSOR", "PI_HARDWARE_CURSOR");
|
|
753
899
|
#debugRedraw = TUI.#readDebugRedrawFlag();
|
|
900
|
+
#legacyMultiplexerFullRender = false;
|
|
754
901
|
// macOS: steady-block cursor anchors CJK IME overlays; disable with GJC_TUI_IME_CURSOR=0.
|
|
755
902
|
readonly #useImeBlockCursor = $flag("GJC_TUI_IME_CURSOR", process.platform === "darwin");
|
|
756
903
|
// showHardwareCursor=false but cursor is shown for IME anchoring (macOS).
|
|
757
904
|
#imeCursorActive = false;
|
|
758
|
-
#clearOnShrink = $pickflag("GJC_CLEAR_ON_SHRINK", "PI_CLEAR_ON_SHRINK");
|
|
905
|
+
#clearOnShrink = $pickflag("GJC_CLEAR_ON_SHRINK", "PI_CLEAR_ON_SHRINK");
|
|
906
|
+
|
|
759
907
|
// Default-on: reuse the previous normalized off-screen prefix and only normalize/diff the
|
|
760
908
|
// visible window, bounding per-frame work on huge transcripts. Output stays byte-identical;
|
|
761
909
|
// set PI_TUI_VIRTUAL_VIEWPORT=0 to restore legacy full-transcript normalization.
|
|
@@ -811,6 +959,15 @@ export class TUI extends Container {
|
|
|
811
959
|
TUI.#renderCounters.differentialGuardVisibleWidthCalls += 1;
|
|
812
960
|
return visibleWidth(line);
|
|
813
961
|
}
|
|
962
|
+
#recordDurableLines(lines: string[], rawLines: string[], start: number, end: number): void {
|
|
963
|
+
const retainedLength = Math.max(this.#durableRenderedLines.length, this.#durableLineCount);
|
|
964
|
+
if (this.#durableRenderedLines.length < retainedLength) this.#durableRenderedLines.length = retainedLength;
|
|
965
|
+
if (this.#durableRawLines.length < retainedLength) this.#durableRawLines.length = retainedLength;
|
|
966
|
+
for (let index = start; index <= end && index < lines.length; index += 1) {
|
|
967
|
+
this.#durableRenderedLines[index] = lines[index]!;
|
|
968
|
+
this.#durableRawLines[index] = rawLines[index] ?? lines[index]!;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
814
971
|
|
|
815
972
|
// Overlay stack for modal components rendered on top of base content
|
|
816
973
|
overlayStack: {
|
|
@@ -838,6 +995,8 @@ export class TUI extends Container {
|
|
|
838
995
|
) {
|
|
839
996
|
super();
|
|
840
997
|
this.terminal = terminal;
|
|
998
|
+
this.#legacyMultiplexerFullRender =
|
|
999
|
+
isMultiplexerSession(Bun.env) && envIsEnabled(Bun.env.PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER);
|
|
841
1000
|
if (showHardwareCursor !== undefined) {
|
|
842
1001
|
this.#showHardwareCursor = showHardwareCursor;
|
|
843
1002
|
}
|
|
@@ -849,6 +1008,7 @@ export class TUI extends Container {
|
|
|
849
1008
|
this.#lineTruncationCache.clear();
|
|
850
1009
|
this.#lineNormalizationCache.clear();
|
|
851
1010
|
this.#lineEmitWidthCache.clear();
|
|
1011
|
+
this.#tabWidthRepairPending = true;
|
|
852
1012
|
this.requestRender(true, "tab-width-change");
|
|
853
1013
|
});
|
|
854
1014
|
}
|
|
@@ -876,16 +1036,10 @@ export class TUI extends Container {
|
|
|
876
1036
|
}
|
|
877
1037
|
this.requestRender();
|
|
878
1038
|
}
|
|
879
|
-
|
|
880
1039
|
getClearOnShrink(): boolean {
|
|
881
1040
|
return this.#clearOnShrink;
|
|
882
1041
|
}
|
|
883
1042
|
|
|
884
|
-
/**
|
|
885
|
-
* Set whether to trigger full re-render when content shrinks.
|
|
886
|
-
* When true (default), empty rows are cleared when content shrinks.
|
|
887
|
-
* When false, empty rows remain (reduces redraws on slower terminals).
|
|
888
|
-
*/
|
|
889
1043
|
setClearOnShrink(enabled: boolean): void {
|
|
890
1044
|
this.#clearOnShrink = enabled;
|
|
891
1045
|
}
|
|
@@ -1025,7 +1179,7 @@ export class TUI extends Container {
|
|
|
1025
1179
|
return this.#viewportAnchorComponent;
|
|
1026
1180
|
}
|
|
1027
1181
|
|
|
1028
|
-
/** Clear manual viewport ownership before replacing the transcript identity
|
|
1182
|
+
/** Clear manual viewport ownership and durable history before replacing the transcript identity. */
|
|
1029
1183
|
resetViewportAnchorIntent(): void {
|
|
1030
1184
|
this.#manualViewportTop = undefined;
|
|
1031
1185
|
this.#manualViewportAnchor = null;
|
|
@@ -1034,6 +1188,7 @@ export class TUI extends Container {
|
|
|
1034
1188
|
this.#viewportAnchorFrame = null;
|
|
1035
1189
|
this.#scrollbackResumeViewportTop = undefined;
|
|
1036
1190
|
this.#nativeScrollbackViewportTop = 0;
|
|
1191
|
+
this.#nativeScrollbackAdmissionPending = false;
|
|
1037
1192
|
this.#transcriptIdentityResetPending = true;
|
|
1038
1193
|
this.#manualOutputNotice = false;
|
|
1039
1194
|
this.#paintedManualOutputNotice = false;
|
|
@@ -1047,6 +1202,11 @@ export class TUI extends Container {
|
|
|
1047
1202
|
this.#widthSettleTimer = undefined;
|
|
1048
1203
|
}
|
|
1049
1204
|
this.#widthSettleRepairPending = false;
|
|
1205
|
+
// Replacing the transcript identity starts a new durable history namespace.
|
|
1206
|
+
this.#durableLineCount = 0;
|
|
1207
|
+
this.#durableRenderedLines.length = 0;
|
|
1208
|
+
this.#durableRawLines.length = 0;
|
|
1209
|
+
this.#transcriptIdentityReplaced = true;
|
|
1050
1210
|
}
|
|
1051
1211
|
|
|
1052
1212
|
/** Allow one semantic-neighbor reconciliation after a definitive same-transcript rebuild. */
|
|
@@ -1240,6 +1400,7 @@ export class TUI extends Container {
|
|
|
1240
1400
|
transcriptLineCount: this.#latestRenderedTranscriptLineCount,
|
|
1241
1401
|
suffixLineCount: this.#latestRenderedSuffixLineCount,
|
|
1242
1402
|
},
|
|
1403
|
+
true,
|
|
1243
1404
|
);
|
|
1244
1405
|
if (!contentPainted) {
|
|
1245
1406
|
this.#manualViewportTop = previousManualViewportTop;
|
|
@@ -1298,16 +1459,18 @@ export class TUI extends Container {
|
|
|
1298
1459
|
this.#previousLines = liveLines;
|
|
1299
1460
|
this.#manualTranscriptLineCount = liveTranscriptLineCount;
|
|
1300
1461
|
this.#manualSuffixLineCount = liveSuffixLineCount;
|
|
1462
|
+
this.#latestRenderedLines = liveLines.slice();
|
|
1301
1463
|
if (this.#scrollbackResumeViewportTop === undefined) {
|
|
1302
1464
|
this.#nativeScrollbackViewportTop = liveViewportTop;
|
|
1303
1465
|
}
|
|
1304
|
-
|
|
1305
|
-
//
|
|
1306
|
-
//
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1466
|
+
this.#nativeScrollbackAdmissionPending = liveLines.length > this.#durableLineCount;
|
|
1467
|
+
// Repairs deferred while the user was reading scrollback run only
|
|
1468
|
+
// after the transactional live repaint has committed.
|
|
1469
|
+
if (this.#widthSettleRepairPending || this.#tabWidthRepairPending) {
|
|
1470
|
+
this.requestRender(
|
|
1471
|
+
true,
|
|
1472
|
+
this.#tabWidthRepairPending ? "tab-width-change.deferred" : "resize.width-settled.deferred",
|
|
1473
|
+
);
|
|
1311
1474
|
}
|
|
1312
1475
|
},
|
|
1313
1476
|
true,
|
|
@@ -1491,11 +1654,11 @@ export class TUI extends Container {
|
|
|
1491
1654
|
return !this.#terminalUnavailable && this.terminal.available;
|
|
1492
1655
|
}
|
|
1493
1656
|
|
|
1494
|
-
#markTerminalUnavailable(): void {
|
|
1657
|
+
#markTerminalUnavailable(settleRenderWaiters = true): void {
|
|
1495
1658
|
this.#terminalUnavailable = true;
|
|
1496
1659
|
this.#stopped = true;
|
|
1497
1660
|
this.#renderRequested = false;
|
|
1498
|
-
this.#settleRenderCommitWaiters(false);
|
|
1661
|
+
if (settleRenderWaiters) this.#settleRenderCommitWaiters(false);
|
|
1499
1662
|
if (this.#renderTimer) {
|
|
1500
1663
|
clearTimeout(this.#renderTimer);
|
|
1501
1664
|
this.#renderTimer = undefined;
|
|
@@ -1504,8 +1667,8 @@ export class TUI extends Container {
|
|
|
1504
1667
|
this.#clearSixelProbeState();
|
|
1505
1668
|
}
|
|
1506
1669
|
|
|
1507
|
-
#writeTerminal(data: string): boolean {
|
|
1508
|
-
return this.#guardTerminalOperation(() => this.terminal.write(data));
|
|
1670
|
+
#writeTerminal(data: string, deferRenderFailure = false): boolean {
|
|
1671
|
+
return this.#guardTerminalOperation(() => this.terminal.write(data), !deferRenderFailure);
|
|
1509
1672
|
}
|
|
1510
1673
|
|
|
1511
1674
|
#hideCursor(): boolean {
|
|
@@ -1516,19 +1679,19 @@ export class TUI extends Container {
|
|
|
1516
1679
|
return this.#guardTerminalOperation(() => this.terminal.showCursor());
|
|
1517
1680
|
}
|
|
1518
1681
|
|
|
1519
|
-
#guardTerminalOperation(operation: () => void): boolean {
|
|
1682
|
+
#guardTerminalOperation(operation: () => void, settleRenderWaiters = true): boolean {
|
|
1520
1683
|
if (!this.terminalAvailable) {
|
|
1521
|
-
this.#markTerminalUnavailable();
|
|
1684
|
+
this.#markTerminalUnavailable(settleRenderWaiters);
|
|
1522
1685
|
return false;
|
|
1523
1686
|
}
|
|
1524
1687
|
try {
|
|
1525
1688
|
operation();
|
|
1526
1689
|
} catch {
|
|
1527
|
-
this.#markTerminalUnavailable();
|
|
1690
|
+
this.#markTerminalUnavailable(settleRenderWaiters);
|
|
1528
1691
|
return false;
|
|
1529
1692
|
}
|
|
1530
1693
|
if (!this.terminal.available) {
|
|
1531
|
-
this.#markTerminalUnavailable();
|
|
1694
|
+
this.#markTerminalUnavailable(settleRenderWaiters);
|
|
1532
1695
|
return false;
|
|
1533
1696
|
}
|
|
1534
1697
|
return true;
|
|
@@ -1706,18 +1869,21 @@ export class TUI extends Container {
|
|
|
1706
1869
|
clearTimeout(this.#widthSettleTimer);
|
|
1707
1870
|
this.#widthSettleTimer = undefined;
|
|
1708
1871
|
}
|
|
1709
|
-
// An armed TIMER dies with the session, but
|
|
1710
|
-
//
|
|
1711
|
-
//
|
|
1712
|
-
//
|
|
1713
|
-
//
|
|
1714
|
-
// start() issues a forced full render that repairs everything anyway.
|
|
1872
|
+
// An armed TIMER dies with the session, but a repair already deferred while
|
|
1873
|
+
// the user was reading scrollback must survive a temporary stop/start
|
|
1874
|
+
// (Ctrl-Z resume, external editor): manual viewport ownership survives
|
|
1875
|
+
// restart, so followLiveViewport() still needs the pending repair. Without
|
|
1876
|
+
// manual ownership the flags are moot — start() issues a forced full render.
|
|
1715
1877
|
if (this.#manualViewportTop === undefined) {
|
|
1716
1878
|
this.#widthSettleRepairPending = false;
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1879
|
+
this.#tabWidthRepairPending = false;
|
|
1880
|
+
}
|
|
1881
|
+
// Move the cursor after the frame actually displayed to prevent
|
|
1882
|
+
// overwriting/artifacts on exit. The latest logical frame can differ while
|
|
1883
|
+
// a semantic viewport retains the previously painted frame.
|
|
1884
|
+
const displayedFrameLines = this.#previousLines.length || this.#latestRenderedLines.length;
|
|
1885
|
+
if (displayedFrameLines > 0) {
|
|
1886
|
+
const targetRow = displayedFrameLines; // Line after the last content
|
|
1721
1887
|
const lineDiff = targetRow - this.#hardwareCursorRow;
|
|
1722
1888
|
if (lineDiff > 0) {
|
|
1723
1889
|
this.#writeTerminal(`\x1b[${lineDiff}B`);
|
|
@@ -1736,44 +1902,54 @@ export class TUI extends Container {
|
|
|
1736
1902
|
} catch {
|
|
1737
1903
|
this.#markTerminalUnavailable();
|
|
1738
1904
|
}
|
|
1739
|
-
// Teardown
|
|
1740
|
-
//
|
|
1741
|
-
//
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1905
|
+
// Teardown normally releases the retained rendered transcript. A temporary
|
|
1906
|
+
// non-manual restart keeps only the durable baseline until its first render:
|
|
1907
|
+
// that render can admit a raw-prefix-proven append without replaying history.
|
|
1908
|
+
this.#restartViewportRepaintPending =
|
|
1909
|
+
this.#manualViewportTop === undefined && (this.#previousLines.length > 0 || this.#maxLinesRendered > 0);
|
|
1910
|
+
if (this.#restartViewportRepaintPending) {
|
|
1911
|
+
this.#restartDurableLineCount = this.#durableLineCount;
|
|
1912
|
+
this.#restartDurableRenderedLines = this.#durableRenderedLines.slice();
|
|
1913
|
+
this.#restartDurableRawLines = this.#durableRawLines.slice();
|
|
1914
|
+
this.#restartDurableWidth = this.#previousWidth;
|
|
1915
|
+
} else {
|
|
1916
|
+
this.#restartDurableLineCount = 0;
|
|
1917
|
+
this.#restartDurableRenderedLines = [];
|
|
1918
|
+
this.#restartDurableRawLines = [];
|
|
1919
|
+
this.#restartDurableWidth = 0;
|
|
1920
|
+
}
|
|
1746
1921
|
this.#latestRenderedLines = [];
|
|
1747
|
-
this.#previousRaw = [];
|
|
1748
1922
|
this.#kittyPlacementSpans = [];
|
|
1923
|
+
this.#latestRaw = [];
|
|
1924
|
+
this.#durableLineCount = 0;
|
|
1925
|
+
this.#nativeScrollbackAdmissionPending = false;
|
|
1926
|
+
this.#durableRenderedLines.length = 0;
|
|
1927
|
+
this.#durableRawLines.length = 0;
|
|
1928
|
+
this.#previousLines = [];
|
|
1929
|
+
this.#transcriptIdentityReplaced = false;
|
|
1749
1930
|
this.#lineNormalizationCache.clear();
|
|
1750
1931
|
this.#lineTruncationCache.clear();
|
|
1751
1932
|
this.#lineEmitWidthCache.clear();
|
|
1752
1933
|
this.#previousWidth = 0;
|
|
1753
1934
|
this.#previousHeight = 0;
|
|
1935
|
+
this.#resizeRenderQueued = false;
|
|
1936
|
+
this.#resizeRenderMutationQueued = false;
|
|
1937
|
+
this.#renderMutationQueued = false;
|
|
1938
|
+
this.#widthSettleRenderQueued = false;
|
|
1939
|
+
this.#forcedRenderQueued = false;
|
|
1754
1940
|
}
|
|
1755
1941
|
|
|
1756
1942
|
/**
|
|
1757
1943
|
* Viewport-repaint-aware resize render request.
|
|
1758
1944
|
*
|
|
1759
|
-
* A forced
|
|
1760
|
-
*
|
|
1761
|
-
*
|
|
1762
|
-
* `3J` escape (users navigate scrollback history), so replaying every transcript line
|
|
1763
|
-
* piles it back on top of scrollback — the "top of screen scrolls down to the prompt at
|
|
1764
|
-
* high speed" resize storm. Windows Terminal/ConPTY can also visibly jump to
|
|
1765
|
-
* the transcript top during streaming redraws, so viewport-repaint sessions
|
|
1766
|
-
* keep force off and let `#doRender` repaint only the live viewport. Set
|
|
1767
|
-
* `PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER=1` to restore the legacy tmux redraw.
|
|
1945
|
+
* A forced repaint resets `#previousWidth`/`#previousHeight` to -1, which makes
|
|
1946
|
+
* `#doRender` treat the frame as a dimension change. Repaints stay anchored to
|
|
1947
|
+
* the live viewport so native scrollback is never replayed or erased.
|
|
1768
1948
|
*
|
|
1769
1949
|
* Spurious resize events (SIGWINCH with unchanged dimensions — iTerm2 tab
|
|
1770
1950
|
* switches and window focus changes, the self-sent SIGWINCH after resume)
|
|
1771
|
-
* must not force either:
|
|
1772
|
-
*
|
|
1773
|
-
* scrollback (`2J`/`H`/`3J`) and replays the whole transcript, which can
|
|
1774
|
-
* park the native viewport at the transcript top. Only force when the grid
|
|
1775
|
-
* size actually changed since the last committed frame; a plain diff render
|
|
1776
|
-
* is a no-op otherwise.
|
|
1951
|
+
* must not force either: only force when the grid size actually changed since
|
|
1952
|
+
* the last committed frame.
|
|
1777
1953
|
*/
|
|
1778
1954
|
requestResizeRender(): void {
|
|
1779
1955
|
// Width is tracked against the last OBSERVED terminal width, not against
|
|
@@ -1786,7 +1962,13 @@ export class TUI extends Container {
|
|
|
1786
1962
|
this.#lastObservedWidth = observedWidth;
|
|
1787
1963
|
const heightChanged = this.#previousHeight !== this.terminal.rows;
|
|
1788
1964
|
if (widthChanged) this.#scheduleWidthSettleRedraw();
|
|
1789
|
-
this.requestRender(
|
|
1965
|
+
this.requestRender(
|
|
1966
|
+
heightChanged &&
|
|
1967
|
+
!shouldUseViewportRepaintForHost(Bun.env, process.platform, {
|
|
1968
|
+
includeProcessTerminal: this.terminal.isProcessTerminal === true,
|
|
1969
|
+
}),
|
|
1970
|
+
"resize",
|
|
1971
|
+
);
|
|
1790
1972
|
}
|
|
1791
1973
|
|
|
1792
1974
|
/**
|
|
@@ -1848,19 +2030,43 @@ export class TUI extends Container {
|
|
|
1848
2030
|
return;
|
|
1849
2031
|
}
|
|
1850
2032
|
if (renderMetrics.enabled) renderMetrics.recordRequest(source);
|
|
2033
|
+
const widthSettleRequest = source.startsWith("resize.width-settled");
|
|
2034
|
+
const mutationRequest = source !== "resize" && !widthSettleRequest;
|
|
2035
|
+
if (source === "resize") {
|
|
2036
|
+
this.#resizeRenderQueued = true;
|
|
2037
|
+
if (this.#renderRequested && this.#renderMutationQueued) {
|
|
2038
|
+
// A resize request coalesced into an already pending mutation means
|
|
2039
|
+
// the component changed before that frame settled. Preserve this
|
|
2040
|
+
// bit regardless of which request arrived first.
|
|
2041
|
+
this.#resizeRenderMutationQueued = true;
|
|
2042
|
+
}
|
|
2043
|
+
} else if (mutationRequest) {
|
|
2044
|
+
if (this.#resizeRenderQueued && this.#renderRequested) {
|
|
2045
|
+
// A mutation request coalesced into the pending resize frame means
|
|
2046
|
+
// the component changed before that frame settled. Keep this bit
|
|
2047
|
+
// separate from the terminal resize itself so a resize-only repaint
|
|
2048
|
+
// remains transient.
|
|
2049
|
+
this.#resizeRenderMutationQueued = true;
|
|
2050
|
+
}
|
|
2051
|
+
this.#renderMutationQueued = true;
|
|
2052
|
+
}
|
|
2053
|
+
if (widthSettleRequest) this.#widthSettleRenderQueued = true;
|
|
1851
2054
|
if (force) {
|
|
1852
2055
|
// A forced full redraw supersedes any queued input-priority render.
|
|
1853
2056
|
this.#inputRenderPending = false;
|
|
1854
|
-
this.#
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
this.#lineEmitWidthCache.clear();
|
|
1860
|
-
this.#previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
|
|
1861
|
-
this.#previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
|
|
2057
|
+
if (!widthSettleRequest) this.#forcedRenderQueued = true;
|
|
2058
|
+
if (!widthSettleRequest) {
|
|
2059
|
+
this.#previousWidth = -1; // -1 triggers widthChanged
|
|
2060
|
+
this.#previousHeight = -1; // -1 triggers heightChanged
|
|
2061
|
+
}
|
|
1862
2062
|
this.#lineNormalizationCacheLimit = 0;
|
|
1863
2063
|
this.#lineTruncationCacheLimit = 0;
|
|
2064
|
+
if (this.#latestRenderedLines.length === 0) {
|
|
2065
|
+
this.#cursorRow = 0;
|
|
2066
|
+
this.#hardwareCursorRow = 0;
|
|
2067
|
+
this.#viewportTopRow = 0;
|
|
2068
|
+
this.#maxLinesRendered = 0;
|
|
2069
|
+
}
|
|
1864
2070
|
if (this.#renderTimer) {
|
|
1865
2071
|
clearTimeout(this.#renderTimer);
|
|
1866
2072
|
this.#renderTimer = undefined;
|
|
@@ -2032,7 +2238,6 @@ export class TUI extends Container {
|
|
|
2032
2238
|
if (DEVICE_REPORT_PATTERN.test(data)) {
|
|
2033
2239
|
return;
|
|
2034
2240
|
}
|
|
2035
|
-
|
|
2036
2241
|
// Global debug key handler (registry: tui.global.debug, default Shift+Ctrl+D)
|
|
2037
2242
|
if (getKeybindings().matches(data, "tui.global.debug") && this.onDebug) {
|
|
2038
2243
|
this.onDebug();
|
|
@@ -2153,8 +2358,9 @@ export class TUI extends Container {
|
|
|
2153
2358
|
const selection = this.#orderedMouseSelection();
|
|
2154
2359
|
if (selection === null) return "";
|
|
2155
2360
|
const selected: string[] = [];
|
|
2361
|
+
const selectionLines = this.#manualViewportTop === undefined ? this.#latestRenderedLines : this.#previousLines;
|
|
2156
2362
|
for (let lineIndex = selection.start.line; lineIndex <= selection.end.line; lineIndex++) {
|
|
2157
|
-
const line =
|
|
2363
|
+
const line = selectionLines[lineIndex];
|
|
2158
2364
|
if (line === undefined || TERMINAL.isImageLine(line)) {
|
|
2159
2365
|
selected.push("");
|
|
2160
2366
|
continue;
|
|
@@ -2612,7 +2818,7 @@ export class TUI extends Container {
|
|
|
2612
2818
|
const key = `${width}\0${normalized}`;
|
|
2613
2819
|
const cached = this.#lineTruncationCache.get(key);
|
|
2614
2820
|
if (cached !== undefined) {
|
|
2615
|
-
this.#lineEmitWidthCache.set(cached,
|
|
2821
|
+
this.#lineEmitWidthCache.set(cached, visibleWidth(cached));
|
|
2616
2822
|
lines[lineIndex] = cached;
|
|
2617
2823
|
continue;
|
|
2618
2824
|
}
|
|
@@ -2627,7 +2833,7 @@ export class TUI extends Container {
|
|
|
2627
2833
|
const truncatedLine = truncated[i] ?? "";
|
|
2628
2834
|
const terminated = truncatedLine + (truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
|
|
2629
2835
|
this.#lineTruncationCache.set(`${width}\0${normalized}`, terminated);
|
|
2630
|
-
this.#lineEmitWidthCache.set(terminated,
|
|
2836
|
+
this.#lineEmitWidthCache.set(terminated, visibleWidth(truncatedLine));
|
|
2631
2837
|
lines[lineIndex] = terminated;
|
|
2632
2838
|
}
|
|
2633
2839
|
|
|
@@ -2640,6 +2846,12 @@ export class TUI extends Container {
|
|
|
2640
2846
|
return lines;
|
|
2641
2847
|
}
|
|
2642
2848
|
|
|
2849
|
+
#padLineToWidth(line: string, width: number): string {
|
|
2850
|
+
if (TERMINAL.isImageLine(line)) return line;
|
|
2851
|
+
const lineWidth = this.#visibleWidthForDifferentialGuard(line);
|
|
2852
|
+
return lineWidth >= width ? line : line + " ".repeat(width - lineWidth);
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2643
2855
|
#kittyPlacementKey(reference: KittyPlacementReference): string {
|
|
2644
2856
|
return `${reference.imageId}:${reference.placementId}`;
|
|
2645
2857
|
}
|
|
@@ -2891,6 +3103,7 @@ export class TUI extends Container {
|
|
|
2891
3103
|
placementsToClear: KittyPlacementSpan[] = this.#kittyPlacementSpans,
|
|
2892
3104
|
placementsToPaint: KittyPlacementSpan[] = placementsToClear,
|
|
2893
3105
|
geometry?: { transcriptLineCount: number; suffixLineCount: number },
|
|
3106
|
+
avoidScrollback = true,
|
|
2894
3107
|
): boolean {
|
|
2895
3108
|
const paintManual = this.#manualViewportTop !== undefined && !paintLive;
|
|
2896
3109
|
const transcriptLineCount = geometry?.transcriptLineCount ?? this.#manualTranscriptLineCount;
|
|
@@ -2907,7 +3120,6 @@ export class TUI extends Container {
|
|
|
2907
3120
|
let nextViewportTop = Math.max(0, Math.min(maxViewportTop, viewportTop));
|
|
2908
3121
|
if (paintManual)
|
|
2909
3122
|
nextViewportTop = this.#kittyViewportTopIncludingPlacementAnchors(nextViewportTop, placementsToPaint);
|
|
2910
|
-
const currentScreenRow = Math.max(0, Math.min(height - 1, this.#hardwareCursorRow - this.#viewportTopRow));
|
|
2911
3123
|
const transcriptCapacity = paintManual ? this.#manualTranscriptCapacity(height, suffixLineCount) : height;
|
|
2912
3124
|
const noticeRows = paintManual && this.#manualOutputNotice && height > suffixLineCount ? 1 : 0;
|
|
2913
3125
|
const deletePlan = this.#kittyPlacementDeletePlan(
|
|
@@ -2924,14 +3136,10 @@ export class TUI extends Container {
|
|
|
2924
3136
|
]
|
|
2925
3137
|
: [{ top: nextViewportTop, bottom: nextViewportTop + height }];
|
|
2926
3138
|
let buffer = `\x1b[?2026h${deletePlan.output}`;
|
|
2927
|
-
|
|
2928
|
-
buffer += `\x1b[${currentScreenRow}A`;
|
|
2929
|
-
}
|
|
2930
|
-
buffer += "\r";
|
|
3139
|
+
buffer += "\x1b[H";
|
|
2931
3140
|
const committedTranscriptRows: Array<number | null> = [];
|
|
2932
3141
|
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
2933
|
-
if (screenRow > 0) buffer += "\r\n";
|
|
2934
|
-
buffer += "\x1b[2K";
|
|
3142
|
+
if (screenRow > 0) buffer += avoidScrollback ? "\r\x1b[1B" : "\r\n";
|
|
2935
3143
|
const lineIndex = nextViewportTop + screenRow;
|
|
2936
3144
|
const suffixRow = screenRow - transcriptCapacity - noticeRows;
|
|
2937
3145
|
const line =
|
|
@@ -2946,14 +3154,17 @@ export class TUI extends Container {
|
|
|
2946
3154
|
screenRow < transcriptCapacity && lineIndex < transcriptLineCount ? lineIndex : null,
|
|
2947
3155
|
);
|
|
2948
3156
|
const isImage = TERMINAL.isImageLine(line);
|
|
3157
|
+
if (avoidScrollback && isImage) buffer += "\x1b7\x1b[2K";
|
|
2949
3158
|
if (!isImage && this.#visibleWidthForDifferentialGuard(line) > width) {
|
|
2950
3159
|
let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
|
|
2951
3160
|
truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
|
|
2952
|
-
buffer += truncatedLine;
|
|
3161
|
+
buffer += this.#padLineToWidth(truncatedLine, width);
|
|
2953
3162
|
} else {
|
|
2954
|
-
buffer += line;
|
|
3163
|
+
buffer += this.#padLineToWidth(line, width);
|
|
2955
3164
|
}
|
|
3165
|
+
if (avoidScrollback && isImage) buffer += "\x1b8";
|
|
2956
3166
|
}
|
|
3167
|
+
if (avoidScrollback) buffer += "\r";
|
|
2957
3168
|
|
|
2958
3169
|
const finalPhysicalRow = nextViewportTop + Math.max(0, height - 1);
|
|
2959
3170
|
let cursorSeq = "\x1b[?25l";
|
|
@@ -3052,6 +3263,18 @@ export class TUI extends Container {
|
|
|
3052
3263
|
|
|
3053
3264
|
#doRender(): void {
|
|
3054
3265
|
if (this.#stopped || !this.terminalAvailable) return;
|
|
3266
|
+
const transcriptIdentityReplaced = this.#transcriptIdentityReplaced;
|
|
3267
|
+
const restartViewportRepaintPending = this.#restartViewportRepaintPending;
|
|
3268
|
+
const resizeRenderMutationQueued = this.#resizeRenderMutationQueued;
|
|
3269
|
+
const widthSettleRenderQueued = this.#widthSettleRenderQueued;
|
|
3270
|
+
const tabWidthRepairPending = this.#tabWidthRepairPending;
|
|
3271
|
+
const forcedRenderQueued = this.#forcedRenderQueued;
|
|
3272
|
+
this.#resizeRenderQueued = false;
|
|
3273
|
+
this.#resizeRenderMutationQueued = false;
|
|
3274
|
+
this.#renderMutationQueued = false;
|
|
3275
|
+
this.#widthSettleRenderQueued = false;
|
|
3276
|
+
this.#tabWidthRepairPending = tabWidthRepairPending && this.#manualViewportTop !== undefined;
|
|
3277
|
+
this.#forcedRenderQueued = false;
|
|
3055
3278
|
const width = this.terminal.columns;
|
|
3056
3279
|
const height = this.terminal.rows;
|
|
3057
3280
|
let viewportTop = Math.max(0, this.#maxLinesRendered - height);
|
|
@@ -3063,7 +3286,6 @@ export class TUI extends Container {
|
|
|
3063
3286
|
return targetScreenRow - currentScreenRow;
|
|
3064
3287
|
};
|
|
3065
3288
|
|
|
3066
|
-
// Render direct children once so the registered transcript component retains row ownership.
|
|
3067
3289
|
const renderTreeStart = renderMetrics.now();
|
|
3068
3290
|
const renderedLines: string[] = [];
|
|
3069
3291
|
const renderedChildren = new Map<Component, string[]>();
|
|
@@ -3077,16 +3299,19 @@ export class TUI extends Container {
|
|
|
3077
3299
|
for (let childIndex = 0; childIndex < this.children.length; childIndex++) {
|
|
3078
3300
|
const child = this.children[childIndex];
|
|
3079
3301
|
const rendered = safeRenderComponentWithViewportAnchors(child, width, "tui-child");
|
|
3080
|
-
|
|
3302
|
+
const safeLines = rendered.lines.map(stripTerminalEraseControls);
|
|
3303
|
+
renderedChildren.set(child, safeLines);
|
|
3304
|
+
const childStart = renderedLines.length;
|
|
3081
3305
|
if (child === this.#viewportAnchorComponent && rendered.anchors.some(anchor => anchor !== null)) {
|
|
3082
|
-
anchorFrame = { startRow:
|
|
3306
|
+
anchorFrame = { startRow: childStart, anchors: rendered.anchors };
|
|
3083
3307
|
}
|
|
3084
3308
|
const owner: KittyPlacementOwner = hasStickySuffix && childIndex >= pinnedChildIndex ? "suffix" : "transcript";
|
|
3085
|
-
for (
|
|
3309
|
+
for (let lineIndex = 0; lineIndex < rendered.lines.length; lineIndex++) {
|
|
3310
|
+
const line = rendered.lines[lineIndex]!;
|
|
3086
3311
|
for (const placement of extractKittyPlacementReferences(line)) {
|
|
3087
3312
|
placementOwners.set(this.#kittyPlacementKey(placement), owner);
|
|
3088
3313
|
}
|
|
3089
|
-
renderedLines.push(line);
|
|
3314
|
+
renderedLines.push(safeLines[lineIndex] ?? line);
|
|
3090
3315
|
}
|
|
3091
3316
|
}
|
|
3092
3317
|
const sourceTranscriptLineCount = hasStickySuffix
|
|
@@ -3120,13 +3345,22 @@ export class TUI extends Container {
|
|
|
3120
3345
|
|
|
3121
3346
|
newLines = this.#applyMouseSelection(newLines);
|
|
3122
3347
|
|
|
3123
|
-
// Terminate every non-image line so
|
|
3348
|
+
// Terminate every non-image line so the latest frame mirrors emitted bytes
|
|
3124
3349
|
// (closes SGR + OSC 8 hyperlink state). Must run after cursor extraction
|
|
3125
3350
|
// because the marker is embedded mid-line, and before any diff/full render
|
|
3126
3351
|
// path so cache comparisons stay byte-accurate.
|
|
3127
|
-
// Width/height change detection (used for
|
|
3352
|
+
// Width/height change detection (used for normalization reuse and repaint decisions).
|
|
3128
3353
|
const widthChanged = this.#previousWidth !== 0 && this.#previousWidth !== width;
|
|
3354
|
+
const widthMetadataChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
|
|
3355
|
+
if (widthMetadataChanged) {
|
|
3356
|
+
// Emitted widths are viewport-dependent for truncated rows. The no-repair
|
|
3357
|
+
// resize path reuses the latest frame during repaint, so discard carried
|
|
3358
|
+
// width metadata before any differential guard reads it.
|
|
3359
|
+
this.#lineEmitWidthCache.clear();
|
|
3360
|
+
}
|
|
3129
3361
|
const heightChanged = this.#previousHeight !== 0 && this.#previousHeight !== height;
|
|
3362
|
+
const initialRender = this.#previousLines.length === 0 && this.#maxLinesRendered === 0;
|
|
3363
|
+
let coalescedWidthAppend = false;
|
|
3130
3364
|
|
|
3131
3365
|
// Normalize/truncate lines for emission. The virtual viewport is default-on;
|
|
3132
3366
|
// PI_TUI_VIRTUAL_VIEWPORT=0 opts out. When enabled, reuse the previous frame's
|
|
@@ -3135,27 +3369,27 @@ export class TUI extends Container {
|
|
|
3135
3369
|
// re-normalized and the diff starts at the window. Output is byte-identical to the
|
|
3136
3370
|
// full path (reused entries are deterministic normalizations of identical raw lines).
|
|
3137
3371
|
const VIEWPORT_NORMALIZE_OVERSCAN = 8;
|
|
3138
|
-
const rawLines = newLines;
|
|
3372
|
+
const rawLines = newLines.slice();
|
|
3139
3373
|
const total = rawLines.length;
|
|
3140
3374
|
let diffStart = 0;
|
|
3141
3375
|
let usedWindowNormalize = false;
|
|
3142
3376
|
if (
|
|
3143
3377
|
this.#virtualViewport &&
|
|
3144
3378
|
!widthChanged &&
|
|
3145
|
-
this.#
|
|
3146
|
-
this.#
|
|
3379
|
+
this.#latestRaw.length > 0 &&
|
|
3380
|
+
this.#latestRenderedLines.length === this.#latestRaw.length
|
|
3147
3381
|
) {
|
|
3148
3382
|
const winTop = Math.max(0, total - height - VIEWPORT_NORMALIZE_OVERSCAN);
|
|
3149
|
-
if (winTop <= this.#
|
|
3383
|
+
if (winTop <= this.#latestRenderedLines.length && winTop <= this.#latestRaw.length) {
|
|
3150
3384
|
let stable = true;
|
|
3151
3385
|
for (let i = 0; i < winTop; i++) {
|
|
3152
|
-
if (rawLines[i] !== this.#
|
|
3386
|
+
if (rawLines[i] !== this.#latestRaw[i]) {
|
|
3153
3387
|
stable = false;
|
|
3154
3388
|
break;
|
|
3155
3389
|
}
|
|
3156
3390
|
}
|
|
3157
3391
|
if (stable) {
|
|
3158
|
-
const windowed = this.#
|
|
3392
|
+
const windowed = this.#latestRenderedLines.slice(0, winTop);
|
|
3159
3393
|
for (let i = winTop; i < total; i++) {
|
|
3160
3394
|
windowed.push(rawLines[i]);
|
|
3161
3395
|
}
|
|
@@ -3168,10 +3402,7 @@ export class TUI extends Container {
|
|
|
3168
3402
|
}
|
|
3169
3403
|
}
|
|
3170
3404
|
if (!usedWindowNormalize) {
|
|
3171
|
-
newLines = this.#applyLineResetsAndTruncate(
|
|
3172
|
-
}
|
|
3173
|
-
if (this.#virtualViewport) {
|
|
3174
|
-
this.#previousRaw = rawLines;
|
|
3405
|
+
newLines = this.#applyLineResetsAndTruncate(rawLines.slice(), width);
|
|
3175
3406
|
}
|
|
3176
3407
|
if (renderMetrics.enabled) {
|
|
3177
3408
|
renderMetrics.recordLineCount("rendered", total);
|
|
@@ -3180,6 +3411,9 @@ export class TUI extends Container {
|
|
|
3180
3411
|
if (usedWindowNormalize) renderMetrics.recordLineCount("offscreenScan", diffStart);
|
|
3181
3412
|
}
|
|
3182
3413
|
const nextKittyPlacementSpans = this.#kittyPlacementSpansForLines(newLines, placementOwners);
|
|
3414
|
+
const previousLogicalFrame = this.#latestRenderedLines.slice();
|
|
3415
|
+
const previousRawFrame = this.#latestRaw.slice();
|
|
3416
|
+
const previousRenderedLength = previousLogicalFrame.length;
|
|
3183
3417
|
this.#latestRenderedLines = newLines;
|
|
3184
3418
|
this.#latestRenderedTranscriptLineCount = nextTranscriptLineCount;
|
|
3185
3419
|
this.#latestRenderedSuffixLineCount = nextSuffixLineCount;
|
|
@@ -3248,14 +3482,25 @@ export class TUI extends Container {
|
|
|
3248
3482
|
previousKittyPlacementSpans,
|
|
3249
3483
|
nextKittyPlacementSpans,
|
|
3250
3484
|
{ transcriptLineCount: nextTranscriptLineCount, suffixLineCount: nextSuffixLineCount },
|
|
3485
|
+
true,
|
|
3251
3486
|
);
|
|
3252
|
-
if (
|
|
3487
|
+
if (contentPainted) {
|
|
3488
|
+
this.#latestRenderedLines = newLines;
|
|
3489
|
+
if (this.#virtualViewport) this.#latestRaw = rawLines;
|
|
3490
|
+
} else restoreManualIntent();
|
|
3253
3491
|
return;
|
|
3254
3492
|
}
|
|
3255
3493
|
// A formerly valid semantic target is temporarily absent (provider removal,
|
|
3256
3494
|
// replacement, eviction, or object deletion). Keep the last resolved frame
|
|
3257
3495
|
// instead of silently reinterpreting manual intent as a numeric viewport.
|
|
3258
|
-
|
|
3496
|
+
// Keep the committed physical baseline first: #latestRenderedLines may already
|
|
3497
|
+
// reflect source changes that are intentionally hidden until the anchor recovers.
|
|
3498
|
+
const retainedLines =
|
|
3499
|
+
this.#previousLines.length > 0
|
|
3500
|
+
? this.#previousLines
|
|
3501
|
+
: previousLogicalFrame.length > 0
|
|
3502
|
+
? previousLogicalFrame
|
|
3503
|
+
: newLines;
|
|
3259
3504
|
contentPainted = false;
|
|
3260
3505
|
this.#repaintViewportFromLines(
|
|
3261
3506
|
retainedLines,
|
|
@@ -3283,6 +3528,8 @@ export class TUI extends Container {
|
|
|
3283
3528
|
this.#previousHeight === height &&
|
|
3284
3529
|
nextViewportTop === this.#manualViewportTop &&
|
|
3285
3530
|
this.#manualOutputNotice === this.#paintedManualOutputNotice &&
|
|
3531
|
+
this.#latestRenderedLines.length === newLines.length &&
|
|
3532
|
+
this.#latestRenderedLines.every((line, index) => line === newLines[index]) &&
|
|
3286
3533
|
newLines.length === this.#previousLines.length &&
|
|
3287
3534
|
newLines.every((line, index) => line === this.#previousLines[index])
|
|
3288
3535
|
) {
|
|
@@ -3317,12 +3564,20 @@ export class TUI extends Container {
|
|
|
3317
3564
|
return;
|
|
3318
3565
|
}
|
|
3319
3566
|
// Helper to clear scrollback and viewport and render all new lines
|
|
3320
|
-
|
|
3567
|
+
const shouldPreserveScrollbackOnFullClear =
|
|
3568
|
+
shouldUseViewportRepaintForHost(Bun.env, process.platform, {
|
|
3569
|
+
includeProcessTerminal: this.terminal.isProcessTerminal === true,
|
|
3570
|
+
}) || this.#legacyMultiplexerFullRender;
|
|
3571
|
+
let viewportRepaint: (
|
|
3572
|
+
reason: string,
|
|
3573
|
+
targetViewportTopOrAllowPastLiveBottom?: number | boolean,
|
|
3574
|
+
allowPastLiveBottom?: boolean,
|
|
3575
|
+
) => boolean;
|
|
3321
3576
|
const fullRender = (clear: boolean, reason = "full render", forceScrollbackClear = false): void => {
|
|
3322
3577
|
if (
|
|
3323
3578
|
clear &&
|
|
3324
3579
|
!forceScrollbackClear &&
|
|
3325
|
-
shouldPreserveScrollbackOnFullClear
|
|
3580
|
+
shouldPreserveScrollbackOnFullClear &&
|
|
3326
3581
|
this.#scrollbackResumeViewportTop !== undefined
|
|
3327
3582
|
) {
|
|
3328
3583
|
viewportRepaint(`preserving full replay blocked after scrollback-unsafe contraction: ${reason}`);
|
|
@@ -3344,9 +3599,7 @@ export class TUI extends Container {
|
|
|
3344
3599
|
// of the stale-width copy instead of replacing it).
|
|
3345
3600
|
if (clear)
|
|
3346
3601
|
buffer +=
|
|
3347
|
-
!forceScrollbackClear && shouldPreserveScrollbackOnFullClear
|
|
3348
|
-
? "\x1b[2J\x1b[H"
|
|
3349
|
-
: "\x1b[2J\x1b[H\x1b[3J";
|
|
3602
|
+
!forceScrollbackClear && shouldPreserveScrollbackOnFullClear ? "\x1b[2J\x1b[H" : "\x1b[2J\x1b[H\x1b[3J";
|
|
3350
3603
|
for (let i = 0; i < newLines.length; i++) {
|
|
3351
3604
|
if (i > 0) buffer += "\r\n";
|
|
3352
3605
|
// Lines were pre-terminated/normalized by #applyLineResets; image
|
|
@@ -3366,7 +3619,7 @@ export class TUI extends Container {
|
|
|
3366
3619
|
this.#nativeScrollbackViewportTop = clear
|
|
3367
3620
|
? this.#viewportTopRow
|
|
3368
3621
|
: Math.max(this.#nativeScrollbackViewportTop, this.#viewportTopRow);
|
|
3369
|
-
if (clear && (forceScrollbackClear || !shouldPreserveScrollbackOnFullClear
|
|
3622
|
+
if (clear && (forceScrollbackClear || !shouldPreserveScrollbackOnFullClear)) {
|
|
3370
3623
|
this.#scrollbackResumeViewportTop = undefined;
|
|
3371
3624
|
}
|
|
3372
3625
|
this.#previousLines = newLines;
|
|
@@ -3381,106 +3634,235 @@ export class TUI extends Container {
|
|
|
3381
3634
|
this.#manualTranscriptLineCount = nextTranscriptLineCount;
|
|
3382
3635
|
this.#manualSuffixLineCount = nextSuffixLineCount;
|
|
3383
3636
|
this.#refreshPaintedLiveViewportObservation(height);
|
|
3637
|
+
this.#durableLineCount = newLines.length;
|
|
3638
|
+
this.#durableRenderedLines = newLines.slice();
|
|
3639
|
+
this.#durableRawLines = rawLines.slice();
|
|
3640
|
+
this.#transcriptIdentityReplaced = false;
|
|
3384
3641
|
})
|
|
3385
3642
|
)
|
|
3386
3643
|
return;
|
|
3644
|
+
if (this.#virtualViewport) this.#latestRaw = rawLines;
|
|
3387
3645
|
};
|
|
3388
3646
|
|
|
3389
|
-
viewportRepaint = (
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
const
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3647
|
+
viewportRepaint = (
|
|
3648
|
+
reason: string,
|
|
3649
|
+
targetViewportTopOrAllowPastLiveBottom: number | boolean = Math.max(0, newLines.length - height),
|
|
3650
|
+
allowPastLiveBottom = false,
|
|
3651
|
+
): boolean => {
|
|
3652
|
+
const targetViewportTop =
|
|
3653
|
+
typeof targetViewportTopOrAllowPastLiveBottom === "number"
|
|
3654
|
+
? targetViewportTopOrAllowPastLiveBottom
|
|
3655
|
+
: Math.max(0, newLines.length - height);
|
|
3656
|
+
const paintPastLiveBottom =
|
|
3657
|
+
typeof targetViewportTopOrAllowPastLiveBottom === "boolean"
|
|
3658
|
+
? targetViewportTopOrAllowPastLiveBottom
|
|
3659
|
+
: allowPastLiveBottom;
|
|
3660
|
+
return this.#repaintViewportFromLines(
|
|
3661
|
+
newLines,
|
|
3662
|
+
width,
|
|
3663
|
+
height,
|
|
3664
|
+
targetViewportTop,
|
|
3665
|
+
cursorPos,
|
|
3666
|
+
reason,
|
|
3667
|
+
paintPastLiveBottom,
|
|
3668
|
+
() => {
|
|
3669
|
+
this.#previousLines = newLines;
|
|
3670
|
+
this.#previousWidth = width;
|
|
3671
|
+
this.#previousHeight = height;
|
|
3672
|
+
this.#manualTranscriptLineCount = nextTranscriptLineCount;
|
|
3673
|
+
this.#manualSuffixLineCount = nextSuffixLineCount;
|
|
3674
|
+
this.#refreshPaintedLiveViewportObservation(height);
|
|
3675
|
+
this.#latestRenderedLines = newLines.slice();
|
|
3676
|
+
if (this.#virtualViewport) this.#latestRaw = rawLines.slice();
|
|
3677
|
+
},
|
|
3678
|
+
false,
|
|
3679
|
+
previousKittyPlacementSpans,
|
|
3680
|
+
nextKittyPlacementSpans,
|
|
3681
|
+
{ transcriptLineCount: nextTranscriptLineCount, suffixLineCount: nextSuffixLineCount },
|
|
3682
|
+
true,
|
|
3683
|
+
);
|
|
3684
|
+
};
|
|
3685
|
+
if (transcriptIdentityReplaced && !initialRender) {
|
|
3686
|
+
fullRender(true, "transcript identity replaced", true);
|
|
3687
|
+
return;
|
|
3688
|
+
}
|
|
3689
|
+
if (tabWidthRepairPending && !initialRender) {
|
|
3690
|
+
fullRender(true, "tab width changed", true);
|
|
3691
|
+
return;
|
|
3692
|
+
}
|
|
3693
|
+
// A width change may only use the durable append path when the current raw
|
|
3694
|
+
// frame proves that the previous raw frame is an unchanged prefix. Otherwise
|
|
3695
|
+
// component reflow (including row-count growth) is indistinguishable from an
|
|
3696
|
+
// append, so repaint the live viewport without replaying durable history.
|
|
3697
|
+
// Resize-only frames must never enter this path: a row-count increase caused
|
|
3698
|
+
// solely by reflow is not durable content and must remain a viewport repaint.
|
|
3699
|
+
let retainedLength = -1;
|
|
3700
|
+
if (widthChanged && !initialRender) {
|
|
3701
|
+
// Raw-prefix equality only proves a durable row prefix when every retained
|
|
3702
|
+
// raw row fits at both widths. Otherwise find the conservative physical
|
|
3703
|
+
// reflow boundary before appending the mutation suffix.
|
|
3704
|
+
let rawPrefixProven = false;
|
|
3705
|
+
if (this.#virtualViewport && rawLines.length > previousRawFrame.length) {
|
|
3706
|
+
const previousWidth = this.#previousWidth;
|
|
3707
|
+
if (previousWidth > 0) {
|
|
3708
|
+
rawPrefixProven = true;
|
|
3709
|
+
const durableWidth = Math.min(previousWidth, width);
|
|
3710
|
+
for (let i = 0; i < previousRawFrame.length; i++) {
|
|
3711
|
+
if (rawLines[i] !== previousRawFrame[i] || visibleWidth(rawLines[i]) > durableWidth) {
|
|
3712
|
+
rawPrefixProven = false;
|
|
3713
|
+
break;
|
|
3714
|
+
}
|
|
3715
|
+
}
|
|
3716
|
+
if (rawPrefixProven) retainedLength = previousRawFrame.length;
|
|
3415
3717
|
}
|
|
3416
3718
|
}
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
cursorToRow = cursor.toRow;
|
|
3425
|
-
}
|
|
3426
|
-
buffer += cursorSeq;
|
|
3427
|
-
buffer += "\x1b[?2026l";
|
|
3428
|
-
let contentWritten = false;
|
|
3429
|
-
this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length, () => {
|
|
3430
|
-
contentWritten = true;
|
|
3431
|
-
this.#hardwareCursorRow = cursorToRow;
|
|
3432
|
-
this.#cursorRow = Math.max(0, newLines.length - 1);
|
|
3433
|
-
this.#maxLinesRendered = newLines.length;
|
|
3434
|
-
this.#viewportTopRow = nextViewportTop;
|
|
3435
|
-
this.#previousLines = newLines;
|
|
3436
|
-
this.#previousWidth = width;
|
|
3437
|
-
this.#previousHeight = height;
|
|
3438
|
-
this.#kittyPlacementSpans = this.#kittyCommittedPlacementsAfterPaint(
|
|
3439
|
-
previousKittyPlacementSpans,
|
|
3440
|
-
nextKittyPlacementSpans,
|
|
3441
|
-
deletePlan,
|
|
3442
|
-
[{ top: nextViewportTop, bottom: nextViewportTop + height }],
|
|
3443
|
-
);
|
|
3444
|
-
this.#manualTranscriptLineCount = nextTranscriptLineCount;
|
|
3445
|
-
this.#manualSuffixLineCount = nextSuffixLineCount;
|
|
3446
|
-
this.#refreshPaintedLiveViewportObservation(height);
|
|
3447
|
-
});
|
|
3448
|
-
if (!contentWritten) return false;
|
|
3449
|
-
|
|
3450
|
-
if (this.#debugRedraw) {
|
|
3451
|
-
const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
|
|
3452
|
-
this.#appendDebugRedrawLog(msg);
|
|
3719
|
+
if (rawPrefixProven) {
|
|
3720
|
+
// Raw rows can expand into a different number of physical rows at
|
|
3721
|
+
// the new width (components may also expose width-sensitive rows).
|
|
3722
|
+
// Derive the retained boundary from rendered frames so reflow
|
|
3723
|
+
// continuations are not committed as durable output.
|
|
3724
|
+
retainedLength = findSafeReflowSuffixStart(previousLogicalFrame, newLines);
|
|
3725
|
+
if (retainedLength < 0) rawPrefixProven = false;
|
|
3453
3726
|
}
|
|
3454
|
-
|
|
3455
|
-
|
|
3727
|
+
if (!rawPrefixProven) {
|
|
3728
|
+
// A raw row that exceeded the old/new width may have been truncated in
|
|
3729
|
+
// the previous frame. Match the rendered frame so a resize cannot
|
|
3730
|
+
// mistake that truncation for a stable durable boundary.
|
|
3731
|
+
const previousFrameLines = previousLogicalFrame;
|
|
3732
|
+
const hasPresentationMetadata =
|
|
3733
|
+
previousRawFrame.some(line => !TERMINAL.isImageLine(line) && Bun.stripANSI(line) !== line) ||
|
|
3734
|
+
rawLines.some(line => !TERMINAL.isImageLine(line) && Bun.stripANSI(line) !== line);
|
|
3735
|
+
const stableLogicalBoundary = hasPresentationMetadata
|
|
3736
|
+
? -1
|
|
3737
|
+
: findStableLogicalAppendBoundary(previousFrameLines, rawLines);
|
|
3738
|
+
retainedLength =
|
|
3739
|
+
stableLogicalBoundary >= 0
|
|
3740
|
+
? stableLogicalBoundary
|
|
3741
|
+
: hasPresentationMetadata
|
|
3742
|
+
? -1
|
|
3743
|
+
: findSafeReflowSuffixStart(previousFrameLines, rawLines);
|
|
3744
|
+
}
|
|
3745
|
+
}
|
|
3746
|
+
const distinctPostContractionRows = hasDistinctPostContractionRows(
|
|
3747
|
+
previousLogicalFrame,
|
|
3748
|
+
newLines,
|
|
3749
|
+
this.#durableRenderedLines,
|
|
3750
|
+
rawLines,
|
|
3751
|
+
this.#durableRawLines,
|
|
3752
|
+
);
|
|
3753
|
+
const durableAppend = newLines.length > this.#durableLineCount || distinctPostContractionRows;
|
|
3754
|
+
// A stale durable frontier can sit behind a transient reflow frame. Coalesced
|
|
3755
|
+
// resize/mutation output is an append only when the desired frame also grew
|
|
3756
|
+
// beyond that frame; otherwise CRLF would commit reflow rows a second time.
|
|
3757
|
+
const logicalAppend = newLines.length > previousRenderedLength;
|
|
3758
|
+
if (widthSettleRenderQueued && this.#widthSettleRepairPending && !initialRender) {
|
|
3759
|
+
// The debounced repair is the only permitted full clear/replay after a
|
|
3760
|
+
// resize storm. It must run before resize-only admission so old-width
|
|
3761
|
+
// wraps are replaced in native scrollback as well as the live viewport.
|
|
3762
|
+
this.#widthSettleRepairPending = false;
|
|
3763
|
+
fullRender(true, "width settled", true);
|
|
3764
|
+
return;
|
|
3765
|
+
}
|
|
3766
|
+
const useViewportRepaintPath = shouldUseViewportRepaintForHost(Bun.env, process.platform, {
|
|
3767
|
+
includeProcessTerminal: this.terminal.isProcessTerminal === true,
|
|
3768
|
+
});
|
|
3769
|
+
const widthReflowRequired =
|
|
3770
|
+
this.#previousWidth > 0 &&
|
|
3771
|
+
rawLines.some(
|
|
3772
|
+
line => !TERMINAL.isImageLine(line) && visibleWidth(line) > Math.min(this.#previousWidth, width),
|
|
3773
|
+
);
|
|
3774
|
+
if (
|
|
3775
|
+
widthChanged &&
|
|
3776
|
+
!this.#legacyMultiplexerFullRender &&
|
|
3777
|
+
!initialRender &&
|
|
3778
|
+
(!resizeRenderMutationQueued ||
|
|
3779
|
+
!durableAppend ||
|
|
3780
|
+
!logicalAppend ||
|
|
3781
|
+
retainedLength < 0 ||
|
|
3782
|
+
retainedLength >= newLines.length) &&
|
|
3783
|
+
useViewportRepaintPath
|
|
3784
|
+
) {
|
|
3785
|
+
// Resize-only frames, and frames without a proven append suffix, repaint
|
|
3786
|
+
// the live viewport without replaying durable history. Only on viewport-
|
|
3787
|
+
// repaint hosts (multiplexers, Windows Terminal, process terminals); plain
|
|
3788
|
+
// terminals fall through to fullRender so the whole frame is replayed.
|
|
3789
|
+
if (forcedRenderQueued) this.#fullRedrawCount += 1;
|
|
3790
|
+
viewportRepaint(`terminal width changed (${this.#previousWidth} -> ${width})`, true, true);
|
|
3791
|
+
return;
|
|
3792
|
+
}
|
|
3793
|
+
if (widthChanged && !initialRender && resizeRenderMutationQueued) {
|
|
3794
|
+
this.#latestRenderedLines = newLines.slice(0, retainedLength);
|
|
3795
|
+
if (this.#virtualViewport) this.#latestRaw = rawLines.slice(0, retainedLength);
|
|
3796
|
+
coalescedWidthAppend = true;
|
|
3797
|
+
}
|
|
3456
3798
|
|
|
3457
3799
|
const debugRedraw = this.#debugRedraw;
|
|
3458
3800
|
const logRedraw = (reason: string): void => {
|
|
3459
3801
|
if (!debugRedraw) return;
|
|
3460
|
-
const msg = `[${new Date().toISOString()}] fullRender: ${reason} (
|
|
3802
|
+
const msg = `[${new Date().toISOString()}] fullRender: ${reason} (new=${newLines.length}, height=${height})\n`;
|
|
3461
3803
|
this.#appendDebugRedrawLog(msg);
|
|
3462
3804
|
};
|
|
3463
3805
|
|
|
3806
|
+
if (restartViewportRepaintPending && initialRender) {
|
|
3807
|
+
const restartAppendProven =
|
|
3808
|
+
width === this.#restartDurableWidth &&
|
|
3809
|
+
rawLines.length >= this.#restartDurableLineCount &&
|
|
3810
|
+
rawLines
|
|
3811
|
+
.slice(0, this.#restartDurableLineCount)
|
|
3812
|
+
.every((line, index) => line === this.#restartDurableRawLines[index]);
|
|
3813
|
+
if (restartAppendProven && rawLines.length > this.#restartDurableLineCount) {
|
|
3814
|
+
const appendBuffer = `\x1b[?2026h${newLines.slice(this.#restartDurableLineCount).join("\r\n")}\x1b[?2026l`;
|
|
3815
|
+
if (!this.#writeTerminal(appendBuffer)) return;
|
|
3816
|
+
// The append already reached native scrollback. Advance both the live
|
|
3817
|
+
// frontier and the retained restart baseline before the viewport write:
|
|
3818
|
+
// a subsequent terminal failure must not re-admit this suffix on restart.
|
|
3819
|
+
this.#durableLineCount = newLines.length;
|
|
3820
|
+
this.#durableRenderedLines = newLines.slice();
|
|
3821
|
+
this.#durableRawLines = rawLines.slice();
|
|
3822
|
+
this.#restartDurableLineCount = newLines.length;
|
|
3823
|
+
this.#restartDurableRenderedLines = newLines.slice();
|
|
3824
|
+
this.#restartDurableRawLines = rawLines.slice();
|
|
3825
|
+
this.#restartDurableWidth = width;
|
|
3826
|
+
}
|
|
3827
|
+
if (viewportRepaint("restart after temporary stop")) {
|
|
3828
|
+
if (restartAppendProven) {
|
|
3829
|
+
this.#durableLineCount = newLines.length;
|
|
3830
|
+
this.#durableRenderedLines = newLines.slice();
|
|
3831
|
+
this.#durableRawLines = rawLines.slice();
|
|
3832
|
+
} else {
|
|
3833
|
+
this.#durableLineCount = this.#restartDurableLineCount;
|
|
3834
|
+
this.#durableRenderedLines = this.#restartDurableRenderedLines.slice();
|
|
3835
|
+
this.#durableRawLines = this.#restartDurableRawLines.slice();
|
|
3836
|
+
}
|
|
3837
|
+
this.#restartDurableLineCount = 0;
|
|
3838
|
+
this.#restartDurableRenderedLines = [];
|
|
3839
|
+
this.#restartDurableRawLines = [];
|
|
3840
|
+
this.#restartDurableWidth = 0;
|
|
3841
|
+
this.#restartViewportRepaintPending = false;
|
|
3842
|
+
}
|
|
3843
|
+
return;
|
|
3844
|
+
}
|
|
3464
3845
|
// First render - just output everything without clearing (assumes clean screen)
|
|
3465
|
-
if (
|
|
3846
|
+
if (initialRender) {
|
|
3466
3847
|
logRedraw("first render");
|
|
3467
3848
|
fullRender(false, "first render");
|
|
3468
3849
|
return;
|
|
3469
3850
|
}
|
|
3470
3851
|
|
|
3471
|
-
// Width changes always need a full re-render because wrapping changes
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
// The one debounced post-resize repair: a full clear+replay so stale
|
|
3476
|
-
// old-width wrapping is repaired in scrollback history too, not just
|
|
3477
|
-
// the live viewport. forceScrollbackClear erases the stale-width
|
|
3478
|
-
// history instead of stacking the replay on top of it. Safe against
|
|
3479
|
-
// the replay storm because it runs once per settled width sequence,
|
|
3480
|
-
// never once per SIGWINCH.
|
|
3852
|
+
// Width changes always need a full re-render because wrapping changes, unless
|
|
3853
|
+
// a proven coalesced append is continuing through the durable append path.
|
|
3854
|
+
if (widthChanged && !coalescedWidthAppend) {
|
|
3855
|
+
if (!widthReflowRequired) {
|
|
3481
3856
|
this.#widthSettleRepairPending = false;
|
|
3482
|
-
|
|
3483
|
-
|
|
3857
|
+
logRedraw(`terminal width changed without reflow (${this.#previousWidth} -> ${width})`);
|
|
3858
|
+
if (useViewportRepaintPath) {
|
|
3859
|
+
viewportRepaint(`terminal width changed without reflow (${this.#previousWidth} -> ${width})`);
|
|
3860
|
+
} else {
|
|
3861
|
+
fullRender(true, "terminal width changed without reflow");
|
|
3862
|
+
}
|
|
3863
|
+
return;
|
|
3864
|
+
}
|
|
3865
|
+
if (useViewportRepaintPath) {
|
|
3484
3866
|
logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
|
|
3485
3867
|
// In viewport-repaint sessions a per-event full replay can either pile
|
|
3486
3868
|
// the transcript back onto scrollback (tmux/screen) or visibly jump to
|
|
@@ -3499,7 +3881,7 @@ export class TUI extends Container {
|
|
|
3499
3881
|
// but Termux changes height when the software keyboard shows or hides.
|
|
3500
3882
|
// In that environment, a full redraw causes the entire history to replay on every toggle.
|
|
3501
3883
|
if (heightChanged) {
|
|
3502
|
-
if (useViewportRepaintPath
|
|
3884
|
+
if (useViewportRepaintPath) {
|
|
3503
3885
|
viewportRepaint(`terminal height changed (${this.#previousHeight} -> ${height})`);
|
|
3504
3886
|
return;
|
|
3505
3887
|
}
|
|
@@ -3513,11 +3895,7 @@ export class TUI extends Container {
|
|
|
3513
3895
|
// Configurable via setClearOnShrink() or GJC_CLEAR_ON_SHRINK=0 env var
|
|
3514
3896
|
if (this.#clearOnShrink && newLines.length < this.#previousLines.length && this.overlayStack.length === 0) {
|
|
3515
3897
|
logRedraw(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
|
|
3516
|
-
if (
|
|
3517
|
-
useViewportRepaintPath(this.terminal) ||
|
|
3518
|
-
((this.#previousLines.length > height || newLines.length > height) &&
|
|
3519
|
-
allowsHostNeutralOverflowRepaint(this.terminal))
|
|
3520
|
-
) {
|
|
3898
|
+
if (useViewportRepaintPath) {
|
|
3521
3899
|
viewportRepaint(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
|
|
3522
3900
|
} else {
|
|
3523
3901
|
fullRender(true, "clearOnShrink");
|
|
@@ -3543,14 +3921,55 @@ export class TUI extends Container {
|
|
|
3543
3921
|
lastChanged = i;
|
|
3544
3922
|
}
|
|
3545
3923
|
}
|
|
3546
|
-
|
|
3924
|
+
// Regrowth entirely within rows already committed before a contraction is
|
|
3925
|
+
// a viewport repaint, never a new scrollback append.
|
|
3926
|
+
if (
|
|
3927
|
+
!initialRender &&
|
|
3928
|
+
!coalescedWidthAppend &&
|
|
3929
|
+
newLines.length > this.#previousLines.length &&
|
|
3930
|
+
newLines.length <= this.#durableLineCount &&
|
|
3931
|
+
!distinctPostContractionRows
|
|
3932
|
+
) {
|
|
3933
|
+
viewportRepaint("content regrowth within durable history", true, true);
|
|
3934
|
+
return;
|
|
3935
|
+
}
|
|
3936
|
+
const appendedLines = newLines.length > this.#previousLines.length || durableAppend;
|
|
3547
3937
|
if (appendedLines) {
|
|
3548
|
-
if (
|
|
3549
|
-
|
|
3938
|
+
if (
|
|
3939
|
+
this.#nativeScrollbackAdmissionPending &&
|
|
3940
|
+
this.#durableLineCount <= firstChanged &&
|
|
3941
|
+
previousLogicalFrame.length < newLines.length &&
|
|
3942
|
+
firstChanged >= previousLogicalFrame.length
|
|
3943
|
+
) {
|
|
3944
|
+
// Following a manual viewport repaints the live frame in place, so its
|
|
3945
|
+
// newest rows are not yet in native scrollback. Advance by a newline
|
|
3946
|
+
// before emitting the new frontier; the terminal admits the existing
|
|
3947
|
+
// bottom row without replaying its bytes.
|
|
3948
|
+
firstChanged = previousLogicalFrame.length;
|
|
3949
|
+
} else if (coalescedWidthAppend && retainedLength >= 0) {
|
|
3950
|
+
// The terminal reflows the retained prefix during a resize. Emit only
|
|
3951
|
+
// the proven durable suffix; replaying the reflowed prefix would append
|
|
3952
|
+
// historical rows to native scrollback a second time.
|
|
3953
|
+
firstChanged = retainedLength;
|
|
3954
|
+
} else if (firstChanged === -1 || (durableAppend && firstChanged === previousLogicalFrame.length)) {
|
|
3955
|
+
// A resize repaint updates #latestRenderedLines without committing the
|
|
3956
|
+
// reflowed viewport rows to scrollback. Never rewind the suffix start
|
|
3957
|
+
// below that repaint boundary: doing so re-emits reflow rows on the
|
|
3958
|
+
// following real append. A contraction can still retain a higher
|
|
3959
|
+
// durable boundary, hence the maximum.
|
|
3960
|
+
firstChanged =
|
|
3961
|
+
durableAppend && !coalescedWidthAppend && !distinctPostContractionRows
|
|
3962
|
+
? Math.max(this.#durableLineCount, previousLogicalFrame.length)
|
|
3963
|
+
: previousLogicalFrame.length;
|
|
3550
3964
|
}
|
|
3551
3965
|
lastChanged = newLines.length - 1;
|
|
3552
3966
|
}
|
|
3553
|
-
let appendStart =
|
|
3967
|
+
let appendStart =
|
|
3968
|
+
appendedLines &&
|
|
3969
|
+
firstChanged > 0 &&
|
|
3970
|
+
(firstChanged === this.#previousLines.length ||
|
|
3971
|
+
firstChanged === previousLogicalFrame.length ||
|
|
3972
|
+
firstChanged === this.#durableLineCount);
|
|
3554
3973
|
if (firstChanged >= 0) {
|
|
3555
3974
|
const changedTop = firstChanged;
|
|
3556
3975
|
let expanded: boolean;
|
|
@@ -3595,7 +4014,13 @@ export class TUI extends Container {
|
|
|
3595
4014
|
);
|
|
3596
4015
|
return;
|
|
3597
4016
|
}
|
|
3598
|
-
if (
|
|
4017
|
+
if (distinctPostContractionRows) this.#scrollbackResumeViewportTop = undefined;
|
|
4018
|
+
if (
|
|
4019
|
+
appendedLines &&
|
|
4020
|
+
this.#scrollbackResumeViewportTop !== undefined &&
|
|
4021
|
+
nextLiveViewportTop > prevViewportTop &&
|
|
4022
|
+
!distinctPostContractionRows
|
|
4023
|
+
) {
|
|
3599
4024
|
const resumeViewportTop = this.#scrollbackResumeViewportTop;
|
|
3600
4025
|
if (nextLiveViewportTop <= resumeViewportTop) {
|
|
3601
4026
|
viewportRepaint(
|
|
@@ -3643,11 +4068,7 @@ export class TUI extends Container {
|
|
|
3643
4068
|
const extraLines = this.#previousLines.length - newLines.length;
|
|
3644
4069
|
if (extraLines > height) {
|
|
3645
4070
|
logRedraw(`extraLines > height (${extraLines} > ${height})`);
|
|
3646
|
-
|
|
3647
|
-
viewportRepaint(`extraLines > height (${extraLines} > ${height})`);
|
|
3648
|
-
} else {
|
|
3649
|
-
fullRender(true, "extraLines > height");
|
|
3650
|
-
}
|
|
4071
|
+
viewportRepaint(`extraLines > height (${extraLines} > ${height})`);
|
|
3651
4072
|
return;
|
|
3652
4073
|
}
|
|
3653
4074
|
const clearStartOffset = newLines.length > 0 && extraLines > 0 ? 1 : 0;
|
|
@@ -3655,7 +4076,7 @@ export class TUI extends Container {
|
|
|
3655
4076
|
buffer += `\x1b[${clearStartOffset}B`;
|
|
3656
4077
|
}
|
|
3657
4078
|
for (let i = 0; i < extraLines; i++) {
|
|
3658
|
-
buffer +=
|
|
4079
|
+
buffer += `\r${" ".repeat(width)}`;
|
|
3659
4080
|
if (i < extraLines - 1) buffer += "\x1b[1B";
|
|
3660
4081
|
}
|
|
3661
4082
|
const moveUp = extraLines - 1 + clearStartOffset;
|
|
@@ -3686,8 +4107,13 @@ export class TUI extends Container {
|
|
|
3686
4107
|
})
|
|
3687
4108
|
)
|
|
3688
4109
|
return;
|
|
4110
|
+
this.#latestRenderedLines = newLines;
|
|
4111
|
+
if (this.#virtualViewport) this.#latestRaw = rawLines;
|
|
4112
|
+
this.#transcriptIdentityReplaced = false;
|
|
3689
4113
|
}
|
|
3690
|
-
this.#
|
|
4114
|
+
this.#latestRenderedLines = newLines;
|
|
4115
|
+
if (this.#virtualViewport) this.#latestRaw = rawLines;
|
|
4116
|
+
this.#durableLineCount = Math.max(this.#durableLineCount, newLines.length);
|
|
3691
4117
|
this.#previousWidth = width;
|
|
3692
4118
|
this.#previousHeight = height;
|
|
3693
4119
|
this.#maxLinesRendered = newLines.length;
|
|
@@ -3701,24 +4127,73 @@ export class TUI extends Container {
|
|
|
3701
4127
|
// Differential rendering can only touch what was actually visible. If a
|
|
3702
4128
|
// streaming status/header line changes above a live-following viewport, keep
|
|
3703
4129
|
// the terminal pinned by diffing from the visible top instead of clearing and
|
|
3704
|
-
// replaying the transcript.
|
|
3705
|
-
//
|
|
3706
|
-
//
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
4130
|
+
// replaying the transcript. Historical mutations repaint the viewport so
|
|
4131
|
+
// native scrollback is never replayed or repaired.
|
|
4132
|
+
// When a historical mutation is accompanied by growth, commit only the
|
|
4133
|
+
// changed visible suffix. This advances native scrollback without replaying
|
|
4134
|
+
// the mutated off-screen prefix; the latest frame is updated below so each
|
|
4135
|
+
// appended row is emitted exactly once.
|
|
4136
|
+
if (firstChanged < prevViewportTop && appendedLines) {
|
|
4137
|
+
// A same-length substitution above the viewport (a streaming status line,
|
|
4138
|
+
// say) leaves every later row at its original index, so the visible suffix
|
|
4139
|
+
// can still be committed. Growth *inside* the off-screen prefix instead
|
|
4140
|
+
// shifts committed content down across the scrollback frontier: the rows
|
|
4141
|
+
// this frame would commit already sit in native scrollback under their old
|
|
4142
|
+
// index, so emitting them appends a second copy — a pending tool block
|
|
4143
|
+
// stranded above its own completed copy, with the rows between duplicated.
|
|
4144
|
+
// The last committed row changing is the observable signal of that shift.
|
|
4145
|
+
const committedBoundary = prevViewportTop - 1;
|
|
4146
|
+
const committedBoundaryShifted =
|
|
4147
|
+
committedBoundary >= diffStart &&
|
|
4148
|
+
(this.#previousLines[committedBoundary] ?? "") !== (newLines[committedBoundary] ?? "");
|
|
4149
|
+
if (committedBoundaryShifted) {
|
|
4150
|
+
const reason = `offscreen growth shifted committed rows (${firstChanged} < ${prevViewportTop})`;
|
|
4151
|
+
logRedraw(reason);
|
|
4152
|
+
if (useViewportRepaintPath) viewportRepaint(reason);
|
|
4153
|
+
else fullRender(true, reason);
|
|
3716
4154
|
return;
|
|
3717
4155
|
}
|
|
3718
|
-
|
|
4156
|
+
let suffixStart = -1;
|
|
4157
|
+
for (let i = Math.max(diffStart, prevViewportTop); i < maxLines; i++) {
|
|
4158
|
+
const oldLine = i < this.#previousLines.length ? this.#previousLines[i] : "";
|
|
4159
|
+
const newLine = i < newLines.length ? newLines[i] : "";
|
|
4160
|
+
if (oldLine !== newLine) {
|
|
4161
|
+
suffixStart = i;
|
|
4162
|
+
break;
|
|
4163
|
+
}
|
|
4164
|
+
}
|
|
4165
|
+
if (suffixStart >= 0) {
|
|
4166
|
+
firstChanged = suffixStart;
|
|
4167
|
+
appendStart =
|
|
4168
|
+
firstChanged > 0 &&
|
|
4169
|
+
(firstChanged === previousLogicalFrame.length || firstChanged === this.#durableLineCount);
|
|
4170
|
+
}
|
|
4171
|
+
}
|
|
4172
|
+
// A transient width-reflow repaint can leave the latest logical frame
|
|
4173
|
+
// above the durable boundary: those reflowed rows are visible, but were
|
|
4174
|
+
// intentionally not committed to native scrollback. If a later update
|
|
4175
|
+
// has the same row count and changes only that off-screen prefix,
|
|
4176
|
+
// `durableAppend` is true solely because the boundary is stale. Treat it
|
|
4177
|
+
// as a viewport repaint rather than moving through or replaying history.
|
|
4178
|
+
if (
|
|
4179
|
+
firstChanged < prevViewportTop &&
|
|
4180
|
+
newLines.length === previousLogicalFrame.length &&
|
|
4181
|
+
this.#latestRenderedLines.length > this.#durableLineCount
|
|
4182
|
+
) {
|
|
4183
|
+
logRedraw("offscreen mutation after transient reflow");
|
|
4184
|
+
viewportRepaint("offscreen mutation after transient reflow");
|
|
3719
4185
|
return;
|
|
3720
4186
|
}
|
|
3721
4187
|
|
|
4188
|
+
if (firstChanged < prevViewportTop && !durableAppend) {
|
|
4189
|
+
logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
|
|
4190
|
+
if (useViewportRepaintPath) {
|
|
4191
|
+
viewportRepaint(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
|
|
4192
|
+
} else {
|
|
4193
|
+
fullRender(true, "firstChanged < viewportTop");
|
|
4194
|
+
}
|
|
4195
|
+
return;
|
|
4196
|
+
}
|
|
3722
4197
|
// Render from first changed line to end
|
|
3723
4198
|
// Build buffer with all updates wrapped in synchronized output
|
|
3724
4199
|
const deletePlan = this.#kittyPlacementDeletePlan(previousKittyPlacementSpans, nextKittyPlacementSpans, [
|
|
@@ -3726,15 +4201,47 @@ export class TUI extends Container {
|
|
|
3726
4201
|
]);
|
|
3727
4202
|
let buffer = `\x1b[?2026h${deletePlan.output}`; // Begin synchronized output
|
|
3728
4203
|
const prevViewportBottom = prevViewportTop + height - 1;
|
|
3729
|
-
const
|
|
4204
|
+
const nativeScrollbackAdmission =
|
|
4205
|
+
appendedLines &&
|
|
4206
|
+
this.#nativeScrollbackAdmissionPending &&
|
|
4207
|
+
this.#durableLineCount <= firstChanged &&
|
|
4208
|
+
previousLogicalFrame.length < newLines.length &&
|
|
4209
|
+
firstChanged >= previousLogicalFrame.length &&
|
|
4210
|
+
appendStart;
|
|
4211
|
+
if (nativeScrollbackAdmission) {
|
|
4212
|
+
// A live repaint can leave the hardware cursor one row beyond the
|
|
4213
|
+
// logical frontier when the bottom row wrapped at terminal width.
|
|
4214
|
+
// Let the geometry branch perform the native scroll from that row;
|
|
4215
|
+
// moving back first would only advance a blank row.
|
|
4216
|
+
appendStart = false;
|
|
4217
|
+
}
|
|
4218
|
+
const moveTargetRow = coalescedWidthAppend
|
|
4219
|
+
? newLines.length - 1
|
|
4220
|
+
: nativeScrollbackAdmission
|
|
4221
|
+
? firstChanged
|
|
4222
|
+
: appendStart
|
|
4223
|
+
? firstChanged - 1
|
|
4224
|
+
: firstChanged;
|
|
3730
4225
|
if (moveTargetRow > prevViewportBottom) {
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
4226
|
+
if (nativeScrollbackAdmission) {
|
|
4227
|
+
// The logical cursor row can be one row ahead of the physical xterm
|
|
4228
|
+
// cursor after a live viewport repaint (a full-width row leaves a
|
|
4229
|
+
// pending wrap). CUD is bounded by the terminal's scroll margin, so
|
|
4230
|
+
// moving by one viewport height reliably reaches the physical bottom
|
|
4231
|
+
// without replaying any transcript bytes.
|
|
4232
|
+
buffer += `\x1b[${Math.max(1, height)}B`;
|
|
4233
|
+
} else {
|
|
4234
|
+
const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
|
|
4235
|
+
const moveToBottom = height - 1 - currentScreenRow;
|
|
4236
|
+
if (moveToBottom > 0) {
|
|
4237
|
+
buffer += `\x1b[${moveToBottom}B`;
|
|
4238
|
+
}
|
|
3735
4239
|
}
|
|
3736
4240
|
const scroll = moveTargetRow - prevViewportBottom;
|
|
3737
|
-
|
|
4241
|
+
// Native admission follows a repaint at the live bottom. Use IND rather
|
|
4242
|
+
// than LF so the terminal performs one unambiguous scroll without
|
|
4243
|
+
// reinterpreting a pending wrapped row.
|
|
4244
|
+
buffer += (nativeScrollbackAdmission ? "\r\x1bD" : "\r\n").repeat(scroll);
|
|
3738
4245
|
prevViewportTop += scroll;
|
|
3739
4246
|
viewportTop += scroll;
|
|
3740
4247
|
hardwareCursorRow = moveTargetRow;
|
|
@@ -3755,7 +4262,7 @@ export class TUI extends Container {
|
|
|
3755
4262
|
const renderEnd = Math.min(lastChanged, newLines.length - 1);
|
|
3756
4263
|
for (let i = firstChanged; i <= renderEnd; i++) {
|
|
3757
4264
|
if (i > firstChanged) buffer += "\r\n";
|
|
3758
|
-
buffer += "\x1b[2K";
|
|
4265
|
+
buffer += "\x1b[2K";
|
|
3759
4266
|
const line = newLines[i];
|
|
3760
4267
|
let truncatedLine = line;
|
|
3761
4268
|
const isImage = TERMINAL.isImageLine(line);
|
|
@@ -3783,7 +4290,7 @@ export class TUI extends Container {
|
|
|
3783
4290
|
}
|
|
3784
4291
|
// Non-image lines are pre-terminated/normalized by #applyLineResets;
|
|
3785
4292
|
// truncated lines re-append LINE_TERMINATOR above.
|
|
3786
|
-
buffer += truncatedLine;
|
|
4293
|
+
buffer += this.#padLineToWidth(truncatedLine, width);
|
|
3787
4294
|
}
|
|
3788
4295
|
|
|
3789
4296
|
// Track where cursor ended up after rendering
|
|
@@ -3799,7 +4306,7 @@ export class TUI extends Container {
|
|
|
3799
4306
|
}
|
|
3800
4307
|
const extraLines = this.#previousLines.length - newLines.length;
|
|
3801
4308
|
for (let i = newLines.length; i < this.#previousLines.length; i++) {
|
|
3802
|
-
buffer +=
|
|
4309
|
+
buffer += `\r\n${" ".repeat(width)}`;
|
|
3803
4310
|
}
|
|
3804
4311
|
// Move cursor back to end of new content
|
|
3805
4312
|
buffer += `\x1b[${extraLines}A`;
|
|
@@ -3825,13 +4332,13 @@ export class TUI extends Container {
|
|
|
3825
4332
|
`finalCursorRow: ${finalCursorRow}`,
|
|
3826
4333
|
`cursorPos: ${JSON.stringify(cursorPos)}`,
|
|
3827
4334
|
`newLines.length: ${newLines.length}`,
|
|
3828
|
-
`
|
|
4335
|
+
`latestRenderedLines.length: ${this.#latestRenderedLines.length}`,
|
|
3829
4336
|
"",
|
|
3830
4337
|
"=== newLines ===",
|
|
3831
4338
|
JSON.stringify(newLines, null, 2),
|
|
3832
4339
|
"",
|
|
3833
|
-
"===
|
|
3834
|
-
JSON.stringify(this.#
|
|
4340
|
+
"=== latestRenderedLines ===",
|
|
4341
|
+
JSON.stringify(this.#latestRenderedLines, null, 2),
|
|
3835
4342
|
"",
|
|
3836
4343
|
"=== buffer ===",
|
|
3837
4344
|
JSON.stringify(buffer),
|
|
@@ -3864,6 +4371,12 @@ export class TUI extends Container {
|
|
|
3864
4371
|
})
|
|
3865
4372
|
)
|
|
3866
4373
|
return;
|
|
4374
|
+
this.#latestRenderedLines = newLines;
|
|
4375
|
+
if (this.#virtualViewport) this.#latestRaw = rawLines;
|
|
4376
|
+
this.#durableLineCount = Math.max(this.#durableLineCount, newLines.length);
|
|
4377
|
+
this.#recordDurableLines(newLines, rawLines, firstChanged, renderEnd);
|
|
4378
|
+
this.#nativeScrollbackAdmissionPending = false;
|
|
4379
|
+
this.#transcriptIdentityReplaced = false;
|
|
3867
4380
|
}
|
|
3868
4381
|
|
|
3869
4382
|
/**
|
|
@@ -3923,10 +4436,9 @@ export class TUI extends Container {
|
|
|
3923
4436
|
}
|
|
3924
4437
|
|
|
3925
4438
|
/**
|
|
3926
|
-
* Register an emitter whose
|
|
3927
|
-
*
|
|
3928
|
-
*
|
|
3929
|
-
* outside the line-based component model. Return null to emit nothing.
|
|
4439
|
+
* Register an emitter whose payload is delivered after each shared render
|
|
4440
|
+
* transaction. The emitter is an exempt physical overlay: its bytes are
|
|
4441
|
+
* deliberately kept out of the shared transcript write.
|
|
3930
4442
|
*/
|
|
3931
4443
|
setPostRenderEmitter(emitter: (() => string | null) | undefined): void {
|
|
3932
4444
|
this.#postRenderEmitter = emitter;
|
|
@@ -3940,21 +4452,31 @@ export class TUI extends Container {
|
|
|
3940
4452
|
totalLines: number,
|
|
3941
4453
|
onBufferWritten?: () => void,
|
|
3942
4454
|
): boolean {
|
|
4455
|
+
if (!this.#writeTerminal(buffer)) {
|
|
4456
|
+
return false;
|
|
4457
|
+
}
|
|
4458
|
+
onBufferWritten?.();
|
|
4459
|
+
this.#lastRenderWriteSucceeded = true;
|
|
4460
|
+
|
|
3943
4461
|
const overlay = this.#postRenderEmitter?.();
|
|
3944
4462
|
if (overlay) {
|
|
3945
4463
|
// DECSC/DECRC keep the hardware cursor stable; the dedicated
|
|
3946
4464
|
// synchronized block prevents visible tearing while the overlay
|
|
3947
4465
|
// area is cleared and redrawn.
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
4466
|
+
const overlayBuffer = `\x1b[?2026h\x1b7${overlay}\x1b8\x1b[?2026l`;
|
|
4467
|
+
// Overlay delivery is outside shared transcript ownership. The
|
|
4468
|
+
// shared write has already committed even when this exempt write
|
|
4469
|
+
// fails, so do not make callers retry the shared bytes.
|
|
4470
|
+
if (!this.#writeTerminal(overlayBuffer, true)) {
|
|
4471
|
+
return true;
|
|
4472
|
+
}
|
|
3955
4473
|
}
|
|
3956
|
-
|
|
3957
|
-
|
|
4474
|
+
if (!this.#imeCursorActive) return true;
|
|
4475
|
+
// Cursor positioning is outside shared transcript ownership. A failure still
|
|
4476
|
+
// makes the terminal unavailable, but cannot uncommit the shared frame. The
|
|
4477
|
+
// onBufferWritten callback has already run; the return value propagates
|
|
4478
|
+
// terminal availability so callers can detect the detach.
|
|
4479
|
+
const cursorWritten = this.#writeCursorPosition(cursorPos, totalLines, true);
|
|
3958
4480
|
return cursorWritten;
|
|
3959
4481
|
}
|
|
3960
4482
|
|
|
@@ -3963,13 +4485,21 @@ export class TUI extends Container {
|
|
|
3963
4485
|
* synchronized output block. Use when there is no surrounding render buffer
|
|
3964
4486
|
* to embed the sequences into.
|
|
3965
4487
|
*/
|
|
3966
|
-
#writeCursorPosition(
|
|
4488
|
+
#writeCursorPosition(
|
|
4489
|
+
cursorPos: { row: number; col: number } | null,
|
|
4490
|
+
totalLines: number,
|
|
4491
|
+
deferRenderFailure = false,
|
|
4492
|
+
): boolean {
|
|
3967
4493
|
if (!cursorPos || totalLines <= 0) {
|
|
3968
|
-
return
|
|
4494
|
+
return deferRenderFailure
|
|
4495
|
+
? this.#guardTerminalOperation(() => this.terminal.hideCursor(), false)
|
|
4496
|
+
: this.#hideCursor();
|
|
3969
4497
|
}
|
|
3970
4498
|
const { seq, toRow } = this.#cursorControlSequence(cursorPos, totalLines, this.#hardwareCursorRow);
|
|
3971
4499
|
// No \x1b[?2026h/l wrapper: synchronized output flushes terminal state and discards macOS IME composition.
|
|
3972
|
-
if (!this.#writeTerminal(seq))
|
|
4500
|
+
if (!this.#writeTerminal(seq, deferRenderFailure)) {
|
|
4501
|
+
return false;
|
|
4502
|
+
}
|
|
3973
4503
|
this.#hardwareCursorRow = toRow;
|
|
3974
4504
|
return true;
|
|
3975
4505
|
}
|