@prometheus-ai/tui 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +704 -0
  3. package/dist/types/autocomplete.d.ts +76 -0
  4. package/dist/types/bracketed-paste.d.ts +26 -0
  5. package/dist/types/components/box.d.ts +17 -0
  6. package/dist/types/components/cancellable-loader.d.ts +21 -0
  7. package/dist/types/components/editor.d.ts +105 -0
  8. package/dist/types/components/image.d.ts +84 -0
  9. package/dist/types/components/input.d.ts +18 -0
  10. package/dist/types/components/loader.d.ts +13 -0
  11. package/dist/types/components/markdown.d.ts +61 -0
  12. package/dist/types/components/scroll-view.d.ts +40 -0
  13. package/dist/types/components/select-list.d.ts +48 -0
  14. package/dist/types/components/settings-list.d.ts +41 -0
  15. package/dist/types/components/spacer.d.ts +11 -0
  16. package/dist/types/components/tab-bar.d.ts +56 -0
  17. package/dist/types/components/text.d.ts +13 -0
  18. package/dist/types/components/truncated-text.d.ts +10 -0
  19. package/dist/types/deccara.d.ts +49 -0
  20. package/dist/types/editor-component.d.ts +36 -0
  21. package/dist/types/fuzzy.d.ts +15 -0
  22. package/dist/types/index.d.ts +28 -0
  23. package/dist/types/keybindings.d.ts +189 -0
  24. package/dist/types/keys.d.ts +208 -0
  25. package/dist/types/kill-ring.d.ts +27 -0
  26. package/dist/types/kitty-graphics.d.ts +94 -0
  27. package/dist/types/stdin-buffer.d.ts +43 -0
  28. package/dist/types/symbols.d.ts +25 -0
  29. package/dist/types/terminal-capabilities.d.ts +196 -0
  30. package/dist/types/terminal.d.ts +103 -0
  31. package/dist/types/ttyid.d.ts +9 -0
  32. package/dist/types/tui.d.ts +275 -0
  33. package/dist/types/utils.d.ts +89 -0
  34. package/package.json +73 -0
  35. package/src/autocomplete.ts +871 -0
  36. package/src/bracketed-paste.ts +47 -0
  37. package/src/components/box.ts +156 -0
  38. package/src/components/cancellable-loader.ts +40 -0
  39. package/src/components/editor.ts +2695 -0
  40. package/src/components/image.ts +318 -0
  41. package/src/components/input.ts +459 -0
  42. package/src/components/loader.ts +86 -0
  43. package/src/components/markdown.ts +1189 -0
  44. package/src/components/scroll-view.ts +166 -0
  45. package/src/components/select-list.ts +331 -0
  46. package/src/components/settings-list.ts +212 -0
  47. package/src/components/spacer.ts +28 -0
  48. package/src/components/tab-bar.ts +175 -0
  49. package/src/components/text.ts +110 -0
  50. package/src/components/truncated-text.ts +61 -0
  51. package/src/deccara.ts +314 -0
  52. package/src/editor-component.ts +71 -0
  53. package/src/fuzzy.ts +143 -0
  54. package/src/index.ts +44 -0
  55. package/src/keybindings.ts +279 -0
  56. package/src/keys.ts +537 -0
  57. package/src/kill-ring.ts +46 -0
  58. package/src/kitty-graphics.ts +270 -0
  59. package/src/stdin-buffer.ts +423 -0
  60. package/src/symbols.ts +26 -0
  61. package/src/terminal-capabilities.ts +1009 -0
  62. package/src/terminal.ts +1114 -0
  63. package/src/ttyid.ts +70 -0
  64. package/src/tui.ts +2988 -0
  65. package/src/utils.ts +452 -0
