@oh-my-pi/pi-tui 18.2.0 → 18.2.1

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/tui.ts CHANGED
@@ -1313,11 +1313,21 @@ export class TUI extends Container {
1313
1313
  * there self-sustains. Every other terminal keeps the alt-borrow path. Inside a
1314
1314
  * multiplexer the mux owns the grid and consumes the toggles itself, so an
1315
1315
  * inherited Warp marker must not divert the mux-tuned borrow path.
1316
+ *
1317
+ * A ConPTY host is excluded for the same reason as a multiplexer: conhost owns
1318
+ * the grid the application writes to. Measured on conhost, resizing the
1319
+ * pseudoconsole makes it re-emit its whole viewport from `CSI H` with absolute
1320
+ * addressing while the application writes nothing, and it re-homes the cursor,
1321
+ * so the settled DSR reply carries column 1 instead of the probe's tag column
1322
+ * and can never be attributed. In-place resize has neither of its
1323
+ * preconditions there — a recoverable anchor and a grid nobody else
1324
+ * repaints — so keep the borrow, whose settled transaction ends in the
1325
+ * {@link ResizeScrollbackMode} rebuild that erases conhost's stale copy.
1316
1326
  */
1317
1327
  #resizeRepaintsInPlace(): boolean {
1318
1328
  const override = resizeInPlaceOverride();
1319
1329
  if (override !== null) return override;
1320
- if (isInsideTerminalMultiplexer()) return false;
1330
+ if (isInsideTerminalMultiplexer() || this.terminal.hostOwnsGridOnResize === true) return false;
1321
1331
  return Bun.env.TERM_PROGRAM?.toLowerCase() === "warpterminal";
1322
1332
  }
1323
1333
 
@@ -1484,6 +1494,7 @@ export class TUI extends Container {
1484
1494
  this.#parkedViewportOffset = 0;
1485
1495
  }
1486
1496
  this.#noteAltBufferToggle();
