@oh-my-pi/pi-tui 17.4.2 → 18.0.0

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/src/terminal.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { dlopen, FFIType, ptr } from "bun:ffi";
2
2
  import * as fs from "node:fs";
3
+ import { TtyWriter } from "@oh-my-pi/pi-natives";
3
4
  import {
4
5
  $env,
5
6
  isBunTestRuntime,
@@ -377,11 +378,37 @@ export function emergencyTerminalRestore(): void {
377
378
  }
378
379
  /** Terminal-reported appearance (dark/light mode). */
379
380
  export type TerminalAppearance = "dark" | "light";
381
+ /** Options for {@link Terminal.start}. */
382
+ export interface TerminalStartOptions {
383
+ /**
384
+ * Paint-only start: skip raw mode, stdin ownership, and every probe that
385
+ * elicits a response on stdin. The host tty keeps cooked-mode line editing
386
+ * (kernel echo lands at the hardware cursor), and typed bytes stay queued
387
+ * in the kernel until {@link Terminal.enableInput} takes ownership and
388
+ * replays them through `onInput`. Used for the startup prepaint so typing
389
+ * echoes even while module loading blocks the event loop.
390
+ */
391
+ deferInput?: boolean;
392
+ }
380
393
  /** Identity of an accepted explicit terminal appearance refresh request. */
381
394
  export type TerminalAppearanceRequestToken = number;
382
395
  export interface Terminal {
383
396
  // Start the terminal with input, resize, and host-disconnect handlers.
384
- start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void;
397
+ start(
398
+ onInput: (data: string) => void,
399
+ onResize: () => void,
400
+ onDisconnect?: () => void,
401
+ options?: TerminalStartOptions,
402
+ ): void;
403
+
404
+ /**
405
+ * Take ownership of stdin after a `deferInput` start: enable raw mode,
406
+ * attach input handlers, and run the capability probes start() skipped.
407
+ * Bytes the user typed in cooked mode meanwhile are replayed through
408
+ * `onInput`. No-op when input was never deferred. Optional so custom
409
+ * Terminals built against older pi-tui versions keep working.
410
+ */
411
+ enableInput?(): void;
385
412
 
386
413
  // Stop the terminal and restore state
387
414
  stop(): void;
@@ -400,6 +427,14 @@ export interface Terminal {
400
427
  // Get terminal dimensions
401
428
  get columns(): number;
402
429
  get rows(): number;
430
+ /**
431
+ * Output bytes accepted but not yet delivered to the terminal, when the
432
+ * implementation can report it. The renderer skips composing new frames
433
+ * while this backlog is deep, so a slow terminal receives only fresh
434
+ * frames instead of a queue of stale ones. Optional so custom Terminals
435
+ * built against older pi-tui versions keep working.
436
+ */
437
+ readonly pendingOutputBytes?: number;
403
438
 
404
439
  // Whether Kitty keyboard protocol is active
405
440
  get kittyProtocolActive(): boolean;
@@ -539,6 +574,8 @@ export class ProcessTerminal implements Terminal {
539
574
  #wasRaw = false;
540
575
  #inputHandler?: (data: string) => void;
541
576
  #resizeHandler?: () => void;
577
+ /** True between a `deferInput` start() and enableInput(). */
578
+ #inputDeferred = false;
542
579
  #stdoutResizeListener?: () => void;
543
580
  #kittyProtocolActive = false;
544
581
  #kittyEnableSeq: string | null = null;
@@ -578,6 +615,12 @@ export class ProcessTerminal implements Terminal {
578
615
  // and the writable buffer grows without bound as cosmetic frames pile up.
579
616
  // See OutputBacklogGuard and #6854.
580
617
  #stdoutBacklog = new OutputBacklogGuard();
618
+ // Off-thread output pump (unix TTYs): Bun's `process.stdout.write` blocks
619
+ // the event loop until the terminal drains, so a slow/occluded emulator
620
+ // froze the whole TUI for the duration of a multi-MB repaint. The pump
621
+ // enqueues frames and performs the blocking write(2) on its own thread;
622
+ // `pendingOutputBytes` exposes the backlog for render-side frame skipping.
623
+ #outputPump?: TtyWriter;
581
624
  #stdoutDrainArmed = false;
582
625
  #stdoutDrainHandler = () => {
583
626
  this.#stdoutDrainArmed = false;
@@ -697,7 +740,12 @@ export class ProcessTerminal implements Terminal {
697
740
  this.#privateModeCallbacks.push(callback);
698
741
  }
699
742
 
700
- start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void {
743
+ start(
744
+ onInput: (data: string) => void,
745
+ onResize: () => void,
746
+ onDisconnect?: () => void,
747
+ options?: TerminalStartOptions,
748
+ ): void {
701
749
  this.#inputHandler = onInput;
702
750
  this.#resizeHandler = onResize;
703
751
  this.#disconnectHandler = onDisconnect;
@@ -715,12 +763,65 @@ export class ProcessTerminal implements Terminal {
715
763
  // Register for emergency cleanup
716
764
  activeTerminal = this;
717
765
  terminalEverStarted = true;
766
+ // Own the blocking write(2) on a pump thread (unix TTYs only). A stale
767
+ // prebuilt natives module without the export falls back to direct writes.
768
+ // Test suites spy on `process.stdout.write` with a faked isTTY, so the
769
+ // pump stays off under `bun test` — same philosophy as isTerminalHeadless.
770
+ if (process.platform !== "win32" && process.stdout.isTTY && !isBunTestRuntime() && !this.#outputPump) {
771
+ try {
772
+ this.#outputPump = new TtyWriter(1);
773
+ } catch (err) {
774
+ logger.debug("tty output pump unavailable; using direct stdout writes", { err: String(err) });
775
+ }
776
+ }
718
777
 
719
778
  // Keep unmanaged fd-2 writes (macOS libmalloc/framework diagnostics) off
720
779
  // the viewport while we own the terminal; released in stop(). See
721
780
  // stderr-guard in pi-utils (mirrors openai/codex#24459).
722
781
  suppressTerminalStderr();
723
782
 
783
+ // Set up resize handler immediately. The OS refreshes process.stdout
784
+ // dimensions before firing `resize`, so it is authoritative for geometry:
785
+ // reconcile any stale cached DEC 2048 report before notifying the renderer.
786
+ this.#stdoutResizeListener = () => {
787
+ // Conservative: some hosts reset modes across a resize/reattach, so
788
+ // re-establish cursor visibility on the next explicit call.
789
+ this.#cursorVisible = undefined;
790
+ this.#reconcileInBandGeometryOnResize();
791
+ this.#resizeHandler?.();
792
+ };
793
+ process.stdout.on("resize", this.#stdoutResizeListener);
794
+
795
+ // Refresh terminal dimensions - they may be stale after suspend/resume
796
+ // (SIGWINCH is lost while process is stopped). Unix only.
797
+ if (process.platform !== "win32") {
798
+ process.kill(process.pid, "SIGWINCH");
799
+ }
800
+
801
+ setHangulCompatibilityJamoWidth(TERMINAL.hangulJamoWidth);
802
+
803
+ if (options?.deferInput) {
804
+ this.#inputDeferred = true;
805
+ return;
806
+ }
807
+ this.#attachInput();
808
+ }
809
+
810
+ enableInput(): void {
811
+ if (!this.#inputDeferred) return;
812
+ this.#inputDeferred = false;
813
+ if (this.#headless || this.#dead) return;
814
+ this.#attachInput();
815
+ }
816
+
817
+ /**
818
+ * Own stdin: raw mode, input listeners, and the capability probes that
819
+ * elicit stdin responses. Split from start() so a `deferInput` prepaint can
820
+ * leave the tty in cooked mode (kernel echo + line editing) while startup
821
+ * module loading blocks the event loop, then adopt the kernel-buffered
822
+ * keystrokes here once the app can process them.
823
+ */
824
+ #attachInput(): void {
724
825
  // A multiplexer or SSH disconnect can leave isTTY true after its pty has
725
826
  // been revoked. Raw mode is then impossible, so take the normal terminal
726
827
  // disconnect path rather than letting Bun abort startup with EIO.
@@ -751,24 +852,6 @@ export class ProcessTerminal implements Terminal {
751
852
  // See #6374.
752
853
  this.#safeWrite("\x1b[?1l\x1b>");
753
854
 
754
- // Set up resize handler immediately. The OS refreshes process.stdout
755
- // dimensions before firing `resize`, so it is authoritative for geometry:
756
- // reconcile any stale cached DEC 2048 report before notifying the renderer.
757
- this.#stdoutResizeListener = () => {
758
- // Conservative: some hosts reset modes across a resize/reattach, so
759
- // re-establish cursor visibility on the next explicit call.
760
- this.#cursorVisible = undefined;
761
- this.#reconcileInBandGeometryOnResize();
762
- this.#resizeHandler?.();
763
- };
764
- process.stdout.on("resize", this.#stdoutResizeListener);
765
-
766
- // Refresh terminal dimensions - they may be stale after suspend/resume
767
- // (SIGWINCH is lost while process is stopped). Unix only.
768
- if (process.platform !== "win32") {
769
- process.kill(process.pid, "SIGWINCH");
770
- }
771
-
772
855
  // On Windows, enable ENABLE_VIRTUAL_TERMINAL_INPUT so the console sends
773
856
  // VT escape sequences (e.g. \x1b[Z for Shift+Tab) instead of raw console
774
857
  // events that lose modifier information. Must run after setRawMode(true)
@@ -781,8 +864,6 @@ export class ProcessTerminal implements Terminal {
781
864
  // Explicit probes are safe only after their response parser and stdin
782
865
  // data handler are installed. Keep this false throughout temporary stops.
783
866
  this.#active = true;
784
- setHangulCompatibilityJamoWidth(TERMINAL.hangulJamoWidth);
785
-
786
867
  // Query terminal background color via OSC 11 for dark/light detection.
787
868
  // Uses DA1 (Primary Device Attributes) as a sentinel: terminals process
788
869
  // sequences in order, so if DA1 arrives before OSC 11 response,
@@ -1530,6 +1611,7 @@ export class ProcessTerminal implements Terminal {
1530
1611
  stop(): void {
1531
1612
  // Suppress observer/timer callbacks before any teardown can yield or throw.
1532
1613
  this.#active = false;
1614
+ this.#inputDeferred = false;
1533
1615
  if (this.#headless) return;
1534
1616
  // Unregister from emergency cleanup
1535
1617
  if (activeTerminal === this) {
@@ -1649,6 +1731,13 @@ export class ProcessTerminal implements Terminal {
1649
1731
  }
1650
1732
  this.#stdoutBacklog.reset();
1651
1733
  this.#resizeHandler = undefined;
1734
+ // Flush the restore sequences enqueued above (bounded — a stalled PTY
1735
+ // must not wedge exit), then retire the pump. Later writes (emergency
1736
+ // restore's showCursor) fall back to direct stdout writes.
1737
+ if (this.#outputPump) {
1738
+ this.#outputPump.stop(1000);
1739
+ this.#outputPump = undefined;
1740
+ }
1652
1741
 
1653
1742
  // Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
1654
1743
  // re-interpreted after raw mode is disabled. This fixes a race condition
@@ -1724,6 +1813,23 @@ export class ProcessTerminal implements Terminal {
1724
1813
  if (!process.stdout.isTTY) return;
1725
1814
  this.#ensureStdoutErrorHandler();
1726
1815
  this.#trackCursorVisibility(data);
1816
+ const pump = this.#outputPump;
1817
+ if (pump) {
1818
+ if (pump.dead) {
1819
+ this.#markTerminalDisconnected("stdout failed; output pump died");
1820
+ return;
1821
+ }
1822
+ try {
1823
+ // Same stalled-consumer bound as the stream path (#6854): a PTY reader
1824
+ // that never drains must tear the terminal down, not grow the queue.
1825
+ if (pump.write(data) > MAX_STDOUT_BACKLOG_BYTES) {
1826
+ this.#markTerminalDisconnected("stdout backlog exceeded cap; PTY consumer stalled");
1827
+ }
1828
+ } catch (err) {
1829
+ this.#markTerminalDisconnected("stdout failed", err);
1830
+ }
1831
+ return;
1832
+ }
1727
1833
  // A console-sharing child process may have flipped the console codepage
1728
1834
  // away from UTF-8; repair it before any bytes hit WriteFile so no frame
1729
1835
  // is ever translated through an OEM codepage. See ensureWindowsConsoleUtf8.
@@ -1771,6 +1877,11 @@ export class ProcessTerminal implements Terminal {
1771
1877
  if (this.#inBandResizeActive && this.#reportedColumns) return this.#reportedColumns;
1772
1878
  return process.stdout.columns || Number(Bun.env.COLUMNS) || 80;
1773
1879
  }
1880
+ get pendingOutputBytes(): number {
1881
+ if (this.#outputPump) return this.#outputPump.pending();
1882
+ // Stream fallback: bytes queued past the high-water mark by refused writes.
1883
+ return process.stdout.writableLength ?? 0;
1884
+ }
1774
1885
 
1775
1886
  get rows(): number {
1776
1887
  if (this.#inBandResizeActive && this.#reportedRows) return this.#reportedRows;
package/src/tui.ts CHANGED
@@ -117,6 +117,12 @@ export interface TUIOptions {
117
117
  export interface TUIStartOptions {
118
118
  /** Clear saved native scrollback before the first paint. */
119
119
  clearScrollback?: boolean;
120
+ /**
121
+ * Paint without owning stdin: the terminal stays in cooked mode (kernel
122
+ * echo + line editing at the hardware cursor) until {@link TUI.enableInput}
123
+ * switches to raw input and replays the kernel-buffered keystrokes.
124
+ */
125
+ deferInput?: boolean;
120
126
  }
121
127
 
122
128
  const DEFAULT_RENDER_SCHEDULER: RenderScheduler = {
@@ -374,6 +380,27 @@ export interface RenderRequestOptions {
374
380
  clearScrollback?: boolean;
375
381
  }
376
382
 
383
+ /**
384
+ * What a settled in-place width resize (multiplexer pane or an in-place-latched
385
+ * direct terminal) does to native scrollback, which the host rewrapped at the
386
+ * old width:
387
+ * - `append`: replay the transcript at the current width below the old-wrap
388
+ * history — one fresh copy per settled resize, nothing destroyed.
389
+ * - `rebuild`: clear native history first (ED3) and replay — history holds the
390
+ * transcript exactly once at the current width. Requires a host that honors
391
+ * an inner ED3 (tmux does; GNU screen ignores it, degrading to `append`),
392
+ * and erases pre-session pane history.
393
+ * - `preserve`: repaint the viewport only — zero history growth; scrollback
394
+ * keeps the old-width wrap until content next scrolls off.
395
+ *
396
+ * The raw engine defaults to `preserve` (append-only native scrollback, the
397
+ * engine's baseline contract); `PI_TUI_RESIZE_SCROLLBACK` overrides that
398
+ * initial value. The coding agent applies its `tui.resizeScrollback` setting
399
+ * (default `append`) on top at startup, so interactive sessions refresh
400
+ * stale-width history out of the box.
401
+ */
402
+ export type ResizeScrollbackMode = "rebuild" | "append" | "preserve";
403
+
377
404
  /** Type guard to check if a component implements Focusable */
378
405
  export function isFocusable(component: Component | null): component is Component & Focusable {
379
406
  return component !== null && "focused" in component;
@@ -720,12 +747,18 @@ export class Container
720
747
  }
721
748
  for (let leadingIndex = 0; leadingIndex < marker.leading.length; leadingIndex++) {
722
749
  const captured = marker.leading[leadingIndex]!;
723
- const currentRows = this.#memoChildLines[leadingIndex]!;
750
+ // Resolution runs only inside a width epoch, where a leading child's
751
+ // physical row count legitimately changes with the new wrap — comparing
752
+ // it to the captured count conflates reflow with mutation and fails
753
+ // resolution for every wrapping revisionless child. Identity plus the
754
+ // width-independent revision (when the component reports one) is the
755
+ // stability proof; a revisionless leading child that mutated in the
756
+ // settle window degrades to the accepted stale-history tradeoff, the
757
+ // same as any off-window mutation of committed rows.
724
758
  if (
725
759
  this.#memoChildren[leadingIndex] !== captured.component ||
726
- (captured.revision === undefined
727
- ? currentRows.length !== captured.rowCount
728
- : getNativeScrollbackWidthEpochRevision(captured.component) !== captured.revision)
760
+ (captured.revision !== undefined &&
761
+ getNativeScrollbackWidthEpochRevision(captured.component) !== captured.revision)
729
762
  ) {
730
763
  return undefined;
731
764
  }
@@ -1207,6 +1240,18 @@ export class TUI extends Container {
1207
1240
  * feels dead to the user and no longer justifies further CPU savings.
1208
1241
  */
1209
1242
  static readonly #MAX_ADAPTIVE_RENDER_MS = 200;
1243
+ /**
1244
+ * Output backpressure gate. While the terminal still owes more than this
1245
+ * many bytes, composing another frame would only queue a stale paint
1246
+ * behind the backlog — and once the kernel PTY buffer is full, handing the
1247
+ * runtime more bytes degrades into thread-blocking writes. Defer the
1248
+ * render (keeping its forced/clear-scrollback intent) and retry shortly;
1249
+ * the eventual frame composes the latest component state, so a slow
1250
+ * terminal receives only fresh frames instead of every intermediate one.
1251
+ */
1252
+ static readonly #MAX_PENDING_OUTPUT_BYTES = 256 * 1024;
1253
+ /** Retry cadence while the output backlog gate is holding renders back. */
1254
+ static readonly #OUTPUT_BACKLOG_RETRY_MS = 10;
1210
1255
  #inputRenderGraceUntilMs = 0;
1211
1256
  // Pane-reflow settle window for tmux/screen/zellij. The host process gets
1212
1257
  // SIGWINCH (and `process.stdout` already reports the new geometry) before
@@ -1373,6 +1418,20 @@ export class TUI extends Container {
1373
1418
  #hasEverRendered = false;
1374
1419
  #scrollbackRebuildEnabled =
1375
1420
  Bun.env.PI_TUI_SCROLLBACK_REBUILD === "1" || Bun.env.PI_TUI_SCROLLBACK_REBUILD === "true";
1421
+ #resizeScrollbackMode: ResizeScrollbackMode = TUI.#initialResizeScrollbackMode();
1422
+ static #initialResizeScrollbackMode(): ResizeScrollbackMode {
1423
+ const raw = Bun.env.PI_TUI_RESIZE_SCROLLBACK;
1424
+ return raw === "rebuild" || raw === "preserve" || raw === "append" ? raw : "preserve";
1425
+ }
1426
+ // A width epoch settled while a visible overlay froze commits, so the
1427
+ // resize-scrollback refresh could not run. Consumed by the first uncovered
1428
+ // authoritative normal-screen render so the stale old-width history is
1429
+ // repaired even if no further resize arrives. Any full paint clears it,
1430
+ // including one that fires while an overlay is still visible (session
1431
+ // replace, divergence rebuild): a full paint re-emits the committed prefix
1432
+ // from the recomposed current-width frame, which is exactly the refresh
1433
+ // this latch is waiting for — it supersedes the pending replay.
1434
+ #resizeScrollbackReplayPending = false;
1376
1435
  // Set by the terminal resize callback; consumed by the next render. A resize
1377
1436
  // event invalidates the committed screen even when the dimensions net out
1378
1437
  // unchanged by render time (e.g. a 6→4→6 round trip coalesced into one frame
@@ -1421,6 +1480,8 @@ export class TUI extends Container {
1421
1480
  // {@link #resizeRepaintsInPlace} routes resizes through the in-place path.
1422
1481
  #altToggleResizesInPlace = false;
1423
1482
  #stopped = false;
1483
+ /** True between a `deferInput` start() and enableInput(). */
1484
+ #inputDeferred = false;
1424
1485
  // Always-on event-loop lag probe. The high default threshold keeps it quiet;
1425
1486
  // it only logs `ui.loop-blocked` (with the current loop phase) when a frame
1426
1487
  // budget is genuinely starved. Armed in start(), disarmed in stop().
@@ -1551,11 +1612,17 @@ export class TUI extends Container {
1551
1612
  for (let index = 0; index < marker.leading.length; index++) {
1552
1613
  const captured = marker.leading[index]!;
1553
1614
  const current = this.#frameSegments[index];
1615
+ // Width epoch context: a leading root child's row count legitimately
1616
+ // changes with the new wrap (the startup banner and warning texts wrap
1617
+ // differently per width), so a captured-vs-current row count comparison
1618
+ // conflates reflow with mutation and forces the conservative replay on
1619
+ // every width change. Identity plus the width-independent revision
1620
+ // (when reported) proves stability; a revisionless leading child that
1621
+ // mutated inside the settle window degrades to the accepted
1622
+ // stale-history tradeoff instead of failing resolution.
1554
1623
  if (
1555
1624
  current?.component !== captured.component ||
1556
- (captured.revision === undefined
1557
- ? current.rowCount !== captured.rowCount
1558
- : current.widthEpochRevision !== captured.revision)
1625
+ (captured.revision !== undefined && current.widthEpochRevision !== captured.revision)
1559
1626
  ) {
1560
1627
  return undefined;
1561
1628
  }
@@ -1973,6 +2040,22 @@ export class TUI extends Container {
1973
2040
  this.#scrollbackRebuildEnabled = enabled;
1974
2041
  }
1975
2042
 
2043
+ /**
2044
+ * Get how a settled in-place width resize refreshes native scrollback.
2045
+ */
2046
+ getResizeScrollback(): ResizeScrollbackMode {
2047
+ return this.#resizeScrollbackMode;
2048
+ }
2049
+
2050
+ /**
2051
+ * Set how a settled in-place width resize refreshes native scrollback
2052
+ * (see {@link ResizeScrollbackMode}; engine default `preserve` — the coding
2053
+ * agent applies its `tui.resizeScrollback` setting, default `append`).
2054
+ */
2055
+ setResizeScrollback(mode: ResizeScrollbackMode): void {
2056
+ this.#resizeScrollbackMode = mode;
2057
+ }
2058
+
1976
2059
  getShowHardwareCursor(): boolean {
1977
2060
  return this.#showHardwareCursor;
1978
2061
  }
@@ -2134,6 +2217,7 @@ export class TUI extends Container {
2134
2217
 
2135
2218
  start(options?: TUIStartOptions): void {
2136
2219
  this.#stopped = false;
2220
+ this.#inputDeferred = options?.deferInput === true;
2137
2221
  this.#watchdog.start();
2138
2222
  this.#ghosttyInitialImageDelayDone = false;
2139
2223
  this.#ghosttyImageReadyAtMs = this.#renderScheduler.now() + TUI.#GHOSTTY_INITIAL_IMAGE_DELAY_MS;
@@ -2216,6 +2300,7 @@ export class TUI extends Container {
2216
2300
  });
2217
2301
  },
2218
2302
  () => this.stop(),
2303
+ { deferInput: this.#inputDeferred },
2219
2304
  );
2220
2305
  if (this.#stopped) return;
2221
2306
  for (const listener of this.#startListeners) {
@@ -2227,9 +2312,24 @@ export class TUI extends Container {
2227
2312
  }
2228
2313
  this.terminal.hideCursor();
2229
2314
  this.#recordHardwareCursorHidden();
2315
+ if (!this.#inputDeferred) {
2316
+ this.#querySixelSupport();
2317
+ this.#queryCellSize();
2318
+ }
2319
+ this.requestRender(true, { clearScrollback: options?.clearScrollback === true });
2320
+ }
2321
+ /**
2322
+ * Take ownership of stdin after a `deferInput` start: raw mode, input
2323
+ * handlers, and the response-eliciting capability probes start() skipped.
2324
+ * Keystrokes typed in cooked mode meanwhile arrive through the normal input
2325
+ * path. Idempotent; no-op when input was never deferred.
2326
+ */
2327
+ enableInput(): void {
2328
+ if (!this.#inputDeferred || this.#stopped) return;
2329
+ this.#inputDeferred = false;
2330
+ this.terminal.enableInput?.();
2230
2331
  this.#querySixelSupport();
2231
2332
  this.#queryCellSize();
2232
- this.requestRender(true, { clearScrollback: options?.clearScrollback === true });
2233
2333
  }
2234
2334
 
2235
2335
  addStartListener(listener: StartListener): () => void {
@@ -2925,6 +3025,18 @@ export class TUI extends Container {
2925
3025
  }
2926
3026
  }
2927
3027
 
3028
+ #runScheduledRender = (): void => {
3029
+ this.#renderTimer = undefined;
3030
+ if (this.#stopped || !this.#renderRequested) {
3031
+ return;
3032
+ }
3033
+ this.#renderRequested = false;
3034
+ this.#executeRender();
3035
+ if (this.#renderRequested) {
3036
+ this.#scheduleRender();
3037
+ }
3038
+ };
3039
+
2928
3040
  #scheduleRender(): void {
2929
3041
  if (this.#stopped || this.#renderTimer || !this.#renderRequested) {
2930
3042
  return;
@@ -2949,17 +3061,7 @@ export class TUI extends Container {
2949
3061
  const adaptiveDelay = Math.max(0, adaptiveFloor - elapsed);
2950
3062
  const inputGraceDelay = Math.max(0, this.#inputRenderGraceUntilMs - now);
2951
3063
  const delay = Math.max(cadenceDelay, adaptiveDelay, inputGraceDelay);
2952
- this.#renderTimer = this.#renderScheduler.scheduleRender(() => {
2953
- this.#renderTimer = undefined;
2954
- if (this.#stopped || !this.#renderRequested) {
2955
- return;
2956
- }
2957
- this.#renderRequested = false;
2958
- this.#executeRender();
2959
- if (this.#renderRequested) {
2960
- this.#scheduleRender();
2961
- }
2962
- }, delay);
3064
+ this.#renderTimer = this.#renderScheduler.scheduleRender(this.#runScheduledRender, delay);
2963
3065
  }
2964
3066
 
2965
3067
  /**
@@ -2968,11 +3070,28 @@ export class TUI extends Container {
2968
3070
  * reads it re-entrantly) and compute the cost once the paint returns.
2969
3071
  */
2970
3072
  #executeRender(): void {
3073
+ if (this.#deferRenderForOutputBacklog()) return;
2971
3074
  const start = this.#renderScheduler.now();
2972
3075
  this.#lastRenderAt = start;
2973
3076
  this.#doRender();
2974
3077
  this.#lastFrameCostMs = this.#renderScheduler.now() - start;
2975
3078
  }
3079
+ /**
3080
+ * True when the frame was deferred because the terminal's output backlog
3081
+ * exceeds {@link TUI.#MAX_PENDING_OUTPUT_BYTES}. Re-arms a retry render;
3082
+ * one-shot paint intents (`#clearScrollbackOnNextRender`,
3083
+ * `#forceViewportRepaintOnNextRender`) survive untouched for it.
3084
+ */
3085
+ #deferRenderForOutputBacklog(): boolean {
3086
+ const pending = this.terminal.pendingOutputBytes;
3087
+ if (pending === undefined || pending <= TUI.#MAX_PENDING_OUTPUT_BYTES) return false;
3088
+ this.#renderRequested = true;
3089
+ this.#renderTimer ??= this.#renderScheduler.scheduleRender(
3090
+ this.#runScheduledRender,
3091
+ TUI.#OUTPUT_BACKLOG_RETRY_MS,
3092
+ );
3093
+ return true;
3094
+ }
2976
3095
 
2977
3096
  #handleInput(data: string): void {
2978
3097
  // Ctrl+C/Esc use app-level double-press windows. Give those gestures one
@@ -3762,6 +3881,13 @@ export class TUI extends Container {
3762
3881
  if (widthEpochReset && hasVisibleOverlay && this.#widthEpochOverlayBoundary === undefined) {
3763
3882
  this.#widthEpochOverlayBoundary = capturedWidthEpochBoundary;
3764
3883
  }
3884
+ // A width epoch settled while an overlay covered the transcript: the
3885
+ // scrollback refresh cannot run now (overlays freeze commits), and no
3886
+ // second width resize may ever come. Latch it and consume it on the
3887
+ // first uncovered authoritative normal-screen render.
3888
+ if (widthEpochReset && hasVisibleOverlay && this.#resizeScrollbackMode !== "preserve") {
3889
+ this.#resizeScrollbackReplayPending = true;
3890
+ }
3765
3891
  const replayUnresolvedOverlayFrame = widthEpochReset && this.#widthEpochOverlayReplayPending;
3766
3892
  const replayUnresolvedWidthEpoch =
3767
3893
  replayUnresolvedOverlayFrame ||
@@ -3795,7 +3921,21 @@ export class TUI extends Container {
3795
3921
  !geometryChanged &&
3796
3922
  !isMultiplexerSession() &&
3797
3923
  (committedRowsResynced || frameLength <= this.#committedRows);
3798
- const fullPaint = firstPaint || replaceRequested || geometryRebuild || divergenceRebuild;
3924
+ // A settled in-place width resize left native history wrapped at the old
3925
+ // width (the host rewraps its own scrollback; long lines stay shredded at
3926
+ // the old boundaries). Unless the mode is `preserve`, refresh it with one
3927
+ // full paint of the recomposed current-width frame: `append` leaves the
3928
+ // old-wrap copy above (one fresh copy per settled resize, never loss);
3929
+ // `rebuild` clears native history first so it holds the transcript
3930
+ // exactly once. Epochs settled under a visible overlay keep the deferred
3931
+ // bounded path for that frame and consume the latched refresh here once
3932
+ // the overlay closes.
3933
+ const resizeScrollbackReplay =
3934
+ (widthEpochReset || this.#resizeScrollbackReplayPending) &&
3935
+ !hasVisibleOverlay &&
3936
+ this.#resizeScrollbackMode !== "preserve";
3937
+ const fullPaint =
3938
+ firstPaint || replaceRequested || geometryRebuild || divergenceRebuild || resizeScrollbackReplay;
3799
3939
  // Height-only mux resizes move rows between the pane's scrollback and its
3800
3940
  // grid. A shrink with a full grid pushes the grid-top rows into pane
3801
3941
  // scrollback without a commit; a grow pulls the scrollback tail back into
@@ -3969,7 +4109,10 @@ export class TUI extends Container {
3969
4109
  const intent: RenderIntent = fullPaint
3970
4110
  ? {
3971
4111
  kind: "fullPaint",
3972
- clearScrollback: divergenceRebuild || ((replaceRequested || geometryRebuild) && !isMultiplexerSession()),
4112
+ clearScrollback:
4113
+ divergenceRebuild ||
4114
+ (resizeScrollbackReplay && this.#resizeScrollbackMode === "rebuild") ||
4115
+ ((replaceRequested || geometryRebuild) && !isMultiplexerSession()),
3973
4116
  }
3974
4117
  : { kind: "update", chunkTo, windowTop };
3975
4118
  this.#logRedraw(intent, frameLength, height);
@@ -3997,7 +4140,15 @@ export class TUI extends Container {
3997
4140
  // re-emission. Width epochs retain an opaque native-row ledger, so close
3998
4141
  // the old placement-coordinate epoch with its captured seam on reset and
3999
4142
  // use the current-width commit seam calculated below thereafter.
4000
- if (widthEpochReset) {
4143
+ // An `append` scrollback replay is exactly such a reset: the old-width
4144
+ // attach rows must not be compared against the current-width commit seam,
4145
+ // so the coordinate epoch transitions here and the full paint re-anchors
4146
+ // placements at current-width rows (archived cells keep their identity
4147
+ // and advance their placement id on the next emit). Only the ED3
4148
+ // `rebuild` replay skips this: the clear destroys every placement cell
4149
+ // and the emitter restarts the epochs via `resetPlacementEpochs()`, same
4150
+ // as a direct-terminal geometry rebuild.
4151
+ if (widthEpochReset && !(intent.kind === "fullPaint" && intent.clearScrollback)) {
4001
4152
  this.#imageBudget.observeCommitWatermark(placementEpochWatermark);
4002
4153
  this.#imageBudget.beginPlacementCoordinateEpoch();
4003
4154
  } else if (intent.kind === "fullPaint" || this.#widthEpochBaselineRows === undefined) {
@@ -4013,7 +4164,9 @@ export class TUI extends Container {
4013
4164
  cursorTrackingLineCount,
4014
4165
  boundConptyPaint: !unboundedConptyPaint,
4015
4166
  leadingSequence: deferredAltExit,
4016
- copyScreenToScrollback: true,
4167
+ // A width-epoch replay must not push the invalidated old-width
4168
+ // viewport into native history on terminals that support it.
4169
+ copyScreenToScrollback: !resizeScrollbackReplay,
4017
4170
  });
4018
4171
  this.#pendingAltExit = "";
4019
4172
  this.#committedPrefix = rawFrame.slice(0, chunkTo);
@@ -4025,6 +4178,7 @@ export class TUI extends Container {
4025
4178
  this.#widthEpochOverlayReplayPending = false;
4026
4179
  this.#widthEpochOverlayBoundary = undefined;
4027
4180
  this.#widthEpochCommittedPrefix = undefined;
4181
+ this.#resizeScrollbackReplayPending = false;
4028
4182
  this.#publishCommittedRows();
4029
4183
  if (!firstPaint && frameLength > height) this.#armPostFullPaintSettle();
4030
4184
  return;
package/src/utils.ts CHANGED
@@ -225,6 +225,13 @@ export function getSegmenter(): Intl.Segmenter {
225
225
  // added back so width matches the native truncate/slice/wrap helpers.
226
226
  const OSC66_SPAN_REGEX = /\x1b\]66;([^;]*);([\s\S]*?)(?:\x07|\x1b\\)/g;
227
227
  const OSC66_PREFIX = "\x1b]66;";
228
+ // APC sequences (`ESC _ ... ST|BEL`) — Kitty graphics commands such as the
229
+ // virtual-placement prefix on Unicode-placeholder image lines, or the TUI's
230
+ // BEL-terminated cursor marker. `Bun.stringWidth` strips CSI/OSC but counts APC
231
+ // payloads as printable text, so they are removed before measuring (they occupy
232
+ // zero cells — matching the native width engine in pi-natives/text.rs).
233
+ const APC_SPAN_REGEX = /\x1b_[\s\S]*?(?:\x07|\x1b\\)/g;
234
+ const APC_PREFIX = "\x1b_";
228
235
  const PRINTABLE_ASCII_REGEX = /^[\u0020-\u007e]*$/;
229
236
 
230
237
  // Pin Bun.stringWidth semantics to the native width engine and guard against Bun
@@ -296,8 +303,9 @@ let visibleWidthCacheEpoch = widthConfigEpoch;
296
303
  * Visible width of a string in terminal columns, excluding ANSI/OSC escapes.
297
304
  *
298
305
  * `Bun.stringWidth` does the heavy lifting (UAX#11 width tables + ANSI/OSC
299
- * stripping); this adds the two corrections it omits — tabs (expanded to
300
- * `tabWidth` cells) and OSC 66 text-sizing payloads (scaled by `s=`).
306
+ * stripping); this adds the corrections it omits — tabs (expanded to
307
+ * `tabWidth` cells), OSC 66 text-sizing payloads (scaled by `s=`), and APC
308
+ * sequences (counted as printable by Bun, actually zero cells).
301
309
  */
302
310
  export function visibleWidth(str: string): number {
303
311
  if (!str) return 0;
@@ -337,7 +345,8 @@ export function visibleWidth(str: string): number {
337
345
  }
338
346
  }
339
347
 
340
- let width = Bun.stringWidth(str, STRING_WIDTH_OPTS);
348
+ const measurable = hasEsc && str.includes(APC_PREFIX) ? str.replace(APC_SPAN_REGEX, "") : str;
349
+ let width = Bun.stringWidth(measurable, STRING_WIDTH_OPTS);
341
350
  if (tabCount > 0) width += tabCount * DEFAULT_TAB_WIDTH;
342
351
 
343
352
  if (hasEsc && str.includes(OSC66_PREFIX)) {