@oh-my-pi/pi-tui 17.4.2 → 18.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tui.ts CHANGED
@@ -1,31 +1,27 @@
1
1
  /**
2
- * Minimal TUI implementation with differential rendering.
2
+ * Minimal TUI implementation with explicit history batches.
3
3
  *
4
- * Append-only render contract: rows committed to native scrollback are
5
- * immutable — the tape is the terminal's visual record. Whatever scrolls
6
- * above the window enters history exactly once, in order: as exact-final
7
- * bytes when the component seam (`NativeScrollbackLiveRegion`) declared them
8
- * final, else as a frozen snapshot of what was on screen. When recorded
9
- * history diverges from the frame (a finalized block replacing its
10
- * scrolled-off live render), the engine erases and replays (ED3, `CSI 3 J`)
11
- * so history holds the content exactly once — the same replay used for
12
- * gestures (session replace, resize, resetDisplay). Multiplexer panes, where
13
- * ED3 is unsafe, instead re-anchor and recommit below the stale fragment —
14
- * duplication, never loss. The engine never probes or guesses the terminal's
15
- * scroll position, and the hot path clamps over-wide lines instead of
16
- * throwing. See `docs/tui-core-renderer.md`.
4
+ * Two output channels: a product-owned {@link TerminalFrameProvider} returns,
5
+ * per frame, an optional immutable {@link HistoryBatch} (finalized transcript
6
+ * rows appended to native history exactly once, gated by a monotonic id and
7
+ * acknowledgement) plus the complete mutable viewport. The writer anchors the
8
+ * viewport directly below whatever history remains visible, diffs
9
+ * viewport-only frames, and never infers finality from a row's position.
10
+ * Destructive clears (ED3) happen through explicit user gestures or configured
11
+ * settled-width rebuilds. Hosts without a
12
+ * provider paint their composed children as a bounded viewport and never
13
+ * touch history. See `docs/tui-core-renderer.md`.
17
14
  */
18
15
  import * as fs from "node:fs";
19
16
  import { performance } from "node:perf_hooks";
20
- import { $flag, getDebugLogPath } from "@oh-my-pi/pi-utils";
17
+ import { $flag, getDebugLogPath, logger } from "@oh-my-pi/pi-utils";
21
18
  import { DEFAULT_MAX_INLINE_IMAGES, ImageBudget } from "./components/image";
22
- import { planDeccaraFills } from "./deccara";
23
19
  import { isKeyRelease, matchesKey } from "./keys";
24
20
  import { LoopWatchdog } from "./loop-watchdog";
