@oh-my-pi/pi-tui 17.2.14 → 17.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/dist/types/components/editor.d.ts +6 -2
- package/dist/types/components/image.d.ts +6 -0
- package/dist/types/components/markdown.d.ts +6 -2
- package/dist/types/components/text.d.ts +1 -0
- package/dist/types/tui.d.ts +24 -1
- package/dist/types/utils.d.ts +12 -0
- package/package.json +3 -3
- package/src/components/editor.ts +64 -3
- package/src/components/image.ts +10 -0
- package/src/components/markdown.ts +79 -11
- package/src/components/text.ts +10 -0
- package/src/tui.ts +1022 -48
- package/src/utils.ts +28 -0
package/src/tui.ts
CHANGED
|
@@ -39,7 +39,9 @@ import {
|
|
|
39
39
|
import {
|
|
40
40
|
Ellipsis,
|
|
41
41
|
extractSegments,
|
|
42
|
+
isOsc66Line,
|
|
42
43
|
normalizeTerminalOutput,
|
|
44
|
+
osc66MaxScale,
|
|
43
45
|
sliceByColumn,
|
|
44
46
|
sliceWithWidth,
|
|
45
47
|
truncateToWidth,
|
|
@@ -219,6 +221,22 @@ export interface NativeScrollbackCommittedRows {
|
|
|
219
221
|
setNativeScrollbackCommittedRows(rows: number): void;
|
|
220
222
|
}
|
|
221
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
|
+
|
|
222
240
|
/**
|
|
223
241
|
* A component that discards rows after they enter native scrollback implements
|
|
224
242
|
* this hook so a destructive full replay can rehydrate its complete frame.
|
|
@@ -235,6 +253,19 @@ function setNativeScrollbackCommittedRows(component: Component, rows: number): v
|
|
|
235
253
|
(component as Component & Partial<NativeScrollbackCommittedRows>).setNativeScrollbackCommittedRows?.(rows);
|
|
236
254
|
}
|
|
237
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
|
+
|
|
238
269
|
function isOverlayFocusTarget(owner: Component, component: Component | null): boolean {
|
|
239
270
|
if (component === owner) return true;
|
|
240
271
|
if (!component) return false;
|
|
@@ -327,6 +358,7 @@ export interface RenderRequestOptions {
|
|
|
327
358
|
/** Clear terminal scrollback for intentional transcript replacement. */
|
|
328
359
|
clearScrollback?: boolean;
|
|
329
360
|
}
|
|
361
|
+
|
|
330
362
|
/** Type guard to check if a component implements Focusable */
|
|
331
363
|
export function isFocusable(component: Component | null): component is Component & Focusable {
|
|
332
364
|
return component !== null && "focused" in component;
|
|
@@ -381,9 +413,27 @@ function parseSizeValue(value: SizeValue | undefined, referenceSize: number): nu
|
|
|
381
413
|
return undefined;
|
|
382
414
|
}
|
|
383
415
|
|
|
384
|
-
/**
|
|
416
|
+
/**
|
|
417
|
+
* Detect sessions where ED3 cannot safely rebuild scrollback. A direct HerdR
|
|
418
|
+
* pane is deliberately excluded: HerdR's Ghostty core implements ED3, so the
|
|
419
|
+
* source-owned replay used by direct terminals is both supported and the only
|
|
420
|
+
* way to avoid hardening host-reflowed soft wraps. Nested multiplexers remain
|
|
421
|
+
* unsafe because the inner tmux/screen/Zellij layer still owns its history.
|
|
422
|
+
*/
|
|
385
423
|
function isMultiplexerSession(): boolean {
|
|
386
|
-
|
|
424
|
+
if (!isInsideTerminalMultiplexer()) return false;
|
|
425
|
+
if (Bun.env.HERDR_ENV !== "1") return true;
|
|
426
|
+
const term = Bun.env.TERM?.toLowerCase() ?? "";
|
|
427
|
+
return Boolean(
|
|
428
|
+
Bun.env.TMUX ||
|
|
429
|
+
Bun.env.STY ||
|
|
430
|
+
Bun.env.ZELLIJ ||
|
|
431
|
+
Bun.env.CMUX_WORKSPACE_ID ||
|
|
432
|
+
Bun.env.CMUX_SURFACE_ID ||
|
|
433
|
+
Bun.env.CMUX_REMOTE_TRANSPORT ||
|
|
434
|
+
term.startsWith("tmux") ||
|
|
435
|
+
term.startsWith("screen"),
|
|
436
|
+
);
|
|
387
437
|
}
|
|
388
438
|
|
|
389
439
|
/**
|
|
@@ -486,7 +536,9 @@ export interface OverlayHandle {
|
|
|
486
536
|
/**
|
|
487
537
|
* Container - a component that contains other components
|
|
488
538
|
*/
|
|
489
|
-
export class Container
|
|
539
|
+
export class Container
|
|
540
|
+
implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay, NativeScrollbackWidthEpoch
|
|
541
|
+
{
|
|
490
542
|
children: Component[] = [];
|
|
491
543
|
|
|
492
544
|
// Memoized concatenation of the children's latest renders. Children are
|
|
@@ -498,7 +550,29 @@ export class Container implements Component, NativeScrollbackCommittedRows, Nati
|
|
|
498
550
|
// on invalidate().
|
|
499
551
|
#memoLines: string[] | undefined;
|
|
500
552
|
#memoChildLines: (readonly string[])[] = [];
|
|
553
|
+
#memoChildWidthEpochRevisions: Array<number | undefined> = [];
|
|
501
554
|
#memoWidth = -1;
|
|
555
|
+
// Child identities matching #memoChildLines. Kept separately because callers
|
|
556
|
+
// may append children after the last emitted render but before SIGWINCH.
|
|
557
|
+
#memoChildren: Component[] = [];
|
|
558
|
+
#widthEpochBoundaries = new WeakMap<
|
|
559
|
+
object,
|
|
560
|
+
{
|
|
561
|
+
component: Component;
|
|
562
|
+
childBoundary: unknown;
|
|
563
|
+
sourceIndex: number;
|
|
564
|
+
leading: ReadonlyArray<{ component: Component; revision: number | undefined; rowCount: number }>;
|
|
565
|
+
trailing: ReadonlyArray<{
|
|
566
|
+
component: Component;
|
|
567
|
+
revision: number | undefined;
|
|
568
|
+
rowCount: number;
|
|
569
|
+
hadRows: boolean;
|
|
570
|
+
}>;
|
|
571
|
+
}
|
|
572
|
+
>();
|
|
573
|
+
#activeWidthEpochBoundary: object | undefined;
|
|
574
|
+
#widthEpochRevision = 0;
|
|
575
|
+
#widthEpochChildRevisions = new WeakMap<Component, number | undefined>();
|
|
502
576
|
|
|
503
577
|
#ignoreTight = false;
|
|
504
578
|
|
|
@@ -513,6 +587,7 @@ export class Container implements Component, NativeScrollbackCommittedRows, Nati
|
|
|
513
587
|
|
|
514
588
|
addChild(component: Component): void {
|
|
515
589
|
this.children.push(component);
|
|
590
|
+
this.#widthEpochRevision++;
|
|
516
591
|
if (this.#ignoreTight) {
|
|
517
592
|
component.setIgnoreTight?.(true);
|
|
518
593
|
}
|
|
@@ -523,11 +598,13 @@ export class Container implements Component, NativeScrollbackCommittedRows, Nati
|
|
|
523
598
|
const index = this.children.indexOf(component);
|
|
524
599
|
if (index !== -1) {
|
|
525
600
|
this.children.splice(index, 1);
|
|
601
|
+
this.#widthEpochRevision++;
|
|
526
602
|
this.#memoLines = undefined;
|
|
527
603
|
}
|
|
528
604
|
}
|
|
529
605
|
|
|
530
606
|
clear(): void {
|
|
607
|
+
if (this.children.length > 0) this.#widthEpochRevision++;
|
|
531
608
|
this.children = [];
|
|
532
609
|
this.#memoLines = undefined;
|
|
533
610
|
}
|
|
@@ -584,23 +661,189 @@ export class Container implements Component, NativeScrollbackCommittedRows, Nati
|
|
|
584
661
|
for (const child of this.children) prepareNativeScrollbackReplay(child);
|
|
585
662
|
}
|
|
586
663
|
|
|
664
|
+
captureNativeScrollbackWidthEpoch(): unknown {
|
|
665
|
+
const refs = this.#memoChildLines;
|
|
666
|
+
const children = this.#memoChildren;
|
|
667
|
+
if (this.#memoLines === undefined || refs.length !== children.length) return undefined;
|
|
668
|
+
for (let index = children.length - 1; index >= 0; index--) {
|
|
669
|
+
const component = children[index]!;
|
|
670
|
+
const source = getNativeScrollbackWidthEpoch(component);
|
|
671
|
+
const childBoundary = source?.captureNativeScrollbackWidthEpoch();
|
|
672
|
+
if (childBoundary === undefined) continue;
|
|
673
|
+
const marker = {};
|
|
674
|
+
this.#activeWidthEpochBoundary = marker;
|
|
675
|
+
this.#widthEpochBoundaries.set(marker, {
|
|
676
|
+
component,
|
|
677
|
+
childBoundary,
|
|
678
|
+
sourceIndex: index,
|
|
679
|
+
leading: children.slice(0, index).map((child, leadingIndex) => ({
|
|
680
|
+
component: child,
|
|
681
|
+
revision: this.#memoChildWidthEpochRevisions[leadingIndex],
|
|
682
|
+
rowCount: refs[leadingIndex]!.length,
|
|
683
|
+
})),
|
|
684
|
+
trailing: children.slice(index + 1).map((child, trailingIndex) => ({
|
|
685
|
+
component: child,
|
|
686
|
+
revision: this.#memoChildWidthEpochRevisions[index + 1 + trailingIndex],
|
|
687
|
+
rowCount: refs[index + 1 + trailingIndex]!.length,
|
|
688
|
+
hadRows: refs[index + 1 + trailingIndex]!.length > 0,
|
|
689
|
+
})),
|
|
690
|
+
});
|
|
691
|
+
return marker;
|
|
692
|
+
}
|
|
693
|
+
return undefined;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
resolveNativeScrollbackWidthEpoch(boundary: unknown): number | undefined {
|
|
697
|
+
if (typeof boundary !== "object" || boundary === null) return undefined;
|
|
698
|
+
const marker = this.#widthEpochBoundaries.get(boundary);
|
|
699
|
+
if (!marker) return undefined;
|
|
700
|
+
const index = marker.sourceIndex;
|
|
701
|
+
if (
|
|
702
|
+
this.#memoChildren[index] !== marker.component ||
|
|
703
|
+
this.#memoLines === undefined ||
|
|
704
|
+
this.#memoChildLines.length !== this.#memoChildren.length
|
|
705
|
+
) {
|
|
706
|
+
return undefined;
|
|
707
|
+
}
|
|
708
|
+
for (let leadingIndex = 0; leadingIndex < marker.leading.length; leadingIndex++) {
|
|
709
|
+
const captured = marker.leading[leadingIndex]!;
|
|
710
|
+
const currentRows = this.#memoChildLines[leadingIndex]!;
|
|
711
|
+
if (
|
|
712
|
+
this.#memoChildren[leadingIndex] !== captured.component ||
|
|
713
|
+
(captured.revision === undefined
|
|
714
|
+
? currentRows.length !== captured.rowCount
|
|
715
|
+
: getNativeScrollbackWidthEpochRevision(captured.component) !== captured.revision)
|
|
716
|
+
) {
|
|
717
|
+
return undefined;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
const childRows = getNativeScrollbackWidthEpoch(marker.component)?.resolveNativeScrollbackWidthEpoch(
|
|
721
|
+
marker.childBoundary,
|
|
722
|
+
);
|
|
723
|
+
if (childRows === undefined) return undefined;
|
|
724
|
+
let rows = childRows;
|
|
725
|
+
for (let i = 0; i < index; i++) rows += this.#memoChildLines[i]!.length;
|
|
726
|
+
for (let trailingIndex = 0; trailingIndex < marker.trailing.length; trailingIndex++) {
|
|
727
|
+
const captured = marker.trailing[trailingIndex]!;
|
|
728
|
+
const currentIndex = index + 1 + trailingIndex;
|
|
729
|
+
const currentRows = this.#memoChildLines[currentIndex];
|
|
730
|
+
if (
|
|
731
|
+
this.#memoChildren[currentIndex] !== captured.component ||
|
|
732
|
+
currentRows === undefined ||
|
|
733
|
+
(captured.revision === undefined
|
|
734
|
+
? currentRows.length !== captured.rowCount
|
|
735
|
+
: getNativeScrollbackWidthEpochRevision(captured.component) !== captured.revision)
|
|
736
|
+
) {
|
|
737
|
+
let capturedRows = 0;
|
|
738
|
+
for (let index = trailingIndex; index < marker.trailing.length; index++) {
|
|
739
|
+
capturedRows += marker.trailing[index]!.rowCount;
|
|
740
|
+
}
|
|
741
|
+
let settledRows = 0;
|
|
742
|
+
for (let index = currentIndex; index < this.#memoChildLines.length; index++) {
|
|
743
|
+
settledRows += this.#memoChildLines[index]!.length;
|
|
744
|
+
}
|
|
745
|
+
rows += Math.min(capturedRows, settledRows);
|
|
746
|
+
break;
|
|
747
|
+
}
|
|
748
|
+
rows += currentRows.length;
|
|
749
|
+
}
|
|
750
|
+
return rows;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
getNativeScrollbackWidthEpochRows(): number | undefined {
|
|
754
|
+
if (this.#memoLines === undefined || this.#memoChildLines.length !== this.#memoChildren.length) return undefined;
|
|
755
|
+
const marker =
|
|
756
|
+
this.#activeWidthEpochBoundary === undefined
|
|
757
|
+
? undefined
|
|
758
|
+
: this.#widthEpochBoundaries.get(this.#activeWidthEpochBoundary);
|
|
759
|
+
if (marker !== undefined) {
|
|
760
|
+
const index = marker.sourceIndex;
|
|
761
|
+
if (this.#memoChildren[index] !== marker.component) return undefined;
|
|
762
|
+
const rows = getNativeScrollbackWidthEpoch(marker.component)?.getNativeScrollbackWidthEpochRows();
|
|
763
|
+
if (rows === undefined) return undefined;
|
|
764
|
+
let boundary = rows;
|
|
765
|
+
for (let leading = 0; leading < index; leading++) boundary += this.#memoChildLines[leading]!.length;
|
|
766
|
+
for (let trailing = index + 1; trailing < this.#memoChildLines.length; trailing++) {
|
|
767
|
+
boundary += this.#memoChildLines[trailing]!.length;
|
|
768
|
+
}
|
|
769
|
+
return boundary;
|
|
770
|
+
}
|
|
771
|
+
let offset = this.#memoLines.length;
|
|
772
|
+
for (let index = this.#memoChildren.length - 1; index >= 0; index--) {
|
|
773
|
+
offset -= this.#memoChildLines[index]!.length;
|
|
774
|
+
const rows = getNativeScrollbackWidthEpoch(this.#memoChildren[index]!)?.getNativeScrollbackWidthEpochRows();
|
|
775
|
+
if (rows !== undefined) {
|
|
776
|
+
let boundary = offset + rows;
|
|
777
|
+
for (let trailing = index + 1; trailing < this.#memoChildLines.length; trailing++) {
|
|
778
|
+
boundary += this.#memoChildLines[trailing]!.length;
|
|
779
|
+
}
|
|
780
|
+
return boundary;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
return undefined;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
isNativeScrollbackWidthEpochAppendOnly(boundary: unknown): boolean {
|
|
787
|
+
if (typeof boundary !== "object" || boundary === null) return true;
|
|
788
|
+
const marker = this.#widthEpochBoundaries.get(boundary);
|
|
789
|
+
if (!marker) return true;
|
|
790
|
+
const source = getNativeScrollbackWidthEpoch(marker.component);
|
|
791
|
+
if (source?.isNativeScrollbackWidthEpochAppendOnly?.(marker.childBoundary) === false) return false;
|
|
792
|
+
if (!marker.trailing.some(child => child.hadRows)) return true;
|
|
793
|
+
for (let trailingIndex = 0; trailingIndex < marker.trailing.length; trailingIndex++) {
|
|
794
|
+
const captured = marker.trailing[trailingIndex]!;
|
|
795
|
+
const currentIndex = marker.sourceIndex + 1 + trailingIndex;
|
|
796
|
+
const currentRows = this.#memoChildLines[currentIndex];
|
|
797
|
+
const changed =
|
|
798
|
+
this.#memoChildren[currentIndex] !== captured.component ||
|
|
799
|
+
currentRows === undefined ||
|
|
800
|
+
(captured.revision === undefined
|
|
801
|
+
? currentRows.length !== captured.rowCount
|
|
802
|
+
: getNativeScrollbackWidthEpochRevision(captured.component) !== captured.revision);
|
|
803
|
+
if (changed && (captured.hadRows || marker.trailing.slice(trailingIndex + 1).some(child => child.hadRows))) {
|
|
804
|
+
return false;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
const previousRows = source?.resolveNativeScrollbackWidthEpoch(marker.childBoundary);
|
|
808
|
+
const currentRows = source?.getNativeScrollbackWidthEpochRows();
|
|
809
|
+
return previousRows === undefined || currentRows === undefined || currentRows <= previousRows;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
getNativeScrollbackWidthEpochRevision(): number {
|
|
813
|
+
for (const child of this.children) {
|
|
814
|
+
const revision = getNativeScrollbackWidthEpochRevision(child);
|
|
815
|
+
if (!this.#widthEpochChildRevisions.has(child)) {
|
|
816
|
+
this.#widthEpochChildRevisions.set(child, revision);
|
|
817
|
+
} else if (this.#widthEpochChildRevisions.get(child) !== revision) {
|
|
818
|
+
this.#widthEpochChildRevisions.set(child, revision);
|
|
819
|
+
this.#widthEpochRevision++;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
return this.#widthEpochRevision;
|
|
823
|
+
}
|
|
824
|
+
|
|
587
825
|
render(width: number): readonly string[] {
|
|
588
826
|
width = Math.max(1, width);
|
|
589
827
|
const children = this.children;
|
|
590
828
|
const count = children.length;
|
|
591
829
|
let refs = this.#memoChildLines;
|
|
830
|
+
let revisions = this.#memoChildWidthEpochRevisions;
|
|
592
831
|
let unchanged = this.#memoLines !== undefined && this.#memoWidth === width && refs.length === count;
|
|
593
832
|
if (refs.length !== count) {
|
|
594
833
|
refs = new Array(count);
|
|
595
834
|
this.#memoChildLines = refs;
|
|
835
|
+
revisions = new Array(count);
|
|
836
|
+
this.#memoChildWidthEpochRevisions = revisions;
|
|
596
837
|
}
|
|
597
838
|
for (let i = 0; i < count; i++) {
|
|
598
839
|
const childLines = children[i]!.render(width);
|
|
840
|
+
revisions[i] = getNativeScrollbackWidthEpochRevision(children[i]!);
|
|
599
841
|
if (refs[i] !== childLines) {
|
|
600
842
|
unchanged = false;
|
|
601
843
|
refs[i] = childLines;
|
|
602
844
|
}
|
|
603
845
|
}
|
|
846
|
+
this.#memoChildren = children.slice();
|
|
604
847
|
this.#memoWidth = width;
|
|
605
848
|
if (unchanged) return this.#memoLines!;
|
|
606
849
|
const lines: string[] = [];
|
|
@@ -656,6 +899,7 @@ interface FrameSegment {
|
|
|
656
899
|
lines: readonly string[];
|
|
657
900
|
start: number;
|
|
658
901
|
rowCount: number;
|
|
902
|
+
widthEpochRevision?: number;
|
|
659
903
|
liveLocalStart?: number;
|
|
660
904
|
liveRegionPinned: boolean;
|
|
661
905
|
}
|
|
@@ -973,6 +1217,10 @@ export class TUI extends Container {
|
|
|
973
1217
|
// the drag has been quiet for this long. Multiplexer sessions keep their own
|
|
974
1218
|
// debounce (`#armMultiplexerResizeTimer`, see #2088) and never take this path.
|
|
975
1219
|
static readonly #RESIZE_VIEWPORT_SETTLE_MS = 120;
|
|
1220
|
+
// A scale-`s` OSC 66 heading reserves `s - 1` rows, and the protocol
|
|
1221
|
+
// caps `s` at 7. This bounds spacer lookups and supplies enough context
|
|
1222
|
+
// above the resize viewport to classify every legal heading exactly.
|
|
1223
|
+
static readonly #OSC66_MAX_SPACER_ROWS = 6;
|
|
976
1224
|
// Ghostty can drop Kitty graphics commands sent during its first post-startup
|
|
977
1225
|
// settle window, leaving only Unicode placeholder cells. Hold the first image
|
|
978
1226
|
// paint until that window has passed; later images render normally.
|
|
@@ -1041,6 +1289,42 @@ export class TUI extends Container {
|
|
|
1041
1289
|
// snapshot (duplication, never loss). Re-based on full paints / shrinks /
|
|
1042
1290
|
// geometry frames.
|
|
1043
1291
|
#committedPrefixAuditRows = 0;
|
|
1292
|
+
// Width reflow terminates the meaning of the old committed physical-row
|
|
1293
|
+
// index in an in-place resize session. This is the current-width frame
|
|
1294
|
+
// baseline; only later physical-row growth may advance the append ledger.
|
|
1295
|
+
#widthEpochBaselineRows: number | undefined;
|
|
1296
|
+
// An unresolved captured source boundary was replayed from row zero. While
|
|
1297
|
+
// its live region remains pinned, advance the baseline only through rows
|
|
1298
|
+
// actually emitted; a reported final seam may otherwise skip deferred rows.
|
|
1299
|
+
#widthEpochReplayUnresolved = false;
|
|
1300
|
+
// An overlay-covered width reset with unresolved pending growth owes a
|
|
1301
|
+
// conservative replay from row zero. Sticky across later covered resizes —
|
|
1302
|
+
// even if their source boundary resolves — until an uncovered paint pays it.
|
|
1303
|
+
#widthEpochOverlayReplayPending = false;
|
|
1304
|
+
// The first logical boundary captured while a normal-buffer overlay covers
|
|
1305
|
+
// a width epoch. Later covered resizes must keep resolving this unpaid seam
|
|
1306
|
+
// instead of adopting hidden growth as the next epoch's source boundary.
|
|
1307
|
+
#widthEpochOverlayBoundary: unknown;
|
|
1308
|
+
// Same-width snapshots physically appended after a width transition. The
|
|
1309
|
+
// ordinary committed prefix includes opaque old-width native rows and can
|
|
1310
|
+
// no longer be indexed against the reflowed frame; this local ledger lets
|
|
1311
|
+
// newly-final post-epoch rows retain the one-time strict audit contract.
|
|
1312
|
+
#widthEpochCommittedPrefix?: {
|
|
1313
|
+
nativeBaseRows: number;
|
|
1314
|
+
frameRows: number[];
|
|
1315
|
+
prefix: string[];
|
|
1316
|
+
auditRows: number;
|
|
1317
|
+
};
|
|
1318
|
+
// Logical source boundary captured from the last emitted frame at the first
|
|
1319
|
+
// SIGWINCH in a multiplexer resize burst. Unlike physical row counts, the
|
|
1320
|
+
// opaque marker survives width reflow and resolves after the settled render.
|
|
1321
|
+
#multiplexerWidthEpochBoundary: unknown;
|
|
1322
|
+
#multiplexerWidthEpochPending = false;
|
|
1323
|
+
// Normal-buffer boundary borrowed by a fullscreen alt overlay. If the host
|
|
1324
|
+
// resizes while the transcript is hidden, this remains the physical seam
|
|
1325
|
+
// from before the overlay instead of adopting hidden growth on exit.
|
|
1326
|
+
#altWidthEpochBoundary: unknown;
|
|
1327
|
+
|
|
1044
1328
|
// Frame row currently mapped to screen row 0. Monotonic between full
|
|
1045
1329
|
// paints: a shrink never re-exposes scrolled-off rows (they cannot be
|
|
1046
1330
|
// un-scrolled without rewriting history); live rows repaint at fixed
|
|
@@ -1083,6 +1367,7 @@ export class TUI extends Container {
|
|
|
1083
1367
|
// flag below so the settled paint still honours every caller's request.
|
|
1084
1368
|
#multiplexerResizeTimer: RenderTimer | undefined;
|
|
1085
1369
|
#deferredForcedClearScrollback = false;
|
|
1370
|
+
#multiplexerResizeHasPendingRender = false;
|
|
1086
1371
|
// True from the first SIGWINCH of a non-multiplexer drag until the settle
|
|
1087
1372
|
// timer fires. While set, every `#doRender` short-circuits to the viewport
|
|
1088
1373
|
// fast path (`#renderResizeViewport`) instead of an authoritative full
|
|
@@ -1136,6 +1421,18 @@ export class TUI extends Container {
|
|
|
1136
1421
|
// Per-root-child segment ledger backing the stable-prefix computation.
|
|
1137
1422
|
#frameSegments: FrameSegment[] = [];
|
|
1138
1423
|
#composeWidth = -1;
|
|
1424
|
+
#rootWidthEpochBoundaries = new WeakMap<
|
|
1425
|
+
object,
|
|
1426
|
+
{
|
|
1427
|
+
component: Component;
|
|
1428
|
+
childBoundary: unknown;
|
|
1429
|
+
sourceIndex: number;
|
|
1430
|
+
leading: ReadonlyArray<{ component: Component; revision: number | undefined; rowCount: number }>;
|
|
1431
|
+
trailing: ReadonlyArray<{ component: Component; revision: number | undefined; rowCount: number }>;
|
|
1432
|
+
hasTrailingRows: boolean;
|
|
1433
|
+
}
|
|
1434
|
+
>();
|
|
1435
|
+
|
|
1139
1436
|
// Cursor markers stripped at ingestion, ascending by frame row.
|
|
1140
1437
|
#frameCursorMarkers: { row: number; col: number }[] = [];
|
|
1141
1438
|
// Leading rows of #composedFrame byte-identical to the previous compose.
|
|
@@ -1185,6 +1482,147 @@ export class TUI extends Container {
|
|
|
1185
1482
|
this.#watchdog = new LoopWatchdog();
|
|
1186
1483
|
}
|
|
1187
1484
|
|
|
1485
|
+
override captureNativeScrollbackWidthEpoch(): unknown {
|
|
1486
|
+
const liveSource = this.#frameSegments.findIndex(segment => segment.liveLocalStart !== undefined);
|
|
1487
|
+
const indices = Array.from({ length: this.#frameSegments.length }, (_value, index) => index)
|
|
1488
|
+
.reverse()
|
|
1489
|
+
.filter(index => index !== liveSource);
|
|
1490
|
+
if (liveSource >= 0) indices.unshift(liveSource);
|
|
1491
|
+
for (const index of indices) {
|
|
1492
|
+
const segment = this.#frameSegments[index]!;
|
|
1493
|
+
const source = getNativeScrollbackWidthEpoch(segment.component);
|
|
1494
|
+
const childBoundary = source?.captureNativeScrollbackWidthEpoch();
|
|
1495
|
+
if (childBoundary === undefined) continue;
|
|
1496
|
+
const marker = {};
|
|
1497
|
+
this.#rootWidthEpochBoundaries.set(marker, {
|
|
1498
|
+
component: segment.component,
|
|
1499
|
+
childBoundary,
|
|
1500
|
+
sourceIndex: index,
|
|
1501
|
+
leading: this.#frameSegments.slice(0, index).map(candidate => ({
|
|
1502
|
+
component: candidate.component,
|
|
1503
|
+
revision: candidate.widthEpochRevision,
|
|
1504
|
+
rowCount: candidate.rowCount,
|
|
1505
|
+
})),
|
|
1506
|
+
trailing: this.#frameSegments.slice(index + 1).map(candidate => ({
|
|
1507
|
+
component: candidate.component,
|
|
1508
|
+
revision: candidate.widthEpochRevision,
|
|
1509
|
+
rowCount: candidate.rowCount,
|
|
1510
|
+
})),
|
|
1511
|
+
hasTrailingRows: this.#frameSegments.slice(index + 1).some(candidate => candidate.rowCount > 0),
|
|
1512
|
+
});
|
|
1513
|
+
return marker;
|
|
1514
|
+
}
|
|
1515
|
+
return undefined;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
override resolveNativeScrollbackWidthEpoch(boundary: unknown): number | undefined {
|
|
1519
|
+
if (typeof boundary !== "object" || boundary === null) return undefined;
|
|
1520
|
+
const marker = this.#rootWidthEpochBoundaries.get(boundary);
|
|
1521
|
+
if (!marker) return undefined;
|
|
1522
|
+
const segment = this.#frameSegments[marker.sourceIndex];
|
|
1523
|
+
if (segment?.component !== marker.component) return undefined;
|
|
1524
|
+
for (let index = 0; index < marker.leading.length; index++) {
|
|
1525
|
+
const captured = marker.leading[index]!;
|
|
1526
|
+
const current = this.#frameSegments[index];
|
|
1527
|
+
if (
|
|
1528
|
+
current?.component !== captured.component ||
|
|
1529
|
+
(captured.revision === undefined
|
|
1530
|
+
? current.rowCount !== captured.rowCount
|
|
1531
|
+
: current.widthEpochRevision !== captured.revision)
|
|
1532
|
+
) {
|
|
1533
|
+
return undefined;
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
const childRows = getNativeScrollbackWidthEpoch(marker.component)?.resolveNativeScrollbackWidthEpoch(
|
|
1537
|
+
marker.childBoundary,
|
|
1538
|
+
);
|
|
1539
|
+
if (childRows === undefined) return undefined;
|
|
1540
|
+
let rows = segment.start + childRows;
|
|
1541
|
+
for (let trailingIndex = 0; trailingIndex < marker.trailing.length; trailingIndex++) {
|
|
1542
|
+
const captured = marker.trailing[trailingIndex]!;
|
|
1543
|
+
const candidate = this.#frameSegments[marker.sourceIndex + 1 + trailingIndex];
|
|
1544
|
+
// Changed/removed tails are not individually cross-width comparable.
|
|
1545
|
+
// Preserve the shared physical row count of the remaining tail as one
|
|
1546
|
+
// span; only aggregate height growth belongs to the current suffix.
|
|
1547
|
+
if (
|
|
1548
|
+
candidate?.component !== captured.component ||
|
|
1549
|
+
(captured.revision === undefined
|
|
1550
|
+
? candidate.rowCount !== captured.rowCount
|
|
1551
|
+
: candidate.widthEpochRevision !== captured.revision)
|
|
1552
|
+
) {
|
|
1553
|
+
let capturedRows = 0;
|
|
1554
|
+
for (let index = trailingIndex; index < marker.trailing.length; index++) {
|
|
1555
|
+
capturedRows += marker.trailing[index]!.rowCount;
|
|
1556
|
+
}
|
|
1557
|
+
let settledRows = 0;
|
|
1558
|
+
for (let index = marker.sourceIndex + 1 + trailingIndex; index < this.#frameSegments.length; index++) {
|
|
1559
|
+
settledRows += this.#frameSegments[index]!.rowCount;
|
|
1560
|
+
}
|
|
1561
|
+
rows += Math.min(capturedRows, settledRows);
|
|
1562
|
+
break;
|
|
1563
|
+
}
|
|
1564
|
+
rows += candidate.rowCount;
|
|
1565
|
+
}
|
|
1566
|
+
return rows;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
#getNativeScrollbackWidthEpochCurrentRows(boundary: unknown): number | undefined {
|
|
1570
|
+
if (typeof boundary !== "object" || boundary === null) return undefined;
|
|
1571
|
+
const marker = this.#rootWidthEpochBoundaries.get(boundary);
|
|
1572
|
+
if (!marker) return undefined;
|
|
1573
|
+
const index = marker.sourceIndex;
|
|
1574
|
+
if (this.#frameSegments[index]?.component !== marker.component) return undefined;
|
|
1575
|
+
const sourceRows = getNativeScrollbackWidthEpoch(marker.component)?.getNativeScrollbackWidthEpochRows();
|
|
1576
|
+
if (sourceRows === undefined) return undefined;
|
|
1577
|
+
let rows = this.#frameSegments[index]!.start + sourceRows;
|
|
1578
|
+
for (let trailing = index + 1; trailing < this.#frameSegments.length; trailing++) {
|
|
1579
|
+
rows += this.#frameSegments[trailing]!.rowCount;
|
|
1580
|
+
}
|
|
1581
|
+
return rows;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
#isNativeScrollbackWidthEpochAppendOnly(boundary: unknown): boolean {
|
|
1585
|
+
if (typeof boundary !== "object" || boundary === null) return true;
|
|
1586
|
+
const marker = this.#rootWidthEpochBoundaries.get(boundary);
|
|
1587
|
+
if (!marker) return true;
|
|
1588
|
+
const source = getNativeScrollbackWidthEpoch(marker.component);
|
|
1589
|
+
if (source?.isNativeScrollbackWidthEpochAppendOnly?.(marker.childBoundary) === false) return false;
|
|
1590
|
+
if (!marker.hasTrailingRows) return true;
|
|
1591
|
+
for (let trailingIndex = 0; trailingIndex < marker.trailing.length; trailingIndex++) {
|
|
1592
|
+
const captured = marker.trailing[trailingIndex]!;
|
|
1593
|
+
const current = this.#frameSegments[marker.sourceIndex + 1 + trailingIndex];
|
|
1594
|
+
const changed =
|
|
1595
|
+
current?.component !== captured.component ||
|
|
1596
|
+
(captured.revision === undefined
|
|
1597
|
+
? current.rowCount !== captured.rowCount
|
|
1598
|
+
: current.widthEpochRevision !== captured.revision);
|
|
1599
|
+
if (
|
|
1600
|
+
changed &&
|
|
1601
|
+
(captured.rowCount > 0 || marker.trailing.slice(trailingIndex + 1).some(segment => segment.rowCount > 0))
|
|
1602
|
+
) {
|
|
1603
|
+
return false;
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
const previousRows = source?.resolveNativeScrollbackWidthEpoch(marker.childBoundary);
|
|
1607
|
+
const currentRows = source?.getNativeScrollbackWidthEpochRows();
|
|
1608
|
+
return previousRows === undefined || currentRows === undefined || currentRows <= previousRows;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
override getNativeScrollbackWidthEpochRows(): number | undefined {
|
|
1612
|
+
for (let index = this.#frameSegments.length - 1; index >= 0; index--) {
|
|
1613
|
+
const segment = this.#frameSegments[index]!;
|
|
1614
|
+
const rows = getNativeScrollbackWidthEpoch(segment.component)?.getNativeScrollbackWidthEpochRows();
|
|
1615
|
+
if (rows !== undefined) {
|
|
1616
|
+
let boundary = segment.start + rows;
|
|
1617
|
+
for (let trailing = index + 1; trailing < this.#frameSegments.length; trailing++) {
|
|
1618
|
+
boundary += this.#frameSegments[trailing]!.rowCount;
|
|
1619
|
+
}
|
|
1620
|
+
return boundary;
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
return undefined;
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1188
1626
|
override render(width: number): readonly string[] {
|
|
1189
1627
|
width = Math.max(1, width);
|
|
1190
1628
|
this.#nativeScrollbackLiveRegionStart = undefined;
|
|
@@ -1192,6 +1630,14 @@ export class TUI extends Container {
|
|
|
1192
1630
|
const children = this.children;
|
|
1193
1631
|
const previousSegments = this.#frameSegments;
|
|
1194
1632
|
const segments: FrameSegment[] = new Array(children.length);
|
|
1633
|
+
// The transition frame cannot map the old-width native commit count into
|
|
1634
|
+
// current-width component rows. Once the epoch baseline exists,
|
|
1635
|
+
// #windowTopRow is the current-width commit seam while #committedRows
|
|
1636
|
+
// remains the opaque native ledger.
|
|
1637
|
+
const committedCoordinatesOpaque =
|
|
1638
|
+
this.#composeWidth > 0 && this.#composeWidth !== width && this.#resizeRepaintsInPlace();
|
|
1639
|
+
const componentCommittedRows =
|
|
1640
|
+
this.#widthEpochBaselineRows === undefined ? this.#committedRows : this.#windowTopRow;
|
|
1195
1641
|
// A width change re-renders every child; nothing carries over.
|
|
1196
1642
|
let chainStable = this.#composeWidth === width;
|
|
1197
1643
|
this.#composeWidth = width;
|
|
@@ -1210,11 +1656,13 @@ export class TUI extends Container {
|
|
|
1210
1656
|
let childLines: readonly string[];
|
|
1211
1657
|
let liveLocalStart: number | undefined;
|
|
1212
1658
|
let liveRegionPinned = false;
|
|
1659
|
+
let widthEpochRevision: number | undefined;
|
|
1213
1660
|
let reported: number | undefined;
|
|
1214
1661
|
if (reuse) {
|
|
1215
1662
|
childLines = previous.lines;
|
|
1216
1663
|
liveLocalStart = previous.liveLocalStart;
|
|
1217
1664
|
liveRegionPinned = previous.liveRegionPinned;
|
|
1665
|
+
widthEpochRevision = previous.widthEpochRevision;
|
|
1218
1666
|
} else {
|
|
1219
1667
|
// Feed the engine's committed-row claim (from the previous frame's
|
|
1220
1668
|
// emit) before rendering so the child can skip re-deriving blocks
|
|
@@ -1226,8 +1674,14 @@ export class TUI extends Container {
|
|
|
1226
1674
|
// own future rows being pre-committed.
|
|
1227
1675
|
const prevRows = previous !== undefined && previous.component === child ? previous.rowCount : 0;
|
|
1228
1676
|
const prevStart = previous !== undefined && previous.component === child ? previous.start : offset;
|
|
1229
|
-
|
|
1677
|
+
if (!committedCoordinatesOpaque) {
|
|
1678
|
+
setNativeScrollbackCommittedRows(
|
|
1679
|
+
child,
|
|
1680
|
+
Math.min(prevRows, Math.max(0, componentCommittedRows - prevStart)),
|
|
1681
|
+
);
|
|
1682
|
+
}
|
|
1230
1683
|
childLines = child.render(width);
|
|
1684
|
+
widthEpochRevision = getNativeScrollbackWidthEpochRevision(child);
|
|
1231
1685
|
const liveRegionStart = getNativeScrollbackLiveRegionStart(child);
|
|
1232
1686
|
if (liveRegionStart !== undefined) {
|
|
1233
1687
|
liveLocalStart = Number.isFinite(liveRegionStart)
|
|
@@ -1283,6 +1737,7 @@ export class TUI extends Container {
|
|
|
1283
1737
|
lines: childLines,
|
|
1284
1738
|
start: offset,
|
|
1285
1739
|
rowCount: childLines.length,
|
|
1740
|
+
widthEpochRevision,
|
|
1286
1741
|
liveLocalStart,
|
|
1287
1742
|
liveRegionPinned,
|
|
1288
1743
|
};
|
|
@@ -1621,6 +2076,12 @@ export class TUI extends Container {
|
|
|
1621
2076
|
if (this.#altEnterWidth === this.terminal.columns && this.#altEnterHeight !== this.terminal.rows) {
|
|
1622
2077
|
this.#altToggleResizesInPlace = true;
|
|
1623
2078
|
}
|
|
2079
|
+
if (this.#previousWidth > 0 && this.terminal.columns !== this.#previousWidth) {
|
|
2080
|
+
this.#multiplexerWidthEpochPending = true;
|
|
2081
|
+
if (this.#multiplexerWidthEpochBoundary === undefined) {
|
|
2082
|
+
this.#multiplexerWidthEpochBoundary = this.#altWidthEpochBoundary;
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
1624
2085
|
this.#resizeEventPending = true;
|
|
1625
2086
|
this.requestRender();
|
|
1626
2087
|
return;
|
|
@@ -1634,7 +2095,18 @@ export class TUI extends Container {
|
|
|
1634
2095
|
this.#requestResizeViewportPaint();
|
|
1635
2096
|
return;
|
|
1636
2097
|
}
|
|
1637
|
-
this.#
|
|
2098
|
+
if (this.#previousWidth > 0 && this.terminal.columns !== this.#previousWidth) {
|
|
2099
|
+
this.#multiplexerWidthEpochPending = true;
|
|
2100
|
+
if (this.#multiplexerWidthEpochBoundary === undefined) {
|
|
2101
|
+
this.#multiplexerWidthEpochBoundary = this.captureNativeScrollbackWidthEpoch();
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
this.#armMultiplexerResizeTimer({
|
|
2105
|
+
clearScrollback: false,
|
|
2106
|
+
hasPendingRender:
|
|
2107
|
+
this.#multiplexerResizeTimer === undefined &&
|
|
2108
|
+
(this.#renderRequested || this.#renderTimer !== undefined),
|
|
2109
|
+
});
|
|
1638
2110
|
},
|
|
1639
2111
|
() => this.stop(),
|
|
1640
2112
|
);
|
|
@@ -1915,7 +2387,7 @@ export class TUI extends Container {
|
|
|
1915
2387
|
// the same `#prepareForcedRender(!isMultiplexerSession())` path via
|
|
1916
2388
|
// `requestRender(true)`, so the clear-scrollback intent is preserved.
|
|
1917
2389
|
if (this.#multiplexerResizeTimer) {
|
|
1918
|
-
this.#armMultiplexerResizeTimer(!isMultiplexerSession());
|
|
2390
|
+
this.#armMultiplexerResizeTimer({ clearScrollback: !isMultiplexerSession(), hasPendingRender: true });
|
|
1919
2391
|
return;
|
|
1920
2392
|
}
|
|
1921
2393
|
this.#prepareForcedRender(!isMultiplexerSession());
|
|
@@ -1938,7 +2410,10 @@ export class TUI extends Container {
|
|
|
1938
2410
|
// so this guard only catches external callers — the deferred render
|
|
1939
2411
|
// itself proceeds straight to `#prepareForcedRender`.
|
|
1940
2412
|
if (this.#multiplexerResizeTimer) {
|
|
1941
|
-
this.#armMultiplexerResizeTimer(
|
|
2413
|
+
this.#armMultiplexerResizeTimer({
|
|
2414
|
+
clearScrollback: options?.clearScrollback === true,
|
|
2415
|
+
hasPendingRender: true,
|
|
2416
|
+
});
|
|
1942
2417
|
return;
|
|
1943
2418
|
}
|
|
1944
2419
|
// A forced render preempts the post-full-paint ConPTY settle: it owns
|
|
@@ -2142,6 +2617,7 @@ export class TUI extends Container {
|
|
|
2142
2617
|
screenStart + i,
|
|
2143
2618
|
segment.start + i,
|
|
2144
2619
|
this.#committedRows,
|
|
2620
|
+
this.#osc66SpacerGlyphWidth(this.#preparedFrame, segment.start + i),
|
|
2145
2621
|
);
|
|
2146
2622
|
}
|
|
2147
2623
|
const cursorControl = this.#cursorControlSequence(
|
|
@@ -2167,6 +2643,10 @@ export class TUI extends Container {
|
|
|
2167
2643
|
|
|
2168
2644
|
/** Ordinary (non-forced) scheduling shared by full and component-scoped requests. */
|
|
2169
2645
|
#requestOrdinaryRender(): void {
|
|
2646
|
+
if (this.#multiplexerResizeTimer) {
|
|
2647
|
+
this.#multiplexerResizeHasPendingRender = true;
|
|
2648
|
+
return;
|
|
2649
|
+
}
|
|
2170
2650
|
// Coalesce non-forced renders inside the post-full-paint ConPTY settle
|
|
2171
2651
|
// window into one trailing render. Spinner/blink/streaming components
|
|
2172
2652
|
// otherwise fire `requestRender(false)` at 30 Hz while the host is still
|
|
@@ -2250,8 +2730,9 @@ export class TUI extends Container {
|
|
|
2250
2730
|
* intent into `#deferredForcedClearScrollback` — the timer's callback
|
|
2251
2731
|
* consumes that flag exactly once when it re-enters `requestRender(true)`.
|
|
2252
2732
|
*/
|
|
2253
|
-
#armMultiplexerResizeTimer(clearScrollback: boolean): void {
|
|
2254
|
-
this.#deferredForcedClearScrollback ||= clearScrollback;
|
|
2733
|
+
#armMultiplexerResizeTimer(options: { clearScrollback: boolean; hasPendingRender?: boolean }): void {
|
|
2734
|
+
this.#deferredForcedClearScrollback ||= options.clearScrollback;
|
|
2735
|
+
this.#multiplexerResizeHasPendingRender ||= options.hasPendingRender === true;
|
|
2255
2736
|
if (this.#renderTimer) {
|
|
2256
2737
|
this.#renderTimer.cancel();
|
|
2257
2738
|
this.#renderTimer = undefined;
|
|
@@ -2884,6 +3365,7 @@ export class TUI extends Container {
|
|
|
2884
3365
|
this.#altPreviousLines = [];
|
|
2885
3366
|
this.#altEnterWidth = width;
|
|
2886
3367
|
this.#altEnterHeight = height;
|
|
3368
|
+
this.#altWidthEpochBoundary = this.captureNativeScrollbackWidthEpoch();
|
|
2887
3369
|
} else if (!wantAlt && this.#altActive) {
|
|
2888
3370
|
const mouseExit = this.#altMouseTrackingActive ? MOUSE_TRACKING_OFF : "";
|
|
2889
3371
|
const enhancementExit = this.#keyboardEnhancementExit();
|
|
@@ -2901,6 +3383,7 @@ export class TUI extends Container {
|
|
|
2901
3383
|
this.#altActive = false;
|
|
2902
3384
|
this.#altMouseTrackingActive = false;
|
|
2903
3385
|
this.#altPreviousLines = [];
|
|
3386
|
+
this.#altWidthEpochBoundary = undefined;
|
|
2904
3387
|
// A resize while on the alt buffer reflowed the terminal's saved
|
|
2905
3388
|
// normal screen; it no longer matches our accounting, so force the
|
|
2906
3389
|
// geometry rebuild path instead of a stale diff. A pure height change
|
|
@@ -3018,12 +3501,30 @@ export class TUI extends Container {
|
|
|
3018
3501
|
const finalBoundary = Math.max(0, Math.min(frameLength, liveRegionStart ?? frameLength));
|
|
3019
3502
|
|
|
3020
3503
|
// 2. Transition state captured before any emitter runs.
|
|
3021
|
-
|
|
3504
|
+
let prevWindowTop = this.#windowTopRow;
|
|
3022
3505
|
const prevHardwareCursorRow = this.#hardwareCursorRow;
|
|
3023
3506
|
const resizeEventOccurred = this.#resizeEventPending;
|
|
3024
3507
|
this.#resizeEventPending = false;
|
|
3508
|
+
const resizeHadPendingRender = this.#multiplexerResizeHasPendingRender;
|
|
3509
|
+
this.#multiplexerResizeHasPendingRender = false;
|
|
3025
3510
|
if (resizeEventOccurred) this.#forgetHardwareCursorState();
|
|
3026
3511
|
const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
|
|
3512
|
+
const widthEpochOccurred = widthChanged || (resizeEventOccurred && this.#multiplexerWidthEpochPending);
|
|
3513
|
+
const capturedWidthEpochBoundary = this.#multiplexerWidthEpochBoundary;
|
|
3514
|
+
const widthEpochBoundary = this.#widthEpochOverlayBoundary ?? capturedWidthEpochBoundary;
|
|
3515
|
+
const widthEpochSourceBoundary = widthEpochOccurred
|
|
3516
|
+
? this.resolveNativeScrollbackWidthEpoch(widthEpochBoundary)
|
|
3517
|
+
: undefined;
|
|
3518
|
+
const widthEpochCurrentRows = widthEpochOccurred
|
|
3519
|
+
? this.#getNativeScrollbackWidthEpochCurrentRows(widthEpochBoundary)
|
|
3520
|
+
: undefined;
|
|
3521
|
+
const widthEpochAppendOnly = widthEpochOccurred
|
|
3522
|
+
? this.#isNativeScrollbackWidthEpochAppendOnly(widthEpochBoundary)
|
|
3523
|
+
: true;
|
|
3524
|
+
if (resizeEventOccurred) {
|
|
3525
|
+
this.#multiplexerWidthEpochBoundary = undefined;
|
|
3526
|
+
this.#multiplexerWidthEpochPending = false;
|
|
3527
|
+
}
|
|
3027
3528
|
// A resize event with net-unchanged dimensions still reflowed the
|
|
3028
3529
|
// terminal buffer; classify it as a height change so geometry handling
|
|
3029
3530
|
// repaints instead of diffing against a screen that no longer exists.
|
|
@@ -3031,6 +3532,12 @@ export class TUI extends Container {
|
|
|
3031
3532
|
(this.#previousHeight > 0 && this.#previousHeight !== height) ||
|
|
3032
3533
|
(resizeEventOccurred && this.#previousHeight > 0);
|
|
3033
3534
|
const geometryChanged = widthChanged || heightChanged;
|
|
3535
|
+
const widthEpochReset = widthEpochOccurred && this.#resizeRepaintsInPlace();
|
|
3536
|
+
// A later width reset cannot use the opaque native ledger against
|
|
3537
|
+
// attachment rows from the current-width placement epoch. Capture that
|
|
3538
|
+
// epoch's seam before reset classification replaces its baseline.
|
|
3539
|
+
const placementEpochWatermark = this.#widthEpochBaselineRows === undefined ? this.#committedRows : prevWindowTop;
|
|
3540
|
+
if (widthEpochReset) this.#widthEpochCommittedPrefix = undefined;
|
|
3034
3541
|
|
|
3035
3542
|
// Committed-prefix audit. Rows below the audit mark are hard-verified
|
|
3036
3543
|
// exact bytes; rows between the mark and the current exactness boundary
|
|
@@ -3045,6 +3552,56 @@ export class TUI extends Container {
|
|
|
3045
3552
|
// every row), and skipped when the composed frame's stable prefix
|
|
3046
3553
|
// covers every verified row and no rows newly became final.
|
|
3047
3554
|
let committedRowsResynced = false;
|
|
3555
|
+
const widthEpochPrefix = this.#widthEpochCommittedPrefix;
|
|
3556
|
+
if (widthEpochPrefix && !geometryChanged && !this.#clearScrollbackOnNextRender) {
|
|
3557
|
+
let newlyFinalRows = 0;
|
|
3558
|
+
while (
|
|
3559
|
+
newlyFinalRows < widthEpochPrefix.frameRows.length &&
|
|
3560
|
+
widthEpochPrefix.frameRows[newlyFinalRows]! < finalBoundary
|
|
3561
|
+
) {
|
|
3562
|
+
newlyFinalRows++;
|
|
3563
|
+
}
|
|
3564
|
+
widthEpochPrefix.auditRows = Math.min(widthEpochPrefix.auditRows, newlyFinalRows);
|
|
3565
|
+
const verifiedTailRow = widthEpochPrefix.frameRows[widthEpochPrefix.auditRows - 1];
|
|
3566
|
+
const shouldAudit =
|
|
3567
|
+
newlyFinalRows > widthEpochPrefix.auditRows ||
|
|
3568
|
+
(verifiedTailRow !== undefined && this.#renderStablePrefixRows <= verifiedTailRow);
|
|
3569
|
+
let resyncTo = -1;
|
|
3570
|
+
const firstMissing = widthEpochPrefix.frameRows.findIndex(row => row >= frameLength);
|
|
3571
|
+
if (firstMissing >= 0) {
|
|
3572
|
+
const surviving = widthEpochPrefix.frameRows.slice(0, firstMissing).map(row => rawFrame[row]!);
|
|
3573
|
+
for (let i = 0; i < surviving.length; i++) {
|
|
3574
|
+
if (!rowsEquivalent(surviving[i]!, widthEpochPrefix.prefix[i]!)) {
|
|
3575
|
+
resyncTo = i;
|
|
3576
|
+
break;
|
|
3577
|
+
}
|
|
3578
|
+
}
|
|
3579
|
+
if (resyncTo < 0) resyncTo = firstMissing;
|
|
3580
|
+
} else if (shouldAudit) {
|
|
3581
|
+
const current = widthEpochPrefix.frameRows.map(row => rawFrame[row]!);
|
|
3582
|
+
resyncTo = findCommittedPrefixResync(
|
|
3583
|
+
current,
|
|
3584
|
+
widthEpochPrefix.prefix,
|
|
3585
|
+
widthEpochPrefix.auditRows,
|
|
3586
|
+
newlyFinalRows,
|
|
3587
|
+
);
|
|
3588
|
+
if (resyncTo < 0) widthEpochPrefix.auditRows = newlyFinalRows;
|
|
3589
|
+
}
|
|
3590
|
+
if (resyncTo >= 0) {
|
|
3591
|
+
const recoveryRow = Math.min(frameLength, widthEpochPrefix.frameRows[resyncTo] ?? frameLength);
|
|
3592
|
+
widthEpochPrefix.frameRows.length = resyncTo;
|
|
3593
|
+
widthEpochPrefix.prefix.length = resyncTo;
|
|
3594
|
+
widthEpochPrefix.auditRows = Math.min(widthEpochPrefix.auditRows, resyncTo);
|
|
3595
|
+
this.#committedRows = widthEpochPrefix.nativeBaseRows + resyncTo;
|
|
3596
|
+
this.#widthEpochBaselineRows = recoveryRow;
|
|
3597
|
+
this.#windowTopRow = recoveryRow;
|
|
3598
|
+
prevWindowTop = recoveryRow;
|
|
3599
|
+
if ($flag("PI_DEBUG_REDRAW")) {
|
|
3600
|
+
const msg = `[${new Date().toISOString()}] width epoch commit resync: local prefix diverged at row ${recoveryRow}; recommitting\n`;
|
|
3601
|
+
fs.appendFileSync(getDebugLogPath(), msg);
|
|
3602
|
+
}
|
|
3603
|
+
}
|
|
3604
|
+
}
|
|
3048
3605
|
const newlyFinalEnd = Math.min(this.#committedRows, finalBoundary);
|
|
3049
3606
|
// The exactness boundary can RETREAT (a markdown rewind, a mermaid fence
|
|
3050
3607
|
// appearing, a fast-path reset re-opening a block): rows verified under
|
|
@@ -3052,12 +3609,13 @@ export class TUI extends Container {
|
|
|
3052
3609
|
// snapshots instead of auditing content that is expected to change —
|
|
3053
3610
|
// their committed bytes stay as the visual record, and the next boundary
|
|
3054
3611
|
// rise strict-verifies them once like any other frozen row.
|
|
3055
|
-
if (this.#committedPrefixAuditRows > newlyFinalEnd) {
|
|
3612
|
+
if (this.#widthEpochBaselineRows === undefined && this.#committedPrefixAuditRows > newlyFinalEnd) {
|
|
3056
3613
|
this.#committedPrefixAuditRows = newlyFinalEnd;
|
|
3057
3614
|
}
|
|
3058
3615
|
const auditRan =
|
|
3059
3616
|
this.#hasEverRendered &&
|
|
3060
3617
|
!geometryChanged &&
|
|
3618
|
+
this.#widthEpochBaselineRows === undefined &&
|
|
3061
3619
|
!this.#clearScrollbackOnNextRender &&
|
|
3062
3620
|
(this.#renderStablePrefixRows < this.#committedPrefixAuditRows ||
|
|
3063
3621
|
newlyFinalEnd > this.#committedPrefixAuditRows);
|
|
@@ -3073,7 +3631,12 @@ export class TUI extends Container {
|
|
|
3073
3631
|
// record and the frame part ways — so the surviving exact prefix stays
|
|
3074
3632
|
// recognized and is never re-shown or re-committed. Only genuinely new
|
|
3075
3633
|
// content repaints below it.
|
|
3076
|
-
if (
|
|
3634
|
+
if (
|
|
3635
|
+
this.#widthEpochBaselineRows === undefined &&
|
|
3636
|
+
!geometryChanged &&
|
|
3637
|
+
!this.#clearScrollbackOnNextRender &&
|
|
3638
|
+
frameLength < this.#committedRows
|
|
3639
|
+
) {
|
|
3077
3640
|
const limit = Math.min(this.#committedRows, frameLength);
|
|
3078
3641
|
let diverged = limit;
|
|
3079
3642
|
for (let i = 0; i < limit; i++) {
|
|
@@ -3103,6 +3666,25 @@ export class TUI extends Container {
|
|
|
3103
3666
|
break;
|
|
3104
3667
|
}
|
|
3105
3668
|
}
|
|
3669
|
+
// Without a logical source boundary, pending growth folded into an
|
|
3670
|
+
// overlay-covered width reset cannot be separated from reflow. Replay
|
|
3671
|
+
// conservatively from row zero after the overlay closes: duplication is
|
|
3672
|
+
// preferable to dropping rows that were never emitted anywhere.
|
|
3673
|
+
if (widthEpochReset && hasVisibleOverlay && widthEpochSourceBoundary === undefined && resizeHadPendingRender) {
|
|
3674
|
+
this.#widthEpochOverlayReplayPending = true;
|
|
3675
|
+
}
|
|
3676
|
+
if (widthEpochReset && hasVisibleOverlay && this.#widthEpochOverlayBoundary === undefined) {
|
|
3677
|
+
this.#widthEpochOverlayBoundary = capturedWidthEpochBoundary;
|
|
3678
|
+
}
|
|
3679
|
+
const replayUnresolvedOverlayFrame = widthEpochReset && this.#widthEpochOverlayReplayPending;
|
|
3680
|
+
const replayUnresolvedWidthEpoch =
|
|
3681
|
+
replayUnresolvedOverlayFrame ||
|
|
3682
|
+
(widthEpochReset && liveRegionPinned && this.#widthEpochReplayUnresolved) ||
|
|
3683
|
+
(widthEpochReset &&
|
|
3684
|
+
resizeHadPendingRender &&
|
|
3685
|
+
widthEpochBoundary !== undefined &&
|
|
3686
|
+
widthEpochSourceBoundary === undefined);
|
|
3687
|
+
if (replayUnresolvedWidthEpoch) prevWindowTop = 0;
|
|
3106
3688
|
|
|
3107
3689
|
// 4. Classify. A resize is an explicit user gesture: normally the engine
|
|
3108
3690
|
// erases and replays so history rewraps at the new geometry (the reader
|
|
@@ -3130,10 +3712,44 @@ export class TUI extends Container {
|
|
|
3130
3712
|
const fullPaint = firstPaint || replaceRequested || geometryRebuild || divergenceRebuild;
|
|
3131
3713
|
let windowTop: number;
|
|
3132
3714
|
let chunkTo: number;
|
|
3715
|
+
let widthEpochAppendFrom = 0;
|
|
3716
|
+
let widthEpochAppendTo = 0;
|
|
3133
3717
|
if (fullPaint) {
|
|
3134
3718
|
committedPrefixResliced = true;
|
|
3135
3719
|
windowTop = Math.max(0, frameLength - height);
|
|
3136
3720
|
chunkTo = liveRegionPinned ? Math.min(windowTop, finalBoundary) : windowTop;
|
|
3721
|
+
} else if (widthEpochReset) {
|
|
3722
|
+
// A terminal width change ends the physical-row coordinate epoch.
|
|
3723
|
+
// Resolve the last emitted logical source boundary at the new width;
|
|
3724
|
+
// updates queued during debounce are the current-boundary suffix.
|
|
3725
|
+
// Components without the source contract retain the conservative
|
|
3726
|
+
// legacy fallback, but never compare cross-width counts when a marker
|
|
3727
|
+
// resolved successfully.
|
|
3728
|
+
this.#widthEpochBaselineRows = replayUnresolvedWidthEpoch
|
|
3729
|
+
? 0
|
|
3730
|
+
: (widthEpochSourceBoundary ??
|
|
3731
|
+
(resizeHadPendingRender ? Math.min(frameLength, this.#previousFrameLength) : frameLength));
|
|
3732
|
+
this.#widthEpochReplayUnresolved = replayUnresolvedWidthEpoch;
|
|
3733
|
+
windowTop = Math.max(0, frameLength - height);
|
|
3734
|
+
chunkTo = this.#committedRows;
|
|
3735
|
+
widthEpochAppendFrom = this.#widthEpochBaselineRows;
|
|
3736
|
+
widthEpochAppendTo =
|
|
3737
|
+
hasVisibleOverlay || widthEpochCurrentRows === undefined
|
|
3738
|
+
? hasVisibleOverlay
|
|
3739
|
+
? widthEpochAppendFrom
|
|
3740
|
+
: Math.max(widthEpochAppendFrom, liveRegionPinned ? finalBoundary : frameLength)
|
|
3741
|
+
: Math.max(widthEpochAppendFrom, widthEpochCurrentRows);
|
|
3742
|
+
} else if (this.#widthEpochBaselineRows !== undefined) {
|
|
3743
|
+
// Only rows physically appended after the width epoch may drive the
|
|
3744
|
+
// terminal forward. Keep the native commit count independent of this
|
|
3745
|
+
// frame-coordinate baseline. Overlays defer all emission; a pinned
|
|
3746
|
+
// live region clips advancement to its exact final boundary so mutable
|
|
3747
|
+
// rows remain viewport-only until finalization.
|
|
3748
|
+
windowTop = Math.max(0, frameLength - height);
|
|
3749
|
+
chunkTo = this.#committedRows;
|
|
3750
|
+
widthEpochAppendFrom = this.#widthEpochBaselineRows;
|
|
3751
|
+
const appendBoundary = liveRegionPinned ? finalBoundary : frameLength;
|
|
3752
|
+
widthEpochAppendTo = hasVisibleOverlay ? widthEpochAppendFrom : Math.max(widthEpochAppendFrom, appendBoundary);
|
|
3137
3753
|
} else if (
|
|
3138
3754
|
frameLength <= this.#committedRows ||
|
|
3139
3755
|
(committedRowsResynced &&
|
|
@@ -3251,9 +3867,15 @@ export class TUI extends Container {
|
|
|
3251
3867
|
// Feed this frame's commit target to the placement-epoch tracker before
|
|
3252
3868
|
// any placement resolves against it — an epoch whose rows commit during
|
|
3253
3869
|
// frames that never rewrite its line must still advance on the next
|
|
3254
|
-
// re-emission
|
|
3255
|
-
//
|
|
3256
|
-
|
|
3870
|
+
// re-emission. Width epochs retain an opaque native-row ledger, so close
|
|
3871
|
+
// the old placement-coordinate epoch with its captured seam on reset and
|
|
3872
|
+
// use the current-width commit seam calculated below thereafter.
|
|
3873
|
+
if (widthEpochReset) {
|
|
3874
|
+
this.#imageBudget.observeCommitWatermark(placementEpochWatermark);
|
|
3875
|
+
this.#imageBudget.beginPlacementCoordinateEpoch();
|
|
3876
|
+
} else if (intent.kind === "fullPaint" || this.#widthEpochBaselineRows === undefined) {
|
|
3877
|
+
this.#imageBudget.observeCommitWatermark(chunkTo);
|
|
3878
|
+
}
|
|
3257
3879
|
|
|
3258
3880
|
// 6. Emit.
|
|
3259
3881
|
if (intent.kind === "fullPaint") {
|
|
@@ -3264,16 +3886,145 @@ export class TUI extends Container {
|
|
|
3264
3886
|
cursorTrackingLineCount,
|
|
3265
3887
|
boundConptyPaint: !unboundedConptyPaint,
|
|
3266
3888
|
leadingSequence: deferredAltExit,
|
|
3889
|
+
copyScreenToScrollback: true,
|
|
3267
3890
|
});
|
|
3268
3891
|
this.#pendingAltExit = "";
|
|
3269
3892
|
this.#committedPrefix = rawFrame.slice(0, chunkTo);
|
|
3270
3893
|
this.#committedPrefixAuditRows = Math.min(chunkTo, finalBoundary);
|
|
3271
3894
|
this.#clearScrollbackOnNextRender = false;
|
|
3272
3895
|
this.#hasEverRendered = true;
|
|
3896
|
+
this.#widthEpochBaselineRows = undefined;
|
|
3897
|
+
this.#widthEpochReplayUnresolved = false;
|
|
3898
|
+
this.#widthEpochOverlayReplayPending = false;
|
|
3899
|
+
this.#widthEpochOverlayBoundary = undefined;
|
|
3900
|
+
this.#widthEpochCommittedPrefix = undefined;
|
|
3273
3901
|
this.#publishCommittedRows();
|
|
3274
3902
|
if (!firstPaint && frameLength > height) this.#armPostFullPaintSettle();
|
|
3275
3903
|
return;
|
|
3276
3904
|
}
|
|
3905
|
+
if (this.#widthEpochBaselineRows !== undefined) {
|
|
3906
|
+
const logicalAppend =
|
|
3907
|
+
!replayUnresolvedOverlayFrame &&
|
|
3908
|
+
widthEpochSourceBoundary !== undefined &&
|
|
3909
|
+
widthEpochCurrentRows !== undefined;
|
|
3910
|
+
const logicalPrefixAppend = logicalAppend && widthEpochAppendOnly;
|
|
3911
|
+
let scrollRows: number;
|
|
3912
|
+
let commitFrom: number;
|
|
3913
|
+
let commitTo: number;
|
|
3914
|
+
if (replayUnresolvedWidthEpoch) {
|
|
3915
|
+
commitFrom = 0;
|
|
3916
|
+
commitTo = liveRegionPinned ? Math.min(windowTop, finalBoundary) : windowTop;
|
|
3917
|
+
scrollRows = commitTo;
|
|
3918
|
+
} else if (logicalAppend && !logicalPrefixAppend) {
|
|
3919
|
+
const sourceWindowTop = Math.max(0, widthEpochSourceBoundary - height);
|
|
3920
|
+
const logicalSuffixRows = Math.max(0, widthEpochCurrentRows - widthEpochSourceBoundary);
|
|
3921
|
+
const appendWindowMovement = Math.max(0, windowTop - sourceWindowTop);
|
|
3922
|
+
scrollRows = Math.min(logicalSuffixRows, appendWindowMovement);
|
|
3923
|
+
commitFrom = Math.max(0, windowTop - scrollRows);
|
|
3924
|
+
commitTo = commitFrom + scrollRows;
|
|
3925
|
+
} else if (!logicalAppend) {
|
|
3926
|
+
const windowMovement = Math.max(0, windowTop - prevWindowTop);
|
|
3927
|
+
const previousViewportRows = Math.min(
|
|
3928
|
+
this.#previousHeight,
|
|
3929
|
+
Math.max(0, this.#previousFrameLength - prevWindowTop),
|
|
3930
|
+
);
|
|
3931
|
+
const hostHeightShrinkRows = Math.min(windowMovement, Math.max(0, previousViewportRows - height));
|
|
3932
|
+
const appendWindowMovement = windowMovement - hostHeightShrinkRows;
|
|
3933
|
+
const epochGrowthRows = Math.max(0, widthEpochAppendTo - widthEpochAppendFrom);
|
|
3934
|
+
scrollRows = Math.min(appendWindowMovement, epochGrowthRows);
|
|
3935
|
+
commitFrom = prevWindowTop + hostHeightShrinkRows;
|
|
3936
|
+
commitTo = commitFrom + scrollRows;
|
|
3937
|
+
} else {
|
|
3938
|
+
commitFrom = widthEpochSourceBoundary;
|
|
3939
|
+
const logicalSuffixRows = Math.max(0, widthEpochCurrentRows - commitFrom);
|
|
3940
|
+
const sourceWindowTop = Math.max(0, commitFrom - height);
|
|
3941
|
+
const appendWindowMovement = Math.max(0, windowTop - sourceWindowTop);
|
|
3942
|
+
scrollRows = Math.min(logicalSuffixRows, appendWindowMovement);
|
|
3943
|
+
commitTo = commitFrom + scrollRows;
|
|
3944
|
+
}
|
|
3945
|
+
if (hasVisibleOverlay) {
|
|
3946
|
+
scrollRows = 0;
|
|
3947
|
+
commitTo = commitFrom;
|
|
3948
|
+
}
|
|
3949
|
+
this.#imageBudget.observeCommitWatermark(commitTo);
|
|
3950
|
+
this.#emitWidthEpochBaseline(frame, window, width, height, cursorPos, purgeSequence, imageTransmitBuffer, {
|
|
3951
|
+
repaintFromScreenRow: 0,
|
|
3952
|
+
commitFrom,
|
|
3953
|
+
commitTo,
|
|
3954
|
+
appendOnly: logicalAppend,
|
|
3955
|
+
prepaintWindowTop: logicalAppend && !logicalPrefixAppend && !hasVisibleOverlay ? commitFrom : undefined,
|
|
3956
|
+
windowTop,
|
|
3957
|
+
cursorTrackingLineCount,
|
|
3958
|
+
leadingSequence: deferredAltExit,
|
|
3959
|
+
});
|
|
3960
|
+
this.#pendingAltExit = "";
|
|
3961
|
+
if (!hasVisibleOverlay) {
|
|
3962
|
+
this.#widthEpochOverlayReplayPending = false;
|
|
3963
|
+
this.#widthEpochOverlayBoundary = undefined;
|
|
3964
|
+
if (liveRegionPinned) {
|
|
3965
|
+
this.#widthEpochBaselineRows = this.#widthEpochReplayUnresolved ? commitTo : widthEpochAppendTo;
|
|
3966
|
+
this.#windowTopRow = logicalAppend ? windowTop : prevWindowTop + scrollRows;
|
|
3967
|
+
} else {
|
|
3968
|
+
this.#widthEpochBaselineRows = frameLength;
|
|
3969
|
+
this.#widthEpochReplayUnresolved = false;
|
|
3970
|
+
this.#windowTopRow = windowTop;
|
|
3971
|
+
}
|
|
3972
|
+
this.#committedRows += scrollRows;
|
|
3973
|
+
if (!widthEpochReset && this.#widthEpochCommittedPrefix) {
|
|
3974
|
+
const epochPrefix = this.#widthEpochCommittedPrefix;
|
|
3975
|
+
// A height grow can expose tracked rows and let them scroll off
|
|
3976
|
+
// again. Retire that superseded logical suffix into the opaque
|
|
3977
|
+
// native base before recording its fresh same-width snapshot.
|
|
3978
|
+
const overlap = epochPrefix.frameRows.findIndex(row => row >= commitFrom);
|
|
3979
|
+
if (overlap >= 0) {
|
|
3980
|
+
epochPrefix.nativeBaseRows += epochPrefix.frameRows.length - overlap;
|
|
3981
|
+
epochPrefix.frameRows.length = overlap;
|
|
3982
|
+
epochPrefix.prefix.length = overlap;
|
|
3983
|
+
epochPrefix.auditRows = Math.min(epochPrefix.auditRows, overlap);
|
|
3984
|
+
}
|
|
3985
|
+
for (let row = commitFrom; row < commitTo; row++) {
|
|
3986
|
+
epochPrefix.frameRows.push(row);
|
|
3987
|
+
epochPrefix.prefix.push(rawFrame[row]!);
|
|
3988
|
+
}
|
|
3989
|
+
while (
|
|
3990
|
+
epochPrefix.auditRows < epochPrefix.frameRows.length &&
|
|
3991
|
+
epochPrefix.frameRows[epochPrefix.auditRows]! < finalBoundary
|
|
3992
|
+
) {
|
|
3993
|
+
epochPrefix.auditRows++;
|
|
3994
|
+
}
|
|
3995
|
+
}
|
|
3996
|
+
} else if (widthEpochReset) {
|
|
3997
|
+
// The overlay freezes commits and subsequent hidden-growth movement,
|
|
3998
|
+
// but the resize itself changed physical-row coordinates. Rebase the
|
|
3999
|
+
// window reference once so growth backfills from the settled width.
|
|
4000
|
+
this.#windowTopRow = replayUnresolvedOverlayFrame
|
|
4001
|
+
? 0
|
|
4002
|
+
: logicalAppend
|
|
4003
|
+
? Math.max(0, widthEpochSourceBoundary! - height)
|
|
4004
|
+
: windowTop;
|
|
4005
|
+
}
|
|
4006
|
+
if (widthEpochReset) {
|
|
4007
|
+
let trackedFrom = commitFrom;
|
|
4008
|
+
let trackedTo = commitTo;
|
|
4009
|
+
if (logicalPrefixAppend) trackedTo = Math.max(trackedFrom, trackedTo - height);
|
|
4010
|
+
if (trackedTo > this.#windowTopRow) {
|
|
4011
|
+
trackedFrom = trackedTo;
|
|
4012
|
+
}
|
|
4013
|
+
const frameRows = Array.from({ length: trackedTo - trackedFrom }, (_value, index) => trackedFrom + index);
|
|
4014
|
+
let auditRows = 0;
|
|
4015
|
+
while (auditRows < frameRows.length && frameRows[auditRows]! < finalBoundary) auditRows++;
|
|
4016
|
+
this.#widthEpochCommittedPrefix = {
|
|
4017
|
+
nativeBaseRows: this.#committedRows - frameRows.length,
|
|
4018
|
+
frameRows,
|
|
4019
|
+
prefix: frameRows.map(row => rawFrame[row]!),
|
|
4020
|
+
auditRows,
|
|
4021
|
+
};
|
|
4022
|
+
}
|
|
4023
|
+
this.#clearScrollbackOnNextRender = false;
|
|
4024
|
+
this.#hasEverRendered = true;
|
|
4025
|
+
this.#publishCommittedRows(this.#windowTopRow);
|
|
4026
|
+
return;
|
|
4027
|
+
}
|
|
3277
4028
|
if (imageTransmitBuffer.length > 0) {
|
|
3278
4029
|
this.terminal.write(imageTransmitBuffer);
|
|
3279
4030
|
}
|
|
@@ -3334,11 +4085,11 @@ export class TUI extends Container {
|
|
|
3334
4085
|
* rows that just entered immutable native scrollback, stranding an
|
|
3335
4086
|
* orphaned copy above the repainted block.
|
|
3336
4087
|
*/
|
|
3337
|
-
#publishCommittedRows(): void {
|
|
4088
|
+
#publishCommittedRows(committedRows = this.#committedRows): void {
|
|
3338
4089
|
for (const segment of this.#frameSegments) {
|
|
3339
4090
|
setNativeScrollbackCommittedRows(
|
|
3340
4091
|
segment.component,
|
|
3341
|
-
Math.min(segment.rowCount, Math.max(0,
|
|
4092
|
+
Math.min(segment.rowCount, Math.max(0, committedRows - segment.start)),
|
|
3342
4093
|
);
|
|
3343
4094
|
}
|
|
3344
4095
|
}
|
|
@@ -3552,7 +4303,44 @@ export class TUI extends Container {
|
|
|
3552
4303
|
return col;
|
|
3553
4304
|
}
|
|
3554
4305
|
|
|
3555
|
-
|
|
4306
|
+
/**
|
|
4307
|
+
* Columns to preserve when `lines[index]` is a blank row that a scaled OSC 66
|
|
4308
|
+
* heading flows into, or `-1` when it is not such a row. A scale-`s` heading
|
|
4309
|
+
* occupies `s` rows and `visibleWidth` columns, so the `s - 1` blank rows
|
|
4310
|
+
* beneath it hold the multicell glyph's lower half; those columns must never
|
|
4311
|
+
* be erased or overdrawn or the glyph vanishes, leaving reserved-but-invisible
|
|
4312
|
+
* space (issue #8318). Scans upward across the contiguous blank run so every
|
|
4313
|
+
* reserved row of a scale ≥ 3 heading is covered, not just the first.
|
|
4314
|
+
*/
|
|
4315
|
+
#osc66SpacerGlyphWidth(lines: readonly string[], index: number): number {
|
|
4316
|
+
if (index <= 0 || lines[index] !== "") return -1;
|
|
4317
|
+
let gap = 1;
|
|
4318
|
+
while (gap < TUI.#OSC66_MAX_SPACER_ROWS && index - gap > 0 && lines[index - gap] === "") {
|
|
4319
|
+
gap++;
|
|
4320
|
+
}
|
|
4321
|
+
const above = lines[index - gap];
|
|
4322
|
+
if (above === undefined || !isOsc66Line(above) || gap > osc66MaxScale(above) - 1) return -1;
|
|
4323
|
+
return visibleWidth(above);
|
|
4324
|
+
}
|
|
4325
|
+
|
|
4326
|
+
#lineRewriteSequence(
|
|
4327
|
+
line: string,
|
|
4328
|
+
width: number,
|
|
4329
|
+
screenRow = -1,
|
|
4330
|
+
frameRow = -1,
|
|
4331
|
+
committedTo = -1,
|
|
4332
|
+
spacerGlyphWidth = -1,
|
|
4333
|
+
): string {
|
|
4334
|
+
// Reserved lower half of a scaled OSC 66 heading. The glyph re-emitted on
|
|
4335
|
+
// the row above owns columns `[0, spacerGlyphWidth)` here, so preserve
|
|
4336
|
+
// them (any erase there clears the glyph — issue #8318) but still clear
|
|
4337
|
+
// stale cells to their right: a row can reflow from wider text into this
|
|
4338
|
+
// spacer, and the glyph write never covers those columns. Leading reset
|
|
4339
|
+
// keeps the erase on the default background (BCE).
|
|
4340
|
+
if (spacerGlyphWidth >= 0) {
|
|
4341
|
+
if (spacerGlyphWidth >= width) return "";
|
|
4342
|
+
return `${SEGMENT_RESET}\x1b[${spacerGlyphWidth}C${ERASE_TO_END_OF_LINE}`;
|
|
4343
|
+
}
|
|
3556
4344
|
if (TERMINAL.isImageLine(line)) {
|
|
3557
4345
|
return ERASE_LINE + this.#imageLineSequence(line, screenRow, frameRow, committedTo);
|
|
3558
4346
|
}
|
|
@@ -3646,6 +4434,122 @@ export class TUI extends Container {
|
|
|
3646
4434
|
);
|
|
3647
4435
|
}
|
|
3648
4436
|
|
|
4437
|
+
#emitWidthEpochBaseline(
|
|
4438
|
+
frame: readonly string[],
|
|
4439
|
+
window: string[],
|
|
4440
|
+
width: number,
|
|
4441
|
+
height: number,
|
|
4442
|
+
cursorPos: { row: number; col: number } | null,
|
|
4443
|
+
purgeSequence: string,
|
|
4444
|
+
imageTransmitBuffer: string,
|
|
4445
|
+
options: {
|
|
4446
|
+
repaintFromScreenRow: number;
|
|
4447
|
+
commitFrom: number;
|
|
4448
|
+
commitTo: number;
|
|
4449
|
+
appendOnly: boolean;
|
|
4450
|
+
prepaintWindowTop?: number;
|
|
4451
|
+
windowTop: number;
|
|
4452
|
+
cursorTrackingLineCount: number;
|
|
4453
|
+
leadingSequence: string;
|
|
4454
|
+
},
|
|
4455
|
+
): void {
|
|
4456
|
+
this.#fullRedrawCount += 1;
|
|
4457
|
+
let buffer = this.#paintBeginSequence + purgeSequence + options.leadingSequence + imageTransmitBuffer;
|
|
4458
|
+
if (options.commitTo > options.commitFrom) {
|
|
4459
|
+
if (options.appendOnly) {
|
|
4460
|
+
if (options.prepaintWindowTop !== undefined) {
|
|
4461
|
+
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
4462
|
+
const frameRow = options.prepaintWindowTop + screenRow;
|
|
4463
|
+
buffer += `\x1b[${screenRow + 1};1H`;
|
|
4464
|
+
buffer += this.#lineRewriteSequence(
|
|
4465
|
+
frame[frameRow] ?? "",
|
|
4466
|
+
width,
|
|
4467
|
+
screenRow,
|
|
4468
|
+
frameRow,
|
|
4469
|
+
options.commitTo,
|
|
4470
|
+
);
|
|
4471
|
+
}
|
|
4472
|
+
}
|
|
4473
|
+
buffer += `\x1b[${height};1H`;
|
|
4474
|
+
for (let row = options.commitFrom; row < options.commitTo; row++) {
|
|
4475
|
+
const enteringRow = options.prepaintWindowTop === undefined ? row : row + height;
|
|
4476
|
+
buffer += "\r\n";
|
|
4477
|
+
buffer += this.#lineRewriteSequence(
|
|
4478
|
+
frame[enteringRow] ?? "",
|
|
4479
|
+
width,
|
|
4480
|
+
height - 1,
|
|
4481
|
+
enteringRow,
|
|
4482
|
+
options.commitTo,
|
|
4483
|
+
);
|
|
4484
|
+
}
|
|
4485
|
+
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
4486
|
+
buffer += `\x1b[${screenRow + 1};1H`;
|
|
4487
|
+
buffer += this.#lineRewriteSequence(
|
|
4488
|
+
window[screenRow] ?? "",
|
|
4489
|
+
width,
|
|
4490
|
+
screenRow,
|
|
4491
|
+
options.windowTop + screenRow,
|
|
4492
|
+
options.commitTo,
|
|
4493
|
+
);
|
|
4494
|
+
}
|
|
4495
|
+
} else {
|
|
4496
|
+
buffer += "\x1b[1;1H";
|
|
4497
|
+
let wroteLine = false;
|
|
4498
|
+
for (let row = options.commitFrom; row < options.commitTo; row++) {
|
|
4499
|
+
if (wroteLine) buffer += "\r\n";
|
|
4500
|
+
buffer += this.#lineRewriteSequence(
|
|
4501
|
+
frame[row] ?? "",
|
|
4502
|
+
width,
|
|
4503
|
+
Math.min(row - options.commitFrom, height - 1),
|
|
4504
|
+
row,
|
|
4505
|
+
options.commitTo,
|
|
4506
|
+
);
|
|
4507
|
+
wroteLine = true;
|
|
4508
|
+
}
|
|
4509
|
+
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
4510
|
+
if (wroteLine) buffer += "\r\n";
|
|
4511
|
+
buffer += this.#lineRewriteSequence(
|
|
4512
|
+
window[screenRow] ?? "",
|
|
4513
|
+
width,
|
|
4514
|
+
Math.min(options.commitTo - options.commitFrom + screenRow, height - 1),
|
|
4515
|
+
options.windowTop + screenRow,
|
|
4516
|
+
options.commitTo,
|
|
4517
|
+
);
|
|
4518
|
+
wroteLine = true;
|
|
4519
|
+
}
|
|
4520
|
+
}
|
|
4521
|
+
} else {
|
|
4522
|
+
for (let screenRow = options.repaintFromScreenRow; screenRow < height; screenRow++) {
|
|
4523
|
+
buffer += `\x1b[${screenRow + 1};1H`;
|
|
4524
|
+
buffer += this.#lineRewriteSequence(
|
|
4525
|
+
window[screenRow] ?? "",
|
|
4526
|
+
width,
|
|
4527
|
+
screenRow,
|
|
4528
|
+
options.windowTop + screenRow,
|
|
4529
|
+
options.commitTo,
|
|
4530
|
+
);
|
|
4531
|
+
}
|
|
4532
|
+
}
|
|
4533
|
+
buffer += "\r";
|
|
4534
|
+
const contentRows = Math.max(1, Math.min(height, frame.length - options.windowTop));
|
|
4535
|
+
const contentBottomRow = options.windowTop + contentRows - 1;
|
|
4536
|
+
const target = this.#targetHardwareCursorState(cursorPos, options.cursorTrackingLineCount);
|
|
4537
|
+
if (target) {
|
|
4538
|
+
const screenRow = Math.max(0, Math.min(height - 1, target.row - options.windowTop));
|
|
4539
|
+
buffer += `\x1b[${screenRow + 1};${target.col + 1}H`;
|
|
4540
|
+
buffer += target.visible ? "\x1b[?25h" : "\x1b[?25l";
|
|
4541
|
+
} else {
|
|
4542
|
+
buffer += `\x1b[${contentRows};1H\x1b[?25l`;
|
|
4543
|
+
}
|
|
4544
|
+
buffer += this.#paintEndSequence;
|
|
4545
|
+
this.terminal.write(buffer);
|
|
4546
|
+
|
|
4547
|
+
this.#commit(frame, window, width, height, {
|
|
4548
|
+
toRow: target?.row ?? contentBottomRow,
|
|
4549
|
+
state: target,
|
|
4550
|
+
visible: target?.visible ?? false,
|
|
4551
|
+
});
|
|
4552
|
+
}
|
|
3649
4553
|
/**
|
|
3650
4554
|
* Replay the frame from home, optionally clearing native scrollback first:
|
|
3651
4555
|
* committed prefix `[0, chunkTo)` followed by the visible window. ED3
|
|
@@ -3677,6 +4581,7 @@ export class TUI extends Container {
|
|
|
3677
4581
|
*/
|
|
3678
4582
|
boundConptyPaint: boolean;
|
|
3679
4583
|
leadingSequence: string;
|
|
4584
|
+
copyScreenToScrollback: boolean;
|
|
3680
4585
|
},
|
|
3681
4586
|
): void {
|
|
3682
4587
|
this.#fullRedrawCount += 1;
|
|
@@ -3734,12 +4639,11 @@ export class TUI extends Container {
|
|
|
3734
4639
|
}
|
|
3735
4640
|
}
|
|
3736
4641
|
} else {
|
|
3737
|
-
//
|
|
3738
|
-
// terminals
|
|
3739
|
-
//
|
|
3740
|
-
//
|
|
3741
|
-
|
|
3742
|
-
if (TERMINAL.supportsScreenToScrollback) buffer += "\x1b[22J";
|
|
4642
|
+
// ED2 clears only the viewport. Initial/non-destructive replays may
|
|
4643
|
+
// first ask supporting terminals to preserve the prior screen, but a
|
|
4644
|
+
// width-epoch repaint MUST NOT copy that invalidated viewport into
|
|
4645
|
+
// native history.
|
|
4646
|
+
if (options.copyScreenToScrollback && TERMINAL.supportsScreenToScrollback) buffer += "\x1b[22J";
|
|
3743
4647
|
buffer += "\x1b[2J\x1b[H";
|
|
3744
4648
|
}
|
|
3745
4649
|
if (imageTransmitBuffer.length > 0) buffer += imageTransmitBuffer;
|
|
@@ -3771,7 +4675,14 @@ export class TUI extends Container {
|
|
|
3771
4675
|
if (i > 0) buffer += "\r\n";
|
|
3772
4676
|
const writeRow = Math.min(i, height - 1);
|
|
3773
4677
|
buffer += options.clearScrollback
|
|
3774
|
-
? this.#lineRewriteSequence(
|
|
4678
|
+
? this.#lineRewriteSequence(
|
|
4679
|
+
frame[i] ?? "",
|
|
4680
|
+
width,
|
|
4681
|
+
writeRow,
|
|
4682
|
+
i,
|
|
4683
|
+
chunkTo,
|
|
4684
|
+
this.#osc66SpacerGlyphWidth(frame, i),
|
|
4685
|
+
)
|
|
3775
4686
|
: this.#terminalLine(frame[i] ?? "", writeRow, i, chunkTo);
|
|
3776
4687
|
}
|
|
3777
4688
|
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
@@ -3780,7 +4691,14 @@ export class TUI extends Container {
|
|
|
3780
4691
|
const writeRow = Math.min(chunkTo + screenRow, height - 1);
|
|
3781
4692
|
const frameRow = windowTop + screenRow;
|
|
3782
4693
|
buffer += options.clearScrollback
|
|
3783
|
-
? this.#lineRewriteSequence(
|
|
4694
|
+
? this.#lineRewriteSequence(
|
|
4695
|
+
line,
|
|
4696
|
+
width,
|
|
4697
|
+
writeRow,
|
|
4698
|
+
frameRow,
|
|
4699
|
+
chunkTo,
|
|
4700
|
+
this.#osc66SpacerGlyphWidth(frame, frameRow),
|
|
4701
|
+
)
|
|
3784
4702
|
: this.#terminalLine(line, writeRow, frameRow, chunkTo);
|
|
3785
4703
|
}
|
|
3786
4704
|
} else {
|
|
@@ -3792,7 +4710,14 @@ export class TUI extends Container {
|
|
|
3792
4710
|
const line = visibleTexts && i >= visibleStart ? visibleTexts[i - visibleStart] : (paintLines[i] ?? "");
|
|
3793
4711
|
const writeRow = Math.min(i, height - 1);
|
|
3794
4712
|
buffer += options.clearScrollback
|
|
3795
|
-
? this.#lineRewriteSequence(
|
|
4713
|
+
? this.#lineRewriteSequence(
|
|
4714
|
+
line,
|
|
4715
|
+
width,
|
|
4716
|
+
writeRow,
|
|
4717
|
+
-1,
|
|
4718
|
+
chunkTo,
|
|
4719
|
+
this.#osc66SpacerGlyphWidth(paintLines, i),
|
|
4720
|
+
)
|
|
3796
4721
|
: this.#terminalLine(line, writeRow, -1, chunkTo);
|
|
3797
4722
|
}
|
|
3798
4723
|
}
|
|
@@ -3879,43 +4804,59 @@ export class TUI extends Container {
|
|
|
3879
4804
|
// off a partial walk. The settle paint's own beginPass()/endPass() is the
|
|
3880
4805
|
// authoritative accounting, and its beginPass() wipes these frames.
|
|
3881
4806
|
this.#imageBudget.beginPass(true);
|
|
3882
|
-
const {
|
|
3883
|
-
this.#emitResizeViewport(
|
|
4807
|
+
const { framed, viewportTop, contentRows } = this.#composeResizeViewport(width, height);
|
|
4808
|
+
this.#emitResizeViewport(framed, viewportTop, height, contentRows, width);
|
|
3884
4809
|
this.#resizeViewportPaintCount += 1;
|
|
3885
4810
|
}
|
|
3886
4811
|
|
|
3887
4812
|
/**
|
|
3888
4813
|
* Build the viewport window for a resize fast-path frame: the bottom
|
|
3889
4814
|
* `height` rows of the would-be full frame, collected bottom-up across root
|
|
3890
|
-
* children
|
|
3891
|
-
*
|
|
4815
|
+
* children, plus up to {@link #OSC66_MAX_SPACER_ROWS} rows above the
|
|
4816
|
+
* fold. {@link ViewportTailProvider}s (the transcript) yield only their tail;
|
|
4817
|
+
* the small live-region children below render in full — so every child
|
|
3892
4818
|
* entirely above the fold is skipped. A frame shorter than the viewport is
|
|
3893
4819
|
* top-aligned with blank rows below, matching the full-paint window geometry
|
|
3894
4820
|
* (windowTop = max(0, frameLength - height)). Cursor markers are stripped
|
|
3895
4821
|
* (the drag hides the hardware cursor) and rows are width-fitted via the
|
|
3896
4822
|
* stateless preparer, so no persistent prepared-frame cache is touched.
|
|
4823
|
+
*
|
|
4824
|
+
* Returns the visible rows preceded by the context rows in frame order
|
|
4825
|
+
* (`framed`), the index where the viewport begins (`viewportTop`), and the
|
|
4826
|
+
* visible content count. The context rows are never emitted; they only let
|
|
4827
|
+
* {@link #osc66SpacerGlyphWidth} see a scaled heading that scrolled just
|
|
4828
|
+
* above the fold, so its reserved rows are preserved instead of erased
|
|
4829
|
+
* (issue #8318).
|
|
3897
4830
|
*/
|
|
3898
|
-
#composeResizeViewport(
|
|
3899
|
-
|
|
4831
|
+
#composeResizeViewport(
|
|
4832
|
+
width: number,
|
|
4833
|
+
height: number,
|
|
4834
|
+
): { framed: readonly string[]; viewportTop: number; contentRows: number } {
|
|
4835
|
+
const maxRows = height + TUI.#OSC66_MAX_SPACER_ROWS;
|
|
4836
|
+
const tail: string[] = []; // bottom-first: viewport rows plus context above
|
|
3900
4837
|
const children = this.children;
|
|
3901
|
-
for (let i = children.length - 1; i >= 0 && tail.length <
|
|
4838
|
+
for (let i = children.length - 1; i >= 0 && tail.length < maxRows; i--) {
|
|
3902
4839
|
const child = children[i]!;
|
|
3903
4840
|
const provider = asViewportTailProvider(child);
|
|
3904
|
-
const rows = provider ? provider.renderViewportTail(width,
|
|
3905
|
-
for (let r = rows.length - 1; r >= 0 && tail.length <
|
|
4841
|
+
const rows = provider ? provider.renderViewportTail(width, maxRows - tail.length) : child.render(width);
|
|
4842
|
+
for (let r = rows.length - 1; r >= 0 && tail.length < maxRows; r--) {
|
|
3906
4843
|
tail.push(rows[r]!);
|
|
3907
4844
|
}
|
|
3908
4845
|
}
|
|
3909
|
-
const
|
|
4846
|
+
const contentRows = Math.min(tail.length, height);
|
|
4847
|
+
const extra = tail.length - contentRows; // context rows above the fold
|
|
3910
4848
|
const window: string[] = new Array(height);
|
|
3911
4849
|
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
3912
|
-
// `tail` holds the bottom
|
|
3913
|
-
//
|
|
3914
|
-
|
|
3915
|
-
window[screenRow] = screenRow < count ? tail[count - 1 - screenRow]! : "";
|
|
4850
|
+
// `tail` holds the bottom rows first. The bottom `contentRows` fill the
|
|
4851
|
+
// viewport (top-aligned with blanks below on underflow).
|
|
4852
|
+
window[screenRow] = screenRow < contentRows ? tail[contentRows - 1 - screenRow]! : "";
|
|
3916
4853
|
}
|
|
3917
4854
|
this.#extractCursorMarkers(window);
|
|
3918
|
-
|
|
4855
|
+
// Frame order: context rows above the fold (top-first) then the window.
|
|
4856
|
+
const framed: string[] = new Array(extra + height);
|
|
4857
|
+
for (let k = 0; k < extra; k++) framed[k] = tail[tail.length - 1 - k]!;
|
|
4858
|
+
for (let screenRow = 0; screenRow < height; screenRow++) framed[extra + screenRow] = window[screenRow]!;
|
|
4859
|
+
return { framed: this.#prepareLinesArray(framed, width), viewportTop: extra, contentRows };
|
|
3919
4860
|
}
|
|
3920
4861
|
|
|
3921
4862
|
/**
|
|
@@ -3985,13 +4926,30 @@ export class TUI extends Container {
|
|
|
3985
4926
|
* flash, #5854). Normal-screen history is rebuilt once at settle via
|
|
3986
4927
|
* `#emitFullPaint`.
|
|
3987
4928
|
*/
|
|
3988
|
-
#emitResizeViewport(
|
|
4929
|
+
#emitResizeViewport(
|
|
4930
|
+
framed: readonly string[],
|
|
4931
|
+
viewportTop: number,
|
|
4932
|
+
height: number,
|
|
4933
|
+
contentRows: number,
|
|
4934
|
+
width: number,
|
|
4935
|
+
): void {
|
|
3989
4936
|
const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
|
|
3990
4937
|
const altEnter = widthChanged ? this.#enterResizeAltSequence() : "";
|
|
3991
4938
|
let buffer = `${this.#paintBeginSequence + altEnter}\x1b[H`;
|
|
3992
4939
|
for (let r = 0; r < height; r++) {
|
|
3993
4940
|
if (r > 0) buffer += "\r\n";
|
|
3994
|
-
|
|
4941
|
+
// `framed` carries context rows above the fold; the visible window
|
|
4942
|
+
// starts at `viewportTop`, and the spacer lookup scans within `framed`
|
|
4943
|
+
// so a heading just above the fold is still seen (issue #8318).
|
|
4944
|
+
const idx = viewportTop + r;
|
|
4945
|
+
buffer += this.#lineRewriteSequence(
|
|
4946
|
+
framed[idx] ?? "",
|
|
4947
|
+
width,
|
|
4948
|
+
r,
|
|
4949
|
+
-1,
|
|
4950
|
+
this.#committedRows,
|
|
4951
|
+
this.#osc66SpacerGlyphWidth(framed, idx),
|
|
4952
|
+
);
|
|
3995
4953
|
}
|
|
3996
4954
|
// Park the hardware cursor at the real content bottom, not the padded
|
|
3997
4955
|
// viewport bottom: a later height shrink would otherwise scroll the live
|
|
@@ -4057,7 +5015,7 @@ export class TUI extends Container {
|
|
|
4057
5015
|
let buffer = `${this.#paintBeginSequence}\x1b[H`;
|
|
4058
5016
|
for (let r = 0; r < height; r++) {
|
|
4059
5017
|
if (r > 0) buffer += "\r\n";
|
|
4060
|
-
buffer += this.#lineRewriteSequence(fitted[r], width, r, -1, -1);
|
|
5018
|
+
buffer += this.#lineRewriteSequence(fitted[r], width, r, -1, -1, this.#osc66SpacerGlyphWidth(fitted, r));
|
|
4061
5019
|
}
|
|
4062
5020
|
buffer += this.#paintEndSequence;
|
|
4063
5021
|
this.terminal.write(buffer);
|
|
@@ -4136,7 +5094,7 @@ export class TUI extends Container {
|
|
|
4136
5094
|
const moveToBottom = height - 1 - currentScreenRow;
|
|
4137
5095
|
if (moveToBottom > 0) buffer += `\x1b[${moveToBottom}B`;
|
|
4138
5096
|
for (let r = height - scroll; r < height; r++) {
|
|
4139
|
-
buffer += `\r\n${this.#lineRewriteSequence(window[r] ?? "", width, height - 1, windowTop + r, chunkTo)}`;
|
|
5097
|
+
buffer += `\r\n${this.#lineRewriteSequence(window[r] ?? "", width, height - 1, windowTop + r, chunkTo, this.#osc66SpacerGlyphWidth(frame, windowTop + r))}`;
|
|
4140
5098
|
}
|
|
4141
5099
|
// Rewrite any remaining changed rows after the shift.
|
|
4142
5100
|
let firstChanged = -1;
|
|
@@ -4153,7 +5111,14 @@ export class TUI extends Container {
|
|
|
4153
5111
|
buffer += "\r";
|
|
4154
5112
|
for (let r = firstChanged; r <= lastChanged; r++) {
|
|
4155
5113
|
if (r > firstChanged) buffer += "\r\n";
|
|
4156
|
-
buffer += this.#lineRewriteSequence(
|
|
5114
|
+
buffer += this.#lineRewriteSequence(
|
|
5115
|
+
window[r] ?? "",
|
|
5116
|
+
width,
|
|
5117
|
+
r,
|
|
5118
|
+
windowTop + r,
|
|
5119
|
+
chunkTo,
|
|
5120
|
+
this.#osc66SpacerGlyphWidth(frame, windowTop + r),
|
|
5121
|
+
);
|
|
4157
5122
|
}
|
|
4158
5123
|
cursorFromRow = windowTop + lastChanged;
|
|
4159
5124
|
}
|
|
@@ -4228,6 +5193,7 @@ export class TUI extends Container {
|
|
|
4228
5193
|
r,
|
|
4229
5194
|
windowTop + r,
|
|
4230
5195
|
this.#committedRows,
|
|
5196
|
+
this.#osc66SpacerGlyphWidth(frame, windowTop + r),
|
|
4231
5197
|
);
|
|
4232
5198
|
}
|
|
4233
5199
|
buffer += fillSequence;
|
|
@@ -4259,7 +5225,14 @@ export class TUI extends Container {
|
|
|
4259
5225
|
let wroteLine = false;
|
|
4260
5226
|
for (let i = chunkFrom; i < chunkTo; i++) {
|
|
4261
5227
|
if (wroteLine) buffer += "\r\n";
|
|
4262
|
-
buffer += this.#lineRewriteSequence(
|
|
5228
|
+
buffer += this.#lineRewriteSequence(
|
|
5229
|
+
frame[i] ?? "",
|
|
5230
|
+
width,
|
|
5231
|
+
Math.min(i - chunkFrom, height - 1),
|
|
5232
|
+
i,
|
|
5233
|
+
chunkTo,
|
|
5234
|
+
this.#osc66SpacerGlyphWidth(frame, i),
|
|
5235
|
+
);
|
|
4263
5236
|
wroteLine = true;
|
|
4264
5237
|
}
|
|
4265
5238
|
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
@@ -4270,6 +5243,7 @@ export class TUI extends Container {
|
|
|
4270
5243
|
Math.min(chunkTo - chunkFrom + screenRow, height - 1),
|
|
4271
5244
|
windowTop + screenRow,
|
|
4272
5245
|
chunkTo,
|
|
5246
|
+
this.#osc66SpacerGlyphWidth(frame, windowTop + screenRow),
|
|
4273
5247
|
);
|
|
4274
5248
|
wroteLine = true;
|
|
4275
5249
|
}
|