@prometheus-ai/tui 0.5.3 → 0.5.8
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/dist/types/autocomplete.d.ts +3 -1
- package/dist/types/components/box.d.ts +1 -1
- package/dist/types/components/editor.d.ts +35 -2
- package/dist/types/components/image.d.ts +22 -3
- package/dist/types/components/input.d.ts +6 -1
- package/dist/types/components/loader.d.ts +9 -2
- package/dist/types/components/markdown.d.ts +3 -1
- package/dist/types/components/scroll-view.d.ts +23 -1
- package/dist/types/components/select-list.d.ts +19 -1
- package/dist/types/components/settings-list.d.ts +87 -7
- package/dist/types/components/spacer.d.ts +1 -1
- package/dist/types/components/tab-bar.d.ts +37 -4
- package/dist/types/components/text.d.ts +2 -2
- package/dist/types/components/truncated-text.d.ts +1 -1
- package/dist/types/fuzzy.d.ts +10 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/keybindings.d.ts +5 -3
- package/dist/types/keys.d.ts +1 -1
- package/dist/types/kill-ring.d.ts +0 -7
- package/dist/types/kitty-graphics.d.ts +16 -31
- package/dist/types/loop-watchdog.d.ts +39 -0
- package/dist/types/mouse.d.ts +41 -0
- package/dist/types/stdin-buffer.d.ts +17 -0
- package/dist/types/terminal-capabilities.d.ts +74 -18
- package/dist/types/terminal.d.ts +34 -36
- package/dist/types/tui.d.ts +191 -79
- package/dist/types/utils.d.ts +5 -2
- package/package.json +4 -4
- package/src/autocomplete.ts +79 -65
- package/src/components/box.ts +43 -63
- package/src/components/editor.ts +471 -136
- package/src/components/image.ts +85 -9
- package/src/components/input.ts +12 -3
- package/src/components/loader.ts +35 -21
- package/src/components/markdown.ts +174 -53
- package/src/components/scroll-view.ts +63 -2
- package/src/components/select-list.ts +233 -38
- package/src/components/settings-list.ts +626 -64
- package/src/components/spacer.ts +9 -5
- package/src/components/tab-bar.ts +153 -28
- package/src/components/text.ts +6 -2
- package/src/components/truncated-text.ts +10 -2
- package/src/fuzzy.ts +214 -59
- package/src/index.ts +3 -1
- package/src/keybindings.ts +72 -14
- package/src/keys.ts +1 -1
- package/src/kill-ring.ts +5 -0
- package/src/kitty-graphics.ts +2 -101
- package/src/loop-watchdog.ts +106 -0
- package/src/mouse.ts +55 -0
- package/src/stdin-buffer.ts +291 -81
- package/src/terminal-capabilities.ts +206 -168
- package/src/terminal.ts +367 -110
- package/src/tui.ts +2102 -1729
- package/src/utils.ts +92 -60
package/src/tui.ts
CHANGED
|
@@ -1,29 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Minimal TUI implementation with differential rendering.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* instead of throwing.
|
|
4
|
+
* Append-only render contract: rows committed to native scrollback are
|
|
5
|
+
* immutable. All mutation is confined to the visible window; rows enter
|
|
6
|
+
* history exactly once, in order, when the component-reported commit boundary
|
|
7
|
+
* (`NativeScrollbackLiveRegion`) says they are final. ED3 (`CSI 3 J`) is
|
|
8
|
+
* emitted only for gesture-driven replays (session replace, resize,
|
|
9
|
+
* resetDisplay) where snapping the viewport is acceptable. The engine never
|
|
10
|
+
* probes or guesses the terminal's scroll position, and the hot path clamps
|
|
11
|
+
* over-wide lines instead of throwing. See `docs/tui-core-renderer.md`.
|
|
12
12
|
*/
|
|
13
13
|
import * as fs from "node:fs";
|
|
14
|
-
import * as path from "node:path";
|
|
15
14
|
import { performance } from "node:perf_hooks";
|
|
16
15
|
import { $flag, getDebugLogPath } from "@prometheus-ai/utils";
|
|
17
16
|
import { DEFAULT_MAX_INLINE_IMAGES, ImageBudget } from "./components/image";
|
|
18
17
|
import { planDeccaraFills } from "./deccara";
|
|
19
18
|
import { isKeyRelease, matchesKey } from "./keys";
|
|
20
|
-
import
|
|
19
|
+
import { LoopWatchdog } from "./loop-watchdog";
|
|
20
|
+
import { isConPTYHosted, setAltScreenActive, type Terminal } from "./terminal";
|
|
21
21
|
import {
|
|
22
22
|
encodeKittyDeleteImage,
|
|
23
23
|
ImageProtocol,
|
|
24
24
|
setCellDimensions,
|
|
25
25
|
setTerminalImageProtocol,
|
|
26
26
|
shouldEnableSynchronizedOutputByDefault,
|
|
27
|
+
synchronizedOutputUserOverride,
|
|
27
28
|
TERMINAL,
|
|
28
29
|
} from "./terminal-capabilities";
|
|
29
30
|
import {
|
|
@@ -38,12 +39,20 @@ import {
|
|
|
38
39
|
|
|
39
40
|
const SEGMENT_RESET = "\x1b[0m";
|
|
40
41
|
/**
|
|
41
|
-
* Per-line terminator written
|
|
42
|
+
* Per-line terminator written after every non-image content row. It closes both
|
|
42
43
|
* SGR state and any in-flight OSC 8 hyperlink so styles/links cannot bleed
|
|
43
|
-
* across lines in scrollback.
|
|
44
|
-
*
|
|
44
|
+
* across lines in scrollback. Kept out of the diff/width cache because reset
|
|
45
|
+
* bytes are deterministic write framing, not content.
|
|
45
46
|
*/
|
|
46
47
|
const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x07";
|
|
48
|
+
const ERASE_LINE = "\x1b[2K";
|
|
49
|
+
const ERASE_TO_END_OF_LINE = "\x1b[K";
|
|
50
|
+
// Keep the common short-row path out of native width/truncation. Longer rows
|
|
51
|
+
// are fit by visible cells, not source code units, so zero-width-heavy prefixes
|
|
52
|
+
// cannot hide visible suffix text that still belongs in the viewport.
|
|
53
|
+
const LINE_FIT_MIN_SOURCE_CODE_UNITS = 4096;
|
|
54
|
+
const LINE_FIT_MAX_SOURCE_CODE_UNITS = 65536;
|
|
55
|
+
const LINE_FIT_SOURCE_WIDTH_MULTIPLIER = 64;
|
|
47
56
|
// Hide the hardware cursor before each paint/move write. Ghostty-style bar
|
|
48
57
|
// cursors can otherwise leave visual afterimages while the TUI repaints the
|
|
49
58
|
// row under a visible cursor. Paint writes also disable terminal autowrap:
|
|
@@ -65,9 +74,19 @@ const CURSOR_BEGIN = `${HIDE_CURSOR}${SYNC_OUTPUT_BEGIN}`;
|
|
|
65
74
|
const CURSOR_BEGIN_NO_SYNC = HIDE_CURSOR;
|
|
66
75
|
const CURSOR_END = SYNC_OUTPUT_END;
|
|
67
76
|
const CURSOR_END_NO_SYNC = "";
|
|
77
|
+
// Mouse reporting, enabled only for the lifetime of a fullscreen overlay so the
|
|
78
|
+
// rest of the app keeps the terminal's native text selection. 1000h = button
|
|
79
|
+
// click tracking, 1003h = any-motion tracking so overlays can light up hover
|
|
80
|
+
// targets (the pointer moving with no button held), 1006h = SGR extended
|
|
81
|
+
// coordinates so columns/rows past 223 are reported.
|
|
82
|
+
const MOUSE_TRACKING_ON = "\x1b[?1000h\x1b[?1003h\x1b[?1006h";
|
|
83
|
+
const MOUSE_TRACKING_OFF = "\x1b[?1006l\x1b[?1003l\x1b[?1000l";
|
|
84
|
+
const ALT_SCREEN_ENTER = "\x1b[?1049h";
|
|
85
|
+
const ALT_SCREEN_EXIT = "\x1b[?1049l";
|
|
68
86
|
|
|
69
87
|
type InputListenerResult = { consume?: boolean; data?: string } | undefined;
|
|
70
88
|
type InputListener = (data: string) => InputListenerResult;
|
|
89
|
+
type StartListener = () => void;
|
|
71
90
|
|
|
72
91
|
export interface RenderTimer {
|
|
73
92
|
cancel(): void;
|
|
@@ -83,6 +102,11 @@ export interface TUIOptions {
|
|
|
83
102
|
renderScheduler?: RenderScheduler;
|
|
84
103
|
}
|
|
85
104
|
|
|
105
|
+
export interface TUIStartOptions {
|
|
106
|
+
/** Clear saved native scrollback before the first paint. */
|
|
107
|
+
clearScrollback?: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
86
110
|
const DEFAULT_RENDER_SCHEDULER: RenderScheduler = {
|
|
87
111
|
now: () => performance.now(),
|
|
88
112
|
scheduleImmediate: callback => {
|
|
@@ -100,14 +124,26 @@ const DEFAULT_RENDER_SCHEDULER: RenderScheduler = {
|
|
|
100
124
|
|
|
101
125
|
/**
|
|
102
126
|
* Component interface - all components must implement this
|
|
127
|
+
*
|
|
128
|
+
* Render contract: the returned array (and its rows) belongs to the component.
|
|
129
|
+
* Callers MUST NOT mutate it — components are allowed to return a cached array
|
|
130
|
+
* and will return the exact same reference for as long as their rendered
|
|
131
|
+
* content is unchanged. Conversely, a component MUST return a fresh array
|
|
132
|
+
* reference whenever its content changed; reference equality across two
|
|
133
|
+
* render() calls is the engine's proof that the rows are byte-identical
|
|
134
|
+
* (containers memoize their concatenation on it, and the TUI derives the
|
|
135
|
+
* frame's stable prefix from it). A component that mutates a previously
|
|
136
|
+
* returned array in place must implement {@link RenderStablePrefix} to declare
|
|
137
|
+
* which leading rows survived.
|
|
103
138
|
*/
|
|
104
139
|
export interface Component {
|
|
105
140
|
/**
|
|
106
|
-
* Render the component to
|
|
107
|
-
*
|
|
108
|
-
*
|
|
141
|
+
* Render the component to an array of physical rows at the given width.
|
|
142
|
+
* The result is component-owned and `readonly` to the caller; an unchanged
|
|
143
|
+
* component may (and should) return the same array reference it returned
|
|
144
|
+
* last time.
|
|
109
145
|
*/
|
|
110
|
-
render(width: number): string[];
|
|
146
|
+
render(width: number): readonly string[];
|
|
111
147
|
|
|
112
148
|
/**
|
|
113
149
|
* Optional handler for keyboard input when component has focus
|
|
@@ -121,33 +157,68 @@ export interface Component {
|
|
|
121
157
|
wantsKeyRelease?: boolean;
|
|
122
158
|
|
|
123
159
|
/**
|
|
124
|
-
*
|
|
160
|
+
* Optional hook to invalidate any cached rendering state.
|
|
125
161
|
* Called when theme changes or when component needs to re-render from scratch.
|
|
126
162
|
*/
|
|
127
|
-
invalidate(): void;
|
|
163
|
+
invalidate?(): void;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Optional teardown. Called when the component is permanently removed from
|
|
167
|
+
* the live tree (e.g. a transcript reset). Release timers, intervals, and
|
|
168
|
+
* subscriptions here. Must be idempotent. Containers propagate dispose to
|
|
169
|
+
* their children; leaf components without resources may omit it.
|
|
170
|
+
*/
|
|
171
|
+
dispose?(): void;
|
|
128
172
|
}
|
|
129
173
|
|
|
130
174
|
/**
|
|
131
|
-
*
|
|
132
|
-
* renders a
|
|
133
|
-
* line index where that suffix begins after each render.
|
|
134
|
-
*
|
|
135
|
-
*
|
|
175
|
+
* Component seam for append-only native-scrollback commits. A component that
|
|
176
|
+
* renders a finalized prefix followed by a live/mutating suffix reports the
|
|
177
|
+
* local line index where that suffix begins after each render. The engine
|
|
178
|
+
* commits rows to native scrollback only up to that boundary; everything
|
|
179
|
+
* below repaints in place inside the visible window and never enters history
|
|
180
|
+
* until it finalizes.
|
|
136
181
|
*
|
|
137
182
|
* `getNativeScrollbackCommitSafeEnd` optionally reports a *deeper* boundary
|
|
138
|
-
* inside
|
|
139
|
-
* append-only (
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
183
|
+
* inside the live suffix: the line index up to which the live region is
|
|
184
|
+
* append-only (earlier rows never re-layout — a streaming assistant message).
|
|
185
|
+
* Rows in `[liveRegionStart, commitSafeEnd)` may commit even though they are
|
|
186
|
+
* technically live, because they will never change. Without it, a single live
|
|
187
|
+
* block that alone overflows the window would hold its scrolled-off head out
|
|
188
|
+
* of history until it finalizes. Volatile live blocks (tool previews that
|
|
189
|
+
* collapse) omit it. Defaults to `liveRegionStart` when absent; a root that
|
|
190
|
+
* reports no seam at all commits everything that scrolls (shell semantics).
|
|
191
|
+
* `getNativeScrollbackSnapshotSafeEnd` optionally reports a still deeper
|
|
192
|
+
* boundary: the line index up to which the live region is *durable* — its rows
|
|
193
|
+
* may still change bytes later (a streaming markdown table re-aligning its
|
|
194
|
+
* columns every row), but their CURRENT snapshot is permanent content, so
|
|
195
|
+
* dropping them when they scroll above the window is forbidden. Unlike
|
|
196
|
+
* `commitSafeEnd` (byte-stable: offered rows are asserted never to re-layout and
|
|
197
|
+
* stay under the committed-prefix audit), rows committed under the snapshot end
|
|
198
|
+
* are audit-EXEMPT once they pass the window top — the engine appends their
|
|
199
|
+
* scroll-off snapshot and never recommits them, so later layout drift becomes a
|
|
200
|
+
* frozen stale row in history (duplication never loss) instead of either a
|
|
201
|
+
* dropped row or an audit re-anchor spray. Provisional live blocks (collapsing
|
|
202
|
+
* tool/edit previews whose head is a throwaway tail window) omit it. Defaults to
|
|
203
|
+
* `commitSafeEnd ?? liveRegionStart` when absent.
|
|
204
|
+
*
|
|
205
|
+
* When several root children report a seam in the same frame, the topmost
|
|
206
|
+
* one (and its commit-safe / snapshot-safe extension) defines the boundary:
|
|
207
|
+
* commits are prefix-only, so everything below the first seam is already
|
|
208
|
+
* excluded.
|
|
147
209
|
*/
|
|
148
210
|
export interface NativeScrollbackLiveRegion {
|
|
149
211
|
getNativeScrollbackLiveRegionStart(): number | undefined;
|
|
150
212
|
getNativeScrollbackCommitSafeEnd?(): number | undefined;
|
|
213
|
+
getNativeScrollbackSnapshotSafeEnd?(): number | undefined;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface NativeScrollbackCommittedRows {
|
|
217
|
+
setNativeScrollbackCommittedRows(rows: number): void;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function setNativeScrollbackCommittedRows(component: Component, rows: number): void {
|
|
221
|
+
(component as Component & Partial<NativeScrollbackCommittedRows>).setNativeScrollbackCommittedRows?.(rows);
|
|
151
222
|
}
|
|
152
223
|
|
|
153
224
|
function getNativeScrollbackLiveRegionStart(component: Component): number | undefined {
|
|
@@ -158,6 +229,68 @@ function getNativeScrollbackCommitSafeEnd(component: Component): number | undefi
|
|
|
158
229
|
return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackCommitSafeEnd?.();
|
|
159
230
|
}
|
|
160
231
|
|
|
232
|
+
function getNativeScrollbackSnapshotSafeEnd(component: Component): number | undefined {
|
|
233
|
+
return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackSnapshotSafeEnd?.();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Opt-in stability report for components that mutate their returned render
|
|
238
|
+
* array in place across frames (instead of returning a fresh array per
|
|
239
|
+
* change). The engine reads it right after the component's `render()` returns:
|
|
240
|
+
* the report counts the leading rows of the just-returned array that are
|
|
241
|
+
* byte-identical to the array state the reader last observed. The engine uses
|
|
242
|
+
* it to reuse the composed frame's prefix — skipping marker extraction, line
|
|
243
|
+
* preparation, and the committed-prefix audit for those rows.
|
|
244
|
+
*
|
|
245
|
+
* Contract:
|
|
246
|
+
* - Reading CONSUMES the report: it re-bases the baseline to the current
|
|
247
|
+
* array state. The accumulated count therefore covers every render since
|
|
248
|
+
* the previous read, so out-of-band `render()` calls between engine frames
|
|
249
|
+
* (an exporter walking the tree) can only lower the report, never inflate
|
|
250
|
+
* it past what the engine actually has.
|
|
251
|
+
* - An implementer that cannot prove stability for a frame must lower the
|
|
252
|
+
* accumulated count to 0 for that render.
|
|
253
|
+
* - Rows at or beyond the report may have been mutated in place; rows before
|
|
254
|
+
* it must be the identical string values at the identical indices.
|
|
255
|
+
*/
|
|
256
|
+
export interface RenderStablePrefix {
|
|
257
|
+
getRenderStablePrefixRows(): number;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function getRenderStablePrefixRows(component: Component): number | undefined {
|
|
261
|
+
return (component as Component & Partial<RenderStablePrefix>).getRenderStablePrefixRows?.();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Opt-in fast path for composing only the visible tail of a tall component
|
|
266
|
+
* during a terminal resize. A drag emits a SIGWINCH burst, and the width
|
|
267
|
+
* changes on every event: a full compose re-lays-out (and, for markdown,
|
|
268
|
+
* re-lexes) the entire transcript per event — O(history) work that is
|
|
269
|
+
* discarded the instant the next event arrives. While the resize is in flight
|
|
270
|
+
* the engine paints only the viewport, so it asks each tall root child for at
|
|
271
|
+
* most `maxRows` rows from the bottom of its render at `width` and skips
|
|
272
|
+
* composing everything above the fold. The authoritative full paint replays
|
|
273
|
+
* once the drag settles (see {@link TUI} resize handling).
|
|
274
|
+
*
|
|
275
|
+
* Contract:
|
|
276
|
+
* - Returns the BOTTOM rows of the component's full render at `width`, in
|
|
277
|
+
* top-to-bottom order, capped at `maxRows` (fewer when the component is
|
|
278
|
+
* shorter). The rows MUST be byte-identical to the corresponding tail of
|
|
279
|
+
* what `render(width)` would have returned, modulo a one-row separator at
|
|
280
|
+
* the very top edge (a transient frame the settle paint overwrites).
|
|
281
|
+
* - MUST NOT mutate any persistent full-compose state: the next `render()`
|
|
282
|
+
* (the settle paint) has to reconcile exactly as if the tail render never
|
|
283
|
+
* happened. Warming pure per-width render caches is fine and desirable.
|
|
284
|
+
*/
|
|
285
|
+
export interface ViewportTailProvider {
|
|
286
|
+
renderViewportTail(width: number, maxRows: number): readonly string[];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function asViewportTailProvider(component: Component): ViewportTailProvider | undefined {
|
|
290
|
+
const candidate = component as Component & Partial<ViewportTailProvider>;
|
|
291
|
+
return typeof candidate.renderViewportTail === "function" ? (candidate as ViewportTailProvider) : undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
161
294
|
/**
|
|
162
295
|
* Interface for components that can receive focus and display a cursor.
|
|
163
296
|
* When focused, the component should emit CURSOR_MARKER at the cursor position
|
|
@@ -180,23 +313,6 @@ export interface Focusable {
|
|
|
180
313
|
export interface RenderRequestOptions {
|
|
181
314
|
/** Clear terminal scrollback for intentional transcript replacement. */
|
|
182
315
|
clearScrollback?: boolean;
|
|
183
|
-
/**
|
|
184
|
-
* Bypass the unknown-Windows-viewport deferral for this render so the
|
|
185
|
-
* caller's intentional live UI mutation reaches the terminal even when
|
|
186
|
-
* `Terminal#isNativeViewportAtBottom()` cannot answer.
|
|
187
|
-
*
|
|
188
|
-
* Use only for renders driven by direct user interaction (autocomplete
|
|
189
|
-
* updates, IME, etc.). Any background/offscreen transcript change that
|
|
190
|
-
* coalesces into the same frame WILL also bypass the deferral and reach
|
|
191
|
-
* native scrollback — that is the trade-off, and the reason ordinary
|
|
192
|
-
* `requestRender()` calls must continue to omit this flag.
|
|
193
|
-
*/
|
|
194
|
-
allowUnknownViewportMutation?: boolean;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/** Options for deferred native scrollback rebuild checkpoints. Reserved for API stability. */
|
|
198
|
-
export interface NativeScrollbackRefreshOptions {
|
|
199
|
-
allowUnknownViewport?: boolean;
|
|
200
316
|
}
|
|
201
317
|
/** Type guard to check if a component implements Focusable */
|
|
202
318
|
export function isFocusable(component: Component | null): component is Component & Focusable {
|
|
@@ -254,7 +370,13 @@ function parseSizeValue(value: SizeValue | undefined, referenceSize: number): nu
|
|
|
254
370
|
|
|
255
371
|
/** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
|
|
256
372
|
function isMultiplexerSession(): boolean {
|
|
257
|
-
|
|
373
|
+
// TMUX/STY/ZELLIJ are the authoritative signals, but they can be stripped while
|
|
374
|
+
// TERM survives (`sudo` without -E, `su`, env-sanitizing launchers/ssh). Fall back to
|
|
375
|
+
// the TERM prefix like every sibling multiplexer check (terminal-capabilities.ts) so a
|
|
376
|
+
// resize never emits ED3 into a tmux/screen pane and wipes its scrollback history.
|
|
377
|
+
if (Bun.env.TMUX || Bun.env.STY || Bun.env.ZELLIJ) return true;
|
|
378
|
+
const term = Bun.env.TERM?.toLowerCase() ?? "";
|
|
379
|
+
return term.startsWith("tmux") || term.startsWith("screen");
|
|
258
380
|
}
|
|
259
381
|
|
|
260
382
|
/**
|
|
@@ -295,6 +417,17 @@ export interface OverlayOptions {
|
|
|
295
417
|
* Called each render cycle with current terminal dimensions.
|
|
296
418
|
*/
|
|
297
419
|
visible?: (termWidth: number, termHeight: number) => boolean;
|
|
420
|
+
|
|
421
|
+
// === Fullscreen ===
|
|
422
|
+
/**
|
|
423
|
+
* Borrow the terminal's alternate screen buffer for this overlay's lifetime
|
|
424
|
+
* (vim/less idiom). While the topmost visible overlay sets this, the engine
|
|
425
|
+
* paints only the modal on the alt screen and emits no ED3 / scrollback
|
|
426
|
+
* bytes, so the transcript on the normal screen stays untouched and is not
|
|
427
|
+
* scrollable behind the modal. Defaults off — all other overlays are
|
|
428
|
+
* unchanged and still draw over the transcript on the normal screen.
|
|
429
|
+
*/
|
|
430
|
+
fullscreen?: boolean;
|
|
298
431
|
}
|
|
299
432
|
|
|
300
433
|
/**
|
|
@@ -315,90 +448,235 @@ export interface OverlayHandle {
|
|
|
315
448
|
export class Container implements Component {
|
|
316
449
|
children: Component[] = [];
|
|
317
450
|
|
|
451
|
+
// Memoized concatenation of the children's latest renders. Children are
|
|
452
|
+
// still rendered every frame (renders carry side effects: image placement
|
|
453
|
+
// registration, seam/stability reports); the memo only skips rebuilding
|
|
454
|
+
// the concatenated array when every child returned the exact same array
|
|
455
|
+
// reference at the same width — which, per the Component render contract,
|
|
456
|
+
// proves the rows are byte-identical. Cleared on any child-list change and
|
|
457
|
+
// on invalidate().
|
|
458
|
+
#memoLines: string[] | undefined;
|
|
459
|
+
#memoChildLines: (readonly string[])[] = [];
|
|
460
|
+
#memoWidth = -1;
|
|
461
|
+
|
|
318
462
|
addChild(component: Component): void {
|
|
319
463
|
this.children.push(component);
|
|
464
|
+
this.#memoLines = undefined;
|
|
320
465
|
}
|
|
321
466
|
|
|
322
467
|
removeChild(component: Component): void {
|
|
323
468
|
const index = this.children.indexOf(component);
|
|
324
469
|
if (index !== -1) {
|
|
325
470
|
this.children.splice(index, 1);
|
|
471
|
+
this.#memoLines = undefined;
|
|
326
472
|
}
|
|
327
473
|
}
|
|
328
474
|
|
|
329
475
|
clear(): void {
|
|
330
476
|
this.children = [];
|
|
477
|
+
this.#memoLines = undefined;
|
|
331
478
|
}
|
|
332
479
|
|
|
333
480
|
invalidate(): void {
|
|
481
|
+
this.#memoLines = undefined;
|
|
334
482
|
for (const child of this.children) {
|
|
335
483
|
child.invalidate?.();
|
|
336
484
|
}
|
|
337
485
|
}
|
|
338
486
|
|
|
339
|
-
|
|
487
|
+
/**
|
|
488
|
+
* Propagate teardown to children. Call when the container's children are
|
|
489
|
+
* being permanently discarded (not when they are detached for reuse — use
|
|
490
|
+
* {@link clear} for that). Idempotent per child via each child's own dispose.
|
|
491
|
+
*/
|
|
492
|
+
dispose(): void {
|
|
493
|
+
for (const child of this.children) {
|
|
494
|
+
child.dispose?.();
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
render(width: number): readonly string[] {
|
|
340
499
|
width = Math.max(1, width);
|
|
500
|
+
const children = this.children;
|
|
501
|
+
const count = children.length;
|
|
502
|
+
let refs = this.#memoChildLines;
|
|
503
|
+
let unchanged = this.#memoLines !== undefined && this.#memoWidth === width && refs.length === count;
|
|
504
|
+
if (refs.length !== count) {
|
|
505
|
+
refs = new Array(count);
|
|
506
|
+
this.#memoChildLines = refs;
|
|
507
|
+
}
|
|
508
|
+
for (let i = 0; i < count; i++) {
|
|
509
|
+
const childLines = children[i]!.render(width);
|
|
510
|
+
if (refs[i] !== childLines) {
|
|
511
|
+
unchanged = false;
|
|
512
|
+
refs[i] = childLines;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
this.#memoWidth = width;
|
|
516
|
+
if (unchanged) return this.#memoLines!;
|
|
341
517
|
const lines: string[] = [];
|
|
342
|
-
for (
|
|
343
|
-
|
|
518
|
+
for (let i = 0; i < count; i++) {
|
|
519
|
+
const childLines = refs[i]!;
|
|
520
|
+
for (let j = 0; j < childLines.length; j++) lines.push(childLines[j]!);
|
|
344
521
|
}
|
|
522
|
+
this.#memoLines = lines;
|
|
345
523
|
return lines;
|
|
346
524
|
}
|
|
347
525
|
}
|
|
348
526
|
|
|
349
527
|
/**
|
|
350
|
-
* Render intent. `#
|
|
351
|
-
*
|
|
528
|
+
* Render intent. `#doRender` classifies each frame, and the matching `#emit*`
|
|
529
|
+
* method owns the bytes written and the state update.
|
|
352
530
|
*
|
|
353
|
-
* - `
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
* - `liveRegionPinned`: ED3-risk/unknown foreground stream with a reported live
|
|
361
|
-
* suffix — optionally append newly sealed rows, then repaint the live/mutable
|
|
362
|
-
* tail without letting transient rows enter native history.
|
|
363
|
-
* - `viewportRepaint`: rewrite the visible viewport in place. If `appendFrom`
|
|
364
|
-
* is set, emit those tail rows as scrollback growth first so streaming
|
|
365
|
-
* output reaches terminal history before the corrected viewport is drawn.
|
|
366
|
-
* - `deferredShrink`: pure content shrink would re-expose rows already in
|
|
367
|
-
* native history. Keep row indices stable with blank tail padding, repaint
|
|
368
|
-
* only the viewport, and defer the real shorter replay to a checkpoint.
|
|
369
|
-
* - `deferredTailRepaint`: a deferred history mutation also changed the active
|
|
370
|
-
* grid's bottom row; repaint only that row relative to the tracked hardware
|
|
371
|
-
* cursor so a bottom-anchored spinner can advance without rewriting rows that
|
|
372
|
-
* a slightly-scrolled reader can still see.
|
|
373
|
-
* - `deferredMutation`: a row-inserting edit would reindex native scrollback
|
|
374
|
-
* while the user is scrolled. Defer all bytes until a safe rebuild checkpoint.
|
|
375
|
-
* - `shrink`: trailing rows were dropped — clear extras inline.
|
|
376
|
-
* - `diff`: differential repaint of visible rows / append new rows below.
|
|
531
|
+
* - `fullPaint`: gesture-driven replay — initial paint, session replacement,
|
|
532
|
+
* resize, resetDisplay. Clears the viewport and (for destructive replaces,
|
|
533
|
+
* outside multiplexers) native scrollback via ED3, then writes the
|
|
534
|
+
* committed prefix and the visible window. The only ED3 callsite in the
|
|
535
|
+
* engine.
|
|
536
|
+
* - `update`: ordinary frame. Commits the newly settled chunk at the
|
|
537
|
+
* scrollback seam (if any) and repaints the window with relative moves.
|
|
377
538
|
*/
|
|
378
539
|
type RenderIntent =
|
|
379
|
-
| { kind: "
|
|
380
|
-
| { kind: "
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
540
|
+
| { kind: "fullPaint"; clearScrollback: boolean }
|
|
541
|
+
| { kind: "update"; chunkTo: number; windowTop: number };
|
|
542
|
+
|
|
543
|
+
interface HardwareCursorState {
|
|
544
|
+
row: number;
|
|
545
|
+
col: number;
|
|
546
|
+
visible: boolean;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
interface HardwareCursorUpdate {
|
|
550
|
+
toRow: number;
|
|
551
|
+
state: HardwareCursorState | null;
|
|
552
|
+
visible?: boolean;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
interface CursorControlResult extends HardwareCursorUpdate {
|
|
556
|
+
seq: string;
|
|
557
|
+
toCol: number;
|
|
558
|
+
visible: boolean;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* One root child's contribution to the composed frame: the array reference its
|
|
563
|
+
* render() returned, the frame row it starts at, the row count recorded at
|
|
564
|
+
* compose time (in-place mutators keep the reference but may change length),
|
|
565
|
+
* and the child-local seam reports captured at render time — replayed verbatim
|
|
566
|
+
* when a component-scoped frame reuses this segment without re-rendering.
|
|
567
|
+
*/
|
|
568
|
+
interface FrameSegment {
|
|
569
|
+
component: Component;
|
|
570
|
+
lines: readonly string[];
|
|
571
|
+
start: number;
|
|
572
|
+
rowCount: number;
|
|
573
|
+
liveLocalStart?: number;
|
|
574
|
+
commitLocalEnd?: number;
|
|
575
|
+
snapshotLocalEnd?: number;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Depth-first identity search through `Container`-shaped children. */
|
|
579
|
+
function subtreeContains(root: Component, target: Component): boolean {
|
|
580
|
+
if (root === target) return true;
|
|
581
|
+
const children = (root as Partial<Container>).children;
|
|
582
|
+
if (!Array.isArray(children)) return false;
|
|
583
|
+
for (let i = 0; i < children.length; i++) {
|
|
584
|
+
if (subtreeContains(children[i]!, target)) return true;
|
|
585
|
+
}
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
interface PreparedLine {
|
|
590
|
+
raw: string;
|
|
591
|
+
width: number;
|
|
592
|
+
line: string;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const SGR_SEQUENCE = /\x1b\[[0-9;:]*m/g;
|
|
596
|
+
|
|
597
|
+
/** Compare two rows ignoring SGR styling (theme restyles keep alignment). */
|
|
598
|
+
function rowsEquivalent(a: string, b: string): boolean {
|
|
599
|
+
if (a === b) return true;
|
|
600
|
+
return a.replace(SGR_SEQUENCE, "") === b.replace(SGR_SEQUENCE, "");
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function isBlankRow(row: string): boolean {
|
|
604
|
+
if (row.length === 0) return true;
|
|
605
|
+
return row.replace(SGR_SEQUENCE, "").trim().length === 0;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Tail-alignment sampling bounds: look back through up to LOOKBACK rows of
|
|
609
|
+
// the committed prefix to collect SAMPLES non-blank comparisons.
|
|
610
|
+
const RESYNC_TAIL_LOOKBACK = 24;
|
|
611
|
+
const RESYNC_TAIL_SAMPLES = 8;
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Decide whether `frame` still aligns with the committed prefix, and where to
|
|
615
|
+
* re-anchor the commit index when it does not. Returns the resync row index,
|
|
616
|
+
* or -1 when no resync is needed.
|
|
617
|
+
*
|
|
618
|
+
* The detector exploits the asymmetry between the two mutation classes: an
|
|
619
|
+
* in-place edit or restyle of committed rows disturbs only the touched rows
|
|
620
|
+
* (alignment below them is intact — the stale copy in history is the
|
|
621
|
+
* long-accepted artifact), while any insertion or deletion shifts EVERY row
|
|
622
|
+
* below it, including the rows just above the commit boundary. So the prefix
|
|
623
|
+
* *tail* is sampled (up to 8 non-blank rows within the last 24, compared
|
|
624
|
+
* SGR-stripped so theme changes stay quiet, tolerating one mismatch for a
|
|
625
|
+
* legitimate single-row edit): aligned ⇒ no resync; misaligned ⇒ resync at
|
|
626
|
+
* the first non-equivalent row, recommitting from there — duplication, never
|
|
627
|
+
* loss. Highly repetitive tails (identical filler rows) can mask a shift, in
|
|
628
|
+
* which case the skipped rows are content-identical to the committed ones —
|
|
629
|
+
* observationally harmless. Exported for the render-stress harness, whose
|
|
630
|
+
* shadow commit ledger must mirror the engine's law exactly.
|
|
631
|
+
*/
|
|
632
|
+
export function findCommittedPrefixResync(
|
|
633
|
+
frame: readonly string[],
|
|
634
|
+
prefix: readonly string[],
|
|
635
|
+
auditLimit: number = prefix.length,
|
|
636
|
+
): number {
|
|
637
|
+
// Audit only the byte-stable leading prefix [0, auditLimit); rows committed
|
|
638
|
+
// under a durable snapshot end (beyond auditLimit) may drift legitimately and
|
|
639
|
+
// are exempt, so their drift never triggers a re-anchor.
|
|
640
|
+
const committed = Math.min(prefix.length, Math.max(0, Math.trunc(auditLimit)));
|
|
641
|
+
if (committed === 0) return -1;
|
|
642
|
+
if (frame.length >= committed) {
|
|
643
|
+
let samples = 0;
|
|
644
|
+
let mismatches = 0;
|
|
645
|
+
const lookback = Math.min(RESYNC_TAIL_LOOKBACK, committed);
|
|
646
|
+
for (let j = 1; j <= lookback && samples < RESYNC_TAIL_SAMPLES; j++) {
|
|
647
|
+
const row = frame[committed - j]!;
|
|
648
|
+
const old = prefix[committed - j]!;
|
|
649
|
+
if (row === old) {
|
|
650
|
+
if (!isBlankRow(row)) samples++;
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
if (isBlankRow(row) && isBlankRow(old)) continue;
|
|
654
|
+
samples++;
|
|
655
|
+
if (!rowsEquivalent(row, old)) mismatches++;
|
|
656
|
+
}
|
|
657
|
+
// No signal (all-blank tail) or at most one edited row: aligned.
|
|
658
|
+
if (samples === 0 || mismatches <= 1) return -1;
|
|
659
|
+
}
|
|
660
|
+
// Misaligned (or the frame no longer covers the prefix): re-anchor at the
|
|
661
|
+
// first row whose content actually changed.
|
|
662
|
+
const limit = Math.min(committed, frame.length);
|
|
663
|
+
for (let i = 0; i < limit; i++) {
|
|
664
|
+
if (!rowsEquivalent(frame[i]!, prefix[i]!)) return i;
|
|
665
|
+
}
|
|
666
|
+
return limit < committed ? limit : -1;
|
|
667
|
+
}
|
|
391
668
|
|
|
392
669
|
/**
|
|
393
670
|
* TUI - Main class for managing terminal UI with differential rendering
|
|
394
671
|
*/
|
|
395
672
|
export class TUI extends Container {
|
|
396
673
|
terminal: Terminal;
|
|
397
|
-
#
|
|
674
|
+
#previousFrameLength = 0;
|
|
398
675
|
#previousWidth = 0;
|
|
399
676
|
#previousHeight = 0;
|
|
400
677
|
#focusedComponent: Component | null = null;
|
|
401
678
|
#inputListeners = new Set<InputListener>();
|
|
679
|
+
#startListeners = new Set<StartListener>();
|
|
402
680
|
|
|
403
681
|
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
|
|
404
682
|
onDebug?: () => void;
|
|
@@ -406,60 +684,110 @@ export class TUI extends Container {
|
|
|
406
684
|
#renderTimer: RenderTimer | undefined;
|
|
407
685
|
#renderScheduler: RenderScheduler;
|
|
408
686
|
#lastRenderAt = 0;
|
|
409
|
-
static readonly #MIN_RENDER_INTERVAL_MS =
|
|
410
|
-
|
|
687
|
+
static readonly #MIN_RENDER_INTERVAL_MS = 1000 / 30;
|
|
688
|
+
// Pane-reflow settle window for tmux/screen/zellij. The host process gets
|
|
689
|
+
// SIGWINCH (and `process.stdout` already reports the new geometry) before
|
|
690
|
+
// the multiplexer finishes repainting the pane at the new size, and
|
|
691
|
+
// drag-resize/pane-close animations fire several events in flight. A forced
|
|
692
|
+
// render on each SIGWINCH races those mid-reflow paints — the multiplexer's
|
|
693
|
+
// catch-up paint then partially overwrites the TUI output, which the user
|
|
694
|
+
// sees as a viewport flash or blank screen before the next throttled frame
|
|
695
|
+
// arrives (issue #2088). Coalescing every SIGWINCH inside this window into
|
|
696
|
+
// a single forced render lets the multiplexer settle first.
|
|
697
|
+
static readonly #MULTIPLEXER_RESIZE_DEBOUNCE_MS = 50;
|
|
698
|
+
// Resize viewport fast path (non-multiplexer). A drag emits a SIGWINCH burst,
|
|
699
|
+
// and outside a multiplexer the host gets each new geometry atomically. The
|
|
700
|
+
// authoritative resize paint erases and replays the entire transcript so it
|
|
701
|
+
// rewraps at the new width — O(history) compose (markdown re-lexes every
|
|
702
|
+
// block, the per-width cache missing on every distinct drag width) plus an
|
|
703
|
+
// O(history) write that pushes all of it back through native scrollback. At
|
|
704
|
+
// drag rates that whole-history pass is recomputed dozens of times a second
|
|
705
|
+
// and discarded the instant the next event lands. While the drag is in
|
|
706
|
+
// flight the engine instead composes and paints ONLY the viewport (see
|
|
707
|
+
// `#renderResizeViewport`): a state-isolated, throwaway frame that never
|
|
708
|
+
// touches the commit ledger. The authoritative full replay fires once, after
|
|
709
|
+
// the drag has been quiet for this long. Multiplexer sessions keep their own
|
|
710
|
+
// debounce (`#armMultiplexerResizeTimer`, see #2088) and never take this path.
|
|
711
|
+
static readonly #RESIZE_VIEWPORT_SETTLE_MS = 120;
|
|
712
|
+
// Ghostty can drop Kitty graphics commands sent during its first post-startup
|
|
713
|
+
// settle window, leaving only Unicode placeholder cells. Hold the first image
|
|
714
|
+
// paint until that window has passed; later images render normally.
|
|
715
|
+
static readonly #GHOSTTY_INITIAL_IMAGE_DELAY_MS = 100;
|
|
716
|
+
// Post-paint settle window for ConPTY hosts. The `sessionReplace` /
|
|
717
|
+
// `historyRebuild` / `overlayRebuild` intents drive `#emitFullPaint` over
|
|
718
|
+
// a transcript that overflows the viewport, scroll-pushing everything past
|
|
719
|
+
// the last `height` rows into native scrollback. Windows Terminal's
|
|
720
|
+
// viewport-follow logic gets lossy during that burst: spinner/blink-driven
|
|
721
|
+
// `requestRender(false)` calls firing inside the window each produce another
|
|
722
|
+
// diff write, and the WT host processes them faster than its viewport
|
|
723
|
+
// tracker can keep up — the visible tail ends up parked a few rows above
|
|
724
|
+
// the actual last row until any focus event (Alt+Tab) forces a host repaint.
|
|
725
|
+
// Coalescing every non-forced render inside this window into a single
|
|
726
|
+
// trailing render lets the host fully settle the big paint before any
|
|
727
|
+
// follow-up writes touch the buffer. The first-ever `initial` paint is
|
|
728
|
+
// deliberately exempt: nothing has been on screen yet, so no drift can
|
|
729
|
+
// have accumulated, and tests that start the TUI over an over-tall
|
|
730
|
+
// component depend on the next paint firing without delay. Only armed on
|
|
731
|
+
// ConPTY hosts (`isConPTYHosted()`); other terminals do not exhibit the
|
|
732
|
+
// drift and would just see an unnecessary post-paint latency. See #2095.
|
|
733
|
+
static readonly #CONPTY_POST_FULL_PAINT_SETTLE_MS = 150;
|
|
734
|
+
#postFullPaintSettleUntilMs = 0;
|
|
735
|
+
#postFullPaintSettleTimer: RenderTimer | undefined;
|
|
411
736
|
#hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
|
|
412
|
-
#
|
|
737
|
+
#hardwareCursorState: HardwareCursorState | null = null;
|
|
738
|
+
#hardwareCursorVisibilityKnown = false;
|
|
739
|
+
#hardwareCursorVisible = false;
|
|
413
740
|
#sixelProbePendingDa = false;
|
|
414
741
|
#sixelProbePendingGraphics = false;
|
|
415
742
|
#sixelProbeBuffer = "";
|
|
416
743
|
#sixelProbeTimeout?: NodeJS.Timeout;
|
|
417
744
|
#sixelProbeUnsubscribe?: () => void;
|
|
418
745
|
#showHardwareCursor = $flag("PROMETHEUS_HARDWARE_CURSOR");
|
|
419
|
-
#clearOnShrink = $flag("PROMETHEUS_CLEAR_ON_SHRINK"); // Clear empty rows when content shrinks (default: off)
|
|
420
746
|
#synchronizedOutputEnabled = shouldEnableSynchronizedOutputByDefault();
|
|
421
747
|
#paintBeginSequence = this.#synchronizedOutputEnabled ? PAINT_BEGIN : PAINT_BEGIN_NO_SYNC;
|
|
422
748
|
#paintEndSequence = this.#synchronizedOutputEnabled ? PAINT_END : PAINT_END_NO_SYNC;
|
|
423
749
|
#cursorBeginSequence = this.#synchronizedOutputEnabled ? CURSOR_BEGIN : CURSOR_BEGIN_NO_SYNC;
|
|
424
750
|
#cursorEndSequence = this.#synchronizedOutputEnabled ? CURSOR_END : CURSOR_END_NO_SYNC;
|
|
425
|
-
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
|
|
431
|
-
#
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
#
|
|
751
|
+
// Rows of the current frame physically committed to the terminal tape
|
|
752
|
+
// (native scrollback or scrolled past the window top). Immutable by
|
|
753
|
+
// contract: the engine never rewrites them, and components keep mutable
|
|
754
|
+
// rows below the `NativeScrollbackLiveRegion` boundary so they never get
|
|
755
|
+
// here while they can still change.
|
|
756
|
+
#committedRows = 0;
|
|
757
|
+
// Raw rows mirroring [0, #committedRows) — the engine's claim of what it
|
|
758
|
+
// committed, audited each ordinary frame against the current render to
|
|
759
|
+
// detect components re-laying-out committed content (see
|
|
760
|
+
// #auditCommittedPrefix). Holds references to component-cached strings, so
|
|
761
|
+
// the audit is a pointer walk in the common case.
|
|
762
|
+
#committedPrefix: string[] = [];
|
|
763
|
+
// Length of the leading committed prefix [0, #committedPrefixAuditRows) that
|
|
764
|
+
// is BYTE-STABLE and therefore audited. Rows [auditRows, committedRows) were
|
|
765
|
+
// committed under a component's snapshot-safe (durable, non-byte-stable) end:
|
|
766
|
+
// their scroll-off snapshot is permanent so dropping them is forbidden, but
|
|
767
|
+
// they may drift afterward (a streaming table widening), so re-auditing them
|
|
768
|
+
// would re-anchor on every drift and spray duplicate snapshots. Once a
|
|
769
|
+
// snapshot row commits (auditRows < committedRows) the cap is permanent until
|
|
770
|
+
// a wholesale re-slice (full paint / shrink / geometry) re-bases it.
|
|
771
|
+
#committedPrefixAuditRows = 0;
|
|
772
|
+
// Frame row currently mapped to screen row 0. Monotonic between full
|
|
773
|
+
// paints: a shrink never re-exposes scrolled-off rows (they cannot be
|
|
774
|
+
// un-scrolled without rewriting history); live rows repaint at fixed
|
|
775
|
+
// positions with blank rows below the shrunken tail.
|
|
776
|
+
#windowTopRow = 0;
|
|
777
|
+
// Exactly what is painted on the screen rows (post-composite, prepared).
|
|
778
|
+
#previousWindow: string[] = [];
|
|
435
779
|
#nativeScrollbackLiveRegionStart: number | undefined;
|
|
436
780
|
#nativeScrollbackCommitSafeEnd: number | undefined;
|
|
437
|
-
#
|
|
438
|
-
#deferredTailLine: string | undefined;
|
|
439
|
-
// Highest `#maxLinesRendered` reached during a foreground tool turn while
|
|
440
|
-
// intermediate frames were prevented from committing to terminal scrollback.
|
|
441
|
-
// Used after the tool finishes to push the settled content into scrollback
|
|
442
|
-
// via a non-destructive full paint (no ED 3). Reset to 0 once rows are
|
|
443
|
-
// committed (via any `#emitFullPaint`, `#emitDiff`, or `#emitAppendTail`
|
|
444
|
-
// path).
|
|
445
|
-
#streamingHighWater = 0;
|
|
446
|
-
// Tracks whether the previous frame was inside a foreground tool streaming
|
|
447
|
-
// turn. Used to reset `#streamingHighWater` on fresh streaming starts.
|
|
448
|
-
#previousStreamingActive = false;
|
|
781
|
+
#nativeScrollbackSnapshotSafeEnd: number | undefined;
|
|
449
782
|
#fullRedrawCount = 0;
|
|
450
783
|
// Caps how many inline images render as live graphics; older ones fall back
|
|
451
784
|
// to text via a purge + full redraw. Cap is configured by the host app.
|
|
452
785
|
#imageBudget = new ImageBudget(DEFAULT_MAX_INLINE_IMAGES, () => this.requestRender());
|
|
786
|
+
#ghosttyInitialImageDelayDone = false;
|
|
787
|
+
#ghosttyInitialImageDelayTimer: RenderTimer | undefined;
|
|
788
|
+
#ghosttyImageReadyAtMs = 0;
|
|
453
789
|
#clearScrollbackOnNextRender = false;
|
|
454
790
|
#forceViewportRepaintOnNextRender = false;
|
|
455
|
-
#allowUnknownViewportMutationOnNextRender = false;
|
|
456
|
-
#eagerNativeScrollbackRebuild = false;
|
|
457
|
-
// Set when eager mode is switched off; applied after the next frame is
|
|
458
|
-
// classified so teardown frames from the same event batch still render
|
|
459
|
-
// eagerly (see setEagerNativeScrollbackRebuild).
|
|
460
|
-
#eagerNativeScrollbackRebuildDisablePending = false;
|
|
461
|
-
#previousVisibleOverlayComponents: Component[] = [];
|
|
462
|
-
#visibleOverlayComponentsThisRender: Component[] = [];
|
|
463
791
|
#hasEverRendered = false;
|
|
464
792
|
// Set by the terminal resize callback; consumed by the next render. A resize
|
|
465
793
|
// event invalidates the committed screen even when the dimensions net out
|
|
@@ -468,7 +796,91 @@ export class TUI extends Container {
|
|
|
468
796
|
// between the viewport and scrollback, so the previous frame no longer
|
|
469
797
|
// describes the screen. Tracking only the dimension delta misses this.
|
|
470
798
|
#resizeEventPending = false;
|
|
799
|
+
// Active multiplexer SIGWINCH debounce. Reset on each event so the timer
|
|
800
|
+
// only fires once the pane stops resizing. Forced renders (resetDisplay,
|
|
801
|
+
// finishSixelProbe, …) issued during the settle window route through the
|
|
802
|
+
// same timer; their `clearScrollback` intent is OR'd into the deferred
|
|
803
|
+
// flag below so the settled paint still honours every caller's request.
|
|
804
|
+
#multiplexerResizeTimer: RenderTimer | undefined;
|
|
805
|
+
#deferredForcedClearScrollback = false;
|
|
806
|
+
// True from the first SIGWINCH of a non-multiplexer drag until the settle
|
|
807
|
+
// timer fires. While set, every `#doRender` short-circuits to the viewport
|
|
808
|
+
// fast path (`#renderResizeViewport`) instead of an authoritative full
|
|
809
|
+
// paint, and no commit/window/diff state is advanced.
|
|
810
|
+
#resizeViewportActive = false;
|
|
811
|
+
// Set only by the resize callback's cheap-paint request. A concurrent
|
|
812
|
+
// caller-forced render (tool finalization, reset, image reconciliation) must
|
|
813
|
+
// not be downgraded to the throwaway viewport path just because a resize
|
|
814
|
+
// settle window is active.
|
|
815
|
+
#resizeViewportPaintPending = false;
|
|
816
|
+
// Quiet-window timer that ends the drag: its callback clears the flag and
|
|
817
|
+
// drives the one authoritative full paint. Reset on every resize event so it
|
|
818
|
+
// only fires once the drag stops. Cancelled on stop().
|
|
819
|
+
#resizeViewportSettleTimer: RenderTimer | undefined;
|
|
820
|
+
// Count of transient viewport-only resize paints emitted. Distinct from
|
|
821
|
+
// `#fullRedrawCount`: these never enter native scrollback and exist only for
|
|
822
|
+
// the lifetime of the drag. Exposed for tests/diagnostics.
|
|
823
|
+
#resizeViewportPaintCount = 0;
|
|
824
|
+
// During a live resize drag the terminal's normal buffer may reflow full-width
|
|
825
|
+
// rows before our repaint lands. Borrow the alternate screen for throwaway
|
|
826
|
+
// resize frames so width changes truncate the transient viewport instead of
|
|
827
|
+
// pushing wrapped fragments into native scrollback.
|
|
828
|
+
#resizeAltActive = false;
|
|
471
829
|
#stopped = false;
|
|
830
|
+
// Always-on event-loop lag probe. The high default threshold keeps it quiet;
|
|
831
|
+
// it only logs `ui.loop-blocked` (with the current loop phase) when a frame
|
|
832
|
+
// budget is genuinely starved. Armed in start(), disarmed in stop().
|
|
833
|
+
#watchdog: LoopWatchdog;
|
|
834
|
+
|
|
835
|
+
// Transient alternate-screen state for a fullscreen overlay. While active, the
|
|
836
|
+
// engine paints only the modal on the alt buffer and leaves every
|
|
837
|
+
// normal-screen accounting field (#previousFrameLength, #viewportTopRow, …)
|
|
838
|
+
// untouched, so exiting reconciles cleanly against the terminal-restored
|
|
839
|
+
// normal screen. #altPreviousLines is the last alt frame, for repaint-skip.
|
|
840
|
+
#altActive = false;
|
|
841
|
+
#altPreviousLines: string[] = [];
|
|
842
|
+
#altEnterWidth = 0;
|
|
843
|
+
#altEnterHeight = 0;
|
|
844
|
+
|
|
845
|
+
// Persistent composed frame. The render override splices only rows at/after
|
|
846
|
+
// the stable prefix each frame; cursor markers are stripped at ingestion so
|
|
847
|
+
// the frame never carries them. Returned to render() callers — treated as
|
|
848
|
+
// immutable by them per the Component render contract.
|
|
849
|
+
#composedFrame: string[] = [];
|
|
850
|
+
// Per-root-child segment ledger backing the stable-prefix computation.
|
|
851
|
+
#frameSegments: FrameSegment[] = [];
|
|
852
|
+
#composeWidth = -1;
|
|
853
|
+
// Cursor markers stripped at ingestion, ascending by frame row.
|
|
854
|
+
#frameCursorMarkers: { row: number; col: number }[] = [];
|
|
855
|
+
// Leading rows of #composedFrame byte-identical to the previous compose.
|
|
856
|
+
#renderStablePrefixRows = 0;
|
|
857
|
+
|
|
858
|
+
// Component-scoped render accumulation. Targets are the components handed
|
|
859
|
+
// to requestComponentRender() since the last frame; the flag stays true
|
|
860
|
+
// only while EVERY pending request is component-scoped. Both are consumed
|
|
861
|
+
// once per frame by #doRender.
|
|
862
|
+
#componentRenderTargets = new Set<Component>();
|
|
863
|
+
#pendingRenderComponentsOnly = false;
|
|
864
|
+
// Root children that must re-render during the current compose; null for a
|
|
865
|
+
// full compose. Non-null only for the duration of a component-scoped
|
|
866
|
+
// render() call inside #doRender (the scratch set below, reused per frame).
|
|
867
|
+
#partialComposeRoots: Set<Component> | null = null;
|
|
868
|
+
#partialComposeRootsScratch = new Set<Component>();
|
|
869
|
+
// Target component -> containing root child, so animation-rate requests do
|
|
870
|
+
// not re-walk a huge transcript subtree every frame.
|
|
871
|
+
#componentRootCache = new WeakMap<Component, Component>();
|
|
872
|
+
|
|
873
|
+
// Persistent prepared frame, row-aligned with #composedFrame. Entries store
|
|
874
|
+
// normalized, width-fitted content rows without the per-line terminal
|
|
875
|
+
// terminator; terminators are appended only at write time so width checks
|
|
876
|
+
// stay on content, not reset bytes. #preparedValidRows counts the leading
|
|
877
|
+
// rows known prepared against the CURRENT composed frame: a compose lowers
|
|
878
|
+
// it to the stable prefix, a completed prepare raises it to the frame
|
|
879
|
+
// length, and an abandoned frame (ghostty image defer) leaves it lowered so
|
|
880
|
+
// the next prepare revalidates the splice.
|
|
881
|
+
#preparedFrame: string[] = [];
|
|
882
|
+
#preparedMeta: PreparedLine[] = [];
|
|
883
|
+
#preparedValidRows = 0;
|
|
472
884
|
|
|
473
885
|
// Overlay stack for modal components rendered on top of base content
|
|
474
886
|
overlayStack: {
|
|
@@ -483,33 +895,178 @@ export class TUI extends Container {
|
|
|
483
895
|
this.terminal = terminal;
|
|
484
896
|
this.#renderScheduler = options?.renderScheduler ?? DEFAULT_RENDER_SCHEDULER;
|
|
485
897
|
this.#showHardwareCursor = showHardwareCursor === undefined ? this.#showHardwareCursor : showHardwareCursor;
|
|
898
|
+
this.#watchdog = new LoopWatchdog();
|
|
486
899
|
}
|
|
487
900
|
|
|
488
|
-
override render(width: number): string[] {
|
|
901
|
+
override render(width: number): readonly string[] {
|
|
489
902
|
width = Math.max(1, width);
|
|
490
903
|
this.#nativeScrollbackLiveRegionStart = undefined;
|
|
491
904
|
this.#nativeScrollbackCommitSafeEnd = undefined;
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
905
|
+
this.#nativeScrollbackSnapshotSafeEnd = undefined;
|
|
906
|
+
const children = this.children;
|
|
907
|
+
const previousSegments = this.#frameSegments;
|
|
908
|
+
const segments: FrameSegment[] = new Array(children.length);
|
|
909
|
+
// A width change re-renders every child; nothing carries over.
|
|
910
|
+
let chainStable = this.#composeWidth === width;
|
|
911
|
+
this.#composeWidth = width;
|
|
912
|
+
let offset = 0;
|
|
913
|
+
let stableRows = 0;
|
|
914
|
+
const partialRoots = this.#partialComposeRoots;
|
|
915
|
+
for (let index = 0; index < children.length; index++) {
|
|
916
|
+
const child = children[index]!;
|
|
917
|
+
const previous = previousSegments[index];
|
|
918
|
+
// Component-scoped frame: a root child outside every requested
|
|
919
|
+
// subtree provably did not change (content mutations route through
|
|
920
|
+
// a render request, which would have made this frame a full one) —
|
|
921
|
+
// reuse its previous rows and seam report without calling render().
|
|
922
|
+
const reuse =
|
|
923
|
+
partialRoots !== null && previous !== undefined && previous.component === child && !partialRoots.has(child);
|
|
924
|
+
let childLines: readonly string[];
|
|
925
|
+
let liveLocalStart: number | undefined;
|
|
926
|
+
let commitLocalEnd: number | undefined;
|
|
927
|
+
let snapshotLocalEnd: number | undefined;
|
|
928
|
+
let reported: number | undefined;
|
|
929
|
+
if (reuse) {
|
|
930
|
+
childLines = previous.lines;
|
|
931
|
+
liveLocalStart = previous.liveLocalStart;
|
|
932
|
+
commitLocalEnd = previous.commitLocalEnd;
|
|
933
|
+
snapshotLocalEnd = previous.snapshotLocalEnd;
|
|
934
|
+
} else {
|
|
935
|
+
// Feed the engine's committed-row claim (from the previous frame's
|
|
936
|
+
// emit) before rendering so the child can skip re-deriving blocks
|
|
937
|
+
// that already live in immutable native scrollback. Reused segments
|
|
938
|
+
// skip this: they never call render(), so the signal is moot.
|
|
939
|
+
setNativeScrollbackCommittedRows(child, Math.max(0, this.#committedRows - offset));
|
|
940
|
+
childLines = child.render(width);
|
|
941
|
+
const liveRegionStart = getNativeScrollbackLiveRegionStart(child);
|
|
942
|
+
if (liveRegionStart !== undefined) {
|
|
943
|
+
liveLocalStart = Number.isFinite(liveRegionStart)
|
|
944
|
+
? Math.max(0, Math.min(childLines.length, Math.trunc(liveRegionStart)))
|
|
506
945
|
: childLines.length;
|
|
507
|
-
|
|
946
|
+
const commitSafeEnd = getNativeScrollbackCommitSafeEnd(child);
|
|
947
|
+
if (commitSafeEnd !== undefined) {
|
|
948
|
+
commitLocalEnd = Number.isFinite(commitSafeEnd)
|
|
949
|
+
? Math.max(liveLocalStart, Math.min(childLines.length, Math.trunc(commitSafeEnd)))
|
|
950
|
+
: childLines.length;
|
|
951
|
+
}
|
|
952
|
+
// Durable snapshot end: clamped at/above the byte-stable end (or
|
|
953
|
+
// the live-region start when none) so a child can never report a
|
|
954
|
+
// shallower durable boundary than its byte-stable one.
|
|
955
|
+
const snapshotSafeEnd = getNativeScrollbackSnapshotSafeEnd(child);
|
|
956
|
+
if (snapshotSafeEnd !== undefined) {
|
|
957
|
+
const snapshotFloor = commitLocalEnd ?? liveLocalStart;
|
|
958
|
+
snapshotLocalEnd = Number.isFinite(snapshotSafeEnd)
|
|
959
|
+
? Math.max(snapshotFloor, Math.min(childLines.length, Math.trunc(snapshotSafeEnd)))
|
|
960
|
+
: childLines.length;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
// Consume the stability report unconditionally for implementers:
|
|
964
|
+
// reading re-bases the component's baseline to the state this
|
|
965
|
+
// compose is about to ingest (used or not, the current rows are
|
|
966
|
+
// what ends up in the composed frame). Reused segments are
|
|
967
|
+
// deliberately NOT read — their baseline must stay anchored to
|
|
968
|
+
// the last render the engine actually observed.
|
|
969
|
+
reported = getRenderStablePrefixRows(child);
|
|
970
|
+
}
|
|
971
|
+
// Topmost seam wins. Commits are prefix-only: the first child that
|
|
972
|
+
// reports a live region (plus its own commit-safe extension) already
|
|
973
|
+
// bounds everything below it, so a lower sibling's seam (e.g. a
|
|
974
|
+
// status loader under a streaming transcript) must never overwrite
|
|
975
|
+
// it — moving the boundary down would commit the earlier child's
|
|
976
|
+
// still-mutable rows as stale history.
|
|
977
|
+
if (liveLocalStart !== undefined && this.#nativeScrollbackLiveRegionStart === undefined) {
|
|
978
|
+
this.#nativeScrollbackLiveRegionStart = offset + liveLocalStart;
|
|
979
|
+
if (commitLocalEnd !== undefined) {
|
|
980
|
+
this.#nativeScrollbackCommitSafeEnd = offset + commitLocalEnd;
|
|
981
|
+
}
|
|
982
|
+
if (snapshotLocalEnd !== undefined) {
|
|
983
|
+
this.#nativeScrollbackSnapshotSafeEnd = offset + snapshotLocalEnd;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
if (chainStable) {
|
|
987
|
+
if (previous !== undefined && previous.component === child && previous.start === offset) {
|
|
988
|
+
let stableCount = 0;
|
|
989
|
+
if (reported !== undefined) {
|
|
990
|
+
// In-place mutator: its report overrides reference equality.
|
|
991
|
+
// Rows beyond the previous row count cannot be "unchanged".
|
|
992
|
+
stableCount = Number.isFinite(reported)
|
|
993
|
+
? Math.max(0, Math.min(childLines.length, previous.rowCount, Math.trunc(reported)))
|
|
994
|
+
: 0;
|
|
995
|
+
} else if (previous.lines === childLines) {
|
|
996
|
+
stableCount = childLines.length;
|
|
997
|
+
}
|
|
998
|
+
stableRows += stableCount;
|
|
999
|
+
// The chain survives only a fully stable segment: identical rows
|
|
1000
|
+
// AND identical row count (a grown/shrunk segment shifts every
|
|
1001
|
+
// row below it).
|
|
1002
|
+
if (stableCount < childLines.length || previous.rowCount !== childLines.length) chainStable = false;
|
|
1003
|
+
} else {
|
|
1004
|
+
chainStable = false;
|
|
508
1005
|
}
|
|
509
1006
|
}
|
|
510
|
-
|
|
1007
|
+
segments[index] = {
|
|
1008
|
+
component: child,
|
|
1009
|
+
lines: childLines,
|
|
1010
|
+
start: offset,
|
|
1011
|
+
rowCount: childLines.length,
|
|
1012
|
+
liveLocalStart,
|
|
1013
|
+
commitLocalEnd,
|
|
1014
|
+
snapshotLocalEnd,
|
|
1015
|
+
};
|
|
1016
|
+
offset += childLines.length;
|
|
1017
|
+
}
|
|
1018
|
+
this.#frameSegments = segments;
|
|
1019
|
+
|
|
1020
|
+
const frame = this.#composedFrame;
|
|
1021
|
+
// Defensive clamp: stable rows can never exceed what the previous
|
|
1022
|
+
// compose actually materialized (only reachable if a child render threw
|
|
1023
|
+
// mid-compose on the previous frame).
|
|
1024
|
+
if (stableRows > frame.length) stableRows = frame.length;
|
|
1025
|
+
if (stableRows !== offset || frame.length !== offset) {
|
|
1026
|
+
// Re-ingest every row at/after the stable prefix: truncate, strip
|
|
1027
|
+
// cursor markers, record their positions.
|
|
1028
|
+
frame.length = stableRows;
|
|
1029
|
+
this.#pruneFrameCursorMarkers(stableRows);
|
|
1030
|
+
for (const segment of segments) {
|
|
1031
|
+
const lines = segment.lines;
|
|
1032
|
+
const from = segment.start >= stableRows ? 0 : stableRows - segment.start;
|
|
1033
|
+
for (let i = from; i < lines.length; i++) this.#ingestFrameRow(lines[i]!);
|
|
1034
|
+
}
|
|
511
1035
|
}
|
|
512
|
-
|
|
1036
|
+
this.#renderStablePrefixRows = stableRows;
|
|
1037
|
+
this.#preparedValidRows = Math.min(this.#preparedValidRows, stableRows);
|
|
1038
|
+
return frame;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/** Drop cached cursor markers at/after `fromRow` (those rows re-ingest). */
|
|
1042
|
+
#pruneFrameCursorMarkers(fromRow: number): void {
|
|
1043
|
+
const markers = this.#frameCursorMarkers;
|
|
1044
|
+
let keep = markers.length;
|
|
1045
|
+
while (keep > 0 && markers[keep - 1]!.row >= fromRow) keep--;
|
|
1046
|
+
markers.length = keep;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Append one row to the composed frame, stripping CURSOR_MARKER occurrences
|
|
1051
|
+
* (internal sentinels that must never reach the terminal, the committed
|
|
1052
|
+
* prefix, or the resync audit) and recording the first marker's position.
|
|
1053
|
+
*/
|
|
1054
|
+
#ingestFrameRow(line: string): void {
|
|
1055
|
+
let markerIndex = line.indexOf(CURSOR_MARKER);
|
|
1056
|
+
if (markerIndex === -1) {
|
|
1057
|
+
this.#composedFrame.push(line);
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
this.#frameCursorMarkers.push({
|
|
1061
|
+
row: this.#composedFrame.length,
|
|
1062
|
+
col: visibleWidth(line.slice(0, markerIndex)),
|
|
1063
|
+
});
|
|
1064
|
+
let stripped = line;
|
|
1065
|
+
while (markerIndex !== -1) {
|
|
1066
|
+
stripped = stripped.slice(0, markerIndex) + stripped.slice(markerIndex + CURSOR_MARKER.length);
|
|
1067
|
+
markerIndex = stripped.indexOf(CURSOR_MARKER, markerIndex);
|
|
1068
|
+
}
|
|
1069
|
+
this.#composedFrame.push(stripped);
|
|
513
1070
|
}
|
|
514
1071
|
|
|
515
1072
|
#syncTerminalCursorMode(component: Component | null): void {
|
|
@@ -522,6 +1079,20 @@ export class TUI extends Container {
|
|
|
522
1079
|
return this.#fullRedrawCount;
|
|
523
1080
|
}
|
|
524
1081
|
|
|
1082
|
+
/**
|
|
1083
|
+
* Transient viewport-only paints emitted by the non-multiplexer resize fast
|
|
1084
|
+
* path. These never touch native scrollback or the commit ledger, so they
|
|
1085
|
+
* are counted apart from {@link fullRedraws}.
|
|
1086
|
+
*/
|
|
1087
|
+
get resizeViewportPaints(): number {
|
|
1088
|
+
return this.#resizeViewportPaintCount;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/** Whether a non-multiplexer resize drag is currently in flight. */
|
|
1092
|
+
get resizeViewportActive(): boolean {
|
|
1093
|
+
return this.#resizeViewportActive;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
525
1096
|
/** Shared budget that caps how many inline images render as live graphics. */
|
|
526
1097
|
get imageBudget(): ImageBudget {
|
|
527
1098
|
return this.#imageBudget;
|
|
@@ -546,78 +1117,31 @@ export class TUI extends Container {
|
|
|
546
1117
|
this.#syncTerminalCursorMode(this.#focusedComponent);
|
|
547
1118
|
if (!enabled) {
|
|
548
1119
|
this.terminal.hideCursor();
|
|
1120
|
+
this.#recordHardwareCursorHidden();
|
|
549
1121
|
}
|
|
550
1122
|
this.requestRender();
|
|
551
1123
|
}
|
|
552
1124
|
|
|
553
|
-
getClearOnShrink(): boolean {
|
|
554
|
-
return this.#clearOnShrink;
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
/**
|
|
558
|
-
* Set whether to trigger full re-render when content shrinks.
|
|
559
|
-
* When true (default), empty rows are cleared when content shrinks.
|
|
560
|
-
* When false, empty rows remain (reduces redraws on slower terminals).
|
|
561
|
-
*/
|
|
562
|
-
setClearOnShrink(enabled: boolean): void {
|
|
563
|
-
this.#clearOnShrink = enabled;
|
|
564
|
-
}
|
|
565
|
-
|
|
566
1125
|
/**
|
|
567
1126
|
* Whether DEC 2026 synchronized-output wrappers are currently emitted around
|
|
568
|
-
* paints. Starts from conservative terminal/env detection and is
|
|
569
|
-
*
|
|
1127
|
+
* paints. Starts from conservative terminal/env detection and is reconciled at
|
|
1128
|
+
* runtime against the terminal's DECRQM mode-2026 report — enabled on a
|
|
1129
|
+
* positive report, disabled on a negative one.
|
|
570
1130
|
*/
|
|
571
1131
|
get synchronizedOutput(): boolean {
|
|
572
1132
|
return this.#synchronizedOutputEnabled;
|
|
573
1133
|
}
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
* where `isNativeViewportAtBottom()` is `undefined`), instead of deferring to a
|
|
579
|
-
* non-destructive repaint. This trades the anti-yank guarantee for a clean,
|
|
580
|
-
* duplicate-free history and is meant for windows where output above the fold
|
|
581
|
-
* is actively re-rendering — e.g. a tool whose result is still streaming and
|
|
582
|
-
* re-laying-out rows that have already scrolled into history. A terminal that
|
|
583
|
-
* reports a *known*-scrolled viewport still defers, as does native Windows
|
|
584
|
-
* (the viewport is never observable there and ConPTY hosts erase host
|
|
585
|
-
* scrollback on ED3 — #1635/#1746); only the unknown POSIX case is forced to
|
|
586
|
-
* rebuild. POSIX hosts known to disturb scrolled readers on xterm ED3
|
|
587
|
-
* (`CSI 3 J`, erase saved lines) also defer the eager opt-in; checkpoint
|
|
588
|
-
* rebuilds are unaffected.
|
|
589
|
-
*
|
|
590
|
-
* Disabling stays active through one already-requested frame: the event batch
|
|
591
|
-
* that ends a foreground stream both removes its UI rows (loader/status
|
|
592
|
-
* teardown — a shrink) and clears this flag before the throttled render timer
|
|
593
|
-
* fires. If the flag dropped immediately, that teardown frame would hit the
|
|
594
|
-
* ED3-risk idle deferral and freeze on screen (stale spinner) until the next
|
|
595
|
-
* keystroke. When no render is pending, disable immediately so a later
|
|
596
|
-
* unrelated content mutation does not inherit foreground-stream privileges.
|
|
597
|
-
*/
|
|
598
|
-
setEagerNativeScrollbackRebuild(enabled: boolean): void {
|
|
599
|
-
if (enabled) {
|
|
600
|
-
this.#eagerNativeScrollbackRebuild = true;
|
|
601
|
-
this.#eagerNativeScrollbackRebuildDisablePending = false;
|
|
602
|
-
return;
|
|
603
|
-
}
|
|
604
|
-
if (!this.#eagerNativeScrollbackRebuild) return;
|
|
605
|
-
if (this.#renderRequested || this.#renderTimer !== undefined) {
|
|
606
|
-
this.#eagerNativeScrollbackRebuildDisablePending = true;
|
|
607
|
-
return;
|
|
608
|
-
}
|
|
609
|
-
if (this.#hasEagerEraseScrollbackRisk()) {
|
|
610
|
-
this.#streamingHighWater = 0;
|
|
611
|
-
this.#markNativeScrollbackDirty();
|
|
612
|
-
}
|
|
613
|
-
this.#eagerNativeScrollbackRebuild = false;
|
|
614
|
-
this.#eagerNativeScrollbackRebuildDisablePending = false;
|
|
1134
|
+
#deccaraFillsEnabled(): boolean {
|
|
1135
|
+
// DECCARA fill rectangles arrive after shortened row text; synchronized
|
|
1136
|
+
// output hides that intermediate default-background state from users.
|
|
1137
|
+
return TERMINAL.deccara && this.#synchronizedOutputEnabled;
|
|
615
1138
|
}
|
|
616
1139
|
|
|
617
1140
|
setFocus(component: Component | null): void {
|
|
1141
|
+
const previousFocusedComponent = this.#focusedComponent;
|
|
618
1142
|
// Clear focused flag on old component
|
|
619
|
-
if (isFocusable(
|
|
620
|
-
|
|
1143
|
+
if (isFocusable(previousFocusedComponent)) {
|
|
1144
|
+
previousFocusedComponent.focused = false;
|
|
621
1145
|
}
|
|
622
1146
|
|
|
623
1147
|
this.#focusedComponent = component;
|
|
@@ -630,6 +1154,11 @@ export class TUI extends Container {
|
|
|
630
1154
|
}
|
|
631
1155
|
}
|
|
632
1156
|
|
|
1157
|
+
/** Component currently receiving keyboard input, if any. */
|
|
1158
|
+
getFocused(): Component | null {
|
|
1159
|
+
return this.#focusedComponent;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
633
1162
|
/**
|
|
634
1163
|
* Show an overlay component with configurable positioning and sizing.
|
|
635
1164
|
* Returns a handle to control the overlay's visibility.
|
|
@@ -642,6 +1171,7 @@ export class TUI extends Container {
|
|
|
642
1171
|
this.setFocus(component);
|
|
643
1172
|
}
|
|
644
1173
|
this.terminal.hideCursor();
|
|
1174
|
+
this.#recordHardwareCursorHidden();
|
|
645
1175
|
this.requestRender();
|
|
646
1176
|
|
|
647
1177
|
// Return handle for controlling this overlay
|
|
@@ -655,7 +1185,10 @@ export class TUI extends Container {
|
|
|
655
1185
|
const topVisible = this.#getTopmostVisibleOverlay();
|
|
656
1186
|
this.setFocus(topVisible?.component ?? entry.preFocus);
|
|
657
1187
|
}
|
|
658
|
-
if (this.overlayStack.length === 0)
|
|
1188
|
+
if (this.overlayStack.length === 0) {
|
|
1189
|
+
this.terminal.hideCursor();
|
|
1190
|
+
this.#recordHardwareCursorHidden();
|
|
1191
|
+
}
|
|
659
1192
|
this.requestRender();
|
|
660
1193
|
}
|
|
661
1194
|
},
|
|
@@ -688,7 +1221,10 @@ export class TUI extends Container {
|
|
|
688
1221
|
// Find topmost visible overlay, or fall back to preFocus
|
|
689
1222
|
const topVisible = this.#getTopmostVisibleOverlay();
|
|
690
1223
|
this.setFocus(topVisible?.component ?? overlay.preFocus);
|
|
691
|
-
if (this.overlayStack.length === 0)
|
|
1224
|
+
if (this.overlayStack.length === 0) {
|
|
1225
|
+
this.terminal.hideCursor();
|
|
1226
|
+
this.#recordHardwareCursorHidden();
|
|
1227
|
+
}
|
|
692
1228
|
this.requestRender();
|
|
693
1229
|
}
|
|
694
1230
|
|
|
@@ -716,41 +1252,77 @@ export class TUI extends Container {
|
|
|
716
1252
|
return undefined;
|
|
717
1253
|
}
|
|
718
1254
|
|
|
719
|
-
#overlayVisibilityReduced(visibleComponents: readonly Component[]): boolean {
|
|
720
|
-
if (this.#previousVisibleOverlayComponents.length === 0) return false;
|
|
721
|
-
for (const component of this.#previousVisibleOverlayComponents) {
|
|
722
|
-
if (!visibleComponents.includes(component)) return true;
|
|
723
|
-
}
|
|
724
|
-
return false;
|
|
725
|
-
}
|
|
726
|
-
|
|
727
1255
|
override invalidate(): void {
|
|
728
1256
|
super.invalidate();
|
|
729
1257
|
for (const overlay of this.overlayStack) overlay.component.invalidate?.();
|
|
730
1258
|
}
|
|
731
1259
|
|
|
732
|
-
start(): void {
|
|
1260
|
+
start(options?: TUIStartOptions): void {
|
|
733
1261
|
this.#stopped = false;
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
//
|
|
1262
|
+
this.#watchdog.start();
|
|
1263
|
+
this.#ghosttyInitialImageDelayDone = false;
|
|
1264
|
+
this.#ghosttyImageReadyAtMs = this.#renderScheduler.now() + TUI.#GHOSTTY_INITIAL_IMAGE_DELAY_MS;
|
|
1265
|
+
// A DECRQM report for mode 2026 is authoritative: enable synchronized
|
|
1266
|
+
// output when the terminal reports support (upgrading conservatively
|
|
1267
|
+
// defaulted-off hosts like zellij/tmux-master/foot) and disable it when
|
|
1268
|
+
// the terminal reports it unsupported. An explicit user opt-out/force
|
|
1269
|
+
// (resolved at construction) still wins, so skip the probe in that case.
|
|
738
1270
|
this.terminal.onPrivateModeReport?.((mode, supported) => {
|
|
739
|
-
if (mode
|
|
740
|
-
|
|
741
|
-
|
|
1271
|
+
if (mode !== 2026) return;
|
|
1272
|
+
if (synchronizedOutputUserOverride() !== null) return;
|
|
1273
|
+
this.#setSynchronizedOutput(supported);
|
|
742
1274
|
});
|
|
743
1275
|
this.terminal.start(
|
|
744
1276
|
data => this.#handleInput(data),
|
|
745
1277
|
() => {
|
|
1278
|
+
// Real terminals deliver SIGWINCH (and the equivalent ConPTY
|
|
1279
|
+
// notification) atomically with the new `process.stdout` geometry, so
|
|
1280
|
+
// a forced render must fire immediately: it clears and replays at the
|
|
1281
|
+
// fresh size before the terminal's reflow settles into a state a
|
|
1282
|
+
// throttled frame would race. Multiplexer panes (tmux/screen/zellij)
|
|
1283
|
+
// do not give that guarantee. The host receives SIGWINCH while the
|
|
1284
|
+
// multiplexer is still mid-reflow — it has not finished repainting
|
|
1285
|
+
// the pane buffer at the new size — and a drag-resize or pane-close
|
|
1286
|
+
// animation fires several events in flight. Forcing a render on each
|
|
1287
|
+
// event races those mid-reflow paints: the multiplexer's catch-up
|
|
1288
|
+
// paint then partially overwrites the TUI output, which the user sees
|
|
1289
|
+
// as a viewport flash or blank screen before the next throttled
|
|
1290
|
+
// frame arrives (issue #2088). `#armMultiplexerResizeTimer` coalesces
|
|
1291
|
+
// SIGWINCHes (and any forced repaints arriving during the settle
|
|
1292
|
+
// window) into a single render once the pane is quiet —
|
|
1293
|
+
// `#resizeEventPending` is set first so the eventual render still
|
|
1294
|
+
// classifies as a resize.
|
|
746
1295
|
this.#resizeEventPending = true;
|
|
747
|
-
|
|
1296
|
+
if (!isMultiplexerSession()) {
|
|
1297
|
+
// Enter the viewport fast path and (re)arm the settle timer, then
|
|
1298
|
+
// request the cheap viewport-only paint. The authoritative full
|
|
1299
|
+
// replay fires from the settle timer once the drag goes quiet.
|
|
1300
|
+
this.#beginResizeViewport();
|
|
1301
|
+
this.#requestResizeViewportPaint();
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
this.#armMultiplexerResizeTimer(false);
|
|
748
1305
|
},
|
|
749
1306
|
);
|
|
1307
|
+
for (const listener of this.#startListeners) {
|
|
1308
|
+
try {
|
|
1309
|
+
listener();
|
|
1310
|
+
} catch {
|
|
1311
|
+
// Startup listeners are feature hooks; one broken hook must not prevent rendering.
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
750
1314
|
this.terminal.hideCursor();
|
|
1315
|
+
this.#recordHardwareCursorHidden();
|
|
751
1316
|
this.#querySixelSupport();
|
|
752
1317
|
this.#queryCellSize();
|
|
753
|
-
this.requestRender(true);
|
|
1318
|
+
this.requestRender(true, { clearScrollback: options?.clearScrollback === true });
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
addStartListener(listener: StartListener): () => void {
|
|
1322
|
+
this.#startListeners.add(listener);
|
|
1323
|
+
return () => {
|
|
1324
|
+
this.#startListeners.delete(listener);
|
|
1325
|
+
};
|
|
754
1326
|
}
|
|
755
1327
|
|
|
756
1328
|
addInputListener(listener: InputListener): () => void {
|
|
@@ -901,8 +1473,8 @@ export class TUI extends Container {
|
|
|
901
1473
|
|
|
902
1474
|
/**
|
|
903
1475
|
* Toggle synchronized-output (DEC 2026) wrappers on paint/cursor writes and
|
|
904
|
-
* recompute the cached begin/end sequences.
|
|
905
|
-
*
|
|
1476
|
+
* recompute the cached begin/end sequences. Driven by the terminal's DECRQM
|
|
1477
|
+
* mode-2026 report (#1765 covers the static env opt-out).
|
|
906
1478
|
*/
|
|
907
1479
|
#setSynchronizedOutput(enabled: boolean): void {
|
|
908
1480
|
if (this.#synchronizedOutputEnabled === enabled) return;
|
|
@@ -914,6 +1486,18 @@ export class TUI extends Container {
|
|
|
914
1486
|
}
|
|
915
1487
|
|
|
916
1488
|
stop(): void {
|
|
1489
|
+
// Leave the alt buffer first so the teardown cursor math below runs against
|
|
1490
|
+
// the restored normal screen (which #previousLines still describes).
|
|
1491
|
+
if (this.#resizeAltActive) {
|
|
1492
|
+
this.terminal.write(this.#leaveResizeAltSequence());
|
|
1493
|
+
}
|
|
1494
|
+
if (this.#altActive) {
|
|
1495
|
+
const kittyPop = this.terminal.kittyEnableSequence ? "\x1b[<u" : "";
|
|
1496
|
+
this.terminal.write(`${MOUSE_TRACKING_OFF}${kittyPop}\x1b[?1049l`);
|
|
1497
|
+
setAltScreenActive(false);
|
|
1498
|
+
this.#altActive = false;
|
|
1499
|
+
this.#altPreviousLines = [];
|
|
1500
|
+
}
|
|
917
1501
|
if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
|
|
918
1502
|
for (const id of this.#imageBudget.takeAllTransmittedIds()) {
|
|
919
1503
|
this.terminal.write(encodeKittyDeleteImage(id));
|
|
@@ -921,19 +1505,35 @@ export class TUI extends Container {
|
|
|
921
1505
|
}
|
|
922
1506
|
this.#clearSixelProbeState();
|
|
923
1507
|
this.#stopped = true;
|
|
1508
|
+
this.#watchdog.stop();
|
|
924
1509
|
if (this.#renderTimer) {
|
|
925
1510
|
this.#renderTimer.cancel();
|
|
926
1511
|
this.#renderTimer = undefined;
|
|
927
1512
|
}
|
|
1513
|
+
if (this.#ghosttyInitialImageDelayTimer) {
|
|
1514
|
+
this.#ghosttyInitialImageDelayTimer.cancel();
|
|
1515
|
+
this.#ghosttyInitialImageDelayTimer = undefined;
|
|
1516
|
+
}
|
|
1517
|
+
if (this.#multiplexerResizeTimer) {
|
|
1518
|
+
this.#multiplexerResizeTimer.cancel();
|
|
1519
|
+
this.#multiplexerResizeTimer = undefined;
|
|
1520
|
+
}
|
|
1521
|
+
if (this.#resizeViewportSettleTimer) {
|
|
1522
|
+
this.#resizeViewportSettleTimer.cancel();
|
|
1523
|
+
this.#resizeViewportSettleTimer = undefined;
|
|
1524
|
+
}
|
|
1525
|
+
this.#resizeViewportActive = false;
|
|
1526
|
+
this.#clearPostFullPaintSettle();
|
|
1527
|
+
this.#deferredForcedClearScrollback = false;
|
|
928
1528
|
// Place the parent shell on the first line after the rendered content. When
|
|
929
1529
|
// that line is still inside the viewport, moving there and writing `\r` is
|
|
930
1530
|
// enough; emitting `\r\n` would create an extra blank row. If the content
|
|
931
1531
|
// already reaches the viewport bottom, scroll exactly once so the prompt
|
|
932
1532
|
// lands directly below the last visible TUI row.
|
|
933
|
-
if (this.#
|
|
934
|
-
const targetRow = this.#
|
|
935
|
-
const viewportBottom = this.#
|
|
936
|
-
const clampedCursorRow = Math.max(this.#
|
|
1533
|
+
if (this.#previousFrameLength > 0) {
|
|
1534
|
+
const targetRow = this.#previousFrameLength;
|
|
1535
|
+
const viewportBottom = this.#windowTopRow + this.terminal.rows - 1;
|
|
1536
|
+
const clampedCursorRow = Math.max(this.#windowTopRow, Math.min(this.#hardwareCursorRow, viewportBottom));
|
|
937
1537
|
const moveTargetRow = Math.min(targetRow, viewportBottom);
|
|
938
1538
|
const lineDiff = moveTargetRow - clampedCursorRow;
|
|
939
1539
|
if (lineDiff > 0) {
|
|
@@ -945,44 +1545,37 @@ export class TUI extends Container {
|
|
|
945
1545
|
}
|
|
946
1546
|
|
|
947
1547
|
this.terminal.showCursor();
|
|
1548
|
+
this.#forgetHardwareCursorState();
|
|
948
1549
|
this.terminal.stop();
|
|
949
1550
|
}
|
|
950
1551
|
|
|
951
|
-
/**
|
|
952
|
-
* Rebuild native terminal scrollback if live rendering deferred a history rewrite.
|
|
953
|
-
* Callers should only invoke this at checkpoints where the user is expected to be
|
|
954
|
-
* at the terminal bottom, such as after submitting a new prompt.
|
|
955
|
-
*/
|
|
956
|
-
refreshNativeScrollbackIfDirty(_options?: NativeScrollbackRefreshOptions): boolean {
|
|
957
|
-
if (!this.#nativeScrollbackDirty || this.#stopped) return false;
|
|
958
|
-
// Multiplexer panes preserve their own history and never receive a
|
|
959
|
-
// destructive clear, so a checkpoint "replay" cannot reconcile anything —
|
|
960
|
-
// it would only append a duplicate copy of the transcript to pane
|
|
961
|
-
// history. Drop the dirty flag; there is nothing actionable behind it.
|
|
962
|
-
if (isMultiplexerSession()) {
|
|
963
|
-
this.#clearNativeScrollbackDirty();
|
|
964
|
-
return false;
|
|
965
|
-
}
|
|
966
|
-
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
|
|
967
|
-
if (!this.#canReplayNativeScrollbackAtCheckpoint(nativeViewportAtBottom)) {
|
|
968
|
-
return false;
|
|
969
|
-
}
|
|
970
|
-
this.#prepareForcedRender(true, false);
|
|
971
|
-
this.#renderRequested = false;
|
|
972
|
-
this.#lastRenderAt = this.#renderScheduler.now();
|
|
973
|
-
this.#doRender();
|
|
974
|
-
return true;
|
|
975
|
-
}
|
|
976
|
-
|
|
977
1552
|
/**
|
|
978
1553
|
* Force an immediate full replay of the current frame, including native
|
|
979
1554
|
* scrollback. This is the keyboard-accessible equivalent of the resize reset:
|
|
980
1555
|
* no queued diff frame or terminal scrollback probe can downgrade it to a
|
|
981
1556
|
* viewport-only repaint.
|
|
1557
|
+
*
|
|
1558
|
+
* Invalidates every component first so the replay reflects current state. A
|
|
1559
|
+
* geometry-driven reset thaws frozen scrollback snapshots implicitly (the new
|
|
1560
|
+
* width misses every cached snapshot), but a same-width reset would otherwise
|
|
1561
|
+
* replay stale snapshots — leaving host-frozen blocks (e.g. a transcript whose
|
|
1562
|
+
* committed rows are immutable on ED3-risk terminals) showing pre-mutation
|
|
1563
|
+
* content. Invalidation is the generic signal those containers use to retire
|
|
1564
|
+
* their snapshots, which is exactly what a user-driven display reset wants.
|
|
982
1565
|
*/
|
|
983
1566
|
resetDisplay(): void {
|
|
984
1567
|
if (this.#stopped) return;
|
|
985
|
-
this
|
|
1568
|
+
this.invalidate();
|
|
1569
|
+
// A reset that lands inside a tmux/screen/zellij resize burst would
|
|
1570
|
+
// paint mid-reflow and re-introduce the flash race (issue #2088).
|
|
1571
|
+
// Fold it into the in-flight debounce instead; the settled paint runs
|
|
1572
|
+
// the same `#prepareForcedRender(!isMultiplexerSession())` path via
|
|
1573
|
+
// `requestRender(true)`, so the clear-scrollback intent is preserved.
|
|
1574
|
+
if (this.#multiplexerResizeTimer) {
|
|
1575
|
+
this.#armMultiplexerResizeTimer(!isMultiplexerSession());
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
this.#prepareForcedRender(!isMultiplexerSession());
|
|
986
1579
|
this.#resizeEventPending = true;
|
|
987
1580
|
this.#renderRequested = false;
|
|
988
1581
|
this.#lastRenderAt = this.#renderScheduler.now();
|
|
@@ -990,10 +1583,28 @@ export class TUI extends Container {
|
|
|
990
1583
|
}
|
|
991
1584
|
|
|
992
1585
|
requestRender(force = false, options?: RenderRequestOptions): void {
|
|
993
|
-
|
|
994
|
-
this.#
|
|
1586
|
+
// Any non-component-scoped request makes the pending frame a full one.
|
|
1587
|
+
this.#pendingRenderComponentsOnly = false;
|
|
995
1588
|
if (force) {
|
|
996
|
-
this.#
|
|
1589
|
+
this.#resizeViewportPaintPending = false;
|
|
1590
|
+
// Forced repaints landing inside the multiplexer resize debounce
|
|
1591
|
+
// (e.g. `#finishSixelProbe`, image-budget eviction, a programmatic
|
|
1592
|
+
// `requestRender(true)`) would paint into a still-reflowing pane
|
|
1593
|
+
// and reintroduce the flash race. Fold them into the in-flight
|
|
1594
|
+
// debounce while preserving the caller's `clearScrollback` intent
|
|
1595
|
+
// for the settled paint. The timer's own callback clears
|
|
1596
|
+
// `#multiplexerResizeTimer` before re-entering `requestRender(true)`,
|
|
1597
|
+
// so this guard only catches external callers — the deferred render
|
|
1598
|
+
// itself proceeds straight to `#prepareForcedRender`.
|
|
1599
|
+
if (this.#multiplexerResizeTimer) {
|
|
1600
|
+
this.#armMultiplexerResizeTimer(options?.clearScrollback === true);
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
// A forced render preempts the post-full-paint ConPTY settle: it owns
|
|
1604
|
+
// the next paint and is going to redraw the buffer anyway, so the
|
|
1605
|
+
// trailing coalesced render queued by the settle would only race it.
|
|
1606
|
+
this.#clearPostFullPaintSettle();
|
|
1607
|
+
this.#prepareForcedRender(options?.clearScrollback === true);
|
|
997
1608
|
this.#renderRequested = true;
|
|
998
1609
|
this.#renderScheduler.scheduleImmediate(() => {
|
|
999
1610
|
if (this.#stopped || !this.#renderRequested) {
|
|
@@ -1005,23 +1616,231 @@ export class TUI extends Container {
|
|
|
1005
1616
|
});
|
|
1006
1617
|
return;
|
|
1007
1618
|
}
|
|
1619
|
+
this.#requestOrdinaryRender();
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
/**
|
|
1623
|
+
* Schedule a render on behalf of `component` after a self-contained change
|
|
1624
|
+
* (spinner frame, blink) that cannot have affected any other component.
|
|
1625
|
+
*
|
|
1626
|
+
* When every request since the last frame is component-scoped and the
|
|
1627
|
+
* frame is otherwise quiet — no resize or geometry change, no overlays, no
|
|
1628
|
+
* live inline images, no forced repaint, unchanged root child list — the
|
|
1629
|
+
* next compose re-renders only the root subtrees containing the requesting
|
|
1630
|
+
* components and reuses the previous frame's rows (and seam reports) for
|
|
1631
|
+
* every other root child, skipping the full component-tree walk that makes
|
|
1632
|
+
* long transcripts expensive to repaint at animation rate. Any concurrent
|
|
1633
|
+
* full request or unsafe condition downgrades the frame to a normal full
|
|
1634
|
+
* compose, so this is never less correct than `requestRender()` — only
|
|
1635
|
+
* cheaper.
|
|
1636
|
+
*/
|
|
1637
|
+
requestComponentRender(component: Component): void {
|
|
1638
|
+
if (this.#stopped) return;
|
|
1639
|
+
// Start a component-scoped accumulation only when nothing else is in
|
|
1640
|
+
// flight (a pending throttled request or a deferred ConPTY settle
|
|
1641
|
+
// replay may carry full-render intent that must not be narrowed).
|
|
1642
|
+
if (!this.#renderRequested && this.#postFullPaintSettleTimer === undefined) {
|
|
1643
|
+
this.#pendingRenderComponentsOnly = true;
|
|
1644
|
+
}
|
|
1645
|
+
this.#componentRenderTargets.add(component);
|
|
1646
|
+
this.#requestOrdinaryRender();
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/** Ordinary (non-forced) scheduling shared by full and component-scoped requests. */
|
|
1650
|
+
#requestOrdinaryRender(): void {
|
|
1651
|
+
// Coalesce non-forced renders inside the post-full-paint ConPTY settle
|
|
1652
|
+
// window into one trailing render. Spinner/blink/streaming components
|
|
1653
|
+
// otherwise fire `requestRender(false)` at 30 Hz while the host is still
|
|
1654
|
+
// catching up with the previous big paint, and each follow-up viewport
|
|
1655
|
+
// repaint nudges Windows Terminal's viewport tracker further off the
|
|
1656
|
+
// last row (see #2095).
|
|
1657
|
+
if (this.#postFullPaintSettleUntilMs > 0) {
|
|
1658
|
+
const now = this.#renderScheduler.now();
|
|
1659
|
+
if (now < this.#postFullPaintSettleUntilMs) {
|
|
1660
|
+
if (this.#postFullPaintSettleTimer === undefined) {
|
|
1661
|
+
this.#postFullPaintSettleTimer = this.#renderScheduler.scheduleRender(() => {
|
|
1662
|
+
this.#postFullPaintSettleTimer = undefined;
|
|
1663
|
+
this.#postFullPaintSettleUntilMs = 0;
|
|
1664
|
+
if (this.#stopped) return;
|
|
1665
|
+
this.#requestOrdinaryRender();
|
|
1666
|
+
}, this.#postFullPaintSettleUntilMs - now);
|
|
1667
|
+
}
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
this.#postFullPaintSettleUntilMs = 0;
|
|
1671
|
+
}
|
|
1008
1672
|
if (this.#renderRequested) return;
|
|
1009
1673
|
this.#renderRequested = true;
|
|
1010
1674
|
this.#renderScheduler.scheduleImmediate(() => this.#scheduleRender());
|
|
1011
1675
|
}
|
|
1012
1676
|
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
this.#clearScrollbackOnNextRender
|
|
1677
|
+
/**
|
|
1678
|
+
* Decide whether this frame may compose component-scoped, and resolve the
|
|
1679
|
+
* requested components to the root children that must re-render. Returns
|
|
1680
|
+
* null — full compose — whenever a global condition could invalidate rows
|
|
1681
|
+
* the partial compose would reuse, or when a requested component is not
|
|
1682
|
+
* reachable from the current root child list.
|
|
1683
|
+
*/
|
|
1684
|
+
#resolvePartialComposeRoots(width: number, height: number): Set<Component> | null {
|
|
1685
|
+
if (this.#componentRenderTargets.size === 0) return null;
|
|
1686
|
+
if (!this.#hasEverRendered || this.#resizeEventPending) return null;
|
|
1687
|
+
if (width !== this.#previousWidth || height !== this.#previousHeight || width !== this.#composeWidth) return null;
|
|
1688
|
+
if (this.#clearScrollbackOnNextRender || this.#forceViewportRepaintOnNextRender) return null;
|
|
1689
|
+
if (this.overlayStack.length > 0) return null;
|
|
1690
|
+
// The image budget audits display order across the whole frame; a
|
|
1691
|
+
// partial walk would under-count it. Engage only on image-free frames.
|
|
1692
|
+
if (!this.#imageBudget.quiescent) return null;
|
|
1693
|
+
// The root child list must match the segment ledger exactly — a
|
|
1694
|
+
// structural change shifts offsets under every reused segment.
|
|
1695
|
+
const children = this.children;
|
|
1696
|
+
const segments = this.#frameSegments;
|
|
1697
|
+
if (segments.length !== children.length) return null;
|
|
1698
|
+
for (let i = 0; i < children.length; i++) {
|
|
1699
|
+
if (segments[i]!.component !== children[i]) return null;
|
|
1700
|
+
}
|
|
1701
|
+
const roots = this.#partialComposeRootsScratch;
|
|
1702
|
+
roots.clear();
|
|
1703
|
+
for (const target of this.#componentRenderTargets) {
|
|
1704
|
+
const root = this.#resolveComponentRoot(target);
|
|
1705
|
+
if (root === null) return null;
|
|
1706
|
+
roots.add(root);
|
|
1707
|
+
}
|
|
1708
|
+
return roots;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
/** Root child whose subtree contains `target`, memoized per component. */
|
|
1712
|
+
#resolveComponentRoot(target: Component): Component | null {
|
|
1713
|
+
const cached = this.#componentRootCache.get(target);
|
|
1714
|
+
if (cached !== undefined && this.children.includes(cached) && subtreeContains(cached, target)) {
|
|
1715
|
+
return cached;
|
|
1716
|
+
}
|
|
1717
|
+
for (const child of this.children) {
|
|
1718
|
+
if (subtreeContains(child, target)) {
|
|
1719
|
+
this.#componentRootCache.set(target, child);
|
|
1720
|
+
return child;
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
this.#componentRootCache.delete(target);
|
|
1724
|
+
return null;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
/**
|
|
1728
|
+
* Arm or extend the multiplexer-resize debounce so a single forced render
|
|
1729
|
+
* fires once the pane is quiet. Called by the SIGWINCH callback on every
|
|
1730
|
+
* resize event, and by `requestRender(true)` / `resetDisplay()` when they
|
|
1731
|
+
* land inside an in-flight settle window. Each call cancels the prior
|
|
1732
|
+
* timer, supersedes any queued throttled render (otherwise it would race
|
|
1733
|
+
* tmux's mid-reflow paint), and OR's the caller's `clearScrollback`
|
|
1734
|
+
* intent into `#deferredForcedClearScrollback` — the timer's callback
|
|
1735
|
+
* consumes that flag exactly once when it re-enters `requestRender(true)`.
|
|
1736
|
+
*/
|
|
1737
|
+
#armMultiplexerResizeTimer(clearScrollback: boolean): void {
|
|
1738
|
+
this.#deferredForcedClearScrollback ||= clearScrollback;
|
|
1739
|
+
if (this.#renderTimer) {
|
|
1740
|
+
this.#renderTimer.cancel();
|
|
1741
|
+
this.#renderTimer = undefined;
|
|
1742
|
+
}
|
|
1743
|
+
this.#renderRequested = false;
|
|
1744
|
+
if (this.#multiplexerResizeTimer) {
|
|
1745
|
+
this.#multiplexerResizeTimer.cancel();
|
|
1746
|
+
}
|
|
1747
|
+
this.#multiplexerResizeTimer = this.#renderScheduler.scheduleRender(() => {
|
|
1748
|
+
this.#multiplexerResizeTimer = undefined;
|
|
1749
|
+
if (this.#stopped) {
|
|
1750
|
+
this.#deferredForcedClearScrollback = false;
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
const deferredClearScrollback = this.#deferredForcedClearScrollback;
|
|
1754
|
+
this.#deferredForcedClearScrollback = false;
|
|
1755
|
+
this.requestRender(true, { clearScrollback: deferredClearScrollback });
|
|
1756
|
+
}, TUI.#MULTIPLEXER_RESIZE_DEBOUNCE_MS);
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
/**
|
|
1760
|
+
* Arm the post-full-paint settle window after an `#emitFullPaint` that
|
|
1761
|
+
* pushed content into native scrollback on a ConPTY host. Idempotent inside
|
|
1762
|
+
* the window: a later overflowing paint extends `until` to the later
|
|
1763
|
+
* deadline so back-to-back big paints do not double-fire the trailing
|
|
1764
|
+
* coalesced render, and the existing deferred timer is rescheduled to the
|
|
1765
|
+
* later deadline.
|
|
1766
|
+
*
|
|
1767
|
+
* Mid-composition callers (most notably `ImageBudget.endPass()`, which can
|
|
1768
|
+
* call `requestRender()` from inside the in-flight paint when a new image
|
|
1769
|
+
* trips the budget) queue their render *before* the settle exists, so they
|
|
1770
|
+
* fall through the gate and set `#renderRequested` / `#renderTimer` on the
|
|
1771
|
+
* 30 Hz throttle. Without absorbing those, the throttled follow-up fires
|
|
1772
|
+
* inside the 150 ms quiet window and reintroduces the cascade the settle
|
|
1773
|
+
* was meant to stop. Cancel both, then eagerly arm the trailing settle
|
|
1774
|
+
* timer so the in-flight request still rides one coalesced render at the
|
|
1775
|
+
* end of the window. See #2095.
|
|
1776
|
+
*/
|
|
1777
|
+
#armPostFullPaintSettle(): void {
|
|
1778
|
+
if (!isConPTYHosted()) return;
|
|
1779
|
+
const until = this.#renderScheduler.now() + TUI.#CONPTY_POST_FULL_PAINT_SETTLE_MS;
|
|
1780
|
+
if (until <= this.#postFullPaintSettleUntilMs) return;
|
|
1781
|
+
this.#postFullPaintSettleUntilMs = until;
|
|
1782
|
+
const hadPendingRender = this.#renderRequested || this.#renderTimer !== undefined;
|
|
1783
|
+
// Reclaim any render that was queued during the in-flight composition:
|
|
1784
|
+
// `#renderRequested` was set before the settle existed and would
|
|
1785
|
+
// otherwise fire on the standard throttle inside the window.
|
|
1786
|
+
this.#renderRequested = false;
|
|
1787
|
+
if (this.#renderTimer) {
|
|
1788
|
+
this.#renderTimer.cancel();
|
|
1789
|
+
this.#renderTimer = undefined;
|
|
1790
|
+
}
|
|
1791
|
+
if (this.#postFullPaintSettleTimer) {
|
|
1792
|
+
this.#postFullPaintSettleTimer.cancel();
|
|
1793
|
+
this.#postFullPaintSettleTimer = undefined;
|
|
1794
|
+
}
|
|
1795
|
+
if (hadPendingRender) {
|
|
1796
|
+
// Replay the absorbed request via the trailing settle timer so the
|
|
1797
|
+
// caller's render still happens — just deferred to the end of the
|
|
1798
|
+
// window. Subsequent `requestRender(false)` calls during the
|
|
1799
|
+
// settle see this timer and fold into it (existing gate at L1263).
|
|
1800
|
+
this.#postFullPaintSettleTimer = this.#renderScheduler.scheduleRender(() => {
|
|
1801
|
+
this.#postFullPaintSettleTimer = undefined;
|
|
1802
|
+
this.#postFullPaintSettleUntilMs = 0;
|
|
1803
|
+
if (this.#stopped) return;
|
|
1804
|
+
this.#requestOrdinaryRender();
|
|
1805
|
+
}, TUI.#CONPTY_POST_FULL_PAINT_SETTLE_MS);
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
#clearPostFullPaintSettle(): void {
|
|
1810
|
+
if (this.#postFullPaintSettleTimer) {
|
|
1811
|
+
this.#postFullPaintSettleTimer.cancel();
|
|
1812
|
+
this.#postFullPaintSettleTimer = undefined;
|
|
1813
|
+
}
|
|
1814
|
+
this.#postFullPaintSettleUntilMs = 0;
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
#maybeDeferGhosttyInitialImagePaint(): boolean {
|
|
1818
|
+
if (this.#ghosttyInitialImageDelayDone) return false;
|
|
1819
|
+
if (TERMINAL.id !== "ghostty" || TERMINAL.imageProtocol !== ImageProtocol.Kitty) {
|
|
1820
|
+
this.#ghosttyInitialImageDelayDone = true;
|
|
1821
|
+
return false;
|
|
1822
|
+
}
|
|
1823
|
+
if (!this.#imageBudget.hasPendingTransmits()) return false;
|
|
1824
|
+
if (this.#ghosttyInitialImageDelayTimer) return true;
|
|
1825
|
+
|
|
1826
|
+
const delayMs = Math.max(0, this.#ghosttyImageReadyAtMs - this.#renderScheduler.now());
|
|
1827
|
+
if (delayMs === 0) {
|
|
1828
|
+
this.#ghosttyInitialImageDelayDone = true;
|
|
1829
|
+
return false;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
this.#ghosttyInitialImageDelayTimer = this.#renderScheduler.scheduleRender(() => {
|
|
1833
|
+
this.#ghosttyInitialImageDelayTimer = undefined;
|
|
1834
|
+
this.#ghosttyInitialImageDelayDone = true;
|
|
1835
|
+
if (this.#stopped) return;
|
|
1836
|
+
this.#lastRenderAt = this.#renderScheduler.now();
|
|
1837
|
+
this.#doRender();
|
|
1838
|
+
if (this.#renderRequested) this.#scheduleRender();
|
|
1839
|
+
}, delayMs);
|
|
1840
|
+
return true;
|
|
1841
|
+
}
|
|
1842
|
+
#prepareForcedRender(clearScrollback: boolean): void {
|
|
1843
|
+
this.#clearScrollbackOnNextRender ||= clearScrollback;
|
|
1025
1844
|
this.#forceViewportRepaintOnNextRender = true;
|
|
1026
1845
|
if (this.#renderTimer) {
|
|
1027
1846
|
this.#renderTimer.cancel();
|
|
@@ -1033,6 +1852,13 @@ export class TUI extends Container {
|
|
|
1033
1852
|
if (this.#stopped || this.#renderTimer || !this.#renderRequested) {
|
|
1034
1853
|
return;
|
|
1035
1854
|
}
|
|
1855
|
+
// Defer any new throttled render scheduled inside the multiplexer
|
|
1856
|
+
// resize settle window: it would race tmux's mid-reflow pane repaint.
|
|
1857
|
+
// `#renderRequested` stays set so the eventual forced render — armed
|
|
1858
|
+
// by the SIGWINCH callback — picks up the latest component state.
|
|
1859
|
+
if (this.#multiplexerResizeTimer) {
|
|
1860
|
+
return;
|
|
1861
|
+
}
|
|
1036
1862
|
const elapsed = this.#renderScheduler.now() - this.#lastRenderAt;
|
|
1037
1863
|
const delay = Math.max(0, TUI.#MIN_RENDER_INTERVAL_MS - elapsed);
|
|
1038
1864
|
this.#renderTimer = this.#renderScheduler.scheduleRender(() => {
|
|
@@ -1100,7 +1926,7 @@ export class TUI extends Container {
|
|
|
1100
1926
|
return;
|
|
1101
1927
|
}
|
|
1102
1928
|
this.#focusedComponent.handleInput(data);
|
|
1103
|
-
this.requestRender(
|
|
1929
|
+
this.requestRender();
|
|
1104
1930
|
}
|
|
1105
1931
|
}
|
|
1106
1932
|
|
|
@@ -1133,7 +1959,7 @@ export class TUI extends Container {
|
|
|
1133
1959
|
overlayHeight: number,
|
|
1134
1960
|
termWidth: number,
|
|
1135
1961
|
termHeight: number,
|
|
1136
|
-
): { width: number; row: number; col: number; maxHeight: number
|
|
1962
|
+
): { width: number; row: number; col: number; maxHeight: number } {
|
|
1137
1963
|
const opt = options ?? {};
|
|
1138
1964
|
|
|
1139
1965
|
// Parse margin (clamp to non-negative)
|
|
@@ -1160,14 +1986,12 @@ export class TUI extends Container {
|
|
|
1160
1986
|
width = Math.max(1, Math.min(width, availWidth));
|
|
1161
1987
|
|
|
1162
1988
|
// === Resolve maxHeight ===
|
|
1163
|
-
let maxHeight = parseSizeValue(opt.maxHeight, termHeight);
|
|
1164
|
-
|
|
1165
|
-
if (maxHeight !== undefined) {
|
|
1166
|
-
maxHeight = Math.max(1, Math.min(maxHeight, availHeight));
|
|
1167
|
-
}
|
|
1989
|
+
let maxHeight = parseSizeValue(opt.maxHeight, termHeight) ?? availHeight;
|
|
1990
|
+
maxHeight = Math.max(1, Math.min(maxHeight, availHeight));
|
|
1168
1991
|
|
|
1169
|
-
// Effective overlay height
|
|
1170
|
-
|
|
1992
|
+
// Effective overlay height: maxHeight is always resolved (defaults to
|
|
1993
|
+
// availHeight above), so the overlay is unconditionally clamped to fit.
|
|
1994
|
+
const effectiveHeight = Math.min(overlayHeight, maxHeight);
|
|
1171
1995
|
|
|
1172
1996
|
// === Resolve position ===
|
|
1173
1997
|
let row: number;
|
|
@@ -1262,83 +2086,34 @@ export class TUI extends Container {
|
|
|
1262
2086
|
}
|
|
1263
2087
|
}
|
|
1264
2088
|
|
|
1265
|
-
/**
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
2089
|
+
/**
|
|
2090
|
+
* Composite all visible overlays into the window slice (screen
|
|
2091
|
+
* coordinates, in stack order, later = on top). Overlays never touch the
|
|
2092
|
+
* frame: composited rows exist only in the painted window, and commits are
|
|
2093
|
+
* frozen while an overlay is visible, so overlay pixels can never enter
|
|
2094
|
+
* native scrollback.
|
|
2095
|
+
*/
|
|
2096
|
+
#compositeOverlaysIntoWindow(window: string[], termWidth: number, termHeight: number): string[] {
|
|
2097
|
+
const result = [...window];
|
|
1274
2098
|
for (const entry of this.overlayStack) {
|
|
1275
|
-
// Skip invisible overlays (hidden or visible() returns false)
|
|
1276
2099
|
if (!this.#isOverlayVisible(entry)) continue;
|
|
1277
|
-
|
|
1278
2100
|
const { component, options } = entry;
|
|
1279
|
-
|
|
1280
2101
|
// Get layout with height=0 first to determine width and maxHeight
|
|
1281
|
-
// (width and maxHeight don't depend on overlay height)
|
|
2102
|
+
// (width and maxHeight don't depend on overlay height).
|
|
1282
2103
|
const { width, maxHeight } = this.#resolveOverlayLayout(options, 0, termWidth, termHeight);
|
|
1283
|
-
|
|
1284
|
-
// Render component at calculated width
|
|
1285
2104
|
let overlayLines = component.render(width);
|
|
1286
|
-
|
|
1287
|
-
// Apply maxHeight if specified
|
|
1288
|
-
if (maxHeight !== undefined && overlayLines.length > maxHeight) {
|
|
2105
|
+
if (overlayLines.length > maxHeight) {
|
|
1289
2106
|
overlayLines = overlayLines.slice(0, maxHeight);
|
|
1290
2107
|
}
|
|
1291
|
-
|
|
1292
|
-
// Get final row/col with actual overlay height
|
|
1293
2108
|
const { row, col } = this.#resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight);
|
|
1294
|
-
|
|
1295
|
-
rendered.push({ overlayLines, row, col, w: width });
|
|
1296
|
-
minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);
|
|
1297
|
-
}
|
|
1298
|
-
|
|
1299
|
-
// Ensure result is tall enough for overlay placement.
|
|
1300
|
-
// NOTE: Do not pad to maxLinesRendered.
|
|
1301
|
-
// maxLinesRendered tracks the terminal "working area" (max lines ever rendered) and can be much larger
|
|
1302
|
-
// than the current content. Padding to it can cause the renderer to output hundreds/thousands of blank
|
|
1303
|
-
// lines, effectively scrolling the terminal when an overlay is shown.
|
|
1304
|
-
const workingHeight = Math.max(result.length, minLinesNeeded);
|
|
1305
|
-
|
|
1306
|
-
// Extend result with empty lines if content is too short for overlay placement
|
|
1307
|
-
while (result.length < workingHeight) {
|
|
1308
|
-
result.push("");
|
|
1309
|
-
}
|
|
1310
|
-
|
|
1311
|
-
const viewportStart = Math.max(0, workingHeight - termHeight);
|
|
1312
|
-
|
|
1313
|
-
// Track which lines were modified for final verification
|
|
1314
|
-
const modifiedLines = new Set<number>();
|
|
1315
|
-
|
|
1316
|
-
// Composite each overlay
|
|
1317
|
-
for (const { overlayLines, row, col, w } of rendered) {
|
|
1318
2109
|
for (let i = 0; i < overlayLines.length; i++) {
|
|
1319
|
-
const idx =
|
|
1320
|
-
if (idx
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
visibleWidth(overlayLines[i]) > w ? sliceByColumn(overlayLines[i], 0, w, true) : overlayLines[i];
|
|
1325
|
-
result[idx] = this.#compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth);
|
|
1326
|
-
modifiedLines.add(idx);
|
|
1327
|
-
}
|
|
1328
|
-
}
|
|
1329
|
-
}
|
|
1330
|
-
|
|
1331
|
-
// Final verification: ensure no composited line exceeds terminal width
|
|
1332
|
-
// This is a belt-and-suspenders safeguard - compositeLineAt should already
|
|
1333
|
-
// guarantee this, but we verify here to prevent crashes from any edge cases
|
|
1334
|
-
// Only check lines that were actually modified (optimization)
|
|
1335
|
-
for (const idx of modifiedLines) {
|
|
1336
|
-
const lineWidth = visibleWidth(result[idx]);
|
|
1337
|
-
if (lineWidth > termWidth) {
|
|
1338
|
-
result[idx] = sliceByColumn(result[idx], 0, termWidth, true);
|
|
2110
|
+
const idx = row + i;
|
|
2111
|
+
if (idx < 0 || idx >= result.length) continue;
|
|
2112
|
+
const truncatedOverlayLine =
|
|
2113
|
+
visibleWidth(overlayLines[i]) > width ? sliceByColumn(overlayLines[i], 0, width, true) : overlayLines[i];
|
|
2114
|
+
result[idx] = this.#compositeLineAt(result[idx], truncatedOverlayLine, col, width, termWidth);
|
|
1339
2115
|
}
|
|
1340
2116
|
}
|
|
1341
|
-
|
|
1342
2117
|
return result;
|
|
1343
2118
|
}
|
|
1344
2119
|
|
|
@@ -1394,27 +2169,20 @@ export class TUI extends Container {
|
|
|
1394
2169
|
}
|
|
1395
2170
|
|
|
1396
2171
|
/**
|
|
1397
|
-
*
|
|
1398
|
-
*
|
|
1399
|
-
*
|
|
1400
|
-
*
|
|
1401
|
-
*
|
|
1402
|
-
* @returns Cursor position { row, col } or null if no marker found
|
|
2172
|
+
* Strip every CURSOR_MARKER from the rendered lines (markers are internal
|
|
2173
|
+
* sentinels and must never reach the terminal, the committed prefix, or
|
|
2174
|
+
* the resync audit) and return the positions of the stripped markers,
|
|
2175
|
+
* bottom-most first. Callers pick the visible one once the window top is
|
|
2176
|
+
* known.
|
|
1403
2177
|
*/
|
|
1404
|
-
#
|
|
1405
|
-
|
|
1406
|
-
// even when the focused component is above the visible viewport. Only a
|
|
1407
|
-
// visible marker becomes a hardware cursor target.
|
|
1408
|
-
const viewportTop = Math.max(0, lines.length - height);
|
|
1409
|
-
let cursor: { row: number; col: number } | null = null;
|
|
2178
|
+
#extractCursorMarkers(lines: string[]): { row: number; col: number }[] {
|
|
2179
|
+
const markers: { row: number; col: number }[] = [];
|
|
1410
2180
|
for (let row = lines.length - 1; row >= 0; row--) {
|
|
1411
2181
|
const line = lines[row];
|
|
1412
2182
|
let markerIndex = line.indexOf(CURSOR_MARKER);
|
|
1413
2183
|
if (markerIndex === -1) continue;
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
cursor = { row, col: visibleWidth(beforeMarker) };
|
|
1417
|
-
}
|
|
2184
|
+
const beforeMarker = line.slice(0, markerIndex);
|
|
2185
|
+
markers.push({ row, col: visibleWidth(beforeMarker) });
|
|
1418
2186
|
let stripped = line;
|
|
1419
2187
|
while (markerIndex !== -1) {
|
|
1420
2188
|
stripped = stripped.slice(0, markerIndex) + stripped.slice(markerIndex + CURSOR_MARKER.length);
|
|
@@ -1422,1521 +2190,1119 @@ export class TUI extends Container {
|
|
|
1422
2190
|
}
|
|
1423
2191
|
lines[row] = stripped;
|
|
1424
2192
|
}
|
|
1425
|
-
return
|
|
2193
|
+
return markers;
|
|
1426
2194
|
}
|
|
1427
2195
|
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
* array in place so downstream diffing/storage sees exactly the bytes
|
|
1432
|
-
* written to the terminal — without this, the diff cache disagrees with
|
|
1433
|
-
* emitted output and OSC 8 hyperlink state can leak across lines.
|
|
1434
|
-
*/
|
|
1435
|
-
#applyLineResets(lines: string[]): string[] {
|
|
1436
|
-
for (let i = 0; i < lines.length; i++) {
|
|
1437
|
-
const line = lines[i];
|
|
1438
|
-
if (TERMINAL.isImageLine(line)) continue;
|
|
1439
|
-
const normalized = normalizeTerminalOutput(line);
|
|
1440
|
-
// Only close OSC 8 hyperlinks when the line actually opened one;
|
|
1441
|
-
// emitting `\x1b]8;;\x07` on every line just feeds the terminal's OSC
|
|
1442
|
-
// parser for no reason (measurable cost in xterm.js parse loop).
|
|
1443
|
-
lines[i] = normalized + (normalized.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
|
|
1444
|
-
}
|
|
1445
|
-
return lines;
|
|
2196
|
+
#terminalLine(line: string): string {
|
|
2197
|
+
if (TERMINAL.isImageLine(line)) return line;
|
|
2198
|
+
return line + (line.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
|
|
1446
2199
|
}
|
|
1447
2200
|
|
|
1448
2201
|
/**
|
|
1449
|
-
* Render one frame.
|
|
1450
|
-
*
|
|
1451
|
-
*
|
|
2202
|
+
* Render one frame.
|
|
2203
|
+
*
|
|
2204
|
+
* Append-only pipeline: compose the frame, derive the commit boundary from
|
|
2205
|
+
* the component-reported live-region seam, advance the committed-row count
|
|
2206
|
+
* monotonically, and emit either a gesture-driven full paint or an
|
|
2207
|
+
* incremental update. Scrollback is `frame[0..committedRows)` at all
|
|
2208
|
+
* times — no viewport probes, no deferred reconciliation.
|
|
1452
2209
|
*/
|
|
1453
2210
|
#doRender(): void {
|
|
1454
2211
|
if (this.#stopped) return;
|
|
1455
2212
|
const width = this.terminal.columns;
|
|
1456
2213
|
const height = this.terminal.rows;
|
|
1457
2214
|
|
|
1458
|
-
//
|
|
1459
|
-
//
|
|
1460
|
-
this.#
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
2215
|
+
// Consume the component-scoped accumulation: it describes the render
|
|
2216
|
+
// requests made up to this frame, whichever path the frame takes.
|
|
2217
|
+
const componentScopedOnly = this.#pendingRenderComponentsOnly;
|
|
2218
|
+
this.#pendingRenderComponentsOnly = false;
|
|
2219
|
+
|
|
2220
|
+
// Fullscreen alt-screen short-circuit. While the topmost visible overlay
|
|
2221
|
+
// requests it, borrow the terminal's alternate buffer and paint only the
|
|
2222
|
+
// modal there; the normal screen and all accounting stay untouched.
|
|
2223
|
+
const wantAlt = this.#wantsAltScreen();
|
|
2224
|
+
if (wantAlt && !this.#altActive) {
|
|
2225
|
+
// Kitty keyboard flags are per-screen: re-push our level on the freshly
|
|
2226
|
+
// entered alternate screen, or Esc/modified keys revert to legacy
|
|
2227
|
+
// encoding inside fullscreen overlays (Ghostty/kitty). See kitty
|
|
2228
|
+
// keyboard-protocol docs: the mode stack is separate per screen.
|
|
2229
|
+
this.terminal.write(`\x1b[?1049h${this.terminal.kittyEnableSequence ?? ""}${MOUSE_TRACKING_ON}`);
|
|
2230
|
+
setAltScreenActive(true);
|
|
2231
|
+
this.terminal.hideCursor();
|
|
2232
|
+
this.#forgetHardwareCursorState();
|
|
2233
|
+
this.#recordHardwareCursorHidden();
|
|
2234
|
+
this.#altActive = true;
|
|
2235
|
+
this.#altPreviousLines = [];
|
|
2236
|
+
this.#altEnterWidth = width;
|
|
2237
|
+
this.#altEnterHeight = height;
|
|
2238
|
+
} else if (!wantAlt && this.#altActive) {
|
|
2239
|
+
const kittyPop = this.terminal.kittyEnableSequence ? "\x1b[<u" : "";
|
|
2240
|
+
this.terminal.write(`${MOUSE_TRACKING_OFF}${kittyPop}\x1b[?1049l`);
|
|
2241
|
+
setAltScreenActive(false);
|
|
2242
|
+
this.#forgetHardwareCursorState();
|
|
2243
|
+
this.#altActive = false;
|
|
2244
|
+
this.#altPreviousLines = [];
|
|
2245
|
+
// A resize while on the alt buffer reflowed the terminal's saved
|
|
2246
|
+
// normal screen; it no longer matches our accounting, so force the
|
|
2247
|
+
// geometry rebuild path instead of a stale diff.
|
|
2248
|
+
if (width !== this.#altEnterWidth || height !== this.#altEnterHeight) {
|
|
2249
|
+
this.#resizeEventPending = true;
|
|
1472
2250
|
}
|
|
1473
2251
|
}
|
|
1474
|
-
this.#
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
lines = this.#fitLinesToWidth(this.#applyLineResets(lines), width);
|
|
1479
|
-
if (lines !== baseLines) {
|
|
1480
|
-
this.#extractCursorPosition(baseLines, height);
|
|
1481
|
-
baseLines = this.#fitLinesToWidth(this.#applyLineResets(baseLines), width);
|
|
2252
|
+
if (this.#altActive) {
|
|
2253
|
+
this.#componentRenderTargets.clear();
|
|
2254
|
+
this.#renderAltFrame(width, height);
|
|
2255
|
+
return;
|
|
1482
2256
|
}
|
|
1483
2257
|
|
|
1484
|
-
//
|
|
1485
|
-
|
|
2258
|
+
// Resize viewport fast path. While a non-multiplexer drag is in flight,
|
|
2259
|
+
// paint only the viewport and skip composing the off-screen history.
|
|
2260
|
+
// Strictly state-isolated: it never consumes #resizeEventPending nor
|
|
2261
|
+
// advances any commit/window/diff field, so the authoritative full paint
|
|
2262
|
+
// the settle timer queues reconciles as if these throwaway frames never
|
|
2263
|
+
// ran. A visible overlay composites over the transcript and needs the
|
|
2264
|
+
// whole window, so fall through to the normal forced paint when one is up
|
|
2265
|
+
// (overlay resizes are not on the drag-cost hot path).
|
|
2266
|
+
if (
|
|
2267
|
+
this.#resizeViewportPaintPending &&
|
|
2268
|
+
this.#resizeViewportActive &&
|
|
2269
|
+
this.#hasEverRendered &&
|
|
2270
|
+
this.#getTopmostVisibleOverlay() === undefined
|
|
2271
|
+
) {
|
|
2272
|
+
this.#resizeViewportPaintPending = false;
|
|
2273
|
+
this.#componentRenderTargets.clear();
|
|
2274
|
+
this.#renderResizeViewport(width, height);
|
|
2275
|
+
return;
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
// 1. Compose the frame. Bracket the render so the image budget observes
|
|
2279
|
+
// every inline image in display order (overlays carry none). A
|
|
2280
|
+
// component-scoped frame skips the budget pass instead — it is gated on
|
|
2281
|
+
// a quiescent budget, and a partial tree walk would under-count display
|
|
2282
|
+
// order — and re-renders only the requested root subtrees, reusing the
|
|
2283
|
+
// previous segment of every other root child.
|
|
2284
|
+
const partialRoots = componentScopedOnly ? this.#resolvePartialComposeRoots(width, height) : null;
|
|
2285
|
+
this.#componentRenderTargets.clear();
|
|
2286
|
+
let rawFrame: readonly string[];
|
|
2287
|
+
if (partialRoots !== null) {
|
|
2288
|
+
this.#partialComposeRoots = partialRoots;
|
|
2289
|
+
try {
|
|
2290
|
+
rawFrame = this.render(width);
|
|
2291
|
+
} finally {
|
|
2292
|
+
this.#partialComposeRoots = null;
|
|
2293
|
+
}
|
|
2294
|
+
} else {
|
|
2295
|
+
this.#imageBudget.beginPass();
|
|
2296
|
+
rawFrame = this.render(width);
|
|
2297
|
+
this.#imageBudget.endPass();
|
|
2298
|
+
}
|
|
2299
|
+
// Ghostty initial-image deferral must run before any render state is
|
|
2300
|
+
// consumed (#resizeEventPending, hardware-cursor state, commit
|
|
2301
|
+
// re-anchoring): the early return abandons this frame and the deferred
|
|
2302
|
+
// render recomposes from scratch, so consuming state here would
|
|
2303
|
+
// misclassify a pending resize as an ordinary diff and corrupt the paint.
|
|
2304
|
+
if (this.#maybeDeferGhosttyInitialImagePaint()) return;
|
|
2305
|
+
// Cursor markers were stripped at compose time (they are internal
|
|
2306
|
+
// sentinels and must never reach the terminal, the committed prefix, or
|
|
2307
|
+
// the audit); the visible marker is chosen after the window top is
|
|
2308
|
+
// known. Ascending by frame row.
|
|
2309
|
+
const cursorMarkers = this.#frameCursorMarkers;
|
|
2310
|
+
const liveRegionStart = this.#nativeScrollbackLiveRegionStart;
|
|
2311
|
+
const commitSafeEnd = this.#nativeScrollbackCommitSafeEnd;
|
|
2312
|
+
const snapshotSafeEnd = this.#nativeScrollbackSnapshotSafeEnd;
|
|
2313
|
+
|
|
2314
|
+
// 2. Transition state captured before any emitter runs.
|
|
2315
|
+
const prevWindowTop = this.#windowTopRow;
|
|
1486
2316
|
const prevHardwareCursorRow = this.#hardwareCursorRow;
|
|
1487
2317
|
const resizeEventOccurred = this.#resizeEventPending;
|
|
1488
2318
|
this.#resizeEventPending = false;
|
|
2319
|
+
if (resizeEventOccurred) this.#forgetHardwareCursorState();
|
|
1489
2320
|
const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
|
|
1490
|
-
// A resize event with net-unchanged dimensions still reflowed the
|
|
1491
|
-
// buffer; classify it as a height change so
|
|
1492
|
-
//
|
|
2321
|
+
// A resize event with net-unchanged dimensions still reflowed the
|
|
2322
|
+
// terminal buffer; classify it as a height change so geometry handling
|
|
2323
|
+
// repaints instead of diffing against a screen that no longer exists.
|
|
1493
2324
|
const heightChanged =
|
|
1494
2325
|
(this.#previousHeight > 0 && this.#previousHeight !== height) ||
|
|
1495
2326
|
(resizeEventOccurred && this.#previousHeight > 0);
|
|
1496
|
-
const
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
//
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
this.#
|
|
2327
|
+
const geometryChanged = widthChanged || heightChanged;
|
|
2328
|
+
|
|
2329
|
+
// Committed-prefix audit: rows below the commit index are physically in
|
|
2330
|
+
// terminal history and must never re-layout. When a component violates
|
|
2331
|
+
// that — a budget-demoted image collapsing to its one-line fallback, a
|
|
2332
|
+
// TTSR rewind truncating a block whose sealed prefix already committed —
|
|
2333
|
+
// keeping the old index would silently skip that many rows of
|
|
2334
|
+
// everything below (content loss). Re-anchor at the divergence instead:
|
|
2335
|
+
// the stale copy stays in history and rows recommit from there —
|
|
2336
|
+
// duplication, never loss. Skipped on geometry frames (a rewrap
|
|
2337
|
+
// legitimately reflows every row; the mux branch re-bases the prefix
|
|
2338
|
+
// and non-mux geometry replays from scratch), and skipped when the
|
|
2339
|
+
// composed frame's stable prefix covers every committed row — bytes
|
|
2340
|
+
// that provably did not change since the last (aligned) frame cannot
|
|
2341
|
+
// have diverged.
|
|
2342
|
+
let committedRowsResynced = false;
|
|
2343
|
+
if (
|
|
2344
|
+
this.#hasEverRendered &&
|
|
2345
|
+
!geometryChanged &&
|
|
2346
|
+
!this.#clearScrollbackOnNextRender &&
|
|
2347
|
+
this.#renderStablePrefixRows < this.#committedPrefixAuditRows
|
|
2348
|
+
) {
|
|
2349
|
+
const committedRowsBeforeAudit = this.#committedRows;
|
|
2350
|
+
this.#auditCommittedPrefix(rawFrame);
|
|
2351
|
+
committedRowsResynced = this.#committedRows !== committedRowsBeforeAudit;
|
|
2352
|
+
}
|
|
2353
|
+
// Committed-prefix state this frame's commit math extends from (post-audit).
|
|
2354
|
+
// Drives the byte-stable audit-rows cap recomputed after the emit.
|
|
2355
|
+
const preCommitRows = this.#committedRows;
|
|
2356
|
+
const preCommitAuditRows = this.#committedPrefixAuditRows;
|
|
2357
|
+
|
|
2358
|
+
// 3. Window and commit math (lengths only; content prepared below).
|
|
2359
|
+
const frameLength = rawFrame.length;
|
|
2360
|
+
let hasVisibleOverlay = false;
|
|
2361
|
+
for (const entry of this.overlayStack) {
|
|
2362
|
+
if (this.#isOverlayVisible(entry)) {
|
|
2363
|
+
hasVisibleOverlay = true;
|
|
2364
|
+
break;
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
// Two commit boundaries. byteStableBoundary: rows below it are byte-stable
|
|
2368
|
+
// (asserted never to re-layout) and stay under the committed-prefix audit.
|
|
2369
|
+
// durableBoundary: rows below it are durable — their scroll-off snapshot is
|
|
2370
|
+
// permanent (dropping them is forbidden) but may still drift afterward, so
|
|
2371
|
+
// they commit audit-EXEMPT. Both build on the finalized prefix (live-region
|
|
2372
|
+
// start); the whole frame when the root reports no seam (shell semantics:
|
|
2373
|
+
// whatever scrolls is final).
|
|
2374
|
+
const byteStableBoundary = Math.max(0, Math.min(frameLength, commitSafeEnd ?? liveRegionStart ?? frameLength));
|
|
2375
|
+
const durableBoundary = Math.max(
|
|
2376
|
+
byteStableBoundary,
|
|
2377
|
+
Math.min(frameLength, snapshotSafeEnd ?? byteStableBoundary),
|
|
1514
2378
|
);
|
|
1515
|
-
|
|
1516
|
-
//
|
|
1517
|
-
//
|
|
1518
|
-
//
|
|
1519
|
-
//
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
//
|
|
1538
|
-
//
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
//
|
|
1546
|
-
//
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
// multiplexer needs — and the `liveRegionPinned` planner above
|
|
1574
|
-
// keeps the actively-mutating live tail out of pane history while
|
|
1575
|
-
// committing only the sealed prefix (issue #1974).
|
|
1576
|
-
this.#markNativeScrollbackDirty();
|
|
1577
|
-
this.#streamingHighWater = Math.max(this.#streamingHighWater, lines.length);
|
|
1578
|
-
this.#scrollbackHighWater = 0;
|
|
1579
|
-
lines = lines.slice(-height);
|
|
1580
|
-
intent = { kind: "viewportRepaint" };
|
|
1581
|
-
} else {
|
|
1582
|
-
// Explicit reconcile or a non-committing frame (noop): let the
|
|
1583
|
-
// planned intent stand, but keep tracking the streaming peak.
|
|
1584
|
-
this.#streamingHighWater = Math.max(this.#streamingHighWater, lines.length);
|
|
2379
|
+
|
|
2380
|
+
// 4. Classify. A resize is an explicit user gesture: outside a
|
|
2381
|
+
// multiplexer it erases and replays so history rewraps at the new
|
|
2382
|
+
// geometry (the reader snapped to the bottom just dragged the window);
|
|
2383
|
+
// inside one the pane reflows its own history, so repaint in place.
|
|
2384
|
+
const firstPaint = !this.#hasEverRendered;
|
|
2385
|
+
const replaceRequested = this.#clearScrollbackOnNextRender;
|
|
2386
|
+
const geometryRebuild = geometryChanged && !isMultiplexerSession();
|
|
2387
|
+
const fullPaint = firstPaint || replaceRequested || geometryRebuild;
|
|
2388
|
+
let windowTop: number;
|
|
2389
|
+
let chunkTo: number;
|
|
2390
|
+
let committedPrefixResliced = false;
|
|
2391
|
+
if (fullPaint) {
|
|
2392
|
+
committedPrefixResliced = true;
|
|
2393
|
+
windowTop = Math.max(0, frameLength - height);
|
|
2394
|
+
chunkTo = Math.min(durableBoundary, windowTop);
|
|
2395
|
+
} else if (
|
|
2396
|
+
frameLength <= this.#committedRows ||
|
|
2397
|
+
(committedRowsResynced &&
|
|
2398
|
+
frameLength - this.#committedRows < height &&
|
|
2399
|
+
cursorMarkers.some(marker => marker.row >= this.#committedRows))
|
|
2400
|
+
) {
|
|
2401
|
+
// Either the frame shrank into the committed prefix, or a
|
|
2402
|
+
// committed-prefix resync left a focused cursor tail shorter than the
|
|
2403
|
+
// viewport. The latter happens when a streaming/live block had an
|
|
2404
|
+
// append-only prefix committed, then collapses on abort/finalize:
|
|
2405
|
+
// the audit re-anchors #committedRows at the first divergent row, but
|
|
2406
|
+
// flooring windowTop there would pin the editor near the top and
|
|
2407
|
+
// leave blank rows underneath. Re-show the frame tail instead. The
|
|
2408
|
+
// stale committed copy stays in native history; duplicating a few rows
|
|
2409
|
+
// is preferable to a live editor gap and matches the existing
|
|
2410
|
+
// "duplication, never loss" resync contract.
|
|
2411
|
+
windowTop = Math.max(0, frameLength - height);
|
|
2412
|
+
chunkTo = Math.min(durableBoundary, windowTop);
|
|
2413
|
+
committedPrefixResliced = true;
|
|
2414
|
+
this.#committedRows = chunkTo;
|
|
2415
|
+
this.#committedPrefix = rawFrame.slice(0, chunkTo);
|
|
2416
|
+
} else {
|
|
2417
|
+
// Re-anchor to the frame tail, floored at the committed boundary: a
|
|
2418
|
+
// shrink (or overlay close) pulls the window back down, but never
|
|
2419
|
+
// onto rows already in native history — re-showing those on the
|
|
2420
|
+
// grid would duplicate them for a scrolling reader. On a
|
|
2421
|
+
// multiplexer resize the pane reflowed its own history; committed
|
|
2422
|
+
// rows keep their old wrap there, same as any shell output.
|
|
2423
|
+
windowTop = Math.max(this.#committedRows, frameLength - height, 0);
|
|
2424
|
+
// Overlays freeze commits: composited rows must never enter
|
|
2425
|
+
// history, and the hidden gap backfills via the chunk once the
|
|
2426
|
+
// overlay closes. A multiplexer resize also commits nothing — the
|
|
2427
|
+
// pane keeps its own (old-wrap) history — and re-bases the audit
|
|
2428
|
+
// prefix at the new width so the accepted wrap drift does not read
|
|
2429
|
+
// as a violation on the next ordinary frame.
|
|
2430
|
+
chunkTo =
|
|
2431
|
+
hasVisibleOverlay || geometryChanged
|
|
2432
|
+
? this.#committedRows
|
|
2433
|
+
: Math.max(this.#committedRows, Math.min(durableBoundary, windowTop));
|
|
2434
|
+
if (geometryChanged) {
|
|
2435
|
+
committedPrefixResliced = true;
|
|
2436
|
+
this.#committedPrefix = rawFrame.slice(0, this.#committedRows);
|
|
1585
2437
|
}
|
|
1586
2438
|
}
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
2439
|
+
|
|
2440
|
+
// 5. Pick the visible cursor marker (bottom-most at or below the window
|
|
2441
|
+
// top), prepare lines, and build the visible window slice.
|
|
2442
|
+
let cursorPos: { row: number; col: number } | null = null;
|
|
2443
|
+
for (let i = cursorMarkers.length - 1; i >= 0; i--) {
|
|
2444
|
+
const marker = cursorMarkers[i]!;
|
|
2445
|
+
if (marker.row >= windowTop) {
|
|
2446
|
+
cursorPos = marker;
|
|
2447
|
+
break;
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
const frame = this.#prepareFrame(rawFrame, width);
|
|
2451
|
+
let window: string[] = new Array(height);
|
|
2452
|
+
for (let r = 0; r < height; r++) window[r] = frame[windowTop + r] ?? "";
|
|
2453
|
+
if (hasVisibleOverlay) {
|
|
2454
|
+
window = this.#compositeOverlaysIntoWindow(window, width, height);
|
|
2455
|
+
const overlayMarkers = this.#extractCursorMarkers(window);
|
|
2456
|
+
if (overlayMarkers.length > 0) {
|
|
2457
|
+
cursorPos = { row: windowTop + overlayMarkers[0]!.row, col: overlayMarkers[0]!.col };
|
|
2458
|
+
}
|
|
2459
|
+
window = this.#prepareLinesArray(window, width);
|
|
1590
2460
|
}
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
2461
|
+
|
|
2462
|
+
const intent: RenderIntent = fullPaint
|
|
2463
|
+
? { kind: "fullPaint", clearScrollback: replaceRequested || geometryRebuild ? !isMultiplexerSession() : false }
|
|
2464
|
+
: { kind: "update", chunkTo, windowTop };
|
|
2465
|
+
this.#logRedraw(intent, frameLength, height);
|
|
2466
|
+
|
|
2467
|
+
// Load newly-displayed image data once, before this frame's placements
|
|
2468
|
+
// (and any emitter) reference it. `a=t` produces no display, so writing
|
|
2469
|
+
// it ahead of the synchronized paint is artifact-free.
|
|
1597
2470
|
const imageTransmits = this.#imageBudget.takeTransmits();
|
|
1598
2471
|
if (imageTransmits.length > 0) {
|
|
1599
2472
|
let transmitBuffer = "";
|
|
1600
2473
|
for (const seq of imageTransmits) transmitBuffer += seq;
|
|
1601
2474
|
this.terminal.write(transmitBuffer);
|
|
1602
2475
|
}
|
|
1603
|
-
//
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
this.#nativeScrollbackCommitSafeEnd,
|
|
1629
|
-
);
|
|
1630
|
-
} else {
|
|
1631
|
-
this.#emitFullPaint(lines, width, height, cursorPos, { clearViewport: true, clearScrollback: false });
|
|
1632
|
-
}
|
|
1633
|
-
this.#hasEverRendered = true;
|
|
1634
|
-
return;
|
|
1635
|
-
}
|
|
1636
|
-
case "sessionReplace":
|
|
1637
|
-
this.#clearScrollbackOnNextRender = false;
|
|
1638
|
-
this.#clearNativeScrollbackDirty();
|
|
1639
|
-
this.#emitFullPaint(lines, width, height, cursorPos, {
|
|
1640
|
-
clearViewport: true,
|
|
1641
|
-
clearScrollback: !isMultiplexerSession(),
|
|
1642
|
-
});
|
|
1643
|
-
this.#hasEverRendered = true;
|
|
1644
|
-
return;
|
|
1645
|
-
case "historyRebuild":
|
|
1646
|
-
this.#clearNativeScrollbackDirty();
|
|
1647
|
-
this.#emitFullPaint(lines, width, height, cursorPos, {
|
|
1648
|
-
clearViewport: true,
|
|
1649
|
-
clearScrollback: !isMultiplexerSession(),
|
|
1650
|
-
});
|
|
1651
|
-
return;
|
|
1652
|
-
case "overlayRebuild":
|
|
1653
|
-
this.#clearNativeScrollbackDirty();
|
|
1654
|
-
this.#emitFullPaint(baseLines, width, height, null, {
|
|
1655
|
-
clearViewport: true,
|
|
1656
|
-
clearScrollback: !isMultiplexerSession(),
|
|
1657
|
-
});
|
|
1658
|
-
this.#emitViewportRepaint(lines, width, height, cursorPos);
|
|
1659
|
-
return;
|
|
1660
|
-
case "liveRegionPinned":
|
|
1661
|
-
this.#emitLiveRegionPinnedRepaint(
|
|
1662
|
-
lines,
|
|
1663
|
-
width,
|
|
1664
|
-
height,
|
|
1665
|
-
cursorPos,
|
|
1666
|
-
intent.appendFrom,
|
|
1667
|
-
intent.appendTo,
|
|
1668
|
-
intent.renderViewportTop,
|
|
1669
|
-
prevViewportTop,
|
|
1670
|
-
prevHardwareCursorRow,
|
|
1671
|
-
);
|
|
1672
|
-
return;
|
|
1673
|
-
case "viewportRepaint":
|
|
1674
|
-
if (intent.appendFrom !== undefined) {
|
|
1675
|
-
this.#emitAppendTail(lines, intent.appendFrom, height, width, prevViewportTop, prevHardwareCursorRow);
|
|
1676
|
-
}
|
|
1677
|
-
this.#emitViewportRepaint(lines, width, height, cursorPos);
|
|
1678
|
-
return;
|
|
1679
|
-
case "deferredTailRepaint":
|
|
1680
|
-
this.#emitDeferredTailRepaint(
|
|
1681
|
-
intent.line,
|
|
1682
|
-
width,
|
|
1683
|
-
height,
|
|
1684
|
-
intent.row,
|
|
1685
|
-
prevViewportTop,
|
|
1686
|
-
prevHardwareCursorRow,
|
|
1687
|
-
);
|
|
1688
|
-
return;
|
|
1689
|
-
case "deferredMutation":
|
|
1690
|
-
return;
|
|
1691
|
-
case "deferredShrink":
|
|
1692
|
-
this.#emitViewportRepaint(
|
|
1693
|
-
this.#padDeferredShrinkLines(lines, intent.paddedLength),
|
|
1694
|
-
width,
|
|
1695
|
-
height,
|
|
1696
|
-
cursorPos,
|
|
1697
|
-
);
|
|
1698
|
-
return;
|
|
1699
|
-
case "shrink":
|
|
1700
|
-
this.#emitShrink(lines, width, height, cursorPos, prevHardwareCursorRow, prevViewportTop);
|
|
1701
|
-
return;
|
|
1702
|
-
case "diff":
|
|
1703
|
-
this.#emitDiff(
|
|
1704
|
-
lines,
|
|
1705
|
-
width,
|
|
1706
|
-
height,
|
|
1707
|
-
cursorPos,
|
|
1708
|
-
intent.firstChanged,
|
|
1709
|
-
intent.lastChanged,
|
|
1710
|
-
intent.appendedLines,
|
|
1711
|
-
prevViewportTop,
|
|
1712
|
-
prevHardwareCursorRow,
|
|
1713
|
-
);
|
|
1714
|
-
return;
|
|
2476
|
+
// Purge graphics for images the budget demoted to text. Kitty keeps
|
|
2477
|
+
// images in a store that text clears don't touch; demoted rows still
|
|
2478
|
+
// visible re-render as text and the window diff repaints them.
|
|
2479
|
+
// Committed placements are immutable — their pixels are deleted but
|
|
2480
|
+
// their rows are not rewritten.
|
|
2481
|
+
let purgeSequence = "";
|
|
2482
|
+
if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
|
|
2483
|
+
for (const id of this.#imageBudget.takePurgeIds()) purgeSequence += encodeKittyDeleteImage(id);
|
|
2484
|
+
} else {
|
|
2485
|
+
this.#imageBudget.takePurgeIds();
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
// 6. Emit.
|
|
2489
|
+
if (intent.kind === "fullPaint") {
|
|
2490
|
+
this.#emitFullPaint(frame, window, width, height, cursorPos, purgeSequence, {
|
|
2491
|
+
clearScrollback: intent.clearScrollback,
|
|
2492
|
+
chunkTo,
|
|
2493
|
+
windowTop,
|
|
2494
|
+
});
|
|
2495
|
+
this.#committedPrefix = rawFrame.slice(0, chunkTo);
|
|
2496
|
+
this.#updateCommittedAuditRows(true, preCommitRows, preCommitAuditRows, byteStableBoundary);
|
|
2497
|
+
this.#clearScrollbackOnNextRender = false;
|
|
2498
|
+
this.#hasEverRendered = true;
|
|
2499
|
+
if (!firstPaint && frameLength > height) this.#armPostFullPaintSettle();
|
|
2500
|
+
return;
|
|
1715
2501
|
}
|
|
2502
|
+
this.#emitUpdate(frame, window, width, height, cursorPos, purgeSequence, {
|
|
2503
|
+
chunkTo,
|
|
2504
|
+
windowTop,
|
|
2505
|
+
prevWindowTop,
|
|
2506
|
+
prevHardwareCursorRow,
|
|
2507
|
+
forceWindowRewrite: this.#forceViewportRepaintOnNextRender || (geometryChanged && isMultiplexerSession()),
|
|
2508
|
+
});
|
|
2509
|
+
for (let i = this.#committedPrefix.length; i < chunkTo; i++) {
|
|
2510
|
+
this.#committedPrefix.push(rawFrame[i] ?? "");
|
|
2511
|
+
}
|
|
2512
|
+
this.#updateCommittedAuditRows(committedPrefixResliced, preCommitRows, preCommitAuditRows, byteStableBoundary);
|
|
1716
2513
|
}
|
|
1717
2514
|
|
|
1718
2515
|
/**
|
|
1719
|
-
*
|
|
1720
|
-
*
|
|
1721
|
-
*
|
|
1722
|
-
*
|
|
1723
|
-
*
|
|
1724
|
-
* to the differential machinery below.
|
|
2516
|
+
* Detect committed-prefix violations and re-anchor the commit index at the
|
|
2517
|
+
* first moved row, so subsequent rows recommit instead of being skipped:
|
|
2518
|
+
* the stale copy stays in history — duplication, never loss. Pure in-place
|
|
2519
|
+
* restyles keep their alignment and are left alone (stale styling in
|
|
2520
|
+
* history was always the accepted artifact).
|
|
1725
2521
|
*/
|
|
1726
|
-
#
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
): RenderIntent {
|
|
1738
|
-
// A forced scrollback wipe can be queued before start()'s initial paint runs
|
|
1739
|
-
// (cold `prometheus --resume` does this while replacing the welcome frame with the
|
|
1740
|
-
// restored transcript). Honor it before the normal initial-preserve path so
|
|
1741
|
-
// the first committed frame is the clean session replay, not a deferred wipe
|
|
1742
|
-
// that waits for the user's first keystroke.
|
|
1743
|
-
if (this.#clearScrollbackOnNextRender) return { kind: "sessionReplace" };
|
|
1744
|
-
|
|
1745
|
-
// Initial paint after start(): scrollback must keep its prior shell
|
|
1746
|
-
// content, but the viewport must be cleared so stale rows do not bleed
|
|
1747
|
-
// into the new UI.
|
|
1748
|
-
if (!this.#hasEverRendered) return { kind: "initial" };
|
|
1749
|
-
|
|
1750
|
-
const forceViewportRepaint = this.#forceViewportRepaintOnNextRender;
|
|
1751
|
-
const eagerEraseScrollbackRisk = this.#hasEagerEraseScrollbackRisk();
|
|
1752
|
-
if (overlayVisibilityReduced && !isMultiplexerSession()) {
|
|
1753
|
-
return hasVisibleOverlay ? { kind: "overlayRebuild" } : { kind: "historyRebuild" };
|
|
1754
|
-
}
|
|
1755
|
-
|
|
1756
|
-
// A terminal resize (width or height change) reflows the terminal's own
|
|
1757
|
-
// buffer, moving rows between the viewport and native scrollback and
|
|
1758
|
-
// invalidating every cursor/viewport anchor the diff and append emitters
|
|
1759
|
-
// rely on. Always reset cleanly at the new geometry and redraw. Inside a
|
|
1760
|
-
// multiplexer the pane's saved lines cannot be erased (ED 3 is a no-op there
|
|
1761
|
-
// and a full replay only duplicates the transcript), so repaint the visible
|
|
1762
|
-
// window in place; a visible overlay rebuilds with its composite. This
|
|
1763
|
-
// deliberately drops the no-overflow and confirmed-scrolled guards — a
|
|
1764
|
-
// resize is an explicit user action, so a scrolled reader snaps to the
|
|
1765
|
-
// bottom and preexisting shell scrollback above the UI is cleared. The
|
|
1766
|
-
// streaming cap above explicitly exempts geometry changes, so even during
|
|
1767
|
-
// active ED3-risk foreground streaming this rebuild stands and erases the
|
|
1768
|
-
// scrollback the terminal just re-wrapped at the old size.
|
|
1769
|
-
if (widthChanged || heightChanged) {
|
|
1770
|
-
if (isMultiplexerSession()) return { kind: "viewportRepaint" };
|
|
1771
|
-
return hasVisibleOverlay ? { kind: "overlayRebuild" } : { kind: "historyRebuild" };
|
|
1772
|
-
}
|
|
1773
|
-
|
|
1774
|
-
// Same dirty-scrollback opt-in policy as the non-overlay branch below: an
|
|
1775
|
-
// ED3-risk macOS/POSIX terminal with an unobservable viewport ignores
|
|
1776
|
-
// focused-input unknown opt-ins, so overlay selector Up/Down moves do not
|
|
1777
|
-
// become ED3 clears plus full transcript replays. Non-ED3-risk POSIX still
|
|
1778
|
-
// honors direct-input/IME/autocomplete opt-ins.
|
|
1779
|
-
if (hasVisibleOverlay) {
|
|
1780
|
-
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
|
|
1781
|
-
// Multiplexer panes never get a destructive scrollback clear
|
|
1782
|
-
// (clearScrollback is forced off inside them), so a dirty-scrollback
|
|
1783
|
-
// "rebuild" would only append a full duplicate copy of the transcript
|
|
1784
|
-
// to pane history on every dirty frame. Keep repainting the viewport
|
|
1785
|
-
// and leave reconciliation to explicit checkpoints.
|
|
1786
|
-
const allowDirtyUnknownViewportMutation = allowUnknownViewportMutation && !eagerEraseScrollbackRisk;
|
|
1787
|
-
if (
|
|
1788
|
-
this.#nativeScrollbackDirty &&
|
|
1789
|
-
!isMultiplexerSession() &&
|
|
1790
|
-
this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, allowDirtyUnknownViewportMutation)
|
|
1791
|
-
) {
|
|
1792
|
-
return { kind: "overlayRebuild" };
|
|
1793
|
-
}
|
|
1794
|
-
this.#markNativeScrollbackDirty();
|
|
1795
|
-
return { kind: "viewportRepaint" };
|
|
1796
|
-
}
|
|
1797
|
-
|
|
1798
|
-
const liveRegionPinnedIntent = this.#planLiveRegionPinnedRender(
|
|
1799
|
-
newLines,
|
|
1800
|
-
height,
|
|
1801
|
-
liveRegionStart,
|
|
1802
|
-
commitSafeEnd,
|
|
1803
|
-
eagerEraseScrollbackRisk,
|
|
1804
|
-
allowUnknownViewportMutation,
|
|
1805
|
-
);
|
|
1806
|
-
if (liveRegionPinnedIntent) return liveRegionPinnedIntent;
|
|
1807
|
-
|
|
1808
|
-
// After foreground tool streaming: when content finally shrinks from the
|
|
1809
|
-
// streaming peak, rebuild with ED 3 to commit the settled state cleanly.
|
|
1810
|
-
// The check uses `#streamingHighWater` (the real peak) rather than
|
|
1811
|
-
// `#previousLines.length` because unpinned ED3-risk streaming frames may
|
|
1812
|
-
// commit only a viewport slice while native history is deferred.
|
|
1813
|
-
if (this.#streamingHighWater > height && newLines.length < this.#streamingHighWater && newLines.length > height) {
|
|
1814
|
-
this.#streamingHighWater = 0;
|
|
1815
|
-
return { kind: "historyRebuild" };
|
|
1816
|
-
}
|
|
1817
|
-
if (this.#streamingHighWater > 0 && newLines.length <= height) {
|
|
1818
|
-
this.#streamingHighWater = 0;
|
|
1819
|
-
}
|
|
1820
|
-
|
|
1821
|
-
if (this.#nativeScrollbackDirty && !isMultiplexerSession()) {
|
|
1822
|
-
// A dirty flag means older native history is stale; it is not required to
|
|
1823
|
-
// make the current focused-input frame correct. On ED3-risk macOS/POSIX
|
|
1824
|
-
// terminals with an unobservable viewport, ignore focused-input unknown
|
|
1825
|
-
// opt-ins so Up/Down selector moves do not become ED3 clears plus full
|
|
1826
|
-
// transcript replays. Non-ED3-risk POSIX terminals keep their safe
|
|
1827
|
-
// direct-input/IME/autocomplete opt-in.
|
|
1828
|
-
const allowDirtyUnknownViewportMutation = allowUnknownViewportMutation && !eagerEraseScrollbackRisk;
|
|
1829
|
-
if (
|
|
1830
|
-
this.#canRebuildNativeScrollbackLive(this.#readNativeViewportAtBottom(), allowDirtyUnknownViewportMutation)
|
|
1831
|
-
) {
|
|
1832
|
-
return { kind: "historyRebuild" };
|
|
1833
|
-
}
|
|
2522
|
+
#auditCommittedPrefix(rawFrame: readonly string[]): void {
|
|
2523
|
+
const prefix = this.#committedPrefix;
|
|
2524
|
+
if (prefix.length === 0) return;
|
|
2525
|
+
const resyncTo = findCommittedPrefixResync(rawFrame, prefix, this.#committedPrefixAuditRows);
|
|
2526
|
+
if (resyncTo < 0) return;
|
|
2527
|
+
this.#committedRows = resyncTo;
|
|
2528
|
+
this.#committedPrefixAuditRows = Math.min(this.#committedPrefixAuditRows, resyncTo);
|
|
2529
|
+
prefix.length = resyncTo;
|
|
2530
|
+
if ($flag("PROMETHEUS_DEBUG_REDRAW")) {
|
|
2531
|
+
const msg = `[${new Date().toISOString()}] commit resync: committed prefix diverged at row ${resyncTo}; recommitting\n`;
|
|
2532
|
+
fs.appendFileSync(getDebugLogPath(), msg);
|
|
1834
2533
|
}
|
|
2534
|
+
}
|
|
1835
2535
|
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
// scroll position is not observable and ED3 can yank readers.
|
|
1860
|
-
if (this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, false)) {
|
|
1861
|
-
return { kind: "historyRebuild" };
|
|
1862
|
-
}
|
|
1863
|
-
// POSIX terminals — and Windows Terminal/ConPTY — that cannot report the
|
|
1864
|
-
// viewport position fall through here (`canRebuildNativeScrollbackLive` is
|
|
1865
|
-
// false). A destructive rebuild emits `\x1b[3J` (xterm erase saved lines),
|
|
1866
|
-
// which can clear or reposition native scrollback and yank a scrolled-up
|
|
1867
|
-
// reader (issue #1635), so it is unsafe while the probe is unavailable.
|
|
1868
|
-
//
|
|
1869
|
-
const paddedViewportTop = Math.max(0, this.#previousLines.length - height);
|
|
1870
|
-
// ED3-risk terminals with an unobservable viewport cannot safely clear
|
|
1871
|
-
// saved lines. Direct user-input frames (autocomplete/IME) may still
|
|
1872
|
-
// repaint the live viewport: the user action pins the host to the tail, and
|
|
1873
|
-
// emitting zero bytes leaves stale autocomplete rows on screen until a later
|
|
1874
|
-
// checkpoint. When the changed rows are at or below the previous viewport
|
|
1875
|
-
// top, keep the old bottom anchor by padding the frame to its previous
|
|
1876
|
-
// length; that clears stale popup rows without re-exposing rows already
|
|
1877
|
-
// committed to native history. If an offscreen edit shifted rows above the
|
|
1878
|
-
// viewport, padding would repaint the wrong seam, so use a viewport repaint
|
|
1879
|
-
// for liveness and keep history dirty. Active eager streaming also uses a
|
|
1880
|
-
// viewport repaint so the live tail keeps moving. With neither direct input
|
|
1881
|
-
// nor active eager streaming, the reader may be scrolled, so defer
|
|
1882
|
-
// completely rather than repainting over their history.
|
|
1883
|
-
if (nativeViewportAtBottom === undefined && eagerEraseScrollbackRisk) {
|
|
1884
|
-
this.#markNativeScrollbackDirty();
|
|
1885
|
-
if (allowUnknownViewportMutation) {
|
|
1886
|
-
return diff.firstChanged < prevViewportTop
|
|
1887
|
-
? { kind: "viewportRepaint" }
|
|
1888
|
-
: { kind: "deferredShrink", paddedLength: this.#previousLines.length };
|
|
1889
|
-
}
|
|
1890
|
-
return this.#eagerNativeScrollbackRebuild
|
|
1891
|
-
? { kind: "viewportRepaint" }
|
|
1892
|
-
: this.#planDeferredTailRepaint(newLines, prevViewportTop, height);
|
|
1893
|
-
}
|
|
2536
|
+
/**
|
|
2537
|
+
* Recompute the byte-stable audit-rows cap after a commit. The audited prefix
|
|
2538
|
+
* [0, auditRows) holds rows committed while byte-stable; rows committed under a
|
|
2539
|
+
* durable snapshot end (beyond byteStableBoundary) are excluded so the audit
|
|
2540
|
+
* never re-anchors on their expected drift (a streaming table widening). A
|
|
2541
|
+
* wholesale re-slice (full paint / shrink / geometry) re-bases the prefix from
|
|
2542
|
+
* the current frame, so the cap is just min(committed, byteStableBoundary). An
|
|
2543
|
+
* incremental extend keeps the cap once any snapshot row has committed
|
|
2544
|
+
* (auditRows < committedRows): a later rise in byteStableBoundary (a table
|
|
2545
|
+
* finalizing) must not pull already-committed stale snapshots back under audit.
|
|
2546
|
+
*/
|
|
2547
|
+
#updateCommittedAuditRows(
|
|
2548
|
+
resliced: boolean,
|
|
2549
|
+
preCommittedRows: number,
|
|
2550
|
+
preAuditRows: number,
|
|
2551
|
+
byteStableBoundary: number,
|
|
2552
|
+
): void {
|
|
2553
|
+
const committed = this.#committedRows;
|
|
2554
|
+
this.#committedPrefixAuditRows =
|
|
2555
|
+
resliced || preAuditRows >= preCommittedRows
|
|
2556
|
+
? Math.min(committed, byteStableBoundary)
|
|
2557
|
+
: Math.min(preAuditRows, committed);
|
|
2558
|
+
}
|
|
1894
2559
|
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
2560
|
+
/**
|
|
2561
|
+
* Prepare the composed frame for emission, in place. Rows below
|
|
2562
|
+
* `#preparedValidRows` are already prepared against the current frame (the
|
|
2563
|
+
* compose lowered that floor to the stable prefix); rows at/after it are
|
|
2564
|
+
* revalidated positionally — a row whose raw content and width match its
|
|
2565
|
+
* cached entry reuses the prepared line, anything else re-prepares.
|
|
2566
|
+
*/
|
|
2567
|
+
#prepareFrame(frame: readonly string[], width: number): string[] {
|
|
2568
|
+
const prepared = this.#preparedFrame;
|
|
2569
|
+
const meta = this.#preparedMeta;
|
|
2570
|
+
if (prepared.length > frame.length) {
|
|
2571
|
+
prepared.length = frame.length;
|
|
2572
|
+
meta.length = frame.length;
|
|
2573
|
+
}
|
|
2574
|
+
for (let i = Math.min(this.#preparedValidRows, prepared.length); i < frame.length; i++) {
|
|
2575
|
+
const raw = frame[i]!;
|
|
2576
|
+
const cached = meta[i];
|
|
2577
|
+
if (cached !== undefined && cached.raw === raw && cached.width === width) {
|
|
2578
|
+
prepared[i] = cached.line;
|
|
2579
|
+
continue;
|
|
1905
2580
|
}
|
|
1906
|
-
|
|
2581
|
+
const entry = this.#prepareLine(raw, width);
|
|
2582
|
+
meta[i] = entry;
|
|
2583
|
+
prepared[i] = entry.line;
|
|
1907
2584
|
}
|
|
2585
|
+
this.#preparedValidRows = frame.length;
|
|
2586
|
+
return prepared;
|
|
2587
|
+
}
|
|
1908
2588
|
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
if (
|
|
1915
|
-
isMultiplexerSession() &&
|
|
1916
|
-
diff.firstChanged !== -1 &&
|
|
1917
|
-
newLines.length < this.#previousLines.length &&
|
|
1918
|
-
naturalViewportTop !== prevViewportTop
|
|
1919
|
-
) {
|
|
1920
|
-
return { kind: "viewportRepaint" };
|
|
2589
|
+
/** Stateless variant for overlay-composited windows and alt-screen frames. */
|
|
2590
|
+
#prepareLinesArray(lines: readonly string[], width: number): string[] {
|
|
2591
|
+
const prepared: string[] = new Array(lines.length);
|
|
2592
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2593
|
+
prepared[i] = this.#prepareLine(lines[i]!, width).line;
|
|
1921
2594
|
}
|
|
2595
|
+
return prepared;
|
|
2596
|
+
}
|
|
1922
2597
|
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
// previous viewport top and would only clear the old suffix, hiding the
|
|
1927
|
-
// editor above the live window.
|
|
1928
|
-
if (
|
|
1929
|
-
allowUnknownViewportMutation &&
|
|
1930
|
-
diff.firstChanged !== -1 &&
|
|
1931
|
-
newLines.length < this.#previousLines.length &&
|
|
1932
|
-
naturalViewportTop !== prevViewportTop
|
|
1933
|
-
) {
|
|
1934
|
-
return { kind: "viewportRepaint" };
|
|
1935
|
-
}
|
|
1936
|
-
|
|
1937
|
-
// A shrink that moves the bottom-anchored viewport upward must re-anchor the
|
|
1938
|
-
// visible window. The shrink-across-high-water block above already
|
|
1939
|
-
// rebuilt/deferred when the shrink re-exposes rows committed to native
|
|
1940
|
-
// scrollback (`naturalViewportTop < #scrollbackHighWater`). The remaining
|
|
1941
|
-
// case slips through when the high-water mark lags the logical viewport top:
|
|
1942
|
-
// non-destructive viewport repaints during foreground-tool streaming on
|
|
1943
|
-
// ED3-risk terminals (ghostty/kitty/…) advance `#maxLinesRendered` without
|
|
1944
|
-
// committing the overflow to native history, so a later shrink finds
|
|
1945
|
-
// `naturalViewportTop >= #scrollbackHighWater` yet still needs to move the
|
|
1946
|
-
// window up. The diff emitter below anchors to `#maxLinesRendered - height`
|
|
1947
|
-
// and would only rewrite the suffix — dropping the newly exposed top row and
|
|
1948
|
-
// leaving a blank at the bottom, so the rows below appear to render over the
|
|
1949
|
-
// ones above. Repaint the true bottom-anchored tail and leave stale
|
|
1950
|
-
// scrollback for the next checkpoint.
|
|
1951
|
-
if (
|
|
1952
|
-
!isMultiplexerSession() &&
|
|
1953
|
-
diff.firstChanged !== -1 &&
|
|
1954
|
-
newLines.length < this.#previousLines.length &&
|
|
1955
|
-
naturalViewportTop < prevViewportTop
|
|
1956
|
-
) {
|
|
1957
|
-
this.#markNativeScrollbackDirty();
|
|
1958
|
-
return { kind: "viewportRepaint" };
|
|
2598
|
+
#prepareLine(raw: string, width: number): PreparedLine {
|
|
2599
|
+
if (TERMINAL.isImageLine(raw)) {
|
|
2600
|
+
return { raw, width, line: raw };
|
|
1959
2601
|
}
|
|
1960
|
-
|
|
1961
|
-
const
|
|
1962
|
-
this.#
|
|
1963
|
-
if (
|
|
1964
|
-
|
|
1965
|
-
diff.appendedLines &&
|
|
1966
|
-
diff.firstChanged < this.#previousLines.length &&
|
|
1967
|
-
!isMultiplexerSession()
|
|
1968
|
-
) {
|
|
1969
|
-
// A checkpoint replay is followed by one frame where transient live chrome
|
|
1970
|
-
// (status/footer rows) may be inserted inside the visible suffix and then
|
|
1971
|
-
// disappear; repaint it in place so it never enters scrollback. If the
|
|
1972
|
-
// insertion grows the overflow boundary, native history would lose rows
|
|
1973
|
-
// while the viewport looks correct, so rebuild instead.
|
|
1974
|
-
const appendedTailStart = this.#findAppendedTailStart(newLines);
|
|
1975
|
-
const overflowBefore = Math.max(0, this.#previousLines.length - height);
|
|
1976
|
-
const overflowAfter = Math.max(0, newLines.length - height);
|
|
1977
|
-
if (
|
|
1978
|
-
appendedTailStart === newLines.length &&
|
|
1979
|
-
diff.firstChanged >= prevViewportTop &&
|
|
1980
|
-
overflowAfter <= overflowBefore
|
|
1981
|
-
) {
|
|
1982
|
-
return { kind: "viewportRepaint" };
|
|
1983
|
-
}
|
|
1984
|
-
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
|
|
1985
|
-
if (this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, allowUnknownViewportMutation)) {
|
|
1986
|
-
return { kind: "historyRebuild" };
|
|
1987
|
-
}
|
|
1988
|
-
this.#markNativeScrollbackDirty();
|
|
1989
|
-
return { kind: "viewportRepaint" };
|
|
1990
|
-
}
|
|
1991
|
-
|
|
1992
|
-
if (diff.firstChanged === -1) {
|
|
1993
|
-
// Content unchanged. A forced render still refreshes the visible viewport
|
|
1994
|
-
// but keeps the existing diff basis so later coalesced content mutations
|
|
1995
|
-
// can still update native scrollback correctly.
|
|
1996
|
-
if (forceViewportRepaint) return { kind: "viewportRepaint" };
|
|
1997
|
-
return { kind: "noop" };
|
|
1998
|
-
}
|
|
1999
|
-
|
|
2000
|
-
const contentGrew = newLines.length > this.#previousLines.length;
|
|
2001
|
-
const pureAppend = diff.appendedLines && diff.firstChanged === this.#previousLines.length;
|
|
2002
|
-
const structuralMutation = newLines.length !== this.#previousLines.length || diff.firstChanged < prevViewportTop;
|
|
2003
|
-
if (pureAppend && contentGrew && this.#previousLines.length >= height && !isMultiplexerSession()) {
|
|
2004
|
-
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
|
|
2005
|
-
if (this.#nativeViewportIsKnownScrolled(nativeViewportAtBottom)) {
|
|
2006
|
-
this.#markNativeScrollbackDirty();
|
|
2007
|
-
return { kind: "deferredMutation" };
|
|
2008
|
-
}
|
|
2009
|
-
if (nativeViewportAtBottom === undefined && allowUnknownViewportMutation) {
|
|
2010
|
-
// Direct input can grow transient live UI (autocomplete/IME/editor
|
|
2011
|
-
// wraps) while the previous frame already touched the viewport bottom.
|
|
2012
|
-
// A diff append would `\r\n`-scroll those transient rows into native
|
|
2013
|
-
// history, and a later popup shrink would duplicate the stable prefix at
|
|
2014
|
-
// the scrollback seam. Repaint the live viewport in place instead; the
|
|
2015
|
-
// dirty checkpoint owns native-history reconciliation.
|
|
2016
|
-
this.#markNativeScrollbackDirty();
|
|
2017
|
-
return { kind: "viewportRepaint" };
|
|
2018
|
-
}
|
|
2019
|
-
if (this.#nativeViewportIsScrolled(nativeViewportAtBottom, allowUnknownViewportMutation)) {
|
|
2020
|
-
this.#markNativeScrollbackDirty();
|
|
2021
|
-
// Unknown viewport (e.g. native Windows Terminal where the probe cannot
|
|
2022
|
-
// see WT host scrollback) is a different case: a no-op there freezes the
|
|
2023
|
-
// editor on the keystroke that grows `lines.length` past the viewport
|
|
2024
|
-
// (the wrap keystroke). Fall through to a non-destructive viewport
|
|
2025
|
-
// repaint instead so the live UI keeps updating without yanking a
|
|
2026
|
-
// possibly-scrolled reader.
|
|
2027
|
-
return { kind: "viewportRepaint" };
|
|
2028
|
-
}
|
|
2602
|
+
const source = this.#lineFitSource(raw, width);
|
|
2603
|
+
const normalized = normalizeTerminalOutput(source);
|
|
2604
|
+
const asciiWidth = this.#ansiAsciiLineWidth(normalized, width);
|
|
2605
|
+
if ((asciiWidth ?? visibleWidth(normalized)) <= width) {
|
|
2606
|
+
return { raw, width, line: normalized };
|
|
2029
2607
|
}
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
// stale history; over-counting scrolls an extra row and duplicates the
|
|
2055
|
-
// line at the viewport top. Rebuild whenever the replay checkpoint allows.
|
|
2056
|
-
if (
|
|
2057
|
-
contentGrew &&
|
|
2058
|
-
diff.firstChanged < prevViewportTop &&
|
|
2059
|
-
this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, false)
|
|
2060
|
-
) {
|
|
2061
|
-
const appendedTailStart = diff.appendedLines ? this.#findAppendedTailStart(newLines) : newLines.length;
|
|
2062
|
-
const tailAppendCount = newLines.length - appendedTailStart;
|
|
2063
|
-
const addedCount = newLines.length - this.#previousLines.length;
|
|
2064
|
-
if (addedCount !== tailAppendCount) {
|
|
2065
|
-
return { kind: "historyRebuild" };
|
|
2608
|
+
const line = truncateToWidth(normalized, width, Ellipsis.Omit);
|
|
2609
|
+
return { raw, width, line };
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
#lineFitSource(raw: string, width: number): string {
|
|
2613
|
+
const safeWidth = Number.isFinite(width) ? Math.max(1, Math.trunc(width)) : 1;
|
|
2614
|
+
const maxSourceLength = Math.min(
|
|
2615
|
+
LINE_FIT_MAX_SOURCE_CODE_UNITS,
|
|
2616
|
+
Math.max(LINE_FIT_MIN_SOURCE_CODE_UNITS, safeWidth * LINE_FIT_SOURCE_WIDTH_MULTIPLIER),
|
|
2617
|
+
);
|
|
2618
|
+
if (raw.length <= maxSourceLength) return raw;
|
|
2619
|
+
|
|
2620
|
+
let output = "";
|
|
2621
|
+
let cells = 0;
|
|
2622
|
+
for (let i = 0; i < raw.length && cells < safeWidth; ) {
|
|
2623
|
+
if (raw.charCodeAt(i) === 0x1b) {
|
|
2624
|
+
const end = this.#ansiSequenceEnd(raw, i);
|
|
2625
|
+
if (end < 0) break;
|
|
2626
|
+
if (this.#ansiSequenceHasVisiblePayload(raw, i)) {
|
|
2627
|
+
const sequence = raw.slice(i, end);
|
|
2628
|
+
if (output.length + sequence.length <= maxSourceLength) {
|
|
2629
|
+
output += sequence;
|
|
2630
|
+
cells += visibleWidth(sequence);
|
|
2631
|
+
}
|
|
2066
2632
|
}
|
|
2633
|
+
i = end;
|
|
2634
|
+
continue;
|
|
2067
2635
|
}
|
|
2068
|
-
if (
|
|
2069
|
-
newLines.length !== this.#previousLines.length &&
|
|
2070
|
-
this.#scrollbackHighWater > 0 &&
|
|
2071
|
-
this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, allowUnknownViewportMutation)
|
|
2072
|
-
) {
|
|
2073
|
-
return { kind: "historyRebuild" };
|
|
2074
|
-
}
|
|
2075
|
-
}
|
|
2076
|
-
|
|
2077
|
-
// Configurable shrink-clear: opt-in path that repaints to wipe rows the
|
|
2078
|
-
// diff path would leave behind.
|
|
2079
|
-
if (this.#clearOnShrink && newLines.length < this.#previousLines.length && this.overlayStack.length === 0) {
|
|
2080
|
-
return { kind: "viewportRepaint" };
|
|
2081
|
-
}
|
|
2082
|
-
|
|
2083
|
-
// Pure trailing shrink: all changed indices live past the new tail.
|
|
2084
|
-
if (diff.firstChanged >= newLines.length) {
|
|
2085
|
-
return { kind: "shrink" };
|
|
2086
|
-
}
|
|
2087
2636
|
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
if (
|
|
2098
|
-
!isMultiplexerSession() &&
|
|
2099
|
-
this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, allowUnknownViewportMutation)
|
|
2100
|
-
) {
|
|
2101
|
-
return { kind: "historyRebuild" };
|
|
2637
|
+
const code = raw.charCodeAt(i);
|
|
2638
|
+
const next = code >= 0xd800 && code <= 0xdbff && i + 1 < raw.length ? i + 2 : i + 1;
|
|
2639
|
+
const char = raw.slice(i, next);
|
|
2640
|
+
const charWidth = visibleWidth(char);
|
|
2641
|
+
if (charWidth > 0 && cells + charWidth > safeWidth) break;
|
|
2642
|
+
if (output.length + char.length > maxSourceLength) {
|
|
2643
|
+
if (charWidth > 0) break;
|
|
2644
|
+
i = next;
|
|
2645
|
+
continue;
|
|
2102
2646
|
}
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
return this.#planDeferredTailRepaint(newLines, prevViewportTop, height);
|
|
2647
|
+
if (charWidth === 0) {
|
|
2648
|
+
const remainingVisibleCells = safeWidth - cells;
|
|
2649
|
+
const reservedCodeUnits = remainingVisibleCells * 2;
|
|
2650
|
+
if (output.length + char.length > maxSourceLength - reservedCodeUnits) {
|
|
2651
|
+
i = next;
|
|
2652
|
+
continue;
|
|
2653
|
+
}
|
|
2111
2654
|
}
|
|
2112
|
-
|
|
2655
|
+
output += char;
|
|
2656
|
+
cells += charWidth;
|
|
2657
|
+
i = next;
|
|
2113
2658
|
}
|
|
2114
2659
|
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2660
|
+
return output + SEGMENT_RESET;
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
#ansiSequenceEnd(line: string, start: number): number {
|
|
2664
|
+
const next = line.charCodeAt(start + 1);
|
|
2665
|
+
if (next === 0x5b) {
|
|
2666
|
+
let i = start + 2;
|
|
2667
|
+
while (i < line.length) {
|
|
2668
|
+
const final = line.charCodeAt(i);
|
|
2669
|
+
if (final >= 0x40 && final <= 0x7e) return i + 1;
|
|
2670
|
+
i++;
|
|
2119
2671
|
}
|
|
2120
|
-
|
|
2121
|
-
|
|
2672
|
+
return -1;
|
|
2673
|
+
}
|
|
2674
|
+
if (next === 0x5d) {
|
|
2675
|
+
let i = start + 2;
|
|
2676
|
+
while (i < line.length) {
|
|
2677
|
+
const osc = line.charCodeAt(i);
|
|
2678
|
+
if (osc === 0x07) return i + 1;
|
|
2679
|
+
if (osc === 0x1b && line.charCodeAt(i + 1) === 0x5c) return i + 2;
|
|
2680
|
+
i++;
|
|
2122
2681
|
}
|
|
2682
|
+
return -1;
|
|
2123
2683
|
}
|
|
2684
|
+
return start + 2 <= line.length ? start + 2 : -1;
|
|
2685
|
+
}
|
|
2124
2686
|
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2687
|
+
#ansiSequenceHasVisiblePayload(line: string, start: number): boolean {
|
|
2688
|
+
// OSC 66 (`\x1b]66;META;TEXT\x1b\\`) carries visible cells inside the payload.
|
|
2689
|
+
return (
|
|
2690
|
+
line.charCodeAt(start + 1) === 0x5d &&
|
|
2691
|
+
line.charCodeAt(start + 2) === 0x36 &&
|
|
2692
|
+
line.charCodeAt(start + 3) === 0x36 &&
|
|
2693
|
+
line.charCodeAt(start + 4) === 0x3b
|
|
2694
|
+
);
|
|
2131
2695
|
}
|
|
2132
2696
|
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2697
|
+
#ansiAsciiLineWidth(line: string, maxWidth: number): number | undefined {
|
|
2698
|
+
let col = 0;
|
|
2699
|
+
for (let i = 0; i < line.length; ) {
|
|
2700
|
+
const code = line.charCodeAt(i);
|
|
2701
|
+
if (code === 0x1b) {
|
|
2702
|
+
const next = line.charCodeAt(i + 1);
|
|
2703
|
+
if (next === 0x5b) {
|
|
2704
|
+
let j = i + 2;
|
|
2705
|
+
while (j < line.length) {
|
|
2706
|
+
const final = line.charCodeAt(j);
|
|
2707
|
+
if (final >= 0x40 && final <= 0x7e) break;
|
|
2708
|
+
j++;
|
|
2709
|
+
}
|
|
2710
|
+
if (j >= line.length) return undefined;
|
|
2711
|
+
i = j + 1;
|
|
2712
|
+
continue;
|
|
2713
|
+
}
|
|
2714
|
+
if (next === 0x5d) {
|
|
2715
|
+
// OSC 66 text-sizing spans carry visible payload inside the OSC.
|
|
2716
|
+
// Fall back to visibleWidth() so scaled cells stay exact.
|
|
2717
|
+
if (
|
|
2718
|
+
line.charCodeAt(i + 2) === 0x36 &&
|
|
2719
|
+
line.charCodeAt(i + 3) === 0x36 &&
|
|
2720
|
+
line.charCodeAt(i + 4) === 0x3b
|
|
2721
|
+
) {
|
|
2722
|
+
return undefined;
|
|
2723
|
+
}
|
|
2724
|
+
let j = i + 2;
|
|
2725
|
+
while (j < line.length) {
|
|
2726
|
+
const osc = line.charCodeAt(j);
|
|
2727
|
+
if (osc === 0x07) {
|
|
2728
|
+
i = j + 1;
|
|
2729
|
+
break;
|
|
2730
|
+
}
|
|
2731
|
+
if (osc === 0x1b && line.charCodeAt(j + 1) === 0x5c) {
|
|
2732
|
+
i = j + 2;
|
|
2733
|
+
break;
|
|
2734
|
+
}
|
|
2735
|
+
j++;
|
|
2736
|
+
}
|
|
2737
|
+
if (j >= line.length) return undefined;
|
|
2738
|
+
continue;
|
|
2739
|
+
}
|
|
2740
|
+
return undefined;
|
|
2149
2741
|
}
|
|
2742
|
+
if (code < 0x20 || code > 0x7e) return undefined;
|
|
2743
|
+
col++;
|
|
2744
|
+
if (col > maxWidth) return col;
|
|
2745
|
+
i++;
|
|
2150
2746
|
}
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2747
|
+
return col;
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
#lineRewriteSequence(line: string, width: number): string {
|
|
2751
|
+
if (TERMINAL.isImageLine(line)) return ERASE_LINE + line;
|
|
2752
|
+
const terminalLine = this.#terminalLine(line);
|
|
2753
|
+
const asciiWidth = this.#ansiAsciiLineWidth(line, width);
|
|
2754
|
+
if (asciiWidth !== undefined) {
|
|
2755
|
+
// Exact width model: skip the erase only when the row truly fills
|
|
2756
|
+
// the line (an EL there would eat the last cell via pending-wrap).
|
|
2757
|
+
return asciiWidth >= width ? terminalLine : terminalLine + ERASE_TO_END_OF_LINE;
|
|
2155
2758
|
}
|
|
2156
|
-
|
|
2759
|
+
// Non-ASCII rows: the native measure can over-count combining-heavy
|
|
2760
|
+
// scripts, so a row it calls "full" may render short and leave stale
|
|
2761
|
+
// cells from the previous occupant — which would then scroll into
|
|
2762
|
+
// history baked into the committed row. Erase the line first instead
|
|
2763
|
+
// (rewrites always start at column 1, so EL-to-end clears the whole
|
|
2764
|
+
// row); the leading reset keeps BCE on the default background.
|
|
2765
|
+
return SEGMENT_RESET + ERASE_TO_END_OF_LINE + terminalLine;
|
|
2157
2766
|
}
|
|
2158
2767
|
|
|
2159
2768
|
/**
|
|
2160
|
-
*
|
|
2161
|
-
*
|
|
2162
|
-
* "new appends" relative to the unchanged tail. Used to push streaming
|
|
2163
|
-
* output into scrollback even when an offscreen edit also moved rows.
|
|
2769
|
+
* Single state-transition point. Every emitter calls this exactly once at
|
|
2770
|
+
* the end so cursor/window accounting stays consistent.
|
|
2164
2771
|
*/
|
|
2165
|
-
#
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
length += 1;
|
|
2179
|
-
}
|
|
2180
|
-
if (length > bestLength) {
|
|
2181
|
-
bestLength = length;
|
|
2182
|
-
bestEnd = end;
|
|
2183
|
-
}
|
|
2184
|
-
}
|
|
2185
|
-
return bestEnd === -1 ? newLines.length : bestEnd + 1;
|
|
2772
|
+
#commit(
|
|
2773
|
+
lines: readonly string[],
|
|
2774
|
+
window: string[],
|
|
2775
|
+
width: number,
|
|
2776
|
+
height: number,
|
|
2777
|
+
hardwareCursor: HardwareCursorUpdate,
|
|
2778
|
+
): void {
|
|
2779
|
+
this.#previousFrameLength = lines.length;
|
|
2780
|
+
this.#previousWindow = window;
|
|
2781
|
+
this.#forceViewportRepaintOnNextRender = false;
|
|
2782
|
+
this.#previousWidth = width;
|
|
2783
|
+
this.#previousHeight = height;
|
|
2784
|
+
this.#recordHardwareCursorUpdate(hardwareCursor);
|
|
2186
2785
|
}
|
|
2187
2786
|
|
|
2188
|
-
#
|
|
2189
|
-
|
|
2787
|
+
#targetHardwareCursorState(
|
|
2788
|
+
cursorPos: { row: number; col: number } | null,
|
|
2789
|
+
totalLines: number,
|
|
2790
|
+
): HardwareCursorState | null {
|
|
2791
|
+
if (!cursorPos || totalLines <= 0) return null;
|
|
2792
|
+
return {
|
|
2793
|
+
row: Math.max(0, Math.min(cursorPos.row, totalLines - 1)),
|
|
2794
|
+
col: Math.max(0, cursorPos.col),
|
|
2795
|
+
visible: this.#showHardwareCursor,
|
|
2796
|
+
};
|
|
2190
2797
|
}
|
|
2191
2798
|
|
|
2192
|
-
#
|
|
2193
|
-
this.#
|
|
2799
|
+
#recordHardwareCursorState(state: HardwareCursorState): void {
|
|
2800
|
+
this.#hardwareCursorRow = state.row;
|
|
2801
|
+
this.#hardwareCursorState = state;
|
|
2802
|
+
this.#hardwareCursorVisible = state.visible;
|
|
2803
|
+
this.#hardwareCursorVisibilityKnown = true;
|
|
2194
2804
|
}
|
|
2195
2805
|
|
|
2196
|
-
#
|
|
2197
|
-
|
|
2198
|
-
|
|
2806
|
+
#recordHardwareCursorRowOnly(row: number, visible?: boolean): void {
|
|
2807
|
+
this.#hardwareCursorRow = row;
|
|
2808
|
+
this.#hardwareCursorState = null;
|
|
2809
|
+
if (visible !== undefined) {
|
|
2810
|
+
this.#hardwareCursorVisible = visible;
|
|
2811
|
+
this.#hardwareCursorVisibilityKnown = true;
|
|
2812
|
+
}
|
|
2199
2813
|
}
|
|
2200
2814
|
|
|
2201
|
-
#
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
return second === true ? true : second;
|
|
2815
|
+
#recordHardwareCursorUpdate(update: HardwareCursorUpdate): void {
|
|
2816
|
+
if (update.state) {
|
|
2817
|
+
this.#recordHardwareCursorState(update.state);
|
|
2818
|
+
return;
|
|
2819
|
+
}
|
|
2820
|
+
this.#recordHardwareCursorRowOnly(update.toRow, update.visible);
|
|
2208
2821
|
}
|
|
2209
2822
|
|
|
2210
|
-
#
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
nativeViewportAtBottom === false ||
|
|
2216
|
-
(nativeViewportAtBottom === undefined && process.platform === "win32" && !allowUnknownViewportMutation)
|
|
2217
|
-
);
|
|
2823
|
+
#recordHardwareCursorHidden(): void {
|
|
2824
|
+
this.#hardwareCursorVisible = false;
|
|
2825
|
+
this.#hardwareCursorVisibilityKnown = true;
|
|
2826
|
+
if (!this.#hardwareCursorState) return;
|
|
2827
|
+
this.#hardwareCursorState = { ...this.#hardwareCursorState, visible: false };
|
|
2218
2828
|
}
|
|
2219
2829
|
|
|
2220
|
-
#
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
#canReplayNativeScrollbackAtCheckpoint(nativeViewportAtBottom: boolean | undefined): boolean {
|
|
2224
|
-
return nativeViewportAtBottom === true;
|
|
2830
|
+
#forgetHardwareCursorState(): void {
|
|
2831
|
+
this.#hardwareCursorState = null;
|
|
2832
|
+
this.#hardwareCursorVisibilityKnown = false;
|
|
2225
2833
|
}
|
|
2226
2834
|
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
* Decides whether a destructive native scrollback rebuild
|
|
2230
|
-
* (`historyRebuild`/`overlayRebuild`, which clears saved lines and may move
|
|
2231
|
-
* the native viewport) is safe to emit *during ordinary rendering*. POSIX
|
|
2232
|
-
* terminals cannot report whether the user has scrolled up
|
|
2233
|
-
* (`isNativeViewportAtBottom()` is `undefined`), so an unknown position is
|
|
2234
|
-
* treated as unsafe: defer to a non-destructive viewport repaint and keep
|
|
2235
|
-
* scrollback dirty until a later render has a positive at-tail proof. A prompt
|
|
2236
|
-
* submit is no longer treated as proof for unobservable host scrollback.
|
|
2237
|
-
* this, every offscreen transcript edit while streaming wiped scrollback and
|
|
2238
|
-
* yanked a scrolled-up reader out of their current context.
|
|
2239
|
-
* `allowUnknownViewportMutation` (autocomplete/IME) opts directly
|
|
2240
|
-
* user-driven POSIX frames back into the rebuild. Native Windows and Windows
|
|
2241
|
-
* Terminal still cannot trust an unknown probe during live rendering — ConPTY
|
|
2242
|
-
* may be fronting host scrollback we cannot observe — so they keep deferring.
|
|
2243
|
-
*/
|
|
2244
|
-
#canRebuildNativeScrollbackLive(
|
|
2245
|
-
nativeViewportAtBottom: boolean | undefined,
|
|
2246
|
-
allowUnknownViewportMutation: boolean,
|
|
2247
|
-
): boolean {
|
|
2835
|
+
#sameHardwareCursorState(state: HardwareCursorState): boolean {
|
|
2836
|
+
const current = this.#hardwareCursorState;
|
|
2248
2837
|
return (
|
|
2249
|
-
|
|
2250
|
-
(nativeViewportAtBottom === undefined && allowUnknownViewportMutation && process.platform !== "win32")
|
|
2838
|
+
current !== null && current.row === state.row && current.col === state.col && current.visible === state.visible
|
|
2251
2839
|
);
|
|
2252
2840
|
}
|
|
2253
2841
|
|
|
2254
|
-
#planLiveRegionPinnedRender(
|
|
2255
|
-
newLines: string[],
|
|
2256
|
-
height: number,
|
|
2257
|
-
liveRegionStart: number | undefined,
|
|
2258
|
-
commitSafeEnd: number | undefined,
|
|
2259
|
-
eagerEraseScrollbackRisk: boolean,
|
|
2260
|
-
allowUnknownViewportMutation: boolean,
|
|
2261
|
-
): RenderIntent | undefined {
|
|
2262
|
-
if (
|
|
2263
|
-
liveRegionStart === undefined ||
|
|
2264
|
-
liveRegionStart >= newLines.length ||
|
|
2265
|
-
!this.#eagerNativeScrollbackRebuild ||
|
|
2266
|
-
!eagerEraseScrollbackRisk ||
|
|
2267
|
-
allowUnknownViewportMutation
|
|
2268
|
-
) {
|
|
2269
|
-
return undefined;
|
|
2270
|
-
}
|
|
2271
|
-
// Multiplexers (tmux/screen/zellij) cannot erase pane history with `\x1b[3J`
|
|
2272
|
-
// and cannot answer a viewport-position probe, so the destructive checkpoint
|
|
2273
|
-
// rebuild path is forever unavailable. The pinned emitter is built from the
|
|
2274
|
-
// opposite primitives — relative cursor moves, per-line `\x1b[2K`, and
|
|
2275
|
-
// `\r\n` to scroll sealed rows past the viewport bottom — which are exactly
|
|
2276
|
-
// what tmux pane history accepts. Without this commit-as-you-go path, the
|
|
2277
|
-
// streaming cap below clipped every frame to the visible tail and the
|
|
2278
|
-
// scrolled-off head was committed nowhere (issue #1974).
|
|
2279
|
-
if (newLines.length <= height && this.#scrollbackHighWater === 0) return undefined;
|
|
2280
|
-
if (this.#readNativeViewportAtBottom() !== undefined) return undefined;
|
|
2281
|
-
|
|
2282
|
-
this.#markNativeScrollbackDirty();
|
|
2283
|
-
const naturalViewportTop = Math.max(0, newLines.length - height);
|
|
2284
|
-
// Rows before the live-region boundary are sealed. The commit boundary is
|
|
2285
|
-
// the deeper of the sealed start and the append-only `commitSafeEnd`: a
|
|
2286
|
-
// streaming assistant block reports a `commitSafeEnd` spanning its whole
|
|
2287
|
-
// body, so its head rows that scroll above the viewport commit to native
|
|
2288
|
-
// scrollback instead of vanishing (committed nowhere, repainted nowhere).
|
|
2289
|
-
// A volatile live block (a tool preview that later collapses) omits
|
|
2290
|
-
// `commitSafeEnd`, so the boundary falls back to `liveRegionStart` and its
|
|
2291
|
-
// mutable rows stay deferred — otherwise a pending box that later collapses
|
|
2292
|
-
// to its running/final shape leaves the old top half in scrollback and
|
|
2293
|
-
// repaints the new tail below it, visually splitting one box across the
|
|
2294
|
-
// scrollback seam.
|
|
2295
|
-
const commitBoundary = commitSafeEnd ?? liveRegionStart;
|
|
2296
|
-
const sealedAppendTo = Math.min(naturalViewportTop, commitBoundary);
|
|
2297
|
-
const appendTo = Math.max(0, sealedAppendTo);
|
|
2298
|
-
const appendFrom = Math.min(this.#scrollbackHighWater, appendTo);
|
|
2299
|
-
// If the live-region collapse would re-expose committed rows already written
|
|
2300
|
-
// to native scrollback, clamp the repaint below that committed prefix so
|
|
2301
|
-
// committed rows are not duplicated. Mutable rows beyond the commit boundary
|
|
2302
|
-
// may remain hidden above the viewport until the next checkpoint rebuild;
|
|
2303
|
-
// that is safer than committing transient rows that can later re-layout.
|
|
2304
|
-
const committedSealedEnd = Math.min(this.#scrollbackHighWater, commitBoundary);
|
|
2305
|
-
const renderViewportTop = Math.max(naturalViewportTop, committedSealedEnd);
|
|
2306
|
-
return { kind: "liveRegionPinned", appendFrom, appendTo, renderViewportTop };
|
|
2307
|
-
}
|
|
2308
|
-
|
|
2309
|
-
#planDeferredTailRepaint(newLines: string[], prevViewportTop: number, height: number): RenderIntent {
|
|
2310
|
-
const row = prevViewportTop + height - 1;
|
|
2311
|
-
if (row < 0 || row >= this.#previousLines.length || newLines.length !== this.#previousLines.length) {
|
|
2312
|
-
return { kind: "deferredMutation" };
|
|
2313
|
-
}
|
|
2314
|
-
const line = newLines[row] ?? "";
|
|
2315
|
-
const previousLine = this.#deferredTailLine ?? this.#previousLines[row] ?? "";
|
|
2316
|
-
if (line === previousLine) {
|
|
2317
|
-
return { kind: "deferredMutation" };
|
|
2318
|
-
}
|
|
2319
|
-
return { kind: "deferredTailRepaint", row, line };
|
|
2320
|
-
}
|
|
2321
|
-
|
|
2322
|
-
#padDeferredShrinkLines(lines: string[], paddedLength: number): string[] {
|
|
2323
|
-
if (lines.length >= paddedLength) return lines;
|
|
2324
|
-
return [...lines, ...new Array<string>(paddedLength - lines.length).fill("")];
|
|
2325
|
-
}
|
|
2326
|
-
/**
|
|
2327
|
-
* Truncate a line to the visible viewport width. Image lines are left
|
|
2328
|
-
* alone, narrow lines pass through unchanged. Truncation re-appends the
|
|
2329
|
-
* per-line terminator so SGR/OSC 8 state does not leak across rows when
|
|
2330
|
-
* `truncateToWidth` drops the trailing bytes appended by
|
|
2331
|
-
* {@link #applyLineResets}.
|
|
2332
|
-
*/
|
|
2333
|
-
#fitLinesToWidth(lines: string[], width: number): string[] {
|
|
2334
|
-
for (let i = 0; i < lines.length; i++) {
|
|
2335
|
-
lines[i] = this.#fitLineToWidth(lines[i], width);
|
|
2336
|
-
}
|
|
2337
|
-
return lines;
|
|
2338
|
-
}
|
|
2339
|
-
|
|
2340
|
-
#fitLineToWidth(line: string, width: number): string {
|
|
2341
|
-
if (TERMINAL.isImageLine(line)) return line;
|
|
2342
|
-
if (visibleWidth(line) <= width) return line;
|
|
2343
|
-
const truncated = truncateToWidth(line, width, Ellipsis.Omit);
|
|
2344
|
-
return truncated + (truncated.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
|
|
2345
|
-
}
|
|
2346
|
-
|
|
2347
2842
|
/**
|
|
2348
|
-
*
|
|
2349
|
-
*
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
this.#deferredTailLine = undefined;
|
|
2354
|
-
this.#previousLines = lines;
|
|
2355
|
-
this.#previousVisibleOverlayComponents = this.#visibleOverlayComponentsThisRender;
|
|
2356
|
-
this.#forceViewportRepaintOnNextRender = false;
|
|
2357
|
-
this.#previousWidth = width;
|
|
2358
|
-
this.#previousHeight = height;
|
|
2359
|
-
this.#cursorRow = Math.max(0, lines.length - 1);
|
|
2360
|
-
this.#viewportTopRow = viewportTop;
|
|
2361
|
-
this.#hardwareCursorRow = hardwareCursorRow;
|
|
2362
|
-
}
|
|
2363
|
-
|
|
2364
|
-
/**
|
|
2365
|
-
* Clear the viewport (optionally scrollback) and emit the full transcript.
|
|
2366
|
-
* Backs `initial`, `sessionReplace`, and `historyRebuild` intents.
|
|
2843
|
+
* Clear the viewport (optionally native scrollback) and replay the frame:
|
|
2844
|
+
* committed prefix `[0, chunkTo)` followed by the visible window. ED3
|
|
2845
|
+
* (`CSI 3 J`) is emitted here and only here, and only for gesture-driven
|
|
2846
|
+
* paints (session replace, resize, resetDisplay, or an explicit
|
|
2847
|
+
* `clearScrollback` initial paint).
|
|
2367
2848
|
*/
|
|
2368
2849
|
#emitFullPaint(
|
|
2369
|
-
|
|
2850
|
+
frame: readonly string[],
|
|
2851
|
+
window: string[],
|
|
2370
2852
|
width: number,
|
|
2371
2853
|
height: number,
|
|
2372
2854
|
cursorPos: { row: number; col: number } | null,
|
|
2373
|
-
|
|
2855
|
+
purgeSequence: string,
|
|
2856
|
+
options: { clearScrollback: boolean; chunkTo: number; windowTop: number },
|
|
2374
2857
|
): void {
|
|
2375
2858
|
this.#fullRedrawCount += 1;
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
// images in a store that text-clear escapes don't touch, so delete them by
|
|
2379
|
-
// id; other protocols bake images into cells the clear-screen below wipes.
|
|
2380
|
-
const purgeIds = this.#imageBudget.takePurgeIds();
|
|
2381
|
-
if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
|
|
2382
|
-
for (const id of purgeIds) buffer += encodeKittyDeleteImage(id);
|
|
2383
|
-
}
|
|
2384
|
-
if (options.clearViewport) {
|
|
2385
|
-
if (options.clearScrollback) {
|
|
2386
|
-
buffer += "\x1b[2J\x1b[H\x1b[3J";
|
|
2387
|
-
} else {
|
|
2388
|
-
// Best-effort: push the pre-paint screen into scrollback on terminals
|
|
2389
|
-
// that implement kitty's ED 22 (copy-screen-to-scrollback-then-erase).
|
|
2390
|
-
// ED 22 is not universal: multiplexers (tmux/screen/zellij), non-kitty
|
|
2391
|
-
// terminals, and old kitty ignore the unknown ED parameter, which left
|
|
2392
|
-
// the initial paint with no viewport clear (stale prior-program content
|
|
2393
|
-
// bled through until a resize). Always follow with ED 2 so the viewport
|
|
2394
|
-
// is cleared regardless; on real kitty, ED 2 over the now-blank screen
|
|
2395
|
-
// is a no-op and does not push a second (blank) copy to scrollback.
|
|
2396
|
-
if (TERMINAL.supportsScreenToScrollback) buffer += "\x1b[22J";
|
|
2397
|
-
buffer += "\x1b[2J\x1b[H";
|
|
2398
|
-
}
|
|
2399
|
-
}
|
|
2400
|
-
// Only the final viewport rows stay on screen; everything above scrolls
|
|
2401
|
-
// into native scrollback, so optimize the visible tail with DECCARA
|
|
2402
|
-
// rectangles while writing scrollback-bound rows as full styled strings
|
|
2403
|
-
// (their background must survive in history, which DECCARA cannot reach).
|
|
2404
|
-
const visibleStart = Math.max(0, lines.length - height);
|
|
2405
|
-
let fillSequence = "";
|
|
2406
|
-
let visibleTexts: string[] | null = null;
|
|
2407
|
-
if (TERMINAL.deccara && visibleStart < lines.length) {
|
|
2408
|
-
const visible: string[] = new Array(lines.length - visibleStart);
|
|
2409
|
-
for (let k = 0; k < visible.length; k++) {
|
|
2410
|
-
visible[k] = this.#fitLineToWidth(lines[visibleStart + k], width);
|
|
2411
|
-
}
|
|
2412
|
-
const plan = planDeccaraFills(visible, width);
|
|
2413
|
-
visibleTexts = plan.texts;
|
|
2414
|
-
fillSequence = plan.sequence;
|
|
2415
|
-
}
|
|
2416
|
-
for (let i = 0; i < lines.length; i++) {
|
|
2417
|
-
if (i > 0) buffer += "\r\n";
|
|
2418
|
-
buffer +=
|
|
2419
|
-
visibleTexts && i >= visibleStart ? visibleTexts[i - visibleStart] : this.#fitLineToWidth(lines[i], width);
|
|
2420
|
-
}
|
|
2421
|
-
buffer += fillSequence;
|
|
2422
|
-
const finalRow = Math.max(0, lines.length - 1);
|
|
2423
|
-
const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, finalRow);
|
|
2424
|
-
buffer += seq;
|
|
2425
|
-
buffer += this.#paintEndSequence;
|
|
2426
|
-
this.terminal.write(buffer);
|
|
2427
|
-
|
|
2428
|
-
this.#maxLinesRendered = options.clearViewport ? lines.length : Math.max(this.#maxLinesRendered, lines.length);
|
|
2859
|
+
const { chunkTo, windowTop } = options;
|
|
2860
|
+
let buffer = this.#paintBeginSequence + this.#leaveResizeAltSequence() + purgeSequence;
|
|
2429
2861
|
if (options.clearScrollback) {
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
#emitInitialLiveRegionPinnedPaint(
|
|
2447
|
-
lines: string[],
|
|
2448
|
-
width: number,
|
|
2449
|
-
height: number,
|
|
2450
|
-
cursorPos: { row: number; col: number } | null,
|
|
2451
|
-
liveRegionStart: number,
|
|
2452
|
-
commitSafeEnd: number | undefined,
|
|
2453
|
-
): void {
|
|
2454
|
-
this.#fullRedrawCount += 1;
|
|
2455
|
-
this.#markNativeScrollbackDirty();
|
|
2456
|
-
const naturalViewportTop = Math.max(0, lines.length - height);
|
|
2457
|
-
const commitBoundary = commitSafeEnd ?? liveRegionStart;
|
|
2458
|
-
const appendTo = Math.max(0, Math.min(naturalViewportTop, commitBoundary, lines.length));
|
|
2459
|
-
const viewportTop = naturalViewportTop;
|
|
2460
|
-
|
|
2461
|
-
let buffer = this.#paintBeginSequence;
|
|
2462
|
-
if (TERMINAL.supportsScreenToScrollback) buffer += "\x1b[22J";
|
|
2463
|
-
buffer += "\x1b[2J\x1b[H";
|
|
2464
|
-
|
|
2862
|
+
buffer += "\x1b[2J\x1b[H\x1b[3J";
|
|
2863
|
+
} else {
|
|
2864
|
+
// Best-effort: push the pre-paint screen into scrollback on
|
|
2865
|
+
// terminals that implement kitty's ED 22
|
|
2866
|
+
// (copy-screen-to-scrollback-then-erase). Always follow with ED 2 so
|
|
2867
|
+
// the viewport is cleared regardless; on real kitty, ED 2 over the
|
|
2868
|
+
// now-blank screen is a no-op and does not push a second copy.
|
|
2869
|
+
if (TERMINAL.supportsScreenToScrollback) buffer += "\x1b[22J";
|
|
2870
|
+
buffer += "\x1b[2J\x1b[H";
|
|
2871
|
+
}
|
|
2872
|
+
// DECCARA fills optimize only the rows that stay visible; history-bound
|
|
2873
|
+
// rows are written as full styled strings (their background must
|
|
2874
|
+
// survive in scrollback, which DECCARA cannot reach).
|
|
2875
|
+
const { texts, sequence } = this.#deccaraFillsEnabled()
|
|
2876
|
+
? planDeccaraFills(window, width)
|
|
2877
|
+
: { texts: window, sequence: "" };
|
|
2465
2878
|
let wroteLine = false;
|
|
2466
|
-
for (let i = 0; i <
|
|
2879
|
+
for (let i = 0; i < chunkTo; i++) {
|
|
2467
2880
|
if (wroteLine) buffer += "\r\n";
|
|
2468
|
-
buffer += this.#
|
|
2881
|
+
buffer += this.#terminalLine(frame[i] ?? "");
|
|
2469
2882
|
wroteLine = true;
|
|
2470
2883
|
}
|
|
2471
2884
|
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
2472
2885
|
if (wroteLine) buffer += "\r\n";
|
|
2473
|
-
buffer += this.#
|
|
2886
|
+
buffer += this.#terminalLine(texts[screenRow] ?? "");
|
|
2474
2887
|
wroteLine = true;
|
|
2475
2888
|
}
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2889
|
+
buffer += sequence;
|
|
2890
|
+
// Park the hardware cursor at real content bottom, not the padded
|
|
2891
|
+
// window bottom — a later height shrink would otherwise scroll live
|
|
2892
|
+
// rows into scrollback and duplicate them per resize step.
|
|
2893
|
+
const contentRows = Math.max(1, Math.min(height, frame.length - windowTop));
|
|
2894
|
+
const parkUp = height - contentRows;
|
|
2480
2895
|
if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
|
|
2481
|
-
const
|
|
2482
|
-
|
|
2896
|
+
const contentBottomRow = windowTop + contentRows - 1;
|
|
2897
|
+
const cursorControl = this.#cursorControlSequence(cursorPos, frame.length, contentBottomRow);
|
|
2898
|
+
buffer += cursorControl.seq;
|
|
2483
2899
|
buffer += this.#paintEndSequence;
|
|
2484
2900
|
this.terminal.write(buffer);
|
|
2485
2901
|
|
|
2486
|
-
this.#
|
|
2487
|
-
this.#
|
|
2488
|
-
this.#commit(
|
|
2902
|
+
this.#committedRows = chunkTo;
|
|
2903
|
+
this.#windowTopRow = windowTop;
|
|
2904
|
+
this.#commit(frame, window, width, height, cursorControl);
|
|
2489
2905
|
}
|
|
2906
|
+
|
|
2490
2907
|
/**
|
|
2491
|
-
*
|
|
2492
|
-
*
|
|
2908
|
+
* Enter (or extend) the non-multiplexer resize fast path. Marks the drag
|
|
2909
|
+
* active so subsequent `#doRender` calls paint viewport-only, then (re)arms
|
|
2910
|
+
* the quiet-window timer whose callback ends the drag with one authoritative
|
|
2911
|
+
* full paint. Reset on every SIGWINCH, so the full replay fires only once the
|
|
2912
|
+
* user stops dragging.
|
|
2493
2913
|
*/
|
|
2494
|
-
#
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
buffer += sequence;
|
|
2519
|
-
// The loop unconditionally writes `height` rows from screen row 0, so the
|
|
2520
|
-
// hardware cursor lands at the padded viewport bottom (`viewportTop +
|
|
2521
|
-
// height - 1`) even when the content is shorter than the viewport and the
|
|
2522
|
-
// trailing rows are blank. Parking it below the content is unsafe: a later
|
|
2523
|
-
// terminal height *shrink* scrolls the live content rows up into native
|
|
2524
|
-
// scrollback to keep that cursor on screen, and the next repaint redraws
|
|
2525
|
-
// them — committing a duplicate copy of the visible block to history once
|
|
2526
|
-
// per resize step (a drag-resize multiplies it). Move the cursor up to the
|
|
2527
|
-
// real content bottom so it matches the post-paint invariant every other
|
|
2528
|
-
// emitter holds and the reflow has no live rows to scroll away. The move is
|
|
2529
|
-
// physical (not just tracked), so `#cursorControlSequence`'s relative
|
|
2530
|
-
// `rowDelta` stays correct and the IME cursor still lands on its row after a
|
|
2531
|
-
// height-grow resize.
|
|
2532
|
-
const viewportBottomRow = viewportTop + height - 1;
|
|
2533
|
-
const contentBottomRow = Math.min(viewportBottomRow, Math.max(viewportTop, lines.length - 1));
|
|
2534
|
-
const parkUp = viewportBottomRow - contentBottomRow;
|
|
2535
|
-
if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
|
|
2536
|
-
const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, contentBottomRow);
|
|
2537
|
-
buffer += seq;
|
|
2538
|
-
buffer += this.#paintEndSequence;
|
|
2539
|
-
this.terminal.write(buffer);
|
|
2540
|
-
|
|
2541
|
-
this.#maxLinesRendered = lines.length;
|
|
2542
|
-
this.#commit(lines, width, height, viewportTop, toRow);
|
|
2914
|
+
#beginResizeViewport(): void {
|
|
2915
|
+
this.#resizeViewportActive = true;
|
|
2916
|
+
this.#resizeViewportSettleTimer?.cancel();
|
|
2917
|
+
this.#resizeViewportSettleTimer = this.#renderScheduler.scheduleRender(() => {
|
|
2918
|
+
this.#resizeViewportSettleTimer = undefined;
|
|
2919
|
+
this.#resizeViewportActive = false;
|
|
2920
|
+
if (this.#stopped) return;
|
|
2921
|
+
// The drag is quiet: replay the rewrapped transcript authoritatively.
|
|
2922
|
+
// #resizeEventPending was preserved across every viewport-only frame
|
|
2923
|
+
// (the fast path never consumes it), so this classifies as a geometry
|
|
2924
|
+
// rebuild — ED3 + full history — and the clearScrollback intent below
|
|
2925
|
+
// matches the gesture-driven reset path.
|
|
2926
|
+
this.#resizeEventPending = true;
|
|
2927
|
+
this.requestRender(true, { clearScrollback: !isMultiplexerSession() });
|
|
2928
|
+
}, TUI.#RESIZE_VIEWPORT_SETTLE_MS);
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
#requestResizeViewportPaint(): void {
|
|
2932
|
+
if (this.#stopped) return;
|
|
2933
|
+
this.#resizeViewportPaintPending = true;
|
|
2934
|
+
this.#renderRequested = false;
|
|
2935
|
+
this.#lastRenderAt = this.#renderScheduler.now();
|
|
2936
|
+
this.#doRender();
|
|
2937
|
+
if (this.#renderRequested) this.#scheduleRender();
|
|
2543
2938
|
}
|
|
2544
2939
|
|
|
2545
2940
|
/**
|
|
2546
|
-
*
|
|
2547
|
-
*
|
|
2548
|
-
*
|
|
2549
|
-
*
|
|
2550
|
-
*
|
|
2551
|
-
* Uses only the no-scroll-snap vocabulary of {@link #emitDiff}: relative
|
|
2552
|
-
* cursor moves, per-line `\x1b[2K`, and `\r\n` to push the sealed chunk into
|
|
2553
|
-
* history. It deliberately avoids a full-screen erase (`\x1b[2J`) and absolute
|
|
2554
|
-
* cursor home (`\x1b[H`): on Ghostty those snap a reader scrolled into history
|
|
2555
|
-
* back to the bottom on every frame.
|
|
2941
|
+
* Compose and paint only the viewport for one resize fast-path frame.
|
|
2942
|
+
* State-isolated: advances no commit/window/diff field and calls neither
|
|
2943
|
+
* `#commit` nor `#emitFullPaint`, so the settle full paint reconciles against
|
|
2944
|
+
* the pre-drag screen state.
|
|
2556
2945
|
*/
|
|
2557
|
-
#
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
this.#
|
|
2569
|
-
const
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
// Position at the top visible row with a relative move. Terminals clamp the
|
|
2575
|
-
// hardware cursor to the viewport on resize, so clamp our tracking to match
|
|
2576
|
-
// before computing the delta (mirrors #emitDiff).
|
|
2577
|
-
const clampedCursor = Math.min(prevHardwareCursorRow, prevViewportTop + height - 1);
|
|
2578
|
-
const currentScreenRow = Math.max(0, Math.min(height - 1, clampedCursor - prevViewportTop));
|
|
2579
|
-
let buffer = this.#paintBeginSequence;
|
|
2580
|
-
if (currentScreenRow > 0) buffer += `\x1b[${currentScreenRow}A`;
|
|
2581
|
-
buffer += "\r";
|
|
2946
|
+
#renderResizeViewport(width: number, height: number): void {
|
|
2947
|
+
if (width <= 0 || height <= 0) return;
|
|
2948
|
+
// Tail renders call block.render(), which observes inline images on the
|
|
2949
|
+
// budget. This is a STABLE (partial) pass: the tail walk is bottom-up and
|
|
2950
|
+
// sees only the visible subset, so display-order-by-call-order is wrong
|
|
2951
|
+
// here — `beginPass(true)` makes observe() replay the last committed
|
|
2952
|
+
// live/text split per image id instead, so images keep their on-screen
|
|
2953
|
+
// state through the drag. Reset the pass each frame so a long drag does
|
|
2954
|
+
// not accumulate; never endPass() here — that mutates the demotion ledger
|
|
2955
|
+
// off a partial walk. The settle paint's own beginPass()/endPass() is the
|
|
2956
|
+
// authoritative accounting, and its beginPass() wipes these frames.
|
|
2957
|
+
this.#imageBudget.beginPass(true);
|
|
2958
|
+
const { window, contentRows } = this.#composeResizeViewport(width, height);
|
|
2959
|
+
this.#emitResizeViewport(window, height, contentRows, width);
|
|
2960
|
+
this.#resizeViewportPaintCount += 1;
|
|
2961
|
+
}
|
|
2582
2962
|
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2963
|
+
/**
|
|
2964
|
+
* Build the viewport window for a resize fast-path frame: the bottom
|
|
2965
|
+
* `height` rows of the would-be full frame, collected bottom-up across root
|
|
2966
|
+
* children. {@link ViewportTailProvider}s (the transcript) yield only their
|
|
2967
|
+
* tail; the small live-region children below render in full — so every child
|
|
2968
|
+
* entirely above the fold is skipped. A frame shorter than the viewport is
|
|
2969
|
+
* top-aligned with blank rows below, matching the full-paint window geometry
|
|
2970
|
+
* (windowTop = max(0, frameLength - height)). Cursor markers are stripped
|
|
2971
|
+
* (the drag hides the hardware cursor) and rows are width-fitted via the
|
|
2972
|
+
* stateless preparer, so no persistent prepared-frame cache is touched.
|
|
2973
|
+
*/
|
|
2974
|
+
#composeResizeViewport(width: number, height: number): { window: readonly string[]; contentRows: number } {
|
|
2975
|
+
const tail: string[] = []; // bottom-first
|
|
2976
|
+
const children = this.children;
|
|
2977
|
+
for (let i = children.length - 1; i >= 0 && tail.length < height; i--) {
|
|
2978
|
+
const child = children[i]!;
|
|
2979
|
+
const provider = asViewportTailProvider(child);
|
|
2980
|
+
const rows = provider ? provider.renderViewportTail(width, height - tail.length) : child.render(width);
|
|
2981
|
+
for (let r = rows.length - 1; r >= 0 && tail.length < height; r--) {
|
|
2982
|
+
tail.push(rows[r]!);
|
|
2983
|
+
}
|
|
2592
2984
|
}
|
|
2985
|
+
const count = tail.length;
|
|
2986
|
+
const window: string[] = new Array(height);
|
|
2593
2987
|
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2988
|
+
// `tail` holds the bottom `count` frame rows, bottom-first. They fill
|
|
2989
|
+
// the viewport when the frame overflows it and sit at the top (blanks
|
|
2990
|
+
// below) when it underflows.
|
|
2991
|
+
window[screenRow] = screenRow < count ? tail[count - 1 - screenRow]! : "";
|
|
2597
2992
|
}
|
|
2993
|
+
this.#extractCursorMarkers(window);
|
|
2994
|
+
return { window: this.#prepareLinesArray(window, width), contentRows: count };
|
|
2995
|
+
}
|
|
2598
2996
|
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
this.terminal.
|
|
2997
|
+
/** Enter or leave the alternate screen borrowed for transient resize frames. */
|
|
2998
|
+
#enterResizeAltSequence(): string {
|
|
2999
|
+
if (this.#resizeAltActive || this.#altActive) return "";
|
|
3000
|
+
this.#resizeAltActive = true;
|
|
3001
|
+
setAltScreenActive(true);
|
|
3002
|
+
this.#forgetHardwareCursorState();
|
|
3003
|
+
this.#recordHardwareCursorHidden();
|
|
3004
|
+
return `${ALT_SCREEN_ENTER}${this.terminal.kittyEnableSequence ?? ""}`;
|
|
3005
|
+
}
|
|
2607
3006
|
|
|
2608
|
-
|
|
2609
|
-
if (
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
3007
|
+
#leaveResizeAltSequence(): string {
|
|
3008
|
+
if (!this.#resizeAltActive) return "";
|
|
3009
|
+
const kittyPop = this.terminal.kittyEnableSequence ? "\x1b[<u" : "";
|
|
3010
|
+
this.#resizeAltActive = false;
|
|
3011
|
+
setAltScreenActive(false);
|
|
3012
|
+
this.#forgetHardwareCursorState();
|
|
3013
|
+
return `${kittyPop}${ALT_SCREEN_EXIT}`;
|
|
2613
3014
|
}
|
|
2614
3015
|
|
|
2615
3016
|
/**
|
|
2616
|
-
*
|
|
2617
|
-
*
|
|
2618
|
-
*
|
|
2619
|
-
*
|
|
3017
|
+
* Emit a throwaway viewport repaint for the resize fast path as an alternate-
|
|
3018
|
+
* screen per-row overwrite. The normal buffer may reflow full-width rows on a
|
|
3019
|
+
* width change before the app can repaint; keeping the drag on the alternate
|
|
3020
|
+
* screen makes those transient resizes truncate instead of pushing wrapped
|
|
3021
|
+
* fragments into native scrollback. Normal-screen history is rebuilt once at
|
|
3022
|
+
* settle via `#emitFullPaint`.
|
|
2620
3023
|
*/
|
|
2621
|
-
#
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
// exists. Without this the scroll math points outside the viewport.
|
|
2634
|
-
const clampedCursor = Math.min(prevHardwareCursorRow, prevViewportTop + height - 1);
|
|
2635
|
-
const currentScreenRow = Math.max(0, Math.min(height - 1, clampedCursor - prevViewportTop));
|
|
2636
|
-
const moveToBottom = height - 1 - currentScreenRow;
|
|
2637
|
-
if (moveToBottom > 0) buffer += `\x1b[${moveToBottom}B`;
|
|
2638
|
-
for (let i = start; i < lines.length; i++) {
|
|
2639
|
-
buffer += "\r\n";
|
|
2640
|
-
buffer += this.#fitLineToWidth(lines[i], width);
|
|
2641
|
-
}
|
|
3024
|
+
#emitResizeViewport(window: readonly string[], height: number, contentRows: number, width: number): void {
|
|
3025
|
+
let buffer = `${this.#paintBeginSequence + this.#enterResizeAltSequence()}\x1b[H`;
|
|
3026
|
+
for (let r = 0; r < height; r++) {
|
|
3027
|
+
if (r > 0) buffer += "\r\n";
|
|
3028
|
+
buffer += this.#lineRewriteSequence(window[r] ?? "", width);
|
|
3029
|
+
}
|
|
3030
|
+
// Park the hardware cursor at the real content bottom, not the padded
|
|
3031
|
+
// viewport bottom: a later height shrink would otherwise scroll the live
|
|
3032
|
+
// rows below the cursor into native scrollback and duplicate them until
|
|
3033
|
+
// the settle rebuild erases it.
|
|
3034
|
+
const parkUp = height - Math.max(1, contentRows);
|
|
3035
|
+
if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
|
|
2642
3036
|
buffer += this.#paintEndSequence;
|
|
2643
3037
|
this.terminal.write(buffer);
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
/** Topmost visible overlay requests the alternate-screen buffer. */
|
|
3041
|
+
#wantsAltScreen(): boolean {
|
|
3042
|
+
for (let i = this.overlayStack.length - 1; i >= 0; i--) {
|
|
3043
|
+
const entry = this.overlayStack[i]!;
|
|
3044
|
+
if (!this.#isOverlayVisible(entry)) continue;
|
|
3045
|
+
return entry.options?.fullscreen === true;
|
|
2647
3046
|
}
|
|
3047
|
+
return false;
|
|
2648
3048
|
}
|
|
2649
3049
|
|
|
2650
3050
|
/**
|
|
2651
|
-
*
|
|
2652
|
-
*
|
|
2653
|
-
*
|
|
2654
|
-
*
|
|
2655
|
-
* unchanged while allowing bottom-anchored live chrome (spinner/status tail) to
|
|
2656
|
-
* advance for users at the tail.
|
|
3051
|
+
* Compose and paint a single fullscreen overlay frame on the alt buffer.
|
|
3052
|
+
* Cursor markers are stripped (the modal draws its own in-band caret and
|
|
3053
|
+
* keeps the hardware cursor hidden), and only the modal is composited over a
|
|
3054
|
+
* blank base — the transcript is never touched while the alt buffer is up.
|
|
2657
3055
|
*/
|
|
2658
|
-
#
|
|
2659
|
-
|
|
2660
|
-
width
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
prevHardwareCursorRow: number,
|
|
2665
|
-
): void {
|
|
2666
|
-
const viewportBottom = prevViewportTop + height - 1;
|
|
2667
|
-
if (row !== viewportBottom) return;
|
|
2668
|
-
|
|
2669
|
-
let buffer = this.#paintBeginSequence;
|
|
2670
|
-
const clampedCursor = Math.min(prevHardwareCursorRow, viewportBottom);
|
|
2671
|
-
const currentScreenRow = Math.max(0, Math.min(height - 1, clampedCursor - prevViewportTop));
|
|
2672
|
-
const moveDown = height - 1 - currentScreenRow;
|
|
2673
|
-
if (moveDown > 0) buffer += `\x1b[${moveDown}B`;
|
|
2674
|
-
buffer += `\r\x1b[2K${this.#fitLineToWidth(line, width)}\x1b[?25l`;
|
|
2675
|
-
buffer += this.#paintEndSequence;
|
|
2676
|
-
this.terminal.write(buffer);
|
|
2677
|
-
|
|
2678
|
-
this.#deferredTailLine = line;
|
|
2679
|
-
this.#previousWidth = width;
|
|
2680
|
-
this.#previousHeight = height;
|
|
2681
|
-
this.#viewportTopRow = prevViewportTop;
|
|
2682
|
-
this.#hardwareCursorRow = row;
|
|
3056
|
+
#renderAltFrame(width: number, height: number): void {
|
|
3057
|
+
const base: string[] = new Array(Math.max(0, height)).fill("");
|
|
3058
|
+
let lines = this.#compositeOverlaysIntoWindow(base, width, height);
|
|
3059
|
+
this.#extractCursorMarkers(lines);
|
|
3060
|
+
lines = this.#prepareLinesArray(lines, width);
|
|
3061
|
+
this.#emitAltFrame(lines, width, height);
|
|
2683
3062
|
}
|
|
2684
3063
|
|
|
2685
3064
|
/**
|
|
2686
|
-
*
|
|
2687
|
-
*
|
|
2688
|
-
*
|
|
2689
|
-
*
|
|
3065
|
+
* Full per-row viewport rewrite on the alt buffer. Emits only sync-output
|
|
3066
|
+
* brackets, a cursor home, and per-row rewrites — never ED3, append-tail, or
|
|
3067
|
+
* any native-scrollback byte, so it is fully isolated from the planner and
|
|
3068
|
+
* #commit. The hardware cursor stays hidden (it is never re-shown here).
|
|
2690
3069
|
*/
|
|
2691
|
-
#
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
if (extraLines > height) {
|
|
2706
|
-
this.#emitViewportRepaint(lines, width, height, cursorPos);
|
|
2707
|
-
return;
|
|
2708
|
-
}
|
|
2709
|
-
|
|
2710
|
-
const viewportTop = Math.max(0, this.#maxLinesRendered - height);
|
|
2711
|
-
const targetRow = Math.max(0, lines.length - 1);
|
|
2712
|
-
|
|
2713
|
-
let buffer = this.#paintBeginSequence;
|
|
2714
|
-
|
|
2715
|
-
const clampedCursor = Math.min(prevHardwareCursorRow, prevViewportTop + height - 1);
|
|
2716
|
-
const currentScreenRow = clampedCursor - prevViewportTop;
|
|
2717
|
-
const targetScreenRow = targetRow - viewportTop;
|
|
2718
|
-
const lineDiff = targetScreenRow - currentScreenRow;
|
|
2719
|
-
if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
|
|
2720
|
-
else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
|
|
2721
|
-
buffer += "\r";
|
|
2722
|
-
|
|
2723
|
-
const clearStartOffset = lines.length > 0 ? 1 : 0;
|
|
2724
|
-
if (clearStartOffset > 0) {
|
|
2725
|
-
buffer += `\x1b[${clearStartOffset}B`;
|
|
3070
|
+
#emitAltFrame(lines: string[], width: number, height: number): void {
|
|
3071
|
+
const fitted: string[] = new Array(height);
|
|
3072
|
+
for (let r = 0; r < height; r++) fitted[r] = lines[r] ?? "";
|
|
3073
|
+
// Flush queued image-data transmits (`a=t`, no visible output) before the
|
|
3074
|
+
// paint so id-keyed placements and placeholder cells composed into this
|
|
3075
|
+
// frame resolve against loaded data. The normal-screen path flushes these
|
|
3076
|
+
// ahead of its paint; without this, an image first shown inside a
|
|
3077
|
+
// fullscreen overlay (e.g. the settings shape preview) would render as
|
|
3078
|
+
// blank placeholder cells until the overlay closed.
|
|
3079
|
+
const imageTransmits = this.#imageBudget.takeTransmits();
|
|
3080
|
+
if (imageTransmits.length > 0) {
|
|
3081
|
+
let transmitBuffer = "";
|
|
3082
|
+
for (const seq of imageTransmits) transmitBuffer += seq;
|
|
3083
|
+
this.terminal.write(transmitBuffer);
|
|
2726
3084
|
}
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
3085
|
+
// Skip an identical repaint (the modal is mostly static between
|
|
3086
|
+
// keystrokes) — unless a forced repaint (resetDisplay,
|
|
3087
|
+
// requestRender(true)) is pending: the redraw gesture must repair a
|
|
3088
|
+
// corrupted modal even when our cached frame is byte-identical.
|
|
3089
|
+
const force = this.#forceViewportRepaintOnNextRender;
|
|
3090
|
+
this.#forceViewportRepaintOnNextRender = false;
|
|
3091
|
+
if (!force && this.#altPreviousLines.length === height) {
|
|
3092
|
+
let same = true;
|
|
3093
|
+
for (let r = 0; r < height; r++) {
|
|
3094
|
+
if (fitted[r] !== this.#altPreviousLines[r]) {
|
|
3095
|
+
same = false;
|
|
3096
|
+
break;
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
if (same) return;
|
|
2730
3100
|
}
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
buffer +=
|
|
3101
|
+
let buffer = `${this.#paintBeginSequence}\x1b[H`;
|
|
3102
|
+
for (let r = 0; r < height; r++) {
|
|
3103
|
+
if (r > 0) buffer += "\r\n";
|
|
3104
|
+
buffer += this.#lineRewriteSequence(fitted[r], width);
|
|
2734
3105
|
}
|
|
2735
|
-
|
|
2736
|
-
const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, targetRow);
|
|
2737
|
-
buffer += seq;
|
|
2738
3106
|
buffer += this.#paintEndSequence;
|
|
2739
3107
|
this.terminal.write(buffer);
|
|
2740
|
-
|
|
2741
|
-
this.#
|
|
2742
|
-
this.#commit(lines, width, height, Math.max(0, lines.length - height), toRow);
|
|
3108
|
+
this.#altPreviousLines = fitted;
|
|
3109
|
+
this.#fullRedrawCount += 1;
|
|
2743
3110
|
}
|
|
2744
3111
|
|
|
2745
3112
|
/**
|
|
2746
|
-
*
|
|
2747
|
-
*
|
|
2748
|
-
*
|
|
2749
|
-
*
|
|
3113
|
+
* Incremental frame update. Three byte shapes:
|
|
3114
|
+
*
|
|
3115
|
+
* - scroll-append: the rows leaving the screen are exactly the newly
|
|
3116
|
+
* committed chunk, already painted with final content — emit `\r\n` plus
|
|
3117
|
+
* the new bottom rows, then rewrite whatever else changed in place;
|
|
3118
|
+
* - in-window diff: nothing scrolls, nothing commits — rewrite the changed
|
|
3119
|
+
* row range (cursor-only when nothing changed);
|
|
3120
|
+
* - seam rewrite: write the chunk at the scrollback seam, then rewrite the
|
|
3121
|
+
* whole window (live-region re-layout, hidden-gap backfill, mux resize).
|
|
3122
|
+
*
|
|
3123
|
+
* Only chunk rows ever enter native history; the live window repaints in
|
|
3124
|
+
* place with relative moves. This path never emits ED2/ED3 or an absolute
|
|
3125
|
+
* cursor home — those snap a reader scrolled into history back to the
|
|
3126
|
+
* bottom on several terminal families.
|
|
2750
3127
|
*/
|
|
2751
|
-
#
|
|
2752
|
-
|
|
3128
|
+
#emitUpdate(
|
|
3129
|
+
frame: readonly string[],
|
|
3130
|
+
window: string[],
|
|
2753
3131
|
width: number,
|
|
2754
3132
|
height: number,
|
|
2755
3133
|
cursorPos: { row: number; col: number } | null,
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
3134
|
+
purgeSequence: string,
|
|
3135
|
+
options: {
|
|
3136
|
+
chunkTo: number;
|
|
3137
|
+
windowTop: number;
|
|
3138
|
+
prevWindowTop: number;
|
|
3139
|
+
prevHardwareCursorRow: number;
|
|
3140
|
+
forceWindowRewrite: boolean;
|
|
3141
|
+
},
|
|
2761
3142
|
): void {
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
// Scroll-
|
|
2775
|
-
//
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
hardwareCursorRow = moveTargetRow;
|
|
2787
|
-
}
|
|
2788
|
-
|
|
2789
|
-
// Position cursor at the row we need to start writing from.
|
|
2790
|
-
const currentScreenRow = hardwareCursorRow - activeViewportTop;
|
|
2791
|
-
const targetScreenRow = moveTargetRow - viewportTop;
|
|
2792
|
-
const lineDiff = targetScreenRow - currentScreenRow;
|
|
2793
|
-
if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
|
|
2794
|
-
else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
|
|
2795
|
-
buffer += appendStart ? "\r\n" : "\r";
|
|
2796
|
-
|
|
2797
|
-
// Repaint only firstChanged..lastChanged, not all rows to the end.
|
|
2798
|
-
// This bounds flicker for single-row updates (e.g. spinner ticks).
|
|
2799
|
-
const renderEnd = Math.min(lastChanged, lines.length - 1);
|
|
2800
|
-
// Optimize the in-place rewrite of a contiguous visible row range with
|
|
2801
|
-
// DECCARA. The rectangle coordinates are absolute screen rows, so two
|
|
2802
|
-
// effects that the relatively-positioned text absorbs transparently must
|
|
2803
|
-
// be folded into the coordinates explicitly:
|
|
2804
|
-
// 1. Writing rows past the viewport bottom scrolls the terminal, so the
|
|
2805
|
-
// rewritten rows settle `scrollAmount` rows higher than where they
|
|
2806
|
-
// were first painted. The rectangles must target the post-scroll rows.
|
|
2807
|
-
// 2. Rows pushed into history keep their full background padding (DECCARA
|
|
2808
|
-
// cannot reach scrollback), so only rows that remain in the final
|
|
2809
|
-
// viewport are shortened and repainted.
|
|
2810
|
-
// The append/scroll branch (`moveTargetRow > prevViewportBottom`) already
|
|
2811
|
-
// pushed rows into history and is excluded.
|
|
2812
|
-
const scrollAmount = Math.max(0, renderEnd - viewportTop - (height - 1));
|
|
2813
|
-
const fillViewportTop = viewportTop + scrollAmount;
|
|
2814
|
-
const fillStart = Math.max(firstChanged, fillViewportTop);
|
|
2815
|
-
let fillSequence = "";
|
|
2816
|
-
let fillTexts: string[] | null = null;
|
|
2817
|
-
if (TERMINAL.deccara && !appendStart && moveTargetRow <= prevViewportBottom && renderEnd >= fillStart) {
|
|
2818
|
-
const slice: string[] = new Array(renderEnd - fillStart + 1);
|
|
2819
|
-
for (let i = fillStart; i <= renderEnd; i++) {
|
|
2820
|
-
slice[i - fillStart] = this.#fitLineToWidth(lines[i], width);
|
|
3143
|
+
const { chunkTo, windowTop, prevWindowTop, prevHardwareCursorRow, forceWindowRewrite } = options;
|
|
3144
|
+
const chunkFrom = this.#committedRows;
|
|
3145
|
+
const chunkLength = chunkTo - chunkFrom;
|
|
3146
|
+
const scroll = windowTop - prevWindowTop;
|
|
3147
|
+
const previousWindow = this.#previousWindow;
|
|
3148
|
+
const contentRows = Math.max(1, Math.min(height, frame.length - windowTop));
|
|
3149
|
+
const contentBottomRow = windowTop + contentRows - 1;
|
|
3150
|
+
// Terminals clamp the hardware cursor to the viewport on resize; clamp
|
|
3151
|
+
// our tracking to match so relative moves land correctly.
|
|
3152
|
+
const clampedCursor = Math.min(prevHardwareCursorRow, prevWindowTop + height - 1);
|
|
3153
|
+
const currentScreenRow = Math.max(0, Math.min(height - 1, clampedCursor - prevWindowTop));
|
|
3154
|
+
|
|
3155
|
+
// Scroll-append: committing exactly the rows that scroll off the top,
|
|
3156
|
+
// with content untouched since they were painted.
|
|
3157
|
+
if (
|
|
3158
|
+
!forceWindowRewrite &&
|
|
3159
|
+
chunkLength > 0 &&
|
|
3160
|
+
chunkLength === scroll &&
|
|
3161
|
+
scroll < height &&
|
|
3162
|
+
chunkFrom === prevWindowTop
|
|
3163
|
+
) {
|
|
3164
|
+
let prefixIntact = previousWindow.length === height;
|
|
3165
|
+
for (let i = 0; prefixIntact && i < chunkLength; i++) {
|
|
3166
|
+
if (previousWindow[i] !== frame[chunkFrom + i]) prefixIntact = false;
|
|
2821
3167
|
}
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
3168
|
+
if (prefixIntact) {
|
|
3169
|
+
let buffer = this.#paintBeginSequence + purgeSequence;
|
|
3170
|
+
const moveToBottom = height - 1 - currentScreenRow;
|
|
3171
|
+
if (moveToBottom > 0) buffer += `\x1b[${moveToBottom}B`;
|
|
3172
|
+
for (let r = height - scroll; r < height; r++) {
|
|
3173
|
+
buffer += `\r\n${this.#lineRewriteSequence(window[r] ?? "", width)}`;
|
|
3174
|
+
}
|
|
3175
|
+
// Rewrite any remaining changed rows after the shift.
|
|
3176
|
+
let firstChanged = -1;
|
|
3177
|
+
let lastChanged = -1;
|
|
3178
|
+
for (let r = 0; r < height - scroll; r++) {
|
|
3179
|
+
if ((window[r] ?? "") === (previousWindow[r + scroll] ?? "")) continue;
|
|
3180
|
+
if (firstChanged === -1) firstChanged = r;
|
|
3181
|
+
lastChanged = r;
|
|
3182
|
+
}
|
|
3183
|
+
let cursorFromRow = windowTop + height - 1;
|
|
3184
|
+
if (firstChanged !== -1) {
|
|
3185
|
+
const up = height - 1 - firstChanged;
|
|
3186
|
+
if (up > 0) buffer += `\x1b[${up}A`;
|
|
3187
|
+
buffer += "\r";
|
|
3188
|
+
for (let r = firstChanged; r <= lastChanged; r++) {
|
|
3189
|
+
if (r > firstChanged) buffer += "\r\n";
|
|
3190
|
+
buffer += this.#lineRewriteSequence(window[r] ?? "", width);
|
|
3191
|
+
}
|
|
3192
|
+
cursorFromRow = windowTop + lastChanged;
|
|
3193
|
+
}
|
|
3194
|
+
const cursorControl = this.#cursorControlSequence(cursorPos, frame.length, cursorFromRow);
|
|
3195
|
+
buffer += cursorControl.seq;
|
|
3196
|
+
buffer += this.#paintEndSequence;
|
|
3197
|
+
this.terminal.write(buffer);
|
|
3198
|
+
this.#committedRows = chunkTo;
|
|
3199
|
+
this.#windowTopRow = windowTop;
|
|
3200
|
+
this.#commit(frame, window, width, height, cursorControl);
|
|
3201
|
+
return;
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
3204
|
+
|
|
3205
|
+
// In-window diff: nothing scrolls, nothing commits.
|
|
3206
|
+
if (chunkLength === 0 && scroll === 0) {
|
|
3207
|
+
if (forceWindowRewrite) this.#fullRedrawCount += 1;
|
|
3208
|
+
let firstChanged = forceWindowRewrite ? 0 : -1;
|
|
3209
|
+
let lastChanged = forceWindowRewrite ? height - 1 : -1;
|
|
3210
|
+
if (!forceWindowRewrite) {
|
|
3211
|
+
const comparable = previousWindow.length === height;
|
|
3212
|
+
for (let r = 0; r < height; r++) {
|
|
3213
|
+
if (comparable && (window[r] ?? "") === (previousWindow[r] ?? "")) continue;
|
|
3214
|
+
if (firstChanged === -1) firstChanged = r;
|
|
3215
|
+
lastChanged = r;
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
if (firstChanged === -1) {
|
|
3219
|
+
if (purgeSequence.length > 0) this.terminal.write(purgeSequence);
|
|
3220
|
+
this.#writeCursorPosition(cursorPos, frame.length);
|
|
3221
|
+
this.#previousWidth = width;
|
|
3222
|
+
this.#previousHeight = height;
|
|
3223
|
+
return;
|
|
3224
|
+
}
|
|
3225
|
+
let buffer = this.#paintBeginSequence + purgeSequence;
|
|
3226
|
+
const rowDelta = firstChanged - currentScreenRow;
|
|
3227
|
+
if (rowDelta > 0) buffer += `\x1b[${rowDelta}B`;
|
|
3228
|
+
else if (rowDelta < 0) buffer += `\x1b[${-rowDelta}A`;
|
|
3229
|
+
buffer += "\r";
|
|
3230
|
+
// DECCARA-optimize the contiguous rewritten range (visible rows
|
|
3231
|
+
// only; rectangles are absolute screen rows).
|
|
3232
|
+
let fillTexts: string[] | null = null;
|
|
3233
|
+
let fillSequence = "";
|
|
3234
|
+
if (this.#deccaraFillsEnabled()) {
|
|
3235
|
+
const slice: string[] = new Array(lastChanged - firstChanged + 1);
|
|
3236
|
+
for (let r = firstChanged; r <= lastChanged; r++) slice[r - firstChanged] = window[r] ?? "";
|
|
3237
|
+
const plan = planDeccaraFills(slice, width, firstChanged);
|
|
3238
|
+
fillTexts = plan.texts;
|
|
3239
|
+
fillSequence = plan.sequence;
|
|
3240
|
+
}
|
|
3241
|
+
for (let r = firstChanged; r <= lastChanged; r++) {
|
|
3242
|
+
if (r > firstChanged) buffer += "\r\n";
|
|
3243
|
+
buffer += this.#lineRewriteSequence(fillTexts ? fillTexts[r - firstChanged] : (window[r] ?? ""), width);
|
|
2839
3244
|
}
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
3245
|
+
buffer += fillSequence;
|
|
3246
|
+
// Never park below real content (a height shrink would scroll live
|
|
3247
|
+
// rows into history and duplicate them per resize step).
|
|
3248
|
+
let cursorFromRow = windowTop + lastChanged;
|
|
3249
|
+
const contentBottomScreenRow = contentBottomRow - windowTop;
|
|
3250
|
+
if (lastChanged > contentBottomScreenRow) {
|
|
3251
|
+
buffer += `\x1b[${lastChanged - contentBottomScreenRow}A`;
|
|
3252
|
+
cursorFromRow = contentBottomRow;
|
|
2843
3253
|
}
|
|
2844
|
-
|
|
3254
|
+
const cursorControl = this.#cursorControlSequence(cursorPos, frame.length, cursorFromRow);
|
|
3255
|
+
buffer += cursorControl.seq;
|
|
3256
|
+
buffer += this.#paintEndSequence;
|
|
3257
|
+
this.terminal.write(buffer);
|
|
3258
|
+
this.#commit(frame, window, width, height, cursorControl);
|
|
3259
|
+
return;
|
|
2845
3260
|
}
|
|
2846
|
-
// DECCARA rectangles for the rewritten visible fills. Absolute-positioned,
|
|
2847
|
-
// so emitting them after the trailing-shrink cursor moves is safe.
|
|
2848
|
-
buffer += fillSequence;
|
|
2849
3261
|
|
|
2850
|
-
|
|
2851
|
-
|
|
3262
|
+
// Seam rewrite: write the chunk into history, then the whole window.
|
|
3263
|
+
// Cursor moves to the window top with a relative move; the chunk rows
|
|
3264
|
+
// pass through the screen and scroll off as the window rows are written
|
|
3265
|
+
// below them, so the rows entering scrollback are exactly the chunk.
|
|
3266
|
+
this.#fullRedrawCount += 1;
|
|
3267
|
+
let buffer = this.#paintBeginSequence + purgeSequence;
|
|
3268
|
+
if (currentScreenRow > 0) buffer += `\x1b[${currentScreenRow}A`;
|
|
3269
|
+
buffer += "\r";
|
|
3270
|
+
let wroteLine = false;
|
|
3271
|
+
for (let i = chunkFrom; i < chunkTo; i++) {
|
|
3272
|
+
if (wroteLine) buffer += "\r\n";
|
|
3273
|
+
buffer += this.#lineRewriteSequence(frame[i] ?? "", width);
|
|
3274
|
+
wroteLine = true;
|
|
3275
|
+
}
|
|
3276
|
+
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
3277
|
+
if (wroteLine) buffer += "\r\n";
|
|
3278
|
+
buffer += this.#lineRewriteSequence(window[screenRow] ?? "", width);
|
|
3279
|
+
wroteLine = true;
|
|
3280
|
+
}
|
|
3281
|
+
const parkUp = height - 1 - (contentBottomRow - windowTop);
|
|
3282
|
+
if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
|
|
3283
|
+
const cursorControl = this.#cursorControlSequence(cursorPos, frame.length, contentBottomRow);
|
|
3284
|
+
buffer += cursorControl.seq;
|
|
2852
3285
|
buffer += this.#paintEndSequence;
|
|
2853
|
-
|
|
2854
|
-
this.#writeDiffDebug(
|
|
2855
|
-
lines,
|
|
2856
|
-
firstChanged,
|
|
2857
|
-
viewportTop,
|
|
2858
|
-
height,
|
|
2859
|
-
lineDiff,
|
|
2860
|
-
hardwareCursorRow,
|
|
2861
|
-
renderEnd,
|
|
2862
|
-
finalCursorRow,
|
|
2863
|
-
cursorPos,
|
|
2864
|
-
toRow,
|
|
2865
|
-
buffer,
|
|
2866
|
-
);
|
|
2867
3286
|
this.terminal.write(buffer);
|
|
2868
|
-
|
|
2869
|
-
this.#
|
|
2870
|
-
|
|
2871
|
-
const pushedNow = Math.max(0, lines.length - height);
|
|
2872
|
-
if (pushedNow > this.#scrollbackHighWater) {
|
|
2873
|
-
this.#scrollbackHighWater = pushedNow;
|
|
2874
|
-
}
|
|
2875
|
-
}
|
|
2876
|
-
this.#commit(lines, width, height, Math.max(0, lines.length - height), toRow);
|
|
3287
|
+
this.#committedRows = chunkTo;
|
|
3288
|
+
this.#windowTopRow = windowTop;
|
|
3289
|
+
this.#commit(frame, window, width, height, cursorControl);
|
|
2877
3290
|
}
|
|
2878
3291
|
|
|
2879
3292
|
/** Optional intent log under PROMETHEUS_DEBUG_REDRAW. */
|
|
2880
3293
|
#logRedraw(intent: RenderIntent, newLength: number, height: number): void {
|
|
2881
3294
|
if (!$flag("PROMETHEUS_DEBUG_REDRAW")) return;
|
|
2882
3295
|
const detail =
|
|
2883
|
-
intent.kind === "
|
|
2884
|
-
?
|
|
2885
|
-
: intent.
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
? `${intent.kind}(row=${intent.row})`
|
|
2891
|
-
: intent.kind;
|
|
2892
|
-
const msg = `[${new Date().toISOString()}] render: ${detail} (prev=${this.#previousLines.length}, new=${newLength}, height=${height})\n`;
|
|
3296
|
+
intent.kind === "update"
|
|
3297
|
+
? `update(chunk=${this.#committedRows}..${intent.chunkTo}, windowTop=${intent.windowTop})`
|
|
3298
|
+
: `fullPaint(clearScrollback=${intent.clearScrollback})`;
|
|
3299
|
+
const state =
|
|
3300
|
+
`committed=${this.#committedRows}, windowTop=${this.#windowTopRow}, ` +
|
|
3301
|
+
`lrStart=${this.#nativeScrollbackLiveRegionStart}, commitSafeEnd=${this.#nativeScrollbackCommitSafeEnd}`;
|
|
3302
|
+
const msg = `[${new Date().toISOString()}] render: ${detail} (prev=${this.#previousFrameLength}, new=${newLength}, height=${height}, ${state})\n`;
|
|
2893
3303
|
fs.appendFileSync(getDebugLogPath(), msg);
|
|
2894
3304
|
}
|
|
2895
3305
|
|
|
2896
|
-
/** Optional per-render dump under PROMETHEUS_TUI_DEBUG; isolated so #emitDiff stays readable. */
|
|
2897
|
-
#writeDiffDebug(
|
|
2898
|
-
lines: string[],
|
|
2899
|
-
firstChanged: number,
|
|
2900
|
-
viewportTop: number,
|
|
2901
|
-
height: number,
|
|
2902
|
-
lineDiff: number,
|
|
2903
|
-
hardwareCursorRow: number,
|
|
2904
|
-
renderEnd: number,
|
|
2905
|
-
finalCursorRow: number,
|
|
2906
|
-
cursorPos: { row: number; col: number } | null,
|
|
2907
|
-
toRow: number,
|
|
2908
|
-
buffer: string,
|
|
2909
|
-
): void {
|
|
2910
|
-
if (!$flag("PROMETHEUS_TUI_DEBUG")) return;
|
|
2911
|
-
const debugDir = "/tmp/tui";
|
|
2912
|
-
fs.mkdirSync(debugDir, { recursive: true });
|
|
2913
|
-
const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
|
|
2914
|
-
const debugData = [
|
|
2915
|
-
`firstChanged: ${firstChanged}`,
|
|
2916
|
-
`viewportTop: ${viewportTop}`,
|
|
2917
|
-
`cursorRow: ${this.#cursorRow}`,
|
|
2918
|
-
`height: ${height}`,
|
|
2919
|
-
`lineDiff: ${lineDiff}`,
|
|
2920
|
-
`hardwareCursorRow: ${hardwareCursorRow}`,
|
|
2921
|
-
`hardwareCursorRow (post): ${toRow}`,
|
|
2922
|
-
`renderEnd: ${renderEnd}`,
|
|
2923
|
-
`finalCursorRow: ${finalCursorRow}`,
|
|
2924
|
-
`cursorPos: ${JSON.stringify(cursorPos)}`,
|
|
2925
|
-
`newLines.length: ${lines.length}`,
|
|
2926
|
-
`previousLines.length: ${this.#previousLines.length}`,
|
|
2927
|
-
"",
|
|
2928
|
-
"=== newLines ===",
|
|
2929
|
-
JSON.stringify(lines, null, 2),
|
|
2930
|
-
"",
|
|
2931
|
-
"=== previousLines ===",
|
|
2932
|
-
JSON.stringify(this.#previousLines, null, 2),
|
|
2933
|
-
"",
|
|
2934
|
-
"=== buffer ===",
|
|
2935
|
-
JSON.stringify(buffer),
|
|
2936
|
-
].join("\n");
|
|
2937
|
-
fs.writeFileSync(debugPath, debugData);
|
|
2938
|
-
}
|
|
2939
|
-
|
|
2940
3306
|
/**
|
|
2941
3307
|
* Build cursor control sequences to position the hardware cursor for the IME
|
|
2942
3308
|
* candidate window. Returns escape sequences and the resulting cursor row for
|
|
@@ -2948,16 +3314,15 @@ export class TUI extends Container {
|
|
|
2948
3314
|
cursorPos: { row: number; col: number } | null,
|
|
2949
3315
|
totalLines: number,
|
|
2950
3316
|
fromRow: number,
|
|
2951
|
-
):
|
|
2952
|
-
// No IME target or no content — hide cursor regardless of preference
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
const targetCol = Math.max(0, cursorPos.col);
|
|
3317
|
+
): CursorControlResult {
|
|
3318
|
+
// No IME target or no content — hide cursor regardless of preference.
|
|
3319
|
+
const target = this.#targetHardwareCursorState(cursorPos, totalLines);
|
|
3320
|
+
if (!target) {
|
|
3321
|
+
return { seq: "\x1b[?25l", toRow: fromRow, toCol: 0, visible: false, state: null };
|
|
3322
|
+
}
|
|
2958
3323
|
|
|
2959
|
-
// Move cursor from current position to target
|
|
2960
|
-
const rowDelta =
|
|
3324
|
+
// Move cursor from current position to target.
|
|
3325
|
+
const rowDelta = target.row - fromRow;
|
|
2961
3326
|
let seq = "";
|
|
2962
3327
|
if (rowDelta > 0) {
|
|
2963
3328
|
seq += `\x1b[${rowDelta}B`; // Move down
|
|
@@ -2965,10 +3330,14 @@ export class TUI extends Container {
|
|
|
2965
3330
|
seq += `\x1b[${-rowDelta}A`; // Move up
|
|
2966
3331
|
}
|
|
2967
3332
|
// Move to absolute column (1-indexed)
|
|
2968
|
-
seq += `\x1b[${
|
|
2969
|
-
seq +=
|
|
3333
|
+
seq += `\x1b[${target.col + 1}G`;
|
|
3334
|
+
seq += target.visible ? "\x1b[?25h" : "\x1b[?25l";
|
|
3335
|
+
|
|
3336
|
+
return { seq, toRow: target.row, toCol: target.col, visible: target.visible, state: target };
|
|
3337
|
+
}
|
|
2970
3338
|
|
|
2971
|
-
|
|
3339
|
+
#isHiddenCursorKnown(): boolean {
|
|
3340
|
+
return this.#hardwareCursorVisibilityKnown && !this.#hardwareCursorVisible;
|
|
2972
3341
|
}
|
|
2973
3342
|
|
|
2974
3343
|
/**
|
|
@@ -2977,12 +3346,16 @@ export class TUI extends Container {
|
|
|
2977
3346
|
* to embed the sequences into.
|
|
2978
3347
|
*/
|
|
2979
3348
|
#writeCursorPosition(cursorPos: { row: number; col: number } | null, totalLines: number): void {
|
|
2980
|
-
|
|
3349
|
+
const target = this.#targetHardwareCursorState(cursorPos, totalLines);
|
|
3350
|
+
if (!target) {
|
|
3351
|
+
if (this.#isHiddenCursorKnown()) return;
|
|
2981
3352
|
this.terminal.hideCursor();
|
|
3353
|
+
this.#recordHardwareCursorHidden();
|
|
2982
3354
|
return;
|
|
2983
3355
|
}
|
|
2984
|
-
|
|
2985
|
-
this.#
|
|
2986
|
-
this.terminal.write(`${this.#cursorBeginSequence}${seq}${this.#cursorEndSequence}`);
|
|
3356
|
+
if (this.#sameHardwareCursorState(target)) return;
|
|
3357
|
+
const cursorControl = this.#cursorControlSequence(cursorPos, totalLines, this.#hardwareCursorRow);
|
|
3358
|
+
this.terminal.write(`${this.#cursorBeginSequence}${cursorControl.seq}${this.#cursorEndSequence}`);
|
|
3359
|
+
this.#recordHardwareCursorUpdate(cursorControl);
|
|
2987
3360
|
}
|
|
2988
3361
|
}
|