@oh-my-pi/pi-tui 15.9.3 → 15.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -1
- package/dist/types/components/image.d.ts +2 -0
- package/dist/types/stdin-buffer.d.ts +2 -2
- package/dist/types/terminal-capabilities.d.ts +13 -18
- package/dist/types/tui.d.ts +16 -5
- package/package.json +3 -3
- package/src/components/image.ts +10 -0
- package/src/stdin-buffer.ts +7 -6
- package/src/terminal-capabilities.ts +75 -19
- package/src/terminal.ts +45 -6
- package/src/tui.ts +207 -186
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.9.5] - 2026-06-05
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Changed terminal resize handling so any width or height change always performs a clean reset + redraw: the renderer now unconditionally clears the viewport and native scrollback (`CSI 2 J` / `CSI 3 J`) and replays the full transcript at the new geometry, replacing the previous matrix of conditional viewport-repaint / history-rebuild / deferred-mutation branches. Multiplexer panes still repaint the visible window in place (pane scrollback cannot be erased), but a resize during active ED3-risk foreground streaming now performs the same clean rebuild rather than downgrading to a non-destructive viewport repaint: the terminal already re-wrapped its saved lines at the old width, so the rebuild must erase them (ED 3) instead of leaving the mis-wrapped history on screen. As a deliberate tradeoff this drops the prior no-overflow and confirmed-scrolled guards on resize: a reader scrolled into history snaps back to the bottom and preexisting shell scrollback above the UI is cleared.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed ED3-risk foreground streaming dropping the scrolled-off head of an append-only live block that alone overflows the viewport (a long streamed assistant reply). The live-region pin again committed native scrollback only up to the live-region start, so once the live block grew past the viewport its earlier rows scrolled above the viewport top but were committed nowhere and repainted nowhere — they vanished, leaving the reply looking like a ~viewport-tall circular buffer. The `NativeScrollbackLiveRegion` seam now also reports an optional append-only `getNativeScrollbackCommitSafeEnd`, and the pinned commit boundary is the deeper of the sealed start and that append-only end: rows in `[liveRegionStart, commitSafeEnd)` above the viewport top commit to scrollback, while volatile live blocks (tool previews that collapse) omit the boundary and keep their mutable rows deferred — preserving the pending-box-above-running-box fix.
|
|
14
|
+
|
|
15
|
+
## [15.9.4] - 2026-06-05
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- Added `PI_TUI_SYNC_OUTPUT=0` and `PI_TUI_SYNC_OUTPUT=1` to explicitly disable or force-enable DEC 2026 synchronized-output mode, alongside `PI_FORCE_SYNC_OUTPUT=1` as a force-on alias
|
|
19
|
+
- Added `PI_TUI_ED3_SAFE=1` environment override to treat a terminal as non-ED3-risk for eager native scrollback rebuilds on unknown POSIX hosts
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Changed native-scrollback safety defaults to treat unknown POSIX, SSH, and multiplexer-shaped terminals as ED3-risk for passive rendering; checkpoint replay now requires a positive at-tail viewport proof instead of assuming prompt submit makes host scrollback safe.
|
|
24
|
+
- Changed synchronized-output defaults to a conservative opt-in profile: DEC 2026 paint wrappers stay disabled for remote/multiplexer/VTE/unknown terminals unless explicitly forced, while the autowrap guards remain active.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Fixed ED3-risk unknown-viewport renders repainting offscreen structural edits over stale native scrollback, which could duplicate or shift rows when async blocks collapsed or middle rows were deleted.
|
|
29
|
+
- Fixed ED3-risk foreground streams committing mutable live-region rows into native scrollback, which could leave a stale `pending` tool box above the `running` box after the preview re-rendered.
|
|
30
|
+
- Fixed TUI shutdown leaving paint-time terminal state and Kitty image data behind by restoring synchronized-output/autowrap modes and purging all transmitted Kitty image ids on stop.
|
|
31
|
+
- Fixed stdin buffering splitting surrogate-pair text into UTF-16 halves and reduced timing sensitivity for incomplete escape sequences.
|
|
32
|
+
- Fixed terminal content not reflowing after a resize on terminals using DEC 2048 in-band resize (kitty/Ghostty/iTerm2/WezTerm). `ProcessTerminal.columns`/`rows` returned the last cached in-band report even after the OS already knew the new size, so a SIGWINCH whose in-band report was dropped or malformed (split past the stdin flush window, `:`-subparameter fields) re-rendered the whole transcript at the stale width. OS resize events now reconcile cached in-band geometry against the live `process.stdout` dimensions, dropping a stale cached value so the next render uses the true size; a valid in-band report still re-seeds pixel sizing.
|
|
33
|
+
|
|
5
34
|
## [15.9.3] - 2026-06-05
|
|
6
35
|
|
|
7
36
|
### Fixed
|
|
@@ -1043,4 +1072,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon
|
|
|
1043
1072
|
|
|
1044
1073
|
### Fixed
|
|
1045
1074
|
|
|
1046
|
-
- **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
|
|
1075
|
+
- **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
|
|
@@ -64,6 +64,8 @@ export declare class ImageBudget {
|
|
|
64
64
|
endPass(): boolean;
|
|
65
65
|
/** Image ids to delete from the terminal this frame; clears the pending set. */
|
|
66
66
|
takePurgeIds(): readonly number[];
|
|
67
|
+
/** All image ids believed to be loaded in the terminal store; clears tracking. */
|
|
68
|
+
takeAllTransmittedIds(): readonly number[];
|
|
67
69
|
/** Whether `imageId`'s data still needs to be transmitted to the terminal. */
|
|
68
70
|
shouldTransmit(imageId: number): boolean;
|
|
69
71
|
/**
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
import { EventEmitter } from "events";
|
|
20
20
|
export type StdinBufferOptions = {
|
|
21
21
|
/**
|
|
22
|
-
* Maximum time to wait for sequence completion (default:
|
|
23
|
-
* After this time,
|
|
22
|
+
* Maximum time to wait for sequence completion (default: 75ms).
|
|
23
|
+
* After this time, a genuinely incomplete escape is flushed.
|
|
24
24
|
*/
|
|
25
25
|
timeout?: number;
|
|
26
26
|
};
|
|
@@ -36,28 +36,23 @@ export declare function isNotificationSuppressed(): boolean;
|
|
|
36
36
|
*/
|
|
37
37
|
export declare function isWindowsTerminalPreviewSixelSupported(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
|
|
38
38
|
/**
|
|
39
|
-
* Whether
|
|
40
|
-
*
|
|
39
|
+
* Whether live-frame native scrollback rebuilds are unsafe when the terminal
|
|
40
|
+
* viewport position is unobservable.
|
|
41
41
|
*
|
|
42
|
-
* A TUI history rebuild emits xterm ED3 (`CSI 3 J`, erase saved lines).
|
|
43
|
-
* terminals
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
42
|
+
* A TUI history rebuild emits xterm ED3 (`CSI 3 J`, erase saved lines). Many
|
|
43
|
+
* terminals either clamp a scrolled reader back to the active tail or erase host
|
|
44
|
+
* scrollback when ED3 lands. The important property is not the brand name — it
|
|
45
|
+
* is that an unknown viewport position cannot be proven safe. Environment
|
|
46
|
+
* markers are therefore only used to prove *risk* or a strongly-known profile;
|
|
47
|
+
* unknown POSIX/remote/multiplexer shapes default to risky for passive renders.
|
|
48
48
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* platform checks (the viewport position is never observable on Windows — see
|
|
53
|
-
* `Terminal.isNativeViewportAtBottom`); a `WT_SESSION` sighting on any other
|
|
54
|
-
* platform means the outer host is Windows Terminal fronting a WSL distro (WT
|
|
55
|
-
* propagates the variable into the Linux environment), where the same ED3
|
|
56
|
-
* yank applies. See #1610.
|
|
57
|
-
*
|
|
58
|
-
* Pure helper for tests and `TERMINAL` trait construction. See #1682 and #1719.
|
|
49
|
+
* Native win32 is excluded here because the renderer has dedicated ConPTY
|
|
50
|
+
* deferral paths; a `WT_SESSION` sighting on POSIX means Windows Terminal is the
|
|
51
|
+
* outer host fronting WSL, where the same ED3 yank applies. See #1610/#1682/#1799.
|
|
59
52
|
*/
|
|
60
53
|
export declare function detectTerminalEagerEraseScrollbackRisk(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
|
|
54
|
+
/** Whether DEC 2026 synchronized-output wrappers should be enabled by default. */
|
|
55
|
+
export declare function shouldEnableSynchronizedOutputByDefault(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, terminalId?: TerminalId): boolean;
|
|
61
56
|
/**
|
|
62
57
|
* Whether the terminal applies Kitty-style DECCARA rectangular SGR changes
|
|
63
58
|
* (`CSI Pt ; Pl ; Pb ; Pr ; <sgr> $ r`) extended to background color, so large
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -48,9 +48,21 @@ export interface Component {
|
|
|
48
48
|
* line index where that suffix begins after each render. TUI treats that suffix
|
|
49
49
|
* — and every root child rendered below it — as not yet safe to commit to native
|
|
50
50
|
* scrollback on ED3-risk terminals whose viewport position is unobservable.
|
|
51
|
+
*
|
|
52
|
+
* `getNativeScrollbackCommitSafeEnd` optionally reports a *deeper* boundary
|
|
53
|
+
* inside that live suffix: the line index up to which the live region is
|
|
54
|
+
* append-only (its earlier rows never re-layout, only new rows append at the
|
|
55
|
+
* bottom — a streaming assistant message). Rows in `[liveRegionStart,
|
|
56
|
+
* commitSafeEnd)` that scroll above the viewport are safe to commit to native
|
|
57
|
+
* scrollback even though they are technically live, because they will never
|
|
58
|
+
* change. Without this, a single live block that alone overflows the viewport
|
|
59
|
+
* loses its scrolled-off head (committed nowhere, repainted nowhere). Volatile
|
|
60
|
+
* live blocks (tool previews that collapse) omit it, so their mutable rows stay
|
|
61
|
+
* deferred. Defaults to `liveRegionStart` when absent.
|
|
51
62
|
*/
|
|
52
63
|
export interface NativeScrollbackLiveRegion {
|
|
53
64
|
getNativeScrollbackLiveRegionStart(): number | undefined;
|
|
65
|
+
getNativeScrollbackCommitSafeEnd?(): number | undefined;
|
|
54
66
|
}
|
|
55
67
|
/**
|
|
56
68
|
* Interface for components that can receive focus and display a cursor.
|
|
@@ -86,9 +98,8 @@ export interface RenderRequestOptions {
|
|
|
86
98
|
*/
|
|
87
99
|
allowUnknownViewportMutation?: boolean;
|
|
88
100
|
}
|
|
89
|
-
/** Options for deferred native scrollback rebuild checkpoints. */
|
|
101
|
+
/** Options for deferred native scrollback rebuild checkpoints. Reserved for API stability. */
|
|
90
102
|
export interface NativeScrollbackRefreshOptions {
|
|
91
|
-
/** Allow replay when the terminal cannot report viewport state. Use only for explicit user submit checkpoints. */
|
|
92
103
|
allowUnknownViewport?: boolean;
|
|
93
104
|
}
|
|
94
105
|
/** Type guard to check if a component implements Focusable */
|
|
@@ -204,8 +215,8 @@ export declare class TUI extends Container {
|
|
|
204
215
|
setClearOnShrink(enabled: boolean): void;
|
|
205
216
|
/**
|
|
206
217
|
* Whether DEC 2026 synchronized-output wrappers are currently emitted around
|
|
207
|
-
* paints. Starts from
|
|
208
|
-
* the terminal reports mode 2026 unsupported via DECRQM.
|
|
218
|
+
* paints. Starts from conservative terminal/env detection and is force-disabled
|
|
219
|
+
* at runtime if the terminal reports mode 2026 unsupported via DECRQM.
|
|
209
220
|
*/
|
|
210
221
|
get synchronizedOutput(): boolean;
|
|
211
222
|
/**
|
|
@@ -252,6 +263,6 @@ export declare class TUI extends Container {
|
|
|
252
263
|
* Callers should only invoke this at checkpoints where the user is expected to be
|
|
253
264
|
* at the terminal bottom, such as after submitting a new prompt.
|
|
254
265
|
*/
|
|
255
|
-
refreshNativeScrollbackIfDirty(
|
|
266
|
+
refreshNativeScrollbackIfDirty(_options?: NativeScrollbackRefreshOptions): boolean;
|
|
256
267
|
requestRender(force?: boolean, options?: RenderRequestOptions): void;
|
|
257
268
|
}
|
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.5",
|
|
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.5",
|
|
41
|
+
"@oh-my-pi/pi-utils": "15.9.5",
|
|
42
42
|
"lru-cache": "11.5.1",
|
|
43
43
|
"marked": "^18.0.4"
|
|
44
44
|
},
|
package/src/components/image.ts
CHANGED
|
@@ -163,6 +163,16 @@ export class ImageBudget {
|
|
|
163
163
|
return ids;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
/** All image ids believed to be loaded in the terminal store; clears tracking. */
|
|
167
|
+
takeAllTransmittedIds(): readonly number[] {
|
|
168
|
+
if (this.#transmitted.size === 0) return EMPTY_IDS;
|
|
169
|
+
const ids = [...this.#transmitted];
|
|
170
|
+
this.#transmitted.clear();
|
|
171
|
+
this.#purgeIds = [];
|
|
172
|
+
this.#pendingTransmits = [];
|
|
173
|
+
return ids;
|
|
174
|
+
}
|
|
175
|
+
|
|
166
176
|
/** Whether `imageId`'s data still needs to be transmitted to the terminal. */
|
|
167
177
|
shouldTransmit(imageId: number): boolean {
|
|
168
178
|
return !this.#transmitted.has(imageId);
|
package/src/stdin-buffer.ts
CHANGED
|
@@ -233,9 +233,10 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain
|
|
|
233
233
|
return { sequences, remainder: remaining };
|
|
234
234
|
}
|
|
235
235
|
} else {
|
|
236
|
-
// Not an escape sequence - take a
|
|
237
|
-
|
|
238
|
-
|
|
236
|
+
// Not an escape sequence - take one Unicode scalar, not a UTF-16 code unit.
|
|
237
|
+
const char = Array.from(remaining)[0] ?? "";
|
|
238
|
+
sequences.push(char);
|
|
239
|
+
pos += char.length;
|
|
239
240
|
}
|
|
240
241
|
}
|
|
241
242
|
|
|
@@ -244,8 +245,8 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain
|
|
|
244
245
|
|
|
245
246
|
export type StdinBufferOptions = {
|
|
246
247
|
/**
|
|
247
|
-
* Maximum time to wait for sequence completion (default:
|
|
248
|
-
* After this time,
|
|
248
|
+
* Maximum time to wait for sequence completion (default: 75ms).
|
|
249
|
+
* After this time, a genuinely incomplete escape is flushed.
|
|
249
250
|
*/
|
|
250
251
|
timeout?: number;
|
|
251
252
|
};
|
|
@@ -269,7 +270,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
269
270
|
|
|
270
271
|
constructor(options: StdinBufferOptions = {}) {
|
|
271
272
|
super();
|
|
272
|
-
this.#timeoutMs = options.timeout ??
|
|
273
|
+
this.#timeoutMs = options.timeout ?? 75;
|
|
273
274
|
}
|
|
274
275
|
|
|
275
276
|
process(data: string | Buffer): void {
|
|
@@ -118,33 +118,44 @@ export function isWindowsTerminalPreviewSixelSupported(
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
/**
|
|
121
|
-
* Whether
|
|
122
|
-
*
|
|
121
|
+
* Whether live-frame native scrollback rebuilds are unsafe when the terminal
|
|
122
|
+
* viewport position is unobservable.
|
|
123
123
|
*
|
|
124
|
-
* A TUI history rebuild emits xterm ED3 (`CSI 3 J`, erase saved lines).
|
|
125
|
-
* terminals
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
124
|
+
* A TUI history rebuild emits xterm ED3 (`CSI 3 J`, erase saved lines). Many
|
|
125
|
+
* terminals either clamp a scrolled reader back to the active tail or erase host
|
|
126
|
+
* scrollback when ED3 lands. The important property is not the brand name — it
|
|
127
|
+
* is that an unknown viewport position cannot be proven safe. Environment
|
|
128
|
+
* markers are therefore only used to prove *risk* or a strongly-known profile;
|
|
129
|
+
* unknown POSIX/remote/multiplexer shapes default to risky for passive renders.
|
|
130
130
|
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
* platform checks (the viewport position is never observable on Windows — see
|
|
135
|
-
* `Terminal.isNativeViewportAtBottom`); a `WT_SESSION` sighting on any other
|
|
136
|
-
* platform means the outer host is Windows Terminal fronting a WSL distro (WT
|
|
137
|
-
* propagates the variable into the Linux environment), where the same ED3
|
|
138
|
-
* yank applies. See #1610.
|
|
139
|
-
*
|
|
140
|
-
* Pure helper for tests and `TERMINAL` trait construction. See #1682 and #1719.
|
|
131
|
+
* Native win32 is excluded here because the renderer has dedicated ConPTY
|
|
132
|
+
* deferral paths; a `WT_SESSION` sighting on POSIX means Windows Terminal is the
|
|
133
|
+
* outer host fronting WSL, where the same ED3 yank applies. See #1610/#1682/#1799.
|
|
141
134
|
*/
|
|
142
135
|
export function detectTerminalEagerEraseScrollbackRisk(
|
|
143
136
|
env: NodeJS.ProcessEnv = Bun.env,
|
|
144
137
|
platform: NodeJS.Platform = process.platform,
|
|
145
138
|
): boolean {
|
|
146
139
|
if (platform === "win32") return false;
|
|
140
|
+
|
|
141
|
+
const term = env.TERM?.toLowerCase() ?? "";
|
|
142
|
+
const termProgram = env.TERM_PROGRAM?.toLowerCase() ?? "";
|
|
143
|
+
const colorTerm = env.COLORTERM?.toLowerCase() ?? "";
|
|
144
|
+
|
|
145
|
+
if (env.PI_TUI_ED3_SAFE === "1") return false;
|
|
147
146
|
if (env.WT_SESSION) return true;
|
|
147
|
+
if (
|
|
148
|
+
env.SSH_CONNECTION ||
|
|
149
|
+
env.SSH_CLIENT ||
|
|
150
|
+
env.SSH_TTY ||
|
|
151
|
+
env.TMUX ||
|
|
152
|
+
env.STY ||
|
|
153
|
+
env.ZELLIJ ||
|
|
154
|
+
term.startsWith("tmux") ||
|
|
155
|
+
term.startsWith("screen")
|
|
156
|
+
) {
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
148
159
|
if (
|
|
149
160
|
env.WEZTERM_PANE ||
|
|
150
161
|
env.KITTY_WINDOW_ID ||
|
|
@@ -155,17 +166,62 @@ export function detectTerminalEagerEraseScrollbackRisk(
|
|
|
155
166
|
) {
|
|
156
167
|
return true;
|
|
157
168
|
}
|
|
158
|
-
switch (
|
|
169
|
+
switch (termProgram) {
|
|
159
170
|
case "alacritty":
|
|
160
171
|
case "apple_terminal":
|
|
161
172
|
case "ghostty":
|
|
173
|
+
case "gnome-terminal":
|
|
162
174
|
case "iterm.app":
|
|
175
|
+
case "kgx":
|
|
163
176
|
case "kitty":
|
|
177
|
+
case "ptyxis":
|
|
164
178
|
case "wezterm":
|
|
179
|
+
case "xfce4-terminal":
|
|
165
180
|
return true;
|
|
166
181
|
default:
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
if (platform === "linux" && (colorTerm === "truecolor" || colorTerm === "24bit")) return true;
|
|
185
|
+
// Unknown POSIX terminals have no scroll-position oracle. Treat them as risky
|
|
186
|
+
// for passive ED3 until a positive terminal-specific integration proves safe.
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Whether DEC 2026 synchronized-output wrappers should be enabled by default. */
|
|
191
|
+
export function shouldEnableSynchronizedOutputByDefault(
|
|
192
|
+
env: NodeJS.ProcessEnv = Bun.env,
|
|
193
|
+
platform: NodeJS.Platform = process.platform,
|
|
194
|
+
terminalId: TerminalId = TERMINAL_ID,
|
|
195
|
+
): boolean {
|
|
196
|
+
if (env.PI_NO_SYNC_OUTPUT || env.PI_TUI_SYNC_OUTPUT === "0") return false;
|
|
197
|
+
if (env.PI_FORCE_SYNC_OUTPUT === "1" || env.PI_TUI_SYNC_OUTPUT === "1") return true;
|
|
198
|
+
if (platform === "win32") return false;
|
|
199
|
+
|
|
200
|
+
const term = env.TERM?.toLowerCase() ?? "";
|
|
201
|
+
const termProgram = env.TERM_PROGRAM?.toLowerCase() ?? "";
|
|
202
|
+
if (
|
|
203
|
+
env.SSH_CONNECTION ||
|
|
204
|
+
env.SSH_CLIENT ||
|
|
205
|
+
env.SSH_TTY ||
|
|
206
|
+
env.TMUX ||
|
|
207
|
+
env.STY ||
|
|
208
|
+
env.ZELLIJ ||
|
|
209
|
+
term.startsWith("tmux") ||
|
|
210
|
+
term.startsWith("screen")
|
|
211
|
+
) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
if (env.VTE_VERSION) return false;
|
|
215
|
+
switch (termProgram) {
|
|
216
|
+
case "gnome-terminal":
|
|
217
|
+
case "kgx":
|
|
218
|
+
case "ptyxis":
|
|
219
|
+
case "xfce4-terminal":
|
|
167
220
|
return false;
|
|
221
|
+
default:
|
|
222
|
+
break;
|
|
168
223
|
}
|
|
224
|
+
return terminalId === "kitty" || terminalId === "ghostty" || terminalId === "wezterm" || terminalId === "iterm2";
|
|
169
225
|
}
|
|
170
226
|
|
|
171
227
|
/**
|
package/src/terminal.ts
CHANGED
|
@@ -35,7 +35,9 @@ export function emergencyTerminalRestore(): void {
|
|
|
35
35
|
// Blind restore only if we know a terminal was started but lost track of it
|
|
36
36
|
// This avoids writing escape sequences for non-TUI commands (grep, commit, etc.)
|
|
37
37
|
process.stdout.write(
|
|
38
|
-
"\x1b[?
|
|
38
|
+
"\x1b[?2026l" + // End synchronized output
|
|
39
|
+
"\x1b[?7h" + // Restore autowrap
|
|
40
|
+
"\x1b[?2004l" + // Disable bracketed paste
|
|
39
41
|
"\x1b[?2031l" + // Disable Mode 2031 appearance notifications
|
|
40
42
|
"\x1b[?2048l" + // Disable in-band resize notifications
|
|
41
43
|
"\x1b[<u" + // Pop kitty keyboard protocol
|
|
@@ -173,6 +175,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
173
175
|
#wasRaw = false;
|
|
174
176
|
#inputHandler?: (data: string) => void;
|
|
175
177
|
#resizeHandler?: () => void;
|
|
178
|
+
#stdoutResizeListener?: () => void;
|
|
176
179
|
#kittyProtocolActive = false;
|
|
177
180
|
#modifyOtherKeysActive = false;
|
|
178
181
|
#modifyOtherKeysTimeout?: Timer;
|
|
@@ -239,8 +242,14 @@ export class ProcessTerminal implements Terminal {
|
|
|
239
242
|
// Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
|
|
240
243
|
this.#safeWrite("\x1b[?2004h");
|
|
241
244
|
|
|
242
|
-
// Set up resize handler immediately
|
|
243
|
-
|
|
245
|
+
// Set up resize handler immediately. The OS refreshes process.stdout
|
|
246
|
+
// dimensions before firing `resize`, so it is authoritative for geometry:
|
|
247
|
+
// reconcile any stale cached DEC 2048 report before notifying the renderer.
|
|
248
|
+
this.#stdoutResizeListener = () => {
|
|
249
|
+
this.#reconcileInBandGeometryOnResize();
|
|
250
|
+
this.#resizeHandler?.();
|
|
251
|
+
};
|
|
252
|
+
process.stdout.on("resize", this.#stdoutResizeListener);
|
|
244
253
|
|
|
245
254
|
// Refresh terminal dimensions - they may be stale after suspend/resume
|
|
246
255
|
// (SIGWINCH is lost while process is stopped). Unix only.
|
|
@@ -845,6 +854,31 @@ export class ProcessTerminal implements Terminal {
|
|
|
845
854
|
}
|
|
846
855
|
}
|
|
847
856
|
|
|
857
|
+
/**
|
|
858
|
+
* Reconcile cached in-band geometry with the OS on an OS-level resize.
|
|
859
|
+
*
|
|
860
|
+
* SIGWINCH (POSIX) and ConPTY (Windows) refresh `process.stdout.columns`/
|
|
861
|
+
* `rows` before the `resize` event fires, so they are authoritative for the
|
|
862
|
+
* new cell geometry. A cached DEC 2048 report can be stale: the matching
|
|
863
|
+
* post-resize report may be dropped (split across stdin reads past the flush
|
|
864
|
+
* window) or carry `:`-subparameters the parser skips, leaving the getters
|
|
865
|
+
* pinned to the old size — which freezes the rendered width because the
|
|
866
|
+
* renderer reflows against {@link columns}/{@link rows}, not the live OS
|
|
867
|
+
* value. Drop a cached dimension that disagrees with the live OS value; the
|
|
868
|
+
* terminal's next valid in-band report re-seeds pixel sizing.
|
|
869
|
+
*/
|
|
870
|
+
#reconcileInBandGeometryOnResize(): void {
|
|
871
|
+
if (!this.#inBandResizeActive) return;
|
|
872
|
+
const osColumns = process.stdout.columns;
|
|
873
|
+
const osRows = process.stdout.rows;
|
|
874
|
+
if (this.#reportedColumns !== undefined && osColumns > 0 && this.#reportedColumns !== osColumns) {
|
|
875
|
+
this.#reportedColumns = undefined;
|
|
876
|
+
}
|
|
877
|
+
if (this.#reportedRows !== undefined && osRows > 0 && this.#reportedRows !== osRows) {
|
|
878
|
+
this.#reportedRows = undefined;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
848
882
|
async drainInput(maxMs = 1000, idleMs = 50): Promise<void> {
|
|
849
883
|
if (this.#kittyProtocolActive) {
|
|
850
884
|
// Disable Kitty keyboard protocol first so any late key releases
|
|
@@ -897,6 +931,10 @@ export class ProcessTerminal implements Terminal {
|
|
|
897
931
|
this.#safeWrite(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
|
|
898
932
|
}
|
|
899
933
|
|
|
934
|
+
// Leave paint-time terminal modes even if the process exits between the
|
|
935
|
+
// begin/end halves of a frame. Safe no-ops on terminals that ignored them.
|
|
936
|
+
this.#safeWrite("\x1b[?2026l\x1b[?7h");
|
|
937
|
+
|
|
900
938
|
// Disable bracketed paste mode
|
|
901
939
|
this.#safeWrite("\x1b[?2004l");
|
|
902
940
|
|
|
@@ -958,10 +996,11 @@ export class ProcessTerminal implements Terminal {
|
|
|
958
996
|
}
|
|
959
997
|
this.#inputHandler = undefined;
|
|
960
998
|
this.#appearance = undefined;
|
|
961
|
-
if (this.#
|
|
962
|
-
process.stdout.removeListener("resize", this.#
|
|
963
|
-
this.#
|
|
999
|
+
if (this.#stdoutResizeListener) {
|
|
1000
|
+
process.stdout.removeListener("resize", this.#stdoutResizeListener);
|
|
1001
|
+
this.#stdoutResizeListener = undefined;
|
|
964
1002
|
}
|
|
1003
|
+
this.#resizeHandler = undefined;
|
|
965
1004
|
|
|
966
1005
|
// Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
|
|
967
1006
|
// re-interpreted after raw mode is disabled. This fixes a race condition
|
package/src/tui.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
ImageProtocol,
|
|
15
15
|
setCellDimensions,
|
|
16
16
|
setTerminalImageProtocol,
|
|
17
|
+
shouldEnableSynchronizedOutputByDefault,
|
|
17
18
|
TERMINAL,
|
|
18
19
|
} from "./terminal-capabilities";
|
|
19
20
|
import {
|
|
@@ -123,15 +124,31 @@ export interface Component {
|
|
|
123
124
|
* line index where that suffix begins after each render. TUI treats that suffix
|
|
124
125
|
* — and every root child rendered below it — as not yet safe to commit to native
|
|
125
126
|
* scrollback on ED3-risk terminals whose viewport position is unobservable.
|
|
127
|
+
*
|
|
128
|
+
* `getNativeScrollbackCommitSafeEnd` optionally reports a *deeper* boundary
|
|
129
|
+
* inside that live suffix: the line index up to which the live region is
|
|
130
|
+
* append-only (its earlier rows never re-layout, only new rows append at the
|
|
131
|
+
* bottom — a streaming assistant message). Rows in `[liveRegionStart,
|
|
132
|
+
* commitSafeEnd)` that scroll above the viewport are safe to commit to native
|
|
133
|
+
* scrollback even though they are technically live, because they will never
|
|
134
|
+
* change. Without this, a single live block that alone overflows the viewport
|
|
135
|
+
* loses its scrolled-off head (committed nowhere, repainted nowhere). Volatile
|
|
136
|
+
* live blocks (tool previews that collapse) omit it, so their mutable rows stay
|
|
137
|
+
* deferred. Defaults to `liveRegionStart` when absent.
|
|
126
138
|
*/
|
|
127
139
|
export interface NativeScrollbackLiveRegion {
|
|
128
140
|
getNativeScrollbackLiveRegionStart(): number | undefined;
|
|
141
|
+
getNativeScrollbackCommitSafeEnd?(): number | undefined;
|
|
129
142
|
}
|
|
130
143
|
|
|
131
144
|
function getNativeScrollbackLiveRegionStart(component: Component): number | undefined {
|
|
132
145
|
return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackLiveRegionStart?.();
|
|
133
146
|
}
|
|
134
147
|
|
|
148
|
+
function getNativeScrollbackCommitSafeEnd(component: Component): number | undefined {
|
|
149
|
+
return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackCommitSafeEnd?.();
|
|
150
|
+
}
|
|
151
|
+
|
|
135
152
|
/**
|
|
136
153
|
* Interface for components that can receive focus and display a cursor.
|
|
137
154
|
* When focused, the component should emit CURSOR_MARKER at the cursor position
|
|
@@ -168,9 +185,8 @@ export interface RenderRequestOptions {
|
|
|
168
185
|
allowUnknownViewportMutation?: boolean;
|
|
169
186
|
}
|
|
170
187
|
|
|
171
|
-
/** Options for deferred native scrollback rebuild checkpoints. */
|
|
188
|
+
/** Options for deferred native scrollback rebuild checkpoints. Reserved for API stability. */
|
|
172
189
|
export interface NativeScrollbackRefreshOptions {
|
|
173
|
-
/** Allow replay when the terminal cannot report viewport state. Use only for explicit user submit checkpoints. */
|
|
174
190
|
allowUnknownViewport?: boolean;
|
|
175
191
|
}
|
|
176
192
|
/** Type guard to check if a component implements Focusable */
|
|
@@ -387,7 +403,7 @@ export class TUI extends Container {
|
|
|
387
403
|
#sixelProbeUnsubscribe?: () => void;
|
|
388
404
|
#showHardwareCursor = $flag("PI_HARDWARE_CURSOR");
|
|
389
405
|
#clearOnShrink = $flag("PI_CLEAR_ON_SHRINK"); // Clear empty rows when content shrinks (default: off)
|
|
390
|
-
#synchronizedOutputEnabled =
|
|
406
|
+
#synchronizedOutputEnabled = shouldEnableSynchronizedOutputByDefault();
|
|
391
407
|
#paintBeginSequence = this.#synchronizedOutputEnabled ? PAINT_BEGIN : PAINT_BEGIN_NO_SYNC;
|
|
392
408
|
#paintEndSequence = this.#synchronizedOutputEnabled ? PAINT_END : PAINT_END_NO_SYNC;
|
|
393
409
|
#cursorBeginSequence = this.#synchronizedOutputEnabled ? CURSOR_BEGIN : CURSOR_BEGIN_NO_SYNC;
|
|
@@ -403,6 +419,7 @@ export class TUI extends Container {
|
|
|
403
419
|
// not scroll replayed live chrome (status/editor) into fresh history.
|
|
404
420
|
#suppressNextSuffixScroll = false;
|
|
405
421
|
#nativeScrollbackLiveRegionStart: number | undefined;
|
|
422
|
+
#nativeScrollbackCommitSafeEnd: number | undefined;
|
|
406
423
|
#nativeScrollbackDirty = false;
|
|
407
424
|
// Highest `#maxLinesRendered` reached during a foreground tool turn while
|
|
408
425
|
// intermediate frames were prevented from committing to terminal scrollback.
|
|
@@ -456,6 +473,7 @@ export class TUI extends Container {
|
|
|
456
473
|
override render(width: number): string[] {
|
|
457
474
|
width = Math.max(1, width);
|
|
458
475
|
this.#nativeScrollbackLiveRegionStart = undefined;
|
|
476
|
+
this.#nativeScrollbackCommitSafeEnd = undefined;
|
|
459
477
|
const lines: string[] = [];
|
|
460
478
|
for (const child of this.children) {
|
|
461
479
|
const offset = lines.length;
|
|
@@ -466,6 +484,13 @@ export class TUI extends Container {
|
|
|
466
484
|
? Math.max(0, Math.min(childLines.length, Math.trunc(liveRegionStart)))
|
|
467
485
|
: childLines.length;
|
|
468
486
|
this.#nativeScrollbackLiveRegionStart = offset + boundedStart;
|
|
487
|
+
const commitSafeEnd = getNativeScrollbackCommitSafeEnd(child);
|
|
488
|
+
if (commitSafeEnd !== undefined) {
|
|
489
|
+
const boundedEnd = Number.isFinite(commitSafeEnd)
|
|
490
|
+
? Math.max(boundedStart, Math.min(childLines.length, Math.trunc(commitSafeEnd)))
|
|
491
|
+
: childLines.length;
|
|
492
|
+
this.#nativeScrollbackCommitSafeEnd = offset + boundedEnd;
|
|
493
|
+
}
|
|
469
494
|
}
|
|
470
495
|
lines.push(...childLines);
|
|
471
496
|
}
|
|
@@ -525,8 +550,8 @@ export class TUI extends Container {
|
|
|
525
550
|
|
|
526
551
|
/**
|
|
527
552
|
* Whether DEC 2026 synchronized-output wrappers are currently emitted around
|
|
528
|
-
* paints. Starts from
|
|
529
|
-
* the terminal reports mode 2026 unsupported via DECRQM.
|
|
553
|
+
* paints. Starts from conservative terminal/env detection and is force-disabled
|
|
554
|
+
* at runtime if the terminal reports mode 2026 unsupported via DECRQM.
|
|
530
555
|
*/
|
|
531
556
|
get synchronizedOutput(): boolean {
|
|
532
557
|
return this.#synchronizedOutputEnabled;
|
|
@@ -874,6 +899,11 @@ export class TUI extends Container {
|
|
|
874
899
|
}
|
|
875
900
|
|
|
876
901
|
stop(): void {
|
|
902
|
+
if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
|
|
903
|
+
for (const id of this.#imageBudget.takeAllTransmittedIds()) {
|
|
904
|
+
this.terminal.write(encodeKittyDeleteImage(id));
|
|
905
|
+
}
|
|
906
|
+
}
|
|
877
907
|
this.#clearSixelProbeState();
|
|
878
908
|
this.#stopped = true;
|
|
879
909
|
if (this.#renderTimer) {
|
|
@@ -908,7 +938,7 @@ export class TUI extends Container {
|
|
|
908
938
|
* Callers should only invoke this at checkpoints where the user is expected to be
|
|
909
939
|
* at the terminal bottom, such as after submitting a new prompt.
|
|
910
940
|
*/
|
|
911
|
-
refreshNativeScrollbackIfDirty(
|
|
941
|
+
refreshNativeScrollbackIfDirty(_options?: NativeScrollbackRefreshOptions): boolean {
|
|
912
942
|
if (!this.#nativeScrollbackDirty || this.#stopped) return false;
|
|
913
943
|
// Multiplexer panes preserve their own history and never receive a
|
|
914
944
|
// destructive clear, so a checkpoint "replay" cannot reconcile anything —
|
|
@@ -918,18 +948,11 @@ export class TUI extends Container {
|
|
|
918
948
|
this.#clearNativeScrollbackDirty();
|
|
919
949
|
return false;
|
|
920
950
|
}
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
if (nativeViewportAtBottom === false && allowUnknownViewport) {
|
|
924
|
-
const retriedViewportAtBottom = this.#readNativeViewportAtBottom();
|
|
925
|
-
if (this.#canReplayNativeScrollbackAtCheckpoint(retriedViewportAtBottom, allowUnknownViewport)) {
|
|
926
|
-
nativeViewportAtBottom = retriedViewportAtBottom;
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
if (!this.#canReplayNativeScrollbackAtCheckpoint(nativeViewportAtBottom, allowUnknownViewport)) {
|
|
951
|
+
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
|
|
952
|
+
if (!this.#canReplayNativeScrollbackAtCheckpoint(nativeViewportAtBottom)) {
|
|
930
953
|
return false;
|
|
931
954
|
}
|
|
932
|
-
this.#prepareForcedRender(true,
|
|
955
|
+
this.#prepareForcedRender(true, false);
|
|
933
956
|
this.#renderRequested = false;
|
|
934
957
|
this.#lastRenderAt = this.#renderScheduler.now();
|
|
935
958
|
this.#doRender();
|
|
@@ -957,7 +980,7 @@ export class TUI extends Container {
|
|
|
957
980
|
this.#renderScheduler.scheduleImmediate(() => this.#scheduleRender());
|
|
958
981
|
}
|
|
959
982
|
|
|
960
|
-
#prepareForcedRender(clearScrollback: boolean,
|
|
983
|
+
#prepareForcedRender(clearScrollback: boolean, _allowUnknownViewportMutation: boolean): void {
|
|
961
984
|
const geometryChanged =
|
|
962
985
|
(this.#previousWidth > 0 && this.#previousWidth !== this.terminal.columns) ||
|
|
963
986
|
(this.#previousHeight > 0 && this.#previousHeight !== this.terminal.rows);
|
|
@@ -967,7 +990,7 @@ export class TUI extends Container {
|
|
|
967
990
|
const replayGeometry =
|
|
968
991
|
geometryChanged &&
|
|
969
992
|
!isMultiplexerSession() &&
|
|
970
|
-
this.#canReplayNativeScrollbackAtCheckpoint(this.#readNativeViewportAtBottom()
|
|
993
|
+
this.#canReplayNativeScrollbackAtCheckpoint(this.#readNativeViewportAtBottom());
|
|
971
994
|
this.#clearScrollbackOnNextRender ||= clearScrollback || replayGeometry;
|
|
972
995
|
this.#forceViewportRepaintOnNextRender = true;
|
|
973
996
|
if (this.#renderTimer) {
|
|
@@ -1457,6 +1480,7 @@ export class TUI extends Container {
|
|
|
1457
1480
|
overlayVisibilityReduced,
|
|
1458
1481
|
allowUnknownViewportMutation,
|
|
1459
1482
|
this.#nativeScrollbackLiveRegionStart,
|
|
1483
|
+
this.#nativeScrollbackCommitSafeEnd,
|
|
1460
1484
|
);
|
|
1461
1485
|
// 3b. Defer scrollback commits during foreground streaming, but only on
|
|
1462
1486
|
// ED3-risk terminals whose committed scrollback cannot be rewritten without
|
|
@@ -1476,8 +1500,18 @@ export class TUI extends Container {
|
|
|
1476
1500
|
if (streamingWasActive && eagerEraseScrollbackRisk) {
|
|
1477
1501
|
const streamingActive =
|
|
1478
1502
|
this.#eagerNativeScrollbackRebuild && !this.#eagerNativeScrollbackRebuildDisablePending;
|
|
1503
|
+
// A terminal resize reflowed native scrollback at the OLD geometry, so the
|
|
1504
|
+
// saved rows are already mis-wrapped garbage. The planned historyRebuild
|
|
1505
|
+
// must stand and erase them (ED 3) — capping to a viewport repaint would
|
|
1506
|
+
// leave the corrupt history on screen. Like the other reconciles, a resize
|
|
1507
|
+
// is an explicit user action that snaps the host to the bottom, so there is
|
|
1508
|
+
// no scrolled reader to yank.
|
|
1509
|
+
const geometryChanged = widthChanged || heightChanged;
|
|
1479
1510
|
const explicitReconcile =
|
|
1480
|
-
explicitViewportMutation ||
|
|
1511
|
+
explicitViewportMutation ||
|
|
1512
|
+
this.#clearScrollbackOnNextRender ||
|
|
1513
|
+
overlayVisibilityReduced ||
|
|
1514
|
+
geometryChanged;
|
|
1481
1515
|
// The defer below exists only to avoid `\r\n`-scrolling transient frames
|
|
1482
1516
|
// past a reader parked in native scrollback. When the terminal can report
|
|
1483
1517
|
// that the viewport is at the tail, there is no scrolled reader to yank,
|
|
@@ -1536,10 +1570,31 @@ export class TUI extends Container {
|
|
|
1536
1570
|
this.#previousWidth = width;
|
|
1537
1571
|
this.#previousHeight = height;
|
|
1538
1572
|
return;
|
|
1539
|
-
case "initial":
|
|
1540
|
-
|
|
1573
|
+
case "initial": {
|
|
1574
|
+
const liveRegionStart = this.#nativeScrollbackLiveRegionStart;
|
|
1575
|
+
if (
|
|
1576
|
+
this.#eagerNativeScrollbackRebuild &&
|
|
1577
|
+
eagerEraseScrollbackRisk &&
|
|
1578
|
+
!allowUnknownViewportMutation &&
|
|
1579
|
+
liveRegionStart !== undefined &&
|
|
1580
|
+
liveRegionStart < lines.length &&
|
|
1581
|
+
!isMultiplexerSession() &&
|
|
1582
|
+
this.#readNativeViewportAtBottom() === undefined
|
|
1583
|
+
) {
|
|
1584
|
+
this.#emitInitialLiveRegionPinnedPaint(
|
|
1585
|
+
lines,
|
|
1586
|
+
width,
|
|
1587
|
+
height,
|
|
1588
|
+
cursorPos,
|
|
1589
|
+
liveRegionStart,
|
|
1590
|
+
this.#nativeScrollbackCommitSafeEnd,
|
|
1591
|
+
);
|
|
1592
|
+
} else {
|
|
1593
|
+
this.#emitFullPaint(lines, width, height, cursorPos, { clearViewport: true, clearScrollback: false });
|
|
1594
|
+
}
|
|
1541
1595
|
this.#hasEverRendered = true;
|
|
1542
1596
|
return;
|
|
1597
|
+
}
|
|
1543
1598
|
case "sessionReplace":
|
|
1544
1599
|
this.#clearScrollbackOnNextRender = false;
|
|
1545
1600
|
this.#clearNativeScrollbackDirty();
|
|
@@ -1613,11 +1668,11 @@ export class TUI extends Container {
|
|
|
1613
1668
|
|
|
1614
1669
|
/**
|
|
1615
1670
|
* Map the current frame onto a single render intent. Order matters: forced
|
|
1616
|
-
* resets and session replacement short-circuit
|
|
1617
|
-
*
|
|
1618
|
-
*
|
|
1619
|
-
*
|
|
1620
|
-
*
|
|
1671
|
+
* resets and session replacement short-circuit first, then a terminal resize
|
|
1672
|
+
* (width or height change) always reduces to a clean reset + redraw at the new
|
|
1673
|
+
* geometry — `historyRebuild` normally, `viewportRepaint` inside a multiplexer
|
|
1674
|
+
* whose pane scrollback cannot be erased. Pure content mutations fall through
|
|
1675
|
+
* to the differential machinery below.
|
|
1621
1676
|
*/
|
|
1622
1677
|
#planRender(
|
|
1623
1678
|
newLines: string[],
|
|
@@ -1629,6 +1684,7 @@ export class TUI extends Container {
|
|
|
1629
1684
|
overlayVisibilityReduced: boolean,
|
|
1630
1685
|
allowUnknownViewportMutation: boolean,
|
|
1631
1686
|
liveRegionStart: number | undefined,
|
|
1687
|
+
commitSafeEnd: number | undefined,
|
|
1632
1688
|
): RenderIntent {
|
|
1633
1689
|
// Initial paint after start(): scrollback must keep its prior shell
|
|
1634
1690
|
// content, but the viewport must be cleared so stale rows do not bleed
|
|
@@ -1643,6 +1699,25 @@ export class TUI extends Container {
|
|
|
1643
1699
|
if (overlayVisibilityReduced && !isMultiplexerSession()) {
|
|
1644
1700
|
return hasVisibleOverlay ? { kind: "overlayRebuild" } : { kind: "historyRebuild" };
|
|
1645
1701
|
}
|
|
1702
|
+
|
|
1703
|
+
// A terminal resize (width or height change) reflows the terminal's own
|
|
1704
|
+
// buffer, moving rows between the viewport and native scrollback and
|
|
1705
|
+
// invalidating every cursor/viewport anchor the diff and append emitters
|
|
1706
|
+
// rely on. Always reset cleanly at the new geometry and redraw. Inside a
|
|
1707
|
+
// multiplexer the pane's saved lines cannot be erased (ED 3 is a no-op there
|
|
1708
|
+
// and a full replay only duplicates the transcript), so repaint the visible
|
|
1709
|
+
// window in place; a visible overlay rebuilds with its composite. This
|
|
1710
|
+
// deliberately drops the no-overflow and confirmed-scrolled guards — a
|
|
1711
|
+
// resize is an explicit user action, so a scrolled reader snaps to the
|
|
1712
|
+
// bottom and preexisting shell scrollback above the UI is cleared. The
|
|
1713
|
+
// streaming cap above explicitly exempts geometry changes, so even during
|
|
1714
|
+
// active ED3-risk foreground streaming this rebuild stands and erases the
|
|
1715
|
+
// scrollback the terminal just re-wrapped at the old size.
|
|
1716
|
+
if (widthChanged || heightChanged) {
|
|
1717
|
+
if (isMultiplexerSession()) return { kind: "viewportRepaint" };
|
|
1718
|
+
return hasVisibleOverlay ? { kind: "overlayRebuild" } : { kind: "historyRebuild" };
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1646
1721
|
if (hasVisibleOverlay) {
|
|
1647
1722
|
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
|
|
1648
1723
|
// Multiplexer panes never get a destructive scrollback clear
|
|
@@ -1665,9 +1740,9 @@ export class TUI extends Container {
|
|
|
1665
1740
|
newLines,
|
|
1666
1741
|
height,
|
|
1667
1742
|
liveRegionStart,
|
|
1743
|
+
commitSafeEnd,
|
|
1668
1744
|
eagerEraseScrollbackRisk,
|
|
1669
1745
|
allowUnknownViewportMutation,
|
|
1670
|
-
widthChanged || heightChanged,
|
|
1671
1746
|
);
|
|
1672
1747
|
if (liveRegionPinnedIntent) return liveRegionPinnedIntent;
|
|
1673
1748
|
|
|
@@ -1712,14 +1787,11 @@ export class TUI extends Container {
|
|
|
1712
1787
|
this.#markNativeScrollbackDirty();
|
|
1713
1788
|
return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
|
|
1714
1789
|
}
|
|
1715
|
-
// A
|
|
1716
|
-
//
|
|
1717
|
-
//
|
|
1718
|
-
//
|
|
1719
|
-
if (
|
|
1720
|
-
widthChanged ||
|
|
1721
|
-
this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, allowUnknownViewportMutation)
|
|
1722
|
-
) {
|
|
1790
|
+
// A shrink that re-exposes rows already committed to native scrollback
|
|
1791
|
+
// must rebuild so the stale committed copy is cleared. Rebuild only with a
|
|
1792
|
+
// positive at-tail proof; unknown viewports stay dirty because the host
|
|
1793
|
+
// scroll position is not observable and ED3 can yank readers.
|
|
1794
|
+
if (this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, false)) {
|
|
1723
1795
|
return { kind: "historyRebuild" };
|
|
1724
1796
|
}
|
|
1725
1797
|
// POSIX terminals — and Windows Terminal/ConPTY — that cannot report the
|
|
@@ -1744,16 +1816,17 @@ export class TUI extends Container {
|
|
|
1744
1816
|
return this.#eagerNativeScrollbackRebuild ? { kind: "viewportRepaint" } : { kind: "deferredMutation" };
|
|
1745
1817
|
}
|
|
1746
1818
|
|
|
1747
|
-
// Non-ED3-risk POSIX with an unobservable viewport.
|
|
1748
|
-
//
|
|
1749
|
-
//
|
|
1750
|
-
//
|
|
1751
|
-
// top, `deferredShrink` would draw only blank padding and hide the live
|
|
1752
|
-
// prompt, so rebuild history instead (ED3 is safe on these terminals).
|
|
1819
|
+
// Non-ED3-risk POSIX with an unobservable viewport. `deferredShrink` is
|
|
1820
|
+
// safe only when changed rows are at or below the previous viewport top.
|
|
1821
|
+
// Middle/offscreen deletes renumber rows above the viewport and padding
|
|
1822
|
+
// the old length would repaint shifted rows or blank tail cells.
|
|
1753
1823
|
if (newLines.length <= paddedViewportTop) {
|
|
1754
1824
|
return { kind: "historyRebuild" };
|
|
1755
1825
|
}
|
|
1756
1826
|
this.#markNativeScrollbackDirty();
|
|
1827
|
+
if (diff.firstChanged < prevViewportTop) {
|
|
1828
|
+
return { kind: "deferredMutation" };
|
|
1829
|
+
}
|
|
1757
1830
|
return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
|
|
1758
1831
|
}
|
|
1759
1832
|
|
|
@@ -1813,8 +1886,6 @@ export class TUI extends Container {
|
|
|
1813
1886
|
this.#suppressNextSuffixScroll = false;
|
|
1814
1887
|
if (
|
|
1815
1888
|
suppressSuffixScroll &&
|
|
1816
|
-
!widthChanged &&
|
|
1817
|
-
!heightChanged &&
|
|
1818
1889
|
diff.appendedLines &&
|
|
1819
1890
|
diff.firstChanged < this.#previousLines.length &&
|
|
1820
1891
|
!isMultiplexerSession()
|
|
@@ -1843,51 +1914,13 @@ export class TUI extends Container {
|
|
|
1843
1914
|
}
|
|
1844
1915
|
|
|
1845
1916
|
if (diff.firstChanged === -1) {
|
|
1846
|
-
//
|
|
1847
|
-
// the
|
|
1848
|
-
//
|
|
1849
|
-
// repaint can leave native history out of sync with the transcript, and —
|
|
1850
|
-
// unlike a content-bearing resize, which rebuilds via the geometry branch
|
|
1851
|
-
// below — nothing flags it. Mark scrollback dirty so the next checkpoint
|
|
1852
|
-
// (refreshNativeScrollbackIfDirty) reconciles it; a known-at-bottom reader
|
|
1853
|
-
// rebuilds unconditionally at its checkpoint and needs no flag.
|
|
1854
|
-
if (
|
|
1855
|
-
(widthChanged || heightChanged) &&
|
|
1856
|
-
!isMultiplexerSession() &&
|
|
1857
|
-
newLines.length > height &&
|
|
1858
|
-
this.#readNativeViewportAtBottom() !== true
|
|
1859
|
-
) {
|
|
1860
|
-
this.#markNativeScrollbackDirty();
|
|
1861
|
-
}
|
|
1862
|
-
// Content unchanged. A forced render still needs to refresh the visible
|
|
1863
|
-
// viewport, but it must keep the existing diff basis so later coalesced
|
|
1864
|
-
// content mutations can still update native scrollback correctly.
|
|
1917
|
+
// Content unchanged. A forced render still refreshes the visible viewport
|
|
1918
|
+
// but keeps the existing diff basis so later coalesced content mutations
|
|
1919
|
+
// can still update native scrollback correctly.
|
|
1865
1920
|
if (forceViewportRepaint) return { kind: "viewportRepaint" };
|
|
1866
|
-
// Width changes alter wrapping geometry; height changes expose or hide
|
|
1867
|
-
// viewport rows. Repaint any non-multiplexer resize, including Termux
|
|
1868
|
-
// software-keyboard toggles: leaving the new rows blank creates phantom
|
|
1869
|
-
// viewport space that later appends can fill without growing scrollback.
|
|
1870
|
-
if (widthChanged) return { kind: "viewportRepaint" };
|
|
1871
|
-
if (heightChanged && !isMultiplexerSession()) return { kind: "viewportRepaint" };
|
|
1872
1921
|
return { kind: "noop" };
|
|
1873
1922
|
}
|
|
1874
1923
|
|
|
1875
|
-
// Width changes rewrap native history. Any non-append content change must
|
|
1876
|
-
// rebuild the committed transcript when the viewport is safe; a viewport
|
|
1877
|
-
// repaint can leave old-width wrapped fragments above the live frame, and
|
|
1878
|
-
// later appends then splice new rows onto stale history.
|
|
1879
|
-
if (widthChanged) {
|
|
1880
|
-
const pureAppend = diff.appendedLines && diff.firstChanged === this.#previousLines.length;
|
|
1881
|
-
if (!pureAppend) {
|
|
1882
|
-
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
|
|
1883
|
-
if (this.#nativeViewportIsScrolled(nativeViewportAtBottom, allowUnknownViewportMutation)) {
|
|
1884
|
-
this.#markNativeScrollbackDirty();
|
|
1885
|
-
return { kind: "viewportRepaint" };
|
|
1886
|
-
}
|
|
1887
|
-
return isMultiplexerSession() ? { kind: "viewportRepaint" } : { kind: "historyRebuild" };
|
|
1888
|
-
}
|
|
1889
|
-
}
|
|
1890
|
-
|
|
1891
1924
|
const contentGrew = newLines.length > this.#previousLines.length;
|
|
1892
1925
|
const pureAppend = diff.appendedLines && diff.firstChanged === this.#previousLines.length;
|
|
1893
1926
|
const structuralMutation = newLines.length !== this.#previousLines.length || diff.firstChanged < prevViewportTop;
|
|
@@ -1909,10 +1942,11 @@ export class TUI extends Container {
|
|
|
1909
1942
|
return { kind: "viewportRepaint" };
|
|
1910
1943
|
}
|
|
1911
1944
|
}
|
|
1912
|
-
//
|
|
1913
|
-
//
|
|
1914
|
-
//
|
|
1915
|
-
|
|
1945
|
+
// A structural mutation (offscreen edit or inserted rows) while bottom-
|
|
1946
|
+
// anchored: when the reader is scrolled, repaint/clamp without trusting the
|
|
1947
|
+
// stale viewport anchors; otherwise rebuild native history when a safe
|
|
1948
|
+
// checkpoint allows.
|
|
1949
|
+
if (!pureAppend && structuralMutation && !isMultiplexerSession()) {
|
|
1916
1950
|
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
|
|
1917
1951
|
if (this.#nativeViewportIsScrolled(nativeViewportAtBottom, allowUnknownViewportMutation)) {
|
|
1918
1952
|
this.#markNativeScrollbackDirty();
|
|
@@ -1955,67 +1989,6 @@ export class TUI extends Container {
|
|
|
1955
1989
|
}
|
|
1956
1990
|
}
|
|
1957
1991
|
|
|
1958
|
-
// Height changes shift the visible window. Repaint when content didn't grow,
|
|
1959
|
-
// but skip inside multiplexers (panes manage their own redraws — handled by
|
|
1960
|
-
// the multiplexer geometry branch below). Termux is deliberately included:
|
|
1961
|
-
// a resize with no content change still exposes or hides viewport rows, and
|
|
1962
|
-
// leaving those rows blank lets later appends fill phantom space instead of
|
|
1963
|
-
// growing native scrollback.
|
|
1964
|
-
if (heightChanged && !contentGrew && !isMultiplexerSession()) {
|
|
1965
|
-
return { kind: "viewportRepaint" };
|
|
1966
|
-
}
|
|
1967
|
-
|
|
1968
|
-
// A height change that also grew the content into a frame that now fits
|
|
1969
|
-
// entirely on screen cannot use the diff or append-tail emitters below:
|
|
1970
|
-
// both position scrolled rows against the previous viewport top and
|
|
1971
|
-
// hardware cursor row, which the reflow just invalidated, so the appended
|
|
1972
|
-
// tail lands `height`-delta rows too low. With no overflow there is no
|
|
1973
|
-
// native scrollback to preserve, so repaint the viewport at the new
|
|
1974
|
-
// geometry. (Height changes with overflow keep the existing deferral.)
|
|
1975
|
-
if (heightChanged && newLines.length <= height && !isMultiplexerSession()) {
|
|
1976
|
-
return { kind: "viewportRepaint" };
|
|
1977
|
-
}
|
|
1978
|
-
|
|
1979
|
-
// Any other geometry change (height shrink with content overflowing the
|
|
1980
|
-
// viewport, or a width change carrying a pure append) must not reach the
|
|
1981
|
-
// anchor-relative diff/append emitters below either. The terminal reflowed
|
|
1982
|
-
// its own buffer on resize — a height shrink moves committed rows between
|
|
1983
|
-
// scrollback and viewport — so the previous frame's viewport-top and
|
|
1984
|
-
// hardware-cursor anchors no longer describe the screen, and scrolling
|
|
1985
|
-
// relative to them splices phantom blank rows into native scrollback
|
|
1986
|
-
// (stress repro: darwin-normal-large seed 0x5eed1234 op 1062, a
|
|
1987
|
-
// resizeHeight coalesced with a streamed append). A resize is an explicit
|
|
1988
|
-
// user action, so rebuilding history at the new geometry is the
|
|
1989
|
-
// established tradeoff (see the width-change branch above); a reader
|
|
1990
|
-
// confirmed scrolled into history is still never yanked. Termux is included
|
|
1991
|
-
// (it is not a multiplexer and ED3 clears its own scrollback): a content-
|
|
1992
|
-
// bearing resize must not reach the stale-anchor emitters below.
|
|
1993
|
-
if ((heightChanged || widthChanged) && !isMultiplexerSession()) {
|
|
1994
|
-
// No overflow → nothing of ours in native scrollback to reconcile; an
|
|
1995
|
-
// in-place repaint also keeps preexisting shell scrollback intact.
|
|
1996
|
-
if (newLines.length <= height) {
|
|
1997
|
-
return { kind: "viewportRepaint" };
|
|
1998
|
-
}
|
|
1999
|
-
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
|
|
2000
|
-
if (this.#nativeViewportIsKnownScrolled(nativeViewportAtBottom)) {
|
|
2001
|
-
this.#markNativeScrollbackDirty();
|
|
2002
|
-
return { kind: "viewportRepaint" };
|
|
2003
|
-
}
|
|
2004
|
-
return { kind: "historyRebuild" };
|
|
2005
|
-
}
|
|
2006
|
-
|
|
2007
|
-
// The same geometry hazard inside a multiplexer: tmux reflows the pane
|
|
2008
|
-
// grid (visible rows AND pane history) on resize, so the anchor-relative
|
|
2009
|
-
// diff/append emitters below are equally invalid — but a destructive
|
|
2010
|
-
// rebuild is impossible there (pane history cannot be cleared; a full
|
|
2011
|
-
// replay only appends a duplicate transcript copy). Repaint the visible
|
|
2012
|
-
// window in place at the new geometry. Applies even under Termux: a
|
|
2013
|
-
// repaint per keyboard-toggle resize is cheaper than splicing phantom
|
|
2014
|
-
// rows into the pane.
|
|
2015
|
-
if ((heightChanged || widthChanged) && isMultiplexerSession()) {
|
|
2016
|
-
return { kind: "viewportRepaint" };
|
|
2017
|
-
}
|
|
2018
|
-
|
|
2019
1992
|
// Configurable shrink-clear: opt-in path that repaints to wipe rows the
|
|
2020
1993
|
// diff path would leave behind.
|
|
2021
1994
|
if (this.#clearOnShrink && newLines.length < this.#previousLines.length && this.overlayStack.length === 0) {
|
|
@@ -2043,6 +2016,14 @@ export class TUI extends Container {
|
|
|
2043
2016
|
return { kind: "historyRebuild" };
|
|
2044
2017
|
}
|
|
2045
2018
|
this.#markNativeScrollbackDirty();
|
|
2019
|
+
if (
|
|
2020
|
+
nativeViewportAtBottom === undefined &&
|
|
2021
|
+
eagerEraseScrollbackRisk &&
|
|
2022
|
+
!cleanTailAppend &&
|
|
2023
|
+
!this.#eagerNativeScrollbackRebuild
|
|
2024
|
+
) {
|
|
2025
|
+
return { kind: "deferredMutation" };
|
|
2026
|
+
}
|
|
2046
2027
|
return { kind: "viewportRepaint", appendFrom: cleanTailAppend ? this.#previousLines.length : undefined };
|
|
2047
2028
|
}
|
|
2048
2029
|
|
|
@@ -2149,15 +2130,8 @@ export class TUI extends Container {
|
|
|
2149
2130
|
#nativeViewportIsKnownScrolled(nativeViewportAtBottom: boolean | undefined): boolean {
|
|
2150
2131
|
return nativeViewportAtBottom === false;
|
|
2151
2132
|
}
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
nativeViewportAtBottom: boolean | undefined,
|
|
2155
|
-
allowUnknownViewport: boolean,
|
|
2156
|
-
): boolean {
|
|
2157
|
-
return (
|
|
2158
|
-
nativeViewportAtBottom === true ||
|
|
2159
|
-
(nativeViewportAtBottom === undefined && (allowUnknownViewport || process.platform !== "win32"))
|
|
2160
|
-
);
|
|
2133
|
+
#canReplayNativeScrollbackAtCheckpoint(nativeViewportAtBottom: boolean | undefined): boolean {
|
|
2134
|
+
return nativeViewportAtBottom === true;
|
|
2161
2135
|
}
|
|
2162
2136
|
|
|
2163
2137
|
/**
|
|
@@ -2167,10 +2141,9 @@ export class TUI extends Container {
|
|
|
2167
2141
|
* the native viewport) is safe to emit *during ordinary rendering*. POSIX
|
|
2168
2142
|
* terminals cannot report whether the user has scrolled up
|
|
2169
2143
|
* (`isNativeViewportAtBottom()` is `undefined`), so an unknown position is
|
|
2170
|
-
* treated as unsafe: defer to a non-destructive viewport repaint
|
|
2171
|
-
* scrollback dirty
|
|
2172
|
-
*
|
|
2173
|
-
* editor keystroke has already pinned the terminal to the bottom. Without
|
|
2144
|
+
* treated as unsafe: defer to a non-destructive viewport repaint and keep
|
|
2145
|
+
* scrollback dirty until a later render has a positive at-tail proof. A prompt
|
|
2146
|
+
* submit is no longer treated as proof for unobservable host scrollback.
|
|
2174
2147
|
* this, every offscreen transcript edit while streaming wiped scrollback and
|
|
2175
2148
|
* yanked a scrolled-up reader out of their current context.
|
|
2176
2149
|
* `allowUnknownViewportMutation` (autocomplete/IME) opts directly
|
|
@@ -2192,21 +2165,16 @@ export class TUI extends Container {
|
|
|
2192
2165
|
newLines: string[],
|
|
2193
2166
|
height: number,
|
|
2194
2167
|
liveRegionStart: number | undefined,
|
|
2168
|
+
commitSafeEnd: number | undefined,
|
|
2195
2169
|
eagerEraseScrollbackRisk: boolean,
|
|
2196
2170
|
allowUnknownViewportMutation: boolean,
|
|
2197
|
-
geometryChanged: boolean,
|
|
2198
2171
|
): 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
2172
|
if (
|
|
2204
2173
|
liveRegionStart === undefined ||
|
|
2205
2174
|
liveRegionStart >= newLines.length ||
|
|
2206
2175
|
!this.#eagerNativeScrollbackRebuild ||
|
|
2207
2176
|
!eagerEraseScrollbackRisk ||
|
|
2208
2177
|
allowUnknownViewportMutation ||
|
|
2209
|
-
geometryChanged ||
|
|
2210
2178
|
isMultiplexerSession()
|
|
2211
2179
|
) {
|
|
2212
2180
|
return undefined;
|
|
@@ -2216,25 +2184,28 @@ export class TUI extends Container {
|
|
|
2216
2184
|
|
|
2217
2185
|
this.#markNativeScrollbackDirty();
|
|
2218
2186
|
const naturalViewportTop = Math.max(0, newLines.length - height);
|
|
2219
|
-
// Rows before the live-region boundary are sealed.
|
|
2220
|
-
//
|
|
2221
|
-
//
|
|
2222
|
-
//
|
|
2223
|
-
//
|
|
2224
|
-
//
|
|
2225
|
-
//
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
//
|
|
2229
|
-
//
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
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;
|
|
2187
|
+
// Rows before the live-region boundary are sealed. The commit boundary is
|
|
2188
|
+
// the deeper of the sealed start and the append-only `commitSafeEnd`: a
|
|
2189
|
+
// streaming assistant block reports a `commitSafeEnd` spanning its whole
|
|
2190
|
+
// body, so its head rows that scroll above the viewport commit to native
|
|
2191
|
+
// scrollback instead of vanishing (committed nowhere, repainted nowhere).
|
|
2192
|
+
// A volatile live block (a tool preview that later collapses) omits
|
|
2193
|
+
// `commitSafeEnd`, so the boundary falls back to `liveRegionStart` and its
|
|
2194
|
+
// mutable rows stay deferred — otherwise a pending box that later collapses
|
|
2195
|
+
// to its running/final shape leaves the old top half in scrollback and
|
|
2196
|
+
// repaints the new tail below it, visually splitting one box across the
|
|
2197
|
+
// scrollback seam.
|
|
2198
|
+
const commitBoundary = commitSafeEnd ?? liveRegionStart;
|
|
2199
|
+
const sealedAppendTo = Math.min(naturalViewportTop, commitBoundary);
|
|
2200
|
+
const appendTo = Math.max(0, sealedAppendTo);
|
|
2237
2201
|
const appendFrom = Math.min(this.#scrollbackHighWater, appendTo);
|
|
2202
|
+
// If the live-region collapse would re-expose committed rows already written
|
|
2203
|
+
// to native scrollback, clamp the repaint below that committed prefix so
|
|
2204
|
+
// committed rows are not duplicated. Mutable rows beyond the commit boundary
|
|
2205
|
+
// may remain hidden above the viewport until the next checkpoint rebuild;
|
|
2206
|
+
// that is safer than committing transient rows that can later re-layout.
|
|
2207
|
+
const committedSealedEnd = Math.min(this.#scrollbackHighWater, commitBoundary);
|
|
2208
|
+
const renderViewportTop = Math.max(naturalViewportTop, committedSealedEnd);
|
|
2238
2209
|
return { kind: "liveRegionPinned", appendFrom, appendTo, renderViewportTop };
|
|
2239
2210
|
}
|
|
2240
2211
|
|
|
@@ -2355,6 +2326,56 @@ export class TUI extends Container {
|
|
|
2355
2326
|
this.#commit(lines, width, height, Math.max(0, this.#maxLinesRendered - height), toRow);
|
|
2356
2327
|
}
|
|
2357
2328
|
|
|
2329
|
+
/**
|
|
2330
|
+
* Initial foreground-stream paint on ED3-risk hosts with unknown viewport
|
|
2331
|
+
* position. Clears only the visible screen, commits the stable prefix, and
|
|
2332
|
+
* paints the mutable live tail without first writing hidden live rows into
|
|
2333
|
+
* native scrollback.
|
|
2334
|
+
*/
|
|
2335
|
+
#emitInitialLiveRegionPinnedPaint(
|
|
2336
|
+
lines: string[],
|
|
2337
|
+
width: number,
|
|
2338
|
+
height: number,
|
|
2339
|
+
cursorPos: { row: number; col: number } | null,
|
|
2340
|
+
liveRegionStart: number,
|
|
2341
|
+
commitSafeEnd: number | undefined,
|
|
2342
|
+
): void {
|
|
2343
|
+
this.#fullRedrawCount += 1;
|
|
2344
|
+
this.#markNativeScrollbackDirty();
|
|
2345
|
+
const naturalViewportTop = Math.max(0, lines.length - height);
|
|
2346
|
+
const commitBoundary = commitSafeEnd ?? liveRegionStart;
|
|
2347
|
+
const appendTo = Math.max(0, Math.min(naturalViewportTop, commitBoundary, lines.length));
|
|
2348
|
+
const viewportTop = naturalViewportTop;
|
|
2349
|
+
|
|
2350
|
+
let buffer = this.#paintBeginSequence;
|
|
2351
|
+
if (TERMINAL.supportsScreenToScrollback) buffer += "\x1b[22J";
|
|
2352
|
+
buffer += "\x1b[2J\x1b[H";
|
|
2353
|
+
|
|
2354
|
+
let wroteLine = false;
|
|
2355
|
+
for (let i = 0; i < appendTo; i++) {
|
|
2356
|
+
if (wroteLine) buffer += "\r\n";
|
|
2357
|
+
buffer += this.#fitLineToWidth(lines[i] ?? "", width);
|
|
2358
|
+
wroteLine = true;
|
|
2359
|
+
}
|
|
2360
|
+
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
2361
|
+
if (wroteLine) buffer += "\r\n";
|
|
2362
|
+
buffer += this.#fitLineToWidth(lines[viewportTop + screenRow] ?? "", width);
|
|
2363
|
+
wroteLine = true;
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
const viewportBottomRow = viewportTop + height - 1;
|
|
2367
|
+
const contentBottomRow = Math.min(viewportBottomRow, Math.max(viewportTop, lines.length - 1));
|
|
2368
|
+
const parkUp = viewportBottomRow - contentBottomRow;
|
|
2369
|
+
if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
|
|
2370
|
+
const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, contentBottomRow);
|
|
2371
|
+
buffer += seq;
|
|
2372
|
+
buffer += this.#paintEndSequence;
|
|
2373
|
+
this.terminal.write(buffer);
|
|
2374
|
+
|
|
2375
|
+
this.#maxLinesRendered = Math.max(lines.length, viewportTop + height);
|
|
2376
|
+
this.#scrollbackHighWater = appendTo;
|
|
2377
|
+
this.#commit(lines, width, height, viewportTop, toRow);
|
|
2378
|
+
}
|
|
2358
2379
|
/**
|
|
2359
2380
|
* Rewrite the visible viewport in place. Cursor home, clear each row,
|
|
2360
2381
|
* emit the bottom-anchored slice of `lines`. No scrollback growth.
|