@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/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
- /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
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
- return isInsideTerminalMultiplexer();
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 implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay {
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
- setNativeScrollbackCommittedRows(child, Math.min(prevRows, Math.max(0, this.#committedRows - prevStart)));
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.#armMultiplexerResizeTimer(false);
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(options?.clearScrollback === true);
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
- const prevWindowTop = this.#windowTopRow;
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 (!geometryChanged && !this.#clearScrollbackOnNextRender && frameLength < this.#committedRows) {
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, and the raw per-frame value keeps the check correct
3255
- // across committed-ledger rewinds.
3256
- this.#imageBudget.observeCommitWatermark(chunkTo);
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, this.#committedRows - segment.start)),
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
- #lineRewriteSequence(line: string, width: number, screenRow = -1, frameRow = -1, committedTo = -1): string {
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
- // Best-effort: push the pre-paint screen into scrollback on
3738
- // terminals that implement kitty's ED 22
3739
- // (copy-screen-to-scrollback-then-erase). Always follow with ED 2 so
3740
- // the viewport is cleared regardless; on real kitty, ED 2 over the
3741
- // now-blank screen is a no-op and does not push a second copy.
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(frame[i] ?? "", width, writeRow, i, chunkTo)
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(line, width, writeRow, frameRow, chunkTo)
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(line, width, writeRow, -1, chunkTo)
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 { window, contentRows } = this.#composeResizeViewport(width, height);
3883
- this.#emitResizeViewport(window, height, contentRows, width);
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. {@link ViewportTailProvider}s (the transcript) yield only their
3891
- * tail; the small live-region children below render in full — so every child
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(width: number, height: number): { window: readonly string[]; contentRows: number } {
3899
- const tail: string[] = []; // bottom-first
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 < height; i--) {
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, height - tail.length) : child.render(width);
3905
- for (let r = rows.length - 1; r >= 0 && tail.length < height; r--) {
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 count = tail.length;
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 `count` frame rows, bottom-first. They fill
3913
- // the viewport when the frame overflows it and sit at the top (blanks
3914
- // below) when it underflows.
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
- return { window: this.#prepareLinesArray(window, width), contentRows: count };
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(window: readonly string[], height: number, contentRows: number, width: number): void {
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
- buffer += this.#lineRewriteSequence(window[r] ?? "", width, r, -1, this.#committedRows);
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(window[r] ?? "", width, r, windowTop + r, chunkTo);
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(frame[i] ?? "", width, Math.min(i - chunkFrom, height - 1), i, chunkTo);
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
  }