25
- import { isConPTYHosted, setAltScreenActive, type Terminal } from "./terminal";
21
+ import { setAltScreenActive, type Terminal } from "./terminal";
26
22
  import {
23
+ encodeKittyDeleteAllImages,
27
24
  encodeKittyDeleteImage,
28
- encodeKittyDeletePlacement,
29
25
  encodeKittyPlacementLine,
30
26
  ImageProtocol,
31
27
  isImageProtocolForced,
@@ -82,10 +78,6 @@ const PAINT_BEGIN = `${HIDE_CURSOR}${SYNC_OUTPUT_BEGIN}${DISABLE_AUTOWRAP}`;
82
78
  const PAINT_END = `${ENABLE_AUTOWRAP}${SYNC_OUTPUT_END}`;
83
79
  const PAINT_BEGIN_NO_SYNC = `${HIDE_CURSOR}${DISABLE_AUTOWRAP}`;
84
80
  const PAINT_END_NO_SYNC = ENABLE_AUTOWRAP;
85
- const CURSOR_BEGIN = `${HIDE_CURSOR}${SYNC_OUTPUT_BEGIN}`;
86
- const CURSOR_BEGIN_NO_SYNC = HIDE_CURSOR;
87
- const CURSOR_END = SYNC_OUTPUT_END;
88
- const CURSOR_END_NO_SYNC = "";
89
81
  // Mouse reporting is scoped to fullscreen overlays that opt into pointer
90
82
  // interaction. 1000h = button click tracking, 1003h = any-motion tracking for
91
83
  // hover targets, and 1006h = SGR extended coordinates past column/row 223.
@@ -93,8 +85,6 @@ const CURSOR_END_NO_SYNC = "";
93
85
  // native text selection.
94
86
  const MOUSE_TRACKING_ON = "\x1b[?1000h\x1b[?1003h\x1b[?1006h";
95
87
  const MOUSE_TRACKING_OFF = "\x1b[?1006l\x1b[?1003l\x1b[?1000l";
96
- const ALT_SCREEN_ENTER = "\x1b[?1049h";
97
- const ALT_SCREEN_EXIT = "\x1b[?1049l";
98
88
 
99
89
  type InputListenerResult = { consume?: boolean; data?: string } | undefined;
100
90
  type InputListener = (data: string) => InputListenerResult;
@@ -113,10 +103,43 @@ export interface RenderScheduler {
113
103
  export interface TUIOptions {
114
104
  renderScheduler?: RenderScheduler;
115
105
  }
106
+ /** Physical terminal dimensions supplied to a frame provider. */
107
+ export interface ViewportSize {
108
+ readonly columns: number;
109
+ readonly rows: number;
110
+ }
111
+
112
+ /** Immutable finalized rows offered until the terminal accepts this identifier. */
113
+ export interface HistoryBatch {
114
+ readonly id: number;
115
+ readonly rows: readonly string[];
116
+ }
117
+
118
+ /** One history append and the complete mutable viewport for a terminal frame. */
119
+ export interface TerminalFramePlan {
120
+ readonly history?: HistoryBatch;
121
+ readonly viewport: readonly string[];
122
+ }
123
+
124
+ /** Produces bounded terminal frames and retires acknowledged history batches. */
125
+ export interface TerminalFrameProvider {
126
+ renderFrame(viewport: ViewportSize): TerminalFramePlan;
127
+ acknowledgeHistory(id: number): void;
128
+ /** Full semantic viewport used only on the transient resize buffer. */
129
+ renderResizeFrame?(viewport: ViewportSize): readonly string[];
130
+ /** Re-offer finalized history after a display reset or resize replay. */
131
+ resetHistory?(): void;
132
+ }
116
133
 
117
134
  export interface TUIStartOptions {
118
135
  /** Clear saved native scrollback before the first paint. */
119
136
  clearScrollback?: boolean;
137
+ /**
138
+ * Paint without owning stdin: the terminal stays in cooked mode (kernel
139
+ * echo + line editing at the hardware cursor) until {@link TUI.enableInput}
140
+ * switches to raw input and replays the kernel-buffered keystrokes.
141
+ */
142
+ deferInput?: boolean;
120
143
  }
121
144
 
122
145
  const DEFAULT_RENDER_SCHEDULER: RenderScheduler = {
@@ -193,87 +216,6 @@ export interface OverlayFocusOwner {
193
216
  ownsOverlayFocusTarget(component: Component): boolean;
194
217
  }
195
218
 
196
- /**
197
- * Component seam for append-only native-scrollback commits. A component whose
198
- * rendered rows can still change reports, after each render, the local line
199
- * index where that mutable suffix begins. Rows above the boundary are declared
200
- * FINAL — byte-stable at the current width for the component's lifetime — and
201
- * commit to native scrollback as exact, audited content. Rows at/after the
202
- * boundary repaint in place inside the visible window; when they scroll above
203
- * the window top they normally commit as frozen visual snapshots.
204
- *
205
- * A viewport-pinned region opts out of those mutable snapshot commits. Its
206
- * offscreen mutable rows are virtually clipped until the boundary advances;
207
- * use this for fixed-height dashboards whose frames replace each other rather
208
- * than append. A root that reports no seam commits everything that scrolls as
209
- * final (shell semantics).
210
- *
211
- * When several root children report a seam in the same frame, the topmost one
212
- * defines the boundary and pinning policy: commits are prefix-only, so
213
- * everything below the first seam is already excluded.
214
- */
215
- export interface NativeScrollbackLiveRegion {
216
- getNativeScrollbackLiveRegionStart(): number | undefined;
217
- /** Keeps the mutable suffix viewport-local instead of recording frozen snapshots. */
218
- isNativeScrollbackLiveRegionPinned?(): boolean;
219
- /**
220
- * Local row where viewport pinning begins. When omitted, pinning (if
221
- * reported) starts at {@link getNativeScrollbackLiveRegionStart}. A nested
222
- * transcript uses this to keep an earlier unpinned live seam while still
223
- * capping commits at a later pinned dashboard (hub wait, todo snapshot).
224
- */
225
- getNativeScrollbackLiveRegionPinnedStart?(): number | undefined;
226
- }
227
-
228
- export interface NativeScrollbackCommittedRows {
229
- setNativeScrollbackCommittedRows(rows: number): void;
230
- }
231
-
232
- /**
233
- * Width-independent source boundary for multiplexer resize epochs. Capture
234
- * reads the last rendered source state; resolve maps that same logical boundary
235
- * into the most recent render's physical rows at its new width. The current
236
- * boundary identifies the source tail after updates queued during the resize.
237
- */
238
- export interface NativeScrollbackWidthEpoch {
239
- captureNativeScrollbackWidthEpoch(): unknown;
240
- resolveNativeScrollbackWidthEpoch(boundary: unknown): number | undefined;
241
- getNativeScrollbackWidthEpochRows(): number | undefined;
242
- /** False when updates can insert before captured trailing rows. */
243
- isNativeScrollbackWidthEpochAppendOnly?(boundary: unknown): boolean;
244
- /** Changes when child structure mutates independently of width reflow. */
245
- getNativeScrollbackWidthEpochRevision?(): number;
246
- }
247
-
248
- /**
249
- * A component that discards rows after they enter native scrollback implements
250
- * this hook so a destructive full replay can rehydrate its complete frame.
251
- */
252
- export interface NativeScrollbackReplay {
253
- prepareNativeScrollbackReplay(): void;
254
- }
255
-
256
- function prepareNativeScrollbackReplay(component: Component): void {
257
- (component as Component & Partial<NativeScrollbackReplay>).prepareNativeScrollbackReplay?.();
258
- }
259
-
260
- function setNativeScrollbackCommittedRows(component: Component, rows: number): void {
261
- (component as Component & Partial<NativeScrollbackCommittedRows>).setNativeScrollbackCommittedRows?.(rows);
262
- }
263
-
264
- function getNativeScrollbackWidthEpoch(component: Component): NativeScrollbackWidthEpoch | undefined {
265
- const candidate = component as Component & Partial<NativeScrollbackWidthEpoch>;
266
- return candidate.captureNativeScrollbackWidthEpoch &&
267
- candidate.resolveNativeScrollbackWidthEpoch &&
268
- candidate.getNativeScrollbackWidthEpochRows
269
- ? (candidate as NativeScrollbackWidthEpoch)
270
- : undefined;
271
- }
272
-
273
- function getNativeScrollbackWidthEpochRevision(component: Component): number | undefined {
274
- return (component as Component & Partial<NativeScrollbackWidthEpoch>).getNativeScrollbackWidthEpochRevision?.();
275
- }
276
-
277
219
  function isOverlayFocusTarget(owner: Component, component: Component | null): boolean {
278
220
  if (component === owner) return true;
279
221
  if (!component) return false;
@@ -281,75 +223,6 @@ function isOverlayFocusTarget(owner: Component, component: Component | null): bo
281
223
  return candidate.ownsOverlayFocusTarget?.(component) === true;
282
224
  }
283
225
 
284
- function getNativeScrollbackLiveRegionStart(component: Component): number | undefined {
285
- return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackLiveRegionStart?.();
286
- }
287
-
288
- function getNativeScrollbackLiveRegionPinnedStart(component: Component): number | undefined {
289
- const start = (
290
- component as Component & Partial<NativeScrollbackLiveRegion>
291
- ).getNativeScrollbackLiveRegionPinnedStart?.();
292
- return start === undefined || !Number.isFinite(start) ? undefined : start;
293
- }
294
-
295
- /**
296
- * Opt-in stability report for components that mutate their returned render
297
- * array in place across frames (instead of returning a fresh array per
298
- * change). The engine reads it right after the component's `render()` returns:
299
- * the report counts the leading rows of the just-returned array that are
300
- * byte-identical to the array state the reader last observed. The engine uses
301
- * it to reuse the composed frame's prefix — skipping marker extraction, line
302
- * preparation, and the committed-prefix audit for those rows.
303
- *
304
- * Contract:
305
- * - Reading CONSUMES the report: it re-bases the baseline to the current
306
- * array state. The accumulated count therefore covers every render since
307
- * the previous read, so out-of-band `render()` calls between engine frames
308
- * (an exporter walking the tree) can only lower the report, never inflate
309
- * it past what the engine actually has.
310
- * - An implementer that cannot prove stability for a frame must lower the
311
- * accumulated count to 0 for that render.
312
- * - Rows at or beyond the report may have been mutated in place; rows before
313
- * it must be the identical string values at the identical indices.
314
- */
315
- export interface RenderStablePrefix {
316
- getRenderStablePrefixRows(): number;
317
- }
318
-
319
- function getRenderStablePrefixRows(component: Component): number | undefined {
320
- return (component as Component & Partial<RenderStablePrefix>).getRenderStablePrefixRows?.();
321
- }
322
-
323
- /**
324
- * Opt-in fast path for composing only the visible tail of a tall component
325
- * during a terminal resize. A drag emits a SIGWINCH burst, and the width
326
- * changes on every event: a full compose re-lays-out (and, for markdown,
327
- * re-lexes) the entire transcript per event — O(history) work that is
328
- * discarded the instant the next event arrives. While the resize is in flight
329
- * the engine paints only the viewport, so it asks each tall root child for at
330
- * most `maxRows` rows from the bottom of its render at `width` and skips
331
- * composing everything above the fold. The authoritative full paint replays
332
- * once the drag settles (see {@link TUI} resize handling).
333
- *
334
- * Contract:
335
- * - Returns the BOTTOM rows of the component's full render at `width`, in
336
- * top-to-bottom order, capped at `maxRows` (fewer when the component is
337
- * shorter). The rows MUST be byte-identical to the corresponding tail of
338
- * what `render(width)` would have returned, modulo a one-row separator at
339
- * the very top edge (a transient frame the settle paint overwrites).
340
- * - MUST NOT mutate any persistent full-compose state: the next `render()`
341
- * (the settle paint) has to reconcile exactly as if the tail render never
342
- * happened. Warming pure per-width render caches is fine and desirable.
343
- */
344
- export interface ViewportTailProvider {
345
- renderViewportTail(width: number, maxRows: number): readonly string[];
346
- }
347
-
348
- function asViewportTailProvider(component: Component): ViewportTailProvider | undefined {
349
- const candidate = component as Component & Partial<ViewportTailProvider>;
350
- return typeof candidate.renderViewportTail === "function" ? (candidate as ViewportTailProvider) : undefined;
351
- }
352
-
353
226
  /**
354
227
  * Interface for components that can receive focus and display a cursor.
355
228
  * When focused, the component should emit CURSOR_MARKER at the cursor position
@@ -373,6 +246,13 @@ export interface RenderRequestOptions {
373
246
  /** Clear terminal scrollback for intentional transcript replacement. */
374
247
  clearScrollback?: boolean;
375
248
  }
249
+ /**
250
+ * Controls how a settled terminal resize refreshes native history.
251
+ *
252
+ * `append` replays the current transcript below retained history, `rebuild`
253
+ * clears history before replaying it, and `preserve` repaints only the viewport.
254
+ */
255
+ export type ResizeScrollbackMode = "append" | "rebuild" | "preserve";
376
256
 
377
257
  /** Type guard to check if a component implements Focusable */
378
258
  export function isFocusable(component: Component | null): component is Component & Focusable {
@@ -428,56 +308,6 @@ function parseSizeValue(value: SizeValue | undefined, referenceSize: number): nu
428
308
  return undefined;
429
309
  }
430
310
 
431
- /**
432
- * Detect sessions where ED3 cannot safely rebuild scrollback. Direct HerdR
433
- * panes support explicit clears; nested multiplexers remain unsafe because the
434
- * inner tmux/screen/Zellij layer owns their history.
435
- */
436
- function isMultiplexerSession(): boolean {
437
- if (!isInsideTerminalMultiplexer()) return false;
438
- if (Bun.env.HERDR_ENV !== "1") return true;
439
- const term = Bun.env.TERM?.toLowerCase() ?? "";
440
- return Boolean(
441
- Bun.env.TMUX ||
442
- Bun.env.STY ||
443
- Bun.env.ZELLIJ ||
444
- Bun.env.CMUX_WORKSPACE_ID ||
445
- Bun.env.CMUX_SURFACE_ID ||
446
- Bun.env.CMUX_REMOTE_TRANSPORT ||
447
- term.startsWith("tmux") ||
448
- term.startsWith("screen"),
449
- );
450
- }
451
-
452
- /**
453
- * Terminals that re-report their size whenever the alternate screen buffer is
454
- * toggled. The non-multiplexer resize fast path ({@link TUI.#beginResizeViewport})
455
- * borrows the alternate screen for throwaway drag frames; on these terminals
456
- * entering/leaving the alt buffer emits a fresh SIGWINCH (Warp reports a height
457
- * one row different for the alt buffer), which re-enters the fast path — a
458
- * self-sustaining resize loop that floods ED3 full repaints even though the
459
- * geometry never actually changes. Routing them through the in-place
460
- * (multiplexer) resize path never touches the alt buffer, breaking the loop.
461
- *
462
- * `PI_TUI_RESIZE_IN_PLACE=1|0` forces this on/off for any terminal.
463
- */
464
- function reportsSizeOnAltScreenToggle(): boolean {
465
- const override = Bun.env.PI_TUI_RESIZE_IN_PLACE;
466
- if (override === "0" || override === "false") return false;
467
- if (override === "1" || override === "true") return true;
468
- return Bun.env.TERM_PROGRAM?.toLowerCase() === "warpterminal";
469
- }
470
-
471
- /**
472
- * Resize should repaint the visible window in place — no alternate-screen
473
- * borrow, no ED3 scrollback rewrap — for multiplexer and direct HerdR panes,
474
- * plus terminals that loop on alt-screen toggles. Direct HerdR remains a
475
- * direct terminal for explicit transcript replacement and display reset.
476
- */
477
- function resizeRepaintsInPlace(): boolean {
478
- return isMultiplexerSession() || Bun.env.HERDR_ENV === "1" || reportsSizeOnAltScreenToggle();
479
- }
480
-
481
311
  /**
482
312
  * Options for overlay positioning and sizing.
483
313
  * Values can be absolute numbers or percentage strings (e.g., "50%").
@@ -549,43 +379,18 @@ export interface OverlayHandle {
549
379
  /**
550
380
  * Container - a component that contains other components
551
381
  */
552
- export class Container
553
- implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay, NativeScrollbackWidthEpoch
554
- {
382
+ export class Container implements Component {
555
383
  children: Component[] = [];
556
384
 
557
385
  // Memoized concatenation of the children's latest renders. Children are
558
386
  // still rendered every frame (renders carry side effects: image placement
559
- // registration, seam/stability reports); the memo only skips rebuilding
560
- // the concatenated array when every child returned the exact same array
561
- // reference at the same width — which, per the Component render contract,
562
- // proves the rows are byte-identical. Cleared on any child-list change and
563
- // on invalidate().
387
+ // registration); the memo only skips rebuilding the concatenated array when
388
+ // every child returned the exact same array reference at the same width —
389
+ // which, per the Component render contract, proves the rows are
390
+ // byte-identical. Cleared on any child-list change and on invalidate().
564
391
  #memoLines: string[] | undefined;
565
392
  #memoChildLines: (readonly string[])[] = [];
566
- #memoChildWidthEpochRevisions: Array<number | undefined> = [];
567
393
  #memoWidth = -1;
568
- // Child identities matching #memoChildLines. Kept separately because callers
569
- // may append children after the last emitted render but before SIGWINCH.
570
- #memoChildren: Component[] = [];
571
- #widthEpochBoundaries = new WeakMap<
572
- object,
573
- {
574
- component: Component;
575
- childBoundary: unknown;
576
- sourceIndex: number;
577
- leading: ReadonlyArray<{ component: Component; revision: number | undefined; rowCount: number }>;
578
- trailing: ReadonlyArray<{
579
- component: Component;
580
- revision: number | undefined;
581
- rowCount: number;
582
- hadRows: boolean;
583
- }>;
584
- }
585
- >();
586
- #activeWidthEpochBoundary: object | undefined;
587
- #widthEpochRevision = 0;
588
- #widthEpochChildRevisions = new WeakMap<Component, number | undefined>();
589
394
 
590
395
  #ignoreTight = false;
591
396
 
@@ -600,7 +405,6 @@ export class Container
600
405
 
601
406
  addChild(component: Component): void {
602
407
  this.children.push(component);
603
- this.#widthEpochRevision++;
604
408
  if (this.#ignoreTight) {
605
409
  component.setIgnoreTight?.(true);
606
410
  }
@@ -611,13 +415,11 @@ export class Container
611
415
  const index = this.children.indexOf(component);
612
416
  if (index !== -1) {
613
417
  this.children.splice(index, 1);
614
- this.#widthEpochRevision++;
615
418
  this.#memoLines = undefined;
616
419
  }
617
420
  }
618
421
 
619
422
  clear(): void {
620
- if (this.children.length > 0) this.#widthEpochRevision++;
621
423
  this.children = [];
622
424
  this.#memoLines = undefined;
623
425
  }
@@ -646,217 +448,23 @@ export class Container
646
448
  }
647
449
  }
648
450
 
649
- /**
650
- * Split the committed prefix from the container's most recently rendered
651
- * rows across its children. The memoized child arrays are the exact geometry
652
- * that produced that frame; when the child list was invalidated or rebuilt,
653
- * there is no safe old-to-new coordinate mapping, so propagation waits for
654
- * the next render/post-emit publication.
655
- */
656
- setNativeScrollbackCommittedRows(rows: number): void {
657
- const refs = this.#memoChildLines;
658
- if (this.#memoLines === undefined || refs.length !== this.children.length) return;
659
- const committed = Number.isFinite(rows) ? Math.max(0, Math.trunc(rows)) : 0;
660
- let offset = 0;
661
- for (let i = 0; i < this.children.length; i++) {
662
- const childRows = refs[i];
663
- if (childRows === undefined) return;
664
- setNativeScrollbackCommittedRows(
665
- this.children[i]!,
666
- Math.min(childRows.length, Math.max(0, committed - offset)),
667
- );
668
- offset += childRows.length;
669
- }
670
- }
671
-
672
- /** Recursively discard layout locks that are meaningful only to the old tape. */
673
- prepareNativeScrollbackReplay(): void {
674
- for (const child of this.children) prepareNativeScrollbackReplay(child);
675
- }
676
-
677
- captureNativeScrollbackWidthEpoch(): unknown {
678
- const refs = this.#memoChildLines;
679
- const children = this.#memoChildren;
680
- if (this.#memoLines === undefined || refs.length !== children.length) return undefined;
681
- for (let index = children.length - 1; index >= 0; index--) {
682
- const component = children[index]!;
683
- const source = getNativeScrollbackWidthEpoch(component);
684
- const childBoundary = source?.captureNativeScrollbackWidthEpoch();
685
- if (childBoundary === undefined) continue;
686
- const marker = {};
687
- this.#activeWidthEpochBoundary = marker;
688
- this.#widthEpochBoundaries.set(marker, {
689
- component,
690
- childBoundary,
691
- sourceIndex: index,
692
- leading: children.slice(0, index).map((child, leadingIndex) => ({
693
- component: child,
694
- revision: this.#memoChildWidthEpochRevisions[leadingIndex],
695
- rowCount: refs[leadingIndex]!.length,
696
- })),
697
- trailing: children.slice(index + 1).map((child, trailingIndex) => ({
698
- component: child,
699
- revision: this.#memoChildWidthEpochRevisions[index + 1 + trailingIndex],
700
- rowCount: refs[index + 1 + trailingIndex]!.length,
701
- hadRows: refs[index + 1 + trailingIndex]!.length > 0,
702
- })),
703
- });
704
- return marker;
705
- }
706
- return undefined;
707
- }
708
-
709
- resolveNativeScrollbackWidthEpoch(boundary: unknown): number | undefined {
710
- if (typeof boundary !== "object" || boundary === null) return undefined;
711
- const marker = this.#widthEpochBoundaries.get(boundary);
712
- if (!marker) return undefined;
713
- const index = marker.sourceIndex;
714
- if (
715
- this.#memoChildren[index] !== marker.component ||
716
- this.#memoLines === undefined ||
717
- this.#memoChildLines.length !== this.#memoChildren.length
718
- ) {
719
- return undefined;
720
- }
721
- for (let leadingIndex = 0; leadingIndex < marker.leading.length; leadingIndex++) {
722
- const captured = marker.leading[leadingIndex]!;
723
- const currentRows = this.#memoChildLines[leadingIndex]!;
724
- if (
725
- this.#memoChildren[leadingIndex] !== captured.component ||
726
- (captured.revision === undefined
727
- ? currentRows.length !== captured.rowCount
728
- : getNativeScrollbackWidthEpochRevision(captured.component) !== captured.revision)
729
- ) {
730
- return undefined;
731
- }
732
- }
733
- const childRows = getNativeScrollbackWidthEpoch(marker.component)?.resolveNativeScrollbackWidthEpoch(
734
- marker.childBoundary,
735
- );
736
- if (childRows === undefined) return undefined;
737
- let rows = childRows;
738
- for (let i = 0; i < index; i++) rows += this.#memoChildLines[i]!.length;
739
- for (let trailingIndex = 0; trailingIndex < marker.trailing.length; trailingIndex++) {
740
- const captured = marker.trailing[trailingIndex]!;
741
- const currentIndex = index + 1 + trailingIndex;
742
- const currentRows = this.#memoChildLines[currentIndex];
743
- if (
744
- this.#memoChildren[currentIndex] !== captured.component ||
745
- currentRows === undefined ||
746
- (captured.revision === undefined
747
- ? currentRows.length !== captured.rowCount
748
- : getNativeScrollbackWidthEpochRevision(captured.component) !== captured.revision)
749
- ) {
750
- let capturedRows = 0;
751
- for (let index = trailingIndex; index < marker.trailing.length; index++) {
752
- capturedRows += marker.trailing[index]!.rowCount;
753
- }
754
- let settledRows = 0;
755
- for (let index = currentIndex; index < this.#memoChildLines.length; index++) {
756
- settledRows += this.#memoChildLines[index]!.length;
757
- }
758
- rows += Math.min(capturedRows, settledRows);
759
- break;
760
- }
761
- rows += currentRows.length;
762
- }
763
- return rows;
764
- }
765
-
766
- getNativeScrollbackWidthEpochRows(): number | undefined {
767
- if (this.#memoLines === undefined || this.#memoChildLines.length !== this.#memoChildren.length) return undefined;
768
- const marker =
769
- this.#activeWidthEpochBoundary === undefined
770
- ? undefined
771
- : this.#widthEpochBoundaries.get(this.#activeWidthEpochBoundary);
772
- if (marker !== undefined) {
773
- const index = marker.sourceIndex;
774
- if (this.#memoChildren[index] !== marker.component) return undefined;
775
- const rows = getNativeScrollbackWidthEpoch(marker.component)?.getNativeScrollbackWidthEpochRows();
776
- if (rows === undefined) return undefined;
777
- let boundary = rows;
778
- for (let leading = 0; leading < index; leading++) boundary += this.#memoChildLines[leading]!.length;
779
- for (let trailing = index + 1; trailing < this.#memoChildLines.length; trailing++) {
780
- boundary += this.#memoChildLines[trailing]!.length;
781
- }
782
- return boundary;
783
- }
784
- let offset = this.#memoLines.length;
785
- for (let index = this.#memoChildren.length - 1; index >= 0; index--) {
786
- offset -= this.#memoChildLines[index]!.length;
787
- const rows = getNativeScrollbackWidthEpoch(this.#memoChildren[index]!)?.getNativeScrollbackWidthEpochRows();
788
- if (rows !== undefined) {
789
- let boundary = offset + rows;
790
- for (let trailing = index + 1; trailing < this.#memoChildLines.length; trailing++) {
791
- boundary += this.#memoChildLines[trailing]!.length;
792
- }
793
- return boundary;
794
- }
795
- }
796
- return undefined;
797
- }
798
-
799
- isNativeScrollbackWidthEpochAppendOnly(boundary: unknown): boolean {
800
- if (typeof boundary !== "object" || boundary === null) return true;
801
- const marker = this.#widthEpochBoundaries.get(boundary);
802
- if (!marker) return true;
803
- const source = getNativeScrollbackWidthEpoch(marker.component);
804
- if (source?.isNativeScrollbackWidthEpochAppendOnly?.(marker.childBoundary) === false) return false;
805
- if (!marker.trailing.some(child => child.hadRows)) return true;
806
- for (let trailingIndex = 0; trailingIndex < marker.trailing.length; trailingIndex++) {
807
- const captured = marker.trailing[trailingIndex]!;
808
- const currentIndex = marker.sourceIndex + 1 + trailingIndex;
809
- const currentRows = this.#memoChildLines[currentIndex];
810
- const changed =
811
- this.#memoChildren[currentIndex] !== captured.component ||
812
- currentRows === undefined ||
813
- (captured.revision === undefined
814
- ? currentRows.length !== captured.rowCount
815
- : getNativeScrollbackWidthEpochRevision(captured.component) !== captured.revision);
816
- if (changed && (captured.hadRows || marker.trailing.slice(trailingIndex + 1).some(child => child.hadRows))) {
817
- return false;
818
- }
819
- }
820
- const previousRows = source?.resolveNativeScrollbackWidthEpoch(marker.childBoundary);
821
- const currentRows = source?.getNativeScrollbackWidthEpochRows();
822
- return previousRows === undefined || currentRows === undefined || currentRows <= previousRows;
823
- }
824
-
825
- getNativeScrollbackWidthEpochRevision(): number {
826
- for (const child of this.children) {
827
- const revision = getNativeScrollbackWidthEpochRevision(child);
828
- if (!this.#widthEpochChildRevisions.has(child)) {
829
- this.#widthEpochChildRevisions.set(child, revision);
830
- } else if (this.#widthEpochChildRevisions.get(child) !== revision) {
831
- this.#widthEpochChildRevisions.set(child, revision);
832
- this.#widthEpochRevision++;
833
- }
834
- }
835
- return this.#widthEpochRevision;
836
- }
837
-
838
451
  render(width: number): readonly string[] {
839
452
  width = Math.max(1, width);
840
453
  const children = this.children;
841
454
  const count = children.length;
842
455
  let refs = this.#memoChildLines;
843
- let revisions = this.#memoChildWidthEpochRevisions;
844
456
  let unchanged = this.#memoLines !== undefined && this.#memoWidth === width && refs.length === count;
845
457
  if (refs.length !== count) {
846
458
  refs = new Array(count);
847
459
  this.#memoChildLines = refs;
848
- revisions = new Array(count);
849
- this.#memoChildWidthEpochRevisions = revisions;
850
460
  }
851
461
  for (let i = 0; i < count; i++) {
852
462
  const childLines = children[i]!.render(width);
853
- revisions[i] = getNativeScrollbackWidthEpochRevision(children[i]!);
854
463
  if (refs[i] !== childLines) {
855
464
  unchanged = false;
856
465
  refs[i] = childLines;
857
466
  }
858
467
  }
859
- this.#memoChildren = children.slice();
860
468
  this.#memoWidth = width;
861
469
  if (unchanged) return this.#memoLines!;
862
470
  const lines: string[] = [];
@@ -869,75 +477,18 @@ export class Container
869
477
  }
870
478
  }
871
479
 
872
- /**
873
- * Render intent. `#doRender` classifies each frame, and the matching `#emit*`
874
- * method owns the bytes written and the state update.
875
- *
876
- * - `fullPaint`: gesture-driven replay — initial paint, session replacement,
877
- * resize, resetDisplay. Rewrites the frame from home; destructive replaces
878
- * clear native scrollback via ED3 without first blanking the viewport. The
879
- * only ED3 callsite in the engine.
880
- * - `update`: ordinary frame. Commits the newly settled chunk at the
881
- * scrollback seam (if any) and repaints the window with relative moves.
882
- */
883
- type RenderIntent =
884
- | { kind: "fullPaint"; clearScrollback: boolean }
885
- | { kind: "update"; chunkTo: number; windowTop: number };
886
-
887
480
  interface HardwareCursorState {
888
481
  row: number;
889
482
  col: number;
890
483
  visible: boolean;
891
484
  }
892
485
 
893
- interface HardwareCursorUpdate {
894
- toRow: number;
895
- state: HardwareCursorState | null;
896
- visible?: boolean;
897
- }
898
-
899
- interface CursorControlResult extends HardwareCursorUpdate {
900
- seq: string;
901
- toCol: number;
902
- visible: boolean;
903
- }
904
-
905
- /**
906
- * One root child's contribution to the composed frame: its rendered rows,
907
- * frame span, and live-region report captured at render time. Component-scoped
908
- * frames replay the seam and viewport-pinning policy without re-rendering.
909
- */
910
- interface FrameSegment {
911
- component: Component;
912
- lines: readonly string[];
913
- start: number;
914
- rowCount: number;
915
- widthEpochRevision?: number;
916
- liveLocalStart?: number;
917
- liveRegionPinned: boolean;
918
- /** Local pin start; when omitted, pinning begins at `liveLocalStart`. */
919
- liveRegionPinnedStart?: number;
920
- }
921
-
922
- /** Depth-first identity search through `Container`-shaped children. */
923
- function subtreeContains(root: Component, target: Component): boolean {
924
- if (root === target) return true;
925
- const children = (root as Partial<Container>).children;
926
- if (!Array.isArray(children)) return false;
927
- for (let i = 0; i < children.length; i++) {
928
- if (subtreeContains(children[i]!, target)) return true;
929
- }
930
- return false;
931
- }
932
-
933
486
  interface PreparedLine {
934
487
  raw: string;
935
488
  width: number;
936
489
  line: string;
937
490
  }
938
491
 
939
- const SGR_SEQUENCE = /\x1b\[[0-9;:]*m/g;
940
-
941
492
  // SGR coalescing. The renderer's component tree emits a styled span as
942
493
  // `<set-color>text<reset>`, so adjacent spans produce runs of byte-adjacent
943
494
  // SGR sequences (e.g. a `CSI 39 m` fg-reset immediately followed by the next
@@ -1071,112 +622,29 @@ export function coalesceAdjacentSgr(line: string): string {
1071
622
  return out + line.slice(copiedUpto);
1072
623
  }
1073
624
 
1074
- /** Compare two rows ignoring SGR styling (theme restyles keep alignment). */
1075
- function rowsEquivalent(a: string, b: string): boolean {
1076
- if (a === b) return true;
1077
- return a.replace(SGR_SEQUENCE, "") === b.replace(SGR_SEQUENCE, "");
1078
- }
1079
-
1080
- function isBlankRow(row: string): boolean {
1081
- if (row.length === 0) return true;
1082
- return row.replace(SGR_SEQUENCE, "").trim().length === 0;
1083
- }
1084
-
1085
- // Tail-alignment sampling bounds: look back through up to LOOKBACK rows of
1086
- // the committed prefix to collect SAMPLES non-blank comparisons.
1087
- const RESYNC_TAIL_LOOKBACK = 24;
1088
- const RESYNC_TAIL_SAMPLES = 8;
1089
-
1090
- /**
1091
- * Decide whether `frame` still aligns with the committed prefix, and where to
1092
- * re-anchor the commit index when it does not. Returns the resync row index,
1093
- * or -1 when no resync is needed.
1094
- *
1095
- * Zones (verifiedTo ≤ finalTo ≤ prefix.length):
1096
- * [0, verifiedTo) VERIFIED exact rows — sampled with tolerance.
1097
- * [verifiedTo, finalTo) NEWLY-FINAL rows — frozen visual snapshots whose
1098
- * source just became declared-final (the block finalized / a barrier
1099
- * cleared). Hard-scanned in FULL with no tolerance: any content change
1100
- * (a pending header settling, a preview replaced by its result, a tail
1101
- * shifting up after a barrier removal) re-anchors so the engine can
1102
- * erase-and-replay history with the final content exactly once (or, on
1103
- * ED3-unsafe multiplexers, recommit it below the frozen snapshot —
1104
- * duplication, never loss) instead of committing it nowhere and
1105
- * painting it nowhere.
1106
- * [finalTo, prefix.length) FROZEN visual snapshots of still-live rows —
1107
- * exempt: their drift is expected (a collapsing preview, a ticking
1108
- * progress tree) and must never spray re-anchors mid-run.
1109
- *
1110
- * The verified zone's sampled check exploits the asymmetry between the two
1111
- * mutation classes: an in-place edit/restyle disturbs only the touched rows
1112
- * (alignment below stays intact; the stale copy in history is the accepted
1113
- * artifact), while an insertion/deletion shifts EVERY row below it. Up to 8
1114
- * non-blank rows within the last 24 verified rows are compared SGR-stripped
1115
- * (theme changes stay quiet), tolerating a SINGLE mismatch. The tolerance is
1116
- * load-bearing for roots that report NO seam: an animated row already in
1117
- * history would otherwise re-anchor on every glyph tick.
1118
- *
1119
- * Highly repetitive tails (identical filler rows) can mask a shift in the tail
1120
- * sample, in which case the skipped rows are content-identical to the committed
1121
- * ones — observationally harmless. Exported for the render-stress harness, whose
1122
- * shadow commit ledger must mirror the engine's law exactly.
1123
- */
1124
- export function findCommittedPrefixResync(
1125
- frame: readonly string[],
1126
- prefix: readonly string[],
1127
- verifiedTo: number = prefix.length,
1128
- finalTo: number = verifiedTo,
1129
- ): number {
1130
- const verified = Math.min(prefix.length, Math.max(0, Math.trunc(verifiedTo)));
1131
- const hardEnd = Math.min(prefix.length, Math.max(verified, Math.trunc(finalTo)));
1132
- if (hardEnd === 0) return -1;
1133
- if (frame.length >= hardEnd) {
1134
- // 1. Hard scan: frozen snapshots whose source just became final. Full
1135
- // scan, no tolerance — a finalized row that changed must re-anchor.
1136
- let hardMismatch = false;
1137
- for (let i = verified; i < hardEnd; i++) {
1138
- if (!rowsEquivalent(frame[i]!, prefix[i]!)) {
1139
- hardMismatch = true;
1140
- break;
1141
- }
1142
- }
1143
- if (!hardMismatch) {
1144
- // 2. Tail sample over the verified zone (only when the hard scan is
1145
- // clean): walk up from its end until LOOKBACK rows or SAMPLES
1146
- // non-blank comparisons.
1147
- let samples = 0;
1148
- let mismatches = 0;
1149
- for (let j = 1; j <= verified && j <= RESYNC_TAIL_LOOKBACK && samples < RESYNC_TAIL_SAMPLES; j++) {
1150
- const idx = verified - j;
1151
- const row = frame[idx]!;
1152
- const old = prefix[idx]!;
1153
- if (row === old) {
1154
- if (!isBlankRow(row)) samples++;
1155
- continue;
1156
- }
1157
- if (isBlankRow(row) && isBlankRow(old)) continue;
1158
- samples++;
1159
- if (!rowsEquivalent(row, old)) mismatches++;
1160
- }
1161
- // No signal (all-blank tail) or at most one edited row: aligned.
1162
- if (samples === 0 || mismatches <= 1) return -1;
1163
- }
1164
- }
1165
- // Misaligned (hard mismatch, tail-sample shift, or the frame no longer
1166
- // covers the checked zones): re-anchor at the first row whose content
1167
- // changed.
1168
- const limit = Math.min(hardEnd, frame.length);
1169
- for (let i = 0; i < limit; i++) {
1170
- if (!rowsEquivalent(frame[i]!, prefix[i]!)) return i;
1171
- }
1172
- return limit < hardEnd ? limit : -1;
1173
- }
1174
-
1175
625
  /**
1176
626
  * TUI - Main class for managing terminal UI with differential rendering
1177
627
  */
1178
628
  export class TUI extends Container {
1179
629
  terminal: Terminal;
630
+ #frameProvider: TerminalFrameProvider | undefined;
631
+ #acceptedHistoryBatchId = 0;
632
+ // Screen row where the provider's mutable viewport begins (0-based); rows
633
+ // above it hold history still visible on the physical screen.
634
+ #providerViewportTop = 0;
635
+ // Viewport-relative row of the hardware cursor after the last normal paint
636
+ // (0 = parked at the viewport top). A resize reflows the normal buffer
637
+ // before the app hears about it; terminals keep the cursor attached to its
638
+ // logical line through rewrap, so a DSR round trip against this parked
639
+ // cursor recovers the reflowed viewport anchor (see #resolveResizeAnchor).
640
+ #parkedViewportOffset = 0;
641
+ // In-flight post-resize anchor probe: the stale viewport snapshot and park
642
+ // offset captured when CSI 6n was written, plus the no-reply fallback timer.
643
+ #resizeProbe: { window: readonly string[]; offset: number; timer: RenderTimer } | undefined;
644
+ // Unanswered CSI 6n probes; CPR replies are consumed from input while > 0.
645
+ #pendingCprReplies = 0;
646
+ // Prepared rows painted by the previous provider frame, for row diffing.
647
+ #providerWindow: string[] = [];
1180
648
  #previousFrameLength = 0;
1181
649
  #previousWidth = 0;
1182
650
  #previousHeight = 0;
@@ -1207,31 +675,23 @@ export class TUI extends Container {
1207
675
  * feels dead to the user and no longer justifies further CPU savings.
1208
676
  */
1209
677
  static readonly #MAX_ADAPTIVE_RENDER_MS = 200;
1210
- #inputRenderGraceUntilMs = 0;
1211
- // Pane-reflow settle window for tmux/screen/zellij. The host process gets
1212
- // SIGWINCH (and `process.stdout` already reports the new geometry) before
1213
- // the multiplexer finishes repainting the pane at the new size, and
1214
- // drag-resize/pane-close animations fire several events in flight. A forced
1215
- // render on each SIGWINCH races those mid-reflow paints — the multiplexer's
1216
- // catch-up paint then partially overwrites the TUI output, which the user
1217
- // sees as a viewport flash or blank screen before the next throttled frame
1218
- // arrives (issue #2088). Coalescing every SIGWINCH inside this window into
1219
- // a single forced render lets the multiplexer settle first.
1220
- static readonly #MULTIPLEXER_RESIZE_DEBOUNCE_MS = 50;
1221
- // Resize viewport fast path (non-multiplexer). A drag emits a SIGWINCH burst,
1222
- // and outside a multiplexer the host gets each new geometry atomically. The
1223
- // authoritative resize paint erases and replays the entire transcript so it
1224
- // rewraps at the new width — O(history) compose (markdown re-lexes every
1225
- // block, the per-width cache missing on every distinct drag width) plus an
1226
- // O(history) write that pushes all of it back through native scrollback. At
1227
- // drag rates that whole-history pass is recomputed dozens of times a second
1228
- // and discarded the instant the next event lands. While the drag is in
1229
- // flight the engine instead composes and paints ONLY the viewport (see
1230
- // `#renderResizeViewport`): a state-isolated, throwaway frame that never
1231
- // touches the commit ledger. The authoritative full replay fires once, after
1232
- // the drag has been quiet for this long. Multiplexer sessions keep their own
1233
- // debounce (`#armMultiplexerResizeTimer`, see #2088) and never take this path.
678
+ /**
679
+ * Output backpressure gate. While the terminal still owes more than this
680
+ * many bytes, composing another frame would only queue a stale paint
681
+ * behind the backlog — and once the kernel PTY buffer is full, handing the
682
+ * runtime more bytes degrades into thread-blocking writes. Defer the
683
+ * render (keeping its forced/clear-scrollback intent) and retry shortly;
684
+ * the eventual frame composes the latest component state, so a slow
685
+ * terminal receives only fresh frames instead of every intermediate one.
686
+ */
687
+ static readonly #MAX_PENDING_OUTPUT_BYTES = 256 * 1024;
688
+ /** Retry cadence while the output backlog gate is holding renders back. */
689
+ static readonly #OUTPUT_BACKLOG_RETRY_MS = 10;
690
+ /** Quiet window before restoring the normal buffer after resize. */
1234
691
  static readonly #RESIZE_VIEWPORT_SETTLE_MS = 120;
692
+ /** Longest wait for a CPR reply before the settled repaint falls back. */
693
+ static readonly #RESIZE_PROBE_TIMEOUT_MS = 200;
694
+ #inputRenderGraceUntilMs = 0;
1235
695
  // A scale-`s` OSC 66 heading reserves `s - 1` rows, and the protocol
1236
696
  // caps `s` at 7. This bounds spacer lookups and supplies enough context
1237
697
  // above the resize viewport to classify every legal heading exactly.
@@ -1240,32 +700,8 @@ export class TUI extends Container {
1240
700
  // settle window, leaving only Unicode placeholder cells. Hold the first image
1241
701
  // paint until that window has passed; later images render normally.
1242
702
  static readonly #GHOSTTY_INITIAL_IMAGE_DELAY_MS = 100;
1243
- // Post-paint settle window for ConPTY hosts. The `sessionReplace` /
1244
- // `historyRebuild` / `overlayRebuild` intents drive `#emitFullPaint` over
1245
- // a transcript that overflows the viewport, scroll-pushing everything past
1246
- // the last `height` rows into native scrollback. Windows Terminal's
1247
- // viewport-follow logic gets lossy during that burst: spinner/blink-driven
1248
- // `requestRender(false)` calls firing inside the window each produce another
1249
- // diff write, and the WT host processes them faster than its viewport
1250
- // tracker can keep up — the visible tail ends up parked a few rows above
1251
- // the actual last row until any focus event (Alt+Tab) forces a host repaint.
1252
- // Coalescing every non-forced render inside this window into a single
1253
- // trailing render lets the host fully settle the big paint before any
1254
- // follow-up writes touch the buffer. The first-ever `initial` paint is
1255
- // deliberately exempt: nothing has been on screen yet, so no drift can
1256
- // have accumulated, and tests that start the TUI over an over-tall
1257
- // component depend on the next paint firing without delay. Only armed on
1258
- // ConPTY hosts (`isConPTYHosted()`); other terminals do not exhibit the
1259
- // drift and would just see an unnecessary post-paint latency. See #2095.
1260
- static readonly #CONPTY_POST_FULL_PAINT_SETTLE_MS = 150;
1261
- static readonly #CONPTY_FRAME_TRUNCATE_THRESHOLD_BYTES = 512 * 1024;
1262
- static readonly #CONPTY_FRAME_RETAIN_BYTES = 64 * 1024;
1263
- #postFullPaintSettleUntilMs = 0;
1264
- #postFullPaintSettleTimer: RenderTimer | undefined;
1265
703
  #hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
1266
704
  #hardwareCursorState: HardwareCursorState | null = null;
1267
- #hardwareCursorVisibilityKnown = false;
1268
- #hardwareCursorVisible = false;
1269
705
  #sixelProbePendingGraphics = false;
1270
706
  #sixelProbeBuffer = "";
1271
707
  #sixelProbeTimeout?: NodeJS.Timeout;
@@ -1274,86 +710,7 @@ export class TUI extends Container {
1274
710
  #synchronizedOutputEnabled = shouldEnableSynchronizedOutputByDefault();
1275
711
  #paintBeginSequence = this.#synchronizedOutputEnabled ? PAINT_BEGIN : PAINT_BEGIN_NO_SYNC;
1276
712
  #paintEndSequence = this.#synchronizedOutputEnabled ? PAINT_END : PAINT_END_NO_SYNC;
1277
- #cursorBeginSequence = this.#synchronizedOutputEnabled ? CURSOR_BEGIN : CURSOR_BEGIN_NO_SYNC;
1278
- #cursorEndSequence = this.#synchronizedOutputEnabled ? CURSOR_END : CURSOR_END_NO_SYNC;
1279
- // Rows of the current frame physically committed to the terminal tape
1280
- // (native scrollback or scrolled past the window top). Immutable by
1281
- // contract: the engine never rewrites them. Rows below
1282
- // #committedPrefixAuditRows entered as exact-final bytes (the component
1283
- // seam declared them); rows at/after it are frozen visual snapshots that
1284
- // scrolled off the window top while still live.
1285
- #committedRows = 0;
1286
- // Raw rows mirroring [0, #committedRows) — the engine's claim of what it
1287
- // committed. The audited prefix [0, #committedPrefixAuditRows) is checked
1288
- // each ordinary frame against the current render to detect components
1289
- // re-laying-out declared-final content (see #auditCommittedPrefix). Holds
1290
- // references to component-cached strings, so the audit is a pointer walk
1291
- // in the common case.
1292
- #committedPrefix: string[] = [];
1293
- // Rows of the committed prefix that were HARD-VERIFIED as exact-final
1294
- // bytes (committed below the exactness boundary, or frozen snapshots that
1295
- // passed the one-time strict scan when the boundary rose past them). Rows
1296
- // in [#committedPrefixAuditRows, #committedRows) are frozen visual
1297
- // snapshots of still-live content — the terminal's record of what was on
1298
- // screen when it scrolled off — and are audit-exempt while their source
1299
- // remains live, so a collapsing preview never sprays re-anchors mid-run.
1300
- // When the exactness boundary rises past them (the block finalized), they
1301
- // are strict-scanned exactly once: unchanged rows join the verified zone,
1302
- // a divergence re-anchors so the final content recommits below the frozen
1303
- // snapshot (duplication, never loss). Re-based on full paints / shrinks /
1304
- // geometry frames.
1305
- #committedPrefixAuditRows = 0;
1306
- // Width reflow terminates the meaning of the old committed physical-row
1307
- // index in an in-place resize session. This is the current-width frame
1308
- // baseline; only later physical-row growth may advance the append ledger.
1309
- #widthEpochBaselineRows: number | undefined;
1310
- // An unresolved captured source boundary was replayed from row zero. While
1311
- // its live region remains pinned, advance the baseline only through rows
1312
- // actually emitted; a reported final seam may otherwise skip deferred rows.
1313
- #widthEpochReplayUnresolved = false;
1314
- // An overlay-covered width reset with unresolved pending growth owes a
1315
- // conservative replay from row zero. Sticky across later covered resizes —
1316
- // even if their source boundary resolves — until an uncovered paint pays it.
1317
- #widthEpochOverlayReplayPending = false;
1318
- // The first logical boundary captured while a normal-buffer overlay covers
1319
- // a width epoch. Later covered resizes must keep resolving this unpaid seam
1320
- // instead of adopting hidden growth as the next epoch's source boundary.
1321
- #widthEpochOverlayBoundary: unknown;
1322
- // Same-width snapshots physically appended after a width transition. The
1323
- // ordinary committed prefix includes opaque old-width native rows and can
1324
- // no longer be indexed against the reflowed frame; this local ledger lets
1325
- // newly-final post-epoch rows retain the one-time strict audit contract.
1326
- #widthEpochCommittedPrefix?: {
1327
- nativeBaseRows: number;
1328
- frameRows: number[];
1329
- prefix: string[];
1330
- auditRows: number;
1331
- };
1332
- // Logical source boundary captured from the last emitted frame at the first
1333
- // SIGWINCH in a multiplexer resize burst. Unlike physical row counts, the
1334
- // opaque marker survives width reflow and resolves after the settled render.
1335
- #multiplexerWidthEpochBoundary: unknown;
1336
- #multiplexerWidthEpochPending = false;
1337
- // Normal-buffer boundary borrowed by a fullscreen alt overlay. If the host
1338
- // resizes while the transcript is hidden, this remains the physical seam
1339
- // from before the overlay instead of adopting hidden growth on exit.
1340
- #altWidthEpochBoundary: unknown;
1341
-
1342
- // Frame row currently mapped to screen row 0. Monotonic between full
1343
- // paints: a shrink never re-exposes scrolled-off rows (they cannot be
1344
- // un-scrolled without rewriting history); live rows repaint at fixed
1345
- // positions with blank rows below the shrunken tail.
1346
- #windowTopRow = 0;
1347
- // Exactly what is painted on the screen rows (post-composite, prepared).
1348
- #previousWindow: string[] = [];
1349
- #nativeScrollbackLiveRegionStart: number | undefined;
1350
- #nativeScrollbackLiveRegionPinned = false;
1351
- // Start row of the topmost live region that pinned itself. The topmost seam
1352
- // governs the exactness boundary and the frame-wide pin policy, but a pinned
1353
- // region BELOW an unpinned seam (an anchored HUD/panel under a streaming
1354
- // transcript) still must never commit its rows to native scrollback. This is
1355
- // the ceiling no commit may cross, independent of the topmost seam's policy.
1356
- #nativeScrollbackPinnedBoundary: number | undefined;
713
+
1357
714
  #fullRedrawCount = 0;
1358
715
  // Caps how many inline images render as live graphics; older ones fall back
1359
716
  // to text via a purge + full redraw. Cap is configured by the host app.
@@ -1362,65 +719,14 @@ export class TUI extends Container {
1362
719
  #ghosttyInitialImageDelayTimer: RenderTimer | undefined;
1363
720
  #ghosttyImageReadyAtMs = 0;
1364
721
  #clearScrollbackOnNextRender = false;
1365
- // Set by `resetDisplay()` and consumed by the next authoritative normal-screen
1366
- // render. If that render is a full paint, it is a user-driven replay of the
1367
- // current transcript (Ctrl+O expand, thinking/setting toggles, display reset)
1368
- // that must show every row, so it opts out of #truncateLargeConptyFrame.
1369
- // Multiplexer resets render as in-place updates; consuming the flag there
1370
- // prevents a later /resume or handoff bulk replacement from inheriting it.
1371
- #unboundedConptyPaintRequested = false;
722
+ // Consumed by the next frame: a user-driven redraw gesture (resetDisplay,
723
+ // requestRender(true)) that must rewrite the viewport even when the diff
724
+ // believes nothing changed.
1372
725
  #forceViewportRepaintOnNextRender = false;
1373
726
  #hasEverRendered = false;
1374
- #scrollbackRebuildEnabled =
1375
- Bun.env.PI_TUI_SCROLLBACK_REBUILD === "1" || Bun.env.PI_TUI_SCROLLBACK_REBUILD === "true";
1376
- // Set by the terminal resize callback; consumed by the next render. A resize
1377
- // event invalidates the committed screen even when the dimensions net out
1378
- // unchanged by render time (e.g. a 6→4→6 round trip coalesced into one frame
1379
- // budget): the terminal reflowed its buffer on each event, moving rows
1380
- // between the viewport and scrollback, so the previous frame no longer
1381
- // describes the screen. Tracking only the dimension delta misses this.
1382
- #resizeEventPending = false;
1383
- // Active multiplexer SIGWINCH debounce. Reset on each event so the timer
1384
- // only fires once the pane stops resizing. Forced renders (resetDisplay,
1385
- // finishSixelProbe, …) issued during the settle window route through the
1386
- // same timer; their `clearScrollback` intent is OR'd into the deferred
1387
- // flag below so the settled paint still honours every caller's request.
1388
- #multiplexerResizeTimer: RenderTimer | undefined;
1389
- #deferredForcedClearScrollback = false;
1390
- #multiplexerResizeHasPendingRender = false;
1391
- // Rows a mux pane pushed into its own scrollback on a height-only shrink
1392
- // without the engine committing them, plus the commit seam at push time.
1393
- // A later height grow pulls the pane's scrollback tail back into the grid:
1394
- // the pushed rows come out first — but only while no commit has buried
1395
- // them — and only the remainder of the pull removes committed rows.
1396
- #muxPushedRows = 0;
1397
- #muxPushSeam = 0;
1398
- // True from the first SIGWINCH of a non-multiplexer drag until the settle
1399
- // timer fires. While set, every `#doRender` short-circuits to the viewport
1400
- // fast path (`#renderResizeViewport`) instead of an authoritative full
1401
- // paint, and no commit/window/diff state is advanced.
1402
- #resizeViewportActive = false;
1403
- // Quiet-window timer that ends the drag: its callback clears the flag and
1404
- // drives the one authoritative full paint. Reset on every resize event so it
1405
- // only fires once the drag stops. Cancelled on stop().
1406
- #resizeViewportSettleTimer: RenderTimer | undefined;
1407
- // Count of transient viewport-only resize paints emitted. Distinct from
1408
- // `#fullRedrawCount`: these never enter native scrollback and exist only for
1409
- // the lifetime of the drag. Exposed for tests/diagnostics.
1410
- #resizeViewportPaintCount = 0;
1411
- // During a live resize drag the terminal's normal buffer may reflow full-width
1412
- // rows before our repaint lands. Borrow the alternate screen for throwaway
1413
- // resize frames so width changes truncate the transient viewport instead of
1414
- // pushing wrapped fragments into native scrollback.
1415
- #resizeAltActive = false;
1416
- // Latched once this terminal is observed re-reporting its size across an
1417
- // alternate-screen toggle (a pure height change between alt-buffer enter and
1418
- // exit). That is the Warp-class quirk {@link reportsSizeOnAltScreenToggle}
1419
- // hardcodes: without it, leaving a fullscreen overlay flashes a destructive
1420
- // ED3 full paint and the revert SIGWINCH flashes another (#6511). Once set,
1421
- // {@link #resizeRepaintsInPlace} routes resizes through the in-place path.
1422
- #altToggleResizesInPlace = false;
1423
727
  #stopped = false;
728
+ /** True between a `deferInput` start() and enableInput(). */
729
+ #inputDeferred = false;
1424
730
  // Always-on event-loop lag probe. The high default threshold keeps it quiet;
1425
731
  // it only logs `ui.loop-blocked` (with the current loop phase) when a frame
1426
732
  // budget is genuinely starved. Armed in start(), disarmed in stop().
@@ -1436,63 +742,15 @@ export class TUI extends Container {
1436
742
  #altPreviousLines: string[] = [];
1437
743
  #altEnterWidth = 0;
1438
744
  #altEnterHeight = 0;
745
+ #resizeAltActive = false;
746
+ #resizeSettleTimer: RenderTimer | undefined;
747
+ #suppressResizeUntil = 0;
748
+ #resizeScrollbackMode: ResizeScrollbackMode = TUI.#initialResizeScrollbackMode();
749
+ #resizeReplaySize: string | undefined;
1439
750
  // Holds an alternate-screen exit until its replacement full paint can emit it
1440
751
  // atomically. It must survive a deferred Ghostty image frame.
1441
752
  #pendingAltExit = "";
1442
753
 
1443
- // Persistent composed frame. The render override splices only rows at/after
1444
- // the stable prefix each frame; cursor markers are stripped at ingestion so
1445
- // the frame never carries them. Returned to render() callers — treated as
1446
- // immutable by them per the Component render contract.
1447
- #composedFrame: string[] = [];
1448
- // Per-root-child segment ledger backing the stable-prefix computation.
1449
- #frameSegments: FrameSegment[] = [];
1450
- #composeWidth = -1;
1451
- #rootWidthEpochBoundaries = new WeakMap<
1452
- object,
1453
- {
1454
- component: Component;
1455
- childBoundary: unknown;
1456
- sourceIndex: number;
1457
- leading: ReadonlyArray<{ component: Component; revision: number | undefined; rowCount: number }>;
1458
- trailing: ReadonlyArray<{ component: Component; revision: number | undefined; rowCount: number }>;
1459
- hasTrailingRows: boolean;
1460
- }
1461
- >();
1462
-
1463
- // Cursor markers stripped at ingestion, ascending by frame row.
1464
- #frameCursorMarkers: { row: number; col: number }[] = [];
1465
- // Leading rows of #composedFrame byte-identical to the previous compose.
1466
- #renderStablePrefixRows = 0;
1467
-
1468
- // Component-scoped render accumulation. Targets are the components handed
1469
- // to requestComponentRender() since the last frame; the flag stays true
1470
- // only while EVERY pending request is component-scoped. Both are consumed
1471
- // once per frame by #doRender.
1472
- #componentRenderTargets = new Set<Component>();
1473
- #pendingRenderComponentsOnly = false;
1474
- // Root children that must re-render during the current compose; null for a
1475
- // full compose. Non-null only for the duration of a component-scoped
1476
- // render() call inside #doRender (the scratch set below, reused per frame).
1477
- #partialComposeRoots: Set<Component> | null = null;
1478
- #partialComposeRootsScratch = new Set<Component>();
1479
- // Target component -> containing root child, so animation-rate requests do
1480
- // not re-walk a huge transcript subtree every frame.
1481
- #componentRootCache = new WeakMap<Component, Component>();
1482
- #scopedInputRenderComponents = new WeakSet<Component>();
1483
-
1484
- // Persistent prepared frame, row-aligned with #composedFrame. Entries store
1485
- // normalized, width-fitted content rows without the per-line terminal
1486
- // terminator; terminators are appended only at write time so width checks
1487
- // stay on content, not reset bytes. #preparedValidRows counts the leading
1488
- // rows known prepared against the CURRENT composed frame: a compose lowers
1489
- // it to the stable prefix, a completed prepare raises it to the frame
1490
- // length, and an abandoned frame (ghostty image defer) leaves it lowered so
1491
- // the next prepare revalidates the splice.
1492
- #preparedFrame: string[] = [];
1493
- #preparedMeta: PreparedLine[] = [];
1494
- #preparedValidRows = 0;
1495
-
1496
754
  // Overlay stack for modal components rendered on top of base content
1497
755
  overlayStack: {
1498
756
  component: Component;
@@ -1508,400 +766,17 @@ export class TUI extends Container {
1508
766
  this.#showHardwareCursor = showHardwareCursor === undefined ? this.#showHardwareCursor : showHardwareCursor;
1509
767
  this.#watchdog = new LoopWatchdog();
1510
768
  }
1511
-
1512
- override captureNativeScrollbackWidthEpoch(): unknown {
1513
- const liveSource = this.#frameSegments.findIndex(segment => segment.liveLocalStart !== undefined);
1514
- const indices = Array.from({ length: this.#frameSegments.length }, (_value, index) => index)
1515
- .reverse()
1516
- .filter(index => index !== liveSource);
1517
- if (liveSource >= 0) indices.unshift(liveSource);
1518
- for (const index of indices) {
1519
- const segment = this.#frameSegments[index]!;
1520
- const source = getNativeScrollbackWidthEpoch(segment.component);
1521
- const childBoundary = source?.captureNativeScrollbackWidthEpoch();
1522
- if (childBoundary === undefined) continue;
1523
- const marker = {};
1524
- this.#rootWidthEpochBoundaries.set(marker, {
1525
- component: segment.component,
1526
- childBoundary,
1527
- sourceIndex: index,
1528
- leading: this.#frameSegments.slice(0, index).map(candidate => ({
1529
- component: candidate.component,
1530
- revision: candidate.widthEpochRevision,
1531
- rowCount: candidate.rowCount,
1532
- })),
1533
- trailing: this.#frameSegments.slice(index + 1).map(candidate => ({
1534
- component: candidate.component,
1535
- revision: candidate.widthEpochRevision,
1536
- rowCount: candidate.rowCount,
1537
- })),
1538
- hasTrailingRows: this.#frameSegments.slice(index + 1).some(candidate => candidate.rowCount > 0),
1539
- });
1540
- return marker;
1541
- }
1542
- return undefined;
1543
- }
1544
-
1545
- override resolveNativeScrollbackWidthEpoch(boundary: unknown): number | undefined {
1546
- if (typeof boundary !== "object" || boundary === null) return undefined;
1547
- const marker = this.#rootWidthEpochBoundaries.get(boundary);
1548
- if (!marker) return undefined;
1549
- const segment = this.#frameSegments[marker.sourceIndex];
1550
- if (segment?.component !== marker.component) return undefined;
1551
- for (let index = 0; index < marker.leading.length; index++) {
1552
- const captured = marker.leading[index]!;
1553
- const current = this.#frameSegments[index];
1554
- if (
1555
- current?.component !== captured.component ||
1556
- (captured.revision === undefined
1557
- ? current.rowCount !== captured.rowCount
1558
- : current.widthEpochRevision !== captured.revision)
1559
- ) {
1560
- return undefined;
1561
- }
1562
- }
1563
- const childRows = getNativeScrollbackWidthEpoch(marker.component)?.resolveNativeScrollbackWidthEpoch(
1564
- marker.childBoundary,
1565
- );
1566
- if (childRows === undefined) return undefined;
1567
- let rows = segment.start + childRows;
1568
- for (let trailingIndex = 0; trailingIndex < marker.trailing.length; trailingIndex++) {
1569
- const captured = marker.trailing[trailingIndex]!;
1570
- const candidate = this.#frameSegments[marker.sourceIndex + 1 + trailingIndex];
1571
- // Changed/removed tails are not individually cross-width comparable.
1572
- // Preserve the shared physical row count of the remaining tail as one
1573
- // span; only aggregate height growth belongs to the current suffix.
1574
- if (
1575
- candidate?.component !== captured.component ||
1576
- (captured.revision === undefined
1577
- ? candidate.rowCount !== captured.rowCount
1578
- : candidate.widthEpochRevision !== captured.revision)
1579
- ) {
1580
- let capturedRows = 0;
1581
- for (let index = trailingIndex; index < marker.trailing.length; index++) {
1582
- capturedRows += marker.trailing[index]!.rowCount;
1583
- }
1584
- let settledRows = 0;
1585
- for (let index = marker.sourceIndex + 1 + trailingIndex; index < this.#frameSegments.length; index++) {
1586
- settledRows += this.#frameSegments[index]!.rowCount;
1587
- }
1588
- rows += Math.min(capturedRows, settledRows);
1589
- break;
1590
- }
1591
- rows += candidate.rowCount;
1592
- }
1593
- return rows;
1594
- }
1595
-
1596
- #getNativeScrollbackWidthEpochCurrentRows(boundary: unknown): number | undefined {
1597
- if (typeof boundary !== "object" || boundary === null) return undefined;
1598
- const marker = this.#rootWidthEpochBoundaries.get(boundary);
1599
- if (!marker) return undefined;
1600
- const index = marker.sourceIndex;
1601
- if (this.#frameSegments[index]?.component !== marker.component) return undefined;
1602
- const sourceRows = getNativeScrollbackWidthEpoch(marker.component)?.getNativeScrollbackWidthEpochRows();
1603
- if (sourceRows === undefined) return undefined;
1604
- let rows = this.#frameSegments[index]!.start + sourceRows;
1605
- for (let trailing = index + 1; trailing < this.#frameSegments.length; trailing++) {
1606
- rows += this.#frameSegments[trailing]!.rowCount;
1607
- }
1608
- return rows;
1609
- }
1610
-
1611
- #isNativeScrollbackWidthEpochAppendOnly(boundary: unknown): boolean {
1612
- if (typeof boundary !== "object" || boundary === null) return true;
1613
- const marker = this.#rootWidthEpochBoundaries.get(boundary);
1614
- if (!marker) return true;
1615
- const source = getNativeScrollbackWidthEpoch(marker.component);
1616
- if (source?.isNativeScrollbackWidthEpochAppendOnly?.(marker.childBoundary) === false) return false;
1617
- if (!marker.hasTrailingRows) return true;
1618
- for (let trailingIndex = 0; trailingIndex < marker.trailing.length; trailingIndex++) {
1619
- const captured = marker.trailing[trailingIndex]!;
1620
- const current = this.#frameSegments[marker.sourceIndex + 1 + trailingIndex];
1621
- const changed =
1622
- current?.component !== captured.component ||
1623
- (captured.revision === undefined
1624
- ? current.rowCount !== captured.rowCount
1625
- : current.widthEpochRevision !== captured.revision);
1626
- if (
1627
- changed &&
1628
- (captured.rowCount > 0 || marker.trailing.slice(trailingIndex + 1).some(segment => segment.rowCount > 0))
1629
- ) {
1630
- return false;
1631
- }
1632
- }
1633
- const previousRows = source?.resolveNativeScrollbackWidthEpoch(marker.childBoundary);
1634
- const currentRows = source?.getNativeScrollbackWidthEpochRows();
1635
- return previousRows === undefined || currentRows === undefined || currentRows <= previousRows;
1636
- }
1637
-
1638
- override getNativeScrollbackWidthEpochRows(): number | undefined {
1639
- for (let index = this.#frameSegments.length - 1; index >= 0; index--) {
1640
- const segment = this.#frameSegments[index]!;
1641
- const rows = getNativeScrollbackWidthEpoch(segment.component)?.getNativeScrollbackWidthEpochRows();
1642
- if (rows !== undefined) {
1643
- let boundary = segment.start + rows;
1644
- for (let trailing = index + 1; trailing < this.#frameSegments.length; trailing++) {
1645
- boundary += this.#frameSegments[trailing]!.rowCount;
1646
- }
1647
- return boundary;
1648
- }
1649
- }
1650
- return undefined;
1651
- }
1652
-
1653
- override render(width: number): readonly string[] {
1654
- width = Math.max(1, width);
1655
- this.#nativeScrollbackLiveRegionStart = undefined;
1656
- this.#nativeScrollbackLiveRegionPinned = false;
1657
- this.#nativeScrollbackPinnedBoundary = undefined;
1658
- const children = this.children;
1659
- const previousSegments = this.#frameSegments;
1660
- const segments: FrameSegment[] = new Array(children.length);
1661
- // The transition frame cannot map the old-width native commit count into
1662
- // current-width component rows. Once the epoch baseline exists,
1663
- // #windowTopRow is the current-width commit seam while #committedRows
1664
- // remains the opaque native ledger.
1665
- const committedCoordinatesOpaque =
1666
- this.#composeWidth > 0 && this.#composeWidth !== width && this.#resizeRepaintsInPlace();
1667
- const componentCommittedRows =
1668
- this.#widthEpochBaselineRows === undefined ? this.#committedRows : this.#windowTopRow;
1669
- // A width change re-renders every child; nothing carries over.
1670
- let chainStable = this.#composeWidth === width;
1671
- this.#composeWidth = width;
1672
- let offset = 0;
1673
- let stableRows = 0;
1674
- const partialRoots = this.#partialComposeRoots;
1675
- for (let index = 0; index < children.length; index++) {
1676
- const child = children[index]!;
1677
- const previous = previousSegments[index];
1678
- // Component-scoped frame: a root child outside every requested
1679
- // subtree provably did not change (content mutations route through
1680
- // a render request, which would have made this frame a full one) —
1681
- // reuse its previous rows and seam report without calling render().
1682
- const reuse =
1683
- partialRoots !== null && previous !== undefined && previous.component === child && !partialRoots.has(child);
1684
- let childLines: readonly string[];
1685
- let liveLocalStart: number | undefined;
1686
- let liveRegionPinned = false;
1687
- let liveRegionPinnedStart: number | undefined;
1688
- let widthEpochRevision: number | undefined;
1689
- let reported: number | undefined;
1690
- if (reuse) {
1691
- childLines = previous.lines;
1692
- liveLocalStart = previous.liveLocalStart;
1693
- liveRegionPinned = previous.liveRegionPinned;
1694
- liveRegionPinnedStart = previous.liveRegionPinnedStart;
1695
- widthEpochRevision = previous.widthEpochRevision;
1696
- } else {
1697
- // Feed the engine's committed-row claim (from the previous frame's
1698
- // emit) before rendering so the child can skip re-deriving blocks
1699
- // that already live in immutable native scrollback. Reused segments
1700
- // skip this: they never call render(), so the signal is moot. The
1701
- // claim is in the previous frame's coordinates and never exceeds
1702
- // the rows the child actually contributed there — history that
1703
- // advanced into LATER root children must not read as this child's
1704
- // own future rows being pre-committed.
1705
- const prevRows = previous !== undefined && previous.component === child ? previous.rowCount : 0;
1706
- const prevStart = previous !== undefined && previous.component === child ? previous.start : offset;
1707
- if (!committedCoordinatesOpaque) {
1708
- setNativeScrollbackCommittedRows(
1709
- child,
1710
- Math.min(prevRows, Math.max(0, componentCommittedRows - prevStart)),
1711
- );
1712
- }
1713
- childLines = child.render(width);
1714
- widthEpochRevision = getNativeScrollbackWidthEpochRevision(child);
1715
- const liveRegionStart = getNativeScrollbackLiveRegionStart(child);
1716
- if (liveRegionStart !== undefined) {
1717
- liveLocalStart = Number.isFinite(liveRegionStart)
1718
- ? Math.max(0, Math.min(childLines.length, Math.trunc(liveRegionStart)))
1719
- : childLines.length;
1720
- }
1721
- if (liveLocalStart !== undefined) {
1722
- liveRegionPinned =
1723
- (child as Component & Partial<NativeScrollbackLiveRegion>).isNativeScrollbackLiveRegionPinned?.() ===
1724
- true;
1725
- if (liveRegionPinned) {
1726
- const pinStart = getNativeScrollbackLiveRegionPinnedStart(child);
1727
- if (pinStart !== undefined) {
1728
- liveRegionPinnedStart = Math.max(
1729
- liveLocalStart,
1730
- Math.min(childLines.length, Math.trunc(pinStart)),
1731
- );
1732
- }
1733
- }
1734
- }
1735
- // Consume the stability report unconditionally for implementers:
1736
- // reading re-bases the component's baseline to the state this
1737
- // compose is about to ingest (used or not, the current rows are
1738
- // what ends up in the composed frame). Reused segments are
1739
- // deliberately NOT read — their baseline must stay anchored to
1740
- // the last render the engine actually observed.
1741
- reported = getRenderStablePrefixRows(child);
1742
- }
1743
- // Topmost seam wins. Commits are prefix-only: the first child that
1744
- // reports a live region already bounds everything below it, so a
1745
- // lower sibling's seam (e.g. a status loader under a streaming
1746
- // transcript) must never overwrite it — moving the boundary down
1747
- // would commit the earlier child's still-mutable rows as stale
1748
- // history.
1749
- if (liveLocalStart !== undefined) {
1750
- const start = offset + liveLocalStart;
1751
- if (this.#nativeScrollbackLiveRegionStart === undefined) {
1752
- this.#nativeScrollbackLiveRegionStart = start;
1753
- this.#nativeScrollbackLiveRegionPinned = liveRegionPinned;
1754
- }
1755
- // A pinned region anywhere in the frame caps commits at its start,
1756
- // even when an earlier unpinned seam won the topmost merge above:
1757
- // its rows (a growing anchored panel) must never reach scrollback.
1758
- if (liveRegionPinned && this.#nativeScrollbackPinnedBoundary === undefined) {
1759
- this.#nativeScrollbackPinnedBoundary = offset + (liveRegionPinnedStart ?? liveLocalStart);
1760
- }
1761
- }
1762
- if (chainStable) {
1763
- if (previous !== undefined && previous.component === child && previous.start === offset) {
1764
- let stableCount = 0;
1765
- if (reported !== undefined) {
1766
- // In-place mutator: its report overrides reference equality.
1767
- // Rows beyond the previous row count cannot be "unchanged".
1768
- stableCount = Number.isFinite(reported)
1769
- ? Math.max(0, Math.min(childLines.length, previous.rowCount, Math.trunc(reported)))
1770
- : 0;
1771
- } else if (previous.lines === childLines) {
1772
- stableCount = childLines.length;
1773
- }
1774
- stableRows += stableCount;
1775
- // The chain survives only a fully stable segment: identical rows
1776
- // AND identical row count (a grown/shrunk segment shifts every
1777
- // row below it).
1778
- if (stableCount < childLines.length || previous.rowCount !== childLines.length) chainStable = false;
1779
- } else {
1780
- chainStable = false;
1781
- }
1782
- }
1783
- segments[index] = {
1784
- component: child,
1785
- lines: childLines,
1786
- start: offset,
1787
- rowCount: childLines.length,
1788
- widthEpochRevision,
1789
- liveLocalStart,
1790
- liveRegionPinned,
1791
- liveRegionPinnedStart,
1792
- };
1793
- offset += childLines.length;
1794
- }
1795
- this.#frameSegments = segments;
1796
-
1797
- const frame = this.#composedFrame;
1798
- // Defensive clamp: stable rows can never exceed what the previous
1799
- // compose actually materialized (only reachable if a child render threw
1800
- // mid-compose on the previous frame).
1801
- if (stableRows > frame.length) stableRows = frame.length;
1802
- if (stableRows !== offset || frame.length !== offset) {
1803
- // Re-ingest every row at/after the stable prefix: truncate, strip
1804
- // cursor markers, record their positions.
1805
- frame.length = stableRows;
1806
- this.#pruneFrameCursorMarkers(stableRows);
1807
- for (const segment of segments) {
1808
- const lines = segment.lines;
1809
- const from = segment.start >= stableRows ? 0 : stableRows - segment.start;
1810
- for (let i = from; i < lines.length; i++) this.#ingestFrameRow(lines[i]!);
1811
- }
1812
- }
1813
- this.#renderStablePrefixRows = stableRows;
1814
- this.#preparedValidRows = Math.min(this.#preparedValidRows, stableRows);
1815
- return frame;
1816
- }
1817
-
1818
- /** Drop cached cursor markers at/after `fromRow` (those rows re-ingest). */
1819
- #pruneFrameCursorMarkers(fromRow: number): void {
1820
- const markers = this.#frameCursorMarkers;
1821
- let keep = markers.length;
1822
- while (keep > 0 && markers[keep - 1]!.row >= fromRow) keep--;
1823
- markers.length = keep;
1824
- }
1825
-
1826
- /**
1827
- * Replace the cursor markers contributed by one fixed-size root segment.
1828
- * The ledger stays sorted by absolute frame row; entries belonging to later
1829
- * siblings move as one tail and otherwise retain their identity.
1830
- */
1831
- #replaceFrameCursorMarkers(startRow: number, lines: readonly string[]): void {
1832
- const markers = this.#frameCursorMarkers;
1833
- const endRow = startRow + lines.length;
1834
- let intervalStart = 0;
1835
- while (intervalStart < markers.length && markers[intervalStart]!.row < startRow) intervalStart++;
1836
- let intervalEnd = intervalStart;
1837
- while (intervalEnd < markers.length && markers[intervalEnd]!.row < endRow) intervalEnd++;
1838
-
1839
- let nextCount = 0;
1840
- for (let row = 0; row < lines.length; row++) {
1841
- if (lines[row]!.includes(CURSOR_MARKER)) nextCount++;
1842
- }
1843
-
1844
- const previousCount = intervalEnd - intervalStart;
1845
- const delta = nextCount - previousCount;
1846
- const previousLength = markers.length;
1847
- if (delta > 0) {
1848
- markers.length = previousLength + delta;
1849
- for (let index = previousLength - 1; index >= intervalEnd; index--) {
1850
- markers[index + delta] = markers[index]!;
1851
- }
1852
- } else if (delta < 0) {
1853
- for (let index = intervalEnd; index < previousLength; index++) {
1854
- markers[index + delta] = markers[index]!;
1855
- }
1856
- markers.length = previousLength + delta;
1857
- }
1858
-
1859
- const reusableCount = Math.min(previousCount, nextCount);
1860
- let markerSlot = intervalStart;
1861
- for (let row = 0; row < lines.length; row++) {
1862
- const line = lines[row]!;
1863
- const markerIndex = line.indexOf(CURSOR_MARKER);
1864
- if (markerIndex === -1) continue;
1865
- const absoluteRow = startRow + row;
1866
- const col = visibleWidth(line.slice(0, markerIndex));
1867
- if (markerSlot < intervalStart + reusableCount) {
1868
- const marker = markers[markerSlot]!;
1869
- marker.row = absoluteRow;
1870
- marker.col = col;
1871
- } else {
1872
- markers[markerSlot] = { row: absoluteRow, col };
1873
- }
1874
- markerSlot++;
1875
- }
1876
- }
1877
-
1878
- /** Strip every internal cursor sentinel from one rendered row. */
1879
- #stripCursorMarkers(line: string, markerIndex = line.indexOf(CURSOR_MARKER)): string {
1880
- if (markerIndex === -1) return line;
1881
- let stripped = line;
1882
- while (markerIndex !== -1) {
1883
- stripped = stripped.slice(0, markerIndex) + stripped.slice(markerIndex + CURSOR_MARKER.length);
1884
- markerIndex = stripped.indexOf(CURSOR_MARKER, markerIndex);
1885
- }
1886
- return stripped;
769
+ static #initialResizeScrollbackMode(): ResizeScrollbackMode {
770
+ const mode = Bun.env.PI_TUI_RESIZE_SCROLLBACK;
771
+ return mode === "append" || mode === "rebuild" || mode === "preserve" ? mode : "preserve";
1887
772
  }
1888
773
 
1889
- /**
1890
- * Append one row to the composed frame, stripping CURSOR_MARKER occurrences
1891
- * (internal sentinels that must never reach the terminal, the committed
1892
- * prefix, or the resync audit) and recording the first marker's position.
1893
- */
1894
- #ingestFrameRow(line: string): void {
1895
- const markerIndex = line.indexOf(CURSOR_MARKER);
1896
- if (markerIndex === -1) {
1897
- this.#composedFrame.push(line);
1898
- return;
1899
- }
1900
- this.#frameCursorMarkers.push({
1901
- row: this.#composedFrame.length,
1902
- col: visibleWidth(line.slice(0, markerIndex)),
1903
- });
1904
- this.#composedFrame.push(this.#stripCursorMarkers(line, markerIndex));
774
+ /** Install the product-owned bounded frame provider. */
775
+ setFrameProvider(provider: TerminalFrameProvider | undefined): void {
776
+ this.#frameProvider = provider;
777
+ this.#providerWindow = [];
778
+ this.#resizeReplaySize = undefined;
779
+ this.requestRender(true);
1905
780
  }
