@oh-my-pi/pi-tui 16.1.8 → 16.1.10

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,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.1.10] - 2026-06-21
6
+
7
+ ### Fixed
8
+
9
+ - Fixed streaming output being lost from native scrollback below a commit-unstable "barrier" block (a provisional/collapsed tool preview, a displaceable `job` poll, or a reflowing-markdown reply) once the content under it overflowed the viewport. The engine committed native scrollback only up to the barrier's seam, so rows that scrolled above the window under the barrier were committed nowhere and repainted nowhere — they vanished, and a later shift/finalize/removal of the barrier silently dropped the rows beneath it. The append-only commit floor is now `windowTop` in every non-frozen paint path (ordinary update, shrink re-slice, full paint), so whatever scrolls above the window always reaches history; the seam boundaries now only classify which committed rows stay byte-stable-audited vs. durable-exempt. The committed-prefix audit is range-aware: it audits the forced-overflow suffix in full (re-anchoring — duplication, never loss — when a barrier finalizes), exempts the durable middle (a streaming table re-aligning its columns) from re-anchor spray, and runs a full hard scan of rows a frame newly marks permanent so a single-row finalize edit far above the commit boundary still re-anchors instead of being dropped.
10
+
5
11
  ## [16.1.8] - 2026-06-20
6
12
 
7
13
  ### Added
@@ -294,21 +294,40 @@ export declare function coalesceAdjacentSgr(line: string): string;
294
294
  * re-anchor the commit index when it does not. Returns the resync row index,
295
295
  * or -1 when no resync is needed.
296
296
  *
297
- * The detector exploits the asymmetry between the two mutation classes: an
298
- * in-place edit or restyle of committed rows disturbs only the touched rows
299
- * (alignment below them is intact the stale copy in history is the
300
- * long-accepted artifact), while any insertion or deletion shifts EVERY row
301
- * below it, including the rows just above the commit boundary. So the prefix
302
- * *tail* is sampled (up to 8 non-blank rows within the last 24, compared
303
- * SGR-stripped so theme changes stay quiet, tolerating one mismatch for a
304
- * legitimate single-row edit): aligned no resync; misaligned ⇒ resync at
305
- * the first non-equivalent row, recommitting from there — duplication, never
306
- * loss. Highly repetitive tails (identical filler rows) can mask a shift, in
307
- * which case the skipped rows are content-identical to the committed ones —
308
- * observationally harmless. Exported for the render-stress harness, whose
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:
305
+ *
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.
324
+ *
325
+ * Highly repetitive tails (identical filler rows) can mask a shift in the tail
326
+ * sample, in which case the skipped rows are content-identical to the committed
327
+ * ones — observationally harmless. Exported for the render-stress harness, whose
309
328
  * shadow commit ledger must mirror the engine's law exactly.
310
329
  */
311
- export declare function findCommittedPrefixResync(frame: readonly string[], prefix: readonly string[], auditLimit?: number): number;
330
+ export declare function findCommittedPrefixResync(frame: readonly string[], prefix: readonly string[], auditTo?: number, exemptFrom?: number, exemptTo?: number, permanentEnd?: number): number;
312
331
  /**
313
332
  * TUI - Main class for managing terminal UI with differential rendering
314
333
  */
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.1.8",
4
+ "version": "16.1.10",
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.1.8",
41
- "@oh-my-pi/pi-utils": "16.1.8",
40
+ "@oh-my-pi/pi-natives": "16.1.10",
41
+ "@oh-my-pi/pi-utils": "16.1.10",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.5"
44
44
  },
package/src/tui.ts CHANGED
@@ -812,52 +812,94 @@ const RESYNC_TAIL_SAMPLES = 8;
812
812
  * re-anchor the commit index when it does not. Returns the resync row index,
813
813
  * or -1 when no resync is needed.
814
814
  *