1497
+ this.#imageBudget.beginAltScreenLifecycle();
1487
1498
  this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}`);
1488
1499
  }
1489
1500
  this.#resizeSettleTimer?.cancel();
@@ -1531,6 +1542,24 @@ export class TUI extends Container {
1531
1542
  epoch: this.#geometryEpoch,
1532
1543
  retried: retry,
1533
1544
  };
1545
+ // A ConPTY host re-homes the cursor on every resize, so its reply carries
1546
+ // column 1 and can never be attributed to a tag. Sending the DSR would
1547
+ // only burn a tag column for the rest of the session (dead tags are
1548
+ // deliberately never reclaimed) and stall the settled repaint for the
1549
+ // full timeout, so anchor from the fallback immediately. Two exemptions:
1550
+ // a multiplexer answers DSR from its own grid, so under WSL-in-tmux the
1551
+ // reply is attributable and the width-reflow / hidden-grow / reversed-
1552
+ // burst logic still needs it; and PI_TUI_RESIZE_IN_PLACE=1 forces the
1553
+ // in-place repaint, whose anchor is only as good as this probe, so the
1554
+ // documented escape hatch restores the whole pre-change path.
1555
+ if (
1556
+ this.terminal.hostOwnsGridOnResize === true &&
1557
+ !isInsideTerminalMultiplexer() &&
1558
+ resizeInPlaceOverride() !== true
1559
+ ) {
1560
+ this.#resolveResizeAnchor(undefined);
1561
+ return;
1562
+ }
1534
1563
  // Tags are never expired by age: a reply has no lifetime guarantee, and
1535
1564
  // freeing a column while its reply may still arrive would let that
1536
1565
  // reply match a newer tag on the reused column. Dead tags only
@@ -1687,7 +1716,7 @@ export class TUI extends Container {
1687
1716
  const provider = this.#frameProvider;
1688
1717
  let rendered: readonly string[];
1689
1718
  do {
1690
- this.#imageBudget.beginPass();
1719
+ this.#imageBudget.beginPass(false, true);
1691
1720
  rendered =
1692
1721
  provider?.renderResizeFrame?.({ columns: width, rows: height }) ??
1693
1722
  (provider ? provider.renderFrame({ columns: width, rows: height }).viewport : this.render(width));
@@ -1857,6 +1886,18 @@ export class TUI extends Container {
1857
1886
  this.#paintEndSequence = enabled ? PAINT_END : PAINT_END_NO_SYNC;
1858
1887
  }
1859
1888
 
1889
+ /**
1890
+ * Retire every eligible history batch into native scrollback before quitting.
1891
+ *
1892
+ * The only frame path that deliberately does not composite overlays. Its
1893
+ * output is the transcript the shell prompt lands under, and it forces
1894
+ * commits that {@link #compositeOverlaysIntoWindow} otherwise relies on being
1895
+ * frozen while an overlay is up — so a modal painted here would leave debris
1896
+ * above the prompt and could reach native scrollback. `stop()` drops the
1897
+ * alternate buffer without unstacking the overlay, so leaving it in would also
1898
+ * charge a no-longer-painted modal's images against the cap and delete the
1899
+ * transcript's visible graphics on the way out.
1900
+ */
1860
1901
  #flushHistoryBeforeStop(): void {
1861
1902
  const provider = this.#frameProvider;
1862
1903
  if (provider?.beginHistoryFlush === undefined) return;
@@ -1866,13 +1907,14 @@ export class TUI extends Container {
1866
1907
  provider.beginHistoryFlush();
1867
1908
  while (true) {
1868
1909
  let plan: TerminalFramePlan;
1910
+ let viewport: string[];
1869
1911
  do {
1870
1912
  this.#imageBudget.beginPass();
1871
1913
  plan = provider.renderFrame({ columns: width, rows: height });
1914
+ viewport = Array.from(plan.viewport);
1915
+ if (viewport.length > height) viewport = viewport.slice(0, height);
1872
1916
  } while (this.#imageBudget.endPass());
1873
1917
  if (plan.history === undefined) return;
1874
- let viewport = Array.from(plan.viewport);
1875
- if (viewport.length > height) viewport = viewport.slice(0, height);
1876
1918
  const acceptedBefore = this.#acceptedHistoryBatchId;
1877
1919
  this.#emitPlanFrame(width, height, viewport, plan.history, provider);
1878
1920
  if (plan.history.id > acceptedBefore && this.#acceptedHistoryBatchId === acceptedBefore) {
@@ -2068,7 +2110,6 @@ export class TUI extends Container {
2068
2110
  #prepareForcedRender(clearScrollback: boolean): void {
2069
2111
  if (clearScrollback && !this.#clearScrollbackOnNextRender) {
2070
2112
  this.#frameProvider?.beginHistoryReplay?.();
2071
- if (TERMINAL.imageProtocol === ImageProtocol.Kitty) this.#imageBudget.forgetTransmitted();
2072
2113
  }
2073
2114
  this.#clearScrollbackOnNextRender ||= clearScrollback;
2074
2115
  this.#forceViewportRepaintOnNextRender = true;
@@ -2405,6 +2446,19 @@ export class TUI extends Container {
2405
2446
  * frozen while an overlay is visible, so overlay pixels can never enter
2406
2447
  * native scrollback.
2407
2448
  */
2449
+ /**
2450
+ * Composite the visible overlays onto a full-height copy of `viewport`, or
2451
+ * hand it back untouched when nothing is stacked. Callers run this inside
2452
+ * their image-budget pass so the frame's whole image set — transcript plus
2453
+ * modal — reaches one reconcile, instead of leaving the overlay's graphics
2454
+ * outside the cap for as long as it stays up.
2455
+ */
2456
+ #compositeVisibleOverlays(viewport: string[], width: number, height: number): string[] {
2457
+ if (this.#getTopmostVisibleOverlay() === undefined) return viewport;
2458
+ while (viewport.length < height) viewport.push("");
2459
+ return this.#compositeOverlaysIntoWindow(viewport, width, height);
2460
+ }
2461
+
2408
2462
  #compositeOverlaysIntoWindow(window: string[], termWidth: number, termHeight: number): string[] {
2409
2463
  const result = [...window];
2410
2464
  for (const entry of this.overlayStack) {
@@ -2554,17 +2608,19 @@ export class TUI extends Container {
2554
2608
  if (!provider || width <= 0 || height <= 0) return;
2555
2609
  this.#debugNextWindowTop = 0;
2556
2610
  let plan: TerminalFramePlan;
2611
+ let viewport: string[];
2557
2612
  do {
2558
2613
  this.#imageBudget.beginPass();
2559
2614
  plan = provider.renderFrame({ columns: width, rows: height });
2615
+ viewport = Array.from(plan.viewport);
2616
+ if (viewport.length > height) {
2617
+ const message = `Frame provider returned ${viewport.length} rows for a ${height}-row viewport`;
2618
+ if (Bun.env.NODE_ENV === "test" || Bun.env.NODE_ENV === "development") throw new Error(message);
2619
+ logger.error("TUI layout contract violated", { rows: viewport.length, height });
2620
+ viewport = viewport.slice(0, height);
2621
+ }
2622
+ viewport = this.#compositeVisibleOverlays(viewport, width, height);
2560
2623
  } while (this.#imageBudget.endPass());
2561
- let viewport = Array.from(plan.viewport);
2562
- if (viewport.length > height) {
2563
- const message = `Frame provider returned ${viewport.length} rows for a ${height}-row viewport`;
2564
- if (Bun.env.NODE_ENV === "test" || Bun.env.NODE_ENV === "development") throw new Error(message);
2565
- logger.error("TUI layout contract violated", { rows: viewport.length, height });
2566
- viewport = viewport.slice(0, height);
2567
- }
2568
2624
  if (this.#maybeDeferGhosttyInitialImagePaint()) return;
2569
2625
  this.#emitPlanFrame(width, height, viewport, plan.history, provider);
2570
2626
  }
@@ -2666,11 +2722,12 @@ export class TUI extends Container {
2666
2722
  offered: HistoryBatch | undefined,
2667
2723
  provider: TerminalFrameProvider | undefined,
2668
2724
  ): void {
2725
+ // Callers composite their overlays inside the budget pass, so `viewportRows`
2726
+ // is already the complete frame. Bound the store here rather than at
2727
+ // endPass(): this is the last point before the purge and transmit bytes go
2728
+ // out, and it runs once per emitted frame instead of once per retry.
2669
2729
  let viewport = viewportRows;
2670
- if (this.#getTopmostVisibleOverlay() !== undefined) {
2671
- while (viewport.length < height) viewport.push("");
2672
- viewport = this.#compositeOverlaysIntoWindow(viewport, width, height);
2673
- }
2730
+ this.#imageBudget.limitResidentImages();
2674
2731
  const history = offered !== undefined && offered.id > this.#acceptedHistoryBatchId ? offered : undefined;
2675
2732
  if (offered !== undefined && offered.id <= this.#acceptedHistoryBatchId) provider?.acknowledgeHistory(offered.id);
2676
2733
 
@@ -2720,14 +2777,19 @@ export class TUI extends Container {
2720
2777
  // explicitly destructive, so remove every placement—not only the ones
2721
2778
  // this TUI tracked—then resend images composed for the clean replay.
2722
2779
  buffer += encodeKittyDeleteAllImages();
2780
+ // `d=A` spares virtual placements, and erasing the placeholder text it
2781
+ // leaves behind does not remove the prototype either. The ids this
2782
+ // reset forgot are named explicitly here — their tracking is gone, so
2783
+ // nothing downstream could find them again.
2784
+ for (const id of this.#imageBudget.takeResetPurgeIds()) buffer += encodeKittyDeleteImage(id);
2723
2785
  this.#imageBudget.resetPlacementEpochs();
2724
2786
  }
2725
- for (const sequence of this.#imageBudget.takeTransmits()) buffer += sequence;
2726
2787
  if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
2727
2788
  for (const id of this.#imageBudget.takePurgeIds()) buffer += encodeKittyDeleteImage(id);
2728
2789
  } else {
2729
2790
  this.#imageBudget.takePurgeIds();
2730
2791
  }
2792
+ for (const sequence of this.#imageBudget.takeTransmits()) buffer += sequence;
2731
2793
  // ED2 MUST precede ED3: tmux implements ED2 by scrolling the live screen
2732
2794
  // into pane history (so cleared content stays reachable), so erasing
2733
2795
  // history first would let ED2 refill it with a copy of the old screen —
@@ -2780,7 +2842,7 @@ export class TUI extends Container {
2780
2842
  buffer += `\x1b[${startTop + 1};1H`;
2781
2843
  let screenRow = startTop;
2782
2844
  for (let index = 0; index < preparedHistory.lines.length; index++) {
2783
- if (screenRow > startTop) buffer += "\r\n";
2845
+ if (screenRow > startTop) buffer += "\n";
2784
2846
  buffer += this.#lineRewriteSequence(
2785
2847
  preparedHistory.rows[index]!,
2786
2848
  width,
@@ -2792,7 +2854,7 @@ export class TUI extends Container {
2792
2854
  screenRow++;
2793
2855
  }
2794
2856
  for (let index = 0; index < rows; index++) {
2795
- if (screenRow > startTop) buffer += "\r\n";
2857
+ if (screenRow > startTop) buffer += "\n";
2796
2858
  buffer += this.#lineRewriteSequence(
2797
2859
  prepared.rows[index]!,
2798
2860
  width,
@@ -2903,6 +2965,7 @@ export class TUI extends Container {
2903
2965
  // screen, or Esc/modified keys revert to legacy encoding inside
2904
2966
  // fullscreen overlays (Ghostty/kitty/iTerm2).
2905
2967
  this.#noteAltBufferToggle();
2968
+ this.#imageBudget.beginAltScreenLifecycle();
2906
2969
  this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}`);