package/src/tui.ts ADDED
@@ -0,0 +1,2988 @@
1
+ /**
2
+ * Minimal TUI implementation with differential rendering.
3
+ *
4
+ * Before changing the render planner, native-scrollback bookkeeping, capability
5
+ * detection, or width math, read `docs/tui-core-renderer.md`: it documents the
6
+ * failure modes (yank / corruption / flash / width crashes) and the invariants
7
+ * this engine must not violate. The short version: the renderer cannot observe
8
+ * the terminal's scroll position on most hosts, so ED3 (`CSI 3 J`) is confined
9
+ * to the destructive `clearScrollback` path, an unobservable viewport probe is
10
+ * never trusted for passive streaming, and the hot path clamps over-wide lines
11
+ * instead of throwing.
12
+ */
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ import { performance } from "node:perf_hooks";
16
+ import { $flag, getDebugLogPath } from "@prometheus-ai/utils";
17
+ import { DEFAULT_MAX_INLINE_IMAGES, ImageBudget } from "./components/image";
18
+ import { planDeccaraFills } from "./deccara";
19
+ import { isKeyRelease, matchesKey } from "./keys";
20
+ import type { Terminal } from "./terminal";
21
+ import {
22
+ encodeKittyDeleteImage,
23
+ ImageProtocol,
24
+ setCellDimensions,
25
+ setTerminalImageProtocol,
26
+ shouldEnableSynchronizedOutputByDefault,
27
+ TERMINAL,
28
+ } from "./terminal-capabilities";
29
+ import {
30
+ Ellipsis,
31
+ extractSegments,
32
+ normalizeTerminalOutput,
33
+ sliceByColumn,
34
+ sliceWithWidth,
35
+ truncateToWidth,
36
+ visibleWidth,
37
+ } from "./utils";
38
+
39
+ const SEGMENT_RESET = "\x1b[0m";
40
+ /**
41
+ * Per-line terminator written at the end of every non-image line. Closes both
42
+ * SGR state and any in-flight OSC 8 hyperlink so styles/links cannot bleed
43
+ * across lines in scrollback. Applied by {@link TUI.#applyLineResets} before
44
+ * diffing so `#previousLines` mirrors what was actually written.
45
+ */
46
+ const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x07";
47
+ // Hide the hardware cursor before each paint/move write. Ghostty-style bar
48
+ // cursors can otherwise leave visual afterimages while the TUI repaints the
49
+ // row under a visible cursor. Paint writes also disable terminal autowrap:
50
+ // several terminals keep a "pending wrap" flag after an exact-width row, so a
51
+ // following cursor move can first wrap to the next row and produce staircase
52
+ // trails. The TUI emits explicit CRLFs and restores autowrap before leaving the
53
+ // paint. Synchronized output can be disabled for terminals with broken DEC 2026
54
+ // implementations; autowrap discipline stays on either way.
55
+ const HIDE_CURSOR = "\x1b[?25l";
56
+ const SYNC_OUTPUT_BEGIN = "\x1b[?2026h";
57
+ const SYNC_OUTPUT_END = "\x1b[?2026l";
58
+ const DISABLE_AUTOWRAP = "\x1b[?7l";
59
+ const ENABLE_AUTOWRAP = "\x1b[?7h";
60
+ const PAINT_BEGIN = `${HIDE_CURSOR}${SYNC_OUTPUT_BEGIN}${DISABLE_AUTOWRAP}`;
61
+ const PAINT_END = `${ENABLE_AUTOWRAP}${SYNC_OUTPUT_END}`;
62
+ const PAINT_BEGIN_NO_SYNC = `${HIDE_CURSOR}${DISABLE_AUTOWRAP}`;
63
+ const PAINT_END_NO_SYNC = ENABLE_AUTOWRAP;
64
+ const CURSOR_BEGIN = `${HIDE_CURSOR}${SYNC_OUTPUT_BEGIN}`;
65
+ const CURSOR_BEGIN_NO_SYNC = HIDE_CURSOR;
66
+ const CURSOR_END = SYNC_OUTPUT_END;
67
+ const CURSOR_END_NO_SYNC = "";
68
+
69
+ type InputListenerResult = { consume?: boolean; data?: string } | undefined;
70
+ type InputListener = (data: string) => InputListenerResult;
71
+
72
+ export interface RenderTimer {
73
+ cancel(): void;
74
+ }
75
+
76
+ export interface RenderScheduler {
77
+ now(): number;
78
+ scheduleImmediate(callback: () => void): void;
79
+ scheduleRender(callback: () => void, delayMs: number): RenderTimer;
80
+ }
81
+
82
+ export interface TUIOptions {
83
+ renderScheduler?: RenderScheduler;
84
+ }
85
+
86
+ const DEFAULT_RENDER_SCHEDULER: RenderScheduler = {
87
+ now: () => performance.now(),
88
+ scheduleImmediate: callback => {
89
+ process.nextTick(callback);
90
+ },
91
+ scheduleRender: (callback, delayMs) => {
92
+ const timer = setTimeout(callback, delayMs);
93
+ return {
94
+ cancel: () => {
95
+ clearTimeout(timer);
96
+ },
97
+ };
98
+ },
99
+ };
100
+
101
+ /**
102
+ * Component interface - all components must implement this
103
+ */
104
+ export interface Component {
105
+ /**
106
+ * Render the component to lines for the given viewport width
107
+ * @param width - Current viewport width
108
+ * @returns Array of strings, each representing a line
109
+ */
110
+ render(width: number): string[];
111
+
112
+ /**
113
+ * Optional handler for keyboard input when component has focus
114
+ */
115
+ handleInput?(data: string): void;
116
+
117
+ /**
118
+ * If true, component receives key release events (Kitty protocol).
119
+ * Default is false - release events are filtered out.
120
+ */
121
+ wantsKeyRelease?: boolean;
122
+
123
+ /**
124
+ * Invalidate any cached rendering state.
125
+ * Called when theme changes or when component needs to re-render from scratch.
126
+ */
127
+ invalidate(): void;
128
+ }
129
+
130
+ /**
131
+ * Optional component seam for native-scrollback pinning. A component that
132
+ * renders a stable prefix followed by a live/transient suffix reports the local
133
+ * line index where that suffix begins after each render. TUI treats that suffix
134
+ * — and every root child rendered below it — as not yet safe to commit to native
135
+ * scrollback on ED3-risk terminals whose viewport position is unobservable.
136
+ *
137
+ * `getNativeScrollbackCommitSafeEnd` optionally reports a *deeper* boundary
138
+ * inside that live suffix: the line index up to which the live region is
139
+ * append-only (its earlier rows never re-layout, only new rows append at the
140
+ * bottom — a streaming assistant message). Rows in `[liveRegionStart,
141
+ * commitSafeEnd)` that scroll above the viewport are safe to commit to native
142
+ * scrollback even though they are technically live, because they will never
143
+ * change. Without this, a single live block that alone overflows the viewport
144
+ * loses its scrolled-off head (committed nowhere, repainted nowhere). Volatile
145
+ * live blocks (tool previews that collapse) omit it, so their mutable rows stay
146
+ * deferred. Defaults to `liveRegionStart` when absent.
147
+ */
148
+ export interface NativeScrollbackLiveRegion {
149
+ getNativeScrollbackLiveRegionStart(): number | undefined;
150
+ getNativeScrollbackCommitSafeEnd?(): number | undefined;
151
+ }
152
+
153
+ function getNativeScrollbackLiveRegionStart(component: Component): number | undefined {
154
+ return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackLiveRegionStart?.();
155
+ }
156
+
157
+ function getNativeScrollbackCommitSafeEnd(component: Component): number | undefined {
158
+ return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackCommitSafeEnd?.();
159
+ }
160
+
161
+ /**
162
+ * Interface for components that can receive focus and display a cursor.
163
+ * When focused, the component should emit CURSOR_MARKER at the cursor position
164
+ * in its render output. TUI will find this marker and position the hardware
165
+ * cursor there for proper IME candidate window positioning.
166
+ *
167
+ * Components that can switch between terminal-cursor and software-cursor
168
+ * rendering expose `setUseTerminalCursor`; TUI keeps that mode in sync with
169
+ * its resolved hardware-cursor preference whenever focus or the preference
170
+ * changes.
171
+ */
172
+ export interface Focusable {
173
+ /** Set by TUI when focus changes. Component should emit CURSOR_MARKER when true. */
174
+ focused: boolean;
175
+ /** Set by TUI when hardware cursor rendering is enabled or disabled. */
176
+ setUseTerminalCursor?(useTerminalCursor: boolean): void;
177
+ }
178
+
179
+ /** Options for scheduling a TUI render. */
180
+ export interface RenderRequestOptions {
181
+ /** Clear terminal scrollback for intentional transcript replacement. */
182
+ clearScrollback?: boolean;
183
+ /**
184
+ * Bypass the unknown-Windows-viewport deferral for this render so the
185
+ * caller's intentional live UI mutation reaches the terminal even when
186
+ * `Terminal#isNativeViewportAtBottom()` cannot answer.
187
+ *
188
+ * Use only for renders driven by direct user interaction (autocomplete
189
+ * updates, IME, etc.). Any background/offscreen transcript change that
190
+ * coalesces into the same frame WILL also bypass the deferral and reach
191
+ * native scrollback — that is the trade-off, and the reason ordinary
192
+ * `requestRender()` calls must continue to omit this flag.
193
+ */
194
+ allowUnknownViewportMutation?: boolean;
195
+ }
196
+
197
+ /** Options for deferred native scrollback rebuild checkpoints. Reserved for API stability. */
198
+ export interface NativeScrollbackRefreshOptions {
199
+ allowUnknownViewport?: boolean;
200
+ }
201
+ /** Type guard to check if a component implements Focusable */
202
+ export function isFocusable(component: Component | null): component is Component & Focusable {
203
+ return component !== null && "focused" in component;
204
+ }
205
+
206
+ /**
207
+ * Cursor position marker - APC (Application Program Command) sequence.
208
+ * This is a zero-width escape sequence that terminals ignore.
209
+ * Components emit this at the cursor position when focused.
210
+ * TUI finds and strips this marker, then positions the hardware cursor there.
211
+ */
212
+ export const CURSOR_MARKER = "\x1b_pi:c\x07";
213
+
214
+ export { visibleWidth };
215
+
216
+ /**
217
+ * Anchor position for overlays
218
+ */
219
+ export type OverlayAnchor =
220
+ | "center"
221
+ | "top-left"
222
+ | "top-right"
223
+ | "bottom-left"
224
+ | "bottom-right"
225
+ | "top-center"
226
+ | "bottom-center"
227
+ | "left-center"
228
+ | "right-center";
229
+
230
+ /**
231
+ * Margin configuration for overlays
232
+ */
233
+ export interface OverlayMargin {
234
+ top?: number;
235
+ right?: number;
236
+ bottom?: number;
237
+ left?: number;
238
+ }
239
+
240
+ /** Value that can be absolute (number) or percentage (string like "50%") */
241
+ export type SizeValue = number | `${number}%`;
242
+
243
+ /** Parse a SizeValue into absolute value given a reference size */
244
+ function parseSizeValue(value: SizeValue | undefined, referenceSize: number): number | undefined {
245
+ if (value === undefined) return undefined;
246
+ if (typeof value === "number") return value;
247
+ // Parse percentage string like "50%"
248
+ const match = value.match(/^(\d+(?:\.\d+)?)%$/);
249
+ if (match) {
250
+ return Math.floor((referenceSize * parseFloat(match[1])) / 100);
251
+ }
252
+ return undefined;
253
+ }
254
+
255
+ /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
256
+ function isMultiplexerSession(): boolean {
257
+ return Boolean(Bun.env.TMUX || Bun.env.STY || Bun.env.ZELLIJ);
258
+ }
259
+
260
+ /**
261
+ * Options for overlay positioning and sizing.
262
+ * Values can be absolute numbers or percentage strings (e.g., "50%").
263
+ */
264
+ export interface OverlayOptions {
265
+ // === Sizing ===
266
+ /** Width in columns, or percentage of terminal width (e.g., "50%") */
267
+ width?: SizeValue;
268
+ /** Minimum width in columns */
269
+ minWidth?: number;
270
+ /** Maximum height in rows, or percentage of terminal height (e.g., "50%") */
271
+ maxHeight?: SizeValue;
272
+
273
+ // === Positioning - anchor-based ===
274
+ /** Anchor point for positioning (default: 'center') */
275
+ anchor?: OverlayAnchor;
276
+ /** Horizontal offset from anchor position (positive = right) */
277
+ offsetX?: number;
278
+ /** Vertical offset from anchor position (positive = down) */
279
+ offsetY?: number;
280
+
281
+ // === Positioning - percentage or absolute ===
282
+ /** Row position: absolute number, or percentage (e.g., "25%" = 25% from top) */
283
+ row?: SizeValue;
284
+ /** Column position: absolute number, or percentage (e.g., "50%" = centered horizontally) */
285
+ col?: SizeValue;
286
+
287
+ // === Margin from terminal edges ===
288
+ /** Margin from terminal edges. Number applies to all sides. */
289
+ margin?: OverlayMargin | number;
290
+
291
+ // === Visibility ===
292
+ /**
293
+ * Control overlay visibility based on terminal dimensions.
294
+ * If provided, overlay is only rendered when this returns true.
295
+ * Called each render cycle with current terminal dimensions.
296
+ */
297
+ visible?: (termWidth: number, termHeight: number) => boolean;
298
+ }
299
+
300
+ /**
301
+ * Handle returned by showOverlay for controlling the overlay
302
+ */
303
+ export interface OverlayHandle {
304
+ /** Permanently remove the overlay (cannot be shown again) */
305
+ hide(): void;
306
+ /** Temporarily hide or show the overlay */
307
+ setHidden(hidden: boolean): void;
308
+ /** Check if overlay is temporarily hidden */
309
+ isHidden(): boolean;
310
+ }
311
+
312
+ /**
313
+ * Container - a component that contains other components
314
+ */
315
+ export class Container implements Component {
316
+ children: Component[] = [];
317
+
318
+ addChild(component: Component): void {
319
+ this.children.push(component);
320
+ }
321
+
322
+ removeChild(component: Component): void {
323
+ const index = this.children.indexOf(component);
324
+ if (index !== -1) {
325
+ this.children.splice(index, 1);
326
+ }
327
+ }
328
+
329
+ clear(): void {
330
+ this.children = [];
331
+ }
332
+
333
+ invalidate(): void {
334
+ for (const child of this.children) {
335
+ child.invalidate?.();
336
+ }
337
+ }
338
+
339
+ render(width: number): string[] {
340
+ width = Math.max(1, width);
341
+ const lines: string[] = [];
342
+ for (const child of this.children) {
343
+ lines.push(...child.render(width));
344
+ }
345
+ return lines;
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Render intent. `#planRender` decides which one a frame is, and the
351
+ * corresponding `#emit*` method owns the bytes written and the state update.
352
+ *
353
+ * - `noop`: no content change, only cursor may move.
354
+ * - `initial`: first paint after `start()` — clear viewport, emit transcript.
355
+ * - `sessionReplace`: caller asked for `{ clearScrollback: true }` on a forced
356
+ * render — clear viewport, clear scrollback (outside multiplexers).
357
+ * - `historyRebuild`: a geometry change (terminal resize) left native history
358
+ * wrapped at the old size — clear viewport and scrollback so it rewraps at the
359
+ * new geometry. Also flushes deferred content-only rewrites.
360
+ * - `liveRegionPinned`: ED3-risk/unknown foreground stream with a reported live
361
+ * suffix — optionally append newly sealed rows, then repaint the live/mutable
362
+ * tail without letting transient rows enter native history.
363
+ * - `viewportRepaint`: rewrite the visible viewport in place. If `appendFrom`
364
+ * is set, emit those tail rows as scrollback growth first so streaming
365
+ * output reaches terminal history before the corrected viewport is drawn.
366
+ * - `deferredShrink`: pure content shrink would re-expose rows already in
367
+ * native history. Keep row indices stable with blank tail padding, repaint
368
+ * only the viewport, and defer the real shorter replay to a checkpoint.
369
+ * - `deferredTailRepaint`: a deferred history mutation also changed the active
370
+ * grid's bottom row; repaint only that row relative to the tracked hardware
371
+ * cursor so a bottom-anchored spinner can advance without rewriting rows that
372
+ * a slightly-scrolled reader can still see.
373
+ * - `deferredMutation`: a row-inserting edit would reindex native scrollback
374
+ * while the user is scrolled. Defer all bytes until a safe rebuild checkpoint.
375
+ * - `shrink`: trailing rows were dropped — clear extras inline.
376
+ * - `diff`: differential repaint of visible rows / append new rows below.
377
+ */
378
+ type RenderIntent =
379
+ | { kind: "noop" }
380
+ | { kind: "initial" }
381
+ | { kind: "sessionReplace" }
382
+ | { kind: "historyRebuild" }
383
+ | { kind: "overlayRebuild" }
384
+ | { kind: "liveRegionPinned"; appendFrom: number; appendTo: number; renderViewportTop: number }
385
+ | { kind: "viewportRepaint"; appendFrom?: number }
386
+ | { kind: "deferredShrink"; paddedLength: number }
387
+ | { kind: "deferredTailRepaint"; row: number; line: string }
388
+ | { kind: "deferredMutation" }
389
+ | { kind: "shrink" }
390
+ | { kind: "diff"; firstChanged: number; lastChanged: number; appendedLines: boolean };
391
+
392
+ /**
393
+ * TUI - Main class for managing terminal UI with differential rendering
394
+ */
395
+ export class TUI extends Container {
396
+ terminal: Terminal;
397
+ #previousLines: string[] = [];
398
+ #previousWidth = 0;
399
+ #previousHeight = 0;
400
+ #focusedComponent: Component | null = null;
401
+ #inputListeners = new Set<InputListener>();
402
+
403
+ /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
404
+ onDebug?: () => void;
405
+ #renderRequested = false;
406
+ #renderTimer: RenderTimer | undefined;
407
+ #renderScheduler: RenderScheduler;
408
+ #lastRenderAt = 0;
409
+ static readonly #MIN_RENDER_INTERVAL_MS = 16;
410
+ #cursorRow = 0; // Logical cursor row (end of rendered content)
411
+ #hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
412
+ #viewportTopRow = 0; // Content row currently mapped to screen row 0
413
+ #sixelProbePendingDa = false;
414
+ #sixelProbePendingGraphics = false;
415
+ #sixelProbeBuffer = "";
416
+ #sixelProbeTimeout?: NodeJS.Timeout;
417
+ #sixelProbeUnsubscribe?: () => void;
418
+ #showHardwareCursor = $flag("PROMETHEUS_HARDWARE_CURSOR");
419
+ #clearOnShrink = $flag("PROMETHEUS_CLEAR_ON_SHRINK"); // Clear empty rows when content shrinks (default: off)
420
+ #synchronizedOutputEnabled = shouldEnableSynchronizedOutputByDefault();
421
+ #paintBeginSequence = this.#synchronizedOutputEnabled ? PAINT_BEGIN : PAINT_BEGIN_NO_SYNC;
422
+ #paintEndSequence = this.#synchronizedOutputEnabled ? PAINT_END : PAINT_END_NO_SYNC;
423
+ #cursorBeginSequence = this.#synchronizedOutputEnabled ? CURSOR_BEGIN : CURSOR_BEGIN_NO_SYNC;
424
+ #cursorEndSequence = this.#synchronizedOutputEnabled ? CURSOR_END : CURSOR_END_NO_SYNC;
425
+ #maxLinesRendered = 0; // Line count from last render, used for viewport calculation
426
+ // Highest count of content rows currently sitting in terminal scrollback
427
+ // above the visible viewport. Used to detect shrink-across-viewport-boundary
428
+ // frames where the new transcript would re-expose rows the terminal has
429
+ // already committed to history — without intervention the rows visibly
430
+ // duplicate once the user scrolls back.
431
+ #scrollbackHighWater = 0;
432
+ // Set after a clear+full replay so the next insert-above-suffix frame does
433
+ // not scroll replayed live chrome (status/editor) into fresh history.
434
+ #suppressNextSuffixScroll = false;
435
+ #nativeScrollbackLiveRegionStart: number | undefined;
436
+ #nativeScrollbackCommitSafeEnd: number | undefined;
437
+ #nativeScrollbackDirty = false;
438
+ #deferredTailLine: string | undefined;
439
+ // Highest `#maxLinesRendered` reached during a foreground tool turn while
440
+ // intermediate frames were prevented from committing to terminal scrollback.
441
+ // Used after the tool finishes to push the settled content into scrollback
442
+ // via a non-destructive full paint (no ED 3). Reset to 0 once rows are
443
+ // committed (via any `#emitFullPaint`, `#emitDiff`, or `#emitAppendTail`
444
+ // path).
445
+ #streamingHighWater = 0;
446
+ // Tracks whether the previous frame was inside a foreground tool streaming
447
+ // turn. Used to reset `#streamingHighWater` on fresh streaming starts.
448
+ #previousStreamingActive = false;
449
+ #fullRedrawCount = 0;
450
+ // Caps how many inline images render as live graphics; older ones fall back
451
+ // to text via a purge + full redraw. Cap is configured by the host app.
452
+ #imageBudget = new ImageBudget(DEFAULT_MAX_INLINE_IMAGES, () => this.requestRender());
453
+ #clearScrollbackOnNextRender = false;
454
+ #forceViewportRepaintOnNextRender = false;
455
+ #allowUnknownViewportMutationOnNextRender = false;
456
+ #eagerNativeScrollbackRebuild = false;
457
+ // Set when eager mode is switched off; applied after the next frame is
458
+ // classified so teardown frames from the same event batch still render
459
+ // eagerly (see setEagerNativeScrollbackRebuild).
460
+ #eagerNativeScrollbackRebuildDisablePending = false;
461
+ #previousVisibleOverlayComponents: Component[] = [];
462
+ #visibleOverlayComponentsThisRender: Component[] = [];
463
+ #hasEverRendered = false;
464
+ // Set by the terminal resize callback; consumed by the next render. A resize
465
+ // event invalidates the committed screen even when the dimensions net out
466
+ // unchanged by render time (e.g. a 6→4→6 round trip coalesced into one frame
467
+ // budget): the terminal reflowed its buffer on each event, moving rows
468
+ // between the viewport and scrollback, so the previous frame no longer
469
+ // describes the screen. Tracking only the dimension delta misses this.
470
+ #resizeEventPending = false;
471
+ #stopped = false;
472
+
473
+ // Overlay stack for modal components rendered on top of base content
474
+ overlayStack: {
475
+ component: Component;
476
+ options?: OverlayOptions;
477
+ preFocus: Component | null;
478
+ hidden: boolean;
479
+ }[] = [];
480
+
481
+ constructor(terminal: Terminal, showHardwareCursor?: boolean, options?: TUIOptions) {
482
+ super();
483
+ this.terminal = terminal;
484
+ this.#renderScheduler = options?.renderScheduler ?? DEFAULT_RENDER_SCHEDULER;
485
+ this.#showHardwareCursor = showHardwareCursor === undefined ? this.#showHardwareCursor : showHardwareCursor;
486
+ }
487
+
488
+ override render(width: number): string[] {
489
+ width = Math.max(1, width);
490
+ this.#nativeScrollbackLiveRegionStart = undefined;
491
+ this.#nativeScrollbackCommitSafeEnd = undefined;
492
+ const lines: string[] = [];
493
+ for (const child of this.children) {
494
+ const offset = lines.length;
495
+ const childLines = child.render(width);
496
+ const liveRegionStart = getNativeScrollbackLiveRegionStart(child);
497
+ if (liveRegionStart !== undefined) {
498
+ const boundedStart = Number.isFinite(liveRegionStart)
499
+ ? Math.max(0, Math.min(childLines.length, Math.trunc(liveRegionStart)))
500
+ : childLines.length;
501
+ this.#nativeScrollbackLiveRegionStart = offset + boundedStart;
502
+ const commitSafeEnd = getNativeScrollbackCommitSafeEnd(child);
503
+ if (commitSafeEnd !== undefined) {
504
+ const boundedEnd = Number.isFinite(commitSafeEnd)
505
+ ? Math.max(boundedStart, Math.min(childLines.length, Math.trunc(commitSafeEnd)))
506
+ : childLines.length;
507
+ this.#nativeScrollbackCommitSafeEnd = offset + boundedEnd;
508
+ }
509
+ }
510
+ lines.push(...childLines);
511
+ }
512
+ return lines;
513
+ }
514
+
515
+ #syncTerminalCursorMode(component: Component | null): void {
516
+ if (isFocusable(component)) {
517
+ component.setUseTerminalCursor?.(this.#showHardwareCursor);
518
+ }
519
+ }
520
+
521
+ get fullRedraws(): number {
522
+ return this.#fullRedrawCount;
523
+ }
524
+
525
+ /** Shared budget that caps how many inline images render as live graphics. */
526
+ get imageBudget(): ImageBudget {
527
+ return this.#imageBudget;
528
+ }
529
+
530
+ /**
531
+ * Set how many inline images stay live graphics before older ones fall back
532
+ * to text (`0` disables the cap). Older images are hidden via a graphics purge
533
+ * plus a full redraw on the frame after a new image exceeds the cap.
534
+ */
535
+ setMaxInlineImages(cap: number): void {
536
+ this.#imageBudget.setCap(cap);
537
+ }
538
+
539
+ getShowHardwareCursor(): boolean {
540
+ return this.#showHardwareCursor;
541
+ }
542
+
543
+ setShowHardwareCursor(enabled: boolean): void {
544
+ if (this.#showHardwareCursor === enabled) return;
545
+ this.#showHardwareCursor = enabled;
546
+ this.#syncTerminalCursorMode(this.#focusedComponent);
547
+ if (!enabled) {
548
+ this.terminal.hideCursor();
549
+ }
550
+ this.requestRender();
551
+ }
552
+
553
+ getClearOnShrink(): boolean {
554
+ return this.#clearOnShrink;
555
+ }
556
+
557
+ /**
558
+ * Set whether to trigger full re-render when content shrinks.
559
+ * When true (default), empty rows are cleared when content shrinks.
560
+ * When false, empty rows remain (reduces redraws on slower terminals).
561
+ */
562
+ setClearOnShrink(enabled: boolean): void {
563
+ this.#clearOnShrink = enabled;
564
+ }
565
+
566
+ /**
567
+ * Whether DEC 2026 synchronized-output wrappers are currently emitted around
568
+ * paints. Starts from conservative terminal/env detection and is force-disabled
569
+ * at runtime if the terminal reports mode 2026 unsupported via DECRQM.
570
+ */
571
+ get synchronizedOutput(): boolean {
572
+ return this.#synchronizedOutputEnabled;
573
+ }
574
+
575
+ /**
576
+ * When enabled, live render frames rebuild native scrollback on offscreen and
577
+ * structural changes even when the viewport position is unobservable (POSIX,
578
+ * where `isNativeViewportAtBottom()` is `undefined`), instead of deferring to a
579
+ * non-destructive repaint. This trades the anti-yank guarantee for a clean,
580
+ * duplicate-free history and is meant for windows where output above the fold
581
+ * is actively re-rendering — e.g. a tool whose result is still streaming and
582
+ * re-laying-out rows that have already scrolled into history. A terminal that
583
+ * reports a *known*-scrolled viewport still defers, as does native Windows
584
+ * (the viewport is never observable there and ConPTY hosts erase host
585
+ * scrollback on ED3 — #1635/#1746); only the unknown POSIX case is forced to
586
+ * rebuild. POSIX hosts known to disturb scrolled readers on xterm ED3
587
+ * (`CSI 3 J`, erase saved lines) also defer the eager opt-in; checkpoint
588
+ * rebuilds are unaffected.
589
+ *
590
+ * Disabling stays active through one already-requested frame: the event batch
591
+ * that ends a foreground stream both removes its UI rows (loader/status
592
+ * teardown — a shrink) and clears this flag before the throttled render timer
593
+ * fires. If the flag dropped immediately, that teardown frame would hit the
594
+ * ED3-risk idle deferral and freeze on screen (stale spinner) until the next
595
+ * keystroke. When no render is pending, disable immediately so a later
596
+ * unrelated content mutation does not inherit foreground-stream privileges.
597
+ */
598
+ setEagerNativeScrollbackRebuild(enabled: boolean): void {
599
+ if (enabled) {
600
+ this.#eagerNativeScrollbackRebuild = true;
601
+ this.#eagerNativeScrollbackRebuildDisablePending = false;
602
+ return;
603
+ }
604
+ if (!this.#eagerNativeScrollbackRebuild) return;
605
+ if (this.#renderRequested || this.#renderTimer !== undefined) {
606
+ this.#eagerNativeScrollbackRebuildDisablePending = true;
607
+ return;
608
+ }
609
+ if (this.#hasEagerEraseScrollbackRisk()) {
610
+ this.#streamingHighWater = 0;
611
+ this.#markNativeScrollbackDirty();
612
+ }
613
+ this.#eagerNativeScrollbackRebuild = false;
614
+ this.#eagerNativeScrollbackRebuildDisablePending = false;
615
+ }
616
+
617
+ setFocus(component: Component | null): void {
618
+ // Clear focused flag on old component
619
+ if (isFocusable(this.#focusedComponent)) {
620
+ this.#focusedComponent.focused = false;
621
+ }
622
+
623
+ this.#focusedComponent = component;
624
+
625
+ // Set focused flag on new component and keep its software/hardware cursor
626
+ // rendering mode aligned with TUI's single cursor-visibility preference.
627
+ if (isFocusable(component)) {
628
+ component.focused = true;
629
+ this.#syncTerminalCursorMode(component);
630
+ }
631
+ }
632
+
633
+ /**
634
+ * Show an overlay component with configurable positioning and sizing.
635
+ * Returns a handle to control the overlay's visibility.
636
+ */
637
+ showOverlay(component: Component, options?: OverlayOptions): OverlayHandle {
638
+ const entry = { component, options, preFocus: this.#focusedComponent, hidden: false };
639
+ this.overlayStack.push(entry);
640
+ // Only focus if overlay is actually visible
641
+ if (this.#isOverlayVisible(entry)) {
642
+ this.setFocus(component);
643
+ }
644
+ this.terminal.hideCursor();
645
+ this.requestRender();
646
+
647
+ // Return handle for controlling this overlay
648
+ return {
649
+ hide: () => {
650
+ const index = this.overlayStack.indexOf(entry);
651
+ if (index !== -1) {
652
+ this.overlayStack.splice(index, 1);
653
+ // Restore focus if this overlay had focus
654
+ if (this.#focusedComponent === component) {
655
+ const topVisible = this.#getTopmostVisibleOverlay();
656
+ this.setFocus(topVisible?.component ?? entry.preFocus);
657
+ }
658
+ if (this.overlayStack.length === 0) this.terminal.hideCursor();
659
+ this.requestRender();
660
+ }
661
+ },
662
+ setHidden: (hidden: boolean) => {
663
+ if (entry.hidden === hidden) return;
664
+ entry.hidden = hidden;
665
+ // Update focus when hiding/showing
666
+ if (hidden) {
667
+ // If this overlay had focus, move focus to next visible or preFocus
668
+ if (this.#focusedComponent === component) {
669
+ const topVisible = this.#getTopmostVisibleOverlay();
670
+ this.setFocus(topVisible?.component ?? entry.preFocus);
671
+ }
672
+ } else {
673
+ // Restore focus to this overlay when showing (if it's actually visible)
674
+ if (this.#isOverlayVisible(entry)) {
675
+ this.setFocus(component);
676
+ }
677
+ }
678
+ this.requestRender();
679
+ },
680
+ isHidden: () => entry.hidden,
681
+ };
682
+ }
683
+
684
+ /** Hide the topmost overlay and restore previous focus. */
685
+ hideOverlay(): void {
686
+ const overlay = this.overlayStack.pop();
687
+ if (!overlay) return;
688
+ // Find topmost visible overlay, or fall back to preFocus
689
+ const topVisible = this.#getTopmostVisibleOverlay();
690
+ this.setFocus(topVisible?.component ?? overlay.preFocus);
691
+ if (this.overlayStack.length === 0) this.terminal.hideCursor();
692
+ this.requestRender();
693
+ }
694
+
695
+ /** Check if there are any visible overlays */
696
+ hasOverlay(): boolean {
697
+ return this.overlayStack.some(o => this.#isOverlayVisible(o));
698
+ }
699
+
700
+ /** Check if an overlay entry is currently visible */
701
+ #isOverlayVisible(entry: (typeof this.overlayStack)[number]): boolean {
702
+ if (entry.hidden) return false;
703
+ if (entry.options?.visible) {
704
+ return entry.options.visible(this.terminal.columns, this.terminal.rows);
705
+ }
706
+ return true;
707
+ }
708
+
709
+ /** Find the topmost visible overlay, if any */
710
+ #getTopmostVisibleOverlay(): (typeof this.overlayStack)[number] | undefined {
711
+ for (let i = this.overlayStack.length - 1; i >= 0; i--) {
712
+ if (this.#isOverlayVisible(this.overlayStack[i])) {
713
+ return this.overlayStack[i];
714
+ }
715
+ }
716
+ return undefined;
717
+ }
718
+
719
+ #overlayVisibilityReduced(visibleComponents: readonly Component[]): boolean {
720
+ if (this.#previousVisibleOverlayComponents.length === 0) return false;
721
+ for (const component of this.#previousVisibleOverlayComponents) {
722
+ if (!visibleComponents.includes(component)) return true;
723
+ }
724
+ return false;
725
+ }
726
+
727
+ override invalidate(): void {
728
+ super.invalidate();
729
+ for (const overlay of this.overlayStack) overlay.component.invalidate?.();
730
+ }
731
+
732
+ start(): void {
733
+ this.#stopped = false;
734
+ // Disable synchronized output if the terminal reports DEC 2026 unsupported
735
+ // via DECRQM. PROMETHEUS_NO_SYNC_OUTPUT already forces it off at construction, so
736
+ // only react when the user has not already opted out. Future paints drop
737
+ // the begin/end markers; the autowrap guards stay (see #1765).
738
+ this.terminal.onPrivateModeReport?.((mode, supported) => {
739
+ if (mode === 2026 && !supported && !$flag("PROMETHEUS_NO_SYNC_OUTPUT")) {
740
+ this.#setSynchronizedOutput(false);
741
+ }
742
+ });
743
+ this.terminal.start(
744
+ data => this.#handleInput(data),
745
+ () => {
746
+ this.#resizeEventPending = true;
747
+ this.requestRender();
748
+ },
749
+ );
750
+ this.terminal.hideCursor();
751
+ this.#querySixelSupport();
752
+ this.#queryCellSize();
753
+ this.requestRender(true);
754
+ }
755
+
756
+ addInputListener(listener: InputListener): () => void {
757
+ this.#inputListeners.add(listener);
758
+ return () => {
759
+ this.#inputListeners.delete(listener);
760
+ };
761
+ }
762
+
763
+ removeInputListener(listener: InputListener): void {
764
+ this.#inputListeners.delete(listener);
765
+ }
766
+
767
+ #querySixelSupport(): void {
768
+ if (TERMINAL.imageProtocol) return;
769
+ if (process.platform !== "win32") return;
770
+ if (!Bun.env.WT_SESSION) return;
771
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return;
772
+
773
+ this.#clearSixelProbeState();
774
+ this.#sixelProbePendingDa = true;
775
+ this.#sixelProbePendingGraphics = true;
776
+ this.#sixelProbeUnsubscribe = this.addInputListener(data => this.#handleSixelProbeInput(data));
777
+ this.terminal.write("\x1b[c");
778
+ this.terminal.write("\x1b[?2;1;0S");
779
+ this.#sixelProbeTimeout = setTimeout(() => {
780
+ this.#finishSixelProbe(false);
781
+ }, 250);
782
+ }
783
+
784
+ #handleSixelProbeInput(data: string): InputListenerResult {
785
+ if (!this.#sixelProbePendingDa && !this.#sixelProbePendingGraphics) {
786
+ return undefined;
787
+ }
788
+
789
+ this.#sixelProbeBuffer += data;
790
+ let passthrough = "";
791
+ let probeOutcome: boolean | null = null;
792
+
793
+ while (this.#sixelProbeBuffer.length > 0) {
794
+ const daMatch = this.#sixelProbeBuffer.match(/\x1b\[\?([0-9;]+)c/u);
795
+ const graphicsMatch = this.#sixelProbeBuffer.match(/\x1b\[\?2;(\d+);([0-9;]+)S/u);
796
+
797
+ if (!daMatch && !graphicsMatch) break;
798
+
799
+ const daIndex = daMatch?.index ?? Number.POSITIVE_INFINITY;
800
+ const graphicsIndex = graphicsMatch?.index ?? Number.POSITIVE_INFINITY;
801
+ const useDa = daIndex <= graphicsIndex;
802
+ const match = useDa ? daMatch : graphicsMatch;
803
+ if (!match || match.index === undefined) break;
804
+
805
+ passthrough += this.#sixelProbeBuffer.slice(0, match.index);
806
+ this.#sixelProbeBuffer = this.#sixelProbeBuffer.slice(match.index + match[0].length);
807
+
808
+ if (useDa && this.#sixelProbePendingDa) {
809
+ this.#sixelProbePendingDa = false;
810
+ const attributes = (match[1] ?? "")
811
+ .split(";")
812
+ .map(value => Number.parseInt(value, 10))
813
+ .filter(value => Number.isFinite(value));
814
+ const hasSixelAttribute = attributes.includes(4);
815
+ if (hasSixelAttribute) {
816
+ this.#sixelProbePendingGraphics = false;
817
+ probeOutcome = true;
818
+ } else if (!this.#sixelProbePendingGraphics) {
819
+ probeOutcome = false;
820
+ }
821
+ } else if (!useDa && this.#sixelProbePendingGraphics) {
822
+ this.#sixelProbePendingGraphics = false;
823
+ const status = Number.parseInt(match[1] ?? "", 10);
824
+ const supportsSixel = !Number.isNaN(status) && status !== 0;
825
+ if (supportsSixel) {
826
+ this.#sixelProbePendingDa = false;
827
+ probeOutcome = true;
828
+ } else if (!this.#sixelProbePendingDa) {
829
+ probeOutcome = false;
830
+ }
831
+ }
832
+ }
833
+
834
+ if (this.#sixelProbePendingDa || this.#sixelProbePendingGraphics) {
835
+ const partialStart = this.#getSixelProbePartialStart(this.#sixelProbeBuffer);
836
+ if (partialStart >= 0) {
837
+ passthrough += this.#sixelProbeBuffer.slice(0, partialStart);
838
+ this.#sixelProbeBuffer = this.#sixelProbeBuffer.slice(partialStart);
839
+ } else {
840
+ passthrough += this.#sixelProbeBuffer;
841
+ this.#sixelProbeBuffer = "";
842
+ }
843
+ } else {
844
+ passthrough += this.#sixelProbeBuffer;
845
+ this.#sixelProbeBuffer = "";
846
+ }
847
+
848
+ if (probeOutcome !== null) {
849
+ this.#finishSixelProbe(probeOutcome);
850
+ }
851
+
852
+ if (passthrough.length === 0) {
853
+ return { consume: true };
854
+ }
855
+
856
+ return { data: passthrough };
857
+ }
858
+
859
+ #getSixelProbePartialStart(buffer: string): number {
860
+ const lastEsc = buffer.lastIndexOf("\x1b");
861
+ if (lastEsc < 0) return -1;
862
+ const tail = buffer.slice(lastEsc);
863
+ if (/^\x1b\[\?[0-9;]*$/u.test(tail)) {
864
+ return lastEsc;
865
+ }
866
+ return -1;
867
+ }
868
+
869
+ #clearSixelProbeState(): void {
870
+ if (this.#sixelProbeTimeout) {
871
+ clearTimeout(this.#sixelProbeTimeout);
872
+ this.#sixelProbeTimeout = undefined;
873
+ }
874
+ if (this.#sixelProbeUnsubscribe) {
875
+ this.#sixelProbeUnsubscribe();
876
+ this.#sixelProbeUnsubscribe = undefined;
877
+ }
878
+ this.#sixelProbePendingDa = false;
879
+ this.#sixelProbePendingGraphics = false;
880
+ this.#sixelProbeBuffer = "";
881
+ }
882
+
883
+ #finishSixelProbe(supported: boolean): void {
884
+ this.#clearSixelProbeState();
885
+ if (!supported || TERMINAL.imageProtocol) return;
886
+
887
+ setTerminalImageProtocol(ImageProtocol.Sixel);
888
+ this.#queryCellSize();
889
+ this.invalidate();
890
+ this.requestRender(true);
891
+ }
892
+ #queryCellSize(): void {
893
+ // Only query if terminal supports images (cell size is only used for image rendering)
894
+ if (!TERMINAL.imageProtocol) {
895
+ return;
896
+ }
897
+ // Query terminal for cell size in pixels: CSI 16 t
898
+ // Response format: CSI 6 ; height ; width t
899
+ this.terminal.write("\x1b[16t");
900
+ }
901
+
902
+ /**
903
+ * Toggle synchronized-output (DEC 2026) wrappers on paint/cursor writes and
904
+ * recompute the cached begin/end sequences. Honors a DECRQM report that the
905
+ * terminal does not support 2026 (#1765 covers the static env opt-out).
906
+ */
907
+ #setSynchronizedOutput(enabled: boolean): void {
908
+ if (this.#synchronizedOutputEnabled === enabled) return;
909
+ this.#synchronizedOutputEnabled = enabled;
910
+ this.#paintBeginSequence = enabled ? PAINT_BEGIN : PAINT_BEGIN_NO_SYNC;
911
+ this.#paintEndSequence = enabled ? PAINT_END : PAINT_END_NO_SYNC;
912
+ this.#cursorBeginSequence = enabled ? CURSOR_BEGIN : CURSOR_BEGIN_NO_SYNC;
913
+ this.#cursorEndSequence = enabled ? CURSOR_END : CURSOR_END_NO_SYNC;
914
+ }
915
+
916
+ stop(): void {
917
+ if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
918
+ for (const id of this.#imageBudget.takeAllTransmittedIds()) {
919
+ this.terminal.write(encodeKittyDeleteImage(id));
920
+ }
921
+ }
922
+ this.#clearSixelProbeState();
923
+ this.#stopped = true;
924
+ if (this.#renderTimer) {
925
+ this.#renderTimer.cancel();
926
+ this.#renderTimer = undefined;
927
+ }
928
+ // Place the parent shell on the first line after the rendered content. When
929
+ // that line is still inside the viewport, moving there and writing `\r` is
930
+ // enough; emitting `\r\n` would create an extra blank row. If the content
931
+ // already reaches the viewport bottom, scroll exactly once so the prompt
932
+ // lands directly below the last visible TUI row.
933
+ if (this.#previousLines.length > 0) {
934
+ const targetRow = this.#previousLines.length;
935
+ const viewportBottom = this.#viewportTopRow + this.terminal.rows - 1;
936
+ const clampedCursorRow = Math.max(this.#viewportTopRow, Math.min(this.#hardwareCursorRow, viewportBottom));
937
+ const moveTargetRow = Math.min(targetRow, viewportBottom);
938
+ const lineDiff = moveTargetRow - clampedCursorRow;
939
+ if (lineDiff > 0) {
940
+ this.terminal.write(`\x1b[${lineDiff}B`);
941
+ } else if (lineDiff < 0) {
942
+ this.terminal.write(`\x1b[${-lineDiff}A`);
943
+ }
944
+ this.terminal.write(targetRow <= viewportBottom ? "\r" : "\r\n");
945
+ }
946
+
947
+ this.terminal.showCursor();
948
+ this.terminal.stop();
949
+ }
950
+
951
+ /**
952
+ * Rebuild native terminal scrollback if live rendering deferred a history rewrite.
953
+ * Callers should only invoke this at checkpoints where the user is expected to be
954
+ * at the terminal bottom, such as after submitting a new prompt.
955
+ */
956
+ refreshNativeScrollbackIfDirty(_options?: NativeScrollbackRefreshOptions): boolean {
957
+ if (!this.#nativeScrollbackDirty || this.#stopped) return false;
958
+ // Multiplexer panes preserve their own history and never receive a
959
+ // destructive clear, so a checkpoint "replay" cannot reconcile anything —
960
+ // it would only append a duplicate copy of the transcript to pane
961
+ // history. Drop the dirty flag; there is nothing actionable behind it.
962
+ if (isMultiplexerSession()) {
963
+ this.#clearNativeScrollbackDirty();
964
+ return false;
965
+ }
966
+ const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
967
+ if (!this.#canReplayNativeScrollbackAtCheckpoint(nativeViewportAtBottom)) {
968
+ return false;
969
+ }
970
+ this.#prepareForcedRender(true, false);
971
+ this.#renderRequested = false;
972
+ this.#lastRenderAt = this.#renderScheduler.now();
973
+ this.#doRender();
974
+ return true;
975
+ }
976
+
977
+ /**
978
+ * Force an immediate full replay of the current frame, including native
979
+ * scrollback. This is the keyboard-accessible equivalent of the resize reset:
980
+ * no queued diff frame or terminal scrollback probe can downgrade it to a
981
+ * viewport-only repaint.
982
+ */
983
+ resetDisplay(): void {
984
+ if (this.#stopped) return;
985
+ this.#prepareForcedRender(!isMultiplexerSession(), true);
986
+ this.#resizeEventPending = true;
987
+ this.#renderRequested = false;
988
+ this.#lastRenderAt = this.#renderScheduler.now();
989
+ this.#doRender();
990
+ }
991
+
992
+ requestRender(force = false, options?: RenderRequestOptions): void {
993
+ const allowUnknownViewportMutation = options?.allowUnknownViewportMutation === true;
994
+ this.#allowUnknownViewportMutationOnNextRender ||= allowUnknownViewportMutation;
995
+ if (force) {
996
+ this.#prepareForcedRender(options?.clearScrollback === true, allowUnknownViewportMutation);
997
+ this.#renderRequested = true;
998
+ this.#renderScheduler.scheduleImmediate(() => {
999
+ if (this.#stopped || !this.#renderRequested) {
1000
+ return;
1001
+ }
1002
+ this.#renderRequested = false;
1003
+ this.#lastRenderAt = this.#renderScheduler.now();
1004
+ this.#doRender();
1005
+ });
1006
+ return;
1007
+ }
1008
+ if (this.#renderRequested) return;
1009
+ this.#renderRequested = true;
1010
+ this.#renderScheduler.scheduleImmediate(() => this.#scheduleRender());
1011
+ }
1012
+
1013
+ #prepareForcedRender(clearScrollback: boolean, _allowUnknownViewportMutation: boolean): void {
1014
+ const geometryChanged =
1015
+ (this.#previousWidth > 0 && this.#previousWidth !== this.terminal.columns) ||
1016
+ (this.#previousHeight > 0 && this.#previousHeight !== this.terminal.rows);
1017
+ // A geometry replay rewraps clearable native scrollback at the new size.
1018
+ // Inside a multiplexer the pane reflows its own history and a replay only
1019
+ // duplicates it, so never promote forced renders to sessionReplace there.
1020
+ const replayGeometry =
1021
+ geometryChanged &&
1022
+ !isMultiplexerSession() &&
1023
+ this.#canReplayNativeScrollbackAtCheckpoint(this.#readNativeViewportAtBottom());
1024
+ this.#clearScrollbackOnNextRender ||= clearScrollback || replayGeometry;
1025
+ this.#forceViewportRepaintOnNextRender = true;
1026
+ if (this.#renderTimer) {
1027
+ this.#renderTimer.cancel();
1028
+ this.#renderTimer = undefined;
1029
+ }
1030
+ }
1031
+
1032
+ #scheduleRender(): void {
1033
+ if (this.#stopped || this.#renderTimer || !this.#renderRequested) {
1034
+ return;
1035
+ }
1036
+ const elapsed = this.#renderScheduler.now() - this.#lastRenderAt;
1037
+ const delay = Math.max(0, TUI.#MIN_RENDER_INTERVAL_MS - elapsed);
1038
+ this.#renderTimer = this.#renderScheduler.scheduleRender(() => {
1039
+ this.#renderTimer = undefined;
1040
+ if (this.#stopped || !this.#renderRequested) {
1041
+ return;
1042
+ }
1043
+ this.#renderRequested = false;
1044
+ this.#lastRenderAt = this.#renderScheduler.now();
1045
+ this.#doRender();
1046
+ if (this.#renderRequested) {
1047
+ this.#scheduleRender();
1048
+ }
1049
+ }, delay);
1050
+ }
1051
+
1052
+ #handleInput(data: string): void {
1053
+ if (this.#inputListeners.size > 0) {
1054
+ let current = data;
1055
+ for (const listener of this.#inputListeners) {
1056
+ const result = listener(current);
1057
+ if (result?.consume) {
1058
+ return;
1059
+ }
1060
+ if (result?.data !== undefined) {
1061
+ current = result.data;
1062
+ }
1063
+ }
1064
+ if (current.length === 0) {
1065
+ return;
1066
+ }
1067
+ data = current;
1068
+ }
1069
+
1070
+ // Consume terminal cell size responses without blocking unrelated input.
1071
+ if (this.#consumeCellSizeResponse(data)) {
1072
+ return;
1073
+ }
1074
+
1075
+ // Global debug key handler (Shift+Ctrl+D)
1076
+ if (matchesKey(data, "shift+ctrl+d") && this.onDebug) {
1077
+ this.onDebug();
1078
+ return;
1079
+ }
1080
+
1081
+ // If focused component is an overlay, verify it's still visible
1082
+ // (visibility can change due to terminal resize or visible() callback)
1083
+ const focusedOverlay = this.overlayStack.find(o => o.component === this.#focusedComponent);
1084
+ if (focusedOverlay && !this.#isOverlayVisible(focusedOverlay)) {
1085
+ // Focused overlay is no longer visible, redirect to topmost visible overlay
1086
+ const topVisible = this.#getTopmostVisibleOverlay();
1087
+ if (topVisible) {
1088
+ this.setFocus(topVisible.component);
1089
+ } else {
1090
+ // No visible overlays, restore to preFocus
1091
+ this.setFocus(focusedOverlay.preFocus);
1092
+ }
1093
+ }
1094
+
1095
+ // Pass input to focused component (including Ctrl+C)
1096
+ // The focused component can decide how to handle Ctrl+C
1097
+ if (this.#focusedComponent?.handleInput) {
1098
+ // Filter out key release events unless component opts in
1099
+ if (isKeyRelease(data) && !this.#focusedComponent.wantsKeyRelease) {
1100
+ return;
1101
+ }
1102
+ this.#focusedComponent.handleInput(data);
1103
+ this.requestRender(false, { allowUnknownViewportMutation: true });
1104
+ }
1105
+ }
1106
+
1107
+ #consumeCellSizeResponse(data: string): boolean {
1108
+ // Response format: ESC [ 6 ; height ; width t
1109
+ const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
1110
+ if (!match) {
1111
+ return false;
1112
+ }
1113
+
1114
+ const heightPx = parseInt(match[1], 10);
1115
+ const widthPx = parseInt(match[2], 10);
1116
+ if (heightPx <= 0 || widthPx <= 0) {
1117
+ return true;
1118
+ }
1119
+
1120
+ setCellDimensions({ widthPx, heightPx });
1121
+ // Invalidate all components so images re-render with correct dimensions.
1122
+ this.invalidate();
1123
+ this.requestRender();
1124
+ return true;
1125
+ }
1126
+
1127
+ /**
1128
+ * Resolve overlay layout from options.
1129
+ * Returns { width, row, col, maxHeight } for rendering.
1130
+ */
1131
+ #resolveOverlayLayout(
1132
+ options: OverlayOptions | undefined,
1133
+ overlayHeight: number,
1134
+ termWidth: number,
1135
+ termHeight: number,
1136
+ ): { width: number; row: number; col: number; maxHeight: number | undefined } {
1137
+ const opt = options ?? {};
1138
+
1139
+ // Parse margin (clamp to non-negative)
1140
+ const margin =
1141
+ typeof opt.margin === "number"
1142
+ ? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin }
1143
+ : (opt.margin ?? {});
1144
+ const marginTop = Math.max(0, margin.top ?? 0);
1145
+ const marginRight = Math.max(0, margin.right ?? 0);
1146
+ const marginBottom = Math.max(0, margin.bottom ?? 0);
1147
+ const marginLeft = Math.max(0, margin.left ?? 0);
1148
+
1149
+ // Available space after margins
1150
+ const availWidth = Math.max(1, termWidth - marginLeft - marginRight);
1151
+ const availHeight = Math.max(1, termHeight - marginTop - marginBottom);
1152
+
1153
+ // === Resolve width ===
1154
+ let width = parseSizeValue(opt.width, termWidth) ?? Math.min(80, availWidth);
1155
+ // Apply minWidth
1156
+ if (opt.minWidth !== undefined) {
1157
+ width = Math.max(width, opt.minWidth);
1158
+ }
1159
+ // Clamp to available space
1160
+ width = Math.max(1, Math.min(width, availWidth));
1161
+
1162
+ // === Resolve maxHeight ===
1163
+ let maxHeight = parseSizeValue(opt.maxHeight, termHeight);
1164
+ // Clamp to available space
1165
+ if (maxHeight !== undefined) {
1166
+ maxHeight = Math.max(1, Math.min(maxHeight, availHeight));
1167
+ }
1168
+
1169
+ // Effective overlay height (may be clamped by maxHeight)
1170
+ const effectiveHeight = maxHeight !== undefined ? Math.min(overlayHeight, maxHeight) : overlayHeight;
1171
+
1172
+ // === Resolve position ===
1173
+ let row: number;
1174
+ let col: number;
1175
+
1176
+ if (opt.row !== undefined) {
1177
+ if (typeof opt.row === "string") {
1178
+ // Percentage: 0% = top, 100% = bottom (overlay stays within bounds)
1179
+ const match = opt.row.match(/^(\d+(?:\.\d+)?)%$/);
1180
+ if (match) {
1181
+ const maxRow = Math.max(0, availHeight - effectiveHeight);
1182
+ const percent = parseFloat(match[1]) / 100;
1183
+ row = marginTop + Math.floor(maxRow * percent);
1184
+ } else {
1185
+ // Invalid format, fall back to center
1186
+ row = this.#resolveAnchorRow("center", effectiveHeight, availHeight, marginTop);
1187
+ }
1188
+ } else {
1189
+ // Absolute row position
1190
+ row = opt.row;
1191
+ }
1192
+ } else {
1193
+ // Anchor-based (default: center)
1194
+ const anchor = opt.anchor ?? "center";
1195
+ row = this.#resolveAnchorRow(anchor, effectiveHeight, availHeight, marginTop);
1196
+ }
1197
+
1198
+ if (opt.col !== undefined) {
1199
+ if (typeof opt.col === "string") {
1200
+ // Percentage: 0% = left, 100% = right (overlay stays within bounds)
1201
+ const match = opt.col.match(/^(\d+(?:\.\d+)?)%$/);
1202
+ if (match) {
1203
+ const maxCol = Math.max(0, availWidth - width);
1204
+ const percent = parseFloat(match[1]) / 100;
1205
+ col = marginLeft + Math.floor(maxCol * percent);
1206
+ } else {
1207
+ // Invalid format, fall back to center
1208
+ col = this.#resolveAnchorCol("center", width, availWidth, marginLeft);
1209
+ }
1210
+ } else {
1211
+ // Absolute column position
1212
+ col = opt.col;
1213
+ }
1214
+ } else {
1215
+ // Anchor-based (default: center)
1216
+ const anchor = opt.anchor ?? "center";
1217
+ col = this.#resolveAnchorCol(anchor, width, availWidth, marginLeft);
1218
+ }
1219
+
1220
+ // Apply offsets
1221
+ if (opt.offsetY !== undefined) row += opt.offsetY;
1222
+ if (opt.offsetX !== undefined) col += opt.offsetX;
1223
+
1224
+ // Clamp to terminal bounds (respecting margins)
1225
+ row = Math.max(marginTop, Math.min(row, termHeight - marginBottom - effectiveHeight));
1226
+ col = Math.max(marginLeft, Math.min(col, termWidth - marginRight - width));
1227
+
1228
+ return { width, row, col, maxHeight };
1229
+ }
1230
+
1231
+ #resolveAnchorRow(anchor: OverlayAnchor, height: number, availHeight: number, marginTop: number): number {
1232
+ switch (anchor) {
1233
+ case "top-left":
1234
+ case "top-center":
1235
+ case "top-right":
1236
+ return marginTop;
1237
+ case "bottom-left":
1238
+ case "bottom-center":
1239
+ case "bottom-right":
1240
+ return marginTop + availHeight - height;
1241
+ case "left-center":
1242
+ case "center":
1243
+ case "right-center":
1244
+ return marginTop + Math.floor((availHeight - height) / 2);
1245
+ }
1246
+ }
1247
+
1248
+ #resolveAnchorCol(anchor: OverlayAnchor, width: number, availWidth: number, marginLeft: number): number {
1249
+ switch (anchor) {
1250
+ case "top-left":
1251
+ case "left-center":
1252
+ case "bottom-left":
1253
+ return marginLeft;
1254
+ case "top-right":
1255
+ case "right-center":
1256
+ case "bottom-right":
1257
+ return marginLeft + availWidth - width;
1258
+ case "top-center":
1259
+ case "center":
1260
+ case "bottom-center":
1261
+ return marginLeft + Math.floor((availWidth - width) / 2);
1262
+ }
1263
+ }
1264
+
1265
+ /** Composite all overlays into content lines (in stack order, later = on top). */
1266
+ #compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] {
1267
+ if (this.overlayStack.length === 0) return lines;
1268
+ const result = [...lines];
1269
+
1270
+ // Pre-render all visible overlays and calculate positions
1271
+ const rendered: { overlayLines: string[]; row: number; col: number; w: number }[] = [];
1272
+ let minLinesNeeded = result.length;
1273
+
1274
+ for (const entry of this.overlayStack) {
1275
+ // Skip invisible overlays (hidden or visible() returns false)
1276
+ if (!this.#isOverlayVisible(entry)) continue;
1277
+
1278
+ const { component, options } = entry;
1279
+
1280
+ // Get layout with height=0 first to determine width and maxHeight
1281
+ // (width and maxHeight don't depend on overlay height)
1282
+ const { width, maxHeight } = this.#resolveOverlayLayout(options, 0, termWidth, termHeight);
1283
+
1284
+ // Render component at calculated width
1285
+ let overlayLines = component.render(width);
1286
+
1287
+ // Apply maxHeight if specified
1288
+ if (maxHeight !== undefined && overlayLines.length > maxHeight) {
1289
+ overlayLines = overlayLines.slice(0, maxHeight);
1290
+ }
1291
+
1292
+ // Get final row/col with actual overlay height
1293
+ const { row, col } = this.#resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight);
1294
+
1295
+ rendered.push({ overlayLines, row, col, w: width });
1296
+ minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);
1297
+ }
1298
+
1299
+ // Ensure result is tall enough for overlay placement.
1300
+ // NOTE: Do not pad to maxLinesRendered.
1301
+ // maxLinesRendered tracks the terminal "working area" (max lines ever rendered) and can be much larger
1302
+ // than the current content. Padding to it can cause the renderer to output hundreds/thousands of blank
1303
+ // lines, effectively scrolling the terminal when an overlay is shown.
1304
+ const workingHeight = Math.max(result.length, minLinesNeeded);
1305
+
1306
+ // Extend result with empty lines if content is too short for overlay placement
1307
+ while (result.length < workingHeight) {
1308
+ result.push("");
1309
+ }
1310
+
1311
+ const viewportStart = Math.max(0, workingHeight - termHeight);
1312
+
1313
+ // Track which lines were modified for final verification
1314
+ const modifiedLines = new Set<number>();
1315
+
1316
+ // Composite each overlay
1317
+ for (const { overlayLines, row, col, w } of rendered) {
1318
+ for (let i = 0; i < overlayLines.length; i++) {
1319
+ const idx = viewportStart + row + i;
1320
+ if (idx >= 0 && idx < result.length) {
1321
+ // Defensive: truncate overlay line to declared width before compositing
1322
+ // (components should already respect width, but this ensures it)
1323
+ const truncatedOverlayLine =
1324
+ visibleWidth(overlayLines[i]) > w ? sliceByColumn(overlayLines[i], 0, w, true) : overlayLines[i];
1325
+ result[idx] = this.#compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth);
1326
+ modifiedLines.add(idx);
1327
+ }
1328
+ }
1329
+ }
1330
+
1331
+ // Final verification: ensure no composited line exceeds terminal width
1332
+ // This is a belt-and-suspenders safeguard - compositeLineAt should already
1333
+ // guarantee this, but we verify here to prevent crashes from any edge cases
1334
+ // Only check lines that were actually modified (optimization)
1335
+ for (const idx of modifiedLines) {
1336
+ const lineWidth = visibleWidth(result[idx]);
1337
+ if (lineWidth > termWidth) {
1338
+ result[idx] = sliceByColumn(result[idx], 0, termWidth, true);
1339
+ }
1340
+ }
1341
+
1342
+ return result;
1343
+ }
1344
+
1345
+ /** Splice overlay content into a base line at a specific column. Single-pass optimized. */
1346
+ #compositeLineAt(
1347
+ baseLine: string,
1348
+ overlayLine: string,
1349
+ startCol: number,
1350
+ overlayWidth: number,
1351
+ totalWidth: number,
1352
+ ): string {
1353
+ if (TERMINAL.isImageLine(baseLine)) return baseLine;
1354
+
1355
+ // Single pass through baseLine extracts both before and after segments
1356
+ const afterStart = startCol + overlayWidth;
1357
+ const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
1358
+
1359
+ // Extract overlay with width tracking (strict=true to exclude wide chars at boundary)
1360
+ const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
1361
+
1362
+ // Pad segments to target widths
1363
+ const beforePad = Math.max(0, startCol - base.beforeWidth);
1364
+ const overlayPad = Math.max(0, overlayWidth - overlay.width);
1365
+ const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
1366
+ const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
1367
+ const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
1368
+ const afterPad = Math.max(0, afterTarget - base.afterWidth);
1369
+
1370
+ // Compose result
1371
+ const r = SEGMENT_RESET;
1372
+ const result =
1373
+ base.before +
1374
+ " ".repeat(beforePad) +
1375
+ r +
1376
+ overlay.text +
1377
+ " ".repeat(overlayPad) +
1378
+ r +
1379
+ base.after +
1380
+ " ".repeat(afterPad);
1381
+
1382
+ // CRITICAL: Always verify and truncate to terminal width.
1383
+ // This is the final safeguard against width overflow which would crash the TUI.
1384
+ // Width tracking can drift from actual visible width due to:
1385
+ // - Complex ANSI/OSC sequences (hyperlinks, colors)
1386
+ // - Wide characters at segment boundaries
1387
+ // - Edge cases in segment extraction
1388
+ const resultWidth = visibleWidth(result);
1389
+ if (resultWidth <= totalWidth) {
1390
+ return result;
1391
+ }
1392
+ // Truncate with strict=true to ensure we don't exceed totalWidth
1393
+ return sliceByColumn(result, 0, totalWidth, true);
1394
+ }
1395
+
1396
+ /**
1397
+ * Find and extract cursor position from rendered lines.
1398
+ * Searches for CURSOR_MARKER, calculates its position, and strips it from the output.
1399
+ * Only scans the bottom terminal height lines (visible viewport).
1400
+ * @param lines - Rendered lines to search
1401
+ * @param height - Terminal height (visible viewport size)
1402
+ * @returns Cursor position { row, col } or null if no marker found
1403
+ */
1404
+ #extractCursorPosition(lines: string[], height: number): { row: number; col: number } | null {
1405
+ // Cursor markers are internal sentinels and must never reach the terminal,
1406
+ // even when the focused component is above the visible viewport. Only a
1407
+ // visible marker becomes a hardware cursor target.
1408
+ const viewportTop = Math.max(0, lines.length - height);
1409
+ let cursor: { row: number; col: number } | null = null;
1410
+ for (let row = lines.length - 1; row >= 0; row--) {
1411
+ const line = lines[row];
1412
+ let markerIndex = line.indexOf(CURSOR_MARKER);
1413
+ if (markerIndex === -1) continue;
1414
+ if (cursor === null && row >= viewportTop) {
1415
+ const beforeMarker = line.slice(0, markerIndex);
1416
+ cursor = { row, col: visibleWidth(beforeMarker) };
1417
+ }
1418
+ let stripped = line;
1419
+ while (markerIndex !== -1) {
1420
+ stripped = stripped.slice(0, markerIndex) + stripped.slice(markerIndex + CURSOR_MARKER.length);
1421
+ markerIndex = stripped.indexOf(CURSOR_MARKER, markerIndex);
1422
+ }
1423
+ lines[row] = stripped;
1424
+ }
1425
+ return cursor;
1426
+ }
1427
+
1428
+ /**
1429
+ * Append the per-line terminator ({@link LINE_TERMINATOR}) to every
1430
+ * non-image line and normalize for terminal rendering. Mutates the input
1431
+ * array in place so downstream diffing/storage sees exactly the bytes
1432
+ * written to the terminal — without this, the diff cache disagrees with
1433
+ * emitted output and OSC 8 hyperlink state can leak across lines.
1434
+ */
1435
+ #applyLineResets(lines: string[]): string[] {
1436
+ for (let i = 0; i < lines.length; i++) {
1437
+ const line = lines[i];
1438
+ if (TERMINAL.isImageLine(line)) continue;
1439
+ const normalized = normalizeTerminalOutput(line);
1440
+ // Only close OSC 8 hyperlinks when the line actually opened one;
1441
+ // emitting `\x1b]8;;\x07` on every line just feeds the terminal's OSC
1442
+ // parser for no reason (measurable cost in xterm.js parse loop).
1443
+ lines[i] = normalized + (normalized.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
1444
+ }
1445
+ return lines;
1446
+ }
1447
+
1448
+ /**
1449
+ * Render one frame. Composes the frame, classifies the intent, and delegates
1450
+ * to the matching emitter. Each emitter owns its bytes and ends with
1451
+ * {@link #commit}, the single state-transition point.
1452
+ */
1453
+ #doRender(): void {
1454
+ if (this.#stopped) return;
1455
+ const width = this.terminal.columns;
1456
+ const height = this.terminal.rows;
1457
+
1458
+ // 1. Compose the frame. Bracket the transcript render so the image budget
1459
+ // observes every inline image in display order (overlays carry none).
1460
+ this.#imageBudget.beginPass();
1461
+ let baseLines = this.render(width);
1462
+ if (this.#imageBudget.endPass()) {
1463
+ // A new image pushed the live-graphics count past the cap: force a full
1464
+ // redraw (so off-screen rows repaint as text) and purge the demoted
1465
+ // images' graphics in #emitFullPaint.
1466
+ this.#clearScrollbackOnNextRender = true;
1467
+ }
1468
+ const visibleOverlayComponents: Component[] = [];
1469
+ if (this.overlayStack.length > 0 || this.#previousVisibleOverlayComponents.length > 0) {
1470
+ for (const entry of this.overlayStack) {
1471
+ if (this.#isOverlayVisible(entry)) visibleOverlayComponents.push(entry.component);
1472
+ }
1473
+ }
1474
+ this.#visibleOverlayComponentsThisRender = visibleOverlayComponents;
1475
+ const overlayVisibilityReduced = this.#overlayVisibilityReduced(visibleOverlayComponents);
1476
+ let lines = visibleOverlayComponents.length > 0 ? this.#compositeOverlays(baseLines, width, height) : baseLines;
1477
+ const cursorPos = this.#extractCursorPosition(lines, height);
1478
+ lines = this.#fitLinesToWidth(this.#applyLineResets(lines), width);
1479
+ if (lines !== baseLines) {
1480
+ this.#extractCursorPosition(baseLines, height);
1481
+ baseLines = this.#fitLinesToWidth(this.#applyLineResets(baseLines), width);
1482
+ }
1483
+
1484
+ // 2. Capture transition + pre-render state before any emitter runs.
1485
+ const prevViewportTop = this.#viewportTopRow;
1486
+ const prevHardwareCursorRow = this.#hardwareCursorRow;
1487
+ const resizeEventOccurred = this.#resizeEventPending;
1488
+ this.#resizeEventPending = false;
1489
+ const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
1490
+ // A resize event with net-unchanged dimensions still reflowed the terminal
1491
+ // buffer; classify it as a height change so the geometry branches repaint
1492
+ // or rebuild instead of diffing against a screen that no longer exists.
1493
+ const heightChanged =
1494
+ (this.#previousHeight > 0 && this.#previousHeight !== height) ||
1495
+ (resizeEventOccurred && this.#previousHeight > 0);
1496
+ const eagerEraseScrollbackRisk = this.#hasEagerEraseScrollbackRisk();
1497
+ const eagerRebuildAllowed = this.#eagerNativeScrollbackRebuild && !eagerEraseScrollbackRisk;
1498
+ const explicitViewportMutation = this.#allowUnknownViewportMutationOnNextRender;
1499
+ const allowUnknownViewportMutation = explicitViewportMutation || eagerRebuildAllowed;
1500
+ this.#allowUnknownViewportMutationOnNextRender = false;
1501
+
1502
+ // 3. Classify intent.
1503
+ let intent = this.#planRender(
1504
+ lines,
1505
+ widthChanged,
1506
+ heightChanged,
1507
+ prevViewportTop,
1508
+ height,
1509
+ visibleOverlayComponents.length > 0,
1510
+ overlayVisibilityReduced,
1511
+ allowUnknownViewportMutation,
1512
+ this.#nativeScrollbackLiveRegionStart,
1513
+ this.#nativeScrollbackCommitSafeEnd,
1514
+ );
1515
+ // 3b. Defer scrollback commits during foreground streaming, but only on
1516
+ // ED3-risk terminals whose committed scrollback cannot be rewritten without
1517
+ // yanking a scrolled reader. There the eager rebuild is gated off and the
1518
+ // diff emitter would otherwise `\r\n`-scroll every transient frame (spinner
1519
+ // ticks, partial output) into native history. Non-ED3-risk terminals keep
1520
+ // their eager live rebuild, which already commits cleanly. Explicit
1521
+ // reconciles — the prompt-submit checkpoint (`clearScrollbackOnNextRender`),
1522
+ // user-input/IME opt-ins (`explicitViewportMutation`), and overlay visibility
1523
+ // reductions that must scrub transient overlay cells from native history —
1524
+ // are never deferred: the triggering interaction pins the host to the bottom.
1525
+ const streamingWasActive = this.#eagerNativeScrollbackRebuild;
1526
+ if (streamingWasActive && !this.#previousStreamingActive) {
1527
+ this.#streamingHighWater = 0;
1528
+ }
1529
+ this.#previousStreamingActive = streamingWasActive;
1530
+ if (streamingWasActive && eagerEraseScrollbackRisk) {
1531
+ const streamingActive =
1532
+ this.#eagerNativeScrollbackRebuild && !this.#eagerNativeScrollbackRebuildDisablePending;
1533
+ // A terminal resize reflowed native scrollback at the OLD geometry, so the
1534
+ // saved rows are already mis-wrapped garbage. The planned historyRebuild
1535
+ // must stand and erase them (ED 3) — capping to a viewport repaint would
1536
+ // leave the corrupt history on screen. Like the other reconciles, a resize
1537
+ // is an explicit user action that snaps the host to the bottom, so there is
1538
+ // no scrolled reader to yank.
1539
+ const geometryChanged = widthChanged || heightChanged;
1540
+ const explicitReconcile =
1541
+ explicitViewportMutation ||
1542
+ this.#clearScrollbackOnNextRender ||
1543
+ overlayVisibilityReduced ||
1544
+ geometryChanged;
1545
+ // The defer below exists only to avoid `\r\n`-scrolling transient frames
1546
+ // past a reader parked in native scrollback. When the terminal can report
1547
+ // that the viewport is at the tail, there is no scrolled reader to yank,
1548
+ // so the planned intent must stand and commit normally — otherwise a row
1549
+ // that scrolls above the viewport top is dropped (neither pushed to
1550
+ // history nor kept in the capped viewport). Production POSIX ED3-risk
1551
+ // terminals cannot report this and stay `undefined`, so they still defer.
1552
+ const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
1553
+ if (!streamingActive) {
1554
+ // Streaming just ended. Keep native scrollback dirty so the next
1555
+ // checkpoint reconciles the settled transcript; never erase here.
1556
+ this.#streamingHighWater = 0;
1557
+ this.#markNativeScrollbackDirty();
1558
+ } else if (
1559
+ !explicitReconcile &&
1560
+ nativeViewportAtBottom !== true &&
1561
+ !isMultiplexerSession() &&
1562
+ (intent.kind === "sessionReplace" ||
1563
+ intent.kind === "historyRebuild" ||
1564
+ intent.kind === "overlayRebuild" ||
1565
+ (intent.kind === "diff" && intent.appendedLines))
1566
+ ) {
1567
+ // Cap the frame to the viewport and keep scrollback dirty: transient
1568
+ // rows never enter history, and the checkpoint reconciles later.
1569
+ // Multiplexers (tmux/screen/zellij) are excluded: their checkpoint
1570
+ // reconcile is a no-op (pane history cannot be erased), so any rows
1571
+ // dropped here are dropped forever. Pane history is append-only
1572
+ // anyway, so a normal diff/append `\r\n` commit is exactly what the
1573
+ // multiplexer needs — and the `liveRegionPinned` planner above
1574
+ // keeps the actively-mutating live tail out of pane history while
1575
+ // committing only the sealed prefix (issue #1974).
1576
+ this.#markNativeScrollbackDirty();
1577
+ this.#streamingHighWater = Math.max(this.#streamingHighWater, lines.length);
1578
+ this.#scrollbackHighWater = 0;
1579
+ lines = lines.slice(-height);
1580
+ intent = { kind: "viewportRepaint" };
1581
+ } else {
1582
+ // Explicit reconcile or a non-committing frame (noop): let the
1583
+ // planned intent stand, but keep tracking the streaming peak.
1584
+ this.#streamingHighWater = Math.max(this.#streamingHighWater, lines.length);
1585
+ }
1586
+ }
1587
+ if (this.#eagerNativeScrollbackRebuildDisablePending) {
1588
+ this.#eagerNativeScrollbackRebuildDisablePending = false;
1589
+ this.#eagerNativeScrollbackRebuild = false;
1590
+ }
1591
+ this.#logRedraw(intent, lines.length, height);
1592
+ // Load any newly-displayed image data into the terminal once, before this
1593
+ // frame's placements (and any emitter) reference it. Data persists across
1594
+ // paints, so subsequent frames re-emit only the tiny placement sequence.
1595
+ // `a=t` produces no display, so writing it ahead of the synchronized paint
1596
+ // is artifact-free.
1597
+ const imageTransmits = this.#imageBudget.takeTransmits();
1598
+ if (imageTransmits.length > 0) {
1599
+ let transmitBuffer = "";
1600
+ for (const seq of imageTransmits) transmitBuffer += seq;
1601
+ this.terminal.write(transmitBuffer);
1602
+ }
1603
+ // 4. Execute.
1604
+ switch (intent.kind) {
1605
+ case "noop":
1606
+ this.#writeCursorPosition(cursorPos, lines.length);
1607
+ this.#viewportTopRow = Math.max(0, this.#maxLinesRendered - height);
1608
+ this.#previousWidth = width;
1609
+ this.#previousHeight = height;
1610
+ return;
1611
+ case "initial": {
1612
+ const liveRegionStart = this.#nativeScrollbackLiveRegionStart;
1613
+ if (
1614
+ this.#eagerNativeScrollbackRebuild &&
1615
+ eagerEraseScrollbackRisk &&
1616
+ !allowUnknownViewportMutation &&
1617
+ liveRegionStart !== undefined &&
1618
+ liveRegionStart < lines.length &&
1619
+ !isMultiplexerSession() &&
1620
+ this.#readNativeViewportAtBottom() === undefined
1621
+ ) {
1622
+ this.#emitInitialLiveRegionPinnedPaint(
1623
+ lines,
1624
+ width,
1625
+ height,
1626
+ cursorPos,
1627
+ liveRegionStart,
1628
+ this.#nativeScrollbackCommitSafeEnd,
1629
+ );
1630
+ } else {
1631
+ this.#emitFullPaint(lines, width, height, cursorPos, { clearViewport: true, clearScrollback: false });
1632
+ }
1633
+ this.#hasEverRendered = true;
1634
+ return;
1635
+ }
1636
+ case "sessionReplace":
1637
+ this.#clearScrollbackOnNextRender = false;
1638
+ this.#clearNativeScrollbackDirty();
1639
+ this.#emitFullPaint(lines, width, height, cursorPos, {
1640
+ clearViewport: true,
1641
+ clearScrollback: !isMultiplexerSession(),
1642
+ });
1643
+ this.#hasEverRendered = true;
1644
+ return;
1645
+ case "historyRebuild":
1646
+ this.#clearNativeScrollbackDirty();
1647
+ this.#emitFullPaint(lines, width, height, cursorPos, {
1648
+ clearViewport: true,
1649
+ clearScrollback: !isMultiplexerSession(),
1650
+ });
1651
+ return;
1652
+ case "overlayRebuild":
1653
+ this.#clearNativeScrollbackDirty();
1654
+ this.#emitFullPaint(baseLines, width, height, null, {
1655
+ clearViewport: true,
1656
+ clearScrollback: !isMultiplexerSession(),
1657
+ });
1658
+ this.#emitViewportRepaint(lines, width, height, cursorPos);
1659
+ return;
1660
+ case "liveRegionPinned":
1661
+ this.#emitLiveRegionPinnedRepaint(
1662
+ lines,
1663
+ width,
1664
+ height,
1665
+ cursorPos,
1666
+ intent.appendFrom,
1667
+ intent.appendTo,
1668
+ intent.renderViewportTop,
1669
+ prevViewportTop,
1670
+ prevHardwareCursorRow,
1671
+ );
1672
+ return;
1673
+ case "viewportRepaint":
1674
+ if (intent.appendFrom !== undefined) {
1675
+ this.#emitAppendTail(lines, intent.appendFrom, height, width, prevViewportTop, prevHardwareCursorRow);
1676
+ }
1677
+ this.#emitViewportRepaint(lines, width, height, cursorPos);
1678
+ return;
1679
+ case "deferredTailRepaint":
1680
+ this.#emitDeferredTailRepaint(
1681
+ intent.line,
1682
+ width,
1683
+ height,
1684
+ intent.row,
1685
+ prevViewportTop,
1686
+ prevHardwareCursorRow,
1687
+ );
1688
+ return;
1689
+ case "deferredMutation":
1690
+ return;
1691
+ case "deferredShrink":
1692
+ this.#emitViewportRepaint(
1693
+ this.#padDeferredShrinkLines(lines, intent.paddedLength),
1694
+ width,
1695
+ height,
1696
+ cursorPos,
1697
+ );
1698
+ return;
1699
+ case "shrink":
1700
+ this.#emitShrink(lines, width, height, cursorPos, prevHardwareCursorRow, prevViewportTop);
1701
+ return;
1702
+ case "diff":
1703
+ this.#emitDiff(
1704
+ lines,
1705
+ width,
1706
+ height,
1707
+ cursorPos,
1708
+ intent.firstChanged,
1709
+ intent.lastChanged,
1710
+ intent.appendedLines,
1711
+ prevViewportTop,
1712
+ prevHardwareCursorRow,
1713
+ );
1714
+ return;
1715
+ }
1716
+ }
1717
+
1718
+ /**
1719
+ * Map the current frame onto a single render intent. Order matters: forced
1720
+ * resets and session replacement short-circuit first, then a terminal resize
1721
+ * (width or height change) always reduces to a clean reset + redraw at the new
1722
+ * geometry — `historyRebuild` normally, `viewportRepaint` inside a multiplexer
1723
+ * whose pane scrollback cannot be erased. Pure content mutations fall through
1724
+ * to the differential machinery below.
1725
+ */
1726
+ #planRender(
1727
+ newLines: string[],
1728
+ widthChanged: boolean,
1729
+ heightChanged: boolean,
1730
+ prevViewportTop: number,
1731
+ height: number,
1732
+ hasVisibleOverlay: boolean,
1733
+ overlayVisibilityReduced: boolean,
1734
+ allowUnknownViewportMutation: boolean,
1735
+ liveRegionStart: number | undefined,
1736
+ commitSafeEnd: number | undefined,
1737
+ ): RenderIntent {
1738
+ // A forced scrollback wipe can be queued before start()'s initial paint runs
1739
+ // (cold `prometheus --resume` does this while replacing the welcome frame with the
1740
+ // restored transcript). Honor it before the normal initial-preserve path so
1741
+ // the first committed frame is the clean session replay, not a deferred wipe
1742
+ // that waits for the user's first keystroke.
1743
+ if (this.#clearScrollbackOnNextRender) return { kind: "sessionReplace" };
1744
+
1745
+ // Initial paint after start(): scrollback must keep its prior shell
1746
+ // content, but the viewport must be cleared so stale rows do not bleed
1747
+ // into the new UI.
1748
+ if (!this.#hasEverRendered) return { kind: "initial" };
1749
+
1750
+ const forceViewportRepaint = this.#forceViewportRepaintOnNextRender;
1751
+ const eagerEraseScrollbackRisk = this.#hasEagerEraseScrollbackRisk();
1752
+ if (overlayVisibilityReduced && !isMultiplexerSession()) {
1753
+ return hasVisibleOverlay ? { kind: "overlayRebuild" } : { kind: "historyRebuild" };
1754
+ }
1755
+
1756
+ // A terminal resize (width or height change) reflows the terminal's own
1757
+ // buffer, moving rows between the viewport and native scrollback and
1758
+ // invalidating every cursor/viewport anchor the diff and append emitters
1759
+ // rely on. Always reset cleanly at the new geometry and redraw. Inside a
1760
+ // multiplexer the pane's saved lines cannot be erased (ED 3 is a no-op there
1761
+ // and a full replay only duplicates the transcript), so repaint the visible
1762
+ // window in place; a visible overlay rebuilds with its composite. This
1763
+ // deliberately drops the no-overflow and confirmed-scrolled guards — a
1764
+ // resize is an explicit user action, so a scrolled reader snaps to the
1765
+ // bottom and preexisting shell scrollback above the UI is cleared. The
1766
+ // streaming cap above explicitly exempts geometry changes, so even during
1767
+ // active ED3-risk foreground streaming this rebuild stands and erases the
1768
+ // scrollback the terminal just re-wrapped at the old size.
1769
+ if (widthChanged || heightChanged) {
1770
+ if (isMultiplexerSession()) return { kind: "viewportRepaint" };
1771
+ return hasVisibleOverlay ? { kind: "overlayRebuild" } : { kind: "historyRebuild" };
1772
+ }
1773
+
1774
+ // Same dirty-scrollback opt-in policy as the non-overlay branch below: an
1775
+ // ED3-risk macOS/POSIX terminal with an unobservable viewport ignores
1776
+ // focused-input unknown opt-ins, so overlay selector Up/Down moves do not
1777
+ // become ED3 clears plus full transcript replays. Non-ED3-risk POSIX still
1778
+ // honors direct-input/IME/autocomplete opt-ins.
1779
+ if (hasVisibleOverlay) {
1780
+ const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
1781
+ // Multiplexer panes never get a destructive scrollback clear
1782
+ // (clearScrollback is forced off inside them), so a dirty-scrollback
1783
+ // "rebuild" would only append a full duplicate copy of the transcript
1784
+ // to pane history on every dirty frame. Keep repainting the viewport
1785
+ // and leave reconciliation to explicit checkpoints.
1786
+ const allowDirtyUnknownViewportMutation = allowUnknownViewportMutation && !eagerEraseScrollbackRisk;
1787
+ if (
1788
+ this.#nativeScrollbackDirty &&
1789
+ !isMultiplexerSession() &&
1790
+ this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, allowDirtyUnknownViewportMutation)
1791
+ ) {
1792
+ return { kind: "overlayRebuild" };
1793
+ }
1794
+ this.#markNativeScrollbackDirty();
1795
+ return { kind: "viewportRepaint" };
1796
+ }
1797
+
1798
+ const liveRegionPinnedIntent = this.#planLiveRegionPinnedRender(
1799
+ newLines,
1800
+ height,
1801
+ liveRegionStart,
1802
+ commitSafeEnd,
1803
+ eagerEraseScrollbackRisk,
1804
+ allowUnknownViewportMutation,
1805
+ );
1806
+ if (liveRegionPinnedIntent) return liveRegionPinnedIntent;
1807
+
1808
+ // After foreground tool streaming: when content finally shrinks from the
1809
+ // streaming peak, rebuild with ED 3 to commit the settled state cleanly.
1810
+ // The check uses `#streamingHighWater` (the real peak) rather than
1811
+ // `#previousLines.length` because unpinned ED3-risk streaming frames may
1812
+ // commit only a viewport slice while native history is deferred.
1813
+ if (this.#streamingHighWater > height && newLines.length < this.#streamingHighWater && newLines.length > height) {
1814
+ this.#streamingHighWater = 0;
1815
+ return { kind: "historyRebuild" };
1816
+ }
1817
+ if (this.#streamingHighWater > 0 && newLines.length <= height) {
1818
+ this.#streamingHighWater = 0;
1819
+ }
1820
+
1821
+ if (this.#nativeScrollbackDirty && !isMultiplexerSession()) {
1822
+ // A dirty flag means older native history is stale; it is not required to
1823
+ // make the current focused-input frame correct. On ED3-risk macOS/POSIX
1824
+ // terminals with an unobservable viewport, ignore focused-input unknown
1825
+ // opt-ins so Up/Down selector moves do not become ED3 clears plus full
1826
+ // transcript replays. Non-ED3-risk POSIX terminals keep their safe
1827
+ // direct-input/IME/autocomplete opt-in.
1828
+ const allowDirtyUnknownViewportMutation = allowUnknownViewportMutation && !eagerEraseScrollbackRisk;
1829
+ if (
1830
+ this.#canRebuildNativeScrollbackLive(this.#readNativeViewportAtBottom(), allowDirtyUnknownViewportMutation)
1831
+ ) {
1832
+ return { kind: "historyRebuild" };
1833
+ }
1834
+ }
1835
+
1836
+ const diff = this.#diffLines(newLines);
1837
+ // Shrink across the viewport boundary: the new transcript would re-expose
1838
+ // rows already committed to native scrollback. Rebuild immediately when the
1839
+ // viewport is known/allowed to be at the tail; otherwise defer the rewrite
1840
+ // and repaint against the previous row count so users scrolled into history
1841
+ // are not yanked. A viewport-only repaint for a bottom-anchored shrink leaves
1842
+ // stale high-water rows in native scrollback and duplicates the new tail above
1843
+ // the viewport.
1844
+ const naturalViewportTop = Math.max(0, newLines.length - height);
1845
+ if (
1846
+ diff.firstChanged !== -1 &&
1847
+ newLines.length < this.#previousLines.length &&
1848
+ naturalViewportTop < this.#scrollbackHighWater &&
1849
+ !isMultiplexerSession()
1850
+ ) {
1851
+ const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
1852
+ if (this.#nativeViewportIsScrolled(nativeViewportAtBottom, allowUnknownViewportMutation)) {
1853
+ this.#markNativeScrollbackDirty();
1854
+ return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
1855
+ }
1856
+ // A shrink that re-exposes rows already committed to native scrollback
1857
+ // must rebuild so the stale committed copy is cleared. Rebuild only with a
1858
+ // positive at-tail proof; unknown viewports stay dirty because the host
1859
+ // scroll position is not observable and ED3 can yank readers.
1860
+ if (this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, false)) {
1861
+ return { kind: "historyRebuild" };
1862
+ }
1863
+ // POSIX terminals — and Windows Terminal/ConPTY — that cannot report the
1864
+ // viewport position fall through here (`canRebuildNativeScrollbackLive` is
1865
+ // false). A destructive rebuild emits `\x1b[3J` (xterm erase saved lines),
1866
+ // which can clear or reposition native scrollback and yank a scrolled-up
1867
+ // reader (issue #1635), so it is unsafe while the probe is unavailable.
1868
+ //
1869
+ const paddedViewportTop = Math.max(0, this.#previousLines.length - height);
1870
+ // ED3-risk terminals with an unobservable viewport cannot safely clear
1871
+ // saved lines. Direct user-input frames (autocomplete/IME) may still
1872
+ // repaint the live viewport: the user action pins the host to the tail, and
1873
+ // emitting zero bytes leaves stale autocomplete rows on screen until a later
1874
+ // checkpoint. When the changed rows are at or below the previous viewport
1875
+ // top, keep the old bottom anchor by padding the frame to its previous
1876
+ // length; that clears stale popup rows without re-exposing rows already
1877
+ // committed to native history. If an offscreen edit shifted rows above the
1878
+ // viewport, padding would repaint the wrong seam, so use a viewport repaint
1879
+ // for liveness and keep history dirty. Active eager streaming also uses a
1880
+ // viewport repaint so the live tail keeps moving. With neither direct input
1881
+ // nor active eager streaming, the reader may be scrolled, so defer
1882
+ // completely rather than repainting over their history.
1883
+ if (nativeViewportAtBottom === undefined && eagerEraseScrollbackRisk) {
1884
+ this.#markNativeScrollbackDirty();
1885
+ if (allowUnknownViewportMutation) {
1886
+ return diff.firstChanged < prevViewportTop
1887
+ ? { kind: "viewportRepaint" }
1888
+ : { kind: "deferredShrink", paddedLength: this.#previousLines.length };
1889
+ }
1890
+ return this.#eagerNativeScrollbackRebuild
1891
+ ? { kind: "viewportRepaint" }
1892
+ : this.#planDeferredTailRepaint(newLines, prevViewportTop, height);
1893
+ }
1894
+
1895
+ // Non-ED3-risk POSIX with an unobservable viewport. `deferredShrink` is
1896
+ // safe only when changed rows are at or below the previous viewport top.
1897
+ // Middle/offscreen deletes renumber rows above the viewport and padding
1898
+ // the old length would repaint shifted rows or blank tail cells.
1899
+ if (newLines.length <= paddedViewportTop) {
1900
+ return { kind: "historyRebuild" };
1901
+ }
1902
+ this.#markNativeScrollbackDirty();
1903
+ if (diff.firstChanged < prevViewportTop) {
1904
+ return this.#planDeferredTailRepaint(newLines, prevViewportTop, height);
1905
+ }
1906
+ return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
1907
+ }
1908
+
1909
+ // Multiplexer panes do not give us a safe native-history rebuild path, but
1910
+ // a shrink can still move the logical viewport upward (for example hiding an
1911
+ // overlay that extended past the base frame). A row-diff from the old
1912
+ // viewport top would only clear the old suffix and leave the newly exposed
1913
+ // base rows stale/blank, so repaint the live viewport in place.
1914
+ if (
1915
+ isMultiplexerSession() &&
1916
+ diff.firstChanged !== -1 &&
1917
+ newLines.length < this.#previousLines.length &&
1918
+ naturalViewportTop !== prevViewportTop
1919
+ ) {
1920
+ return { kind: "viewportRepaint" };
1921
+ }
1922
+
1923
+ // Direct-input shrink can also move the natural viewport upward even when
1924
+ // no stale high-water scrollback is involved (for example slash autocomplete
1925
+ // filtering from many rows to a few). The diff emitter is anchored to the
1926
+ // previous viewport top and would only clear the old suffix, hiding the
1927
+ // editor above the live window.
1928
+ if (
1929
+ allowUnknownViewportMutation &&
1930
+ diff.firstChanged !== -1 &&
1931
+ newLines.length < this.#previousLines.length &&
1932
+ naturalViewportTop !== prevViewportTop
1933
+ ) {
1934
+ return { kind: "viewportRepaint" };
1935
+ }
1936
+
1937
+ // A shrink that moves the bottom-anchored viewport upward must re-anchor the
1938
+ // visible window. The shrink-across-high-water block above already
1939
+ // rebuilt/deferred when the shrink re-exposes rows committed to native
1940
+ // scrollback (`naturalViewportTop < #scrollbackHighWater`). The remaining
1941
+ // case slips through when the high-water mark lags the logical viewport top:
1942
+ // non-destructive viewport repaints during foreground-tool streaming on
1943
+ // ED3-risk terminals (ghostty/kitty/…) advance `#maxLinesRendered` without
1944
+ // committing the overflow to native history, so a later shrink finds
1945
+ // `naturalViewportTop >= #scrollbackHighWater` yet still needs to move the
1946
+ // window up. The diff emitter below anchors to `#maxLinesRendered - height`
1947
+ // and would only rewrite the suffix — dropping the newly exposed top row and
1948
+ // leaving a blank at the bottom, so the rows below appear to render over the
1949
+ // ones above. Repaint the true bottom-anchored tail and leave stale
1950
+ // scrollback for the next checkpoint.
1951
+ if (
1952
+ !isMultiplexerSession() &&
1953
+ diff.firstChanged !== -1 &&
1954
+ newLines.length < this.#previousLines.length &&
1955
+ naturalViewportTop < prevViewportTop
1956
+ ) {
1957
+ this.#markNativeScrollbackDirty();
1958
+ return { kind: "viewportRepaint" };
1959
+ }
1960
+
1961
+ const suppressSuffixScroll = this.#suppressNextSuffixScroll;
1962
+ this.#suppressNextSuffixScroll = false;
1963
+ if (
1964
+ suppressSuffixScroll &&
1965
+ diff.appendedLines &&
1966
+ diff.firstChanged < this.#previousLines.length &&
1967
+ !isMultiplexerSession()
1968
+ ) {
1969
+ // A checkpoint replay is followed by one frame where transient live chrome
1970
+ // (status/footer rows) may be inserted inside the visible suffix and then
1971
+ // disappear; repaint it in place so it never enters scrollback. If the
1972
+ // insertion grows the overflow boundary, native history would lose rows
1973
+ // while the viewport looks correct, so rebuild instead.
1974
+ const appendedTailStart = this.#findAppendedTailStart(newLines);
1975
+ const overflowBefore = Math.max(0, this.#previousLines.length - height);
1976
+ const overflowAfter = Math.max(0, newLines.length - height);
1977
+ if (
1978
+ appendedTailStart === newLines.length &&
1979
+ diff.firstChanged >= prevViewportTop &&
1980
+ overflowAfter <= overflowBefore
1981
+ ) {
1982
+ return { kind: "viewportRepaint" };
1983
+ }
1984
+ const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
1985
+ if (this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, allowUnknownViewportMutation)) {
1986
+ return { kind: "historyRebuild" };
1987
+ }
1988
+ this.#markNativeScrollbackDirty();
1989
+ return { kind: "viewportRepaint" };
1990
+ }
1991
+
1992
+ if (diff.firstChanged === -1) {
1993
+ // Content unchanged. A forced render still refreshes the visible viewport
1994
+ // but keeps the existing diff basis so later coalesced content mutations
1995
+ // can still update native scrollback correctly.
1996
+ if (forceViewportRepaint) return { kind: "viewportRepaint" };
1997
+ return { kind: "noop" };
1998
+ }
1999
+
2000
+ const contentGrew = newLines.length > this.#previousLines.length;
2001
+ const pureAppend = diff.appendedLines && diff.firstChanged === this.#previousLines.length;
2002
+ const structuralMutation = newLines.length !== this.#previousLines.length || diff.firstChanged < prevViewportTop;
2003
+ if (pureAppend && contentGrew && this.#previousLines.length >= height && !isMultiplexerSession()) {
2004
+ const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
2005
+ if (this.#nativeViewportIsKnownScrolled(nativeViewportAtBottom)) {
2006
+ this.#markNativeScrollbackDirty();
2007
+ return { kind: "deferredMutation" };
2008
+ }
2009
+ if (nativeViewportAtBottom === undefined && allowUnknownViewportMutation) {
2010
+ // Direct input can grow transient live UI (autocomplete/IME/editor
2011
+ // wraps) while the previous frame already touched the viewport bottom.
2012
+ // A diff append would `\r\n`-scroll those transient rows into native
2013
+ // history, and a later popup shrink would duplicate the stable prefix at
2014
+ // the scrollback seam. Repaint the live viewport in place instead; the
2015
+ // dirty checkpoint owns native-history reconciliation.
2016
+ this.#markNativeScrollbackDirty();
2017
+ return { kind: "viewportRepaint" };
2018
+ }
2019
+ if (this.#nativeViewportIsScrolled(nativeViewportAtBottom, allowUnknownViewportMutation)) {
2020
+ this.#markNativeScrollbackDirty();
2021
+ // Unknown viewport (e.g. native Windows Terminal where the probe cannot
2022
+ // see WT host scrollback) is a different case: a no-op there freezes the
2023
+ // editor on the keystroke that grows `lines.length` past the viewport
2024
+ // (the wrap keystroke). Fall through to a non-destructive viewport
2025
+ // repaint instead so the live UI keeps updating without yanking a
2026
+ // possibly-scrolled reader.
2027
+ return { kind: "viewportRepaint" };
2028
+ }
2029
+ }
2030
+ // A structural mutation (offscreen edit or inserted rows) while bottom-
2031
+ // anchored: when the reader is scrolled, repaint/clamp without trusting the
2032
+ // stale viewport anchors; otherwise rebuild native history when a safe
2033
+ // checkpoint allows.
2034
+ if (!pureAppend && structuralMutation && !isMultiplexerSession()) {
2035
+ const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
2036
+ if (this.#nativeViewportIsScrolled(nativeViewportAtBottom, allowUnknownViewportMutation)) {
2037
+ this.#markNativeScrollbackDirty();
2038
+ // See the matching comment on the pure-append branch above: confirmed
2039
+ // scrolled stays a no-op; unknown viewport repaints the visible window
2040
+ // so slash-command transitions and offscreen chrome edits paint on the
2041
+ // same frame instead of stalling until the next prompt submit.
2042
+ if (this.#nativeViewportIsKnownScrolled(nativeViewportAtBottom)) {
2043
+ return { kind: "deferredMutation" };
2044
+ }
2045
+ return { kind: "viewportRepaint" };
2046
+ }
2047
+ // The append-tail path can only scroll a clean pure-tail append over an
2048
+ // offscreen edit into history: the rows it pushes must equal the net
2049
+ // growth, i.e. `#findAppendedTailStart` must land on `previousLines.length`
2050
+ // (`tailAppendCount === addedCount`). Any mismatch is structurally
2051
+ // ambiguous — more added than the matched tail means offscreen rows were
2052
+ // inserted (a collapsed cell expanding); fewer means the previous last
2053
+ // line repeats earlier so the tail is mis-located. Under-counting splices
2054
+ // stale history; over-counting scrolls an extra row and duplicates the
2055
+ // line at the viewport top. Rebuild whenever the replay checkpoint allows.
2056
+ if (
2057
+ contentGrew &&
2058
+ diff.firstChanged < prevViewportTop &&
2059
+ this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, false)
2060
+ ) {
2061
+ const appendedTailStart = diff.appendedLines ? this.#findAppendedTailStart(newLines) : newLines.length;
2062
+ const tailAppendCount = newLines.length - appendedTailStart;
2063
+ const addedCount = newLines.length - this.#previousLines.length;
2064
+ if (addedCount !== tailAppendCount) {
2065
+ return { kind: "historyRebuild" };
2066
+ }
2067
+ }
2068
+ if (
2069
+ newLines.length !== this.#previousLines.length &&
2070
+ this.#scrollbackHighWater > 0 &&
2071
+ this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, allowUnknownViewportMutation)
2072
+ ) {
2073
+ return { kind: "historyRebuild" };
2074
+ }
2075
+ }
2076
+
2077
+ // Configurable shrink-clear: opt-in path that repaints to wipe rows the
2078
+ // diff path would leave behind.
2079
+ if (this.#clearOnShrink && newLines.length < this.#previousLines.length && this.overlayStack.length === 0) {
2080
+ return { kind: "viewportRepaint" };
2081
+ }
2082
+
2083
+ // Pure trailing shrink: all changed indices live past the new tail.
2084
+ if (diff.firstChanged >= newLines.length) {
2085
+ return { kind: "shrink" };
2086
+ }
2087
+
2088
+ // Offscreen edit: repainting only the viewport leaves native history stale
2089
+ // while the user is bottom-anchored. Rebuild whenever replay is safe. If
2090
+ // replay is not safe, keep the viewport stable, mark history dirty, and only
2091
+ // scroll a clean appended tail so newly streamed rows remain reachable until
2092
+ // the next checkpoint rebuild.
2093
+ if (diff.firstChanged < prevViewportTop) {
2094
+ const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
2095
+ const cleanTailAppend =
2096
+ diff.appendedLines && this.#findAppendedTailStart(newLines) === this.#previousLines.length;
2097
+ if (
2098
+ !isMultiplexerSession() &&
2099
+ this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, allowUnknownViewportMutation)
2100
+ ) {
2101
+ return { kind: "historyRebuild" };
2102
+ }
2103
+ this.#markNativeScrollbackDirty();
2104
+ if (
2105
+ nativeViewportAtBottom === undefined &&
2106
+ eagerEraseScrollbackRisk &&
2107
+ !cleanTailAppend &&
2108
+ !this.#eagerNativeScrollbackRebuild
2109
+ ) {
2110
+ return this.#planDeferredTailRepaint(newLines, prevViewportTop, height);
2111
+ }
2112
+ return { kind: "viewportRepaint", appendFrom: cleanTailAppend ? this.#previousLines.length : undefined };
2113
+ }
2114
+
2115
+ if (forceViewportRepaint) {
2116
+ if (isMultiplexerSession()) return { kind: "viewportRepaint" };
2117
+ if (pureAppend && contentGrew && this.#previousLines.length >= height) {
2118
+ return { kind: "viewportRepaint", appendFrom: this.#previousLines.length };
2119
+ }
2120
+ if (newLines.length === this.#previousLines.length && diff.firstChanged >= prevViewportTop) {
2121
+ return { kind: "viewportRepaint" };
2122
+ }
2123
+ }
2124
+
2125
+ return {
2126
+ kind: "diff",
2127
+ firstChanged: diff.firstChanged,
2128
+ lastChanged: diff.lastChanged,
2129
+ appendedLines: diff.appendedLines,
2130
+ };
2131
+ }
2132
+
2133
+ /**
2134
+ * Two-pointer diff over `#previousLines` and `newLines`. `firstChanged` is
2135
+ * `-1` when the two are identical; otherwise it is the first differing
2136
+ * index. Trailing appends are normalized so `lastChanged` always ends at the
2137
+ * last row that needs to be touched.
2138
+ */
2139
+ #diffLines(newLines: string[]): { firstChanged: number; lastChanged: number; appendedLines: boolean } {
2140
+ let firstChanged = -1;
2141
+ let lastChanged = -1;
2142
+ const maxLines = Math.max(newLines.length, this.#previousLines.length);
2143
+ for (let i = 0; i < maxLines; i++) {
2144
+ const oldLine = i < this.#previousLines.length ? this.#previousLines[i] : "";
2145
+ const newLine = i < newLines.length ? newLines[i] : "";
2146
+ if (oldLine !== newLine) {
2147
+ if (firstChanged === -1) firstChanged = i;
2148
+ lastChanged = i;
2149
+ }
2150
+ }
2151
+ const appendedLines = newLines.length > this.#previousLines.length;
2152
+ if (appendedLines) {
2153
+ if (firstChanged === -1) firstChanged = this.#previousLines.length;
2154
+ lastChanged = newLines.length - 1;
2155
+ }
2156
+ return { firstChanged, lastChanged, appendedLines };
2157
+ }
2158
+
2159
+ /**
2160
+ * Locate the longest suffix of `#previousLines` that appears in `newLines`.
2161
+ * The returned index is the first row past that suffix — the rows that are
2162
+ * "new appends" relative to the unchanged tail. Used to push streaming
2163
+ * output into scrollback even when an offscreen edit also moved rows.
2164
+ */
2165
+ #findAppendedTailStart(newLines: string[]): number {
2166
+ if (this.#previousLines.length === 0) return newLines.length;
2167
+ const previousLast = this.#previousLines[this.#previousLines.length - 1];
2168
+ let bestEnd = -1;
2169
+ let bestLength = 0;
2170
+ for (let end = newLines.length - 1; end >= 0; end--) {
2171
+ if (newLines[end] !== previousLast) continue;
2172
+ let length = 1;
2173
+ while (
2174
+ length < this.#previousLines.length &&
2175
+ end - length >= 0 &&
2176
+ this.#previousLines[this.#previousLines.length - 1 - length] === newLines[end - length]
2177
+ ) {
2178
+ length += 1;
2179
+ }
2180
+ if (length > bestLength) {
2181
+ bestLength = length;
2182
+ bestEnd = end;
2183
+ }
2184
+ }
2185
+ return bestEnd === -1 ? newLines.length : bestEnd + 1;
2186
+ }
2187
+
2188
+ #markNativeScrollbackDirty(): void {
2189
+ this.#nativeScrollbackDirty = true;
2190
+ }
2191
+
2192
+ #clearNativeScrollbackDirty(): void {
2193
+ this.#nativeScrollbackDirty = false;
2194
+ }
2195
+
2196
+ #hasEagerEraseScrollbackRisk(): boolean {
2197
+ if (process.platform === "win32") return false;
2198
+ return this.terminal.hasEagerEraseScrollbackRisk?.() ?? TERMINAL.eagerEraseScrollbackRisk;
2199
+ }
2200
+
2201
+ #readNativeViewportAtBottom(): boolean | undefined {
2202
+ // A stale positive is destructive: live history rebuilds clear native
2203
+ // scrollback. Require two consecutive at-bottom probes before trusting it.
2204
+ const first = this.terminal.isNativeViewportAtBottom?.();
2205
+ if (first !== true) return first;
2206
+ const second = this.terminal.isNativeViewportAtBottom?.();
2207
+ return second === true ? true : second;
2208
+ }
2209
+
2210
+ #nativeViewportIsScrolled(
2211
+ nativeViewportAtBottom: boolean | undefined,
2212
+ allowUnknownViewportMutation = false,
2213
+ ): boolean {
2214
+ return (
2215
+ nativeViewportAtBottom === false ||
2216
+ (nativeViewportAtBottom === undefined && process.platform === "win32" && !allowUnknownViewportMutation)
2217
+ );
2218
+ }
2219
+
2220
+ #nativeViewportIsKnownScrolled(nativeViewportAtBottom: boolean | undefined): boolean {
2221
+ return nativeViewportAtBottom === false;
2222
+ }
2223
+ #canReplayNativeScrollbackAtCheckpoint(nativeViewportAtBottom: boolean | undefined): boolean {
2224
+ return nativeViewportAtBottom === true;
2225
+ }
2226
+
2227
+ /**
2228
+ * Live-frame counterpart to {@link #canReplayNativeScrollbackAtCheckpoint}.
2229
+ * Decides whether a destructive native scrollback rebuild
2230
+ * (`historyRebuild`/`overlayRebuild`, which clears saved lines and may move
2231
+ * the native viewport) is safe to emit *during ordinary rendering*. POSIX
2232
+ * terminals cannot report whether the user has scrolled up
2233
+ * (`isNativeViewportAtBottom()` is `undefined`), so an unknown position is
2234
+ * treated as unsafe: defer to a non-destructive viewport repaint and keep
2235
+ * scrollback dirty until a later render has a positive at-tail proof. A prompt
2236
+ * submit is no longer treated as proof for unobservable host scrollback.
2237
+ * this, every offscreen transcript edit while streaming wiped scrollback and
2238
+ * yanked a scrolled-up reader out of their current context.
2239
+ * `allowUnknownViewportMutation` (autocomplete/IME) opts directly
2240
+ * user-driven POSIX frames back into the rebuild. Native Windows and Windows
2241
+ * Terminal still cannot trust an unknown probe during live rendering — ConPTY
2242
+ * may be fronting host scrollback we cannot observe — so they keep deferring.
2243
+ */
2244
+ #canRebuildNativeScrollbackLive(
2245
+ nativeViewportAtBottom: boolean | undefined,
2246
+ allowUnknownViewportMutation: boolean,
2247
+ ): boolean {
2248
+ return (
2249
+ nativeViewportAtBottom === true ||
2250
+ (nativeViewportAtBottom === undefined && allowUnknownViewportMutation && process.platform !== "win32")
2251
+ );
2252
+ }
2253
+
2254
+ #planLiveRegionPinnedRender(
2255
+ newLines: string[],
2256
+ height: number,
2257
+ liveRegionStart: number | undefined,
2258
+ commitSafeEnd: number | undefined,
2259
+ eagerEraseScrollbackRisk: boolean,
2260
+ allowUnknownViewportMutation: boolean,
2261
+ ): RenderIntent | undefined {
2262
+ if (
2263
+ liveRegionStart === undefined ||
2264
+ liveRegionStart >= newLines.length ||
2265
+ !this.#eagerNativeScrollbackRebuild ||
2266
+ !eagerEraseScrollbackRisk ||
2267
+ allowUnknownViewportMutation
2268
+ ) {
2269
+ return undefined;
2270
+ }
2271
+ // Multiplexers (tmux/screen/zellij) cannot erase pane history with `\x1b[3J`
2272
+ // and cannot answer a viewport-position probe, so the destructive checkpoint
2273
+ // rebuild path is forever unavailable. The pinned emitter is built from the
2274
+ // opposite primitives — relative cursor moves, per-line `\x1b[2K`, and
2275
+ // `\r\n` to scroll sealed rows past the viewport bottom — which are exactly
2276
+ // what tmux pane history accepts. Without this commit-as-you-go path, the
2277
+ // streaming cap below clipped every frame to the visible tail and the
2278
+ // scrolled-off head was committed nowhere (issue #1974).
2279
+ if (newLines.length <= height && this.#scrollbackHighWater === 0) return undefined;
2280
+ if (this.#readNativeViewportAtBottom() !== undefined) return undefined;
2281
+
2282
+ this.#markNativeScrollbackDirty();
2283
+ const naturalViewportTop = Math.max(0, newLines.length - height);
2284
+ // Rows before the live-region boundary are sealed. The commit boundary is
2285
+ // the deeper of the sealed start and the append-only `commitSafeEnd`: a
2286
+ // streaming assistant block reports a `commitSafeEnd` spanning its whole
2287
+ // body, so its head rows that scroll above the viewport commit to native
2288
+ // scrollback instead of vanishing (committed nowhere, repainted nowhere).
2289
+ // A volatile live block (a tool preview that later collapses) omits
2290
+ // `commitSafeEnd`, so the boundary falls back to `liveRegionStart` and its
2291
+ // mutable rows stay deferred — otherwise a pending box that later collapses
2292
+ // to its running/final shape leaves the old top half in scrollback and
2293
+ // repaints the new tail below it, visually splitting one box across the
2294
+ // scrollback seam.
2295
+ const commitBoundary = commitSafeEnd ?? liveRegionStart;
2296
+ const sealedAppendTo = Math.min(naturalViewportTop, commitBoundary);
2297
+ const appendTo = Math.max(0, sealedAppendTo);
2298
+ const appendFrom = Math.min(this.#scrollbackHighWater, appendTo);
2299
+ // If the live-region collapse would re-expose committed rows already written
2300
+ // to native scrollback, clamp the repaint below that committed prefix so
2301
+ // committed rows are not duplicated. Mutable rows beyond the commit boundary
2302
+ // may remain hidden above the viewport until the next checkpoint rebuild;
2303
+ // that is safer than committing transient rows that can later re-layout.
2304
+ const committedSealedEnd = Math.min(this.#scrollbackHighWater, commitBoundary);
2305
+ const renderViewportTop = Math.max(naturalViewportTop, committedSealedEnd);
2306
+ return { kind: "liveRegionPinned", appendFrom, appendTo, renderViewportTop };
2307
+ }
2308
+
2309
+ #planDeferredTailRepaint(newLines: string[], prevViewportTop: number, height: number): RenderIntent {
2310
+ const row = prevViewportTop + height - 1;
2311
+ if (row < 0 || row >= this.#previousLines.length || newLines.length !== this.#previousLines.length) {
2312
+ return { kind: "deferredMutation" };
2313
+ }
2314
+ const line = newLines[row] ?? "";
2315
+ const previousLine = this.#deferredTailLine ?? this.#previousLines[row] ?? "";
2316
+ if (line === previousLine) {
2317
+ return { kind: "deferredMutation" };
2318
+ }
2319
+ return { kind: "deferredTailRepaint", row, line };
2320
+ }
2321
+
2322
+ #padDeferredShrinkLines(lines: string[], paddedLength: number): string[] {
2323
+ if (lines.length >= paddedLength) return lines;
2324
+ return [...lines, ...new Array<string>(paddedLength - lines.length).fill("")];
2325
+ }
2326
+ /**
2327
+ * Truncate a line to the visible viewport width. Image lines are left
2328
+ * alone, narrow lines pass through unchanged. Truncation re-appends the
2329
+ * per-line terminator so SGR/OSC 8 state does not leak across rows when
2330
+ * `truncateToWidth` drops the trailing bytes appended by
2331
+ * {@link #applyLineResets}.
2332
+ */
2333
+ #fitLinesToWidth(lines: string[], width: number): string[] {
2334
+ for (let i = 0; i < lines.length; i++) {
2335
+ lines[i] = this.#fitLineToWidth(lines[i], width);
2336
+ }
2337
+ return lines;
2338
+ }
2339
+
2340
+ #fitLineToWidth(line: string, width: number): string {
2341
+ if (TERMINAL.isImageLine(line)) return line;
2342
+ if (visibleWidth(line) <= width) return line;
2343
+ const truncated = truncateToWidth(line, width, Ellipsis.Omit);
2344
+ return truncated + (truncated.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
2345
+ }
2346
+
2347
+ /**
2348
+ * Single state-transition point. Every emitter calls this exactly once at
2349
+ * the end so cursor/viewport/scrollback accounting stays consistent.
2350
+ */
2351
+
2352
+ #commit(lines: string[], width: number, height: number, viewportTop: number, hardwareCursorRow: number): void {
2353
+ this.#deferredTailLine = undefined;
2354
+ this.#previousLines = lines;
2355
+ this.#previousVisibleOverlayComponents = this.#visibleOverlayComponentsThisRender;
2356
+ this.#forceViewportRepaintOnNextRender = false;
2357
+ this.#previousWidth = width;
2358
+ this.#previousHeight = height;
2359
+ this.#cursorRow = Math.max(0, lines.length - 1);
2360
+ this.#viewportTopRow = viewportTop;
2361
+ this.#hardwareCursorRow = hardwareCursorRow;
2362
+ }
2363
+
2364
+ /**
2365
+ * Clear the viewport (optionally scrollback) and emit the full transcript.
2366
+ * Backs `initial`, `sessionReplace`, and `historyRebuild` intents.
2367
+ */
2368
+ #emitFullPaint(
2369
+ lines: string[],
2370
+ width: number,
2371
+ height: number,
2372
+ cursorPos: { row: number; col: number } | null,
2373
+ options: { clearViewport: boolean; clearScrollback: boolean },
2374
+ ): void {
2375
+ this.#fullRedrawCount += 1;
2376
+ let buffer = this.#paintBeginSequence;
2377
+ // Purge graphics for images the budget just demoted to text. Kitty keeps
2378
+ // images in a store that text-clear escapes don't touch, so delete them by
2379
+ // id; other protocols bake images into cells the clear-screen below wipes.
2380
+ const purgeIds = this.#imageBudget.takePurgeIds();
2381
+ if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
2382
+ for (const id of purgeIds) buffer += encodeKittyDeleteImage(id);
2383
+ }
2384
+ if (options.clearViewport) {
2385
+ if (options.clearScrollback) {
2386
+ buffer += "\x1b[2J\x1b[H\x1b[3J";
2387
+ } else {
2388
+ // Best-effort: push the pre-paint screen into scrollback on terminals
2389
+ // that implement kitty's ED 22 (copy-screen-to-scrollback-then-erase).
2390
+ // ED 22 is not universal: multiplexers (tmux/screen/zellij), non-kitty
2391
+ // terminals, and old kitty ignore the unknown ED parameter, which left
2392
+ // the initial paint with no viewport clear (stale prior-program content
2393
+ // bled through until a resize). Always follow with ED 2 so the viewport
2394
+ // is cleared regardless; on real kitty, ED 2 over the now-blank screen
2395
+ // is a no-op and does not push a second (blank) copy to scrollback.
2396
+ if (TERMINAL.supportsScreenToScrollback) buffer += "\x1b[22J";
2397
+ buffer += "\x1b[2J\x1b[H";
2398
+ }
2399
+ }
2400
+ // Only the final viewport rows stay on screen; everything above scrolls
2401
+ // into native scrollback, so optimize the visible tail with DECCARA
2402
+ // rectangles while writing scrollback-bound rows as full styled strings
2403
+ // (their background must survive in history, which DECCARA cannot reach).
2404
+ const visibleStart = Math.max(0, lines.length - height);
2405
+ let fillSequence = "";
2406
+ let visibleTexts: string[] | null = null;
2407
+ if (TERMINAL.deccara && visibleStart < lines.length) {
2408
+ const visible: string[] = new Array(lines.length - visibleStart);
2409
+ for (let k = 0; k < visible.length; k++) {
2410
+ visible[k] = this.#fitLineToWidth(lines[visibleStart + k], width);
2411
+ }
2412
+ const plan = planDeccaraFills(visible, width);
2413
+ visibleTexts = plan.texts;
2414
+ fillSequence = plan.sequence;
2415
+ }
2416
+ for (let i = 0; i < lines.length; i++) {
2417
+ if (i > 0) buffer += "\r\n";
2418
+ buffer +=
2419
+ visibleTexts && i >= visibleStart ? visibleTexts[i - visibleStart] : this.#fitLineToWidth(lines[i], width);
2420
+ }
2421
+ buffer += fillSequence;
2422
+ const finalRow = Math.max(0, lines.length - 1);
2423
+ const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, finalRow);
2424
+ buffer += seq;
2425
+ buffer += this.#paintEndSequence;
2426
+ this.terminal.write(buffer);
2427
+
2428
+ this.#maxLinesRendered = options.clearViewport ? lines.length : Math.max(this.#maxLinesRendered, lines.length);
2429
+ if (options.clearScrollback) {
2430
+ this.#scrollbackHighWater = 0;
2431
+ this.#suppressNextSuffixScroll = lines.length > height;
2432
+ }
2433
+ const pushedNow = Math.max(0, lines.length - height);
2434
+ if (pushedNow > this.#scrollbackHighWater) {
2435
+ this.#scrollbackHighWater = pushedNow;
2436
+ }
2437
+ this.#commit(lines, width, height, Math.max(0, this.#maxLinesRendered - height), toRow);
2438
+ }
2439
+
2440
+ /**
2441
+ * Initial foreground-stream paint on ED3-risk hosts with unknown viewport
2442
+ * position. Clears only the visible screen, commits the stable prefix, and
2443
+ * paints the mutable live tail without first writing hidden live rows into
2444
+ * native scrollback.
2445
+ */
2446
+ #emitInitialLiveRegionPinnedPaint(
2447
+ lines: string[],
2448
+ width: number,
2449
+ height: number,
2450
+ cursorPos: { row: number; col: number } | null,
2451
+ liveRegionStart: number,
2452
+ commitSafeEnd: number | undefined,
2453
+ ): void {
2454
+ this.#fullRedrawCount += 1;
2455
+ this.#markNativeScrollbackDirty();
2456
+ const naturalViewportTop = Math.max(0, lines.length - height);
2457
+ const commitBoundary = commitSafeEnd ?? liveRegionStart;
2458
+ const appendTo = Math.max(0, Math.min(naturalViewportTop, commitBoundary, lines.length));
2459
+ const viewportTop = naturalViewportTop;
2460
+
2461
+ let buffer = this.#paintBeginSequence;
2462
+ if (TERMINAL.supportsScreenToScrollback) buffer += "\x1b[22J";
2463
+ buffer += "\x1b[2J\x1b[H";
2464
+
2465
+ let wroteLine = false;
2466
+ for (let i = 0; i < appendTo; i++) {
2467
+ if (wroteLine) buffer += "\r\n";
2468
+ buffer += this.#fitLineToWidth(lines[i] ?? "", width);
2469
+ wroteLine = true;
2470
+ }
2471
+ for (let screenRow = 0; screenRow < height; screenRow++) {
2472
+ if (wroteLine) buffer += "\r\n";
2473
+ buffer += this.#fitLineToWidth(lines[viewportTop + screenRow] ?? "", width);
2474
+ wroteLine = true;
2475
+ }
2476
+
2477
+ const viewportBottomRow = viewportTop + height - 1;
2478
+ const contentBottomRow = Math.min(viewportBottomRow, Math.max(viewportTop, lines.length - 1));
2479
+ const parkUp = viewportBottomRow - contentBottomRow;
2480
+ if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
2481
+ const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, contentBottomRow);
2482
+ buffer += seq;
2483
+ buffer += this.#paintEndSequence;
2484
+ this.terminal.write(buffer);
2485
+
2486
+ this.#maxLinesRendered = Math.max(lines.length, viewportTop + height);
2487
+ this.#scrollbackHighWater = appendTo;
2488
+ this.#commit(lines, width, height, viewportTop, toRow);
2489
+ }
2490
+ /**
2491
+ * Rewrite the visible viewport in place. Cursor home, clear each row,
2492
+ * emit the bottom-anchored slice of `lines`. No scrollback growth.
2493
+ */
2494
+ #emitViewportRepaint(
2495
+ lines: string[],
2496
+ width: number,
2497
+ height: number,
2498
+ cursorPos: { row: number; col: number } | null,
2499
+ ): void {
2500
+ this.#fullRedrawCount += 1;
2501
+ const viewportTop = Math.max(0, lines.length - height);
2502
+ // Each visible screen row, bottom-anchored, blank past content.
2503
+ const visible: string[] = new Array(height);
2504
+ for (let screenRow = 0; screenRow < height; screenRow++) {
2505
+ visible[screenRow] = this.#fitLineToWidth(lines[viewportTop + screenRow] ?? "", width);
2506
+ }
2507
+ const { texts, sequence } = TERMINAL.deccara
2508
+ ? planDeccaraFills(visible, width)
2509
+ : { texts: visible, sequence: "" };
2510
+ let buffer = `${this.#paintBeginSequence}\x1b[H`;
2511
+ for (let screenRow = 0; screenRow < height; screenRow++) {
2512
+ if (screenRow > 0) buffer += "\r\n";
2513
+ buffer += "\x1b[2K";
2514
+ buffer += texts[screenRow];
2515
+ }
2516
+ // DECCARA rectangles paint the visible fills before cursor positioning;
2517
+ // the cleared cells written above are what the rectangles repaint.
2518
+ buffer += sequence;
2519
+ // The loop unconditionally writes `height` rows from screen row 0, so the
2520
+ // hardware cursor lands at the padded viewport bottom (`viewportTop +
2521
+ // height - 1`) even when the content is shorter than the viewport and the
2522
+ // trailing rows are blank. Parking it below the content is unsafe: a later
2523
+ // terminal height *shrink* scrolls the live content rows up into native
2524
+ // scrollback to keep that cursor on screen, and the next repaint redraws
2525
+ // them — committing a duplicate copy of the visible block to history once
2526
+ // per resize step (a drag-resize multiplies it). Move the cursor up to the
2527
+ // real content bottom so it matches the post-paint invariant every other
2528
+ // emitter holds and the reflow has no live rows to scroll away. The move is
2529
+ // physical (not just tracked), so `#cursorControlSequence`'s relative
2530
+ // `rowDelta` stays correct and the IME cursor still lands on its row after a
2531
+ // height-grow resize.
2532
+ const viewportBottomRow = viewportTop + height - 1;
2533
+ const contentBottomRow = Math.min(viewportBottomRow, Math.max(viewportTop, lines.length - 1));
2534
+ const parkUp = viewportBottomRow - contentBottomRow;
2535
+ if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
2536
+ const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, contentBottomRow);
2537
+ buffer += seq;
2538
+ buffer += this.#paintEndSequence;
2539
+ this.terminal.write(buffer);
2540
+
2541
+ this.#maxLinesRendered = lines.length;
2542
+ this.#commit(lines, width, height, viewportTop, toRow);
2543
+ }
2544
+
2545
+ /**
2546
+ * Foreground-stream live-region paint for ED3-risk terminals with an
2547
+ * unobservable viewport. Commits the newly-sealed chunk to native scrollback
2548
+ * (so finished blocks stay scrollable) and repaints the live tail in place,
2549
+ * leaving the transient live region out of saved lines.
2550
+ *
2551
+ * Uses only the no-scroll-snap vocabulary of {@link #emitDiff}: relative
2552
+ * cursor moves, per-line `\x1b[2K`, and `\r\n` to push the sealed chunk into
2553
+ * history. It deliberately avoids a full-screen erase (`\x1b[2J`) and absolute
2554
+ * cursor home (`\x1b[H`): on Ghostty those snap a reader scrolled into history
2555
+ * back to the bottom on every frame.
2556
+ */
2557
+ #emitLiveRegionPinnedRepaint(
2558
+ lines: string[],
2559
+ width: number,
2560
+ height: number,
2561
+ cursorPos: { row: number; col: number } | null,
2562
+ appendFrom: number,
2563
+ appendTo: number,
2564
+ renderViewportTop: number,
2565
+ prevViewportTop: number,
2566
+ prevHardwareCursorRow: number,
2567
+ ): void {
2568
+ this.#fullRedrawCount += 1;
2569
+ const naturalViewportTop = Math.max(0, lines.length - height);
2570
+ const viewportTop = Math.max(0, Math.min(renderViewportTop, lines.length));
2571
+ const boundedAppendTo = Math.max(0, Math.min(appendTo, naturalViewportTop, lines.length));
2572
+ const boundedAppendFrom = Math.max(0, Math.min(appendFrom, boundedAppendTo));
2573
+
2574
+ // Position at the top visible row with a relative move. Terminals clamp the
2575
+ // hardware cursor to the viewport on resize, so clamp our tracking to match
2576
+ // before computing the delta (mirrors #emitDiff).
2577
+ const clampedCursor = Math.min(prevHardwareCursorRow, prevViewportTop + height - 1);
2578
+ const currentScreenRow = Math.max(0, Math.min(height - 1, clampedCursor - prevViewportTop));
2579
+ let buffer = this.#paintBeginSequence;
2580
+ if (currentScreenRow > 0) buffer += `\x1b[${currentScreenRow}A`;
2581
+ buffer += "\r";
2582
+
2583
+ // Write the sealed chunk followed by the full viewport from the top row.
2584
+ // The first (boundedAppendTo - boundedAppendFrom) rows scroll into native
2585
+ // history; the trailing `height` rows fill the viewport. Each row clears
2586
+ // itself with `\x1b[2K` instead of relying on a screen-wide erase.
2587
+ let wroteLine = false;
2588
+ for (let i = boundedAppendFrom; i < boundedAppendTo; i++) {
2589
+ if (wroteLine) buffer += "\r\n";
2590
+ buffer += `\x1b[2K${this.#fitLineToWidth(lines[i] ?? "", width)}`;
2591
+ wroteLine = true;
2592
+ }
2593
+ for (let screenRow = 0; screenRow < height; screenRow++) {
2594
+ if (wroteLine) buffer += "\r\n";
2595
+ buffer += `\x1b[2K${this.#fitLineToWidth(lines[viewportTop + screenRow] ?? "", width)}`;
2596
+ wroteLine = true;
2597
+ }
2598
+
2599
+ const viewportBottomRow = viewportTop + height - 1;
2600
+ const contentBottomRow = Math.min(viewportBottomRow, Math.max(viewportTop, lines.length - 1));
2601
+ const parkUp = viewportBottomRow - contentBottomRow;
2602
+ if (parkUp > 0) buffer += `\x1b[${parkUp}A`;
2603
+ const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, contentBottomRow);
2604
+ buffer += seq;
2605
+ buffer += this.#paintEndSequence;
2606
+ this.terminal.write(buffer);
2607
+
2608
+ this.#maxLinesRendered = Math.max(lines.length, viewportTop + height);
2609
+ if (boundedAppendTo > this.#scrollbackHighWater) {
2610
+ this.#scrollbackHighWater = boundedAppendTo;
2611
+ }
2612
+ this.#commit(lines, width, height, viewportTop, toRow);
2613
+ }
2614
+
2615
+ /**
2616
+ * Push the appended tail into terminal scrollback by `\r\n`-ing past the
2617
+ * previous viewport bottom. Used as a prefix to {@link #emitViewportRepaint}
2618
+ * when an offscreen edit and an append land in the same frame; does not
2619
+ * call {@link #commit} (the following repaint owns final state).
2620
+ */
2621
+ #emitAppendTail(
2622
+ lines: string[],
2623
+ start: number,
2624
+ height: number,
2625
+ width: number,
2626
+ prevViewportTop: number,
2627
+ prevHardwareCursorRow: number,
2628
+ ): void {
2629
+ if (start >= lines.length) return;
2630
+ let buffer = this.#paintBeginSequence;
2631
+ // Clamp tracked cursor to the visible viewport bottom — terminals clamp
2632
+ // on resize, so a prior frame may have committed a row that no longer
2633
+ // exists. Without this the scroll math points outside the viewport.
2634
+ const clampedCursor = Math.min(prevHardwareCursorRow, prevViewportTop + height - 1);
2635
+ const currentScreenRow = Math.max(0, Math.min(height - 1, clampedCursor - prevViewportTop));
2636
+ const moveToBottom = height - 1 - currentScreenRow;
2637
+ if (moveToBottom > 0) buffer += `\x1b[${moveToBottom}B`;
2638
+ for (let i = start; i < lines.length; i++) {
2639
+ buffer += "\r\n";
2640
+ buffer += this.#fitLineToWidth(lines[i], width);
2641
+ }
2642
+ buffer += this.#paintEndSequence;
2643
+ this.terminal.write(buffer);
2644
+ const pushedNow = Math.max(0, lines.length - height);
2645
+ if (pushedNow > this.#scrollbackHighWater) {
2646
+ this.#scrollbackHighWater = pushedNow;
2647
+ }
2648
+ }
2649
+
2650
+ /**
2651
+ * Paint only the active-grid bottom row while a scrollback mutation remains
2652
+ * deferred. If the native viewport is unknown and the user is scrolled up by a
2653
+ * single line, every active-grid row except the bottom can still be visible in
2654
+ * their scrollback window; touching only this row keeps that reader's viewport
2655
+ * unchanged while allowing bottom-anchored live chrome (spinner/status tail) to
2656
+ * advance for users at the tail.
2657
+ */
2658
+ #emitDeferredTailRepaint(
2659
+ line: string,
2660
+ width: number,
2661
+ height: number,
2662
+ row: number,
2663
+ prevViewportTop: number,
2664
+ prevHardwareCursorRow: number,
2665
+ ): void {
2666
+ const viewportBottom = prevViewportTop + height - 1;
2667
+ if (row !== viewportBottom) return;
2668
+
2669
+ let buffer = this.#paintBeginSequence;
2670
+ const clampedCursor = Math.min(prevHardwareCursorRow, viewportBottom);
2671
+ const currentScreenRow = Math.max(0, Math.min(height - 1, clampedCursor - prevViewportTop));
2672
+ const moveDown = height - 1 - currentScreenRow;
2673
+ if (moveDown > 0) buffer += `\x1b[${moveDown}B`;
2674
+ buffer += `\r\x1b[2K${this.#fitLineToWidth(line, width)}\x1b[?25l`;
2675
+ buffer += this.#paintEndSequence;
2676
+ this.terminal.write(buffer);
2677
+
2678
+ this.#deferredTailLine = line;
2679
+ this.#previousWidth = width;
2680
+ this.#previousHeight = height;
2681
+ this.#viewportTopRow = prevViewportTop;
2682
+ this.#hardwareCursorRow = row;
2683
+ }
2684
+
2685
+ /**
2686
+ * Trailing-shrink: prior content shared a prefix with the new content; the
2687
+ * extra rows below the new tail need to be cleared without scrolling. Falls
2688
+ * back to {@link #emitViewportRepaint} when more rows must be cleared than
2689
+ * fit on screen.
2690
+ */
2691
+ #emitShrink(
2692
+ lines: string[],
2693
+ width: number,
2694
+ height: number,
2695
+ cursorPos: { row: number; col: number } | null,
2696
+ prevHardwareCursorRow: number,
2697
+ prevViewportTop: number,
2698
+ ): void {
2699
+ const extraLines = this.#previousLines.length - lines.length;
2700
+ if (extraLines <= 0) {
2701
+ this.#commit(lines, width, height, Math.max(0, lines.length - height), prevHardwareCursorRow);
2702
+ this.#maxLinesRendered = lines.length;
2703
+ return;
2704
+ }
2705
+ if (extraLines > height) {
2706
+ this.#emitViewportRepaint(lines, width, height, cursorPos);
2707
+ return;
2708
+ }
2709
+
2710
+ const viewportTop = Math.max(0, this.#maxLinesRendered - height);
2711
+ const targetRow = Math.max(0, lines.length - 1);
2712
+
2713
+ let buffer = this.#paintBeginSequence;
2714
+
2715
+ const clampedCursor = Math.min(prevHardwareCursorRow, prevViewportTop + height - 1);
2716
+ const currentScreenRow = clampedCursor - prevViewportTop;
2717
+ const targetScreenRow = targetRow - viewportTop;
2718
+ const lineDiff = targetScreenRow - currentScreenRow;
2719
+ if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
2720
+ else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
2721
+ buffer += "\r";
2722
+
2723
+ const clearStartOffset = lines.length > 0 ? 1 : 0;
2724
+ if (clearStartOffset > 0) {
2725
+ buffer += `\x1b[${clearStartOffset}B`;
2726
+ }
2727
+ for (let i = 0; i < extraLines; i++) {
2728
+ buffer += "\r\x1b[2K";
2729
+ if (i < extraLines - 1) buffer += "\x1b[1B";
2730
+ }
2731
+ const moveUp = extraLines - 1 + clearStartOffset;
2732
+ if (moveUp > 0) {
2733
+ buffer += `\x1b[${moveUp}A`;
2734
+ }
2735
+
2736
+ const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, targetRow);
2737
+ buffer += seq;
2738
+ buffer += this.#paintEndSequence;
2739
+ this.terminal.write(buffer);
2740
+
2741
+ this.#maxLinesRendered = lines.length;
2742
+ this.#commit(lines, width, height, Math.max(0, lines.length - height), toRow);
2743
+ }
2744
+
2745
+ /**
2746
+ * Differential rewrite from `firstChanged` through `lastChanged`. Handles
2747
+ * three sub-shapes: pure append below the prior viewport (scroll + write),
2748
+ * in-place replace of visible rows, and replace-plus-trailing-shrink (clear
2749
+ * extras after writing). Cursor math is local to this method.
2750
+ */
2751
+ #emitDiff(
2752
+ lines: string[],
2753
+ width: number,
2754
+ height: number,
2755
+ cursorPos: { row: number; col: number } | null,
2756
+ firstChanged: number,
2757
+ lastChanged: number,
2758
+ appendedLines: boolean,
2759
+ prevViewportTop: number,
2760
+ prevHardwareCursorRow: number,
2761
+ ): void {
2762
+ let viewportTop = Math.max(0, this.#maxLinesRendered - height);
2763
+ let activeViewportTop = prevViewportTop;
2764
+ // Terminals clamp the hardware cursor to the visible viewport on resize.
2765
+ // If our tracked row is past the viewport bottom, the real cursor was
2766
+ // clamped; clamp our tracking to match so relative moves land correctly.
2767
+ let hardwareCursorRow = Math.min(prevHardwareCursorRow, activeViewportTop + height - 1);
2768
+
2769
+ const appendStart = appendedLines && firstChanged === this.#previousLines.length && firstChanged > 0;
2770
+ const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
2771
+
2772
+ let buffer = this.#paintBeginSequence;
2773
+
2774
+ // Scroll-down branch: target row is past the bottom of the previous
2775
+ // viewport (a pure append). Emit `\r\n`s so the terminal pushes the
2776
+ // existing viewport into scrollback before we start writing.
2777
+ const prevViewportBottom = activeViewportTop + height - 1;
2778
+ if (moveTargetRow > prevViewportBottom) {
2779
+ const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - activeViewportTop));
2780
+ const moveToBottom = height - 1 - currentScreenRow;
2781
+ if (moveToBottom > 0) buffer += `\x1b[${moveToBottom}B`;
2782
+ const scroll = moveTargetRow - prevViewportBottom;
2783
+ buffer += "\r\n".repeat(scroll);
2784
+ activeViewportTop += scroll;
2785
+ viewportTop += scroll;
2786
+ hardwareCursorRow = moveTargetRow;
2787
+ }
2788
+
2789
+ // Position cursor at the row we need to start writing from.
2790
+ const currentScreenRow = hardwareCursorRow - activeViewportTop;
2791
+ const targetScreenRow = moveTargetRow - viewportTop;
2792
+ const lineDiff = targetScreenRow - currentScreenRow;
2793
+ if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
2794
+ else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
2795
+ buffer += appendStart ? "\r\n" : "\r";
2796
+
2797
+ // Repaint only firstChanged..lastChanged, not all rows to the end.
2798
+ // This bounds flicker for single-row updates (e.g. spinner ticks).
2799
+ const renderEnd = Math.min(lastChanged, lines.length - 1);
2800
+ // Optimize the in-place rewrite of a contiguous visible row range with
2801
+ // DECCARA. The rectangle coordinates are absolute screen rows, so two
2802
+ // effects that the relatively-positioned text absorbs transparently must
2803
+ // be folded into the coordinates explicitly:
2804
+ // 1. Writing rows past the viewport bottom scrolls the terminal, so the
2805
+ // rewritten rows settle `scrollAmount` rows higher than where they
2806
+ // were first painted. The rectangles must target the post-scroll rows.
2807
+ // 2. Rows pushed into history keep their full background padding (DECCARA
2808
+ // cannot reach scrollback), so only rows that remain in the final
2809
+ // viewport are shortened and repainted.
2810
+ // The append/scroll branch (`moveTargetRow > prevViewportBottom`) already
2811
+ // pushed rows into history and is excluded.
2812
+ const scrollAmount = Math.max(0, renderEnd - viewportTop - (height - 1));
2813
+ const fillViewportTop = viewportTop + scrollAmount;
2814
+ const fillStart = Math.max(firstChanged, fillViewportTop);
2815
+ let fillSequence = "";
2816
+ let fillTexts: string[] | null = null;
2817
+ if (TERMINAL.deccara && !appendStart && moveTargetRow <= prevViewportBottom && renderEnd >= fillStart) {
2818
+ const slice: string[] = new Array(renderEnd - fillStart + 1);
2819
+ for (let i = fillStart; i <= renderEnd; i++) {
2820
+ slice[i - fillStart] = this.#fitLineToWidth(lines[i], width);
2821
+ }
2822
+ const plan = planDeccaraFills(slice, width, fillStart - fillViewportTop);
2823
+ fillTexts = plan.texts;
2824
+ fillSequence = plan.sequence;
2825
+ }
2826
+ for (let i = firstChanged; i <= renderEnd; i++) {
2827
+ if (i > firstChanged) buffer += "\r\n";
2828
+ buffer += "\x1b[2K";
2829
+ buffer += fillTexts && i >= fillStart ? fillTexts[i - fillStart] : this.#fitLineToWidth(lines[i], width);
2830
+ }
2831
+
2832
+ // If the prior frame was taller, clear the trailing rows.
2833
+ let finalCursorRow = renderEnd;
2834
+ if (this.#previousLines.length > lines.length) {
2835
+ if (renderEnd < lines.length - 1) {
2836
+ const moveDown = lines.length - 1 - renderEnd;
2837
+ buffer += `\x1b[${moveDown}B`;
2838
+ finalCursorRow = lines.length - 1;
2839
+ }
2840
+ const extraLines = this.#previousLines.length - lines.length;
2841
+ for (let i = lines.length; i < this.#previousLines.length; i++) {
2842
+ buffer += "\r\n\x1b[2K";
2843
+ }
2844
+ buffer += `\x1b[${extraLines}A`;
2845
+ }
2846
+ // DECCARA rectangles for the rewritten visible fills. Absolute-positioned,
2847
+ // so emitting them after the trailing-shrink cursor moves is safe.
2848
+ buffer += fillSequence;
2849
+
2850
+ const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, finalCursorRow);
2851
+ buffer += seq;
2852
+ buffer += this.#paintEndSequence;
2853
+
2854
+ this.#writeDiffDebug(
2855
+ lines,
2856
+ firstChanged,
2857
+ viewportTop,
2858
+ height,
2859
+ lineDiff,
2860
+ hardwareCursorRow,
2861
+ renderEnd,
2862
+ finalCursorRow,
2863
+ cursorPos,
2864
+ toRow,
2865
+ buffer,
2866
+ );
2867
+ this.terminal.write(buffer);
2868
+
2869
+ this.#maxLinesRendered = lines.length;
2870
+ if (lines.length > this.#previousLines.length) {
2871
+ const pushedNow = Math.max(0, lines.length - height);
2872
+ if (pushedNow > this.#scrollbackHighWater) {
2873
+ this.#scrollbackHighWater = pushedNow;
2874
+ }
2875
+ }
2876
+ this.#commit(lines, width, height, Math.max(0, lines.length - height), toRow);
2877
+ }
2878
+
2879
+ /** Optional intent log under PROMETHEUS_DEBUG_REDRAW. */
2880
+ #logRedraw(intent: RenderIntent, newLength: number, height: number): void {
2881
+ if (!$flag("PROMETHEUS_DEBUG_REDRAW")) return;
2882
+ const detail =
2883
+ intent.kind === "diff"
2884
+ ? `${intent.kind}(first=${intent.firstChanged}, last=${intent.lastChanged}, appended=${intent.appendedLines})`
2885
+ : intent.kind === "liveRegionPinned"
2886
+ ? `${intent.kind}(append=${intent.appendFrom}..${intent.appendTo}, viewportTop=${intent.renderViewportTop})`
2887
+ : intent.kind === "viewportRepaint" && intent.appendFrom !== undefined
2888
+ ? `${intent.kind}(appendFrom=${intent.appendFrom})`
2889
+ : intent.kind === "deferredTailRepaint"
2890
+ ? `${intent.kind}(row=${intent.row})`
2891
+ : intent.kind;
2892
+ const msg = `[${new Date().toISOString()}] render: ${detail} (prev=${this.#previousLines.length}, new=${newLength}, height=${height})\n`;
2893
+ fs.appendFileSync(getDebugLogPath(), msg);
2894
+ }
2895
+
2896
+ /** Optional per-render dump under PROMETHEUS_TUI_DEBUG; isolated so #emitDiff stays readable. */
2897
+ #writeDiffDebug(
2898
+ lines: string[],
2899
+ firstChanged: number,
2900
+ viewportTop: number,
2901
+ height: number,
2902
+ lineDiff: number,
2903
+ hardwareCursorRow: number,
2904
+ renderEnd: number,
2905
+ finalCursorRow: number,
2906
+ cursorPos: { row: number; col: number } | null,
2907
+ toRow: number,
2908
+ buffer: string,
2909
+ ): void {
2910
+ if (!$flag("PROMETHEUS_TUI_DEBUG")) return;
2911
+ const debugDir = "/tmp/tui";
2912
+ fs.mkdirSync(debugDir, { recursive: true });
2913
+ const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
2914
+ const debugData = [
2915
+ `firstChanged: ${firstChanged}`,
2916
+ `viewportTop: ${viewportTop}`,
2917
+ `cursorRow: ${this.#cursorRow}`,
2918
+ `height: ${height}`,
2919
+ `lineDiff: ${lineDiff}`,
2920
+ `hardwareCursorRow: ${hardwareCursorRow}`,
2921
+ `hardwareCursorRow (post): ${toRow}`,
2922
+ `renderEnd: ${renderEnd}`,
2923
+ `finalCursorRow: ${finalCursorRow}`,
2924
+ `cursorPos: ${JSON.stringify(cursorPos)}`,
2925
+ `newLines.length: ${lines.length}`,
2926
+ `previousLines.length: ${this.#previousLines.length}`,
2927
+ "",
2928
+ "=== newLines ===",
2929
+ JSON.stringify(lines, null, 2),
2930
+ "",
2931
+ "=== previousLines ===",
2932
+ JSON.stringify(this.#previousLines, null, 2),
2933
+ "",
2934
+ "=== buffer ===",
2935
+ JSON.stringify(buffer),
2936
+ ].join("\n");
2937
+ fs.writeFileSync(debugPath, debugData);
2938
+ }
2939
+
2940
+ /**
2941
+ * Build cursor control sequences to position the hardware cursor for the IME
2942
+ * candidate window. Returns escape sequences and the resulting cursor row for
2943
+ * the caller to update `#hardwareCursorRow`. The sequences should be appended
2944
+ * into the caller's own synchronized output block to avoid a flicker between
2945
+ * content and cursor frames.
2946
+ */
2947
+ #cursorControlSequence(
2948
+ cursorPos: { row: number; col: number } | null,
2949
+ totalLines: number,
2950
+ fromRow: number,
2951
+ ): { seq: string; toRow: number } {
2952
+ // No IME target or no content — hide cursor regardless of preference
2953
+ if (!cursorPos || totalLines <= 0) return { seq: "\x1b[?25l", toRow: fromRow };
2954
+
2955
+ // Clamp cursor position to valid range
2956
+ const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));
2957
+ const targetCol = Math.max(0, cursorPos.col);
2958
+
2959
+ // Move cursor from current position to target
2960
+ const rowDelta = targetRow - fromRow;
2961
+ let seq = "";
2962
+ if (rowDelta > 0) {
2963
+ seq += `\x1b[${rowDelta}B`; // Move down
2964
+ } else if (rowDelta < 0) {
2965
+ seq += `\x1b[${-rowDelta}A`; // Move up
2966
+ }
2967
+ // Move to absolute column (1-indexed)
2968
+ seq += `\x1b[${targetCol + 1}G`;
2969
+ seq += this.#showHardwareCursor ? "\x1b[?25h" : "\x1b[?25l";
2970
+
2971
+ return { seq, toRow: targetRow };
2972
+ }
2973
+
2974
+ /**
2975
+ * Write the hardware cursor position to the terminal as a standalone
2976
+ * synchronized output block. Use when there is no surrounding render buffer
2977
+ * to embed the sequences into.
2978
+ */
2979
+ #writeCursorPosition(cursorPos: { row: number; col: number } | null, totalLines: number): void {
2980
+ if (!cursorPos || totalLines <= 0) {
2981
+ this.terminal.hideCursor();
2982
+ return;
2983
+ }
2984
+ const { seq, toRow } = this.#cursorControlSequence(cursorPos, totalLines, this.#hardwareCursorRow);
2985
+ this.#hardwareCursorRow = toRow;
2986
+ this.terminal.write(`${this.#cursorBeginSequence}${seq}${this.#cursorEndSequence}`);
2987
+ }
2988
+ }