815
- * The detector exploits the asymmetry between the two mutation classes: an
816
- * in-place edit or restyle of committed rows disturbs only the touched rows
817
- * (alignment below them is intact the stale copy in history is the
818
- * long-accepted artifact), while any insertion or deletion shifts EVERY row
819
- * below it, including the rows just above the commit boundary. So the prefix
820
- * *tail* is sampled (up to 8 non-blank rows within the last 24, compared
821
- * SGR-stripped so theme changes stay quiet, tolerating one mismatch for a
822
- * legitimate single-row edit): aligned no resync; misaligned ⇒ resync at
823
- * the first non-equivalent row, recommitting from there — duplication, never
824
- * loss. Highly repetitive tails (identical filler rows) can mask a shift, in
825
- * which case the skipped rows are content-identical to the committed ones —
826
- * observationally harmless. Exported for the render-stress harness, whose
815
+ * Audits the committed prefix [0, auditTo) EXCEPT the exempt window
816
+ * [exemptFrom, exemptTo): rows in the window are durable snapshots (a streaming
817
+ * table re-aligning its columns) that may drift legitimately, so their drift
818
+ * never triggers a re-anchor. Rows below the window including forced-overflow
819
+ * rows committed only because they scrolled above the viewport under a
820
+ * commit-unstable barrier ARE audited.
821
+ *
822
+ * Two detectors run over the audited rows:
823
+ *
824
+ * 1. Hard scan of the now-permanent forced suffix [exemptTo, permanentEnd):
825
+ * forced-overflow rows that THIS frame asserts are durable/permanent (index <
826
+ * permanentEnd the barrier above them finalized or cleared, so durableBoundary
827
+ * rose past them). A content change there is real finalized content, so ANY
828
+ * mismatch re-anchors. Scanned in FULL, not sampled, so a single edit far above
829
+ * the commit boundary with an unchanged tail still re-anchors (duplication,
830
+ * never loss) instead of being committed nowhere and painted nowhere.
831
+ * 2. Tail sample (only when the hard scan is clean): exploits the asymmetry
832
+ * between the two mutation classes — an in-place edit/restyle of a committed
833
+ * row disturbs only the touched rows (alignment below intact; the stale copy
834
+ * in history is the long-accepted artifact), while an insertion/deletion
835
+ * shifts EVERY row below it. So up to 8 non-blank rows within the last 24
836
+ * audited rows are compared SGR-stripped (theme changes stay quiet),
837
+ * tolerating a SINGLE non-hard mismatch (a legitimate one-row edit): aligned ⇒
838
+ * no resync; misaligned ⇒ resync at the first non-equivalent audited row. The
839
+ * tolerance keeps both an offscreen still-live barrier (a ticking spinner) and
840
+ * a no-seam in-place row edit from spraying duplicate snapshots every frame;
841
+ * the hard scan above is what forbids it from swallowing a finalized row.
842
+ *
843
+ * Highly repetitive tails (identical filler rows) can mask a shift in the tail
844
+ * sample, in which case the skipped rows are content-identical to the committed
845
+ * ones — observationally harmless. Exported for the render-stress harness, whose
827
846
  * shadow commit ledger must mirror the engine's law exactly.
828
847
  */
