@oh-my-pi/pi-tui 17.2.14 → 17.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.3.0] - 2026-08-13
6
+
7
+ ### Fixed
8
+
9
+ - Fixed an issue where repeated pane-width adjustments or terminal resizing could corrupt native scrollback and soft-wrap behavior.
10
+ - Fixed an issue where scaled OSC 66 Markdown headings (such as "Large Headings" on Kitty) would render as invisible placeholders or get partially cleared after a redraw or terminal resize.
11
+
5
12
  ## [17.2.13] - 2026-08-11
6
13
 
7
14
  ### Fixed
@@ -15,6 +15,8 @@ export interface EditorTopBorder {
15
15
  content: string;
16
16
  /** Visible width of the content */
17
17
  width: number;
18
+ /** Optional logical revision that changes independently of available width. */
19
+ revision?: number;
18
20
  }
19
21
  interface HistoryEntry {
20
22
  prompt: string;
@@ -74,8 +76,9 @@ export declare class Editor implements Component, Focusable {
74
76
  *
75
77
  * Use this when the top border derives from state that mutates far faster
76
78
  * than the render cadence (session events, streaming, subagent updates).
77
- * The TUI already throttles renders, so a provider is invoked at most once
78
- * per frame and never does wasted work between paints.
79
+ * The TUI already throttles renders, so a provider is invoked exactly once
80
+ * per frame and does no work between paints. Return a logical `revision` to
81
+ * distinguish concurrent status mutations from pure width reflow.
79
82
  */
80
83
  setTopBorderProvider(provider: ((availableWidth: number) => EditorTopBorder | undefined) | undefined): void;
81
84
  /**
@@ -111,6 +114,7 @@ export declare class Editor implements Component, Focusable {
111
114
  render(width: number): readonly string[];
112
115
  handleInput(data: string): void;
113
116
  getText(): string;
117
+ getNativeScrollbackWidthEpochRevision(): number;
114
118
  /** Whether the buffer text equals `value`, without `getText()`'s full join —
115
119
  * O(1) for the hot per-keystroke probes against short single-line values. */
116
120
  textEquals(value: string): boolean;
@@ -92,6 +92,12 @@ export declare class ImageBudget {
92
92
  * re-emission.
93
93
  */
94
94
  observeCommitWatermark(committedTo: number): void;
95
+ /**
96
+ * End the physical-row coordinate epoch after observing its final commit
97
+ * watermark. Placement ids and latched archive state survive, but attachment
98
+ * rows do not: the next placement emit records them in the new-width frame.
99
+ */
100
+ beginPlacementCoordinateEpoch(): void;
95
101
  /**
96
102
  * Resolve the placement id and geometry for a direct-placement emit whose
97
103
  * topmost attached cell sits at `attachTopFrameRow` — the first frame row
@@ -1,5 +1,5 @@
1
1
  import type { SymbolTheme } from "../symbols.js";
2
- import type { Component, NativeScrollbackCommittedRows, NativeScrollbackReplay } from "../tui.js";
2
+ import type { Component, NativeScrollbackCommittedRows, NativeScrollbackReplay, NativeScrollbackWidthEpoch } from "../tui.js";
3
3
  /** @internal exported for tests — must stay index-identical to the old regex scan. */
4
4
  export declare function mathStartIndex(src: string): number | undefined;
5
5
  /** @internal exported for tests — must stay index-identical to the old regex scan. */
@@ -53,7 +53,7 @@ export interface MarkdownTheme {
53
53
  resolveMermaidAscii?: (source: string, maxWidth?: number) => string | null;
54
54
  symbols: SymbolTheme;
55
55
  }
56
- export declare class Markdown implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay {
56
+ export declare class Markdown implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay, NativeScrollbackWidthEpoch {
57
57
  #private;
58
58
  setIgnoreTight(ignore: boolean): this;
59
59
  constructor(text: string, paddingX: number, paddingY: number, theme: MarkdownTheme, defaultTextStyle?: DefaultTextStyle, codeBlockIndent?: number);
@@ -71,6 +71,10 @@ export declare class Markdown implements Component, NativeScrollbackCommittedRow
71
71
  * lineage), and on cache-served non-streaming renders.
72
72
  */
73
73
  getLastRenderSettledRows(): number;
74
+ captureNativeScrollbackWidthEpoch(): unknown;
75
+ resolveNativeScrollbackWidthEpoch(boundary: unknown): number | undefined;
76
+ getNativeScrollbackWidthEpochRows(): number | undefined;
77
+ isNativeScrollbackWidthEpochAppendOnly(boundary: unknown): boolean;
74
78
  /**
75
79
  * Freeze every table whose first physical row is already part of the native
76
80
  * scrollback prefix. The recorded widths came from the exact frame that was
@@ -14,6 +14,7 @@ export declare class Text implements Component {
14
14
  constructor(text?: string, paddingX?: number, paddingY?: number, customBgFn?: (text: string) => string);
15
15
  getText(): string;
16
16
  setText(text: string): boolean;
17
+ getNativeScrollbackWidthEpochRevision(): number;
17
18
  setCustomBgFn(customBgFn?: (text: string) => string): void;
18
19
  /**
19
20
  * Supply a foreground styler applied to the text at render time (e.g. a
@@ -102,6 +102,21 @@ export interface NativeScrollbackLiveRegion {
102
102
  export interface NativeScrollbackCommittedRows {
103
103
  setNativeScrollbackCommittedRows(rows: number): void;
104
104
  }
105
+ /**
106
+ * Width-independent source boundary for multiplexer resize epochs. Capture
107
+ * reads the last rendered source state; resolve maps that same logical boundary
108
+ * into the most recent render's physical rows at its new width. The current
109
+ * boundary identifies the source tail after updates queued during the resize.
110
+ */
111
+ export interface NativeScrollbackWidthEpoch {
112
+ captureNativeScrollbackWidthEpoch(): unknown;
113
+ resolveNativeScrollbackWidthEpoch(boundary: unknown): number | undefined;
114
+ getNativeScrollbackWidthEpochRows(): number | undefined;
115
+ /** False when updates can insert before captured trailing rows. */
116
+ isNativeScrollbackWidthEpochAppendOnly?(boundary: unknown): boolean;
117
+ /** Changes when child structure mutates independently of width reflow. */
118
+ getNativeScrollbackWidthEpochRevision?(): number;
119
+ }
105
120
  /**
106
121
  * A component that discards rows after they enter native scrollback implements
107
122
  * this hook so a destructive full replay can rehydrate its complete frame.
@@ -261,7 +276,7 @@ export interface OverlayHandle {
261
276
  /**
262
277
  * Container - a component that contains other components
263
278
  */
264
- export declare class Container implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay {
279
+ export declare class Container implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay, NativeScrollbackWidthEpoch {
265
280
  #private;
266
281
  children: Component[];
267
282
  setIgnoreTight(ignore: boolean): this;
@@ -287,6 +302,11 @@ export declare class Container implements Component, NativeScrollbackCommittedRo
287
302
  setNativeScrollbackCommittedRows(rows: number): void;
288
303
  /** Recursively discard layout locks that are meaningful only to the old tape. */
289
304
  prepareNativeScrollbackReplay(): void;
305
+ captureNativeScrollbackWidthEpoch(): unknown;
306
+ resolveNativeScrollbackWidthEpoch(boundary: unknown): number | undefined;
307
+ getNativeScrollbackWidthEpochRows(): number | undefined;
308
+ isNativeScrollbackWidthEpochAppendOnly(boundary: unknown): boolean;
309
+ getNativeScrollbackWidthEpochRevision(): number;
290
310
  render(width: number): readonly string[];
291
311
  }
292
312
  /**
@@ -346,6 +366,9 @@ export declare class TUI extends Container {
346
366
  hidden: boolean;
347
367
  }[];
348
368
  constructor(terminal: Terminal, showHardwareCursor?: boolean, options?: TUIOptions);
369
+ captureNativeScrollbackWidthEpoch(): unknown;
370
+ resolveNativeScrollbackWidthEpoch(boundary: unknown): number | undefined;
371
+ getNativeScrollbackWidthEpochRows(): number | undefined;
349
372
  render(width: number): readonly string[];
350
373
  get fullRedraws(): number;
351
374
  /**
@@ -46,6 +46,18 @@ export declare function getSegmenter(): Intl.Segmenter;
46
46
  * `tabWidth` cells) and OSC 66 text-sizing payloads (scaled by `s=`).
47
47
  */
48
48
  export declare function visibleWidth(str: string): number;
49
+ /**
50
+ * True when a row carries a Kitty OSC 66 text-sizing span (`\x1b]66;…`).
51
+ * Scaled spans must bypass wrapping/padding and, when scaled up, reserve the
52
+ * terminal rows their multicell glyphs flow into.
53
+ */
54
+ export declare function isOsc66Line(line: string): boolean;
55
+ /**
56
+ * Largest `s=` scale among the OSC 66 spans in a line (1 when none is scaled).
57
+ * A scale-`s` heading occupies `s` terminal rows, so the `s - 1` blank rows
58
+ * beneath it are the glyph's lower half and must never be erased or overdrawn.
59
+ */
60
+ export declare function osc66MaxScale(line: string): number;
49
61
  /**
50
62
  * Normalize text for terminal output without changing logical editor content.
51
63
  * Some terminals render precomposed Thai/Lao AM vowels inconsistently during
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "17.2.14",
4
+ "version": "17.3.0",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "17.2.14",
41
- "@oh-my-pi/pi-utils": "17.2.14"
40
+ "@oh-my-pi/pi-natives": "17.3.0",
41
+ "@oh-my-pi/pi-utils": "17.3.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "ghostty-web": "^0.4.0"
@@ -391,6 +391,8 @@ export interface EditorTopBorder {
391
391
  content: string;
392
392
  /** Visible width of the content */
393
393
  width: number;
394
+ /** Optional logical revision that changes independently of available width. */
395
+ revision?: number;
394
396
  }
395
397
 
396
398
  interface HistoryEntry {
@@ -410,6 +412,8 @@ export class Editor implements Component, Focusable {
410
412
  cursorLine: 0,
411
413
  cursorCol: 0,
412
414
  };
415
+ #widthEpochText = "";
416
+ #widthEpochRevision = 0;
413
417
 
414
418
  /** Focusable interface - set by TUI when focus changes */
415
419
  focused: boolean = false;
@@ -515,6 +519,9 @@ export class Editor implements Component, Focusable {
515
519
  // per-event rebuilds down to one per rendered frame (see #4145).
516
520
  #topBorderContent?: EditorTopBorder;
517
521
  #topBorderProvider?: (availableWidth: number) => EditorTopBorder | undefined;
522
+ #topBorderProviderWidth: number | undefined;
523
+ #topBorderProviderSignature: string | undefined;
524
+ #topBorderProviderRevision: number | undefined;
518
525
  #borderVisible = true;
519
526
 
520
527
  constructor(theme: EditorTheme) {
@@ -536,7 +543,10 @@ export class Editor implements Component, Focusable {
536
543
  * per-event rebuilds to one per painted frame.
537
544
  */
538
545
  setTopBorder(content: EditorTopBorder | undefined): void {
546
+ if (this.#topBorderContent?.content === content?.content && this.#topBorderContent?.width === content?.width)
547
+ return;
539
548
  this.#topBorderContent = content;
549
+ this.#widthEpochRevision++;
540
550
  }
541
551
 
542
552
  /**
@@ -546,18 +556,26 @@ export class Editor implements Component, Focusable {
546
556
  *
547
557
  * Use this when the top border derives from state that mutates far faster
548
558
  * than the render cadence (session events, streaming, subagent updates).
549
- * The TUI already throttles renders, so a provider is invoked at most once
550
- * per frame and never does wasted work between paints.
559
+ * The TUI already throttles renders, so a provider is invoked exactly once
560
+ * per frame and does no work between paints. Return a logical `revision` to
561
+ * distinguish concurrent status mutations from pure width reflow.
551
562
  */
552
563
  setTopBorderProvider(provider: ((availableWidth: number) => EditorTopBorder | undefined) | undefined): void {
564
+ if (this.#topBorderProvider === provider) return;
553
565
  this.#topBorderProvider = provider;
566
+ this.#topBorderProviderWidth = undefined;
567
+ this.#topBorderProviderSignature = undefined;
568
+ this.#topBorderProviderRevision = undefined;
569
+ this.#widthEpochRevision++;
554
570
  }
555
571
 
556
572
  /**
557
573
  * Show or hide the editor border chrome.
558
574
  */
559
575
  setBorderVisible(borderVisible: boolean): void {
576
+ if (this.#borderVisible === borderVisible) return;
560
577
  this.#borderVisible = borderVisible;
578
+ this.#widthEpochRevision++;
561
579
  }
562
580
 
563
581
  setPromptGutter(promptGutter: string | undefined): void {
@@ -578,12 +596,16 @@ export class Editor implements Component, Focusable {
578
596
  * Use the real terminal cursor instead of rendering a cursor glyph.
579
597
  */
580
598
  setUseTerminalCursor(useTerminalCursor: boolean): void {
599
+ if (this.#useTerminalCursor === useTerminalCursor) return;
581
600
  this.#useTerminalCursor = useTerminalCursor;
601
+ this.#widthEpochRevision++;
582
602
  }
583
603
 
584
604
  /** Render a dedicated bottom border so terminal-local IME preedit cannot shift editor chrome. */
585
605
  setImeSafeCursorLayout(enabled: boolean): void {
606
+ if (this.#imeSafeCursorLayout === enabled) return;
586
607
  this.#imeSafeCursorLayout = enabled;
608
+ this.#widthEpochRevision++;
587
609
  }
588
610
 
589
611
  getUseTerminalCursor(): boolean {
@@ -593,6 +615,7 @@ export class Editor implements Component, Focusable {
593
615
  setMaxHeight(maxHeight: number | undefined): void {
594
616
  if (this.#maxHeight === maxHeight) return;
595
617
  this.#maxHeight = maxHeight;
618
+ this.#widthEpochRevision++;
596
619
  // Don't reset scrollOffset — #updateScrollOffset will clamp it on next render
597
620
  }
598
621
 
@@ -613,6 +636,10 @@ export class Editor implements Component, Focusable {
613
636
  const newMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5;
614
637
  if (this.#autocompleteMaxVisible !== newMaxVisible) {
615
638
  this.#autocompleteMaxVisible = newMaxVisible;
639
+ if (this.#autocompleteState !== null) {
640
+ this.#autocompleteList?.setMaxVisible(newMaxVisible);
641
+ this.#widthEpochRevision++;
642
+ }
616
643
  }
617
644
  }
618
645
 
@@ -900,7 +927,27 @@ export class Editor implements Component, Focusable {
900
927
  // Provider (lazy) wins over eager content — a host that installs both
901
928
  // wants the coalesced path; falling back to eager keeps existing
902
929
  // setTopBorder callers working unchanged.
903
- const topBorder = this.#topBorderProvider ? this.#topBorderProvider(topFillWidth) : this.#topBorderContent;
930
+ let topBorder: EditorTopBorder | undefined;
931
+ if (this.#topBorderProvider) {
932
+ const previousWidth = this.#topBorderProviderWidth;
933
+ topBorder = this.#topBorderProvider(topFillWidth);
934
+ const signature = topBorder ? `${topBorder.width}\0${topBorder.content}` : "";
935
+ const revision = topBorder?.revision;
936
+ if (
937
+ (previousWidth !== undefined &&
938
+ revision !== undefined &&
939
+ this.#topBorderProviderRevision !== undefined &&
940
+ revision !== this.#topBorderProviderRevision) ||
941
+ (previousWidth === topFillWidth && signature !== this.#topBorderProviderSignature)
942
+ ) {
943
+ this.#widthEpochRevision++;
944
+ }
945
+ this.#topBorderProviderWidth = topFillWidth;
946
+ this.#topBorderProviderSignature = signature;
947
+ this.#topBorderProviderRevision = revision;
948
+ } else {
949
+ topBorder = this.#topBorderContent;
950
+ }
904
951
  if (topBorder) {
905
952
  const { content, width: statusWidth } = topBorder;
906
953
  if (statusWidth <= topFillWidth) {
@@ -1240,6 +1287,7 @@ export class Editor implements Component, Focusable {
1240
1287
  kb.matchesCanonical(canonical, "tui.select.pageDown")
1241
1288
  ) {
1242
1289
  this.#autocompleteList.handleInput(data);
1290
+ this.#widthEpochRevision++;
1243
1291
  this.onAutocompleteUpdate?.();
1244
1292
  return;
1245
1293
  }
@@ -1670,6 +1718,15 @@ export class Editor implements Component, Focusable {
1670
1718
  return this.#state.lines.join("\n");
1671
1719
  }
1672
1720
 
1721
+ getNativeScrollbackWidthEpochRevision(): number {
1722
+ const text = this.getText();
1723
+ if (text !== this.#widthEpochText) {
1724
+ this.#widthEpochText = text;
1725
+ this.#widthEpochRevision++;
1726
+ }
1727
+ return this.#widthEpochRevision;
1728
+ }
1729
+
1673
1730
  /** Whether the buffer text equals `value`, without `getText()`'s full join —
1674
1731
  * O(1) for the hot per-keystroke probes against short single-line values. */
1675
1732
  textEquals(value: string): boolean {
@@ -3149,6 +3206,7 @@ export class Editor implements Component, Focusable {
3149
3206
  this.#autocompletePrefix = suggestions.prefix;
3150
3207
  this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
3151
3208
  this.#autocompleteState = "regular";
3209
+ this.#widthEpochRevision++;
3152
3210
  this.onAutocompleteUpdate?.();
3153
3211
  } else {
3154
3212
  this.#cancelAutocomplete();
@@ -3207,6 +3265,7 @@ export class Editor implements Component, Focusable {
3207
3265
  this.#autocompletePrefix = suggestions.prefix;
3208
3266
  this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
3209
3267
  this.#autocompleteState = "force";
3268
+ this.#widthEpochRevision++;
3210
3269
  this.onAutocompleteUpdate?.();
3211
3270
  } else {
3212
3271
  this.#cancelAutocomplete();
@@ -3221,6 +3280,7 @@ export class Editor implements Component, Focusable {
3221
3280
  this.#autocompleteState = null;
3222
3281
  this.#autocompleteList = undefined;
3223
3282
  this.#autocompletePrefix = "";
3283
+ if (wasAutocompleting) this.#widthEpochRevision++;
3224
3284
  if (notifyCancel && wasAutocompleting) {
3225
3285
  this.onAutocompleteCancel?.();
3226
3286
  }
@@ -3252,6 +3312,7 @@ export class Editor implements Component, Focusable {
3252
3312
  this.#autocompletePrefix = suggestions.prefix;
3253
3313
  // Always create new SelectList to ensure update
3254
3314
  this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
3315
+ this.#widthEpochRevision++;
3255
3316
  this.onAutocompleteUpdate?.();
3256
3317
  } else {
3257
3318
  this.#cancelAutocomplete();
@@ -309,6 +309,16 @@ export class ImageBudget {
309
309
  }
310
310
  }
311
311
 
312
+ /**
313
+ * End the physical-row coordinate epoch after observing its final commit
314
+ * watermark. Placement ids and latched archive state survive, but attachment
315
+ * rows do not: the next placement emit records them in the new-width frame.
316
+ */
317
+ beginPlacementCoordinateEpoch(): void {
318
+ for (const state of this.#placementState.values()) state.lastAttachTopFrameRow = undefined;
319
+ this.#watchedPlacements.clear();
320
+ }
321
+
312
322
  /**
313
323
  * Resolve the placement id and geometry for a direct-placement emit whose
314
324
  * topmost attached cell sits at `attachTopFrameRow` — the first frame row
@@ -11,13 +11,19 @@ import { latexToBlock } from "../latex-block";
11
11
  import { inlineMathSpanEnd, isBareMathEnvironment, latexToUnicode } from "../latex-to-unicode";
12
12
  import type { SymbolTheme } from "../symbols";
13
13
  import { TERMINAL } from "../terminal-capabilities";
14
- import type { Component, NativeScrollbackCommittedRows, NativeScrollbackReplay } from "../tui";
14
+ import type {
15
+ Component,
16
+ NativeScrollbackCommittedRows,
17
+ NativeScrollbackReplay,
18
+ NativeScrollbackWidthEpoch,
19
+ } from "../tui";
15
20
  import {
16
21
  applyBackgroundToLine,
17
22
  Ellipsis,
18
23
  encodeTextSized,
19
24
  getPaddingX,
20
25
  getSegmenter,
26
+ isOsc66Line,
21
27
  padding,
22
28
  replaceTabs,
23
29
  truncateToWidth,
@@ -38,15 +44,11 @@ function normalizeOsc8Terminators(text: string): string {
38
44
  }
39
45
 
40
46
  // OSC 66 (Kitty text-sizing) heading spans are emitted as a single indivisible
41
- // unit by the H1 render path. Like image-protocol lines, they must bypass
42
- // ANSI wrapping and width padding: re-wrapping splits/normalizes the sized span
43
- // (recomputing the explicit `w=` cell count and hoisting SGR out of the OSC
44
- // payload), and padding would append trailing cells past the doubled glyph.
45
- const OSC66_LINE_PREFIX = "\x1b]66;";
46
-
47
- function isOsc66Line(line: string): boolean {
48
- return line.includes(OSC66_LINE_PREFIX);
49
- }
47
+ // unit by the H1 render path. Like image-protocol lines, they bypass ANSI
48
+ // wrapping and width padding (see `isOsc66Line` in ../utils): re-wrapping
49
+ // splits/normalizes the sized span (recomputing the explicit `w=` cell count
50
+ // and hoisting SGR out of the OSC payload), and padding would append trailing
51
+ // cells past the doubled glyph.
50
52
 
51
53
  function normalizeHtmlEntitiesForTerminal(raw: string): string {
52
54
  const parseCodePoint = (value: number): string => {
@@ -1412,7 +1414,9 @@ interface RenderedTableLayout extends TableLayoutLock {
1412
1414
  endRow: number;
1413
1415
  }
1414
1416
 
1415
- export class Markdown implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay {
1417
+ export class Markdown
1418
+ implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay, NativeScrollbackWidthEpoch
1419
+ {
1416
1420
  #text: string;
1417
1421
  #paddingX: number; // Left/right padding
1418
1422
  #paddingY: number; // Top/bottom padding
@@ -1450,6 +1454,16 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
1450
1454
  // exposure to 0 and re-earns it — the exposure is hard-monotone within a
1451
1455
  // text lineage.
1452
1456
  #settledExposedText?: string;
1457
+ // Semantic source state that produced the most recent render. Unlike #text,
1458
+ // it does not advance when streaming updates arrive before the next paint.
1459
+ #lastRenderedText?: string;
1460
+ #lastRenderedTransientRenderCache = false;
1461
+ #lastRenderedHasMutableTrailingRow = false;
1462
+ #widthEpochBoundaries = new WeakMap<
1463
+ object,
1464
+ { text: string; transientRenderCache: boolean; hasMutableTrailingRow: boolean }
1465
+ >();
1466
+
1453
1467
  // True while #renderStreamingContentLines renders the frozen token range:
1454
1468
  // frozen code blocks highlight even in transient mode so their bytes match
1455
1469
  // the finalized render (they render once into the prefix line cache, so
@@ -1545,6 +1559,56 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
1545
1559
  return this.#lastRenderSettledRows;
1546
1560
  }
1547
1561
 
1562
+ captureNativeScrollbackWidthEpoch(): unknown {
1563
+ if (this.#lastRenderedText === undefined) return undefined;
1564
+ const marker = {};
1565
+ this.#widthEpochBoundaries.set(marker, {
1566
+ text: this.#lastRenderedText,
1567
+ transientRenderCache: this.#lastRenderedTransientRenderCache,
1568
+ hasMutableTrailingRow: this.#lastRenderedHasMutableTrailingRow,
1569
+ });
1570
+ return marker;
1571
+ }
1572
+
1573
+ resolveNativeScrollbackWidthEpoch(boundary: unknown): number | undefined {
1574
+ if (typeof boundary !== "object" || boundary === null || this.#cachedWidth === undefined) return undefined;
1575
+ const captured = this.#widthEpochBoundaries.get(boundary);
1576
+ if (captured === undefined) return undefined;
1577
+ const snapshot = new Markdown(
1578
+ captured.text,
1579
+ this.#paddingX,
1580
+ this.#paddingY,
1581
+ this.#theme,
1582
+ this.#defaultTextStyle,
1583
+ this.#codeBlockIndent,
1584
+ );
1585
+ snapshot.#ignoreTight = this.#ignoreTight;
1586
+ snapshot.#transientRenderCache = captured.transientRenderCache;
1587
+ return Math.max(
1588
+ 0,
1589
+ snapshot.render(this.#cachedWidth).length - this.#paddingY - (captured.hasMutableTrailingRow ? 1 : 0),
1590
+ );
1591
+ }
1592
+
1593
+ getNativeScrollbackWidthEpochRows(): number | undefined {
1594
+ return this.#cachedLines === undefined ? undefined : this.#widthEpochRows(this.#cachedLines.length);
1595
+ }
1596
+
1597
+ isNativeScrollbackWidthEpochAppendOnly(boundary: unknown): boolean {
1598
+ if (typeof boundary !== "object" || boundary === null) return true;
1599
+ return this.#widthEpochBoundaries.get(boundary)?.hasMutableTrailingRow !== true;
1600
+ }
1601
+
1602
+ #widthEpochRows(renderedRows: number): number {
1603
+ return Math.max(0, renderedRows - this.#paddingY - (this.#transientRenderCache ? 1 : 0));
1604
+ }
1605
+
1606
+ #recordLastRenderedState(hasContentRows: boolean): void {
1607
+ this.#lastRenderedText = this.#text;
1608
+ this.#lastRenderedTransientRenderCache = this.#transientRenderCache;
1609
+ this.#lastRenderedHasMutableTrailingRow = this.#transientRenderCache && hasContentRows;
1610
+ }
1611
+
1548
1612
  /**
1549
1613
  * Freeze every table whose first physical row is already part of the native
1550
1614
  * scrollback prefix. The recorded widths came from the exact frame that was
@@ -1644,6 +1708,7 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
1644
1708
  // Returning the cached reference is load-bearing: parents memoize their
1645
1709
  // concatenation on reference equality.
1646
1710
  if (this.#cachedLines && this.#cachedText === this.#text && this.#cachedWidth === width) {
1711
+ this.#recordLastRenderedState(this.#cachedLines.length > 0);
1647
1712
  return this.#cachedLines;
1648
1713
  }
1649
1714
 
@@ -1660,6 +1725,7 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
1660
1725
  this.#cachedText = this.#text;
1661
1726
  this.#cachedWidth = width;
1662
1727
  this.#cachedLines = EMPTY_RENDER_LINES;
1728
+ this.#recordLastRenderedState(false);
1663
1729
  return EMPTY_RENDER_LINES;
1664
1730
  }
1665
1731
 
@@ -1695,6 +1761,7 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
1695
1761
  this.#cachedText = this.#text;
1696
1762
  this.#cachedWidth = width;
1697
1763
  this.#cachedLines = cached.lines;
1764
+ this.#recordLastRenderedState(cached.lines.length > 0);
1698
1765
  return cached.lines;
1699
1766
  }
1700
1767
  }
@@ -1738,6 +1805,7 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
1738
1805
  })),
1739
1806
  });
1740
1807
  }
1808
+ this.#recordLastRenderedState(contentLines.length > 0);
1741
1809
 
1742
1810
  return result;
1743
1811
  }
@@ -25,11 +25,14 @@ export class Text implements Component {
25
25
  #paddingY: number; // Top/bottom padding
26
26
  #customBgFn?: (text: string) => string;
27
27
  #styleFn?: (text: string) => string;
28
+ #widthEpochRevision = 0;
28
29
 
29
30
  #ignoreTight = false;
30
31
 
31
32
  setIgnoreTight(ignore: boolean): this {
33
+ if (this.#ignoreTight === ignore) return this;
32
34
  this.#ignoreTight = ignore;
35
+ this.#widthEpochRevision++;
33
36
  this.invalidate();
34
37
  return this;
35
38
  }
@@ -60,15 +63,21 @@ export class Text implements Component {
60
63
  this.#cachedWidth = undefined;
61
64
  this.#cachedWidthEpoch = undefined;
62
65
  this.#cachedLines = undefined;
66
+ this.#widthEpochRevision++;
63
67
  return true;
64
68
  }
65
69
 
70
+ getNativeScrollbackWidthEpochRevision(): number {
71
+ return this.#widthEpochRevision;
72
+ }
73
+
66
74
  setCustomBgFn(customBgFn?: (text: string) => string): void {
67
75
  this.#customBgFn = customBgFn;
68
76
  this.#cachedText = undefined;
69
77
  this.#cachedWidth = undefined;
70
78
  this.#cachedWidthEpoch = undefined;
71
79
  this.#cachedLines = undefined;
80
+ this.#widthEpochRevision++;
72
81
  }
73
82
 
74
83
  /**
@@ -83,6 +92,7 @@ export class Text implements Component {
83
92
  this.#cachedWidth = undefined;
84
93
  this.#cachedWidthEpoch = undefined;
85
94
  this.#cachedLines = undefined;
95
+ this.#widthEpochRevision++;
86
96
  return this;
87
97
  }
88
98