jeopi-tui 16.2.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1861 -0
- package/README.md +705 -0
- package/dist/types/autocomplete.d.ts +99 -0
- package/dist/types/bracketed-paste.d.ts +51 -0
- package/dist/types/components/box.d.ts +31 -0
- package/dist/types/components/cancellable-loader.d.ts +21 -0
- package/dist/types/components/editor.d.ts +155 -0
- package/dist/types/components/image.d.ts +112 -0
- package/dist/types/components/input.d.ts +23 -0
- package/dist/types/components/loader.d.ts +20 -0
- package/dist/types/components/markdown.d.ts +64 -0
- package/dist/types/components/scroll-view.d.ts +62 -0
- package/dist/types/components/select-list.d.ts +68 -0
- package/dist/types/components/settings-list.d.ts +123 -0
- package/dist/types/components/spacer.d.ts +11 -0
- package/dist/types/components/tab-bar.d.ts +89 -0
- package/dist/types/components/text.d.ts +14 -0
- package/dist/types/components/truncated-text.d.ts +10 -0
- package/dist/types/deccara.d.ts +49 -0
- package/dist/types/desktop-notify.d.ts +51 -0
- package/dist/types/editor-component.d.ts +38 -0
- package/dist/types/fuzzy.d.ts +32 -0
- package/dist/types/index.d.ts +32 -0
- package/dist/types/keybindings.d.ts +191 -0
- package/dist/types/keys.d.ts +208 -0
- package/dist/types/kill-ring.d.ts +20 -0
- package/dist/types/kitty-graphics.d.ts +79 -0
- package/dist/types/latex-block.d.ts +7 -0
- package/dist/types/latex-to-unicode.d.ts +33 -0
- package/dist/types/loop-watchdog.d.ts +39 -0
- package/dist/types/mouse.d.ts +67 -0
- package/dist/types/stdin-buffer.d.ts +60 -0
- package/dist/types/symbols.d.ts +25 -0
- package/dist/types/terminal-capabilities.d.ts +284 -0
- package/dist/types/terminal.d.ts +107 -0
- package/dist/types/ttyid.d.ts +9 -0
- package/dist/types/tui.d.ts +423 -0
- package/dist/types/utils.d.ts +95 -0
- package/package.json +73 -0
- package/src/autocomplete.ts +1026 -0
- package/src/bracketed-paste.ts +123 -0
- package/src/components/box.ts +194 -0
- package/src/components/cancellable-loader.ts +40 -0
- package/src/components/editor.ts +3092 -0
- package/src/components/image.ts +444 -0
- package/src/components/input.ts +474 -0
- package/src/components/loader.ts +103 -0
- package/src/components/markdown.ts +2068 -0
- package/src/components/scroll-view.ts +227 -0
- package/src/components/select-list.ts +531 -0
- package/src/components/settings-list.ts +793 -0
- package/src/components/spacer.ts +32 -0
- package/src/components/tab-bar.ts +300 -0
- package/src/components/text.ts +122 -0
- package/src/components/truncated-text.ts +69 -0
- package/src/deccara.ts +314 -0
- package/src/desktop-notify.ts +186 -0
- package/src/editor-component.ts +74 -0
- package/src/fuzzy.ts +356 -0
- package/src/index.ts +51 -0
- package/src/keybindings.ts +337 -0
- package/src/keys.ts +561 -0
- package/src/kill-ring.ts +51 -0
- package/src/kitty-graphics.ts +171 -0
- package/src/latex-block.ts +461 -0
- package/src/latex-to-unicode.ts +1994 -0
- package/src/loop-watchdog.ts +106 -0
- package/src/mouse.ts +105 -0
- package/src/stdin-buffer.ts +669 -0
- package/src/symbols.ts +26 -0
- package/src/terminal-capabilities.ts +1152 -0
- package/src/terminal.ts +1463 -0
- package/src/ttyid.ts +84 -0
- package/src/tui.ts +3901 -0
- package/src/utils.ts +570 -0
package/src/tui.ts
ADDED
|
@@ -0,0 +1,3901 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal TUI implementation with differential rendering.
|
|
3
|
+
*
|
|
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
|
+
*/
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import { performance } from "node:perf_hooks";
|
|
15
|
+
import { $flag, getDebugLogPath } from "jeopi-utils";
|
|
16
|
+
import { DEFAULT_MAX_INLINE_IMAGES, ImageBudget } from "./components/image";
|
|
17
|
+
import { planDeccaraFills } from "./deccara";
|
|
18
|
+
import { isKeyRelease, matchesKey } from "./keys";
|
|
19
|
+
import { LoopWatchdog } from "./loop-watchdog";
|
|
20
|
+
import { isConPTYHosted, setAltScreenActive, type Terminal } from "./terminal";
|
|
21
|
+
import {
|
|
22
|
+
encodeKittyDeleteImage,
|
|
23
|
+
ImageProtocol,
|
|
24
|
+
isInsideTerminalMultiplexer,
|
|
25
|
+
setCellDimensions,
|
|
26
|
+
setTerminalImageProtocol,
|
|
27
|
+
shouldEnableSynchronizedOutputByDefault,
|
|
28
|
+
synchronizedOutputUserOverride,
|
|
29
|
+
TERMINAL,
|
|
30
|
+
} from "./terminal-capabilities";
|
|
31
|
+
import {
|
|
32
|
+
Ellipsis,
|
|
33
|
+
extractSegments,
|
|
34
|
+
normalizeTerminalOutput,
|
|
35
|
+
sliceByColumn,
|
|
36
|
+
sliceWithWidth,
|
|
37
|
+
truncateToWidth,
|
|
38
|
+
visibleWidth,
|
|
39
|
+
} from "./utils";
|
|
40
|
+
|
|
41
|
+
const SEGMENT_RESET = "\x1b[0m";
|
|
42
|
+
/**
|
|
43
|
+
* Per-line terminator written after every non-image content row. It closes both
|
|
44
|
+
* SGR state and any in-flight OSC 8 hyperlink so styles/links cannot bleed
|
|
45
|
+
* across lines in scrollback. Kept out of the diff/width cache because reset
|
|
46
|
+
* bytes are deterministic write framing, not content.
|
|
47
|
+
*/
|
|
48
|
+
const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x07";
|
|
49
|
+
const ERASE_LINE = "\x1b[2K";
|
|
50
|
+
const ERASE_TO_END_OF_LINE = "\x1b[K";
|
|
51
|
+
// Keep the common short-row path out of native width/truncation. Longer rows
|
|
52
|
+
// are fit by visible cells, not source code units, so zero-width-heavy prefixes
|
|
53
|
+
// cannot hide visible suffix text that still belongs in the viewport.
|
|
54
|
+
const LINE_FIT_MIN_SOURCE_CODE_UNITS = 4096;
|
|
55
|
+
const LINE_FIT_MAX_SOURCE_CODE_UNITS = 65536;
|
|
56
|
+
const LINE_FIT_SOURCE_WIDTH_MULTIPLIER = 64;
|
|
57
|
+
// Hide the hardware cursor before each paint/move write. Ghostty-style bar
|
|
58
|
+
// cursors can otherwise leave visual afterimages while the TUI repaints the
|
|
59
|
+
// row under a visible cursor. Paint writes also disable terminal autowrap:
|
|
60
|
+
// several terminals keep a "pending wrap" flag after an exact-width row, so a
|
|
61
|
+
// following cursor move can first wrap to the next row and produce staircase
|
|
62
|
+
// trails. The TUI emits explicit CRLFs and restores autowrap before leaving the
|
|
63
|
+
// paint. Synchronized output can be disabled for terminals with broken DEC 2026
|
|
64
|
+
// implementations; autowrap discipline stays on either way.
|
|
65
|
+
const HIDE_CURSOR = "\x1b[?25l";
|
|
66
|
+
const SYNC_OUTPUT_BEGIN = "\x1b[?2026h";
|
|
67
|
+
const SYNC_OUTPUT_END = "\x1b[?2026l";
|
|
68
|
+
const DISABLE_AUTOWRAP = "\x1b[?7l";
|
|
69
|
+
const ENABLE_AUTOWRAP = "\x1b[?7h";
|
|
70
|
+
const PAINT_BEGIN = `${HIDE_CURSOR}${SYNC_OUTPUT_BEGIN}${DISABLE_AUTOWRAP}`;
|
|
71
|
+
const PAINT_END = `${ENABLE_AUTOWRAP}${SYNC_OUTPUT_END}`;
|
|
72
|
+
const PAINT_BEGIN_NO_SYNC = `${HIDE_CURSOR}${DISABLE_AUTOWRAP}`;
|
|
73
|
+
const PAINT_END_NO_SYNC = ENABLE_AUTOWRAP;
|
|
74
|
+
const CURSOR_BEGIN = `${HIDE_CURSOR}${SYNC_OUTPUT_BEGIN}`;
|
|
75
|
+
const CURSOR_BEGIN_NO_SYNC = HIDE_CURSOR;
|
|
76
|
+
const CURSOR_END = SYNC_OUTPUT_END;
|
|
77
|
+
const CURSOR_END_NO_SYNC = "";
|
|
78
|
+
// Mouse reporting, enabled only for the lifetime of a fullscreen overlay so the
|
|
79
|
+
// rest of the app keeps the terminal's native text selection. 1000h = button
|
|
80
|
+
// click tracking, 1003h = any-motion tracking so overlays can light up hover
|
|
81
|
+
// targets (the pointer moving with no button held), 1006h = SGR extended
|
|
82
|
+
// coordinates so columns/rows past 223 are reported.
|
|
83
|
+
const MOUSE_TRACKING_ON = "\x1b[?1000h\x1b[?1003h\x1b[?1006h";
|
|
84
|
+
const MOUSE_TRACKING_OFF = "\x1b[?1006l\x1b[?1003l\x1b[?1000l";
|
|
85
|
+
const ALT_SCREEN_ENTER = "\x1b[?1049h";
|
|
86
|
+
const ALT_SCREEN_EXIT = "\x1b[?1049l";
|
|
87
|
+
|
|
88
|
+
type InputListenerResult = { consume?: boolean; data?: string } | undefined;
|
|
89
|
+
type InputListener = (data: string) => InputListenerResult;
|
|
90
|
+
type StartListener = () => void;
|
|
91
|
+
|
|
92
|
+
export interface RenderTimer {
|
|
93
|
+
cancel(): void;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface RenderScheduler {
|
|
97
|
+
now(): number;
|
|
98
|
+
scheduleImmediate(callback: () => void): void;
|
|
99
|
+
scheduleRender(callback: () => void, delayMs: number): RenderTimer;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface TUIOptions {
|
|
103
|
+
renderScheduler?: RenderScheduler;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface TUIStartOptions {
|
|
107
|
+
/** Clear saved native scrollback before the first paint. */
|
|
108
|
+
clearScrollback?: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const DEFAULT_RENDER_SCHEDULER: RenderScheduler = {
|
|
112
|
+
now: () => performance.now(),
|
|
113
|
+
scheduleImmediate: callback => {
|
|
114
|
+
setImmediate(callback);
|
|
115
|
+
},
|
|
116
|
+
scheduleRender: (callback, delayMs) => {
|
|
117
|
+
const timer = setTimeout(callback, delayMs);
|
|
118
|
+
return {
|
|
119
|
+
cancel: () => {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Component interface - all components must implement this
|
|
128
|
+
*
|
|
129
|
+
* Render contract: the returned array (and its rows) belongs to the component.
|
|
130
|
+
* Callers MUST NOT mutate it — components are allowed to return a cached array
|
|
131
|
+
* and will return the exact same reference for as long as their rendered
|
|
132
|
+
* content is unchanged. Conversely, a component MUST return a fresh array
|
|
133
|
+
* reference whenever its content changed; reference equality across two
|
|
134
|
+
* render() calls is the engine's proof that the rows are byte-identical
|
|
135
|
+
* (containers memoize their concatenation on it, and the TUI derives the
|
|
136
|
+
* frame's stable prefix from it). A component that mutates a previously
|
|
137
|
+
* returned array in place must implement {@link RenderStablePrefix} to declare
|
|
138
|
+
* which leading rows survived.
|
|
139
|
+
*/
|
|
140
|
+
export interface Component {
|
|
141
|
+
/**
|
|
142
|
+
* Render the component to an array of physical rows at the given width.
|
|
143
|
+
* The result is component-owned and `readonly` to the caller; an unchanged
|
|
144
|
+
* component may (and should) return the same array reference it returned
|
|
145
|
+
* last time.
|
|
146
|
+
*/
|
|
147
|
+
render(width: number): readonly string[];
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Optional handler for keyboard input when component has focus
|
|
151
|
+
*/
|
|
152
|
+
handleInput?(data: string): void;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* If true, component receives key release events (Kitty protocol).
|
|
156
|
+
* Default is false - release events are filtered out.
|
|
157
|
+
*/
|
|
158
|
+
wantsKeyRelease?: boolean;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Optional hook to invalidate any cached rendering state.
|
|
162
|
+
* Called when theme changes or when component needs to re-render from scratch.
|
|
163
|
+
*/
|
|
164
|
+
invalidate?(): void;
|
|
165
|
+
/**
|
|
166
|
+
* Optional hook to set whether this component ignores tight layout mode.
|
|
167
|
+
*/
|
|
168
|
+
setIgnoreTight?(ignore: boolean): any;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Optional teardown. Called when the component is permanently removed from
|
|
172
|
+
* the live tree (e.g. a transcript reset). Release timers, intervals, and
|
|
173
|
+
* subscriptions here. Must be idempotent. Containers propagate dispose to
|
|
174
|
+
* their children; leaf components without resources may omit it.
|
|
175
|
+
*/
|
|
176
|
+
dispose?(): void;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Lets an overlay root delegate keyboard focus to components it owns. */
|
|
180
|
+
export interface OverlayFocusOwner {
|
|
181
|
+
/** Returns true when `component` is a focus target inside this overlay. */
|
|
182
|
+
ownsOverlayFocusTarget(component: Component): boolean;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Component seam for append-only native-scrollback commits. A component that
|
|
187
|
+
* renders a finalized prefix followed by a live/mutating suffix reports the
|
|
188
|
+
* local line index where that suffix begins after each render. The engine
|
|
189
|
+
* commits rows to native scrollback only up to that boundary; everything
|
|
190
|
+
* below repaints in place inside the visible window and never enters history
|
|
191
|
+
* until it finalizes.
|
|
192
|
+
*
|
|
193
|
+
* `getNativeScrollbackCommitSafeEnd` optionally reports a *deeper* boundary
|
|
194
|
+
* inside the live suffix: the line index up to which the live region is
|
|
195
|
+
* append-only (earlier rows never re-layout — a streaming assistant message).
|
|
196
|
+
* Rows in `[liveRegionStart, commitSafeEnd)` may commit even though they are
|
|
197
|
+
* technically live, because they will never change. Without it, a single live
|
|
198
|
+
* block that alone overflows the window would hold its scrolled-off head out
|
|
199
|
+
* of history until it finalizes. Volatile live blocks (tool previews that
|
|
200
|
+
* collapse) omit it. Defaults to `liveRegionStart` when absent; a root that
|
|
201
|
+
* reports no seam at all commits everything that scrolls (shell semantics).
|
|
202
|
+
* `getNativeScrollbackSnapshotSafeEnd` optionally reports a still deeper
|
|
203
|
+
* boundary: the line index up to which the live region is *durable* — its rows
|
|
204
|
+
* may still change bytes later (a streaming markdown table re-aligning its
|
|
205
|
+
* columns every row), but their CURRENT snapshot is permanent content, so
|
|
206
|
+
* dropping them when they scroll above the window is forbidden. Unlike
|
|
207
|
+
* `commitSafeEnd` (byte-stable: offered rows are asserted never to re-layout and
|
|
208
|
+
* stay under the committed-prefix audit), rows committed under the snapshot end
|
|
209
|
+
* are audit-EXEMPT once they pass the window top — the engine appends their
|
|
210
|
+
* scroll-off snapshot and never recommits them, so later layout drift becomes a
|
|
211
|
+
* frozen stale row in history (duplication never loss) instead of either a
|
|
212
|
+
* dropped row or an audit re-anchor spray. Provisional live blocks (collapsing
|
|
213
|
+
* tool/edit previews whose head is a throwaway tail window) omit it. Defaults to
|
|
214
|
+
* `commitSafeEnd ?? liveRegionStart` when absent.
|
|
215
|
+
*
|
|
216
|
+
* When several root children report a seam in the same frame, the topmost
|
|
217
|
+
* one (and its commit-safe / snapshot-safe extension) defines the boundary:
|
|
218
|
+
* commits are prefix-only, so everything below the first seam is already
|
|
219
|
+
* excluded.
|
|
220
|
+
*/
|
|
221
|
+
export interface NativeScrollbackLiveRegion {
|
|
222
|
+
getNativeScrollbackLiveRegionStart(): number | undefined;
|
|
223
|
+
getNativeScrollbackCommitSafeEnd?(): number | undefined;
|
|
224
|
+
getNativeScrollbackSnapshotSafeEnd?(): number | undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface NativeScrollbackCommittedRows {
|
|
228
|
+
setNativeScrollbackCommittedRows(rows: number): void;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function setNativeScrollbackCommittedRows(component: Component, rows: number): void {
|
|
232
|
+
(component as Component & Partial<NativeScrollbackCommittedRows>).setNativeScrollbackCommittedRows?.(rows);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function isOverlayFocusTarget(owner: Component, component: Component | null): boolean {
|
|
236
|
+
if (component === owner) return true;
|
|
237
|
+
if (!component) return false;
|
|
238
|
+
const candidate = owner as Component & Partial<OverlayFocusOwner>;
|
|
239
|
+
return candidate.ownsOverlayFocusTarget?.(component) === true;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function getNativeScrollbackLiveRegionStart(component: Component): number | undefined {
|
|
243
|
+
return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackLiveRegionStart?.();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function getNativeScrollbackCommitSafeEnd(component: Component): number | undefined {
|
|
247
|
+
return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackCommitSafeEnd?.();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function getNativeScrollbackSnapshotSafeEnd(component: Component): number | undefined {
|
|
251
|
+
return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackSnapshotSafeEnd?.();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Opt-in stability report for components that mutate their returned render
|
|
256
|
+
* array in place across frames (instead of returning a fresh array per
|
|
257
|
+
* change). The engine reads it right after the component's `render()` returns:
|
|
258
|
+
* the report counts the leading rows of the just-returned array that are
|
|
259
|
+
* byte-identical to the array state the reader last observed. The engine uses
|
|
260
|
+
* it to reuse the composed frame's prefix — skipping marker extraction, line
|
|
261
|
+
* preparation, and the committed-prefix audit for those rows.
|
|
262
|
+
*
|
|
263
|
+
* Contract:
|
|
264
|
+
* - Reading CONSUMES the report: it re-bases the baseline to the current
|
|
265
|
+
* array state. The accumulated count therefore covers every render since
|
|
266
|
+
* the previous read, so out-of-band `render()` calls between engine frames
|
|
267
|
+
* (an exporter walking the tree) can only lower the report, never inflate
|
|
268
|
+
* it past what the engine actually has.
|
|
269
|
+
* - An implementer that cannot prove stability for a frame must lower the
|
|
270
|
+
* accumulated count to 0 for that render.
|
|
271
|
+
* - Rows at or beyond the report may have been mutated in place; rows before
|
|
272
|
+
* it must be the identical string values at the identical indices.
|
|
273
|
+
*/
|
|
274
|
+
export interface RenderStablePrefix {
|
|
275
|
+
getRenderStablePrefixRows(): number;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function getRenderStablePrefixRows(component: Component): number | undefined {
|
|
279
|
+
return (component as Component & Partial<RenderStablePrefix>).getRenderStablePrefixRows?.();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Opt-in fast path for composing only the visible tail of a tall component
|
|
284
|
+
* during a terminal resize. A drag emits a SIGWINCH burst, and the width
|
|
285
|
+
* changes on every event: a full compose re-lays-out (and, for markdown,
|
|
286
|
+
* re-lexes) the entire transcript per event — O(history) work that is
|
|
287
|
+
* discarded the instant the next event arrives. While the resize is in flight
|
|
288
|
+
* the engine paints only the viewport, so it asks each tall root child for at
|
|
289
|
+
* most `maxRows` rows from the bottom of its render at `width` and skips
|
|
290
|
+
* composing everything above the fold. The authoritative full paint replays
|
|
291
|
+
* once the drag settles (see {@link TUI} resize handling).
|
|
292
|
+
*
|
|
293
|
+
* Contract:
|
|
294
|
+
* - Returns the BOTTOM rows of the component's full render at `width`, in
|
|
295
|
+
* top-to-bottom order, capped at `maxRows` (fewer when the component is
|
|
296
|
+
* shorter). The rows MUST be byte-identical to the corresponding tail of
|
|
297
|
+
* what `render(width)` would have returned, modulo a one-row separator at
|
|
298
|
+
* the very top edge (a transient frame the settle paint overwrites).
|
|
299
|
+
* - MUST NOT mutate any persistent full-compose state: the next `render()`
|
|
300
|
+
* (the settle paint) has to reconcile exactly as if the tail render never
|
|
301
|
+
* happened. Warming pure per-width render caches is fine and desirable.
|
|
302
|
+
*/
|
|
303
|
+
export interface ViewportTailProvider {
|
|
304
|
+
renderViewportTail(width: number, maxRows: number): readonly string[];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function asViewportTailProvider(component: Component): ViewportTailProvider | undefined {
|
|
308
|
+
const candidate = component as Component & Partial<ViewportTailProvider>;
|
|
309
|
+
return typeof candidate.renderViewportTail === "function" ? (candidate as ViewportTailProvider) : undefined;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Interface for components that can receive focus and display a cursor.
|
|
314
|
+
* When focused, the component should emit CURSOR_MARKER at the cursor position
|
|
315
|
+
* in its render output. TUI will find this marker and position the hardware
|
|
316
|
+
* cursor there for proper IME candidate window positioning.
|
|
317
|
+
*
|
|
318
|
+
* Components that can switch between terminal-cursor and software-cursor
|
|
319
|
+
* rendering expose `setUseTerminalCursor`; TUI keeps that mode in sync with
|
|
320
|
+
* its resolved hardware-cursor preference whenever focus or the preference
|
|
321
|
+
* changes.
|
|
322
|
+
*/
|
|
323
|
+
export interface Focusable {
|
|
324
|
+
/** Set by TUI when focus changes. Component should emit CURSOR_MARKER when true. */
|
|
325
|
+
focused: boolean;
|
|
326
|
+
/** Set by TUI when hardware cursor rendering is enabled or disabled. */
|
|
327
|
+
setUseTerminalCursor?(useTerminalCursor: boolean): void;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Options for scheduling a TUI render. */
|
|
331
|
+
export interface RenderRequestOptions {
|
|
332
|
+
/** Clear terminal scrollback for intentional transcript replacement. */
|
|
333
|
+
clearScrollback?: boolean;
|
|
334
|
+
}
|
|
335
|
+
/** Type guard to check if a component implements Focusable */
|
|
336
|
+
export function isFocusable(component: Component | null): component is Component & Focusable {
|
|
337
|
+
return component !== null && "focused" in component;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Cursor position marker - APC (Application Program Command) sequence.
|
|
342
|
+
* This is a zero-width escape sequence that terminals ignore.
|
|
343
|
+
* Components emit this at the cursor position when focused.
|
|
344
|
+
* TUI finds and strips this marker, then positions the hardware cursor there.
|
|
345
|
+
*/
|
|
346
|
+
export const CURSOR_MARKER = "\x1b_pi:c\x07";
|
|
347
|
+
|
|
348
|
+
export { visibleWidth };
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Anchor position for overlays
|
|
352
|
+
*/
|
|
353
|
+
export type OverlayAnchor =
|
|
354
|
+
| "center"
|
|
355
|
+
| "top-left"
|
|
356
|
+
| "top-right"
|
|
357
|
+
| "bottom-left"
|
|
358
|
+
| "bottom-right"
|
|
359
|
+
| "top-center"
|
|
360
|
+
| "bottom-center"
|
|
361
|
+
| "left-center"
|
|
362
|
+
| "right-center";
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Margin configuration for overlays
|
|
366
|
+
*/
|
|
367
|
+
export interface OverlayMargin {
|
|
368
|
+
top?: number;
|
|
369
|
+
right?: number;
|
|
370
|
+
bottom?: number;
|
|
371
|
+
left?: number;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Value that can be absolute (number) or percentage (string like "50%") */
|
|
375
|
+
export type SizeValue = number | `${number}%`;
|
|
376
|
+
|
|
377
|
+
/** Parse a SizeValue into absolute value given a reference size */
|
|
378
|
+
function parseSizeValue(value: SizeValue | undefined, referenceSize: number): number | undefined {
|
|
379
|
+
if (value === undefined) return undefined;
|
|
380
|
+
if (typeof value === "number") return value;
|
|
381
|
+
// Parse percentage string like "50%"
|
|
382
|
+
const match = value.match(/^(\d+(?:\.\d+)?)%$/);
|
|
383
|
+
if (match) {
|
|
384
|
+
return Math.floor((referenceSize * parseFloat(match[1])) / 100);
|
|
385
|
+
}
|
|
386
|
+
return undefined;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
|
|
390
|
+
function isMultiplexerSession(): boolean {
|
|
391
|
+
return isInsideTerminalMultiplexer();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Terminals that re-report their size whenever the alternate screen buffer is
|
|
396
|
+
* toggled. The non-multiplexer resize fast path ({@link TUI.#beginResizeViewport})
|
|
397
|
+
* borrows the alternate screen for throwaway drag frames; on these terminals
|
|
398
|
+
* entering/leaving the alt buffer emits a fresh SIGWINCH (Warp reports a height
|
|
399
|
+
* one row different for the alt buffer), which re-enters the fast path — a
|
|
400
|
+
* self-sustaining resize loop that floods ED3 full repaints even though the
|
|
401
|
+
* geometry never actually changes. Routing them through the in-place
|
|
402
|
+
* (multiplexer) resize path never touches the alt buffer, breaking the loop.
|
|
403
|
+
*
|
|
404
|
+
* `PI_TUI_RESIZE_IN_PLACE=1|0` forces this on/off for any terminal.
|
|
405
|
+
*/
|
|
406
|
+
function reportsSizeOnAltScreenToggle(): boolean {
|
|
407
|
+
const override = Bun.env.PI_TUI_RESIZE_IN_PLACE;
|
|
408
|
+
if (override === "0" || override === "false") return false;
|
|
409
|
+
if (override === "1" || override === "true") return true;
|
|
410
|
+
return Bun.env.TERM_PROGRAM?.toLowerCase() === "warpterminal";
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Resize should repaint the visible window in place — no alternate-screen
|
|
415
|
+
* borrow, no ED3 scrollback rewrap — for multiplexer panes and for terminals
|
|
416
|
+
* that loop on alt-screen toggles. The tradeoff is identical to a multiplexer:
|
|
417
|
+
* scrollback above the window keeps its old wrap instead of being re-flowed.
|
|
418
|
+
*/
|
|
419
|
+
function resizeRepaintsInPlace(): boolean {
|
|
420
|
+
return isMultiplexerSession() || reportsSizeOnAltScreenToggle();
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Options for overlay positioning and sizing.
|
|
425
|
+
* Values can be absolute numbers or percentage strings (e.g., "50%").
|
|
426
|
+
*/
|
|
427
|
+
export interface OverlayOptions {
|
|
428
|
+
// === Sizing ===
|
|
429
|
+
/** Width in columns, or percentage of terminal width (e.g., "50%") */
|
|
430
|
+
width?: SizeValue;
|
|
431
|
+
/** Minimum width in columns */
|
|
432
|
+
minWidth?: number;
|
|
433
|
+
/** Maximum height in rows, or percentage of terminal height (e.g., "50%") */
|
|
434
|
+
maxHeight?: SizeValue;
|
|
435
|
+
|
|
436
|
+
// === Positioning - anchor-based ===
|
|
437
|
+
/** Anchor point for positioning (default: 'center') */
|
|
438
|
+
anchor?: OverlayAnchor;
|
|
439
|
+
/** Horizontal offset from anchor position (positive = right) */
|
|
440
|
+
offsetX?: number;
|
|
441
|
+
/** Vertical offset from anchor position (positive = down) */
|
|
442
|
+
offsetY?: number;
|
|
443
|
+
|
|
444
|
+
// === Positioning - percentage or absolute ===
|
|
445
|
+
/** Row position: absolute number, or percentage (e.g., "25%" = 25% from top) */
|
|
446
|
+
row?: SizeValue;
|
|
447
|
+
/** Column position: absolute number, or percentage (e.g., "50%" = centered horizontally) */
|
|
448
|
+
col?: SizeValue;
|
|
449
|
+
|
|
450
|
+
// === Margin from terminal edges ===
|
|
451
|
+
/** Margin from terminal edges. Number applies to all sides. */
|
|
452
|
+
margin?: OverlayMargin | number;
|
|
453
|
+
|
|
454
|
+
// === Visibility ===
|
|
455
|
+
/**
|
|
456
|
+
* Control overlay visibility based on terminal dimensions.
|
|
457
|
+
* If provided, overlay is only rendered when this returns true.
|
|
458
|
+
* Called each render cycle with current terminal dimensions.
|
|
459
|
+
*/
|
|
460
|
+
visible?: (termWidth: number, termHeight: number) => boolean;
|
|
461
|
+
|
|
462
|
+
// === Fullscreen ===
|
|
463
|
+
/**
|
|
464
|
+
* Borrow the terminal's alternate screen buffer for this overlay's lifetime
|
|
465
|
+
* (vim/less idiom). While the topmost visible overlay sets this, the engine
|
|
466
|
+
* paints only the modal on the alt screen and emits no ED3 / scrollback
|
|
467
|
+
* bytes, so the transcript on the normal screen stays untouched and is not
|
|
468
|
+
* scrollable behind the modal. Defaults off — all other overlays are
|
|
469
|
+
* unchanged and still draw over the transcript on the normal screen.
|
|
470
|
+
*/
|
|
471
|
+
fullscreen?: boolean;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Handle returned by showOverlay for controlling the overlay
|
|
476
|
+
*/
|
|
477
|
+
export interface OverlayHandle {
|
|
478
|
+
/** Permanently remove the overlay (cannot be shown again) */
|
|
479
|
+
hide(): void;
|
|
480
|
+
/** Temporarily hide or show the overlay */
|
|
481
|
+
setHidden(hidden: boolean): void;
|
|
482
|
+
/** Check if overlay is temporarily hidden */
|
|
483
|
+
isHidden(): boolean;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Container - a component that contains other components
|
|
488
|
+
*/
|
|
489
|
+
export class Container implements Component {
|
|
490
|
+
children: Component[] = [];
|
|
491
|
+
|
|
492
|
+
// Memoized concatenation of the children's latest renders. Children are
|
|
493
|
+
// still rendered every frame (renders carry side effects: image placement
|
|
494
|
+
// registration, seam/stability reports); the memo only skips rebuilding
|
|
495
|
+
// the concatenated array when every child returned the exact same array
|
|
496
|
+
// reference at the same width — which, per the Component render contract,
|
|
497
|
+
// proves the rows are byte-identical. Cleared on any child-list change and
|
|
498
|
+
// on invalidate().
|
|
499
|
+
#memoLines: string[] | undefined;
|
|
500
|
+
#memoChildLines: (readonly string[])[] = [];
|
|
501
|
+
#memoWidth = -1;
|
|
502
|
+
|
|
503
|
+
#ignoreTight = false;
|
|
504
|
+
|
|
505
|
+
setIgnoreTight(ignore: boolean): this {
|
|
506
|
+
this.#ignoreTight = ignore;
|
|
507
|
+
for (const child of this.children) {
|
|
508
|
+
child.setIgnoreTight?.(ignore);
|
|
509
|
+
}
|
|
510
|
+
this.invalidate();
|
|
511
|
+
return this;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
addChild(component: Component): void {
|
|
515
|
+
this.children.push(component);
|
|
516
|
+
if (this.#ignoreTight) {
|
|
517
|
+
component.setIgnoreTight?.(true);
|
|
518
|
+
}
|
|
519
|
+
this.#memoLines = undefined;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
removeChild(component: Component): void {
|
|
523
|
+
const index = this.children.indexOf(component);
|
|
524
|
+
if (index !== -1) {
|
|
525
|
+
this.children.splice(index, 1);
|
|
526
|
+
this.#memoLines = undefined;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
clear(): void {
|
|
531
|
+
this.children = [];
|
|
532
|
+
this.#memoLines = undefined;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
invalidate(): void {
|
|
536
|
+
this.#memoLines = undefined;
|
|
537
|
+
for (const child of this.children) {
|
|
538
|
+
child.invalidate?.();
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Propagate teardown to children. Call when the container's children are
|
|
544
|
+
* being permanently discarded (not when they are detached for reuse — use
|
|
545
|
+
* {@link clear} for that). Idempotent per child via each child's own dispose.
|
|
546
|
+
*/
|
|
547
|
+
dispose(): void {
|
|
548
|
+
for (const child of this.children) {
|
|
549
|
+
child.dispose?.();
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
render(width: number): readonly string[] {
|
|
554
|
+
width = Math.max(1, width);
|
|
555
|
+
const children = this.children;
|
|
556
|
+
const count = children.length;
|
|
557
|
+
let refs = this.#memoChildLines;
|
|
558
|
+
let unchanged = this.#memoLines !== undefined && this.#memoWidth === width && refs.length === count;
|
|
559
|
+
if (refs.length !== count) {
|
|
560
|
+
refs = new Array(count);
|
|
561
|
+
this.#memoChildLines = refs;
|
|
562
|
+
}
|
|
563
|
+
for (let i = 0; i < count; i++) {
|
|
564
|
+
const childLines = children[i]!.render(width);
|
|
565
|
+
if (refs[i] !== childLines) {
|
|
566
|
+
unchanged = false;
|
|
567
|
+
refs[i] = childLines;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
this.#memoWidth = width;
|
|
571
|
+
if (unchanged) return this.#memoLines!;
|
|
572
|
+
const lines: string[] = [];
|
|
573
|
+
for (let i = 0; i < count; i++) {
|
|
574
|
+
const childLines = refs[i]!;
|
|
575
|
+
for (let j = 0; j < childLines.length; j++) lines.push(childLines[j]!);
|
|
576
|
+
}
|
|
577
|
+
this.#memoLines = lines;
|
|
578
|
+
return lines;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Render intent. `#doRender` classifies each frame, and the matching `#emit*`
|
|
584
|
+
* method owns the bytes written and the state update.
|
|
585
|
+
*
|
|
586
|
+
* - `fullPaint`: gesture-driven replay — initial paint, session replacement,
|
|
587
|
+
* resize, resetDisplay. Clears the viewport and (for destructive replaces,
|
|
588
|
+
* outside multiplexers) native scrollback via ED3, then writes the
|
|
589
|
+
* committed prefix and the visible window. The only ED3 callsite in the
|
|
590
|
+
* engine.
|
|
591
|
+
* - `update`: ordinary frame. Commits the newly settled chunk at the
|
|
592
|
+
* scrollback seam (if any) and repaints the window with relative moves.
|
|
593
|
+
*/
|
|
594
|
+
type RenderIntent =
|
|
595
|
+
| { kind: "fullPaint"; clearScrollback: boolean }
|
|
596
|
+
| { kind: "update"; chunkTo: number; windowTop: number };
|
|
597
|
+
|
|
598
|
+
interface HardwareCursorState {
|
|
599
|
+
row: number;
|
|
600
|
+
col: number;
|
|
601
|
+
visible: boolean;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
interface HardwareCursorUpdate {
|
|
605
|
+
toRow: number;
|
|
606
|
+
state: HardwareCursorState | null;
|
|
607
|
+
visible?: boolean;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
interface CursorControlResult extends HardwareCursorUpdate {
|
|
611
|
+
seq: string;
|
|
612
|
+
toCol: number;
|
|
613
|
+
visible: boolean;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* One root child's contribution to the composed frame: the array reference its
|
|
618
|
+
* render() returned, the frame row it starts at, the row count recorded at
|
|
619
|
+
* compose time (in-place mutators keep the reference but may change length),
|
|
620
|
+
* and the child-local seam reports captured at render time — replayed verbatim
|
|
621
|
+
* when a component-scoped frame reuses this segment without re-rendering.
|
|
622
|
+
*/
|
|
623
|
+
interface FrameSegment {
|
|
624
|
+
component: Component;
|
|
625
|
+
lines: readonly string[];
|
|
626
|
+
start: number;
|
|
627
|
+
rowCount: number;
|
|
628
|
+
liveLocalStart?: number;
|
|
629
|
+
commitLocalEnd?: number;
|
|
630
|
+
snapshotLocalEnd?: number;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** Depth-first identity search through `Container`-shaped children. */
|
|
634
|
+
function subtreeContains(root: Component, target: Component): boolean {
|
|
635
|
+
if (root === target) return true;
|
|
636
|
+
const children = (root as Partial<Container>).children;
|
|
637
|
+
if (!Array.isArray(children)) return false;
|
|
638
|
+
for (let i = 0; i < children.length; i++) {
|
|
639
|
+
if (subtreeContains(children[i]!, target)) return true;
|
|
640
|
+
}
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
interface PreparedLine {
|
|
645
|
+
raw: string;
|
|
646
|
+
width: number;
|
|
647
|
+
line: string;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const SGR_SEQUENCE = /\x1b\[[0-9;:]*m/g;
|
|
651
|
+
|
|
652
|
+
// SGR coalescing. The renderer's component tree emits a styled span as
|
|
653
|
+
// `<set-color>text<reset>`, so adjacent spans produce runs of byte-adjacent
|
|
654
|
+
// SGR sequences (e.g. a `CSI 39 m` fg-reset immediately followed by the next
|
|
655
|
+
// span's `CSI 38;2;r;g;b m`). Two byte-adjacent SGR sequences are semantically
|
|
656
|
+
// identical to one SGR carrying both parameter lists (SGR params apply
|
|
657
|
+
// left-to-right), so merging the run into a single `CSI … m` is
|
|
658
|
+
// behavior-preserving: it drops the redundant `ESC[`/`m` framing and lets the
|
|
659
|
+
// terminal dispatch one SGR instead of several. On a real transcript ~40% of
|
|
660
|
+
// all SGR sequences are collapsible this way, which meaningfully cuts the
|
|
661
|
+
// per-frame byte volume and SGR-dispatch count a slow (xterm.js/WebGL) terminal
|
|
662
|
+
// must process. On by default; `PI_NO_SGR_COALESCE=1` disables it.
|
|
663
|
+
const SGR_COALESCE_ENABLED = !$flag("PI_NO_SGR_COALESCE");
|
|
664
|
+
const CC_ESC = 0x1b;
|
|
665
|
+
const CC_BRACKET = 0x5b; // [
|
|
666
|
+
const CC_M = 0x6d; // m
|
|
667
|
+
const CC_SEMI = 0x3b; // ;
|
|
668
|
+
const CC_COLON = 0x3a; // :
|
|
669
|
+
// Max parameter tokens per emitted merged SGR. Kept well under xterm.js's
|
|
670
|
+
// 32-param cap (and the tighter limits of some real terminals) so a long
|
|
671
|
+
// adjacent run is split into several valid CSIs instead of overflowing one.
|
|
672
|
+
const MERGE_TOKEN_CAP = 16;
|
|
673
|
+
|
|
674
|
+
function isSgrParamByte(c: number): boolean {
|
|
675
|
+
return (c >= 0x30 && c <= 0x39) || c === CC_SEMI || c === CC_COLON;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// True when a parameter list ends mid extended-color spec in the ambiguous
|
|
679
|
+
// semicolon form: `38/48/58;2` with fewer than three channel values, or
|
|
680
|
+
// `38/48/58;5` with no palette index. Concatenating another list after such a
|
|
681
|
+
// run would let the next code be absorbed as the missing channel/index (e.g.
|
|
682
|
+
// `38;2;255;0` + `31` → `38;2;255;0;31`, where `31` becomes blue instead of a
|
|
683
|
+
// standalone fg-red), changing the rendered color. The self-delimiting colon
|
|
684
|
+
// form (`38:2::r:g:b`) is unambiguous — its tokens never equal a bare `38`, so
|
|
685
|
+
// the scan treats it as a complete unit and merging stays safe.
|
|
686
|
+
function endsWithIncompleteExtendedColor(params: string): boolean {
|
|
687
|
+
const t = params.split(";");
|
|
688
|
+
let i = 0;
|
|
689
|
+
while (i < t.length) {
|
|
690
|
+
const tok = t[i];
|
|
691
|
+
if (tok === "38" || tok === "48" || tok === "58") {
|
|
692
|
+
const mode = t[i + 1];
|
|
693
|
+
if (mode === undefined) return true; // introducer with no mode
|
|
694
|
+
if (mode === "2") {
|
|
695
|
+
if (i + 4 >= t.length) return true; // missing r/g/b
|
|
696
|
+
i += 5;
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
if (mode === "5") {
|
|
700
|
+
if (i + 2 >= t.length) return true; // missing index
|
|
701
|
+
i += 3;
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
i += 1;
|
|
706
|
+
}
|
|
707
|
+
return false;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Merge runs of byte-adjacent SGR sequences (`CSI [0-9;:]* m`) into one. Only
|
|
712
|
+
* CSI-SGR sequences are touched; text, cursor moves, OSC, hyperlinks and image
|
|
713
|
+
* payloads pass through verbatim. Returns the original reference when nothing
|
|
714
|
+
* merges, so SGR-light lines incur only a single `indexOf` scan.
|
|
715
|
+
*/
|
|
716
|
+
export function coalesceAdjacentSgr(line: string): string {
|
|
717
|
+
if (!SGR_COALESCE_ENABLED || line.indexOf("\x1b[") === -1) return line;
|
|
718
|
+
const n = line.length;
|
|
719
|
+
let out = "";
|
|
720
|
+
let copiedUpto = 0;
|
|
721
|
+
let i = 0;
|
|
722
|
+
while (i < n) {
|
|
723
|
+
if (line.charCodeAt(i) !== CC_ESC || line.charCodeAt(i + 1) !== CC_BRACKET) {
|
|
724
|
+
i++;
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
// Scan a candidate SGR sequence: ESC [ <params> m.
|
|
728
|
+
let j = i + 2;
|
|
729
|
+
while (j < n && isSgrParamByte(line.charCodeAt(j))) j++;
|
|
730
|
+
if (j >= n || line.charCodeAt(j) !== CC_M) {
|
|
731
|
+
// Not an SGR (e.g. cursor move); leave it in the pending region.
|
|
732
|
+
i = j;
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
// Collect the run of adjacent SGR sequences starting here.
|
|
736
|
+
const params: string[] = [line.slice(i + 2, j)];
|
|
737
|
+
let k = j + 1;
|
|
738
|
+
while (k < n && line.charCodeAt(k) === CC_ESC && line.charCodeAt(k + 1) === CC_BRACKET) {
|
|
739
|
+
let p = k + 2;
|
|
740
|
+
while (p < n && isSgrParamByte(line.charCodeAt(p))) p++;
|
|
741
|
+
if (p >= n || line.charCodeAt(p) !== CC_M) break;
|
|
742
|
+
params.push(line.slice(k + 2, p));
|
|
743
|
+
k = p + 1;
|
|
744
|
+
}
|
|
745
|
+
if (params.length > 1) {
|
|
746
|
+
out += line.slice(copiedUpto, i);
|
|
747
|
+
// Emit the merged run, but flush the current group before appending a
|
|
748
|
+
// list when (a) the previous list ended mid extended-color, so the
|
|
749
|
+
// next code cannot be absorbed as its missing channel/index, or (b)
|
|
750
|
+
// the token count would exceed MERGE_TOKEN_CAP. SGR params apply
|
|
751
|
+
// left-to-right regardless of how they are grouped across adjacent
|
|
752
|
+
// CSIs, so a capped/guarded split stays behavior-preserving — while a
|
|
753
|
+
// single unbounded merge would overflow a terminal's CSI parameter
|
|
754
|
+
// buffer (xterm.js caps at 32 and silently truncates the rest,
|
|
755
|
+
// corrupting colors). Empty params (`CSI m`) mean a full reset;
|
|
756
|
+
// normalize to `0` so the merged list stays unambiguous.
|
|
757
|
+
let group = "";
|
|
758
|
+
let groupTokens = 0;
|
|
759
|
+
let groupOpenSafe = true;
|
|
760
|
+
for (let q = 0; q < params.length; q++) {
|
|
761
|
+
const norm = params[q]!.length === 0 ? "0" : params[q]!;
|
|
762
|
+
let tk = 1;
|
|
763
|
+
for (let z = 0; z < norm.length; z++) {
|
|
764
|
+
const cc = norm.charCodeAt(z);
|
|
765
|
+
if (cc === CC_SEMI || cc === CC_COLON) tk++;
|
|
766
|
+
}
|
|
767
|
+
if (groupTokens > 0 && (!groupOpenSafe || groupTokens + tk > MERGE_TOKEN_CAP)) {
|
|
768
|
+
out += `\x1b[${group}m`;
|
|
769
|
+
group = "";
|
|
770
|
+
groupTokens = 0;
|
|
771
|
+
}
|
|
772
|
+
group += group.length === 0 ? norm : `;${norm}`;
|
|
773
|
+
groupTokens += tk;
|
|
774
|
+
groupOpenSafe = !endsWithIncompleteExtendedColor(norm);
|
|
775
|
+
}
|
|
776
|
+
if (group.length > 0) out += `\x1b[${group}m`;
|
|
777
|
+
copiedUpto = k;
|
|
778
|
+
}
|
|
779
|
+
i = k;
|
|
780
|
+
}
|
|
781
|
+
if (copiedUpto === 0) return line;
|
|
782
|
+
return out + line.slice(copiedUpto);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** Compare two rows ignoring SGR styling (theme restyles keep alignment). */
|
|
786
|
+
function rowsEquivalent(a: string, b: string): boolean {
|
|
787
|
+
if (a === b) return true;
|
|
788
|
+
return a.replace(SGR_SEQUENCE, "") === b.replace(SGR_SEQUENCE, "");
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function isBlankRow(row: string): boolean {
|
|
792
|
+
if (row.length === 0) return true;
|
|
793
|
+
return row.replace(SGR_SEQUENCE, "").trim().length === 0;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Tail-alignment sampling bounds: look back through up to LOOKBACK rows of
|
|
797
|
+
// the committed prefix to collect SAMPLES non-blank comparisons.
|
|
798
|
+
const RESYNC_TAIL_LOOKBACK = 24;
|
|
799
|
+
const RESYNC_TAIL_SAMPLES = 8;
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Decide whether `frame` still aligns with the committed prefix, and where to
|
|
803
|
+
* re-anchor the commit index when it does not. Returns the resync row index,
|
|
804
|
+
* or -1 when no resync is needed.
|
|
805
|
+
*
|
|
806
|
+
* Audits the committed prefix [0, auditTo) EXCEPT the exempt window
|
|
807
|
+
* [exemptFrom, exemptTo): rows in the window are durable snapshots (a streaming
|
|
808
|
+
* table re-aligning its columns) that may drift legitimately, so their drift
|
|
809
|
+
* never triggers a re-anchor. Rows below the window — including forced-overflow
|
|
810
|
+
* rows committed only because they scrolled above the viewport under a
|
|
811
|
+
* commit-unstable barrier — ARE audited.
|
|
812
|
+
*
|
|
813
|
+
* Two detectors run over the audited rows:
|
|
814
|
+
*
|
|
815
|
+
* 1. Hard scan of the now-permanent forced suffix [exemptTo, permanentEnd):
|
|
816
|
+
* forced-overflow rows that THIS frame asserts are durable/permanent (index <
|
|
817
|
+
* permanentEnd — the barrier above them finalized or cleared, so durableBoundary
|
|
818
|
+
* rose past them). A content change there is real finalized content, so ANY
|
|
819
|
+
* mismatch re-anchors. Scanned in FULL, not sampled, so a single edit far above
|
|
820
|
+
* the commit boundary with an unchanged tail still re-anchors (duplication,
|
|
821
|
+
* never loss) instead of being committed nowhere and painted nowhere.
|
|
822
|
+
* 2. Tail sample (only when the hard scan is clean): exploits the asymmetry
|
|
823
|
+
* between the two mutation classes — an in-place edit/restyle of a committed
|
|
824
|
+
* row disturbs only the touched rows (alignment below intact; the stale copy
|
|
825
|
+
* in history is the long-accepted artifact), while an insertion/deletion
|
|
826
|
+
* shifts EVERY row below it. So up to 8 non-blank rows within the last 24
|
|
827
|
+
* audited rows are compared SGR-stripped (theme changes stay quiet),
|
|
828
|
+
* tolerating a SINGLE non-hard mismatch (a legitimate one-row edit): aligned ⇒
|
|
829
|
+
* no resync; misaligned ⇒ resync at the first non-equivalent audited row. The
|
|
830
|
+
* tolerance keeps both an offscreen still-live barrier (a ticking spinner) and
|
|
831
|
+
* a no-seam in-place row edit from spraying duplicate snapshots every frame;
|
|
832
|
+
* the hard scan above is what forbids it from swallowing a finalized row.
|
|
833
|
+
*
|
|
834
|
+
* Highly repetitive tails (identical filler rows) can mask a shift in the tail
|
|
835
|
+
* sample, in which case the skipped rows are content-identical to the committed
|
|
836
|
+
* ones — observationally harmless. Exported for the render-stress harness, whose
|
|
837
|
+
* shadow commit ledger must mirror the engine's law exactly.
|
|
838
|
+
*/
|
|
839
|
+
export function findCommittedPrefixResync(
|
|
840
|
+
frame: readonly string[],
|
|
841
|
+
prefix: readonly string[],
|
|
842
|
+
auditTo: number = prefix.length,
|
|
843
|
+
exemptFrom: number = auditTo,
|
|
844
|
+
exemptTo: number = exemptFrom,
|
|
845
|
+
permanentEnd = 0,
|
|
846
|
+
): number {
|
|
847
|
+
const committed = Math.min(prefix.length, Math.max(0, Math.trunc(auditTo)));
|
|
848
|
+
if (committed === 0) return -1;
|
|
849
|
+
// Exempt window [exFrom, exTo) clamped into the committed prefix. Rows there
|
|
850
|
+
// are durable-snapshot drift and skipped by both detectors and the scan.
|
|
851
|
+
const exFrom = Math.max(0, Math.min(committed, Math.trunc(exemptFrom)));
|
|
852
|
+
const exTo = Math.max(exFrom, Math.min(committed, Math.trunc(exemptTo)));
|
|
853
|
+
const audited = (i: number): boolean => i < exFrom || i >= exTo;
|
|
854
|
+
if (frame.length >= committed) {
|
|
855
|
+
// 1. Hard scan: forced-overflow rows now asserted permanent. Full scan, no
|
|
856
|
+
// tolerance — a finalized row that changed must re-anchor.
|
|
857
|
+
const hardEnd = Math.min(committed, Math.max(0, Math.trunc(permanentEnd)));
|
|
858
|
+
let hardMismatch = false;
|
|
859
|
+
for (let i = exTo; i < hardEnd; i++) {
|
|
860
|
+
if (!rowsEquivalent(frame[i]!, prefix[i]!)) {
|
|
861
|
+
hardMismatch = true;
|
|
862
|
+
break;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
if (!hardMismatch) {
|
|
866
|
+
// 2. Tail sample. Walk up from the commit boundary, skipping exempt
|
|
867
|
+
// rows, until LOOKBACK audited rows or SAMPLES non-blank comparisons.
|
|
868
|
+
let samples = 0;
|
|
869
|
+
let mismatches = 0;
|
|
870
|
+
let scanned = 0;
|
|
871
|
+
for (let j = 1; j <= committed && scanned < RESYNC_TAIL_LOOKBACK && samples < RESYNC_TAIL_SAMPLES; j++) {
|
|
872
|
+
const idx = committed - j;
|
|
873
|
+
if (!audited(idx)) continue;
|
|
874
|
+
scanned++;
|
|
875
|
+
const row = frame[idx]!;
|
|
876
|
+
const old = prefix[idx]!;
|
|
877
|
+
if (row === old) {
|
|
878
|
+
if (!isBlankRow(row)) samples++;
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
if (isBlankRow(row) && isBlankRow(old)) continue;
|
|
882
|
+
samples++;
|
|
883
|
+
if (!rowsEquivalent(row, old)) mismatches++;
|
|
884
|
+
}
|
|
885
|
+
// No signal (all-blank/all-exempt tail) or at most one edited row: aligned.
|
|
886
|
+
if (samples === 0 || mismatches <= 1) return -1;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
// Misaligned (hard mismatch, tail-sample shift, or the frame no longer covers
|
|
890
|
+
// the prefix): re-anchor at the first audited row whose content changed.
|
|
891
|
+
const limit = Math.min(committed, frame.length);
|
|
892
|
+
for (let i = 0; i < limit; i++) {
|
|
893
|
+
if (!audited(i)) continue;
|
|
894
|
+
if (!rowsEquivalent(frame[i]!, prefix[i]!)) return i;
|
|
895
|
+
}
|
|
896
|
+
return limit < committed ? limit : -1;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* TUI - Main class for managing terminal UI with differential rendering
|
|
901
|
+
*/
|
|
902
|
+
export class TUI extends Container {
|
|
903
|
+
terminal: Terminal;
|
|
904
|
+
#previousFrameLength = 0;
|
|
905
|
+
#previousWidth = 0;
|
|
906
|
+
#previousHeight = 0;
|
|
907
|
+
#focusedComponent: Component | null = null;
|
|
908
|
+
#inputListeners = new Set<InputListener>();
|
|
909
|
+
#startListeners = new Set<StartListener>();
|
|
910
|
+
|
|
911
|
+
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
|
|
912
|
+
onDebug?: () => void;
|
|
913
|
+
#renderRequested = false;
|
|
914
|
+
#renderTimer: RenderTimer | undefined;
|
|
915
|
+
#renderScheduler: RenderScheduler;
|
|
916
|
+
#lastRenderAt = 0;
|
|
917
|
+
/**
|
|
918
|
+
* Wall-clock cost of the most recent `#doRender()` call. Used by
|
|
919
|
+
* `#scheduleRender` to inflate the next render delay proportionally so a
|
|
920
|
+
* spike of slow frames (large transcript diffs, huge assistant text wrap,
|
|
921
|
+
* component-tree walks) does not busy-loop the CPU: the throttle would
|
|
922
|
+
* otherwise collapse to zero once `elapsed >= MIN_RENDER_INTERVAL_MS` and
|
|
923
|
+
* fire the next frame immediately (see #4145).
|
|
924
|
+
*/
|
|
925
|
+
#lastFrameCostMs = 0;
|
|
926
|
+
static readonly #MIN_RENDER_INTERVAL_MS = 1000 / 30;
|
|
927
|
+
static readonly #INPUT_RENDER_GRACE_MS = TUI.#MIN_RENDER_INTERVAL_MS;
|
|
928
|
+
/**
|
|
929
|
+
* Cap on the adaptive floor derived from `#lastFrameCostMs`. Bounds the UI
|
|
930
|
+
* responsiveness at ~5 fps under sustained heavy renders — anything slower
|
|
931
|
+
* feels dead to the user and no longer justifies further CPU savings.
|
|
932
|
+
*/
|
|
933
|
+
static readonly #MAX_ADAPTIVE_RENDER_MS = 200;
|
|
934
|
+
#inputRenderGraceUntilMs = 0;
|
|
935
|
+
// Pane-reflow settle window for tmux/screen/zellij. The host process gets
|
|
936
|
+
// SIGWINCH (and `process.stdout` already reports the new geometry) before
|
|
937
|
+
// the multiplexer finishes repainting the pane at the new size, and
|
|
938
|
+
// drag-resize/pane-close animations fire several events in flight. A forced
|
|
939
|
+
// render on each SIGWINCH races those mid-reflow paints — the multiplexer's
|
|
940
|
+
// catch-up paint then partially overwrites the TUI output, which the user
|
|
941
|
+
// sees as a viewport flash or blank screen before the next throttled frame
|
|
942
|
+
// arrives (issue #2088). Coalescing every SIGWINCH inside this window into
|
|
943
|
+
// a single forced render lets the multiplexer settle first.
|
|
944
|
+
static readonly #MULTIPLEXER_RESIZE_DEBOUNCE_MS = 50;
|
|
945
|
+
// Resize viewport fast path (non-multiplexer). A drag emits a SIGWINCH burst,
|
|
946
|
+
// and outside a multiplexer the host gets each new geometry atomically. The
|
|
947
|
+
// authoritative resize paint erases and replays the entire transcript so it
|
|
948
|
+
// rewraps at the new width — O(history) compose (markdown re-lexes every
|
|
949
|
+
// block, the per-width cache missing on every distinct drag width) plus an
|
|
950
|
+
// O(history) write that pushes all of it back through native scrollback. At
|
|
951
|
+
// drag rates that whole-history pass is recomputed dozens of times a second
|
|
952
|
+
// and discarded the instant the next event lands. While the drag is in
|
|
953
|
+
// flight the engine instead composes and paints ONLY the viewport (see
|
|
954
|
+
// `#renderResizeViewport`): a state-isolated, throwaway frame that never
|
|
955
|
+
// touches the commit ledger. The authoritative full replay fires once, after
|
|
956
|
+
// the drag has been quiet for this long. Multiplexer sessions keep their own
|
|
957
|
+
// debounce (`#armMultiplexerResizeTimer`, see #2088) and never take this path.
|
|
958
|
+
static readonly #RESIZE_VIEWPORT_SETTLE_MS = 120;
|
|
959
|
+
// Ghostty can drop Kitty graphics commands sent during its first post-startup
|
|
960
|
+
// settle window, leaving only Unicode placeholder cells. Hold the first image
|
|
961
|
+
// paint until that window has passed; later images render normally.
|
|
962
|
+
static readonly #GHOSTTY_INITIAL_IMAGE_DELAY_MS = 100;
|
|
963
|
+
// Post-paint settle window for ConPTY hosts. The `sessionReplace` /
|
|
964
|
+
// `historyRebuild` / `overlayRebuild` intents drive `#emitFullPaint` over
|
|
965
|
+
// a transcript that overflows the viewport, scroll-pushing everything past
|
|
966
|
+
// the last `height` rows into native scrollback. Windows Terminal's
|
|
967
|
+
// viewport-follow logic gets lossy during that burst: spinner/blink-driven
|
|
968
|
+
// `requestRender(false)` calls firing inside the window each produce another
|
|
969
|
+
// diff write, and the WT host processes them faster than its viewport
|
|
970
|
+
// tracker can keep up — the visible tail ends up parked a few rows above
|
|
971
|
+
// the actual last row until any focus event (Alt+Tab) forces a host repaint.
|
|
972
|
+
// Coalescing every non-forced render inside this window into a single
|
|
973
|
+
// trailing render lets the host fully settle the big paint before any
|
|
974
|
+
// follow-up writes touch the buffer. The first-ever `initial` paint is
|
|
975
|
+
// deliberately exempt: nothing has been on screen yet, so no drift can
|
|
976
|
+
// have accumulated, and tests that start the TUI over an over-tall
|
|
977
|
+
// component depend on the next paint firing without delay. Only armed on
|
|
978
|
+
// ConPTY hosts (`isConPTYHosted()`); other terminals do not exhibit the
|
|
979
|
+
// drift and would just see an unnecessary post-paint latency. See #2095.
|
|
980
|
+
static readonly #CONPTY_POST_FULL_PAINT_SETTLE_MS = 150;
|
|
981
|
+
static readonly #CONPTY_FRAME_TRUNCATE_THRESHOLD_BYTES = 512 * 1024;
|
|
982
|
+
static readonly #CONPTY_FRAME_RETAIN_BYTES = 64 * 1024;
|
|
983
|
+
#postFullPaintSettleUntilMs = 0;
|
|
984
|
+
#postFullPaintSettleTimer: RenderTimer | undefined;
|
|
985
|
+
#hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
|
|
986
|
+
#hardwareCursorState: HardwareCursorState | null = null;
|
|
987
|
+
#hardwareCursorVisibilityKnown = false;
|
|
988
|
+
#hardwareCursorVisible = false;
|
|
989
|
+
#sixelProbePendingDa = false;
|
|
990
|
+
#sixelProbePendingGraphics = false;
|
|
991
|
+
#sixelProbeBuffer = "";
|
|
992
|
+
#sixelProbeTimeout?: NodeJS.Timeout;
|
|
993
|
+
#sixelProbeUnsubscribe?: () => void;
|
|
994
|
+
#showHardwareCursor = $flag("PI_HARDWARE_CURSOR");
|
|
995
|
+
#synchronizedOutputEnabled = shouldEnableSynchronizedOutputByDefault();
|
|
996
|
+
#paintBeginSequence = this.#synchronizedOutputEnabled ? PAINT_BEGIN : PAINT_BEGIN_NO_SYNC;
|
|
997
|
+
#paintEndSequence = this.#synchronizedOutputEnabled ? PAINT_END : PAINT_END_NO_SYNC;
|
|
998
|
+
#cursorBeginSequence = this.#synchronizedOutputEnabled ? CURSOR_BEGIN : CURSOR_BEGIN_NO_SYNC;
|
|
999
|
+
#cursorEndSequence = this.#synchronizedOutputEnabled ? CURSOR_END : CURSOR_END_NO_SYNC;
|
|
1000
|
+
// Rows of the current frame physically committed to the terminal tape
|
|
1001
|
+
// (native scrollback or scrolled past the window top). Immutable by
|
|
1002
|
+
// contract: the engine never rewrites them, and components keep mutable
|
|
1003
|
+
// rows below the `NativeScrollbackLiveRegion` boundary so they never get
|
|
1004
|
+
// here while they can still change.
|
|
1005
|
+
#committedRows = 0;
|
|
1006
|
+
// Raw rows mirroring [0, #committedRows) — the engine's claim of what it
|
|
1007
|
+
// committed, audited each ordinary frame against the current render to
|
|
1008
|
+
// detect components re-laying-out committed content (see
|
|
1009
|
+
// #auditCommittedPrefix). Holds references to component-cached strings, so
|
|
1010
|
+
// the audit is a pointer walk in the common case.
|
|
1011
|
+
#committedPrefix: string[] = [];
|
|
1012
|
+
// The committed prefix [0, committedRows) splits into three audit zones by
|
|
1013
|
+
// two monotone marks auditRows ≤ durableRows ≤ committedRows:
|
|
1014
|
+
// [0, auditRows) BYTE-STABLE — audited (re-anchor on any shift).
|
|
1015
|
+
// [auditRows, durableRows) DURABLE snapshot — exempt: rows may drift in
|
|
1016
|
+
// place (a streaming table widening) without re-anchoring, so their
|
|
1017
|
+
// expected drift never sprays duplicate snapshots.
|
|
1018
|
+
// [durableRows, committedRows) FORCED-overflow — audited: rows committed
|
|
1019
|
+
// only because they scrolled above the window under a commit-unstable
|
|
1020
|
+
// barrier; auditing them re-anchors (duplication, never loss) when the
|
|
1021
|
+
// barrier later shifts/finalizes/removes, instead of stranding a stale
|
|
1022
|
+
// prefix that silently drops the rows beneath it.
|
|
1023
|
+
// Both marks re-base on a wholesale re-slice (full paint / shrink / geometry)
|
|
1024
|
+
// and otherwise advance per the persistence rules in #updateCommittedAuditRows.
|
|
1025
|
+
// #auditCommittedPrefix audits [0, committedRows) skipping the exempt window
|
|
1026
|
+
// [auditRows, durableRows).
|
|
1027
|
+
#committedPrefixAuditRows = 0;
|
|
1028
|
+
#committedPrefixDurableRows = 0;
|
|
1029
|
+
// Frame row currently mapped to screen row 0. Monotonic between full
|
|
1030
|
+
// paints: a shrink never re-exposes scrolled-off rows (they cannot be
|
|
1031
|
+
// un-scrolled without rewriting history); live rows repaint at fixed
|
|
1032
|
+
// positions with blank rows below the shrunken tail.
|
|
1033
|
+
#windowTopRow = 0;
|
|
1034
|
+
// Exactly what is painted on the screen rows (post-composite, prepared).
|
|
1035
|
+
#previousWindow: string[] = [];
|
|
1036
|
+
#nativeScrollbackLiveRegionStart: number | undefined;
|
|
1037
|
+
#nativeScrollbackCommitSafeEnd: number | undefined;
|
|
1038
|
+
#nativeScrollbackSnapshotSafeEnd: number | undefined;
|
|
1039
|
+
#fullRedrawCount = 0;
|
|
1040
|
+
// Caps how many inline images render as live graphics; older ones fall back
|
|
1041
|
+
// to text via a purge + full redraw. Cap is configured by the host app.
|
|
1042
|
+
#imageBudget = new ImageBudget(DEFAULT_MAX_INLINE_IMAGES, () => this.requestRender());
|
|
1043
|
+
#ghosttyInitialImageDelayDone = false;
|
|
1044
|
+
#ghosttyInitialImageDelayTimer: RenderTimer | undefined;
|
|
1045
|
+
#ghosttyImageReadyAtMs = 0;
|
|
1046
|
+
#clearScrollbackOnNextRender = false;
|
|
1047
|
+
#forceViewportRepaintOnNextRender = false;
|
|
1048
|
+
#hasEverRendered = false;
|
|
1049
|
+
// Set by the terminal resize callback; consumed by the next render. A resize
|
|
1050
|
+
// event invalidates the committed screen even when the dimensions net out
|
|
1051
|
+
// unchanged by render time (e.g. a 6→4→6 round trip coalesced into one frame
|
|
1052
|
+
// budget): the terminal reflowed its buffer on each event, moving rows
|
|
1053
|
+
// between the viewport and scrollback, so the previous frame no longer
|
|
1054
|
+
// describes the screen. Tracking only the dimension delta misses this.
|
|
1055
|
+
#resizeEventPending = false;
|
|
1056
|
+
// Active multiplexer SIGWINCH debounce. Reset on each event so the timer
|
|
1057
|
+
// only fires once the pane stops resizing. Forced renders (resetDisplay,
|
|
1058
|
+
// finishSixelProbe, …) issued during the settle window route through the
|
|
1059
|
+
// same timer; their `clearScrollback` intent is OR'd into the deferred
|
|
1060
|
+
// flag below so the settled paint still honours every caller's request.
|
|
1061
|
+
#multiplexerResizeTimer: RenderTimer | undefined;
|
|
1062
|
+
#deferredForcedClearScrollback = false;
|
|
1063
|
+
// True from the first SIGWINCH of a non-multiplexer drag until the settle
|
|
1064
|
+
// timer fires. While set, every `#doRender` short-circuits to the viewport
|
|
1065
|
+
// fast path (`#renderResizeViewport`) instead of an authoritative full
|
|
1066
|
+
// paint, and no commit/window/diff state is advanced.
|
|
1067
|
+
#resizeViewportActive = false;
|
|
1068
|
+
// Quiet-window timer that ends the drag: its callback clears the flag and
|
|
1069
|
+
// drives the one authoritative full paint. Reset on every resize event so it
|
|
1070
|
+
// only fires once the drag stops. Cancelled on stop().
|
|
1071
|
+
#resizeViewportSettleTimer: RenderTimer | undefined;
|
|
1072
|
+
// Count of transient viewport-only resize paints emitted. Distinct from
|
|
1073
|
+
// `#fullRedrawCount`: these never enter native scrollback and exist only for
|
|
1074
|
+
// the lifetime of the drag. Exposed for tests/diagnostics.
|
|
1075
|
+
#resizeViewportPaintCount = 0;
|
|
1076
|
+
// During a live resize drag the terminal's normal buffer may reflow full-width
|
|
1077
|
+
// rows before our repaint lands. Borrow the alternate screen for throwaway
|
|
1078
|
+
// resize frames so width changes truncate the transient viewport instead of
|
|
1079
|
+
// pushing wrapped fragments into native scrollback.
|
|
1080
|
+
#resizeAltActive = false;
|
|
1081
|
+
#stopped = false;
|
|
1082
|
+
// Always-on event-loop lag probe. The high default threshold keeps it quiet;
|
|
1083
|
+
// it only logs `ui.loop-blocked` (with the current loop phase) when a frame
|
|
1084
|
+
// budget is genuinely starved. Armed in start(), disarmed in stop().
|
|
1085
|
+
#watchdog: LoopWatchdog;
|
|
1086
|
+
|
|
1087
|
+
// Transient alternate-screen state for a fullscreen overlay. While active, the
|
|
1088
|
+
// engine paints only the modal on the alt buffer and leaves every
|
|
1089
|
+
// normal-screen accounting field (#previousFrameLength, #viewportTopRow, …)
|
|
1090
|
+
// untouched, so exiting reconciles cleanly against the terminal-restored
|
|
1091
|
+
// normal screen. #altPreviousLines is the last alt frame, for repaint-skip.
|
|
1092
|
+
#altActive = false;
|
|
1093
|
+
#altPreviousLines: string[] = [];
|
|
1094
|
+
#altEnterWidth = 0;
|
|
1095
|
+
#altEnterHeight = 0;
|
|
1096
|
+
|
|
1097
|
+
// Persistent composed frame. The render override splices only rows at/after
|
|
1098
|
+
// the stable prefix each frame; cursor markers are stripped at ingestion so
|
|
1099
|
+
// the frame never carries them. Returned to render() callers — treated as
|
|
1100
|
+
// immutable by them per the Component render contract.
|
|
1101
|
+
#composedFrame: string[] = [];
|
|
1102
|
+
// Per-root-child segment ledger backing the stable-prefix computation.
|
|
1103
|
+
#frameSegments: FrameSegment[] = [];
|
|
1104
|
+
#composeWidth = -1;
|
|
1105
|
+
// Cursor markers stripped at ingestion, ascending by frame row.
|
|
1106
|
+
#frameCursorMarkers: { row: number; col: number }[] = [];
|
|
1107
|
+
// Leading rows of #composedFrame byte-identical to the previous compose.
|
|
1108
|
+
#renderStablePrefixRows = 0;
|
|
1109
|
+
|
|
1110
|
+
// Component-scoped render accumulation. Targets are the components handed
|
|
1111
|
+
// to requestComponentRender() since the last frame; the flag stays true
|
|
1112
|
+
// only while EVERY pending request is component-scoped. Both are consumed
|
|
1113
|
+
// once per frame by #doRender.
|
|
1114
|
+
#componentRenderTargets = new Set<Component>();
|
|
1115
|
+
#pendingRenderComponentsOnly = false;
|
|
1116
|
+
// Root children that must re-render during the current compose; null for a
|
|
1117
|
+
// full compose. Non-null only for the duration of a component-scoped
|
|
1118
|
+
// render() call inside #doRender (the scratch set below, reused per frame).
|
|
1119
|
+
#partialComposeRoots: Set<Component> | null = null;
|
|
1120
|
+
#partialComposeRootsScratch = new Set<Component>();
|
|
1121
|
+
// Target component -> containing root child, so animation-rate requests do
|
|
1122
|
+
// not re-walk a huge transcript subtree every frame.
|
|
1123
|
+
#componentRootCache = new WeakMap<Component, Component>();
|
|
1124
|
+
|
|
1125
|
+
// Persistent prepared frame, row-aligned with #composedFrame. Entries store
|
|
1126
|
+
// normalized, width-fitted content rows without the per-line terminal
|
|
1127
|
+
// terminator; terminators are appended only at write time so width checks
|
|
1128
|
+
// stay on content, not reset bytes. #preparedValidRows counts the leading
|
|
1129
|
+
// rows known prepared against the CURRENT composed frame: a compose lowers
|
|
1130
|
+
// it to the stable prefix, a completed prepare raises it to the frame
|
|
1131
|
+
// length, and an abandoned frame (ghostty image defer) leaves it lowered so
|
|
1132
|
+
// the next prepare revalidates the splice.
|
|
1133
|
+
#preparedFrame: string[] = [];
|
|
1134
|
+
#preparedMeta: PreparedLine[] = [];
|
|
1135
|
+
#preparedValidRows = 0;
|
|
1136
|
+
|
|
1137
|
+
// Overlay stack for modal components rendered on top of base content
|
|
1138
|
+
overlayStack: {
|
|
1139
|
+
component: Component;
|
|
1140
|
+
options?: OverlayOptions;
|
|
1141
|
+
preFocus: Component | null;
|
|
1142
|
+
hidden: boolean;
|
|
1143
|
+
}[] = [];
|
|
1144
|
+
|
|
1145
|
+
constructor(terminal: Terminal, showHardwareCursor?: boolean, options?: TUIOptions) {
|
|
1146
|
+
super();
|
|
1147
|
+
this.terminal = terminal;
|
|
1148
|
+
this.#renderScheduler = options?.renderScheduler ?? DEFAULT_RENDER_SCHEDULER;
|
|
1149
|
+
this.#showHardwareCursor = showHardwareCursor === undefined ? this.#showHardwareCursor : showHardwareCursor;
|
|
1150
|
+
this.#watchdog = new LoopWatchdog();
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
override render(width: number): readonly string[] {
|
|
1154
|
+
width = Math.max(1, width);
|
|
1155
|
+
this.#nativeScrollbackLiveRegionStart = undefined;
|
|
1156
|
+
this.#nativeScrollbackCommitSafeEnd = undefined;
|
|
1157
|
+
this.#nativeScrollbackSnapshotSafeEnd = undefined;
|
|
1158
|
+
const children = this.children;
|
|
1159
|
+
const previousSegments = this.#frameSegments;
|
|
1160
|
+
const segments: FrameSegment[] = new Array(children.length);
|
|
1161
|
+
// A width change re-renders every child; nothing carries over.
|
|
1162
|
+
let chainStable = this.#composeWidth === width;
|
|
1163
|
+
this.#composeWidth = width;
|
|
1164
|
+
let offset = 0;
|
|
1165
|
+
let stableRows = 0;
|
|
1166
|
+
const partialRoots = this.#partialComposeRoots;
|
|
1167
|
+
for (let index = 0; index < children.length; index++) {
|
|
1168
|
+
const child = children[index]!;
|
|
1169
|
+
const previous = previousSegments[index];
|
|
1170
|
+
// Component-scoped frame: a root child outside every requested
|
|
1171
|
+
// subtree provably did not change (content mutations route through
|
|
1172
|
+
// a render request, which would have made this frame a full one) —
|
|
1173
|
+
// reuse its previous rows and seam report without calling render().
|
|
1174
|
+
const reuse =
|
|
1175
|
+
partialRoots !== null && previous !== undefined && previous.component === child && !partialRoots.has(child);
|
|
1176
|
+
let childLines: readonly string[];
|
|
1177
|
+
let liveLocalStart: number | undefined;
|
|
1178
|
+
let commitLocalEnd: number | undefined;
|
|
1179
|
+
let snapshotLocalEnd: number | undefined;
|
|
1180
|
+
let reported: number | undefined;
|
|
1181
|
+
if (reuse) {
|
|
1182
|
+
childLines = previous.lines;
|
|
1183
|
+
liveLocalStart = previous.liveLocalStart;
|
|
1184
|
+
commitLocalEnd = previous.commitLocalEnd;
|
|
1185
|
+
snapshotLocalEnd = previous.snapshotLocalEnd;
|
|
1186
|
+
} else {
|
|
1187
|
+
// Feed the engine's committed-row claim (from the previous frame's
|
|
1188
|
+
// emit) before rendering so the child can skip re-deriving blocks
|
|
1189
|
+
// that already live in immutable native scrollback. Reused segments
|
|
1190
|
+
// skip this: they never call render(), so the signal is moot.
|
|
1191
|
+
setNativeScrollbackCommittedRows(child, Math.max(0, this.#committedRows - offset));
|
|
1192
|
+
childLines = child.render(width);
|
|
1193
|
+
const liveRegionStart = getNativeScrollbackLiveRegionStart(child);
|
|
1194
|
+
if (liveRegionStart !== undefined) {
|
|
1195
|
+
liveLocalStart = Number.isFinite(liveRegionStart)
|
|
1196
|
+
? Math.max(0, Math.min(childLines.length, Math.trunc(liveRegionStart)))
|
|
1197
|
+
: childLines.length;
|
|
1198
|
+
const commitSafeEnd = getNativeScrollbackCommitSafeEnd(child);
|
|
1199
|
+
if (commitSafeEnd !== undefined) {
|
|
1200
|
+
commitLocalEnd = Number.isFinite(commitSafeEnd)
|
|
1201
|
+
? Math.max(liveLocalStart, Math.min(childLines.length, Math.trunc(commitSafeEnd)))
|
|
1202
|
+
: childLines.length;
|
|
1203
|
+
}
|
|
1204
|
+
// Durable snapshot end: clamped at/above the byte-stable end (or
|
|
1205
|
+
// the live-region start when none) so a child can never report a
|
|
1206
|
+
// shallower durable boundary than its byte-stable one.
|
|
1207
|
+
const snapshotSafeEnd = getNativeScrollbackSnapshotSafeEnd(child);
|
|
1208
|
+
if (snapshotSafeEnd !== undefined) {
|
|
1209
|
+
const snapshotFloor = commitLocalEnd ?? liveLocalStart;
|
|
1210
|
+
snapshotLocalEnd = Number.isFinite(snapshotSafeEnd)
|
|
1211
|
+
? Math.max(snapshotFloor, Math.min(childLines.length, Math.trunc(snapshotSafeEnd)))
|
|
1212
|
+
: childLines.length;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
// Consume the stability report unconditionally for implementers:
|
|
1216
|
+
// reading re-bases the component's baseline to the state this
|
|
1217
|
+
// compose is about to ingest (used or not, the current rows are
|
|
1218
|
+
// what ends up in the composed frame). Reused segments are
|
|
1219
|
+
// deliberately NOT read — their baseline must stay anchored to
|
|
1220
|
+
// the last render the engine actually observed.
|
|
1221
|
+
reported = getRenderStablePrefixRows(child);
|
|
1222
|
+
}
|
|
1223
|
+
// Topmost seam wins. Commits are prefix-only: the first child that
|
|
1224
|
+
// reports a live region (plus its own commit-safe extension) already
|
|
1225
|
+
// bounds everything below it, so a lower sibling's seam (e.g. a
|
|
1226
|
+
// status loader under a streaming transcript) must never overwrite
|
|
1227
|
+
// it — moving the boundary down would commit the earlier child's
|
|
1228
|
+
// still-mutable rows as stale history.
|
|
1229
|
+
if (liveLocalStart !== undefined && this.#nativeScrollbackLiveRegionStart === undefined) {
|
|
1230
|
+
this.#nativeScrollbackLiveRegionStart = offset + liveLocalStart;
|
|
1231
|
+
if (commitLocalEnd !== undefined) {
|
|
1232
|
+
this.#nativeScrollbackCommitSafeEnd = offset + commitLocalEnd;
|
|
1233
|
+
}
|
|
1234
|
+
if (snapshotLocalEnd !== undefined) {
|
|
1235
|
+
this.#nativeScrollbackSnapshotSafeEnd = offset + snapshotLocalEnd;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
if (chainStable) {
|
|
1239
|
+
if (previous !== undefined && previous.component === child && previous.start === offset) {
|
|
1240
|
+
let stableCount = 0;
|
|
1241
|
+
if (reported !== undefined) {
|
|
1242
|
+
// In-place mutator: its report overrides reference equality.
|
|
1243
|
+
// Rows beyond the previous row count cannot be "unchanged".
|
|
1244
|
+
stableCount = Number.isFinite(reported)
|
|
1245
|
+
? Math.max(0, Math.min(childLines.length, previous.rowCount, Math.trunc(reported)))
|
|
1246
|
+
: 0;
|
|
1247
|
+
} else if (previous.lines === childLines) {
|
|
1248
|
+
stableCount = childLines.length;
|
|
1249
|
+
}
|
|
1250
|
+
stableRows += stableCount;
|
|
1251
|
+
// The chain survives only a fully stable segment: identical rows
|
|
1252
|
+
// AND identical row count (a grown/shrunk segment shifts every
|
|
1253
|
+
// row below it).
|
|
1254
|
+
if (stableCount < childLines.length || previous.rowCount !== childLines.length) chainStable = false;
|
|
1255
|
+
} else {
|
|
1256
|
+
chainStable = false;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
segments[index] = {
|
|
1260
|
+
component: child,
|
|
1261
|
+
lines: childLines,
|
|
1262
|
+
start: offset,
|
|
1263
|
+
rowCount: childLines.length,
|
|
1264
|
+
liveLocalStart,
|
|
1265
|
+
commitLocalEnd,
|
|
1266
|
+
snapshotLocalEnd,
|
|
1267
|
+
};
|
|
1268
|
+
offset += childLines.length;
|
|
1269
|
+
}
|
|
1270
|
+
this.#frameSegments = segments;
|
|
1271
|
+
|
|
1272
|
+
const frame = this.#composedFrame;
|
|
1273
|
+
// Defensive clamp: stable rows can never exceed what the previous
|
|
1274
|
+
// compose actually materialized (only reachable if a child render threw
|
|
1275
|
+
// mid-compose on the previous frame).
|
|
1276
|
+
if (stableRows > frame.length) stableRows = frame.length;
|
|
1277
|
+
if (stableRows !== offset || frame.length !== offset) {
|
|
1278
|
+
// Re-ingest every row at/after the stable prefix: truncate, strip
|
|
1279
|
+
// cursor markers, record their positions.
|
|
1280
|
+
frame.length = stableRows;
|
|
1281
|
+
this.#pruneFrameCursorMarkers(stableRows);
|
|
1282
|
+
for (const segment of segments) {
|
|
1283
|
+
const lines = segment.lines;
|
|
1284
|
+
const from = segment.start >= stableRows ? 0 : stableRows - segment.start;
|
|
1285
|
+
for (let i = from; i < lines.length; i++) this.#ingestFrameRow(lines[i]!);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
this.#renderStablePrefixRows = stableRows;
|
|
1289
|
+
this.#preparedValidRows = Math.min(this.#preparedValidRows, stableRows);
|
|
1290
|
+
return frame;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/** Drop cached cursor markers at/after `fromRow` (those rows re-ingest). */
|
|
1294
|
+
#pruneFrameCursorMarkers(fromRow: number): void {
|
|
1295
|
+
const markers = this.#frameCursorMarkers;
|
|
1296
|
+
let keep = markers.length;
|
|
1297
|
+
while (keep > 0 && markers[keep - 1]!.row >= fromRow) keep--;
|
|
1298
|
+
markers.length = keep;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* Append one row to the composed frame, stripping CURSOR_MARKER occurrences
|
|
1303
|
+
* (internal sentinels that must never reach the terminal, the committed
|
|
1304
|
+
* prefix, or the resync audit) and recording the first marker's position.
|
|
1305
|
+
*/
|
|
1306
|
+
#ingestFrameRow(line: string): void {
|
|
1307
|
+
let markerIndex = line.indexOf(CURSOR_MARKER);
|
|
1308
|
+
if (markerIndex === -1) {
|
|
1309
|
+
this.#composedFrame.push(line);
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
this.#frameCursorMarkers.push({
|
|
1313
|
+
row: this.#composedFrame.length,
|
|
1314
|
+
col: visibleWidth(line.slice(0, markerIndex)),
|
|
1315
|
+
});
|
|
1316
|
+
let stripped = line;
|
|
1317
|
+
while (markerIndex !== -1) {
|
|
1318
|
+
stripped = stripped.slice(0, markerIndex) + stripped.slice(markerIndex + CURSOR_MARKER.length);
|
|
1319
|
+
markerIndex = stripped.indexOf(CURSOR_MARKER, markerIndex);
|
|
1320
|
+
}
|
|
1321
|
+
this.#composedFrame.push(stripped);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
#syncTerminalCursorMode(component: Component | null): void {
|
|
1325
|
+
if (isFocusable(component)) {
|
|
1326
|
+
component.setUseTerminalCursor?.(this.#showHardwareCursor);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
get fullRedraws(): number {
|
|
1331
|
+
return this.#fullRedrawCount;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* Transient viewport-only paints emitted by the non-multiplexer resize fast
|
|
1336
|
+
* path. These never touch native scrollback or the commit ledger, so they
|
|
1337
|
+
* are counted apart from {@link fullRedraws}.
|
|
1338
|
+
*/
|
|
1339
|
+
get resizeViewportPaints(): number {
|
|
1340
|
+
return this.#resizeViewportPaintCount;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/** Whether a non-multiplexer resize drag is currently in flight. */
|
|
1344
|
+
get resizeViewportActive(): boolean {
|
|
1345
|
+
return this.#resizeViewportActive;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/** Shared budget that caps how many inline images render as live graphics. */
|
|
1349
|
+
get imageBudget(): ImageBudget {
|
|
1350
|
+
return this.#imageBudget;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/**
|
|
1354
|
+
* Set how many inline images stay live graphics before older ones fall back
|
|
1355
|
+
* to text (`0` disables the cap). Older images are hidden via a graphics purge
|
|
1356
|
+
* plus a full redraw on the frame after a new image exceeds the cap.
|
|
1357
|
+
*/
|
|
1358
|
+
setMaxInlineImages(cap: number): void {
|
|
1359
|
+
this.#imageBudget.setCap(cap);
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
getShowHardwareCursor(): boolean {
|
|
1363
|
+
return this.#showHardwareCursor;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
setShowHardwareCursor(enabled: boolean): void {
|
|
1367
|
+
if (this.#showHardwareCursor === enabled) return;
|
|
1368
|
+
this.#showHardwareCursor = enabled;
|
|
1369
|
+
this.#syncTerminalCursorMode(this.#focusedComponent);
|
|
1370
|
+
if (!enabled) {
|
|
1371
|
+
this.terminal.hideCursor();
|
|
1372
|
+
this.#recordHardwareCursorHidden();
|
|
1373
|
+
}
|
|
1374
|
+
this.requestRender();
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* Whether DEC 2026 synchronized-output wrappers are currently emitted around
|
|
1379
|
+
* paints. Starts from conservative terminal/env detection and is reconciled at
|
|
1380
|
+
* runtime against the terminal's DECRQM mode-2026 report — enabled on a
|
|
1381
|
+
* positive report, disabled on a negative one.
|
|
1382
|
+
*/
|
|
1383
|
+
get synchronizedOutput(): boolean {
|
|
1384
|
+
return this.#synchronizedOutputEnabled;
|
|
1385
|
+
}
|
|
1386
|
+
#deccaraFillsEnabled(): boolean {
|
|
1387
|
+
// DECCARA fill rectangles arrive after shortened row text; synchronized
|
|
1388
|
+
// output hides that intermediate default-background state from users.
|
|
1389
|
+
return TERMINAL.deccara && this.#synchronizedOutputEnabled;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
setFocus(component: Component | null): void {
|
|
1393
|
+
const topVisibleOverlay = this.#getTopmostVisibleOverlay();
|
|
1394
|
+
if (topVisibleOverlay && !isOverlayFocusTarget(topVisibleOverlay.component, component)) {
|
|
1395
|
+
const currentFocus = this.#focusedComponent;
|
|
1396
|
+
component = isOverlayFocusTarget(topVisibleOverlay.component, currentFocus)
|
|
1397
|
+
? currentFocus
|
|
1398
|
+
: topVisibleOverlay.component;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
const previousFocusedComponent = this.#focusedComponent;
|
|
1402
|
+
// Clear focused flag on old component
|
|
1403
|
+
if (isFocusable(previousFocusedComponent)) {
|
|
1404
|
+
previousFocusedComponent.focused = false;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
this.#focusedComponent = component;
|
|
1408
|
+
|
|
1409
|
+
// Set focused flag on new component and keep its software/hardware cursor
|
|
1410
|
+
// rendering mode aligned with TUI's single cursor-visibility preference.
|
|
1411
|
+
if (isFocusable(component)) {
|
|
1412
|
+
component.focused = true;
|
|
1413
|
+
this.#syncTerminalCursorMode(component);
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
/** Component currently receiving keyboard input, if any. */
|
|
1418
|
+
getFocused(): Component | null {
|
|
1419
|
+
return this.#focusedComponent;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/**
|
|
1423
|
+
* Show an overlay component with configurable positioning and sizing.
|
|
1424
|
+
* Returns a handle to control the overlay's visibility.
|
|
1425
|
+
*/
|
|
1426
|
+
showOverlay(component: Component, options?: OverlayOptions): OverlayHandle {
|
|
1427
|
+
component.setIgnoreTight?.(true);
|
|
1428
|
+
const entry = { component, options, preFocus: this.#focusedComponent, hidden: false };
|
|
1429
|
+
this.overlayStack.push(entry);
|
|
1430
|
+
// Only focus if overlay is actually visible
|
|
1431
|
+
if (this.#isOverlayVisible(entry)) {
|
|
1432
|
+
this.setFocus(component);
|
|
1433
|
+
}
|
|
1434
|
+
this.terminal.hideCursor();
|
|
1435
|
+
this.#recordHardwareCursorHidden();
|
|
1436
|
+
this.requestRender();
|
|
1437
|
+
|
|
1438
|
+
// Return handle for controlling this overlay
|
|
1439
|
+
return {
|
|
1440
|
+
hide: () => {
|
|
1441
|
+
const index = this.overlayStack.indexOf(entry);
|
|
1442
|
+
if (index !== -1) {
|
|
1443
|
+
this.overlayStack.splice(index, 1);
|
|
1444
|
+
// Restore focus if this overlay or one of its owned targets had focus
|
|
1445
|
+
if (isOverlayFocusTarget(component, this.#focusedComponent)) {
|
|
1446
|
+
const topVisible = this.#getTopmostVisibleOverlay();
|
|
1447
|
+
this.setFocus(topVisible?.component ?? entry.preFocus);
|
|
1448
|
+
}
|
|
1449
|
+
if (this.overlayStack.length === 0) {
|
|
1450
|
+
this.terminal.hideCursor();
|
|
1451
|
+
this.#recordHardwareCursorHidden();
|
|
1452
|
+
}
|
|
1453
|
+
this.requestRender();
|
|
1454
|
+
}
|
|
1455
|
+
},
|
|
1456
|
+
setHidden: (hidden: boolean) => {
|
|
1457
|
+
if (entry.hidden === hidden) return;
|
|
1458
|
+
entry.hidden = hidden;
|
|
1459
|
+
// Update focus when hiding/showing
|
|
1460
|
+
if (hidden) {
|
|
1461
|
+
// If this overlay or one of its owned targets had focus, move focus to next visible or preFocus
|
|
1462
|
+
if (isOverlayFocusTarget(component, this.#focusedComponent)) {
|
|
1463
|
+
const topVisible = this.#getTopmostVisibleOverlay();
|
|
1464
|
+
this.setFocus(topVisible?.component ?? entry.preFocus);
|
|
1465
|
+
}
|
|
1466
|
+
} else {
|
|
1467
|
+
// Restore focus to this overlay when showing (if it's actually visible)
|
|
1468
|
+
if (this.#isOverlayVisible(entry)) {
|
|
1469
|
+
this.setFocus(component);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
this.requestRender();
|
|
1473
|
+
},
|
|
1474
|
+
isHidden: () => entry.hidden,
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/** Hide the topmost overlay and restore previous focus. */
|
|
1479
|
+
hideOverlay(): void {
|
|
1480
|
+
const overlay = this.overlayStack.pop();
|
|
1481
|
+
if (!overlay) return;
|
|
1482
|
+
// Find topmost visible overlay, or fall back to preFocus
|
|
1483
|
+
const topVisible = this.#getTopmostVisibleOverlay();
|
|
1484
|
+
this.setFocus(topVisible?.component ?? overlay.preFocus);
|
|
1485
|
+
if (this.overlayStack.length === 0) {
|
|
1486
|
+
this.terminal.hideCursor();
|
|
1487
|
+
this.#recordHardwareCursorHidden();
|
|
1488
|
+
}
|
|
1489
|
+
this.requestRender();
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
/** Check if there are any visible overlays */
|
|
1493
|
+
hasOverlay(): boolean {
|
|
1494
|
+
return this.overlayStack.some(o => this.#isOverlayVisible(o));
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
/** Check if an overlay entry is currently visible */
|
|
1498
|
+
#isOverlayVisible(entry: (typeof this.overlayStack)[number]): boolean {
|
|
1499
|
+
if (entry.hidden) return false;
|
|
1500
|
+
if (entry.options?.visible) {
|
|
1501
|
+
return entry.options.visible(this.terminal.columns, this.terminal.rows);
|
|
1502
|
+
}
|
|
1503
|
+
return true;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
/** Find the topmost visible overlay, if any */
|
|
1507
|
+
#getTopmostVisibleOverlay(): (typeof this.overlayStack)[number] | undefined {
|
|
1508
|
+
for (let i = this.overlayStack.length - 1; i >= 0; i--) {
|
|
1509
|
+
if (this.#isOverlayVisible(this.overlayStack[i])) {
|
|
1510
|
+
return this.overlayStack[i];
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
return undefined;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
override invalidate(): void {
|
|
1517
|
+
super.invalidate();
|
|
1518
|
+
for (const overlay of this.overlayStack) overlay.component.invalidate?.();
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
start(options?: TUIStartOptions): void {
|
|
1522
|
+
this.#stopped = false;
|
|
1523
|
+
this.#watchdog.start();
|
|
1524
|
+
this.#ghosttyInitialImageDelayDone = false;
|
|
1525
|
+
this.#ghosttyImageReadyAtMs = this.#renderScheduler.now() + TUI.#GHOSTTY_INITIAL_IMAGE_DELAY_MS;
|
|
1526
|
+
// A DECRQM report for mode 2026 is authoritative: enable synchronized
|
|
1527
|
+
// output when the terminal reports support (upgrading conservatively
|
|
1528
|
+
// defaulted-off hosts like zellij/tmux-master/foot) and disable it when
|
|
1529
|
+
// the terminal reports it unsupported. An explicit user opt-out/force
|
|
1530
|
+
// (resolved at construction) still wins, so skip the probe in that case.
|
|
1531
|
+
this.terminal.onPrivateModeReport?.((mode, supported) => {
|
|
1532
|
+
if (mode !== 2026) return;
|
|
1533
|
+
if (synchronizedOutputUserOverride() !== null) return;
|
|
1534
|
+
this.#setSynchronizedOutput(supported);
|
|
1535
|
+
});
|
|
1536
|
+
this.terminal.start(
|
|
1537
|
+
data => this.#handleInput(data),
|
|
1538
|
+
() => {
|
|
1539
|
+
// Real terminals deliver SIGWINCH (and the equivalent ConPTY
|
|
1540
|
+
// notification) atomically with the new `process.stdout` geometry, so
|
|
1541
|
+
// a forced render must fire immediately: it clears and replays at the
|
|
1542
|
+
// fresh size before the terminal's reflow settles into a state a
|
|
1543
|
+
// throttled frame would race. Multiplexer panes (tmux/screen/zellij)
|
|
1544
|
+
// do not give that guarantee. The host receives SIGWINCH while the
|
|
1545
|
+
// multiplexer is still mid-reflow — it has not finished repainting
|
|
1546
|
+
// the pane buffer at the new size — and a drag-resize or pane-close
|
|
1547
|
+
// animation fires several events in flight. Forcing a render on each
|
|
1548
|
+
// event races those mid-reflow paints: the multiplexer's catch-up
|
|
1549
|
+
// paint then partially overwrites the TUI output, which the user sees
|
|
1550
|
+
// as a viewport flash or blank screen before the next throttled
|
|
1551
|
+
// frame arrives (issue #2088). `#armMultiplexerResizeTimer` coalesces
|
|
1552
|
+
// SIGWINCHes (and any forced repaints arriving during the settle
|
|
1553
|
+
// window) into a single render once the pane is quiet —
|
|
1554
|
+
// `#resizeEventPending` is set first so the eventual render still
|
|
1555
|
+
// classifies as a resize.
|
|
1556
|
+
this.#resizeEventPending = true;
|
|
1557
|
+
if (!resizeRepaintsInPlace()) {
|
|
1558
|
+
// Enter the viewport fast path and (re)arm the settle timer, then
|
|
1559
|
+
// request the cheap viewport-only paint. The authoritative full
|
|
1560
|
+
// replay fires from the settle timer once the drag goes quiet.
|
|
1561
|
+
this.#beginResizeViewport();
|
|
1562
|
+
this.#requestResizeViewportPaint();
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
this.#armMultiplexerResizeTimer(false);
|
|
1566
|
+
},
|
|
1567
|
+
);
|
|
1568
|
+
for (const listener of this.#startListeners) {
|
|
1569
|
+
try {
|
|
1570
|
+
listener();
|
|
1571
|
+
} catch {
|
|
1572
|
+
// Startup listeners are feature hooks; one broken hook must not prevent rendering.
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
this.terminal.hideCursor();
|
|
1576
|
+
this.#recordHardwareCursorHidden();
|
|
1577
|
+
this.#querySixelSupport();
|
|
1578
|
+
this.#queryCellSize();
|
|
1579
|
+
this.requestRender(true, { clearScrollback: options?.clearScrollback === true });
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
addStartListener(listener: StartListener): () => void {
|
|
1583
|
+
this.#startListeners.add(listener);
|
|
1584
|
+
return () => {
|
|
1585
|
+
this.#startListeners.delete(listener);
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
addInputListener(listener: InputListener): () => void {
|
|
1590
|
+
this.#inputListeners.add(listener);
|
|
1591
|
+
return () => {
|
|
1592
|
+
this.#inputListeners.delete(listener);
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
removeInputListener(listener: InputListener): void {
|
|
1597
|
+
this.#inputListeners.delete(listener);
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
#querySixelSupport(): void {
|
|
1601
|
+
if (TERMINAL.imageProtocol) return;
|
|
1602
|
+
if (process.platform !== "win32") return;
|
|
1603
|
+
if (!Bun.env.WT_SESSION) return;
|
|
1604
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return;
|
|
1605
|
+
|
|
1606
|
+
this.#clearSixelProbeState();
|
|
1607
|
+
this.#sixelProbePendingDa = true;
|
|
1608
|
+
this.#sixelProbePendingGraphics = true;
|
|
1609
|
+
this.#sixelProbeUnsubscribe = this.addInputListener(data => this.#handleSixelProbeInput(data));
|
|
1610
|
+
this.terminal.write("\x1b[c");
|
|
1611
|
+
this.terminal.write("\x1b[?2;1;0S");
|
|
1612
|
+
this.#sixelProbeTimeout = setTimeout(() => {
|
|
1613
|
+
this.#finishSixelProbe(false);
|
|
1614
|
+
}, 250);
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
#handleSixelProbeInput(data: string): InputListenerResult {
|
|
1618
|
+
if (!this.#sixelProbePendingDa && !this.#sixelProbePendingGraphics) {
|
|
1619
|
+
return undefined;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
this.#sixelProbeBuffer += data;
|
|
1623
|
+
let passthrough = "";
|
|
1624
|
+
let probeOutcome: boolean | null = null;
|
|
1625
|
+
|
|
1626
|
+
while (this.#sixelProbeBuffer.length > 0) {
|
|
1627
|
+
const daMatch = this.#sixelProbeBuffer.match(/\x1b\[\?([0-9;]+)c/u);
|
|
1628
|
+
const graphicsMatch = this.#sixelProbeBuffer.match(/\x1b\[\?2;(\d+);([0-9;]+)S/u);
|
|
1629
|
+
|
|
1630
|
+
if (!daMatch && !graphicsMatch) break;
|
|
1631
|
+
|
|
1632
|
+
const daIndex = daMatch?.index ?? Number.POSITIVE_INFINITY;
|
|
1633
|
+
const graphicsIndex = graphicsMatch?.index ?? Number.POSITIVE_INFINITY;
|
|
1634
|
+
const useDa = daIndex <= graphicsIndex;
|
|
1635
|
+
const match = useDa ? daMatch : graphicsMatch;
|
|
1636
|
+
if (!match || match.index === undefined) break;
|
|
1637
|
+
|
|
1638
|
+
passthrough += this.#sixelProbeBuffer.slice(0, match.index);
|
|
1639
|
+
this.#sixelProbeBuffer = this.#sixelProbeBuffer.slice(match.index + match[0].length);
|
|
1640
|
+
|
|
1641
|
+
if (useDa && this.#sixelProbePendingDa) {
|
|
1642
|
+
this.#sixelProbePendingDa = false;
|
|
1643
|
+
const attributes = (match[1] ?? "")
|
|
1644
|
+
.split(";")
|
|
1645
|
+
.map(value => Number.parseInt(value, 10))
|
|
1646
|
+
.filter(value => Number.isFinite(value));
|
|
1647
|
+
const hasSixelAttribute = attributes.includes(4);
|
|
1648
|
+
if (hasSixelAttribute) {
|
|
1649
|
+
this.#sixelProbePendingGraphics = false;
|
|
1650
|
+
probeOutcome = true;
|
|
1651
|
+
} else if (!this.#sixelProbePendingGraphics) {
|
|
1652
|
+
probeOutcome = false;
|
|
1653
|
+
}
|
|
1654
|
+
} else if (!useDa && this.#sixelProbePendingGraphics) {
|
|
1655
|
+
this.#sixelProbePendingGraphics = false;
|
|
1656
|
+
const status = Number.parseInt(match[1] ?? "", 10);
|
|
1657
|
+
const supportsSixel = !Number.isNaN(status) && status !== 0;
|
|
1658
|
+
if (supportsSixel) {
|
|
1659
|
+
this.#sixelProbePendingDa = false;
|
|
1660
|
+
probeOutcome = true;
|
|
1661
|
+
} else if (!this.#sixelProbePendingDa) {
|
|
1662
|
+
probeOutcome = false;
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
if (this.#sixelProbePendingDa || this.#sixelProbePendingGraphics) {
|
|
1668
|
+
const partialStart = this.#getSixelProbePartialStart(this.#sixelProbeBuffer);
|
|
1669
|
+
if (partialStart >= 0) {
|
|
1670
|
+
passthrough += this.#sixelProbeBuffer.slice(0, partialStart);
|
|
1671
|
+
this.#sixelProbeBuffer = this.#sixelProbeBuffer.slice(partialStart);
|
|
1672
|
+
} else {
|
|
1673
|
+
passthrough += this.#sixelProbeBuffer;
|
|
1674
|
+
this.#sixelProbeBuffer = "";
|
|
1675
|
+
}
|
|
1676
|
+
} else {
|
|
1677
|
+
passthrough += this.#sixelProbeBuffer;
|
|
1678
|
+
this.#sixelProbeBuffer = "";
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
if (probeOutcome !== null) {
|
|
1682
|
+
this.#finishSixelProbe(probeOutcome);
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
if (passthrough.length === 0) {
|
|
1686
|
+
return { consume: true };
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
return { data: passthrough };
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
#getSixelProbePartialStart(buffer: string): number {
|
|
1693
|
+
const lastEsc = buffer.lastIndexOf("\x1b");
|
|
1694
|
+
if (lastEsc < 0) return -1;
|
|
1695
|
+
const tail = buffer.slice(lastEsc);
|
|
1696
|
+
if (/^\x1b\[\?[0-9;]*$/u.test(tail)) {
|
|
1697
|
+
return lastEsc;
|
|
1698
|
+
}
|
|
1699
|
+
return -1;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
#clearSixelProbeState(): void {
|
|
1703
|
+
if (this.#sixelProbeTimeout) {
|
|
1704
|
+
clearTimeout(this.#sixelProbeTimeout);
|
|
1705
|
+
this.#sixelProbeTimeout = undefined;
|
|
1706
|
+
}
|
|
1707
|
+
if (this.#sixelProbeUnsubscribe) {
|
|
1708
|
+
this.#sixelProbeUnsubscribe();
|
|
1709
|
+
this.#sixelProbeUnsubscribe = undefined;
|
|
1710
|
+
}
|
|
1711
|
+
this.#sixelProbePendingDa = false;
|
|
1712
|
+
this.#sixelProbePendingGraphics = false;
|
|
1713
|
+
this.#sixelProbeBuffer = "";
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
#finishSixelProbe(supported: boolean): void {
|
|
1717
|
+
this.#clearSixelProbeState();
|
|
1718
|
+
if (!supported || TERMINAL.imageProtocol) return;
|
|
1719
|
+
|
|
1720
|
+
setTerminalImageProtocol(ImageProtocol.Sixel);
|
|
1721
|
+
this.#queryCellSize();
|
|
1722
|
+
this.invalidate();
|
|
1723
|
+
this.requestRender(true);
|
|
1724
|
+
}
|
|
1725
|
+
#queryCellSize(): void {
|
|
1726
|
+
// Only query if terminal supports images (cell size is only used for image rendering)
|
|
1727
|
+
if (!TERMINAL.imageProtocol) {
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
// Query terminal for cell size in pixels: CSI 16 t
|
|
1731
|
+
// Response format: CSI 6 ; height ; width t
|
|
1732
|
+
this.terminal.write("\x1b[16t");
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
/**
|
|
1736
|
+
* Toggle synchronized-output (DEC 2026) wrappers on paint/cursor writes and
|
|
1737
|
+
* recompute the cached begin/end sequences. Driven by the terminal's DECRQM
|
|
1738
|
+
* mode-2026 report (#1765 covers the static env opt-out).
|
|
1739
|
+
*/
|
|
1740
|
+
#setSynchronizedOutput(enabled: boolean): void {
|
|
1741
|
+
if (this.#synchronizedOutputEnabled === enabled) return;
|
|
1742
|
+
this.#synchronizedOutputEnabled = enabled;
|
|
1743
|
+
this.#paintBeginSequence = enabled ? PAINT_BEGIN : PAINT_BEGIN_NO_SYNC;
|
|
1744
|
+
this.#paintEndSequence = enabled ? PAINT_END : PAINT_END_NO_SYNC;
|
|
1745
|
+
this.#cursorBeginSequence = enabled ? CURSOR_BEGIN : CURSOR_BEGIN_NO_SYNC;
|
|
1746
|
+
this.#cursorEndSequence = enabled ? CURSOR_END : CURSOR_END_NO_SYNC;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
stop(): void {
|
|
1750
|
+
// Leave the alt buffer first so the teardown cursor math below runs against
|
|
1751
|
+
// the restored normal screen (which #previousLines still describes).
|
|
1752
|
+
if (this.#resizeAltActive) {
|
|
1753
|
+
this.terminal.write(this.#leaveResizeAltSequence());
|
|
1754
|
+
}
|
|
1755
|
+
if (this.#altActive) {
|
|
1756
|
+
const enhancementExit = this.#keyboardEnhancementExit();
|
|
1757
|
+
this.terminal.write(`${MOUSE_TRACKING_OFF}${enhancementExit}\x1b[?1049l`);
|
|
1758
|
+
setAltScreenActive(false);
|
|
1759
|
+
this.#altActive = false;
|
|
1760
|
+
this.#altPreviousLines = [];
|
|
1761
|
+
}
|
|
1762
|
+
if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
|
|
1763
|
+
for (const id of this.#imageBudget.takeAllTransmittedIds()) {
|
|
1764
|
+
this.terminal.write(encodeKittyDeleteImage(id));
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
this.#clearSixelProbeState();
|
|
1768
|
+
this.#stopped = true;
|
|
1769
|
+
this.#watchdog.stop();
|
|
1770
|
+
if (this.#renderTimer) {
|
|
1771
|
+
this.#renderTimer.cancel();
|
|
1772
|
+
this.#renderTimer = undefined;
|
|
1773
|
+
}
|
|
1774
|
+
if (this.#ghosttyInitialImageDelayTimer) {
|
|
1775
|
+
this.#ghosttyInitialImageDelayTimer.cancel();
|
|
1776
|
+
this.#ghosttyInitialImageDelayTimer = undefined;
|
|
1777
|
+
}
|
|
1778
|
+
if (this.#multiplexerResizeTimer) {
|
|
1779
|
+
this.#multiplexerResizeTimer.cancel();
|
|
1780
|
+
this.#multiplexerResizeTimer = undefined;
|
|
1781
|
+
}
|
|
1782
|
+
if (this.#resizeViewportSettleTimer) {
|
|
1783
|
+
this.#resizeViewportSettleTimer.cancel();
|
|
1784
|
+
this.#resizeViewportSettleTimer = undefined;
|
|
1785
|
+
}
|
|
1786
|
+
this.#resizeViewportActive = false;
|
|
1787
|
+
this.#clearPostFullPaintSettle();
|
|
1788
|
+
this.#deferredForcedClearScrollback = false;
|
|
1789
|
+
// Place the parent shell on the first line after the rendered content. When
|
|
1790
|
+
// that line is still inside the viewport, moving there and writing `\r` is
|
|
1791
|
+
// enough; emitting `\r\n` would create an extra blank row. If the content
|
|
1792
|
+
// already reaches the viewport bottom, scroll exactly once so the prompt
|
|
1793
|
+
// lands directly below the last visible TUI row.
|
|
1794
|
+
if (this.#previousFrameLength > 0) {
|
|
1795
|
+
const targetRow = this.#previousFrameLength;
|
|
1796
|
+
const viewportBottom = this.#windowTopRow + this.terminal.rows - 1;
|
|
1797
|
+
const clampedCursorRow = Math.max(this.#windowTopRow, Math.min(this.#hardwareCursorRow, viewportBottom));
|
|
1798
|
+
const moveTargetRow = Math.min(targetRow, viewportBottom);
|
|
1799
|
+
const lineDiff = moveTargetRow - clampedCursorRow;
|
|
1800
|
+
if (lineDiff > 0) {
|
|
1801
|
+
this.terminal.write(`\x1b[${lineDiff}B`);
|
|
1802
|
+
} else if (lineDiff < 0) {
|
|
1803
|
+
this.terminal.write(`\x1b[${-lineDiff}A`);
|
|
1804
|
+
}
|
|
1805
|
+
this.terminal.write(targetRow <= viewportBottom ? "\r" : "\r\n");
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
this.terminal.showCursor();
|
|
1809
|
+
this.#forgetHardwareCursorState();
|
|
1810
|
+
this.terminal.stop();
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
/**
|
|
1814
|
+
* Force an immediate full replay of the current frame, including native
|
|
1815
|
+
* scrollback. This is the keyboard-accessible equivalent of the resize reset:
|
|
1816
|
+
* no queued diff frame or terminal scrollback probe can downgrade it to a
|
|
1817
|
+
* viewport-only repaint.
|
|
1818
|
+
*
|
|
1819
|
+
* Invalidates every component first so the replay reflects current state. A
|
|
1820
|
+
* geometry-driven reset thaws frozen scrollback snapshots implicitly (the new
|
|
1821
|
+
* width misses every cached snapshot), but a same-width reset would otherwise
|
|
1822
|
+
* replay stale snapshots — leaving host-frozen blocks (e.g. a transcript whose
|
|
1823
|
+
* committed rows are immutable on ED3-risk terminals) showing pre-mutation
|
|
1824
|
+
* content. Invalidation is the generic signal those containers use to retire
|
|
1825
|
+
* their snapshots, which is exactly what a user-driven display reset wants.
|
|
1826
|
+
*/
|
|
1827
|
+
resetDisplay(): void {
|
|
1828
|
+
if (this.#stopped) return;
|
|
1829
|
+
this.invalidate();
|
|
1830
|
+
// A reset that lands inside a tmux/screen/zellij resize burst would
|
|
1831
|
+
// paint mid-reflow and re-introduce the flash race (issue #2088).
|
|
1832
|
+
// Fold it into the in-flight debounce instead; the settled paint runs
|
|
1833
|
+
// the same `#prepareForcedRender(!isMultiplexerSession())` path via
|
|
1834
|
+
// `requestRender(true)`, so the clear-scrollback intent is preserved.
|
|
1835
|
+
if (this.#multiplexerResizeTimer) {
|
|
1836
|
+
this.#armMultiplexerResizeTimer(!isMultiplexerSession());
|
|
1837
|
+
return;
|
|
1838
|
+
}
|
|
1839
|
+
this.#prepareForcedRender(!isMultiplexerSession());
|
|
1840
|
+
this.#resizeEventPending = true;
|
|
1841
|
+
this.#renderRequested = false;
|
|
1842
|
+
this.#executeRender();
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
requestRender(force = false, options?: RenderRequestOptions): void {
|
|
1846
|
+
// Any non-component-scoped request makes the pending frame a full one.
|
|
1847
|
+
this.#pendingRenderComponentsOnly = false;
|
|
1848
|
+
if (force) {
|
|
1849
|
+
// Forced repaints landing inside the multiplexer resize debounce
|
|
1850
|
+
// (e.g. `#finishSixelProbe`, image-budget eviction, a programmatic
|
|
1851
|
+
// `requestRender(true)`) would paint into a still-reflowing pane
|
|
1852
|
+
// and reintroduce the flash race. Fold them into the in-flight
|
|
1853
|
+
// debounce while preserving the caller's `clearScrollback` intent
|
|
1854
|
+
// for the settled paint. The timer's own callback clears
|
|
1855
|
+
// `#multiplexerResizeTimer` before re-entering `requestRender(true)`,
|
|
1856
|
+
// so this guard only catches external callers — the deferred render
|
|
1857
|
+
// itself proceeds straight to `#prepareForcedRender`.
|
|
1858
|
+
if (this.#multiplexerResizeTimer) {
|
|
1859
|
+
this.#armMultiplexerResizeTimer(options?.clearScrollback === true);
|
|
1860
|
+
return;
|
|
1861
|
+
}
|
|
1862
|
+
// A forced render preempts the post-full-paint ConPTY settle: it owns
|
|
1863
|
+
// the next paint and is going to redraw the buffer anyway, so the
|
|
1864
|
+
// trailing coalesced render queued by the settle would only race it.
|
|
1865
|
+
this.#clearPostFullPaintSettle();
|
|
1866
|
+
this.#prepareForcedRender(options?.clearScrollback === true);
|
|
1867
|
+
this.#renderRequested = true;
|
|
1868
|
+
this.#renderScheduler.scheduleImmediate(() => {
|
|
1869
|
+
if (this.#stopped || !this.#renderRequested) {
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
this.#renderRequested = false;
|
|
1873
|
+
this.#executeRender();
|
|
1874
|
+
});
|
|
1875
|
+
return;
|
|
1876
|
+
}
|
|
1877
|
+
this.#requestOrdinaryRender();
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
/**
|
|
1881
|
+
* Schedule a render on behalf of `component` after a self-contained change
|
|
1882
|
+
* (spinner frame, blink) that cannot have affected any other component.
|
|
1883
|
+
*
|
|
1884
|
+
* When every request since the last frame is component-scoped and the
|
|
1885
|
+
* frame is otherwise quiet — no resize or geometry change, no overlays, no
|
|
1886
|
+
* live inline images, no forced repaint, unchanged root child list — the
|
|
1887
|
+
* next compose re-renders only the root subtrees containing the requesting
|
|
1888
|
+
* components and reuses the previous frame's rows (and seam reports) for
|
|
1889
|
+
* every other root child, skipping the full component-tree walk that makes
|
|
1890
|
+
* long transcripts expensive to repaint at animation rate. Any concurrent
|
|
1891
|
+
* full request or unsafe condition downgrades the frame to a normal full
|
|
1892
|
+
* compose, so this is never less correct than `requestRender()` — only
|
|
1893
|
+
* cheaper.
|
|
1894
|
+
*/
|
|
1895
|
+
requestComponentRender(component: Component): void {
|
|
1896
|
+
if (this.#stopped) return;
|
|
1897
|
+
// Start a component-scoped accumulation only when nothing else is in
|
|
1898
|
+
// flight (a pending throttled request or a deferred ConPTY settle
|
|
1899
|
+
// replay may carry full-render intent that must not be narrowed).
|
|
1900
|
+
if (!this.#renderRequested && this.#postFullPaintSettleTimer === undefined) {
|
|
1901
|
+
this.#pendingRenderComponentsOnly = true;
|
|
1902
|
+
}
|
|
1903
|
+
this.#componentRenderTargets.add(component);
|
|
1904
|
+
this.#requestOrdinaryRender();
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
/** Ordinary (non-forced) scheduling shared by full and component-scoped requests. */
|
|
1908
|
+
#requestOrdinaryRender(): void {
|
|
1909
|
+
// Coalesce non-forced renders inside the post-full-paint ConPTY settle
|
|
1910
|
+
// window into one trailing render. Spinner/blink/streaming components
|
|
1911
|
+
// otherwise fire `requestRender(false)` at 30 Hz while the host is still
|
|
1912
|
+
// catching up with the previous big paint, and each follow-up viewport
|
|
1913
|
+
// repaint nudges Windows Terminal's viewport tracker further off the
|
|
1914
|
+
// last row (see #2095).
|
|
1915
|
+
if (this.#postFullPaintSettleUntilMs > 0) {
|
|
1916
|
+
const now = this.#renderScheduler.now();
|
|
1917
|
+
if (now < this.#postFullPaintSettleUntilMs) {
|
|
1918
|
+
if (this.#postFullPaintSettleTimer === undefined) {
|
|
1919
|
+
this.#postFullPaintSettleTimer = this.#renderScheduler.scheduleRender(() => {
|
|
1920
|
+
this.#postFullPaintSettleTimer = undefined;
|
|
1921
|
+
this.#postFullPaintSettleUntilMs = 0;
|
|
1922
|
+
if (this.#stopped) return;
|
|
1923
|
+
this.#requestOrdinaryRender();
|
|
1924
|
+
}, this.#postFullPaintSettleUntilMs - now);
|
|
1925
|
+
}
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1928
|
+
this.#postFullPaintSettleUntilMs = 0;
|
|
1929
|
+
}
|
|
1930
|
+
if (this.#renderRequested) return;
|
|
1931
|
+
this.#renderRequested = true;
|
|
1932
|
+
this.#renderScheduler.scheduleImmediate(() => this.#scheduleRender());
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
/**
|
|
1936
|
+
* Decide whether this frame may compose component-scoped, and resolve the
|
|
1937
|
+
* requested components to the root children that must re-render. Returns
|
|
1938
|
+
* null — full compose — whenever a global condition could invalidate rows
|
|
1939
|
+
* the partial compose would reuse, or when a requested component is not
|
|
1940
|
+
* reachable from the current root child list.
|
|
1941
|
+
*/
|
|
1942
|
+
#resolvePartialComposeRoots(width: number, height: number): Set<Component> | null {
|
|
1943
|
+
if (this.#componentRenderTargets.size === 0) return null;
|
|
1944
|
+
if (!this.#hasEverRendered || this.#resizeEventPending) return null;
|
|
1945
|
+
if (width !== this.#previousWidth || height !== this.#previousHeight || width !== this.#composeWidth) return null;
|
|
1946
|
+
if (this.#clearScrollbackOnNextRender || this.#forceViewportRepaintOnNextRender) return null;
|
|
1947
|
+
if (this.overlayStack.length > 0) return null;
|
|
1948
|
+
// The image budget audits display order across the whole frame; a
|
|
1949
|
+
// partial walk would under-count it. Engage only on image-free frames.
|
|
1950
|
+
if (!this.#imageBudget.quiescent) return null;
|
|
1951
|
+
// The root child list must match the segment ledger exactly — a
|
|
1952
|
+
// structural change shifts offsets under every reused segment.
|
|
1953
|
+
const children = this.children;
|
|
1954
|
+
const segments = this.#frameSegments;
|
|
1955
|
+
if (segments.length !== children.length) return null;
|
|
1956
|
+
for (let i = 0; i < children.length; i++) {
|
|
1957
|
+
if (segments[i]!.component !== children[i]) return null;
|
|
1958
|
+
}
|
|
1959
|
+
const roots = this.#partialComposeRootsScratch;
|
|
1960
|
+
roots.clear();
|
|
1961
|
+
for (const target of this.#componentRenderTargets) {
|
|
1962
|
+
const root = this.#resolveComponentRoot(target);
|
|
1963
|
+
if (root === null) return null;
|
|
1964
|
+
roots.add(root);
|
|
1965
|
+
}
|
|
1966
|
+
return roots;
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
/** Root child whose subtree contains `target`, memoized per component. */
|
|
1970
|
+
#resolveComponentRoot(target: Component): Component | null {
|
|
1971
|
+
const cached = this.#componentRootCache.get(target);
|
|
1972
|
+
if (cached !== undefined && this.children.includes(cached) && subtreeContains(cached, target)) {
|
|
1973
|
+
return cached;
|
|
1974
|
+
}
|
|
1975
|
+
for (const child of this.children) {
|
|
1976
|
+
if (subtreeContains(child, target)) {
|
|
1977
|
+
this.#componentRootCache.set(target, child);
|
|
1978
|
+
return child;
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
this.#componentRootCache.delete(target);
|
|
1982
|
+
return null;
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
/**
|
|
1986
|
+
* Arm or extend the multiplexer-resize debounce so a single forced render
|
|
1987
|
+
* fires once the pane is quiet. Called by the SIGWINCH callback on every
|
|
1988
|
+
* resize event, and by `requestRender(true)` / `resetDisplay()` when they
|
|
1989
|
+
* land inside an in-flight settle window. Each call cancels the prior
|
|
1990
|
+
* timer, supersedes any queued throttled render (otherwise it would race
|
|
1991
|
+
* tmux's mid-reflow paint), and OR's the caller's `clearScrollback`
|
|
1992
|
+
* intent into `#deferredForcedClearScrollback` — the timer's callback
|
|
1993
|
+
* consumes that flag exactly once when it re-enters `requestRender(true)`.
|
|
1994
|
+
*/
|
|
1995
|
+
#armMultiplexerResizeTimer(clearScrollback: boolean): void {
|
|
1996
|
+
this.#deferredForcedClearScrollback ||= clearScrollback;
|
|
1997
|
+
if (this.#renderTimer) {
|
|
1998
|
+
this.#renderTimer.cancel();
|
|
1999
|
+
this.#renderTimer = undefined;
|
|
2000
|
+
}
|
|
2001
|
+
this.#renderRequested = false;
|
|
2002
|
+
if (this.#multiplexerResizeTimer) {
|
|
2003
|
+
this.#multiplexerResizeTimer.cancel();
|
|
2004
|
+
}
|
|
2005
|
+
this.#multiplexerResizeTimer = this.#renderScheduler.scheduleRender(() => {
|
|
2006
|
+
this.#multiplexerResizeTimer = undefined;
|
|
2007
|
+
if (this.#stopped) {
|
|
2008
|
+
this.#deferredForcedClearScrollback = false;
|
|
2009
|
+
return;
|
|
2010
|
+
}
|
|
2011
|
+
const deferredClearScrollback = this.#deferredForcedClearScrollback;
|
|
2012
|
+
this.#deferredForcedClearScrollback = false;
|
|
2013
|
+
this.requestRender(true, { clearScrollback: deferredClearScrollback });
|
|
2014
|
+
}, TUI.#MULTIPLEXER_RESIZE_DEBOUNCE_MS);
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
/**
|
|
2018
|
+
* Arm the post-full-paint settle window after an `#emitFullPaint` that
|
|
2019
|
+
* pushed content into native scrollback on a ConPTY host. Idempotent inside
|
|
2020
|
+
* the window: a later overflowing paint extends `until` to the later
|
|
2021
|
+
* deadline so back-to-back big paints do not double-fire the trailing
|
|
2022
|
+
* coalesced render, and the existing deferred timer is rescheduled to the
|
|
2023
|
+
* later deadline.
|
|
2024
|
+
*
|
|
2025
|
+
* Mid-composition callers (most notably `ImageBudget.endPass()`, which can
|
|
2026
|
+
* call `requestRender()` from inside the in-flight paint when a new image
|
|
2027
|
+
* trips the budget) queue their render *before* the settle exists, so they
|
|
2028
|
+
* fall through the gate and set `#renderRequested` / `#renderTimer` on the
|
|
2029
|
+
* 30 Hz throttle. Without absorbing those, the throttled follow-up fires
|
|
2030
|
+
* inside the 150 ms quiet window and reintroduces the cascade the settle
|
|
2031
|
+
* was meant to stop. Cancel both, then eagerly arm the trailing settle
|
|
2032
|
+
* timer so the in-flight request still rides one coalesced render at the
|
|
2033
|
+
* end of the window. See #2095.
|
|
2034
|
+
*/
|
|
2035
|
+
#armPostFullPaintSettle(): void {
|
|
2036
|
+
if (!isConPTYHosted()) return;
|
|
2037
|
+
const until = this.#renderScheduler.now() + TUI.#CONPTY_POST_FULL_PAINT_SETTLE_MS;
|
|
2038
|
+
if (until <= this.#postFullPaintSettleUntilMs) return;
|
|
2039
|
+
this.#postFullPaintSettleUntilMs = until;
|
|
2040
|
+
const hadPendingRender = this.#renderRequested || this.#renderTimer !== undefined;
|
|
2041
|
+
// Reclaim any render that was queued during the in-flight composition:
|
|
2042
|
+
// `#renderRequested` was set before the settle existed and would
|
|
2043
|
+
// otherwise fire on the standard throttle inside the window.
|
|
2044
|
+
this.#renderRequested = false;
|
|
2045
|
+
if (this.#renderTimer) {
|
|
2046
|
+
this.#renderTimer.cancel();
|
|
2047
|
+
this.#renderTimer = undefined;
|
|
2048
|
+
}
|
|
2049
|
+
if (this.#postFullPaintSettleTimer) {
|
|
2050
|
+
this.#postFullPaintSettleTimer.cancel();
|
|
2051
|
+
this.#postFullPaintSettleTimer = undefined;
|
|
2052
|
+
}
|
|
2053
|
+
if (hadPendingRender) {
|
|
2054
|
+
// Replay the absorbed request via the trailing settle timer so the
|
|
2055
|
+
// caller's render still happens — just deferred to the end of the
|
|
2056
|
+
// window. Subsequent `requestRender(false)` calls during the
|
|
2057
|
+
// settle see this timer and fold into it (existing gate at L1263).
|
|
2058
|
+
this.#postFullPaintSettleTimer = this.#renderScheduler.scheduleRender(() => {
|
|
2059
|
+
this.#postFullPaintSettleTimer = undefined;
|
|
2060
|
+
this.#postFullPaintSettleUntilMs = 0;
|
|
2061
|
+
if (this.#stopped) return;
|
|
2062
|
+
this.#requestOrdinaryRender();
|
|
2063
|
+
}, TUI.#CONPTY_POST_FULL_PAINT_SETTLE_MS);
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
#clearPostFullPaintSettle(): void {
|
|
2068
|
+
if (this.#postFullPaintSettleTimer) {
|
|
2069
|
+
this.#postFullPaintSettleTimer.cancel();
|
|
2070
|
+
this.#postFullPaintSettleTimer = undefined;
|
|
2071
|
+
}
|
|
2072
|
+
this.#postFullPaintSettleUntilMs = 0;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
#maybeDeferGhosttyInitialImagePaint(): boolean {
|
|
2076
|
+
if (this.#ghosttyInitialImageDelayDone) return false;
|
|
2077
|
+
if (TERMINAL.id !== "ghostty" || TERMINAL.imageProtocol !== ImageProtocol.Kitty) {
|
|
2078
|
+
this.#ghosttyInitialImageDelayDone = true;
|
|
2079
|
+
return false;
|
|
2080
|
+
}
|
|
2081
|
+
if (!this.#imageBudget.hasPendingTransmits()) return false;
|
|
2082
|
+
if (this.#ghosttyInitialImageDelayTimer) return true;
|
|
2083
|
+
|
|
2084
|
+
const delayMs = Math.max(0, this.#ghosttyImageReadyAtMs - this.#renderScheduler.now());
|
|
2085
|
+
if (delayMs === 0) {
|
|
2086
|
+
this.#ghosttyInitialImageDelayDone = true;
|
|
2087
|
+
return false;
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
this.#ghosttyInitialImageDelayTimer = this.#renderScheduler.scheduleRender(() => {
|
|
2091
|
+
this.#ghosttyInitialImageDelayTimer = undefined;
|
|
2092
|
+
this.#ghosttyInitialImageDelayDone = true;
|
|
2093
|
+
if (this.#stopped) return;
|
|
2094
|
+
this.#executeRender();
|
|
2095
|
+
if (this.#renderRequested) this.#scheduleRender();
|
|
2096
|
+
}, delayMs);
|
|
2097
|
+
return true;
|
|
2098
|
+
}
|
|
2099
|
+
#prepareForcedRender(clearScrollback: boolean): void {
|
|
2100
|
+
this.#clearScrollbackOnNextRender ||= clearScrollback;
|
|
2101
|
+
this.#forceViewportRepaintOnNextRender = true;
|
|
2102
|
+
if (this.#renderTimer) {
|
|
2103
|
+
this.#renderTimer.cancel();
|
|
2104
|
+
this.#renderTimer = undefined;
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
#scheduleRender(): void {
|
|
2109
|
+
if (this.#stopped || this.#renderTimer || !this.#renderRequested) {
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
// Defer any new throttled render scheduled inside the multiplexer
|
|
2113
|
+
// resize settle window: it would race tmux's mid-reflow pane repaint.
|
|
2114
|
+
// `#renderRequested` stays set so the eventual forced render — armed
|
|
2115
|
+
// by the SIGWINCH callback — picks up the latest component state.
|
|
2116
|
+
if (this.#multiplexerResizeTimer) {
|
|
2117
|
+
return;
|
|
2118
|
+
}
|
|
2119
|
+
const now = this.#renderScheduler.now();
|
|
2120
|
+
const elapsed = now - this.#lastRenderAt;
|
|
2121
|
+
const cadenceDelay = Math.max(0, TUI.#MIN_RENDER_INTERVAL_MS - elapsed);
|
|
2122
|
+
// Adaptive backpressure — target ~50% render duty cycle: the next frame
|
|
2123
|
+
// starts no sooner than `last_frame_end + last_frame_cost`, i.e.
|
|
2124
|
+
// `last_frame_start + 2 × last_frame_cost`. So `elapsed` (which counts
|
|
2125
|
+
// from the last frame's start) must already exceed twice the cost
|
|
2126
|
+
// before we allow the follow-up render to fire. Capped so a
|
|
2127
|
+
// pathological one-off spike doesn't lock the UI (#4145).
|
|
2128
|
+
const adaptiveFloor = Math.min(TUI.#MAX_ADAPTIVE_RENDER_MS, this.#lastFrameCostMs * 2);
|
|
2129
|
+
const adaptiveDelay = Math.max(0, adaptiveFloor - elapsed);
|
|
2130
|
+
const inputGraceDelay = Math.max(0, this.#inputRenderGraceUntilMs - now);
|
|
2131
|
+
const delay = Math.max(cadenceDelay, adaptiveDelay, inputGraceDelay);
|
|
2132
|
+
this.#renderTimer = this.#renderScheduler.scheduleRender(() => {
|
|
2133
|
+
this.#renderTimer = undefined;
|
|
2134
|
+
if (this.#stopped || !this.#renderRequested) {
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
2137
|
+
this.#renderRequested = false;
|
|
2138
|
+
this.#executeRender();
|
|
2139
|
+
if (this.#renderRequested) {
|
|
2140
|
+
this.#scheduleRender();
|
|
2141
|
+
}
|
|
2142
|
+
}, delay);
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
/**
|
|
2146
|
+
* Wrap `#doRender()` so every path records the wall-clock frame cost that
|
|
2147
|
+
* feeds adaptive backpressure. Set `#lastRenderAt` first (some render code
|
|
2148
|
+
* reads it re-entrantly) and compute the cost once the paint returns.
|
|
2149
|
+
*/
|
|
2150
|
+
#executeRender(): void {
|
|
2151
|
+
const start = this.#renderScheduler.now();
|
|
2152
|
+
this.#lastRenderAt = start;
|
|
2153
|
+
this.#doRender();
|
|
2154
|
+
this.#lastFrameCostMs = this.#renderScheduler.now() - start;
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
#handleInput(data: string): void {
|
|
2158
|
+
// Raw-mode Ctrl+C/Esc arrive as stdin data, not process signals. If the
|
|
2159
|
+
// first key in a double-key gesture schedules an immediate slow repaint,
|
|
2160
|
+
// the queued second key can sit behind that repaint long enough for the
|
|
2161
|
+
// app-level double-press window to expire. Give the input queue one frame
|
|
2162
|
+
// before ordinary paints; forced repaints still bypass this path.
|
|
2163
|
+
this.#inputRenderGraceUntilMs = this.#renderScheduler.now() + TUI.#INPUT_RENDER_GRACE_MS;
|
|
2164
|
+
if (this.#inputListeners.size > 0) {
|
|
2165
|
+
let current = data;
|
|
2166
|
+
for (const listener of this.#inputListeners) {
|
|
2167
|
+
const result = listener(current);
|
|
2168
|
+
if (result?.consume) {
|
|
2169
|
+
return;
|
|
2170
|
+
}
|
|
2171
|
+
if (result?.data !== undefined) {
|
|
2172
|
+
current = result.data;
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
if (current.length === 0) {
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
data = current;
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
// Consume terminal cell size responses without blocking unrelated input.
|
|
2182
|
+
if (this.#consumeCellSizeResponse(data)) {
|
|
2183
|
+
return;
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
// Global debug key handler (Shift+Ctrl+D)
|
|
2187
|
+
if (matchesKey(data, "shift+ctrl+d") && this.onDebug) {
|
|
2188
|
+
this.onDebug();
|
|
2189
|
+
return;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
// If focused component is an overlay, verify it's still visible
|
|
2193
|
+
// (visibility can change due to terminal resize or visible() callback)
|
|
2194
|
+
const focusedOverlay = this.overlayStack.find(o => o.component === this.#focusedComponent);
|
|
2195
|
+
if (focusedOverlay && !this.#isOverlayVisible(focusedOverlay)) {
|
|
2196
|
+
// Focused overlay is no longer visible, redirect to topmost visible overlay
|
|
2197
|
+
const topVisible = this.#getTopmostVisibleOverlay();
|
|
2198
|
+
if (topVisible) {
|
|
2199
|
+
this.setFocus(topVisible.component);
|
|
2200
|
+
} else {
|
|
2201
|
+
// No visible overlays, restore to preFocus
|
|
2202
|
+
this.setFocus(focusedOverlay.preFocus);
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
// Pass input to focused component (including Ctrl+C)
|
|
2207
|
+
// The focused component can decide how to handle Ctrl+C
|
|
2208
|
+
if (this.#focusedComponent?.handleInput) {
|
|
2209
|
+
// Filter out key release events unless component opts in
|
|
2210
|
+
if (isKeyRelease(data) && !this.#focusedComponent.wantsKeyRelease) {
|
|
2211
|
+
return;
|
|
2212
|
+
}
|
|
2213
|
+
this.#focusedComponent.handleInput(data);
|
|
2214
|
+
this.requestRender();
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
#consumeCellSizeResponse(data: string): boolean {
|
|
2219
|
+
// Response format: ESC [ 6 ; height ; width t
|
|
2220
|
+
const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
|
|
2221
|
+
if (!match) {
|
|
2222
|
+
return false;
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
const heightPx = parseInt(match[1], 10);
|
|
2226
|
+
const widthPx = parseInt(match[2], 10);
|
|
2227
|
+
if (heightPx <= 0 || widthPx <= 0) {
|
|
2228
|
+
return true;
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
setCellDimensions({ widthPx, heightPx });
|
|
2232
|
+
// Invalidate all components so images re-render with correct dimensions.
|
|
2233
|
+
this.invalidate();
|
|
2234
|
+
this.requestRender();
|
|
2235
|
+
return true;
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
/**
|
|
2239
|
+
* Resolve overlay layout from options.
|
|
2240
|
+
* Returns { width, row, col, maxHeight } for rendering.
|
|
2241
|
+
*/
|
|
2242
|
+
#resolveOverlayLayout(
|
|
2243
|
+
options: OverlayOptions | undefined,
|
|
2244
|
+
overlayHeight: number,
|
|
2245
|
+
termWidth: number,
|
|
2246
|
+
termHeight: number,
|
|
2247
|
+
): { width: number; row: number; col: number; maxHeight: number } {
|
|
2248
|
+
const opt = options ?? {};
|
|
2249
|
+
|
|
2250
|
+
// Parse margin (clamp to non-negative)
|
|
2251
|
+
const margin =
|
|
2252
|
+
typeof opt.margin === "number"
|
|
2253
|
+
? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin }
|
|
2254
|
+
: (opt.margin ?? {});
|
|
2255
|
+
const marginTop = Math.max(0, margin.top ?? 0);
|
|
2256
|
+
const marginRight = Math.max(0, margin.right ?? 0);
|
|
2257
|
+
const marginBottom = Math.max(0, margin.bottom ?? 0);
|
|
2258
|
+
const marginLeft = Math.max(0, margin.left ?? 0);
|
|
2259
|
+
|
|
2260
|
+
// Available space after margins
|
|
2261
|
+
const availWidth = Math.max(1, termWidth - marginLeft - marginRight);
|
|
2262
|
+
const availHeight = Math.max(1, termHeight - marginTop - marginBottom);
|
|
2263
|
+
|
|
2264
|
+
// === Resolve width ===
|
|
2265
|
+
let width = parseSizeValue(opt.width, termWidth) ?? Math.min(80, availWidth);
|
|
2266
|
+
// Apply minWidth
|
|
2267
|
+
if (opt.minWidth !== undefined) {
|
|
2268
|
+
width = Math.max(width, opt.minWidth);
|
|
2269
|
+
}
|
|
2270
|
+
// Clamp to available space
|
|
2271
|
+
width = Math.max(1, Math.min(width, availWidth));
|
|
2272
|
+
|
|
2273
|
+
// === Resolve maxHeight ===
|
|
2274
|
+
let maxHeight = parseSizeValue(opt.maxHeight, termHeight) ?? availHeight;
|
|
2275
|
+
maxHeight = Math.max(1, Math.min(maxHeight, availHeight));
|
|
2276
|
+
|
|
2277
|
+
// Effective overlay height: maxHeight is always resolved (defaults to
|
|
2278
|
+
// availHeight above), so the overlay is unconditionally clamped to fit.
|
|
2279
|
+
const effectiveHeight = Math.min(overlayHeight, maxHeight);
|
|
2280
|
+
|
|
2281
|
+
// === Resolve position ===
|
|
2282
|
+
let row: number;
|
|
2283
|
+
let col: number;
|
|
2284
|
+
|
|
2285
|
+
if (opt.row !== undefined) {
|
|
2286
|
+
if (typeof opt.row === "string") {
|
|
2287
|
+
// Percentage: 0% = top, 100% = bottom (overlay stays within bounds)
|
|
2288
|
+
const match = opt.row.match(/^(\d+(?:\.\d+)?)%$/);
|
|
2289
|
+
if (match) {
|
|
2290
|
+
const maxRow = Math.max(0, availHeight - effectiveHeight);
|
|
2291
|
+
const percent = parseFloat(match[1]) / 100;
|
|
2292
|
+
row = marginTop + Math.floor(maxRow * percent);
|
|
2293
|
+
} else {
|
|
2294
|
+
// Invalid format, fall back to center
|
|
2295
|
+
row = this.#resolveAnchorRow("center", effectiveHeight, availHeight, marginTop);
|
|
2296
|
+
}
|
|
2297
|
+
} else {
|
|
2298
|
+
// Absolute row position
|
|
2299
|
+
row = opt.row;
|
|
2300
|
+
}
|
|
2301
|
+
} else {
|
|
2302
|
+
// Anchor-based (default: center)
|
|
2303
|
+
const anchor = opt.anchor ?? "center";
|
|
2304
|
+
row = this.#resolveAnchorRow(anchor, effectiveHeight, availHeight, marginTop);
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
if (opt.col !== undefined) {
|
|
2308
|
+
if (typeof opt.col === "string") {
|
|
2309
|
+
// Percentage: 0% = left, 100% = right (overlay stays within bounds)
|
|
2310
|
+
const match = opt.col.match(/^(\d+(?:\.\d+)?)%$/);
|
|
2311
|
+
if (match) {
|
|
2312
|
+
const maxCol = Math.max(0, availWidth - width);
|
|
2313
|
+
const percent = parseFloat(match[1]) / 100;
|
|
2314
|
+
col = marginLeft + Math.floor(maxCol * percent);
|
|
2315
|
+
} else {
|
|
2316
|
+
// Invalid format, fall back to center
|
|
2317
|
+
col = this.#resolveAnchorCol("center", width, availWidth, marginLeft);
|
|
2318
|
+
}
|
|
2319
|
+
} else {
|
|
2320
|
+
// Absolute column position
|
|
2321
|
+
col = opt.col;
|
|
2322
|
+
}
|
|
2323
|
+
} else {
|
|
2324
|
+
// Anchor-based (default: center)
|
|
2325
|
+
const anchor = opt.anchor ?? "center";
|
|
2326
|
+
col = this.#resolveAnchorCol(anchor, width, availWidth, marginLeft);
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
// Apply offsets
|
|
2330
|
+
if (opt.offsetY !== undefined) row += opt.offsetY;
|
|
2331
|
+
if (opt.offsetX !== undefined) col += opt.offsetX;
|
|
2332
|
+
|
|
2333
|
+
// Clamp to terminal bounds (respecting margins)
|
|
2334
|
+
row = Math.max(marginTop, Math.min(row, termHeight - marginBottom - effectiveHeight));
|
|
2335
|
+
col = Math.max(marginLeft, Math.min(col, termWidth - marginRight - width));
|
|
2336
|
+
|
|
2337
|
+
return { width, row, col, maxHeight };
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
#resolveAnchorRow(anchor: OverlayAnchor, height: number, availHeight: number, marginTop: number): number {
|
|
2341
|
+
switch (anchor) {
|
|
2342
|
+
case "top-left":
|
|
2343
|
+
case "top-center":
|
|
2344
|
+
case "top-right":
|
|
2345
|
+
return marginTop;
|
|
2346
|
+
case "bottom-left":
|
|
2347
|
+
case "bottom-center":
|
|
2348
|
+
case "bottom-right":
|
|
2349
|
+
return marginTop + availHeight - height;
|
|
2350
|
+
case "left-center":
|
|
2351
|
+
case "center":
|
|
2352
|
+
case "right-center":
|
|
2353
|
+
return marginTop + Math.floor((availHeight - height) / 2);
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
#resolveAnchorCol(anchor: OverlayAnchor, width: number, availWidth: number, marginLeft: number): number {
|
|
2358
|
+
switch (anchor) {
|
|
2359
|
+
case "top-left":
|
|
2360
|
+
case "left-center":
|
|
2361
|
+
case "bottom-left":
|
|
2362
|
+
return marginLeft;
|
|
2363
|
+
case "top-right":
|
|
2364
|
+
case "right-center":
|
|
2365
|
+
case "bottom-right":
|
|
2366
|
+
return marginLeft + availWidth - width;
|
|
2367
|
+
case "top-center":
|
|
2368
|
+
case "center":
|
|
2369
|
+
case "bottom-center":
|
|
2370
|
+
return marginLeft + Math.floor((availWidth - width) / 2);
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
/**
|
|
2375
|
+
* Composite all visible overlays into the window slice (screen
|
|
2376
|
+
* coordinates, in stack order, later = on top). Overlays never touch the
|
|
2377
|
+
* frame: composited rows exist only in the painted window, and commits are
|
|
2378
|
+
* frozen while an overlay is visible, so overlay pixels can never enter
|
|
2379
|
+
* native scrollback.
|
|
2380
|
+
*/
|
|
2381
|
+
#compositeOverlaysIntoWindow(window: string[], termWidth: number, termHeight: number): string[] {
|
|
2382
|
+
const result = [...window];
|
|
2383
|
+
for (const entry of this.overlayStack) {
|
|
2384
|
+
if (!this.#isOverlayVisible(entry)) continue;
|
|
2385
|
+
const { component, options } = entry;
|
|
2386
|
+
// Get layout with height=0 first to determine width and maxHeight
|
|
2387
|
+
// (width and maxHeight don't depend on overlay height).
|
|
2388
|
+
const { width, maxHeight } = this.#resolveOverlayLayout(options, 0, termWidth, termHeight);
|
|
2389
|
+
let overlayLines = component.render(width);
|
|
2390
|
+
if (overlayLines.length > maxHeight) {
|
|
2391
|
+
const anchor = options?.anchor ?? "center";
|
|
2392
|
+
overlayLines =
|
|
2393
|
+
anchor === "bottom-left" || anchor === "bottom-center" || anchor === "bottom-right"
|
|
2394
|
+
? overlayLines.slice(overlayLines.length - maxHeight)
|
|
2395
|
+
: overlayLines.slice(0, maxHeight);
|
|
2396
|
+
}
|
|
2397
|
+
const { row, col } = this.#resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight);
|
|
2398
|
+
for (let i = 0; i < overlayLines.length; i++) {
|
|
2399
|
+
const idx = row + i;
|
|
2400
|
+
if (idx < 0 || idx >= result.length) continue;
|
|
2401
|
+
const truncatedOverlayLine =
|
|
2402
|
+
visibleWidth(overlayLines[i]) > width ? sliceByColumn(overlayLines[i], 0, width, true) : overlayLines[i];
|
|
2403
|
+
result[idx] = this.#compositeLineAt(result[idx], truncatedOverlayLine, col, width, termWidth);
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
return result;
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
/** Splice overlay content into a base line at a specific column. Single-pass optimized. */
|
|
2410
|
+
#compositeLineAt(
|
|
2411
|
+
baseLine: string,
|
|
2412
|
+
overlayLine: string,
|
|
2413
|
+
startCol: number,
|
|
2414
|
+
overlayWidth: number,
|
|
2415
|
+
totalWidth: number,
|
|
2416
|
+
): string {
|
|
2417
|
+
if (TERMINAL.isImageLine(baseLine)) return baseLine;
|
|
2418
|
+
|
|
2419
|
+
// Single pass through baseLine extracts both before and after segments
|
|
2420
|
+
const afterStart = startCol + overlayWidth;
|
|
2421
|
+
const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
|
|
2422
|
+
|
|
2423
|
+
// Extract overlay with width tracking (strict=true to exclude wide chars at boundary)
|
|
2424
|
+
const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
|
|
2425
|
+
|
|
2426
|
+
// Pad segments to target widths
|
|
2427
|
+
const beforePad = Math.max(0, startCol - base.beforeWidth);
|
|
2428
|
+
const overlayPad = Math.max(0, overlayWidth - overlay.width);
|
|
2429
|
+
const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
|
|
2430
|
+
const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
|
|
2431
|
+
const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
|
|
2432
|
+
const afterPad = Math.max(0, afterTarget - base.afterWidth);
|
|
2433
|
+
|
|
2434
|
+
// Compose result
|
|
2435
|
+
const r = SEGMENT_RESET;
|
|
2436
|
+
const result =
|
|
2437
|
+
base.before +
|
|
2438
|
+
" ".repeat(beforePad) +
|
|
2439
|
+
r +
|
|
2440
|
+
overlay.text +
|
|
2441
|
+
" ".repeat(overlayPad) +
|
|
2442
|
+
r +
|
|
2443
|
+
base.after +
|
|
2444
|
+
" ".repeat(afterPad);
|
|
2445
|
+
|
|
2446
|
+
// CRITICAL: Always verify and truncate to terminal width.
|
|
2447
|
+
// This is the final safeguard against width overflow which would crash the TUI.
|
|
2448
|
+
// Width tracking can drift from actual visible width due to:
|
|
2449
|
+
// - Complex ANSI/OSC sequences (hyperlinks, colors)
|
|
2450
|
+
// - Wide characters at segment boundaries
|
|
2451
|
+
// - Edge cases in segment extraction
|
|
2452
|
+
const resultWidth = visibleWidth(result);
|
|
2453
|
+
if (resultWidth <= totalWidth) {
|
|
2454
|
+
return result;
|
|
2455
|
+
}
|
|
2456
|
+
// Truncate with strict=true to ensure we don't exceed totalWidth
|
|
2457
|
+
return sliceByColumn(result, 0, totalWidth, true);
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
/**
|
|
2461
|
+
* Strip every CURSOR_MARKER from the rendered lines (markers are internal
|
|
2462
|
+
* sentinels and must never reach the terminal, the committed prefix, or
|
|
2463
|
+
* the resync audit) and return the positions of the stripped markers,
|
|
2464
|
+
* bottom-most first. Callers pick the visible one once the window top is
|
|
2465
|
+
* known.
|
|
2466
|
+
*/
|
|
2467
|
+
#extractCursorMarkers(lines: string[]): { row: number; col: number }[] {
|
|
2468
|
+
const markers: { row: number; col: number }[] = [];
|
|
2469
|
+
for (let row = lines.length - 1; row >= 0; row--) {
|
|
2470
|
+
const line = lines[row];
|
|
2471
|
+
let markerIndex = line.indexOf(CURSOR_MARKER);
|
|
2472
|
+
if (markerIndex === -1) continue;
|
|
2473
|
+
const beforeMarker = line.slice(0, markerIndex);
|
|
2474
|
+
markers.push({ row, col: visibleWidth(beforeMarker) });
|
|
2475
|
+
let stripped = line;
|
|
2476
|
+
while (markerIndex !== -1) {
|
|
2477
|
+
stripped = stripped.slice(0, markerIndex) + stripped.slice(markerIndex + CURSOR_MARKER.length);
|
|
2478
|
+
markerIndex = stripped.indexOf(CURSOR_MARKER, markerIndex);
|
|
2479
|
+
}
|
|
2480
|
+
lines[row] = stripped;
|
|
2481
|
+
}
|
|
2482
|
+
return markers;
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
#truncateLargeConptyFrame(
|
|
2486
|
+
lines: string[],
|
|
2487
|
+
width: number,
|
|
2488
|
+
height: number,
|
|
2489
|
+
cursorPos: { row: number; col: number } | null,
|
|
2490
|
+
): { lines: string[]; cursorPos: { row: number; col: number } | null } {
|
|
2491
|
+
if (!isConPTYHosted()) return { lines, cursorPos };
|
|
2492
|
+
|
|
2493
|
+
let totalBytes = 0;
|
|
2494
|
+
let exceedsThreshold = false;
|
|
2495
|
+
for (const line of lines) {
|
|
2496
|
+
totalBytes += Buffer.byteLength(line, "utf8") + 8;
|
|
2497
|
+
if (totalBytes > TUI.#CONPTY_FRAME_TRUNCATE_THRESHOLD_BYTES) {
|
|
2498
|
+
exceedsThreshold = true;
|
|
2499
|
+
break;
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
if (!exceedsThreshold) return { lines, cursorPos };
|
|
2503
|
+
|
|
2504
|
+
let retainedBytes = 0;
|
|
2505
|
+
let retainedStart = lines.length;
|
|
2506
|
+
while (
|
|
2507
|
+
retainedStart > 0 &&
|
|
2508
|
+
(retainedBytes < TUI.#CONPTY_FRAME_RETAIN_BYTES || lines.length - retainedStart < height)
|
|
2509
|
+
) {
|
|
2510
|
+
retainedStart -= 1;
|
|
2511
|
+
retainedBytes += Buffer.byteLength(lines[retainedStart] ?? "", "utf8") + 8;
|
|
2512
|
+
}
|
|
2513
|
+
if (retainedStart <= 0) return { lines, cursorPos };
|
|
2514
|
+
|
|
2515
|
+
const marker = truncateToWidth(
|
|
2516
|
+
`[${retainedStart} older lines hidden to keep Windows console resume responsive]`,
|
|
2517
|
+
width,
|
|
2518
|
+
Ellipsis.Omit,
|
|
2519
|
+
);
|
|
2520
|
+
const truncated = new Array<string>(lines.length - retainedStart + 1);
|
|
2521
|
+
truncated[0] = marker;
|
|
2522
|
+
for (let i = retainedStart; i < lines.length; i++) {
|
|
2523
|
+
truncated[i - retainedStart + 1] = lines[i] ?? "";
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
if (cursorPos === null || cursorPos.row < retainedStart) {
|
|
2527
|
+
return { lines: truncated, cursorPos: null };
|
|
2528
|
+
}
|
|
2529
|
+
return {
|
|
2530
|
+
lines: truncated,
|
|
2531
|
+
cursorPos: { row: cursorPos.row - retainedStart + 1, col: cursorPos.col },
|
|
2532
|
+
};
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
#terminalLine(line: string): string {
|
|
2536
|
+
if (TERMINAL.isImageLine(line)) return line;
|
|
2537
|
+
const coalesced = coalesceAdjacentSgr(line);
|
|
2538
|
+
return coalesced + (line.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
/**
|
|
2542
|
+
* Render one frame.
|
|
2543
|
+
*
|
|
2544
|
+
* Append-only pipeline: compose the frame, derive the commit boundary from
|
|
2545
|
+
* the component-reported live-region seam, advance the committed-row count
|
|
2546
|
+
* monotonically, and emit either a gesture-driven full paint or an
|
|
2547
|
+
* incremental update. Scrollback is `frame[0..committedRows)` at all
|
|
2548
|
+
* times — no viewport probes, no deferred reconciliation.
|
|
2549
|
+
*/
|
|
2550
|
+
#doRender(): void {
|
|
2551
|
+
if (this.#stopped) return;
|
|
2552
|
+
const width = this.terminal.columns;
|
|
2553
|
+
const height = this.terminal.rows;
|
|
2554
|
+
|
|
2555
|
+
// Consume the component-scoped accumulation: it describes the render
|
|
2556
|
+
// requests made up to this frame, whichever path the frame takes.
|
|
2557
|
+
const componentScopedOnly = this.#pendingRenderComponentsOnly;
|
|
2558
|
+
this.#pendingRenderComponentsOnly = false;
|
|
2559
|
+
|
|
2560
|
+
// Fullscreen alt-screen short-circuit. While the topmost visible overlay
|
|
2561
|
+
// requests it, borrow the terminal's alternate buffer and paint only the
|
|
2562
|
+
// modal there; the normal screen and all accounting stay untouched.
|
|
2563
|
+
const wantAlt = this.#wantsAltScreen();
|
|
2564
|
+
if (wantAlt && !this.#altActive) {
|
|
2565
|
+
// Enhanced keyboard modes can be buffer-local: re-push the active
|
|
2566
|
+
// modified-key reporting sequence on the freshly entered alternate
|
|
2567
|
+
// screen, or Esc/modified keys revert to legacy encoding inside
|
|
2568
|
+
// fullscreen overlays (Ghostty/kitty/iTerm2).
|
|
2569
|
+
this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}${MOUSE_TRACKING_ON}`);
|
|
2570
|
+
setAltScreenActive(true);
|
|
2571
|
+
this.terminal.hideCursor();
|
|
2572
|
+
this.#forgetHardwareCursorState();
|
|
2573
|
+
this.#recordHardwareCursorHidden();
|
|
2574
|
+
this.#altActive = true;
|
|
2575
|
+
this.#altPreviousLines = [];
|
|
2576
|
+
this.#altEnterWidth = width;
|
|
2577
|
+
this.#altEnterHeight = height;
|
|
2578
|
+
} else if (!wantAlt && this.#altActive) {
|
|
2579
|
+
const enhancementExit = this.#keyboardEnhancementExit();
|
|
2580
|
+
this.terminal.write(`${MOUSE_TRACKING_OFF}${enhancementExit}\x1b[?1049l`);
|
|
2581
|
+
setAltScreenActive(false);
|
|
2582
|
+
this.#forgetHardwareCursorState();
|
|
2583
|
+
this.#altActive = false;
|
|
2584
|
+
this.#altPreviousLines = [];
|
|
2585
|
+
// A resize while on the alt buffer reflowed the terminal's saved
|
|
2586
|
+
// normal screen; it no longer matches our accounting, so force the
|
|
2587
|
+
// geometry rebuild path instead of a stale diff.
|
|
2588
|
+
if (width !== this.#altEnterWidth || height !== this.#altEnterHeight) {
|
|
2589
|
+
this.#resizeEventPending = true;
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
if (this.#altActive) {
|
|
2593
|
+
this.#componentRenderTargets.clear();
|
|
2594
|
+
this.#renderAltFrame(width, height);
|
|
2595
|
+
return;
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
// Resize viewport fast path. While a non-multiplexer drag is in flight,
|
|
2599
|
+
// paint only the viewport and skip composing the off-screen history.
|
|
2600
|
+
// Strictly state-isolated: it never consumes #resizeEventPending nor
|
|
2601
|
+
// advances any commit/window/diff field, so the authoritative full paint
|
|
2602
|
+
// the settle timer queues reconciles as if these throwaway frames never
|
|
2603
|
+
// ran. Two render sources reach here mid-drag and BOTH must stay on this
|
|
2604
|
+
// path:
|
|
2605
|
+
// - the resize callback's own cheap paint after each SIGWINCH;
|
|
2606
|
+
// - an ordinary (non-forced) render from a live block that keeps
|
|
2607
|
+
// animating through the drag — a spinner tick, a streamed token, a
|
|
2608
|
+
// cursor blink — firing requestRender(false)/requestComponentRender.
|
|
2609
|
+
// #resizeEventPending is still set (the fast path never consumed it),
|
|
2610
|
+
// so without this branch the ordinary render falls through to the
|
|
2611
|
+
// geometry-rebuild full paint below, which LEAVES the borrowed
|
|
2612
|
+
// alternate screen to repaint the whole transcript on the normal
|
|
2613
|
+
// screen — then the next SIGWINCH re-enters the alt screen and paints
|
|
2614
|
+
// only the tail, so the block flashes in for one frame and vanishes.
|
|
2615
|
+
// A forced render (tool finalization, reset, image reconciliation) must
|
|
2616
|
+
// still preempt: it set #forceViewportRepaintOnNextRender via
|
|
2617
|
+
// #prepareForcedRender and owns the next authoritative paint, so it falls
|
|
2618
|
+
// through. A visible overlay composites over the transcript and needs the
|
|
2619
|
+
// whole window, so it also falls through (overlay resizes are not on the
|
|
2620
|
+
// drag-cost hot path).
|
|
2621
|
+
if (
|
|
2622
|
+
this.#resizeViewportActive &&
|
|
2623
|
+
!this.#forceViewportRepaintOnNextRender &&
|
|
2624
|
+
this.#hasEverRendered &&
|
|
2625
|
+
this.#getTopmostVisibleOverlay() === undefined
|
|
2626
|
+
) {
|
|
2627
|
+
this.#componentRenderTargets.clear();
|
|
2628
|
+
this.#renderResizeViewport(width, height);
|
|
2629
|
+
return;
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
// 1. Compose the frame. Bracket the render so the image budget observes
|
|
2633
|
+
// every inline image in display order (overlays carry none). A
|
|
2634
|
+
// component-scoped frame skips the budget pass instead — it is gated on
|
|
2635
|
+
// a quiescent budget, and a partial tree walk would under-count display
|
|
2636
|
+
// order — and re-renders only the requested root subtrees, reusing the
|
|
2637
|
+
// previous segment of every other root child.
|
|
2638
|
+
const partialRoots = componentScopedOnly ? this.#resolvePartialComposeRoots(width, height) : null;
|
|
2639
|
+
this.#componentRenderTargets.clear();
|
|
2640
|
+
let rawFrame: readonly string[];
|
|
2641
|
+
if (partialRoots !== null) {
|
|
2642
|
+
this.#partialComposeRoots = partialRoots;
|
|
2643
|
+
try {
|
|
2644
|
+
rawFrame = this.render(width);
|
|
2645
|
+
} finally {
|
|
2646
|
+
this.#partialComposeRoots = null;
|
|
2647
|
+
}
|
|
2648
|
+
} else {
|
|
2649
|
+
this.#imageBudget.beginPass();
|
|
2650
|
+
rawFrame = this.render(width);
|
|
2651
|
+
this.#imageBudget.endPass();
|
|
2652
|
+
}
|
|
2653
|
+
// Ghostty initial-image deferral must run before any render state is
|
|
2654
|
+
// consumed (#resizeEventPending, hardware-cursor state, commit
|
|
2655
|
+
// re-anchoring): the early return abandons this frame and the deferred
|
|
2656
|
+
// render recomposes from scratch, so consuming state here would
|
|
2657
|
+
// misclassify a pending resize as an ordinary diff and corrupt the paint.
|
|
2658
|
+
if (this.#maybeDeferGhosttyInitialImagePaint()) return;
|
|
2659
|
+
// Cursor markers were stripped at compose time (they are internal
|
|
2660
|
+
// sentinels and must never reach the terminal, the committed prefix, or
|
|
2661
|
+
// the audit); the visible marker is chosen after the window top is
|
|
2662
|
+
// known. Ascending by frame row.
|
|
2663
|
+
const cursorMarkers = this.#frameCursorMarkers;
|
|
2664
|
+
const liveRegionStart = this.#nativeScrollbackLiveRegionStart;
|
|
2665
|
+
const commitSafeEnd = this.#nativeScrollbackCommitSafeEnd;
|
|
2666
|
+
const snapshotSafeEnd = this.#nativeScrollbackSnapshotSafeEnd;
|
|
2667
|
+
|
|
2668
|
+
// Commit boundaries (also used by the window/commit math in section 3),
|
|
2669
|
+
// hoisted above the audit gate because the resync needs byteStableBoundary
|
|
2670
|
+
// to tell a now-permanent forced row (must re-anchor) from a still-live one.
|
|
2671
|
+
// The commit floor is windowTop in every non-frozen path (see chunkTo), so
|
|
2672
|
+
// whatever scrolls above the window is committed — never committed nowhere
|
|
2673
|
+
// AND painted nowhere (the loss bug). The boundaries no longer gate the
|
|
2674
|
+
// commit; they define the audit-exempt span. byteStableBoundary: rows below
|
|
2675
|
+
// it are byte-stable (never re-layout), audited. durableBoundary: rows in
|
|
2676
|
+
// [byteStableBoundary, durableBoundary) are durable — permanent on scroll-off
|
|
2677
|
+
// but may drift in place (a streaming table re-aligning), committed
|
|
2678
|
+
// audit-EXEMPT. Rows at/beyond durableBoundary committed only because they
|
|
2679
|
+
// scrolled above the window (a commit-unstable barrier over a long tail) are
|
|
2680
|
+
// forced-overflow rows: audited, so a later shift/finalize/removal re-anchors
|
|
2681
|
+
// (duplication, never loss) instead of stranding a stale prefix. Built on the
|
|
2682
|
+
// finalized prefix (live-region start); the whole frame when the root reports
|
|
2683
|
+
// no seam (shell semantics: whatever scrolls is final).
|
|
2684
|
+
const frameLength = rawFrame.length;
|
|
2685
|
+
const byteStableBoundary = Math.max(0, Math.min(frameLength, commitSafeEnd ?? liveRegionStart ?? frameLength));
|
|
2686
|
+
const durableBoundary = Math.max(
|
|
2687
|
+
byteStableBoundary,
|
|
2688
|
+
Math.min(frameLength, snapshotSafeEnd ?? byteStableBoundary),
|
|
2689
|
+
);
|
|
2690
|
+
|
|
2691
|
+
// 2. Transition state captured before any emitter runs.
|
|
2692
|
+
const prevWindowTop = this.#windowTopRow;
|
|
2693
|
+
const prevHardwareCursorRow = this.#hardwareCursorRow;
|
|
2694
|
+
const resizeEventOccurred = this.#resizeEventPending;
|
|
2695
|
+
this.#resizeEventPending = false;
|
|
2696
|
+
if (resizeEventOccurred) this.#forgetHardwareCursorState();
|
|
2697
|
+
const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
|
|
2698
|
+
// A resize event with net-unchanged dimensions still reflowed the
|
|
2699
|
+
// terminal buffer; classify it as a height change so geometry handling
|
|
2700
|
+
// repaints instead of diffing against a screen that no longer exists.
|
|
2701
|
+
const heightChanged =
|
|
2702
|
+
(this.#previousHeight > 0 && this.#previousHeight !== height) ||
|
|
2703
|
+
(resizeEventOccurred && this.#previousHeight > 0);
|
|
2704
|
+
const geometryChanged = widthChanged || heightChanged;
|
|
2705
|
+
|
|
2706
|
+
// Committed-prefix audit: rows below the commit index are physically in
|
|
2707
|
+
// terminal history and must never re-layout. When a component violates
|
|
2708
|
+
// that — a budget-demoted image collapsing to its one-line fallback, a
|
|
2709
|
+
// TTSR rewind truncating a block whose sealed prefix already committed —
|
|
2710
|
+
// keeping the old index would silently skip that many rows of
|
|
2711
|
+
// everything below (content loss). Re-anchor at the divergence instead:
|
|
2712
|
+
// the stale copy stays in history and rows recommit from there —
|
|
2713
|
+
// duplication, never loss. Skipped on geometry frames (a rewrap
|
|
2714
|
+
// legitimately reflows every row; the mux branch re-bases the prefix
|
|
2715
|
+
// and non-mux geometry replays from scratch), and skipped when the
|
|
2716
|
+
// composed frame's stable prefix covers every committed row — bytes
|
|
2717
|
+
// that provably did not change since the last (aligned) frame cannot
|
|
2718
|
+
// have diverged.
|
|
2719
|
+
let committedRowsResynced = false;
|
|
2720
|
+
// Audit covers [0, auditRows) and the forced suffix [durableRows,
|
|
2721
|
+
// committedRows); the durable middle [auditRows, durableRows) is exempt
|
|
2722
|
+
// (in-place drift). Two reasons to run the audit this frame:
|
|
2723
|
+
// - the stable prefix does not cover every audited row (auditUpper); or
|
|
2724
|
+
// - a forced-overflow row this frame became durable/permanent
|
|
2725
|
+
// (committedPrefixDurableRows < hardAuditEnd): the barrier above it
|
|
2726
|
+
// finalized, so its committed bytes must be re-checked even though the
|
|
2727
|
+
// stable prefix says nothing moved — a stale committed copy there would
|
|
2728
|
+
// silently drop the row. The hard scan in findCommittedPrefixResync
|
|
2729
|
+
// covers [durableRows, hardAuditEnd) in full (no tail-sample miss).
|
|
2730
|
+
const auditUpper =
|
|
2731
|
+
this.#committedPrefixDurableRows < this.#committedRows ? this.#committedRows : this.#committedPrefixAuditRows;
|
|
2732
|
+
const hardAuditEnd = Math.min(this.#committedRows, durableBoundary);
|
|
2733
|
+
const needHardAudit = this.#committedPrefixDurableRows < hardAuditEnd;
|
|
2734
|
+
const auditRan =
|
|
2735
|
+
this.#hasEverRendered &&
|
|
2736
|
+
!geometryChanged &&
|
|
2737
|
+
!this.#clearScrollbackOnNextRender &&
|
|
2738
|
+
(this.#renderStablePrefixRows < auditUpper || needHardAudit);
|
|
2739
|
+
if (auditRan) {
|
|
2740
|
+
const committedRowsBeforeAudit = this.#committedRows;
|
|
2741
|
+
this.#auditCommittedPrefix(rawFrame, durableBoundary);
|
|
2742
|
+
committedRowsResynced = this.#committedRows !== committedRowsBeforeAudit;
|
|
2743
|
+
}
|
|
2744
|
+
// Committed-prefix state this frame's commit math extends from (post-audit).
|
|
2745
|
+
// Drives the audit-rows / durable-rows caps recomputed after the emit.
|
|
2746
|
+
const preCommitRows = this.#committedRows;
|
|
2747
|
+
const preCommitAuditRows = this.#committedPrefixAuditRows;
|
|
2748
|
+
const preCommitDurableRows = this.#committedPrefixDurableRows;
|
|
2749
|
+
|
|
2750
|
+
// 3. Window and commit math (lengths only; content prepared below).
|
|
2751
|
+
let hasVisibleOverlay = false;
|
|
2752
|
+
for (const entry of this.overlayStack) {
|
|
2753
|
+
if (this.#isOverlayVisible(entry)) {
|
|
2754
|
+
hasVisibleOverlay = true;
|
|
2755
|
+
break;
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
// 4. Classify. A resize is an explicit user gesture: normally the engine
|
|
2760
|
+
// erases and replays so history rewraps at the new geometry (the reader
|
|
2761
|
+
// snapped to the bottom just dragged the window). Multiplexer panes — and
|
|
2762
|
+
// terminals that re-report size on alt-screen toggles — instead repaint in
|
|
2763
|
+
// place, because an ED3 rewrap is unsafe (pane scrollback / alt-screen
|
|
2764
|
+
// feedback loop), so committed history keeps its old wrap.
|
|
2765
|
+
const firstPaint = !this.#hasEverRendered;
|
|
2766
|
+
const replaceRequested = this.#clearScrollbackOnNextRender;
|
|
2767
|
+
const geometryRebuild = geometryChanged && !resizeRepaintsInPlace();
|
|
2768
|
+
const fullPaint = firstPaint || replaceRequested || geometryRebuild;
|
|
2769
|
+
let windowTop: number;
|
|
2770
|
+
let chunkTo: number;
|
|
2771
|
+
let committedPrefixResliced = false;
|
|
2772
|
+
if (fullPaint) {
|
|
2773
|
+
committedPrefixResliced = true;
|
|
2774
|
+
windowTop = Math.max(0, frameLength - height);
|
|
2775
|
+
chunkTo = windowTop;
|
|
2776
|
+
} else if (
|
|
2777
|
+
frameLength <= this.#committedRows ||
|
|
2778
|
+
(committedRowsResynced &&
|
|
2779
|
+
frameLength - this.#committedRows < height &&
|
|
2780
|
+
cursorMarkers.some(marker => marker.row >= this.#committedRows))
|
|
2781
|
+
) {
|
|
2782
|
+
// Either the frame shrank into the committed prefix, or a
|
|
2783
|
+
// committed-prefix resync left a focused cursor tail shorter than the
|
|
2784
|
+
// viewport. The latter happens when a streaming/live block had an
|
|
2785
|
+
// append-only prefix committed, then collapses on abort/finalize:
|
|
2786
|
+
// the audit re-anchors #committedRows at the first divergent row, but
|
|
2787
|
+
// flooring windowTop there would pin the editor near the top and
|
|
2788
|
+
// leave blank rows underneath. Re-show the frame tail instead. The
|
|
2789
|
+
// stale committed copy stays in native history; duplicating a few rows
|
|
2790
|
+
// is preferable to a live editor gap and matches the existing
|
|
2791
|
+
// "duplication, never loss" resync contract.
|
|
2792
|
+
windowTop = Math.max(0, frameLength - height);
|
|
2793
|
+
chunkTo = windowTop;
|
|
2794
|
+
committedPrefixResliced = true;
|
|
2795
|
+
this.#committedRows = chunkTo;
|
|
2796
|
+
this.#committedPrefix = rawFrame.slice(0, chunkTo);
|
|
2797
|
+
} else {
|
|
2798
|
+
// Re-anchor to the frame tail, floored at the committed boundary: a
|
|
2799
|
+
// shrink (or overlay close) pulls the window back down, but never
|
|
2800
|
+
// onto rows already in native history — re-showing those on the
|
|
2801
|
+
// grid would duplicate them for a scrolling reader. On a
|
|
2802
|
+
// multiplexer resize the pane reflowed its own history; committed
|
|
2803
|
+
// rows keep their old wrap there, same as any shell output.
|
|
2804
|
+
windowTop = Math.max(this.#committedRows, frameLength - height, 0);
|
|
2805
|
+
// Overlays freeze commits: composited rows must never enter
|
|
2806
|
+
// history, and the hidden gap backfills via the chunk once the
|
|
2807
|
+
// overlay closes. A multiplexer resize also commits nothing — the
|
|
2808
|
+
// pane keeps its own (old-wrap) history — and re-bases the audit
|
|
2809
|
+
// prefix at the new width so the accepted wrap drift does not read
|
|
2810
|
+
// as a violation on the next ordinary frame.
|
|
2811
|
+
chunkTo = hasVisibleOverlay || geometryChanged ? this.#committedRows : windowTop;
|
|
2812
|
+
if (geometryChanged) {
|
|
2813
|
+
committedPrefixResliced = true;
|
|
2814
|
+
this.#committedPrefix = rawFrame.slice(0, this.#committedRows);
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
// 5. Pick the visible cursor marker (bottom-most at or below the window
|
|
2819
|
+
// top), prepare lines, and build the visible window slice.
|
|
2820
|
+
let cursorPos: { row: number; col: number } | null = null;
|
|
2821
|
+
for (let i = cursorMarkers.length - 1; i >= 0; i--) {
|
|
2822
|
+
const marker = cursorMarkers[i]!;
|
|
2823
|
+
if (marker.row >= windowTop) {
|
|
2824
|
+
cursorPos = marker;
|
|
2825
|
+
break;
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
const frame = this.#prepareFrame(rawFrame, width);
|
|
2829
|
+
let window: string[] = new Array(height);
|
|
2830
|
+
for (let r = 0; r < height; r++) window[r] = frame[windowTop + r] ?? "";
|
|
2831
|
+
if (hasVisibleOverlay) {
|
|
2832
|
+
window = this.#compositeOverlaysIntoWindow(window, width, height);
|
|
2833
|
+
const overlayMarkers = this.#extractCursorMarkers(window);
|
|
2834
|
+
if (overlayMarkers.length > 0) {
|
|
2835
|
+
cursorPos = { row: windowTop + overlayMarkers[0]!.row, col: overlayMarkers[0]!.col };
|
|
2836
|
+
}
|
|
2837
|
+
window = this.#prepareLinesArray(window, width);
|
|
2838
|
+
}
|
|
2839
|
+
const cursorTrackingLineCount = hasVisibleOverlay ? Math.max(frame.length, windowTop + height) : frame.length;
|
|
2840
|
+
|
|
2841
|
+
const intent: RenderIntent = fullPaint
|
|
2842
|
+
? { kind: "fullPaint", clearScrollback: replaceRequested || geometryRebuild ? !isMultiplexerSession() : false }
|
|
2843
|
+
: { kind: "update", chunkTo, windowTop };
|
|
2844
|
+
this.#logRedraw(intent, frameLength, height);
|
|
2845
|
+
|
|
2846
|
+
// Load newly-displayed image data once, before this frame's placements
|
|
2847
|
+
// reference it. For full paints, the emitter may need to place the
|
|
2848
|
+
// transmit after a destructive clear (ED2/ED3) but before row replay, so
|
|
2849
|
+
// build the buffer here and let the emitter decide where it lands.
|
|
2850
|
+
let imageTransmitBuffer = "";
|
|
2851
|
+
for (const seq of this.#imageBudget.takeTransmits()) imageTransmitBuffer += seq;
|
|
2852
|
+
// Purge graphics for images the budget demoted to text. Kitty keeps
|
|
2853
|
+
// images in a store that text clears don't touch; demoted rows still
|
|
2854
|
+
// visible re-render as text and the window diff repaints them.
|
|
2855
|
+
// Committed placements are immutable — their pixels are deleted but
|
|
2856
|
+
// their rows are not rewritten.
|
|
2857
|
+
let purgeSequence = "";
|
|
2858
|
+
if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
|
|
2859
|
+
for (const id of this.#imageBudget.takePurgeIds()) purgeSequence += encodeKittyDeleteImage(id);
|
|
2860
|
+
} else {
|
|
2861
|
+
this.#imageBudget.takePurgeIds();
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
// 6. Emit.
|
|
2865
|
+
if (intent.kind === "fullPaint") {
|
|
2866
|
+
this.#emitFullPaint(frame, window, width, height, cursorPos, purgeSequence, imageTransmitBuffer, {
|
|
2867
|
+
clearScrollback: intent.clearScrollback,
|
|
2868
|
+
chunkTo,
|
|
2869
|
+
windowTop,
|
|
2870
|
+
cursorTrackingLineCount,
|
|
2871
|
+
});
|
|
2872
|
+
this.#committedPrefix = rawFrame.slice(0, chunkTo);
|
|
2873
|
+
this.#updateCommittedAuditRows(
|
|
2874
|
+
true,
|
|
2875
|
+
preCommitRows,
|
|
2876
|
+
preCommitAuditRows,
|
|
2877
|
+
preCommitDurableRows,
|
|
2878
|
+
byteStableBoundary,
|
|
2879
|
+
durableBoundary,
|
|
2880
|
+
false,
|
|
2881
|
+
);
|
|
2882
|
+
this.#clearScrollbackOnNextRender = false;
|
|
2883
|
+
this.#hasEverRendered = true;
|
|
2884
|
+
if (!firstPaint && frameLength > height) this.#armPostFullPaintSettle();
|
|
2885
|
+
return;
|
|
2886
|
+
}
|
|
2887
|
+
if (imageTransmitBuffer.length > 0) {
|
|
2888
|
+
this.terminal.write(imageTransmitBuffer);
|
|
2889
|
+
}
|
|
2890
|
+
this.#emitUpdate(frame, window, width, height, cursorPos, purgeSequence, {
|
|
2891
|
+
chunkTo,
|
|
2892
|
+
windowTop,
|
|
2893
|
+
prevWindowTop,
|
|
2894
|
+
prevHardwareCursorRow,
|
|
2895
|
+
forceWindowRewrite: this.#forceViewportRepaintOnNextRender || (geometryChanged && resizeRepaintsInPlace()),
|
|
2896
|
+
repaintVirtualScrollInPlace: hasVisibleOverlay,
|
|
2897
|
+
cursorTrackingLineCount,
|
|
2898
|
+
});
|
|
2899
|
+
for (let i = this.#committedPrefix.length; i < chunkTo; i++) {
|
|
2900
|
+
this.#committedPrefix.push(rawFrame[i] ?? "");
|
|
2901
|
+
}
|
|
2902
|
+
this.#updateCommittedAuditRows(
|
|
2903
|
+
committedPrefixResliced,
|
|
2904
|
+
preCommitRows,
|
|
2905
|
+
preCommitAuditRows,
|
|
2906
|
+
preCommitDurableRows,
|
|
2907
|
+
byteStableBoundary,
|
|
2908
|
+
durableBoundary,
|
|
2909
|
+
auditRan,
|
|
2910
|
+
);
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
/**
|
|
2914
|
+
* Detect committed-prefix violations and re-anchor the commit index at the
|
|
2915
|
+
* first moved row, so subsequent rows recommit instead of being skipped:
|
|
2916
|
+
* the stale copy stays in history — duplication, never loss. Pure in-place
|
|
2917
|
+
* restyles keep their alignment and are left alone (stale styling in
|
|
2918
|
+
* history was always the accepted artifact).
|
|
2919
|
+
*/
|
|
2920
|
+
#auditCommittedPrefix(rawFrame: readonly string[], permanentEnd: number): void {
|
|
2921
|
+
const prefix = this.#committedPrefix;
|
|
2922
|
+
if (prefix.length === 0) return;
|
|
2923
|
+
const resyncTo = findCommittedPrefixResync(
|
|
2924
|
+
rawFrame,
|
|
2925
|
+
prefix,
|
|
2926
|
+
prefix.length,
|
|
2927
|
+
this.#committedPrefixAuditRows,
|
|
2928
|
+
this.#committedPrefixDurableRows,
|
|
2929
|
+
permanentEnd,
|
|
2930
|
+
);
|
|
2931
|
+
if (resyncTo < 0) return;
|
|
2932
|
+
this.#committedRows = resyncTo;
|
|
2933
|
+
this.#committedPrefixAuditRows = Math.min(this.#committedPrefixAuditRows, resyncTo);
|
|
2934
|
+
this.#committedPrefixDurableRows = Math.min(this.#committedPrefixDurableRows, resyncTo);
|
|
2935
|
+
prefix.length = resyncTo;
|
|
2936
|
+
if ($flag("PI_DEBUG_REDRAW")) {
|
|
2937
|
+
const msg = `[${new Date().toISOString()}] commit resync: committed prefix diverged at row ${resyncTo}; recommitting\n`;
|
|
2938
|
+
fs.appendFileSync(getDebugLogPath(), msg);
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
/**
|
|
2943
|
+
* Recompute the audit-rows / durable-rows marks after a commit (see the
|
|
2944
|
+
* #committedPrefixAuditRows field doc for the three audit zones).
|
|
2945
|
+
*
|
|
2946
|
+
* auditRows tracks the byte-stable boundary; durableRows the durable snapshot
|
|
2947
|
+
* boundary. A wholesale re-slice (full paint / shrink / geometry) re-bases
|
|
2948
|
+
* each mark from the current frame (min(committed, boundary)). An incremental
|
|
2949
|
+
* extend keeps a mark once a row past it has committed (mark < committed): a
|
|
2950
|
+
* later RISE in a boundary (a table finalizing) must neither pull
|
|
2951
|
+
* already-committed stale snapshots back under the byte-stable cap nor
|
|
2952
|
+
* retroactively exempt forced-overflow rows already audited. durableRows is
|
|
2953
|
+
* floored at auditRows so the exempt window can never invert.
|
|
2954
|
+
*/
|
|
2955
|
+
#updateCommittedAuditRows(
|
|
2956
|
+
resliced: boolean,
|
|
2957
|
+
preCommittedRows: number,
|
|
2958
|
+
preAuditRows: number,
|
|
2959
|
+
preDurableRows: number,
|
|
2960
|
+
byteStableBoundary: number,
|
|
2961
|
+
durableBoundary: number,
|
|
2962
|
+
hardAudited: boolean,
|
|
2963
|
+
): void {
|
|
2964
|
+
const committed = this.#committedRows;
|
|
2965
|
+
const auditRows =
|
|
2966
|
+
resliced || preAuditRows >= preCommittedRows
|
|
2967
|
+
? Math.min(committed, byteStableBoundary)
|
|
2968
|
+
: Math.min(preAuditRows, committed);
|
|
2969
|
+
// durableRows also advances when a hard audit ran this frame: the resync's
|
|
2970
|
+
// full hard scan verified the forced suffix [durableRows, min(committed,
|
|
2971
|
+
// durableBoundary)) (re-anchoring on any divergence), so those rows are now
|
|
2972
|
+
// proven durable and may leave the audited set — otherwise the durable-rise
|
|
2973
|
+
// gate would re-fire the full scan every frame (and spray on later drift).
|
|
2974
|
+
const durableRows =
|
|
2975
|
+
resliced || preDurableRows >= preCommittedRows || hardAudited
|
|
2976
|
+
? Math.min(committed, durableBoundary)
|
|
2977
|
+
: Math.min(preDurableRows, committed);
|
|
2978
|
+
this.#committedPrefixAuditRows = auditRows;
|
|
2979
|
+
this.#committedPrefixDurableRows = Math.max(auditRows, durableRows);
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2982
|
+
/**
|
|
2983
|
+
* Prepare the composed frame for emission, in place. Rows below
|
|
2984
|
+
* `#preparedValidRows` are already prepared against the current frame (the
|
|
2985
|
+
* compose lowered that floor to the stable prefix); rows at/after it are
|
|
2986
|
+
* revalidated positionally — a row whose raw content and width match its
|
|
2987
|
+
* cached entry reuses the prepared line, anything else re-prepares.
|
|
2988
|
+
*/
|
|
2989
|
+
#prepareFrame(frame: readonly string[], width: number): string[] {
|
|
2990
|
+
const prepared = this.#preparedFrame;
|
|
2991
|
+
const meta = this.#preparedMeta;
|
|
2992
|
+
if (prepared.length > frame.length) {
|
|
2993
|
+
prepared.length = frame.length;
|
|
2994
|
+
meta.length = frame.length;
|
|
2995
|
+
}
|
|
2996
|
+
for (let i = Math.min(this.#preparedValidRows, prepared.length); i < frame.length; i++) {
|
|
2997
|
+
const raw = frame[i]!;
|
|
2998
|
+
const cached = meta[i];
|
|
2999
|
+
if (cached !== undefined && cached.raw === raw && cached.width === width) {
|
|
3000
|
+
prepared[i] = cached.line;
|
|
3001
|
+
continue;
|
|
3002
|
+
}
|
|
3003
|
+
const entry = this.#prepareLine(raw, width);
|
|
3004
|
+
meta[i] = entry;
|
|
3005
|
+
prepared[i] = entry.line;
|
|
3006
|
+
}
|
|
3007
|
+
this.#preparedValidRows = frame.length;
|
|
3008
|
+
return prepared;
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
/** Stateless variant for overlay-composited windows and alt-screen frames. */
|
|
3012
|
+
#prepareLinesArray(lines: readonly string[], width: number): string[] {
|
|
3013
|
+
const prepared: string[] = new Array(lines.length);
|
|
3014
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3015
|
+
prepared[i] = this.#prepareLine(lines[i]!, width).line;
|
|
3016
|
+
}
|
|
3017
|
+
return prepared;
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
#prepareLine(raw: string, width: number): PreparedLine {
|
|
3021
|
+
if (TERMINAL.isImageLine(raw)) {
|
|
3022
|
+
return { raw, width, line: raw };
|
|
3023
|
+
}
|
|
3024
|
+
const source = this.#lineFitSource(raw, width);
|
|
3025
|
+
const normalized = normalizeTerminalOutput(source);
|
|
3026
|
+
const asciiWidth = this.#ansiAsciiLineWidth(normalized, width);
|
|
3027
|
+
if ((asciiWidth ?? visibleWidth(normalized)) <= width) {
|
|
3028
|
+
return { raw, width, line: normalized };
|
|
3029
|
+
}
|
|
3030
|
+
const line = truncateToWidth(normalized, width, Ellipsis.Omit);
|
|
3031
|
+
return { raw, width, line };
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
#lineFitSource(raw: string, width: number): string {
|
|
3035
|
+
const safeWidth = Number.isFinite(width) ? Math.max(1, Math.trunc(width)) : 1;
|
|
3036
|
+
const maxSourceLength = Math.min(
|
|
3037
|
+
LINE_FIT_MAX_SOURCE_CODE_UNITS,
|
|
3038
|
+
Math.max(LINE_FIT_MIN_SOURCE_CODE_UNITS, safeWidth * LINE_FIT_SOURCE_WIDTH_MULTIPLIER),
|
|
3039
|
+
);
|
|
3040
|
+
if (raw.length <= maxSourceLength) return raw;
|
|
3041
|
+
|
|
3042
|
+
let output = "";
|
|
3043
|
+
let cells = 0;
|
|
3044
|
+
for (let i = 0; i < raw.length && cells < safeWidth; ) {
|
|
3045
|
+
if (raw.charCodeAt(i) === 0x1b) {
|
|
3046
|
+
const end = this.#ansiSequenceEnd(raw, i);
|
|
3047
|
+
if (end < 0) break;
|
|
3048
|
+
if (this.#ansiSequenceHasVisiblePayload(raw, i)) {
|
|
3049
|
+
const sequence = raw.slice(i, end);
|
|
3050
|
+
if (output.length + sequence.length <= maxSourceLength) {
|
|
3051
|
+
output += sequence;
|
|
3052
|
+
cells += visibleWidth(sequence);
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
i = end;
|
|
3056
|
+
continue;
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
const code = raw.charCodeAt(i);
|
|
3060
|
+
const next = code >= 0xd800 && code <= 0xdbff && i + 1 < raw.length ? i + 2 : i + 1;
|
|
3061
|
+
const char = raw.slice(i, next);
|
|
3062
|
+
const charWidth = visibleWidth(char);
|
|
3063
|
+
if (charWidth > 0 && cells + charWidth > safeWidth) break;
|
|
3064
|
+
if (output.length + char.length > maxSourceLength) {
|
|
3065
|
+
if (charWidth > 0) break;
|
|
3066
|
+
i = next;
|
|
3067
|
+
continue;
|
|
3068
|
+
}
|
|
3069
|
+
if (charWidth === 0) {
|
|
3070
|
+
const remainingVisibleCells = safeWidth - cells;
|
|
3071
|
+
const reservedCodeUnits = remainingVisibleCells * 2;
|
|
3072
|
+
if (output.length + char.length > maxSourceLength - reservedCodeUnits) {
|
|
3073
|
+
i = next;
|
|
3074
|
+
continue;
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
3077
|
+
output += char;
|
|
3078
|
+
cells += charWidth;
|
|
3079
|
+
i = next;
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
return output + SEGMENT_RESET;
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
#ansiSequenceEnd(line: string, start: number): number {
|
|
3086
|
+
const next = line.charCodeAt(start + 1);
|
|
3087
|
+
if (next === 0x5b) {
|
|
3088
|
+
let i = start + 2;
|
|
3089
|
+
while (i < line.length) {
|
|
3090
|
+
const final = line.charCodeAt(i);
|
|
3091
|
+
if (final >= 0x40 && final <= 0x7e) return i + 1;
|
|
3092
|
+
i++;
|
|
3093
|
+
}
|
|
3094
|
+
return -1;
|
|
3095
|
+
}
|
|
3096
|
+
if (next === 0x5d) {
|
|
3097
|
+
let i = start + 2;
|
|
3098
|
+
while (i < line.length) {
|
|
3099
|
+
const osc = line.charCodeAt(i);
|
|
3100
|
+
if (osc === 0x07) return i + 1;
|
|
3101
|
+
if (osc === 0x1b && line.charCodeAt(i + 1) === 0x5c) return i + 2;
|
|
3102
|
+
i++;
|
|
3103
|
+
}
|
|
3104
|
+
return -1;
|
|
3105
|
+
}
|
|
3106
|
+
return start + 2 <= line.length ? start + 2 : -1;
|
|
3107
|
+
}
|
|
3108
|
+
|
|
3109
|
+
#ansiSequenceHasVisiblePayload(line: string, start: number): boolean {
|
|
3110
|
+
// OSC 66 (`\x1b]66;META;TEXT\x1b\\`) carries visible cells inside the payload.
|
|
3111
|
+
return (
|
|
3112
|
+
line.charCodeAt(start + 1) === 0x5d &&
|
|
3113
|
+
line.charCodeAt(start + 2) === 0x36 &&
|
|
3114
|
+
line.charCodeAt(start + 3) === 0x36 &&
|
|
3115
|
+
line.charCodeAt(start + 4) === 0x3b
|
|
3116
|
+
);
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
#ansiAsciiLineWidth(line: string, maxWidth: number): number | undefined {
|
|
3120
|
+
let col = 0;
|
|
3121
|
+
for (let i = 0; i < line.length; ) {
|
|
3122
|
+
const code = line.charCodeAt(i);
|
|
3123
|
+
if (code === 0x1b) {
|
|
3124
|
+
const next = line.charCodeAt(i + 1);
|
|
3125
|
+
if (next === 0x5b) {
|
|
3126
|
+
let j = i + 2;
|
|
3127
|
+
while (j < line.length) {
|
|
3128
|
+
const final = line.charCodeAt(j);
|
|
3129
|
+
if (final >= 0x40 && final <= 0x7e) break;
|
|
3130
|
+
j++;
|
|
3131
|
+
}
|
|
3132
|
+
if (j >= line.length) return undefined;
|
|
3133
|
+
i = j + 1;
|
|
3134
|
+
continue;
|
|
3135
|
+
}
|
|
3136
|
+
if (next === 0x5d) {
|
|
3137
|
+
// OSC 66 text-sizing spans carry visible payload inside the OSC.
|
|
3138
|
+
// Fall back to visibleWidth() so scaled cells stay exact.
|
|
3139
|
+
if (
|
|
3140
|
+
line.charCodeAt(i + 2) === 0x36 &&
|
|
3141
|
+
line.charCodeAt(i + 3) === 0x36 &&
|
|
3142
|
+
line.charCodeAt(i + 4) === 0x3b
|
|
3143
|
+
) {
|
|
3144
|
+
return undefined;
|
|
3145
|
+
}
|
|
3146
|
+
let j = i + 2;
|
|
3147
|
+
while (j < line.length) {
|
|
3148
|
+
const osc = line.charCodeAt(j);
|
|
3149
|
+
if (osc === 0x07) {
|
|
3150
|
+
i = j + 1;
|
|
3151
|
+
break;
|
|
3152
|
+
}
|
|
3153
|
+
if (osc === 0x1b && line.charCodeAt(j + 1) === 0x5c) {
|
|
3154
|
+
i = j + 2;
|
|
3155
|
+
break;
|
|
3156
|
+
}
|
|
3157
|
+
j++;
|
|
3158
|
+
}
|
|
3159
|
+
if (j >= line.length) return undefined;
|
|
3160
|
+
continue;
|
|
3161
|
+
}
|
|
3162
|
+
return undefined;
|
|
3163
|
+
}
|
|
3164
|
+
if (code < 0x20 || code > 0x7e) return undefined;
|
|
3165
|
+
col++;
|
|
3166
|
+
if (col > maxWidth) return col;
|
|
3167
|
+
i++;
|
|
3168
|
+
}
|
|
3169
|
+
return col;
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
#lineRewriteSequence(line: string, width: number): string {
|
|
3173
|
+
if (TERMINAL.isImageLine(line)) return ERASE_LINE + line;
|
|
3174
|
+
const terminalLine = this.#terminalLine(line);
|
|
3175
|
+
const asciiWidth = this.#ansiAsciiLineWidth(line, width);
|
|
3176
|
+
if (asciiWidth !== undefined) {
|
|
3177
|
+
// Exact width model: skip the erase only when the row truly fills
|
|
3178
|
+
// the line (an EL there would eat the last cell via pending-wrap).
|
|
3179
|
+
return asciiWidth >= width ? terminalLine : terminalLine + ERASE_TO_END_OF_LINE;
|
|
3180
|
+
}
|
|
3181
|
+
// Non-ASCII rows: the native measure can over-count combining-heavy
|
|
3182
|
+
// scripts, so a row it calls "full" may render short and leave stale
|
|
3183
|
+
// cells from the previous occupant — which would then scroll into
|
|
3184
|
+
// history baked into the committed row. Erase the line first instead
|
|
3185
|
+
// (rewrites always start at column 1, so EL-to-end clears the whole
|
|
3186
|
+
// row); the leading reset keeps BCE on the default background.
|
|
3187
|
+
return SEGMENT_RESET + ERASE_TO_END_OF_LINE + terminalLine;
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3190
|
+
/**
|
|
3191
|
+
* Single state-transition point. Every emitter calls this exactly once at
|
|
3192
|
+
* the end so cursor/window accounting stays consistent.
|
|
3193
|
+
*/
|
|
3194
|
+
#commit(
|
|
3195
|
+
lines: readonly string[],
|
|
3196
|
+
window: string[],
|
|
3197
|
+
width: number,
|
|
3198
|
+
height: number,
|
|
3199
|
+
hardwareCursor: HardwareCursorUpdate,
|
|
3200
|
+
): void {
|
|
3201
|
+
this.#previousFrameLength = lines.length;
|
|
3202
|
+
this.#previousWindow = window;
|
|
3203
|
+
this.#forceViewportRepaintOnNextRender = false;
|
|
3204
|
+
this.#previousWidth = width;
|
|
3205
|
+
this.#previousHeight = height;
|
|
3206
|
+
this.#recordHardwareCursorUpdate(hardwareCursor);
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3209
|
+
#targetHardwareCursorState(
|
|
3210
|
+
cursorPos: { row: number; col: number } | null,
|
|
3211
|
+
totalLines: number,
|
|
3212
|
+
): HardwareCursorState | null {
|
|
3213
|
+
if (!cursorPos || totalLines <= 0) return null;
|
|
3214
|
+
return {
|
|
3215
|
+
row: Math.max(0, Math.min(cursorPos.row, totalLines - 1)),
|
|
3216
|
+
col: Math.max(0, cursorPos.col),
|
|
3217
|
+
visible: this.#showHardwareCursor,
|
|
3218
|
+
};
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3221
|
+
#recordHardwareCursorState(state: HardwareCursorState): void {
|
|
3222
|
+
this.#hardwareCursorRow = state.row;
|
|
3223
|
+
this.#hardwareCursorState = state;
|
|
3224
|
+
this.#hardwareCursorVisible = state.visible;
|
|
3225
|
+
this.#hardwareCursorVisibilityKnown = true;
|
|
3226
|
+
}
|
|
3227
|
+
|
|
3228
|
+
#recordHardwareCursorRowOnly(row: number, visible?: boolean): void {
|
|
3229
|
+
this.#hardwareCursorRow = row;
|
|
3230
|
+
this.#hardwareCursorState = null;
|
|
3231
|
+
if (visible !== undefined) {
|
|
3232
|
+
this.#hardwareCursorVisible = visible;
|
|
3233
|
+
this.#hardwareCursorVisibilityKnown = true;
|
|
3234
|
+
}
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
#recordHardwareCursorUpdate(update: HardwareCursorUpdate): void {
|
|
3238
|
+
if (update.state) {
|
|
3239
|
+
this.#recordHardwareCursorState(update.state);
|
|
3240
|
+
return;
|
|
3241
|
+
}
|
|
3242
|
+
this.#recordHardwareCursorRowOnly(update.toRow, update.visible);
|
|
3243
|
+
}
|
|
3244
|
+
|
|
3245
|
+
#recordHardwareCursorHidden(): void {
|
|
3246
|
+
this.#hardwareCursorVisible = false;
|
|
3247
|
+
this.#hardwareCursorVisibilityKnown = true;
|
|
3248
|
+
if (!this.#hardwareCursorState) return;
|
|
3249
|
+
this.#hardwareCursorState = { ...this.#hardwareCursorState, visible: false };
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
#forgetHardwareCursorState(): void {
|
|
3253
|
+
this.#hardwareCursorState = null;
|
|
3254
|
+
this.#hardwareCursorVisibilityKnown = false;
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3257
|
+
#sameHardwareCursorState(state: HardwareCursorState): boolean {
|
|
3258
|
+
const current = this.#hardwareCursorState;
|
|
3259
|
+
return (
|
|
3260
|
+
current !== null && current.row === state.row && current.col === state.col && current.visible === state.visible
|
|
3261
|
+
);
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
/**
|
|
3265
|
+
* Clear the viewport (optionally native scrollback) and replay the frame:
|
|
3266
|
+
* committed prefix `[0, chunkTo)` followed by the visible window. ED3
|
|
3267
|
+
* (`CSI 3 J`) is emitted here and only here, and only for gesture-driven
|
|
3268
|
+
* paints (session replace, resize, resetDisplay, or an explicit
|
|
3269
|
+
* `clearScrollback` initial paint).
|
|
3270
|
+
*/
|
|
3271
|
+
#emitFullPaint(
|
|
3272
|
+
frame: readonly string[],
|
|
3273
|
+
window: string[],
|
|
3274
|
+
width: number,
|
|
3275
|
+
height: number,
|
|
3276
|
+
cursorPos: { row: number; col: number } | null,
|
|
3277
|
+
purgeSequence: string,
|
|
3278
|
+
imageTransmitBuffer: string,
|
|
3279
|
+
options: {
|
|
3280
|
+
clearScrollback: boolean;
|
|
3281
|
+
chunkTo: number;
|
|
3282
|
+
windowTop: number;
|
|
3283
|
+
cursorTrackingLineCount: number;
|
|
3284
|
+
},
|
|
3285
|
+
): void {
|
|
3286
|
+
this.#fullRedrawCount += 1;
|
|
3287
|
+
const { chunkTo, windowTop, cursorTrackingLineCount } = options;
|
|
3288
|
+
// Map the frame-space cursor into paint space: committed-prefix rows
|
|
3289
|
+
// keep their index, visible-window rows land after the prefix, and a
|
|
3290
|
+
// cursor in neither region (hidden behind the overlay gap) hides.
|
|
3291
|
+
let paintCursorPos: { row: number; col: number } | null = null;
|
|
3292
|
+
if (cursorPos !== null) {
|
|
3293
|
+
if (cursorPos.row < chunkTo) {
|
|
3294
|
+
paintCursorPos = cursorPos;
|
|
3295
|
+
} else if (cursorPos.row >= windowTop && cursorPos.row < windowTop + height) {
|
|
3296
|
+
paintCursorPos = { row: chunkTo + cursorPos.row - windowTop, col: cursorPos.col };
|
|
3297
|
+
}
|
|
3298
|
+
}
|
|
3299
|
+
// ConPTY hosts bound the replay: merge prefix + window into one array
|
|
3300
|
+
// so #truncateLargeConptyFrame can measure the payload and retain only
|
|
3301
|
+
// the tail. Gated on the host check — everywhere else the merge would
|
|
3302
|
+
// copy a pointer per committed row (a 50k-row session = 50k-entry
|
|
3303
|
+
// array per resize step / theme change / session replace) just to be
|
|
3304
|
+
// returned unchanged. `paintLines` stays null unless truncation
|
|
3305
|
+
// actually rewrote the replay.
|
|
3306
|
+
let paintLines: string[] | null = null;
|
|
3307
|
+
let paintLineCount = chunkTo + height;
|
|
3308
|
+
if (isConPTYHosted()) {
|
|
3309
|
+
const merged = new Array<string>(chunkTo + height);
|
|
3310
|
+
for (let i = 0; i < chunkTo; i++) merged[i] = frame[i] ?? "";
|
|
3311
|
+
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
3312
|
+
merged[chunkTo + screenRow] = window[screenRow] ?? "";
|
|
3313
|
+
}
|
|
3314
|
+
const paint = this.#truncateLargeConptyFrame(merged, width, height, paintCursorPos);
|
|
3315
|
+
if (paint.lines !== merged) {
|
|
3316
|
+
paintLines = paint.lines;
|
|
3317
|
+
paintLineCount = paint.lines.length;
|
|
3318
|
+
paintCursorPos = paint.cursorPos;
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
3321
|
+
let buffer = this.#paintBeginSequence + this.#leaveResizeAltSequence() + purgeSequence;
|
|
3322
|
+
if (options.clearScrollback) {
|
|
3323
|
+
buffer += "\x1b[2J\x1b[H\x1b[3J";
|
|
3324
|
+
} else {
|
|
3325
|
+
// Best-effort: push the pre-paint screen into scrollback on
|
|
3326
|
+
// terminals that implement kitty's ED 22
|
|
3327
|
+
// (copy-screen-to-scrollback-then-erase). Always follow with ED 2 so
|
|
3328
|
+
// the viewport is cleared regardless; on real kitty, ED 2 over the
|
|
3329
|
+
// now-blank screen is a no-op and does not push a second copy.
|
|
3330
|
+
if (TERMINAL.supportsScreenToScrollback) buffer += "\x1b[22J";
|
|
3331
|
+
buffer += "\x1b[2J\x1b[H";
|
|
3332
|
+
}
|
|
3333
|
+
if (imageTransmitBuffer.length > 0) buffer += imageTransmitBuffer;
|
|
3334
|
+
// DECCARA fills optimize only the rows that stay visible; history-bound
|
|
3335
|
+
// rows are written as full styled strings (their background must
|
|
3336
|
+
// survive in scrollback, which DECCARA cannot reach).
|
|
3337
|
+
const visibleStart = Math.max(0, paintLineCount - height);
|
|
3338
|
+
let fillSequence = "";
|
|
3339
|
+
let visibleTexts: string[] | null = null;
|
|
3340
|
+
if (this.#deccaraFillsEnabled() && visibleStart < paintLineCount) {
|
|
3341
|
+
// Untruncated, the visible slice is exactly the caller's window
|
|
3342
|
+
// (visibleStart === chunkTo) — reuse it rather than copying;
|
|
3343
|
+
// planDeccaraFills fills its own `texts` and never mutates input.
|
|
3344
|
+
let visible = window;
|
|
3345
|
+
if (paintLines !== null) {
|
|
3346
|
+
visible = new Array<string>(paintLineCount - visibleStart);
|
|
3347
|
+
for (let k = 0; k < visible.length; k++) visible[k] = paintLines[visibleStart + k] ?? "";
|
|
3348
|
+
}
|
|
3349
|
+
const plan = planDeccaraFills(visible, width);
|
|
3350
|
+
visibleTexts = plan.texts;
|
|
3351
|
+
fillSequence = plan.sequence;
|
|
3352
|
+
}
|
|
3353
|
+
if (paintLines === null) {
|
|
3354
|
+
// Common path: emit straight from the source arrays (the
|
|
3355
|
+
// pre-merge two-loop form); byte-identical to replaying the
|
|
3356
|
+
// merged array.
|
|
3357
|
+
for (let i = 0; i < chunkTo; i++) {
|
|
3358
|
+
if (i > 0) buffer += "\r\n";
|
|
3359
|
+
buffer += this.#terminalLine(frame[i] ?? "");
|
|
3360
|
+
}
|
|
3361
|
+
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
3362
|
+
if (chunkTo + screenRow > 0) buffer += "\r\n";
|
|
3363
|
+
buffer += this.#terminalLine(visibleTexts ? (visibleTexts[screenRow] ?? "") : (window[screenRow] ?? ""));
|
|
3364
|
+
}
|
|
3365
|
+
} else {
|
|
3366
|
+
for (let i = 0; i < paintLines.length; i++) {
|
|
3367
|
+
if (i > 0) buffer += "\r\n";
|
|
3368
|
+
buffer += this.#terminalLine(
|
|
3369
|
+
visibleTexts && i >= visibleStart ? visibleTexts[i - visibleStart] : (paintLines[i] ?? ""),
|
|
3370
|
+
);
|
|
3371
|
+
}
|
|
3372
|
+
}
|
|
3373
|
+
buffer += fillSequence;
|
|
3374
|
+
// Park the hardware cursor at real content bottom, not the padded
|
|
3375
|
+
// window bottom — a later height shrink would otherwise scroll live
|
|
3376
|
+
// rows into scrollback and duplicate them per resize step.
|
|
3377
|
+
const contentRows = Math.max(1, Math.min(height, frame.length - windowTop));
|
|
3378
|
+
const parkUp = height - contentRows;
|
|
3379
|
+
if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
|
|
3380
|
+
const contentBottomRow = windowTop + contentRows - 1;
|
|
3381
|
+
const paintContentBottomRow = Math.max(0, paintLineCount - 1 - parkUp);
|
|
3382
|
+
const cursorControl = this.#cursorControlSequence(paintCursorPos, paintLineCount, paintContentBottomRow);
|
|
3383
|
+
buffer += cursorControl.seq;
|
|
3384
|
+
buffer += this.#paintEndSequence;
|
|
3385
|
+
this.terminal.write(buffer);
|
|
3386
|
+
|
|
3387
|
+
const committedCursorState = paintCursorPos
|
|
3388
|
+
? this.#targetHardwareCursorState(cursorPos, cursorTrackingLineCount)
|
|
3389
|
+
: null;
|
|
3390
|
+
const committedCursor = committedCursorState
|
|
3391
|
+
? {
|
|
3392
|
+
toRow: committedCursorState.row,
|
|
3393
|
+
state: committedCursorState,
|
|
3394
|
+
visible: committedCursorState.visible,
|
|
3395
|
+
}
|
|
3396
|
+
: {
|
|
3397
|
+
toRow: contentBottomRow,
|
|
3398
|
+
state: null,
|
|
3399
|
+
visible: cursorControl.visible,
|
|
3400
|
+
};
|
|
3401
|
+
|
|
3402
|
+
this.#committedRows = chunkTo;
|
|
3403
|
+
this.#windowTopRow = windowTop;
|
|
3404
|
+
this.#commit(frame, window, width, height, committedCursor);
|
|
3405
|
+
}
|
|
3406
|
+
|
|
3407
|
+
/**
|
|
3408
|
+
* Enter (or extend) the non-multiplexer resize fast path. Marks the drag
|
|
3409
|
+
* active so subsequent `#doRender` calls paint viewport-only, then (re)arms
|
|
3410
|
+
* the quiet-window timer whose callback ends the drag with one authoritative
|
|
3411
|
+
* full paint. Reset on every SIGWINCH, so the full replay fires only once the
|
|
3412
|
+
* user stops dragging.
|
|
3413
|
+
*/
|
|
3414
|
+
#beginResizeViewport(): void {
|
|
3415
|
+
this.#resizeViewportActive = true;
|
|
3416
|
+
this.#resizeViewportSettleTimer?.cancel();
|
|
3417
|
+
this.#resizeViewportSettleTimer = this.#renderScheduler.scheduleRender(() => {
|
|
3418
|
+
this.#resizeViewportSettleTimer = undefined;
|
|
3419
|
+
this.#resizeViewportActive = false;
|
|
3420
|
+
if (this.#stopped) return;
|
|
3421
|
+
// The drag is quiet: replay the rewrapped transcript authoritatively.
|
|
3422
|
+
// #resizeEventPending was preserved across every viewport-only frame
|
|
3423
|
+
// (the fast path never consumes it), so this classifies as a geometry
|
|
3424
|
+
// rebuild — ED3 + full history — and the clearScrollback intent below
|
|
3425
|
+
// matches the gesture-driven reset path.
|
|
3426
|
+
this.#resizeEventPending = true;
|
|
3427
|
+
this.requestRender(true, { clearScrollback: !isMultiplexerSession() });
|
|
3428
|
+
}, TUI.#RESIZE_VIEWPORT_SETTLE_MS);
|
|
3429
|
+
}
|
|
3430
|
+
|
|
3431
|
+
#requestResizeViewportPaint(): void {
|
|
3432
|
+
if (this.#stopped) return;
|
|
3433
|
+
this.#renderRequested = false;
|
|
3434
|
+
this.#executeRender();
|
|
3435
|
+
if (this.#renderRequested) this.#scheduleRender();
|
|
3436
|
+
}
|
|
3437
|
+
|
|
3438
|
+
/**
|
|
3439
|
+
* Compose and paint only the viewport for one resize fast-path frame.
|
|
3440
|
+
* State-isolated: advances no commit/window/diff field and calls neither
|
|
3441
|
+
* `#commit` nor `#emitFullPaint`, so the settle full paint reconciles against
|
|
3442
|
+
* the pre-drag screen state.
|
|
3443
|
+
*/
|
|
3444
|
+
#renderResizeViewport(width: number, height: number): void {
|
|
3445
|
+
if (width <= 0 || height <= 0) return;
|
|
3446
|
+
// Tail renders call block.render(), which observes inline images on the
|
|
3447
|
+
// budget. This is a STABLE (partial) pass: the tail walk is bottom-up and
|
|
3448
|
+
// sees only the visible subset, so display-order-by-call-order is wrong
|
|
3449
|
+
// here — `beginPass(true)` makes observe() replay the last committed
|
|
3450
|
+
// live/text split per image id instead, so images keep their on-screen
|
|
3451
|
+
// state through the drag. Reset the pass each frame so a long drag does
|
|
3452
|
+
// not accumulate; never endPass() here — that mutates the demotion ledger
|
|
3453
|
+
// off a partial walk. The settle paint's own beginPass()/endPass() is the
|
|
3454
|
+
// authoritative accounting, and its beginPass() wipes these frames.
|
|
3455
|
+
this.#imageBudget.beginPass(true);
|
|
3456
|
+
const { window, contentRows } = this.#composeResizeViewport(width, height);
|
|
3457
|
+
this.#emitResizeViewport(window, height, contentRows, width);
|
|
3458
|
+
this.#resizeViewportPaintCount += 1;
|
|
3459
|
+
}
|
|
3460
|
+
|
|
3461
|
+
/**
|
|
3462
|
+
* Build the viewport window for a resize fast-path frame: the bottom
|
|
3463
|
+
* `height` rows of the would-be full frame, collected bottom-up across root
|
|
3464
|
+
* children. {@link ViewportTailProvider}s (the transcript) yield only their
|
|
3465
|
+
* tail; the small live-region children below render in full — so every child
|
|
3466
|
+
* entirely above the fold is skipped. A frame shorter than the viewport is
|
|
3467
|
+
* top-aligned with blank rows below, matching the full-paint window geometry
|
|
3468
|
+
* (windowTop = max(0, frameLength - height)). Cursor markers are stripped
|
|
3469
|
+
* (the drag hides the hardware cursor) and rows are width-fitted via the
|
|
3470
|
+
* stateless preparer, so no persistent prepared-frame cache is touched.
|
|
3471
|
+
*/
|
|
3472
|
+
#composeResizeViewport(width: number, height: number): { window: readonly string[]; contentRows: number } {
|
|
3473
|
+
const tail: string[] = []; // bottom-first
|
|
3474
|
+
const children = this.children;
|
|
3475
|
+
for (let i = children.length - 1; i >= 0 && tail.length < height; i--) {
|
|
3476
|
+
const child = children[i]!;
|
|
3477
|
+
const provider = asViewportTailProvider(child);
|
|
3478
|
+
const rows = provider ? provider.renderViewportTail(width, height - tail.length) : child.render(width);
|
|
3479
|
+
for (let r = rows.length - 1; r >= 0 && tail.length < height; r--) {
|
|
3480
|
+
tail.push(rows[r]!);
|
|
3481
|
+
}
|
|
3482
|
+
}
|
|
3483
|
+
const count = tail.length;
|
|
3484
|
+
const window: string[] = new Array(height);
|
|
3485
|
+
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
3486
|
+
// `tail` holds the bottom `count` frame rows, bottom-first. They fill
|
|
3487
|
+
// the viewport when the frame overflows it and sit at the top (blanks
|
|
3488
|
+
// below) when it underflows.
|
|
3489
|
+
window[screenRow] = screenRow < count ? tail[count - 1 - screenRow]! : "";
|
|
3490
|
+
}
|
|
3491
|
+
this.#extractCursorMarkers(window);
|
|
3492
|
+
return { window: this.#prepareLinesArray(window, width), contentRows: count };
|
|
3493
|
+
}
|
|
3494
|
+
|
|
3495
|
+
/**
|
|
3496
|
+
* Resolve the active keyboard-enhancement enter sequence. Falls back to the
|
|
3497
|
+
* legacy `kittyEnableSequence` when a custom Terminal predates the
|
|
3498
|
+
* `keyboardEnhancementEnterSequence` property.
|
|
3499
|
+
*/
|
|
3500
|
+
#keyboardEnhancementEnter(): string {
|
|
3501
|
+
return this.terminal.keyboardEnhancementEnterSequence ?? this.terminal.kittyEnableSequence ?? "";
|
|
3502
|
+
}
|
|
3503
|
+
|
|
3504
|
+
/**
|
|
3505
|
+
* Resolve the active keyboard-enhancement exit sequence. Falls back to popping
|
|
3506
|
+
* kitty whenever a custom Terminal exposes its push sequence but predates the
|
|
3507
|
+
* `keyboardEnhancementExitSequence` property.
|
|
3508
|
+
*/
|
|
3509
|
+
#keyboardEnhancementExit(): string {
|
|
3510
|
+
const exit = this.terminal.keyboardEnhancementExitSequence;
|
|
3511
|
+
if (exit !== undefined) return exit ?? "";
|
|
3512
|
+
return this.terminal.kittyEnableSequence ? "\x1b[<u" : "";
|
|
3513
|
+
}
|
|
3514
|
+
|
|
3515
|
+
#enterResizeAltSequence(): string {
|
|
3516
|
+
if (this.#resizeAltActive || this.#altActive) return "";
|
|
3517
|
+
this.#resizeAltActive = true;
|
|
3518
|
+
setAltScreenActive(true);
|
|
3519
|
+
this.#forgetHardwareCursorState();
|
|
3520
|
+
this.#recordHardwareCursorHidden();
|
|
3521
|
+
return `${ALT_SCREEN_ENTER}${this.#keyboardEnhancementEnter()}`;
|
|
3522
|
+
}
|
|
3523
|
+
|
|
3524
|
+
#leaveResizeAltSequence(): string {
|
|
3525
|
+
if (!this.#resizeAltActive) return "";
|
|
3526
|
+
const enhancementExit = this.#keyboardEnhancementExit();
|
|
3527
|
+
this.#resizeAltActive = false;
|
|
3528
|
+
setAltScreenActive(false);
|
|
3529
|
+
this.#forgetHardwareCursorState();
|
|
3530
|
+
return `${enhancementExit}${ALT_SCREEN_EXIT}`;
|
|
3531
|
+
}
|
|
3532
|
+
|
|
3533
|
+
/**
|
|
3534
|
+
* Emit a throwaway viewport repaint for the resize fast path as an alternate-
|
|
3535
|
+
* screen per-row overwrite. The normal buffer may reflow full-width rows on a
|
|
3536
|
+
* width change before the app can repaint; keeping the drag on the alternate
|
|
3537
|
+
* screen makes those transient resizes truncate instead of pushing wrapped
|
|
3538
|
+
* fragments into native scrollback. Normal-screen history is rebuilt once at
|
|
3539
|
+
* settle via `#emitFullPaint`.
|
|
3540
|
+
*/
|
|
3541
|
+
#emitResizeViewport(window: readonly string[], height: number, contentRows: number, width: number): void {
|
|
3542
|
+
let buffer = `${this.#paintBeginSequence + this.#enterResizeAltSequence()}\x1b[H`;
|
|
3543
|
+
for (let r = 0; r < height; r++) {
|
|
3544
|
+
if (r > 0) buffer += "\r\n";
|
|
3545
|
+
buffer += this.#lineRewriteSequence(window[r] ?? "", width);
|
|
3546
|
+
}
|
|
3547
|
+
// Park the hardware cursor at the real content bottom, not the padded
|
|
3548
|
+
// viewport bottom: a later height shrink would otherwise scroll the live
|
|
3549
|
+
// rows below the cursor into native scrollback and duplicate them until
|
|
3550
|
+
// the settle rebuild erases it.
|
|
3551
|
+
const parkUp = height - Math.max(1, contentRows);
|
|
3552
|
+
if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
|
|
3553
|
+
buffer += this.#paintEndSequence;
|
|
3554
|
+
this.terminal.write(buffer);
|
|
3555
|
+
}
|
|
3556
|
+
|
|
3557
|
+
/** Topmost visible overlay requests the alternate-screen buffer. */
|
|
3558
|
+
#wantsAltScreen(): boolean {
|
|
3559
|
+
for (let i = this.overlayStack.length - 1; i >= 0; i--) {
|
|
3560
|
+
const entry = this.overlayStack[i]!;
|
|
3561
|
+
if (!this.#isOverlayVisible(entry)) continue;
|
|
3562
|
+
return entry.options?.fullscreen === true;
|
|
3563
|
+
}
|
|
3564
|
+
return false;
|
|
3565
|
+
}
|
|
3566
|
+
|
|
3567
|
+
/**
|
|
3568
|
+
* Compose and paint a single fullscreen overlay frame on the alt buffer.
|
|
3569
|
+
* Cursor markers are stripped (the modal draws its own in-band caret and
|
|
3570
|
+
* keeps the hardware cursor hidden), and only the modal is composited over a
|
|
3571
|
+
* blank base — the transcript is never touched while the alt buffer is up.
|
|
3572
|
+
*/
|
|
3573
|
+
#renderAltFrame(width: number, height: number): void {
|
|
3574
|
+
const base: string[] = new Array(Math.max(0, height)).fill("");
|
|
3575
|
+
let lines = this.#compositeOverlaysIntoWindow(base, width, height);
|
|
3576
|
+
this.#extractCursorMarkers(lines);
|
|
3577
|
+
lines = this.#prepareLinesArray(lines, width);
|
|
3578
|
+
this.#emitAltFrame(lines, width, height);
|
|
3579
|
+
}
|
|
3580
|
+
|
|
3581
|
+
/**
|
|
3582
|
+
* Full per-row viewport rewrite on the alt buffer. Emits only sync-output
|
|
3583
|
+
* brackets, a cursor home, and per-row rewrites — never ED3, append-tail, or
|
|
3584
|
+
* any native-scrollback byte, so it is fully isolated from the planner and
|
|
3585
|
+
* #commit. The hardware cursor stays hidden (it is never re-shown here).
|
|
3586
|
+
*/
|
|
3587
|
+
#emitAltFrame(lines: string[], width: number, height: number): void {
|
|
3588
|
+
const fitted: string[] = new Array(height);
|
|
3589
|
+
for (let r = 0; r < height; r++) fitted[r] = lines[r] ?? "";
|
|
3590
|
+
// Flush queued image-data transmits (`a=t`, no visible output) before the
|
|
3591
|
+
// paint so id-keyed placements and placeholder cells composed into this
|
|
3592
|
+
// frame resolve against loaded data. The normal-screen path flushes these
|
|
3593
|
+
// ahead of its paint; without this, an image first shown inside a
|
|
3594
|
+
// fullscreen overlay (e.g. the settings shape preview) would render as
|
|
3595
|
+
// blank placeholder cells until the overlay closed.
|
|
3596
|
+
const imageTransmits = this.#imageBudget.takeTransmits();
|
|
3597
|
+
if (imageTransmits.length > 0) {
|
|
3598
|
+
let transmitBuffer = "";
|
|
3599
|
+
for (const seq of imageTransmits) transmitBuffer += seq;
|
|
3600
|
+
this.terminal.write(transmitBuffer);
|
|
3601
|
+
}
|
|
3602
|
+
// Skip an identical repaint (the modal is mostly static between
|
|
3603
|
+
// keystrokes) — unless a forced repaint (resetDisplay,
|
|
3604
|
+
// requestRender(true)) is pending: the redraw gesture must repair a
|
|
3605
|
+
// corrupted modal even when our cached frame is byte-identical.
|
|
3606
|
+
const force = this.#forceViewportRepaintOnNextRender;
|
|
3607
|
+
this.#forceViewportRepaintOnNextRender = false;
|
|
3608
|
+
if (!force && this.#altPreviousLines.length === height) {
|
|
3609
|
+
let same = true;
|
|
3610
|
+
for (let r = 0; r < height; r++) {
|
|
3611
|
+
if (fitted[r] !== this.#altPreviousLines[r]) {
|
|
3612
|
+
same = false;
|
|
3613
|
+
break;
|
|
3614
|
+
}
|
|
3615
|
+
}
|
|
3616
|
+
if (same) return;
|
|
3617
|
+
}
|
|
3618
|
+
let buffer = `${this.#paintBeginSequence}\x1b[H`;
|
|
3619
|
+
for (let r = 0; r < height; r++) {
|
|
3620
|
+
if (r > 0) buffer += "\r\n";
|
|
3621
|
+
buffer += this.#lineRewriteSequence(fitted[r], width);
|
|
3622
|
+
}
|
|
3623
|
+
buffer += this.#paintEndSequence;
|
|
3624
|
+
this.terminal.write(buffer);
|
|
3625
|
+
this.#altPreviousLines = fitted;
|
|
3626
|
+
this.#fullRedrawCount += 1;
|
|
3627
|
+
}
|
|
3628
|
+
|
|
3629
|
+
/**
|
|
3630
|
+
* Incremental frame update. Three byte shapes:
|
|
3631
|
+
*
|
|
3632
|
+
* - scroll-append: the rows leaving the screen are exactly the newly
|
|
3633
|
+
* committed chunk, already painted with final content — emit `\r\n` plus
|
|
3634
|
+
* the new bottom rows, then rewrite whatever else changed in place;
|
|
3635
|
+
* - in-window diff: nothing scrolls, nothing commits — rewrite the changed
|
|
3636
|
+
* row range (cursor-only when nothing changed);
|
|
3637
|
+
* - seam rewrite: write the chunk at the scrollback seam, then rewrite the
|
|
3638
|
+
* whole window (live-region re-layout, hidden-gap backfill, mux resize).
|
|
3639
|
+
*
|
|
3640
|
+
* Only chunk rows ever enter native history; the live window repaints in
|
|
3641
|
+
* place with relative moves. This path never emits ED2/ED3 or an absolute
|
|
3642
|
+
* cursor home — those snap a reader scrolled into history back to the
|
|
3643
|
+
* bottom on several terminal families.
|
|
3644
|
+
*/
|
|
3645
|
+
#emitUpdate(
|
|
3646
|
+
frame: readonly string[],
|
|
3647
|
+
window: string[],
|
|
3648
|
+
width: number,
|
|
3649
|
+
height: number,
|
|
3650
|
+
cursorPos: { row: number; col: number } | null,
|
|
3651
|
+
purgeSequence: string,
|
|
3652
|
+
options: {
|
|
3653
|
+
chunkTo: number;
|
|
3654
|
+
windowTop: number;
|
|
3655
|
+
prevWindowTop: number;
|
|
3656
|
+
prevHardwareCursorRow: number;
|
|
3657
|
+
forceWindowRewrite: boolean;
|
|
3658
|
+
repaintVirtualScrollInPlace: boolean;
|
|
3659
|
+
cursorTrackingLineCount: number;
|
|
3660
|
+
},
|
|
3661
|
+
): void {
|
|
3662
|
+
const {
|
|
3663
|
+
chunkTo,
|
|
3664
|
+
windowTop,
|
|
3665
|
+
prevWindowTop,
|
|
3666
|
+
prevHardwareCursorRow,
|
|
3667
|
+
forceWindowRewrite,
|
|
3668
|
+
repaintVirtualScrollInPlace,
|
|
3669
|
+
cursorTrackingLineCount,
|
|
3670
|
+
} = options;
|
|
3671
|
+
const chunkFrom = this.#committedRows;
|
|
3672
|
+
const chunkLength = chunkTo - chunkFrom;
|
|
3673
|
+
const scroll = windowTop - prevWindowTop;
|
|
3674
|
+
const previousWindow = this.#previousWindow;
|
|
3675
|
+
const contentRows = Math.max(1, Math.min(height, frame.length - windowTop));
|
|
3676
|
+
const contentBottomRow = windowTop + contentRows - 1;
|
|
3677
|
+
// Terminals clamp the hardware cursor to the viewport on resize; clamp
|
|
3678
|
+
// our tracking to match so relative moves land correctly.
|
|
3679
|
+
const clampedCursor = Math.min(prevHardwareCursorRow, prevWindowTop + height - 1);
|
|
3680
|
+
const currentScreenRow = Math.max(0, Math.min(height - 1, clampedCursor - prevWindowTop));
|
|
3681
|
+
|
|
3682
|
+
// Scroll-append: committing exactly the rows that scroll off the top,
|
|
3683
|
+
// with content untouched since they were painted.
|
|
3684
|
+
if (
|
|
3685
|
+
!forceWindowRewrite &&
|
|
3686
|
+
chunkLength > 0 &&
|
|
3687
|
+
chunkLength === scroll &&
|
|
3688
|
+
scroll < height &&
|
|
3689
|
+
chunkFrom === prevWindowTop
|
|
3690
|
+
) {
|
|
3691
|
+
let prefixIntact = previousWindow.length === height;
|
|
3692
|
+
for (let i = 0; prefixIntact && i < chunkLength; i++) {
|
|
3693
|
+
if (previousWindow[i] !== frame[chunkFrom + i]) prefixIntact = false;
|
|
3694
|
+
}
|
|
3695
|
+
if (prefixIntact) {
|
|
3696
|
+
let buffer = this.#paintBeginSequence + purgeSequence;
|
|
3697
|
+
const moveToBottom = height - 1 - currentScreenRow;
|
|
3698
|
+
if (moveToBottom > 0) buffer += `\x1b[${moveToBottom}B`;
|
|
3699
|
+
for (let r = height - scroll; r < height; r++) {
|
|
3700
|
+
buffer += `\r\n${this.#lineRewriteSequence(window[r] ?? "", width)}`;
|
|
3701
|
+
}
|
|
3702
|
+
// Rewrite any remaining changed rows after the shift.
|
|
3703
|
+
let firstChanged = -1;
|
|
3704
|
+
let lastChanged = -1;
|
|
3705
|
+
for (let r = 0; r < height - scroll; r++) {
|
|
3706
|
+
if ((window[r] ?? "") === (previousWindow[r + scroll] ?? "")) continue;
|
|
3707
|
+
if (firstChanged === -1) firstChanged = r;
|
|
3708
|
+
lastChanged = r;
|
|
3709
|
+
}
|
|
3710
|
+
let cursorFromRow = windowTop + height - 1;
|
|
3711
|
+
if (firstChanged !== -1) {
|
|
3712
|
+
const up = height - 1 - firstChanged;
|
|
3713
|
+
if (up > 0) buffer += `\x1b[${up}A`;
|
|
3714
|
+
buffer += "\r";
|
|
3715
|
+
for (let r = firstChanged; r <= lastChanged; r++) {
|
|
3716
|
+
if (r > firstChanged) buffer += "\r\n";
|
|
3717
|
+
buffer += this.#lineRewriteSequence(window[r] ?? "", width);
|
|
3718
|
+
}
|
|
3719
|
+
cursorFromRow = windowTop + lastChanged;
|
|
3720
|
+
}
|
|
3721
|
+
const cursorControl = this.#cursorControlSequence(cursorPos, cursorTrackingLineCount, cursorFromRow);
|
|
3722
|
+
buffer += cursorControl.seq;
|
|
3723
|
+
buffer += this.#paintEndSequence;
|
|
3724
|
+
this.terminal.write(buffer);
|
|
3725
|
+
this.#committedRows = chunkTo;
|
|
3726
|
+
this.#windowTopRow = windowTop;
|
|
3727
|
+
this.#commit(frame, window, width, height, cursorControl);
|
|
3728
|
+
return;
|
|
3729
|
+
}
|
|
3730
|
+
}
|
|
3731
|
+
|
|
3732
|
+
// In-window diff: nothing commits. While an overlay is visible, repaint
|
|
3733
|
+
// the full viewport in place from a top-clamped cursor origin. Overlay
|
|
3734
|
+
// cursor-only frames can leave the tracked row behind the physical cursor;
|
|
3735
|
+
// a relative partial rewrite from that stale origin can CRLF on the bottom
|
|
3736
|
+
// row and scroll native history without appending to the commit tape.
|
|
3737
|
+
const overlayInPlaceRewrite = repaintVirtualScrollInPlace;
|
|
3738
|
+
if (chunkLength === 0 && (scroll === 0 || overlayInPlaceRewrite)) {
|
|
3739
|
+
if (forceWindowRewrite || overlayInPlaceRewrite) this.#fullRedrawCount += 1;
|
|
3740
|
+
let firstChanged = forceWindowRewrite || overlayInPlaceRewrite ? 0 : -1;
|
|
3741
|
+
let lastChanged = forceWindowRewrite || overlayInPlaceRewrite ? height - 1 : -1;
|
|
3742
|
+
if (!forceWindowRewrite && !overlayInPlaceRewrite) {
|
|
3743
|
+
const comparable = previousWindow.length === height;
|
|
3744
|
+
for (let r = 0; r < height; r++) {
|
|
3745
|
+
if (comparable && (window[r] ?? "") === (previousWindow[r] ?? "")) continue;
|
|
3746
|
+
if (firstChanged === -1) firstChanged = r;
|
|
3747
|
+
lastChanged = r;
|
|
3748
|
+
}
|
|
3749
|
+
}
|
|
3750
|
+
if (firstChanged === -1) {
|
|
3751
|
+
if (purgeSequence.length > 0) this.terminal.write(purgeSequence);
|
|
3752
|
+
this.#writeCursorPosition(cursorPos, cursorTrackingLineCount);
|
|
3753
|
+
this.#previousWidth = width;
|
|
3754
|
+
this.#previousHeight = height;
|
|
3755
|
+
return;
|
|
3756
|
+
}
|
|
3757
|
+
let buffer = this.#paintBeginSequence + purgeSequence;
|
|
3758
|
+
if (overlayInPlaceRewrite) {
|
|
3759
|
+
// The cursor tracker can be stale after overlay-only frames. A large
|
|
3760
|
+
// CUU clamps at the viewport top without using absolute cursor home,
|
|
3761
|
+
// so the following full-window rewrite cannot overflow the bottom.
|
|
3762
|
+
if (height > 1) buffer += `\x1b[${height - 1}A`;
|
|
3763
|
+
} else {
|
|
3764
|
+
const rowDelta = firstChanged - currentScreenRow;
|
|
3765
|
+
if (rowDelta > 0) buffer += `\x1b[${rowDelta}B`;
|
|
3766
|
+
else if (rowDelta < 0) buffer += `\x1b[${-rowDelta}A`;
|
|
3767
|
+
}
|
|
3768
|
+
buffer += "\r";
|
|
3769
|
+
// DECCARA-optimize the contiguous rewritten range (visible rows
|
|
3770
|
+
// only; rectangles are absolute screen rows).
|
|
3771
|
+
let fillTexts: string[] | null = null;
|
|
3772
|
+
let fillSequence = "";
|
|
3773
|
+
if (this.#deccaraFillsEnabled()) {
|
|
3774
|
+
const slice: string[] = new Array(lastChanged - firstChanged + 1);
|
|
3775
|
+
for (let r = firstChanged; r <= lastChanged; r++) slice[r - firstChanged] = window[r] ?? "";
|
|
3776
|
+
const plan = planDeccaraFills(slice, width, firstChanged);
|
|
3777
|
+
fillTexts = plan.texts;
|
|
3778
|
+
fillSequence = plan.sequence;
|
|
3779
|
+
}
|
|
3780
|
+
for (let r = firstChanged; r <= lastChanged; r++) {
|
|
3781
|
+
if (r > firstChanged) buffer += "\r\n";
|
|
3782
|
+
buffer += this.#lineRewriteSequence(fillTexts ? fillTexts[r - firstChanged] : (window[r] ?? ""), width);
|
|
3783
|
+
}
|
|
3784
|
+
buffer += fillSequence;
|
|
3785
|
+
// Never park below real content (a height shrink would scroll live
|
|
3786
|
+
// rows into history and duplicate them per resize step).
|
|
3787
|
+
let cursorFromRow = windowTop + lastChanged;
|
|
3788
|
+
const contentBottomScreenRow = contentBottomRow - windowTop;
|
|
3789
|
+
if (lastChanged > contentBottomScreenRow) {
|
|
3790
|
+
buffer += `\x1b[${lastChanged - contentBottomScreenRow}A`;
|
|
3791
|
+
cursorFromRow = contentBottomRow;
|
|
3792
|
+
}
|
|
3793
|
+
const cursorControl = this.#cursorControlSequence(cursorPos, cursorTrackingLineCount, cursorFromRow);
|
|
3794
|
+
buffer += cursorControl.seq;
|
|
3795
|
+
buffer += this.#paintEndSequence;
|
|
3796
|
+
this.terminal.write(buffer);
|
|
3797
|
+
this.#windowTopRow = windowTop;
|
|
3798
|
+
this.#commit(frame, window, width, height, cursorControl);
|
|
3799
|
+
return;
|
|
3800
|
+
}
|
|
3801
|
+
|
|
3802
|
+
// Seam rewrite: write the chunk into history, then the whole window.
|
|
3803
|
+
// Cursor moves to the window top with a relative move; the chunk rows
|
|
3804
|
+
// pass through the screen and scroll off as the window rows are written
|
|
3805
|
+
// below them, so the rows entering scrollback are exactly the chunk.
|
|
3806
|
+
this.#fullRedrawCount += 1;
|
|
3807
|
+
let buffer = this.#paintBeginSequence + purgeSequence;
|
|
3808
|
+
if (currentScreenRow > 0) buffer += `\x1b[${currentScreenRow}A`;
|
|
3809
|
+
buffer += "\r";
|
|
3810
|
+
let wroteLine = false;
|
|
3811
|
+
for (let i = chunkFrom; i < chunkTo; i++) {
|
|
3812
|
+
if (wroteLine) buffer += "\r\n";
|
|
3813
|
+
buffer += this.#lineRewriteSequence(frame[i] ?? "", width);
|
|
3814
|
+
wroteLine = true;
|
|
3815
|
+
}
|
|
3816
|
+
for (let screenRow = 0; screenRow < height; screenRow++) {
|
|
3817
|
+
if (wroteLine) buffer += "\r\n";
|
|
3818
|
+
buffer += this.#lineRewriteSequence(window[screenRow] ?? "", width);
|
|
3819
|
+
wroteLine = true;
|
|
3820
|
+
}
|
|
3821
|
+
const parkUp = height - 1 - (contentBottomRow - windowTop);
|
|
3822
|
+
if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
|
|
3823
|
+
const cursorControl = this.#cursorControlSequence(cursorPos, cursorTrackingLineCount, contentBottomRow);
|
|
3824
|
+
buffer += cursorControl.seq;
|
|
3825
|
+
buffer += this.#paintEndSequence;
|
|
3826
|
+
this.terminal.write(buffer);
|
|
3827
|
+
this.#committedRows = chunkTo;
|
|
3828
|
+
this.#windowTopRow = windowTop;
|
|
3829
|
+
this.#commit(frame, window, width, height, cursorControl);
|
|
3830
|
+
}
|
|
3831
|
+
|
|
3832
|
+
/** Optional intent log under PI_DEBUG_REDRAW. */
|
|
3833
|
+
#logRedraw(intent: RenderIntent, newLength: number, height: number): void {
|
|
3834
|
+
if (!$flag("PI_DEBUG_REDRAW")) return;
|
|
3835
|
+
const detail =
|
|
3836
|
+
intent.kind === "update"
|
|
3837
|
+
? `update(chunk=${this.#committedRows}..${intent.chunkTo}, windowTop=${intent.windowTop})`
|
|
3838
|
+
: `fullPaint(clearScrollback=${intent.clearScrollback})`;
|
|
3839
|
+
const state =
|
|
3840
|
+
`committed=${this.#committedRows}, windowTop=${this.#windowTopRow}, ` +
|
|
3841
|
+
`lrStart=${this.#nativeScrollbackLiveRegionStart}, commitSafeEnd=${this.#nativeScrollbackCommitSafeEnd}`;
|
|
3842
|
+
const msg = `[${new Date().toISOString()}] render: ${detail} (prev=${this.#previousFrameLength}, new=${newLength}, height=${height}, ${state})\n`;
|
|
3843
|
+
fs.appendFileSync(getDebugLogPath(), msg);
|
|
3844
|
+
}
|
|
3845
|
+
|
|
3846
|
+
/**
|
|
3847
|
+
* Build cursor control sequences to position the hardware cursor for the IME
|
|
3848
|
+
* candidate window. Returns escape sequences and the resulting cursor row for
|
|
3849
|
+
* the caller to update `#hardwareCursorRow`. The sequences should be appended
|
|
3850
|
+
* into the caller's own synchronized output block to avoid a flicker between
|
|
3851
|
+
* content and cursor frames.
|
|
3852
|
+
*/
|
|
3853
|
+
#cursorControlSequence(
|
|
3854
|
+
cursorPos: { row: number; col: number } | null,
|
|
3855
|
+
totalLines: number,
|
|
3856
|
+
fromRow: number,
|
|
3857
|
+
): CursorControlResult {
|
|
3858
|
+
// No IME target or no content — hide cursor regardless of preference.
|
|
3859
|
+
const target = this.#targetHardwareCursorState(cursorPos, totalLines);
|
|
3860
|
+
if (!target) {
|
|
3861
|
+
return { seq: "\x1b[?25l", toRow: fromRow, toCol: 0, visible: false, state: null };
|
|
3862
|
+
}
|
|
3863
|
+
|
|
3864
|
+
// Move cursor from current position to target.
|
|
3865
|
+
const rowDelta = target.row - fromRow;
|
|
3866
|
+
let seq = "";
|
|
3867
|
+
if (rowDelta > 0) {
|
|
3868
|
+
seq += `\x1b[${rowDelta}B`; // Move down
|
|
3869
|
+
} else if (rowDelta < 0) {
|
|
3870
|
+
seq += `\x1b[${-rowDelta}A`; // Move up
|
|
3871
|
+
}
|
|
3872
|
+
// Move to absolute column (1-indexed)
|
|
3873
|
+
seq += `\x1b[${target.col + 1}G`;
|
|
3874
|
+
seq += target.visible ? "\x1b[?25h" : "\x1b[?25l";
|
|
3875
|
+
|
|
3876
|
+
return { seq, toRow: target.row, toCol: target.col, visible: target.visible, state: target };
|
|
3877
|
+
}
|
|
3878
|
+
|
|
3879
|
+
#isHiddenCursorKnown(): boolean {
|
|
3880
|
+
return this.#hardwareCursorVisibilityKnown && !this.#hardwareCursorVisible;
|
|
3881
|
+
}
|
|
3882
|
+
|
|
3883
|
+
/**
|
|
3884
|
+
* Write the hardware cursor position to the terminal as a standalone
|
|
3885
|
+
* synchronized output block. Use when there is no surrounding render buffer
|
|
3886
|
+
* to embed the sequences into.
|
|
3887
|
+
*/
|
|
3888
|
+
#writeCursorPosition(cursorPos: { row: number; col: number } | null, totalLines: number): void {
|
|
3889
|
+
const target = this.#targetHardwareCursorState(cursorPos, totalLines);
|
|
3890
|
+
if (!target) {
|
|
3891
|
+
if (this.#isHiddenCursorKnown()) return;
|
|
3892
|
+
this.terminal.hideCursor();
|
|
3893
|
+
this.#recordHardwareCursorHidden();
|
|
3894
|
+
return;
|
|
3895
|
+
}
|
|
3896
|
+
if (this.#sameHardwareCursorState(target)) return;
|
|
3897
|
+
const cursorControl = this.#cursorControlSequence(cursorPos, totalLines, this.#hardwareCursorRow);
|
|
3898
|
+
this.terminal.write(`${this.#cursorBeginSequence}${cursorControl.seq}${this.#cursorEndSequence}`);
|
|
3899
|
+
this.#recordHardwareCursorUpdate(cursorControl);
|
|
3900
|
+
}
|
|
3901
|
+
}
|