@oh-my-pi/pi-tui 16.3.2 → 16.3.4

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,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.3.3] - 2026-07-02
6
+
7
+ ### Fixed
8
+
9
+ - Fixed keyboard fallback behavior (modifyOtherKeys) on unknown SSH terminals, resolving broken Shift input in iOS SSH clients like Redock.
10
+ - Fixed a native scrollback rendering bug where finalized transcript rows below an active block would duplicate when the active block expanded.
11
+ - Fixed autocomplete popups remaining active with stale suggestions after destructive text editing (such as Ctrl+W, Ctrl+U, Ctrl+K, Alt+Backspace, Alt+D, paste, or yank), preventing input corruption when pressing Tab or Enter.
12
+ - Skipped Markdown re-lex + re-wrap when `setText` receives the identical text, mirroring the equality guard on `Text.setText` — cuts one of the top streaming CPU hotspots when providers re-emit unchanged content ([#4353](https://github.com/can1357/oh-my-pi/issues/4353)).
13
+
5
14
  ## [16.3.0] - 2026-07-02
6
15
 
7
16
  ### Fixed
@@ -51,7 +51,7 @@ export declare class Markdown implements Component {
51
51
  #private;
52
52
  setIgnoreTight(ignore: boolean): this;
53
53
  constructor(text: string, paddingX: number, paddingY: number, theme: MarkdownTheme, defaultTextStyle?: DefaultTextStyle, codeBlockIndent?: number);
54
- setText(text: string): void;
54
+ setText(text: string): boolean;
55
55
  invalidate(): void;
56
56
  get transientRenderCache(): boolean;
57
57
  set transientRenderCache(value: boolean);
@@ -105,6 +105,11 @@ export interface OverlayFocusOwner {
105
105
  * dropped row or an audit re-anchor spray. Provisional live blocks (collapsing
106
106
  * tool/edit previews whose head is a throwaway tail window) omit it. Defaults to
107
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.
108
113
  *
109
114
  * When several root children report a seam in the same frame, the topmost
110
115
  * one (and its commit-safe / snapshot-safe extension) defines the boundary:
@@ -115,6 +120,7 @@ export interface NativeScrollbackLiveRegion {
115
120
  getNativeScrollbackLiveRegionStart(): number | undefined;
116
121
  getNativeScrollbackCommitSafeEnd?(): number | undefined;
117
122
  getNativeScrollbackSnapshotSafeEnd?(): number | undefined;
123
+ getNativeScrollbackOfferSafeEnd?(): number | undefined;
118
124
  }
119
125
  export interface NativeScrollbackCommittedRows {
120
126
  setNativeScrollbackCommittedRows(rows: number): void;
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.2",
4
+ "version": "16.3.4",
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.2",
41
- "@oh-my-pi/pi-utils": "16.3.2",
40
+ "@oh-my-pi/pi-natives": "16.3.4",
41
+ "@oh-my-pi/pi-utils": "16.3.4",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.5"
44
44
  },
@@ -1119,6 +1119,15 @@ export class Editor implements Component, Focusable {
1119
1119
 
1120
1120
  // If Tab was pressed, always apply the selection
1121
1121
  if (kb.matches(data, "tui.input.tab")) {
1122
+ // Check for stale autocomplete state due to buffer edits since last refresh
1123
+ // (destructive keys or paste can outrun the debounced update).
1124
+ const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1125
+ const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1126
+ if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor)) {
1127
+ // Autocomplete is stale - silently cancel; Tab has no fallback action here.
1128
+ this.#cancelAutocomplete();
1129
+ return;
1130
+ }
1122
1131
  const selected = this.#autocompleteList.getSelectedItem();
1123
1132
  if (selected && this.#autocompleteProvider) {
1124
1133
  const shouldChainSlashCommandAutocomplete = this.#isSlashCommandNameAutocompleteSelection();
@@ -1183,30 +1192,38 @@ export class Editor implements Component, Focusable {
1183
1192
  }
1184
1193
  // If Enter was pressed on a file path, apply completion
1185
1194
  else if (kb.matches(data, "tui.input.submit") || data === "\n") {
1186
- const selected = this.#autocompleteList.getSelectedItem();
1187
- if (selected && this.#autocompleteProvider) {
1188
- const result = this.#autocompleteProvider.applyCompletion(
1189
- this.#state.lines,
1190
- this.#state.cursorLine,
1191
- this.#state.cursorCol,
1192
- selected,
1193
- this.#autocompletePrefix,
1194
- );
1195
+ // Check for stale autocomplete state due to buffer edits since last refresh.
1196
+ const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1197
+ const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1198
+ if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor)) {
1199
+ // Autocomplete is stale - cancel and fall through to normal submission
1200
+ this.#cancelAutocomplete();
1201
+ } else {
1202
+ const selected = this.#autocompleteList.getSelectedItem();
1203
+ if (selected && this.#autocompleteProvider) {
1204
+ const result = this.#autocompleteProvider.applyCompletion(
1205
+ this.#state.lines,
1206
+ this.#state.cursorLine,
1207
+ this.#state.cursorCol,
1208
+ selected,
1209
+ this.#autocompletePrefix,
1210
+ );
1195
1211
 
1196
- this.#state.lines = result.lines;
1197
- this.#state.cursorLine = result.cursorLine;
1198
- this.#setCursorCol(result.cursorCol);
1212
+ this.#state.lines = result.lines;
1213
+ this.#state.cursorLine = result.cursorLine;
1214
+ this.#setCursorCol(result.cursorCol);
1199
1215
 
1200
- this.#cancelAutocomplete();
1201
- this.onAutocompleteUpdate?.();
1216
+ this.#cancelAutocomplete();
1217
+ this.onAutocompleteUpdate?.();
1202
1218
 
1203
- if (this.onChange) {
1204
- this.onChange(this.getText());
1205
- }
1219
+ if (this.onChange) {
1220
+ this.onChange(this.getText());
1221
+ }
1206
1222
 
1207
- result.onApplied?.();
1223
+ result.onApplied?.();
1224
+ }
1225
+ return;
1208
1226
  }
1209
- return;
1210
1227
  }
1211
1228
  }
1212
1229
  // For other keys (like regular typing), DON'T return here
@@ -1875,7 +1892,6 @@ export class Editor implements Component, Focusable {
1875
1892
  // then evaluate autocomplete triggers once at the final cursor position.
1876
1893
  if (filteredText) {
1877
1894
  this.#insertTextAtCursor(filteredText);
1878
- this.#retriggerAutocompleteAtCursor();
1879
1895
  }
1880
1896
  return;
1881
1897
  }
@@ -2351,6 +2367,7 @@ export class Editor implements Component, Focusable {
2351
2367
  if (this.onChange) {
2352
2368
  this.onChange(this.getText());
2353
2369
  }
2370
+ this.#retriggerAutocompleteAtCursor();
2354
2371
  }
2355
2372
 
2356
2373
  #yankFromKillRing(): void {
@@ -2457,6 +2474,7 @@ export class Editor implements Component, Focusable {
2457
2474
  if (this.onChange) {
2458
2475
  this.onChange(this.getText());
2459
2476
  }
2477
+ this.#retriggerAutocompleteAtCursor();
2460
2478
  }
2461
2479
 
2462
2480
  #deleteToEndOfLine(): void {
@@ -2488,6 +2506,7 @@ export class Editor implements Component, Focusable {
2488
2506
  if (this.onChange) {
2489
2507
  this.onChange(this.getText());
2490
2508
  }
2509
+ this.#retriggerAutocompleteAtCursor();
2491
2510
  }
2492
2511
 
2493
2512
  #deleteWordBackwards(): void {
@@ -2522,6 +2541,7 @@ export class Editor implements Component, Focusable {
2522
2541
  if (this.onChange) {
2523
2542
  this.onChange(this.getText());
2524
2543
  }
2544
+ this.#retriggerAutocompleteAtCursor();
2525
2545
  }
2526
2546
 
2527
2547
  #deleteWordForwards(): void {
@@ -2553,6 +2573,7 @@ export class Editor implements Component, Focusable {
2553
2573
  if (this.onChange) {
2554
2574
  this.onChange(this.getText());
2555
2575
  }
2576
+ this.#retriggerAutocompleteAtCursor();
2556
2577
  }
2557
2578
 
2558
2579
  #handleForwardDelete(): void {
@@ -2844,11 +2865,38 @@ export class Editor implements Component, Focusable {
2844
2865
  return this.#isInSubmittedSlashCommandContext() || this.#isInMidPromptSkillSlashContext();
2845
2866
  }
2846
2867
 
2868
+ /**
2869
+ * Decide whether the popup's `#autocompletePrefix` still safely maps onto the current
2870
+ * text before the cursor for an accept-time (`applyCompletion`) call. Mirrors the
2871
+ * re-anchoring branches in `CombinedAutocompleteProvider.applyCompletion`:
2872
+ *
2873
+ * - Exact match → always safe.
2874
+ * - Path branch is safe when the prefix is still a live suffix of the text; the
2875
+ * provider's default slice at `cursorCol - prefix.length` then hits the right span.
2876
+ * - Slash branch re-anchors when both the prefix and the current text carry a
2877
+ * leading slash command and the current slash token is clean (no whitespace or
2878
+ * inner slash), matching `applyCompletion`'s slash-branch guard.
2879
+ * - `@`-file branch re-anchors via `#extractAtPrefix`; safe when the current text
2880
+ * still ends in a whitespace-anchored `@<token>`.
2881
+ * - Everything else is stale — accepting it would corrupt the buffer (issue #4295).
2882
+ */
2847
2883
  #autocompletePrefixMatchesCursorText(currentTextBeforeCursor: string): boolean {
2848
2884
  if (currentTextBeforeCursor === this.#autocompletePrefix) return true;
2849
- if (findTrailingSlashCommandStart(this.#autocompletePrefix) !== 0) return false;
2850
- const slashStart = findTrailingSlashCommandStart(currentTextBeforeCursor);
2851
- return slashStart !== null && currentTextBeforeCursor.slice(slashStart) === this.#autocompletePrefix;
2885
+
2886
+ if (findLeadingSlashCommandStart(this.#autocompletePrefix) !== null) {
2887
+ const currentLeadingStart = findLeadingSlashCommandStart(currentTextBeforeCursor);
2888
+ if (currentLeadingStart !== null) {
2889
+ const token = currentTextBeforeCursor.slice(currentLeadingStart);
2890
+ if (!token.includes(" ") && !token.slice(1).includes("/")) return true;
2891
+ }
2892
+ return false;
2893
+ }
2894
+
2895
+ if (this.#autocompletePrefix.startsWith("@")) {
2896
+ return /(?:^|\s)@[^\s]*$/.test(currentTextBeforeCursor);
2897
+ }
2898
+
2899
+ return currentTextBeforeCursor.endsWith(this.#autocompletePrefix);
2852
2900
  }
2853
2901
 
2854
2902
  #isSlashCommandNameAutocompleteSelection(): boolean {
@@ -842,7 +842,13 @@ export class Markdown implements Component {
842
842
  this.#codeBlockIndent = Math.max(0, Math.floor(codeBlockIndent));
843
843
  }
844
844
 
845
- setText(text: string): void {
845
+ setText(text: string): boolean {
846
+ // Equality guard: streaming re-emits identical text on ticks that carried
847
+ // no delta (throttled provider frames, reconciled tool-execution updates).
848
+ // Without this, the caller-side `#cachedLines` gets thrown away and the
849
+ // full lex + wrap runs per re-emit — one of the top CPU hotspots during
850
+ // streaming (issue #4353). Mirrors `Text.setText`'s guard.
851
+ if (text === this.#text) return false;
846
852
  this.#text = text;
847
853
  if (!text.trim()) {
848
854
  // Blank replacement: render() early-returns before #lexTokens can see
@@ -853,6 +859,7 @@ export class Markdown implements Component {
853
859
  this.#streamPrefixLineCache = undefined;
854
860
  }
855
861
  this.invalidate();
862
+ return true;
856
863
  }
857
864
 
858
865
  invalidate(): void {
package/src/terminal.ts CHANGED
@@ -35,6 +35,11 @@ export function resolveHangulCompatibilityJamoWidthFromTerminalIdentity(
35
35
  return "platform";
36
36
  }
37
37
 
38
+ function shouldEnableModifyOtherKeysFallback(env: NodeJS.ProcessEnv = Bun.env): boolean {
39
+ if (!env.SSH_CONNECTION && !env.SSH_TTY && !env.SSH_CLIENT) return true;
40
+ return TERMINAL.id !== "base" && TERMINAL.id !== "trueColor";
41
+ }
42
+
38
43
  /**
39
44
  * Maximum encoded UTF-8 bytes per `process.stdout.write` call on Windows.
40
45
  *
@@ -840,13 +845,13 @@ export class ProcessTerminal implements Terminal {
840
845
  break;
841
846
  }
842
847
  case "keyboard": {
843
- // Keyboard probe sentinel: kitty reply never arrived → fall back to modifyOtherKeys.
844
- if (!this.#kittyProtocolActive && !this.#modifyOtherKeysActive && this.#modifyOtherKeysTimeout) {
848
+ // Keyboard probe sentinel: kitty reply never arrived → fall back to modifyOtherKeys
849
+ // only where the resolved terminal is known enough to tolerate it.
850
+ if (this.#modifyOtherKeysTimeout) {
845
851
  clearTimeout(this.#modifyOtherKeysTimeout);
846
852
  this.#modifyOtherKeysTimeout = undefined;
847
- this.#safeWrite("\x1b[>4;2m");
848
- this.#modifyOtherKeysActive = true;
849
853
  }
854
+ this.#enableModifyOtherKeysFallback();
850
855
  break;
851
856
  }
852
857
  case "osc99Probe": {
@@ -1054,6 +1059,13 @@ export class ProcessTerminal implements Terminal {
1054
1059
  }
1055
1060
  }
1056
1061
 
1062
+ #enableModifyOtherKeysFallback(): void {
1063
+ if (this.#kittyProtocolActive || this.#modifyOtherKeysActive) return;
1064
+ if (!shouldEnableModifyOtherKeysFallback()) return;
1065
+ this.#safeWrite("\x1b[>4;2m");
1066
+ this.#modifyOtherKeysActive = true;
1067
+ }
1068
+
1057
1069
  /**
1058
1070
  * Query terminal for Kitty keyboard protocol support and enable if available.
1059
1071
  *
@@ -1073,11 +1085,7 @@ export class ProcessTerminal implements Terminal {
1073
1085
  this.#safeWrite("\x1b[?u\x1b[c");
1074
1086
  this.#modifyOtherKeysTimeout = setTimeout(() => {
1075
1087
  this.#modifyOtherKeysTimeout = undefined;
1076
- if (this.#kittyProtocolActive || this.#modifyOtherKeysActive) {
1077
- return;
1078
- }
1079
- this.#safeWrite("\x1b[>4;2m");
1080
- this.#modifyOtherKeysActive = true;
1088
+ this.#enableModifyOtherKeysFallback();
1081
1089
  }, 150);
1082
1090
  }
1083
1091
 
package/src/tui.ts CHANGED
@@ -212,6 +212,11 @@ export interface OverlayFocusOwner {
212
212
  * dropped row or an audit re-anchor spray. Provisional live blocks (collapsing
213
213
  * tool/edit previews whose head is a throwaway tail window) omit it. Defaults to
214
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.
215
220
  *
216
221
  * When several root children report a seam in the same frame, the topmost
217
222
  * one (and its commit-safe / snapshot-safe extension) defines the boundary:
@@ -222,6 +227,7 @@ export interface NativeScrollbackLiveRegion {
222
227
  getNativeScrollbackLiveRegionStart(): number | undefined;
223
228
  getNativeScrollbackCommitSafeEnd?(): number | undefined;
224
229
  getNativeScrollbackSnapshotSafeEnd?(): number | undefined;
230
+ getNativeScrollbackOfferSafeEnd?(): number | undefined;
225
231
  }
226
232
 
227
233
  export interface NativeScrollbackCommittedRows {
@@ -251,6 +257,10 @@ function getNativeScrollbackSnapshotSafeEnd(component: Component): number | unde
251
257
  return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackSnapshotSafeEnd?.();
252
258
  }
253
259
 
260
+ function getNativeScrollbackOfferSafeEnd(component: Component): number | undefined {
261
+ return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackOfferSafeEnd?.();
262
+ }
263
+
254
264
  /**
255
265
  * Opt-in stability report for components that mutate their returned render
256
266
  * array in place across frames (instead of returning a fresh array per
@@ -628,6 +638,7 @@ interface FrameSegment {
628
638
  liveLocalStart?: number;
629
639
  commitLocalEnd?: number;
630
640
  snapshotLocalEnd?: number;
641
+ offerLocalEnd?: number;
631
642
  }
632
643
 
633
644
  /** Depth-first identity search through `Container`-shaped children. */
@@ -1009,23 +1020,27 @@ export class TUI extends Container {
1009
1020
  // #auditCommittedPrefix). Holds references to component-cached strings, so
1010
1021
  // the audit is a pointer walk in the common case.
1011
1022
  #committedPrefix: string[] = [];
1012
- // The committed prefix [0, committedRows) splits into three audit zones by
1013
- // two monotone marks auditRows ≤ durableRows ≤ committedRows:
1023
+ // The committed prefix [0, committedRows) splits into four zones by three
1024
+ // monotone marks auditRows ≤ durableRows ≤ offerRows ≤ committedRows:
1014
1025
  // [0, auditRows) BYTE-STABLE — audited (re-anchor on any shift).
1015
1026
  // [auditRows, durableRows) DURABLE snapshot — exempt: rows may drift in
1016
1027
  // place (a streaming table widening) without re-anchoring, so their
1017
1028
  // expected drift never sprays duplicate snapshots.
1018
- // [durableRows, committedRows) FORCED-overflow — audited: rows committed
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
1019
1033
  // only because they scrolled above the window under a commit-unstable
1020
1034
  // barrier; auditing them re-anchors (duplication, never loss) when the
1021
1035
  // barrier later shifts/finalizes/removes, instead of stranding a stale
1022
1036
  // prefix that silently drops the rows beneath it.
1023
- // Both marks re-base on a wholesale re-slice (full paint / shrink / geometry)
1024
- // and otherwise advance per the persistence rules in #updateCommittedAuditRows.
1037
+ // Marks re-base on a wholesale re-slice (full paint / shrink / geometry) and
1038
+ // otherwise advance per the persistence rules in #updateCommittedAuditRows.
1025
1039
  // #auditCommittedPrefix audits [0, committedRows) skipping the exempt window
1026
1040
  // [auditRows, durableRows).
1027
1041
  #committedPrefixAuditRows = 0;
1028
1042
  #committedPrefixDurableRows = 0;
1043
+ #committedPrefixOfferRows = 0;
1029
1044
  // Frame row currently mapped to screen row 0. Monotonic between full
1030
1045
  // paints: a shrink never re-exposes scrolled-off rows (they cannot be
1031
1046
  // un-scrolled without rewriting history); live rows repaint at fixed
@@ -1036,6 +1051,7 @@ export class TUI extends Container {
1036
1051
  #nativeScrollbackLiveRegionStart: number | undefined;
1037
1052
  #nativeScrollbackCommitSafeEnd: number | undefined;
1038
1053
  #nativeScrollbackSnapshotSafeEnd: number | undefined;
1054
+ #nativeScrollbackOfferSafeEnd: number | undefined;
1039
1055
  #fullRedrawCount = 0;
1040
1056
  // Caps how many inline images render as live graphics; older ones fall back
1041
1057
  // to text via a purge + full redraw. Cap is configured by the host app.
@@ -1155,6 +1171,7 @@ export class TUI extends Container {
1155
1171
  this.#nativeScrollbackLiveRegionStart = undefined;
1156
1172
  this.#nativeScrollbackCommitSafeEnd = undefined;
1157
1173
  this.#nativeScrollbackSnapshotSafeEnd = undefined;
1174
+ this.#nativeScrollbackOfferSafeEnd = undefined;
1158
1175
  const children = this.children;
1159
1176
  const previousSegments = this.#frameSegments;
1160
1177
  const segments: FrameSegment[] = new Array(children.length);
@@ -1177,12 +1194,14 @@ export class TUI extends Container {
1177
1194
  let liveLocalStart: number | undefined;
1178
1195
  let commitLocalEnd: number | undefined;
1179
1196
  let snapshotLocalEnd: number | undefined;
1197
+ let offerLocalEnd: number | undefined;
1180
1198
  let reported: number | undefined;
1181
1199
  if (reuse) {
1182
1200
  childLines = previous.lines;
1183
1201
  liveLocalStart = previous.liveLocalStart;
1184
1202
  commitLocalEnd = previous.commitLocalEnd;
1185
1203
  snapshotLocalEnd = previous.snapshotLocalEnd;
1204
+ offerLocalEnd = previous.offerLocalEnd;
1186
1205
  } else {
1187
1206
  // Feed the engine's committed-row claim (from the previous frame's
1188
1207
  // emit) before rendering so the child can skip re-deriving blocks
@@ -1211,6 +1230,13 @@ export class TUI extends Container {
1211
1230
  ? Math.max(snapshotFloor, Math.min(childLines.length, Math.trunc(snapshotSafeEnd)))
1212
1231
  : childLines.length;
1213
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
+ }
1214
1240
  }
1215
1241
  // Consume the stability report unconditionally for implementers:
1216
1242
  // reading re-bases the component's baseline to the state this
@@ -1234,6 +1260,9 @@ export class TUI extends Container {
1234
1260
  if (snapshotLocalEnd !== undefined) {
1235
1261
  this.#nativeScrollbackSnapshotSafeEnd = offset + snapshotLocalEnd;
1236
1262
  }
1263
+ if (offerLocalEnd !== undefined) {
1264
+ this.#nativeScrollbackOfferSafeEnd = offset + offerLocalEnd;
1265
+ }
1237
1266
  }
1238
1267
  if (chainStable) {
1239
1268
  if (previous !== undefined && previous.component === child && previous.start === offset) {
@@ -1264,6 +1293,7 @@ export class TUI extends Container {
1264
1293
  liveLocalStart,
1265
1294
  commitLocalEnd,
1266
1295
  snapshotLocalEnd,
1296
+ offerLocalEnd,
1267
1297
  };
1268
1298
  offset += childLines.length;
1269
1299
  }
@@ -2664,6 +2694,7 @@ export class TUI extends Container {
2664
2694
  const liveRegionStart = this.#nativeScrollbackLiveRegionStart;
2665
2695
  const commitSafeEnd = this.#nativeScrollbackCommitSafeEnd;
2666
2696
  const snapshotSafeEnd = this.#nativeScrollbackSnapshotSafeEnd;
2697
+ const offerSafeEnd = this.#nativeScrollbackOfferSafeEnd;
2667
2698
 
2668
2699
  // Commit boundaries (also used by the window/commit math in section 3),
2669
2700
  // hoisted above the audit gate because the resync needs byteStableBoundary
@@ -2671,22 +2702,27 @@ export class TUI extends Container {
2671
2702
  // The commit floor is windowTop in every non-frozen path (see chunkTo), so
2672
2703
  // whatever scrolls above the window is committed — never committed nowhere
2673
2704
  // AND painted nowhere (the loss bug). The boundaries no longer gate the
2674
- // commit; they define the audit-exempt span. byteStableBoundary: rows below
2705
+ // commit; they define audit and repair spans. byteStableBoundary: rows below
2675
2706
  // it are byte-stable (never re-layout), audited. durableBoundary: rows in
2676
2707
  // [byteStableBoundary, durableBoundary) are durable — permanent on scroll-off
2677
2708
  // but may drift in place (a streaming table re-aligning), committed
2678
- // audit-EXEMPT. Rows at/beyond durableBoundary committed only because they
2679
- // scrolled above the window (a commit-unstable barrier over a long tail) are
2680
- // forced-overflow rows: audited, so a later shift/finalize/removal re-anchors
2681
- // (duplication, never loss) instead of stranding a stale prefix. Built on the
2682
- // finalized prefix (live-region start); the whole frame when the root reports
2683
- // no seam (shell semantics: whatever scrolls is final).
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).
2684
2719
  const frameLength = rawFrame.length;
2685
2720
  const byteStableBoundary = Math.max(0, Math.min(frameLength, commitSafeEnd ?? liveRegionStart ?? frameLength));
2686
2721
  const durableBoundary = Math.max(
2687
2722
  byteStableBoundary,
2688
2723
  Math.min(frameLength, snapshotSafeEnd ?? byteStableBoundary),
2689
2724
  );
2725
+ const offerBoundary = Math.max(durableBoundary, Math.min(frameLength, offerSafeEnd ?? durableBoundary));
2690
2726
 
2691
2727
  // 2. Transition state captured before any emitter runs.
2692
2728
  const prevWindowTop = this.#windowTopRow;
@@ -2719,18 +2755,28 @@ export class TUI extends Container {
2719
2755
  let committedRowsResynced = false;
2720
2756
  // Audit covers [0, auditRows) and the forced suffix [durableRows,
2721
2757
  // committedRows); the durable middle [auditRows, durableRows) is exempt
2722
- // (in-place drift). Two reasons to run the audit this frame:
2758
+ // (in-place drift). Three reasons to run the audit this frame:
2723
2759
  // - the stable prefix does not cover every audited row (auditUpper); or
2724
2760
  // - a forced-overflow row this frame became durable/permanent
2725
- // (committedPrefixDurableRows < hardAuditEnd): the barrier above it
2726
- // finalized, so its committed bytes must be re-checked even though the
2727
- // stable prefix says nothing moved — a stale committed copy there would
2728
- // silently drop the row. The hard scan in findCommittedPrefixResync
2729
- // covers [durableRows, hardAuditEnd) in full (no tail-sample miss).
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).
2730
2772
  const auditUpper =
2731
2773
  this.#committedPrefixDurableRows < this.#committedRows ? this.#committedRows : this.#committedPrefixAuditRows;
2732
- const hardAuditEnd = Math.min(this.#committedRows, durableBoundary);
2733
- const needHardAudit = this.#committedPrefixDurableRows < hardAuditEnd;
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;
2734
2780
  const auditRan =
2735
2781
  this.#hasEverRendered &&
2736
2782
  !geometryChanged &&
@@ -2738,14 +2784,21 @@ export class TUI extends Container {
2738
2784
  (this.#renderStablePrefixRows < auditUpper || needHardAudit);
2739
2785
  if (auditRan) {
2740
2786
  const committedRowsBeforeAudit = this.#committedRows;
2741
- this.#auditCommittedPrefix(rawFrame, durableBoundary);
2787
+ const offeredRowsBeforeAudit = this.#committedPrefixOfferRows;
2788
+ const durableRowsBeforeAudit = this.#committedPrefixDurableRows;
2789
+ this.#auditCommittedPrefix(rawFrame, hardAuditEnd);
2742
2790
  committedRowsResynced = this.#committedRows !== committedRowsBeforeAudit;
2791
+ repairOfferedScrollback =
2792
+ offeredRowsBeforeAudit > durableRowsBeforeAudit &&
2793
+ committedRowsResynced &&
2794
+ this.#committedRows < offeredRowsBeforeAudit;
2743
2795
  }
2744
2796
  // Committed-prefix state this frame's commit math extends from (post-audit).
2745
2797
  // Drives the audit-rows / durable-rows caps recomputed after the emit.
2746
2798
  const preCommitRows = this.#committedRows;
2747
2799
  const preCommitAuditRows = this.#committedPrefixAuditRows;
2748
2800
  const preCommitDurableRows = this.#committedPrefixDurableRows;
2801
+ const preCommitOfferRows = this.#committedPrefixOfferRows;
2749
2802
 
2750
2803
  // 3. Window and commit math (lengths only; content prepared below).
2751
2804
  let hasVisibleOverlay = false;
@@ -2763,7 +2816,7 @@ export class TUI extends Container {
2763
2816
  // place, because an ED3 rewrap is unsafe (pane scrollback / alt-screen
2764
2817
  // feedback loop), so committed history keeps its old wrap.
2765
2818
  const firstPaint = !this.#hasEverRendered;
2766
- const replaceRequested = this.#clearScrollbackOnNextRender;
2819
+ const replaceRequested = this.#clearScrollbackOnNextRender || repairOfferedScrollback;
2767
2820
  const geometryRebuild = geometryChanged && !resizeRepaintsInPlace();
2768
2821
  const fullPaint = firstPaint || replaceRequested || geometryRebuild;
2769
2822
  let windowTop: number;
@@ -2875,8 +2928,10 @@ export class TUI extends Container {
2875
2928
  preCommitRows,
2876
2929
  preCommitAuditRows,
2877
2930
  preCommitDurableRows,
2931
+ preCommitOfferRows,
2878
2932
  byteStableBoundary,
2879
2933
  durableBoundary,
2934
+ offerBoundary,
2880
2935
  false,
2881
2936
  );
2882
2937
  this.#clearScrollbackOnNextRender = false;
@@ -2904,8 +2959,10 @@ export class TUI extends Container {
2904
2959
  preCommitRows,
2905
2960
  preCommitAuditRows,
2906
2961
  preCommitDurableRows,
2962
+ preCommitOfferRows,
2907
2963
  byteStableBoundary,
2908
2964
  durableBoundary,
2965
+ offerBoundary,
2909
2966
  auditRan,
2910
2967
  );
2911
2968
  }
@@ -2932,6 +2989,7 @@ export class TUI extends Container {
2932
2989
  this.#committedRows = resyncTo;
2933
2990
  this.#committedPrefixAuditRows = Math.min(this.#committedPrefixAuditRows, resyncTo);
2934
2991
  this.#committedPrefixDurableRows = Math.min(this.#committedPrefixDurableRows, resyncTo);
2992
+ this.#committedPrefixOfferRows = Math.min(this.#committedPrefixOfferRows, resyncTo);
2935
2993
  prefix.length = resyncTo;
2936
2994
  if ($flag("PI_DEBUG_REDRAW")) {
2937
2995
  const msg = `[${new Date().toISOString()}] commit resync: committed prefix diverged at row ${resyncTo}; recommitting\n`;
@@ -2944,21 +3002,24 @@ export class TUI extends Container {
2944
3002
  * #committedPrefixAuditRows field doc for the three audit zones).
2945
3003
  *
2946
3004
  * auditRows tracks the byte-stable boundary; durableRows the durable snapshot
2947
- * boundary. A wholesale re-slice (full paint / shrink / geometry) re-bases
2948
- * each mark from the current frame (min(committed, boundary)). An incremental
2949
- * extend keeps a mark once a row past it has committed (mark < committed): a
2950
- * later RISE in a boundary (a table finalizing) must neither pull
2951
- * already-committed stale snapshots back under the byte-stable cap nor
2952
- * retroactively exempt forced-overflow rows already audited. durableRows is
2953
- * floored at auditRows so the exempt window can never invert.
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.
2954
3013
  */
2955
3014
  #updateCommittedAuditRows(
2956
3015
  resliced: boolean,
2957
3016
  preCommittedRows: number,
2958
3017
  preAuditRows: number,
2959
3018
  preDurableRows: number,
3019
+ preOfferRows: number,
2960
3020
  byteStableBoundary: number,
2961
3021
  durableBoundary: number,
3022
+ offerBoundary: number,
2962
3023
  hardAudited: boolean,
2963
3024
  ): void {
2964
3025
  const committed = this.#committedRows;
@@ -2975,8 +3036,18 @@ export class TUI extends Container {
2975
3036
  resliced || preDurableRows >= preCommittedRows || hardAudited
2976
3037
  ? Math.min(committed, durableBoundary)
2977
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);
2978
3048
  this.#committedPrefixAuditRows = auditRows;
2979
3049
  this.#committedPrefixDurableRows = Math.max(auditRows, durableRows);
3050
+ this.#committedPrefixOfferRows = Math.max(this.#committedPrefixDurableRows, offerRows);
2980
3051
  }
2981
3052
 
2982
3053
  /**