@oh-my-pi/pi-tui 16.3.5 → 16.3.6

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,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.3.6] - 2026-07-04
6
+
7
+ ### Added
8
+
9
+ - Added `Markdown.getLastRenderSettledRows()`: the rendered frozen-token-prefix row count of the most recent streaming render, exposed (hard-monotone per text lineage) for native-scrollback commit gating. Frozen-prefix code blocks now syntax-highlight during streaming renders so settled rows stay byte-stable across finalize.
10
+
11
+ ### Changed
12
+
13
+ - Rewrote the native-scrollback commit law around a visual-record model: whatever scrolls above the viewport enters history exactly once, in order — nothing painted ever vanishes. `NativeScrollbackLiveRegion` is now a single method (`getNativeScrollbackLiveRegionStart`) reporting an exactness boundary: rows below it commit as exact audited bytes; rows above it commit as frozen visual snapshots that are audit-exempt while their source stays live and strict-verified exactly once when the boundary passes them (a divergence recommits the final content below the frozen fragment — duplication, never loss). `getNativeScrollbackCommitSafeEnd`/`getNativeScrollbackSnapshotSafeEnd` and the heuristic promotion machinery they fed are removed.
14
+ - Simplified committed-prefix auditing to one hard-verified mark deriving three zones (verified/newly-final/frozen); a frame that shrinks below the committed row count re-bases at the first divergence against the recorded prefix so a collapsing live suffix never re-shows or re-commits already-recorded rows.
15
+
16
+ ### Fixed
17
+
18
+ - Fixed live tool/eval preview boxes spraying duplicated stale copies into native scrollback mid-run: a still-live block's scrolled rows are now frozen visual snapshots that never re-anchor while it runs; at most one repair happens at finalize.
19
+ - Fixed streamed content vanishing above the viewport mid-turn: scrolled-off rows always reach native scrollback (as exact bytes for declared-final content, as visual snapshots otherwise) instead of being deferred invisibly.
20
+
5
21
  ## [16.3.5] - 2026-07-04
6
22
 
7
23
  ### Fixed
@@ -55,6 +55,16 @@ export declare class Markdown implements Component {
55
55
  invalidate(): void;
56
56
  get transientRenderCache(): boolean;
57
57
  set transientRenderCache(value: boolean);
58
+ /**
59
+ * Rows at the top of the most recent render() (top padding + rendered
60
+ * frozen-token prefix) whose bytes are settled: byte-stable at this
61
+ * width/theme for as long as the text keeps growing append-only. Hosts
62
+ * feed this to transcript commit gating (see the coding agent's
63
+ * `FinalizableBlock.getTranscriptBlockSettledRows`). 0 outside streaming
64
+ * (`transientRenderCache`) mode, after a text rewind (re-earned on the new
65
+ * lineage), and on cache-served non-streaming renders.
66
+ */
67
+ getLastRenderSettledRows(): number;
58
68
  render(width: number): readonly string[];
59
69
  }
60
70
  /**
@@ -76,45 +76,23 @@ export interface OverlayFocusOwner {
76
76
  ownsOverlayFocusTarget(component: Component): boolean;
77
77
  }
78
78
  /**
79
- * Component seam for append-only native-scrollback commits. A component that
80
- * renders a finalized prefix followed by a live/mutating suffix reports the
81
- * local line index where that suffix begins after each render. The engine
82
- * commits rows to native scrollback only up to that boundary; everything
83
- * below repaints in place inside the visible window and never enters history
84
- * until it finalizes.
79
+ * Component seam for append-only native-scrollback commits. A component whose
80
+ * rendered rows can still change reports, after each render, the local line
81
+ * index where that mutable suffix begins. Rows above the boundary are declared
82
+ * FINAL byte-stable at the current width for the component's lifetime — and
83
+ * commit to native scrollback as exact, audited content. Rows at/after the
84
+ * boundary repaint in place inside the visible window; when they scroll above
85
+ * the window top they still commit — the tape records what was on screen —
86
+ * but as frozen visual snapshots that are permanently audit-exempt: later
87
+ * re-layout of their source never re-anchors or recommits them. A root that
88
+ * reports no seam commits everything that scrolls as final (shell semantics).
85
89
  *
86
- * `getNativeScrollbackCommitSafeEnd` optionally reports a *deeper* boundary
87
- * inside the live suffix: the line index up to which the live region is
88
- * append-only (earlier rows never re-layout — a streaming assistant message).
89
- * Rows in `[liveRegionStart, commitSafeEnd)` may commit even though they are
90
- * technically live, because they will never change. Without it, a single live
91
- * block that alone overflows the window would hold its scrolled-off head out
92
- * of history until it finalizes. Volatile live blocks (tool previews that
93
- * collapse) omit it. Defaults to `liveRegionStart` when absent; a root that
94
- * reports no seam at all commits everything that scrolls (shell semantics).
95
- * `getNativeScrollbackSnapshotSafeEnd` optionally reports a still deeper
96
- * boundary: the line index up to which the live region is *durable* — its rows
97
- * may still change bytes later (a streaming markdown table re-aligning its
98
- * columns every row), but their CURRENT snapshot is permanent content, so
99
- * dropping them when they scroll above the window is forbidden. Unlike
100
- * `commitSafeEnd` (byte-stable: offered rows are asserted never to re-layout and
101
- * stay under the committed-prefix audit), rows committed under the snapshot end
102
- * are audit-EXEMPT once they pass the window top — the engine appends their
103
- * scroll-off snapshot and never recommits them, so later layout drift becomes a
104
- * frozen stale row in history (duplication never loss) instead of either a
105
- * dropped row or an audit re-anchor spray. Provisional live blocks (collapsing
106
- * tool/edit previews whose head is a throwaway tail window) omit it. Defaults to
107
- * `commitSafeEnd ?? liveRegionStart` when absent.
108
- *
109
- * When several root children report a seam in the same frame, the topmost
110
- * one (and its commit-safe / snapshot-safe extension) defines the boundary:
111
- * commits are prefix-only, so everything below the first seam is already
112
- * excluded.
90
+ * When several root children report a seam in the same frame, the topmost one
91
+ * defines the boundary: exactness is prefix-only, so everything below the
92
+ * first seam is already excluded.
113
93
  */
114
94
  export interface NativeScrollbackLiveRegion {
115
95
  getNativeScrollbackLiveRegionStart(): number | undefined;
116
- getNativeScrollbackCommitSafeEnd?(): number | undefined;
117
- getNativeScrollbackSnapshotSafeEnd?(): number | undefined;
118
96
  }
