@oh-my-pi/pi-tui 15.11.0 → 15.11.2
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 +22 -0
- package/README.md +1 -0
- package/dist/types/components/image.d.ts +7 -0
- package/dist/types/components/markdown.d.ts +2 -4
- package/dist/types/terminal.d.ts +2 -0
- package/dist/types/tui.d.ts +19 -0
- package/package.json +3 -3
- package/src/components/image.ts +15 -0
- package/src/components/loader.ts +5 -1
- package/src/components/markdown.ts +13 -6
- package/src/terminal.ts +66 -8
- package/src/tui.ts +229 -36
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.11.2] - 2026-06-11
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed Ctrl+C/exit corrupting the parent shell on Windows: `emergencyTerminalRestore()` wrote `\x1b[?1049l` (leave alternate screen) unconditionally on every exit path, and conhost/Windows Terminal execute an unconditional cursor restore for it even when the alt buffer was never entered — with no prior save the cursor jumped to the viewport home, so the shell prompt landed on top of the dead frame. The leave sequence is now gated on tracked alt-screen state (set/cleared by the TUI's fullscreen-overlay enter/leave and stop paths).
|
|
10
|
+
- Skipped native syntax highlighting for transient markdown streaming renders, including nested list code blocks, leaving code blocks plain until their content stabilizes to avoid main-thread highlighter spikes.
|
|
11
|
+
|
|
12
|
+
## [15.11.1] - 2026-06-11
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Added `TUI.requestComponentRender(component)` to schedule component-scoped renders for self-contained updates
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Changed the render pipeline to reuse only affected root subtrees for component-scoped updates, avoiding full-tree compose when animations or other isolated component changes occur
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Fixed component-scoped renders to preserve prior live scrollback seam data for skipped root children, preventing duplicate or missing rows during spinner-only updates
|
|
24
|
+
- Reported committed native scrollback row counts to interested child components so immutable history can be skipped without breaking live-region commit bookkeeping.
|
|
25
|
+
- Fixed `ProcessTerminal` treating asynchronous stdout `EIO` errors as uncaught exceptions: stdout `error` events now mark the terminal dead, disable future renders, and keep the active session process alive ([#2284](https://github.com/can1357/oh-my-pi/issues/2284)).
|
|
26
|
+
|
|
5
27
|
## [15.11.0] - 2026-06-10
|
|
6
28
|
### Added
|
|
7
29
|
|
package/README.md
CHANGED
|
@@ -51,6 +51,7 @@ tui.removeChild(component);
|
|
|
51
51
|
tui.start();
|
|
52
52
|
tui.stop();
|
|
53
53
|
tui.requestRender(); // Request a re-render
|
|
54
|
+
tui.requestComponentRender(component); // Re-render only the root subtree containing `component` when safe (falls back to a full render on resize, overlays, images, or concurrent full requests)
|
|
54
55
|
|
|
55
56
|
// Global debug key handler (Shift+Ctrl+D)
|
|
56
57
|
tui.onDebug = () => console.log("Debug triggered");
|
|
@@ -75,6 +75,13 @@ export declare class ImageBudget {
|
|
|
75
75
|
enqueueTransmit(imageId: number, sequence: string): void;
|
|
76
76
|
/** Whether a frame has image data queued but not yet written to the terminal. */
|
|
77
77
|
hasPendingTransmits(): boolean;
|
|
78
|
+
/**
|
|
79
|
+
* True when the budget has nothing in flight: no live images observed on
|
|
80
|
+
* the last pass, no queued transmits, no pending purges, and no stricter
|
|
81
|
+
* threshold left to apply. A component-scoped frame may skip the observe
|
|
82
|
+
* pass only then — a partial tree walk would under-count display order.
|
|
83
|
+
*/
|
|
84
|
+
get quiescent(): boolean;
|
|
78
85
|
/** Transmit sequences to write before this frame's placements; clears the queue. */
|
|
79
86
|
takeTransmits(): readonly string[];
|
|
80
87
|
}
|
|
@@ -49,13 +49,11 @@ export interface MarkdownTheme {
|
|
|
49
49
|
}
|
|
50
50
|
export declare class Markdown implements Component {
|
|
51
51
|
#private;
|
|
52
|
-
/** When true, skip the module-level LRU (lookup and insert) for this instance's
|
|
53
|
-
* renders. Set for in-flight streaming partials whose text changes every frame —
|
|
54
|
-
* caching those churns the LRU with near-duplicate full-message snapshots. */
|
|
55
|
-
transientRenderCache: boolean;
|
|
56
52
|
constructor(text: string, paddingX: number, paddingY: number, theme: MarkdownTheme, defaultTextStyle?: DefaultTextStyle, codeBlockIndent?: number);
|
|
57
53
|
setText(text: string): void;
|
|
58
54
|
invalidate(): void;
|
|
55
|
+
get transientRenderCache(): boolean;
|
|
56
|
+
set transientRenderCache(value: boolean);
|
|
59
57
|
render(width: number): readonly string[];
|
|
60
58
|
}
|
|
61
59
|
/**
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
* sole production caller.
|
|
20
20
|
*/
|
|
21
21
|
export declare function chunkForConPTY(data: string, maxChunkBytes?: number): string[];
|
|
22
|
+
/** Record alternate-screen state (called by the TUI on `?1049h`/`?1049l` writes). */
|
|
23
|
+
export declare function setAltScreenActive(active: boolean): void;
|
|
22
24
|
/**
|
|
23
25
|
* Emergency terminal restore - call this from signal/crash handlers
|
|
24
26
|
* Resets terminal state without requiring access to the ProcessTerminal instance
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -88,6 +88,9 @@ export interface NativeScrollbackLiveRegion {
|
|
|
88
88
|
getNativeScrollbackLiveRegionStart(): number | undefined;
|
|
89
89
|
getNativeScrollbackCommitSafeEnd?(): number | undefined;
|
|
90
90
|
}
|
|
91
|
+
export interface NativeScrollbackCommittedRows {
|
|
92
|
+
setNativeScrollbackCommittedRows(rows: number): void;
|
|
93
|
+
}
|
|
91
94
|
/**
|
|
92
95
|
* Opt-in stability report for components that mutate their returned render
|
|
93
96
|
* array in place across frames (instead of returning a fresh array per
|
|
@@ -314,4 +317,20 @@ export declare class TUI extends Container {
|
|
|
314
317
|
*/
|
|
315
318
|
resetDisplay(): void;
|
|
316
319
|
requestRender(force?: boolean, options?: RenderRequestOptions): void;
|
|
320
|
+
/**
|
|
321
|
+
* Schedule a render on behalf of `component` after a self-contained change
|
|
322
|
+
* (spinner frame, blink) that cannot have affected any other component.
|
|
323
|
+
*
|
|
324
|
+
* When every request since the last frame is component-scoped and the
|
|
325
|
+
* frame is otherwise quiet — no resize or geometry change, no overlays, no
|
|
326
|
+
* live inline images, no forced repaint, unchanged root child list — the
|
|
327
|
+
* next compose re-renders only the root subtrees containing the requesting
|
|
328
|
+
* components and reuses the previous frame's rows (and seam reports) for
|
|
329
|
+
* every other root child, skipping the full component-tree walk that makes
|
|
330
|
+
* long transcripts expensive to repaint at animation rate. Any concurrent
|
|
331
|
+
* full request or unsafe condition downgrades the frame to a normal full
|
|
332
|
+
* compose, so this is never less correct than `requestRender()` — only
|
|
333
|
+
* cheaper.
|
|
334
|
+
*/
|
|
335
|
+
requestComponentRender(component: Component): void;
|
|
317
336
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "15.11.
|
|
4
|
+
"version": "15.11.2",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-natives": "15.11.
|
|
41
|
-
"@oh-my-pi/pi-utils": "15.11.
|
|
40
|
+
"@oh-my-pi/pi-natives": "15.11.2",
|
|
41
|
+
"@oh-my-pi/pi-utils": "15.11.2",
|
|
42
42
|
"lru-cache": "11.5.1",
|
|
43
43
|
"marked": "^18.0.4"
|
|
44
44
|
},
|
package/src/components/image.ts
CHANGED
|
@@ -196,6 +196,21 @@ export class ImageBudget {
|
|
|
196
196
|
return this.#pendingTransmits.length > 0;
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
/**
|
|
200
|
+
* True when the budget has nothing in flight: no live images observed on
|
|
201
|
+
* the last pass, no queued transmits, no pending purges, and no stricter
|
|
202
|
+
* threshold left to apply. A component-scoped frame may skip the observe
|
|
203
|
+
* pass only then — a partial tree walk would under-count display order.
|
|
204
|
+
*/
|
|
205
|
+
get quiescent(): boolean {
|
|
206
|
+
return (
|
|
207
|
+
this.#lastTotal === 0 &&
|
|
208
|
+
this.#pendingTransmits.length === 0 &&
|
|
209
|
+
this.#purgeIds.length === 0 &&
|
|
210
|
+
this.#planned === this.#onTerminal
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
199
214
|
/** Transmit sequences to write before this frame's placements; clears the queue. */
|
|
200
215
|
takeTransmits(): readonly string[] {
|
|
201
216
|
if (this.#pendingTransmits.length === 0) return EMPTY_TRANSMITS;
|
package/src/components/loader.ts
CHANGED
|
@@ -90,7 +90,11 @@ export class Loader extends Text {
|
|
|
90
90
|
const frame = this.#frames[this.#currentFrame];
|
|
91
91
|
const text = `${this.spinnerColorFn(frame)} ${this.messageColorFn(this.message)}`;
|
|
92
92
|
if (this.setText(text) && this.#ui) {
|
|
93
|
-
this
|
|
93
|
+
// Component-scoped: a spinner tick changes only this component, so
|
|
94
|
+
// the TUI may reuse every other root subtree instead of re-walking
|
|
95
|
+
// the whole tree (full repaints at 12.5 Hz made huge transcripts
|
|
96
|
+
// lag as soon as the loader appeared).
|
|
97
|
+
this.#ui.requestComponentRender(this);
|
|
94
98
|
}
|
|
95
99
|
}
|
|
96
100
|
}
|
|
@@ -295,10 +295,7 @@ export class Markdown implements Component {
|
|
|
295
295
|
#cachedText?: string;
|
|
296
296
|
#cachedWidth?: number;
|
|
297
297
|
#cachedLines?: readonly string[];
|
|
298
|
-
|
|
299
|
-
* renders. Set for in-flight streaming partials whose text changes every frame —
|
|
300
|
-
* caching those churns the LRU with near-duplicate full-message snapshots. */
|
|
301
|
-
transientRenderCache = false;
|
|
298
|
+
#transientRenderCache = false;
|
|
302
299
|
|
|
303
300
|
constructor(
|
|
304
301
|
text: string,
|
|
@@ -326,6 +323,16 @@ export class Markdown implements Component {
|
|
|
326
323
|
this.#cachedWidth = undefined;
|
|
327
324
|
this.#cachedLines = undefined;
|
|
328
325
|
}
|
|
326
|
+
get transientRenderCache(): boolean {
|
|
327
|
+
return this.#transientRenderCache;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
set transientRenderCache(value: boolean) {
|
|
331
|
+
const next = value === true;
|
|
332
|
+
if (this.#transientRenderCache === next) return;
|
|
333
|
+
this.#transientRenderCache = next;
|
|
334
|
+
this.invalidate();
|
|
335
|
+
}
|
|
329
336
|
|
|
330
337
|
render(width: number): readonly string[] {
|
|
331
338
|
// L1: per-instance cache — fastest path for repeated renders of the same
|
|
@@ -618,7 +625,7 @@ export class Markdown implements Component {
|
|
|
618
625
|
|
|
619
626
|
const codeIndent = padding(this.#codeBlockIndent);
|
|
620
627
|
lines.push(this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
|
|
621
|
-
if (this.#theme.highlightCode) {
|
|
628
|
+
if (this.#theme.highlightCode && !this.transientRenderCache) {
|
|
622
629
|
const highlightedLines = this.#theme.highlightCode(token.text, token.lang);
|
|
623
630
|
for (const hlLine of highlightedLines) {
|
|
624
631
|
lines.push(`${codeIndent}${hlLine}`);
|
|
@@ -909,7 +916,7 @@ export class Markdown implements Component {
|
|
|
909
916
|
// Code block in list item
|
|
910
917
|
const codeIndent = padding(this.#codeBlockIndent);
|
|
911
918
|
lines.push({ text: this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`), nested: false });
|
|
912
|
-
if (this.#theme.highlightCode) {
|
|
919
|
+
if (this.#theme.highlightCode && !this.transientRenderCache) {
|
|
913
920
|
const highlightedLines = this.#theme.highlightCode(token.text, token.lang);
|
|
914
921
|
for (const hlLine of highlightedLines) {
|
|
915
922
|
lines.push({ text: `${codeIndent}${hlLine}`, nested: false });
|
package/src/terminal.ts
CHANGED
|
@@ -122,6 +122,37 @@ export function chunkForConPTY(data: string, maxChunkBytes: number = MAX_CONPTY_
|
|
|
122
122
|
let activeTerminal: ProcessTerminal | null = null;
|
|
123
123
|
// Track if a terminal was ever started (for emergency restore logic)
|
|
124
124
|
let terminalEverStarted = false;
|
|
125
|
+
// Whether the alternate screen buffer is currently active (mirrors the TUI's
|
|
126
|
+
// overlay enter/leave writes). Consulted by emergencyTerminalRestore: DECRST
|
|
127
|
+
// 1049 must never be written blindly, because Windows' shared VT dispatcher
|
|
128
|
+
// (conhost and Windows Terminal both use AdaptDispatch) executes an
|
|
129
|
+
// unconditional cursor restore on it — with no prior DECSC save the cursor
|
|
130
|
+
// jumps to the viewport home, dropping the parent shell prompt on top of the
|
|
131
|
+
// dead frame after exit.
|
|
132
|
+
let altScreenActive = false;
|
|
133
|
+
|
|
134
|
+
/** Record alternate-screen state (called by the TUI on `?1049h`/`?1049l` writes). */
|
|
135
|
+
export function setAltScreenActive(active: boolean): void {
|
|
136
|
+
altScreenActive = active;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const stdoutErrorHandlers = new Set<(err: Error) => void>();
|
|
140
|
+
let stdoutErrorListenerInstalled = false;
|
|
141
|
+
|
|
142
|
+
function onStdoutError(err: Error): void {
|
|
143
|
+
for (const handler of stdoutErrorHandlers) handler(err);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function registerStdoutErrorHandler(handler: (err: Error) => void): () => void {
|
|
147
|
+
stdoutErrorHandlers.add(handler);
|
|
148
|
+
if (!stdoutErrorListenerInstalled) {
|
|
149
|
+
process.stdout.on("error", onStdoutError);
|
|
150
|
+
stdoutErrorListenerInstalled = true;
|
|
151
|
+
}
|
|
152
|
+
return () => {
|
|
153
|
+
stdoutErrorHandlers.delete(handler);
|
|
154
|
+
};
|
|
155
|
+
}
|
|
125
156
|
|
|
126
157
|
const STD_INPUT_HANDLE = -10;
|
|
127
158
|
const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200;
|
|
@@ -208,10 +239,15 @@ export function emergencyTerminalRestore(): void {
|
|
|
208
239
|
if (terminal) {
|
|
209
240
|
terminal.stop();
|
|
210
241
|
// stop() never touches the alternate screen — the TUI owns that
|
|
211
|
-
// state and exits it on the normal shutdown path.
|
|
212
|
-
// fullscreen overlay
|
|
213
|
-
//
|
|
214
|
-
|
|
242
|
+
// state and exits it on the normal shutdown path. Only crash paths
|
|
243
|
+
// with a fullscreen overlay still hold the alt buffer here. The
|
|
244
|
+
// leave sequence is gated on the tracked state because it is NOT a
|
|
245
|
+
// universally safe no-op: Windows' VT dispatcher homes the cursor
|
|
246
|
+
// on DECRST 1049 even when the alt buffer is inactive.
|
|
247
|
+
if (altScreenActive) {
|
|
248
|
+
terminal.write("\x1b[?1049l");
|
|
249
|
+
altScreenActive = false;
|
|
250
|
+
}
|
|
215
251
|
terminal.showCursor();
|
|
216
252
|
} else if (terminalEverStarted) {
|
|
217
253
|
// Blind restore only if we know a terminal was started but lost track of it
|
|
@@ -226,9 +262,14 @@ export function emergencyTerminalRestore(): void {
|
|
|
226
262
|
"\x1b[<u" + // Pop kitty keyboard protocol
|
|
227
263
|
"\x1b[>4;0m" + // Disable modifyOtherKeys fallback
|
|
228
264
|
"\x1b[?1006l\x1b[?1003l\x1b[?1000l" + // Disable mouse tracking (fullscreen overlays)
|
|
229
|
-
|
|
265
|
+
// Leave the alternate screen only when a fullscreen overlay
|
|
266
|
+
// actually holds it — on Windows, DECRST 1049 on the main
|
|
267
|
+
// buffer homes the cursor (unconditional CursorRestoreState
|
|
268
|
+
// with no prior save), corrupting the shell handoff on exit.
|
|
269
|
+
(altScreenActive ? "\x1b[?1049l" : "") +
|
|
230
270
|
"\x1b[?25h", // Show cursor
|
|
231
271
|
);
|
|
272
|
+
altScreenActive = false;
|
|
232
273
|
if (process.stdin.setRawMode) {
|
|
233
274
|
process.stdin.setRawMode(false);
|
|
234
275
|
}
|
|
@@ -344,6 +385,11 @@ export class ProcessTerminal implements Terminal {
|
|
|
344
385
|
#stdinDataHandler?: (data: string) => void;
|
|
345
386
|
#dead = false;
|
|
346
387
|
#writeLogPath = $env.PI_TUI_WRITE_LOG || "";
|
|
388
|
+
#stdoutErrorCleanup?: () => void;
|
|
389
|
+
#stdoutErrorHandler = (err: Error) => {
|
|
390
|
+
this.#markTerminalWriteFailed(err);
|
|
391
|
+
};
|
|
392
|
+
|
|
347
393
|
#windowsVTInputRestore?: () => void;
|
|
348
394
|
#appearanceCallbacks: Array<(appearance: TerminalAppearance) => void> = [];
|
|
349
395
|
#appearance: TerminalAppearance | undefined;
|
|
@@ -1182,6 +1228,18 @@ export class ProcessTerminal implements Terminal {
|
|
|
1182
1228
|
if (process.stdin.setRawMode) {
|
|
1183
1229
|
process.stdin.setRawMode(this.#wasRaw);
|
|
1184
1230
|
}
|
|
1231
|
+
this.#stdoutErrorCleanup?.();
|
|
1232
|
+
this.#stdoutErrorCleanup = undefined;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
#ensureStdoutErrorHandler(): void {
|
|
1236
|
+
this.#stdoutErrorCleanup ??= registerStdoutErrorHandler(this.#stdoutErrorHandler);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
#markTerminalWriteFailed(err: unknown): void {
|
|
1240
|
+
if (this.#dead) return;
|
|
1241
|
+
this.#dead = true;
|
|
1242
|
+
logger.warn("terminal write failed; disabling terminal rendering", { err });
|
|
1185
1243
|
}
|
|
1186
1244
|
|
|
1187
1245
|
write(data: string): void {
|
|
@@ -1200,6 +1258,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
1200
1258
|
// Skip control sequences when stdout isn't a TTY (piped output, tests, log
|
|
1201
1259
|
// files). They serve no purpose there and would surface as visible noise.
|
|
1202
1260
|
if (!process.stdout.isTTY) return;
|
|
1261
|
+
this.#ensureStdoutErrorHandler();
|
|
1203
1262
|
// A console-sharing child process may have flipped the console codepage
|
|
1204
1263
|
// away from UTF-8; repair it before any bytes hit WriteFile so no frame
|
|
1205
1264
|
// is ever translated through an OEM codepage. See ensureWindowsConsoleUtf8.
|
|
@@ -1219,15 +1278,14 @@ export class ProcessTerminal implements Terminal {
|
|
|
1219
1278
|
// threshold. See #2034 and #2095.
|
|
1220
1279
|
if (isConPTYHosted() && Buffer.byteLength(data, "utf8") > MAX_CONPTY_WRITE_CHUNK_BYTES) {
|
|
1221
1280
|
for (const chunk of chunkForConPTY(data, MAX_CONPTY_WRITE_CHUNK_BYTES)) {
|
|
1281
|
+
if (this.#dead) break;
|
|
1222
1282
|
process.stdout.write(chunk);
|
|
1223
1283
|
}
|
|
1224
1284
|
} else {
|
|
1225
1285
|
process.stdout.write(data);
|
|
1226
1286
|
}
|
|
1227
1287
|
} catch (err) {
|
|
1228
|
-
|
|
1229
|
-
this.#dead = true;
|
|
1230
|
-
logger.warn("terminal is dead - no recovery possible", { error: err, data });
|
|
1288
|
+
this.#markTerminalWriteFailed(err);
|
|
1231
1289
|
}
|
|
1232
1290
|
}
|
|
1233
1291
|
|
package/src/tui.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { $flag, getDebugLogPath } from "@oh-my-pi/pi-utils";
|
|
|
16
16
|
import { DEFAULT_MAX_INLINE_IMAGES, ImageBudget } from "./components/image";
|
|
17
17
|
import { planDeccaraFills } from "./deccara";
|
|
18
18
|
import { isKeyRelease, matchesKey } from "./keys";
|
|
19
|
-
import { isConPTYHosted, type Terminal } from "./terminal";
|
|
19
|
+
import { isConPTYHosted, setAltScreenActive, type Terminal } from "./terminal";
|
|
20
20
|
import {
|
|
21
21
|
encodeKittyDeleteImage,
|
|
22
22
|
ImageProtocol,
|
|
@@ -191,6 +191,14 @@ export interface NativeScrollbackLiveRegion {
|
|
|
191
191
|
getNativeScrollbackCommitSafeEnd?(): number | undefined;
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
export interface NativeScrollbackCommittedRows {
|
|
195
|
+
setNativeScrollbackCommittedRows(rows: number): void;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function setNativeScrollbackCommittedRows(component: Component, rows: number): void {
|
|
199
|
+
(component as Component & Partial<NativeScrollbackCommittedRows>).setNativeScrollbackCommittedRows?.(rows);
|
|
200
|
+
}
|
|
201
|
+
|
|
194
202
|
function getNativeScrollbackLiveRegionStart(component: Component): number | undefined {
|
|
195
203
|
return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackLiveRegionStart?.();
|
|
196
204
|
}
|
|
@@ -490,14 +498,29 @@ interface CursorControlResult extends HardwareCursorUpdate {
|
|
|
490
498
|
|
|
491
499
|
/**
|
|
492
500
|
* One root child's contribution to the composed frame: the array reference its
|
|
493
|
-
* render() returned, the frame row it starts at,
|
|
494
|
-
* compose time (in-place mutators keep the reference but may change length)
|
|
501
|
+
* render() returned, the frame row it starts at, the row count recorded at
|
|
502
|
+
* compose time (in-place mutators keep the reference but may change length),
|
|
503
|
+
* and the child-local seam reports captured at render time — replayed verbatim
|
|
504
|
+
* when a component-scoped frame reuses this segment without re-rendering.
|
|
495
505
|
*/
|
|
496
506
|
interface FrameSegment {
|
|
497
507
|
component: Component;
|
|
498
508
|
lines: readonly string[];
|
|
499
509
|
start: number;
|
|
500
510
|
rowCount: number;
|
|
511
|
+
liveLocalStart?: number;
|
|
512
|
+
commitLocalEnd?: number;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Depth-first identity search through `Container`-shaped children. */
|
|
516
|
+
function subtreeContains(root: Component, target: Component): boolean {
|
|
517
|
+
if (root === target) return true;
|
|
518
|
+
const children = (root as Partial<Container>).children;
|
|
519
|
+
if (!Array.isArray(children)) return false;
|
|
520
|
+
for (let i = 0; i < children.length; i++) {
|
|
521
|
+
if (subtreeContains(children[i]!, target)) return true;
|
|
522
|
+
}
|
|
523
|
+
return false;
|
|
501
524
|
}
|
|
502
525
|
|
|
503
526
|
interface PreparedLine {
|
|
@@ -711,6 +734,21 @@ export class TUI extends Container {
|
|
|
711
734
|
// Leading rows of #composedFrame byte-identical to the previous compose.
|
|
712
735
|
#renderStablePrefixRows = 0;
|
|
713
736
|
|
|
737
|
+
// Component-scoped render accumulation. Targets are the components handed
|
|
738
|
+
// to requestComponentRender() since the last frame; the flag stays true
|
|
739
|
+
// only while EVERY pending request is component-scoped. Both are consumed
|
|
740
|
+
// once per frame by #doRender.
|
|
741
|
+
#componentRenderTargets = new Set<Component>();
|
|
742
|
+
#pendingRenderComponentsOnly = false;
|
|
743
|
+
// Root children that must re-render during the current compose; null for a
|
|
744
|
+
// full compose. Non-null only for the duration of a component-scoped
|
|
745
|
+
// render() call inside #doRender (the scratch set below, reused per frame).
|
|
746
|
+
#partialComposeRoots: Set<Component> | null = null;
|
|
747
|
+
#partialComposeRootsScratch = new Set<Component>();
|
|
748
|
+
// Target component -> containing root child, so animation-rate requests do
|
|
749
|
+
// not re-walk a huge transcript subtree every frame.
|
|
750
|
+
#componentRootCache = new WeakMap<Component, Component>();
|
|
751
|
+
|
|
714
752
|
// Persistent prepared frame, row-aligned with #composedFrame. Entries store
|
|
715
753
|
// normalized, width-fitted content rows without the per-line terminal
|
|
716
754
|
// terminator; terminators are appended only at write time so width checks
|
|
@@ -750,30 +788,58 @@ export class TUI extends Container {
|
|
|
750
788
|
this.#composeWidth = width;
|
|
751
789
|
let offset = 0;
|
|
752
790
|
let stableRows = 0;
|
|
791
|
+
const partialRoots = this.#partialComposeRoots;
|
|
753
792
|
for (let index = 0; index < children.length; index++) {
|
|
754
793
|
const child = children[index]!;
|
|
755
|
-
const
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
794
|
+
const previous = previousSegments[index];
|
|
795
|
+
// Component-scoped frame: a root child outside every requested
|
|
796
|
+
// subtree provably did not change (content mutations route through
|
|
797
|
+
// a render request, which would have made this frame a full one) —
|
|
798
|
+
// reuse its previous rows and seam report without calling render().
|
|
799
|
+
const reuse =
|
|
800
|
+
partialRoots !== null && previous !== undefined && previous.component === child && !partialRoots.has(child);
|
|
801
|
+
let childLines: readonly string[];
|
|
802
|
+
let liveLocalStart: number | undefined;
|
|
803
|
+
let commitLocalEnd: number | undefined;
|
|
804
|
+
let reported: number | undefined;
|
|
805
|
+
if (reuse) {
|
|
806
|
+
childLines = previous.lines;
|
|
807
|
+
liveLocalStart = previous.liveLocalStart;
|
|
808
|
+
commitLocalEnd = previous.commitLocalEnd;
|
|
809
|
+
} else {
|
|
810
|
+
// Feed the engine's committed-row claim (from the previous frame's
|
|
811
|
+
// emit) before rendering so the child can skip re-deriving blocks
|
|
812
|
+
// that already live in immutable native scrollback. Reused segments
|
|
813
|
+
// skip this: they never call render(), so the signal is moot.
|
|
814
|
+
setNativeScrollbackCommittedRows(child, Math.max(0, this.#committedRows - offset));
|
|
815
|
+
childLines = child.render(width);
|
|
816
|
+
const liveRegionStart = getNativeScrollbackLiveRegionStart(child);
|
|
817
|
+
if (liveRegionStart !== undefined) {
|
|
818
|
+
liveLocalStart = Number.isFinite(liveRegionStart)
|
|
819
|
+
? Math.max(0, Math.min(childLines.length, Math.trunc(liveRegionStart)))
|
|
766
820
|
: childLines.length;
|
|
767
|
-
|
|
821
|
+
const commitSafeEnd = getNativeScrollbackCommitSafeEnd(child);
|
|
822
|
+
if (commitSafeEnd !== undefined) {
|
|
823
|
+
commitLocalEnd = Number.isFinite(commitSafeEnd)
|
|
824
|
+
? Math.max(liveLocalStart, Math.min(childLines.length, Math.trunc(commitSafeEnd)))
|
|
825
|
+
: childLines.length;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
// Consume the stability report unconditionally for implementers:
|
|
829
|
+
// reading re-bases the component's baseline to the state this
|
|
830
|
+
// compose is about to ingest (used or not, the current rows are
|
|
831
|
+
// what ends up in the composed frame). Reused segments are
|
|
832
|
+
// deliberately NOT read — their baseline must stay anchored to
|
|
833
|
+
// the last render the engine actually observed.
|
|
834
|
+
reported = getRenderStablePrefixRows(child);
|
|
835
|
+
}
|
|
836
|
+
if (liveLocalStart !== undefined) {
|
|
837
|
+
this.#nativeScrollbackLiveRegionStart = offset + liveLocalStart;
|
|
838
|
+
if (commitLocalEnd !== undefined) {
|
|
839
|
+
this.#nativeScrollbackCommitSafeEnd = offset + commitLocalEnd;
|
|
768
840
|
}
|
|
769
841
|
}
|
|
770
|
-
// Consume the stability report unconditionally for implementers:
|
|
771
|
-
// reading re-bases the component's baseline to the state this
|
|
772
|
-
// compose is about to ingest (used or not, the current rows are
|
|
773
|
-
// what ends up in the composed frame).
|
|
774
|
-
const reported = getRenderStablePrefixRows(child);
|
|
775
842
|
if (chainStable) {
|
|
776
|
-
const previous = previousSegments[index];
|
|
777
843
|
if (previous !== undefined && previous.component === child && previous.start === offset) {
|
|
778
844
|
let stableCount = 0;
|
|
779
845
|
if (reported !== undefined) {
|
|
@@ -794,7 +860,14 @@ export class TUI extends Container {
|
|
|
794
860
|
chainStable = false;
|
|
795
861
|
}
|
|
796
862
|
}
|
|
797
|
-
segments[index] = {
|
|
863
|
+
segments[index] = {
|
|
864
|
+
component: child,
|
|
865
|
+
lines: childLines,
|
|
866
|
+
start: offset,
|
|
867
|
+
rowCount: childLines.length,
|
|
868
|
+
liveLocalStart,
|
|
869
|
+
commitLocalEnd,
|
|
870
|
+
};
|
|
798
871
|
offset += childLines.length;
|
|
799
872
|
}
|
|
800
873
|
this.#frameSegments = segments;
|
|
@@ -1253,6 +1326,7 @@ export class TUI extends Container {
|
|
|
1253
1326
|
// the restored normal screen (which #previousLines still describes).
|
|
1254
1327
|
if (this.#altActive) {
|
|
1255
1328
|
this.terminal.write(`${MOUSE_TRACKING_OFF}\x1b[?1049l`);
|
|
1329
|
+
setAltScreenActive(false);
|
|
1256
1330
|
this.#altActive = false;
|
|
1257
1331
|
this.#altPreviousLines = [];
|
|
1258
1332
|
}
|
|
@@ -1335,6 +1409,8 @@ export class TUI extends Container {
|
|
|
1335
1409
|
}
|
|
1336
1410
|
|
|
1337
1411
|
requestRender(force = false, options?: RenderRequestOptions): void {
|
|
1412
|
+
// Any non-component-scoped request makes the pending frame a full one.
|
|
1413
|
+
this.#pendingRenderComponentsOnly = false;
|
|
1338
1414
|
if (force) {
|
|
1339
1415
|
// Forced repaints landing inside the multiplexer resize debounce
|
|
1340
1416
|
// (e.g. `#finishSixelProbe`, image-budget eviction, a programmatic
|
|
@@ -1365,6 +1441,38 @@ export class TUI extends Container {
|
|
|
1365
1441
|
});
|
|
1366
1442
|
return;
|
|
1367
1443
|
}
|
|
1444
|
+
this.#requestOrdinaryRender();
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/**
|
|
1448
|
+
* Schedule a render on behalf of `component` after a self-contained change
|
|
1449
|
+
* (spinner frame, blink) that cannot have affected any other component.
|
|
1450
|
+
*
|
|
1451
|
+
* When every request since the last frame is component-scoped and the
|
|
1452
|
+
* frame is otherwise quiet — no resize or geometry change, no overlays, no
|
|
1453
|
+
* live inline images, no forced repaint, unchanged root child list — the
|
|
1454
|
+
* next compose re-renders only the root subtrees containing the requesting
|
|
1455
|
+
* components and reuses the previous frame's rows (and seam reports) for
|
|
1456
|
+
* every other root child, skipping the full component-tree walk that makes
|
|
1457
|
+
* long transcripts expensive to repaint at animation rate. Any concurrent
|
|
1458
|
+
* full request or unsafe condition downgrades the frame to a normal full
|
|
1459
|
+
* compose, so this is never less correct than `requestRender()` — only
|
|
1460
|
+
* cheaper.
|
|
1461
|
+
*/
|
|
1462
|
+
requestComponentRender(component: Component): void {
|
|
1463
|
+
if (this.#stopped) return;
|
|
1464
|
+
// Start a component-scoped accumulation only when nothing else is in
|
|
1465
|
+
// flight (a pending throttled request or a deferred ConPTY settle
|
|
1466
|
+
// replay may carry full-render intent that must not be narrowed).
|
|
1467
|
+
if (!this.#renderRequested && this.#postFullPaintSettleTimer === undefined) {
|
|
1468
|
+
this.#pendingRenderComponentsOnly = true;
|
|
1469
|
+
}
|
|
1470
|
+
this.#componentRenderTargets.add(component);
|
|
1471
|
+
this.#requestOrdinaryRender();
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/** Ordinary (non-forced) scheduling shared by full and component-scoped requests. */
|
|
1475
|
+
#requestOrdinaryRender(): void {
|
|
1368
1476
|
// Coalesce non-forced renders inside the post-full-paint ConPTY settle
|
|
1369
1477
|
// window into one trailing render. Spinner/blink/streaming components
|
|
1370
1478
|
// otherwise fire `requestRender(false)` at 30 Hz while the host is still
|
|
@@ -1379,7 +1487,7 @@ export class TUI extends Container {
|
|
|
1379
1487
|
this.#postFullPaintSettleTimer = undefined;
|
|
1380
1488
|
this.#postFullPaintSettleUntilMs = 0;
|
|
1381
1489
|
if (this.#stopped) return;
|
|
1382
|
-
this
|
|
1490
|
+
this.#requestOrdinaryRender();
|
|
1383
1491
|
}, this.#postFullPaintSettleUntilMs - now);
|
|
1384
1492
|
}
|
|
1385
1493
|
return;
|
|
@@ -1391,6 +1499,56 @@ export class TUI extends Container {
|
|
|
1391
1499
|
this.#renderScheduler.scheduleImmediate(() => this.#scheduleRender());
|
|
1392
1500
|
}
|
|
1393
1501
|
|
|
1502
|
+
/**
|
|
1503
|
+
* Decide whether this frame may compose component-scoped, and resolve the
|
|
1504
|
+
* requested components to the root children that must re-render. Returns
|
|
1505
|
+
* null — full compose — whenever a global condition could invalidate rows
|
|
1506
|
+
* the partial compose would reuse, or when a requested component is not
|
|
1507
|
+
* reachable from the current root child list.
|
|
1508
|
+
*/
|
|
1509
|
+
#resolvePartialComposeRoots(width: number, height: number): Set<Component> | null {
|
|
1510
|
+
if (this.#componentRenderTargets.size === 0) return null;
|
|
1511
|
+
if (!this.#hasEverRendered || this.#resizeEventPending) return null;
|
|
1512
|
+
if (width !== this.#previousWidth || height !== this.#previousHeight || width !== this.#composeWidth) return null;
|
|
1513
|
+
if (this.#clearScrollbackOnNextRender || this.#forceViewportRepaintOnNextRender) return null;
|
|
1514
|
+
if (this.overlayStack.length > 0) return null;
|
|
1515
|
+
// The image budget audits display order across the whole frame; a
|
|
1516
|
+
// partial walk would under-count it. Engage only on image-free frames.
|
|
1517
|
+
if (!this.#imageBudget.quiescent) return null;
|
|
1518
|
+
// The root child list must match the segment ledger exactly — a
|
|
1519
|
+
// structural change shifts offsets under every reused segment.
|
|
1520
|
+
const children = this.children;
|
|
1521
|
+
const segments = this.#frameSegments;
|
|
1522
|
+
if (segments.length !== children.length) return null;
|
|
1523
|
+
for (let i = 0; i < children.length; i++) {
|
|
1524
|
+
if (segments[i]!.component !== children[i]) return null;
|
|
1525
|
+
}
|
|
1526
|
+
const roots = this.#partialComposeRootsScratch;
|
|
1527
|
+
roots.clear();
|
|
1528
|
+
for (const target of this.#componentRenderTargets) {
|
|
1529
|
+
const root = this.#resolveComponentRoot(target);
|
|
1530
|
+
if (root === null) return null;
|
|
1531
|
+
roots.add(root);
|
|
1532
|
+
}
|
|
1533
|
+
return roots;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
/** Root child whose subtree contains `target`, memoized per component. */
|
|
1537
|
+
#resolveComponentRoot(target: Component): Component | null {
|
|
1538
|
+
const cached = this.#componentRootCache.get(target);
|
|
1539
|
+
if (cached !== undefined && this.children.includes(cached) && subtreeContains(cached, target)) {
|
|
1540
|
+
return cached;
|
|
1541
|
+
}
|
|
1542
|
+
for (const child of this.children) {
|
|
1543
|
+
if (subtreeContains(child, target)) {
|
|
1544
|
+
this.#componentRootCache.set(target, child);
|
|
1545
|
+
return child;
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
this.#componentRootCache.delete(target);
|
|
1549
|
+
return null;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1394
1552
|
/**
|
|
1395
1553
|
* Arm or extend the multiplexer-resize debounce so a single forced render
|
|
1396
1554
|
* fires once the pane is quiet. Called by the SIGWINCH callback on every
|
|
@@ -1468,7 +1626,7 @@ export class TUI extends Container {
|
|
|
1468
1626
|
this.#postFullPaintSettleTimer = undefined;
|
|
1469
1627
|
this.#postFullPaintSettleUntilMs = 0;
|
|
1470
1628
|
if (this.#stopped) return;
|
|
1471
|
-
this
|
|
1629
|
+
this.#requestOrdinaryRender();
|
|
1472
1630
|
}, TUI.#CONPTY_POST_FULL_PAINT_SETTLE_MS);
|
|
1473
1631
|
}
|
|
1474
1632
|
}
|
|
@@ -1881,12 +2039,18 @@ export class TUI extends Container {
|
|
|
1881
2039
|
const width = this.terminal.columns;
|
|
1882
2040
|
const height = this.terminal.rows;
|
|
1883
2041
|
|
|
2042
|
+
// Consume the component-scoped accumulation: it describes the render
|
|
2043
|
+
// requests made up to this frame, whichever path the frame takes.
|
|
2044
|
+
const componentScopedOnly = this.#pendingRenderComponentsOnly;
|
|
2045
|
+
this.#pendingRenderComponentsOnly = false;
|
|
2046
|
+
|
|
1884
2047
|
// Fullscreen alt-screen short-circuit. While the topmost visible overlay
|
|
1885
2048
|
// requests it, borrow the terminal's alternate buffer and paint only the
|
|
1886
2049
|
// modal there; the normal screen and all accounting stay untouched.
|
|
1887
2050
|
const wantAlt = this.#wantsAltScreen();
|
|
1888
2051
|
if (wantAlt && !this.#altActive) {
|
|
1889
2052
|
this.terminal.write(`\x1b[?1049h${MOUSE_TRACKING_ON}`);
|
|
2053
|
+
setAltScreenActive(true);
|
|
1890
2054
|
this.terminal.hideCursor();
|
|
1891
2055
|
this.#forgetHardwareCursorState();
|
|
1892
2056
|
this.#recordHardwareCursorHidden();
|
|
@@ -1896,6 +2060,7 @@ export class TUI extends Container {
|
|
|
1896
2060
|
this.#altEnterHeight = height;
|
|
1897
2061
|
} else if (!wantAlt && this.#altActive) {
|
|
1898
2062
|
this.terminal.write(`${MOUSE_TRACKING_OFF}\x1b[?1049l`);
|
|
2063
|
+
setAltScreenActive(false);
|
|
1899
2064
|
this.#forgetHardwareCursorState();
|
|
1900
2065
|
this.#altActive = false;
|
|
1901
2066
|
this.#altPreviousLines = [];
|
|
@@ -1907,15 +2072,32 @@ export class TUI extends Container {
|
|
|
1907
2072
|
}
|
|
1908
2073
|
}
|
|
1909
2074
|
if (this.#altActive) {
|
|
2075
|
+
this.#componentRenderTargets.clear();
|
|
1910
2076
|
this.#renderAltFrame(width, height);
|
|
1911
2077
|
return;
|
|
1912
2078
|
}
|
|
1913
2079
|
|
|
1914
2080
|
// 1. Compose the frame. Bracket the render so the image budget observes
|
|
1915
|
-
// every inline image in display order (overlays carry none).
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
2081
|
+
// every inline image in display order (overlays carry none). A
|
|
2082
|
+
// component-scoped frame skips the budget pass instead — it is gated on
|
|
2083
|
+
// a quiescent budget, and a partial tree walk would under-count display
|
|
2084
|
+
// order — and re-renders only the requested root subtrees, reusing the
|
|
2085
|
+
// previous segment of every other root child.
|
|
2086
|
+
const partialRoots = componentScopedOnly ? this.#resolvePartialComposeRoots(width, height) : null;
|
|
2087
|
+
this.#componentRenderTargets.clear();
|
|
2088
|
+
let rawFrame: readonly string[];
|
|
2089
|
+
if (partialRoots !== null) {
|
|
2090
|
+
this.#partialComposeRoots = partialRoots;
|
|
2091
|
+
try {
|
|
2092
|
+
rawFrame = this.render(width);
|
|
2093
|
+
} finally {
|
|
2094
|
+
this.#partialComposeRoots = null;
|
|
2095
|
+
}
|
|
2096
|
+
} else {
|
|
2097
|
+
this.#imageBudget.beginPass();
|
|
2098
|
+
rawFrame = this.render(width);
|
|
2099
|
+
this.#imageBudget.endPass();
|
|
2100
|
+
}
|
|
1919
2101
|
// Ghostty initial-image deferral must run before any render state is
|
|
1920
2102
|
// consumed (#resizeEventPending, hardware-cursor state, commit
|
|
1921
2103
|
// re-anchoring): the early return abandons this frame and the deferred
|
|
@@ -1958,13 +2140,16 @@ export class TUI extends Container {
|
|
|
1958
2140
|
// composed frame's stable prefix covers every committed row — bytes
|
|
1959
2141
|
// that provably did not change since the last (aligned) frame cannot
|
|
1960
2142
|
// have diverged.
|
|
2143
|
+
let committedRowsResynced = false;
|
|
1961
2144
|
if (
|
|
1962
2145
|
this.#hasEverRendered &&
|
|
1963
2146
|
!geometryChanged &&
|
|
1964
2147
|
!this.#clearScrollbackOnNextRender &&
|
|
1965
2148
|
this.#renderStablePrefixRows < this.#committedRows
|
|
1966
2149
|
) {
|
|
2150
|
+
const committedRowsBeforeAudit = this.#committedRows;
|
|
1967
2151
|
this.#auditCommittedPrefix(rawFrame);
|
|
2152
|
+
committedRowsResynced = this.#committedRows !== committedRowsBeforeAudit;
|
|
1968
2153
|
}
|
|
1969
2154
|
|
|
1970
2155
|
// 3. Window and commit math (lengths only; content prepared below).
|
|
@@ -1995,14 +2180,22 @@ export class TUI extends Container {
|
|
|
1995
2180
|
if (fullPaint) {
|
|
1996
2181
|
windowTop = Math.max(0, frameLength - height);
|
|
1997
2182
|
chunkTo = Math.min(commitBoundary, windowTop);
|
|
1998
|
-
} else if (
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
//
|
|
2005
|
-
//
|
|
2183
|
+
} else if (
|
|
2184
|
+
frameLength <= this.#committedRows ||
|
|
2185
|
+
(committedRowsResynced &&
|
|
2186
|
+
frameLength - this.#committedRows < height &&
|
|
2187
|
+
cursorMarkers.some(marker => marker.row >= this.#committedRows))
|
|
2188
|
+
) {
|
|
2189
|
+
// Either the frame shrank into the committed prefix, or a
|
|
2190
|
+
// committed-prefix resync left a focused cursor tail shorter than the
|
|
2191
|
+
// viewport. The latter happens when a streaming/live block had an
|
|
2192
|
+
// append-only prefix committed, then collapses on abort/finalize:
|
|
2193
|
+
// the audit re-anchors #committedRows at the first divergent row, but
|
|
2194
|
+
// flooring windowTop there would pin the editor near the top and
|
|
2195
|
+
// leave blank rows underneath. Re-show the frame tail instead. The
|
|
2196
|
+
// stale committed copy stays in native history; duplicating a few rows
|
|
2197
|
+
// is preferable to a live editor gap and matches the existing
|
|
2198
|
+
// "duplication, never loss" resync contract.
|
|
2006
2199
|
windowTop = Math.max(0, frameLength - height);
|
|
2007
2200
|
chunkTo = Math.min(commitBoundary, windowTop);
|
|
2008
2201
|
this.#committedRows = chunkTo;
|