@prometheus-ai/tui 0.5.3 → 0.5.8

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