@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.
- package/CHANGELOG.md +7 -0
- package/README.md +704 -0
- package/dist/types/autocomplete.d.ts +76 -0
- package/dist/types/bracketed-paste.d.ts +26 -0
- package/dist/types/components/box.d.ts +17 -0
- package/dist/types/components/cancellable-loader.d.ts +21 -0
- package/dist/types/components/editor.d.ts +105 -0
- package/dist/types/components/image.d.ts +84 -0
- package/dist/types/components/input.d.ts +18 -0
- package/dist/types/components/loader.d.ts +13 -0
- package/dist/types/components/markdown.d.ts +61 -0
- package/dist/types/components/scroll-view.d.ts +40 -0
- package/dist/types/components/select-list.d.ts +48 -0
- package/dist/types/components/settings-list.d.ts +41 -0
- package/dist/types/components/spacer.d.ts +11 -0
- package/dist/types/components/tab-bar.d.ts +56 -0
- package/dist/types/components/text.d.ts +13 -0
- package/dist/types/components/truncated-text.d.ts +10 -0
- package/dist/types/deccara.d.ts +49 -0
- package/dist/types/editor-component.d.ts +36 -0
- package/dist/types/fuzzy.d.ts +15 -0
- package/dist/types/index.d.ts +28 -0
- package/dist/types/keybindings.d.ts +189 -0
- package/dist/types/keys.d.ts +208 -0
- package/dist/types/kill-ring.d.ts +27 -0
- package/dist/types/kitty-graphics.d.ts +94 -0
- package/dist/types/stdin-buffer.d.ts +43 -0
- package/dist/types/symbols.d.ts +25 -0
- package/dist/types/terminal-capabilities.d.ts +196 -0
- package/dist/types/terminal.d.ts +103 -0
- package/dist/types/ttyid.d.ts +9 -0
- package/dist/types/tui.d.ts +275 -0
- package/dist/types/utils.d.ts +89 -0
- package/package.json +73 -0
- package/src/autocomplete.ts +871 -0
- package/src/bracketed-paste.ts +47 -0
- package/src/components/box.ts +156 -0
- package/src/components/cancellable-loader.ts +40 -0
- package/src/components/editor.ts +2695 -0
- package/src/components/image.ts +318 -0
- package/src/components/input.ts +459 -0
- package/src/components/loader.ts +86 -0
- package/src/components/markdown.ts +1189 -0
- package/src/components/scroll-view.ts +166 -0
- package/src/components/select-list.ts +331 -0
- package/src/components/settings-list.ts +212 -0
- package/src/components/spacer.ts +28 -0
- package/src/components/tab-bar.ts +175 -0
- package/src/components/text.ts +110 -0
- package/src/components/truncated-text.ts +61 -0
- package/src/deccara.ts +314 -0
- package/src/editor-component.ts +71 -0
- package/src/fuzzy.ts +143 -0
- package/src/index.ts +44 -0
- package/src/keybindings.ts +279 -0
- package/src/keys.ts +537 -0
- package/src/kill-ring.ts +46 -0
- package/src/kitty-graphics.ts +270 -0
- package/src/stdin-buffer.ts +423 -0
- package/src/symbols.ts +26 -0
- package/src/terminal-capabilities.ts +1009 -0
- package/src/terminal.ts +1114 -0
- package/src/ttyid.ts +70 -0
- package/src/tui.ts +2988 -0
- 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
|
+
}
|