@oh-my-pi/pi-tui 18.0.3 → 18.0.5

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.
@@ -34,6 +34,7 @@ export type TerminalId =
34
34
  | "vscode"
35
35
  | "alacritty"
36
36
  | "warp"
37
+ | "orca"
37
38
  | "base"
38
39
  | "trueColor";
39
40
 
@@ -107,7 +108,7 @@ export class TerminalInfo {
107
108
  /** Renders the Kitty OSC 66 text-sizing protocol (scaled spans). Kitty only. */
108
109
  public readonly supportsTextSizing: boolean = false,
109
110
  /**
110
- * Hangul Compatibility Jamo (U+3131..=U+318E) cell width. Ghostty follows
111
+ * Hangul Compatibility Jamo (U+3131..=U+318E) cell width. Ghostty and Orca follow
111
112
  * UAX#11 (2 cells); Warp paints 1; "platform" keeps the OS default
112
113
  * (macOS narrow, otherwise UAX#11).
113
114
  */
@@ -493,6 +494,7 @@ const KNOWN_TERMINALS = Object.freeze({
493
494
  iterm2: new TerminalInfo("iterm2", ImageProtocol.Iterm2, true, true, NotifyProtocol.Osc9),
494
495
  vscode: new TerminalInfo("vscode", null, true, true, NotifyProtocol.Bell),
495
496
  alacritty: new TerminalInfo("alacritty", null, true, true, NotifyProtocol.Bell),
497
+ orca: new TerminalInfo("orca", null, true, false, NotifyProtocol.Bell, false, false, false, 2),
496
498
  // Warp identifies via TERM_PROGRAM=WarpTerminal and ships the Kitty graphics
497
499
  // protocol on macOS/Linux (direct placement only — no Unicode placeholders, so
498
500
  // detectKittyUnicodePlaceholdersSupport correctly excludes it). It does not
@@ -534,6 +536,7 @@ export function detectTerminalId(env: NodeJS.ProcessEnv = Bun.env): TerminalId {
534
536
  if (caseEq(TERM_PROGRAM, "vscode")) return "vscode";
535
537
  if (caseEq(TERM_PROGRAM, "alacritty")) return "alacritty";
536
538
  if (caseEq(TERM_PROGRAM, "warpterminal")) return "warp";
539
+ if (caseEq(TERM_PROGRAM, "orca")) return "orca";
537
540
  }
538
541
 
539
542
  if (TERM?.toLowerCase().includes("ghostty")) return "ghostty";
package/src/tui.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  * Minimal TUI implementation with explicit history batches.
3
3
  *
4
4
  * Two output channels: a product-owned {@link TerminalFrameProvider} returns,
5
- * per frame, an optional immutable {@link HistoryBatch} (finalized transcript
6
- * rows appended to native history exactly once, gated by a monotonic id and
5
+ * per frame, an optional immutable {@link HistoryBatch} (finalized or naturally
6
+ * emitted stable rows, or one complete replay, gated by a monotonic id and
7
7
  * acknowledgement) plus the complete mutable viewport. The writer anchors the
8
8
  * viewport directly below whatever history remains visible, diffs
9
9
  * viewport-only frames, and never infers finality from a row's position.
@@ -109,13 +109,20 @@ export interface ViewportSize {
109
109
  readonly rows: number;
110
110
  }
111
111
 
112
- /** Immutable finalized rows offered until the terminal accepts this identifier. */
112
+ /** Immutable append or complete replay offered until the terminal accepts this identifier. */
113
113
  export interface HistoryBatch {
114
114
  readonly id: number;
115
115
  readonly rows: readonly string[];
116
+ /**
117
+ * `append` (the default) adds finalized or naturally emitted rows. `replay`
118
+ * is the complete logical ledger; the writer bottom-splits it against the
119
+ * leading blank viewport and serializes the remainder plus final viewport in
120
+ * one synchronous terminal write.
121
+ */
122
+ readonly kind?: "append" | "replay";
116
123
  }
117
124
 
118
- /** One history append and the complete mutable viewport for a terminal frame. */
125
+ /** One history append or complete replay plus the mutable viewport for a terminal frame. */
119
126
  export interface TerminalFramePlan {
120
127
  readonly history?: HistoryBatch;
121
128
  readonly viewport: readonly string[];
@@ -128,7 +135,9 @@ export interface TerminalFrameProvider {
128
135
  /** Full semantic viewport used only on the transient resize buffer. */
129
136
  renderResizeFrame?(viewport: ViewportSize): readonly string[];
130
137
  /** Re-offer finalized history after a display reset or resize replay. */
131
- resetHistory?(): void;
138
+ beginHistoryReplay?(): void;
139
+ /** Force every currently eligible finalized prefix to retire before stop. */
140
+ beginHistoryFlush?(): void;
132
141
  }
133
142
 
134
143
  export interface TUIStartOptions {
@@ -640,9 +649,50 @@ export class TUI extends Container {
640
649
  #parkedViewportOffset = 0;
641
650
  // In-flight post-resize anchor probe: the stale viewport snapshot and park
642
651
  // offset captured when CSI 6n was written, plus the no-reply fallback timer.
643
- #resizeProbe: { window: readonly string[]; offset: number; timer: RenderTimer } | undefined;
644
- // Unanswered CSI 6n probes; CPR replies are consumed from input while > 0.
645
- #pendingCprReplies = 0;
652
+ #resizeProbe:
653
+ | {
654
+ window: readonly string[];
655
+ offset: number;
656
+ timer: RenderTimer;
657
+ epoch: number;
658
+ retried: boolean;
659
+ }
660
+ | undefined;
661
+ // Pre-erase viewport snapshot for the settled resize-anchor probe: the erase
662
+ // in #beginResizeAltPaint empties #providerWindow, so the probe must bound
663
+ // the anchor with the window that was actually on screen when the resize
664
+ // began (see #resolveResizeAnchor's `height - staleRows` clamp).
665
+ #resizeProbeWindow: readonly string[] = [];
666
+ #resizeProbeOffset = 0;
667
+ // Direction tracking for the current coalesced resize burst (reset when a
668
+ // plan frame commits, alongside #previousHeight). A burst containing any
669
+ // height grow invalidates the multiplexer clip model in
670
+ // #resolveResizeAnchor: the grow pulls scrollback into the pane and moves
671
+ // the parked logical row, so the net shrink no longer telescopes from
672
+ // pre-burst state.
673
+ #resizeBurstGrew = false;
674
+ #resizeBurstLastHeight: number | undefined;
675
+ // Sum of every grow step in the burst: bounds how much scrollback a
676
+ // multiplexer can have pulled down across the whole burst, including a
677
+ // shrink-then-regrow that never exceeds the pre-burst height (see the
678
+ // CPR-timeout fallback in #resolveResizeAnchor).
679
+ #resizeBurstPull = 0;
680
+ // Geometry epoch: bumped on every resize transaction entry, so each CSI 6n
681
+ // request records the geometry it was parked under.
682
+ #geometryEpoch = 0;
683
+ // CPR attribution: each request parks a distinct column (CHA) before its
684
+ // CSI 6n; the terminal processes requests serially, so every reply carries
685
+ // its own request's column. That makes attribution exact even when replies
686
+ // are dropped or arbitrarily delayed — anonymous FIFO counting cannot
687
+ // survive drops (forgetting retired requests eagerly misattributes late
688
+ // replies, remembering them forever poisons later probes with phantoms,
689
+ // and age expiry is unsound because replies carry no lifetime guarantee).
690
+ // A rewrap can only invalidate a reply's row via a width-change SIGWINCH,
691
+ // which bumps the geometry epoch and discards the reply anyway, so the
692
+ // scheme is sound on direct terminals too. Tags are never expired; a late
693
+ // reply to a dead tag is stripped and discarded by column.
694
+ #cprColumnTags = new Map<number, number>();
695
+ #cprProbeSeq = 0;
646
696
  // Prepared rows painted by the previous provider frame, for row diffing.
647
697
  #providerWindow: string[] = [];
648
698
  #previousFrameLength = 0;
@@ -1004,7 +1054,7 @@ export class TUI extends Container {
1004
1054
  if (this.#resizeProbe) {
1005
1055
  // The anchor being probed is already stale; restart the transaction.
1006
1056
  this.#cancelResizeProbe();
1007
- this.#beginResizeAltPaint();
1057
+ this.#beginResizeAltPaint(true);
1008
1058
  return;
1009
1059
  }
1010
1060
  if (this.#renderScheduler.now() < this.#suppressResizeUntil) {
@@ -1032,12 +1082,23 @@ export class TUI extends Container {
1032
1082
  }
1033
1083
  this.requestRender(true, { clearScrollback: options?.clearScrollback === true });
1034
1084
  }
1035
- /** Borrow the alternate buffer for stable, history-free resize repainting. */
1036
- #beginResizeAltPaint(): void {
1085
+ /**
1086
+ * Borrow the alternate buffer for stable, history-free resize repainting.
1087
+ * `restartingProbe` marks a transaction restarted by a SIGWINCH that
1088
+ * arrived while the settled anchor probe was in flight: the live window
1089
+ * was already stashed and emptied, so the snapshot below must be skipped
1090
+ * to keep the good stash.
1091
+ */
1092
+ #beginResizeAltPaint(restartingProbe = false): void {
1037
1093
  if (this.#altActive) {
1038
1094
  this.requestRender(true);
1039
1095
  return;
1040
1096
  }
1097
+ const burstLastHeight = this.#resizeBurstLastHeight ?? this.#previousHeight;
1098
+ if (this.terminal.rows > burstLastHeight) this.#resizeBurstGrew = true;
1099
+ this.#resizeBurstLastHeight = this.terminal.rows;
1100
+ this.#resizeBurstPull += Math.max(0, this.terminal.rows - burstLastHeight);
1101
+ this.#geometryEpoch++;
1041
1102
  if (!this.#resizeAltActive) {
1042
1103
  this.#resizeAltActive = true;
1043
1104
  setAltScreenActive(true);
@@ -1056,10 +1117,19 @@ export class TUI extends Container {
1056
1117
  // cursor-relative movement lands on the viewport's top row. On height
1057
1118
  // shrink kitty clamps the cursor instead of moving it with pushed rows,
1058
1119
  // so cursor-relative addressing would start rows late; fall back to the
1059
- // same bottom-preserving bound as resize-anchor recovery. The blank top
1060
- // row then anchors the settled CPR probe (empty window means staleRows 0).
1120
+ // same bottom-preserving bound as resize-anchor recovery. The pre-erase
1121
+ // window is stashed for the settled CPR probe: its reflowed row count
1122
+ // bounds the anchor to `height - staleRows`, so a mis-parked cursor (a
1123
+ // single-step tmux zoom re-lays the pane before SIGWINCH delivery,
1124
+ // moving the park target under us) cannot anchor the settled repaint
1125
+ // over pulled-back history rows or scroll-push the frame into
1126
+ // scrollback again.
1061
1127
  let erase = "";
1062
- if (this.#hasEverRendered && this.#providerWindow.length > 0) {
1128
+ if (!restartingProbe) {
1129
+ this.#resizeProbeWindow = this.#providerWindow;
1130
+ this.#resizeProbeOffset = this.#parkedViewportOffset;
1131
+ }
1132
+ if (this.#hasEverRendered && this.#providerWindow.length > 0 && !isInsideTerminalMultiplexer()) {
1063
1133
  if (this.terminal.rows < this.#previousHeight) {
1064
1134
  const staleRows = this.#reflowedRowCount(
1065
1135
  this.#providerWindow,
@@ -1078,6 +1148,22 @@ export class TUI extends Container {
1078
1148
  );
1079
1149
  erase = `\x1b[?25l${up > 0 ? `\x1b[${up}A` : ""}\r\x1b[J`;
1080
1150
  }
1151
+ // Both erase paths leave the cursor on the viewport's top row, so the
1152
+ // parked offset no longer applies; carrying a stale nonzero offset
1153
+ // into the probe would anchor the settled repaint above the real
1154
+ // viewport top and overwrite visible committed rows.
1155
+ this.#resizeProbeOffset = 0;
1156
+ this.#providerWindow = [];
1157
+ this.#parkedViewportOffset = 0;
1158
+ }
1159
+ if (this.#hasEverRendered && this.#providerWindow.length > 0 && isInsideTerminalMultiplexer()) {
1160
+ // Multiplexers apply the pane re-layout on their own schedule relative
1161
+ // to SIGWINCH delivery, so an immediate erase races it: with the pane
1162
+ // already re-laid the stale coordinates blank pulled-back committed
1163
+ // rows (destroying popped scrollback), and with the pane not yet
1164
+ // re-laid the erase lands on rows about to move. Skip it — the
1165
+ // settled repaint overwrites the live region at the clip-model anchor
1166
+ // and erases below it, race-free after the quiet window.
1081
1167
  this.#providerWindow = [];
1082
1168
  this.#parkedViewportOffset = 0;
1083
1169
  }
@@ -1103,14 +1189,60 @@ export class TUI extends Container {
1103
1189
  * trip against the parked cursor reports where the viewport's logical line
1104
1190
  * landed. The settled repaint waits for the reply (or a short timeout).
1105
1191
  */
1106
- #beginResizeAnchorProbe(): void {
1192
+ #beginResizeAnchorProbe(retry = false): void {
1107
1193
  this.#cancelResizeProbe();
1108
1194
  const timer = this.#renderScheduler.scheduleRender(() => {
1195
+ const probe = this.#resizeProbe;
1196
+ if (probe !== undefined && !probe.retried && (isInsideTerminalMultiplexer() || this.#resizeBurstGrew)) {
1197
+ // A CPR-less resolve is heuristic: a grow's pull span is unknown
1198
+ // on any terminal, and SIGWINCH coalescing can hide intermediate
1199
+ // grows entirely, so even an observed-monotonic multiplexer
1200
+ // shrink cannot be modeled with certainty. A dropped DSR reply
1201
+ // is a transient race — multiplexers in particular answer DSR
1202
+ // themselves — so ask once more before falling back.
1203
+ this.#beginResizeAnchorProbe(true);
1204
+ return;
1205
+ }
1109
1206
  this.#resolveResizeAnchor(undefined);
1110
1207
  }, TUI.#RESIZE_PROBE_TIMEOUT_MS);
1111
- this.#resizeProbe = { window: this.#providerWindow, offset: this.#parkedViewportOffset, timer };
1112
- this.#pendingCprReplies++;
1113
- this.terminal.write("\x1b[6n");
1208
+ this.#resizeProbe = {
1209
+ window: this.#resizeProbeWindow,
1210
+ offset: this.#resizeProbeOffset,
1211
+ timer,
1212
+ epoch: this.#geometryEpoch,
1213
+ retried: retry,
1214
+ };
1215
+ // Tags are never expired by age: a reply has no lifetime guarantee, and
1216
+ // freeing a column while its reply may still arrive would let that
1217
+ // reply match a newer tag on the reused column. Dead tags only
1218
+ // accumulate from genuinely dropped replies; a terminal that drops
1219
+ // enough of them to exhaust the span earns the timeout-only fallback.
1220
+ // Park a distinct column for this request so its reply is
1221
+ // self-identifying, then return the cursor to column 1 immediately:
1222
+ // the reply snapshots the column when the terminal processes the CSI
1223
+ // 6n, but a cursor RESTING on a nonzero column would reflow onto a
1224
+ // later visual row if a direct terminal's width later shrank below it,
1225
+ // corrupting the next probe's cursor-relative math. Columns 1-16 are
1226
+ // never used as tags: column 1 cannot be told apart from a spurious or
1227
+ // clamped reply, and modified F3 keys encode as CSI 1;<mod>R with
1228
+ // modifier codes 2-16, which is byte-identical to a CPR for row 1 on
1229
+ // those columns. A column may not be reused while its tag is live —
1230
+ // the old request's delayed reply would be attributed to the new
1231
+ // epoch — so scan for a free slot.
1232
+ const span = Math.min(30, this.terminal.columns - 16);
1233
+ if (span >= 4) {
1234
+ for (let index = 0; index < span; index++) {
1235
+ const candidate = 17 + ((this.#cprProbeSeq + index) % span);
1236
+ if (this.#cprColumnTags.has(candidate)) continue;
1237
+ this.#cprProbeSeq += index + 1;
1238
+ this.#cprColumnTags.set(candidate, this.#geometryEpoch);
1239
+ this.terminal.write(`\x1b[${candidate}G\x1b[6n\x1b[1G`);
1240
+ return;
1241
+ }
1242
+ }
1243
+ // Degenerate span or full occupancy: an untagged reply could never be
1244
+ // attributed, so no DSR is sent at all; the timeout anchors
1245
+ // conservatively on its own.
1114
1246
  }
1115
1247
 
1116
1248
  #cancelResizeProbe(): void {
@@ -1121,19 +1253,25 @@ export class TUI extends Container {
1121
1253
 
1122
1254
  /**
1123
1255
  * Anchor the settled post-resize repaint. `reportedRow` is the 0-based CPR
1124
- * row of the parked cursor (undefined = probe timed out). The viewport top
1125
- * is `min(reported - parkOffset, height - staleRows)`: terminals track the
1256
+ * row of the parked cursor (undefined = probe timed out). Direct terminals
1257
+ * use `min(reported - parkOffset, height - staleRows)`: they track the
1126
1258
  * cursor exactly through width rewrap, and the second bound reconstructs
1127
- * height-shrink scrollback pushes that leave the cursor behind (kitty clamps
1128
- * the cursor instead of scrolling it) — bottom-preserving resize guarantees
1129
- * the stale viewport ends on the last screen row whenever a push happened.
1130
- * Validated against kitty's real core in resize-anchor-recovery.test.ts.
1259
+ * height-shrink scrollback pushes that leave the cursor behind (kitty
1260
+ * clamps the cursor instead of scrolling it) — bottom-preserving resize
1261
+ * guarantees the stale viewport ends on the last screen row whenever a
1262
+ * push happened; validated against kitty's real core in
1263
+ * resize-anchor-recovery.test.ts. Multiplexers clip on height changes
1264
+ * (though they reflow on width changes), so that bound never applies:
1265
+ * monotonic shrinks use the deterministic clip model below, everything
1266
+ * else trusts the CPR directly.
1131
1267
  */
1132
1268
  #resolveResizeAnchor(reportedRow: number | undefined): void {
1133
1269
  const probe = this.#resizeProbe;
1134
1270
  if (!probe) return;
1135
1271
  probe.timer.cancel();
1136
1272
  this.#resizeProbe = undefined;
1273
+ // Column tags stay live across resolves: their replies are
1274
+ // self-identifying and discarded by tag whenever they arrive.
1137
1275
  const width = this.terminal.columns;
1138
1276
  const height = this.terminal.rows;
1139
1277
  const staleRows = this.#reflowedRowCount(probe.window, 0, probe.window.length, width);
@@ -1141,7 +1279,58 @@ export class TUI extends Container {
1141
1279
  reportedRow === undefined
1142
1280
  ? this.#providerViewportTop
1143
1281
  : reportedRow - this.#reflowedRowCount(probe.window, 0, probe.offset, width);
1144
- const top = Math.max(0, Math.min(reportedTop, height - staleRows));
1282
+ let top: number;
1283
+ if (isInsideTerminalMultiplexer()) {
1284
+ if (reportedRow !== undefined) {
1285
+ // The parked cursor's reply is exact under multiplexer clipping:
1286
+ // discards leave the cursor in place, pushes only occur after
1287
+ // everything below it is discarded (the bottom row IS the
1288
+ // attached position), and grow pull-down rides it down. It
1289
+ // therefore also reflects intermediate geometries that SIGWINCH
1290
+ // coalescing hid from the burst tracker, and always outranks the
1291
+ // clip model. The `height - staleRows` bound must NOT apply here:
1292
+ // it encodes bottom-preserving rewrap, but a multiplexer shrink
1293
+ // may have discarded stale rows below the cursor instead of
1294
+ // pushing the top ones. Frame-size clamping happens when the
1295
+ // settled plan frame is emitted.
1296
+ top = Math.max(0, reportedTop);
1297
+ } else if (height < this.#previousHeight && !this.#resizeBurstGrew) {
1298
+ // Last resort after the retry: model the clip deterministically
1299
+ // from the saved parked cursor. Rows strictly below the cursor
1300
+ // are discarded first (even non-blank ones — measured against
1301
+ // real tmux), and only the remainder of the shrink pushes top
1302
+ // rows into scrollback; across an observed burst the totals
1303
+ // telescope from pre-burst state. SIGWINCH coalescing can hide a
1304
+ // grow from this model, which is why a reply always wins above.
1305
+ const parkedRow = this.#providerViewportTop + this.#reflowedRowCount(probe.window, 0, probe.offset, width);
1306
+ const shrink = this.#previousHeight - height;
1307
+ const discardedBelow = Math.min(shrink, Math.max(0, this.#previousHeight - 1 - parkedRow));
1308
+ const pushed = Math.max(0, shrink - discardedBelow);
1309
+ top = Math.max(0, this.#providerViewportTop - pushed);
1310
+ } else {
1311
+ // CPR-less grow or reversed burst: the pre-resize top is
1312
+ // stale-low, every grow step already pulled scrollback down.
1313
+ // Anchor at the conservative upper bound — pull never exceeds
1314
+ // the burst's accumulated growth, and pushes/discards only lower
1315
+ // the top. Exact when scrollback covers the pull; when it does
1316
+ // not, the repaint lands below the real viewport and leaves
1317
+ // stale rows above rather than overwriting committed ones.
1318
+ top = Math.max(0, this.#providerViewportTop + this.#resizeBurstPull);
1319
+ }
1320
+ } else {
1321
+ // Direct terminals rewrap bottom-preserving: with `staleRows` stale
1322
+ // rows on screen the viewport top cannot exceed `height - staleRows`
1323
+ // whenever a push happened, so the bound reconstructs height-shrink
1324
+ // pushes that leave the cursor behind (kitty clamps the cursor
1325
+ // instead of scrolling it). A CPR-less grow is stale-low like the
1326
+ // multiplexer case — grow pull-down moved the real viewport — so it
1327
+ // anchors at the accumulated pull bound, still under the clamp.
1328
+ const fallbackTop =
1329
+ reportedRow === undefined && this.#resizeBurstGrew
1330
+ ? this.#providerViewportTop + this.#resizeBurstPull
1331
+ : reportedTop;
1332
+ top = Math.max(0, Math.min(fallbackTop, height - staleRows));
1333
+ }
1145
1334
  if ($flag("PI_DEBUG_REDRAW")) {
1146
1335
  const msg = `[${new Date().toISOString()}] resize anchor: size=${width}x${height} cpr=${reportedRow ?? "timeout"} park=${probe.offset} stale=${staleRows} old=${this.#providerViewportTop} top=${top}\n`;
1147
1336
  fs.appendFileSync(getDebugLogPath(), msg);
@@ -1152,14 +1341,17 @@ export class TUI extends Container {
1152
1341
  }
1153
1342
 
1154
1343
  /**
1155
- * Rows `[start, end)` of a previously painted window re-measured at `width`.
1156
- * Direct terminals rewrap on resize (a row spans ceil(cells/width) rows);
1157
- * multiplexers clip in place, so every row still spans exactly one row.
1344
+ * Rows `[start, end)` of a previously painted window re-measured at
1345
+ * `width`. Every terminal rewraps content on a width change — including
1346
+ * multiplexers: tmux clips in place on height changes only, and reflows
1347
+ * the pane (scrollback included) when the width moves, so a row painted
1348
+ * wider than the current width spans ceil(cells/width) physical rows
1349
+ * everywhere. For height-only resizes the painted rows already fit the
1350
+ * width and the count is unchanged.
1158
1351
  */
1159
1352
  #reflowedRowCount(window: readonly string[], start: number, end: number, width: number): number {
1160
1353
  const stop = Math.min(end, window.length);
1161
1354
  const from = Math.max(0, start);
1162
- if (isInsideTerminalMultiplexer()) return Math.max(0, stop - from);
1163
1355
  let rows = 0;
1164
1356
  for (let index = from; index < stop; index++) {
1165
1357
  rows += Math.max(1, Math.ceil(visibleWidth(window[index]!) / Math.max(1, width)));
@@ -1340,6 +1532,28 @@ export class TUI extends Container {
1340
1532
  this.#paintEndSequence = enabled ? PAINT_END : PAINT_END_NO_SYNC;
1341
1533
  }
1342
1534
 
1535
+ #flushHistoryBeforeStop(): void {
1536
+ const provider = this.#frameProvider;
1537
+ if (provider?.beginHistoryFlush === undefined) return;
1538
+ const width = this.terminal.columns;
1539
+ const height = this.terminal.rows;
1540
+ if (width <= 0 || height <= 0) return;
1541
+ provider.beginHistoryFlush();
1542
+ while (true) {
1543
+ this.#imageBudget.beginPass();
1544
+ const plan = provider.renderFrame({ columns: width, rows: height });
1545
+ this.#imageBudget.endPass();
1546
+ if (plan.history === undefined) return;
1547
+ let viewport = Array.from(plan.viewport);
1548
+ if (viewport.length > height) viewport = viewport.slice(0, height);
1549
+ const acceptedBefore = this.#acceptedHistoryBatchId;
1550
+ this.#emitPlanFrame(width, height, viewport, plan.history, provider);
1551
+ if (plan.history.id > acceptedBefore && this.#acceptedHistoryBatchId === acceptedBefore) {
1552
+ throw new Error("History flush did not accept the offered batch");
1553
+ }
1554
+ }
1555
+ }
1556
+
1343
1557
  stop(): void {
1344
1558
  this.#resizeSettleTimer?.cancel();
1345
1559
  this.#resizeSettleTimer = undefined;
@@ -1359,6 +1573,12 @@ export class TUI extends Container {
1359
1573
  this.#altPreviousLines = [];
1360
1574
  this.#pendingAltExit = "";
1361
1575
  }
1576
+ // A latched destructive reset (settled rebuild-mode resize, /clear) pairs
1577
+ // ED3 with a complete-ledger replay. Running that pair during stop would
1578
+ // erase native history and re-stream the whole transcript at quit; drop
1579
+ // the latch so the flush below writes only un-retired rows.
1580
+ this.#clearScrollbackOnNextRender = false;
1581
+ this.#flushHistoryBeforeStop();
1362
1582
  // Deliberately leave transmitted images in the terminal's graphics store:
1363
1583
  // placeholder cells committed to native scrollback render only while their
1364
1584
  // image data lives, so a delete-by-id here blanks every transcript image
@@ -1492,7 +1712,7 @@ export class TUI extends Container {
1492
1712
  }
1493
1713
  #prepareForcedRender(clearScrollback: boolean): void {
1494
1714
  if (clearScrollback && !this.#clearScrollbackOnNextRender) {
1495
- this.#frameProvider?.resetHistory?.();
1715
+ this.#frameProvider?.beginHistoryReplay?.();
1496
1716
  if (TERMINAL.imageProtocol === ImageProtocol.Kitty) this.#imageBudget.forgetTransmitted();
1497
1717
  }
1498
1718
  this.#clearScrollbackOnNextRender ||= clearScrollback;
@@ -1568,12 +1788,39 @@ export class TUI extends Container {
1568
1788
  // Consume CPR replies (CSI row;col R) while an anchor probe is unanswered;
1569
1789
  // they are terminal reports, never keystrokes, and must not reach the
1570
1790
  // focused component.
1571
- while (this.#pendingCprReplies > 0) {
1572
- const match = data.match(/\x1b\[(\d+);(\d+)R/);
1791
+ let searchFrom = 0;
1792
+ while (this.#cprColumnTags.size > 0) {
1793
+ const match = data.slice(searchFrom).match(/\x1b\[(\d+);(\d+)R/);
1573
1794
  if (!match || match.index === undefined) break;
1574
- this.#pendingCprReplies--;
1575
- this.#resolveResizeAnchor(Number(match[1]) - 1);
1576
- data = data.slice(0, match.index) + data.slice(match.index + match[0].length);
1795
+ const row = Number(match[1]);
1796
+ const column = Number(match[2]);
1797
+ if (!this.#cprColumnTags.has(column) && row === 1 && column >= 2) {
1798
+ // CSI 1;<mod>R with no live tag is modified F3: the modifier
1799
+ // parameter spans 2-256 once the lock-state bits (caps 64,
1800
+ // num 128) and hyper/meta are included, so no practical tag
1801
+ // range escapes it entirely. Anything row-1 we did not tag is
1802
+ // treated as a keystroke and left for the focused component;
1803
+ // a tagged column hit by a hyper/meta-modified F3 (params
1804
+ // 17-64, practically unused) is still gated by the epoch check
1805
+ // below.
1806
+ searchFrom += match.index + match[0].length;
1807
+ continue;
1808
+ }
1809
+ if (this.#cprColumnTags.has(column)) {
1810
+ // Column-tagged reply: exact attribution. Resolve only when its
1811
+ // request was parked under the active probe's geometry; a reply
1812
+ // from an older epoch is stale and discarded.
1813
+ const tagEpoch = this.#cprColumnTags.get(column);
1814
+ this.#cprColumnTags.delete(column);
1815
+ const probe = this.#resizeProbe;
1816
+ if (probe !== undefined && tagEpoch === probe.epoch) {
1817
+ this.#resolveResizeAnchor(Number(match[1]) - 1);
1818
+ }
1819
+ }
1820
+ // Other unknown-column replies while expecting tagged ones are our
1821
+ // requests answered with a clamped or mangled column: strip and
1822
+ // discard; the probe timeout covers recovery.
1823
+ data = data.slice(0, searchFrom + match.index) + data.slice(searchFrom + match.index + match[0].length);
1577
1824
  }
1578
1825
  if (data.length === 0) return;
1579
1826
  // Ctrl+C/Esc use app-level double-press windows. Give those gestures one
@@ -1983,7 +2230,7 @@ export class TUI extends Container {
1983
2230
  return;
1984
2231
  }
1985
2232
  const provider = this.#frameProvider;
1986
- if (!provider?.resetHistory) return;
2233
+ if (!provider?.beginHistoryReplay) return;
1987
2234
  this.#resizeReplaySize = size;
1988
2235
  if (this.#clearScrollbackOnNextRender) {
1989
2236
  this.#forceViewportRepaintOnNextRender = true;
@@ -1993,15 +2240,14 @@ export class TUI extends Container {
1993
2240
  this.#prepareForcedRender(true);
1994
2241
  return;
1995
2242
  }
1996
- provider.resetHistory();
2243
+ provider.beginHistoryReplay();
1997
2244
  this.#forceViewportRepaintOnNextRender = true;
1998
2245
  }
1999
2246
 
2000
2247
  /**
2001
- * Physical write transaction: append an unacknowledged history batch above
2002
- * the anchored mutable viewport, repaint the viewport, and erase stale rows
2003
- * below. Appending K rows scrolls at most K rows off the top — the oldest
2004
- * visible history — and the viewport anchor follows the retained history.
2248
+ * Physical write transaction: append an ordinary batch, or bottom-split one
2249
+ * complete replay into a history remainder and final viewport, then serialize
2250
+ * the whole result in one terminal write before acknowledgement.
2005
2251
  */
2006
2252
  #emitPlanFrame(
2007
2253
  width: number,
@@ -2015,11 +2261,28 @@ export class TUI extends Container {
2015
2261
  while (viewport.length < height) viewport.push("");
2016
2262
  viewport = this.#compositeOverlaysIntoWindow(viewport, width, height);
2017
2263
  }
2018
- const markers = this.#extractCursorMarkers(viewport);
2019
- const prepared = this.#prepareLinesArray(viewport, width);
2020
2264
  const history = offered !== undefined && offered.id > this.#acceptedHistoryBatchId ? offered : undefined;
2021
2265
  if (offered !== undefined && offered.id <= this.#acceptedHistoryBatchId) provider?.acknowledgeHistory(offered.id);
2022
- const historyRows = history?.rows ?? [];
2266
+
2267
+ let historyRows = history?.rows ?? [];
2268
+ let replayViewportRows = 0;
2269
+ if (history?.kind === "replay") {
2270
+ // Providers may omit unused leading rows from a short viewport. Make
2271
+ // that logical space explicit before the bottom-first replay split.
2272
+ while (viewport.length < height) viewport.unshift("");
2273
+ let leadingBlankRows = 0;
2274
+ while (leadingBlankRows < viewport.length && !/\S/.test(viewport[leadingBlankRows]!)) {
2275
+ leadingBlankRows++;
2276
+ }
2277
+ const moved = Math.min(historyRows.length, leadingBlankRows);
2278
+ if (moved > 0) {
2279
+ viewport = [...historyRows.slice(historyRows.length - moved), ...viewport.slice(moved)];
2280
+ historyRows = historyRows.slice(0, historyRows.length - moved);
2281
+ replayViewportRows = moved;
2282
+ }
2283
+ }
2284
+ const markers = this.#extractCursorMarkers(viewport);
2285
+ const prepared = this.#prepareLinesArray(viewport, width);
2023
2286
  const preparedHistory = this.#prepareLinesArray(historyRows, width);
2024
2287
  const rows = prepared.length;
2025
2288
  // Destructive reset (session replace, /tree, explicit clear, or a settled
@@ -2051,7 +2314,14 @@ export class TUI extends Container {
2051
2314
  } else {
2052
2315
  this.#imageBudget.takePurgeIds();
2053
2316
  }
2054
- if (destructiveReset) buffer += "\x1b[H\x1b[3J\x1b[2J";
2317
+ // ED2 MUST precede ED3: tmux implements ED2 by scrolling the live screen
2318
+ // into pane history (so cleared content stays reachable), so erasing
2319
+ // history first would let ED2 refill it with a copy of the old screen —
2320
+ // which the replay then repaints, duplicating one full frame per reset.
2321
+ // ED2-then-ED3 clears the screen, then wipes history including that
2322
+ // push. On xterm-family terminals the two erases are independent and
2323
+ // the order is irrelevant.
2324
+ if (destructiveReset) buffer += "\x1b[H\x1b[2J\x1b[3J";
2055
2325
  const diffable =
2056
2326
  geometryStable &&
2057
2327
  historyRows.length === 0 &&
@@ -2112,6 +2382,8 @@ export class TUI extends Container {
2112
2382
  }
2113
2383
  if (newTop + rows < height) buffer += `\x1b[${newTop + rows + 1};1H\x1b[J`;
2114
2384
  }
2385
+ const mutableTop = newTop + replayViewportRows;
2386
+ const mutablePrepared = replayViewportRows > 0 ? prepared.slice(replayViewportRows) : prepared;
2115
2387
  const marker = markers[0];
2116
2388
  const target =
2117
2389
  marker !== undefined && rows > 0
@@ -2119,23 +2391,26 @@ export class TUI extends Container {
2119
2391
  : null;
2120
2392
  if (target) {
2121
2393
  buffer += `\x1b[${target.row + 1};${target.col + 1}H${target.visible ? "\x1b[?25h" : "\x1b[?25l"}`;
2122
- this.#parkedViewportOffset = Math.max(0, target.row - newTop);
2394
+ this.#parkedViewportOffset = Math.max(0, target.row - mutableTop);
2123
2395
  } else {
2124
2396
  // Park the hidden cursor on the viewport's top row: terminals keep the
2125
2397
  // cursor attached to its logical line through resize reflow, so the
2126
2398
  // post-resize anchor probe can recover where the viewport landed.
2127
- buffer += `\x1b[?25l\x1b[${newTop + 1};1H`;
2399
+ buffer += `\x1b[?25l\x1b[${mutableTop + 1};1H`;
2128
2400
  this.#parkedViewportOffset = 0;
2129
2401
  }
2130
2402
  buffer += this.#paintEndSequence;
2131
2403
  this.terminal.write(buffer);
2132
2404
  if (target) this.#recordHardwareCursorState(target);
2133
2405
  else this.#recordHardwareCursorHidden();
2134
- this.#providerWindow = prepared;
2135
- this.#providerViewportTop = newTop;
2406
+ this.#providerWindow = mutablePrepared;
2407
+ this.#providerViewportTop = mutableTop;
2136
2408
  this.#previousWidth = width;
2137
2409
  this.#previousHeight = height;
2138
- this.#previousFrameLength = rows;
2410
+ this.#resizeBurstGrew = false;
2411
+ this.#resizeBurstLastHeight = undefined;
2412
+ this.#resizeBurstPull = 0;
2413
+ this.#previousFrameLength = mutablePrepared.length;
2139
2414
  this.#clearScrollbackOnNextRender = false;
2140
2415
  this.#forceViewportRepaintOnNextRender = false;
2141
2416
  this.#hasEverRendered = true;
@@ -2143,10 +2418,9 @@ export class TUI extends Container {
2143
2418
  if (history !== undefined) {
2144
2419
  this.#acceptedHistoryBatchId = history.id;
2145
2420
  provider?.acknowledgeHistory(history.id);
2146
- // Draining is one batch per frame; the provider may hold further
2147
- // finalized prefixes (large resumed transcripts). Pump the next frame
2148
- // instead of waiting for an unrelated render request.
2149
- this.requestRender();
2421
+ // Normal retirement may hold another ordered batch. Replay is always
2422
+ // complete, so pumping it would create a second visible redraw/write.
2423
+ if (history.kind !== "replay") this.requestRender();
2150
2424
  }
2151
2425
  }
2152
2426