@linxiraos/pi-tui 1.0.0 → 1.0.2
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 +48 -7
- package/README.md +1 -1
- package/package.json +67 -68
- package/src/components/editor.ts +64 -3
- package/src/components/image.ts +169 -3
- package/src/components/loader.ts +2 -3
- package/src/components/markdown.ts +146 -11
- package/src/components/text.ts +10 -0
- package/src/latex-block.ts +116 -6
- package/src/terminal-capabilities.ts +84 -0
- package/src/tui.ts +1109 -56
- package/src/utils.ts +28 -0
- package/dist/types/autocomplete.d.ts +0 -116
- package/dist/types/bracketed-paste.d.ts +0 -51
- package/dist/types/components/box.d.ts +0 -31
- package/dist/types/components/cancellable-loader.d.ts +0 -21
- package/dist/types/components/editor.d.ts +0 -162
- package/dist/types/components/image.d.ts +0 -112
- package/dist/types/components/input.d.ts +0 -25
- package/dist/types/components/loader.d.ts +0 -25
- package/dist/types/components/markdown.d.ts +0 -88
- package/dist/types/components/scroll-view.d.ts +0 -62
- package/dist/types/components/select-list.d.ts +0 -69
- package/dist/types/components/settings-list.d.ts +0 -123
- package/dist/types/components/spacer.d.ts +0 -11
- package/dist/types/components/tab-bar.d.ts +0 -89
- package/dist/types/components/text.d.ts +0 -27
- package/dist/types/components/truncated-text.d.ts +0 -10
- package/dist/types/deccara.d.ts +0 -49
- package/dist/types/desktop-notify.d.ts +0 -52
- package/dist/types/editor-component.d.ts +0 -38
- package/dist/types/fuzzy.d.ts +0 -48
- package/dist/types/index.d.ts +0 -32
- package/dist/types/keybindings.d.ts +0 -197
- package/dist/types/keys.d.ts +0 -210
- package/dist/types/kill-ring.d.ts +0 -20
- package/dist/types/kitty-graphics.d.ts +0 -76
- package/dist/types/latex-block.d.ts +0 -8
- package/dist/types/latex-to-unicode.d.ts +0 -50
- package/dist/types/loop-watchdog.d.ts +0 -44
- package/dist/types/mouse.d.ts +0 -67
- package/dist/types/stdin-buffer.d.ts +0 -60
- package/dist/types/symbols.d.ts +0 -25
- package/dist/types/terminal-capabilities.d.ts +0 -285
- package/dist/types/terminal.d.ts +0 -175
- package/dist/types/tmux.d.ts +0 -6
- package/dist/types/ttyid.d.ts +0 -9
- package/dist/types/tui.d.ts +0 -457
- package/dist/types/utils.d.ts +0 -100
package/src/tui.ts
CHANGED
|
@@ -25,8 +25,11 @@ import { LoopWatchdog } from "./loop-watchdog";
|
|
|
25
25
|
import { isConPTYHosted, setAltScreenActive, type Terminal } from "./terminal";
|
|
26
26
|
import {
|
|
27
27
|
encodeKittyDeleteImage,
|
|
28
|
+
encodeKittyDeletePlacement,
|
|
29
|
+
encodeKittyPlacementLine,
|
|
28
30
|
ImageProtocol,
|
|
29
31
|
isInsideTerminalMultiplexer,
|
|
32
|
+
parseKittyDirectPlacementLine,
|
|
30
33
|
setCellDimensions,
|
|
31
34
|
setTerminalImageProtocol,
|
|
32
35
|
shouldEnableSynchronizedOutputByDefault,
|
|
@@ -36,7 +39,9 @@ import {
|
|
|
36
39
|
import {
|
|
37
40
|
Ellipsis,
|
|
38
41
|
extractSegments,
|
|
42
|
+
isOsc66Line,
|
|
39
43
|
normalizeTerminalOutput,
|
|
44
|
+
osc66MaxScale,
|
|
40
45
|
sliceByColumn,
|
|
41
46
|
sliceWithWidth,
|
|
42
47
|
truncateToWidth,
|
|
@@ -216,6 +221,22 @@ export interface NativeScrollbackCommittedRows {
|
|
|
216
221
|
setNativeScrollbackCommittedRows(rows: number): void;
|
|
217
222
|
}
|
|
218
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Width-independent source boundary for multiplexer resize epochs. Capture
|
|
226
|
+
* reads the last rendered source state; resolve maps that same logical boundary
|
|
227
|
+
* into the most recent render's physical rows at its new width. The current
|
|
228
|
+
* boundary identifies the source tail after updates queued during the resize.
|
|
229
|
+
*/
|
|
230
|
+
export interface NativeScrollbackWidthEpoch {
|
|
231
|
+
captureNativeScrollbackWidthEpoch(): unknown;
|
|
232
|
+
resolveNativeScrollbackWidthEpoch(boundary: unknown): number | undefined;
|
|
233
|
+
getNativeScrollbackWidthEpochRows(): number | undefined;
|
|
234
|
+
/** False when updates can insert before captured trailing rows. */
|
|
235
|
+
isNativeScrollbackWidthEpochAppendOnly?(boundary: unknown): boolean;
|
|
236
|
+
/** Changes when child structure mutates independently of width reflow. */
|
|
237
|
+
getNativeScrollbackWidthEpochRevision?(): number;
|
|
238
|
+
}
|
|
239
|
+
|
|
219
240
|
/**
|
|
220
241
|
* A component that discards rows after they enter native scrollback implements
|
|
221
242
|
* this hook so a destructive full replay can rehydrate its complete frame.
|
|
@@ -232,6 +253,19 @@ function setNativeScrollbackCommittedRows(component: Component, rows: number): v
|
|
|
232
253
|
(component as Component & Partial<NativeScrollbackCommittedRows>).setNativeScrollbackCommittedRows?.(rows);
|
|
233
254
|
}
|
|
234
255
|
|
|
256
|
+
function getNativeScrollbackWidthEpoch(component: Component): NativeScrollbackWidthEpoch | undefined {
|
|
257
|
+
const candidate = component as Component & Partial<NativeScrollbackWidthEpoch>;
|
|
258
|
+
return candidate.captureNativeScrollbackWidthEpoch &&
|
|
259
|
+
candidate.resolveNativeScrollbackWidthEpoch &&
|
|
260
|
+
candidate.getNativeScrollbackWidthEpochRows
|
|
261
|
+
? (candidate as NativeScrollbackWidthEpoch)
|
|
262
|
+
: undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function getNativeScrollbackWidthEpochRevision(component: Component): number | undefined {
|
|
266
|
+
return (component as Component & Partial<NativeScrollbackWidthEpoch>).getNativeScrollbackWidthEpochRevision?.();
|
|
267
|
+
}
|
|
268
|
+
|
|
235
269
|
function isOverlayFocusTarget(owner: Component, component: Component | null): boolean {
|
|
236
270
|
if (component === owner) return true;
|
|
237
271
|
if (!component) return false;
|
|
@@ -324,6 +358,7 @@ export interface RenderRequestOptions {
|
|
|
324
358
|
/** Clear terminal scrollback for intentional transcript replacement. */
|
|
325
359
|
clearScrollback?: boolean;
|
|
326
360
|
}
|
|
361
|
+
|
|
327
362
|
/** Type guard to check if a component implements Focusable */
|
|
328
363
|
export function isFocusable(component: Component | null): component is Component & Focusable {
|
|
329
364
|
return component !== null && "focused" in component;
|
|
@@ -378,9 +413,25 @@ function parseSizeValue(value: SizeValue | undefined, referenceSize: number): nu
|
|
|
378
413
|
return undefined;
|
|
379
414
|
}
|
|
380
415
|
|
|
381
|
-
/**
|
|
416
|
+
/**
|
|
417
|
+
* Detect sessions where ED3 cannot safely rebuild scrollback. Direct HerdR
|
|
418
|
+
* panes support explicit clears; nested multiplexers remain unsafe because the
|
|
419
|
+
* inner tmux/screen/Zellij layer owns their history.
|
|
420
|
+
*/
|
|
382
421
|
function isMultiplexerSession(): boolean {
|
|
383
|
-
|
|
422
|
+
if (!isInsideTerminalMultiplexer()) return false;
|
|
423
|
+
if (Bun.env.HERDR_ENV !== "1") return true;
|
|
424
|
+
const term = Bun.env.TERM?.toLowerCase() ?? "";
|
|
425
|
+
return Boolean(
|
|
426
|
+
Bun.env.TMUX ||
|
|
427
|
+
Bun.env.STY ||
|
|
428
|
+
Bun.env.ZELLIJ ||
|
|
429
|
+
Bun.env.CMUX_WORKSPACE_ID ||
|
|
430
|
+
Bun.env.CMUX_SURFACE_ID ||
|
|
431
|
+
Bun.env.CMUX_REMOTE_TRANSPORT ||
|
|
432
|
+
term.startsWith("tmux") ||
|
|
433
|
+
term.startsWith("screen"),
|
|
434
|
+
);
|
|
384
435
|
}
|
|
385
436
|
|
|
386
437
|
/**
|
|
@@ -404,12 +455,12 @@ function reportsSizeOnAltScreenToggle(): boolean {
|
|
|
404
455
|
|
|
405
456
|
/**
|
|
406
457
|
* Resize should repaint the visible window in place — no alternate-screen
|
|
407
|
-
* borrow, no ED3 scrollback rewrap — for multiplexer
|
|
408
|
-
* that loop on alt-screen toggles.
|
|
409
|
-
*
|
|
458
|
+
* borrow, no ED3 scrollback rewrap — for multiplexer and direct HerdR panes,
|
|
459
|
+
* plus terminals that loop on alt-screen toggles. Direct HerdR remains a
|
|
460
|
+
* direct terminal for explicit transcript replacement and display reset.
|
|
410
461
|
*/
|
|
411
462
|
function resizeRepaintsInPlace(): boolean {
|
|
412
|
-
return isMultiplexerSession() || reportsSizeOnAltScreenToggle();
|
|
463
|
+
return isMultiplexerSession() || Bun.env.HERDR_ENV === "1" || reportsSizeOnAltScreenToggle();
|
|
413
464
|
}
|
|
414
465
|
|
|
415
466
|
/**
|
|
@@ -483,7 +534,9 @@ export interface OverlayHandle {
|
|
|
483
534
|
/**
|
|
484
535
|
* Container - a component that contains other components
|
|
485
536
|
*/
|
|
486
|
-
export class Container
|
|
537
|
+
export class Container
|
|
538
|
+
implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay, NativeScrollbackWidthEpoch
|
|
539
|
+
{
|
|
487
540
|
children: Component[] = [];
|
|
488
541
|
|
|
489
542
|
// Memoized concatenation of the children's latest renders. Children are
|
|
@@ -495,7 +548,29 @@ export class Container implements Component, NativeScrollbackCommittedRows, Nati
|
|
|
495
548
|
// on invalidate().
|
|
496
549
|
#memoLines: string[] | undefined;
|
|
497
550
|
#memoChildLines: (readonly string[])[] = [];
|
|
551
|
+
#memoChildWidthEpochRevisions: Array<number | undefined> = [];
|
|
498
552
|
#memoWidth = -1;
|
|
553
|
+
// Child identities matching #memoChildLines. Kept separately because callers
|
|
554
|
+
// may append children after the last emitted render but before SIGWINCH.
|
|
555
|
+
#memoChildren: Component[] = [];
|
|
556
|
+
#widthEpochBoundaries = new WeakMap<
|
|
557
|
+
object,
|
|
558
|
+
{
|
|
559
|
+
component: Component;
|
|
560
|
+
childBoundary: unknown;
|
|
561
|
+
sourceIndex: number;
|
|
562
|
+
leading: ReadonlyArray<{ component: Component; revision: number | undefined; rowCount: number }>;
|
|
563
|
+
trailing: ReadonlyArray<{
|
|
564
|
+
component: Component;
|
|
565
|
+
revision: number | undefined;
|
|
566
|
+
rowCount: number;
|
|
567
|
+
hadRows: boolean;
|
|
568
|
+
}>;
|
|
569
|
+
}
|
|
570
|
+
>();
|
|
571
|
+
#activeWidthEpochBoundary: object | undefined;
|
|
572
|
+
#widthEpochRevision = 0;
|
|
573
|
+
#widthEpochChildRevisions = new WeakMap<Component, number | undefined>();
|
|
499
574
|
|
|
500
575
|
#ignoreTight = false;
|
|
501
576
|
|
|
@@ -510,6 +585,7 @@ export class Container implements Component, NativeScrollbackCommittedRows, Nati
|
|
|
510
585
|
|
|
511
586
|
addChild(component: Component): void {
|
|
512
587
|
this.children.push(component);
|
|
588
|
+
this.#widthEpochRevision++;
|
|
513
589
|
if (this.#ignoreTight) {
|
|
514
590
|
component.setIgnoreTight?.(true);
|
|
515
591
|
}
|
|
@@ -520,11 +596,13 @@ export class Container implements Component, NativeScrollbackCommittedRows, Nati
|
|
|
520
596
|
const index = this.children.indexOf(component);
|
|
521
597
|
if (index !== -1) {
|
|
522
598
|
this.children.splice(index, 1);
|
|
599
|
+
this.#widthEpochRevision++;
|
|
523
600
|
this.#memoLines = undefined;
|
|
524
601
|
}
|
|
525
602
|
}
|
|
526
603
|
|
|
527
604
|
clear(): void {
|
|
605
|
+
if (this.children.length > 0) this.#widthEpochRevision++;
|
|
528
606
|
this.children = [];
|
|
529
607
|
this.#memoLines = undefined;
|
|
530
608
|
}
|
|
@@ -581,23 +659,189 @@ export class Container implements Component, NativeScrollbackCommittedRows, Nati
|
|
|
581
659
|
for (const child of this.children) prepareNativeScrollbackReplay(child);
|
|
582
660
|
}
|
|
583
661
|
|
|
662
|
+
captureNativeScrollbackWidthEpoch(): unknown {
|
|
663
|
+
const refs = this.#memoChildLines;
|
|
664
|
+
const children = this.#memoChildren;
|
|
665
|
+
if (this.#memoLines === undefined || refs.length !== children.length) return undefined;
|
|
666
|
+
for (let index = children.length - 1; index >= 0; index--) {
|
|
667
|
+
const component = children[index]!;
|
|
668
|
+
const source = getNativeScrollbackWidthEpoch(component);
|
|
669
|
+
const childBoundary = source?.captureNativeScrollbackWidthEpoch();
|
|
670
|
+
if (childBoundary === undefined) continue;
|
|
671
|
+
const marker = {};
|
|
672
|
+
this.#activeWidthEpochBoundary = marker;
|
|
673
|
+
this.#widthEpochBoundaries.set(marker, {
|
|
674
|
+
component,
|
|
675
|
+
childBoundary,
|
|
676
|
+
sourceIndex: index,
|
|
677
|
+
leading: children.slice(0, index).map((child, leadingIndex) => ({
|
|
678
|
+
component: child,
|
|
679
|
+
revision: this.#memoChildWidthEpochRevisions[leadingIndex],
|
|
680
|
+
rowCount: refs[leadingIndex]!.length,
|
|
681
|
+
})),
|
|
682
|
+
trailing: children.slice(index + 1).map((child, trailingIndex) => ({
|
|
683
|
+
component: child,
|
|
684
|
+
revision: this.#memoChildWidthEpochRevisions[index + 1 + trailingIndex],
|
|
685
|
+
rowCount: refs[index + 1 + trailingIndex]!.length,
|
|
686
|
+
hadRows: refs[index + 1 + trailingIndex]!.length > 0,
|
|
687
|
+
})),
|
|
688
|
+
});
|
|
689
|
+
return marker;
|
|
690
|
+
}
|
|
691
|
+
return undefined;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
resolveNativeScrollbackWidthEpoch(boundary: unknown): number | undefined {
|
|
695
|
+
if (typeof boundary !== "object" || boundary === null) return undefined;
|
|
696
|
+
const marker = this.#widthEpochBoundaries.get(boundary);
|
|
697
|
+
if (!marker) return undefined;
|
|
698
|
+
const index = marker.sourceIndex;
|
|
699
|
+
if (
|
|
700
|
+
this.#memoChildren[index] !== marker.component ||
|
|
701
|
+
this.#memoLines === undefined ||
|
|
702
|
+
this.#memoChildLines.length !== this.#memoChildren.length
|
|
703
|
+
) {
|
|
704
|
+
return undefined;
|
|
705
|
+
}
|
|
706
|
+
for (let leadingIndex = 0; leadingIndex < marker.leading.length; leadingIndex++) {
|
|
707
|
+
const captured = marker.leading[leadingIndex]!;
|
|
708
|
+
const currentRows = this.#memoChildLines[leadingIndex]!;
|
|
709
|
+
if (
|
|
710
|
+
this.#memoChildren[leadingIndex] !== captured.component ||
|
|
711
|
+
(captured.revision === undefined
|
|
712
|
+
? currentRows.length !== captured.rowCount
|
|
713
|
+
: getNativeScrollbackWidthEpochRevision(captured.component) !== captured.revision)
|
|
714
|
+
) {
|
|
715
|
+
return undefined;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
const childRows = getNativeScrollbackWidthEpoch(marker.component)?.resolveNativeScrollbackWidthEpoch(
|
|
719
|
+
marker.childBoundary,
|
|
720
|
+
);
|
|
721
|
+
if (childRows === undefined) return undefined;
|
|
722
|
+
let rows = childRows;
|
|
723
|
+
for (let i = 0; i < index; i++) rows += this.#memoChildLines[i]!.length;
|
|
724
|
+
for (let trailingIndex = 0; trailingIndex < marker.trailing.length; trailingIndex++) {
|
|
725
|
+
const captured = marker.trailing[trailingIndex]!;
|
|
726
|
+
const currentIndex = index + 1 + trailingIndex;
|
|
727
|
+
const currentRows = this.#memoChildLines[currentIndex];
|
|
728
|
+
if (
|
|
729
|
+
this.#memoChildren[currentIndex] !== captured.component ||
|
|
730
|
+
currentRows === undefined ||
|
|
731
|
+
(captured.revision === undefined
|
|
732
|
+
? currentRows.length !== captured.rowCount
|
|
733
|
+
: getNativeScrollbackWidthEpochRevision(captured.component) !== captured.revision)
|
|
734
|
+
) {
|
|
735
|
+
let capturedRows = 0;
|
|
736
|
+
for (let index = trailingIndex; index < marker.trailing.length; index++) {
|
|
737
|
+
capturedRows += marker.trailing[index]!.rowCount;
|
|
738
|
+
}
|
|
739
|
+
let settledRows = 0;
|
|
740
|
+
for (let index = currentIndex; index < this.#memoChildLines.length; index++) {
|
|
741
|
+
settledRows += this.#memoChildLines[index]!.length;
|
|
742
|
+
}
|
|
743
|
+
rows += Math.min(capturedRows, settledRows);
|
|
744
|
+
break;
|
|
745
|
+
}
|
|
746
|
+
rows += currentRows.length;
|
|
747
|
+
}
|
|
748
|
+
return rows;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
getNativeScrollbackWidthEpochRows(): number | undefined {
|
|
752
|
+
if (this.#memoLines === undefined || this.#memoChildLines.length !== this.#memoChildren.length) return undefined;
|
|
753
|
+
const marker =
|
|
754
|
+
this.#activeWidthEpochBoundary === undefined
|
|
755
|
+
? undefined
|
|
756
|
+
: this.#widthEpochBoundaries.get(this.#activeWidthEpochBoundary);
|
|
757
|
+
if (marker !== undefined) {
|
|
758
|
+
const index = marker.sourceIndex;
|
|
759
|
+
if (this.#memoChildren[index] !== marker.component) return undefined;
|
|
760
|
+
const rows = getNativeScrollbackWidthEpoch(marker.component)?.getNativeScrollbackWidthEpochRows();
|
|
761
|
+
if (rows === undefined) return undefined;
|
|
762
|
+
let boundary = rows;
|
|
763
|
+
for (let leading = 0; leading < index; leading++) boundary += this.#memoChildLines[leading]!.length;
|
|
764
|
+
for (let trailing = index + 1; trailing < this.#memoChildLines.length; trailing++) {
|
|
765
|
+
boundary += this.#memoChildLines[trailing]!.length;
|
|
766
|
+
}
|
|
767
|
+
return boundary;
|
|
768
|
+
}
|
|
769
|
+
let offset = this.#memoLines.length;
|
|
770
|
+
for (let index = this.#memoChildren.length - 1; index >= 0; index--) {
|
|
771
|
+
offset -= this.#memoChildLines[index]!.length;
|
|
772
|
+
const rows = getNativeScrollbackWidthEpoch(this.#memoChildren[index]!)?.getNativeScrollbackWidthEpochRows();
|
|
773
|
+
if (rows !== undefined) {
|
|
774
|
+
let boundary = offset + rows;
|
|
775
|
+
for (let trailing = index + 1; trailing < this.#memoChildLines.length; trailing++) {
|
|
776
|
+
boundary += this.#memoChildLines[trailing]!.length;
|
|
777
|
+
}
|
|
778
|
+
return boundary;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return undefined;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
isNativeScrollbackWidthEpochAppendOnly(boundary: unknown): boolean {
|
|
785
|
+
if (typeof boundary !== "object" || boundary === null) return true;
|
|
786
|
+
const marker = this.#widthEpochBoundaries.get(boundary);
|
|
787
|
+
if (!marker) return true;
|
|
788
|
+
const source = getNativeScrollbackWidthEpoch(marker.component);
|
|
789
|
+
if (source?.isNativeScrollbackWidthEpochAppendOnly?.(marker.childBoundary) === false) return false;
|
|
790
|
+
if (!marker.trailing.some(child => child.hadRows)) return true;
|
|
791
|
+
for (let trailingIndex = 0; trailingIndex < marker.trailing.length; trailingIndex++) {
|
|
792
|
+
const captured = marker.trailing[trailingIndex]!;
|
|
793
|
+
const currentIndex = marker.sourceIndex + 1 + trailingIndex;
|
|
794
|
+
const currentRows = this.#memoChildLines[currentIndex];
|
|
795
|
+
const changed =
|
|
796
|
+
this.#memoChildren[currentIndex] !== captured.component ||
|
|
797
|
+
currentRows === undefined ||
|
|
798
|
+
(captured.revision === undefined
|
|
799
|
+
? currentRows.length !== captured.rowCount
|
|
800
|
+
: getNativeScrollbackWidthEpochRevision(captured.component) !== captured.revision);
|
|
801
|
+
if (changed && (captured.hadRows || marker.trailing.slice(trailingIndex + 1).some(child => child.hadRows))) {
|
|
802
|
+
return false;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
const previousRows = source?.resolveNativeScrollbackWidthEpoch(marker.childBoundary);
|
|
806
|
+
const currentRows = source?.getNativeScrollbackWidthEpochRows();
|
|
807
|
+
return previousRows === undefined || currentRows === undefined || currentRows <= previousRows;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
getNativeScrollbackWidthEpochRevision(): number {
|
|
811
|
+
for (const child of this.children) {
|
|
812
|
+
const revision = getNativeScrollbackWidthEpochRevision(child);
|
|
813
|
+
if (!this.#widthEpochChildRevisions.has(child)) {
|
|
814
|
+
this.#widthEpochChildRevisions.set(child, revision);
|
|
815
|
+
} else if (this.#widthEpochChildRevisions.get(child) !== revision) {
|
|
816
|
+
this.#widthEpochChildRevisions.set(child, revision);
|
|
817
|
+
this.#widthEpochRevision++;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return this.#widthEpochRevision;
|
|
821
|
+
}
|
|
822
|
+
|
|
584
823
|
render(width: number): readonly string[] {
|
|
585
824
|
width = Math.max(1, width);
|
|
586
825
|
const children = this.children;
|
|
587
826
|
const count = children.length;
|
|
588
827
|
let refs = this.#memoChildLines;
|
|
828
|
+
let revisions = this.#memoChildWidthEpochRevisions;
|
|
589
829
|
let unchanged = this.#memoLines !== undefined && this.#memoWidth === width && refs.length === count;
|
|
590
830
|
if (refs.length !== count) {
|
|
591
831
|
refs = new Array(count);
|
|
592
832
|
this.#memoChildLines = refs;
|
|
833
|
+
revisions = new Array(count);
|
|
834
|
+
this.#memoChildWidthEpochRevisions = revisions;
|
|
593
835
|
}
|
|
594
836
|
for (let i = 0; i < count; i++) {
|
|
595
837
|
const childLines = children[i]!.render(width);
|
|
838
|
+
revisions[i] = getNativeScrollbackWidthEpochRevision(children[i]!);
|
|
596
839
|
if (refs[i] !== childLines) {
|
|
597
840
|
unchanged = false;
|
|
598
841
|
refs[i] = childLines;
|
|
599
842
|
}
|
|
600
843
|
}
|
|
844
|
+
this.#memoChildren = children.slice();
|
|
601
845
|
this.#memoWidth = width;
|
|
602
846
|
if (unchanged) return this.#memoLines!;
|
|
603
847
|
const lines: string[] = [];
|
|
@@ -653,6 +897,7 @@ interface FrameSegment {
|
|
|
653
897
|
lines: readonly string[];
|
|
654
898
|
start: number;
|
|
655
899
|
rowCount: number;
|
|
900
|
+
widthEpochRevision?: number;
|
|
656
901
|
liveLocalStart?: number;
|
|
657
902
|
liveRegionPinned: boolean;
|
|
658
903
|
}
|
|
@@ -970,6 +1215,10 @@ export class TUI extends Container {
|
|
|
970
1215
|
// the drag has been quiet for this long. Multiplexer sessions keep their own
|
|
971
1216
|
// debounce (`#armMultiplexerResizeTimer`, see #2088) and never take this path.
|
|
972
1217
|
static readonly #RESIZE_VIEWPORT_SETTLE_MS = 120;
|
|
1218
|
+
// A scale-`s` OSC 66 heading reserves `s - 1` rows, and the protocol
|
|
1219
|
+
// caps `s` at 7. This bounds spacer lookups and supplies enough context
|
|
1220
|
+
// above the resize viewport to classify every legal heading exactly.
|
|
1221
|
+
static readonly #OSC66_MAX_SPACER_ROWS = 6;
|
|
973
1222
|
// Ghostty can drop Kitty graphics commands sent during its first post-startup
|
|
974
1223
|
// settle window, leaving only Unicode placeholder cells. Hold the first image
|
|
975
1224
|
// paint until that window has passed; later images render normally.
|
|
@@ -1038,6 +1287,42 @@ export class TUI extends Container {
|
|
|
1038
1287
|
// snapshot (duplication, never loss). Re-based on full paints / shrinks /
|
|
1039
1288
|
// geometry frames.
|
|
1040
1289
|
#committedPrefixAuditRows = 0;
|
|
1290
|
+
// Width reflow terminates the meaning of the old committed physical-row
|
|
1291
|
+
// index in an in-place resize session. This is the current-width frame
|
|
1292
|
+
// baseline; only later physical-row growth may advance the append ledger.
|
|
1293
|
+
#widthEpochBaselineRows: number | undefined;
|
|
1294
|
+
// An unresolved captured source boundary was replayed from row zero. While
|
|
1295
|
+
// its live region remains pinned, advance the baseline only through rows
|
|
1296
|
+
// actually emitted; a reported final seam may otherwise skip deferred rows.
|
|
1297
|
+
#widthEpochReplayUnresolved = false;
|
|
1298
|
+
// An overlay-covered width reset with unresolved pending growth owes a
|
|
1299
|
+
// conservative replay from row zero. Sticky across later covered resizes —
|
|
1300
|
+
// even if their source boundary resolves — until an uncovered paint pays it.
|
|
1301
|
+
#widthEpochOverlayReplayPending = false;
|
|
1302
|
+
// The first logical boundary captured while a normal-buffer overlay covers
|
|
1303
|
+
// a width epoch. Later covered resizes must keep resolving this unpaid seam
|
|
1304
|
+
// instead of adopting hidden growth as the next epoch's source boundary.
|
|
1305
|
+
#widthEpochOverlayBoundary: unknown;
|
|
1306
|
+
// Same-width snapshots physically appended after a width transition. The
|
|
1307
|
+
// ordinary committed prefix includes opaque old-width native rows and can
|
|
1308
|
+
// no longer be indexed against the reflowed frame; this local ledger lets
|
|
1309
|
+
// newly-final post-epoch rows retain the one-time strict audit contract.
|
|
1310
|
+
#widthEpochCommittedPrefix?: {
|
|
1311
|
+
nativeBaseRows: number;
|
|
1312
|
+
frameRows: number[];
|
|
1313
|
+
prefix: string[];
|
|
1314
|
+
auditRows: number;
|
|
1315
|
+
};
|
|
1316
|
+
// Logical source boundary captured from the last emitted frame at the first
|
|
1317
|
+
// SIGWINCH in a multiplexer resize burst. Unlike physical row counts, the
|
|
1318
|
+
// opaque marker survives width reflow and resolves after the settled render.
|
|
1319
|
+
#multiplexerWidthEpochBoundary: unknown;
|
|
1320
|
+
#multiplexerWidthEpochPending = false;
|
|
1321
|
+
// Normal-buffer boundary borrowed by a fullscreen alt overlay. If the host
|
|
1322
|
+
// resizes while the transcript is hidden, this remains the physical seam
|
|
1323
|
+
// from before the overlay instead of adopting hidden growth on exit.
|
|
1324
|
+
#altWidthEpochBoundary: unknown;
|
|
1325
|
+
|
|
1041
1326
|
// Frame row currently mapped to screen row 0. Monotonic between full
|
|
1042
1327
|
// paints: a shrink never re-exposes scrolled-off rows (they cannot be
|
|
1043
1328
|
// un-scrolled without rewriting history); live rows repaint at fixed
|
|
@@ -1080,6 +1365,7 @@ export class TUI extends Container {
|
|
|
1080
1365
|
// flag below so the settled paint still honours every caller's request.
|
|
1081
1366
|
#multiplexerResizeTimer: RenderTimer | undefined;
|
|
1082
1367
|
#deferredForcedClearScrollback = false;
|
|
1368
|
+
#multiplexerResizeHasPendingRender = false;
|
|
1083
1369
|
// True from the first SIGWINCH of a non-multiplexer drag until the settle
|
|
1084
1370
|
// timer fires. While set, every `#doRender` short-circuits to the viewport
|
|
1085
1371
|
// fast path (`#renderResizeViewport`) instead of an authoritative full
|
|
@@ -1133,6 +1419,18 @@ export class TUI extends Container {
|
|
|
1133
1419
|
// Per-root-child segment ledger backing the stable-prefix computation.
|
|
1134
1420
|
#frameSegments: FrameSegment[] = [];
|
|
1135
1421
|
#composeWidth = -1;
|
|
1422
|
+
#rootWidthEpochBoundaries = new WeakMap<
|
|
1423
|
+
object,
|
|
1424
|
+
{
|
|
1425
|
+
component: Component;
|
|
1426
|
+
childBoundary: unknown;
|
|
1427
|
+
sourceIndex: number;
|
|
1428
|
+
leading: ReadonlyArray<{ component: Component; revision: number | undefined; rowCount: number }>;
|
|
1429
|
+
trailing: ReadonlyArray<{ component: Component; revision: number | undefined; rowCount: number }>;
|
|
1430
|
+
hasTrailingRows: boolean;
|
|
1431
|
+
}
|
|
1432
|
+
>();
|
|
1433
|
+
|
|
1136
1434
|
// Cursor markers stripped at ingestion, ascending by frame row.
|
|
1137
1435
|
#frameCursorMarkers: { row: number; col: number }[] = [];
|
|
1138
1436
|
// Leading rows of #composedFrame byte-identical to the previous compose.
|
|
@@ -1182,6 +1480,147 @@ export class TUI extends Container {
|
|
|
1182
1480
|
this.#watchdog = new LoopWatchdog();
|
|
1183
1481
|
}
|
|
1184
1482
|
|
|
1483
|
+
override captureNativeScrollbackWidthEpoch(): unknown {
|
|
1484
|
+
const liveSource = this.#frameSegments.findIndex(segment => segment.liveLocalStart !== undefined);
|
|
1485
|
+
const indices = Array.from({ length: this.#frameSegments.length }, (_value, index) => index)
|
|
1486
|
+
.reverse()
|
|
1487
|
+
.filter(index => index !== liveSource);
|
|
1488
|
+
if (liveSource >= 0) indices.unshift(liveSource);
|
|
1489
|
+
for (const index of indices) {
|
|
1490
|
+
const segment = this.#frameSegments[index]!;
|
|
1491
|
+
const source = getNativeScrollbackWidthEpoch(segment.component);
|
|
1492
|
+
const childBoundary = source?.captureNativeScrollbackWidthEpoch();
|
|
1493
|
+
if (childBoundary === undefined) continue;
|
|
1494
|
+
const marker = {};
|
|
1495
|
+
this.#rootWidthEpochBoundaries.set(marker, {
|
|
1496
|
+
component: segment.component,
|
|
1497
|
+
childBoundary,
|
|
1498
|
+
sourceIndex: index,
|
|
1499
|
+
leading: this.#frameSegments.slice(0, index).map(candidate => ({
|
|
1500
|
+
component: candidate.component,
|
|
1501
|
+
revision: candidate.widthEpochRevision,
|
|
1502
|
+
rowCount: candidate.rowCount,
|
|
1503
|
+
})),
|
|
1504
|
+
trailing: this.#frameSegments.slice(index + 1).map(candidate => ({
|
|
1505
|
+
component: candidate.component,
|
|
1506
|
+
revision: candidate.widthEpochRevision,
|
|
1507
|
+
rowCount: candidate.rowCount,
|
|
1508
|
+
})),
|
|
1509
|
+
hasTrailingRows: this.#frameSegments.slice(index + 1).some(candidate => candidate.rowCount > 0),
|
|
1510
|
+
});
|
|
1511
|
+
return marker;
|
|
1512
|
+
}
|
|
1513
|
+
return undefined;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
override resolveNativeScrollbackWidthEpoch(boundary: unknown): number | undefined {
|
|
1517
|
+
if (typeof boundary !== "object" || boundary === null) return undefined;
|
|
1518
|
+
const marker = this.#rootWidthEpochBoundaries.get(boundary);
|
|
1519
|
+
if (!marker) return undefined;
|
|
1520
|
+
const segment = this.#frameSegments[marker.sourceIndex];
|
|
1521
|
+
if (segment?.component !== marker.component) return undefined;
|
|
1522
|
+
for (let index = 0; index < marker.leading.length; index++) {
|
|
1523
|
+
const captured = marker.leading[index]!;
|
|
1524
|
+
const current = this.#frameSegments[index];
|
|
1525
|
+
if (
|
|
1526
|
+
current?.component !== captured.component ||
|
|
1527
|
+
(captured.revision === undefined
|
|
1528
|
+
? current.rowCount !== captured.rowCount
|
|
1529
|
+
: current.widthEpochRevision !== captured.revision)
|
|
1530
|
+
) {
|
|
1531
|
+
return undefined;
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
const childRows = getNativeScrollbackWidthEpoch(marker.component)?.resolveNativeScrollbackWidthEpoch(
|
|
1535
|
+
marker.childBoundary,
|
|
1536
|
+
);
|
|
1537
|
+
if (childRows === undefined) return undefined;
|
|
1538
|
+
let rows = segment.start + childRows;
|
|
1539
|
+
for (let trailingIndex = 0; trailingIndex < marker.trailing.length; trailingIndex++) {
|
|
1540
|
+
const captured = marker.trailing[trailingIndex]!;
|
|
1541
|
+
const candidate = this.#frameSegments[marker.sourceIndex + 1 + trailingIndex];
|
|
1542
|
+
// Changed/removed tails are not individually cross-width comparable.
|
|
1543
|
+
// Preserve the shared physical row count of the remaining tail as one
|
|
1544
|
+
// span; only aggregate height growth belongs to the current suffix.
|
|
1545
|
+
if (
|
|
1546
|
+
candidate?.component !== captured.component ||
|
|
1547
|
+
(captured.revision === undefined
|
|
1548
|
+
? candidate.rowCount !== captured.rowCount
|
|
1549
|
+
: candidate.widthEpochRevision !== captured.revision)
|
|
1550
|
+
) {
|
|
1551
|
+
let capturedRows = 0;
|
|
1552
|
+
for (let index = trailingIndex; index < marker.trailing.length; index++) {
|
|
1553
|
+
capturedRows += marker.trailing[index]!.rowCount;
|
|
1554
|
+
}
|
|
1555
|
+
let settledRows = 0;
|
|
1556
|
+
for (let index = marker.sourceIndex + 1 + trailingIndex; index < this.#frameSegments.length; index++) {
|
|
1557
|
+
settledRows += this.#frameSegments[index]!.rowCount;
|
|
1558
|
+
}
|
|
1559
|
+
rows += Math.min(capturedRows, settledRows);
|
|
1560
|
+
break;
|
|
1561
|
+
}
|
|
1562
|
+
rows += candidate.rowCount;
|
|
1563
|
+
}
|
|
1564
|
+
return rows;
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
#getNativeScrollbackWidthEpochCurrentRows(boundary: unknown): number | undefined {
|
|
1568
|
+
if (typeof boundary !== "object" || boundary === null) return undefined;
|
|
1569
|
+
const marker = this.#rootWidthEpochBoundaries.get(boundary);
|
|
1570
|
+
if (!marker) return undefined;
|
|
1571
|
+
const index = marker.sourceIndex;
|
|
1572
|
+
if (this.#frameSegments[index]?.component !== marker.component) return undefined;
|
|
1573
|
+
const sourceRows = getNativeScrollbackWidthEpoch(marker.component)?.getNativeScrollbackWidthEpochRows();
|
|
1574
|
+
if (sourceRows === undefined) return undefined;
|
|
1575
|
+
let rows = this.#frameSegments[index]!.start + sourceRows;
|
|
1576
|
+
for (let trailing = index + 1; trailing < this.#frameSegments.length; trailing++) {
|
|
1577
|
+
rows += this.#frameSegments[trailing]!.rowCount;
|
|
1578
|
+
}
|
|
1579
|
+
return rows;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
#isNativeScrollbackWidthEpochAppendOnly(boundary: unknown): boolean {
|
|
1583
|
+
if (typeof boundary !== "object" || boundary === null) return true;
|
|
1584
|
+
const marker = this.#rootWidthEpochBoundaries.get(boundary);
|
|
1585
|
+
if (!marker) return true;
|
|
1586
|
+
const source = getNativeScrollbackWidthEpoch(marker.component);
|
|
1587
|
+
if (source?.isNativeScrollbackWidthEpochAppendOnly?.(marker.childBoundary) === false) return false;
|
|
1588
|
+
if (!marker.hasTrailingRows) return true;
|
|
1589
|
+
for (let trailingIndex = 0; trailingIndex < marker.trailing.length; trailingIndex++) {
|
|
1590
|
+
const captured = marker.trailing[trailingIndex]!;
|
|
1591
|
+
const current = this.#frameSegments[marker.sourceIndex + 1 + trailingIndex];
|
|
1592
|
+
const changed =
|
|
1593
|
+
current?.component !== captured.component ||
|
|
1594
|
+
(captured.revision === undefined
|
|
1595
|
+
? current.rowCount !== captured.rowCount
|
|
1596
|
+
: current.widthEpochRevision !== captured.revision);
|
|
1597
|
+
if (
|
|
1598
|
+
changed &&
|
|
1599
|
+
(captured.rowCount > 0 || marker.trailing.slice(trailingIndex + 1).some(segment => segment.rowCount > 0))
|
|
1600
|
+
) {
|
|
1601
|
+
return false;
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
const previousRows = source?.resolveNativeScrollbackWidthEpoch(marker.childBoundary);
|
|
1605
|
+
const currentRows = source?.getNativeScrollbackWidthEpochRows();
|
|
1606
|
+
return previousRows === undefined || currentRows === undefined || currentRows <= previousRows;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
override getNativeScrollbackWidthEpochRows(): number | undefined {
|
|
1610
|
+
for (let index = this.#frameSegments.length - 1; index >= 0; index--) {
|
|
1611
|
+
const segment = this.#frameSegments[index]!;
|
|
1612
|
+
const rows = getNativeScrollbackWidthEpoch(segment.component)?.getNativeScrollbackWidthEpochRows();
|
|
1613
|
+
if (rows !== undefined) {
|
|
1614
|
+
let boundary = segment.start + rows;
|
|
1615
|
+
for (let trailing = index + 1; trailing < this.#frameSegments.length; trailing++) {
|
|
1616
|
+
boundary += this.#frameSegments[trailing]!.rowCount;
|
|
1617
|
+
}
|
|
1618
|
+
return boundary;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
return undefined;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1185
1624
|
override render(width: number): readonly string[] {
|
|
1186
1625
|
width = Math.max(1, width);
|
|
1187
1626
|
this.#nativeScrollbackLiveRegionStart = undefined;
|
|
@@ -1189,6 +1628,14 @@ export class TUI extends Container {
|
|
|
1189
1628
|
const children = this.children;
|
|
1190
1629
|
const previousSegments = this.#frameSegments;
|
|
1191
1630
|
const segments: FrameSegment[] = new Array(children.length);
|
|
1631
|
+
// The transition frame cannot map the old-width native commit count into
|
|
1632
|
+
// current-width component rows. Once the epoch baseline exists,
|
|
1633
|
+
// #windowTopRow is the current-width commit seam while #committedRows
|
|
1634
|
+
// remains the opaque native ledger.
|
|
1635
|
+
const committedCoordinatesOpaque =
|
|
1636
|
+
this.#composeWidth > 0 && this.#composeWidth !== width && this.#resizeRepaintsInPlace();
|
|
1637
|
+
const componentCommittedRows =
|
|
1638
|
+
this.#widthEpochBaselineRows === undefined ? this.#committedRows : this.#windowTopRow;
|
|
1192
1639
|
// A width change re-renders every child; nothing carries over.
|
|
1193
1640
|
let chainStable = this.#composeWidth === width;
|
|
1194
1641
|
this.#composeWidth = width;
|
|
@@ -1207,11 +1654,13 @@ export class TUI extends Container {
|
|
|
1207
1654
|
let childLines: readonly string[];
|
|
1208
1655
|
let liveLocalStart: number | undefined;
|
|
1209
1656
|
let liveRegionPinned = false;
|
|
1657
|
+
let widthEpochRevision: number | undefined;
|
|
1210
1658
|
let reported: number | undefined;
|
|
1211
1659
|
if (reuse) {
|
|
1212
1660
|
childLines = previous.lines;
|
|
1213
1661
|
liveLocalStart = previous.liveLocalStart;
|
|
1214
1662
|
liveRegionPinned = previous.liveRegionPinned;
|
|
1663
|
+
widthEpochRevision = previous.widthEpochRevision;
|
|
1215
1664
|
} else {
|
|
1216
1665
|
// Feed the engine's committed-row claim (from the previous frame's
|
|
1217
1666
|
// emit) before rendering so the child can skip re-deriving blocks
|
|
@@ -1223,8 +1672,14 @@ export class TUI extends Container {
|
|
|
1223
1672
|
// own future rows being pre-committed.
|
|
1224
1673
|
const prevRows = previous !== undefined && previous.component === child ? previous.rowCount : 0;
|
|
1225
1674
|
const prevStart = previous !== undefined && previous.component === child ? previous.start : offset;
|
|
1226
|
-
|
|
1675
|
+
if (!committedCoordinatesOpaque) {
|
|
1676
|
+
setNativeScrollbackCommittedRows(
|
|
1677
|
+
child,
|
|
1678
|
+
Math.min(prevRows, Math.max(0, componentCommittedRows - prevStart)),
|
|
1679
|
+
);
|
|
1680
|
+
}
|
|
1227
1681
|
childLines = child.render(width);
|
|
1682
|
+
widthEpochRevision = getNativeScrollbackWidthEpochRevision(child);
|
|
1228
1683
|
const liveRegionStart = getNativeScrollbackLiveRegionStart(child);
|
|
1229
1684
|
if (liveRegionStart !== undefined) {
|
|
1230
1685
|
liveLocalStart = Number.isFinite(liveRegionStart)
|
|
@@ -1280,6 +1735,7 @@ export class TUI extends Container {
|
|
|
1280
1735
|
lines: childLines,
|
|
1281
1736
|
start: offset,
|
|
1282
1737
|
rowCount: childLines.length,
|
|
1738
|
+
widthEpochRevision,
|
|
1283
1739
|
liveLocalStart,
|
|
1284
1740
|
liveRegionPinned,
|
|
1285
1741
|
};
|
|
@@ -1618,6 +2074,12 @@ export class TUI extends Container {
|
|
|
1618
2074
|
if (this.#altEnterWidth === this.terminal.columns && this.#altEnterHeight !== this.terminal.rows) {
|
|
1619
2075
|
this.#altToggleResizesInPlace = true;
|
|
1620
2076
|
}
|
|
2077
|
+
if (this.#previousWidth > 0 && this.terminal.columns !== this.#previousWidth) {
|
|
2078
|
+
this.#multiplexerWidthEpochPending = true;
|
|
2079
|
+
if (this.#multiplexerWidthEpochBoundary === undefined) {
|
|
2080
|
+
this.#multiplexerWidthEpochBoundary = this.#altWidthEpochBoundary;
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
1621
2083
|
this.#resizeEventPending = true;
|
|
1622
2084
|
this.requestRender();
|
|
1623
2085
|
return;
|
|
@@ -1631,7 +2093,18 @@ export class TUI extends Container {
|
|
|
1631
2093
|
this.#requestResizeViewportPaint();
|
|
1632
2094
|
return;
|
|
1633
2095
|
}
|
|
1634
|
-
this.#
|
|
2096
|
+
if (this.#previousWidth > 0 && this.terminal.columns !== this.#previousWidth) {
|
|
2097
|
+
this.#multiplexerWidthEpochPending = true;
|
|
2098
|
+
if (this.#multiplexerWidthEpochBoundary === undefined) {
|
|
2099
|
+
this.#multiplexerWidthEpochBoundary = this.captureNativeScrollbackWidthEpoch();
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
this.#armMultiplexerResizeTimer({
|
|
2103
|
+
clearScrollback: false,
|
|
2104
|
+
hasPendingRender:
|
|
2105
|
+
this.#multiplexerResizeTimer === undefined &&
|
|
2106
|
+
(this.#renderRequested || this.#renderTimer !== undefined),
|
|
2107
|
+
});
|
|
1635
2108
|
},
|
|
1636
2109
|
() => this.stop(),
|
|
1637
2110
|
);
|
|
@@ -1912,7 +2385,7 @@ export class TUI extends Container {
|
|
|
1912
2385
|
// the same `#prepareForcedRender(!isMultiplexerSession())` path via
|
|
1913
2386
|
// `requestRender(true)`, so the clear-scrollback intent is preserved.
|
|
1914
2387
|
if (this.#multiplexerResizeTimer) {
|
|
1915
|
-
this.#armMultiplexerResizeTimer(!isMultiplexerSession());
|
|
2388
|
+
this.#armMultiplexerResizeTimer({ clearScrollback: !isMultiplexerSession(), hasPendingRender: true });
|
|
1916
2389
|
return;
|
|
1917
2390
|
}
|
|
1918
2391
|
this.#prepareForcedRender(!isMultiplexerSession());
|
|
@@ -1935,7 +2408,10 @@ export class TUI extends Container {
|
|
|
1935
2408
|
// so this guard only catches external callers — the deferred render
|
|
1936
2409
|
// itself proceeds straight to `#prepareForcedRender`.
|
|
1937
2410
|
if (this.#multiplexerResizeTimer) {
|
|
1938
|
-
this.#armMultiplexerResizeTimer(
|
|
2411
|
+
this.#armMultiplexerResizeTimer({
|
|
2412
|
+
clearScrollback: options?.clearScrollback === true,
|
|
2413
|
+
hasPendingRender: true,
|
|
2414
|
+
});
|
|
1939
2415
|
return;
|
|
1940
2416
|
}
|
|
1941
2417
|
// A forced render preempts the post-full-paint ConPTY settle: it owns
|
|
@@ -2133,7 +2609,14 @@ export class TUI extends Container {
|
|
|
2133
2609
|
buffer += "\r";
|
|
2134
2610
|
for (let i = firstChanged; i <= lastChanged; i++) {
|
|
2135
2611
|
if (i > firstChanged) buffer += "\r\n";
|
|
2136
|
-
buffer += this.#lineRewriteSequence(
|
|
2612
|
+
buffer += this.#lineRewriteSequence(
|
|
2613
|
+
this.#preparedFrame[segment.start + i] ?? "",
|
|
2614
|
+
width,
|
|
2615
|
+
screenStart + i,
|
|
2616
|
+
segment.start + i,
|
|
2617
|
+
this.#committedRows,
|
|
2618
|
+
this.#osc66SpacerGlyphWidth(this.#preparedFrame, segment.start + i),
|
|
2619
|
+
);
|
|
2137
2620
|
}
|
|
2138
2621
|
const cursorControl = this.#cursorControlSequence(
|
|
2139
2622
|
cursorPos,
|
|
@@ -2158,6 +2641,10 @@ export class TUI extends Container {
|
|
|
2158
2641
|
|
|
2159
2642
|
/** Ordinary (non-forced) scheduling shared by full and component-scoped requests. */
|
|
2160
2643
|
#requestOrdinaryRender(): void {
|
|
2644
|
+
if (this.#multiplexerResizeTimer) {
|
|
2645
|
+
this.#multiplexerResizeHasPendingRender = true;
|
|
2646
|
+
return;
|
|
2647
|
+
}
|
|
2161
2648
|
// Coalesce non-forced renders inside the post-full-paint ConPTY settle
|
|
2162
2649
|
// window into one trailing render. Spinner/blink/streaming components
|
|
2163
2650
|
// otherwise fire `requestRender(false)` at 30 Hz while the host is still
|
|
@@ -2241,8 +2728,9 @@ export class TUI extends Container {
|
|
|
2241
2728
|
* intent into `#deferredForcedClearScrollback` — the timer's callback
|
|
2242
2729
|
* consumes that flag exactly once when it re-enters `requestRender(true)`.
|
|
2243
2730
|
*/
|
|
2244
|
-
#armMultiplexerResizeTimer(clearScrollback: boolean): void {
|
|
2245
|
-
this.#deferredForcedClearScrollback ||= clearScrollback;
|
|
2731
|
+
#armMultiplexerResizeTimer(options: { clearScrollback: boolean; hasPendingRender?: boolean }): void {
|
|
2732
|
+
this.#deferredForcedClearScrollback ||= options.clearScrollback;
|
|
2733
|
+
this.#multiplexerResizeHasPendingRender ||= options.hasPendingRender === true;
|
|
2246
2734
|
if (this.#renderTimer) {
|
|
2247
2735
|
this.#renderTimer.cancel();
|
|
2248
2736
|
this.#renderTimer = undefined;
|
|
@@ -2796,8 +3284,39 @@ export class TUI extends Container {
|
|
|
2796
3284
|
};
|
|
2797
3285
|
}
|
|
2798
3286
|
|
|
2799
|
-
|
|
2800
|
-
|
|
3287
|
+
/**
|
|
3288
|
+
* Rewrite a Kitty direct-placement line for the viewport row it is written
|
|
3289
|
+
* at, clipping to the visible slice (see {@link encodeKittyPlacementLine})
|
|
3290
|
+
* under the placement id resolved by the budget's epoch tracking (see
|
|
3291
|
+
* {@link ImageBudget.resolvePlacementEmit}). `screenRow` -1 (write position
|
|
3292
|
+
* unknown) and non-placement image lines (placeholder grids, sixel, iTerm2,
|
|
3293
|
+
* tmux-wrapped) pass through verbatim.
|
|
3294
|
+
*/
|
|
3295
|
+
#imageLineSequence(line: string, screenRow: number, frameRow: number, committedTo: number): string {
|
|
3296
|
+
if (screenRow < 0) return line;
|
|
3297
|
+
const parsed = parseKittyDirectPlacementLine(line);
|
|
3298
|
+
if (!parsed) return line;
|
|
3299
|
+
// The emitted placement attaches from the block's first *visible* row
|
|
3300
|
+
// (the clip drops the rows above the viewport), so epoch tracking keys
|
|
3301
|
+
// on that row — not the block origin, which may be long committed.
|
|
3302
|
+
const placement = this.#imageBudget.resolvePlacementEmit(
|
|
3303
|
+
parsed.imageId,
|
|
3304
|
+
frameRow >= 0 ? frameRow - Math.min(parsed.rows - 1, screenRow) : -1,
|
|
3305
|
+
committedTo,
|
|
3306
|
+
);
|
|
3307
|
+
if (!placement) return line;
|
|
3308
|
+
return encodeKittyPlacementLine({
|
|
3309
|
+
imageId: parsed.imageId,
|
|
3310
|
+
placementId: placement.placementId,
|
|
3311
|
+
columns: parsed.columns,
|
|
3312
|
+
rows: parsed.rows,
|
|
3313
|
+
screenRow,
|
|
3314
|
+
imageHeightPx: placement.heightPx,
|
|
3315
|
+
});
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
#terminalLine(line: string, screenRow = -1, frameRow = -1, committedTo = -1): string {
|
|
3319
|
+
if (TERMINAL.isImageLine(line)) return this.#imageLineSequence(line, screenRow, frameRow, committedTo);
|
|
2801
3320
|
const coalesced = coalesceAdjacentSgr(line);
|
|
2802
3321
|
return coalesced + (line.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
|
|
2803
3322
|
}
|
|
@@ -2844,6 +3363,7 @@ export class TUI extends Container {
|
|
|
2844
3363
|
this.#altPreviousLines = [];
|
|
2845
3364
|
this.#altEnterWidth = width;
|
|
2846
3365
|
this.#altEnterHeight = height;
|
|
3366
|
+
this.#altWidthEpochBoundary = this.captureNativeScrollbackWidthEpoch();
|
|
2847
3367
|
} else if (!wantAlt && this.#altActive) {
|
|
2848
3368
|
const mouseExit = this.#altMouseTrackingActive ? MOUSE_TRACKING_OFF : "";
|
|
2849
3369
|
const enhancementExit = this.#keyboardEnhancementExit();
|
|
@@ -2861,6 +3381,7 @@ export class TUI extends Container {
|
|
|
2861
3381
|
this.#altActive = false;
|
|
2862
3382
|
this.#altMouseTrackingActive = false;
|
|
2863
3383
|
this.#altPreviousLines = [];
|
|
3384
|
+
this.#altWidthEpochBoundary = undefined;
|
|
2864
3385
|
// A resize while on the alt buffer reflowed the terminal's saved
|
|
2865
3386
|
// normal screen; it no longer matches our accounting, so force the
|
|
2866
3387
|
// geometry rebuild path instead of a stale diff. A pure height change
|
|
@@ -2978,12 +3499,30 @@ export class TUI extends Container {
|
|
|
2978
3499
|
const finalBoundary = Math.max(0, Math.min(frameLength, liveRegionStart ?? frameLength));
|
|
2979
3500
|
|
|
2980
3501
|
// 2. Transition state captured before any emitter runs.
|
|
2981
|
-
|
|
3502
|
+
let prevWindowTop = this.#windowTopRow;
|
|
2982
3503
|
const prevHardwareCursorRow = this.#hardwareCursorRow;
|
|
2983
3504
|
const resizeEventOccurred = this.#resizeEventPending;
|
|
2984
3505
|
this.#resizeEventPending = false;
|
|
3506
|
+
const resizeHadPendingRender = this.#multiplexerResizeHasPendingRender;
|
|
3507
|
+
this.#multiplexerResizeHasPendingRender = false;
|
|
2985
3508
|
if (resizeEventOccurred) this.#forgetHardwareCursorState();
|
|
2986
3509
|
const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
|
|
3510
|
+
const widthEpochOccurred = widthChanged || (resizeEventOccurred && this.#multiplexerWidthEpochPending);
|
|
3511
|
+
const capturedWidthEpochBoundary = this.#multiplexerWidthEpochBoundary;
|
|
3512
|
+
const widthEpochBoundary = this.#widthEpochOverlayBoundary ?? capturedWidthEpochBoundary;
|
|
3513
|
+
const widthEpochSourceBoundary = widthEpochOccurred
|
|
3514
|
+
? this.resolveNativeScrollbackWidthEpoch(widthEpochBoundary)
|
|
3515
|
+
: undefined;
|
|
3516
|
+
const widthEpochCurrentRows = widthEpochOccurred
|
|
3517
|
+
? this.#getNativeScrollbackWidthEpochCurrentRows(widthEpochBoundary)
|
|
3518
|
+
: undefined;
|
|
3519
|
+
const widthEpochAppendOnly = widthEpochOccurred
|
|
3520
|
+
? this.#isNativeScrollbackWidthEpochAppendOnly(widthEpochBoundary)
|
|
3521
|
+
: true;
|
|
3522
|
+
if (resizeEventOccurred) {
|
|
3523
|
+
this.#multiplexerWidthEpochBoundary = undefined;
|
|
3524
|
+
this.#multiplexerWidthEpochPending = false;
|
|
3525
|
+
}
|
|
2987
3526
|
// A resize event with net-unchanged dimensions still reflowed the
|
|
2988
3527
|
// terminal buffer; classify it as a height change so geometry handling
|
|
2989
3528
|
// repaints instead of diffing against a screen that no longer exists.
|
|
@@ -2991,6 +3530,12 @@ export class TUI extends Container {
|
|
|
2991
3530
|
(this.#previousHeight > 0 && this.#previousHeight !== height) ||
|
|
2992
3531
|
(resizeEventOccurred && this.#previousHeight > 0);
|
|
2993
3532
|
const geometryChanged = widthChanged || heightChanged;
|
|
3533
|
+
const widthEpochReset = widthEpochOccurred && this.#resizeRepaintsInPlace();
|
|
3534
|
+
// A later width reset cannot use the opaque native ledger against
|
|
3535
|
+
// attachment rows from the current-width placement epoch. Capture that
|
|
3536
|
+
// epoch's seam before reset classification replaces its baseline.
|
|
3537
|
+
const placementEpochWatermark = this.#widthEpochBaselineRows === undefined ? this.#committedRows : prevWindowTop;
|
|
3538
|
+
if (widthEpochReset) this.#widthEpochCommittedPrefix = undefined;
|
|
2994
3539
|
|
|
2995
3540
|
// Committed-prefix audit. Rows below the audit mark are hard-verified
|
|
2996
3541
|
// exact bytes; rows between the mark and the current exactness boundary
|
|
@@ -3005,6 +3550,56 @@ export class TUI extends Container {
|
|
|
3005
3550
|
// every row), and skipped when the composed frame's stable prefix
|
|
3006
3551
|
// covers every verified row and no rows newly became final.
|
|
3007
3552
|
let committedRowsResynced = false;
|
|
3553
|
+
const widthEpochPrefix = this.#widthEpochCommittedPrefix;
|
|
3554
|
+
if (widthEpochPrefix && !geometryChanged && !this.#clearScrollbackOnNextRender) {
|
|
3555
|
+
let newlyFinalRows = 0;
|
|
3556
|
+
while (
|
|
3557
|
+
newlyFinalRows < widthEpochPrefix.frameRows.length &&
|
|
3558
|
+
widthEpochPrefix.frameRows[newlyFinalRows]! < finalBoundary
|
|
3559
|
+
) {
|
|
3560
|
+
newlyFinalRows++;
|
|
3561
|
+
}
|
|
3562
|
+
widthEpochPrefix.auditRows = Math.min(widthEpochPrefix.auditRows, newlyFinalRows);
|
|
3563
|
+
const verifiedTailRow = widthEpochPrefix.frameRows[widthEpochPrefix.auditRows - 1];
|
|
3564
|
+
const shouldAudit =
|
|
3565
|
+
newlyFinalRows > widthEpochPrefix.auditRows ||
|
|
3566
|
+
(verifiedTailRow !== undefined && this.#renderStablePrefixRows <= verifiedTailRow);
|
|
3567
|
+
let resyncTo = -1;
|
|
3568
|
+
const firstMissing = widthEpochPrefix.frameRows.findIndex(row => row >= frameLength);
|
|
3569
|
+
if (firstMissing >= 0) {
|
|
3570
|
+
const surviving = widthEpochPrefix.frameRows.slice(0, firstMissing).map(row => rawFrame[row]!);
|
|
3571
|
+
for (let i = 0; i < surviving.length; i++) {
|
|
3572
|
+
if (!rowsEquivalent(surviving[i]!, widthEpochPrefix.prefix[i]!)) {
|
|
3573
|
+
resyncTo = i;
|
|
3574
|
+
break;
|
|
3575
|
+
}
|
|
3576
|
+
}
|
|
3577
|
+
if (resyncTo < 0) resyncTo = firstMissing;
|
|
3578
|
+
} else if (shouldAudit) {
|
|
3579
|
+
const current = widthEpochPrefix.frameRows.map(row => rawFrame[row]!);
|
|
3580
|
+
resyncTo = findCommittedPrefixResync(
|
|
3581
|
+
current,
|
|
3582
|
+
widthEpochPrefix.prefix,
|
|
3583
|
+
widthEpochPrefix.auditRows,
|
|
3584
|
+
newlyFinalRows,
|
|
3585
|
+
);
|
|
3586
|
+
if (resyncTo < 0) widthEpochPrefix.auditRows = newlyFinalRows;
|
|
3587
|
+
}
|
|
3588
|
+
if (resyncTo >= 0) {
|
|
3589
|
+
const recoveryRow = Math.min(frameLength, widthEpochPrefix.frameRows[resyncTo] ?? frameLength);
|
|
3590
|
+
widthEpochPrefix.frameRows.length = resyncTo;
|
|
3591
|
+
widthEpochPrefix.prefix.length = resyncTo;
|
|
3592
|
+
widthEpochPrefix.auditRows = Math.min(widthEpochPrefix.auditRows, resyncTo);
|
|
3593
|
+
this.#committedRows = widthEpochPrefix.nativeBaseRows + resyncTo;
|
|
3594
|
+
this.#widthEpochBaselineRows = recoveryRow;
|
|
3595
|
+
this.#windowTopRow = recoveryRow;
|
|
3596
|
+
prevWindowTop = recoveryRow;
|
|
3597
|
+
if ($flag("PI_DEBUG_REDRAW")) {
|
|
3598
|
+
const msg = `[${new Date().toISOString()}] width epoch commit resync: local prefix diverged at row ${recoveryRow}; recommitting\n`;
|
|
3599
|
+
fs.appendFileSync(getDebugLogPath(), msg);
|
|
3600
|
+
}
|
|
3601
|
+
}
|
|
3602
|
+
}
|
|
3008
3603
|
const newlyFinalEnd = Math.min(this.#committedRows, finalBoundary);
|
|
3009
3604
|
// The exactness boundary can RETREAT (a markdown rewind, a mermaid fence
|
|
3010
3605
|
// appearing, a fast-path reset re-opening a block): rows verified under
|
|
@@ -3012,12 +3607,13 @@ export class TUI extends Container {
|
|
|
3012
3607
|
// snapshots instead of auditing content that is expected to change —
|
|
3013
3608
|
// their committed bytes stay as the visual record, and the next boundary
|
|
3014
3609
|
// rise strict-verifies them once like any other frozen row.
|
|
3015
|
-
if (this.#committedPrefixAuditRows > newlyFinalEnd) {
|
|
3610
|
+
if (this.#widthEpochBaselineRows === undefined && this.#committedPrefixAuditRows > newlyFinalEnd) {
|
|
3016
3611
|
this.#committedPrefixAuditRows = newlyFinalEnd;
|
|
3017
3612
|
}
|
|
3018
3613
|
const auditRan =
|
|
3019
3614
|
this.#hasEverRendered &&
|
|
3020
3615
|
!geometryChanged &&
|
|
3616
|
+
this.#widthEpochBaselineRows === undefined &&
|
|
3021
3617
|
!this.#clearScrollbackOnNextRender &&
|
|
3022
3618
|
(this.#renderStablePrefixRows < this.#committedPrefixAuditRows ||
|
|
3023
3619
|
newlyFinalEnd > this.#committedPrefixAuditRows);
|
|
@@ -3033,7 +3629,12 @@ export class TUI extends Container {
|
|
|
3033
3629
|
// record and the frame part ways — so the surviving exact prefix stays
|
|
3034
3630
|
// recognized and is never re-shown or re-committed. Only genuinely new
|
|
3035
3631
|
// content repaints below it.
|
|
3036
|
-
if (
|
|
3632
|
+
if (
|
|
3633
|
+
this.#widthEpochBaselineRows === undefined &&
|
|
3634
|
+
!geometryChanged &&
|
|
3635
|
+
!this.#clearScrollbackOnNextRender &&
|
|
3636
|
+
frameLength < this.#committedRows
|
|
3637
|
+
) {
|
|
3037
3638
|
const limit = Math.min(this.#committedRows, frameLength);
|
|
3038
3639
|
let diverged = limit;
|
|
3039
3640
|
for (let i = 0; i < limit; i++) {
|
|
@@ -3063,6 +3664,25 @@ export class TUI extends Container {
|
|
|
3063
3664
|
break;
|
|
3064
3665
|
}
|
|
3065
3666
|
}
|
|
3667
|
+
// Without a logical source boundary, pending growth folded into an
|
|
3668
|
+
// overlay-covered width reset cannot be separated from reflow. Replay
|
|
3669
|
+
// conservatively from row zero after the overlay closes: duplication is
|
|
3670
|
+
// preferable to dropping rows that were never emitted anywhere.
|
|
3671
|
+
if (widthEpochReset && hasVisibleOverlay && widthEpochSourceBoundary === undefined && resizeHadPendingRender) {
|
|
3672
|
+
this.#widthEpochOverlayReplayPending = true;
|
|
3673
|
+
}
|
|
3674
|
+
if (widthEpochReset && hasVisibleOverlay && this.#widthEpochOverlayBoundary === undefined) {
|
|
3675
|
+
this.#widthEpochOverlayBoundary = capturedWidthEpochBoundary;
|
|
3676
|
+
}
|
|
3677
|
+
const replayUnresolvedOverlayFrame = widthEpochReset && this.#widthEpochOverlayReplayPending;
|
|
3678
|
+
const replayUnresolvedWidthEpoch =
|
|
3679
|
+
replayUnresolvedOverlayFrame ||
|
|
3680
|
+
(widthEpochReset && liveRegionPinned && this.#widthEpochReplayUnresolved) ||
|
|
3681
|
+
(widthEpochReset &&
|
|
3682
|
+
resizeHadPendingRender &&
|
|
3683
|
+
widthEpochBoundary !== undefined &&
|
|
3684
|
+
widthEpochSourceBoundary === undefined);
|
|
3685
|
+
if (replayUnresolvedWidthEpoch) prevWindowTop = 0;
|
|
3066
3686
|
|
|
3067
3687
|
// 4. Classify. A resize is an explicit user gesture: normally the engine
|
|
3068
3688
|
// erases and replays so history rewraps at the new geometry (the reader
|
|
@@ -3090,10 +3710,44 @@ export class TUI extends Container {
|
|
|
3090
3710
|
const fullPaint = firstPaint || replaceRequested || geometryRebuild || divergenceRebuild;
|
|
3091
3711
|
let windowTop: number;
|
|
3092
3712
|
let chunkTo: number;
|
|
3713
|
+
let widthEpochAppendFrom = 0;
|
|
3714
|
+
let widthEpochAppendTo = 0;
|
|
3093
3715
|
if (fullPaint) {
|
|
3094
3716
|
committedPrefixResliced = true;
|
|
3095
3717
|
windowTop = Math.max(0, frameLength - height);
|
|
3096
3718
|
chunkTo = liveRegionPinned ? Math.min(windowTop, finalBoundary) : windowTop;
|
|
3719
|
+
} else if (widthEpochReset) {
|
|
3720
|
+
// A terminal width change ends the physical-row coordinate epoch.
|
|
3721
|
+
// Resolve the last emitted logical source boundary at the new width;
|
|
3722
|
+
// updates queued during debounce are the current-boundary suffix.
|
|
3723
|
+
// Components without the source contract retain the conservative
|
|
3724
|
+
// legacy fallback, but never compare cross-width counts when a marker
|
|
3725
|
+
// resolved successfully.
|
|
3726
|
+
this.#widthEpochBaselineRows = replayUnresolvedWidthEpoch
|
|
3727
|
+
? 0
|
|
3728
|
+
: (widthEpochSourceBoundary ??
|
|
3729
|
+
(resizeHadPendingRender ? Math.min(frameLength, this.#previousFrameLength) : frameLength));
|
|
3730
|
+
this.#widthEpochReplayUnresolved = replayUnresolvedWidthEpoch;
|
|
3731
|
+
windowTop = Math.max(0, frameLength - height);
|
|
3732
|
+
chunkTo = this.#committedRows;
|
|
3733
|
+
widthEpochAppendFrom = this.#widthEpochBaselineRows;
|
|
3734
|
+
widthEpochAppendTo =
|
|
3735
|
+
hasVisibleOverlay || widthEpochCurrentRows === undefined
|
|
3736
|
+
? hasVisibleOverlay
|
|
3737
|
+
? widthEpochAppendFrom
|
|
3738
|
+
: Math.max(widthEpochAppendFrom, liveRegionPinned ? finalBoundary : frameLength)
|
|
3739
|
+
: Math.max(widthEpochAppendFrom, widthEpochCurrentRows);
|
|
3740
|
+
} else if (this.#widthEpochBaselineRows !== undefined) {
|
|
3741
|
+
// Only rows physically appended after the width epoch may drive the
|
|
3742
|
+
// terminal forward. Keep the native commit count independent of this
|
|
3743
|
+
// frame-coordinate baseline. Overlays defer all emission; a pinned
|
|
3744
|
+
// live region clips advancement to its exact final boundary so mutable
|
|
3745
|
+
// rows remain viewport-only until finalization.
|
|
3746
|
+
windowTop = Math.max(0, frameLength - height);
|
|
3747
|
+
chunkTo = this.#committedRows;
|
|
3748
|
+
widthEpochAppendFrom = this.#widthEpochBaselineRows;
|
|
3749
|
+
const appendBoundary = liveRegionPinned ? finalBoundary : frameLength;
|
|
3750
|
+
widthEpochAppendTo = hasVisibleOverlay ? widthEpochAppendFrom : Math.max(widthEpochAppendFrom, appendBoundary);
|
|
3097
3751
|
} else if (
|
|
3098
3752
|
frameLength <= this.#committedRows ||
|
|
3099
3753
|
(committedRowsResynced &&
|
|
@@ -3208,6 +3862,18 @@ export class TUI extends Container {
|
|
|
3208
3862
|
} else {
|
|
3209
3863
|
this.#imageBudget.takePurgeIds();
|
|
3210
3864
|
}
|
|
3865
|
+
// Feed this frame's commit target to the placement-epoch tracker before
|
|
3866
|
+
// any placement resolves against it — an epoch whose rows commit during
|
|
3867
|
+
// frames that never rewrite its line must still advance on the next
|
|
3868
|
+
// re-emission. Width epochs retain an opaque native-row ledger, so close
|
|
3869
|
+
// the old placement-coordinate epoch with its captured seam on reset and
|
|
3870
|
+
// use the current-width commit seam calculated below thereafter.
|
|
3871
|
+
if (widthEpochReset) {
|
|
3872
|
+
this.#imageBudget.observeCommitWatermark(placementEpochWatermark);
|
|
3873
|
+
this.#imageBudget.beginPlacementCoordinateEpoch();
|
|
3874
|
+
} else if (intent.kind === "fullPaint" || this.#widthEpochBaselineRows === undefined) {
|
|
3875
|
+
this.#imageBudget.observeCommitWatermark(chunkTo);
|
|
3876
|
+
}
|
|
3211
3877
|
|
|
3212
3878
|
// 6. Emit.
|
|
3213
3879
|
if (intent.kind === "fullPaint") {
|
|
@@ -3218,16 +3884,145 @@ export class TUI extends Container {
|
|
|
3218
3884
|
cursorTrackingLineCount,
|
|
3219
3885
|
boundConptyPaint: !unboundedConptyPaint,
|
|
3220
3886
|
leadingSequence: deferredAltExit,
|
|
3887
|
+
copyScreenToScrollback: true,
|
|
3221
3888
|
});
|
|
3222
3889
|
this.#pendingAltExit = "";
|
|
3223
3890
|
this.#committedPrefix = rawFrame.slice(0, chunkTo);
|
|
3224
3891
|
this.#committedPrefixAuditRows = Math.min(chunkTo, finalBoundary);
|
|
3225
3892
|
this.#clearScrollbackOnNextRender = false;
|
|
3226
3893
|
this.#hasEverRendered = true;
|
|
3894
|
+
this.#widthEpochBaselineRows = undefined;
|
|
3895
|
+
this.#widthEpochReplayUnresolved = false;
|
|
3896
|
+
this.#widthEpochOverlayReplayPending = false;
|
|
3897
|
+
this.#widthEpochOverlayBoundary = undefined;
|
|
3898
|
+
this.#widthEpochCommittedPrefix = undefined;
|
|
3227
3899
|
this.#publishCommittedRows();
|
|
3228
3900
|
if (!firstPaint && frameLength > height) this.#armPostFullPaintSettle();
|
|
3229
3901
|
return;
|
|
3230
3902
|
}
|
|
3903
|
+
if (this.#widthEpochBaselineRows !== undefined) {
|
|
3904
|
+
const logicalAppend =
|
|
3905
|
+
!replayUnresolvedOverlayFrame &&
|
|
3906
|
+
widthEpochSourceBoundary !== undefined &&
|
|
3907
|
+
widthEpochCurrentRows !== undefined;
|
|
3908
|
+
const logicalPrefixAppend = logicalAppend && widthEpochAppendOnly;
|
|
3909
|
+
let scrollRows: number;
|
|
3910
|
+
let commitFrom: number;
|
|
3911
|
+
let commitTo: number;
|
|
3912
|
+
if (replayUnresolvedWidthEpoch) {
|
|
3913
|
+
commitFrom = 0;
|
|
3914
|
+
commitTo = liveRegionPinned ? Math.min(windowTop, finalBoundary) : windowTop;
|
|
3915
|
+
scrollRows = commitTo;
|
|
3916
|
+
} else if (logicalAppend && !logicalPrefixAppend) {
|
|
3917
|
+
const sourceWindowTop = Math.max(0, widthEpochSourceBoundary - height);
|
|
3918
|
+
const logicalSuffixRows = Math.max(0, widthEpochCurrentRows - widthEpochSourceBoundary);
|
|
3919
|
+
const appendWindowMovement = Math.max(0, windowTop - sourceWindowTop);
|
|
3920
|
+
scrollRows = Math.min(logicalSuffixRows, appendWindowMovement);
|
|
3921
|
+
commitFrom = Math.max(0, windowTop - scrollRows);
|
|
3922
|
+
commitTo = commitFrom + scrollRows;
|
|
3923
|
+
} else if (!logicalAppend) {
|
|
3924
|
+
const windowMovement = Math.max(0, windowTop - prevWindowTop);
|
|
3925
|
+
const previousViewportRows = Math.min(
|
|
3926
|
+
this.#previousHeight,
|
|
3927
|
+
Math.max(0, this.#previousFrameLength - prevWindowTop),
|
|
3928
|
+
);
|
|
3929
|
+
const hostHeightShrinkRows = Math.min(windowMovement, Math.max(0, previousViewportRows - height));
|
|
3930
|
+
const appendWindowMovement = windowMovement - hostHeightShrinkRows;
|
|
3931
|
+
const epochGrowthRows = Math.max(0, widthEpochAppendTo - widthEpochAppendFrom);
|
|
3932
|
+
scrollRows = Math.min(appendWindowMovement, epochGrowthRows);
|
|
3933
|
+
commitFrom = prevWindowTop + hostHeightShrinkRows;
|
|
3934
|
+
commitTo = commitFrom + scrollRows;
|
|
3935
|
+
} else {
|
|
3936
|
+
commitFrom = widthEpochSourceBoundary;
|
|
3937
|
+
const logicalSuffixRows = Math.max(0, widthEpochCurrentRows - commitFrom);
|
|
3938
|
+
const sourceWindowTop = Math.max(0, commitFrom - height);
|
|
3939
|
+
const appendWindowMovement = Math.max(0, windowTop - sourceWindowTop);
|
|
3940
|
+
scrollRows = Math.min(logicalSuffixRows, appendWindowMovement);
|
|
3941
|
+
commitTo = commitFrom + scrollRows;
|
|
3942
|
+
}
|
|
3943
|
+
if (hasVisibleOverlay) {
|
|
3944
|
+
scrollRows = 0;
|
|
3945
|
+
commitTo = commitFrom;
|
|
3946
|
+
}
|
|
3947
|
+
this.#imageBudget.observeCommitWatermark(commitTo);
|
|
3948
|
+
this.#emitWidthEpochBaseline(frame, window, width, height, cursorPos, purgeSequence, imageTransmitBuffer, {
|
|
3949
|
+
repaintFromScreenRow: 0,
|
|
3950
|
+
commitFrom,
|
|
3951
|
+
commitTo,
|
|
3952
|
+
appendOnly: logicalAppend,
|
|
3953
|
+
prepaintWindowTop: logicalAppend && !logicalPrefixAppend && !hasVisibleOverlay ? commitFrom : undefined,
|
|
3954
|
+
windowTop,
|
|
3955
|
+
cursorTrackingLineCount,
|
|
3956
|
+
leadingSequence: deferredAltExit,
|
|
3957
|
+
});
|
|
3958
|
+
this.#pendingAltExit = "";
|
|
3959
|
+
if (!hasVisibleOverlay) {
|
|
3960
|
+
this.#widthEpochOverlayReplayPending = false;
|
|
3961
|
+
this.#widthEpochOverlayBoundary = undefined;
|
|
3962
|
+
if (liveRegionPinned) {
|
|
3963
|
+
this.#widthEpochBaselineRows = this.#widthEpochReplayUnresolved ? commitTo : widthEpochAppendTo;
|
|
3964
|
+
this.#windowTopRow = logicalAppend ? windowTop : prevWindowTop + scrollRows;
|
|
3965
|
+
} else {
|
|
3966
|
+
this.#widthEpochBaselineRows = frameLength;
|
|
3967
|
+
this.#widthEpochReplayUnresolved = false;
|
|
3968
|
+
this.#windowTopRow = windowTop;
|
|
3969
|
+
}
|
|
3970
|
+
this.#committedRows += scrollRows;
|
|
3971
|
+
if (!widthEpochReset && this.#widthEpochCommittedPrefix) {
|
|
3972
|
+
const epochPrefix = this.#widthEpochCommittedPrefix;
|
|
3973
|
+
// A height grow can expose tracked rows and let them scroll off
|
|
3974
|
+
// again. Retire that superseded logical suffix into the opaque
|
|
3975
|
+
// native base before recording its fresh same-width snapshot.
|
|
3976
|
+
const overlap = epochPrefix.frameRows.findIndex(row => row >= commitFrom);
|
|
3977
|
+
if (overlap >= 0) {
|
|
3978
|
+
epochPrefix.nativeBaseRows += epochPrefix.frameRows.length - overlap;
|
|
3979
|
+
epochPrefix.frameRows.length = overlap;
|
|
3980
|
+
epochPrefix.prefix.length = overlap;
|
|
3981
|
+
epochPrefix.auditRows = Math.min(epochPrefix.auditRows, overlap);
|
|
3982
|
+
}
|
|
3983
|
+
for (let row = commitFrom; row < commitTo; row++) {
|
|
3984
|
+
epochPrefix.frameRows.push(row);
|
|
3985
|
+
epochPrefix.prefix.push(rawFrame[row]!);
|
|
3986
|
+
}
|
|
3987
|
+
while (
|
|
3988
|
+
epochPrefix.auditRows < epochPrefix.frameRows.length &&
|
|
3989
|
+
epochPrefix.frameRows[epochPrefix.auditRows]! < finalBoundary
|
|
3990
|
+
) {
|
|
3991
|
+
epochPrefix.auditRows++;
|
|
3992
|
+
}
|
|
3993
|
+
}
|
|
3994
|
+
} else if (widthEpochReset) {
|
|
3995
|
+
// The overlay freezes commits and subsequent hidden-growth movement,
|
|
3996
|
+
// but the resize itself changed physical-row coordinates. Rebase the
|
|
3997
|
+
// window reference once so growth backfills from the settled width.
|
|
3998
|
+
this.#windowTopRow = replayUnresolvedOverlayFrame
|
|
3999
|
+
? 0
|
|
4000
|
+
: logicalAppend
|
|
4001
|
+
? Math.max(0, widthEpochSourceBoundary! - height)
|
|
4002
|
+
: windowTop;
|
|
4003
|
+
}
|
|
4004
|
+
if (widthEpochReset) {
|
|
4005
|
+
let trackedFrom = commitFrom;
|
|
4006
|
+
let trackedTo = commitTo;
|
|
4007
|
+
if (logicalPrefixAppend) trackedTo = Math.max(trackedFrom, trackedTo - height);
|
|
4008
|
+
if (trackedTo > this.#windowTopRow) {
|
|
4009
|
+
trackedFrom = trackedTo;
|
|
4010
|
+
}
|
|
4011
|
+
const frameRows = Array.from({ length: trackedTo - trackedFrom }, (_value, index) => trackedFrom + index);
|
|
4012
|
+
let auditRows = 0;
|
|
4013
|
+
while (auditRows < frameRows.length && frameRows[auditRows]! < finalBoundary) auditRows++;
|
|
4014
|
+
this.#widthEpochCommittedPrefix = {
|
|
4015
|
+
nativeBaseRows: this.#committedRows - frameRows.length,
|
|
4016
|
+
frameRows,
|
|
4017
|
+
prefix: frameRows.map(row => rawFrame[row]!),
|
|
4018
|
+
auditRows,
|
|
4019
|
+
};
|
|
4020
|
+
}
|
|
4021
|
+
this.#clearScrollbackOnNextRender = false;
|
|
4022
|
+
this.#hasEverRendered = true;
|
|
4023
|
+
this.#publishCommittedRows(this.#windowTopRow);
|
|
4024
|
+
return;
|
|
4025
|
+
}
|
|
3231
4026
|
if (imageTransmitBuffer.length > 0) {
|
|
3232
4027
|
this.terminal.write(imageTransmitBuffer);
|
|
3233
4028
|
}
|
|
@@ -3288,11 +4083,11 @@ export class TUI extends Container {
|
|
|
3288
4083
|
* rows that just entered immutable native scrollback, stranding an
|
|
3289
4084
|
* orphaned copy above the repainted block.
|
|
3290
4085
|
*/
|
|
3291
|
-
#publishCommittedRows(): void {
|
|
4086
|
+
#publishCommittedRows(committedRows = this.#committedRows): void {
|
|
3292
4087
|
for (const segment of this.#frameSegments) {
|
|
3293
4088
|
setNativeScrollbackCommittedRows(
|
|
3294
4089
|
segment.component,
|
|
3295
|
-
Math.min(segment.rowCount, Math.max(0,
|
|
4090
|
+
Math.min(segment.rowCount, Math.max(0, committedRows - segment.start)),
|
|
3296
4091
|
);
|
|
3297
4092
|
}
|
|
3298
4093
|
}
|
|
@@ -3506,8 +4301,47 @@ export class TUI extends Container {
|
|
|
3506
4301
|
return col;
|
|
3507
4302
|
}
|
|
3508
4303
|
|
|
3509
|
-
|
|
3510
|
-
|
|
4304
|
+
/**
|
|
4305
|
+
* Columns to preserve when `lines[index]` is a blank row that a scaled OSC 66
|
|
4306
|
+
* heading flows into, or `-1` when it is not such a row. A scale-`s` heading
|
|
4307
|
+
* occupies `s` rows and `visibleWidth` columns, so the `s - 1` blank rows
|
|
4308
|
+
* beneath it hold the multicell glyph's lower half; those columns must never
|
|
4309
|
+
* be erased or overdrawn or the glyph vanishes, leaving reserved-but-invisible
|
|
4310
|
+
* space (issue #8318). Scans upward across the contiguous blank run so every
|
|
4311
|
+
* reserved row of a scale ≥ 3 heading is covered, not just the first.
|
|
4312
|
+
*/
|
|
4313
|
+
#osc66SpacerGlyphWidth(lines: readonly string[], index: number): number {
|
|
4314
|
+
if (index <= 0 || lines[index] !== "") return -1;
|
|
4315
|
+
let gap = 1;
|
|
4316
|
+
while (gap < TUI.#OSC66_MAX_SPACER_ROWS && index - gap > 0 && lines[index - gap] === "") {
|
|
4317
|
+
gap++;
|
|
4318
|
+
}
|
|
4319
|
+
const above = lines[index - gap];
|
|
4320
|
+
if (above === undefined || !isOsc66Line(above) || gap > osc66MaxScale(above) - 1) return -1;
|
|
4321
|
+
return visibleWidth(above);
|
|
4322
|
+
}
|
|
4323
|
+
|
|
4324
|
+
#lineRewriteSequence(
|
|
4325
|
+
line: string,
|
|
4326
|
+
width: number,
|
|
4327
|
+
screenRow = -1,
|
|
4328
|
+
frameRow = -1,
|
|
4329
|
+
committedTo = -1,
|
|
4330
|
+
spacerGlyphWidth = -1,
|
|
4331
|
+
): string {
|
|
4332
|
+
// Reserved lower half of a scaled OSC 66 heading. The glyph re-emitted on
|
|
4333
|
+
// the row above owns columns `[0, spacerGlyphWidth)` here, so preserve
|
|
4334
|
+
// them (any erase there clears the glyph — issue #8318) but still clear
|
|
4335
|
+
// stale cells to their right: a row can reflow from wider text into this
|
|
4336
|
+
// spacer, and the glyph write never covers those columns. Leading reset
|
|
4337
|
+
// keeps the erase on the default background (BCE).
|
|
4338
|
+
if (spacerGlyphWidth >= 0) {
|
|
4339
|
+
if (spacerGlyphWidth >= width) return "";
|
|
4340
|
+
return `${SEGMENT_RESET}\x1b[${spacerGlyphWidth}C${ERASE_TO_END_OF_LINE}`;
|
|
4341
|
+
}
|
|
4342
|
+
if (TERMINAL.isImageLine(line)) {
|
|
4343
|
+
return ERASE_LINE + this.#imageLineSequence(line, screenRow, frameRow, committedTo);
|
|
4344
|
+
}
|
|
3511
4345
|
const terminalLine = this.#terminalLine(line);
|
|
3512
4346
|
const asciiWidth = this.#ansiAsciiLineWidth(line, width);
|
|
3513
4347
|
if (asciiWidth !== undefined) {
|
|
@@ -3598,6 +4432,122 @@ export class TUI extends Container {
|
|
|
3598
4432
|
);
|
|
3599
4433
|
}
|
|
3600
4434
|
|
|
4435
|
+
#emitWidthEpochBaseline(
|
|
4436
|
+
frame: readonly string[],
|
|
4437
|
+
window: string[],
|
|
4438
|
+
width: number,
|
|
4439
|
+
height: number,
|
|
4440
|
+
cursorPos: { row: number; col: number } | null,
|
|
4441
|
+
purgeSequence: string,
|
|
4442
|
+
imageTransmitBuffer: string,
|
|
4443
|
+
options: {
|
|
4444
|
+
repaintFromScreenRow: number;
|
|
4445
|
+
commitFrom: number;
|
|
4446
|
+
commitTo: number;
|
|
4447
|
+
appendOnly: boolean;
|
|
4448
|
+
prepaintWindowTop?: number;
|
|
4449
|
+
windowTop: number;
|
|
4450
|
+
cursorTrackingLineCount: number;
|
|
4451
|
+
leadingSequence: string;
|
|
4452
|
+
},
|
|
4453
|
+
): void {
|
|
4454
|
+
this.#fullRedrawCount += 1;
|
|
4455
|
+
let buffer = this.#paintBeginSequence + purgeSequence + options.leadingSequence + imageTransmitBuffer;
|
|
4456
|
+
if (options.commitTo > options.commitFrom) {
|
|
4457
|
+
if (options.appendOnly) {
|
|
4458
|
+
if (options.prepaintWindowTop !== undefined) {
|
|
4459
|
+
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
4460
|
+
const frameRow = options.prepaintWindowTop + screenRow;
|
|
4461
|
+
buffer += `\x1b[${screenRow + 1};1H`;
|
|
4462
|
+
buffer += this.#lineRewriteSequence(
|
|
4463
|
+
frame[frameRow] ?? "",
|
|
4464
|
+
width,
|
|
4465
|
+
screenRow,
|
|
4466
|
+
frameRow,
|
|
4467
|
+
options.commitTo,
|
|
4468
|
+
);
|
|
4469
|
+
}
|
|
4470
|
+
}
|
|
4471
|
+
buffer += `\x1b[${height};1H`;
|
|
4472
|
+
for (let row = options.commitFrom; row < options.commitTo; row++) {
|
|
4473
|
+
const enteringRow = options.prepaintWindowTop === undefined ? row : row + height;
|
|
4474
|
+
buffer += "\r\n";
|
|
4475
|
+
buffer += this.#lineRewriteSequence(
|
|
4476
|
+
frame[enteringRow] ?? "",
|
|
4477
|
+
width,
|
|
4478
|
+
height - 1,
|
|
4479
|
+
enteringRow,
|
|
4480
|
+
options.commitTo,
|
|
4481
|
+
);
|
|
4482
|
+
}
|
|
4483
|
+
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
4484
|
+
buffer += `\x1b[${screenRow + 1};1H`;
|
|
4485
|
+
buffer += this.#lineRewriteSequence(
|
|
4486
|
+
window[screenRow] ?? "",
|
|
4487
|
+
width,
|
|
4488
|
+
screenRow,
|
|
4489
|
+
options.windowTop + screenRow,
|
|
4490
|
+
options.commitTo,
|
|
4491
|
+
);
|
|
4492
|
+
}
|
|
4493
|
+
} else {
|
|
4494
|
+
buffer += "\x1b[1;1H";
|
|
4495
|
+
let wroteLine = false;
|
|
4496
|
+
for (let row = options.commitFrom; row < options.commitTo; row++) {
|
|
4497
|
+
if (wroteLine) buffer += "\r\n";
|
|
4498
|
+
buffer += this.#lineRewriteSequence(
|
|
4499
|
+
frame[row] ?? "",
|
|
4500
|
+
width,
|
|
4501
|
+
Math.min(row - options.commitFrom, height - 1),
|
|
4502
|
+
row,
|
|
4503
|
+
options.commitTo,
|
|
4504
|
+
);
|
|
4505
|
+
wroteLine = true;
|
|
4506
|
+
}
|
|
4507
|
+
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
4508
|
+
if (wroteLine) buffer += "\r\n";
|
|
4509
|
+
buffer += this.#lineRewriteSequence(
|
|
4510
|
+
window[screenRow] ?? "",
|
|
4511
|
+
width,
|
|
4512
|
+
Math.min(options.commitTo - options.commitFrom + screenRow, height - 1),
|
|
4513
|
+
options.windowTop + screenRow,
|
|
4514
|
+
options.commitTo,
|
|
4515
|
+
);
|
|
4516
|
+
wroteLine = true;
|
|
4517
|
+
}
|
|
4518
|
+
}
|
|
4519
|
+
} else {
|
|
4520
|
+
for (let screenRow = options.repaintFromScreenRow; screenRow < height; screenRow++) {
|
|
4521
|
+
buffer += `\x1b[${screenRow + 1};1H`;
|
|
4522
|
+
buffer += this.#lineRewriteSequence(
|
|
4523
|
+
window[screenRow] ?? "",
|
|
4524
|
+
width,
|
|
4525
|
+
screenRow,
|
|
4526
|
+
options.windowTop + screenRow,
|
|
4527
|
+
options.commitTo,
|
|
4528
|
+
);
|
|
4529
|
+
}
|
|
4530
|
+
}
|
|
4531
|
+
buffer += "\r";
|
|
4532
|
+
const contentRows = Math.max(1, Math.min(height, frame.length - options.windowTop));
|
|
4533
|
+
const contentBottomRow = options.windowTop + contentRows - 1;
|
|
4534
|
+
const target = this.#targetHardwareCursorState(cursorPos, options.cursorTrackingLineCount);
|
|
4535
|
+
if (target) {
|
|
4536
|
+
const screenRow = Math.max(0, Math.min(height - 1, target.row - options.windowTop));
|
|
4537
|
+
buffer += `\x1b[${screenRow + 1};${target.col + 1}H`;
|
|
4538
|
+
buffer += target.visible ? "\x1b[?25h" : "\x1b[?25l";
|
|
4539
|
+
} else {
|
|
4540
|
+
buffer += `\x1b[${contentRows};1H\x1b[?25l`;
|
|
4541
|
+
}
|
|
4542
|
+
buffer += this.#paintEndSequence;
|
|
4543
|
+
this.terminal.write(buffer);
|
|
4544
|
+
|
|
4545
|
+
this.#commit(frame, window, width, height, {
|
|
4546
|
+
toRow: target?.row ?? contentBottomRow,
|
|
4547
|
+
state: target,
|
|
4548
|
+
visible: target?.visible ?? false,
|
|
4549
|
+
});
|
|
4550
|
+
}
|
|
3601
4551
|
/**
|
|
3602
4552
|
* Replay the frame from home, optionally clearing native scrollback first:
|
|
3603
4553
|
* committed prefix `[0, chunkTo)` followed by the visible window. ED3
|
|
@@ -3629,6 +4579,7 @@ export class TUI extends Container {
|
|
|
3629
4579
|
*/
|
|
3630
4580
|
boundConptyPaint: boolean;
|
|
3631
4581
|
leadingSequence: string;
|
|
4582
|
+
copyScreenToScrollback: boolean;
|
|
3632
4583
|
},
|
|
3633
4584
|
): void {
|
|
3634
4585
|
this.#fullRedrawCount += 1;
|
|
@@ -3674,14 +4625,23 @@ export class TUI extends Container {
|
|
|
3674
4625
|
// Clear native history without blanking the live viewport first. The
|
|
3675
4626
|
// replay below rewrites every visible row from home, including blanks,
|
|
3676
4627
|
// so terminals without DEC 2026 never expose an ED2-cleared frame.
|
|
4628
|
+
// The clear also destroys every placement cell, so placement epochs
|
|
4629
|
+
// restart and every registry entry each image ever placed is deleted
|
|
4630
|
+
// explicitly (`d=i` keeps the transmitted data, so the replay needs
|
|
4631
|
+
// no retransmit). Deleting epoch 1 too matters for images absent from
|
|
4632
|
+
// the replay — nothing would ever replace their stale entry.
|
|
3677
4633
|
buffer += "\x1b[H\x1b[3J";
|
|
4634
|
+
for (const { imageId, lastEpoch } of this.#imageBudget.resetPlacementEpochs()) {
|
|
4635
|
+
for (let placementId = 1; placementId <= lastEpoch; placementId++) {
|
|
4636
|
+
buffer += encodeKittyDeletePlacement(imageId, placementId);
|
|
4637
|
+
}
|
|
4638
|
+
}
|
|
3678
4639
|
} else {
|
|
3679
|
-
//
|
|
3680
|
-
// terminals
|
|
3681
|
-
//
|
|
3682
|
-
//
|
|
3683
|
-
|
|
3684
|
-
if (TERMINAL.supportsScreenToScrollback) buffer += "\x1b[22J";
|
|
4640
|
+
// ED2 clears only the viewport. Initial/non-destructive replays may
|
|
4641
|
+
// first ask supporting terminals to preserve the prior screen, but a
|
|
4642
|
+
// width-epoch repaint MUST NOT copy that invalidated viewport into
|
|
4643
|
+
// native history.
|
|
4644
|
+
if (options.copyScreenToScrollback && TERMINAL.supportsScreenToScrollback) buffer += "\x1b[22J";
|
|
3685
4645
|
buffer += "\x1b[2J\x1b[H";
|
|
3686
4646
|
}
|
|
3687
4647
|
if (imageTransmitBuffer.length > 0) buffer += imageTransmitBuffer;
|
|
@@ -3711,20 +4671,52 @@ export class TUI extends Container {
|
|
|
3711
4671
|
// each row must self-clear stale cells left by the previous viewport.
|
|
3712
4672
|
for (let i = 0; i < chunkTo; i++) {
|
|
3713
4673
|
if (i > 0) buffer += "\r\n";
|
|
4674
|
+
const writeRow = Math.min(i, height - 1);
|
|
3714
4675
|
buffer += options.clearScrollback
|
|
3715
|
-
? this.#lineRewriteSequence(
|
|
3716
|
-
|
|
4676
|
+
? this.#lineRewriteSequence(
|
|
4677
|
+
frame[i] ?? "",
|
|
4678
|
+
width,
|
|
4679
|
+
writeRow,
|
|
4680
|
+
i,
|
|
4681
|
+
chunkTo,
|
|
4682
|
+
this.#osc66SpacerGlyphWidth(frame, i),
|
|
4683
|
+
)
|
|
4684
|
+
: this.#terminalLine(frame[i] ?? "", writeRow, i, chunkTo);
|
|
3717
4685
|
}
|
|
3718
4686
|
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
3719
4687
|
if (chunkTo + screenRow > 0) buffer += "\r\n";
|
|
3720
4688
|
const line = visibleTexts ? (visibleTexts[screenRow] ?? "") : (window[screenRow] ?? "");
|
|
3721
|
-
|
|
4689
|
+
const writeRow = Math.min(chunkTo + screenRow, height - 1);
|
|
4690
|
+
const frameRow = windowTop + screenRow;
|
|
4691
|
+
buffer += options.clearScrollback
|
|
4692
|
+
? this.#lineRewriteSequence(
|
|
4693
|
+
line,
|
|
4694
|
+
width,
|
|
4695
|
+
writeRow,
|
|
4696
|
+
frameRow,
|
|
4697
|
+
chunkTo,
|
|
4698
|
+
this.#osc66SpacerGlyphWidth(frame, frameRow),
|
|
4699
|
+
)
|
|
4700
|
+
: this.#terminalLine(line, writeRow, frameRow, chunkTo);
|
|
3722
4701
|
}
|
|
3723
4702
|
} else {
|
|
4703
|
+
// ConPTY-truncated replay: leading rows were dropped, so frame-space
|
|
4704
|
+
// positions are unknown — placements still clip to the write row but
|
|
4705
|
+
// skip epoch bookkeeping.
|
|
3724
4706
|
for (let i = 0; i < paintLines.length; i++) {
|
|
3725
4707
|
if (i > 0) buffer += "\r\n";
|
|
3726
4708
|
const line = visibleTexts && i >= visibleStart ? visibleTexts[i - visibleStart] : (paintLines[i] ?? "");
|
|
3727
|
-
|
|
4709
|
+
const writeRow = Math.min(i, height - 1);
|
|
4710
|
+
buffer += options.clearScrollback
|
|
4711
|
+
? this.#lineRewriteSequence(
|
|
4712
|
+
line,
|
|
4713
|
+
width,
|
|
4714
|
+
writeRow,
|
|
4715
|
+
-1,
|
|
4716
|
+
chunkTo,
|
|
4717
|
+
this.#osc66SpacerGlyphWidth(paintLines, i),
|
|
4718
|
+
)
|
|
4719
|
+
: this.#terminalLine(line, writeRow, -1, chunkTo);
|
|
3728
4720
|
}
|
|
3729
4721
|
}
|
|
3730
4722
|
buffer += fillSequence;
|
|
@@ -3810,43 +4802,59 @@ export class TUI extends Container {
|
|
|
3810
4802
|
// off a partial walk. The settle paint's own beginPass()/endPass() is the
|
|
3811
4803
|
// authoritative accounting, and its beginPass() wipes these frames.
|
|
3812
4804
|
this.#imageBudget.beginPass(true);
|
|
3813
|
-
const {
|
|
3814
|
-
this.#emitResizeViewport(
|
|
4805
|
+
const { framed, viewportTop, contentRows } = this.#composeResizeViewport(width, height);
|
|
4806
|
+
this.#emitResizeViewport(framed, viewportTop, height, contentRows, width);
|
|
3815
4807
|
this.#resizeViewportPaintCount += 1;
|
|
3816
4808
|
}
|
|
3817
4809
|
|
|
3818
4810
|
/**
|
|
3819
4811
|
* Build the viewport window for a resize fast-path frame: the bottom
|
|
3820
4812
|
* `height` rows of the would-be full frame, collected bottom-up across root
|
|
3821
|
-
* children
|
|
3822
|
-
*
|
|
4813
|
+
* children, plus up to {@link #OSC66_MAX_SPACER_ROWS} rows above the
|
|
4814
|
+
* fold. {@link ViewportTailProvider}s (the transcript) yield only their tail;
|
|
4815
|
+
* the small live-region children below render in full — so every child
|
|
3823
4816
|
* entirely above the fold is skipped. A frame shorter than the viewport is
|
|
3824
4817
|
* top-aligned with blank rows below, matching the full-paint window geometry
|
|
3825
4818
|
* (windowTop = max(0, frameLength - height)). Cursor markers are stripped
|
|
3826
4819
|
* (the drag hides the hardware cursor) and rows are width-fitted via the
|
|
3827
4820
|
* stateless preparer, so no persistent prepared-frame cache is touched.
|
|
4821
|
+
*
|
|
4822
|
+
* Returns the visible rows preceded by the context rows in frame order
|
|
4823
|
+
* (`framed`), the index where the viewport begins (`viewportTop`), and the
|
|
4824
|
+
* visible content count. The context rows are never emitted; they only let
|
|
4825
|
+
* {@link #osc66SpacerGlyphWidth} see a scaled heading that scrolled just
|
|
4826
|
+
* above the fold, so its reserved rows are preserved instead of erased
|
|
4827
|
+
* (issue #8318).
|
|
3828
4828
|
*/
|
|
3829
|
-
#composeResizeViewport(
|
|
3830
|
-
|
|
4829
|
+
#composeResizeViewport(
|
|
4830
|
+
width: number,
|
|
4831
|
+
height: number,
|
|
4832
|
+
): { framed: readonly string[]; viewportTop: number; contentRows: number } {
|
|
4833
|
+
const maxRows = height + TUI.#OSC66_MAX_SPACER_ROWS;
|
|
4834
|
+
const tail: string[] = []; // bottom-first: viewport rows plus context above
|
|
3831
4835
|
const children = this.children;
|
|
3832
|
-
for (let i = children.length - 1; i >= 0 && tail.length <
|
|
4836
|
+
for (let i = children.length - 1; i >= 0 && tail.length < maxRows; i--) {
|
|
3833
4837
|
const child = children[i]!;
|
|
3834
4838
|
const provider = asViewportTailProvider(child);
|
|
3835
|
-
const rows = provider ? provider.renderViewportTail(width,
|
|
3836
|
-
for (let r = rows.length - 1; r >= 0 && tail.length <
|
|
4839
|
+
const rows = provider ? provider.renderViewportTail(width, maxRows - tail.length) : child.render(width);
|
|
4840
|
+
for (let r = rows.length - 1; r >= 0 && tail.length < maxRows; r--) {
|
|
3837
4841
|
tail.push(rows[r]!);
|
|
3838
4842
|
}
|
|
3839
4843
|
}
|
|
3840
|
-
const
|
|
4844
|
+
const contentRows = Math.min(tail.length, height);
|
|
4845
|
+
const extra = tail.length - contentRows; // context rows above the fold
|
|
3841
4846
|
const window: string[] = new Array(height);
|
|
3842
4847
|
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
3843
|
-
// `tail` holds the bottom
|
|
3844
|
-
//
|
|
3845
|
-
|
|
3846
|
-
window[screenRow] = screenRow < count ? tail[count - 1 - screenRow]! : "";
|
|
4848
|
+
// `tail` holds the bottom rows first. The bottom `contentRows` fill the
|
|
4849
|
+
// viewport (top-aligned with blanks below on underflow).
|
|
4850
|
+
window[screenRow] = screenRow < contentRows ? tail[contentRows - 1 - screenRow]! : "";
|
|
3847
4851
|
}
|
|
3848
4852
|
this.#extractCursorMarkers(window);
|
|
3849
|
-
|
|
4853
|
+
// Frame order: context rows above the fold (top-first) then the window.
|
|
4854
|
+
const framed: string[] = new Array(extra + height);
|
|
4855
|
+
for (let k = 0; k < extra; k++) framed[k] = tail[tail.length - 1 - k]!;
|
|
4856
|
+
for (let screenRow = 0; screenRow < height; screenRow++) framed[extra + screenRow] = window[screenRow]!;
|
|
4857
|
+
return { framed: this.#prepareLinesArray(framed, width), viewportTop: extra, contentRows };
|
|
3850
4858
|
}
|
|
3851
4859
|
|
|
3852
4860
|
/**
|
|
@@ -3916,13 +4924,30 @@ export class TUI extends Container {
|
|
|
3916
4924
|
* flash, #5854). Normal-screen history is rebuilt once at settle via
|
|
3917
4925
|
* `#emitFullPaint`.
|
|
3918
4926
|
*/
|
|
3919
|
-
#emitResizeViewport(
|
|
4927
|
+
#emitResizeViewport(
|
|
4928
|
+
framed: readonly string[],
|
|
4929
|
+
viewportTop: number,
|
|
4930
|
+
height: number,
|
|
4931
|
+
contentRows: number,
|
|
4932
|
+
width: number,
|
|
4933
|
+
): void {
|
|
3920
4934
|
const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
|
|
3921
4935
|
const altEnter = widthChanged ? this.#enterResizeAltSequence() : "";
|
|
3922
4936
|
let buffer = `${this.#paintBeginSequence + altEnter}\x1b[H`;
|
|
3923
4937
|
for (let r = 0; r < height; r++) {
|
|
3924
4938
|
if (r > 0) buffer += "\r\n";
|
|
3925
|
-
|
|
4939
|
+
// `framed` carries context rows above the fold; the visible window
|
|
4940
|
+
// starts at `viewportTop`, and the spacer lookup scans within `framed`
|
|
4941
|
+
// so a heading just above the fold is still seen (issue #8318).
|
|
4942
|
+
const idx = viewportTop + r;
|
|
4943
|
+
buffer += this.#lineRewriteSequence(
|
|
4944
|
+
framed[idx] ?? "",
|
|
4945
|
+
width,
|
|
4946
|
+
r,
|
|
4947
|
+
-1,
|
|
4948
|
+
this.#committedRows,
|
|
4949
|
+
this.#osc66SpacerGlyphWidth(framed, idx),
|
|
4950
|
+
);
|
|
3926
4951
|
}
|
|
3927
4952
|
// Park the hardware cursor at the real content bottom, not the padded
|
|
3928
4953
|
// viewport bottom: a later height shrink would otherwise scroll the live
|
|
@@ -3988,7 +5013,7 @@ export class TUI extends Container {
|
|
|
3988
5013
|
let buffer = `${this.#paintBeginSequence}\x1b[H`;
|
|
3989
5014
|
for (let r = 0; r < height; r++) {
|
|
3990
5015
|
if (r > 0) buffer += "\r\n";
|
|
3991
|
-
buffer += this.#lineRewriteSequence(fitted[r], width);
|
|
5016
|
+
buffer += this.#lineRewriteSequence(fitted[r], width, r, -1, -1, this.#osc66SpacerGlyphWidth(fitted, r));
|
|
3992
5017
|
}
|
|
3993
5018
|
buffer += this.#paintEndSequence;
|
|
3994
5019
|
this.terminal.write(buffer);
|
|
@@ -4067,7 +5092,7 @@ export class TUI extends Container {
|
|
|
4067
5092
|
const moveToBottom = height - 1 - currentScreenRow;
|
|
4068
5093
|
if (moveToBottom > 0) buffer += `\x1b[${moveToBottom}B`;
|
|
4069
5094
|
for (let r = height - scroll; r < height; r++) {
|
|
4070
|
-
buffer += `\r\n${this.#lineRewriteSequence(window[r] ?? "", width)}`;
|
|
5095
|
+
buffer += `\r\n${this.#lineRewriteSequence(window[r] ?? "", width, height - 1, windowTop + r, chunkTo, this.#osc66SpacerGlyphWidth(frame, windowTop + r))}`;
|
|
4071
5096
|
}
|
|
4072
5097
|
// Rewrite any remaining changed rows after the shift.
|
|
4073
5098
|
let firstChanged = -1;
|
|
@@ -4084,7 +5109,14 @@ export class TUI extends Container {
|
|
|
4084
5109
|
buffer += "\r";
|
|
4085
5110
|
for (let r = firstChanged; r <= lastChanged; r++) {
|
|
4086
5111
|
if (r > firstChanged) buffer += "\r\n";
|
|
4087
|
-
buffer += this.#lineRewriteSequence(
|
|
5112
|
+
buffer += this.#lineRewriteSequence(
|
|
5113
|
+
window[r] ?? "",
|
|
5114
|
+
width,
|
|
5115
|
+
r,
|
|
5116
|
+
windowTop + r,
|
|
5117
|
+
chunkTo,
|
|
5118
|
+
this.#osc66SpacerGlyphWidth(frame, windowTop + r),
|
|
5119
|
+
);
|
|
4088
5120
|
}
|
|
4089
5121
|
cursorFromRow = windowTop + lastChanged;
|
|
4090
5122
|
}
|
|
@@ -4153,7 +5185,14 @@ export class TUI extends Container {
|
|
|
4153
5185
|
}
|
|
4154
5186
|
for (let r = firstChanged; r <= lastChanged; r++) {
|
|
4155
5187
|
if (r > firstChanged) buffer += "\r\n";
|
|
4156
|
-
buffer += this.#lineRewriteSequence(
|
|
5188
|
+
buffer += this.#lineRewriteSequence(
|
|
5189
|
+
fillTexts ? fillTexts[r - firstChanged] : (window[r] ?? ""),
|
|
5190
|
+
width,
|
|
5191
|
+
r,
|
|
5192
|
+
windowTop + r,
|
|
5193
|
+
this.#committedRows,
|
|
5194
|
+
this.#osc66SpacerGlyphWidth(frame, windowTop + r),
|
|
5195
|
+
);
|
|
4157
5196
|
}
|
|
4158
5197
|
buffer += fillSequence;
|
|
4159
5198
|
// Never park below real content (a height shrink would scroll live
|
|
@@ -4184,12 +5223,26 @@ export class TUI extends Container {
|
|
|
4184
5223
|
let wroteLine = false;
|
|
4185
5224
|
for (let i = chunkFrom; i < chunkTo; i++) {
|
|
4186
5225
|
if (wroteLine) buffer += "\r\n";
|
|
4187
|
-
buffer += this.#lineRewriteSequence(
|
|
5226
|
+
buffer += this.#lineRewriteSequence(
|
|
5227
|
+
frame[i] ?? "",
|
|
5228
|
+
width,
|
|
5229
|
+
Math.min(i - chunkFrom, height - 1),
|
|
5230
|
+
i,
|
|
5231
|
+
chunkTo,
|
|
5232
|
+
this.#osc66SpacerGlyphWidth(frame, i),
|
|
5233
|
+
);
|
|
4188
5234
|
wroteLine = true;
|
|
4189
5235
|
}
|
|
4190
5236
|
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
4191
5237
|
if (wroteLine) buffer += "\r\n";
|
|
4192
|
-
buffer += this.#lineRewriteSequence(
|
|
5238
|
+
buffer += this.#lineRewriteSequence(
|
|
5239
|
+
window[screenRow] ?? "",
|
|
5240
|
+
width,
|
|
5241
|
+
Math.min(chunkTo - chunkFrom + screenRow, height - 1),
|
|
5242
|
+
windowTop + screenRow,
|
|
5243
|
+
chunkTo,
|
|
5244
|
+
this.#osc66SpacerGlyphWidth(frame, windowTop + screenRow),
|
|
5245
|
+
);
|
|
4193
5246
|
wroteLine = true;
|
|
4194
5247
|
}
|
|
4195
5248
|
const parkUp = height - 1 - (contentBottomRow - windowTop);
|