@oh-my-pi/pi-tui 15.9.0 → 15.9.3
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 +26 -0
- package/dist/types/kitty-graphics.d.ts +18 -0
- package/dist/types/tui.d.ts +18 -6
- package/package.json +3 -3
- package/src/kitty-graphics.ts +30 -2
- package/src/terminal-capabilities.ts +8 -0
- package/src/terminal.ts +13 -3
- package/src/tui.ts +296 -22
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.9.3] - 2026-06-05
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed ED3-risk foreground streaming erasing the head of any block that alone overflows the viewport (a tall tool result drawn in one frame, or a multi-line assistant reply growing past the viewport as it streams). The live-region pin committed native scrollback only up to the sealed-prefix boundary (`liveRegionStart`), so rows of the live block that had physically scrolled above the viewport top were neither pushed into scrollback nor kept in the repainted viewport — they vanished. The commit boundary is now the viewport top: every row above the viewport enters scrollback (only the tail still visible in the viewport stays transient and deferred to the checkpoint).
|
|
10
|
+
- Fixed the same ED3-risk live-region pin duplicating already-committed scrollback rows when a foreground stream's live region collapsed mid-turn (a tool preview shrinking to its compact result, an assistant block re-wrapping shorter, a late tool completion). Because growth commits every row above the viewport top to native scrollback, a subsequent shrink moved the bottom-anchored viewport back across those committed rows and the repaint re-drew them into the viewport — so they appeared twice on scroll-up, and with no prompt-submit checkpoint to reconcile (autonomous multi-turn runs, or the session ending into the welcome screen) the duplicate was baked permanently into terminal history. The pinned repaint now separates commit geometry from repaint geometry: a collapse clamps the repaint to the committed sealed boundary (`min(#scrollbackHighWater, liveRegionStart)`) instead of re-exposing those rows, leaving native scrollback un-duplicated without emitting ED3 under a possibly-scrolled reader; stale mutable live-region saved lines still reconcile at the next checkpoint.
|
|
11
|
+
- Fixed hiding overlays during ED3-risk foreground streaming on unknown-viewport terminals leaving the overlay's transient rows in native scrollback. Overlay visibility reductions now bypass the streaming deferral path and rebuild once, so hidden dialog/notification sentinels are scrubbed immediately.
|
|
12
|
+
- Fixed ED3-risk / unknown-viewport terminals (including WSL fronted by Windows Terminal) keeping the foreground-stream eager-rebuild mode active after the stream had already settled. A later scrolled content shrink or resize-with-append could then bypass the anti-yank deferral and repaint from stale geometry, jumping the viewport or replaying the wrong rows. The eager opt-in now drops immediately when no teardown render is pending, and the one-frame post-checkpoint suffix-suppression path no longer overrides geometry reflow handling.
|
|
13
|
+
|
|
14
|
+
## [15.9.2] - 2026-06-05
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Changed foreground-stream rendering on ED3-risk terminals (Ghostty/kitty/Alacritty/VTE/iTerm2 on POSIX) to defer native-scrollback commits for unpinned transient frames: while a turn streams, generic frames repaint only the viewport and suppress `\r\n` scroll growth, so transient output (spinner ticks, partial lines, status rows) never pollutes terminal history. Components that report a `NativeScrollbackLiveRegion` still commit newly sealed prefix rows while keeping the active suffix dirty for checkpoint replay. Native scrollback is reconciled in a single ED3 (`CSI 3 J`) + re-emit at the next checkpoint (prompt submit) or on an explicit user-input/IME opt-in; an erase is never emitted mid-stream under a possibly-scrolled reader. Non-ED3-risk terminals keep their eager live rebuild. ([#1895](https://github.com/can1357/oh-my-pi/pull/1895))
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Fixed ED3-risk foreground streaming dropping sealed transcript rows above the live block until the next prompt-submit checkpoint, which made scrollback beyond the viewport appear duplicated or out of order. The renderer restores native-scrollback live-region pinning so newly sealed rows are appended once while active live rows remain deferred.
|
|
23
|
+
- Fixed inline images (added in 15.9) rendering as a wall of empty PUA box glyphs and producing laggy scrolling on Kitty-protocol terminals that do not implement Unicode placeholders — most notably WezTerm (per upstream wezterm/wezterm#986, placeholder support is still unchecked) and the tmux/screen `getFallbackImageProtocol` path that forces Kitty mode even on non-supporting outer terminals (Terminal.app, etc.). `unicodePlaceholders` now defaults on only for `kitty` and `ghostty`; everything else falls back to direct `a=p,i=…,p=…` placement, which those paths already render correctly. `PI_NO_KITTY_PLACEHOLDERS=1` is still honored as a hard opt-out, and a new `PI_KITTY_PLACEHOLDERS=1` opts in on otherwise-unsupported terminals (e.g. a wezterm nightly that has merged placeholder support) ([#1877](https://github.com/can1357/oh-my-pi/issues/1877)).
|
|
24
|
+
|
|
25
|
+
## [15.9.1] - 2026-06-04
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- Fixed the OSC 11 appearance poll re-querying every 2s forever on terminals that support Mode 2031 but never change theme, whose repeated OSC 11/DA1 writes cleared the user's active text selection (breaking copy every 2 seconds). The poll now stops as soon as DECRQM confirms Mode 2031 support, since push notifications make polling redundant.
|
|
30
|
+
|
|
5
31
|
## [15.9.0] - 2026-06-04
|
|
6
32
|
|
|
7
33
|
### Added
|
|
@@ -9,6 +9,24 @@ export interface KittyGraphicsFeatures {
|
|
|
9
9
|
/** How image data reaches the terminal: in-band base64 or a temp file. */
|
|
10
10
|
transmissionMedium: KittyTransmissionMedium;
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Whether the detected terminal renders Kitty Unicode placeholders (`U=1` +
|
|
14
|
+
* U+10EEEE with row/column diacritics).
|
|
15
|
+
*
|
|
16
|
+
* Only `kitty` (the protocol's origin) and `ghostty` ship a working
|
|
17
|
+
* implementation; WezTerm advertises Kitty graphics but treats placeholder
|
|
18
|
+
* cells as literal PUA glyphs (see wezterm/wezterm#986, "placeholder support"
|
|
19
|
+
* still unchecked), and the tmux/screen fallback can land on any outer
|
|
20
|
+
* terminal. Enabling placeholders on those paths emits a `columns × rows`
|
|
21
|
+
* grid of U+10EEEE per image per frame; the cells render as boxed fallback
|
|
22
|
+
* glyphs and re-emit on every repaint, which is exactly the
|
|
23
|
+
* "stuck/laggy scrolling + ASCII artifact" symptom reported in #1877.
|
|
24
|
+
*
|
|
25
|
+
* `PI_NO_KITTY_PLACEHOLDERS=1` forces off (e.g. for tmux passthrough to a
|
|
26
|
+
* non-supporting outer terminal); `PI_KITTY_PLACEHOLDERS=1` forces on (e.g.
|
|
27
|
+
* for a wezterm nightly that has merged placeholder support).
|
|
28
|
+
*/
|
|
29
|
+
export declare function detectKittyUnicodePlaceholdersSupport(terminalId: string, env?: NodeJS.ProcessEnv): boolean;
|
|
12
30
|
export declare function getKittyGraphics(): Readonly<KittyGraphicsFeatures>;
|
|
13
31
|
export declare function setKittyGraphics(partial: Partial<KittyGraphicsFeatures>): void;
|
|
14
32
|
/**
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -42,6 +42,16 @@ export interface Component {
|
|
|
42
42
|
*/
|
|
43
43
|
invalidate(): void;
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Optional component seam for native-scrollback pinning. A component that
|
|
47
|
+
* renders a stable prefix followed by a live/transient suffix reports the local
|
|
48
|
+
* line index where that suffix begins after each render. TUI treats that suffix
|
|
49
|
+
* — and every root child rendered below it — as not yet safe to commit to native
|
|
50
|
+
* scrollback on ED3-risk terminals whose viewport position is unobservable.
|
|
51
|
+
*/
|
|
52
|
+
export interface NativeScrollbackLiveRegion {
|
|
53
|
+
getNativeScrollbackLiveRegionStart(): number | undefined;
|
|
54
|
+
}
|
|
45
55
|
/**
|
|
46
56
|
* Interface for components that can receive focus and display a cursor.
|
|
47
57
|
* When focused, the component should emit CURSOR_MARKER at the cursor position
|
|
@@ -173,6 +183,7 @@ export declare class TUI extends Container {
|
|
|
173
183
|
hidden: boolean;
|
|
174
184
|
}[];
|
|
175
185
|
constructor(terminal: Terminal, showHardwareCursor?: boolean, options?: TUIOptions);
|
|
186
|
+
render(width: number): string[];
|
|
176
187
|
get fullRedraws(): number;
|
|
177
188
|
/** Shared budget that caps how many inline images render as live graphics. */
|
|
178
189
|
get imageBudget(): ImageBudget;
|
|
@@ -212,12 +223,13 @@ export declare class TUI extends Container {
|
|
|
212
223
|
* (`CSI 3 J`, erase saved lines) also defer the eager opt-in; checkpoint and
|
|
213
224
|
* direct user-input rebuilds are unaffected.
|
|
214
225
|
*
|
|
215
|
-
* Disabling
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
226
|
+
* Disabling stays active through one already-requested frame: the event batch
|
|
227
|
+
* that ends a foreground stream both removes its UI rows (loader/status
|
|
228
|
+
* teardown — a shrink) and clears this flag before the throttled render timer
|
|
229
|
+
* fires. If the flag dropped immediately, that teardown frame would hit the
|
|
230
|
+
* ED3-risk idle deferral and freeze on screen (stale spinner) until the next
|
|
231
|
+
* keystroke. When no render is pending, disable immediately so a later
|
|
232
|
+
* unrelated content mutation does not inherit foreground-stream privileges.
|
|
221
233
|
*/
|
|
222
234
|
setEagerNativeScrollbackRebuild(enabled: boolean): void;
|
|
223
235
|
setFocus(component: Component | null): void;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "15.9.
|
|
4
|
+
"version": "15.9.3",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-natives": "15.9.
|
|
41
|
-
"@oh-my-pi/pi-utils": "15.9.
|
|
40
|
+
"@oh-my-pi/pi-natives": "15.9.3",
|
|
41
|
+
"@oh-my-pi/pi-utils": "15.9.3",
|
|
42
42
|
"lru-cache": "11.5.1",
|
|
43
43
|
"marked": "^18.0.4"
|
|
44
44
|
},
|
package/src/kitty-graphics.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import * as fs from "node:fs";
|
|
18
18
|
import * as os from "node:os";
|
|
19
19
|
import * as path from "node:path";
|
|
20
|
-
import { $env,
|
|
20
|
+
import { $env, logger } from "@oh-my-pi/pi-utils";
|
|
21
21
|
|
|
22
22
|
/** Kitty Unicode placeholder base character (U+10EEEE, Plane 16 PUA). */
|
|
23
23
|
export const KITTY_PLACEHOLDER = "\u{10eeee}";
|
|
@@ -76,8 +76,36 @@ function transmissionOverride(): KittyTransmissionMedium | "auto" {
|
|
|
76
76
|
return "auto";
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Whether the detected terminal renders Kitty Unicode placeholders (`U=1` +
|
|
81
|
+
* U+10EEEE with row/column diacritics).
|
|
82
|
+
*
|
|
83
|
+
* Only `kitty` (the protocol's origin) and `ghostty` ship a working
|
|
84
|
+
* implementation; WezTerm advertises Kitty graphics but treats placeholder
|
|
85
|
+
* cells as literal PUA glyphs (see wezterm/wezterm#986, "placeholder support"
|
|
86
|
+
* still unchecked), and the tmux/screen fallback can land on any outer
|
|
87
|
+
* terminal. Enabling placeholders on those paths emits a `columns × rows`
|
|
88
|
+
* grid of U+10EEEE per image per frame; the cells render as boxed fallback
|
|
89
|
+
* glyphs and re-emit on every repaint, which is exactly the
|
|
90
|
+
* "stuck/laggy scrolling + ASCII artifact" symptom reported in #1877.
|
|
91
|
+
*
|
|
92
|
+
* `PI_NO_KITTY_PLACEHOLDERS=1` forces off (e.g. for tmux passthrough to a
|
|
93
|
+
* non-supporting outer terminal); `PI_KITTY_PLACEHOLDERS=1` forces on (e.g.
|
|
94
|
+
* for a wezterm nightly that has merged placeholder support).
|
|
95
|
+
*/
|
|
96
|
+
export function detectKittyUnicodePlaceholdersSupport(terminalId: string, env: NodeJS.ProcessEnv = Bun.env): boolean {
|
|
97
|
+
const offRaw = env.PI_NO_KITTY_PLACEHOLDERS?.trim().toLowerCase();
|
|
98
|
+
if (offRaw === "1" || offRaw === "true" || offRaw === "on" || offRaw === "yes" || offRaw === "y") return false;
|
|
99
|
+
const force = env.PI_KITTY_PLACEHOLDERS?.trim().toLowerCase();
|
|
100
|
+
if (force === "1" || force === "true" || force === "on" || force === "yes" || force === "y") return true;
|
|
101
|
+
if (force === "0" || force === "false" || force === "off" || force === "no" || force === "n") return false;
|
|
102
|
+
return terminalId === "kitty" || terminalId === "ghostty";
|
|
103
|
+
}
|
|
104
|
+
|
|
79
105
|
let features: KittyGraphicsFeatures = {
|
|
80
|
-
|
|
106
|
+
// Off until `terminal-capabilities` seeds it from the detected terminal id —
|
|
107
|
+
// the default-on path corrupts wezterm and tmux-passthrough sessions.
|
|
108
|
+
unicodePlaceholders: false,
|
|
81
109
|
// Start direct; a successful probe (or explicit `temp-file` override) promotes.
|
|
82
110
|
transmissionMedium: transmissionOverride() === "temp-file" ? "temp-file" : "direct",
|
|
83
111
|
};
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { encodeSixel } from "@oh-my-pi/pi-natives";
|
|
2
2
|
import { $env, isBunTestRuntime } from "@oh-my-pi/pi-utils";
|
|
3
3
|
import {
|
|
4
|
+
detectKittyUnicodePlaceholdersSupport,
|
|
4
5
|
encodeKittyTempFileTransmit,
|
|
5
6
|
getKittyGraphics,
|
|
6
7
|
isPngBase64,
|
|
7
8
|
KITTY_PLACEHOLDER,
|
|
8
9
|
kittyPlaceholdersFit,
|
|
9
10
|
renderKittyPlaceholderLines,
|
|
11
|
+
setKittyGraphics,
|
|
10
12
|
} from "./kitty-graphics";
|
|
11
13
|
|
|
12
14
|
export enum ImageProtocol {
|
|
@@ -321,6 +323,12 @@ export const TERMINAL = (() => {
|
|
|
321
323
|
return resolved;
|
|
322
324
|
})();
|
|
323
325
|
|
|
326
|
+
// Seed Kitty Unicode placeholder support from the resolved terminal id. Only
|
|
327
|
+
// kitty/ghostty are known to honor `U=1` placement; other Kitty-protocol paths
|
|
328
|
+
// (wezterm, tmux/screen fallback) treat the placeholder cells as literal PUA
|
|
329
|
+
// glyphs, which is the "ASCII artifact + laggy scrolling" reported in #1877.
|
|
330
|
+
setKittyGraphics({ unicodePlaceholders: detectKittyUnicodePlaceholdersSupport(TERMINAL.id, Bun.env) });
|
|
331
|
+
|
|
324
332
|
type MutableTerminalInfo = {
|
|
325
333
|
imageProtocol: ImageProtocol | null;
|
|
326
334
|
deccara: boolean;
|
package/src/terminal.ts
CHANGED
|
@@ -281,7 +281,11 @@ export class ProcessTerminal implements Terminal {
|
|
|
281
281
|
this.#safeWrite("\x1b[?2031h");
|
|
282
282
|
|
|
283
283
|
// Start periodic OSC 11 re-query for terminals without Mode 2031
|
|
284
|
-
// (Warp, Alacritty, WezTerm
|
|
284
|
+
// (Warp, Alacritty, older WezTerm). Stops once Mode 2031 support is
|
|
285
|
+
// confirmed via DECRQM (probed below) or a Mode 2031 change notification
|
|
286
|
+
// fires — push notifications supersede polling, and the poll's repeated
|
|
287
|
+
// OSC 11/DA1 writes clear the user's active text selection on some
|
|
288
|
+
// terminals (copy breaks every 2s).
|
|
285
289
|
// Windows Terminal under WSL has been observed to close the hosting tab
|
|
286
290
|
// after repeated OSC 11/DA1 probes. Keep the initial/event-driven probes,
|
|
287
291
|
// but avoid background polling there.
|
|
@@ -291,11 +295,14 @@ export class ProcessTerminal implements Terminal {
|
|
|
291
295
|
|
|
292
296
|
// Probe DEC private-mode support via DECRQM. 2026 (synchronized output)
|
|
293
297
|
// gates the renderer's begin/end markers; 2048 (in-band resize) is enabled
|
|
294
|
-
// only after the terminal confirms support
|
|
298
|
+
// only after the terminal confirms support; 2031 (appearance change
|
|
299
|
+
// notifications) stops the OSC 11 poll once confirmed, since push
|
|
300
|
+
// notifications make polling redundant. Each probe rides the shared DA1
|
|
295
301
|
// sentinel FIFO, so a terminal that ignores DECRQM still resolves (as
|
|
296
302
|
// unsupported) when the DA1 reply arrives.
|
|
297
303
|
this.#queryPrivateMode(2026);
|
|
298
304
|
this.#queryPrivateMode(2048);
|
|
305
|
+
this.#queryPrivateMode(2031);
|
|
299
306
|
}
|
|
300
307
|
|
|
301
308
|
/**
|
|
@@ -785,7 +792,9 @@ export class ProcessTerminal implements Terminal {
|
|
|
785
792
|
/**
|
|
786
793
|
* Record DECRQM support for a private mode (idempotent — first result wins)
|
|
787
794
|
* and notify subscribers. Enables DEC 2048 in-band resize when 2048 resolves
|
|
788
|
-
* supported
|
|
795
|
+
* supported, and stops the OSC 11 poll when 2031 resolves supported (Mode 2031
|
|
796
|
+
* push notifications make periodic re-querying redundant — and the poll's
|
|
797
|
+
* OSC 11/DA1 writes clobber active text selections on some terminals).
|
|
789
798
|
*/
|
|
790
799
|
#resolvePrivateMode(mode: number, supported: boolean): void {
|
|
791
800
|
if (this.#privateModeSupport.has(mode)) return;
|
|
@@ -798,6 +807,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
798
807
|
}
|
|
799
808
|
}
|
|
800
809
|
if (mode === 2048 && supported) this.#enableInBandResize();
|
|
810
|
+
if (mode === 2031 && supported) this.#stopOsc11Poll();
|
|
801
811
|
}
|
|
802
812
|
|
|
803
813
|
/**
|
package/src/tui.ts
CHANGED
|
@@ -117,6 +117,21 @@ export interface Component {
|
|
|
117
117
|
invalidate(): void;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Optional component seam for native-scrollback pinning. A component that
|
|
122
|
+
* renders a stable prefix followed by a live/transient suffix reports the local
|
|
123
|
+
* line index where that suffix begins after each render. TUI treats that suffix
|
|
124
|
+
* — and every root child rendered below it — as not yet safe to commit to native
|
|
125
|
+
* scrollback on ED3-risk terminals whose viewport position is unobservable.
|
|
126
|
+
*/
|
|
127
|
+
export interface NativeScrollbackLiveRegion {
|
|
128
|
+
getNativeScrollbackLiveRegionStart(): number | undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function getNativeScrollbackLiveRegionStart(component: Component): number | undefined {
|
|
132
|
+
return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackLiveRegionStart?.();
|
|
133
|
+
}
|
|
134
|
+
|
|
120
135
|
/**
|
|
121
136
|
* Interface for components that can receive focus and display a cursor.
|
|
122
137
|
* When focused, the component should emit CURSOR_MARKER at the cursor position
|
|
@@ -317,6 +332,9 @@ export class Container implements Component {
|
|
|
317
332
|
* - `historyRebuild`: a geometry change (terminal resize) left native history
|
|
318
333
|
* wrapped at the old size — clear viewport and scrollback so it rewraps at the
|
|
319
334
|
* new geometry. Also flushes deferred content-only rewrites.
|
|
335
|
+
* - `liveRegionPinned`: ED3-risk/unknown foreground stream with a reported live
|
|
336
|
+
* suffix — optionally append newly sealed rows, then repaint the live/mutable
|
|
337
|
+
* tail without letting transient rows enter native history.
|
|
320
338
|
* - `viewportRepaint`: rewrite the visible viewport in place. If `appendFrom`
|
|
321
339
|
* is set, emit those tail rows as scrollback growth first so streaming
|
|
322
340
|
* output reaches terminal history before the corrected viewport is drawn.
|
|
@@ -334,6 +352,7 @@ type RenderIntent =
|
|
|
334
352
|
| { kind: "sessionReplace" }
|
|
335
353
|
| { kind: "historyRebuild" }
|
|
336
354
|
| { kind: "overlayRebuild" }
|
|
355
|
+
| { kind: "liveRegionPinned"; appendFrom: number; appendTo: number; renderViewportTop: number }
|
|
337
356
|
| { kind: "viewportRepaint"; appendFrom?: number }
|
|
338
357
|
| { kind: "deferredShrink"; paddedLength: number }
|
|
339
358
|
| { kind: "deferredMutation" }
|
|
@@ -383,7 +402,18 @@ export class TUI extends Container {
|
|
|
383
402
|
// Set after a clear+full replay so the next insert-above-suffix frame does
|
|
384
403
|
// not scroll replayed live chrome (status/editor) into fresh history.
|
|
385
404
|
#suppressNextSuffixScroll = false;
|
|
405
|
+
#nativeScrollbackLiveRegionStart: number | undefined;
|
|
386
406
|
#nativeScrollbackDirty = false;
|
|
407
|
+
// Highest `#maxLinesRendered` reached during a foreground tool turn while
|
|
408
|
+
// intermediate frames were prevented from committing to terminal scrollback.
|
|
409
|
+
// Used after the tool finishes to push the settled content into scrollback
|
|
410
|
+
// via a non-destructive full paint (no ED 3). Reset to 0 once rows are
|
|
411
|
+
// committed (via any `#emitFullPaint`, `#emitDiff`, or `#emitAppendTail`
|
|
412
|
+
// path).
|
|
413
|
+
#streamingHighWater = 0;
|
|
414
|
+
// Tracks whether the previous frame was inside a foreground tool streaming
|
|
415
|
+
// turn. Used to reset `#streamingHighWater` on fresh streaming starts.
|
|
416
|
+
#previousStreamingActive = false;
|
|
387
417
|
#fullRedrawCount = 0;
|
|
388
418
|
// Caps how many inline images render as live graphics; older ones fall back
|
|
389
419
|
// to text via a purge + full redraw. Cap is configured by the host app.
|
|
@@ -423,6 +453,25 @@ export class TUI extends Container {
|
|
|
423
453
|
this.#showHardwareCursor = showHardwareCursor === undefined ? this.#showHardwareCursor : showHardwareCursor;
|
|
424
454
|
}
|
|
425
455
|
|
|
456
|
+
override render(width: number): string[] {
|
|
457
|
+
width = Math.max(1, width);
|
|
458
|
+
this.#nativeScrollbackLiveRegionStart = undefined;
|
|
459
|
+
const lines: string[] = [];
|
|
460
|
+
for (const child of this.children) {
|
|
461
|
+
const offset = lines.length;
|
|
462
|
+
const childLines = child.render(width);
|
|
463
|
+
const liveRegionStart = getNativeScrollbackLiveRegionStart(child);
|
|
464
|
+
if (liveRegionStart !== undefined) {
|
|
465
|
+
const boundedStart = Number.isFinite(liveRegionStart)
|
|
466
|
+
? Math.max(0, Math.min(childLines.length, Math.trunc(liveRegionStart)))
|
|
467
|
+
: childLines.length;
|
|
468
|
+
this.#nativeScrollbackLiveRegionStart = offset + boundedStart;
|
|
469
|
+
}
|
|
470
|
+
lines.push(...childLines);
|
|
471
|
+
}
|
|
472
|
+
return lines;
|
|
473
|
+
}
|
|
474
|
+
|
|
426
475
|
#syncTerminalCursorMode(component: Component | null): void {
|
|
427
476
|
if (isFocusable(component)) {
|
|
428
477
|
component.setUseTerminalCursor?.(this.#showHardwareCursor);
|
|
@@ -498,12 +547,13 @@ export class TUI extends Container {
|
|
|
498
547
|
* (`CSI 3 J`, erase saved lines) also defer the eager opt-in; checkpoint and
|
|
499
548
|
* direct user-input rebuilds are unaffected.
|
|
500
549
|
*
|
|
501
|
-
* Disabling
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
550
|
+
* Disabling stays active through one already-requested frame: the event batch
|
|
551
|
+
* that ends a foreground stream both removes its UI rows (loader/status
|
|
552
|
+
* teardown — a shrink) and clears this flag before the throttled render timer
|
|
553
|
+
* fires. If the flag dropped immediately, that teardown frame would hit the
|
|
554
|
+
* ED3-risk idle deferral and freeze on screen (stale spinner) until the next
|
|
555
|
+
* keystroke. When no render is pending, disable immediately so a later
|
|
556
|
+
* unrelated content mutation does not inherit foreground-stream privileges.
|
|
507
557
|
*/
|
|
508
558
|
setEagerNativeScrollbackRebuild(enabled: boolean): void {
|
|
509
559
|
if (enabled) {
|
|
@@ -512,7 +562,16 @@ export class TUI extends Container {
|
|
|
512
562
|
return;
|
|
513
563
|
}
|
|
514
564
|
if (!this.#eagerNativeScrollbackRebuild) return;
|
|
515
|
-
this.#
|
|
565
|
+
if (this.#renderRequested || this.#renderTimer !== undefined) {
|
|
566
|
+
this.#eagerNativeScrollbackRebuildDisablePending = true;
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (process.platform !== "win32" && TERMINAL.eagerEraseScrollbackRisk) {
|
|
570
|
+
this.#streamingHighWater = 0;
|
|
571
|
+
this.#markNativeScrollbackDirty();
|
|
572
|
+
}
|
|
573
|
+
this.#eagerNativeScrollbackRebuild = false;
|
|
574
|
+
this.#eagerNativeScrollbackRebuildDisablePending = false;
|
|
516
575
|
}
|
|
517
576
|
|
|
518
577
|
setFocus(component: Component | null): void {
|
|
@@ -1383,11 +1442,12 @@ export class TUI extends Container {
|
|
|
1383
1442
|
(resizeEventOccurred && this.#previousHeight > 0);
|
|
1384
1443
|
const eagerEraseScrollbackRisk = process.platform !== "win32" && TERMINAL.eagerEraseScrollbackRisk;
|
|
1385
1444
|
const eagerRebuildAllowed = this.#eagerNativeScrollbackRebuild && !eagerEraseScrollbackRisk;
|
|
1386
|
-
const
|
|
1445
|
+
const explicitViewportMutation = this.#allowUnknownViewportMutationOnNextRender;
|
|
1446
|
+
const allowUnknownViewportMutation = explicitViewportMutation || eagerRebuildAllowed;
|
|
1387
1447
|
this.#allowUnknownViewportMutationOnNextRender = false;
|
|
1388
1448
|
|
|
1389
1449
|
// 3. Classify intent.
|
|
1390
|
-
|
|
1450
|
+
let intent = this.#planRender(
|
|
1391
1451
|
lines,
|
|
1392
1452
|
widthChanged,
|
|
1393
1453
|
heightChanged,
|
|
@@ -1396,7 +1456,62 @@ export class TUI extends Container {
|
|
|
1396
1456
|
visibleOverlayComponents.length > 0,
|
|
1397
1457
|
overlayVisibilityReduced,
|
|
1398
1458
|
allowUnknownViewportMutation,
|
|
1459
|
+
this.#nativeScrollbackLiveRegionStart,
|
|
1399
1460
|
);
|
|
1461
|
+
// 3b. Defer scrollback commits during foreground streaming, but only on
|
|
1462
|
+
// ED3-risk terminals whose committed scrollback cannot be rewritten without
|
|
1463
|
+
// yanking a scrolled reader. There the eager rebuild is gated off and the
|
|
1464
|
+
// diff emitter would otherwise `\r\n`-scroll every transient frame (spinner
|
|
1465
|
+
// ticks, partial output) into native history. Non-ED3-risk terminals keep
|
|
1466
|
+
// their eager live rebuild, which already commits cleanly. Explicit
|
|
1467
|
+
// reconciles — the prompt-submit checkpoint (`clearScrollbackOnNextRender`),
|
|
1468
|
+
// user-input/IME opt-ins (`explicitViewportMutation`), and overlay visibility
|
|
1469
|
+
// reductions that must scrub transient overlay cells from native history —
|
|
1470
|
+
// are never deferred: the triggering interaction pins the host to the bottom.
|
|
1471
|
+
const streamingWasActive = this.#eagerNativeScrollbackRebuild;
|
|
1472
|
+
if (streamingWasActive && !this.#previousStreamingActive) {
|
|
1473
|
+
this.#streamingHighWater = 0;
|
|
1474
|
+
}
|
|
1475
|
+
this.#previousStreamingActive = streamingWasActive;
|
|
1476
|
+
if (streamingWasActive && eagerEraseScrollbackRisk) {
|
|
1477
|
+
const streamingActive =
|
|
1478
|
+
this.#eagerNativeScrollbackRebuild && !this.#eagerNativeScrollbackRebuildDisablePending;
|
|
1479
|
+
const explicitReconcile =
|
|
1480
|
+
explicitViewportMutation || this.#clearScrollbackOnNextRender || overlayVisibilityReduced;
|
|
1481
|
+
// The defer below exists only to avoid `\r\n`-scrolling transient frames
|
|
1482
|
+
// past a reader parked in native scrollback. When the terminal can report
|
|
1483
|
+
// that the viewport is at the tail, there is no scrolled reader to yank,
|
|
1484
|
+
// so the planned intent must stand and commit normally — otherwise a row
|
|
1485
|
+
// that scrolls above the viewport top is dropped (neither pushed to
|
|
1486
|
+
// history nor kept in the capped viewport). Production POSIX ED3-risk
|
|
1487
|
+
// terminals cannot report this and stay `undefined`, so they still defer.
|
|
1488
|
+
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
|
|
1489
|
+
if (!streamingActive) {
|
|
1490
|
+
// Streaming just ended. Keep native scrollback dirty so the next
|
|
1491
|
+
// checkpoint reconciles the settled transcript; never erase here.
|
|
1492
|
+
this.#streamingHighWater = 0;
|
|
1493
|
+
this.#markNativeScrollbackDirty();
|
|
1494
|
+
} else if (
|
|
1495
|
+
!explicitReconcile &&
|
|
1496
|
+
nativeViewportAtBottom !== true &&
|
|
1497
|
+
(intent.kind === "sessionReplace" ||
|
|
1498
|
+
intent.kind === "historyRebuild" ||
|
|
1499
|
+
intent.kind === "overlayRebuild" ||
|
|
1500
|
+
(intent.kind === "diff" && intent.appendedLines))
|
|
1501
|
+
) {
|
|
1502
|
+
// Cap the frame to the viewport and keep scrollback dirty: transient
|
|
1503
|
+
// rows never enter history, and the checkpoint reconciles later.
|
|
1504
|
+
this.#markNativeScrollbackDirty();
|
|
1505
|
+
this.#streamingHighWater = Math.max(this.#streamingHighWater, lines.length);
|
|
1506
|
+
this.#scrollbackHighWater = 0;
|
|
1507
|
+
lines = lines.slice(-height);
|
|
1508
|
+
intent = { kind: "viewportRepaint" };
|
|
1509
|
+
} else {
|
|
1510
|
+
// Explicit reconcile or a non-committing frame (noop): let the
|
|
1511
|
+
// planned intent stand, but keep tracking the streaming peak.
|
|
1512
|
+
this.#streamingHighWater = Math.max(this.#streamingHighWater, lines.length);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1400
1515
|
if (this.#eagerNativeScrollbackRebuildDisablePending) {
|
|
1401
1516
|
this.#eagerNativeScrollbackRebuildDisablePending = false;
|
|
1402
1517
|
this.#eagerNativeScrollbackRebuild = false;
|
|
@@ -1448,6 +1563,19 @@ export class TUI extends Container {
|
|
|
1448
1563
|
});
|
|
1449
1564
|
this.#emitViewportRepaint(lines, width, height, cursorPos);
|
|
1450
1565
|
return;
|
|
1566
|
+
case "liveRegionPinned":
|
|
1567
|
+
this.#emitLiveRegionPinnedRepaint(
|
|
1568
|
+
lines,
|
|
1569
|
+
width,
|
|
1570
|
+
height,
|
|
1571
|
+
cursorPos,
|
|
1572
|
+
intent.appendFrom,
|
|
1573
|
+
intent.appendTo,
|
|
1574
|
+
intent.renderViewportTop,
|
|
1575
|
+
prevViewportTop,
|
|
1576
|
+
prevHardwareCursorRow,
|
|
1577
|
+
);
|
|
1578
|
+
return;
|
|
1451
1579
|
case "viewportRepaint":
|
|
1452
1580
|
if (intent.appendFrom !== undefined) {
|
|
1453
1581
|
this.#emitAppendTail(lines, intent.appendFrom, height, width, prevViewportTop, prevHardwareCursorRow);
|
|
@@ -1500,6 +1628,7 @@ export class TUI extends Container {
|
|
|
1500
1628
|
hasVisibleOverlay: boolean,
|
|
1501
1629
|
overlayVisibilityReduced: boolean,
|
|
1502
1630
|
allowUnknownViewportMutation: boolean,
|
|
1631
|
+
liveRegionStart: number | undefined,
|
|
1503
1632
|
): RenderIntent {
|
|
1504
1633
|
// Initial paint after start(): scrollback must keep its prior shell
|
|
1505
1634
|
// content, but the viewport must be cleared so stale rows do not bleed
|
|
@@ -1532,6 +1661,29 @@ export class TUI extends Container {
|
|
|
1532
1661
|
return { kind: "viewportRepaint" };
|
|
1533
1662
|
}
|
|
1534
1663
|
|
|
1664
|
+
const liveRegionPinnedIntent = this.#planLiveRegionPinnedRender(
|
|
1665
|
+
newLines,
|
|
1666
|
+
height,
|
|
1667
|
+
liveRegionStart,
|
|
1668
|
+
eagerEraseScrollbackRisk,
|
|
1669
|
+
allowUnknownViewportMutation,
|
|
1670
|
+
widthChanged || heightChanged,
|
|
1671
|
+
);
|
|
1672
|
+
if (liveRegionPinnedIntent) return liveRegionPinnedIntent;
|
|
1673
|
+
|
|
1674
|
+
// After foreground tool streaming: when content finally shrinks from the
|
|
1675
|
+
// streaming peak, rebuild with ED 3 to commit the settled state cleanly.
|
|
1676
|
+
// The check uses `#streamingHighWater` (the real peak) rather than
|
|
1677
|
+
// `#previousLines.length` because unpinned ED3-risk streaming frames may
|
|
1678
|
+
// commit only a viewport slice while native history is deferred.
|
|
1679
|
+
if (this.#streamingHighWater > height && newLines.length < this.#streamingHighWater && newLines.length > height) {
|
|
1680
|
+
this.#streamingHighWater = 0;
|
|
1681
|
+
return { kind: "historyRebuild" };
|
|
1682
|
+
}
|
|
1683
|
+
if (this.#streamingHighWater > 0 && newLines.length <= height) {
|
|
1684
|
+
this.#streamingHighWater = 0;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1535
1687
|
if (
|
|
1536
1688
|
this.#nativeScrollbackDirty &&
|
|
1537
1689
|
!isMultiplexerSession() &&
|
|
@@ -1584,17 +1736,12 @@ export class TUI extends Container {
|
|
|
1584
1736
|
// stale rows until the next input even though the frame has a fresh bottom
|
|
1585
1737
|
// viewport to show (issues #1682, foreground-stream fidelity on collapse).
|
|
1586
1738
|
// Native history stays dirty and reconciles at the next checkpoint. With no
|
|
1587
|
-
// active eager turn the reader may be scrolled
|
|
1588
|
-
//
|
|
1739
|
+
// active eager turn the reader may be scrolled; even a padded shrink repaint
|
|
1740
|
+
// can move ED3-risk unknown host scrollback (WSL/Ghostty-style), so defer
|
|
1741
|
+
// completely rather than repainting over their history.
|
|
1589
1742
|
if (nativeViewportAtBottom === undefined && eagerEraseScrollbackRisk) {
|
|
1590
1743
|
this.#markNativeScrollbackDirty();
|
|
1591
|
-
|
|
1592
|
-
return { kind: "viewportRepaint" };
|
|
1593
|
-
}
|
|
1594
|
-
if (newLines.length <= paddedViewportTop) {
|
|
1595
|
-
return { kind: "deferredMutation" };
|
|
1596
|
-
}
|
|
1597
|
-
return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
|
|
1744
|
+
return this.#eagerNativeScrollbackRebuild ? { kind: "viewportRepaint" } : { kind: "deferredMutation" };
|
|
1598
1745
|
}
|
|
1599
1746
|
|
|
1600
1747
|
// Non-ED3-risk POSIX with an unobservable viewport. If the shrink still
|
|
@@ -1666,6 +1813,8 @@ export class TUI extends Container {
|
|
|
1666
1813
|
this.#suppressNextSuffixScroll = false;
|
|
1667
1814
|
if (
|
|
1668
1815
|
suppressSuffixScroll &&
|
|
1816
|
+
!widthChanged &&
|
|
1817
|
+
!heightChanged &&
|
|
1669
1818
|
diff.appendedLines &&
|
|
1670
1819
|
diff.firstChanged < this.#previousLines.length &&
|
|
1671
1820
|
!isMultiplexerSession()
|
|
@@ -1760,7 +1909,10 @@ export class TUI extends Container {
|
|
|
1760
1909
|
return { kind: "viewportRepaint" };
|
|
1761
1910
|
}
|
|
1762
1911
|
}
|
|
1763
|
-
|
|
1912
|
+
// Geometry changes invalidate the terminal's cursor and viewport anchors;
|
|
1913
|
+
// even if the same coalesced frame also edits offscreen content for a scrolled
|
|
1914
|
+
// reader, the resize-specific branch below must repaint/clamp at the new size.
|
|
1915
|
+
if (!pureAppend && structuralMutation && !heightChanged && !widthChanged && !isMultiplexerSession()) {
|
|
1764
1916
|
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
|
|
1765
1917
|
if (this.#nativeViewportIsScrolled(nativeViewportAtBottom, allowUnknownViewportMutation)) {
|
|
1766
1918
|
this.#markNativeScrollbackDirty();
|
|
@@ -2036,6 +2188,56 @@ export class TUI extends Container {
|
|
|
2036
2188
|
);
|
|
2037
2189
|
}
|
|
2038
2190
|
|
|
2191
|
+
#planLiveRegionPinnedRender(
|
|
2192
|
+
newLines: string[],
|
|
2193
|
+
height: number,
|
|
2194
|
+
liveRegionStart: number | undefined,
|
|
2195
|
+
eagerEraseScrollbackRisk: boolean,
|
|
2196
|
+
allowUnknownViewportMutation: boolean,
|
|
2197
|
+
geometryChanged: boolean,
|
|
2198
|
+
): RenderIntent | undefined {
|
|
2199
|
+
// A width/height change reflows the whole terminal: the relative cursor
|
|
2200
|
+
// positioning this emitter relies on is computed from the pre-resize
|
|
2201
|
+
// geometry and would land on the wrong rows. Defer to the geometry branch
|
|
2202
|
+
// (a full reflow rebuild), which is the established behavior for resizes.
|
|
2203
|
+
if (
|
|
2204
|
+
liveRegionStart === undefined ||
|
|
2205
|
+
liveRegionStart >= newLines.length ||
|
|
2206
|
+
!this.#eagerNativeScrollbackRebuild ||
|
|
2207
|
+
!eagerEraseScrollbackRisk ||
|
|
2208
|
+
allowUnknownViewportMutation ||
|
|
2209
|
+
geometryChanged ||
|
|
2210
|
+
isMultiplexerSession()
|
|
2211
|
+
) {
|
|
2212
|
+
return undefined;
|
|
2213
|
+
}
|
|
2214
|
+
if (newLines.length <= height && this.#scrollbackHighWater === 0) return undefined;
|
|
2215
|
+
if (this.#readNativeViewportAtBottom() !== undefined) return undefined;
|
|
2216
|
+
|
|
2217
|
+
this.#markNativeScrollbackDirty();
|
|
2218
|
+
const naturalViewportTop = Math.max(0, newLines.length - height);
|
|
2219
|
+
// Rows before the live-region boundary are sealed. If a live-region
|
|
2220
|
+
// collapse moves the bottom-anchored viewport back across rows already
|
|
2221
|
+
// written to native scrollback, repainting those sealed rows duplicates
|
|
2222
|
+
// them in history. Clamp only to the committed sealed boundary: mutable
|
|
2223
|
+
// rows inside the live region must remain visible even when an earlier
|
|
2224
|
+
// taller live frame pushed their old contents into native scrollback. The
|
|
2225
|
+
// dirty checkpoint later reconciles those stale mutable saved lines.
|
|
2226
|
+
const committedSealedEnd = Math.min(this.#scrollbackHighWater, liveRegionStart);
|
|
2227
|
+
const renderViewportTop = Math.max(naturalViewportTop, committedSealedEnd);
|
|
2228
|
+
// Every row above the natural viewport top has physically scrolled out of
|
|
2229
|
+
// the live viewport, so the terminal has already pushed it into native
|
|
2230
|
+
// scrollback — there is nowhere else for an off-screen row to live. It must
|
|
2231
|
+
// therefore be committed as real content, *including the head of the live
|
|
2232
|
+
// block itself* when that block alone overflows the viewport (a tall tool
|
|
2233
|
+
// result, a long streamed reply). Only the live tail that remains *within*
|
|
2234
|
+
// the natural viewport stays transient (repainted in place, deferred to the
|
|
2235
|
+
// checkpoint rebuild).
|
|
2236
|
+
const appendTo = naturalViewportTop;
|
|
2237
|
+
const appendFrom = Math.min(this.#scrollbackHighWater, appendTo);
|
|
2238
|
+
return { kind: "liveRegionPinned", appendFrom, appendTo, renderViewportTop };
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2039
2241
|
#padDeferredShrinkLines(lines: string[], paddedLength: number): string[] {
|
|
2040
2242
|
if (lines.length >= paddedLength) return lines;
|
|
2041
2243
|
return [...lines, ...new Array<string>(paddedLength - lines.length).fill("")];
|
|
@@ -2208,6 +2410,76 @@ export class TUI extends Container {
|
|
|
2208
2410
|
this.#commit(lines, width, height, viewportTop, toRow);
|
|
2209
2411
|
}
|
|
2210
2412
|
|
|
2413
|
+
/**
|
|
2414
|
+
* Foreground-stream live-region paint for ED3-risk terminals with an
|
|
2415
|
+
* unobservable viewport. Commits the newly-sealed chunk to native scrollback
|
|
2416
|
+
* (so finished blocks stay scrollable) and repaints the live tail in place,
|
|
2417
|
+
* leaving the transient live region out of saved lines.
|
|
2418
|
+
*
|
|
2419
|
+
* Uses only the no-scroll-snap vocabulary of {@link #emitDiff}: relative
|
|
2420
|
+
* cursor moves, per-line `\x1b[2K`, and `\r\n` to push the sealed chunk into
|
|
2421
|
+
* history. It deliberately avoids a full-screen erase (`\x1b[2J`) and absolute
|
|
2422
|
+
* cursor home (`\x1b[H`): on Ghostty those snap a reader scrolled into history
|
|
2423
|
+
* back to the bottom on every frame.
|
|
2424
|
+
*/
|
|
2425
|
+
#emitLiveRegionPinnedRepaint(
|
|
2426
|
+
lines: string[],
|
|
2427
|
+
width: number,
|
|
2428
|
+
height: number,
|
|
2429
|
+
cursorPos: { row: number; col: number } | null,
|
|
2430
|
+
appendFrom: number,
|
|
2431
|
+
appendTo: number,
|
|
2432
|
+
renderViewportTop: number,
|
|
2433
|
+
prevViewportTop: number,
|
|
2434
|
+
prevHardwareCursorRow: number,
|
|
2435
|
+
): void {
|
|
2436
|
+
this.#fullRedrawCount += 1;
|
|
2437
|
+
const naturalViewportTop = Math.max(0, lines.length - height);
|
|
2438
|
+
const viewportTop = Math.max(0, Math.min(renderViewportTop, lines.length));
|
|
2439
|
+
const boundedAppendTo = Math.max(0, Math.min(appendTo, naturalViewportTop, lines.length));
|
|
2440
|
+
const boundedAppendFrom = Math.max(0, Math.min(appendFrom, boundedAppendTo));
|
|
2441
|
+
|
|
2442
|
+
// Position at the top visible row with a relative move. Terminals clamp the
|
|
2443
|
+
// hardware cursor to the viewport on resize, so clamp our tracking to match
|
|
2444
|
+
// before computing the delta (mirrors #emitDiff).
|
|
2445
|
+
const clampedCursor = Math.min(prevHardwareCursorRow, prevViewportTop + height - 1);
|
|
2446
|
+
const currentScreenRow = Math.max(0, Math.min(height - 1, clampedCursor - prevViewportTop));
|
|
2447
|
+
let buffer = this.#paintBeginSequence;
|
|
2448
|
+
if (currentScreenRow > 0) buffer += `\x1b[${currentScreenRow}A`;
|
|
2449
|
+
buffer += "\r";
|
|
2450
|
+
|
|
2451
|
+
// Write the sealed chunk followed by the full viewport from the top row.
|
|
2452
|
+
// The first (boundedAppendTo - boundedAppendFrom) rows scroll into native
|
|
2453
|
+
// history; the trailing `height` rows fill the viewport. Each row clears
|
|
2454
|
+
// itself with `\x1b[2K` instead of relying on a screen-wide erase.
|
|
2455
|
+
let wroteLine = false;
|
|
2456
|
+
for (let i = boundedAppendFrom; i < boundedAppendTo; i++) {
|
|
2457
|
+
if (wroteLine) buffer += "\r\n";
|
|
2458
|
+
buffer += `\x1b[2K${this.#fitLineToWidth(lines[i] ?? "", width)}`;
|
|
2459
|
+
wroteLine = true;
|
|
2460
|
+
}
|
|
2461
|
+
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
2462
|
+
if (wroteLine) buffer += "\r\n";
|
|
2463
|
+
buffer += `\x1b[2K${this.#fitLineToWidth(lines[viewportTop + screenRow] ?? "", width)}`;
|
|
2464
|
+
wroteLine = true;
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
const viewportBottomRow = viewportTop + height - 1;
|
|
2468
|
+
const contentBottomRow = Math.min(viewportBottomRow, Math.max(viewportTop, lines.length - 1));
|
|
2469
|
+
const parkUp = viewportBottomRow - contentBottomRow;
|
|
2470
|
+
if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
|
|
2471
|
+
const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, contentBottomRow);
|
|
2472
|
+
buffer += seq;
|
|
2473
|
+
buffer += this.#paintEndSequence;
|
|
2474
|
+
this.terminal.write(buffer);
|
|
2475
|
+
|
|
2476
|
+
this.#maxLinesRendered = Math.max(lines.length, viewportTop + height);
|
|
2477
|
+
if (boundedAppendTo > this.#scrollbackHighWater) {
|
|
2478
|
+
this.#scrollbackHighWater = boundedAppendTo;
|
|
2479
|
+
}
|
|
2480
|
+
this.#commit(lines, width, height, viewportTop, toRow);
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2211
2483
|
/**
|
|
2212
2484
|
* Push the appended tail into terminal scrollback by `\r\n`-ing past the
|
|
2213
2485
|
* previous viewport bottom. Used as a prefix to {@link #emitViewportRepaint}
|
|
@@ -2443,9 +2715,11 @@ export class TUI extends Container {
|
|
|
2443
2715
|
const detail =
|
|
2444
2716
|
intent.kind === "diff"
|
|
2445
2717
|
? `${intent.kind}(first=${intent.firstChanged}, last=${intent.lastChanged}, appended=${intent.appendedLines})`
|
|
2446
|
-
: intent.kind === "
|
|
2447
|
-
? `${intent.kind}(
|
|
2448
|
-
: intent.kind
|
|
2718
|
+
: intent.kind === "liveRegionPinned"
|
|
2719
|
+
? `${intent.kind}(append=${intent.appendFrom}..${intent.appendTo}, viewportTop=${intent.renderViewportTop})`
|
|
2720
|
+
: intent.kind === "viewportRepaint" && intent.appendFrom !== undefined
|
|
2721
|
+
? `${intent.kind}(appendFrom=${intent.appendFrom})`
|
|
2722
|
+
: intent.kind;
|
|
2449
2723
|
const msg = `[${new Date().toISOString()}] render: ${detail} (prev=${this.#previousLines.length}, new=${newLength}, height=${height})\n`;
|
|
2450
2724
|
fs.appendFileSync(getDebugLogPath(), msg);
|
|
2451
2725
|
}
|