@gajae-code/tui 0.13.3 → 0.14.0

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/src/tui.ts CHANGED
@@ -32,6 +32,67 @@ import {
32
32
  visibleWidth,
33
33
  visibleWidths,
34
34
  } from "./utils";
35
+ export type CellRect = Readonly<{ column: number; row: number; width: number; height: number }>;
36
+ export type RasterLeaseToken = Readonly<{ ownerId: string; generation: number; rect: CellRect }>;
37
+ export type RasterLeaseInvalidatedNotification = Readonly<{
38
+ type: "raster-lease-invalidated";
39
+ queueId: number;
40
+ token: RasterLeaseToken;
41
+ cause:
42
+ | "intersecting-generic-output"
43
+ | "full-redraw"
44
+ | "resize"
45
+ | "terminal-loss"
46
+ | "capability-loss"
47
+ | "mode-off"
48
+ | "dispose"
49
+ | "explicit"
50
+ | "manual-viewport";
51
+ eraseAck: TerminalOutputAck;
52
+ }>;
53
+ export type RasterLeaseRequest = Readonly<{
54
+ ownerId: string;
55
+ rect: CellRect;
56
+ erase: Readonly<{ type: "raster-erase"; bytes: Uint8Array }>;
57
+ onInvalidated?: (notice: RasterLeaseInvalidatedNotification) => void;
58
+ }>;
59
+ export type TerminalOutputOperation =
60
+ | Readonly<{ type: "generic-render"; rect: CellRect; bytes: Uint8Array }>
61
+ | Readonly<{ type: "generic-full-redraw"; rect: CellRect; bytes: Uint8Array }>
62
+ | Readonly<{
63
+ type: "raster-multipart-batch";
64
+ records: readonly Uint8Array[];
65
+ prefix?: Uint8Array;
66
+ afterPrefix?: () => Promise<boolean>;
67
+ /** Synchronous freshness gate evaluated immediately before terminal output. */
68
+ shouldWrite?: () => boolean;
69
+ replayPrefix?: Uint8Array;
70
+ suffix?: Uint8Array;
71
+ abortSuffix?: Uint8Array;
72
+ restoreCursorVisibility?: boolean;
73
+ }>
74
+ | Readonly<{ type: "raster-erase"; bytes: Uint8Array }>
75
+ | Readonly<{ type: "raster-probe"; bytes: Uint8Array }>
76
+ | Readonly<{
77
+ type: "queued-output";
78
+ bytes: Uint8Array;
79
+ shouldWrite?: () => boolean;
80
+ /** Runs synchronously at the terminal write boundary after a successful write. */
81
+ onWritten?: () => void;
82
+ }>;
83
+ export type TerminalOutputAck = Readonly<{
84
+ queueId: number;
85
+ operation: TerminalOutputOperation["type"];
86
+ status: "written" | "stale-token" | "revoked" | "failed";
87
+ token?: RasterLeaseToken;
88
+ }>;
89
+ export type LifecycleCleanupAck = Readonly<{ attempted: number; written: number; stillPending: number }>;
90
+ export type RasterLeaseAcquireResult =
91
+ | Readonly<{ status: "acquired"; token: RasterLeaseToken }>
92
+ | Readonly<{
93
+ status: "rejected";
94
+ reason: "invalid-geometry" | "terminal-unavailable" | "owner-conflict" | "manual-viewport";
95
+ }>;
35
96
 
36
97
  const SEGMENT_RESET = "\x1b[0m";
37
98
  /**
@@ -44,6 +105,8 @@ const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x07";
44
105
  const MOUSE_SELECTION_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
45
106
  /** Discrete mouse-wheel notch size in terminal rows (xterm/less-style). */
46
107
  export const DEFAULT_WHEEL_LINES = 3;
108
+ /** CSI 6 terminal-cell dimensions beyond this cannot produce a safe pet raster. */
109
+ const MAX_CELL_DIMENSION_PX = 512;
47
110
 
48
111
  /** DA1 (`CSI ? … c`) and XTSMGRAPHICS (`CSI ? … S`) replies to the sixel probe. */
49
112
  const DEVICE_REPORT_PATTERN = /^\x1b\[\?[\d;]*[cS]$/u;
@@ -110,6 +173,10 @@ function stripTerminalEraseControls(bytes: string): string {
110
173
  }
111
174
  type InputListenerResult = { consume?: boolean; data?: string } | undefined;
112
175
  type InputListener = (data: string) => InputListenerResult;
176
+ type PostRenderEmission = {
177
+ payload: string;
178
+ onWritten?: () => void;
179
+ };
113
180
 
114
181
  /**
115
182
  * Component interface - all components must implement this
@@ -184,6 +251,12 @@ export interface Component {
184
251
  * Default is false - release events are filtered out.
185
252
  */
186
253
  wantsKeyRelease?: boolean;
254
+ /**
255
+ * Optional monotonic revision for renderer-level subtree reuse. Components that
256
+ * expose this MUST advance it whenever render output can change without a
257
+ * width change. Unversioned components are always rendered normally.
258
+ */
259
+ getRenderRevision?(): bigint;
187
260
 
188
261
  /**
189
262
  * Invalidate any cached rendering state.
@@ -522,9 +595,19 @@ export class Container implements ViewportAnchorProvider {
522
595
  children: Component[] = [];
523
596
  #disposed = false;
524
597
  #viewportAnchorSources = new Map<Component, ViewportAnchorSource>();
598
+ #renderRevision = 0n;
599
+
600
+ #advanceRenderRevision(): void {
601
+ this.#renderRevision += 1n;
602
+ }
603
+
604
+ getRenderRevision(): bigint {
605
+ return this.#renderRevision;
606
+ }
525
607
 
526
608
  addChild(component: Component): void {
527
609
  this.children.push(component);
610
+ this.#advanceRenderRevision();
528
611
  }
529
612
 
530
613
  removeChild(component: Component): void {
@@ -533,6 +616,7 @@ export class Container implements ViewportAnchorProvider {
533
616
  this.children.splice(index, 1);
534
617
  this.#viewportAnchorSources.delete(component);
535
618
  component.dispose?.();
619
+ this.#advanceRenderRevision();
536
620
  }
537
621
  }
538
622
 
@@ -542,6 +626,7 @@ export class Container implements ViewportAnchorProvider {
542
626
  if (index !== -1) {
543
627
  this.children.splice(index, 1);
544
628
  this.#viewportAnchorSources.delete(component);
629
+ this.#advanceRenderRevision();
545
630
  }
546
631
  }
547
632
 
@@ -563,12 +648,21 @@ export class Container implements ViewportAnchorProvider {
563
648
  for (const child of this.children) child.dispose?.();
564
649
  this.children = [];
565
650
  this.#viewportAnchorSources.clear();
651
+ this.#advanceRenderRevision();
566
652
  }
567
653
 
568
654
  /** Remove all children without disposing them (for detach-then-readd reuse). */
569
655
  detachAll(): void {
570
656
  this.children = [];
571
657
  this.#viewportAnchorSources.clear();
658
+ this.#advanceRenderRevision();
659
+ }
660
+
661
+ /** Replace direct children without disposing reusable components. */
662
+ replaceChildren(children: Component[]): void {
663
+ this.children = children;
664
+ this.#viewportAnchorSources.clear();
665
+ this.#advanceRenderRevision();
572
666
  }
573
667
 
574
668
  /** Registers a direct child as eligible for semantic viewport anchoring. */
@@ -578,6 +672,7 @@ export class Container implements ViewportAnchorProvider {
578
672
  }
579
673
  if (source === null) this.#viewportAnchorSources.delete(component);
580
674
  else this.#viewportAnchorSources.set(component, source);
675
+ this.#advanceRenderRevision();
581
676
  }
582
677
 