119
97
  export interface NativeScrollbackCommittedRows {
120
98
  setNativeScrollbackCommittedRows(rows: number): void;
@@ -294,40 +272,34 @@ export declare function coalesceAdjacentSgr(line: string): string;
294
272
  * re-anchor the commit index when it does not. Returns the resync row index,
295
273
  * or -1 when no resync is needed.
296
274
  *
297
- * Audits the committed prefix [0, auditTo) EXCEPT the exempt window
298
- * [exemptFrom, exemptTo): rows in the window are durable snapshots (a streaming
299
- * table re-aligning its columns) that may drift legitimately, so their drift
300
- * never triggers a re-anchor. Rows below the window including forced-overflow
301
- * rows committed only because they scrolled above the viewport under a
302
- * commit-unstable barrier ARE audited.
303
- *
304
- * Two detectors run over the audited rows:
275
+ * Zones (verifiedTo finalTo prefix.length):
276
+ * [0, verifiedTo) VERIFIED exact rows sampled with tolerance.
277
+ * [verifiedTo, finalTo) NEWLY-FINAL rows frozen visual snapshots whose
278
+ * source just became declared-final (the block finalized / a barrier
279
+ * cleared). Hard-scanned in FULL with no tolerance: any content change
280
+ * (a pending header settling, a preview replaced by its result, a tail
281
+ * shifting up after a barrier removal) re-anchors so the final content
282
+ * recommits below the frozen snapshot duplication, never loss —
283
+ * instead of being committed nowhere and painted nowhere.
284
+ * [finalTo, prefix.length) FROZEN visual snapshots of still-live rows —
285
+ * exempt: their drift is expected (a collapsing preview, a ticking
286
+ * progress tree) and must never spray re-anchors mid-run.
305
287
  *
306
- * 1. Hard scan of the now-permanent forced suffix [exemptTo, permanentEnd):
307
- * forced-overflow rows that THIS frame asserts are durable/permanent (index <
308
- * permanentEnd the barrier above them finalized or cleared, so durableBoundary
309
- * rose past them). A content change there is real finalized content, so ANY
310
- * mismatch re-anchors. Scanned in FULL, not sampled, so a single edit far above
311
- * the commit boundary with an unchanged tail still re-anchors (duplication,
312
- * never loss) instead of being committed nowhere and painted nowhere.
313
- * 2. Tail sample (only when the hard scan is clean): exploits the asymmetry
314
- * between the two mutation classes — an in-place edit/restyle of a committed
315
- * row disturbs only the touched rows (alignment below intact; the stale copy
316
- * in history is the long-accepted artifact), while an insertion/deletion
317
- * shifts EVERY row below it. So up to 8 non-blank rows within the last 24
318
- * audited rows are compared SGR-stripped (theme changes stay quiet),
319
- * tolerating a SINGLE non-hard mismatch (a legitimate one-row edit): aligned ⇒
320
- * no resync; misaligned ⇒ resync at the first non-equivalent audited row. The
321
- * tolerance keeps both an offscreen still-live barrier (a ticking spinner) and
322
- * a no-seam in-place row edit from spraying duplicate snapshots every frame;
323
- * the hard scan above is what forbids it from swallowing a finalized row.
288
+ * The verified zone's sampled check exploits the asymmetry between the two
289
+ * mutation classes: an in-place edit/restyle disturbs only the touched rows
290
+ * (alignment below stays intact; the stale copy in history is the accepted
291
+ * artifact), while an insertion/deletion shifts EVERY row below it. Up to 8
292
+ * non-blank rows within the last 24 verified rows are compared SGR-stripped
293
+ * (theme changes stay quiet), tolerating a SINGLE mismatch. The tolerance is
294
+ * load-bearing for roots that report NO seam: an animated row already in
295
+ * history would otherwise re-anchor on every glyph tick.
324
296
  *
325
297
  * Highly repetitive tails (identical filler rows) can mask a shift in the tail
326
298
  * sample, in which case the skipped rows are content-identical to the committed
327
299
  * ones — observationally harmless. Exported for the render-stress harness, whose
328
300
  * shadow commit ledger must mirror the engine's law exactly.
329
301
  */
330
- export declare function findCommittedPrefixResync(frame: readonly string[], prefix: readonly string[], auditTo?: number, exemptFrom?: number, exemptTo?: number, permanentEnd?: number): number;
302
+ export declare function findCommittedPrefixResync(frame: readonly string[], prefix: readonly string[], verifiedTo?: number, finalTo?: number): number;
331
303
  /**
332
304
  * TUI - Main class for managing terminal UI with differential rendering
333
305
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "16.3.5",
4
+ "version": "16.3.6",
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": "16.3.5",
41
- "@oh-my-pi/pi-utils": "16.3.5",
40
+ "@oh-my-pi/pi-natives": "16.3.6",
41
+ "@oh-my-pi/pi-utils": "16.3.6",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.5"
44
44
  },
@@ -817,6 +817,21 @@ export class Markdown implements Component {
817
817
  #streamPrefixText?: string;
818
818
  #streamPrefixTokens?: Token[];
819
819
  #streamPrefixLineCache?: StreamPrefixLineCache;
820
+ // Rows of the most recent render() that are settled — top padding plus the
821
+ // rendered frozen token prefix — exposed via getLastRenderSettledRows()
822
+ // for native-scrollback commit gating.
823
+ #lastRenderSettledRows = 0;
824
+ // Frozen-prefix text backing the last non-zero settled exposure. Settled
825
+ // rows are declared final downstream, so a render whose frozen text no
826
+ // longer extends this prefix (a rewind / wholesale rewrite) resets the
827
+ // exposure to 0 and re-earns it — the exposure is hard-monotone within a
828
+ // text lineage.
829
+ #settledExposedText?: string;
830
+ // True while #renderStreamingContentLines renders the frozen token range:
831
+ // frozen code blocks highlight even in transient mode so their bytes match
832
+ // the finalized render (they render once into the prefix line cache, so
833
+ // the FFI cost is amortized); the volatile tail stays unhighlighted.
834
+ #renderingFrozenPrefix = false;
820
835
 
821
836
  #ignoreTight = false;
822
837
 
@@ -857,6 +872,7 @@ export class Markdown implements Component {
857
872
  this.#streamPrefixText = undefined;
858
873
  this.#streamPrefixTokens = undefined;
859
874
  this.#streamPrefixLineCache = undefined;
875
+ this.#settledExposedText = undefined;
860
876
  }
861
877
  this.invalidate();
862
878
  return true;
@@ -878,6 +894,19 @@ export class Markdown implements Component {
878
894
  this.invalidate();
879
895
  }
880
896
 
897
+ /**
898
+ * Rows at the top of the most recent render() (top padding + rendered
899
+ * frozen-token prefix) whose bytes are settled: byte-stable at this
900
+ * width/theme for as long as the text keeps growing append-only. Hosts
901
+ * feed this to transcript commit gating (see the coding agent's
902
+ * `FinalizableBlock.getTranscriptBlockSettledRows`). 0 outside streaming
903
+ * (`transientRenderCache`) mode, after a text rewind (re-earned on the new
904
+ * lineage), and on cache-served non-streaming renders.
905
+ */
906
+ getLastRenderSettledRows(): number {
907
+ return this.#lastRenderSettledRows;
908
+ }
909
+
881
910
  // Lex `text` into block tokens, reusing the frozen stable prefix when the text
882
911
  // only grew (the streaming path). Falls back to a full lex whenever the prefix
883
912
  // is no longer a prefix (non-append edit), the text carries reference-link
@@ -965,6 +994,10 @@ export class Markdown implements Component {
965
994
  return this.#cachedLines;
966
995
  }
967
996
 
997
+ // Recomputed below by the streaming path; every other path (cache-served,
998
+ // empty text, non-streaming full render) exposes no settled rows.
999
+ this.#lastRenderSettledRows = 0;
1000
+
968
1001
  // Calculate available width for content (subtract horizontal padding)
969
1002
  const paddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX);
970
1003
  const contentWidth = Math.max(1, width - paddingX * 2);
@@ -1076,9 +1109,16 @@ export class Markdown implements Component {
1076
1109
  }
1077
1110
 
1078
1111
  if (renderedUntil < frozenTokenCount) {
1079
- contentLines.push(
1080
- ...this.#renderContentLines(tokens, renderedUntil, frozenTokenCount, contentWidth, signature),
1081
- );
1112
+ // Frozen tokens render with full fidelity (syntax highlighting on)
1113
+ // so these cached rows byte-match the finalized render.
1114
+ this.#renderingFrozenPrefix = true;
1115
+ try {
1116
+ contentLines.push(
1117
+ ...this.#renderContentLines(tokens, renderedUntil, frozenTokenCount, contentWidth, signature),
1118
+ );
1119
+ } finally {
1120
+ this.#renderingFrozenPrefix = false;
1121
+ }
1082
1122
  renderedUntil = frozenTokenCount;
1083
1123
  }
1084
1124
 
@@ -1089,6 +1129,19 @@ export class Markdown implements Component {
1089
1129
  lines: contentLines.slice(),
1090
1130
  };
1091
1131
 