2907
2970
  this.#setMouseTracking(wantMouse);
2908
2971
  setAltScreenActive(true);
@@ -2959,28 +3022,52 @@ export class TUI extends Container {
2959
3022
  this.#renderAltFrame(width, height);
2960
3023
  return;
2961
3024
  }
3025
+ // #prepareResizeReplay can latch this frame's reset itself (a settled
3026
+ // rebuild-mode resize does), so it runs before the gate; the gate then runs
3027
+ // before either arm composes anything.
3028
+ if (this.#frameProvider !== undefined) this.#prepareResizeReplay(width, height);
3029
+ this.#forgetTransmittedForPendingReset();
2962
3030
  if (this.#frameProvider !== undefined) {
2963
- this.#prepareResizeReplay(width, height);
2964
3031
  this.#renderProviderFrame(width, height);
2965
3032
  return;
2966
3033
  }
2967
3034
  this.#renderChildrenFrame(width, height);
2968
3035
  }
2969
3036
 
3037
+ /**
3038
+ * Drop transmit tracking when a destructive repaint is about to compose the
3039
+ * normal screen, so the pass re-sends every image's data alongside its
3040
+ * placement. That repaint opens with `d=A`, which is what removes the store —
3041
+ * queueing per-id deletes when the reset was merely *latched* lets them ride
3042
+ * out on an unrelated frame instead, and a frame painted on the alternate
3043
+ * buffer carries them off without the repaint that restores them. A latch that
3044
+ * never reaches a repaint — `stop()` drops it — then deletes nothing.
3045
+ *
3046
+ * Must run after everything that can latch the reset for this frame and before
3047
+ * anything composes it — one call on the normal-screen dispatch path, ahead of
3048
+ * the arm split, so a new arm cannot be added without it.
3049
+ */
3050
+ #forgetTransmittedForPendingReset(): void {
3051
+ if (!this.#clearScrollbackOnNextRender) return;
3052
+ if (TERMINAL.imageProtocol !== ImageProtocol.Kitty) return;
3053
+ this.#imageBudget.forgetTransmitted();
3054
+ }
3055
+
2970
3056
  /**
2971
3057
  * Fallback frame for hosts without a frame provider (tests, simple embeds):
2972
3058
  * compose the root children and paint the bottom `height` rows as the
2973
3059
  * mutable viewport. Nothing is ever appended to terminal history.
2974
3060
  */