583
678
  dispose(): void {
@@ -585,10 +680,12 @@ export class Container implements ViewportAnchorProvider {
585
680
  this.#disposed = true;
586
681
  for (const child of this.children) child.dispose?.();
587
682
  this.#viewportAnchorSources.clear();
683
+ this.#advanceRenderRevision();
588
684
  }
589
685
 
590
686
  invalidate(): void {
591
687
  for (const child of this.children) child.invalidate?.();
688
+ this.#advanceRenderRevision();
592
689
  }
593
690
 
594
691
  render(width: number): string[] {
@@ -882,6 +979,18 @@ export class TUI extends Container {
882
979
  #previousHeight = 0;
883
980
  #focusedComponent: Component | null = null;
884
981
  #inputListeners = new Set<InputListener>();
982
+ #viewportAnchorRenderCache:
983
+ | {
984
+ component: Component;
985
+ width: number;
986
+ componentRevision: bigint;
987
+ sourceIdentity: string;
988
+ sourceRevision: bigint;
989
+ rendered: ViewportAnchorRender;
990
+ safeLines: string[];
991
+ kittyPlacements: KittyPlacementReference[][];
992
+ }
993
+ | undefined;
885
994
 
886
995
  /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
887
996
  onDebug?: () => void;
@@ -891,9 +1000,12 @@ export class TUI extends Container {
891
1000
  #committedRenderGeneration = 0;
892
1001
  #renderCommitWaiters = new Map<number, Set<RenderCommitWaiter>>();
893
1002
  #lastRenderWriteSucceeded = false;
1003
+ /** Generation whose render path is currently capturing terminal output. */
1004
+ #renderGenerationInProgress = 0;
894
1005
  #resizeRenderQueued = false;
895
1006
  #resizeRenderMutationQueued = false;
896
1007
  #renderMutationQueued = false;
1008
+ #renderScope: "full" | "layout" = "full";
897
1009
  #renderTimer: NodeJS.Timeout | undefined;
898
1010
  #widthSettleTimer: NodeJS.Timeout | undefined;
899
1011
  #widthSettleRepairPending = false;
@@ -935,6 +1047,7 @@ export class TUI extends Container {
935
1047
  #manualViewportAnchor: ManualViewportAnchor | null = null;
936
1048
  #manualViewportFallbackAnchors: ManualViewportAnchor[] = [];
937
1049
  #reconcileMissingViewportAnchor = false;
1050
+ #manualViewportLeaseSuspendPending = false;
938
1051
  #lastCursorPosition: { row: number; col: number } | null = null;
939
1052
  #latestViewportObservation: TuiViewportObservation | null = null;
940
1053
  #sixelProbePendingDa = false;
@@ -972,6 +1085,39 @@ export class TUI extends Container {
972
1085
  #manualSuffixLineCount = 0;
973
1086
  #committedTranscriptRows: Array<number | null> = [];
974
1087
  #paintedManualOutputNotice = false;
1088
+ #rasterGeneration = 0;
1089
+ #rasterQueueId = 0;
1090
+ #rasterLeases = new Map<
1091
+ string,
1092
+ {
1093
+ token: RasterLeaseToken;
1094
+ erase: Uint8Array;
1095
+ callback?: (n: RasterLeaseInvalidatedNotification) => void;
1096
+ revoked: boolean;
1097
+ }
1098
+ >();
1099
+ #rasterCleanup = new Map<
1100
+ string,
1101
+ {
1102
+ token: RasterLeaseToken;
1103
+ erase: Uint8Array;
1104
+ callback?: (n: RasterLeaseInvalidatedNotification) => void;
1105
+ queueId: number;
1106
+ cause: RasterLeaseInvalidatedNotification["cause"];
1107
+ terminalGeneration: number;
1108
+ }
1109
+ >();
1110
+ #rasterIngress: Promise<unknown> = Promise.resolve();
1111
+ #rasterPending = 0;
1112
+ #pendingDependentGenericBytes: Array<{ bytes: Uint8Array; rect: CellRect; blockedBy: string[] }> = [];
1113
+ #terminalGeneration = 0;
1114
+ /**
1115
+ * Raster lifecycle epoch. `stop()` increments it before lease cleanup so a
1116
+ * queue body captured under a prior running epoch can never write to the
1117
+ * terminal after restoration; `start()` does not reset it, so work enqueued
1118
+ * in the new lifecycle carries the new epoch.
1119
+ */
1120
+ #rasterLifecycle = 0;
975
1121
 
976
1122
  #unsubscribeTabWidthChange?: () => void;
977
1123
  static #renderCounters: TuiRenderCounterSnapshot = {
@@ -1068,8 +1214,33 @@ export class TUI extends Container {
1068
1214
  override dispose(): void {
1069
1215
  this.#unsubscribeTabWidthChange?.();
1070
1216
  this.#unsubscribeTabWidthChange = undefined;
1217
+ this.#finalizeRasterLeases("terminal-loss");
1071
1218
  super.dispose();
1072
1219
  }
1220
+ #finalizeRasterLeases(cause: RasterLeaseInvalidatedNotification["cause"]): void {
1221
+ this.#revokeRasterLeases(cause);
1222
+ void this.notifyTerminalLifecycle({
1223
+ kind: "explicit-cleanup",
1224
+ source: "tui",
1225
+ terminalGeneration: this.#terminalGeneration,
1226
+ });
1227
+ }
1228
+ #flushRasterLeasesBeforeStop(cause: RasterLeaseInvalidatedNotification["cause"]): void {
1229
+ this.#revokeRasterLeases(cause);
1230
+ for (const [owner, record] of this.#rasterCleanup) {
1231
+ const erase = this.#cursorGuardedRasterSequence(new TextDecoder().decode(record.erase));
1232
+ if (!this.#writeTerminal(erase)) return;
1233
+ this.#rasterCleanup.delete(owner);
1234
+ record.callback?.({
1235
+ type: "raster-lease-invalidated",
1236
+ queueId: record.queueId,
1237
+ token: record.token,
1238
+ cause: record.cause,
1239
+ eraseAck: { queueId: record.queueId, operation: "raster-erase", status: "written", token: record.token },
1240
+ });
1241
+ }
1242
+ this.flushTerminalCleanup();
1243
+ }
1073
1244
 
1074
1245
  get fullRedraws(): number {
1075
1246
  return this.#fullRedrawCount;
@@ -1168,11 +1339,13 @@ export class TUI extends Container {
1168
1339
  override removeChild(component: Component): void {
1169
1340
  this.#invalidateFocusForRemovedTree(component);
1170
1341
  super.removeChild(component);
1342
+ if (component === this.#viewportAnchorComponent) this.#viewportAnchorRenderCache = undefined;
1171
1343
  }
1172
1344
 
1173
1345
  override clear(): void {
1174
1346
  for (const child of this.children) this.#invalidateFocusForRemovedTree(child);
1175
1347
  super.clear();
1348
+ this.#viewportAnchorRenderCache = undefined;
1176
1349
  }
1177
1350
 
1178
1351
  #invalidateFocusForRemovedTree(component: Component): void {
@@ -1214,6 +1387,7 @@ export class TUI extends Container {
1214
1387
  }
1215
1388
  if (identityReset || this.#manualViewportTop === undefined) this.#manualOutputNotice = false;
1216
1389
  this.#viewportOutputSource = source;
1390
+ if (identityReset) this.#viewportAnchorRenderCache = undefined;
1217
1391
  this.requestRender();
1218
1392
  }
1219
1393
 
@@ -1224,6 +1398,7 @@ export class TUI extends Container {
1224
1398
  }
1225
1399
  if (this.#viewportAnchorComponent === component) return;
1226
1400
  this.#viewportAnchorComponent = component;
1401
+ this.#viewportAnchorRenderCache = undefined;
1227
1402
  this.#viewportAnchorFrame = null;
1228
1403
  }
1229
1404
  /** Returns the direct component registered as the semantic viewport anchor source. */
@@ -1266,6 +1441,28 @@ export class TUI extends Container {
1266
1441
  if (this.#manualViewportAnchor !== null) this.#reconcileMissingViewportAnchor = true;
1267
1442
  }
1268
1443
 
1444
+ #suspendRasterLeasesForManualViewport(resume: () => void): boolean {
1445
+ if (this.#manualViewportTop !== undefined) return false;
1446
+ if (this.#rasterLeases.size === 0 && this.#rasterCleanup.size === 0) return false;
1447
+ if (this.#manualViewportLeaseSuspendPending) return true;
1448
+
1449
+ this.#manualViewportLeaseSuspendPending = true;
1450
+ this.#revokeRasterLeases("manual-viewport");
1451
+ void this.notifyTerminalLifecycle({
1452
+ kind: "explicit-cleanup",
1453
+ source: "tui",
1454
+ terminalGeneration: this.#terminalGeneration,
1455
+ })
1456
+ .then(result => {
1457
+ this.#manualViewportLeaseSuspendPending = false;
1458
+ if (result.stillPending === 0) resume();
1459
+ })
1460
+ .catch(() => {
1461
+ this.#manualViewportLeaseSuspendPending = false;
1462
+ });
1463
+ return true;
1464
+ }
1465
+
1269
1466
  /** Reveal a semantic viewport anchor without changing the rendered content width. */
1270
1467
  revealViewportAnchor(id: ViewportAnchorId, alignment: "top" | "center" | "bottom"): boolean {
1271
1468
  const height = this.terminal.rows;
@@ -1290,6 +1487,11 @@ export class TUI extends Container {
1290
1487
  const desiredScreenRow =
1291
1488
  alignment === "top" ? 0 : alignment === "center" ? Math.floor(transcriptCapacity / 2) : transcriptCapacity - 1;
1292
1489
  const targetViewportTop = Math.max(0, frame.startRow + selectedRow - desiredScreenRow);
1490
+ if (
1491
+ this.#manualViewportTop === undefined &&
1492
+ this.#suspendRasterLeasesForManualViewport(() => this.revealViewportAnchor(id, alignment))
1493
+ )
1494
+ return true;
1293
1495
  this.#manualViewportAnchor = {
1294
1496
  id: selected.id,
1295
1497
  graphemeIndex:
@@ -1362,6 +1564,11 @@ export class TUI extends Container {
1362
1564
  if (this.#manualViewportTop === undefined && currentViewportTop === maxViewportTop) return true;
1363
1565
  if (this.#manualViewportTop !== undefined) return this.followLiveViewport();
1364
1566
  }
1567
+ if (
1568
+ this.#manualViewportTop === undefined &&
1569
+ this.#suspendRasterLeasesForManualViewport(() => this.scrollViewportBy(delta, options))
1570
+ )
1571
+ return true;
1365
1572
  if (frame !== null) {
1366
1573
  const desiredScreenRow =
1367
1574
  this.#manualViewportAnchor?.desiredScreenRow ??
@@ -1626,10 +1833,447 @@ export class TUI extends Container {
1626
1833
 
1627
1834
  override invalidate(): void {
1628
1835
  super.invalidate();
1836
+ this.#viewportAnchorRenderCache = undefined;
1629
1837
  for (const overlay of this.overlayStack) overlay.component.invalidate?.();
1630
1838
  for (const overlay of this.overlayStack) overlay.mouseBounds = undefined;
1631
1839
  }
1632
1840
 
1841
+ acquireRasterLease(request: RasterLeaseRequest): Promise<RasterLeaseAcquireResult> {
1842
+ return this.#enqueueRaster(isCurrentLifecycle => {
1843
+ if (!isCurrentLifecycle()) return { status: "rejected", reason: "terminal-unavailable" } as const;
1844
+ if (
1845
+ !request ||
1846
+ typeof request.ownerId !== "string" ||
1847
+ !request.rect ||
1848
+ typeof request.erase !== "object" ||
1849
+ request.erase.type !== "raster-erase" ||
1850
+ !(request.erase.bytes instanceof Uint8Array) ||
1851
+ (request.onInvalidated !== undefined && typeof request.onInvalidated !== "function")
1852
+ )
1853
+ return { status: "rejected", reason: "invalid-geometry" };
1854
+ if (!this.#validRect(request.rect)) return { status: "rejected", reason: "invalid-geometry" };
1855
+ if (!this.terminalAvailable) return { status: "rejected", reason: "terminal-unavailable" };
1856
+ if (this.manualViewportActive) return { status: "rejected", reason: "manual-viewport" };
1857
+ if (
1858
+ this.#rasterLeases.has(request.ownerId) ||
1859
+ this.#rasterCleanup.has(request.ownerId) ||
1860
+ [...this.#rasterLeases.values()].some(l => this.#intersects(l.token.rect, request.rect)) ||
1861
+ [...this.#rasterCleanup.values()].some(l => this.#intersects(l.token.rect, request.rect))
1862
+ )
1863
+ return { status: "rejected", reason: "owner-conflict" };
1864
+ const token = Object.freeze({
1865
+ ownerId: request.ownerId,
1866
+ generation: ++this.#rasterGeneration,
1867
+ rect: Object.freeze({ ...request.rect }),
1868
+ });
1869
+ this.#rasterLeases.set(request.ownerId, {
1870
+ token,
1871
+ erase: new Uint8Array(request.erase.bytes),
1872
+ callback: request.onInvalidated,
1873
+ revoked: false,
1874
+ });
1875
+ return { status: "acquired", token };
1876
+ });
1877
+ }
1878
+ submitTerminalOutput(
1879
+ request: Readonly<{ operation: TerminalOutputOperation; token?: RasterLeaseToken }>,
1880
+ ): Promise<TerminalOutputAck> {
1881
+ return this.#enqueueRaster(async isCurrentLifecycle => {
1882
+ const id = ++this.#rasterQueueId;
1883
+ const rawOperation0 =
1884
+ request && typeof request === "object" ? (request as { operation?: unknown }).operation : undefined;
1885
+ const operation0 =
1886
+ rawOperation0 &&
1887
+ typeof rawOperation0 === "object" &&
1888
+ typeof (rawOperation0 as { type?: unknown }).type === "string"
1889
+ ? (rawOperation0 as { type: string }).type
1890
+ : "queued-output";
1891
+ if (!isCurrentLifecycle())
1892
+ return { queueId: id, operation: operation0 as TerminalOutputOperation["type"], status: "failed" as const };
1893
+ const rawOperation =
1894
+ request && typeof request === "object" ? (request as { operation?: unknown }).operation : undefined;
1895
+ const operation =
1896
+ rawOperation &&
1897
+ typeof rawOperation === "object" &&
1898
+ typeof (rawOperation as { type?: unknown }).type === "string"
1899
+ ? (rawOperation as { type: string }).type
1900
+ : "queued-output";
1901
+ const failed = () => ({
1902
+ queueId: id,
1903
+ operation: operation as TerminalOutputOperation["type"],
1904
+ status: "failed" as const,
1905
+ });
1906
+ if (!rawOperation || typeof rawOperation !== "object") return failed();
1907
+ const op = rawOperation as unknown as TerminalOutputOperation;
1908
+ if (
1909
+ ![
1910
+ "generic-render",
1911
+ "generic-full-redraw",
1912
+ "raster-multipart-batch",
1913
+ "raster-erase",
1914
+ "raster-probe",
1915
+ "queued-output",
1916
+ ].includes(op.type as string)
1917
+ )
1918
+ return failed();
1919
+ if (
1920
+ (op.type === "generic-render" || op.type === "generic-full-redraw") &&
1921
+ (!this.#validRect(op.rect as CellRect) || !(op.bytes instanceof Uint8Array))
1922
+ )
1923
+ return failed();
1924
+ if (
1925
+ op.type === "raster-multipart-batch" &&
1926
+ (!Array.isArray(op.records) ||
1927
+ !op.records.every((b: unknown) => b instanceof Uint8Array) ||
1928
+ (op.prefix !== undefined && !(op.prefix instanceof Uint8Array)) ||
1929
+ (op.afterPrefix !== undefined && typeof op.afterPrefix !== "function") ||
1930
+ (op.shouldWrite !== undefined && typeof op.shouldWrite !== "function") ||
1931
+ (op.replayPrefix !== undefined && !(op.replayPrefix instanceof Uint8Array)) ||
1932
+ (op.suffix !== undefined && !(op.suffix instanceof Uint8Array)) ||
1933
+ (op.abortSuffix !== undefined && !(op.abortSuffix instanceof Uint8Array)) ||
1934
+ (op.restoreCursorVisibility !== undefined && typeof op.restoreCursorVisibility !== "boolean") ||
1935
+ ((op.replayPrefix !== undefined || op.abortSuffix !== undefined) &&
1936
+ (op.prefix === undefined || op.afterPrefix === undefined)))
1937
+ )
1938
+ return failed();
1939
+ if (
1940
+ (op.type === "raster-erase" || op.type === "raster-probe" || op.type === "queued-output") &&
1941
+ (!(op.bytes instanceof Uint8Array) ||
1942
+ (op.type === "queued-output" &&
1943
+ ((op.shouldWrite !== undefined && typeof op.shouldWrite !== "function") ||
1944
+ (op.onWritten !== undefined && typeof op.onWritten !== "function"))))
1945
+ )
1946
+ return failed();
1947
+ if (op.type.startsWith("raster-") && op.type !== "raster-probe") {
1948
+ const lease = request?.token && this.#rasterLeases.get(request.token.ownerId);
1949
+ if (!lease || lease.revoked || lease.token !== request?.token)
1950
+ return { queueId: id, operation: op.type, status: lease?.revoked ? "revoked" : "stale-token" };
1951
+ }
1952
+ if ((op.type === "raster-multipart-batch" || op.type === "queued-output") && op.shouldWrite !== undefined) {
1953
+ let shouldWrite: boolean;
1954
+ try {
1955
+ shouldWrite = op.shouldWrite();
1956
+ } catch {
1957
+ return failed();
1958
+ }
1959
+ if (!shouldWrite) return { queueId: id, operation: op.type, status: "stale-token" };
1960
+ }
1961
+ if (op.type === "raster-multipart-batch" && op.prefix !== undefined && op.afterPrefix !== undefined) {
1962
+ const prefixWritten = this.#guardTerminalOperation(() =>
1963
+ this.terminal.write(new TextDecoder().decode(op.prefix)),
1964
+ );
1965
+ if (!prefixWritten) return failed();
1966
+ const abortBarrier = () => {
1967
+ // Abort/cursor-restoration bytes are terminal writes: never emit
1968
+ // them once the running epoch ended (e.g. a user predicate that
1969
+ // itself stops the terminal before throwing or returning false).
1970
+ if (!isCurrentLifecycle()) return;
1971
+ const abortSuffix = op.abortSuffix === undefined ? "" : new TextDecoder().decode(op.abortSuffix);
1972
+ const cursorVisibility = op.restoreCursorVisibility ? this.#cursorVisibilitySequence() : "";
1973
+ if (abortSuffix || cursorVisibility)
1974
+ this.#guardTerminalOperation(() => this.terminal.write(abortSuffix + cursorVisibility));
1975
+ };
1976
+ const flushed = await (this.terminal as Terminal & { flush?: () => Promise<boolean> }).flush?.();
1977
+ // Async boundary: the terminal may have stopped while we awaited.
1978
+ if (!isCurrentLifecycle()) return failed();
1979
+ if (flushed === false) {
1980
+ if (isCurrentLifecycle()) abortBarrier();
1981
+ return failed();
1982
+ }
1983
+ let afterPrefixSucceeded: boolean;
1984
+ try {
1985
+ afterPrefixSucceeded = await op.afterPrefix();
1986
+ } catch {
1987
+ if (isCurrentLifecycle()) abortBarrier();
1988
+ return failed();
1989
+ }
1990
+ if (afterPrefixSucceeded !== true) {
1991
+ if (isCurrentLifecycle()) abortBarrier();
1992
+ return failed();
1993
+ }
1994
+ // Async boundary: afterPrefix awaited external work; re-check epoch.
1995
+ if (!isCurrentLifecycle()) return failed();
1996
+ const currentLease = this.#rasterLeases.get(request.token?.ownerId ?? "");
1997
+ if (!currentLease || currentLease.revoked || currentLease.token !== request.token) {
1998
+ if (isCurrentLifecycle()) abortBarrier();
1999
+ return { queueId: id, operation: op.type, status: currentLease?.revoked ? "revoked" : "stale-token" };
2000
+ }
2001
+ if (op.shouldWrite !== undefined) {
2002
+ let shouldWrite: boolean;
2003
+ try {
2004
+ shouldWrite = op.shouldWrite();
2005
+ } catch {
2006
+ if (isCurrentLifecycle()) abortBarrier();
2007
+ return failed();
2008
+ }
2009
+ if (!shouldWrite) {
2010
+ if (isCurrentLifecycle()) abortBarrier();
2011
+ return { queueId: id, operation: op.type, status: "stale-token" };
2012
+ }
2013
+ }
2014
+ }
2015
+ // Final pre-write gate: an async body may have resumed after stop().
2016
+ if (!isCurrentLifecycle()) return failed();
2017
+ const bytes =
2018
+ op.type === "raster-multipart-batch"
2019
+ ? op.records.map((b: Uint8Array) => new TextDecoder().decode(b)).join("")
2020
+ : new TextDecoder().decode(op.bytes);
2021
+ const finalBytes =
2022
+ op.type === "raster-multipart-batch"
2023
+ ? `${op.afterPrefix === undefined && op.prefix !== undefined ? new TextDecoder().decode(op.prefix) : ""}${op.replayPrefix !== undefined ? new TextDecoder().decode(op.replayPrefix) : ""}${bytes}${op.suffix !== undefined ? new TextDecoder().decode(op.suffix) : ""}${op.restoreCursorVisibility ? this.#cursorVisibilitySequence() : ""}`
2024
+ : bytes;
2025
+ const dependent = op.type === "generic-render" || op.type === "generic-full-redraw";
2026
+ const ok = dependent
2027
+ ? this.#writeProtectedRenderIngress(finalBytes)
2028
+ : this.#guardTerminalOperation(() => this.terminal.write(finalBytes));
2029
+ if (!ok && dependent) {
2030
+ const rect = (op as { rect: CellRect }).rect;
2031
+ const blockedBy = [...this.#rasterCleanup.entries()]
2032
+ .filter(([, record]) => this.#intersects(record.token.rect, rect))
2033
+ .map(([owner]) => owner);
2034
+ this.#pendingDependentGenericBytes.push({ bytes: new Uint8Array(op.bytes), rect, blockedBy });
2035
+ }
2036
+ if (ok && op.type === "queued-output") op.onWritten?.();
2037
+ return { queueId: id, operation: op.type, status: ok ? "written" : "failed", token: request.token };
2038
+ });
2039
+ }
2040
+ invalidateRasterLease(
2041
+ request: Readonly<{ token: RasterLeaseToken; cause: RasterLeaseInvalidatedNotification["cause"] }>,
2042
+ ): Promise<TerminalOutputAck> {
2043
+ return this.#enqueueRaster(isCurrentLifecycle => {
2044
+ const id = ++this.#rasterQueueId,
2045
+ lease = this.#rasterLeases.get(request.token.ownerId);
2046
+ if (!isCurrentLifecycle())
2047
+ return { queueId: id, operation: "raster-erase" as const, status: "stale-token" as const };
2048
+ if (!lease || lease.token !== request.token)
2049
+ return { queueId: id, operation: "raster-erase", status: "stale-token" as const };
2050
+ lease.revoked = true;
2051
+ this.#rasterLeases.delete(request.token.ownerId);
2052
+ const erase = this.#cursorGuardedRasterSequence(new TextDecoder().decode(lease.erase));
2053
+ const ok = this.#guardTerminalOperation(() => this.terminal.write(erase));
2054
+ if (!ok)
2055
+ this.#rasterCleanup.set(request.token.ownerId, {
2056
+ token: lease.token,
2057
+ erase: lease.erase,
2058
+ callback: lease.callback,
2059
+ queueId: id,
2060
+ cause: request.cause,
2061
+ terminalGeneration: this.#terminalGeneration,
2062
+ });
2063
+ else
2064
+ lease.callback?.({
2065
+ type: "raster-lease-invalidated",
2066
+ queueId: id,
2067
+ token: lease.token,
2068
+ cause: request.cause,
2069
+ eraseAck: { queueId: id, operation: "raster-erase", status: "written", token: lease.token },
2070
+ });
2071
+ return {
2072
+ queueId: id,
2073
+ operation: "raster-erase" as const,
2074
+ status: ok ? ("written" as const) : ("failed" as const),
2075
+ token: lease.token,
2076
+ };
2077
+ });
2078
+ }
2079
+ notifyTerminalLifecycle(event: {
2080
+ kind: "availability-restored" | "explicit-cleanup";
2081
+ source: "tui" | "interactive-mode" | "transport";
2082
+ terminalGeneration: number;
2083
+ }): Promise<LifecycleCleanupAck> {
2084
+ if (
2085
+ !event ||
2086
+ (event.kind !== "availability-restored" && event.kind !== "explicit-cleanup") ||
2087
+ (event.source !== "tui" && event.source !== "interactive-mode" && event.source !== "transport") ||
2088
+ !Number.isSafeInteger(event.terminalGeneration) ||
2089
+ event.terminalGeneration < 0
2090
+ ) {
2091
+ return Promise.reject(new TypeError("invalid terminal lifecycle event"));
2092
+ }
2093
+ return this.#enqueueRaster(isCurrentLifecycle => {
2094
+ if (!isCurrentLifecycle()) return { attempted: 0, written: 0, stillPending: 0 };
2095
+ if (event.terminalGeneration !== this.#terminalGeneration)
2096
+ return { attempted: 0, written: 0, stillPending: 0 };
2097
+ this.flushTerminalCleanup(true);
2098
+ if (this.#pendingTerminalCleanup.length > 0) {
2099
+ return {
2100
+ attempted: 0,
2101
+ written: 0,
2102
+ stillPending: this.#rasterCleanup.size + this.#pendingTerminalCleanup.length,
2103
+ };
2104
+ }
2105
+ let attempted = 0,
2106
+ written = 0;
2107
+ for (const [owner, r] of this.#rasterCleanup) {
2108
+ r.terminalGeneration = this.#terminalGeneration;
2109
+ attempted++;
2110
+ if (this.#writeLifecycleCleanup(this.#cursorGuardedRasterSequence(new TextDecoder().decode(r.erase)))) {
2111
+ written++;
2112
+ this.#rasterCleanup.delete(owner);
2113
+ const ack: TerminalOutputAck = {
2114
+ queueId: r.queueId,
2115
+ operation: "raster-erase",
2116
+ status: "written",
2117
+ token: r.token,
2118
+ };
2119
+ r.callback?.({
2120
+ type: "raster-lease-invalidated",
2121
+ queueId: r.queueId,
2122
+ token: r.token,
2123
+ cause: r.cause,
2124
+ eraseAck: ack,
2125
+ });
2126
+ const index = this.#pendingDependentGenericBytes.findIndex(item => item.blockedBy.includes(owner));
2127
+ if (index >= 0) {
2128
+ const item = this.#pendingDependentGenericBytes[index];
2129
+ const blocked = [...this.#rasterLeases.values(), ...this.#rasterCleanup.values()].some(record =>
2130
+ this.#intersects(record.token.rect, item.rect),
2131
+ );
2132
+ if (!blocked && this.#writeDisjointDependentIngress(new TextDecoder().decode(item.bytes), item.rect))
2133
+ this.#pendingDependentGenericBytes.splice(index, 1);
2134
+ }
2135
+ } else {
2136
+ r.terminalGeneration = this.#terminalGeneration;
2137
+ }
2138
+ }
2139
+ if (written > 0) this.requestRender(true);
2140
+ return {
2141
+ attempted,
2142
+ written,
2143
+ stillPending: this.#rasterCleanup.size + this.#pendingTerminalCleanup.length,
2144
+ };
2145
+ });
2146
+ }
2147
+ #enqueueRaster<T>(fn: (isCurrentLifecycle: () => boolean) => T | Promise<T>): Promise<T> {
2148
+ const lifecycle = this.#rasterLifecycle;
2149
+ this.#rasterPending++;
2150
+ // A queue body captured under a prior running epoch must not write to the
2151
+ // terminal after stop() restored it — captured-epoch equality only,
2152
+ // evaluated lazily at entry AND after every await inside async bodies.
2153
+ // Synchronous stop cleanup writes directly, never through a stale body.
2154
+ const isCurrentLifecycle = () => lifecycle === this.#rasterLifecycle;
2155
+ const next: Promise<T> = this.#rasterIngress.then(() => fn(isCurrentLifecycle)) as Promise<T>;
2156
+ this.#rasterIngress = next.catch(() => undefined);
2157
+ void next.then(
2158
+ () => {
2159
+ this.#rasterPending--;
2160
+ },
2161
+ () => {
2162
+ this.#rasterPending--;
2163
+ },
2164
+ );
2165
+ return next;
2166
+ }
2167
+ #validRect(r: CellRect): boolean {
2168
+ return (
2169
+ Object.values(r).every(Number.isSafeInteger) &&
2170
+ r.column >= 0 &&
2171
+ r.row >= 0 &&
2172
+ r.width > 0 &&
2173
+ r.height > 0 &&
2174
+ r.column + r.width <= this.terminal.columns &&
2175
+ r.row + r.height <= this.terminal.rows
2176
+ );
2177
+ }
2178
+ #unleasedRowSegments(row: number, width: number): Array<{ column: number; width: number }> {
2179
+ let segments = [{ column: 0, width }];
2180
+ for (const lease of this.#rasterLeases.values()) {
2181
+ const rect = lease.token.rect;
2182
+ if (row < rect.row || row >= rect.row + rect.height) continue;
2183
+ const protectedStart = rect.column;
2184
+ const protectedEnd = rect.column + rect.width;
2185
+ const next: Array<{ column: number; width: number }> = [];
2186
+ for (const segment of segments) {
2187
+ const segmentEnd = segment.column + segment.width;
2188
+ if (protectedEnd <= segment.column || protectedStart >= segmentEnd) {
2189
+ next.push(segment);
2190
+ continue;
2191
+ }
2192
+ if (protectedStart > segment.column)
2193
+ next.push({ column: segment.column, width: protectedStart - segment.column });
2194
+ if (protectedEnd < segmentEnd) next.push({ column: protectedEnd, width: segmentEnd - protectedEnd });
2195
+ }
2196
+ segments = next;
2197
+ }
2198
+ return segments;
2199
+ }
2200
+ #intersects(a: CellRect, b: CellRect): boolean {
2201
+ return (
2202
+ a.column < b.column + b.width &&
2203
+ b.column < a.column + a.width &&
2204
+ a.row < b.row + b.height &&
2205
+ b.row < a.row + a.height
2206
+ );
2207
+ }
2208
+ #writeRasterPreservingRenderIngress(buffer: string): boolean {
2209
+ if (this.#rasterCleanup.size > 0) return this.#writeProtectedRenderIngress(buffer);
2210
+ return this.#writeTerminal(buffer);
2211
+ }
2212
+ #writeDisjointDependentIngress(buffer: string, rect: CellRect): boolean {
2213
+ if (
2214
+ [...this.#rasterLeases.values(), ...this.#rasterCleanup.values()].some(record =>
2215
+ this.#intersects(record.token.rect, rect),
2216
+ )
2217
+ )
2218
+ return false;
2219
+ return this.#guardTerminalOperation(() => this.terminal.write(buffer));
2220
+ }
2221
+ #writeProtectedRenderIngress(buffer: string): boolean {
2222
+ const affected = [...this.#rasterLeases.values()];
2223
+ const pending = [...this.#rasterCleanup.values()];
2224
+ if (pending.length > 0) return false;
2225
+ if (affected.length === 0 && pending.length === 0) return this.#writeTerminal(buffer);
2226
+ const queueId = ++this.#rasterQueueId;
2227
+ const cleanup = this.#cursorGuardedRasterSequence(
2228
+ [
2229
+ ...pending.map(r => new TextDecoder().decode(r.erase)),
2230
+ ...affected.map(lease => new TextDecoder().decode(lease.erase)),
2231
+ ].join(""),
2232
+ );
2233
+ const ok = this.#guardTerminalOperation(() => this.terminal.write(cleanup + buffer));
2234
+ if (!ok) {
2235
+ this.#terminalUnavailable = true;
2236
+ this.#previousLines = [];
2237
+ this.#renderRequested = true;
2238
+ for (const lease of affected) {
2239
+ this.#rasterLeases.delete(lease.token.ownerId);
2240
+ this.#rasterCleanup.set(lease.token.ownerId, {
2241
+ token: lease.token,
2242
+ erase: lease.erase,
2243
+ callback: lease.callback,
2244
+ queueId,
2245
+ cause: "intersecting-generic-output",
2246
+ terminalGeneration: this.#terminalGeneration,
2247
+ });
2248
+ }
2249
+ return false;
2250
+ }
2251
+ this.#rasterCleanup.clear();
2252
+ for (const lease of affected) {
2253
+ this.#rasterLeases.delete(lease.token.ownerId);
2254
+ }
2255
+ for (const record of pending) {
2256
+ const ack: TerminalOutputAck = { queueId, operation: "raster-erase", status: "written", token: record.token };
2257
+ record.callback?.({
2258
+ type: "raster-lease-invalidated",
2259
+ queueId,
2260
+ token: record.token,
2261
+ cause: record.cause,
2262
+ eraseAck: ack,
2263
+ });
2264
+ }
2265
+ for (const lease of affected) {
2266
+ const ack: TerminalOutputAck = { queueId, operation: "raster-erase", status: "written", token: lease.token };
2267
+ lease.callback?.({
2268
+ type: "raster-lease-invalidated",
2269
+ queueId,
2270
+ token: lease.token,
2271
+ cause: "intersecting-generic-output",
2272
+ eraseAck: ack,
2273
+ });
2274
+ }
2275
+ return true;
2276
+ }
1633
2277
  start(): void {
1634
2278
  this.#stopped = false;
1635
2279
  this.#terminalUnavailable = false;
@@ -1640,11 +2284,34 @@ export class TUI extends Container {
1640
2284
  this.terminal.start(
1641
2285
  data => this.#handleInput(data),
1642
2286
  () => {
2287
+ const hadRasterLease = this.#rasterLeases.size > 0;
2288
+ this.#revokeRasterLeases("resize");
2289
+ // Only a pet raster lease needs refreshed cell metrics on resize; a
2290
+ // plain resize keeps the historical byte stream (no cell query).
2291
+ if (hadRasterLease) this.#queryCellSize(true);
1643
2292
  this.invalidate();
1644
- this.requestResizeRender();
2293
+ if (this.#pendingTerminalCleanup.length > 0 || this.#rasterCleanup.size > 0) {
2294
+ // Retained pet/cleanup output must be flushed before the resize
2295
+ // repaint so it cannot interleave behind the new frame.
2296
+ this.notifyTerminalLifecycle({
2297
+ kind: "explicit-cleanup",
2298
+ source: "tui",
2299
+ terminalGeneration: this.#terminalGeneration,
2300
+ }).then(result => {
2301
+ if (result.stillPending === 0) this.requestResizeRender();
2302
+ });
2303
+ } else {
2304
+ this.requestResizeRender();
2305
+ }
1645
2306
  },
1646
2307
  );
1647
- this.flushTerminalCleanup();
2308
+ if (this.#pendingTerminalCleanup.length > 0 || this.#rasterCleanup.size > 0) {
2309
+ void this.notifyTerminalLifecycle({
2310
+ kind: "availability-restored",
2311
+ source: "tui",
2312
+ terminalGeneration: this.#terminalGeneration,
2313
+ });
2314
+ }
1648
2315
  this.#hideCursor();
1649
2316
  this.#querySixelSupport();
1650
2317
  this.#queryCellSize();
@@ -1706,7 +2373,35 @@ export class TUI extends Container {
1706
2373
  return !this.#terminalUnavailable && this.terminal.available;
1707
2374
  }
1708
2375
 
2376
+ get isRunning(): boolean {
2377
+ return !this.#stopped;
2378
+ }
2379
+
2380
+ get terminalGeneration(): number {
2381
+ return this.#terminalGeneration;
2382
+ }
2383
+ get manualViewportActive(): boolean {
2384
+ return this.#manualViewportTop !== undefined || this.#manualViewportLeaseSuspendPending;
2385
+ }
2386
+ #revokeRasterLeases(cause: RasterLeaseInvalidatedNotification["cause"]): void {
2387
+ const queueId = ++this.#rasterQueueId;
2388
+ for (const lease of this.#rasterLeases.values()) {
2389
+ lease.revoked = true;
2390
+ this.#rasterCleanup.set(lease.token.ownerId, {
2391
+ token: lease.token,
2392
+ erase: lease.erase,
2393
+ callback: lease.callback,
2394
+ queueId,
2395
+ cause,
2396
+ terminalGeneration: this.#terminalGeneration,
2397
+ });
2398
+ }
2399
+ this.#rasterLeases.clear();
2400
+ }
1709
2401
  #markTerminalUnavailable(settleRenderWaiters = true): void {
2402
+ this.#terminalGeneration++;
2403
+ for (const record of this.#rasterCleanup.values()) record.terminalGeneration = this.#terminalGeneration;
2404
+ this.#revokeRasterLeases("terminal-loss");
1710
2405
  this.#terminalUnavailable = true;
1711
2406
  this.#stopped = true;
1712
2407
  this.#renderRequested = false;
@@ -1753,12 +2448,37 @@ export class TUI extends Container {
1753
2448
  return true;
1754
2449
  }
1755
2450
 
2451
+ #writeLifecycleCleanup(data: string): boolean {
2452
+ if (!this.terminal.available) {
2453
+ this.#markTerminalUnavailable();
2454
+ return false;
2455
+ }
2456
+ try {
2457
+ this.terminal.write(data);
2458
+ } catch {
2459
+ this.#markTerminalUnavailable();
2460
+ return false;
2461
+ }
2462
+ if (!this.terminal.available) {
2463
+ this.#markTerminalUnavailable();
2464
+ return false;
2465
+ }
2466
+ this.#terminalUnavailable = false;
2467
+ return true;
2468
+ }
2469
+
1756
2470
  addInputListener(listener: InputListener): () => void {
1757
2471
  this.#inputListeners.add(listener);
1758
2472
  return () => {
1759
2473
  this.#inputListeners.delete(listener);
1760
2474
  };
1761
2475
  }
2476
+ drainInput(maxMs: number, quiescenceMs: number): Promise<void> {
2477
+ return this.terminal.drainInput(maxMs, quiescenceMs);
2478
+ }
2479
+ drainPetProbeInput(maxMs: number, quiescenceMs: number): Promise<void> {
2480
+ return this.terminal.drainPendingInput?.(maxMs, quiescenceMs) ?? Promise.resolve();
2481
+ }
1762
2482
 
1763
2483
  removeInputListener(listener: InputListener): void {
1764
2484
  this.#inputListeners.delete(listener);
@@ -1899,9 +2619,14 @@ export class TUI extends Container {
1899
2619
  this.invalidate();
1900
2620
  this.requestRender(true);
1901
2621
  }
1902
- #queryCellSize(): void {
1903
- // Only query if terminal supports images (cell size is only used for image rendering)
1904
- if (!TERMINAL.imageProtocol) {
2622
+ /** Refresh terminal cell metrics for a verified external image transport. */
2623
+ refreshImageCellSize(): void {
2624
+ this.#queryCellSize(true);
2625
+ }
2626
+ #queryCellSize(force = false): void {
2627
+ // Cell dimensions are also needed by the explicitly verified iTerm transport,
2628
+ // which intentionally does not claim a general TUI image protocol.
2629
+ if (!force && !TERMINAL.imageProtocol) {
1905
2630
  return;
1906
2631
  }
1907
2632
  // Query terminal for cell size in pixels: CSI 16 t
@@ -1910,7 +2635,11 @@ export class TUI extends Container {
1910
2635
  }
1911
2636
 
1912
2637
  stop(): void {
1913
- this.flushTerminalCleanup();
2638
+ // Invalidate every raster-queue body captured under the running epoch
2639
+ // before any teardown: nothing queued before stop may write after
2640
+ // restoration. Synchronous stop cleanup below writes directly.
2641
+ this.#rasterLifecycle++;
2642
+ this.#flushRasterLeasesBeforeStop("terminal-loss");
1914
2643
  const placementCleanup = this.#kittyPlacementDeletePlan(this.#kittyPlacementSpans, [], [], true).output;
1915
2644
  if (placementCleanup.length > 0 && this.#writeTerminal(placementCleanup)) this.#kittyPlacementSpans = [];
1916
2645
  this.#clearSixelProbeState();
@@ -2072,13 +2801,24 @@ export class TUI extends Container {
2072
2801
  this.requestRenderWithGeneration(force, source);
2073
2802
  }
2074
2803
 
2075
- requestRenderWithGeneration(force = false, source = "unknown"): number {
2804
+ #requestRenderWithScope(force: boolean, source: string, scope: "full" | "layout"): number {
2076
2805
  const generation = ++this.#nextRenderGeneration;
2077
2806
  this.#renderRequestedGeneration = Math.max(this.#renderRequestedGeneration, generation);
2807
+ if (scope === "full") this.#renderScope = "full";
2078
2808
  this.#requestRenderCore(force, source, generation);
2079
2809
  return generation;
2080
2810
  }
2081
2811
 
2812
+ /** Request a frame whose mutation is known to be outside the viewport-anchor subtree. */
2813
+ requestLayoutRender(source = "layout"): void {
2814
+ if (!this.#renderRequested) this.#renderScope = "layout";
2815
+ this.#requestRenderWithScope(false, source, "layout");
2816
+ }
2817
+
2818
+ requestRenderWithGeneration(force = false, source = "unknown"): number {
2819
+ return this.#requestRenderWithScope(force, source, "full");
2820
+ }
2821
+
2082
2822
  #requestRenderCore(force: boolean, source: string, generation: number): void {
2083
2823
  if (!this.terminalAvailable) {
2084
2824
  this.#markTerminalUnavailable();
@@ -2139,7 +2879,9 @@ export class TUI extends Container {
2139
2879
  this.#lastRenderAt = performance.now();
2140
2880
  this.#lastRenderWriteSucceeded = false;
2141
2881
  const t0 = renderMetrics.now();
2882
+ this.#renderGenerationInProgress = requestedGeneration;
2142
2883
  this.#doRender();
2884
+ this.#renderGenerationInProgress = 0;
2143
2885
  this.#commitRenderGeneration(requestedGeneration);
2144
2886
  if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
2145
2887
  });
@@ -2182,7 +2924,9 @@ export class TUI extends Container {
2182
2924
  this.#lastRenderAt = performance.now();
2183
2925
  this.#lastRenderWriteSucceeded = false;
2184
2926
  const t0 = renderMetrics.now();
2927
+ this.#renderGenerationInProgress = requestedGeneration;
2185
2928
  this.#doRender();
2929
+ this.#renderGenerationInProgress = 0;
2186
2930
  this.#commitRenderGeneration(requestedGeneration);
2187
2931
  if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
2188
2932
  if (this.#renderRequested) {
@@ -2212,7 +2956,9 @@ export class TUI extends Container {
2212
2956
  this.#lastRenderAt = performance.now();
2213
2957
  this.#lastRenderWriteSucceeded = false;
2214
2958
  const t0 = renderMetrics.now();
2959
+ this.#renderGenerationInProgress = requestedGeneration;
2215
2960
  this.#doRender();
2961
+ this.#renderGenerationInProgress = 0;
2216
2962
  this.#commitRenderGeneration(requestedGeneration);
2217
2963
  if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
2218
2964
  }
@@ -2463,7 +3209,14 @@ export class TUI extends Container {
2463
3209
 
2464
3210
  const heightPx = parseInt(match[1], 10);
2465
3211
  const widthPx = parseInt(match[2], 10);
2466
- if (heightPx <= 0 || widthPx <= 0) {
3212
+ if (
3213
+ !Number.isSafeInteger(heightPx) ||
3214
+ !Number.isSafeInteger(widthPx) ||
3215
+ heightPx <= 0 ||
3216
+ widthPx <= 0 ||
3217
+ heightPx > MAX_CELL_DIMENSION_PX ||
3218
+ widthPx > MAX_CELL_DIMENSION_PX
3219
+ ) {
2467
3220
  return true;
2468
3221
  }
2469
3222
 
@@ -3202,34 +3955,55 @@ export class TUI extends Container {
3202
3955
  { top: transcriptLineCount, bottom: transcriptLineCount + suffixLineCount },
3203
3956
  ]
3204
3957
  : [{ top: nextViewportTop, bottom: nextViewportTop + height }];
3205
- let buffer = deletePlan.output;
3206
- buffer += "\x1b[H";
3958
+ const lineForScreenRow = (screenRow: number): string => {
3959
+ const lineIndex = nextViewportTop + screenRow;
3960
+ const suffixRow = screenRow - transcriptCapacity - noticeRows;
3961
+ return paintManual && screenRow === transcriptCapacity && noticeRows > 0
3962
+ ? "New output — type to follow"
3963
+ : paintManual && suffixRow >= 0
3964
+ ? (lines[transcriptLineCount + suffixRow] ?? "")
3965
+ : paintManual && lineIndex >= transcriptLineCount
3966
+ ? ""
3967
+ : (lines[lineIndex] ?? "");
3968
+ };
3969
+ const visibleLines = Array.from({ length: height }, (_, screenRow) => lineForScreenRow(screenRow));
3970
+ const preserveRasterLeases =
3971
+ this.#rasterLeases.size > 0 &&
3972
+ this.#rasterCleanup.size === 0 &&
3973
+ !visibleLines.some(line => TERMINAL.isImageLine(line));
3974
+ let buffer = `${preserveRasterLeases ? "\x1b[?2026h" : ""}${deletePlan.output}${preserveRasterLeases ? "\x1b[?25l" : ""}`;
3975
+ if (!preserveRasterLeases) buffer += "\x1b[H";
3207
3976
  const committedTranscriptRows: Array<number | null> = [];
3208
3977
  for (let screenRow = 0; screenRow < height; screenRow++) {
3209
- if (screenRow > 0) buffer += avoidScrollback ? "\r\x1b[1B" : "\r\n";
3978
+ if (preserveRasterLeases) buffer += `\x1b[${screenRow + 1};1H`;
3979
+ else if (screenRow > 0) buffer += avoidScrollback ? "\r\x1b[1B" : "\r\n";
3210
3980
  const lineIndex = nextViewportTop + screenRow;
3211
- const suffixRow = screenRow - transcriptCapacity - noticeRows;
3212
- const line =
3213
- paintManual && screenRow === transcriptCapacity && noticeRows > 0
3214
- ? "New output — type to follow"
3215
- : paintManual && suffixRow >= 0
3216
- ? (lines[transcriptLineCount + suffixRow] ?? "")
3217
- : paintManual && lineIndex >= transcriptLineCount
3218
- ? ""
3219
- : (lines[lineIndex] ?? "");
3981
+ const line = visibleLines[screenRow]!;
3220
3982
  committedTranscriptRows.push(
3221
3983
  screenRow < transcriptCapacity && lineIndex < transcriptLineCount ? lineIndex : null,
3222
3984
  );
3223
3985
  const isImage = TERMINAL.isImageLine(line);
3224
- if (avoidScrollback && isImage) buffer += "\x1b7\x1b[2K";
3986
+ if (!preserveRasterLeases && avoidScrollback && isImage) buffer += "\x1b7\x1b[2K";
3225
3987
  if (!isImage && this.#visibleWidthForDifferentialGuard(line) > width) {
3226
3988
  let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
3227
3989
  truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
3228
- buffer += this.#padLineToWidth(truncatedLine, width);
3990
+ if (preserveRasterLeases) {
3991
+ for (const segment of this.#unleasedRowSegments(screenRow, width)) {
3992
+ buffer += `\x1b[${segment.column + 1}G\x1b[${segment.width}X`;
3993
+ buffer += `${sliceByColumn(truncatedLine, segment.column, segment.width, true)}${SEGMENT_RESET}`;
3994
+ }
3995
+ } else {
3996
+ buffer += this.#padLineToWidth(truncatedLine, width);
3997
+ }
3998
+ } else if (preserveRasterLeases) {
3999
+ for (const segment of this.#unleasedRowSegments(screenRow, width)) {
4000
+ buffer += `\x1b[${segment.column + 1}G\x1b[${segment.width}X`;
4001
+ buffer += `${sliceByColumn(line, segment.column, segment.width, true)}${SEGMENT_RESET}`;
4002
+ }
3229
4003
  } else {
3230
4004
  buffer += this.#padLineToWidth(line, width);
3231
4005
  }
3232
- if (avoidScrollback && isImage) buffer += "\x1b8";
4006
+ if (!preserveRasterLeases && avoidScrollback && isImage) buffer += "\x1b8";
3233
4007
  }
3234
4008
  if (avoidScrollback) buffer += "\r";
3235
4009
 
@@ -3244,25 +4018,31 @@ export class TUI extends Container {
3244
4018
  buffer += cursorSeq;
3245
4019
  buffer = this.#frameSynchronizedOutput(buffer);
3246
4020
  let contentWritten = false;
3247
- const writeSucceeded = this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, lines.length, () => {
3248
- contentWritten = true;
3249
- this.#hardwareCursorRow = cursorToRow;
3250
- this.#committedTranscriptRows = committedTranscriptRows;
3251
- this.#cursorRow = Math.max(0, lines.length - 1);
3252
- this.#maxLinesRendered = lines.length;
3253
- this.#viewportTopRow = nextViewportTop;
3254
- if (paintManual) this.#manualViewportTop = nextViewportTop;
3255
- this.#kittyPlacementSpans = this.#kittyCommittedPlacementsAfterPaint(
3256
- placementsToClear,
3257
- placementsToPaint,
3258
- deletePlan,
3259
- emittedRegions,
3260
- );
3261
- onPainted?.();
3262
- this.#paintedManualOutputNotice = paintManual && this.#manualOutputNotice;
3263
- this.#recordPaintedViewportObservation(nextViewportTop, height, paintManual);
3264
- });
3265
- if (!contentWritten) return false;
4021
+ const writeSucceeded = this.#writeRenderBufferAndReanchorImeCursor(
4022
+ buffer,
4023
+ cursorPos,
4024
+ lines.length,
4025
+ () => {
4026
+ contentWritten = true;
4027
+ this.#hardwareCursorRow = cursorToRow;
4028
+ this.#committedTranscriptRows = committedTranscriptRows;
4029
+ this.#cursorRow = Math.max(0, lines.length - 1);
4030
+ this.#maxLinesRendered = lines.length;
4031
+ this.#viewportTopRow = nextViewportTop;
4032
+ if (paintManual) this.#manualViewportTop = nextViewportTop;
4033
+ this.#kittyPlacementSpans = this.#kittyCommittedPlacementsAfterPaint(
4034
+ placementsToClear,
4035
+ placementsToPaint,
4036
+ deletePlan,
4037
+ emittedRegions,
4038
+ );
4039
+ onPainted?.();
4040
+ this.#paintedManualOutputNotice = paintManual && this.#manualOutputNotice;
4041
+ this.#recordPaintedViewportObservation(nextViewportTop, height, paintManual);
4042
+ },
4043
+ preserveRasterLeases,
4044
+ );
4045
+ if (!writeSucceeded || !contentWritten) return false;
3266
4046
 
3267
4047
  if (this.#debugRedraw) {
3268
4048
  const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (lines=${lines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
@@ -3354,6 +4134,8 @@ export class TUI extends Container {
3354
4134
  };
3355
4135
 
3356
4136
  const renderTreeStart = renderMetrics.now();
4137
+ const renderScope = this.#renderScope;
4138
+ this.#renderScope = "full";
3357
4139
  const renderedLines: string[] = [];
3358
4140
  const renderedChildren = new Map<Component, string[]>();
3359
4141
  let anchorFrame: ViewportAnchorFrame | null = null;
@@ -3365,8 +4147,37 @@ export class TUI extends Container {
3365
4147
  const anchorRenderFailureCountBefore = viewportAnchorRenderFailureCount;
3366
4148
  for (let childIndex = 0; childIndex < this.children.length; childIndex++) {
3367
4149
  const child = this.children[childIndex];
3368
- const rendered = safeRenderComponentWithViewportAnchors(child, width, "tui-child");
3369
- const safeLines = rendered.lines.map(stripTerminalEraseControls);
4150
+ const componentRevision = child.getRenderRevision?.();
4151
+ const source = child === this.#viewportAnchorComponent ? this.#viewportOutputSource : null;
4152
+ const cached = this.#viewportAnchorRenderCache;
4153
+ const reuseCached =
4154
+ renderScope === "layout" &&
4155
+ componentRevision !== undefined &&
4156
+ source !== null &&
4157
+ cached?.component === child &&
4158
+ cached.width === width &&
4159
+ cached.componentRevision === componentRevision &&
4160
+ cached.sourceIdentity === source.identity &&
4161
+ cached.sourceRevision === source.revision;
4162
+ const rendered = reuseCached
4163
+ ? cached.rendered
4164
+ : safeRenderComponentWithViewportAnchors(child, width, "tui-child");
4165
+ const safeLines = reuseCached ? cached.safeLines : rendered.lines.map(stripTerminalEraseControls);
4166
+ const kittyPlacements = reuseCached
4167
+ ? cached.kittyPlacements
4168
+ : rendered.lines.map(line => [...extractKittyPlacementReferences(line)]);
4169
+ if (!reuseCached && componentRevision !== undefined && source !== null) {
4170
+ this.#viewportAnchorRenderCache = {
4171
+ component: child,
4172
+ width,
4173
+ componentRevision,
4174
+ sourceIdentity: source.identity,
4175
+ sourceRevision: source.revision,
4176
+ rendered,
4177
+ safeLines,
4178
+ kittyPlacements,
4179
+ };
4180
+ }
3370
4181
  renderedChildren.set(child, safeLines);
3371
4182
  const childStart = renderedLines.length;
3372
4183
  if (child === this.#viewportAnchorComponent && rendered.anchors.some(anchor => anchor !== null)) {
@@ -3374,11 +4185,10 @@ export class TUI extends Container {
3374
4185
  }
3375
4186
  const owner: KittyPlacementOwner = hasStickySuffix && childIndex >= pinnedChildIndex ? "suffix" : "transcript";
3376
4187
  for (let lineIndex = 0; lineIndex < rendered.lines.length; lineIndex++) {
3377
- const line = rendered.lines[lineIndex]!;
3378
- for (const placement of extractKittyPlacementReferences(line)) {
4188
+ for (const placement of kittyPlacements[lineIndex] ?? []) {
3379
4189
  placementOwners.set(this.#kittyPlacementKey(placement), owner);
3380
4190
  }
3381
- renderedLines.push(safeLines[lineIndex] ?? line);
4191
+ renderedLines.push(safeLines[lineIndex] ?? rendered.lines[lineIndex]!);
3382
4192
  }
3383
4193
  }
3384
4194
  const sourceTranscriptLineCount = hasStickySuffix
@@ -4057,8 +4867,15 @@ export class TUI extends Container {
4057
4867
  if (this.#writeCursorPosition(cursorPos, newLines.length)) this.#refreshPaintedLiveViewportObservation(height);
4058
4868
  return;
4059
4869
  }
4060
-
4061
4870
  const nextLiveViewportTop = Math.max(0, newLines.length - height);
4871
+ if (
4872
+ this.#rasterLeases.size > 0 &&
4873
+ this.#rasterCleanup.size === 0 &&
4874
+ !newLines.slice(Math.max(0, newLines.length - height)).some(line => TERMINAL.isImageLine(line))
4875
+ ) {
4876
+ viewportRepaint("changed frame with active raster lease");
4877
+ return;
4878
+ }
4062
4879
  if (newLines.length < this.#previousLines.length && nextLiveViewportTop !== prevViewportTop) {
4063
4880
  viewportRepaint(`content contraction changed viewport top (${prevViewportTop} -> ${nextLiveViewportTop})`);
4064
4881
  return;
@@ -4271,6 +5088,7 @@ export class TUI extends Container {
4271
5088
  return;
4272
5089
  }
4273
5090
  // Render from first changed line to end
5091
+ const renderEnd = Math.min(lastChanged, newLines.length - 1);
4274
5092
  // Build buffer with all updates wrapped in synchronized output
4275
5093
  const deletePlan = this.#kittyPlacementDeletePlan(previousKittyPlacementSpans, nextKittyPlacementSpans, [
4276
5094
  { top: firstChanged, bottom: lastChanged + 1 },
@@ -4298,6 +5116,16 @@ export class TUI extends Container {
4298
5116
  : appendStart
4299
5117
  ? firstChanged - 1
4300
5118
  : firstChanged;
5119
+ const appendWillScroll = appendStart && moveTargetRow >= prevViewportBottom;
5120
+ if (
5121
+ (moveTargetRow > prevViewportBottom || appendWillScroll || renderEnd > prevViewportBottom) &&
5122
+ this.#rasterLeases.size > 0 &&
5123
+ this.#rasterCleanup.size === 0 &&
5124
+ !newLines.slice(Math.max(0, newLines.length - height)).some(line => TERMINAL.isImageLine(line))
5125
+ ) {
5126
+ viewportRepaint("streaming append with active raster lease");
5127
+ return;
5128
+ }
4301
5129
  if (moveTargetRow > prevViewportBottom) {
4302
5130
  if (nativeScrollbackAdmission) {
4303
5131
  // The logical cursor row can be one row ahead of the physical xterm
@@ -4333,12 +5161,16 @@ export class TUI extends Container {
4333
5161
 
4334
5162
  buffer += appendStart ? "\r\n" : "\r"; // Move to column 0
4335
5163
 
4336
- // Only render changed lines (firstChanged to lastChanged), not all lines to end
4337
- // This reduces flicker when only a single line changes (e.g., spinner animation)
4338
- const renderEnd = Math.min(lastChanged, newLines.length - 1);
5164
+ // Only render changed lines (firstChanged to lastChanged), not all lines to end.
5165
+ // This reduces flicker when only a single line changes (e.g., spinner animation).
5166
+ const preserveRasterLeases =
5167
+ this.#rasterLeases.size > 0 &&
5168
+ this.#rasterCleanup.size === 0 &&
5169
+ moveTargetRow <= prevViewportBottom &&
5170
+ !newLines.slice(firstChanged, renderEnd + 1).some(line => TERMINAL.isImageLine(line));
4339
5171
  for (let i = firstChanged; i <= renderEnd; i++) {
4340
5172
  if (i > firstChanged) buffer += "\r\n";
4341
- buffer += "\x1b[2K";
5173
+ if (!preserveRasterLeases) buffer += "\x1b[2K";
4342
5174
  const line = newLines[i];
4343
5175
  let truncatedLine = line;
4344
5176
  const isImage = TERMINAL.isImageLine(line);
@@ -4365,8 +5197,18 @@ export class TUI extends Container {
4365
5197
  truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
4366
5198
  }
4367
5199
  // Non-image lines are pre-terminated/normalized by #applyLineResets;
4368
- // truncated lines re-append LINE_TERMINATOR above.
4369
- buffer += this.#padLineToWidth(truncatedLine, width);
5200
+ // truncated lines re-append LINE_TERMINATOR above. While a raster lease
5201
+ // occupies this screen row, clear and redraw only the complementary cell
5202
+ // spans so ordinary input cannot erase the inline image.
5203
+ if (preserveRasterLeases) {
5204
+ const screenRow = i - viewportTop;
5205
+ for (const segment of this.#unleasedRowSegments(screenRow, width)) {
5206
+ buffer += `\x1b[${segment.column + 1}G\x1b[${segment.width}X`;
5207
+ buffer += `${sliceByColumn(truncatedLine, segment.column, segment.width, true)}${SEGMENT_RESET}`;
5208
+ }
5209
+ } else {
5210
+ buffer += this.#padLineToWidth(truncatedLine, width);
5211
+ }
4370
5212
  }
4371
5213
 
4372
5214
  // Track where cursor ended up after rendering
@@ -4426,25 +5268,31 @@ export class TUI extends Container {
4426
5268
  // frame and geometry are authoritative even when the optional IME cursor
4427
5269
  // write subsequently detaches the terminal.
4428
5270
  if (
4429
- !this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length, () => {
4430
- this.#hardwareCursorRow = toRow;
4431
- this.#cursorRow = Math.max(0, newLines.length - 1);
4432
- this.#maxLinesRendered = newLines.length;
4433
- this.#viewportTopRow = Math.max(0, newLines.length - height);
4434
- this.#nativeScrollbackViewportTop = Math.max(this.#nativeScrollbackViewportTop, this.#viewportTopRow);
4435
- this.#previousLines = newLines;
4436
- this.#previousWidth = width;
4437
- this.#previousHeight = height;
4438
- this.#kittyPlacementSpans = this.#kittyCommittedPlacementsAfterPaint(
4439
- previousKittyPlacementSpans,
4440
- nextKittyPlacementSpans,
4441
- deletePlan,
4442
- [{ top: firstChanged, bottom: renderEnd + 1 }],
4443
- );
4444
- this.#manualTranscriptLineCount = nextTranscriptLineCount;
4445
- this.#manualSuffixLineCount = nextSuffixLineCount;
4446
- this.#refreshPaintedLiveViewportObservation(height);
4447
- })
5271
+ !this.#writeRenderBufferAndReanchorImeCursor(
5272
+ buffer,
5273
+ cursorPos,
5274
+ newLines.length,
5275
+ () => {
5276
+ this.#hardwareCursorRow = toRow;
5277
+ this.#cursorRow = Math.max(0, newLines.length - 1);
5278
+ this.#maxLinesRendered = newLines.length;
5279
+ this.#viewportTopRow = Math.max(0, newLines.length - height);
5280
+ this.#nativeScrollbackViewportTop = Math.max(this.#nativeScrollbackViewportTop, this.#viewportTopRow);
5281
+ this.#previousLines = newLines;
5282
+ this.#previousWidth = width;
5283
+ this.#previousHeight = height;
5284
+ this.#kittyPlacementSpans = this.#kittyCommittedPlacementsAfterPaint(
5285
+ previousKittyPlacementSpans,
5286
+ nextKittyPlacementSpans,
5287
+ deletePlan,
5288
+ [{ top: firstChanged, bottom: renderEnd + 1 }],
5289
+ );
5290
+ this.#manualTranscriptLineCount = nextTranscriptLineCount;
5291
+ this.#manualSuffixLineCount = nextSuffixLineCount;
5292
+ this.#refreshPaintedLiveViewportObservation(height);
5293
+ },
5294
+ preserveRasterLeases,
5295
+ )
4448
5296
  )
4449
5297
  return;
4450
5298
  this.#latestRenderedLines = newLines;
@@ -4494,18 +5342,51 @@ export class TUI extends Container {
4494
5342
 
4495
5343
  return { seq, toRow: targetRow };
4496
5344
  }
5345
+ #cursorVisibilitySequence(): string {
5346
+ if (this.#showHardwareCursor || this.#imeCursorActive)
5347
+ return this.#useImeBlockCursor ? "\x1b[2 q\x1b[?25h" : "\x1b[?25h";
5348
+ return this.#useImeBlockCursor ? "\x1b[0 q\x1b[?25l" : "\x1b[?25l";
5349
+ }
5350
+ #cursorGuardedRasterSequence(payload: string): string {
5351
+ return `\x1b[?2026h\x1b7\x1b[?25l${payload}\x1b8${this.#cursorVisibilitySequence()}\x1b[?2026l`;
5352
+ }
4497
5353
 
4498
5354
  /** Retain terminal cleanup until a write succeeds, even after its component is disposed. */
4499
- queueTerminalCleanup(payload: string, onDelivered?: () => void): void {
4500
- this.#pendingTerminalCleanup.push({ payload, onDelivered });
4501
- this.flushTerminalCleanup();
5355
+ queueTerminalCleanup(payload: string, onDelivered?: () => void): Promise<void> {
5356
+ return this.#enqueueRaster(isCurrentLifecycle => {
5357
+ if (isCurrentLifecycle() && this.#writeTerminal(payload)) {
5358
+ onDelivered?.();
5359
+ return;
5360
+ }
5361
+ // Stale epoch or failed write: retain the payload for a future start()
5362
+ // instead of writing it after terminal restoration.
5363
+ this.#pendingTerminalCleanup.push({ payload, onDelivered });
5364
+ });
5365
+ }
5366
+
5367
+ /** Queue protocol-neutral output behind the same terminal ordering as renders. */
5368
+ queueTerminalOutput(
5369
+ payload: string,
5370
+ options?: { shouldWrite?: () => boolean; onWritten?: () => void },
5371
+ ): Promise<TerminalOutputAck> {
5372
+ return this.submitTerminalOutput({
5373
+ operation: {
5374
+ type: "queued-output",
5375
+ bytes: new TextEncoder().encode(payload),
5376
+ ...(options?.shouldWrite ? { shouldWrite: options.shouldWrite } : {}),
5377
+ ...(options?.onWritten ? { onWritten: options.onWritten } : {}),
5378
+ },
5379
+ });
4502
5380
  }
4503
5381
 
4504
- /** Retry queued terminal cleanup after terminal recovery or before shutdown. */
4505
- flushTerminalCleanup(): void {
5382
+ /** Retry retained cleanup after recovery or before shutdown. */
5383
+ flushTerminalCleanup(restoreTerminalAvailability = false): void {
4506
5384
  while (this.#pendingTerminalCleanup.length > 0) {
4507
5385
  const pending = this.#pendingTerminalCleanup[0];
4508
- if (!this.#writeTerminal(pending.payload)) return;
5386
+ const written = restoreTerminalAvailability
5387
+ ? this.#writeLifecycleCleanup(pending.payload)
5388
+ : this.#writeTerminal(pending.payload);
5389
+ if (!written) return;
4509
5390
  this.#pendingTerminalCleanup.shift();
4510
5391
  pending.onDelivered?.();
4511
5392
  }
@@ -4516,44 +5397,46 @@ export class TUI extends Container {
4516
5397
  * transaction. The emitter is an exempt physical overlay: its bytes are
4517
5398
  * deliberately kept out of the shared transcript write.
4518
5399
  */
4519
- setPostRenderEmitter(emitter: (() => string | null) | undefined): void {
5400
+ setPostRenderEmitter(emitter: (() => string | PostRenderEmission | null) | undefined): void {
4520
5401
  this.#postRenderEmitter = emitter;
4521
5402
  }
4522
5403
 
4523
- #postRenderEmitter: (() => string | null) | undefined;
5404
+ #postRenderEmitter: (() => string | PostRenderEmission | null) | undefined;
4524
5405
 
4525
5406
  #writeRenderBufferAndReanchorImeCursor(
4526
5407
  buffer: string,
4527
5408
  cursorPos: { row: number; col: number } | null,
4528
5409
  totalLines: number,
4529
5410
  onBufferWritten?: () => void,
5411
+ preserveRasterLeases = false,
4530
5412
  ): boolean {
4531
- if (!this.#writeTerminal(buffer)) {
4532
- return false;
4533
- }
4534
- onBufferWritten?.();
4535
- this.#lastRenderWriteSucceeded = true;
4536
-
4537
- const overlay = this.#postRenderEmitter?.();
4538
- if (overlay) {
4539
- // DECSC/DECRC keep the hardware cursor stable; the dedicated
4540
- // synchronized block prevents visible tearing while the overlay
4541
- // area is cleared and redrawn.
4542
- const overlayBuffer = this.#frameSynchronizedOutput(`\x1b7${overlay}\x1b8`);
4543
- // Overlay delivery is outside shared transcript ownership. The
4544
- // shared write has already committed even when this exempt write
4545
- // fails, so do not make callers retry the shared bytes.
4546
- if (!this.#writeTerminal(overlayBuffer, true)) {
4547
- return true;
5413
+ const writeIngress = preserveRasterLeases
5414
+ ? (bytes: string) => this.#writeRasterPreservingRenderIngress(bytes)
5415
+ : (bytes: string) => this.#writeProtectedRenderIngress(bytes);
5416
+ const renderGeneration = this.#renderGenerationInProgress;
5417
+ const write = () => {
5418
+ if (!writeIngress(buffer)) return false;
5419
+ onBufferWritten?.();
5420
+ this.#lastRenderWriteSucceeded = true;
5421
+ if (renderGeneration > 0) this.#settleRenderCommitWaiters(true, renderGeneration);
5422
+ const emission = this.#postRenderEmitter?.();
5423
+ if (emission) {
5424
+ const overlay = typeof emission === "string" ? emission : emission.payload;
5425
+ const overlayBuffer = this.#frameSynchronizedOutput(`\x1b7${overlay}\x1b8`);
5426
+ if (this.#writeTerminal(overlayBuffer, true) && typeof emission !== "string") emission.onWritten?.();
4548
5427
  }
4549
- }
4550
- if (!this.#imeCursorActive) return true;
4551
- // Cursor positioning is outside shared transcript ownership. A failure still
4552
- // makes the terminal unavailable, but cannot uncommit the shared frame. The
4553
- // onBufferWritten callback has already run; the return value propagates
4554
- // terminal availability so callers can detect the detach.
4555
- const cursorWritten = this.#writeCursorPosition(cursorPos, totalLines, true);
4556
- return cursorWritten;
5428
+ if (!this.#imeCursorActive) return true;
5429
+ const cursorWritten = this.#writeCursorPosition(cursorPos, totalLines, true);
5430
+ return cursorWritten;
5431
+ };
5432
+ if (
5433
+ this.#rasterPending === 0 &&
5434
+ this.#rasterCleanup.size === 0 &&
5435
+ (preserveRasterLeases || this.#rasterLeases.size === 0)
5436
+ )
5437
+ return write();
5438
+ this.#enqueueRaster(isCurrentLifecycle => isCurrentLifecycle() && write());
5439
+ return true;
4557
5440
  }
4558
5441
 
4559
5442
  /**