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