1906
781
 
1907
782
  #syncTerminalCursorMode(component: Component | null): void {
@@ -1914,20 +789,6 @@ export class TUI extends Container {
1914
789
  return this.#fullRedrawCount;
1915
790
  }
1916
791
 
1917
- /**
1918
- * Transient viewport-only paints emitted by the non-multiplexer resize fast
1919
- * path. These never touch native scrollback or the commit ledger, so they
1920
- * are counted apart from {@link fullRedraws}.
1921
- */
1922
- get resizeViewportPaints(): number {
1923
- return this.#resizeViewportPaintCount;
1924
- }
1925
-
1926
- /** Whether a non-multiplexer resize drag is currently in flight. */
1927
- get resizeViewportActive(): boolean {
1928
- return this.#resizeViewportActive;
1929
- }
1930
-
1931
792
  /** Shared budget that caps how many inline images render as live graphics. */
1932
793
  get imageBudget(): ImageBudget {
1933
794
  return this.#imageBudget;
@@ -1941,6 +802,15 @@ export class TUI extends Container {
1941
802
  setMaxInlineImages(cap: number): void {
1942
803
  this.#imageBudget.setCap(cap);
1943
804
  }
805
+ /** Return how settled resizes refresh native scrollback. */
806
+ getResizeScrollback(): ResizeScrollbackMode {
807
+ return this.#resizeScrollbackMode;
808
+ }
809
+
810
+ /** Set how settled resizes refresh native scrollback. */
811
+ setResizeScrollback(mode: ResizeScrollbackMode): void {
812
+ this.#resizeScrollbackMode = mode;
813
+ }
1944
814
 
1945
815
  /** Delete every tracked Kitty image from the terminal graphics store. */
1946
816
  clearInlineImages(): void {
@@ -1956,23 +826,6 @@ export class TUI extends Container {
1956
826
  }
1957
827
  }
1958
828
 
1959
- /**
1960
- * Get whether scrollback divergence rebuild is enabled.
1961
- */
1962
- getScrollbackRebuild(): boolean {
1963
- return this.#scrollbackRebuildEnabled;
1964
- }
1965
-
1966
- /**
1967
- * Enable or disable scrollback divergence rebuild (default off).
1968
- * When enabled, the engine will erase and replay the terminal's
1969
- * scrollback (using ED3 / alt buffer / scrollback replay) to avoid
1970
- * duplicate blocks when a block's final form replaces its live preview.
1971
- */
1972
- setScrollbackRebuild(enabled: boolean): void {
1973
- this.#scrollbackRebuildEnabled = enabled;
1974
- }
1975
-
1976
829
  getShowHardwareCursor(): boolean {
1977
830
  return this.#showHardwareCursor;
1978
831
  }
@@ -1997,11 +850,6 @@ export class TUI extends Container {
1997
850
  get synchronizedOutput(): boolean {
1998
851
  return this.#synchronizedOutputEnabled;
1999
852
  }
2000
- #deccaraFillsEnabled(): boolean {
2001
- // DECCARA fill rectangles arrive after shortened row text; synchronized
2002
- // output hides that intermediate default-background state from users.
2003
- return TERMINAL.deccara && this.#synchronizedOutputEnabled;
2004
- }
2005
853
 
2006
854
  setFocus(component: Component | null): void {
2007
855
  const topVisibleOverlay = this.#getTopmostVisibleOverlay();
@@ -2134,6 +982,7 @@ export class TUI extends Container {
2134
982
 
2135
983
  start(options?: TUIStartOptions): void {
2136
984
  this.#stopped = false;
985
+ this.#inputDeferred = options?.deferInput === true;
2137
986
  this.#watchdog.start();
2138
987
  this.#ghosttyInitialImageDelayDone = false;
2139
988
  this.#ghosttyImageReadyAtMs = this.#renderScheduler.now() + TUI.#GHOSTTY_INITIAL_IMAGE_DELAY_MS;
@@ -2152,70 +1001,20 @@ export class TUI extends Container {
2152
1001
  this.terminal.start(
2153
1002
  data => this.#handleInput(data),
2154
1003
  () => {
2155
- // Real terminals deliver SIGWINCH (and the equivalent ConPTY
2156
- // notification) atomically with the new `process.stdout` geometry, so
2157
- // a forced render must fire immediately: it clears and replays at the
2158
- // fresh size before the terminal's reflow settles into a state a
2159
- // throttled frame would race. Multiplexer panes (tmux/screen/zellij)
2160
- // do not give that guarantee. The host receives SIGWINCH while the
2161
- // multiplexer is still mid-reflow — it has not finished repainting
2162
- // the pane buffer at the new size — and a drag-resize or pane-close
2163
- // animation fires several events in flight. Forcing a render on each
2164
- // event races those mid-reflow paints: the multiplexer's catch-up
2165
- // paint then partially overwrites the TUI output, which the user sees
2166
- // as a viewport flash or blank screen before the next throttled
2167
- // frame arrives (issue #2088). `#armMultiplexerResizeTimer` coalesces
2168
- // SIGWINCHes (and any forced repaints arriving during the settle
2169
- // window) into a single render once the pane is quiet —
2170
- // `#resizeEventPending` is set first so the eventual render still
2171
- // classifies as a resize.
2172
- // A SIGWINCH while a fullscreen overlay covers the transcript is
2173
- // either a genuine resize behind the overlay or the alt-toggle size
2174
- // echo (a terminal re-reporting its size whenever the alternate
2175
- // screen buffer toggles). The transcript is not visible either way,
2176
- // so arming the drag/settle would only queue a destructive ED3
2177
- // rebuild that fires as a flash when the overlay closes (#6511). A
2178
- // pure height change is the alt-toggle-echo signature — latch the
2179
- // in-place resize path — and just repaint the overlay at the new
2180
- // size. #resizeEventPending carries to the overlay-exit render so it
2181
- // still classifies as a resize.
2182
- if (this.#altActive) {
2183
- if (this.#altEnterWidth === this.terminal.columns && this.#altEnterHeight !== this.terminal.rows) {
2184
- this.#altToggleResizesInPlace = true;
2185
- }
2186
- if (this.#previousWidth > 0 && this.terminal.columns !== this.#previousWidth) {
2187
- this.#multiplexerWidthEpochPending = true;
2188
- if (this.#multiplexerWidthEpochBoundary === undefined) {
2189
- this.#multiplexerWidthEpochBoundary = this.#altWidthEpochBoundary;
2190
- }
2191
- }
2192
- this.#resizeEventPending = true;
2193
- this.requestRender();
1004
+ if (this.#resizeProbe) {
1005
+ // The anchor being probed is already stale; restart the transaction.
1006
+ this.#cancelResizeProbe();
1007
+ this.#beginResizeAltPaint();
2194
1008
  return;
2195
1009
  }
2196
- this.#resizeEventPending = true;
2197
- if (!this.#resizeRepaintsInPlace()) {
2198
- // Enter the viewport fast path and (re)arm the settle timer, then
2199
- // request the cheap viewport-only paint. The authoritative full
2200
- // replay fires from the settle timer once the drag goes quiet.
2201
- this.#beginResizeViewport();
2202
- this.#requestResizeViewportPaint();
1010
+ if (this.#renderScheduler.now() < this.#suppressResizeUntil) {
1011
+ this.requestRender(true);
2203
1012
  return;
2204
1013
  }
2205
- if (this.#previousWidth > 0 && this.terminal.columns !== this.#previousWidth) {
2206
- this.#multiplexerWidthEpochPending = true;
2207
- if (this.#multiplexerWidthEpochBoundary === undefined) {
2208
- this.#multiplexerWidthEpochBoundary = this.captureNativeScrollbackWidthEpoch();
2209
- }
2210
- }
2211
- this.#armMultiplexerResizeTimer({
2212
- clearScrollback: false,
2213
- hasPendingRender:
2214
- this.#multiplexerResizeTimer === undefined &&
2215
- (this.#renderRequested || this.#renderTimer !== undefined),
2216
- });
1014
+ this.#beginResizeAltPaint();
2217
1015
  },
2218
1016
  () => this.stop(),
1017
+ { deferInput: this.#inputDeferred },
2219
1018
  );
2220
1019
  if (this.#stopped) return;
2221
1020
  for (const listener of this.#startListeners) {
@@ -2227,9 +1026,172 @@ export class TUI extends Container {
2227
1026
  }
2228
1027
  this.terminal.hideCursor();
2229
1028
  this.#recordHardwareCursorHidden();
1029
+ if (!this.#inputDeferred) {
1030
+ this.#querySixelSupport();
1031
+ this.#queryCellSize();
1032
+ }
1033
+ this.requestRender(true, { clearScrollback: options?.clearScrollback === true });
1034
+ }
1035
+ /** Borrow the alternate buffer for stable, history-free resize repainting. */
1036
+ #beginResizeAltPaint(): void {
1037
+ if (this.#altActive) {
1038
+ this.requestRender(true);
1039
+ return;
1040
+ }
1041
+ if (!this.#resizeAltActive) {
1042
+ this.#resizeAltActive = true;
1043
+ setAltScreenActive(true);
1044
+ this.#altPreviousLines = [];
1045
+ this.#forgetHardwareCursorState();
1046
+ this.#recordHardwareCursorHidden();
1047
+ // Erase the mutable live viewport from the normal screen before borrowing
1048
+ // the alt buffer. The terminal keeps reflowing the normal buffer during
1049
+ // the drag, and a height shrink pushes its top rows into scrollback;
1050
+ // with the live region blanked, only committed history rows (correct to
1051
+ // push) or blanks can leave the screen — never live placeholder rows
1052
+ // such as compact tool dots, whose real blocks must enter scrollback
1053
+ // through the ordered history path. Addressing depends on the resize
1054
+ // direction. Terminals keep the parked cursor attached to its logical
1055
+ // line through width rewrap and height-grow scrollback pull-down, so
1056
+ // cursor-relative movement lands on the viewport's top row. On height
1057
+ // shrink kitty clamps the cursor instead of moving it with pushed rows,
1058
+ // so cursor-relative addressing would start rows late; fall back to the
1059
+ // same bottom-preserving bound as resize-anchor recovery. The blank top
1060
+ // row then anchors the settled CPR probe (empty window means staleRows 0).
1061
+ let erase = "";
1062
+ if (this.#hasEverRendered && this.#providerWindow.length > 0) {
1063
+ if (this.terminal.rows < this.#previousHeight) {
1064
+ const staleRows = this.#reflowedRowCount(
1065
+ this.#providerWindow,
1066
+ 0,
1067
+ this.#providerWindow.length,
1068
+ this.terminal.columns,
1069
+ );
1070
+ const top = Math.max(0, Math.min(this.#providerViewportTop, this.terminal.rows - staleRows));
1071
+ erase = `\x1b[?25l\x1b[${top + 1};1H\x1b[J`;
1072
+ } else {
1073
+ const up = this.#reflowedRowCount(
1074
+ this.#providerWindow,
1075
+ 0,
1076
+ this.#parkedViewportOffset,
1077
+ this.terminal.columns,
1078
+ );
1079
+ erase = `\x1b[?25l${up > 0 ? `\x1b[${up}A` : ""}\r\x1b[J`;
1080
+ }
1081
+ this.#providerWindow = [];
1082
+ this.#parkedViewportOffset = 0;
1083
+ }
1084
+ this.terminal.write(`${erase}\x1b[?1049h${this.#keyboardEnhancementEnter()}`);
1085
+ }
1086
+ this.#resizeSettleTimer?.cancel();
1087
+ this.#resizeSettleTimer = this.#renderScheduler.scheduleRender(() => {
1088
+ this.#resizeSettleTimer = undefined;
1089
+ if (this.#stopped || !this.#resizeAltActive) return;
1090
+ this.#resizeAltActive = false;
1091
+ this.#suppressResizeUntil = this.#renderScheduler.now() + 100;
1092
+ this.terminal.write(`${this.#keyboardEnhancementExit()}\x1b[?1049l`);
1093
+ setAltScreenActive(false);
1094
+ this.#altPreviousLines = [];
1095
+ this.#beginResizeAnchorProbe();
1096
+ }, TUI.#RESIZE_VIEWPORT_SETTLE_MS);
1097
+ this.requestRender(true);
1098
+ }
1099
+ /**
1100
+ * Recover the reflowed viewport anchor after the resize alt-buffer borrow
1101
+ * ends. The terminal reflowed the restored normal buffer during the drag, so
1102
+ * `#providerViewportTop` is in stale grid coordinates; a DSR (CSI 6n) round
1103
+ * trip against the parked cursor reports where the viewport's logical line
1104
+ * landed. The settled repaint waits for the reply (or a short timeout).
1105
+ */
1106
+ #beginResizeAnchorProbe(): void {
1107
+ this.#cancelResizeProbe();
1108
+ const timer = this.#renderScheduler.scheduleRender(() => {
1109
+ this.#resolveResizeAnchor(undefined);
1110
+ }, TUI.#RESIZE_PROBE_TIMEOUT_MS);
1111
+ this.#resizeProbe = { window: this.#providerWindow, offset: this.#parkedViewportOffset, timer };
1112
+ this.#pendingCprReplies++;
1113
+ this.terminal.write("\x1b[6n");
1114
+ }
1115
+
1116
+ #cancelResizeProbe(): void {
1117
+ if (!this.#resizeProbe) return;
1118
+ this.#resizeProbe.timer.cancel();
1119
+ this.#resizeProbe = undefined;
1120
+ }
1121
+
1122
+ /**
1123
+ * Anchor the settled post-resize repaint. `reportedRow` is the 0-based CPR
1124
+ * row of the parked cursor (undefined = probe timed out). The viewport top
1125
+ * is `min(reported - parkOffset, height - staleRows)`: terminals track the
1126
+ * cursor exactly through width rewrap, and the second bound reconstructs
1127
+ * height-shrink scrollback pushes that leave the cursor behind (kitty clamps
1128
+ * the cursor instead of scrolling it) — bottom-preserving resize guarantees
1129
+ * the stale viewport ends on the last screen row whenever a push happened.
1130
+ * Validated against kitty's real core in resize-anchor-recovery.test.ts.
1131
+ */
1132
+ #resolveResizeAnchor(reportedRow: number | undefined): void {
1133
+ const probe = this.#resizeProbe;
1134
+ if (!probe) return;
1135
+ probe.timer.cancel();
1136
+ this.#resizeProbe = undefined;
1137
+ const width = this.terminal.columns;
1138
+ const height = this.terminal.rows;
1139
+ const staleRows = this.#reflowedRowCount(probe.window, 0, probe.window.length, width);
1140
+ const reportedTop =
1141
+ reportedRow === undefined
1142
+ ? this.#providerViewportTop
1143
+ : reportedRow - this.#reflowedRowCount(probe.window, 0, probe.offset, width);
1144
+ const top = Math.max(0, Math.min(reportedTop, height - staleRows));
1145
+ if ($flag("PI_DEBUG_REDRAW")) {
1146
+ const msg = `[${new Date().toISOString()}] resize anchor: size=${width}x${height} cpr=${reportedRow ?? "timeout"} park=${probe.offset} stale=${staleRows} old=${this.#providerViewportTop} top=${top}\n`;
1147
+ fs.appendFileSync(getDebugLogPath(), msg);
1148
+ }
1149
+ this.#providerViewportTop = Math.min(top, Math.max(0, height - 1));
1150
+ this.#forceViewportRepaintOnNextRender = true;
1151
+ this.requestRender(true);
1152
+ }
1153
+
1154
+ /**
1155
+ * Rows `[start, end)` of a previously painted window re-measured at `width`.
1156
+ * Direct terminals rewrap on resize (a row spans ceil(cells/width) rows);
1157
+ * multiplexers clip in place, so every row still spans exactly one row.
1158
+ */
1159
+ #reflowedRowCount(window: readonly string[], start: number, end: number, width: number): number {
1160
+ const stop = Math.min(end, window.length);
1161
+ const from = Math.max(0, start);
1162
+ if (isInsideTerminalMultiplexer()) return Math.max(0, stop - from);
1163
+ let rows = 0;
1164
+ for (let index = from; index < stop; index++) {
1165
+ rows += Math.max(1, Math.ceil(visibleWidth(window[index]!) / Math.max(1, width)));
1166
+ }
1167
+ return rows;
1168
+ }
1169
+
1170
+ /** Paint the full semantic tail on the borrowed resize buffer. */
1171
+ #renderResizeAltFrame(width: number, height: number): void {
1172
+ const provider = this.#frameProvider;
1173
+ this.#imageBudget.beginPass();
1174
+ const rendered =
1175
+ provider?.renderResizeFrame?.({ columns: width, rows: height }) ??
1176
+ (provider ? provider.renderFrame({ columns: width, rows: height }).viewport : this.render(width));
1177
+ this.#imageBudget.endPass();
1178
+ const viewport = rendered.length > height ? rendered.slice(rendered.length - height) : Array.from(rendered);
1179
+ this.#extractCursorMarkers(viewport);
1180
+ this.#emitAltFrame(this.#prepareLinesArray(viewport, width), width, height);
1181
+ }
1182
+
1183
+ /**
1184
+ * Take ownership of stdin after a `deferInput` start: raw mode, input
1185
+ * handlers, and the response-eliciting capability probes start() skipped.
1186
+ * Keystrokes typed in cooked mode meanwhile arrive through the normal input
1187
+ * path. Idempotent; no-op when input was never deferred.
1188
+ */
1189
+ enableInput(): void {
1190
+ if (!this.#inputDeferred || this.#stopped) return;
1191
+ this.#inputDeferred = false;
1192
+ this.terminal.enableInput?.();
2230
1193
  this.#querySixelSupport();
2231
1194
  this.#queryCellSize();
2232
- this.requestRender(true, { clearScrollback: options?.clearScrollback === true });
2233
1195
  }
2234
1196
 
2235
1197
  addStartListener(listener: StartListener): () => void {
@@ -2376,15 +1338,16 @@ export class TUI extends Container {
2376
1338
  this.#synchronizedOutputEnabled = enabled;
2377
1339
  this.#paintBeginSequence = enabled ? PAINT_BEGIN : PAINT_BEGIN_NO_SYNC;
2378
1340
  this.#paintEndSequence = enabled ? PAINT_END : PAINT_END_NO_SYNC;
2379
- this.#cursorBeginSequence = enabled ? CURSOR_BEGIN : CURSOR_BEGIN_NO_SYNC;
2380
- this.#cursorEndSequence = enabled ? CURSOR_END : CURSOR_END_NO_SYNC;
2381
1341
  }
2382
1342
 
2383
1343
  stop(): void {
2384
- // Leave the resize alt buffer first so the teardown cursor math below runs
2385
- // against the restored normal screen (which #previousLines still describes).
1344
+ this.#resizeSettleTimer?.cancel();
1345
+ this.#resizeSettleTimer = undefined;
1346
+ this.#cancelResizeProbe();
2386
1347
  if (this.#resizeAltActive) {
2387
- this.terminal.write(this.#leaveResizeAltSequence());
1348
+ this.#resizeAltActive = false;
1349
+ this.terminal.write(`${this.#keyboardEnhancementExit()}\x1b[?1049l`);
1350
+ setAltScreenActive(false);
2388
1351
  }
2389
1352
  if (this.#altActive || this.#pendingAltExit) {
2390
1353
  const mouseExit = this.#altMouseTrackingActive ? MOUSE_TRACKING_OFF : "";
@@ -2408,26 +1371,17 @@ export class TUI extends Container {
2408
1371
  this.#ghosttyInitialImageDelayTimer.cancel();
2409
1372
  this.#ghosttyInitialImageDelayTimer = undefined;
2410
1373
  }
2411
- if (this.#multiplexerResizeTimer) {
2412
- this.#multiplexerResizeTimer.cancel();
2413
- this.#multiplexerResizeTimer = undefined;
2414
- }
2415
- if (this.#resizeViewportSettleTimer) {
2416
- this.#resizeViewportSettleTimer.cancel();
2417
- this.#resizeViewportSettleTimer = undefined;
2418
- }
2419
- this.#resizeViewportActive = false;
2420
- this.#clearPostFullPaintSettle();
2421
- this.#deferredForcedClearScrollback = false;
2422
1374
  // Place the parent shell on the first line after the rendered content. When
2423
1375
  // that line is still inside the viewport, moving there and writing `\r` is
2424
1376
  // enough; emitting `\r\n` would create an extra blank row. If the content
2425
1377
  // already reaches the viewport bottom, scroll exactly once so the prompt
2426
1378
  // lands directly below the last visible TUI row.
2427
1379
  if (this.#previousFrameLength > 0) {
2428
- const targetRow = this.#previousFrameLength;
2429
- const viewportBottom = this.#windowTopRow + this.terminal.rows - 1;
2430
- const clampedCursorRow = Math.max(this.#windowTopRow, Math.min(this.#hardwareCursorRow, viewportBottom));
1380
+ // Provider frames anchor the mutable viewport below retained history;
1381
+ // the shell prompt belongs on the first row after that content.
1382
+ const targetRow = this.#providerViewportTop + this.#previousFrameLength;
1383
+ const viewportBottom = this.terminal.rows - 1;
1384
+ const clampedCursorRow = Math.max(0, Math.min(this.#hardwareCursorRow, viewportBottom));
2431
1385
  const moveTargetRow = Math.min(targetRow, viewportBottom);
2432
1386
  const lineDiff = moveTargetRow - clampedCursorRow;
2433
1387
  if (lineDiff > 0) {
@@ -2446,65 +1400,21 @@ export class TUI extends Container {
2446
1400
  }
2447
1401
 
2448
1402
  /**
2449
- * Force an immediate full replay of the current frame, including native
2450
- * scrollback. This is the keyboard-accessible equivalent of the resize reset:
2451
- * no queued diff frame or terminal scrollback probe can downgrade it to a
2452
- * viewport-only repaint.
2453
- *
2454
- * Invalidates every component first so the replay reflects current state. A
2455
- * geometry-driven reset thaws frozen scrollback snapshots implicitly (the new
2456
- * width misses every cached snapshot), but a same-width reset would otherwise
2457
- * replay stale snapshots — leaving host-frozen blocks (e.g. a transcript whose
2458
- * committed rows are immutable on ED3-risk terminals) showing pre-mutation
2459
- * content. Invalidation is the generic signal those containers use to retire
2460
- * their snapshots, which is exactly what a user-driven display reset wants.
1403
+ * Destructive user-gesture reset: invalidate every component, erase native
1404
+ * history, then repaint from row zero. Reachable only from explicit gestures (session
1405
+ * replace, /tree, an explicit clear) — never from ordinary rendering,
1406
+ * animation, resize, or finalization.
2461
1407
  */
2462
1408
  resetDisplay(): void {
2463
1409
  if (this.#stopped) return;
2464
- // This is a user-driven redraw of the current transcript; it must replay
2465
- // every row, so opt the next full paint out of the ConPTY resume bound.
2466
- // Set before the multiplexer early-return so it survives a deferred paint.
2467
- this.#unboundedConptyPaintRequested = true;
2468
1410
  this.invalidate();
2469
- // A reset that lands inside a tmux/screen/zellij resize burst would
2470
- // paint mid-reflow and re-introduce the flash race (issue #2088).
2471
- // Fold it into the in-flight debounce instead; the settled paint runs
2472
- // the same `#prepareForcedRender(!isMultiplexerSession())` path via
2473
- // `requestRender(true)`, so the clear-scrollback intent is preserved.
2474
- if (this.#multiplexerResizeTimer) {
2475
- this.#armMultiplexerResizeTimer({ clearScrollback: !isMultiplexerSession(), hasPendingRender: true });
2476
- return;
2477
- }
2478
- this.#prepareForcedRender(!isMultiplexerSession());
2479
- this.#resizeEventPending = true;
1411
+ this.#prepareForcedRender(true);
2480
1412
  this.#renderRequested = false;
2481
1413
  this.#executeRender();
2482
1414
  }
2483
1415
 
2484
1416
  requestRender(force = false, options?: RenderRequestOptions): void {
2485
- // Any non-component-scoped request makes the pending frame a full one.
2486
- this.#pendingRenderComponentsOnly = false;
2487
1417
  if (force) {
2488
- // Forced repaints landing inside the multiplexer resize debounce
2489
- // (e.g. `#finishSixelProbe`, image-budget eviction, a programmatic
2490
- // `requestRender(true)`) would paint into a still-reflowing pane
2491
- // and reintroduce the flash race. Fold them into the in-flight
2492
- // debounce while preserving the caller's `clearScrollback` intent
2493
- // for the settled paint. The timer's own callback clears
2494
- // `#multiplexerResizeTimer` before re-entering `requestRender(true)`,
2495
- // so this guard only catches external callers — the deferred render
2496
- // itself proceeds straight to `#prepareForcedRender`.
2497
- if (this.#multiplexerResizeTimer) {
2498
- this.#armMultiplexerResizeTimer({
2499
- clearScrollback: options?.clearScrollback === true,
2500
- hasPendingRender: true,
2501
- });
2502
- return;
2503
- }
2504
- // A forced render preempts the post-full-paint ConPTY settle: it owns
2505
- // the next paint and is going to redraw the buffer anyway, so the
2506
- // trailing coalesced render queued by the settle would only race it.
2507
- this.#clearPostFullPaintSettle();
2508
1418
  this.#prepareForcedRender(options?.clearScrollback === true);
2509
1419
  this.#renderRequested = true;
2510
1420
  this.#renderScheduler.scheduleImmediate(() => {
@@ -2520,378 +1430,38 @@ export class TUI extends Container {
2520
1430
  }
2521
1431
 
2522
1432
  /**
2523
- * Opt `component` into subtree-only renders when input leaves focus stable.
2524
- *
2525
- * The host must explicitly request renders for every sibling mutated by the
2526
- * component's input callbacks. Components without this opt-in retain the
2527
- * legacy full-root render after input.
1433
+ * Paint a forced frame synchronously when startup must hand off an already
1434
+ * visible component tree before further async initialization. Same as
1435
+ * {@link requestRender} minus the `setImmediate` hop.
2528
1436
  */
2529
- enableScopedInputRender(component: Component): void {
2530
- this.#scopedInputRenderComponents.add(component);
1437
+ renderNow(options?: RenderRequestOptions): void {
1438
+ if (this.#stopped) return;
1439
+ this.#prepareForcedRender(options?.clearScrollback === true);
1440
+ this.#renderRequested = false;
1441
+ const start = this.#renderScheduler.now();
1442
+ this.#lastRenderAt = start;
1443
+ this.#doRender();
1444
+ this.#lastFrameCostMs = this.#renderScheduler.now() - start;
2531
1445
  }
2532
1446
 
2533
1447
  /**
2534
1448
  * Schedule a render on behalf of `component` after a self-contained change
2535
- * (spinner frame, blink) that cannot have affected any other component.
2536
- *
2537
- * When every request since the last frame is component-scoped and the
2538
- * frame is otherwise quiet — no resize or geometry change, no overlays, no
2539
- * live inline images, no forced repaint, unchanged root child list — the
2540
- * next compose re-renders only the root subtrees containing the requesting
2541
- * components and reuses the previous frame's rows (and seam reports) for
2542
- * every other root child, skipping the full component-tree walk that makes
2543
- * long transcripts expensive to repaint at animation rate. Any concurrent
2544
- * full request or unsafe condition downgrades the frame to a normal full
2545
- * compose, so this is never less correct than `requestRender()` — only
2546
- * cheaper.
1449
+ * (spinner frame, blink). Frames always compose the bounded viewport from
1450
+ * scratch — retired blocks no longer render — so a scoped request is simply
1451
+ * an ordinary render.
2547
1452
  */
2548
- requestComponentRender(component: Component): void {
1453
+ requestComponentRender(_component: Component): void {
2549
1454
  if (this.#stopped) return;
2550
- // Start a component-scoped accumulation only when nothing else is in
2551
- // flight (a pending throttled request or a deferred ConPTY settle
2552
- // replay may carry full-render intent that must not be narrowed).
2553
- if (!this.#renderRequested && this.#postFullPaintSettleTimer === undefined) {
2554
- this.#pendingRenderComponentsOnly = true;
2555
- }
2556
- this.#componentRenderTargets.add(component);
2557
1455
  this.#requestOrdinaryRender();
2558
1456
  }
2559
1457
 
2560
- /**
2561
- * Rewrite a quiet, visible component segment directly.
2562
- *
2563
- * Loader-style animation changes one already-positioned segment at a fixed
2564
- * size. When the current frame geometry is still valid, rewrite just those
2565
- * rows and update the diff baseline instead of scheduling a full render
2566
- * cycle. Unsafe states fall back to `requestComponentRender()`, preserving
2567
- * the ordinary renderer as the correctness path.
2568
- */
2569
- requestDirectWrite(component: Component): void {
2570
- if (this.#stopped) return;
2571
- if (
2572
- this.#renderRequested ||
2573
- this.#postFullPaintSettleTimer !== undefined ||
2574
- this.#postFullPaintSettleDelay() > 0
2575
- ) {
2576
- this.requestComponentRender(component);
2577
- return;
2578
- }
2579
-
2580
- const width = this.terminal.columns;
2581
- const height = this.terminal.rows;
2582
- if (!this.#hasEverRendered || this.#resizeEventPending) {
2583
- this.requestComponentRender(component);
2584
- return;
2585
- }
2586
- if (width !== this.#previousWidth || height !== this.#previousHeight || width !== this.#composeWidth) {
2587
- this.requestComponentRender(component);
2588
- return;
2589
- }
2590
- if (this.#clearScrollbackOnNextRender || this.#forceViewportRepaintOnNextRender) {
2591
- this.requestComponentRender(component);
2592
- return;
2593
- }
2594
- if (this.overlayStack.length > 0 || this.#altActive || !this.#imageBudget.quiescent) {
2595
- this.requestComponentRender(component);
2596
- return;
2597
- }
2598
-
2599
- const children = this.children;
2600
- const segments = this.#frameSegments;
2601
- if (segments.length !== children.length) {
2602
- this.requestComponentRender(component);
2603
- return;
2604
- }
2605
- for (let i = 0; i < children.length; i++) {
2606
- if (segments[i]!.component !== children[i]) {
2607
- this.requestComponentRender(component);
2608
- return;
2609
- }
2610
- }
2611
-
2612
- const root = this.#resolveComponentRoot(component);
2613
- if (root === null) {
2614
- this.requestComponentRender(component);
2615
- return;
2616
- }
2617
- const segmentIndex = segments.findIndex(segment => segment.component === root);
2618
- if (segmentIndex === -1) {
2619
- this.requestComponentRender(component);
2620
- return;
2621
- }
2622
- const segment = segments[segmentIndex]!;
2623
- const fullyLiveUncommittedSegment = segment.liveLocalStart === 0 && segment.start >= this.#committedRows;
2624
- if (
2625
- (segment.liveLocalStart !== undefined && !fullyLiveUncommittedSegment) ||
2626
- segment.start < this.#committedRows
2627
- ) {
2628
- this.requestComponentRender(component);
2629
- return;
2630
- }
2631
-
2632
- const windowTop = Math.max(this.#committedRows, this.#composedFrame.length - height, 0);
2633
- if (windowTop !== this.#windowTopRow) {
2634
- this.requestComponentRender(component);
2635
- return;
2636
- }
2637
- const screenStart = segment.start - windowTop;
2638
- if (screenStart < 0 || screenStart + segment.rowCount > height) {
2639
- this.requestComponentRender(component);
2640
- return;
2641
- }
2642
-
2643
- const nextLines = root.render(width);
2644
- if (nextLines.length !== segment.rowCount) {
2645
- this.requestComponentRender(component);
2646
- return;
2647
- }
2648
-
2649
- let firstChanged = -1;
2650
- let lastChanged = -1;
2651
- const previousWindow = this.#previousWindow;
2652
- for (let i = 0; i < nextLines.length; i++) {
2653
- const frameRow = segment.start + i;
2654
- const raw = nextLines[i]!;
2655
- const composed = this.#stripCursorMarkers(raw);
2656
- const prepared = this.#prepareLine(composed, width);
2657
- this.#composedFrame[frameRow] = composed;
2658
- this.#preparedMeta[frameRow] = prepared;
2659
- this.#preparedFrame[frameRow] = prepared.line;
2660
- if (previousWindow[screenStart + i] === prepared.line) continue;
2661
- previousWindow[screenStart + i] = prepared.line;
2662
- if (firstChanged === -1) firstChanged = i;
2663
- lastChanged = i;
2664
- }
2665
- this.#replaceFrameCursorMarkers(segment.start, nextLines);
2666
- segment.lines = nextLines;
2667
- this.#preparedValidRows = Math.max(this.#preparedValidRows, segment.start + nextLines.length);
2668
- this.#renderStablePrefixRows = Math.min(this.#renderStablePrefixRows, segment.start);
2669
-
2670
- let cursorPos: { row: number; col: number } | null = null;
2671
- for (let i = this.#frameCursorMarkers.length - 1; i >= 0; i--) {
2672
- const marker = this.#frameCursorMarkers[i]!;
2673
- if (marker.row >= windowTop) {
2674
- cursorPos = marker;
2675
- break;
2676
- }
2677
- }
2678
-
2679
- if (firstChanged === -1) {
2680
- this.#writeCursorPosition(cursorPos, this.#composedFrame.length);
2681
- this.#previousWidth = width;
2682
- this.#previousHeight = height;
2683
- return;
2684
- }
2685
-
2686
- const currentScreenRow = Math.max(0, Math.min(height - 1, this.#hardwareCursorRow - windowTop));
2687
- const targetScreenRow = screenStart + firstChanged;
2688
- const rowDelta = targetScreenRow - currentScreenRow;
2689
- let buffer = this.#paintBeginSequence;
2690
- if (rowDelta > 0) buffer += `\x1b[${rowDelta}B`;
2691
- else if (rowDelta < 0) buffer += `\x1b[${-rowDelta}A`;
2692
- buffer += "\r";
2693
- for (let i = firstChanged; i <= lastChanged; i++) {
2694
- if (i > firstChanged) buffer += "\r\n";
2695
- buffer += this.#lineRewriteSequence(
2696
- this.#preparedFrame[segment.start + i] ?? "",
2697
- width,
2698
- screenStart + i,
2699
- segment.start + i,
2700
- this.#committedRows,
2701
- this.#osc66SpacerGlyphWidth(this.#preparedFrame, segment.start + i),
2702
- );
2703
- }
2704
- const cursorControl = this.#cursorControlSequence(
2705
- cursorPos,
2706
- this.#composedFrame.length,
2707
- segment.start + lastChanged,
2708
- );
2709
- buffer += cursorControl.seq;
2710
- buffer += this.#paintEndSequence;
2711
- this.terminal.write(buffer);
2712
- this.#windowTopRow = windowTop;
2713
- this.#commit(this.#composedFrame, previousWindow, width, height, cursorControl);
2714
- }
2715
-
2716
- #postFullPaintSettleDelay(): number {
2717
- const until = this.#postFullPaintSettleUntilMs;
2718
- if (until <= 0) return 0;
2719
- const remaining = until - this.#renderScheduler.now();
2720
- if (remaining > 0) return remaining;
2721
- this.#postFullPaintSettleUntilMs = 0;
2722
- return 0;
2723
- }
2724
-
2725
- /** Ordinary (non-forced) scheduling shared by full and component-scoped requests. */
1458
+ /** Ordinary (non-forced) render scheduling. */
2726
1459
  #requestOrdinaryRender(): void {
2727
- if (this.#multiplexerResizeTimer) {
2728
- this.#multiplexerResizeHasPendingRender = true;
2729
- return;
2730
- }
2731
- // Coalesce non-forced renders inside the post-full-paint ConPTY settle
2732
- // window into one trailing render. Spinner/blink/streaming components
2733
- // otherwise fire `requestRender(false)` at 30 Hz while the host is still
2734
- // catching up with the previous big paint, and each follow-up viewport
2735
- // repaint nudges Windows Terminal's viewport tracker further off the
2736
- // last row (see #2095).
2737
- const settleDelayMs = this.#postFullPaintSettleDelay();
2738
- if (settleDelayMs > 0) {
2739
- if (this.#postFullPaintSettleTimer === undefined) {
2740
- this.#postFullPaintSettleTimer = this.#renderScheduler.scheduleRender(() => {
2741
- this.#postFullPaintSettleTimer = undefined;
2742
- this.#postFullPaintSettleUntilMs = 0;
2743
- if (this.#stopped) return;
2744
- this.#requestOrdinaryRender();
2745
- }, settleDelayMs);
2746
- }
2747
- return;
2748
- }
2749
1460
  if (this.#renderRequested) return;
2750
1461
  this.#renderRequested = true;
2751
1462
  this.#renderScheduler.scheduleImmediate(() => this.#scheduleRender());
2752
1463
  }
2753
1464
 
2754
- /**
2755
- * Decide whether this frame may compose component-scoped, and resolve the
2756
- * requested components to the root children that must re-render. Returns
2757
- * null — full compose — whenever a global condition could invalidate rows
2758
- * the partial compose would reuse, or when a requested component is not
2759
- * reachable from the current root child list.
2760
- */
2761
- #resolvePartialComposeRoots(width: number, height: number): Set<Component> | null {
2762
- if (this.#componentRenderTargets.size === 0) return null;
2763
- if (!this.#hasEverRendered || this.#resizeEventPending) return null;
2764
- if (width !== this.#previousWidth || height !== this.#previousHeight || width !== this.#composeWidth) return null;
2765
- if (this.#clearScrollbackOnNextRender || this.#forceViewportRepaintOnNextRender) return null;
2766
- if (this.overlayStack.length > 0) return null;
2767
- // The image budget audits display order across the whole frame; a
2768
- // partial walk would under-count it. Engage only on image-free frames.
2769
- if (!this.#imageBudget.quiescent) return null;
2770
- // The root child list must match the segment ledger exactly — a
2771
- // structural change shifts offsets under every reused segment.
2772
- const children = this.children;
2773
- const segments = this.#frameSegments;
2774
- if (segments.length !== children.length) return null;
2775
- for (let i = 0; i < children.length; i++) {
2776
- if (segments[i]!.component !== children[i]) return null;
2777
- }
2778
- const roots = this.#partialComposeRootsScratch;
2779
- roots.clear();
2780
- for (const target of this.#componentRenderTargets) {
2781
- const root = this.#resolveComponentRoot(target);
2782
- if (root === null) return null;
2783
- roots.add(root);
2784
- }
2785
- return roots;
2786
- }
2787
-
2788
- /** Root child whose subtree contains `target`, memoized per component. */
2789
- #resolveComponentRoot(target: Component): Component | null {
2790
- const cached = this.#componentRootCache.get(target);
2791
- if (cached !== undefined && this.children.includes(cached) && subtreeContains(cached, target)) {
2792
- return cached;
2793
- }
2794
- for (const child of this.children) {
2795
- if (subtreeContains(child, target)) {
2796
- this.#componentRootCache.set(target, child);
2797
- return child;
2798
- }
2799
- }
2800
- this.#componentRootCache.delete(target);
2801
- return null;
2802
- }
2803
-
2804
- /**
2805
- * Arm or extend the multiplexer-resize debounce so a single forced render
2806
- * fires once the pane is quiet. Called by the SIGWINCH callback on every
2807
- * resize event, and by `requestRender(true)` / `resetDisplay()` when they
2808
- * land inside an in-flight settle window. Each call cancels the prior
2809
- * timer, supersedes any queued throttled render (otherwise it would race
2810
- * tmux's mid-reflow paint), and OR's the caller's `clearScrollback`
2811
- * intent into `#deferredForcedClearScrollback` — the timer's callback
2812
- * consumes that flag exactly once when it re-enters `requestRender(true)`.
2813
- */
2814
- #armMultiplexerResizeTimer(options: { clearScrollback: boolean; hasPendingRender?: boolean }): void {
2815
- this.#deferredForcedClearScrollback ||= options.clearScrollback;
2816
- this.#multiplexerResizeHasPendingRender ||= options.hasPendingRender === true;
2817
- if (this.#renderTimer) {
2818
- this.#renderTimer.cancel();
2819
- this.#renderTimer = undefined;
2820
- }
2821
- this.#renderRequested = false;
2822
- if (this.#multiplexerResizeTimer) {
2823
- this.#multiplexerResizeTimer.cancel();
2824
- }
2825
- this.#multiplexerResizeTimer = this.#renderScheduler.scheduleRender(() => {
2826
- this.#multiplexerResizeTimer = undefined;
2827
- if (this.#stopped) {
2828
- this.#deferredForcedClearScrollback = false;
2829
- return;
2830
- }
2831
- const deferredClearScrollback = this.#deferredForcedClearScrollback;
2832
- this.#deferredForcedClearScrollback = false;
2833
- this.requestRender(true, { clearScrollback: deferredClearScrollback });
2834
- }, TUI.#MULTIPLEXER_RESIZE_DEBOUNCE_MS);
2835
- }
2836
-
2837
- /**
2838
- * Arm the post-full-paint settle window after an `#emitFullPaint` that
2839
- * pushed content into native scrollback on a ConPTY host. Idempotent inside
2840
- * the window: a later overflowing paint extends `until` to the later
2841
- * deadline so back-to-back big paints do not double-fire the trailing
2842
- * coalesced render, and the existing deferred timer is rescheduled to the
2843
- * later deadline.
2844
- *
2845
- * Mid-composition callers (most notably `ImageBudget.endPass()`, which can
2846
- * call `requestRender()` from inside the in-flight paint when a new image
2847
- * trips the budget) queue their render *before* the settle exists, so they
2848
- * fall through the gate and set `#renderRequested` / `#renderTimer` on the
2849
- * 30 Hz throttle. Without absorbing those, the throttled follow-up fires
2850
- * inside the 150 ms quiet window and reintroduces the cascade the settle
2851
- * was meant to stop. Cancel both, then eagerly arm the trailing settle
2852
- * timer so the in-flight request still rides one coalesced render at the
2853
- * end of the window. See #2095.
2854
- */
2855
- #armPostFullPaintSettle(): void {
2856
- if (!isConPTYHosted()) return;
2857
- const until = this.#renderScheduler.now() + TUI.#CONPTY_POST_FULL_PAINT_SETTLE_MS;
2858
- if (until <= this.#postFullPaintSettleUntilMs) return;
2859
- this.#postFullPaintSettleUntilMs = until;
2860
- const hadPendingRender = this.#renderRequested || this.#renderTimer !== undefined;
2861
- // Reclaim any render that was queued during the in-flight composition:
2862
- // `#renderRequested` was set before the settle existed and would
2863
- // otherwise fire on the standard throttle inside the window.
2864
- this.#renderRequested = false;
2865
- if (this.#renderTimer) {
2866
- this.#renderTimer.cancel();
2867
- this.#renderTimer = undefined;
2868
- }
2869
- if (this.#postFullPaintSettleTimer) {
2870
- this.#postFullPaintSettleTimer.cancel();
2871
- this.#postFullPaintSettleTimer = undefined;
2872
- }
2873
- if (hadPendingRender) {
2874
- // Replay the absorbed request via the trailing settle timer so the
2875
- // caller's render still happens — just deferred to the end of the
2876
- // window. Subsequent `requestRender(false)` calls during the
2877
- // settle see this timer and fold into it (existing gate at L1263).
2878
- this.#postFullPaintSettleTimer = this.#renderScheduler.scheduleRender(() => {
2879
- this.#postFullPaintSettleTimer = undefined;
2880
- this.#postFullPaintSettleUntilMs = 0;
2881
- if (this.#stopped) return;
2882
- this.#requestOrdinaryRender();
2883
- }, TUI.#CONPTY_POST_FULL_PAINT_SETTLE_MS);
2884
- }
2885
- }
2886
-
2887
- #clearPostFullPaintSettle(): void {
2888
- if (this.#postFullPaintSettleTimer) {
2889
- this.#postFullPaintSettleTimer.cancel();
2890
- this.#postFullPaintSettleTimer = undefined;
2891
- }
2892
- this.#postFullPaintSettleUntilMs = 0;
2893
- }
2894
-
2895
1465
  #maybeDeferGhosttyInitialImagePaint(): boolean {
2896
1466
  if (this.#ghosttyInitialImageDelayDone) return false;
2897
1467
  if (TERMINAL.id !== "ghostty" || TERMINAL.imageProtocol !== ImageProtocol.Kitty) {
@@ -2917,6 +1487,10 @@ export class TUI extends Container {
2917
1487
  return true;
2918
1488
  }
2919
1489
  #prepareForcedRender(clearScrollback: boolean): void {
1490
+ if (clearScrollback && !this.#clearScrollbackOnNextRender) {
1491
+ this.#frameProvider?.resetHistory?.();
1492
+ if (TERMINAL.imageProtocol === ImageProtocol.Kitty) this.#imageBudget.forgetTransmitted();
1493
+ }
2920
1494
  this.#clearScrollbackOnNextRender ||= clearScrollback;
2921
1495
  this.#forceViewportRepaintOnNextRender = true;
2922
1496
  if (this.#renderTimer) {
@@ -2925,15 +1499,20 @@ export class TUI extends Container {
2925
1499
  }
2926
1500
  }
2927
1501
 
2928
- #scheduleRender(): void {
2929
- if (this.#stopped || this.#renderTimer || !this.#renderRequested) {
1502
+ #runScheduledRender = (): void => {
1503
+ this.#renderTimer = undefined;
1504
+ if (this.#stopped || !this.#renderRequested) {
2930
1505
  return;
2931
1506
  }
2932
- // Defer any new throttled render scheduled inside the multiplexer
2933
- // resize settle window: it would race tmux's mid-reflow pane repaint.
2934
- // `#renderRequested` stays set so the eventual forced render — armed
2935
- // by the SIGWINCH callback — picks up the latest component state.
2936
- if (this.#multiplexerResizeTimer) {
1507
+ this.#renderRequested = false;
1508
+ this.#executeRender();
1509
+ if (this.#renderRequested) {
1510
+ this.#scheduleRender();
1511
+ }
1512
+ };
1513
+
1514
+ #scheduleRender(): void {
1515
+ if (this.#stopped || this.#renderTimer || !this.#renderRequested) {
2937
1516
  return;
2938
1517
  }
2939
1518
  const now = this.#renderScheduler.now();
@@ -2949,17 +1528,7 @@ export class TUI extends Container {
2949
1528
  const adaptiveDelay = Math.max(0, adaptiveFloor - elapsed);
2950
1529
  const inputGraceDelay = Math.max(0, this.#inputRenderGraceUntilMs - now);
2951
1530
  const delay = Math.max(cadenceDelay, adaptiveDelay, inputGraceDelay);
2952
- this.#renderTimer = this.#renderScheduler.scheduleRender(() => {
2953
- this.#renderTimer = undefined;
2954
- if (this.#stopped || !this.#renderRequested) {
2955
- return;
2956
- }
2957
- this.#renderRequested = false;
2958
- this.#executeRender();
2959
- if (this.#renderRequested) {
2960
- this.#scheduleRender();
2961
- }
2962
- }, delay);
1531
+ this.#renderTimer = this.#renderScheduler.scheduleRender(this.#runScheduledRender, delay);
2963
1532
  }
2964
1533
 
2965
1534
  /**
@@ -2968,13 +1537,41 @@ export class TUI extends Container {
2968
1537
  * reads it re-entrantly) and compute the cost once the paint returns.
2969
1538
  */
2970
1539
  #executeRender(): void {
1540
+ if (this.#deferRenderForOutputBacklog()) return;
2971
1541
  const start = this.#renderScheduler.now();
2972
1542
  this.#lastRenderAt = start;
2973
1543
  this.#doRender();
2974
1544
  this.#lastFrameCostMs = this.#renderScheduler.now() - start;
2975
1545
  }
1546
+ /**
1547
+ * True when the frame was deferred because the terminal's output backlog
1548
+ * exceeds {@link TUI.#MAX_PENDING_OUTPUT_BYTES}. Re-arms a retry render;
1549
+ * one-shot paint intents (`#clearScrollbackOnNextRender`,
1550
+ * `#forceViewportRepaintOnNextRender`) survive untouched for it.
1551
+ */
1552
+ #deferRenderForOutputBacklog(): boolean {
1553
+ const pending = this.terminal.pendingOutputBytes;
1554
+ if (pending === undefined || pending <= TUI.#MAX_PENDING_OUTPUT_BYTES) return false;
1555
+ this.#renderRequested = true;
1556
+ this.#renderTimer ??= this.#renderScheduler.scheduleRender(
1557
+ this.#runScheduledRender,
1558
+ TUI.#OUTPUT_BACKLOG_RETRY_MS,
1559
+ );
1560
+ return true;
1561
+ }
2976
1562
 
2977
1563
  #handleInput(data: string): void {
1564
+ // Consume CPR replies (CSI row;col R) while an anchor probe is unanswered;
1565
+ // they are terminal reports, never keystrokes, and must not reach the
1566
+ // focused component.
1567
+ while (this.#pendingCprReplies > 0) {
1568
+ const match = data.match(/\x1b\[(\d+);(\d+)R/);
1569
+ if (!match || match.index === undefined) break;
1570
+ this.#pendingCprReplies--;
1571
+ this.#resolveResizeAnchor(Number(match[1]) - 1);
1572
+ data = data.slice(0, match.index) + data.slice(match.index + match[0].length);
1573
+ }
1574
+ if (data.length === 0) return;
2978
1575
  // Ctrl+C/Esc use app-level double-press windows. Give those gestures one
2979
1576
  // frame to drain queued input before an ordinary repaint; delaying every
2980
1577
  // key would make idle navigation pay a full frame of latency.
@@ -3035,11 +1632,7 @@ export class TUI extends Container {
3035
1632
  return;
3036
1633
  }
3037
1634
  focused.handleInput(data);
3038
- if (this.#focusedComponent === focused && this.#scopedInputRenderComponents.has(focused)) {
3039
- this.requestComponentRender(focused);
3040
- } else {
3041
- this.requestRender();
3042
- }
1635
+ this.requestRender();
3043
1636
  }
3044
1637
  }
3045
1638
 
@@ -3294,8 +1887,7 @@ export class TUI extends Container {
3294
1887
 
3295
1888
  /**
3296
1889
  * Strip every CURSOR_MARKER from the rendered lines (markers are internal
3297
- * sentinels and must never reach the terminal, the committed prefix, or
3298
- * the resync audit) and return the positions of the stripped markers,
1890
+ * sentinels and must never reach the terminal) and return their positions,
3299
1891
  * bottom-most first. Callers pick the visible one once the window top is
3300
1892
  * known.
3301
1893
  */
@@ -3317,56 +1909,6 @@ export class TUI extends Container {
3317
1909
  return markers;
3318
1910
  }
3319
1911
 
3320
- #truncateLargeConptyFrame(
3321
- lines: string[],
3322
- width: number,
3323
- height: number,
3324
- cursorPos: { row: number; col: number } | null,
3325
- ): { lines: string[]; cursorPos: { row: number; col: number } | null } {
3326
- if (!isConPTYHosted()) return { lines, cursorPos };
3327
-
3328
- let totalBytes = 0;
3329
- let exceedsThreshold = false;
3330
- for (const line of lines) {
3331
- totalBytes += Buffer.byteLength(line, "utf8") + 8;
3332
- if (totalBytes > TUI.#CONPTY_FRAME_TRUNCATE_THRESHOLD_BYTES) {
3333
- exceedsThreshold = true;
3334
- break;
3335
- }
3336
- }
3337
- if (!exceedsThreshold) return { lines, cursorPos };
3338
-
3339
- let retainedBytes = 0;
3340
- let retainedStart = lines.length;
3341
- while (
3342
- retainedStart > 0 &&
3343
- (retainedBytes < TUI.#CONPTY_FRAME_RETAIN_BYTES || lines.length - retainedStart < height)
3344
- ) {
3345
- retainedStart -= 1;
3346
- retainedBytes += Buffer.byteLength(lines[retainedStart] ?? "", "utf8") + 8;
3347
- }
3348
- if (retainedStart <= 0) return { lines, cursorPos };
3349
-
3350
- const marker = truncateToWidth(
3351
- `[${retainedStart} older lines hidden to keep Windows console resume responsive]`,
3352
- width,
3353
- Ellipsis.Omit,
3354
- );
3355
- const truncated = new Array<string>(lines.length - retainedStart + 1);
3356
- truncated[0] = marker;
3357
- for (let i = retainedStart; i < lines.length; i++) {
3358
- truncated[i - retainedStart + 1] = lines[i] ?? "";
3359
- }
3360
-
3361
- if (cursorPos === null || cursorPos.row < retainedStart) {
3362
- return { lines: truncated, cursorPos: null };
3363
- }
3364
- return {
3365
- lines: truncated,
3366
- cursorPos: { row: cursorPos.row - retainedStart + 1, col: cursorPos.col },
3367
- };
3368
- }
3369
-
3370
1912
  /**
3371
1913
  * Rewrite a Kitty direct-placement line for the viewport row it is written
3372
1914
  * at, clipping to the visible slice (see {@link encodeKittyPlacementLine})
@@ -3403,851 +1945,288 @@ export class TUI extends Container {
3403
1945
  const coalesced = coalesceAdjacentSgr(line);
3404
1946
  return coalesced + (line.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
3405
1947
  }
3406
-
1948
+ #renderProviderFrame(width: number, height: number): void {
1949
+ const provider = this.#frameProvider;
1950
+ if (!provider || width <= 0 || height <= 0) return;
1951
+ this.#imageBudget.beginPass();
1952
+ const plan = provider.renderFrame({ columns: width, rows: height });
1953
+ this.#imageBudget.endPass();
1954
+ let viewport = Array.from(plan.viewport);
1955
+ if (viewport.length > height) {
1956
+ const message = `Frame provider returned ${viewport.length} rows for a ${height}-row viewport`;
1957
+ if (Bun.env.NODE_ENV === "test" || Bun.env.NODE_ENV === "development") throw new Error(message);
1958
+ logger.error("TUI layout contract violated", { rows: viewport.length, height });
1959
+ viewport = viewport.slice(0, height);
1960
+ }
1961
+ if (this.#maybeDeferGhosttyInitialImagePaint()) return;
1962
+ this.#emitPlanFrame(width, height, viewport, plan.history, provider);
1963
+ }
3407
1964
  /**
3408
- * Render one frame.
1965
+ * Re-offer finalized history once after a settled resize.
3409
1966
  *
3410
- * Append-only pipeline: compose the frame, derive the commit boundary from
3411
- * the component-reported live-region seam, advance the committed-row count
3412
- * monotonically, and emit either a gesture-driven full paint or an
3413
- * incremental update. Scrollback is `frame[0..committedRows)` at all
3414
- * times — no viewport probes, no deferred reconciliation.
1967
+ * Append mode leaves the terminal's prior copy in place and writes a
1968
+ * current-width copy below it. Rebuild mode routes through the destructive
1969
+ * reset latch so ED3 removes stale history before the same replay.
3415
1970
  */
3416
- #doRender(): void {
3417
- if (this.#stopped) return;
3418
- const width = this.terminal.columns;
3419
- const height = this.terminal.rows;
3420
-
3421
- // Consume the component-scoped accumulation: it describes the render
3422
- // requests made up to this frame, whichever path the frame takes.
3423
- const componentScopedOnly = this.#pendingRenderComponentsOnly;
3424
- this.#pendingRenderComponentsOnly = false;
1971
+ #prepareResizeReplay(width: number, height: number): void {
1972
+ const size = `${width}x${height}`;
1973
+ if (
1974
+ !this.#hasEverRendered ||
1975
+ (this.#previousWidth === width && this.#previousHeight === height) ||
1976
+ this.#resizeReplaySize === size ||
1977
+ this.#resizeScrollbackMode === "preserve"
1978
+ ) {
1979
+ return;
1980
+ }
1981
+ const provider = this.#frameProvider;
1982
+ if (!provider?.resetHistory) return;
1983
+ this.#resizeReplaySize = size;
1984
+ if (this.#clearScrollbackOnNextRender) {
1985
+ this.#forceViewportRepaintOnNextRender = true;
1986
+ return;
1987
+ }
1988
+ if (this.#resizeScrollbackMode === "rebuild") {
1989
+ this.#prepareForcedRender(true);
1990
+ return;
1991
+ }
1992
+ provider.resetHistory();
1993
+ this.#forceViewportRepaintOnNextRender = true;
1994
+ }
3425
1995
 
3426
- // Fullscreen alt-screen short-circuit. While the topmost visible overlay
3427
- // requests it, borrow the terminal's alternate buffer and paint only the
3428
- // modal there; the normal screen and all accounting stay untouched.
3429
- let deferredAltExit = this.#pendingAltExit;
3430
- const topOverlay = this.#getTopmostVisibleOverlay();
3431
- const wantAlt = topOverlay?.options?.fullscreen === true;
3432
- const wantMouseTracking = wantAlt && topOverlay.options?.mouseTracking !== false;
3433
- if (wantAlt && !this.#altActive) {
3434
- // Enhanced keyboard modes can be buffer-local: re-push the active
3435
- // modified-key reporting sequence on the freshly entered alternate
3436
- // screen, or Esc/modified keys revert to legacy encoding inside
3437
- // fullscreen overlays (Ghostty/kitty/iTerm2).
3438
- const mouseEnter = wantMouseTracking ? MOUSE_TRACKING_ON : "";
3439
- this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}${mouseEnter}`);
3440
- setAltScreenActive(true);
3441
- this.terminal.hideCursor();
3442
- this.#forgetHardwareCursorState();
3443
- this.#recordHardwareCursorHidden();
3444
- this.#altActive = true;
3445
- this.#altMouseTrackingActive = wantMouseTracking;
3446
- this.#altPreviousLines = [];
3447
- this.#altEnterWidth = width;
1996
+ /**
1997
+ * Physical write transaction: append an unacknowledged history batch above
1998
+ * the anchored mutable viewport, repaint the viewport, and erase stale rows
1999
+ * below. Appending K rows scrolls at most K rows off the top — the oldest
2000
+ * visible history — and the viewport anchor follows the retained history.
2001
+ */
2002
+ #emitPlanFrame(
2003
+ width: number,
2004
+ height: number,
2005
+ viewportRows: string[],
2006
+ offered: HistoryBatch | undefined,
2007
+ provider: TerminalFrameProvider | undefined,
2008
+ ): void {
2009
+ let viewport = viewportRows;
2010
+ if (this.#getTopmostVisibleOverlay() !== undefined) {
2011
+ while (viewport.length < height) viewport.push("");
2012
+ viewport = this.#compositeOverlaysIntoWindow(viewport, width, height);
2013
+ }
2014
+ const markers = this.#extractCursorMarkers(viewport);
2015
+ const prepared = this.#prepareLinesArray(viewport, width);
2016
+ const history = offered !== undefined && offered.id > this.#acceptedHistoryBatchId ? offered : undefined;
2017
+ if (offered !== undefined && offered.id <= this.#acceptedHistoryBatchId) provider?.acknowledgeHistory(offered.id);
2018
+ const historyRows = history?.rows ?? [];
2019
+ const preparedHistory = this.#prepareLinesArray(historyRows, width);
2020
+ const rows = prepared.length;
2021
+ // Destructive reset (session replace, /tree, explicit clear, or a settled
2022
+ // resize in rebuild mode): erase native history and the viewport,
2023
+ // then repaint from row zero.
2024
+ const destructiveReset = this.#clearScrollbackOnNextRender;
2025
+ if (destructiveReset) {
2026
+ this.#providerViewportTop = 0;
2027
+ this.#providerWindow = [];
2028
+ }
2029
+ // The viewport stays anchored directly below whatever history remains on
2030
+ // screen. Appending K history rows moves the anchor down by K; the write
2031
+ // scrolls only when history + viewport overflow the physical screen, and
2032
+ // the rows that scroll off the top are exactly the oldest history rows.
2033
+ const geometryStable = this.#hasEverRendered && this.#previousWidth === width && this.#previousHeight === height;
2034
+ const startTop = destructiveReset ? 0 : Math.min(this.#providerViewportTop, Math.max(0, height - 1));
2035
+ const newTop = Math.max(0, Math.min(startTop + historyRows.length, height - rows));
2036
+ let buffer = this.#paintBeginSequence;
2037
+ if (destructiveReset && TERMINAL.imageProtocol === ImageProtocol.Kitty) {
2038
+ // ED2/ED3 erase text cells but leave Kitty graphics visible. A reset is
2039
+ // explicitly destructive, so remove every placement—not only the ones
2040
+ // this TUI tracked—then resend images composed for the clean replay.
2041
+ buffer += encodeKittyDeleteAllImages();
2042
+ this.#imageBudget.resetPlacementEpochs();
2043
+ }
2044
+ for (const sequence of this.#imageBudget.takeTransmits()) buffer += sequence;
2045
+ if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
2046
+ for (const id of this.#imageBudget.takePurgeIds()) buffer += encodeKittyDeleteImage(id);
2047
+ } else {
2048
+ this.#imageBudget.takePurgeIds();
2049
+ }
2050
+ if (destructiveReset) buffer += "\x1b[H\x1b[3J\x1b[2J";
2051
+ const diffable =
2052
+ geometryStable &&
2053
+ historyRows.length === 0 &&
2054
+ startTop === newTop &&
2055
+ !this.#forceViewportRepaintOnNextRender &&
2056
+ !destructiveReset &&
2057
+ this.#providerWindow.length > 0;
2058
+ if (diffable) {
2059
+ for (let index = 0; index < rows; index++) {
2060
+ if (this.#providerWindow[index] === prepared[index]) continue;
2061
+ buffer += `\x1b[${newTop + index + 1};1H${this.#lineRewriteSequence(
2062
+ prepared[index] ?? "",
2063
+ width,
2064
+ newTop + index,
2065
+ -1,
2066
+ -1,
2067
+ this.#osc66SpacerGlyphWidth(prepared, index),
2068
+ )}`;
2069
+ }
2070
+ if (this.#providerWindow.length > rows && newTop + rows < height) {
2071
+ buffer += `\x1b[${newTop + rows + 1};1H\x1b[J`;
2072
+ }
2073
+ } else {
2074
+ // This write scrolls when history + viewport overflow the screen; the
2075
+ // terminal pushes the physical top rows into scrollback. Rows above the
2076
+ // old viewport are committed history (correct to push), but old live
2077
+ // viewport rows are not — erase them first so a scroll can only push
2078
+ // committed rows and blanks, never an unfinished frame.
2079
+ const pushed = Math.max(0, startTop + preparedHistory.length + rows - height);
2080
+ if (pushed > this.#providerViewportTop && this.#providerWindow.length > 0) {
2081
+ buffer += `\x1b[${this.#providerViewportTop + 1};1H\x1b[J`;
2082
+ }
2083
+ buffer += `\x1b[${startTop + 1};1H`;
2084
+ let screenRow = startTop;
2085
+ for (let index = 0; index < preparedHistory.length; index++) {
2086
+ if (screenRow > startTop) buffer += "\r\n";
2087
+ buffer += this.#lineRewriteSequence(
2088
+ preparedHistory[index] ?? "",
2089
+ width,
2090
+ Math.min(screenRow, height - 1),
2091
+ -1,
2092
+ -1,
2093
+ this.#osc66SpacerGlyphWidth(preparedHistory, index),
2094
+ );
2095
+ screenRow++;
2096
+ }
2097
+ for (let index = 0; index < rows; index++) {
2098
+ if (screenRow > startTop) buffer += "\r\n";
2099
+ buffer += this.#lineRewriteSequence(
2100
+ prepared[index] ?? "",
2101
+ width,
2102
+ Math.min(screenRow, height - 1),
2103
+ -1,
2104
+ -1,
2105
+ this.#osc66SpacerGlyphWidth(prepared, index),
2106
+ );
2107
+ screenRow++;
2108
+ }
2109
+ if (newTop + rows < height) buffer += `\x1b[${newTop + rows + 1};1H\x1b[J`;
2110
+ }
2111
+ const marker = markers[0];
2112
+ const target =
2113
+ marker !== undefined && rows > 0
2114
+ ? this.#targetHardwareCursorState({ row: newTop + Math.min(marker.row, rows - 1), col: marker.col }, height)
2115
+ : null;
2116
+ if (target) {
2117
+ buffer += `\x1b[${target.row + 1};${target.col + 1}H${target.visible ? "\x1b[?25h" : "\x1b[?25l"}`;
2118
+ this.#parkedViewportOffset = Math.max(0, target.row - newTop);
2119
+ } else {
2120
+ // Park the hidden cursor on the viewport's top row: terminals keep the
2121
+ // cursor attached to its logical line through resize reflow, so the
2122
+ // post-resize anchor probe can recover where the viewport landed.
2123
+ buffer += `\x1b[?25l\x1b[${newTop + 1};1H`;
2124
+ this.#parkedViewportOffset = 0;
2125
+ }
2126
+ buffer += this.#paintEndSequence;
2127
+ this.terminal.write(buffer);
2128
+ if (target) this.#recordHardwareCursorState(target);
2129
+ else this.#recordHardwareCursorHidden();
2130
+ this.#providerWindow = prepared;
2131
+ this.#providerViewportTop = newTop;
2132
+ this.#previousWidth = width;
2133
+ this.#previousHeight = height;
2134
+ this.#previousFrameLength = rows;
2135
+ this.#clearScrollbackOnNextRender = false;
2136
+ this.#forceViewportRepaintOnNextRender = false;
2137
+ this.#hasEverRendered = true;
2138
+ this.#resizeReplaySize = undefined;
2139
+ if (history !== undefined) {
2140
+ this.#acceptedHistoryBatchId = history.id;
2141
+ provider?.acknowledgeHistory(history.id);
2142
+ // Draining is one batch per frame; the provider may hold further
2143
+ // finalized prefixes (large resumed transcripts). Pump the next frame
2144
+ // instead of waiting for an unrelated render request.
2145
+ this.requestRender();
2146
+ }
2147
+ }
2148
+
2149
+ /** Render one frame: alt-screen modal, provider plan, or children fallback. */
2150
+ #doRender(): void {
2151
+ if (this.#stopped) return;
2152
+ const width = this.terminal.columns;
2153
+ const height = this.terminal.rows;
2154
+ if (this.#resizeAltActive) {
2155
+ this.#renderResizeAltFrame(width, height);
2156
+ return;
2157
+ }
2158
+ if (this.#resizeProbe) {
2159
+ // The settled repaint lands via #resolveResizeAnchor; painting now would
2160
+ // use the stale pre-resize anchor.
2161
+ return;
2162
+ }
2163
+
2164
+ // Fullscreen alt-screen short-circuit. While the topmost visible overlay
2165
+ // requests it, borrow the terminal's alternate buffer and paint only the
2166
+ // modal there; the normal screen and all accounting stay untouched.
2167
+ const topOverlay = this.#getTopmostVisibleOverlay();
2168
+ const wantAlt = topOverlay?.options?.fullscreen === true;
2169
+ const wantMouseTracking = wantAlt && topOverlay.options?.mouseTracking !== false;
2170
+ if (wantAlt && !this.#altActive) {
2171
+ // Enhanced keyboard modes can be buffer-local: re-push the active
2172
+ // modified-key reporting sequence on the freshly entered alternate
2173
+ // screen, or Esc/modified keys revert to legacy encoding inside
2174
+ // fullscreen overlays (Ghostty/kitty/iTerm2).
2175
+ const mouseEnter = wantMouseTracking ? MOUSE_TRACKING_ON : "";
2176
+ this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}${mouseEnter}`);
2177
+ setAltScreenActive(true);
2178
+ this.terminal.hideCursor();
2179
+ this.#forgetHardwareCursorState();
2180
+ this.#recordHardwareCursorHidden();
2181
+ this.#altActive = true;
2182
+ this.#altMouseTrackingActive = wantMouseTracking;
2183
+ this.#altPreviousLines = [];
2184
+ this.#altEnterWidth = width;
3448
2185
  this.#altEnterHeight = height;
3449
- this.#altWidthEpochBoundary = this.captureNativeScrollbackWidthEpoch();
3450
2186
  } else if (!wantAlt && this.#altActive) {
3451
2187
  const mouseExit = this.#altMouseTrackingActive ? MOUSE_TRACKING_OFF : "";
3452
2188
  const enhancementExit = this.#keyboardEnhancementExit();
3453
2189
  const exitSequence = `${mouseExit}${enhancementExit}\x1b[?1049l`;
3454
- // Session replacement can finish while a fullscreen selector is still
3455
- // covering the old normal buffer. Keep the overlay visible until the
3456
- // replacement is ready, then fuse the buffer restore into that full paint;
3457
- // a standalone exit exposes the stale session for one terminal frame.
3458
- if (this.#clearScrollbackOnNextRender) {
3459
- this.#pendingAltExit = exitSequence;
3460
- deferredAltExit = exitSequence;
3461
- } else this.terminal.write(exitSequence);
2190
+ this.terminal.write(exitSequence);
3462
2191
  setAltScreenActive(false);
3463
2192
  this.#forgetHardwareCursorState();
3464
2193
  this.#altActive = false;
3465
2194
  this.#altMouseTrackingActive = false;
3466
2195
  this.#altPreviousLines = [];
3467
- this.#altWidthEpochBoundary = undefined;
3468
- // A resize while on the alt buffer reflowed the terminal's saved
3469
- // normal screen; it no longer matches our accounting, so force the
3470
- // geometry rebuild path instead of a stale diff. A pure height change
3471
- // across the alt-buffer boundary (width unchanged) is the signature of
3472
- // a terminal that re-reports its size whenever the alternate screen
3473
- // toggles — the Warp-class quirk. Latch the in-place resize path so
3474
- // this exit and the revert SIGWINCH repaint without an ED3 scrollback
3475
- // rewrap instead of flashing a destructive full paint (#6511).
2196
+ // The alt buffer restore put the pre-overlay normal screen back; a
2197
+ // geometry change while covered invalidates the diff baseline and the
2198
+ // writer's dimension check forces the full anchored rewrite.
3476
2199
  if (width !== this.#altEnterWidth || height !== this.#altEnterHeight) {
3477
- this.#resizeEventPending = true;
3478
- if (width === this.#altEnterWidth) this.#altToggleResizesInPlace = true;
2200
+ this.#forceViewportRepaintOnNextRender = true;
3479
2201
  }
3480
2202
  } else if (wantMouseTracking !== this.#altMouseTrackingActive) {
3481
2203
  this.terminal.write(wantMouseTracking ? MOUSE_TRACKING_ON : MOUSE_TRACKING_OFF);
3482
2204
  this.#altMouseTrackingActive = wantMouseTracking;
3483
2205
  }
3484
2206
  if (this.#altActive) {
3485
- this.#componentRenderTargets.clear();
3486
2207
  this.#renderAltFrame(width, height);
3487
2208
  return;
3488
2209
  }
3489
-
3490
- // Resize viewport fast path. While a non-multiplexer drag is in flight,
3491
- // paint only the viewport and skip composing the off-screen history.
3492
- // Strictly state-isolated: it never consumes #resizeEventPending nor
3493
- // advances any commit/window/diff field, so the authoritative full paint
3494
- // the settle timer queues reconciles as if these throwaway frames never
3495
- // ran. Two render sources reach here mid-drag and BOTH must stay on this
3496
- // path:
3497
- // - the resize callback's own cheap paint after each SIGWINCH;
3498
- // - an ordinary (non-forced) render from a live block that keeps
3499
- // animating through the drag — a spinner tick, a streamed token, a
3500
- // cursor blink — firing requestRender(false)/requestComponentRender.
3501
- // #resizeEventPending is still set (the fast path never consumed it),
3502
- // so without this branch the ordinary render falls through to the
3503
- // geometry-rebuild full paint below, which LEAVES the borrowed
3504
- // alternate screen to repaint the whole transcript on the normal
3505
- // screen — then the next SIGWINCH re-enters the alt screen and paints
3506
- // only the tail, so the block flashes in for one frame and vanishes.
3507
- // A FORCED render mid-drag (tool finalization, resetDisplay, image
3508
- // reconciliation) also stays on the fast path: preempting would leave
3509
- // the borrowed alternate screen and run the geometry-rebuild full paint
3510
- // on the normal screen — ED3 plus an O(history) replay that visibly
3511
- // scrolls the whole transcript through the viewport, once per forced
3512
- // render and once more at settle. The forced intent is not lost: the
3513
- // fast path consumes neither #forceViewportRepaintOnNextRender nor
3514
- // #clearScrollbackOnNextRender, and the settle's authoritative
3515
- // requestRender(true) honors both — same fold-into-the-settle contract
3516
- // as the multiplexer resize debounce. A visible overlay composites over
3517
- // the transcript and needs the whole window, so it falls through
3518
- // (overlay resizes are not on the drag-cost hot path).
3519
- if (this.#resizeViewportActive && this.#hasEverRendered && this.#getTopmostVisibleOverlay() === undefined) {
3520
- this.#componentRenderTargets.clear();
3521
- this.#renderResizeViewport(width, height);
2210
+ if (this.#frameProvider !== undefined) {
2211
+ this.#prepareResizeReplay(width, height);
2212
+ this.#renderProviderFrame(width, height);
3522
2213
  return;
3523
2214
  }
3524
-
3525
- // A destructive replay erases native history and must receive the complete
3526
- // component frame. Give virtualized roots one compose to rehydrate rows
3527
- // they dropped after commit. Height-only and net-unchanged resize events
3528
- // count too: both enter the geometry rebuild path below.
3529
- const replayFullHistory =
3530
- this.#hasEverRendered &&
3531
- !this.#resizeRepaintsInPlace() &&
3532
- (this.#clearScrollbackOnNextRender ||
3533
- this.#resizeEventPending ||
3534
- (this.#previousWidth > 0 && this.#previousWidth !== width) ||
3535
- (this.#previousHeight > 0 && this.#previousHeight !== height));
3536
- if (replayFullHistory) {
3537
- for (const child of this.children) prepareNativeScrollbackReplay(child);
3538
- }
3539
-
3540
- // 1. Compose the frame. Bracket the render so the image budget observes
3541
- // every inline image in display order (overlays carry none). A
3542
- // component-scoped frame skips the budget pass instead — it is gated on
3543
- // a quiescent budget, and a partial tree walk would under-count display
3544
- // order — and re-renders only the requested root subtrees, reusing the
3545
- // previous segment of every other root child.
3546
- const partialRoots = componentScopedOnly ? this.#resolvePartialComposeRoots(width, height) : null;
3547
- this.#componentRenderTargets.clear();
3548
- let rawFrame: readonly string[];
3549
- if (partialRoots !== null) {
3550
- this.#partialComposeRoots = partialRoots;
3551
- try {
3552
- rawFrame = this.render(width);
3553
- } finally {
3554
- this.#partialComposeRoots = null;
3555
- }
3556
- } else {
3557
- this.#imageBudget.beginPass();
3558
- rawFrame = this.render(width);
3559
- this.#imageBudget.endPass();
3560
- }
3561
- // Ghostty initial-image deferral must run before any render state is
3562
- // consumed (#resizeEventPending, hardware-cursor state, commit
3563
- // re-anchoring): the early return abandons this frame and the deferred
3564
- // render recomposes from scratch, so consuming state here would
3565
- // misclassify a pending resize as an ordinary diff and corrupt the paint.
3566
- if (this.#maybeDeferGhosttyInitialImagePaint()) return;
3567
- // Cursor markers were stripped at compose time (they are internal
3568
- // sentinels and must never reach the terminal, the committed prefix, or
3569
- // the audit); the visible marker is chosen after the window top is
3570
- // known. Ascending by frame row.
3571
- const cursorMarkers = this.#frameCursorMarkers;
3572
- const liveRegionStart = this.#nativeScrollbackLiveRegionStart;
3573
- const liveRegionPinned = this.#nativeScrollbackLiveRegionPinned;
3574
-
3575
- // Exactness boundary (used by the audit-zone math below). Rows below it
3576
- // are declared FINAL by the component seam: when they commit, they enter
3577
- // the audited zone (byte-exact, repairable on violation). Rows above it
3578
- // that scroll off the window commit as frozen visual snapshots (see
3579
- // #committedPrefixAuditRows). The whole frame is final when the root
3580
- // reports no seam (shell semantics).
3581
- const frameLength = rawFrame.length;
3582
- const finalBoundary = Math.max(0, Math.min(frameLength, liveRegionStart ?? frameLength));
3583
- // No commit may cross into a pinned region, even one below an unpinned
3584
- // topmost seam (an anchored HUD/panel under a streaming transcript). The
3585
- // topmost seam still governs exactness (finalBoundary); this ceiling only
3586
- // bars a growing pinned region's scrolled-off rows from native scrollback.
3587
- const commitCeiling = this.#nativeScrollbackPinnedBoundary ?? frameLength;
3588
-
3589
- // 2. Transition state captured before any emitter runs.
3590
- let prevWindowTop = this.#windowTopRow;
3591
- const prevHardwareCursorRow = this.#hardwareCursorRow;
3592
- const resizeEventOccurred = this.#resizeEventPending;
3593
- this.#resizeEventPending = false;
3594
- const resizeHadPendingRender = this.#multiplexerResizeHasPendingRender;
3595
- this.#multiplexerResizeHasPendingRender = false;
3596
- if (resizeEventOccurred) this.#forgetHardwareCursorState();
3597
- const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
3598
- const widthEpochOccurred = widthChanged || (resizeEventOccurred && this.#multiplexerWidthEpochPending);
3599
- const capturedWidthEpochBoundary = this.#multiplexerWidthEpochBoundary;
3600
- const widthEpochBoundary = this.#widthEpochOverlayBoundary ?? capturedWidthEpochBoundary;
3601
- const widthEpochSourceBoundary = widthEpochOccurred
3602
- ? this.resolveNativeScrollbackWidthEpoch(widthEpochBoundary)
3603
- : undefined;
3604
- const widthEpochCurrentRows = widthEpochOccurred
3605
- ? this.#getNativeScrollbackWidthEpochCurrentRows(widthEpochBoundary)
3606
- : undefined;
3607
- const widthEpochAppendOnly = widthEpochOccurred
3608
- ? this.#isNativeScrollbackWidthEpochAppendOnly(widthEpochBoundary)
3609
- : true;
3610
- if (resizeEventOccurred) {
3611
- this.#multiplexerWidthEpochBoundary = undefined;
3612
- this.#multiplexerWidthEpochPending = false;
3613
- }
3614
- // A resize event with net-unchanged dimensions still reflowed the
3615
- // terminal buffer; classify it as a height change so geometry handling
3616
- // repaints instead of diffing against a screen that no longer exists.
3617
- const heightChanged =
3618
- (this.#previousHeight > 0 && this.#previousHeight !== height) ||
3619
- (resizeEventOccurred && this.#previousHeight > 0);
3620
- const geometryChanged = widthChanged || heightChanged;
3621
- const widthEpochReset = widthEpochOccurred && this.#resizeRepaintsInPlace();
3622
- // A later width reset cannot use the opaque native ledger against
3623
- // attachment rows from the current-width placement epoch. Capture that
3624
- // epoch's seam before reset classification replaces its baseline.
3625
- const placementEpochWatermark = this.#widthEpochBaselineRows === undefined ? this.#committedRows : prevWindowTop;
3626
- if (widthEpochReset) this.#widthEpochCommittedPrefix = undefined;
3627
-
3628
- // Committed-prefix audit. Rows below the audit mark are hard-verified
3629
- // exact bytes; rows between the mark and the current exactness boundary
3630
- // are frozen snapshots whose source JUST became final and must be
3631
- // verified once (a pending header settling, a barrier clearing above a
3632
- // shifted tail); rows past the boundary are still-live frozen snapshots,
3633
- // exempt so a collapsing preview can never spray re-anchors mid-run. A
3634
- // divergence re-anchors — feeding the divergenceRebuild erase-and-replay
3635
- // below (mux fallback: recommit below the stale copy; duplication, never
3636
- // loss) — instead of silently skipping rows (committed nowhere, painted
3637
- // nowhere). Skipped on geometry frames (a rewrap legitimately reflows
3638
- // every row), and skipped when the composed frame's stable prefix
3639
- // covers every verified row and no rows newly became final.
3640
- let committedRowsResynced = false;
3641
- const widthEpochPrefix = this.#widthEpochCommittedPrefix;
3642
- if (widthEpochPrefix && !geometryChanged && !this.#clearScrollbackOnNextRender) {
3643
- let newlyFinalRows = 0;
3644
- while (
3645
- newlyFinalRows < widthEpochPrefix.frameRows.length &&
3646
- widthEpochPrefix.frameRows[newlyFinalRows]! < finalBoundary
3647
- ) {
3648
- newlyFinalRows++;
3649
- }
3650
- widthEpochPrefix.auditRows = Math.min(widthEpochPrefix.auditRows, newlyFinalRows);
3651
- const verifiedTailRow = widthEpochPrefix.frameRows[widthEpochPrefix.auditRows - 1];
3652
- const shouldAudit =
3653
- newlyFinalRows > widthEpochPrefix.auditRows ||
3654
- (verifiedTailRow !== undefined && this.#renderStablePrefixRows <= verifiedTailRow);
3655
- let resyncTo = -1;
3656
- const firstMissing = widthEpochPrefix.frameRows.findIndex(row => row >= frameLength);
3657
- if (firstMissing >= 0) {
3658
- const surviving = widthEpochPrefix.frameRows.slice(0, firstMissing).map(row => rawFrame[row]!);
3659
- for (let i = 0; i < surviving.length; i++) {
3660
- if (!rowsEquivalent(surviving[i]!, widthEpochPrefix.prefix[i]!)) {
3661
- resyncTo = i;
3662
- break;
3663
- }
3664
- }
3665
- if (resyncTo < 0) resyncTo = firstMissing;
3666
- } else if (shouldAudit) {
3667
- const current = widthEpochPrefix.frameRows.map(row => rawFrame[row]!);
3668
- resyncTo = findCommittedPrefixResync(
3669
- current,
3670
- widthEpochPrefix.prefix,
3671
- widthEpochPrefix.auditRows,
3672
- newlyFinalRows,
3673
- );
3674
- if (resyncTo < 0) widthEpochPrefix.auditRows = newlyFinalRows;
3675
- }
3676
- if (resyncTo >= 0) {
3677
- const recoveryRow = Math.min(frameLength, widthEpochPrefix.frameRows[resyncTo] ?? frameLength);
3678
- widthEpochPrefix.frameRows.length = resyncTo;
3679
- widthEpochPrefix.prefix.length = resyncTo;
3680
- widthEpochPrefix.auditRows = Math.min(widthEpochPrefix.auditRows, resyncTo);
3681
- this.#committedRows = widthEpochPrefix.nativeBaseRows + resyncTo;
3682
- this.#widthEpochBaselineRows = recoveryRow;
3683
- this.#windowTopRow = recoveryRow;
3684
- prevWindowTop = recoveryRow;
3685
- if ($flag("PI_DEBUG_REDRAW")) {
3686
- const msg = `[${new Date().toISOString()}] width epoch commit resync: local prefix diverged at row ${recoveryRow}; recommitting\n`;
3687
- fs.appendFileSync(getDebugLogPath(), msg);
3688
- }
3689
- }
3690
- }
3691
- const newlyFinalEnd = Math.min(this.#committedRows, finalBoundary);
3692
- // The exactness boundary can RETREAT (a markdown rewind, a mermaid fence
3693
- // appearing, a fast-path reset re-opening a block): rows verified under
3694
- // the old boundary have a live source again. Demote them to frozen
3695
- // snapshots instead of auditing content that is expected to change —
3696
- // their committed bytes stay as the visual record, and the next boundary
3697
- // rise strict-verifies them once like any other frozen row.
3698
- if (this.#widthEpochBaselineRows === undefined && this.#committedPrefixAuditRows > newlyFinalEnd) {
3699
- this.#committedPrefixAuditRows = newlyFinalEnd;
3700
- }
3701
- const auditRan =
3702
- this.#hasEverRendered &&
3703
- !geometryChanged &&
3704
- this.#widthEpochBaselineRows === undefined &&
3705
- !this.#clearScrollbackOnNextRender &&
3706
- (this.#renderStablePrefixRows < this.#committedPrefixAuditRows ||
3707
- newlyFinalEnd > this.#committedPrefixAuditRows);
3708
- if (auditRan) {
3709
- const committedRowsBeforeAudit = this.#committedRows;
3710
- this.#auditCommittedPrefix(rawFrame, newlyFinalEnd);
3711
- committedRowsResynced = this.#committedRows !== committedRowsBeforeAudit;
3712
- }
3713
- // A frame that shrank below the committed row count collapsed content
3714
- // that was already recorded (a live suffix collapsing on abort/result).
3715
- // Re-base the commit index at the first divergence against the recorded
3716
- // prefix — frozen snapshots included; a collapse is precisely when the
3717
- // record and the frame part ways — so the surviving exact prefix stays
3718
- // recognized and is never re-shown or re-committed. Only genuinely new
3719
- // content repaints below it.
3720
- if (
3721
- this.#widthEpochBaselineRows === undefined &&
3722
- !geometryChanged &&
3723
- !this.#clearScrollbackOnNextRender &&
3724
- frameLength < this.#committedRows
3725
- ) {
3726
- const limit = Math.min(this.#committedRows, frameLength);
3727
- let diverged = limit;
3728
- for (let i = 0; i < limit; i++) {
3729
- if (!rowsEquivalent(rawFrame[i]!, this.#committedPrefix[i]!)) {
3730
- diverged = i;
3731
- break;
3732
- }
3733
- }
3734
- if (diverged < this.#committedRows) {
3735
- this.#committedRows = diverged;
3736
- this.#committedPrefixAuditRows = Math.min(this.#committedPrefixAuditRows, diverged);
3737
- this.#committedPrefix.length = diverged;
3738
- committedRowsResynced = true;
3739
- }
3740
- }
3741
- // Committed-prefix state this frame's commit math extends from
3742
- // (post-audit): drives the audit-mark advance after the emit.
3743
- const preCommitRows = this.#committedRows;
3744
- const preAuditRows = this.#committedPrefixAuditRows;
3745
- let committedPrefixResliced = false;
3746
-
3747
- // 3. Window and commit math (lengths only; content prepared below).
3748
- let hasVisibleOverlay = false;
3749
- for (const entry of this.overlayStack) {
3750
- if (this.#isOverlayVisible(entry)) {
3751
- hasVisibleOverlay = true;
3752
- break;
3753
- }
3754
- }
3755
- // Without a logical source boundary, pending growth folded into an
3756
- // overlay-covered width reset cannot be separated from reflow. Replay
3757
- // conservatively from row zero after the overlay closes: duplication is
3758
- // preferable to dropping rows that were never emitted anywhere.
3759
- if (widthEpochReset && hasVisibleOverlay && widthEpochSourceBoundary === undefined && resizeHadPendingRender) {
3760
- this.#widthEpochOverlayReplayPending = true;
3761
- }
3762
- if (widthEpochReset && hasVisibleOverlay && this.#widthEpochOverlayBoundary === undefined) {
3763
- this.#widthEpochOverlayBoundary = capturedWidthEpochBoundary;
3764
- }
3765
- const replayUnresolvedOverlayFrame = widthEpochReset && this.#widthEpochOverlayReplayPending;
3766
- const replayUnresolvedWidthEpoch =
3767
- replayUnresolvedOverlayFrame ||
3768
- (widthEpochReset && liveRegionPinned && this.#widthEpochReplayUnresolved) ||
3769
- (widthEpochReset &&
3770
- resizeHadPendingRender &&
3771
- widthEpochBoundary !== undefined &&
3772
- widthEpochSourceBoundary === undefined);
3773
- if (replayUnresolvedWidthEpoch) prevWindowTop = 0;
3774
-
3775
- // 4. Classify. A resize is an explicit user gesture: normally the engine
3776
- // erases and replays so history rewraps at the new geometry (the reader
3777
- // snapped to the bottom just dragged the window). Multiplexer panes — and
3778
- // terminals that re-report size on alt-screen toggles — instead repaint in
3779
- // place, because an ED3 rewrap is unsafe (pane scrollback / alt-screen
3780
- // feedback loop), so committed history keeps its old wrap.
3781
- const firstPaint = !this.#hasEverRendered;
3782
- const replaceRequested = this.#clearScrollbackOnNextRender;
3783
- const geometryRebuild = geometryChanged && !this.#resizeRepaintsInPlace();
3784
- // Committed history no longer matches the frame: a finalized block
3785
- // replaced its scrolled-off live render, or the frame collapsed into
3786
- // recorded rows. Native scrollback is a render cache, not a court
3787
- // record — erase and replay so history holds the content exactly once,
3788
- // instead of recommitting the final form below the stale fragment
3789
- // (a visibly duplicated block). Multiplexer panes cannot ED3 safely
3790
- // and keep the repair-below fallback in the branches under this one.
3791
- const divergenceRebuild =
3792
- this.#scrollbackRebuildEnabled &&
3793
- !firstPaint &&
3794
- !replaceRequested &&
3795
- !geometryChanged &&
3796
- !isMultiplexerSession() &&
3797
- (committedRowsResynced || frameLength <= this.#committedRows);
3798
- const fullPaint = firstPaint || replaceRequested || geometryRebuild || divergenceRebuild;
3799
- // Height-only mux resizes move rows between the pane's scrollback and its
3800
- // grid. A shrink with a full grid pushes the grid-top rows into pane
3801
- // scrollback without a commit; a grow pulls the scrollback tail back into
3802
- // the grid, where the forced in-place window rewrite overwrites it — gone
3803
- // from history. The scrollback is a stack: pushed rows sit at the tail
3804
- // only until the next commit buries them, so they shield a pull only
3805
- // while the commit seam has not moved since the push. The unshielded
3806
- // remainder removes committed rows — drop the commit seam by it and let
3807
- // the next chunk re-commit them. A short frame gains blank rows instead
3808
- // of pulling.
3809
- if (fullPaint || widthChanged) {
3810
- this.#muxPushedRows = 0;
3811
- } else if (
3812
- geometryChanged &&
3813
- this.#previousHeight > 0 &&
3814
- this.#previousFrameLength - this.#windowTopRow >= this.#previousHeight
3815
- ) {
3816
- if (this.#committedRows !== this.#muxPushSeam) this.#muxPushedRows = 0;
3817
- if (height < this.#previousHeight) {
3818
- this.#muxPushedRows += this.#previousHeight - height;
3819
- this.#muxPushSeam = this.#committedRows;
3820
- } else if (height > this.#previousHeight) {
3821
- const pull = height - this.#previousHeight;
3822
- const fromPushed = Math.min(pull, this.#muxPushedRows);
3823
- this.#muxPushedRows -= fromPushed;
3824
- const fromCommitted = Math.min(pull - fromPushed, this.#committedRows);
3825
- this.#committedRows -= fromCommitted;
3826
- this.#committedPrefix.length = this.#committedRows;
3827
- this.#muxPushSeam = this.#committedRows;
3828
- }
3829
- }
3830
- let windowTop: number;
3831
- let chunkTo: number;
3832
- let widthEpochAppendFrom = 0;
3833
- let widthEpochAppendTo = 0;
3834
- if (fullPaint) {
3835
- committedPrefixResliced = true;
3836
- windowTop = Math.max(0, frameLength - height);
3837
- chunkTo = Math.min(windowTop, commitCeiling);
3838
- } else if (widthEpochReset) {
3839
- // A terminal width change ends the physical-row coordinate epoch.
3840
- // Resolve the last emitted logical source boundary at the new width;
3841
- // updates queued during debounce are the current-boundary suffix.
3842
- // Components without the source contract retain the conservative
3843
- // legacy fallback, but never compare cross-width counts when a marker
3844
- // resolved successfully.
3845
- this.#widthEpochBaselineRows = replayUnresolvedWidthEpoch
3846
- ? 0
3847
- : (widthEpochSourceBoundary ??
3848
- (resizeHadPendingRender ? Math.min(frameLength, this.#previousFrameLength) : frameLength));
3849
- this.#widthEpochReplayUnresolved = replayUnresolvedWidthEpoch;
3850
- windowTop = Math.max(0, frameLength - height);
3851
- chunkTo = this.#committedRows;
3852
- widthEpochAppendFrom = this.#widthEpochBaselineRows;
3853
- widthEpochAppendTo =
3854
- hasVisibleOverlay || widthEpochCurrentRows === undefined
3855
- ? hasVisibleOverlay
3856
- ? widthEpochAppendFrom
3857
- : Math.max(widthEpochAppendFrom, commitCeiling)
3858
- : Math.max(widthEpochAppendFrom, widthEpochCurrentRows);
3859
- } else if (this.#widthEpochBaselineRows !== undefined) {
3860
- // Only rows physically appended after the width epoch may drive the
3861
- // terminal forward. Keep the native commit count independent of this
3862
- // frame-coordinate baseline. Overlays defer all emission; a pinned
3863
- // live region clips advancement to its exact final boundary so mutable
3864
- // rows remain viewport-only until finalization.
3865
- windowTop = Math.max(0, frameLength - height);
3866
- chunkTo = this.#committedRows;
3867
- widthEpochAppendFrom = this.#widthEpochBaselineRows;
3868
- const appendBoundary = commitCeiling;
3869
- widthEpochAppendTo = hasVisibleOverlay ? widthEpochAppendFrom : Math.max(widthEpochAppendFrom, appendBoundary);
3870
- } else if (
3871
- frameLength <= this.#committedRows ||
3872
- (committedRowsResynced &&
3873
- frameLength - this.#committedRows < height &&
3874
- cursorMarkers.some(marker => marker.row >= this.#committedRows))
3875
- ) {
3876
- // Multiplexer fallback (a direct terminal takes the divergenceRebuild
3877
- // full paint above): either the frame shrank into the committed
3878
- // prefix, or a committed-prefix resync left a focused cursor tail
3879
- // shorter than the viewport. The latter happens when a streaming/live
3880
- // block had an append-only prefix committed, then collapses on
3881
- // abort/finalize: the audit re-anchors #committedRows at the first
3882
- // divergent row, but flooring windowTop there would pin the editor
3883
- // near the top and leave blank rows underneath. Re-show the frame
3884
- // tail instead. The stale committed copy stays in native history;
3885
- // duplicating a few rows is preferable to a live editor gap —
3886
- // "duplication, never loss" is the ED3-unsafe fallback contract.
3887
- committedPrefixResliced = true;
3888
- windowTop = Math.max(0, frameLength - height);
3889
- chunkTo = Math.min(windowTop, commitCeiling);
3890
- this.#committedRows = chunkTo;
3891
- this.#committedPrefix = rawFrame.slice(0, chunkTo);
3892
- } else if (geometryChanged && Math.max(0, frameLength - height) < this.#committedRows) {
3893
- // Pane growth/reflow can pull rows back out of mux scrollback and into
3894
- // the viewport. Rebase the commit seam to that exposed frame tail before
3895
- // the forced rewrite; flooring at the old seam would paint only the live
3896
- // suffix followed by blanks, then preserve that gap on every stream tick.
3897
- windowTop = Math.max(0, frameLength - height);
3898
- chunkTo = windowTop;
3899
- this.#committedRows = windowTop;
3900
- if (widthChanged) {
3901
- // A rewrap invalidated the recorded bytes: re-base the audit prefix
3902
- // at the new width so the accepted wrap drift does not read as a
3903
- // violation on the next ordinary frame.
3904
- committedPrefixResliced = true;
3905
- this.#committedPrefix = rawFrame.slice(0, windowTop);
3906
- } else {
3907
- // Height-only reflow: the pane did not rewrap, so the recorded
3908
- // bytes are still the true tape record. Truncate instead of
3909
- // reslicing from the current frame — replacing the record with
3910
- // live content would bless drifted frozen snapshots as verified
3911
- // and skip their finalize-time recommit (rows lost from history).
3912
- this.#committedPrefix.length = Math.min(this.#committedPrefix.length, windowTop);
3913
- }
3914
- } else {
3915
- // Re-anchor to the frame tail, floored at the committed boundary: a
3916
- // shrink (or overlay close) pulls the window back down, but never
3917
- // onto rows already in native history — re-showing those on the
3918
- // grid would duplicate them for a scrolling reader. On a
3919
- // multiplexer resize the pane reflowed its own history; committed
3920
- // rows keep their old wrap there, same as any shell output.
3921
- windowTop = Math.max(this.#committedRows, frameLength - height, 0);
3922
- // Whatever scrolls above the window commits — the tape is the visual
3923
- // record; nothing that was painted may vanish. Overlays freeze
3924
- // commits: composited rows must never enter history, and the hidden
3925
- // gap backfills via the chunk once the overlay closes. A multiplexer
3926
- // resize also commits nothing — the pane keeps its own history — and
3927
- // a width rewrap re-bases the audit prefix at the new width so the
3928
- // accepted wrap drift does not read as a violation on the next
3929
- // ordinary frame.
3930
- chunkTo =
3931
- hasVisibleOverlay || geometryChanged
3932
- ? this.#committedRows
3933
- : Math.min(windowTop, Math.max(this.#committedRows, commitCeiling));
3934
- if (widthChanged) {
3935
- committedPrefixResliced = true;
3936
- this.#committedPrefix = rawFrame.slice(0, this.#committedRows);
3937
- }
3938
- }
3939
-
3940
- // 5. Pick the visible cursor marker (bottom-most at or below the window
3941
- // top), prepare lines, and build the visible window slice.
3942
- let cursorPos: { row: number; col: number } | null = null;
3943
- for (let i = cursorMarkers.length - 1; i >= 0; i--) {
3944
- const marker = cursorMarkers[i]!;
3945
- if (marker.row >= windowTop) {
3946
- cursorPos = marker;
3947
- break;
3948
- }
3949
- }
3950
- const frame = this.#prepareFrame(rawFrame, width);
3951
- let window: string[] = new Array(height);
3952
- for (let r = 0; r < height; r++) window[r] = frame[windowTop + r] ?? "";
3953
- if (hasVisibleOverlay) {
3954
- window = this.#compositeOverlaysIntoWindow(window, width, height);
3955
- const overlayMarkers = this.#extractCursorMarkers(window);
3956
- if (overlayMarkers.length > 0) {
3957
- cursorPos = { row: windowTop + overlayMarkers[0]!.row, col: overlayMarkers[0]!.col };
3958
- }
3959
- window = this.#prepareLinesArray(window, width);
3960
- }
3961
- const cursorTrackingLineCount = hasVisibleOverlay ? Math.max(frame.length, windowTop + height) : frame.length;
3962
-
3963
- // `resetDisplay()` requests an unbounded replay of the current
3964
- // transcript. Consume that one-shot intent on this authoritative
3965
- // normal-screen render even when a multiplexer makes it an in-place
3966
- // update; otherwise a later /resume or handoff full paint inherits it.
3967
- const unboundedConptyPaint = this.#unboundedConptyPaintRequested;
3968
- this.#unboundedConptyPaintRequested = false;
3969
- const intent: RenderIntent = fullPaint
3970
- ? {
3971
- kind: "fullPaint",
3972
- clearScrollback: divergenceRebuild || ((replaceRequested || geometryRebuild) && !isMultiplexerSession()),
3973
- }
3974
- : { kind: "update", chunkTo, windowTop };
3975
- this.#logRedraw(intent, frameLength, height);
3976
-
3977
- // Load newly-displayed image data once, before this frame's placements
3978
- // reference it. For full paints, the emitter may need to place the
3979
- // transmit after a destructive clear (ED2/ED3) but before row replay, so
3980
- // build the buffer here and let the emitter decide where it lands.
3981
- let imageTransmitBuffer = "";
3982
- for (const seq of this.#imageBudget.takeTransmits()) imageTransmitBuffer += seq;
3983
- // Purge graphics for images the budget demoted to text. Kitty keeps
3984
- // images in a store that text clears don't touch; demoted rows still
3985
- // visible re-render as text and the window diff repaints them.
3986
- // Committed placements are immutable — their pixels are deleted but
3987
- // their rows are not rewritten.
3988
- let purgeSequence = "";
3989
- if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
3990
- for (const id of this.#imageBudget.takePurgeIds()) purgeSequence += encodeKittyDeleteImage(id);
3991
- } else {
3992
- this.#imageBudget.takePurgeIds();
3993
- }
3994
- // Feed this frame's commit target to the placement-epoch tracker before
3995
- // any placement resolves against it — an epoch whose rows commit during
3996
- // frames that never rewrite its line must still advance on the next
3997
- // re-emission. Width epochs retain an opaque native-row ledger, so close
3998
- // the old placement-coordinate epoch with its captured seam on reset and
3999
- // use the current-width commit seam calculated below thereafter.
4000
- if (widthEpochReset) {
4001
- this.#imageBudget.observeCommitWatermark(placementEpochWatermark);
4002
- this.#imageBudget.beginPlacementCoordinateEpoch();
4003
- } else if (intent.kind === "fullPaint" || this.#widthEpochBaselineRows === undefined) {
4004
- this.#imageBudget.observeCommitWatermark(chunkTo);
4005
- }
4006
-
4007
- // 6. Emit.
4008
- if (intent.kind === "fullPaint") {
4009
- this.#emitFullPaint(frame, window, width, height, cursorPos, purgeSequence, imageTransmitBuffer, {
4010
- clearScrollback: intent.clearScrollback,
4011
- chunkTo,
4012
- windowTop,
4013
- cursorTrackingLineCount,
4014
- boundConptyPaint: !unboundedConptyPaint,
4015
- leadingSequence: deferredAltExit,
4016
- copyScreenToScrollback: true,
4017
- });
4018
- this.#pendingAltExit = "";
4019
- this.#committedPrefix = rawFrame.slice(0, chunkTo);
4020
- this.#committedPrefixAuditRows = Math.min(chunkTo, finalBoundary);
4021
- this.#clearScrollbackOnNextRender = false;
4022
- this.#hasEverRendered = true;
4023
- this.#widthEpochBaselineRows = undefined;
4024
- this.#widthEpochReplayUnresolved = false;
4025
- this.#widthEpochOverlayReplayPending = false;
4026
- this.#widthEpochOverlayBoundary = undefined;
4027
- this.#widthEpochCommittedPrefix = undefined;
4028
- this.#publishCommittedRows();
4029
- if (!firstPaint && frameLength > height) this.#armPostFullPaintSettle();
4030
- return;
4031
- }
4032
- if (this.#widthEpochBaselineRows !== undefined) {
4033
- const logicalAppend =
4034
- !replayUnresolvedOverlayFrame &&
4035
- widthEpochSourceBoundary !== undefined &&
4036
- widthEpochCurrentRows !== undefined;
4037
- const logicalPrefixAppend = logicalAppend && widthEpochAppendOnly;
4038
- let scrollRows: number;
4039
- let commitFrom: number;
4040
- let commitTo: number;
4041
- if (replayUnresolvedWidthEpoch) {
4042
- commitFrom = 0;
4043
- commitTo = Math.min(windowTop, commitCeiling);
4044
- scrollRows = commitTo;
4045
- } else if (logicalAppend && !logicalPrefixAppend) {
4046
- const sourceWindowTop = Math.max(0, widthEpochSourceBoundary - height);
4047
- const logicalSuffixRows = Math.max(0, widthEpochCurrentRows - widthEpochSourceBoundary);
4048
- const appendWindowMovement = Math.max(0, windowTop - sourceWindowTop);
4049
- scrollRows = Math.min(logicalSuffixRows, appendWindowMovement);
4050
- commitFrom = Math.max(0, windowTop - scrollRows);
4051
- commitTo = commitFrom + scrollRows;
4052
- } else if (!logicalAppend) {
4053
- const windowMovement = Math.max(0, windowTop - prevWindowTop);
4054
- const previousViewportRows = Math.min(
4055
- this.#previousHeight,
4056
- Math.max(0, this.#previousFrameLength - prevWindowTop),
4057
- );
4058
- const hostHeightShrinkRows = Math.min(windowMovement, Math.max(0, previousViewportRows - height));
4059
- const appendWindowMovement = windowMovement - hostHeightShrinkRows;
4060
- const epochGrowthRows = Math.max(0, widthEpochAppendTo - widthEpochAppendFrom);
4061
- scrollRows = Math.min(appendWindowMovement, epochGrowthRows);
4062
- commitFrom = prevWindowTop + hostHeightShrinkRows;
4063
- commitTo = commitFrom + scrollRows;
4064
- } else {
4065
- commitFrom = widthEpochSourceBoundary;
4066
- const logicalSuffixRows = Math.max(0, widthEpochCurrentRows - commitFrom);
4067
- const sourceWindowTop = Math.max(0, commitFrom - height);
4068
- const appendWindowMovement = Math.max(0, windowTop - sourceWindowTop);
4069
- scrollRows = Math.min(logicalSuffixRows, appendWindowMovement);
4070
- commitTo = commitFrom + scrollRows;
4071
- }
4072
- if (hasVisibleOverlay) {
4073
- scrollRows = 0;
4074
- commitTo = commitFrom;
4075
- }
4076
- this.#imageBudget.observeCommitWatermark(commitTo);
4077
- this.#emitWidthEpochBaseline(frame, window, width, height, cursorPos, purgeSequence, imageTransmitBuffer, {
4078
- repaintFromScreenRow: 0,
4079
- commitFrom,
4080
- commitTo,
4081
- appendOnly: logicalAppend,
4082
- prepaintWindowTop: logicalAppend && !logicalPrefixAppend && !hasVisibleOverlay ? commitFrom : undefined,
4083
- windowTop,
4084
- cursorTrackingLineCount,
4085
- leadingSequence: deferredAltExit,
4086
- });
4087
- this.#pendingAltExit = "";
4088
- if (!hasVisibleOverlay) {
4089
- this.#widthEpochOverlayReplayPending = false;
4090
- this.#widthEpochOverlayBoundary = undefined;
4091
- if (liveRegionPinned) {
4092
- this.#widthEpochBaselineRows = this.#widthEpochReplayUnresolved ? commitTo : widthEpochAppendTo;
4093
- this.#windowTopRow = logicalAppend ? windowTop : prevWindowTop + scrollRows;
4094
- } else {
4095
- this.#widthEpochBaselineRows = frameLength;
4096
- this.#widthEpochReplayUnresolved = false;
4097
- this.#windowTopRow = windowTop;
4098
- }
4099
- this.#committedRows += scrollRows;
4100
- if (!widthEpochReset && this.#widthEpochCommittedPrefix) {
4101
- const epochPrefix = this.#widthEpochCommittedPrefix;
4102
- // A height grow can expose tracked rows and let them scroll off
4103
- // again. Retire that superseded logical suffix into the opaque
4104
- // native base before recording its fresh same-width snapshot.
4105
- const overlap = epochPrefix.frameRows.findIndex(row => row >= commitFrom);
4106
- if (overlap >= 0) {
4107
- epochPrefix.nativeBaseRows += epochPrefix.frameRows.length - overlap;
4108
- epochPrefix.frameRows.length = overlap;
4109
- epochPrefix.prefix.length = overlap;
4110
- epochPrefix.auditRows = Math.min(epochPrefix.auditRows, overlap);
4111
- }
4112
- for (let row = commitFrom; row < commitTo; row++) {
4113
- epochPrefix.frameRows.push(row);
4114
- epochPrefix.prefix.push(rawFrame[row]!);
4115
- }
4116
- while (
4117
- epochPrefix.auditRows < epochPrefix.frameRows.length &&
4118
- epochPrefix.frameRows[epochPrefix.auditRows]! < finalBoundary
4119
- ) {
4120
- epochPrefix.auditRows++;
4121
- }
4122
- }
4123
- } else if (widthEpochReset) {
4124
- // The overlay freezes commits and subsequent hidden-growth movement,
4125
- // but the resize itself changed physical-row coordinates. Rebase the
4126
- // window reference once so growth backfills from the settled width.
4127
- this.#windowTopRow = replayUnresolvedOverlayFrame
4128
- ? 0
4129
- : logicalAppend
4130
- ? Math.max(0, widthEpochSourceBoundary! - height)
4131
- : windowTop;
4132
- }
4133
- if (widthEpochReset) {
4134
- let trackedFrom = commitFrom;
4135
- let trackedTo = commitTo;
4136
- if (logicalPrefixAppend) trackedTo = Math.max(trackedFrom, trackedTo - height);
4137
- if (trackedTo > this.#windowTopRow) {
4138
- trackedFrom = trackedTo;
4139
- }
4140
- const frameRows = Array.from({ length: trackedTo - trackedFrom }, (_value, index) => trackedFrom + index);
4141
- let auditRows = 0;
4142
- while (auditRows < frameRows.length && frameRows[auditRows]! < finalBoundary) auditRows++;
4143
- this.#widthEpochCommittedPrefix = {
4144
- nativeBaseRows: this.#committedRows - frameRows.length,
4145
- frameRows,
4146
- prefix: frameRows.map(row => rawFrame[row]!),
4147
- auditRows,
4148
- };
4149
- }
4150
- this.#clearScrollbackOnNextRender = false;
4151
- this.#hasEverRendered = true;
4152
- this.#publishCommittedRows(this.#windowTopRow);
4153
- return;
4154
- }
4155
- if (imageTransmitBuffer.length > 0) {
4156
- this.terminal.write(imageTransmitBuffer);
4157
- }
4158
- this.#emitUpdate(frame, window, width, height, cursorPos, purgeSequence, {
4159
- chunkTo,
4160
- windowTop,
4161
- prevWindowTop,
4162
- prevHardwareCursorRow,
4163
- forceWindowRewrite:
4164
- this.#forceViewportRepaintOnNextRender || (geometryChanged && this.#resizeRepaintsInPlace()),
4165
- repaintVirtualScrollInPlace: hasVisibleOverlay,
4166
- cursorTrackingLineCount,
4167
- });
4168
- for (let i = this.#committedPrefix.length; i < chunkTo; i++) {
4169
- this.#committedPrefix.push(rawFrame[i] ?? "");
4170
- }
4171
- // Audit-mark advance. A re-slice re-bases it outright. Otherwise it may
4172
- // advance to the exactness boundary only when this frame verified the
4173
- // newly-final span (auditRan hard-scans it) or no such span existed —
4174
- // rows committed this frame below the boundary are fresh exact bytes.
4175
- if (committedPrefixResliced || auditRan || preAuditRows >= Math.min(preCommitRows, finalBoundary)) {
4176
- this.#committedPrefixAuditRows = Math.min(this.#committedRows, finalBoundary);
4177
- } else {
4178
- this.#committedPrefixAuditRows = Math.min(preAuditRows, this.#committedRows);
4179
- }
4180
- this.#publishCommittedRows();
4181
- }
4182
-
4183
- /**
4184
- * Detect committed-prefix violations (see {@link findCommittedPrefixResync}
4185
- * for the zone semantics) and re-anchor the commit index at the first moved
4186
- * row, so subsequent rows recommit instead of being skipped: the stale copy
4187
- * stays in history — duplication, never loss. Pure in-place restyles keep
4188
- * their alignment and are left alone (stale styling in history was always
4189
- * the accepted artifact).
4190
- */
4191
- #auditCommittedPrefix(rawFrame: readonly string[], newlyFinalEnd: number): void {
4192
- const prefix = this.#committedPrefix;
4193
- if (prefix.length === 0) return;
4194
- const resyncTo = findCommittedPrefixResync(rawFrame, prefix, this.#committedPrefixAuditRows, newlyFinalEnd);
4195
- if (resyncTo < 0) return;
4196
- this.#committedRows = resyncTo;
4197
- this.#committedPrefixAuditRows = Math.min(this.#committedPrefixAuditRows, resyncTo);
4198
- prefix.length = resyncTo;
4199
- if ($flag("PI_DEBUG_REDRAW")) {
4200
- const msg = `[${new Date().toISOString()}] commit resync: committed prefix diverged at row ${resyncTo}; recommitting\n`;
4201
- fs.appendFileSync(getDebugLogPath(), msg);
4202
- }
4203
- }
4204
-
4205
- /**
4206
- * Push the post-emit committed-row count to root children that implement
4207
- * {@link NativeScrollbackCommittedRows}. Compose feeds the same signal
4208
- * before each child render (see {@link render}), but guards that run
4209
- * BETWEEN frames — e.g. a controller consulting the transcript's
4210
- * committed boundary to decide whether a displaceable block may still be
4211
- * retracted — would otherwise observe a count one frame stale and retract
4212
- * rows that just entered immutable native scrollback, stranding an
4213
- * orphaned copy above the repainted block.
4214
- */
4215
- #publishCommittedRows(committedRows = this.#committedRows): void {
4216
- for (const segment of this.#frameSegments) {
4217
- setNativeScrollbackCommittedRows(
4218
- segment.component,
4219
- Math.min(segment.rowCount, Math.max(0, committedRows - segment.start)),
4220
- );
4221
- }
2215
+ this.#renderChildrenFrame(width, height);
4222
2216
  }
4223
2217
 
4224
2218
  /**
4225
- * Prepare the composed frame for emission, in place. Rows below
4226
- * `#preparedValidRows` are already prepared against the current frame (the
4227
- * compose lowered that floor to the stable prefix); rows at/after it are
4228
- * revalidated positionally — a row whose raw content and width match its
4229
- * cached entry reuses the prepared line, anything else re-prepares.
2219
+ * Fallback frame for hosts without a frame provider (tests, simple embeds):
2220
+ * compose the root children and paint the bottom `height` rows as the
2221
+ * mutable viewport. Nothing is ever appended to terminal history.
4230
2222
  */
4231
- #prepareFrame(frame: readonly string[], width: number): string[] {
4232
- const prepared = this.#preparedFrame;
4233
- const meta = this.#preparedMeta;
4234
- if (prepared.length > frame.length) {
4235
- prepared.length = frame.length;
4236
- meta.length = frame.length;
4237
- }
4238
- for (let i = Math.min(this.#preparedValidRows, prepared.length); i < frame.length; i++) {
4239
- const raw = frame[i]!;
4240
- const cached = meta[i];
4241
- if (cached !== undefined && cached.raw === raw && cached.width === width) {
4242
- prepared[i] = cached.line;
4243
- continue;
4244
- }
4245
- const entry = this.#prepareLine(raw, width);
4246
- meta[i] = entry;
4247
- prepared[i] = entry.line;
4248
- }
4249
- this.#preparedValidRows = frame.length;
4250
- return prepared;
2223
+ #renderChildrenFrame(width: number, height: number): void {
2224
+ this.#imageBudget.beginPass();
2225
+ const composed = this.render(width);
2226
+ this.#imageBudget.endPass();
2227
+ if (this.#maybeDeferGhosttyInitialImagePaint()) return;
2228
+ const viewport = composed.length > height ? composed.slice(composed.length - height) : Array.from(composed);
2229
+ this.#emitPlanFrame(width, height, viewport, undefined, undefined);
4251
2230
  }
4252
2231
 
4253
2232
  /** Stateless variant for overlay-composited windows and alt-screen frames. */
@@ -4487,25 +2466,6 @@ export class TUI extends Container {
4487
2466
  return SEGMENT_RESET + ERASE_TO_END_OF_LINE + terminalLine;
4488
2467
  }
4489
2468
 
4490
- /**
4491
- * Single state-transition point. Every emitter calls this exactly once at
4492
- * the end so cursor/window accounting stays consistent.
4493
- */
4494
- #commit(
4495
- lines: readonly string[],
4496
- window: string[],
4497
- width: number,
4498
- height: number,
4499
- hardwareCursor: HardwareCursorUpdate,
4500
- ): void {
4501
- this.#previousFrameLength = lines.length;
4502
- this.#previousWindow = window;
4503
- this.#forceViewportRepaintOnNextRender = false;
4504
- this.#previousWidth = width;
4505
- this.#previousHeight = height;
4506
- this.#recordHardwareCursorUpdate(hardwareCursor);
4507
- }
4508
-
4509
2469
  #targetHardwareCursorState(
4510
2470
  cursorPos: { row: number; col: number } | null,
4511
2471
  totalLines: number,
@@ -4521,469 +2481,15 @@ export class TUI extends Container {
4521
2481
  #recordHardwareCursorState(state: HardwareCursorState): void {
4522
2482
  this.#hardwareCursorRow = state.row;
4523
2483
  this.#hardwareCursorState = state;
4524
- this.#hardwareCursorVisible = state.visible;
4525
- this.#hardwareCursorVisibilityKnown = true;
4526
- }
4527
-
4528
- #recordHardwareCursorRowOnly(row: number, visible?: boolean): void {
4529
- this.#hardwareCursorRow = row;
4530
- this.#hardwareCursorState = null;
4531
- if (visible !== undefined) {
4532
- this.#hardwareCursorVisible = visible;
4533
- this.#hardwareCursorVisibilityKnown = true;
4534
- }
4535
- }
4536
-
4537
- #recordHardwareCursorUpdate(update: HardwareCursorUpdate): void {
4538
- if (update.state) {
4539
- this.#recordHardwareCursorState(update.state);
4540
- return;
4541
- }
4542
- this.#recordHardwareCursorRowOnly(update.toRow, update.visible);
4543
2484
  }
4544
2485
 
4545
2486
  #recordHardwareCursorHidden(): void {
4546
- this.#hardwareCursorVisible = false;
4547
- this.#hardwareCursorVisibilityKnown = true;
4548
2487
  if (!this.#hardwareCursorState) return;
4549
2488
  this.#hardwareCursorState = { ...this.#hardwareCursorState, visible: false };
4550
2489
  }
4551
2490
 
4552
2491
  #forgetHardwareCursorState(): void {
4553
2492
  this.#hardwareCursorState = null;
4554
- this.#hardwareCursorVisibilityKnown = false;
4555
- }
4556
-
4557
- #sameHardwareCursorState(state: HardwareCursorState): boolean {
4558
- const current = this.#hardwareCursorState;
4559
- return (
4560
- current !== null && current.row === state.row && current.col === state.col && current.visible === state.visible
4561
- );
4562
- }
4563
-
4564
- #emitWidthEpochBaseline(
4565
- frame: readonly string[],
4566
- window: string[],
4567
- width: number,
4568
- height: number,
4569
- cursorPos: { row: number; col: number } | null,
4570
- purgeSequence: string,
4571
- imageTransmitBuffer: string,
4572
- options: {
4573
- repaintFromScreenRow: number;
4574
- commitFrom: number;
4575
- commitTo: number;
4576
- appendOnly: boolean;
4577
- prepaintWindowTop?: number;
4578
- windowTop: number;
4579
- cursorTrackingLineCount: number;
4580
- leadingSequence: string;
4581
- },
4582
- ): void {
4583
- this.#fullRedrawCount += 1;
4584
- let buffer = this.#paintBeginSequence + purgeSequence + options.leadingSequence + imageTransmitBuffer;
4585
- if (options.commitTo > options.commitFrom) {
4586
- if (options.appendOnly) {
4587
- if (options.prepaintWindowTop !== undefined) {
4588
- for (let screenRow = 0; screenRow < height; screenRow++) {
4589
- const frameRow = options.prepaintWindowTop + screenRow;
4590
- buffer += `\x1b[${screenRow + 1};1H`;
4591
- buffer += this.#lineRewriteSequence(
4592
- frame[frameRow] ?? "",
4593
- width,
4594
- screenRow,
4595
- frameRow,
4596
- options.commitTo,
4597
- );
4598
- }
4599
- }
4600
- buffer += `\x1b[${height};1H`;
4601
- for (let row = options.commitFrom; row < options.commitTo; row++) {
4602
- const enteringRow = options.prepaintWindowTop === undefined ? row : row + height;
4603
- buffer += "\r\n";
4604
- buffer += this.#lineRewriteSequence(
4605
- frame[enteringRow] ?? "",
4606
- width,
4607
- height - 1,
4608
- enteringRow,
4609
- options.commitTo,
4610
- );
4611
- }
4612
- for (let screenRow = 0; screenRow < height; screenRow++) {
4613
- buffer += `\x1b[${screenRow + 1};1H`;
4614
- buffer += this.#lineRewriteSequence(
4615
- window[screenRow] ?? "",
4616
- width,
4617
- screenRow,
4618
- options.windowTop + screenRow,
4619
- options.commitTo,
4620
- );
4621
- }
4622
- } else {
4623
- buffer += "\x1b[1;1H";
4624
- let wroteLine = false;
4625
- for (let row = options.commitFrom; row < options.commitTo; row++) {
4626
- if (wroteLine) buffer += "\r\n";
4627
- buffer += this.#lineRewriteSequence(
4628
- frame[row] ?? "",
4629
- width,
4630
- Math.min(row - options.commitFrom, height - 1),
4631
- row,
4632
- options.commitTo,
4633
- );
4634
- wroteLine = true;
4635
- }
4636
- for (let screenRow = 0; screenRow < height; screenRow++) {
4637
- if (wroteLine) buffer += "\r\n";
4638
- buffer += this.#lineRewriteSequence(
4639
- window[screenRow] ?? "",
4640
- width,
4641
- Math.min(options.commitTo - options.commitFrom + screenRow, height - 1),
4642
- options.windowTop + screenRow,
4643
- options.commitTo,
4644
- );
4645
- wroteLine = true;
4646
- }
4647
- }
4648
- } else {
4649
- for (let screenRow = options.repaintFromScreenRow; screenRow < height; screenRow++) {
4650
- buffer += `\x1b[${screenRow + 1};1H`;
4651
- buffer += this.#lineRewriteSequence(
4652
- window[screenRow] ?? "",
4653
- width,
4654
- screenRow,
4655
- options.windowTop + screenRow,
4656
- options.commitTo,
4657
- );
4658
- }
4659
- }
4660
- buffer += "\r";
4661
- const contentRows = Math.max(1, Math.min(height, frame.length - options.windowTop));
4662
- const contentBottomRow = options.windowTop + contentRows - 1;
4663
- const target = this.#targetHardwareCursorState(cursorPos, options.cursorTrackingLineCount);
4664
- if (target) {
4665
- const screenRow = Math.max(0, Math.min(height - 1, target.row - options.windowTop));
4666
- buffer += `\x1b[${screenRow + 1};${target.col + 1}H`;
4667
- buffer += target.visible ? "\x1b[?25h" : "\x1b[?25l";
4668
- } else {
4669
- buffer += `\x1b[${contentRows};1H\x1b[?25l`;
4670
- }
4671
- buffer += this.#paintEndSequence;
4672
- this.terminal.write(buffer);
4673
-
4674
- this.#commit(frame, window, width, height, {
4675
- toRow: target?.row ?? contentBottomRow,
4676
- state: target,
4677
- visible: target?.visible ?? false,
4678
- });
4679
- }
4680
- /**
4681
- * Replay the frame from home, optionally clearing native scrollback first:
4682
- * committed prefix `[0, chunkTo)` followed by the visible window. ED3
4683
- * (`CSI 3 J`) is emitted here and only here, and only for gesture-driven
4684
- * paints (session replace, resize, resetDisplay, or an explicit
4685
- * `clearScrollback` initial paint).
4686
- */
4687
- #emitFullPaint(
4688
- frame: readonly string[],
4689
- window: string[],
4690
- width: number,
4691
- height: number,
4692
- cursorPos: { row: number; col: number } | null,
4693
- purgeSequence: string,
4694
- imageTransmitBuffer: string,
4695
- options: {
4696
- clearScrollback: boolean;
4697
- chunkTo: number;
4698
- windowTop: number;
4699
- cursorTrackingLineCount: number;
4700
- /**
4701
- * Whether this paint may be bounded by {@link #truncateLargeConptyFrame}
4702
- * on ConPTY hosts. True for bulk transcript-replacement paints — first
4703
- * paint, /resume, handoff, and resize geometry rebuilds — where a
4704
- * multi-megabyte synchronized frame stalls conhost (issue #2115). False
4705
- * for a user-driven `resetDisplay()` (Ctrl+O expand, thinking/setting
4706
- * toggles, display reset), which must replay the whole transcript so
4707
- * nothing is silently dropped from scrollback (issue #4863).
4708
- */
4709
- boundConptyPaint: boolean;
4710
- leadingSequence: string;
4711
- copyScreenToScrollback: boolean;
4712
- },
4713
- ): void {
4714
- this.#fullRedrawCount += 1;
4715
- const { chunkTo, windowTop, cursorTrackingLineCount } = options;
4716
- // Map the frame-space cursor into paint space: committed-prefix rows
4717
- // keep their index, visible-window rows land after the prefix, and a
4718
- // cursor in neither region (hidden behind the overlay gap) hides.
4719
- let paintCursorPos: { row: number; col: number } | null = null;
4720
- if (cursorPos !== null) {
4721
- if (cursorPos.row < chunkTo) {
4722
- paintCursorPos = cursorPos;
4723
- } else if (cursorPos.row >= windowTop && cursorPos.row < windowTop + height) {
4724
- paintCursorPos = { row: chunkTo + cursorPos.row - windowTop, col: cursorPos.col };
4725
- }
4726
- }
4727
- // ConPTY hosts bound bulk transcript-replacement replays (resume, handoff,
4728
- // first paint, resize): merge prefix + window into one array so
4729
- // #truncateLargeConptyFrame can measure the payload and retain only the
4730
- // tail (#2115). Gated on `boundConptyPaint` — a user-driven `resetDisplay()`
4731
- // (Ctrl+O expand, toggles) sets it false and replays the whole transcript
4732
- // untruncated so nothing is dropped from scrollback (#4863). Gated on the
4733
- // host check too — everywhere else the merge would copy a pointer per
4734
- // committed row (a 50k-row session = 50k-entry array per resize step /
4735
- // theme change / session replace) just to be returned unchanged.
4736
- // `paintLines` stays null unless truncation actually rewrote the replay.
4737
- let paintLines: string[] | null = null;
4738
- let paintLineCount = chunkTo + height;
4739
- if (options.boundConptyPaint && isConPTYHosted()) {
4740
- const merged = new Array<string>(chunkTo + height);
4741
- for (let i = 0; i < chunkTo; i++) merged[i] = frame[i] ?? "";
4742
- for (let screenRow = 0; screenRow < height; screenRow++) {
4743
- merged[chunkTo + screenRow] = window[screenRow] ?? "";
4744
- }
4745
- const paint = this.#truncateLargeConptyFrame(merged, width, height, paintCursorPos);
4746
- if (paint.lines !== merged) {
4747
- paintLines = paint.lines;
4748
- paintLineCount = paint.lines.length;
4749
- paintCursorPos = paint.cursorPos;
4750
- }
4751
- }
4752
- let buffer = this.#paintBeginSequence + this.#leaveResizeAltSequence() + options.leadingSequence + purgeSequence;
4753
- if (options.clearScrollback) {
4754
- // Clear native history without blanking the live viewport first. The
4755
- // replay below rewrites every visible row from home, including blanks,
4756
- // so terminals without DEC 2026 never expose an ED2-cleared frame.
4757
- // The clear also destroys every placement cell, so placement epochs
4758
- // restart and every registry entry each image ever placed is deleted
4759
- // explicitly (`d=i` keeps the transmitted data, so the replay needs
4760
- // no retransmit). Deleting epoch 1 too matters for images absent from
4761
- // the replay — nothing would ever replace their stale entry.
4762
- buffer += "\x1b[H\x1b[3J";
4763
- for (const { imageId, lastEpoch } of this.#imageBudget.resetPlacementEpochs()) {
4764
- for (let placementId = 1; placementId <= lastEpoch; placementId++) {
4765
- buffer += encodeKittyDeletePlacement(imageId, placementId);
4766
- }
4767
- }
4768
- } else {
4769
- // ED2 clears only the viewport. Initial/non-destructive replays may
4770
- // first ask supporting terminals to preserve the prior screen, but a
4771
- // width-epoch repaint MUST NOT copy that invalidated viewport into
4772
- // native history.
4773
- if (options.copyScreenToScrollback && TERMINAL.supportsScreenToScrollback) buffer += "\x1b[22J";
4774
- buffer += "\x1b[2J\x1b[H";
4775
- }
4776
- if (imageTransmitBuffer.length > 0) buffer += imageTransmitBuffer;
4777
- // DECCARA fills optimize only the rows that stay visible; history-bound
4778
- // rows are written as full styled strings (their background must
4779
- // survive in scrollback, which DECCARA cannot reach).
4780
- const visibleStart = Math.max(0, paintLineCount - height);
4781
- let fillSequence = "";
4782
- let visibleTexts: string[] | null = null;
4783
- if (this.#deccaraFillsEnabled() && visibleStart < paintLineCount) {
4784
- // Untruncated, the visible slice is exactly the caller's window
4785
- // (visibleStart === chunkTo) — reuse it rather than copying;
4786
- // planDeccaraFills fills its own `texts` and never mutates input.
4787
- let visible = window;
4788
- if (paintLines !== null) {
4789
- visible = new Array<string>(paintLineCount - visibleStart);
4790
- for (let k = 0; k < visible.length; k++) visible[k] = paintLines[visibleStart + k] ?? "";
4791
- }
4792
- const plan = planDeccaraFills(visible, width);
4793
- visibleTexts = plan.texts;
4794
- fillSequence = plan.sequence;
4795
- }
4796
- if (paintLines === null) {
4797
- // Common path: emit straight from the source arrays (the
4798
- // pre-merge two-loop form); byte-identical to replaying the
4799
- // merged array. Destructive history clears deliberately avoid ED2, so
4800
- // each row must self-clear stale cells left by the previous viewport.
4801
- for (let i = 0; i < chunkTo; i++) {
4802
- if (i > 0) buffer += "\r\n";
4803
- const writeRow = Math.min(i, height - 1);
4804
- buffer += options.clearScrollback
4805
- ? this.#lineRewriteSequence(
4806
- frame[i] ?? "",
4807
- width,
4808
- writeRow,
4809
- i,
4810
- chunkTo,
4811
- this.#osc66SpacerGlyphWidth(frame, i),
4812
- )
4813
- : this.#terminalLine(frame[i] ?? "", writeRow, i, chunkTo);
4814
- }
4815
- for (let screenRow = 0; screenRow < height; screenRow++) {
4816
- if (chunkTo + screenRow > 0) buffer += "\r\n";
4817
- const line = visibleTexts ? (visibleTexts[screenRow] ?? "") : (window[screenRow] ?? "");
4818
- const writeRow = Math.min(chunkTo + screenRow, height - 1);
4819
- const frameRow = windowTop + screenRow;
4820
- buffer += options.clearScrollback
4821
- ? this.#lineRewriteSequence(
4822
- line,
4823
- width,
4824
- writeRow,
4825
- frameRow,
4826
- chunkTo,
4827
- this.#osc66SpacerGlyphWidth(frame, frameRow),
4828
- )
4829
- : this.#terminalLine(line, writeRow, frameRow, chunkTo);
4830
- }
4831
- } else {
4832
- // ConPTY-truncated replay: leading rows were dropped, so frame-space
4833
- // positions are unknown — placements still clip to the write row but
4834
- // skip epoch bookkeeping.
4835
- for (let i = 0; i < paintLines.length; i++) {
4836
- if (i > 0) buffer += "\r\n";
4837
- const line = visibleTexts && i >= visibleStart ? visibleTexts[i - visibleStart] : (paintLines[i] ?? "");
4838
- const writeRow = Math.min(i, height - 1);
4839
- buffer += options.clearScrollback
4840
- ? this.#lineRewriteSequence(
4841
- line,
4842
- width,
4843
- writeRow,
4844
- -1,
4845
- chunkTo,
4846
- this.#osc66SpacerGlyphWidth(paintLines, i),
4847
- )
4848
- : this.#terminalLine(line, writeRow, -1, chunkTo);
4849
- }
4850
- }
4851
- buffer += fillSequence;
4852
- // Park the hardware cursor at real content bottom, not the padded
4853
- // window bottom — a later height shrink would otherwise scroll live
4854
- // rows into scrollback and duplicate them per resize step.
4855
- const contentRows = Math.max(1, Math.min(height, frame.length - windowTop));
4856
- const parkUp = height - contentRows;
4857
- if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
4858
- const contentBottomRow = windowTop + contentRows - 1;
4859
- const paintContentBottomRow = Math.max(0, paintLineCount - 1 - parkUp);
4860
- const cursorControl = this.#cursorControlSequence(paintCursorPos, paintLineCount, paintContentBottomRow);
4861
- buffer += cursorControl.seq;
4862
- buffer += this.#paintEndSequence;
4863
- this.terminal.write(buffer);
4864
-
4865
- const committedCursorState = paintCursorPos
4866
- ? this.#targetHardwareCursorState(cursorPos, cursorTrackingLineCount)
4867
- : null;
4868
- const committedCursor = committedCursorState
4869
- ? {
4870
- toRow: committedCursorState.row,
4871
- state: committedCursorState,
4872
- visible: committedCursorState.visible,
4873
- }
4874
- : {
4875
- toRow: contentBottomRow,
4876
- state: null,
4877
- visible: cursorControl.visible,
4878
- };
4879
-
4880
- this.#committedRows = chunkTo;
4881
- this.#windowTopRow = windowTop;
4882
- this.#commit(frame, window, width, height, committedCursor);
4883
- }
4884
-
4885
- /**
4886
- * Enter (or extend) the non-multiplexer resize fast path. Marks the drag
4887
- * active so subsequent `#doRender` calls paint viewport-only, then (re)arms
4888
- * the quiet-window timer whose callback ends the drag with one authoritative
4889
- * full paint. Reset on every SIGWINCH, so the full replay fires only once the
4890
- * user stops dragging.
4891
- */
4892
- #beginResizeViewport(): void {
4893
- this.#resizeViewportActive = true;
4894
- this.#resizeViewportSettleTimer?.cancel();
4895
- this.#resizeViewportSettleTimer = this.#renderScheduler.scheduleRender(() => {
4896
- this.#resizeViewportSettleTimer = undefined;
4897
- this.#resizeViewportActive = false;
4898
- if (this.#stopped) return;
4899
- // The drag is quiet: replay the rewrapped transcript authoritatively.
4900
- // #resizeEventPending was preserved across every viewport-only frame
4901
- // (the fast path never consumes it), so this classifies as a geometry
4902
- // rebuild — ED3 + full history — and the clearScrollback intent below
4903
- // matches the gesture-driven reset path.
4904
- this.#resizeEventPending = true;
4905
- this.requestRender(true, { clearScrollback: !isMultiplexerSession() });
4906
- }, TUI.#RESIZE_VIEWPORT_SETTLE_MS);
4907
- }
4908
-
4909
- #requestResizeViewportPaint(): void {
4910
- if (this.#stopped) return;
4911
- this.#renderRequested = false;
4912
- this.#executeRender();
4913
- if (this.#renderRequested) this.#scheduleRender();
4914
- }
4915
-
4916
- /**
4917
- * Compose and paint only the viewport for one resize fast-path frame.
4918
- * State-isolated: advances no commit/window/diff field and calls neither
4919
- * `#commit` nor `#emitFullPaint`, so the settle full paint reconciles against
4920
- * the pre-drag screen state.
4921
- */
4922
- #renderResizeViewport(width: number, height: number): void {
4923
- if (width <= 0 || height <= 0) return;
4924
- // Tail renders call block.render(), which observes inline images on the
4925
- // budget. This is a STABLE (partial) pass: the tail walk is bottom-up and
4926
- // sees only the visible subset, so display-order-by-call-order is wrong
4927
- // here — `beginPass(true)` makes observe() replay the last committed
4928
- // live/text split per image id instead, so images keep their on-screen
4929
- // state through the drag. Reset the pass each frame so a long drag does
4930
- // not accumulate; never endPass() here — that mutates the demotion ledger
4931
- // off a partial walk. The settle paint's own beginPass()/endPass() is the
4932
- // authoritative accounting, and its beginPass() wipes these frames.
4933
- this.#imageBudget.beginPass(true);
4934
- const { framed, viewportTop, contentRows } = this.#composeResizeViewport(width, height);
4935
- this.#emitResizeViewport(framed, viewportTop, height, contentRows, width);
4936
- this.#resizeViewportPaintCount += 1;
4937
- }
4938
-
4939
- /**
4940
- * Build the viewport window for a resize fast-path frame: the bottom
4941
- * `height` rows of the would-be full frame, collected bottom-up across root
4942
- * children, plus up to {@link #OSC66_MAX_SPACER_ROWS} rows above the
4943
- * fold. {@link ViewportTailProvider}s (the transcript) yield only their tail;
4944
- * the small live-region children below render in full — so every child
4945
- * entirely above the fold is skipped. A frame shorter than the viewport is
4946
- * top-aligned with blank rows below, matching the full-paint window geometry
4947
- * (windowTop = max(0, frameLength - height)). Cursor markers are stripped
4948
- * (the drag hides the hardware cursor) and rows are width-fitted via the
4949
- * stateless preparer, so no persistent prepared-frame cache is touched.
4950
- *
4951
- * Returns the visible rows preceded by the context rows in frame order
4952
- * (`framed`), the index where the viewport begins (`viewportTop`), and the
4953
- * visible content count. The context rows are never emitted; they only let
4954
- * {@link #osc66SpacerGlyphWidth} see a scaled heading that scrolled just
4955
- * above the fold, so its reserved rows are preserved instead of erased
4956
- * (issue #8318).
4957
- */
4958
- #composeResizeViewport(
4959
- width: number,
4960
- height: number,
4961
- ): { framed: readonly string[]; viewportTop: number; contentRows: number } {
4962
- const maxRows = height + TUI.#OSC66_MAX_SPACER_ROWS;
4963
- const tail: string[] = []; // bottom-first: viewport rows plus context above
4964
- const children = this.children;
4965
- for (let i = children.length - 1; i >= 0 && tail.length < maxRows; i--) {
4966
- const child = children[i]!;
4967
- const provider = asViewportTailProvider(child);
4968
- const rows = provider ? provider.renderViewportTail(width, maxRows - tail.length) : child.render(width);
4969
- for (let r = rows.length - 1; r >= 0 && tail.length < maxRows; r--) {
4970
- tail.push(rows[r]!);
4971
- }
4972
- }
4973
- const contentRows = Math.min(tail.length, height);
4974
- const extra = tail.length - contentRows; // context rows above the fold
4975
- const window: string[] = new Array(height);
4976
- for (let screenRow = 0; screenRow < height; screenRow++) {
4977
- // `tail` holds the bottom rows first. The bottom `contentRows` fill the
4978
- // viewport (top-aligned with blanks below on underflow).
4979
- window[screenRow] = screenRow < contentRows ? tail[contentRows - 1 - screenRow]! : "";
4980
- }
4981
- this.#extractCursorMarkers(window);
4982
- // Frame order: context rows above the fold (top-first) then the window.
4983
- const framed: string[] = new Array(extra + height);
4984
- for (let k = 0; k < extra; k++) framed[k] = tail[tail.length - 1 - k]!;
4985
- for (let screenRow = 0; screenRow < height; screenRow++) framed[extra + screenRow] = window[screenRow]!;
4986
- return { framed: this.#prepareLinesArray(framed, width), viewportTop: extra, contentRows };
4987
2493
  }
4988
2494
 
4989
2495
  /**
@@ -5006,88 +2512,6 @@ export class TUI extends Container {
5006
2512
  return this.terminal.kittyEnableSequence ? "\x1b[<u" : "";
5007
2513
  }
5008
2514
 
5009
- #enterResizeAltSequence(): string {
5010
- if (this.#resizeAltActive || this.#altActive) return "";
5011
- this.#resizeAltActive = true;
5012
- setAltScreenActive(true);
5013
- this.#forgetHardwareCursorState();
5014
- this.#recordHardwareCursorHidden();
5015
- return `${ALT_SCREEN_ENTER}${this.#keyboardEnhancementEnter()}`;
5016
- }
5017
-
5018
- #leaveResizeAltSequence(): string {
5019
- if (!this.#resizeAltActive) return "";
5020
- const enhancementExit = this.#keyboardEnhancementExit();
5021
- this.#resizeAltActive = false;
5022
- setAltScreenActive(false);
5023
- this.#forgetHardwareCursorState();
5024
- return `${enhancementExit}${ALT_SCREEN_EXIT}`;
5025
- }
5026
-
5027
- /**
5028
- * Whether a resize repaints the visible window in place — no alternate-screen
5029
- * borrow, no ED3 scrollback rewrap. Combines the static host detection
5030
- * ({@link resizeRepaintsInPlace}) with the runtime {@link #altToggleResizesInPlace}
5031
- * latch, so a terminal that re-reports its size on alt-screen toggles is
5032
- * treated like Warp once observed, breaking the overlay-exit ED3 flash loop.
5033
- * An explicit `PI_TUI_RESIZE_IN_PLACE=0|false` suppresses the runtime latch;
5034
- * multiplexer handling remains authoritative through the static predicate.
5035
- */
5036
- #resizeRepaintsInPlace(): boolean {
5037
- const override = Bun.env.PI_TUI_RESIZE_IN_PLACE;
5038
- const allowAutoDetection = override !== "0" && override !== "false";
5039
- return resizeRepaintsInPlace() || (allowAutoDetection && this.#altToggleResizesInPlace);
5040
- }
5041
-
5042
- /**
5043
- * Emit a throwaway viewport repaint for the resize fast path as a per-row
5044
- * overwrite. A width change can make the terminal's normal buffer reflow
5045
- * full-width rows before the app repaints, so a width drag borrows the
5046
- * alternate screen: transient resizes truncate the viewport instead of
5047
- * pushing wrapped fragments into native scrollback. A height-only resize
5048
- * reflows nothing, so it repaints the normal screen in place — borrowing the
5049
- * alt buffer there is pure flicker, and on terminals that re-report their
5050
- * size when the alt buffer toggles it is self-sustaining: leaving a
5051
- * fullscreen overlay's alt screen fires a height-only SIGWINCH echo, which
5052
- * would otherwise re-borrow the alt buffer for one frame (the settings-exit
5053
- * flash, #5854). Normal-screen history is rebuilt once at settle via
5054
- * `#emitFullPaint`.
5055
- */
5056
- #emitResizeViewport(
5057
- framed: readonly string[],
5058
- viewportTop: number,
5059
- height: number,
5060
- contentRows: number,
5061
- width: number,
5062
- ): void {
5063
- const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
5064
- const altEnter = widthChanged ? this.#enterResizeAltSequence() : "";
5065
- let buffer = `${this.#paintBeginSequence + altEnter}\x1b[H`;
5066
- for (let r = 0; r < height; r++) {
5067
- if (r > 0) buffer += "\r\n";
5068
- // `framed` carries context rows above the fold; the visible window
5069
- // starts at `viewportTop`, and the spacer lookup scans within `framed`
5070
- // so a heading just above the fold is still seen (issue #8318).
5071
- const idx = viewportTop + r;
5072
- buffer += this.#lineRewriteSequence(
5073
- framed[idx] ?? "",
5074
- width,
5075
- r,
5076
- -1,
5077
- this.#committedRows,
5078
- this.#osc66SpacerGlyphWidth(framed, idx),
5079
- );
5080
- }
5081
- // Park the hardware cursor at the real content bottom, not the padded
5082
- // viewport bottom: a later height shrink would otherwise scroll the live
5083
- // rows below the cursor into native scrollback and duplicate them until
5084
- // the settle rebuild erases it.
5085
- const parkUp = height - Math.max(1, contentRows);
5086
- if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
5087
- buffer += this.#paintEndSequence;
5088
- this.terminal.write(buffer);
5089
- }
5090
-
5091
2515
  /**
5092
2516
  * Compose and paint a single fullscreen overlay frame on the alt buffer.
5093
2517
  * Cursor markers are stripped (the modal draws its own in-band caret and
@@ -5104,9 +2528,8 @@ export class TUI extends Container {
5104
2528
 
5105
2529
  /**
5106
2530
  * Full per-row viewport rewrite on the alt buffer. Emits only sync-output
5107
- * brackets, a cursor home, and per-row rewrites — never ED3, append-tail, or
5108
- * any native-scrollback byte, so it is fully isolated from the planner and
5109
- * #commit. The hardware cursor stays hidden (it is never re-shown here).
2531
+ * brackets, a cursor home, and per-row rewrites — never ED3 or any
2532
+ * native-scrollback byte. The hardware cursor stays hidden here.
5110
2533
  */
5111
2534
  #emitAltFrame(lines: string[], width: number, height: number): void {
5112
2535
  const fitted: string[] = new Array(height);
@@ -5149,309 +2572,4 @@ export class TUI extends Container {
5149
2572
  this.#altPreviousLines = fitted;
5150
2573
  this.#fullRedrawCount += 1;
5151
2574
  }
5152
-
5153
- /**
5154
- * Incremental frame update. Three byte shapes:
5155
- *
5156
- * - scroll-append: the rows leaving the screen are exactly the newly
5157
- * committed chunk, already painted with final content — emit `\r\n` plus
5158
- * the new bottom rows, then rewrite whatever else changed in place;
5159
- * - in-window diff: nothing scrolls, nothing commits — rewrite the changed
5160
- * row range (cursor-only when nothing changed);
5161
- * - seam rewrite: write the chunk at the scrollback seam, then rewrite the
5162
- * whole window (live-region re-layout, hidden-gap backfill, mux resize).
5163
- *
5164
- * Only chunk rows ever enter native history; the live window repaints in
5165
- * place with relative moves. This path never emits ED2/ED3 or an absolute
5166
- * cursor home — those snap a reader scrolled into history back to the
5167
- * bottom on several terminal families.
5168
- */
5169
- #emitUpdate(
5170
- frame: readonly string[],
5171
- window: string[],
5172
- width: number,
5173
- height: number,
5174
- cursorPos: { row: number; col: number } | null,
5175
- purgeSequence: string,
5176
- options: {
5177
- chunkTo: number;
5178
- windowTop: number;
5179
- prevWindowTop: number;
5180
- prevHardwareCursorRow: number;
5181
- forceWindowRewrite: boolean;
5182
- repaintVirtualScrollInPlace: boolean;
5183
- cursorTrackingLineCount: number;
5184
- },
5185
- ): void {
5186
- const {
5187
- chunkTo,
5188
- windowTop,
5189
- prevWindowTop,
5190
- prevHardwareCursorRow,
5191
- forceWindowRewrite,
5192
- repaintVirtualScrollInPlace,
5193
- cursorTrackingLineCount,
5194
- } = options;
5195
- const chunkFrom = this.#committedRows;
5196
- const chunkLength = chunkTo - chunkFrom;
5197
- const scroll = windowTop - prevWindowTop;
5198
- const previousWindow = this.#previousWindow;
5199
- const contentRows = Math.max(1, Math.min(height, frame.length - windowTop));
5200
- const contentBottomRow = windowTop + contentRows - 1;
5201
- // Terminals clamp the hardware cursor to the viewport on resize; clamp
5202
- // our tracking to match so relative moves land correctly.
5203
- const clampedCursor = Math.min(prevHardwareCursorRow, prevWindowTop + height - 1);
5204
- const currentScreenRow = Math.max(0, Math.min(height - 1, clampedCursor - prevWindowTop));
5205
-
5206
- // Scroll-append: committing exactly the rows that scroll off the top,
5207
- // with content untouched since they were painted.
5208
- if (
5209
- !forceWindowRewrite &&
5210
- chunkLength > 0 &&
5211
- chunkLength === scroll &&
5212
- scroll < height &&
5213
- chunkFrom === prevWindowTop
5214
- ) {
5215
- let prefixIntact = previousWindow.length === height;
5216
- for (let i = 0; prefixIntact && i < chunkLength; i++) {
5217
- if (previousWindow[i] !== frame[chunkFrom + i]) prefixIntact = false;
5218
- }
5219
- if (prefixIntact) {
5220
- let buffer = this.#paintBeginSequence + purgeSequence;
5221
- const moveToBottom = height - 1 - currentScreenRow;
5222
- if (moveToBottom > 0) buffer += `\x1b[${moveToBottom}B`;
5223
- for (let r = height - scroll; r < height; r++) {
5224
- buffer += `\r\n${this.#lineRewriteSequence(window[r] ?? "", width, height - 1, windowTop + r, chunkTo, this.#osc66SpacerGlyphWidth(frame, windowTop + r))}`;
5225
- }
5226
- // Rewrite any remaining changed rows after the shift.
5227
- let firstChanged = -1;
5228
- let lastChanged = -1;
5229
- for (let r = 0; r < height - scroll; r++) {
5230
- if ((window[r] ?? "") === (previousWindow[r + scroll] ?? "")) continue;
5231
- if (firstChanged === -1) firstChanged = r;
5232
- lastChanged = r;
5233
- }
5234
- let cursorFromRow = windowTop + height - 1;
5235
- if (firstChanged !== -1) {
5236
- const up = height - 1 - firstChanged;
5237
- if (up > 0) buffer += `\x1b[${up}A`;
5238
- buffer += "\r";
5239
- for (let r = firstChanged; r <= lastChanged; r++) {
5240
- if (r > firstChanged) buffer += "\r\n";
5241
- buffer += this.#lineRewriteSequence(
5242
- window[r] ?? "",
5243
- width,
5244
- r,
5245
- windowTop + r,
5246
- chunkTo,
5247
- this.#osc66SpacerGlyphWidth(frame, windowTop + r),
5248
- );
5249
- }
5250
- cursorFromRow = windowTop + lastChanged;
5251
- }
5252
- const cursorControl = this.#cursorControlSequence(cursorPos, cursorTrackingLineCount, cursorFromRow);
5253
- buffer += cursorControl.seq;
5254
- buffer += this.#paintEndSequence;
5255
- this.terminal.write(buffer);
5256
- this.#committedRows = chunkTo;
5257
- this.#windowTopRow = windowTop;
5258
- this.#commit(frame, window, width, height, cursorControl);
5259
- return;
5260
- }
5261
- }
5262
-
5263
- // In-window diff: nothing commits. Rewrite in place when the window slid
5264
- // without a commit — an overlay visible (composited rows must never enter
5265
- // history), a commit-frozen geometry frame, or the window pulling back
5266
- // down after a shrink. Overlay cursor-only frames can also leave the
5267
- // tracked row behind the physical cursor; a relative partial rewrite from
5268
- // that stale origin can CRLF on the bottom row and scroll native history
5269
- // without appending to the commit tape, so overlays always take the
5270
- // top-clamped full rewrite.
5271
- const inPlaceRewrite = repaintVirtualScrollInPlace || scroll !== 0;
5272
- if (chunkLength === 0) {
5273
- if (forceWindowRewrite || inPlaceRewrite) this.#fullRedrawCount += 1;
5274
- let firstChanged = forceWindowRewrite || inPlaceRewrite ? 0 : -1;
5275
- let lastChanged = forceWindowRewrite || inPlaceRewrite ? height - 1 : -1;
5276
- if (!forceWindowRewrite && !inPlaceRewrite) {
5277
- const comparable = previousWindow.length === height;
5278
- for (let r = 0; r < height; r++) {
5279
- if (comparable && (window[r] ?? "") === (previousWindow[r] ?? "")) continue;
5280
- if (firstChanged === -1) firstChanged = r;
5281
- lastChanged = r;
5282
- }
5283
- }
5284
- if (firstChanged === -1) {
5285
- if (purgeSequence.length > 0) this.terminal.write(purgeSequence);
5286
- this.#writeCursorPosition(cursorPos, cursorTrackingLineCount);
5287
- this.#previousWidth = width;
5288
- this.#previousHeight = height;
5289
- return;
5290
- }
5291
- let buffer = this.#paintBeginSequence + purgeSequence;
5292
- if (inPlaceRewrite) {
5293
- // The cursor tracker can be stale after overlay-only frames, and
5294
- // meaningless after an uncommitted slide. A large CUU clamps at the
5295
- // viewport top without using absolute cursor home, so the following
5296
- // full-window rewrite cannot overflow the bottom.
5297
- if (height > 1) buffer += `\x1b[${height - 1}A`;
5298
- } else {
5299
- const rowDelta = firstChanged - currentScreenRow;
5300
- if (rowDelta > 0) buffer += `\x1b[${rowDelta}B`;
5301
- else if (rowDelta < 0) buffer += `\x1b[${-rowDelta}A`;
5302
- }
5303
- buffer += "\r";
5304
- // DECCARA-optimize the contiguous rewritten range (visible rows
5305
- // only; rectangles are absolute screen rows).
5306
- let fillTexts: string[] | null = null;
5307
- let fillSequence = "";
5308
- if (this.#deccaraFillsEnabled()) {
5309
- const slice: string[] = new Array(lastChanged - firstChanged + 1);
5310
- for (let r = firstChanged; r <= lastChanged; r++) slice[r - firstChanged] = window[r] ?? "";
5311
- const plan = planDeccaraFills(slice, width, firstChanged);
5312
- fillTexts = plan.texts;
5313
- fillSequence = plan.sequence;
5314
- }
5315
- for (let r = firstChanged; r <= lastChanged; r++) {
5316
- if (r > firstChanged) buffer += "\r\n";
5317
- buffer += this.#lineRewriteSequence(
5318
- fillTexts ? fillTexts[r - firstChanged] : (window[r] ?? ""),
5319
- width,
5320
- r,
5321
- windowTop + r,
5322
- this.#committedRows,
5323
- this.#osc66SpacerGlyphWidth(frame, windowTop + r),
5324
- );
5325
- }
5326
- buffer += fillSequence;
5327
- // Never park below real content (a height shrink would scroll live
5328
- // rows into history and duplicate them per resize step).
5329
- let cursorFromRow = windowTop + lastChanged;
5330
- const contentBottomScreenRow = contentBottomRow - windowTop;
5331
- if (lastChanged > contentBottomScreenRow) {
5332
- buffer += `\x1b[${lastChanged - contentBottomScreenRow}A`;
5333
- cursorFromRow = contentBottomRow;
5334
- }
5335
- const cursorControl = this.#cursorControlSequence(cursorPos, cursorTrackingLineCount, cursorFromRow);
5336
- buffer += cursorControl.seq;
5337
- buffer += this.#paintEndSequence;
5338
- this.terminal.write(buffer);
5339
- this.#windowTopRow = windowTop;
5340
- this.#commit(frame, window, width, height, cursorControl);
5341
- return;
5342
- }
5343
-
5344
- // Seam rewrite: write the chunk into history, then the whole window.
5345
- // Cursor moves to the window top with a relative move; the chunk rows
5346
- // pass through the screen and scroll off as the window rows are written
5347
- // below them, so the rows entering scrollback are exactly the chunk.
5348
- this.#fullRedrawCount += 1;
5349
- let buffer = this.#paintBeginSequence + purgeSequence;
5350
- if (currentScreenRow > 0) buffer += `\x1b[${currentScreenRow}A`;
5351
- buffer += "\r";
5352
- let wroteLine = false;
5353
- for (let i = chunkFrom; i < chunkTo; i++) {
5354
- if (wroteLine) buffer += "\r\n";
5355
- buffer += this.#lineRewriteSequence(
5356
- frame[i] ?? "",
5357
- width,
5358
- Math.min(i - chunkFrom, height - 1),
5359
- i,
5360
- chunkTo,
5361
- this.#osc66SpacerGlyphWidth(frame, i),
5362
- );
5363
- wroteLine = true;
5364
- }
5365
- for (let screenRow = 0; screenRow < height; screenRow++) {
5366
- if (wroteLine) buffer += "\r\n";
5367
- buffer += this.#lineRewriteSequence(
5368
- window[screenRow] ?? "",
5369
- width,
5370
- Math.min(chunkTo - chunkFrom + screenRow, height - 1),
5371
- windowTop + screenRow,
5372
- chunkTo,
5373
- this.#osc66SpacerGlyphWidth(frame, windowTop + screenRow),
5374
- );
5375
- wroteLine = true;
5376
- }
5377
- const parkUp = height - 1 - (contentBottomRow - windowTop);
5378
- if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
5379
- const cursorControl = this.#cursorControlSequence(cursorPos, cursorTrackingLineCount, contentBottomRow);
5380
- buffer += cursorControl.seq;
5381
- buffer += this.#paintEndSequence;
5382
- this.terminal.write(buffer);
5383
- this.#committedRows = chunkTo;
5384
- this.#windowTopRow = windowTop;
5385
- this.#commit(frame, window, width, height, cursorControl);
5386
- }
5387
-
5388
- /** Optional intent log under PI_DEBUG_REDRAW. */
5389
- #logRedraw(intent: RenderIntent, newLength: number, height: number): void {
5390
- if (!$flag("PI_DEBUG_REDRAW")) return;
5391
- const detail =
5392
- intent.kind === "update"
5393
- ? `update(chunk=${this.#committedRows}..${intent.chunkTo}, windowTop=${intent.windowTop})`
5394
- : `fullPaint(clearScrollback=${intent.clearScrollback})`;
5395
- const state =
5396
- `committed=${this.#committedRows}, windowTop=${this.#windowTopRow}, ` +
5397
- `lrStart=${this.#nativeScrollbackLiveRegionStart}`;
5398
- const msg = `[${new Date().toISOString()}] render: ${detail} (prev=${this.#previousFrameLength}, new=${newLength}, height=${height}, ${state})\n`;
5399
- fs.appendFileSync(getDebugLogPath(), msg);
5400
- }
5401
-
5402
- /**
5403
- * Build cursor control sequences to position the hardware cursor for the IME
5404
- * candidate window. Returns escape sequences and the resulting cursor row for
5405
- * the caller to update `#hardwareCursorRow`. The sequences should be appended
5406
- * into the caller's own synchronized output block to avoid a flicker between
5407
- * content and cursor frames.
5408
- */
5409
- #cursorControlSequence(
5410
- cursorPos: { row: number; col: number } | null,
5411
- totalLines: number,
5412
- fromRow: number,
5413
- ): CursorControlResult {
5414
- // No IME target or no content — hide cursor regardless of preference.
5415
- const target = this.#targetHardwareCursorState(cursorPos, totalLines);
5416
- if (!target) {
5417
- return { seq: "\x1b[?25l", toRow: fromRow, toCol: 0, visible: false, state: null };
5418
- }
5419
-
5420
- // Move cursor from current position to target.
5421
- const rowDelta = target.row - fromRow;
5422
- let seq = "";
5423
- if (rowDelta > 0) {
5424
- seq += `\x1b[${rowDelta}B`; // Move down
5425
- } else if (rowDelta < 0) {
5426
- seq += `\x1b[${-rowDelta}A`; // Move up
5427
- }
5428
- // Move to absolute column (1-indexed)
5429
- seq += `\x1b[${target.col + 1}G`;
5430
- seq += target.visible ? "\x1b[?25h" : "\x1b[?25l";
5431
-
5432
- return { seq, toRow: target.row, toCol: target.col, visible: target.visible, state: target };
5433
- }
5434
-
5435
- #isHiddenCursorKnown(): boolean {
5436
- return this.#hardwareCursorVisibilityKnown && !this.#hardwareCursorVisible;
5437
- }
5438
-
5439
- /**
5440
- * Write the hardware cursor position to the terminal as a standalone
5441
- * synchronized output block. Use when there is no surrounding render buffer
5442
- * to embed the sequences into.
5443
- */
5444
- #writeCursorPosition(cursorPos: { row: number; col: number } | null, totalLines: number): void {
5445
- const target = this.#targetHardwareCursorState(cursorPos, totalLines);
5446
- if (!target) {
5447
- if (this.#isHiddenCursorKnown()) return;
5448
- this.terminal.hideCursor();
5449
- this.#recordHardwareCursorHidden();
5450
- return;
5451
- }
5452
- if (this.#sameHardwareCursorState(target)) return;
5453
- const cursorControl = this.#cursorControlSequence(cursorPos, totalLines, this.#hardwareCursorRow);
5454
- this.terminal.write(`${this.#cursorBeginSequence}${cursorControl.seq}${this.#cursorEndSequence}`);
5455
- this.#recordHardwareCursorUpdate(cursorControl);
5456
- }
5457
2575
  }