@linxiraos/pi-tui 1.0.0 → 1.0.2

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