@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 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
  }
@@ -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.0",
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.0",
41
- "@oh-my-pi/pi-utils": "15.11.0",
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
  },
@@ -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;
@@ -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.#ui.requestRender();
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
- // Any write failure means terminal is dead - no recovery possible
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, and the row count recorded 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 childLines = child.render(width);
756
- const liveRegionStart = getNativeScrollbackLiveRegionStart(child);
757
- if (liveRegionStart !== undefined) {
758
- const boundedStart = Number.isFinite(liveRegionStart)
759
- ? Math.max(0, Math.min(childLines.length, Math.trunc(liveRegionStart)))
760
- : childLines.length;
761
- this.#nativeScrollbackLiveRegionStart = offset + boundedStart;
762
- const commitSafeEnd = getNativeScrollbackCommitSafeEnd(child);
763
- if (commitSafeEnd !== undefined) {
764
- const boundedEnd = Number.isFinite(commitSafeEnd)
765
- ? Math.max(boundedStart, Math.min(childLines.length, Math.trunc(commitSafeEnd)))
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
- this.#nativeScrollbackCommitSafeEnd = offset + boundedEnd;
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] = { component: child, lines: childLines, start: offset, rowCount: childLines.length };
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.requestRender(false);
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.requestRender(false);
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
- this.#imageBudget.beginPass();
1917
- const rawFrame = this.render(width);
1918
- this.#imageBudget.endPass();
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 (frameLength <= this.#committedRows) {
1999
- // The frame shrank into (or below) the committed prefix: the app
2000
- // replaced content it had already let scroll into history without
2001
- // requesting a session replace. History is immutable without a
2002
- // gesture, so the stale committed copy stays in scrollback;
2003
- // re-anchor the window at the tail and restart commit bookkeeping
2004
- // there so the live grid shows the real content instead of a blank
2005
- // pinned window.
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;