@oh-my-pi/pi-tui 16.3.4 → 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,28 @@
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
+
21
+ ## [16.3.5] - 2026-07-04
22
+
23
+ ### Fixed
24
+
25
+ - Fixed the modifyOtherKeys keyboard fallback enabling on unknown SSH terminals, avoiding broken Shift input in iOS SSH clients such as Redock ([#4325](https://github.com/can1357/oh-my-pi/issues/4325)).
26
+
5
27
  ## [16.3.3] - 2026-07-02
6
28
 
7
29
  ### 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,51 +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
- * `getNativeScrollbackOfferSafeEnd` optionally reports the deepest prefix row
109
- * that may physically enter native scrollback while still remaining audited.
110
- * This is for finalized lower siblings under a live block: the rows may scroll
111
- * off, but a later live-block insertion above them must trigger repair instead
112
- * of becoming durable audit-exempt history.
113
- *
114
- * When several root children report a seam in the same frame, the topmost
115
- * one (and its commit-safe / snapshot-safe extension) defines the boundary:
116
- * commits are prefix-only, so everything below the first seam is already
117
- * 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.
118
93
  */
119
94
  export interface NativeScrollbackLiveRegion {
120
95
  getNativeScrollbackLiveRegionStart(): number | undefined;
121
- getNativeScrollbackCommitSafeEnd?(): number | undefined;
122
- getNativeScrollbackSnapshotSafeEnd?(): number | undefined;
123
- getNativeScrollbackOfferSafeEnd?(): number | undefined;
124
96
  }
125
97
  export interface NativeScrollbackCommittedRows {
126
98
  setNativeScrollbackCommittedRows(rows: number): void;
@@ -300,40 +272,34 @@ export declare function coalesceAdjacentSgr(line: string): string;
300
272
  * re-anchor the commit index when it does not. Returns the resync row index,
301
273
  * or -1 when no resync is needed.
302
274
  *
303
- * Audits the committed prefix [0, auditTo) EXCEPT the exempt window
304
- * [exemptFrom, exemptTo): rows in the window are durable snapshots (a streaming
305
- * table re-aligning its columns) that may drift legitimately, so their drift
306
- * never triggers a re-anchor. Rows below the window including forced-overflow
307
- * rows committed only because they scrolled above the viewport under a
308
- * commit-unstable barrier ARE audited.
309
- *
310
- * 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.
311
287
  *
312
- * 1. Hard scan of the now-permanent forced suffix [exemptTo, permanentEnd):
313
- * forced-overflow rows that THIS frame asserts are durable/permanent (index <
314
- * permanentEnd the barrier above them finalized or cleared, so durableBoundary
315
- * rose past them). A content change there is real finalized content, so ANY
316
- * mismatch re-anchors. Scanned in FULL, not sampled, so a single edit far above
317
- * the commit boundary with an unchanged tail still re-anchors (duplication,
318
- * never loss) instead of being committed nowhere and painted nowhere.
319
- * 2. Tail sample (only when the hard scan is clean): exploits the asymmetry
320
- * between the two mutation classes — an in-place edit/restyle of a committed
321
- * row disturbs only the touched rows (alignment below intact; the stale copy
322
- * in history is the long-accepted artifact), while an insertion/deletion
323
- * shifts EVERY row below it. So up to 8 non-blank rows within the last 24
324
- * audited rows are compared SGR-stripped (theme changes stay quiet),
325
- * tolerating a SINGLE non-hard mismatch (a legitimate one-row edit): aligned ⇒
326
- * no resync; misaligned ⇒ resync at the first non-equivalent audited row. The
327
- * tolerance keeps both an offscreen still-live barrier (a ticking spinner) and
328
- * a no-seam in-place row edit from spraying duplicate snapshots every frame;
329
- * 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.
330
296
  *
331
297
  * Highly repetitive tails (identical filler rows) can mask a shift in the tail
332
298
  * sample, in which case the skipped rows are content-identical to the committed
333
299
  * ones — observationally harmless. Exported for the render-stress harness, whose
334
300
  * shadow commit ledger must mirror the engine's law exactly.
335
301
  */
336
- 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;
337
303
  /**
338
304
  * TUI - Main class for managing terminal UI with differential rendering
339
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.4",
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.4",
41
- "@oh-my-pi/pi-utils": "16.3.4",
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,51 +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
- * `getNativeScrollbackOfferSafeEnd` optionally reports the deepest prefix row
216
- * that may physically enter native scrollback while still remaining audited.
217
- * This is for finalized lower siblings under a live block: the rows may scroll
218
- * off, but a later live-block insertion above them must trigger repair instead
219
- * of becoming durable audit-exempt history.
220
- *
221
- * When several root children report a seam in the same frame, the topmost
222
- * one (and its commit-safe / snapshot-safe extension) defines the boundary:
223
- * commits are prefix-only, so everything below the first seam is already
224
- * 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.
225
201
  */
226
202
  export interface NativeScrollbackLiveRegion {
227
203
  getNativeScrollbackLiveRegionStart(): number | undefined;
228
- getNativeScrollbackCommitSafeEnd?(): number | undefined;
229
- getNativeScrollbackSnapshotSafeEnd?(): number | undefined;
230
- getNativeScrollbackOfferSafeEnd?(): number | undefined;
231
204
  }
232
205
 
233
206
  export interface NativeScrollbackCommittedRows {
@@ -249,18 +222,6 @@ function getNativeScrollbackLiveRegionStart(component: Component): number | unde
249
222
  return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackLiveRegionStart?.();
250
223
  }
251
224
 
252
- function getNativeScrollbackCommitSafeEnd(component: Component): number | undefined {
253
- return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackCommitSafeEnd?.();
254
- }
255
-
256
- function getNativeScrollbackSnapshotSafeEnd(component: Component): number | undefined {
257
- return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackSnapshotSafeEnd?.();
258
- }
259
-
260
- function getNativeScrollbackOfferSafeEnd(component: Component): number | undefined {
261
- return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackOfferSafeEnd?.();
262
- }
263
-
264
225
  /**
265
226
  * Opt-in stability report for components that mutate their returned render
266
227
  * array in place across frames (instead of returning a fresh array per
@@ -627,7 +588,7 @@ interface CursorControlResult extends HardwareCursorUpdate {
627
588
  * One root child's contribution to the composed frame: the array reference its
628
589
  * render() returned, the frame row it starts at, the row count recorded at
629
590
  * compose time (in-place mutators keep the reference but may change length),
630
- * 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
631
592
  * when a component-scoped frame reuses this segment without re-rendering.
632
593
  */
633
594
  interface FrameSegment {
@@ -636,9 +597,6 @@ interface FrameSegment {
636
597
  start: number;
637
598
  rowCount: number;
638
599
  liveLocalStart?: number;
639
- commitLocalEnd?: number;
640
- snapshotLocalEnd?: number;
641
- offerLocalEnd?: number;
642
600
  }
643
601
 
644
602
  /** Depth-first identity search through `Container`-shaped children. */
@@ -814,33 +772,27 @@ const RESYNC_TAIL_SAMPLES = 8;
814
772
  * re-anchor the commit index when it does not. Returns the resync row index,
815
773
  * or -1 when no resync is needed.
816
774
  *
817
- * Audits the committed prefix [0, auditTo) EXCEPT the exempt window
818
- * [exemptFrom, exemptTo): rows in the window are durable snapshots (a streaming
819
- * table re-aligning its columns) that may drift legitimately, so their drift
820
- * never triggers a re-anchor. Rows below the window including forced-overflow
821
- * rows committed only because they scrolled above the viewport under a
822
- * commit-unstable barrier ARE audited.
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.
823
787
  *
824
- * Two detectors run over the audited rows:
825
- *
826
- * 1. Hard scan of the now-permanent forced suffix [exemptTo, permanentEnd):
827
- * forced-overflow rows that THIS frame asserts are durable/permanent (index <
828
- * permanentEnd the barrier above them finalized or cleared, so durableBoundary
829
- * rose past them). A content change there is real finalized content, so ANY
830
- * mismatch re-anchors. Scanned in FULL, not sampled, so a single edit far above
831
- * the commit boundary with an unchanged tail still re-anchors (duplication,
832
- * never loss) instead of being committed nowhere and painted nowhere.
833
- * 2. Tail sample (only when the hard scan is clean): exploits the asymmetry
834
- * between the two mutation classes — an in-place edit/restyle of a committed
835
- * row disturbs only the touched rows (alignment below intact; the stale copy
836
- * in history is the long-accepted artifact), while an insertion/deletion
837
- * shifts EVERY row below it. So up to 8 non-blank rows within the last 24
838
- * audited rows are compared SGR-stripped (theme changes stay quiet),
839
- * tolerating a SINGLE non-hard mismatch (a legitimate one-row edit): aligned ⇒
840
- * no resync; misaligned ⇒ resync at the first non-equivalent audited row. The
841
- * tolerance keeps both an offscreen still-live barrier (a ticking spinner) and
842
- * a no-seam in-place row edit from spraying duplicate snapshots every frame;
843
- * 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.
844
796
  *
845
797
  * Highly repetitive tails (identical filler rows) can mask a shift in the tail
846
798
  * sample, in which case the skipped rows are content-identical to the committed
@@ -850,39 +802,30 @@ const RESYNC_TAIL_SAMPLES = 8;
850
802
  export function findCommittedPrefixResync(
851
803
  frame: readonly string[],
852
804
  prefix: readonly string[],
853
- auditTo: number = prefix.length,
854
- exemptFrom: number = auditTo,
855
- exemptTo: number = exemptFrom,
856
- permanentEnd = 0,
805
+ verifiedTo: number = prefix.length,
806
+ finalTo: number = verifiedTo,
857
807
  ): number {
858
- const committed = Math.min(prefix.length, Math.max(0, Math.trunc(auditTo)));
859
- if (committed === 0) return -1;
860
- // Exempt window [exFrom, exTo) clamped into the committed prefix. Rows there
861
- // are durable-snapshot drift and skipped by both detectors and the scan.
862
- const exFrom = Math.max(0, Math.min(committed, Math.trunc(exemptFrom)));
863
- const exTo = Math.max(exFrom, Math.min(committed, Math.trunc(exemptTo)));
864
- const audited = (i: number): boolean => i < exFrom || i >= exTo;
865
- if (frame.length >= committed) {
866
- // 1. Hard scan: forced-overflow rows now asserted permanent. Full scan, no
867
- // tolerance — a finalized row that changed must re-anchor.
868
- 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.
869
814
  let hardMismatch = false;
870
- for (let i = exTo; i < hardEnd; i++) {
815
+ for (let i = verified; i < hardEnd; i++) {
871
816
  if (!rowsEquivalent(frame[i]!, prefix[i]!)) {
872
817
  hardMismatch = true;
873
818
  break;
874
819
  }
875
820
  }
876
821
  if (!hardMismatch) {
877
- // 2. Tail sample. Walk up from the commit boundary, skipping exempt
878
- // 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.
879
825
  let samples = 0;
880
826
  let mismatches = 0;
881
- let scanned = 0;
882
- for (let j = 1; j <= committed && scanned < RESYNC_TAIL_LOOKBACK && samples < RESYNC_TAIL_SAMPLES; j++) {
883
- const idx = committed - j;
884
- if (!audited(idx)) continue;
885
- scanned++;
827
+ for (let j = 1; j <= verified && j <= RESYNC_TAIL_LOOKBACK && samples < RESYNC_TAIL_SAMPLES; j++) {
828
+ const idx = verified - j;
886
829
  const row = frame[idx]!;
887
830
  const old = prefix[idx]!;
888
831
  if (row === old) {
@@ -893,18 +836,18 @@ export function findCommittedPrefixResync(
893
836
  samples++;
894
837
  if (!rowsEquivalent(row, old)) mismatches++;
895
838
  }
896
- // 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.
897
840
  if (samples === 0 || mismatches <= 1) return -1;
898
841
  }
899
842
  }
900
- // Misaligned (hard mismatch, tail-sample shift, or the frame no longer covers
901
- // the prefix): re-anchor at the first audited row whose content changed.
902
- 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);
903
847
  for (let i = 0; i < limit; i++) {
904
- if (!audited(i)) continue;
905
848
  if (!rowsEquivalent(frame[i]!, prefix[i]!)) return i;
906
849
  }
907
- return limit < committed ? limit : -1;
850
+ return limit < hardEnd ? limit : -1;
908
851
  }
909
852
 
910
853
  /**
@@ -1010,37 +953,31 @@ export class TUI extends Container {
1010
953
  #cursorEndSequence = this.#synchronizedOutputEnabled ? CURSOR_END : CURSOR_END_NO_SYNC;
1011
954
  // Rows of the current frame physically committed to the terminal tape
1012
955
  // (native scrollback or scrolled past the window top). Immutable by
1013
- // contract: the engine never rewrites them, and components keep mutable
1014
- // rows below the `NativeScrollbackLiveRegion` boundary so they never get
1015
- // 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.
1016
960
  #committedRows = 0;
1017
961
  // Raw rows mirroring [0, #committedRows) — the engine's claim of what it
1018
- // committed, audited each ordinary frame against the current render to
1019
- // detect components re-laying-out committed content (see
1020
- // #auditCommittedPrefix). Holds references to component-cached strings, so
1021
- // 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.
1022
967
  #committedPrefix: string[] = [];
1023
- // The committed prefix [0, committedRows) splits into four zones by three
1024
- // monotone marks auditRows durableRows offerRows committedRows:
1025
- // [0, auditRows) BYTE-STABLE audited (re-anchor on any shift).
1026
- // [auditRows, durableRows) DURABLE snapshot exempt: rows may drift in
1027
- // place (a streaming table widening) without re-anchoring, so their
1028
- // expected drift never sprays duplicate snapshots.
1029
- // [durableRows, offerRows) OFFERED audited: rows were allowed into
1030
- // native scrollback while a live block above could still shift them.
1031
- // A mismatch here requires a destructive replay, not a duplicate tail.
1032
- // [offerRows, committedRows) FORCED-overflow audited: rows committed
1033
- // only because they scrolled above the window under a commit-unstable
1034
- // barrier; auditing them re-anchors (duplication, never loss) when the
1035
- // barrier later shifts/finalizes/removes, instead of stranding a stale
1036
- // prefix that silently drops the rows beneath it.
1037
- // Marks re-base on a wholesale re-slice (full paint / shrink / geometry) and
1038
- // otherwise advance per the persistence rules in #updateCommittedAuditRows.
1039
- // #auditCommittedPrefix audits [0, committedRows) skipping the exempt window
1040
- // [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.
1041
980
  #committedPrefixAuditRows = 0;
1042
- #committedPrefixDurableRows = 0;
1043
- #committedPrefixOfferRows = 0;
1044
981
  // Frame row currently mapped to screen row 0. Monotonic between full
1045
982
  // paints: a shrink never re-exposes scrolled-off rows (they cannot be
1046
983
  // un-scrolled without rewriting history); live rows repaint at fixed
@@ -1049,9 +986,6 @@ export class TUI extends Container {
1049
986
  // Exactly what is painted on the screen rows (post-composite, prepared).
1050
987
  #previousWindow: string[] = [];
1051
988
  #nativeScrollbackLiveRegionStart: number | undefined;
1052
- #nativeScrollbackCommitSafeEnd: number | undefined;
1053
- #nativeScrollbackSnapshotSafeEnd: number | undefined;
1054
- #nativeScrollbackOfferSafeEnd: number | undefined;
1055
989
  #fullRedrawCount = 0;
1056
990
  // Caps how many inline images render as live graphics; older ones fall back
1057
991
  // to text via a purge + full redraw. Cap is configured by the host app.
@@ -1169,9 +1103,6 @@ export class TUI extends Container {
1169
1103
  override render(width: number): readonly string[] {
1170
1104
  width = Math.max(1, width);
1171
1105
  this.#nativeScrollbackLiveRegionStart = undefined;
1172
- this.#nativeScrollbackCommitSafeEnd = undefined;
1173
- this.#nativeScrollbackSnapshotSafeEnd = undefined;
1174
- this.#nativeScrollbackOfferSafeEnd = undefined;
1175
1106
  const children = this.children;
1176
1107
  const previousSegments = this.#frameSegments;
1177
1108
  const segments: FrameSegment[] = new Array(children.length);
@@ -1192,16 +1123,10 @@ export class TUI extends Container {
1192
1123
  partialRoots !== null && previous !== undefined && previous.component === child && !partialRoots.has(child);
1193
1124
  let childLines: readonly string[];
1194
1125
  let liveLocalStart: number | undefined;
1195
- let commitLocalEnd: number | undefined;
1196
- let snapshotLocalEnd: number | undefined;
1197
- let offerLocalEnd: number | undefined;
1198
1126
  let reported: number | undefined;
1199
1127
  if (reuse) {
1200
1128
  childLines = previous.lines;
1201
1129
  liveLocalStart = previous.liveLocalStart;
1202
- commitLocalEnd = previous.commitLocalEnd;
1203
- snapshotLocalEnd = previous.snapshotLocalEnd;
1204
- offerLocalEnd = previous.offerLocalEnd;
1205
1130
  } else {
1206
1131
  // Feed the engine's committed-row claim (from the previous frame's
1207
1132
  // emit) before rendering so the child can skip re-deriving blocks
@@ -1214,29 +1139,6 @@ export class TUI extends Container {
1214
1139
  liveLocalStart = Number.isFinite(liveRegionStart)
1215
1140
  ? Math.max(0, Math.min(childLines.length, Math.trunc(liveRegionStart)))
1216
1141
  : childLines.length;
1217
- const commitSafeEnd = getNativeScrollbackCommitSafeEnd(child);
1218
- if (commitSafeEnd !== undefined) {
1219
- commitLocalEnd = Number.isFinite(commitSafeEnd)
1220
- ? Math.max(liveLocalStart, Math.min(childLines.length, Math.trunc(commitSafeEnd)))
1221
- : childLines.length;
1222
- }
1223
- // Durable snapshot end: clamped at/above the byte-stable end (or
1224
- // the live-region start when none) so a child can never report a
1225
- // shallower durable boundary than its byte-stable one.
1226
- const snapshotSafeEnd = getNativeScrollbackSnapshotSafeEnd(child);
1227
- if (snapshotSafeEnd !== undefined) {
1228
- const snapshotFloor = commitLocalEnd ?? liveLocalStart;
1229
- snapshotLocalEnd = Number.isFinite(snapshotSafeEnd)
1230
- ? Math.max(snapshotFloor, Math.min(childLines.length, Math.trunc(snapshotSafeEnd)))
1231
- : childLines.length;
1232
- }
1233
- const offerSafeEnd = getNativeScrollbackOfferSafeEnd(child);
1234
- if (offerSafeEnd !== undefined) {
1235
- const offerFloor = snapshotLocalEnd ?? commitLocalEnd ?? liveLocalStart;
1236
- offerLocalEnd = Number.isFinite(offerSafeEnd)
1237
- ? Math.max(offerFloor, Math.min(childLines.length, Math.trunc(offerSafeEnd)))
1238
- : childLines.length;
1239
- }
1240
1142
  }
1241
1143
  // Consume the stability report unconditionally for implementers:
1242
1144
  // reading re-bases the component's baseline to the state this
@@ -1247,22 +1149,13 @@ export class TUI extends Container {
1247
1149
  reported = getRenderStablePrefixRows(child);
1248
1150
  }
1249
1151
  // Topmost seam wins. Commits are prefix-only: the first child that
1250
- // reports a live region (plus its own commit-safe extension) already
1251
- // bounds everything below it, so a lower sibling's seam (e.g. a
1252
- // status loader under a streaming transcript) must never overwrite
1253
- // it — moving the boundary down would commit the earlier child's
1254
- // 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.
1255
1157
  if (liveLocalStart !== undefined && this.#nativeScrollbackLiveRegionStart === undefined) {
1256
1158
  this.#nativeScrollbackLiveRegionStart = offset + liveLocalStart;
1257
- if (commitLocalEnd !== undefined) {
1258
- this.#nativeScrollbackCommitSafeEnd = offset + commitLocalEnd;
1259
- }
1260
- if (snapshotLocalEnd !== undefined) {
1261
- this.#nativeScrollbackSnapshotSafeEnd = offset + snapshotLocalEnd;
1262
- }
1263
- if (offerLocalEnd !== undefined) {
1264
- this.#nativeScrollbackOfferSafeEnd = offset + offerLocalEnd;
1265
- }
1266
1159
  }
1267
1160
  if (chainStable) {
1268
1161
  if (previous !== undefined && previous.component === child && previous.start === offset) {
@@ -1291,9 +1184,6 @@ export class TUI extends Container {
1291
1184
  start: offset,
1292
1185
  rowCount: childLines.length,
1293
1186
  liveLocalStart,
1294
- commitLocalEnd,
1295
- snapshotLocalEnd,
1296
- offerLocalEnd,
1297
1187
  };
1298
1188
  offset += childLines.length;
1299
1189
  }
@@ -2692,37 +2582,15 @@ export class TUI extends Container {
2692
2582
  // known. Ascending by frame row.
2693
2583
  const cursorMarkers = this.#frameCursorMarkers;
2694
2584
  const liveRegionStart = this.#nativeScrollbackLiveRegionStart;
2695
- const commitSafeEnd = this.#nativeScrollbackCommitSafeEnd;
2696
- const snapshotSafeEnd = this.#nativeScrollbackSnapshotSafeEnd;
2697
- const offerSafeEnd = this.#nativeScrollbackOfferSafeEnd;
2698
-
2699
- // Commit boundaries (also used by the window/commit math in section 3),
2700
- // hoisted above the audit gate because the resync needs byteStableBoundary
2701
- // to tell a now-permanent forced row (must re-anchor) from a still-live one.
2702
- // The commit floor is windowTop in every non-frozen path (see chunkTo), so
2703
- // whatever scrolls above the window is committed — never committed nowhere
2704
- // AND painted nowhere (the loss bug). The boundaries no longer gate the
2705
- // commit; they define audit and repair spans. byteStableBoundary: rows below
2706
- // it are byte-stable (never re-layout), audited. durableBoundary: rows in
2707
- // [byteStableBoundary, durableBoundary) are durable — permanent on scroll-off
2708
- // but may drift in place (a streaming table re-aligning), committed
2709
- // audit-EXEMPT. offerBoundary: rows in [durableBoundary, offerBoundary)
2710
- // were explicitly allowed to enter native scrollback while remaining
2711
- // audited; if they later shift, the stale physical history is repaired by a
2712
- // destructive replay instead of duplicate recommit. Rows at/beyond
2713
- // offerBoundary committed only because they scrolled above the window (a
2714
- // commit-unstable barrier over a long tail) are forced-overflow rows:
2715
- // audited, so a later shift/finalize/removal re-anchors (duplication, never
2716
- // loss) instead of stranding a stale prefix. Built on the finalized prefix
2717
- // (live-region start); the whole frame when the root reports no seam (shell
2718
- // 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).
2719
2592
  const frameLength = rawFrame.length;
2720
- const byteStableBoundary = Math.max(0, Math.min(frameLength, commitSafeEnd ?? liveRegionStart ?? frameLength));
2721
- const durableBoundary = Math.max(
2722
- byteStableBoundary,
2723
- Math.min(frameLength, snapshotSafeEnd ?? byteStableBoundary),
2724
- );
2725
- const offerBoundary = Math.max(durableBoundary, Math.min(frameLength, offerSafeEnd ?? durableBoundary));
2593
+ const finalBoundary = Math.max(0, Math.min(frameLength, liveRegionStart ?? frameLength));
2726
2594
 
2727
2595
  // 2. Transition state captured before any emitter runs.
2728
2596
  const prevWindowTop = this.#windowTopRow;
@@ -2739,66 +2607,67 @@ export class TUI extends Container {
2739
2607
  (resizeEventOccurred && this.#previousHeight > 0);
2740
2608
  const geometryChanged = widthChanged || heightChanged;
2741
2609
 
2742
- // Committed-prefix audit: rows below the commit index are physically in
2743
- // terminal history and must never re-layout. When a component violates
2744
- // that a budget-demoted image collapsing to its one-line fallback, a
2745
- // TTSR rewind truncating a block whose sealed prefix already committed
2746
- // keeping the old index would silently skip that many rows of
2747
- // everything below (content loss). Re-anchor at the divergence instead:
2748
- // the stale copy stays in history and rows recommit from there
2749
- // duplication, never loss. Skipped on geometry frames (a rewrap
2750
- // legitimately reflows every row; the mux branch re-bases the prefix
2751
- // and non-mux geometry replays from scratch), and skipped when the
2752
- // composed frame's stable prefix covers every committed row bytes
2753
- // that provably did not change since the last (aligned) frame cannot
2754
- // 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.
2755
2621
  let committedRowsResynced = false;
2756
- // Audit covers [0, auditRows) and the forced suffix [durableRows,
2757
- // committedRows); the durable middle [auditRows, durableRows) is exempt
2758
- // (in-place drift). Three reasons to run the audit this frame:
2759
- // - the stable prefix does not cover every audited row (auditUpper); or
2760
- // - a forced-overflow row this frame became durable/permanent
2761
- // (committedPrefixDurableRows < min(committed, durableBoundary)): the
2762
- // barrier above it finalized, so its committed bytes must be re-checked
2763
- // even though the stable prefix says nothing moved — a stale committed
2764
- // copy there would silently drop the row; or
2765
- // - a forced-overflow row this frame joined the offered zone
2766
- // (committedPrefixOfferRows < min(committed, offerBoundary)): a
2767
- // finalized sibling under a live block asks for destructive-replay
2768
- // repair, so a single-row shift there must be caught even if
2769
- // tail-sample tolerance would otherwise skip it.
2770
- // The hard scan in findCommittedPrefixResync covers [durableRows,
2771
- // hardAuditEnd) in full (no tail-sample miss).
2772
- const auditUpper =
2773
- this.#committedPrefixDurableRows < this.#committedRows ? this.#committedRows : this.#committedPrefixAuditRows;
2774
- const durableHardEnd = Math.min(this.#committedRows, durableBoundary);
2775
- const offerHardEnd = Math.min(this.#committedRows, offerBoundary);
2776
- const hardAuditEnd = Math.max(durableHardEnd, offerHardEnd);
2777
- const needHardAudit =
2778
- this.#committedPrefixDurableRows < durableHardEnd || this.#committedPrefixOfferRows < offerHardEnd;
2779
- let repairOfferedScrollback = false;
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
+ }
2780
2632
  const auditRan =
2781
2633
  this.#hasEverRendered &&
2782
2634
  !geometryChanged &&
2783
2635
  !this.#clearScrollbackOnNextRender &&
2784
- (this.#renderStablePrefixRows < auditUpper || needHardAudit);
2636
+ (this.#renderStablePrefixRows < this.#committedPrefixAuditRows ||
2637
+ newlyFinalEnd > this.#committedPrefixAuditRows);
2785
2638
  if (auditRan) {
2786
2639
  const committedRowsBeforeAudit = this.#committedRows;
2787
- const offeredRowsBeforeAudit = this.#committedPrefixOfferRows;
2788
- const durableRowsBeforeAudit = this.#committedPrefixDurableRows;
2789
- this.#auditCommittedPrefix(rawFrame, hardAuditEnd);
2640
+ this.#auditCommittedPrefix(rawFrame, newlyFinalEnd);
2790
2641
  committedRowsResynced = this.#committedRows !== committedRowsBeforeAudit;
2791
- repairOfferedScrollback =
2792
- offeredRowsBeforeAudit > durableRowsBeforeAudit &&
2793
- committedRowsResynced &&
2794
- this.#committedRows < offeredRowsBeforeAudit;
2795
2642
  }
2796
- // Committed-prefix state this frame's commit math extends from (post-audit).
2797
- // 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.
2798
2668
  const preCommitRows = this.#committedRows;
2799
- const preCommitAuditRows = this.#committedPrefixAuditRows;
2800
- const preCommitDurableRows = this.#committedPrefixDurableRows;
2801
- const preCommitOfferRows = this.#committedPrefixOfferRows;
2669
+ const preAuditRows = this.#committedPrefixAuditRows;
2670
+ let committedPrefixResliced = false;
2802
2671
 
2803
2672
  // 3. Window and commit math (lengths only; content prepared below).
2804
2673
  let hasVisibleOverlay = false;
@@ -2816,12 +2685,11 @@ export class TUI extends Container {
2816
2685
  // place, because an ED3 rewrap is unsafe (pane scrollback / alt-screen
2817
2686
  // feedback loop), so committed history keeps its old wrap.
2818
2687
  const firstPaint = !this.#hasEverRendered;
2819
- const replaceRequested = this.#clearScrollbackOnNextRender || repairOfferedScrollback;
2688
+ const replaceRequested = this.#clearScrollbackOnNextRender;
2820
2689
  const geometryRebuild = geometryChanged && !resizeRepaintsInPlace();
2821
2690
  const fullPaint = firstPaint || replaceRequested || geometryRebuild;
2822
2691
  let windowTop: number;
2823
2692
  let chunkTo: number;
2824
- let committedPrefixResliced = false;
2825
2693
  if (fullPaint) {
2826
2694
  committedPrefixResliced = true;
2827
2695
  windowTop = Math.max(0, frameLength - height);
@@ -2842,9 +2710,9 @@ export class TUI extends Container {
2842
2710
  // stale committed copy stays in native history; duplicating a few rows
2843
2711
  // is preferable to a live editor gap and matches the existing
2844
2712
  // "duplication, never loss" resync contract.
2713
+ committedPrefixResliced = true;
2845
2714
  windowTop = Math.max(0, frameLength - height);
2846
2715
  chunkTo = windowTop;
2847
- committedPrefixResliced = true;
2848
2716
  this.#committedRows = chunkTo;
2849
2717
  this.#committedPrefix = rawFrame.slice(0, chunkTo);
2850
2718
  } else {
@@ -2855,12 +2723,14 @@ export class TUI extends Container {
2855
2723
  // multiplexer resize the pane reflowed its own history; committed
2856
2724
  // rows keep their old wrap there, same as any shell output.
2857
2725
  windowTop = Math.max(this.#committedRows, frameLength - height, 0);
2858
- // Overlays freeze commits: composited rows must never enter
2859
- // history, and the hidden gap backfills via the chunk once the
2860
- // overlay closes. A multiplexer resize also commits nothing the
2861
- // pane keeps its own (old-wrap) history and re-bases the audit
2862
- // prefix at the new width so the accepted wrap drift does not read
2863
- // 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.
2864
2734
  chunkTo = hasVisibleOverlay || geometryChanged ? this.#committedRows : windowTop;
2865
2735
  if (geometryChanged) {
2866
2736
  committedPrefixResliced = true;
@@ -2923,17 +2793,7 @@ export class TUI extends Container {
2923
2793
  cursorTrackingLineCount,
2924
2794
  });
2925
2795
  this.#committedPrefix = rawFrame.slice(0, chunkTo);
2926
- this.#updateCommittedAuditRows(
2927
- true,
2928
- preCommitRows,
2929
- preCommitAuditRows,
2930
- preCommitDurableRows,
2931
- preCommitOfferRows,
2932
- byteStableBoundary,
2933
- durableBoundary,
2934
- offerBoundary,
2935
- false,
2936
- );
2796
+ this.#committedPrefixAuditRows = Math.min(chunkTo, finalBoundary);
2937
2797
  this.#clearScrollbackOnNextRender = false;
2938
2798
  this.#hasEverRendered = true;
2939
2799
  if (!firstPaint && frameLength > height) this.#armPostFullPaintSettle();
@@ -2954,42 +2814,32 @@ export class TUI extends Container {
2954
2814
  for (let i = this.#committedPrefix.length; i < chunkTo; i++) {
2955
2815
  this.#committedPrefix.push(rawFrame[i] ?? "");
2956
2816
  }
2957
- this.#updateCommittedAuditRows(
2958
- committedPrefixResliced,
2959
- preCommitRows,
2960
- preCommitAuditRows,
2961
- preCommitDurableRows,
2962
- preCommitOfferRows,
2963
- byteStableBoundary,
2964
- durableBoundary,
2965
- offerBoundary,
2966
- auditRan,
2967
- );
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
+ }
2968
2826
  }
2969
2827
 
2970
2828
  /**
2971
- * Detect committed-prefix violations and re-anchor the commit index at the
2972
- * first moved row, so subsequent rows recommit instead of being skipped:
2973
- * the stale copy stays in history duplication, never loss. Pure in-place
2974
- * restyles keep their alignment and are left alone (stale styling in
2975
- * 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).
2976
2835
  */
2977
- #auditCommittedPrefix(rawFrame: readonly string[], permanentEnd: number): void {
2836
+ #auditCommittedPrefix(rawFrame: readonly string[], newlyFinalEnd: number): void {
2978
2837
  const prefix = this.#committedPrefix;
2979
2838
  if (prefix.length === 0) return;
2980
- const resyncTo = findCommittedPrefixResync(
2981
- rawFrame,
2982
- prefix,
2983
- prefix.length,
2984
- this.#committedPrefixAuditRows,
2985
- this.#committedPrefixDurableRows,
2986
- permanentEnd,
2987
- );
2839
+ const resyncTo = findCommittedPrefixResync(rawFrame, prefix, this.#committedPrefixAuditRows, newlyFinalEnd);
2988
2840
  if (resyncTo < 0) return;
2989
2841
  this.#committedRows = resyncTo;
2990
2842
  this.#committedPrefixAuditRows = Math.min(this.#committedPrefixAuditRows, resyncTo);
2991
- this.#committedPrefixDurableRows = Math.min(this.#committedPrefixDurableRows, resyncTo);
2992
- this.#committedPrefixOfferRows = Math.min(this.#committedPrefixOfferRows, resyncTo);
2993
2843
  prefix.length = resyncTo;
2994
2844
  if ($flag("PI_DEBUG_REDRAW")) {
2995
2845
  const msg = `[${new Date().toISOString()}] commit resync: committed prefix diverged at row ${resyncTo}; recommitting\n`;
@@ -2997,59 +2847,6 @@ export class TUI extends Container {
2997
2847
  }
2998
2848
  }
2999
2849
 
3000
- /**
3001
- * Recompute the audit-rows / durable-rows marks after a commit (see the
3002
- * #committedPrefixAuditRows field doc for the three audit zones).
3003
- *
3004
- * auditRows tracks the byte-stable boundary; durableRows the durable snapshot
3005
- * boundary; offerRows the deepest explicit audited-offer boundary. A wholesale
3006
- * re-slice (full paint / shrink / geometry) re-bases each mark from the
3007
- * current frame. An incremental extend keeps a mark once a row past it has
3008
- * committed (mark < committed): a later RISE in a boundary (a table finalizing)
3009
- * must neither pull already-committed stale snapshots back under the
3010
- * byte-stable cap nor retroactively exempt forced-overflow rows already
3011
- * audited. durableRows is floored at auditRows; offerRows is floored at
3012
- * durableRows.
3013
- */
3014
- #updateCommittedAuditRows(
3015
- resliced: boolean,
3016
- preCommittedRows: number,
3017
- preAuditRows: number,
3018
- preDurableRows: number,
3019
- preOfferRows: number,
3020
- byteStableBoundary: number,
3021
- durableBoundary: number,
3022
- offerBoundary: number,
3023
- hardAudited: boolean,
3024
- ): void {
3025
- const committed = this.#committedRows;
3026
- const auditRows =
3027
- resliced || preAuditRows >= preCommittedRows
3028
- ? Math.min(committed, byteStableBoundary)
3029
- : Math.min(preAuditRows, committed);
3030
- // durableRows also advances when a hard audit ran this frame: the resync's
3031
- // full hard scan verified the forced suffix [durableRows, min(committed,
3032
- // durableBoundary)) (re-anchoring on any divergence), so those rows are now
3033
- // proven durable and may leave the audited set — otherwise the durable-rise
3034
- // gate would re-fire the full scan every frame (and spray on later drift).
3035
- const durableRows =
3036
- resliced || preDurableRows >= preCommittedRows || hardAudited
3037
- ? Math.min(committed, durableBoundary)
3038
- : Math.min(preDurableRows, committed);
3039
- // offerRows may EXTEND to include forced-overflow rows within a
3040
- // newly-visible offerBoundary — unlike auditRows/durableRows, retroactive
3041
- // promotion is safe: both offered and forced-overflow zones stay
3042
- // audited; offered only switches divergence repair from tolerant recommit
3043
- // to destructive replay (stronger, not weaker). A dropped offerBoundary
3044
- // still keeps the durability rule via `preOfferRows` (never demote
3045
- // already-offered rows to forced-overflow).
3046
- const offerCap = Math.min(committed, offerBoundary);
3047
- const offerRows = resliced ? offerCap : Math.max(Math.min(preOfferRows, committed), offerCap);
3048
- this.#committedPrefixAuditRows = auditRows;
3049
- this.#committedPrefixDurableRows = Math.max(auditRows, durableRows);
3050
- this.#committedPrefixOfferRows = Math.max(this.#committedPrefixDurableRows, offerRows);
3051
- }
3052
-
3053
2850
  /**
3054
2851
  * Prepare the composed frame for emission, in place. Rows below
3055
2852
  * `#preparedValidRows` are already prepared against the current frame (the
@@ -3800,17 +3597,20 @@ export class TUI extends Container {
3800
3597
  }
3801
3598
  }
3802
3599
 
3803
- // In-window diff: nothing commits. While an overlay is visible, repaint
3804
- // the full viewport in place from a top-clamped cursor origin. Overlay
3805
- // cursor-only frames can leave the tracked row behind the physical cursor;
3806
- // a relative partial rewrite from that stale origin can CRLF on the bottom
3807
- // row and scroll native history without appending to the commit tape.
3808
- const overlayInPlaceRewrite = repaintVirtualScrollInPlace;
3809
- if (chunkLength === 0 && (scroll === 0 || overlayInPlaceRewrite)) {
3810
- if (forceWindowRewrite || overlayInPlaceRewrite) this.#fullRedrawCount += 1;
3811
- let firstChanged = forceWindowRewrite || overlayInPlaceRewrite ? 0 : -1;
3812
- let lastChanged = forceWindowRewrite || overlayInPlaceRewrite ? height - 1 : -1;
3813
- 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) {
3814
3614
  const comparable = previousWindow.length === height;
3815
3615
  for (let r = 0; r < height; r++) {
3816
3616
  if (comparable && (window[r] ?? "") === (previousWindow[r] ?? "")) continue;
@@ -3826,10 +3626,11 @@ export class TUI extends Container {
3826
3626
  return;
3827
3627
  }
3828
3628
  let buffer = this.#paintBeginSequence + purgeSequence;
3829
- if (overlayInPlaceRewrite) {
3830
- // The cursor tracker can be stale after overlay-only frames. A large
3831
- // CUU clamps at the viewport top without using absolute cursor home,
3832
- // 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.
3833
3634
  if (height > 1) buffer += `\x1b[${height - 1}A`;
3834
3635
  } else {
3835
3636
  const rowDelta = firstChanged - currentScreenRow;
@@ -3909,7 +3710,7 @@ export class TUI extends Container {
3909
3710
  : `fullPaint(clearScrollback=${intent.clearScrollback})`;
3910
3711
  const state =
3911
3712
  `committed=${this.#committedRows}, windowTop=${this.#windowTopRow}, ` +
3912
- `lrStart=${this.#nativeScrollbackLiveRegionStart}, commitSafeEnd=${this.#nativeScrollbackCommitSafeEnd}`;
3713
+ `lrStart=${this.#nativeScrollbackLiveRegionStart}`;
3913
3714
  const msg = `[${new Date().toISOString()}] render: ${detail} (prev=${this.#previousFrameLength}, new=${newLength}, height=${height}, ${state})\n`;
3914
3715
  fs.appendFileSync(getDebugLogPath(), msg);
3915
3716
  }