@oh-my-pi/pi-tui 15.11.0 → 15.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +1 -0
- package/dist/types/components/image.d.ts +7 -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/terminal.ts +38 -3
- package/src/tui.ts +225 -35
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.11.1] - 2026-06-11
|
|
6
|
+
### Added
|
|
7
|
+
|
|
8
|
+
- Added `TUI.requestComponentRender(component)` to schedule component-scoped renders for self-contained updates
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- 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
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Fixed component-scoped renders to preserve prior live scrollback seam data for skipped root children, preventing duplicate or missing rows during spinner-only updates
|
|
17
|
+
- Reported committed native scrollback row counts to interested child components so immutable history can be skipped without breaking live-region commit bookkeeping.
|
|
18
|
+
- 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)).
|
|
19
|
+
|
|
5
20
|
## [15.11.0] - 2026-06-10
|
|
6
21
|
### Added
|
|
7
22
|
|
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
|
}
|
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.1",
|
|
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.1",
|
|
41
|
+
"@oh-my-pi/pi-utils": "15.11.1",
|
|
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
|
}
|
package/src/terminal.ts
CHANGED
|
@@ -123,6 +123,24 @@ let activeTerminal: ProcessTerminal | null = null;
|
|
|
123
123
|
// Track if a terminal was ever started (for emergency restore logic)
|
|
124
124
|
let terminalEverStarted = false;
|
|
125
125
|
|
|
126
|
+
const stdoutErrorHandlers = new Set<(err: Error) => void>();
|
|
127
|
+
let stdoutErrorListenerInstalled = false;
|
|
128
|
+
|
|
129
|
+
function onStdoutError(err: Error): void {
|
|
130
|
+
for (const handler of stdoutErrorHandlers) handler(err);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function registerStdoutErrorHandler(handler: (err: Error) => void): () => void {
|
|
134
|
+
stdoutErrorHandlers.add(handler);
|
|
135
|
+
if (!stdoutErrorListenerInstalled) {
|
|
136
|
+
process.stdout.on("error", onStdoutError);
|
|
137
|
+
stdoutErrorListenerInstalled = true;
|
|
138
|
+
}
|
|
139
|
+
return () => {
|
|
140
|
+
stdoutErrorHandlers.delete(handler);
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
126
144
|
const STD_INPUT_HANDLE = -10;
|
|
127
145
|
const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200;
|
|
128
146
|
/** UTF-8 codepage id for SetConsoleCP/SetConsoleOutputCP. */
|
|
@@ -344,6 +362,11 @@ export class ProcessTerminal implements Terminal {
|
|
|
344
362
|
#stdinDataHandler?: (data: string) => void;
|
|
345
363
|
#dead = false;
|
|
346
364
|
#writeLogPath = $env.PI_TUI_WRITE_LOG || "";
|
|
365
|
+
#stdoutErrorCleanup?: () => void;
|
|
366
|
+
#stdoutErrorHandler = (err: Error) => {
|
|
367
|
+
this.#markTerminalWriteFailed(err);
|
|
368
|
+
};
|
|
369
|
+
|
|
347
370
|
#windowsVTInputRestore?: () => void;
|
|
348
371
|
#appearanceCallbacks: Array<(appearance: TerminalAppearance) => void> = [];
|
|
349
372
|
#appearance: TerminalAppearance | undefined;
|
|
@@ -1182,6 +1205,18 @@ export class ProcessTerminal implements Terminal {
|
|
|
1182
1205
|
if (process.stdin.setRawMode) {
|
|
1183
1206
|
process.stdin.setRawMode(this.#wasRaw);
|
|
1184
1207
|
}
|
|
1208
|
+
this.#stdoutErrorCleanup?.();
|
|
1209
|
+
this.#stdoutErrorCleanup = undefined;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
#ensureStdoutErrorHandler(): void {
|
|
1213
|
+
this.#stdoutErrorCleanup ??= registerStdoutErrorHandler(this.#stdoutErrorHandler);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
#markTerminalWriteFailed(err: unknown): void {
|
|
1217
|
+
if (this.#dead) return;
|
|
1218
|
+
this.#dead = true;
|
|
1219
|
+
logger.warn("terminal write failed; disabling terminal rendering", { err });
|
|
1185
1220
|
}
|
|
1186
1221
|
|
|
1187
1222
|
write(data: string): void {
|
|
@@ -1200,6 +1235,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
1200
1235
|
// Skip control sequences when stdout isn't a TTY (piped output, tests, log
|
|
1201
1236
|
// files). They serve no purpose there and would surface as visible noise.
|
|
1202
1237
|
if (!process.stdout.isTTY) return;
|
|
1238
|
+
this.#ensureStdoutErrorHandler();
|
|
1203
1239
|
// A console-sharing child process may have flipped the console codepage
|
|
1204
1240
|
// away from UTF-8; repair it before any bytes hit WriteFile so no frame
|
|
1205
1241
|
// is ever translated through an OEM codepage. See ensureWindowsConsoleUtf8.
|
|
@@ -1219,15 +1255,14 @@ export class ProcessTerminal implements Terminal {
|
|
|
1219
1255
|
// threshold. See #2034 and #2095.
|
|
1220
1256
|
if (isConPTYHosted() && Buffer.byteLength(data, "utf8") > MAX_CONPTY_WRITE_CHUNK_BYTES) {
|
|
1221
1257
|
for (const chunk of chunkForConPTY(data, MAX_CONPTY_WRITE_CHUNK_BYTES)) {
|
|
1258
|
+
if (this.#dead) break;
|
|
1222
1259
|
process.stdout.write(chunk);
|
|
1223
1260
|
}
|
|
1224
1261
|
} else {
|
|
1225
1262
|
process.stdout.write(data);
|
|
1226
1263
|
}
|
|
1227
1264
|
} catch (err) {
|
|
1228
|
-
|
|
1229
|
-
this.#dead = true;
|
|
1230
|
-
logger.warn("terminal is dead - no recovery possible", { error: err, data });
|
|
1265
|
+
this.#markTerminalWriteFailed(err);
|
|
1231
1266
|
}
|
|
1232
1267
|
}
|
|
1233
1268
|
|
package/src/tui.ts
CHANGED
|
@@ -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;
|
|
@@ -1335,6 +1408,8 @@ export class TUI extends Container {
|
|
|
1335
1408
|
}
|
|
1336
1409
|
|
|
1337
1410
|
requestRender(force = false, options?: RenderRequestOptions): void {
|
|
1411
|
+
// Any non-component-scoped request makes the pending frame a full one.
|
|
1412
|
+
this.#pendingRenderComponentsOnly = false;
|
|
1338
1413
|
if (force) {
|
|
1339
1414
|
// Forced repaints landing inside the multiplexer resize debounce
|
|
1340
1415
|
// (e.g. `#finishSixelProbe`, image-budget eviction, a programmatic
|
|
@@ -1365,6 +1440,38 @@ export class TUI extends Container {
|
|
|
1365
1440
|
});
|
|
1366
1441
|
return;
|
|
1367
1442
|
}
|
|
1443
|
+
this.#requestOrdinaryRender();
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/**
|
|
1447
|
+
* Schedule a render on behalf of `component` after a self-contained change
|
|
1448
|
+
* (spinner frame, blink) that cannot have affected any other component.
|
|
1449
|
+
*
|
|
1450
|
+
* When every request since the last frame is component-scoped and the
|
|
1451
|
+
* frame is otherwise quiet — no resize or geometry change, no overlays, no
|
|
1452
|
+
* live inline images, no forced repaint, unchanged root child list — the
|
|
1453
|
+
* next compose re-renders only the root subtrees containing the requesting
|
|
1454
|
+
* components and reuses the previous frame's rows (and seam reports) for
|
|
1455
|
+
* every other root child, skipping the full component-tree walk that makes
|
|
1456
|
+
* long transcripts expensive to repaint at animation rate. Any concurrent
|
|
1457
|
+
* full request or unsafe condition downgrades the frame to a normal full
|
|
1458
|
+
* compose, so this is never less correct than `requestRender()` — only
|
|
1459
|
+
* cheaper.
|
|
1460
|
+
*/
|
|
1461
|
+
requestComponentRender(component: Component): void {
|
|
1462
|
+
if (this.#stopped) return;
|
|
1463
|
+
// Start a component-scoped accumulation only when nothing else is in
|
|
1464
|
+
// flight (a pending throttled request or a deferred ConPTY settle
|
|
1465
|
+
// replay may carry full-render intent that must not be narrowed).
|
|
1466
|
+
if (!this.#renderRequested && this.#postFullPaintSettleTimer === undefined) {
|
|
1467
|
+
this.#pendingRenderComponentsOnly = true;
|
|
1468
|
+
}
|
|
1469
|
+
this.#componentRenderTargets.add(component);
|
|
1470
|
+
this.#requestOrdinaryRender();
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/** Ordinary (non-forced) scheduling shared by full and component-scoped requests. */
|
|
1474
|
+
#requestOrdinaryRender(): void {
|
|
1368
1475
|
// Coalesce non-forced renders inside the post-full-paint ConPTY settle
|
|
1369
1476
|
// window into one trailing render. Spinner/blink/streaming components
|
|
1370
1477
|
// otherwise fire `requestRender(false)` at 30 Hz while the host is still
|
|
@@ -1379,7 +1486,7 @@ export class TUI extends Container {
|
|
|
1379
1486
|
this.#postFullPaintSettleTimer = undefined;
|
|
1380
1487
|
this.#postFullPaintSettleUntilMs = 0;
|
|
1381
1488
|
if (this.#stopped) return;
|
|
1382
|
-
this
|
|
1489
|
+
this.#requestOrdinaryRender();
|
|
1383
1490
|
}, this.#postFullPaintSettleUntilMs - now);
|
|
1384
1491
|
}
|
|
1385
1492
|
return;
|
|
@@ -1391,6 +1498,56 @@ export class TUI extends Container {
|
|
|
1391
1498
|
this.#renderScheduler.scheduleImmediate(() => this.#scheduleRender());
|
|
1392
1499
|
}
|
|
1393
1500
|
|
|
1501
|
+
/**
|
|
1502
|
+
* Decide whether this frame may compose component-scoped, and resolve the
|
|
1503
|
+
* requested components to the root children that must re-render. Returns
|
|
1504
|
+
* null — full compose — whenever a global condition could invalidate rows
|
|
1505
|
+
* the partial compose would reuse, or when a requested component is not
|
|
1506
|
+
* reachable from the current root child list.
|
|
1507
|
+
*/
|
|
1508
|
+
#resolvePartialComposeRoots(width: number, height: number): Set<Component> | null {
|
|
1509
|
+
if (this.#componentRenderTargets.size === 0) return null;
|
|
1510
|
+
if (!this.#hasEverRendered || this.#resizeEventPending) return null;
|
|
1511
|
+
if (width !== this.#previousWidth || height !== this.#previousHeight || width !== this.#composeWidth) return null;
|
|
1512
|
+
if (this.#clearScrollbackOnNextRender || this.#forceViewportRepaintOnNextRender) return null;
|
|
1513
|
+
if (this.overlayStack.length > 0) return null;
|
|
1514
|
+
// The image budget audits display order across the whole frame; a
|
|
1515
|
+
// partial walk would under-count it. Engage only on image-free frames.
|
|
1516
|
+
if (!this.#imageBudget.quiescent) return null;
|
|
1517
|
+
// The root child list must match the segment ledger exactly — a
|
|
1518
|
+
// structural change shifts offsets under every reused segment.
|
|
1519
|
+
const children = this.children;
|
|
1520
|
+
const segments = this.#frameSegments;
|
|
1521
|
+
if (segments.length !== children.length) return null;
|
|
1522
|
+
for (let i = 0; i < children.length; i++) {
|
|
1523
|
+
if (segments[i]!.component !== children[i]) return null;
|
|
1524
|
+
}
|
|
1525
|
+
const roots = this.#partialComposeRootsScratch;
|
|
1526
|
+
roots.clear();
|
|
1527
|
+
for (const target of this.#componentRenderTargets) {
|
|
1528
|
+
const root = this.#resolveComponentRoot(target);
|
|
1529
|
+
if (root === null) return null;
|
|
1530
|
+
roots.add(root);
|
|
1531
|
+
}
|
|
1532
|
+
return roots;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
/** Root child whose subtree contains `target`, memoized per component. */
|
|
1536
|
+
#resolveComponentRoot(target: Component): Component | null {
|
|
1537
|
+
const cached = this.#componentRootCache.get(target);
|
|
1538
|
+
if (cached !== undefined && this.children.includes(cached) && subtreeContains(cached, target)) {
|
|
1539
|
+
return cached;
|
|
1540
|
+
}
|
|
1541
|
+
for (const child of this.children) {
|
|
1542
|
+
if (subtreeContains(child, target)) {
|
|
1543
|
+
this.#componentRootCache.set(target, child);
|
|
1544
|
+
return child;
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
this.#componentRootCache.delete(target);
|
|
1548
|
+
return null;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1394
1551
|
/**
|
|
1395
1552
|
* Arm or extend the multiplexer-resize debounce so a single forced render
|
|
1396
1553
|
* fires once the pane is quiet. Called by the SIGWINCH callback on every
|
|
@@ -1468,7 +1625,7 @@ export class TUI extends Container {
|
|
|
1468
1625
|
this.#postFullPaintSettleTimer = undefined;
|
|
1469
1626
|
this.#postFullPaintSettleUntilMs = 0;
|
|
1470
1627
|
if (this.#stopped) return;
|
|
1471
|
-
this
|
|
1628
|
+
this.#requestOrdinaryRender();
|
|
1472
1629
|
}, TUI.#CONPTY_POST_FULL_PAINT_SETTLE_MS);
|
|
1473
1630
|
}
|
|
1474
1631
|
}
|
|
@@ -1881,6 +2038,11 @@ export class TUI extends Container {
|
|
|
1881
2038
|
const width = this.terminal.columns;
|
|
1882
2039
|
const height = this.terminal.rows;
|
|
1883
2040
|
|
|
2041
|
+
// Consume the component-scoped accumulation: it describes the render
|
|
2042
|
+
// requests made up to this frame, whichever path the frame takes.
|
|
2043
|
+
const componentScopedOnly = this.#pendingRenderComponentsOnly;
|
|
2044
|
+
this.#pendingRenderComponentsOnly = false;
|
|
2045
|
+
|
|
1884
2046
|
// Fullscreen alt-screen short-circuit. While the topmost visible overlay
|
|
1885
2047
|
// requests it, borrow the terminal's alternate buffer and paint only the
|
|
1886
2048
|
// modal there; the normal screen and all accounting stay untouched.
|
|
@@ -1907,15 +2069,32 @@ export class TUI extends Container {
|
|
|
1907
2069
|
}
|
|
1908
2070
|
}
|
|
1909
2071
|
if (this.#altActive) {
|
|
2072
|
+
this.#componentRenderTargets.clear();
|
|
1910
2073
|
this.#renderAltFrame(width, height);
|
|
1911
2074
|
return;
|
|
1912
2075
|
}
|
|
1913
2076
|
|
|
1914
2077
|
// 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
|
-
|
|
2078
|
+
// every inline image in display order (overlays carry none). A
|
|
2079
|
+
// component-scoped frame skips the budget pass instead — it is gated on
|
|
2080
|
+
// a quiescent budget, and a partial tree walk would under-count display
|
|
2081
|
+
// order — and re-renders only the requested root subtrees, reusing the
|
|
2082
|
+
// previous segment of every other root child.
|
|
2083
|
+
const partialRoots = componentScopedOnly ? this.#resolvePartialComposeRoots(width, height) : null;
|
|
2084
|
+
this.#componentRenderTargets.clear();
|
|
2085
|
+
let rawFrame: readonly string[];
|
|
2086
|
+
if (partialRoots !== null) {
|
|
2087
|
+
this.#partialComposeRoots = partialRoots;
|
|
2088
|
+
try {
|
|
2089
|
+
rawFrame = this.render(width);
|
|
2090
|
+
} finally {
|
|
2091
|
+
this.#partialComposeRoots = null;
|
|
2092
|
+
}
|
|
2093
|
+
} else {
|
|
2094
|
+
this.#imageBudget.beginPass();
|
|
2095
|
+
rawFrame = this.render(width);
|
|
2096
|
+
this.#imageBudget.endPass();
|
|
2097
|
+
}
|
|
1919
2098
|
// Ghostty initial-image deferral must run before any render state is
|
|
1920
2099
|
// consumed (#resizeEventPending, hardware-cursor state, commit
|
|
1921
2100
|
// re-anchoring): the early return abandons this frame and the deferred
|
|
@@ -1958,13 +2137,16 @@ export class TUI extends Container {
|
|
|
1958
2137
|
// composed frame's stable prefix covers every committed row — bytes
|
|
1959
2138
|
// that provably did not change since the last (aligned) frame cannot
|
|
1960
2139
|
// have diverged.
|
|
2140
|
+
let committedRowsResynced = false;
|
|
1961
2141
|
if (
|
|
1962
2142
|
this.#hasEverRendered &&
|
|
1963
2143
|
!geometryChanged &&
|
|
1964
2144
|
!this.#clearScrollbackOnNextRender &&
|
|
1965
2145
|
this.#renderStablePrefixRows < this.#committedRows
|
|
1966
2146
|
) {
|
|
2147
|
+
const committedRowsBeforeAudit = this.#committedRows;
|
|
1967
2148
|
this.#auditCommittedPrefix(rawFrame);
|
|
2149
|
+
committedRowsResynced = this.#committedRows !== committedRowsBeforeAudit;
|
|
1968
2150
|
}
|
|
1969
2151
|
|
|
1970
2152
|
// 3. Window and commit math (lengths only; content prepared below).
|
|
@@ -1995,14 +2177,22 @@ export class TUI extends Container {
|
|
|
1995
2177
|
if (fullPaint) {
|
|
1996
2178
|
windowTop = Math.max(0, frameLength - height);
|
|
1997
2179
|
chunkTo = Math.min(commitBoundary, windowTop);
|
|
1998
|
-
} else if (
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
//
|
|
2005
|
-
//
|
|
2180
|
+
} else if (
|
|
2181
|
+
frameLength <= this.#committedRows ||
|
|
2182
|
+
(committedRowsResynced &&
|
|
2183
|
+
frameLength - this.#committedRows < height &&
|
|
2184
|
+
cursorMarkers.some(marker => marker.row >= this.#committedRows))
|
|
2185
|
+
) {
|
|
2186
|
+
// Either the frame shrank into the committed prefix, or a
|
|
2187
|
+
// committed-prefix resync left a focused cursor tail shorter than the
|
|
2188
|
+
// viewport. The latter happens when a streaming/live block had an
|
|
2189
|
+
// append-only prefix committed, then collapses on abort/finalize:
|
|
2190
|
+
// the audit re-anchors #committedRows at the first divergent row, but
|
|
2191
|
+
// flooring windowTop there would pin the editor near the top and
|
|
2192
|
+
// leave blank rows underneath. Re-show the frame tail instead. The
|
|
2193
|
+
// stale committed copy stays in native history; duplicating a few rows
|
|
2194
|
+
// is preferable to a live editor gap and matches the existing
|
|
2195
|
+
// "duplication, never loss" resync contract.
|
|
2006
2196
|
windowTop = Math.max(0, frameLength - height);
|
|
2007
2197
|
chunkTo = Math.min(commitBoundary, windowTop);
|
|
2008
2198
|
this.#committedRows = chunkTo;
|