829
848
  export function findCommittedPrefixResync(
830
849
  frame: readonly string[],
831
850
  prefix: readonly string[],
832
- auditLimit: number = prefix.length,
851
+ auditTo: number = prefix.length,
852
+ exemptFrom: number = auditTo,
853
+ exemptTo: number = exemptFrom,
854
+ permanentEnd = 0,
833
855
  ): number {
834
- // Audit only the byte-stable leading prefix [0, auditLimit); rows committed
835
- // under a durable snapshot end (beyond auditLimit) may drift legitimately and
836
- // are exempt, so their drift never triggers a re-anchor.
837
- const committed = Math.min(prefix.length, Math.max(0, Math.trunc(auditLimit)));
856
+ const committed = Math.min(prefix.length, Math.max(0, Math.trunc(auditTo)));
838
857
  if (committed === 0) return -1;
858
+ // Exempt window [exFrom, exTo) clamped into the committed prefix. Rows there
859
+ // are durable-snapshot drift and skipped by both detectors and the scan.
860
+ const exFrom = Math.max(0, Math.min(committed, Math.trunc(exemptFrom)));
861
+ const exTo = Math.max(exFrom, Math.min(committed, Math.trunc(exemptTo)));
862
+ const audited = (i: number): boolean => i < exFrom || i >= exTo;
839
863
  if (frame.length >= committed) {
840
- let samples = 0;
841
- let mismatches = 0;
842
- const lookback = Math.min(RESYNC_TAIL_LOOKBACK, committed);
843
- for (let j = 1; j <= lookback && samples < RESYNC_TAIL_SAMPLES; j++) {
844
- const row = frame[committed - j]!;
845
- const old = prefix[committed - j]!;
846
- if (row === old) {
847
- if (!isBlankRow(row)) samples++;
848
- continue;
864
+ // 1. Hard scan: forced-overflow rows now asserted permanent. Full scan, no
865
+ // tolerance a finalized row that changed must re-anchor.
866
+ const hardEnd = Math.min(committed, Math.max(0, Math.trunc(permanentEnd)));
867
+ let hardMismatch = false;
868
+ for (let i = exTo; i < hardEnd; i++) {
869
+ if (!rowsEquivalent(frame[i]!, prefix[i]!)) {
870
+ hardMismatch = true;
871
+ break;
872
+ }
873
+ }
874
+ if (!hardMismatch) {
875
+ // 2. Tail sample. Walk up from the commit boundary, skipping exempt
876
+ // rows, until LOOKBACK audited rows or SAMPLES non-blank comparisons.
877
+ let samples = 0;
878
+ let mismatches = 0;
879
+ let scanned = 0;
880
+ for (let j = 1; j <= committed && scanned < RESYNC_TAIL_LOOKBACK && samples < RESYNC_TAIL_SAMPLES; j++) {
881
+ const idx = committed - j;
882
+ if (!audited(idx)) continue;
883
+ scanned++;
884
+ const row = frame[idx]!;
885
+ const old = prefix[idx]!;
886
+ if (row === old) {
887
+ if (!isBlankRow(row)) samples++;
888
+ continue;
889
+ }
890
+ if (isBlankRow(row) && isBlankRow(old)) continue;
891
+ samples++;
892
+ if (!rowsEquivalent(row, old)) mismatches++;
849
893
  }
850
- if (isBlankRow(row) && isBlankRow(old)) continue;
851
- samples++;
852
- if (!rowsEquivalent(row, old)) mismatches++;
894
+ // No signal (all-blank/all-exempt tail) or at most one edited row: aligned.
895
+ if (samples === 0 || mismatches <= 1) return -1;
853
896
  }
854
- // No signal (all-blank tail) or at most one edited row: aligned.
855
- if (samples === 0 || mismatches <= 1) return -1;
856
897
  }
857
- // Misaligned (or the frame no longer covers the prefix): re-anchor at the
858
- // first row whose content actually changed.
898
+ // Misaligned (hard mismatch, tail-sample shift, or the frame no longer covers
899
+ // the prefix): re-anchor at the first audited row whose content changed.
859
900
  const limit = Math.min(committed, frame.length);
860
901
  for (let i = 0; i < limit; i++) {
902
+ if (!audited(i)) continue;
861
903
  if (!rowsEquivalent(frame[i]!, prefix[i]!)) return i;
862
904
  }
863
905
  return limit < committed ? limit : -1;
@@ -957,15 +999,23 @@ export class TUI extends Container {
957
999
  // #auditCommittedPrefix). Holds references to component-cached strings, so
958
1000
  // the audit is a pointer walk in the common case.
959
1001
  #committedPrefix: string[] = [];
960
- // Length of the leading committed prefix [0, #committedPrefixAuditRows) that
961
- // is BYTE-STABLE and therefore audited. Rows [auditRows, committedRows) were
962
- // committed under a component's snapshot-safe (durable, non-byte-stable) end:
963
- // their scroll-off snapshot is permanent so dropping them is forbidden, but
964
- // they may drift afterward (a streaming table widening), so re-auditing them
965
- // would re-anchor on every drift and spray duplicate snapshots. Once a
966
- // snapshot row commits (auditRows < committedRows) the cap is permanent until
967
- // a wholesale re-slice (full paint / shrink / geometry) re-bases it.
1002
+ // The committed prefix [0, committedRows) splits into three audit zones by
1003
+ // two monotone marks auditRows durableRows committedRows:
1004
+ // [0, auditRows) BYTE-STABLE audited (re-anchor on any shift).
1005
+ // [auditRows, durableRows) DURABLE snapshot exempt: rows may drift in
1006
+ // place (a streaming table widening) without re-anchoring, so their
1007
+ // expected drift never sprays duplicate snapshots.
1008
+ // [durableRows, committedRows) FORCED-overflow audited: rows committed
1009
+ // only because they scrolled above the window under a commit-unstable
1010
+ // barrier; auditing them re-anchors (duplication, never loss) when the
1011
+ // barrier later shifts/finalizes/removes, instead of stranding a stale
1012
+ // prefix that silently drops the rows beneath it.
1013
+ // Both marks re-base on a wholesale re-slice (full paint / shrink / geometry)
1014
+ // and otherwise advance per the persistence rules in #updateCommittedAuditRows.
1015
+ // #auditCommittedPrefix audits [0, committedRows) skipping the exempt window
1016
+ // [auditRows, durableRows).
968
1017
  #committedPrefixAuditRows = 0;
1018
+ #committedPrefixDurableRows = 0;
969
1019
  // Frame row currently mapped to screen row 0. Monotonic between full
970
1020
  // paints: a shrink never re-exposes scrolled-off rows (they cannot be
971
1021
  // un-scrolled without rewriting history); live rows repaint at fixed
@@ -2530,6 +2580,29 @@ export class TUI extends Container {
2530
2580
  const commitSafeEnd = this.#nativeScrollbackCommitSafeEnd;
2531
2581
  const snapshotSafeEnd = this.#nativeScrollbackSnapshotSafeEnd;
2532
2582
 
2583
+ // Commit boundaries (also used by the window/commit math in section 3),
2584
+ // hoisted above the audit gate because the resync needs byteStableBoundary
2585
+ // to tell a now-permanent forced row (must re-anchor) from a still-live one.
2586
+ // The commit floor is windowTop in every non-frozen path (see chunkTo), so
2587
+ // whatever scrolls above the window is committed — never committed nowhere
2588
+ // AND painted nowhere (the loss bug). The boundaries no longer gate the
2589
+ // commit; they define the audit-exempt span. byteStableBoundary: rows below
2590
+ // it are byte-stable (never re-layout), audited. durableBoundary: rows in
2591
+ // [byteStableBoundary, durableBoundary) are durable — permanent on scroll-off
2592
+ // but may drift in place (a streaming table re-aligning), committed
2593
+ // audit-EXEMPT. Rows at/beyond durableBoundary committed only because they
2594
+ // scrolled above the window (a commit-unstable barrier over a long tail) are
2595
+ // forced-overflow rows: audited, so a later shift/finalize/removal re-anchors
2596
+ // (duplication, never loss) instead of stranding a stale prefix. Built on the
2597
+ // finalized prefix (live-region start); the whole frame when the root reports
2598
+ // no seam (shell semantics: whatever scrolls is final).
2599
+ const frameLength = rawFrame.length;
2600
+ const byteStableBoundary = Math.max(0, Math.min(frameLength, commitSafeEnd ?? liveRegionStart ?? frameLength));
2601
+ const durableBoundary = Math.max(
2602
+ byteStableBoundary,
2603
+ Math.min(frameLength, snapshotSafeEnd ?? byteStableBoundary),
2604
+ );
2605
+
2533
2606
  // 2. Transition state captured before any emitter runs.
2534
2607
  const prevWindowTop = this.#windowTopRow;
2535
2608
  const prevHardwareCursorRow = this.#hardwareCursorRow;
@@ -2559,23 +2632,37 @@ export class TUI extends Container {
2559
2632
  // that provably did not change since the last (aligned) frame cannot
2560
2633
  // have diverged.
2561
2634
  let committedRowsResynced = false;
2562
- if (
2635
+ // Audit covers [0, auditRows) and the forced suffix [durableRows,
2636
+ // committedRows); the durable middle [auditRows, durableRows) is exempt
2637
+ // (in-place drift). Two reasons to run the audit this frame:
2638
+ // - the stable prefix does not cover every audited row (auditUpper); or
2639
+ // - a forced-overflow row this frame became durable/permanent
2640
+ // (committedPrefixDurableRows < hardAuditEnd): the barrier above it
2641
+ // finalized, so its committed bytes must be re-checked even though the
2642
+ // stable prefix says nothing moved — a stale committed copy there would
2643
+ // silently drop the row. The hard scan in findCommittedPrefixResync
2644
+ // covers [durableRows, hardAuditEnd) in full (no tail-sample miss).
2645
+ const auditUpper =
2646
+ this.#committedPrefixDurableRows < this.#committedRows ? this.#committedRows : this.#committedPrefixAuditRows;
2647
+ const hardAuditEnd = Math.min(this.#committedRows, durableBoundary);
2648
+ const needHardAudit = this.#committedPrefixDurableRows < hardAuditEnd;
2649
+ const auditRan =
2563
2650
  this.#hasEverRendered &&
2564
2651
  !geometryChanged &&
2565
2652
  !this.#clearScrollbackOnNextRender &&
2566
- this.#renderStablePrefixRows < this.#committedPrefixAuditRows
2567
- ) {
2653
+ (this.#renderStablePrefixRows < auditUpper || needHardAudit);
2654
+ if (auditRan) {
2568
2655
  const committedRowsBeforeAudit = this.#committedRows;
2569
- this.#auditCommittedPrefix(rawFrame);
2656
+ this.#auditCommittedPrefix(rawFrame, durableBoundary);
2570
2657
  committedRowsResynced = this.#committedRows !== committedRowsBeforeAudit;
2571
2658
  }
2572
2659
  // Committed-prefix state this frame's commit math extends from (post-audit).
2573
- // Drives the byte-stable audit-rows cap recomputed after the emit.
2660
+ // Drives the audit-rows / durable-rows caps recomputed after the emit.
2574
2661
  const preCommitRows = this.#committedRows;
2575
2662
  const preCommitAuditRows = this.#committedPrefixAuditRows;
2663
+ const preCommitDurableRows = this.#committedPrefixDurableRows;
2576
2664
 
2577
2665
  // 3. Window and commit math (lengths only; content prepared below).
2578
- const frameLength = rawFrame.length;
2579
2666
  let hasVisibleOverlay = false;
2580
2667
  for (const entry of this.overlayStack) {
2581
2668
  if (this.#isOverlayVisible(entry)) {
@@ -2583,18 +2670,6 @@ export class TUI extends Container {
2583
2670
  break;
2584
2671
  }
2585
2672
  }
2586
- // Two commit boundaries. byteStableBoundary: rows below it are byte-stable
2587
- // (asserted never to re-layout) and stay under the committed-prefix audit.
2588
- // durableBoundary: rows below it are durable — their scroll-off snapshot is
2589
- // permanent (dropping them is forbidden) but may still drift afterward, so
2590
- // they commit audit-EXEMPT. Both build on the finalized prefix (live-region
2591
- // start); the whole frame when the root reports no seam (shell semantics:
2592
- // whatever scrolls is final).
2593
- const byteStableBoundary = Math.max(0, Math.min(frameLength, commitSafeEnd ?? liveRegionStart ?? frameLength));
2594
- const durableBoundary = Math.max(
2595
- byteStableBoundary,
2596
- Math.min(frameLength, snapshotSafeEnd ?? byteStableBoundary),
2597
- );
2598
2673
 
2599
2674
  // 4. Classify. A resize is an explicit user gesture: normally the engine
2600
2675
  // erases and replays so history rewraps at the new geometry (the reader
@@ -2612,7 +2687,7 @@ export class TUI extends Container {
2612
2687
  if (fullPaint) {
2613
2688
  committedPrefixResliced = true;
2614
2689
  windowTop = Math.max(0, frameLength - height);
2615
- chunkTo = Math.min(durableBoundary, windowTop);
2690
+ chunkTo = windowTop;
2616
2691
  } else if (
2617
2692
  frameLength <= this.#committedRows ||
2618
2693
  (committedRowsResynced &&
@@ -2630,7 +2705,7 @@ export class TUI extends Container {
2630
2705
  // is preferable to a live editor gap and matches the existing
2631
2706
  // "duplication, never loss" resync contract.
2632
2707
  windowTop = Math.max(0, frameLength - height);
2633
- chunkTo = Math.min(durableBoundary, windowTop);
2708
+ chunkTo = windowTop;
2634
2709
  committedPrefixResliced = true;
2635
2710
  this.#committedRows = chunkTo;
2636
2711
  this.#committedPrefix = rawFrame.slice(0, chunkTo);
@@ -2648,10 +2723,7 @@ export class TUI extends Container {
2648
2723
  // pane keeps its own (old-wrap) history — and re-bases the audit
2649
2724
  // prefix at the new width so the accepted wrap drift does not read
2650
2725
  // as a violation on the next ordinary frame.
2651
- chunkTo =
2652
- hasVisibleOverlay || geometryChanged
2653
- ? this.#committedRows
2654
- : Math.max(this.#committedRows, Math.min(durableBoundary, windowTop));
2726
+ chunkTo = hasVisibleOverlay || geometryChanged ? this.#committedRows : windowTop;
2655
2727
  if (geometryChanged) {
2656
2728
  committedPrefixResliced = true;
2657
2729
  this.#committedPrefix = rawFrame.slice(0, this.#committedRows);
@@ -2711,7 +2783,15 @@ export class TUI extends Container {
2711
2783
  windowTop,
2712
2784
  });
2713
2785
  this.#committedPrefix = rawFrame.slice(0, chunkTo);
2714
- this.#updateCommittedAuditRows(true, preCommitRows, preCommitAuditRows, byteStableBoundary);
2786
+ this.#updateCommittedAuditRows(
2787
+ true,
2788
+ preCommitRows,
2789
+ preCommitAuditRows,
2790
+ preCommitDurableRows,
2791
+ byteStableBoundary,
2792
+ durableBoundary,
2793
+ false,
2794
+ );
2715
2795
  this.#clearScrollbackOnNextRender = false;
2716
2796
  this.#hasEverRendered = true;
2717
2797
  if (!firstPaint && frameLength > height) this.#armPostFullPaintSettle();
@@ -2730,7 +2810,15 @@ export class TUI extends Container {
2730
2810
  for (let i = this.#committedPrefix.length; i < chunkTo; i++) {
2731
2811
  this.#committedPrefix.push(rawFrame[i] ?? "");
2732
2812
  }
2733
- this.#updateCommittedAuditRows(committedPrefixResliced, preCommitRows, preCommitAuditRows, byteStableBoundary);
2813
+ this.#updateCommittedAuditRows(
2814
+ committedPrefixResliced,
2815
+ preCommitRows,
2816
+ preCommitAuditRows,
2817
+ preCommitDurableRows,
2818
+ byteStableBoundary,
2819
+ durableBoundary,
2820
+ auditRan,
2821
+ );
2734
2822
  }
2735
2823
 
2736
2824
  /**
@@ -2740,13 +2828,21 @@ export class TUI extends Container {
2740
2828
  * restyles keep their alignment and are left alone (stale styling in
2741
2829
  * history was always the accepted artifact).
2742
2830
  */
2743
- #auditCommittedPrefix(rawFrame: readonly string[]): void {
2831
+ #auditCommittedPrefix(rawFrame: readonly string[], permanentEnd: number): void {
2744
2832
  const prefix = this.#committedPrefix;
2745
2833
  if (prefix.length === 0) return;
2746
- const resyncTo = findCommittedPrefixResync(rawFrame, prefix, this.#committedPrefixAuditRows);
2834
+ const resyncTo = findCommittedPrefixResync(
2835
+ rawFrame,
2836
+ prefix,
2837
+ prefix.length,
2838
+ this.#committedPrefixAuditRows,
2839
+ this.#committedPrefixDurableRows,
2840
+ permanentEnd,
2841
+ );
2747
2842
  if (resyncTo < 0) return;
2748
2843
  this.#committedRows = resyncTo;
2749
2844
  this.#committedPrefixAuditRows = Math.min(this.#committedPrefixAuditRows, resyncTo);
2845
+ this.#committedPrefixDurableRows = Math.min(this.#committedPrefixDurableRows, resyncTo);
2750
2846
  prefix.length = resyncTo;
2751
2847
  if ($flag("PI_DEBUG_REDRAW")) {
2752
2848
  const msg = `[${new Date().toISOString()}] commit resync: committed prefix diverged at row ${resyncTo}; recommitting\n`;
@@ -2755,27 +2851,43 @@ export class TUI extends Container {
2755
2851
  }
2756
2852
 
2757
2853
  /**
2758
- * Recompute the byte-stable audit-rows cap after a commit. The audited prefix
2759
- * [0, auditRows) holds rows committed while byte-stable; rows committed under a
2760
- * durable snapshot end (beyond byteStableBoundary) are excluded so the audit
2761
- * never re-anchors on their expected drift (a streaming table widening). A
2762
- * wholesale re-slice (full paint / shrink / geometry) re-bases the prefix from
2763
- * the current frame, so the cap is just min(committed, byteStableBoundary). An
2764
- * incremental extend keeps the cap once any snapshot row has committed
2765
- * (auditRows < committedRows): a later rise in byteStableBoundary (a table
2766
- * finalizing) must not pull already-committed stale snapshots back under audit.
2854
+ * Recompute the audit-rows / durable-rows marks after a commit (see the
2855
+ * #committedPrefixAuditRows field doc for the three audit zones).
2856
+ *
2857
+ * auditRows tracks the byte-stable boundary; durableRows the durable snapshot
2858
+ * boundary. A wholesale re-slice (full paint / shrink / geometry) re-bases
2859
+ * each mark from the current frame (min(committed, boundary)). An incremental
2860
+ * extend keeps a mark once a row past it has committed (mark < committed): a
2861
+ * later RISE in a boundary (a table finalizing) must neither pull
2862
+ * already-committed stale snapshots back under the byte-stable cap nor
2863
+ * retroactively exempt forced-overflow rows already audited. durableRows is
2864
+ * floored at auditRows so the exempt window can never invert.
2767
2865
  */
2768
2866
  #updateCommittedAuditRows(
2769
2867
  resliced: boolean,
2770
2868
  preCommittedRows: number,
2771
2869
  preAuditRows: number,
2870
+ preDurableRows: number,
2772
2871
  byteStableBoundary: number,
2872
+ durableBoundary: number,
2873
+ hardAudited: boolean,
2773
2874
  ): void {
2774
2875
  const committed = this.#committedRows;
2775
- this.#committedPrefixAuditRows =
2876
+ const auditRows =
2776
2877
  resliced || preAuditRows >= preCommittedRows
2777
2878
  ? Math.min(committed, byteStableBoundary)
2778
2879
  : Math.min(preAuditRows, committed);
2880
+ // durableRows also advances when a hard audit ran this frame: the resync's
2881
+ // full hard scan verified the forced suffix [durableRows, min(committed,
2882
+ // durableBoundary)) (re-anchoring on any divergence), so those rows are now
2883
+ // proven durable and may leave the audited set — otherwise the durable-rise
2884
+ // gate would re-fire the full scan every frame (and spray on later drift).
2885
+ const durableRows =
2886
+ resliced || preDurableRows >= preCommittedRows || hardAudited
2887
+ ? Math.min(committed, durableBoundary)
2888
+ : Math.min(preDurableRows, committed);
2889
+ this.#committedPrefixAuditRows = auditRows;
2890
+ this.#committedPrefixDurableRows = Math.max(auditRows, durableRows);
2779
2891
  }
2780
2892
 
2781
2893
  /**