1132
+ // Settled exposure (hard-monotone): these rows are declared final to
1133
+ // the host, so expose them only while the frozen text still extends
1134
+ // the previously exposed prefix; a rewind resets to 0 and re-earns on
1135
+ // the rewritten lineage.
1136
+ if (contentLines.length > 0) {
1137
+ if (this.#settledExposedText === undefined || frozenText.startsWith(this.#settledExposedText)) {
1138
+ this.#settledExposedText = frozenText;
1139
+ this.#lastRenderSettledRows = signature.paddingY + contentLines.length;
1140
+ } else {
1141
+ this.#settledExposedText = undefined;
1142
+ }
1143
+ }
1144
+
1092
1145
  if (renderedUntil < tokens.length) {
1093
1146
  contentLines.push(...this.#renderContentLines(tokens, renderedUntil, tokens.length, contentWidth, signature));
1094
1147
  }
@@ -1361,7 +1414,7 @@ export class Markdown implements Component {
1361
1414
 
1362
1415
  const codeIndent = padding(this.#codeBlockIndent);
1363
1416
  lines.push(this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
1364
- if (this.#theme.highlightCode && !this.transientRenderCache) {
1417
+ if (this.#theme.highlightCode && (!this.transientRenderCache || this.#renderingFrozenPrefix)) {
1365
1418
  const highlightedLines = this.#theme.highlightCode(token.text, token.lang);
1366
1419
  for (const hlLine of highlightedLines) {
1367
1420
  lines.push(`${codeIndent}${hlLine}`);
@@ -1751,7 +1804,7 @@ export class Markdown implements Component {
1751
1804
  // Code block in list item
1752
1805
  const codeIndent = padding(this.#codeBlockIndent);
1753
1806
  lines.push({ text: this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`), nested: false });
1754
- if (this.#theme.highlightCode && !this.transientRenderCache) {
1807
+ if (this.#theme.highlightCode && (!this.transientRenderCache || this.#renderingFrozenPrefix)) {
1755
1808
  const highlightedLines = this.#theme.highlightCode(token.text, token.lang);
1756
1809
  for (const hlLine of highlightedLines) {
1757
1810
  lines.push({ text: `${codeIndent}${hlLine}`, nested: false });
package/src/tui.ts CHANGED
@@ -2,9 +2,10 @@
2
2
  * Minimal TUI implementation with differential rendering.
3
3
  *
4
4
  * Append-only render contract: rows committed to native scrollback are
5
- * immutable. All mutation is confined to the visible window; rows enter
6
- * history exactly once, in order, when the component-reported commit boundary
7
- * (`NativeScrollbackLiveRegion`) says they are final. ED3 (`CSI 3 J`) is
5
+ * immutable the tape is the terminal's visual record. Whatever scrolls
6
+ * above the window enters history exactly once, in order: as exact-final
7
+ * bytes when the component seam (`NativeScrollbackLiveRegion`) declared them
8
+ * final, else as a frozen snapshot of what was on screen. ED3 (`CSI 3 J`) is
8
9
  * emitted only for gesture-driven replays (session replace, resize,
9
10
  * resetDisplay) where snapping the viewport is acceptable. The engine never
10
11
  * probes or guesses the terminal's scroll position, and the hot path clamps
@@ -183,45 +184,23 @@ export interface OverlayFocusOwner {
183
184
  }
184
185
 
185
186
  /**
186
- * Component seam for append-only native-scrollback commits. A component that
187
- * renders a finalized prefix followed by a live/mutating suffix reports the
188
- * local line index where that suffix begins after each render. The engine
189
- * commits rows to native scrollback only up to that boundary; everything
190
- * below repaints in place inside the visible window and never enters history
191
- * until it finalizes.
187
+ * Component seam for append-only native-scrollback commits. A component whose
188
+ * rendered rows can still change reports, after each render, the local line
189
+ * index where that mutable suffix begins. Rows above the boundary are declared
190
+ * FINAL byte-stable at the current width for the component's lifetime — and
191
+ * commit to native scrollback as exact, audited content. Rows at/after the
192
+ * boundary repaint in place inside the visible window; when they scroll above
193
+ * the window top they still commit — the tape records what was on screen —
194
+ * but as frozen visual snapshots that are permanently audit-exempt: later
195
+ * re-layout of their source never re-anchors or recommits them. A root that
196
+ * reports no seam commits everything that scrolls as final (shell semantics).
192
197
  *
193
- * `getNativeScrollbackCommitSafeEnd` optionally reports a *deeper* boundary
194
- * inside the live suffix: the line index up to which the live region is
195
- * append-only (earlier rows never re-layout — a streaming assistant message).
196
- * Rows in `[liveRegionStart, commitSafeEnd)` may commit even though they are
197
- * technically live, because they will never change. Without it, a single live
198
- * block that alone overflows the window would hold its scrolled-off head out
199
- * of history until it finalizes. Volatile live blocks (tool previews that
200
- * collapse) omit it. Defaults to `liveRegionStart` when absent; a root that
201
- * reports no seam at all commits everything that scrolls (shell semantics).
202
- * `getNativeScrollbackSnapshotSafeEnd` optionally reports a still deeper
203
- * boundary: the line index up to which the live region is *durable* — its rows
204
- * may still change bytes later (a streaming markdown table re-aligning its
205
- * columns every row), but their CURRENT snapshot is permanent content, so
206
- * dropping them when they scroll above the window is forbidden. Unlike
207
- * `commitSafeEnd` (byte-stable: offered rows are asserted never to re-layout and
208
- * stay under the committed-prefix audit), rows committed under the snapshot end
209
- * are audit-EXEMPT once they pass the window top — the engine appends their
210
- * scroll-off snapshot and never recommits them, so later layout drift becomes a
211
- * frozen stale row in history (duplication never loss) instead of either a
212
- * dropped row or an audit re-anchor spray. Provisional live blocks (collapsing
213
- * tool/edit previews whose head is a throwaway tail window) omit it. Defaults to
214
- * `commitSafeEnd ?? liveRegionStart` when absent.
215
- *
216
- * When several root children report a seam in the same frame, the topmost
217
- * one (and its commit-safe / snapshot-safe extension) defines the boundary:
218
- * commits are prefix-only, so everything below the first seam is already
219
- * excluded.
198
+ * When several root children report a seam in the same frame, the topmost one
199
+ * defines the boundary: exactness is prefix-only, so everything below the
200
+ * first seam is already excluded.
220
201
  */
221
202
  export interface NativeScrollbackLiveRegion {
222
203
  getNativeScrollbackLiveRegionStart(): number | undefined;
223
- getNativeScrollbackCommitSafeEnd?(): number | undefined;
224
- getNativeScrollbackSnapshotSafeEnd?(): number | undefined;
225
204
  }
226
205
 
227
206
  export interface NativeScrollbackCommittedRows {
@@ -243,14 +222,6 @@ function getNativeScrollbackLiveRegionStart(component: Component): number | unde
243
222
  return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackLiveRegionStart?.();
244
223
  }
245
224
 
246
- function getNativeScrollbackCommitSafeEnd(component: Component): number | undefined {
247
- return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackCommitSafeEnd?.();
248
- }
249
-
250
- function getNativeScrollbackSnapshotSafeEnd(component: Component): number | undefined {
251
- return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackSnapshotSafeEnd?.();
252
- }
253
-
254
225
  /**
255
226
  * Opt-in stability report for components that mutate their returned render
256
227
  * array in place across frames (instead of returning a fresh array per
@@ -617,7 +588,7 @@ interface CursorControlResult extends HardwareCursorUpdate {
617
588
  * One root child's contribution to the composed frame: the array reference its
618
589
  * render() returned, the frame row it starts at, the row count recorded at
619
590
  * compose time (in-place mutators keep the reference but may change length),
620
- * and the child-local seam reports captured at render time — replayed verbatim
591
+ * and the child-local seam report captured at render time — replayed verbatim
621
592
  * when a component-scoped frame reuses this segment without re-rendering.
622
593
  */
623
594
  interface FrameSegment {
@@ -626,8 +597,6 @@ interface FrameSegment {
626
597
  start: number;
627
598
  rowCount: number;
628
599
  liveLocalStart?: number;
629
- commitLocalEnd?: number;
630
- snapshotLocalEnd?: number;
631
600
  }
632
601
 
633
602
  /** Depth-first identity search through `Container`-shaped children. */
@@ -803,33 +772,27 @@ const RESYNC_TAIL_SAMPLES = 8;
803
772
  * re-anchor the commit index when it does not. Returns the resync row index,
804
773
  * or -1 when no resync is needed.
805
774
  *
806
- * Audits the committed prefix [0, auditTo) EXCEPT the exempt window
807
- * [exemptFrom, exemptTo): rows in the window are durable snapshots (a streaming
808
- * table re-aligning its columns) that may drift legitimately, so their drift
809
- * never triggers a re-anchor. Rows below the window including forced-overflow
810
- * rows committed only because they scrolled above the viewport under a
811
- * commit-unstable barrier ARE audited.
812
- *
813
- * Two detectors run over the audited rows:
775
+ * Zones (verifiedTo finalTo prefix.length):
776
+ * [0, verifiedTo) VERIFIED exact rows sampled with tolerance.
777
+ * [verifiedTo, finalTo) NEWLY-FINAL rows frozen visual snapshots whose
778
+ * source just became declared-final (the block finalized / a barrier
779
+ * cleared). Hard-scanned in FULL with no tolerance: any content change
780
+ * (a pending header settling, a preview replaced by its result, a tail
781
+ * shifting up after a barrier removal) re-anchors so the final content
782
+ * recommits below the frozen snapshot duplication, never loss —
783
+ * instead of being committed nowhere and painted nowhere.
784
+ * [finalTo, prefix.length) FROZEN visual snapshots of still-live rows —
785
+ * exempt: their drift is expected (a collapsing preview, a ticking
786
+ * progress tree) and must never spray re-anchors mid-run.
814
787
  *
815
- * 1. Hard scan of the now-permanent forced suffix [exemptTo, permanentEnd):
816
- * forced-overflow rows that THIS frame asserts are durable/permanent (index <
817
- * permanentEnd the barrier above them finalized or cleared, so durableBoundary
818
- * rose past them). A content change there is real finalized content, so ANY
819
- * mismatch re-anchors. Scanned in FULL, not sampled, so a single edit far above
820
- * the commit boundary with an unchanged tail still re-anchors (duplication,
821
- * never loss) instead of being committed nowhere and painted nowhere.
822
- * 2. Tail sample (only when the hard scan is clean): exploits the asymmetry
823
- * between the two mutation classes — an in-place edit/restyle of a committed
824
- * row disturbs only the touched rows (alignment below intact; the stale copy
825
- * in history is the long-accepted artifact), while an insertion/deletion
826
- * shifts EVERY row below it. So up to 8 non-blank rows within the last 24
827
- * audited rows are compared SGR-stripped (theme changes stay quiet),
828
- * tolerating a SINGLE non-hard mismatch (a legitimate one-row edit): aligned ⇒
829
- * no resync; misaligned ⇒ resync at the first non-equivalent audited row. The
830
- * tolerance keeps both an offscreen still-live barrier (a ticking spinner) and
831
- * a no-seam in-place row edit from spraying duplicate snapshots every frame;
832
- * the hard scan above is what forbids it from swallowing a finalized row.
788
+ * The verified zone's sampled check exploits the asymmetry between the two
789
+ * mutation classes: an in-place edit/restyle disturbs only the touched rows
790
+ * (alignment below stays intact; the stale copy in history is the accepted
791
+ * artifact), while an insertion/deletion shifts EVERY row below it. Up to 8
792
+ * non-blank rows within the last 24 verified rows are compared SGR-stripped
793
+ * (theme changes stay quiet), tolerating a SINGLE mismatch. The tolerance is
794
+ * load-bearing for roots that report NO seam: an animated row already in
795
+ * history would otherwise re-anchor on every glyph tick.
833
796
  *
834
797
  * Highly repetitive tails (identical filler rows) can mask a shift in the tail
835
798
  * sample, in which case the skipped rows are content-identical to the committed
@@ -839,39 +802,30 @@ const RESYNC_TAIL_SAMPLES = 8;
839
802
  export function findCommittedPrefixResync(
840
803
  frame: readonly string[],
841
804
  prefix: readonly string[],
842
- auditTo: number = prefix.length,
843
- exemptFrom: number = auditTo,
844
- exemptTo: number = exemptFrom,
845
- permanentEnd = 0,
805
+ verifiedTo: number = prefix.length,
806
+ finalTo: number = verifiedTo,
846
807
  ): number {
847
- const committed = Math.min(prefix.length, Math.max(0, Math.trunc(auditTo)));
848
- if (committed === 0) return -1;
849
- // Exempt window [exFrom, exTo) clamped into the committed prefix. Rows there
850
- // are durable-snapshot drift and skipped by both detectors and the scan.
851
- const exFrom = Math.max(0, Math.min(committed, Math.trunc(exemptFrom)));
852
- const exTo = Math.max(exFrom, Math.min(committed, Math.trunc(exemptTo)));
853
- const audited = (i: number): boolean => i < exFrom || i >= exTo;
854
- if (frame.length >= committed) {
855
- // 1. Hard scan: forced-overflow rows now asserted permanent. Full scan, no
856
- // tolerance — a finalized row that changed must re-anchor.
857
- const hardEnd = Math.min(committed, Math.max(0, Math.trunc(permanentEnd)));
808
+ const verified = Math.min(prefix.length, Math.max(0, Math.trunc(verifiedTo)));
809
+ const hardEnd = Math.min(prefix.length, Math.max(verified, Math.trunc(finalTo)));
810
+ if (hardEnd === 0) return -1;
811
+ if (frame.length >= hardEnd) {
812
+ // 1. Hard scan: frozen snapshots whose source just became final. Full
813
+ // scan, no tolerance a finalized row that changed must re-anchor.
858
814
  let hardMismatch = false;
859
- for (let i = exTo; i < hardEnd; i++) {
815
+ for (let i = verified; i < hardEnd; i++) {
860
816
  if (!rowsEquivalent(frame[i]!, prefix[i]!)) {
861
817
  hardMismatch = true;
862
818
  break;
863
819
  }
864
820
  }
865
821
  if (!hardMismatch) {
866
- // 2. Tail sample. Walk up from the commit boundary, skipping exempt
867
- // rows, until LOOKBACK audited rows or SAMPLES non-blank comparisons.
822
+ // 2. Tail sample over the verified zone (only when the hard scan is
823
+ // clean): walk up from its end until LOOKBACK rows or SAMPLES
824
+ // non-blank comparisons.
868
825
  let samples = 0;
869
826
  let mismatches = 0;
870
- let scanned = 0;
871
- for (let j = 1; j <= committed && scanned < RESYNC_TAIL_LOOKBACK && samples < RESYNC_TAIL_SAMPLES; j++) {
872
- const idx = committed - j;
873
- if (!audited(idx)) continue;
874
- scanned++;
827
+ for (let j = 1; j <= verified && j <= RESYNC_TAIL_LOOKBACK && samples < RESYNC_TAIL_SAMPLES; j++) {
828
+ const idx = verified - j;
875
829
  const row = frame[idx]!;
876
830
  const old = prefix[idx]!;
877
831
  if (row === old) {
@@ -882,18 +836,18 @@ export function findCommittedPrefixResync(
882
836
  samples++;
883
837
  if (!rowsEquivalent(row, old)) mismatches++;
884
838
  }
885
- // No signal (all-blank/all-exempt tail) or at most one edited row: aligned.
839
+ // No signal (all-blank tail) or at most one edited row: aligned.
886
840
  if (samples === 0 || mismatches <= 1) return -1;
887
841
  }
888
842
  }
889
- // Misaligned (hard mismatch, tail-sample shift, or the frame no longer covers
890
- // the prefix): re-anchor at the first audited row whose content changed.
891
- const limit = Math.min(committed, frame.length);
843
+ // Misaligned (hard mismatch, tail-sample shift, or the frame no longer
844
+ // covers the checked zones): re-anchor at the first row whose content
845
+ // changed.
846
+ const limit = Math.min(hardEnd, frame.length);
892
847
  for (let i = 0; i < limit; i++) {
893
- if (!audited(i)) continue;
894
848
  if (!rowsEquivalent(frame[i]!, prefix[i]!)) return i;
895
849
  }
896
- return limit < committed ? limit : -1;
850
+ return limit < hardEnd ? limit : -1;
897
851
  }
898
852
 
899
853
  /**
@@ -999,33 +953,31 @@ export class TUI extends Container {
999
953
  #cursorEndSequence = this.#synchronizedOutputEnabled ? CURSOR_END : CURSOR_END_NO_SYNC;
1000
954
  // Rows of the current frame physically committed to the terminal tape
1001
955
  // (native scrollback or scrolled past the window top). Immutable by
1002
- // contract: the engine never rewrites them, and components keep mutable
1003
- // rows below the `NativeScrollbackLiveRegion` boundary so they never get
1004
- // here while they can still change.
956
+ // contract: the engine never rewrites them. Rows below
957
+ // #committedPrefixAuditRows entered as exact-final bytes (the component
958
+ // seam declared them); rows at/after it are frozen visual snapshots that
959
+ // scrolled off the window top while still live.
1005
960
  #committedRows = 0;
1006
961
  // Raw rows mirroring [0, #committedRows) — the engine's claim of what it
1007
- // committed, audited each ordinary frame against the current render to
1008
- // detect components re-laying-out committed content (see
1009
- // #auditCommittedPrefix). Holds references to component-cached strings, so
1010
- // the audit is a pointer walk in the common case.
962
+ // committed. The audited prefix [0, #committedPrefixAuditRows) is checked
963
+ // each ordinary frame against the current render to detect components
964
+ // re-laying-out declared-final content (see #auditCommittedPrefix). Holds
965
+ // references to component-cached strings, so the audit is a pointer walk
966
+ // in the common case.
1011
967
  #committedPrefix: string[] = [];
1012
- // The committed prefix [0, committedRows) splits into three audit zones by
1013
- // two monotone marks auditRows durableRows committedRows:
1014
- // [0, auditRows) BYTE-STABLE audited (re-anchor on any shift).
1015
- // [auditRows, durableRows) DURABLE snapshot exempt: rows may drift in
1016
- // place (a streaming table widening) without re-anchoring, so their
1017
- // expected drift never sprays duplicate snapshots.
1018
- // [durableRows, committedRows) FORCED-overflow audited: rows committed
1019
- // only because they scrolled above the window under a commit-unstable
1020
- // barrier; auditing them re-anchors (duplication, never loss) when the
1021
- // barrier later shifts/finalizes/removes, instead of stranding a stale
1022
- // prefix that silently drops the rows beneath it.
1023
- // Both marks re-base on a wholesale re-slice (full paint / shrink / geometry)
1024
- // and otherwise advance per the persistence rules in #updateCommittedAuditRows.
1025
- // #auditCommittedPrefix audits [0, committedRows) skipping the exempt window
1026
- // [auditRows, durableRows).
968
+ // Rows of the committed prefix that were HARD-VERIFIED as exact-final
969
+ // bytes (committed below the exactness boundary, or frozen snapshots that
970
+ // passed the one-time strict scan when the boundary rose past them). Rows
971
+ // in [#committedPrefixAuditRows, #committedRows) are frozen visual
972
+ // snapshots of still-live content the terminal's record of what was on
973
+ // screen when it scrolled off — and are audit-exempt while their source
974
+ // remains live, so a collapsing preview never sprays re-anchors mid-run.
975
+ // When the exactness boundary rises past them (the block finalized), they
976
+ // are strict-scanned exactly once: unchanged rows join the verified zone,
977
+ // a divergence re-anchors so the final content recommits below the frozen
978
+ // snapshot (duplication, never loss). Re-based on full paints / shrinks /
979
+ // geometry frames.
1027
980
  #committedPrefixAuditRows = 0;
1028
- #committedPrefixDurableRows = 0;
1029
981
  // Frame row currently mapped to screen row 0. Monotonic between full
1030
982
  // paints: a shrink never re-exposes scrolled-off rows (they cannot be
1031
983
  // un-scrolled without rewriting history); live rows repaint at fixed
@@ -1034,8 +986,6 @@ export class TUI extends Container {
1034
986
  // Exactly what is painted on the screen rows (post-composite, prepared).
1035
987
  #previousWindow: string[] = [];
1036
988
  #nativeScrollbackLiveRegionStart: number | undefined;
1037
- #nativeScrollbackCommitSafeEnd: number | undefined;
1038
- #nativeScrollbackSnapshotSafeEnd: number | undefined;
1039
989
  #fullRedrawCount = 0;
1040
990
  // Caps how many inline images render as live graphics; older ones fall back
1041
991
  // to text via a purge + full redraw. Cap is configured by the host app.
@@ -1153,8 +1103,6 @@ export class TUI extends Container {
1153
1103
  override render(width: number): readonly string[] {
1154
1104
  width = Math.max(1, width);
1155
1105
  this.#nativeScrollbackLiveRegionStart = undefined;
1156
- this.#nativeScrollbackCommitSafeEnd = undefined;
1157
- this.#nativeScrollbackSnapshotSafeEnd = undefined;
1158
1106
  const children = this.children;
1159
1107
  const previousSegments = this.#frameSegments;
1160
1108
  const segments: FrameSegment[] = new Array(children.length);
@@ -1175,14 +1123,10 @@ export class TUI extends Container {
1175
1123
  partialRoots !== null && previous !== undefined && previous.component === child && !partialRoots.has(child);
1176
1124
  let childLines: readonly string[];
1177
1125
  let liveLocalStart: number | undefined;
1178
- let commitLocalEnd: number | undefined;
1179
- let snapshotLocalEnd: number | undefined;
1180
1126
  let reported: number | undefined;
1181
1127
  if (reuse) {
1182
1128
  childLines = previous.lines;
1183
1129
  liveLocalStart = previous.liveLocalStart;
1184
- commitLocalEnd = previous.commitLocalEnd;
1185
- snapshotLocalEnd = previous.snapshotLocalEnd;
1186
1130
  } else {
1187
1131
  // Feed the engine's committed-row claim (from the previous frame's
1188
1132
  // emit) before rendering so the child can skip re-deriving blocks
@@ -1195,22 +1139,6 @@ export class TUI extends Container {
1195
1139
  liveLocalStart = Number.isFinite(liveRegionStart)
1196
1140
  ? Math.max(0, Math.min(childLines.length, Math.trunc(liveRegionStart)))
1197
1141
  : childLines.length;
1198
- const commitSafeEnd = getNativeScrollbackCommitSafeEnd(child);
1199
- if (commitSafeEnd !== undefined) {
1200
- commitLocalEnd = Number.isFinite(commitSafeEnd)
1201
- ? Math.max(liveLocalStart, Math.min(childLines.length, Math.trunc(commitSafeEnd)))
1202
- : childLines.length;
1203
- }
1204
- // Durable snapshot end: clamped at/above the byte-stable end (or
1205
- // the live-region start when none) so a child can never report a
1206
- // shallower durable boundary than its byte-stable one.
1207
- const snapshotSafeEnd = getNativeScrollbackSnapshotSafeEnd(child);
1208
- if (snapshotSafeEnd !== undefined) {
1209
- const snapshotFloor = commitLocalEnd ?? liveLocalStart;
1210
- snapshotLocalEnd = Number.isFinite(snapshotSafeEnd)
1211
- ? Math.max(snapshotFloor, Math.min(childLines.length, Math.trunc(snapshotSafeEnd)))
1212
- : childLines.length;
1213
- }
1214
1142
  }
1215
1143
  // Consume the stability report unconditionally for implementers:
1216
1144
  // reading re-bases the component's baseline to the state this
@@ -1221,19 +1149,13 @@ export class TUI extends Container {
1221
1149
  reported = getRenderStablePrefixRows(child);
1222
1150
  }
1223
1151
  // Topmost seam wins. Commits are prefix-only: the first child that
1224
- // reports a live region (plus its own commit-safe extension) already
1225
- // bounds everything below it, so a lower sibling's seam (e.g. a
1226
- // status loader under a streaming transcript) must never overwrite
1227
- // it — moving the boundary down would commit the earlier child's
1228
- // still-mutable rows as stale history.
1152
+ // reports a live region already bounds everything below it, so a
1153
+ // lower sibling's seam (e.g. a status loader under a streaming
1154
+ // transcript) must never overwrite it moving the boundary down
1155
+ // would commit the earlier child's still-mutable rows as stale
1156
+ // history.
1229
1157
  if (liveLocalStart !== undefined && this.#nativeScrollbackLiveRegionStart === undefined) {
1230
1158
  this.#nativeScrollbackLiveRegionStart = offset + liveLocalStart;
1231
- if (commitLocalEnd !== undefined) {
1232
- this.#nativeScrollbackCommitSafeEnd = offset + commitLocalEnd;
1233
- }
1234
- if (snapshotLocalEnd !== undefined) {
1235
- this.#nativeScrollbackSnapshotSafeEnd = offset + snapshotLocalEnd;
1236
- }
1237
1159
  }
1238
1160
  if (chainStable) {
1239
1161
  if (previous !== undefined && previous.component === child && previous.start === offset) {
@@ -1262,8 +1184,6 @@ export class TUI extends Container {
1262
1184
  start: offset,
1263
1185
  rowCount: childLines.length,
1264
1186
  liveLocalStart,
1265
- commitLocalEnd,
1266
- snapshotLocalEnd,
1267
1187
  };
1268
1188
  offset += childLines.length;
1269
1189
  }
@@ -2662,31 +2582,15 @@ export class TUI extends Container {
2662
2582
  // known. Ascending by frame row.
2663
2583
  const cursorMarkers = this.#frameCursorMarkers;
2664
2584
  const liveRegionStart = this.#nativeScrollbackLiveRegionStart;
2665
- const commitSafeEnd = this.#nativeScrollbackCommitSafeEnd;
2666
- const snapshotSafeEnd = this.#nativeScrollbackSnapshotSafeEnd;
2667
-
2668
- // Commit boundaries (also used by the window/commit math in section 3),
2669
- // hoisted above the audit gate because the resync needs byteStableBoundary
2670
- // to tell a now-permanent forced row (must re-anchor) from a still-live one.
2671
- // The commit floor is windowTop in every non-frozen path (see chunkTo), so
2672
- // whatever scrolls above the window is committed — never committed nowhere
2673
- // AND painted nowhere (the loss bug). The boundaries no longer gate the
2674
- // commit; they define the audit-exempt span. byteStableBoundary: rows below
2675
- // it are byte-stable (never re-layout), audited. durableBoundary: rows in
2676
- // [byteStableBoundary, durableBoundary) are durable — permanent on scroll-off
2677
- // but may drift in place (a streaming table re-aligning), committed
2678
- // audit-EXEMPT. Rows at/beyond durableBoundary committed only because they
2679
- // scrolled above the window (a commit-unstable barrier over a long tail) are
2680
- // forced-overflow rows: audited, so a later shift/finalize/removal re-anchors
2681
- // (duplication, never loss) instead of stranding a stale prefix. Built on the
2682
- // finalized prefix (live-region start); the whole frame when the root reports
2683
- // no seam (shell semantics: whatever scrolls is final).
2585
+
2586
+ // Exactness boundary (used by the audit-zone math below). Rows below it
2587
+ // are declared FINAL by the component seam: when they commit, they enter
2588
+ // the audited zone (byte-exact, repairable on violation). Rows above it
2589
+ // that scroll off the window commit as frozen visual snapshots (see
2590
+ // #committedPrefixAuditRows). The whole frame is final when the root
2591
+ // reports no seam (shell semantics).
2684
2592
  const frameLength = rawFrame.length;
2685
- const byteStableBoundary = Math.max(0, Math.min(frameLength, commitSafeEnd ?? liveRegionStart ?? frameLength));
2686
- const durableBoundary = Math.max(
2687
- byteStableBoundary,
2688
- Math.min(frameLength, snapshotSafeEnd ?? byteStableBoundary),
2689
- );
2593
+ const finalBoundary = Math.max(0, Math.min(frameLength, liveRegionStart ?? frameLength));
2690
2594
 
2691
2595
  // 2. Transition state captured before any emitter runs.
2692
2596
  const prevWindowTop = this.#windowTopRow;
@@ -2703,49 +2607,67 @@ export class TUI extends Container {
2703
2607
  (resizeEventOccurred && this.#previousHeight > 0);
2704
2608
  const geometryChanged = widthChanged || heightChanged;
2705
2609
 
2706
- // Committed-prefix audit: rows below the commit index are physically in
2707
- // terminal history and must never re-layout. When a component violates
2708
- // that a budget-demoted image collapsing to its one-line fallback, a
2709
- // TTSR rewind truncating a block whose sealed prefix already committed
2710
- // keeping the old index would silently skip that many rows of
2711
- // everything below (content loss). Re-anchor at the divergence instead:
2712
- // the stale copy stays in history and rows recommit from there
2713
- // duplication, never loss. Skipped on geometry frames (a rewrap
2714
- // legitimately reflows every row; the mux branch re-bases the prefix
2715
- // and non-mux geometry replays from scratch), and skipped when the
2716
- // composed frame's stable prefix covers every committed row bytes
2717
- // that provably did not change since the last (aligned) frame cannot
2718
- // have diverged.
2610
+ // Committed-prefix audit. Rows below the audit mark are hard-verified
2611
+ // exact bytes; rows between the mark and the current exactness boundary
2612
+ // are frozen snapshots whose source JUST became final and must be
2613
+ // verified once (a pending header settling, a barrier clearing above a
2614
+ // shifted tail); rows past the boundary are still-live frozen snapshots,
2615
+ // exempt so a collapsing preview can never spray re-anchors mid-run. A
2616
+ // divergence re-anchors and recommits duplication, never loss
2617
+ // instead of silently skipping rows (committed nowhere, painted
2618
+ // nowhere). Skipped on geometry frames (a rewrap legitimately reflows
2619
+ // every row), and skipped when the composed frame's stable prefix
2620
+ // covers every verified row and no rows newly became final.
2719
2621
  let committedRowsResynced = false;
2720
- // Audit covers [0, auditRows) and the forced suffix [durableRows,
2721
- // committedRows); the durable middle [auditRows, durableRows) is exempt
2722
- // (in-place drift). Two reasons to run the audit this frame:
2723
- // - the stable prefix does not cover every audited row (auditUpper); or
2724
- // - a forced-overflow row this frame became durable/permanent
2725
- // (committedPrefixDurableRows < hardAuditEnd): the barrier above it
2726
- // finalized, so its committed bytes must be re-checked even though the
2727
- // stable prefix says nothing moved — a stale committed copy there would
2728
- // silently drop the row. The hard scan in findCommittedPrefixResync
2729
- // covers [durableRows, hardAuditEnd) in full (no tail-sample miss).
2730
- const auditUpper =
2731
- this.#committedPrefixDurableRows < this.#committedRows ? this.#committedRows : this.#committedPrefixAuditRows;
2732
- const hardAuditEnd = Math.min(this.#committedRows, durableBoundary);
2733
- const needHardAudit = this.#committedPrefixDurableRows < hardAuditEnd;
2622
+ const newlyFinalEnd = Math.min(this.#committedRows, finalBoundary);
2623
+ // The exactness boundary can RETREAT (a markdown rewind, a mermaid fence
2624
+ // appearing, a fast-path reset re-opening a block): rows verified under
2625
+ // the old boundary have a live source again. Demote them to frozen
2626
+ // snapshots instead of auditing content that is expected to change —
2627
+ // their committed bytes stay as the visual record, and the next boundary
2628
+ // rise strict-verifies them once like any other frozen row.
2629
+ if (this.#committedPrefixAuditRows > newlyFinalEnd) {
2630
+ this.#committedPrefixAuditRows = newlyFinalEnd;
2631
+ }
2734
2632
  const auditRan =
2735
2633
  this.#hasEverRendered &&
2736
2634
  !geometryChanged &&
2737
2635
  !this.#clearScrollbackOnNextRender &&
2738
- (this.#renderStablePrefixRows < auditUpper || needHardAudit);
2636
+ (this.#renderStablePrefixRows < this.#committedPrefixAuditRows ||
2637
+ newlyFinalEnd > this.#committedPrefixAuditRows);
2739
2638
  if (auditRan) {
2740
2639
  const committedRowsBeforeAudit = this.#committedRows;
2741
- this.#auditCommittedPrefix(rawFrame, durableBoundary);
2640
+ this.#auditCommittedPrefix(rawFrame, newlyFinalEnd);
2742
2641
  committedRowsResynced = this.#committedRows !== committedRowsBeforeAudit;
2743
2642
  }
2744
- // Committed-prefix state this frame's commit math extends from (post-audit).
2745
- // Drives the audit-rows / durable-rows caps recomputed after the emit.
2643
+ // A frame that shrank below the committed row count collapsed content
2644
+ // that was already recorded (a live suffix collapsing on abort/result).
2645
+ // Re-base the commit index at the first divergence against the recorded
2646
+ // prefix — frozen snapshots included; a collapse is precisely when the
2647
+ // record and the frame part ways — so the surviving exact prefix stays
2648
+ // recognized and is never re-shown or re-committed. Only genuinely new
2649
+ // content repaints below it.
2650
+ if (!geometryChanged && !this.#clearScrollbackOnNextRender && frameLength < this.#committedRows) {
2651
+ const limit = Math.min(this.#committedRows, frameLength);
2652
+ let diverged = limit;
2653
+ for (let i = 0; i < limit; i++) {
2654
+ if (!rowsEquivalent(rawFrame[i]!, this.#committedPrefix[i]!)) {
2655
+ diverged = i;
2656
+ break;
2657
+ }
2658
+ }
2659
+ if (diverged < this.#committedRows) {
2660
+ this.#committedRows = diverged;
2661
+ this.#committedPrefixAuditRows = Math.min(this.#committedPrefixAuditRows, diverged);
2662
+ this.#committedPrefix.length = diverged;
2663
+ committedRowsResynced = true;
2664
+ }
2665
+ }
2666
+ // Committed-prefix state this frame's commit math extends from
2667
+ // (post-audit): drives the audit-mark advance after the emit.
2746
2668
  const preCommitRows = this.#committedRows;
2747
- const preCommitAuditRows = this.#committedPrefixAuditRows;
2748
- const preCommitDurableRows = this.#committedPrefixDurableRows;
2669
+ const preAuditRows = this.#committedPrefixAuditRows;
2670
+ let committedPrefixResliced = false;
2749
2671
 
2750
2672
  // 3. Window and commit math (lengths only; content prepared below).
2751
2673
  let hasVisibleOverlay = false;
@@ -2768,7 +2690,6 @@ export class TUI extends Container {
2768
2690
  const fullPaint = firstPaint || replaceRequested || geometryRebuild;
2769
2691
  let windowTop: number;
2770
2692
  let chunkTo: number;
2771
- let committedPrefixResliced = false;
2772
2693
  if (fullPaint) {
2773
2694
  committedPrefixResliced = true;
2774
2695
  windowTop = Math.max(0, frameLength - height);
@@ -2789,9 +2710,9 @@ export class TUI extends Container {
2789
2710
  // stale committed copy stays in native history; duplicating a few rows
2790
2711
  // is preferable to a live editor gap and matches the existing
2791
2712
  // "duplication, never loss" resync contract.
2713
+ committedPrefixResliced = true;
2792
2714
  windowTop = Math.max(0, frameLength - height);
2793
2715
  chunkTo = windowTop;
2794
- committedPrefixResliced = true;
2795
2716
  this.#committedRows = chunkTo;
2796
2717
  this.#committedPrefix = rawFrame.slice(0, chunkTo);
2797
2718
  } else {
@@ -2802,12 +2723,14 @@ export class TUI extends Container {
2802
2723
  // multiplexer resize the pane reflowed its own history; committed
2803
2724
  // rows keep their old wrap there, same as any shell output.
2804
2725
  windowTop = Math.max(this.#committedRows, frameLength - height, 0);
2805
- // Overlays freeze commits: composited rows must never enter
2806
- // history, and the hidden gap backfills via the chunk once the
2807
- // overlay closes. A multiplexer resize also commits nothing the
2808
- // pane keeps its own (old-wrap) history and re-bases the audit
2809
- // prefix at the new width so the accepted wrap drift does not read
2810
- // as a violation on the next ordinary frame.
2726
+ // Whatever scrolls above the window commits the tape is the visual
2727
+ // record; nothing that was painted may vanish. Overlays freeze
2728
+ // commits: composited rows must never enter history, and the hidden
2729
+ // gap backfills via the chunk once the overlay closes. A multiplexer
2730
+ // resize also commits nothing the pane keeps its own (old-wrap)
2731
+ // history and re-bases the audit prefix at the new width so the
2732
+ // accepted wrap drift does not read as a violation on the next
2733
+ // ordinary frame.
2811
2734
  chunkTo = hasVisibleOverlay || geometryChanged ? this.#committedRows : windowTop;
2812
2735
  if (geometryChanged) {
2813
2736
  committedPrefixResliced = true;
@@ -2870,15 +2793,7 @@ export class TUI extends Container {
2870
2793
  cursorTrackingLineCount,
2871
2794
  });
2872
2795
  this.#committedPrefix = rawFrame.slice(0, chunkTo);
2873
- this.#updateCommittedAuditRows(
2874
- true,
2875
- preCommitRows,
2876
- preCommitAuditRows,
2877
- preCommitDurableRows,
2878
- byteStableBoundary,
2879
- durableBoundary,
2880
- false,
2881
- );
2796
+ this.#committedPrefixAuditRows = Math.min(chunkTo, finalBoundary);
2882
2797
  this.#clearScrollbackOnNextRender = false;
2883
2798
  this.#hasEverRendered = true;
2884
2799
  if (!firstPaint && frameLength > height) this.#armPostFullPaintSettle();
@@ -2899,39 +2814,32 @@ export class TUI extends Container {
2899
2814
  for (let i = this.#committedPrefix.length; i < chunkTo; i++) {
2900
2815
  this.#committedPrefix.push(rawFrame[i] ?? "");
2901
2816
  }
2902
- this.#updateCommittedAuditRows(
2903
- committedPrefixResliced,
2904
- preCommitRows,
2905
- preCommitAuditRows,
2906
- preCommitDurableRows,
2907
- byteStableBoundary,
2908
- durableBoundary,
2909
- auditRan,
2910
- );
2817
+ // Audit-mark advance. A re-slice re-bases it outright. Otherwise it may
2818
+ // advance to the exactness boundary only when this frame verified the
2819
+ // newly-final span (auditRan hard-scans it) or no such span existed —
2820
+ // rows committed this frame below the boundary are fresh exact bytes.
2821
+ if (committedPrefixResliced || auditRan || preAuditRows >= Math.min(preCommitRows, finalBoundary)) {
2822
+ this.#committedPrefixAuditRows = Math.min(this.#committedRows, finalBoundary);
2823
+ } else {
2824
+ this.#committedPrefixAuditRows = Math.min(preAuditRows, this.#committedRows);
2825
+ }
2911
2826
  }
2912
2827
 
2913
2828
  /**
2914
- * Detect committed-prefix violations and re-anchor the commit index at the
2915
- * first moved row, so subsequent rows recommit instead of being skipped:
2916
- * the stale copy stays in history duplication, never loss. Pure in-place
2917
- * restyles keep their alignment and are left alone (stale styling in
2918
- * history was always the accepted artifact).
2829
+ * Detect committed-prefix violations (see {@link findCommittedPrefixResync}
2830
+ * for the zone semantics) and re-anchor the commit index at the first moved
2831
+ * row, so subsequent rows recommit instead of being skipped: the stale copy
2832
+ * stays in history duplication, never loss. Pure in-place restyles keep
2833
+ * their alignment and are left alone (stale styling in history was always
2834
+ * the accepted artifact).
2919
2835
  */
2920
- #auditCommittedPrefix(rawFrame: readonly string[], permanentEnd: number): void {
2836
+ #auditCommittedPrefix(rawFrame: readonly string[], newlyFinalEnd: number): void {
2921
2837
  const prefix = this.#committedPrefix;
2922
2838
  if (prefix.length === 0) return;
2923
- const resyncTo = findCommittedPrefixResync(
2924
- rawFrame,
2925
- prefix,
2926
- prefix.length,
2927
- this.#committedPrefixAuditRows,
2928
- this.#committedPrefixDurableRows,
2929
- permanentEnd,
2930
- );
2839
+ const resyncTo = findCommittedPrefixResync(rawFrame, prefix, this.#committedPrefixAuditRows, newlyFinalEnd);
2931
2840
  if (resyncTo < 0) return;
2932
2841
  this.#committedRows = resyncTo;
2933
2842
  this.#committedPrefixAuditRows = Math.min(this.#committedPrefixAuditRows, resyncTo);
2934
- this.#committedPrefixDurableRows = Math.min(this.#committedPrefixDurableRows, resyncTo);
2935
2843
  prefix.length = resyncTo;
2936
2844
  if ($flag("PI_DEBUG_REDRAW")) {
2937
2845
  const msg = `[${new Date().toISOString()}] commit resync: committed prefix diverged at row ${resyncTo}; recommitting\n`;
@@ -2939,46 +2847,6 @@ export class TUI extends Container {
2939
2847
  }
2940
2848
  }
2941
2849
 
2942
- /**
2943
- * Recompute the audit-rows / durable-rows marks after a commit (see the
2944
- * #committedPrefixAuditRows field doc for the three audit zones).
2945
- *
2946
- * auditRows tracks the byte-stable boundary; durableRows the durable snapshot
2947
- * boundary. A wholesale re-slice (full paint / shrink / geometry) re-bases
2948
- * each mark from the current frame (min(committed, boundary)). An incremental
2949
- * extend keeps a mark once a row past it has committed (mark < committed): a
2950
- * later RISE in a boundary (a table finalizing) must neither pull
2951
- * already-committed stale snapshots back under the byte-stable cap nor
2952
- * retroactively exempt forced-overflow rows already audited. durableRows is
2953
- * floored at auditRows so the exempt window can never invert.
2954
- */
2955
- #updateCommittedAuditRows(
2956
- resliced: boolean,
2957
- preCommittedRows: number,
2958
- preAuditRows: number,
2959
- preDurableRows: number,
2960
- byteStableBoundary: number,
2961
- durableBoundary: number,
2962
- hardAudited: boolean,
2963
- ): void {
2964
- const committed = this.#committedRows;
2965
- const auditRows =
2966
- resliced || preAuditRows >= preCommittedRows
2967
- ? Math.min(committed, byteStableBoundary)
2968
- : Math.min(preAuditRows, committed);
2969
- // durableRows also advances when a hard audit ran this frame: the resync's
2970
- // full hard scan verified the forced suffix [durableRows, min(committed,
2971
- // durableBoundary)) (re-anchoring on any divergence), so those rows are now
2972
- // proven durable and may leave the audited set — otherwise the durable-rise
2973
- // gate would re-fire the full scan every frame (and spray on later drift).
2974
- const durableRows =
2975
- resliced || preDurableRows >= preCommittedRows || hardAudited
2976
- ? Math.min(committed, durableBoundary)
2977
- : Math.min(preDurableRows, committed);
2978
- this.#committedPrefixAuditRows = auditRows;
2979
- this.#committedPrefixDurableRows = Math.max(auditRows, durableRows);
2980
- }
2981
-
2982
2850
  /**
2983
2851
  * Prepare the composed frame for emission, in place. Rows below
2984
2852
  * `#preparedValidRows` are already prepared against the current frame (the
@@ -3729,17 +3597,20 @@ export class TUI extends Container {
3729
3597
  }
3730
3598
  }
3731
3599
 
3732
- // In-window diff: nothing commits. While an overlay is visible, repaint
3733
- // the full viewport in place from a top-clamped cursor origin. Overlay
3734
- // cursor-only frames can leave the tracked row behind the physical cursor;
3735
- // a relative partial rewrite from that stale origin can CRLF on the bottom
3736
- // row and scroll native history without appending to the commit tape.
3737
- const overlayInPlaceRewrite = repaintVirtualScrollInPlace;
3738
- if (chunkLength === 0 && (scroll === 0 || overlayInPlaceRewrite)) {
3739
- if (forceWindowRewrite || overlayInPlaceRewrite) this.#fullRedrawCount += 1;
3740
- let firstChanged = forceWindowRewrite || overlayInPlaceRewrite ? 0 : -1;
3741
- let lastChanged = forceWindowRewrite || overlayInPlaceRewrite ? height - 1 : -1;
3742
- if (!forceWindowRewrite && !overlayInPlaceRewrite) {
3600
+ // In-window diff: nothing commits. Rewrite in place when the window slid
3601
+ // without a commit an overlay visible (composited rows must never enter
3602
+ // history), a commit-frozen geometry frame, or the window pulling back
3603
+ // down after a shrink. Overlay cursor-only frames can also leave the
3604
+ // tracked row behind the physical cursor; a relative partial rewrite from
3605
+ // that stale origin can CRLF on the bottom row and scroll native history
3606
+ // without appending to the commit tape, so overlays always take the
3607
+ // top-clamped full rewrite.
3608
+ const inPlaceRewrite = repaintVirtualScrollInPlace || scroll !== 0;
3609
+ if (chunkLength === 0) {
3610
+ if (forceWindowRewrite || inPlaceRewrite) this.#fullRedrawCount += 1;
3611
+ let firstChanged = forceWindowRewrite || inPlaceRewrite ? 0 : -1;
3612
+ let lastChanged = forceWindowRewrite || inPlaceRewrite ? height - 1 : -1;
3613
+ if (!forceWindowRewrite && !inPlaceRewrite) {
3743
3614
  const comparable = previousWindow.length === height;
3744
3615
  for (let r = 0; r < height; r++) {
3745
3616
  if (comparable && (window[r] ?? "") === (previousWindow[r] ?? "")) continue;
@@ -3755,10 +3626,11 @@ export class TUI extends Container {
3755
3626
  return;
3756
3627
  }
3757
3628
  let buffer = this.#paintBeginSequence + purgeSequence;
3758
- if (overlayInPlaceRewrite) {
3759
- // The cursor tracker can be stale after overlay-only frames. A large
3760
- // CUU clamps at the viewport top without using absolute cursor home,
3761
- // so the following full-window rewrite cannot overflow the bottom.
3629
+ if (inPlaceRewrite) {
3630
+ // The cursor tracker can be stale after overlay-only frames, and
3631
+ // meaningless after an uncommitted slide. A large CUU clamps at the
3632
+ // viewport top without using absolute cursor home, so the following
3633
+ // full-window rewrite cannot overflow the bottom.
3762
3634
  if (height > 1) buffer += `\x1b[${height - 1}A`;
3763
3635
  } else {
3764
3636
  const rowDelta = firstChanged - currentScreenRow;
@@ -3838,7 +3710,7 @@ export class TUI extends Container {
3838
3710
  : `fullPaint(clearScrollback=${intent.clearScrollback})`;
3839
3711
  const state =
3840
3712
  `committed=${this.#committedRows}, windowTop=${this.#windowTopRow}, ` +
3841
- `lrStart=${this.#nativeScrollbackLiveRegionStart}, commitSafeEnd=${this.#nativeScrollbackCommitSafeEnd}`;
3713
+ `lrStart=${this.#nativeScrollbackLiveRegionStart}`;
3842
3714
  const msg = `[${new Date().toISOString()}] render: ${detail} (prev=${this.#previousFrameLength}, new=${newLength}, height=${height}, ${state})\n`;
3843
3715
  fs.appendFileSync(getDebugLogPath(), msg);
3844
3716
  }