@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 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
  /**
@@ -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
@@ -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.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.0",
41
- "@oh-my-pi/pi-utils": "15.11.0",
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
  },
@@ -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
  }
@@ -295,10 +295,7 @@ export class Markdown implements Component {
295
295
  #cachedText?: string;
296
296
  #cachedWidth?: number;
297
297
  #cachedLines?: readonly string[];
298
- /** When true, skip the module-level LRU (lookup and insert) for this instance's
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. A crash while a
212
- // fullscreen overlay is up would otherwise strand the shell on the
213
- // alt buffer. Safe no-op when the alt screen is not active.
214
- terminal.write("\x1b[?1049l");
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
- "\x1b[?1049l" + // Leave the alternate screen (fullscreen overlays)
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
- // 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 });
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, 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;
@@ -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.requestRender(false);
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.requestRender(false);
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
- this.#imageBudget.beginPass();
1917
- const rawFrame = this.render(width);
1918
- this.#imageBudget.endPass();
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 (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.
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;