2975
3061
  #renderChildrenFrame(width: number, height: number): void {
2976
- let composed: readonly string[];
3062
+ let viewport: string[];
2977
3063
  do {
2978
3064
  this.#imageBudget.beginPass();
2979
- composed = this.render(width);
3065
+ const composed = this.render(width);
3066
+ this.#debugNextWindowTop = Math.max(0, composed.length - height);
3067
+ viewport = composed.length > height ? composed.slice(composed.length - height) : Array.from(composed);
3068
+ viewport = this.#compositeVisibleOverlays(viewport, width, height);
2980
3069
  } while (this.#imageBudget.endPass());
2981
3070
  if (this.#maybeDeferGhosttyInitialImagePaint()) return;
2982
- this.#debugNextWindowTop = Math.max(0, composed.length - height);
2983
- const viewport = composed.length > height ? composed.slice(composed.length - height) : Array.from(composed);
2984
3071
  this.#emitPlanFrame(width, height, viewport, undefined, undefined);
2985
3072
  }
2986
3073
 
@@ -3291,6 +3378,12 @@ export class TUI extends Container {
3291
3378
  committedTo = -1,
3292
3379
  spacerGlyphWidth = -1,
3293
3380
  ): string {
3381
+ // End every rewrite at column zero. ConPTY can materialize a pending
3382
+ // wrap before a following cursor-addressing sequence even while DECAWM is
3383
+ // disabled; on the bottom row that becomes an untracked scroll and leaks
3384
+ // live chrome into native history (#9783). The row loops append only LF
3385
+ // because this CR supplies the other half of their explicit CRLF.
3386
+ let rewrite: string;
3294
3387
  // Reserved lower half of a scaled OSC 66 heading. The glyph re-emitted on
3295
3388
  // the row above owns columns `[0, spacerGlyphWidth)` here, so preserve
3296
3389
  // them (any erase there clears the glyph — issue #8318) but still clear
@@ -3298,25 +3391,26 @@ export class TUI extends Container {
3298
3391
  // spacer, and the glyph write never covers those columns. Leading reset
3299
3392
  // keeps the erase on the default background (BCE).
3300
3393
  if (spacerGlyphWidth >= 0) {
3301
- if (spacerGlyphWidth >= width) return "";
3302
- return `${SEGMENT_RESET}\x1b[${spacerGlyphWidth}C${ERASE_TO_END_OF_LINE}`;
3303
- }
3304
- if (line.isImage) {
3305
- return ERASE_LINE + this.#imageLineSequence(line.line, screenRow, frameRow, committedTo);
3306
- }
3307
- const terminalLine = this.#terminalLine(line);
3308
- if (line.asciiWidth !== undefined) {
3309
- // Exact width model: skip the erase only when the row truly fills
3310
- // the line (an EL there would eat the last cell via pending-wrap).
3311
- return line.asciiWidth >= width ? terminalLine : terminalLine + ERASE_TO_END_OF_LINE;
3394
+ rewrite = spacerGlyphWidth >= width ? "" : `${SEGMENT_RESET}\x1b[${spacerGlyphWidth}C${ERASE_TO_END_OF_LINE}`;
3395
+ } else if (line.isImage) {
3396
+ rewrite = ERASE_LINE + this.#imageLineSequence(line.line, screenRow, frameRow, committedTo);
3397
+ } else {
3398
+ const terminalLine = this.#terminalLine(line);
3399
+ if (line.asciiWidth !== undefined) {
3400
+ // Exact width model: skip the erase only when the row truly fills
3401
+ // the line (an EL there would eat the last cell via pending-wrap).
3402
+ rewrite = line.asciiWidth >= width ? terminalLine : terminalLine + ERASE_TO_END_OF_LINE;
3403
+ } else {
3404
+ // Non-ASCII rows: the native measure can over-count combining-heavy
3405
+ // scripts, so a row it calls "full" may render short and leave stale
3406
+ // cells from the previous occupant — which would then scroll into
3407
+ // history baked into the committed row. Erase the line first instead
3408
+ // (rewrites always start at column 1, so EL-to-end clears the whole
3409
+ // row); the leading reset keeps BCE on the default background.
3410
+ rewrite = SEGMENT_RESET + ERASE_TO_END_OF_LINE + terminalLine;
3411
+ }
3312
3412
  }
3313
- // Non-ASCII rows: the native measure can over-count combining-heavy
3314
- // scripts, so a row it calls "full" may render short and leave stale
3315
- // cells from the previous occupant — which would then scroll into
3316
- // history baked into the committed row. Erase the line first instead
3317
- // (rewrites always start at column 1, so EL-to-end clears the whole
3318
- // row); the leading reset keeps BCE on the default background.
3319
- return SEGMENT_RESET + ERASE_TO_END_OF_LINE + terminalLine;
3413
+ return `${rewrite}\r`;
3320
3414
  }
3321
3415
 
3322
3416
  #targetHardwareCursorState(
@@ -3374,7 +3468,11 @@ export class TUI extends Container {
3374
3468
  #renderAltFrame(width: number, height: number): void {
3375
3469
  // oxlint-disable-next-line unicorn/no-new-array -- alt-frame length preallocation
3376
3470
  const base: string[] = new Array(Math.max(0, height)).fill("");
3377
- const lines = this.#compositeOverlaysIntoWindow(base, width, height);
3471
+ let lines: string[];
3472
+ do {
3473
+ this.#imageBudget.beginPass(false, true);
3474
+ lines = this.#compositeOverlaysIntoWindow(base, width, height);
3475
+ } while (this.#imageBudget.endPass());
3378
3476
  this.#extractCursorMarkers(lines);
3379
3477
  const prepared = this.#prepareLinesArray(lines, width, this.#altPreparedRows, height);
3380
3478
  this.#emitAltFrame(prepared, width, height);
@@ -3386,12 +3484,19 @@ export class TUI extends Container {
3386
3484
  * native-scrollback byte. The hardware cursor stays hidden here.
3387
3485
  */
3388
3486
  #emitAltFrame(prepared: PreparedLines, width: number, height: number): void {
3487
+ // The pass that composed this frame ran with `altScreen`, so the normal
3488
+ // screen's own placements behind it are not treated as retired.
3489
+ this.#imageBudget.limitResidentImages();
3389
3490
  // Flush queued image-data transmits (`a=t`, no visible output) before the
3390
3491
  // paint so id-keyed placements and placeholder cells composed into this
3391
3492
  // frame resolve against loaded data. The normal-screen path flushes these
3392
3493
  // ahead of its paint; without this, an image first shown inside a
3393
3494
  // fullscreen overlay (e.g. the settings shape preview) would render as
3394
3495
  // blank placeholder cells until the overlay closed.
3496
+ const purgeIds = this.#imageBudget.takePurgeIds();
3497
+ if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
3498
+ for (const id of purgeIds) this.terminal.write(encodeKittyDeleteImage(id));
3499
+ }
3395
3500
  const imageTransmits = this.#imageBudget.takeTransmits();
3396
3501
  if (imageTransmits.length > 0) {
3397
3502
  let transmitBuffer = "";
@@ -3428,7 +3533,7 @@ export class TUI extends Container {
3428
3533
  }
3429
3534
  let buffer = `${this.#paintBeginSequence}\x1b[H`;
3430
3535
  for (let r = 0; r < height; r++) {
3431
- if (r > 0) buffer += "\r\n";
3536
+ if (r > 0) buffer += "\n";
3432
3537
  buffer += this.#lineRewriteSequence(
3433
3538
  prepared.rows[r]!,
3434
3539
  width,