@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/CHANGELOG.md +20 -0
- package/dist/types/autocomplete.d.ts +2 -1
- package/dist/types/components/editor.d.ts +10 -0
- package/dist/types/components/image.d.ts +49 -7
- package/dist/types/terminal-capabilities.d.ts +18 -0
- package/dist/types/terminal-multiplexer.d.ts +17 -1
- package/dist/types/terminal.d.ts +17 -2
- package/package.json +3 -3
- package/src/autocomplete.ts +48 -19
- package/src/components/editor.ts +42 -1
- package/src/components/image.ts +244 -45
- package/src/terminal-capabilities.ts +52 -4
- package/src/terminal-multiplexer.ts +31 -10
- package/src/terminal.ts +25 -2
- package/src/tui.ts +149 -44
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
|
-
|
|
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 += "\
|
|
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 += "\
|
|
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
|
|
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
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 += "\
|
|
3536
|
+
if (r > 0) buffer += "\n";
|
|
3432
3537
|
buffer += this.#lineRewriteSequence(
|
|
3433
3538
|
prepared.rows[r]!,
|
|
3434
3539
|
width,
|