@linxiraos/pi-tui 1.0.0

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