@gajae-code/tui 0.13.2 → 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.
@@ -349,15 +422,26 @@ export type SizeValue = number | `${number}%`;
349
422
  /** Parse a SizeValue into absolute value given a reference size */
350
423
  function parseSizeValue(value: SizeValue | undefined, referenceSize: number): number | undefined {
351
424
  if (value === undefined) return undefined;
352
- if (typeof value === "number") return value;
425
+ if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
353
426
  // Parse percentage string like "50%"
354
427
  const match = value.match(/^(\d+(?:\.\d+)?)%$/);
355
428
  if (match) {
356
- return Math.floor((referenceSize * parseFloat(match[1])) / 100);
429
+ const percent = Number.parseFloat(match[1]);
430
+ if (!Number.isFinite(percent)) return undefined;
431
+ const parsed = Math.floor((referenceSize * percent) / 100);
432
+ return Number.isFinite(parsed) ? parsed : undefined;
357
433
  }
358
434
  return undefined;
359
435
  }
360
436
 
437
+ function finiteNumber(value: number | undefined, fallback: number): number {
438
+ return value !== undefined && Number.isFinite(value) ? value : fallback;
439
+ }
440
+
441
+ function finiteNonNegative(value: number | undefined, fallback = 0): number {
442
+ return Math.max(0, finiteNumber(value, fallback));
443
+ }
444
+
361
445
  const DISABLED_ENV_VALUES = new Set(["0", "false", "off", "no"]);
362
446
 
363
447
  function envIsEnabled(value: string | undefined): boolean {
@@ -511,9 +595,19 @@ export class Container implements ViewportAnchorProvider {
511
595
  children: Component[] = [];
512
596
  #disposed = false;
513
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
+ }
514
607
 
515
608
  addChild(component: Component): void {
516
609
  this.children.push(component);
610
+ this.#advanceRenderRevision();
517
611
  }
518
612
 
519
613
  removeChild(component: Component): void {
@@ -522,6 +616,7 @@ export class Container implements ViewportAnchorProvider {
522
616
  this.children.splice(index, 1);
523
617
  this.#viewportAnchorSources.delete(component);
524
618
  component.dispose?.();
619
+ this.#advanceRenderRevision();
525
620
  }
526
621
  }
527
622
 
@@ -531,6 +626,7 @@ export class Container implements ViewportAnchorProvider {
531
626
  if (index !== -1) {
532
627
  this.children.splice(index, 1);
533
628
  this.#viewportAnchorSources.delete(component);
629
+ this.#advanceRenderRevision();
534
630
  }
535
631
  }
536
632
 
@@ -552,12 +648,21 @@ export class Container implements ViewportAnchorProvider {
552
648
  for (const child of this.children) child.dispose?.();
553
649
  this.children = [];
554
650
  this.#viewportAnchorSources.clear();
651
+ this.#advanceRenderRevision();
555
652
  }
556
653
 
557
654
  /** Remove all children without disposing them (for detach-then-readd reuse). */
558
655
  detachAll(): void {
559
656
  this.children = [];
560
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();
561
666
  }
562
667
 
563
668
  /** Registers a direct child as eligible for semantic viewport anchoring. */
@@ -567,6 +672,7 @@ export class Container implements ViewportAnchorProvider {
567
672
  }
568
673
  if (source === null) this.#viewportAnchorSources.delete(component);
569
674
  else this.#viewportAnchorSources.set(component, source);
675
+ this.#advanceRenderRevision();
570
676
  }
571
677
 
572
678
  dispose(): void {
@@ -574,10 +680,12 @@ export class Container implements ViewportAnchorProvider {
574
680
  this.#disposed = true;
575
681
  for (const child of this.children) child.dispose?.();
576
682
  this.#viewportAnchorSources.clear();
683
+ this.#advanceRenderRevision();
577
684
  }
578
685
 
579
686
  invalidate(): void {
580
687
  for (const child of this.children) child.invalidate?.();
688
+ this.#advanceRenderRevision();
581
689
  }
582
690
 
583
691
  render(width: number): string[] {
@@ -871,6 +979,18 @@ export class TUI extends Container {
871
979
  #previousHeight = 0;
872
980
  #focusedComponent: Component | null = null;
873
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;
874
994
 
875
995
  /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
876
996
  onDebug?: () => void;
@@ -880,9 +1000,12 @@ export class TUI extends Container {
880
1000
  #committedRenderGeneration = 0;
881
1001
  #renderCommitWaiters = new Map<number, Set<RenderCommitWaiter>>();
882
1002
  #lastRenderWriteSucceeded = false;
1003
+ /** Generation whose render path is currently capturing terminal output. */
1004
+ #renderGenerationInProgress = 0;
883
1005
  #resizeRenderQueued = false;
884
1006
  #resizeRenderMutationQueued = false;
885
1007
  #renderMutationQueued = false;
1008
+ #renderScope: "full" | "layout" = "full";
886
1009
  #renderTimer: NodeJS.Timeout | undefined;
887
1010
  #widthSettleTimer: NodeJS.Timeout | undefined;
888
1011
  #widthSettleRepairPending = false;
@@ -924,6 +1047,7 @@ export class TUI extends Container {
924
1047
  #manualViewportAnchor: ManualViewportAnchor | null = null;
925
1048
  #manualViewportFallbackAnchors: ManualViewportAnchor[] = [];
926
1049
  #reconcileMissingViewportAnchor = false;
1050
+ #manualViewportLeaseSuspendPending = false;
927
1051
  #lastCursorPosition: { row: number; col: number } | null = null;
928
1052
  #latestViewportObservation: TuiViewportObservation | null = null;
929
1053
  #sixelProbePendingDa = false;
@@ -961,6 +1085,39 @@ export class TUI extends Container {
961
1085
  #manualSuffixLineCount = 0;
962
1086
  #committedTranscriptRows: Array<number | null> = [];
963
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;
964
1121
 
965
1122
  #unsubscribeTabWidthChange?: () => void;
966
1123
  static #renderCounters: TuiRenderCounterSnapshot = {
@@ -1057,8 +1214,33 @@ export class TUI extends Container {
1057
1214
  override dispose(): void {
1058
1215
  this.#unsubscribeTabWidthChange?.();
1059
1216
  this.#unsubscribeTabWidthChange = undefined;
1217
+ this.#finalizeRasterLeases("terminal-loss");
1060
1218
  super.dispose();
1061
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
+ }
1062
1244
 
1063
1245
  get fullRedraws(): number {
1064
1246
  return this.#fullRedrawCount;
@@ -1157,11 +1339,13 @@ export class TUI extends Container {
1157
1339
  override removeChild(component: Component): void {
1158
1340
  this.#invalidateFocusForRemovedTree(component);
1159
1341
  super.removeChild(component);
1342
+ if (component === this.#viewportAnchorComponent) this.#viewportAnchorRenderCache = undefined;
1160
1343
  }
1161
1344
 
1162
1345
  override clear(): void {
1163
1346
  for (const child of this.children) this.#invalidateFocusForRemovedTree(child);
1164
1347
  super.clear();
1348
+ this.#viewportAnchorRenderCache = undefined;
1165
1349
  }
1166
1350
 
1167
1351
  #invalidateFocusForRemovedTree(component: Component): void {
@@ -1203,6 +1387,7 @@ export class TUI extends Container {
1203
1387
  }
1204
1388
  if (identityReset || this.#manualViewportTop === undefined) this.#manualOutputNotice = false;
1205
1389
  this.#viewportOutputSource = source;
1390
+ if (identityReset) this.#viewportAnchorRenderCache = undefined;
1206
1391
  this.requestRender();
1207
1392
  }
1208
1393
 
@@ -1213,6 +1398,7 @@ export class TUI extends Container {
1213
1398
  }
1214
1399
  if (this.#viewportAnchorComponent === component) return;
1215
1400
  this.#viewportAnchorComponent = component;
1401
+ this.#viewportAnchorRenderCache = undefined;
1216
1402
  this.#viewportAnchorFrame = null;
1217
1403
  }
1218
1404
  /** Returns the direct component registered as the semantic viewport anchor source. */
@@ -1255,6 +1441,28 @@ export class TUI extends Container {
1255
1441
  if (this.#manualViewportAnchor !== null) this.#reconcileMissingViewportAnchor = true;
1256
1442
  }
1257
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
+
1258
1466
  /** Reveal a semantic viewport anchor without changing the rendered content width. */
1259
1467
  revealViewportAnchor(id: ViewportAnchorId, alignment: "top" | "center" | "bottom"): boolean {
1260
1468
  const height = this.terminal.rows;
@@ -1279,6 +1487,11 @@ export class TUI extends Container {
1279
1487
  const desiredScreenRow =
1280
1488
  alignment === "top" ? 0 : alignment === "center" ? Math.floor(transcriptCapacity / 2) : transcriptCapacity - 1;
1281
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;
1282
1495
  this.#manualViewportAnchor = {
1283
1496
  id: selected.id,
1284
1497
  graphemeIndex:
@@ -1351,6 +1564,11 @@ export class TUI extends Container {
1351
1564
  if (this.#manualViewportTop === undefined && currentViewportTop === maxViewportTop) return true;
1352
1565
  if (this.#manualViewportTop !== undefined) return this.followLiveViewport();
1353
1566
  }
1567
+ if (
1568
+ this.#manualViewportTop === undefined &&
1569
+ this.#suspendRasterLeasesForManualViewport(() => this.scrollViewportBy(delta, options))
1570
+ )
1571
+ return true;
1354
1572
  if (frame !== null) {
1355
1573
  const desiredScreenRow =
1356
1574
  this.#manualViewportAnchor?.desiredScreenRow ??
@@ -1615,10 +1833,447 @@ export class TUI extends Container {
1615
1833
 
1616
1834
  override invalidate(): void {
1617
1835
  super.invalidate();
1836
+ this.#viewportAnchorRenderCache = undefined;
1618
1837
  for (const overlay of this.overlayStack) overlay.component.invalidate?.();
1619
1838
  for (const overlay of this.overlayStack) overlay.mouseBounds = undefined;
1620
1839
  }
1621
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
+ }
1622
2277
  start(): void {
1623
2278
  this.#stopped = false;
1624
2279
  this.#terminalUnavailable = false;
@@ -1629,11 +2284,34 @@ export class TUI extends Container {
1629
2284
  this.terminal.start(
1630
2285
  data => this.#handleInput(data),
1631
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);
1632
2292
  this.invalidate();
1633
- 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
+ }
1634
2306
  },
1635
2307
  );
1636
- 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
+ }
1637
2315
  this.#hideCursor();
1638
2316
  this.#querySixelSupport();
1639
2317
  this.#queryCellSize();
@@ -1695,7 +2373,35 @@ export class TUI extends Container {
1695
2373
  return !this.#terminalUnavailable && this.terminal.available;
1696
2374
  }
1697
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
+ }
1698
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");
1699
2405
  this.#terminalUnavailable = true;
1700
2406
  this.#stopped = true;
1701
2407
  this.#renderRequested = false;
@@ -1742,12 +2448,37 @@ export class TUI extends Container {
1742
2448
  return true;
1743
2449
  }
1744
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
+
1745
2470
  addInputListener(listener: InputListener): () => void {
1746
2471
  this.#inputListeners.add(listener);
1747
2472
  return () => {
1748
2473
  this.#inputListeners.delete(listener);
1749
2474
  };
1750
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
+ }
1751
2482
 
1752
2483
  removeInputListener(listener: InputListener): void {
1753
2484
  this.#inputListeners.delete(listener);
@@ -1888,9 +2619,14 @@ export class TUI extends Container {
1888
2619
  this.invalidate();
1889
2620
  this.requestRender(true);
1890
2621
  }
1891
- #queryCellSize(): void {
1892
- // Only query if terminal supports images (cell size is only used for image rendering)
1893
- 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) {
1894
2630
  return;
1895
2631
  }
1896
2632
  // Query terminal for cell size in pixels: CSI 16 t
@@ -1899,7 +2635,11 @@ export class TUI extends Container {
1899
2635
  }
1900
2636
 
1901
2637
  stop(): void {
1902
- 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");
1903
2643
  const placementCleanup = this.#kittyPlacementDeletePlan(this.#kittyPlacementSpans, [], [], true).output;
1904
2644
  if (placementCleanup.length > 0 && this.#writeTerminal(placementCleanup)) this.#kittyPlacementSpans = [];
1905
2645
  this.#clearSixelProbeState();
@@ -2061,13 +2801,24 @@ export class TUI extends Container {
2061
2801
  this.requestRenderWithGeneration(force, source);
2062
2802
  }
2063
2803
 
2064
- requestRenderWithGeneration(force = false, source = "unknown"): number {
2804
+ #requestRenderWithScope(force: boolean, source: string, scope: "full" | "layout"): number {
2065
2805
  const generation = ++this.#nextRenderGeneration;
2066
2806
  this.#renderRequestedGeneration = Math.max(this.#renderRequestedGeneration, generation);
2807
+ if (scope === "full") this.#renderScope = "full";
2067
2808
  this.#requestRenderCore(force, source, generation);
2068
2809
  return generation;
2069
2810
  }
2070
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
+
2071
2822
  #requestRenderCore(force: boolean, source: string, generation: number): void {
2072
2823
  if (!this.terminalAvailable) {
2073
2824
  this.#markTerminalUnavailable();
@@ -2128,7 +2879,9 @@ export class TUI extends Container {
2128
2879
  this.#lastRenderAt = performance.now();
2129
2880
  this.#lastRenderWriteSucceeded = false;
2130
2881
  const t0 = renderMetrics.now();
2882
+ this.#renderGenerationInProgress = requestedGeneration;
2131
2883
  this.#doRender();
2884
+ this.#renderGenerationInProgress = 0;
2132
2885
  this.#commitRenderGeneration(requestedGeneration);
2133
2886
  if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
2134
2887
  });
@@ -2171,7 +2924,9 @@ export class TUI extends Container {
2171
2924
  this.#lastRenderAt = performance.now();
2172
2925
  this.#lastRenderWriteSucceeded = false;
2173
2926
  const t0 = renderMetrics.now();
2927
+ this.#renderGenerationInProgress = requestedGeneration;
2174
2928
  this.#doRender();
2929
+ this.#renderGenerationInProgress = 0;
2175
2930
  this.#commitRenderGeneration(requestedGeneration);
2176
2931
  if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
2177
2932
  if (this.#renderRequested) {
@@ -2201,7 +2956,9 @@ export class TUI extends Container {
2201
2956
  this.#lastRenderAt = performance.now();
2202
2957
  this.#lastRenderWriteSucceeded = false;
2203
2958
  const t0 = renderMetrics.now();
2959
+ this.#renderGenerationInProgress = requestedGeneration;
2204
2960
  this.#doRender();
2961
+ this.#renderGenerationInProgress = 0;
2205
2962
  this.#commitRenderGeneration(requestedGeneration);
2206
2963
  if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
2207
2964
  }
@@ -2452,7 +3209,14 @@ export class TUI extends Container {
2452
3209
 
2453
3210
  const heightPx = parseInt(match[1], 10);
2454
3211
  const widthPx = parseInt(match[2], 10);
2455
- 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
+ ) {
2456
3220
  return true;
2457
3221
  }
2458
3222
 
@@ -2480,10 +3244,10 @@ export class TUI extends Container {
2480
3244
  typeof opt.margin === "number"
2481
3245
  ? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin }
2482
3246
  : (opt.margin ?? {});
2483
- const marginTop = Math.max(0, margin.top ?? 0);
2484
- const marginRight = Math.max(0, margin.right ?? 0);
2485
- const marginBottom = Math.max(0, margin.bottom ?? 0);
2486
- const marginLeft = Math.max(0, margin.left ?? 0);
3247
+ const marginTop = Math.min(finiteNonNegative(margin.top), Math.max(0, termHeight - 1));
3248
+ const marginRight = Math.min(finiteNonNegative(margin.right), Math.max(0, termWidth - 1));
3249
+ const marginBottom = Math.min(finiteNonNegative(margin.bottom), Math.max(0, termHeight - 1 - marginTop));
3250
+ const marginLeft = Math.min(finiteNonNegative(margin.left), Math.max(0, termWidth - 1 - marginRight));
2487
3251
 
2488
3252
  // Available space after margins
2489
3253
  const availWidth = Math.max(1, termWidth - marginLeft - marginRight);
@@ -2492,14 +3256,15 @@ export class TUI extends Container {
2492
3256
  // === Resolve width ===
2493
3257
  let width = parseSizeValue(opt.width, termWidth) ?? Math.min(80, availWidth);
2494
3258
  // Apply minWidth
2495
- if (opt.minWidth !== undefined) {
3259
+ if (opt.minWidth !== undefined && Number.isFinite(opt.minWidth)) {
2496
3260
  width = Math.max(width, opt.minWidth);
2497
3261
  }
2498
3262
  // Clamp to available space
2499
3263
  width = Math.max(1, Math.min(width, availWidth));
2500
3264
 
2501
3265
  // === Resolve maxHeight ===
2502
- let maxHeight = parseSizeValue(opt.maxHeight, termHeight);
3266
+ const parsedMaxHeight = parseSizeValue(opt.maxHeight, termHeight);
3267
+ let maxHeight = opt.maxHeight !== undefined && parsedMaxHeight === undefined ? availHeight : parsedMaxHeight;
2503
3268
  // Clamp to available space
2504
3269
  if (maxHeight !== undefined) {
2505
3270
  maxHeight = Math.max(1, Math.min(maxHeight, availHeight));
@@ -2519,14 +3284,18 @@ export class TUI extends Container {
2519
3284
  if (match) {
2520
3285
  const maxRow = Math.max(0, availHeight - effectiveHeight);
2521
3286
  const percent = parseFloat(match[1]) / 100;
2522
- row = marginTop + Math.floor(maxRow * percent);
3287
+ row = Number.isFinite(percent)
3288
+ ? marginTop + Math.floor(maxRow * percent)
3289
+ : this.#resolveAnchorRow(opt.anchor ?? "center", effectiveHeight, availHeight, marginTop);
2523
3290
  } else {
2524
3291
  // Invalid format, fall back to center
2525
3292
  row = this.#resolveAnchorRow("center", effectiveHeight, availHeight, marginTop);
2526
3293
  }
2527
- } else {
3294
+ } else if (Number.isFinite(opt.row)) {
2528
3295
  // Absolute row position
2529
3296
  row = opt.row;
3297
+ } else {
3298
+ row = this.#resolveAnchorRow(opt.anchor ?? "center", effectiveHeight, availHeight, marginTop);
2530
3299
  }
2531
3300
  } else {
2532
3301
  // Anchor-based (default: center)
@@ -2541,14 +3310,18 @@ export class TUI extends Container {
2541
3310
  if (match) {
2542
3311
  const maxCol = Math.max(0, availWidth - width);
2543
3312
  const percent = parseFloat(match[1]) / 100;
2544
- col = marginLeft + Math.floor(maxCol * percent);
3313
+ col = Number.isFinite(percent)
3314
+ ? marginLeft + Math.floor(maxCol * percent)
3315
+ : this.#resolveAnchorCol(opt.anchor ?? "center", width, availWidth, marginLeft);
2545
3316
  } else {
2546
3317
  // Invalid format, fall back to center
2547
3318
  col = this.#resolveAnchorCol("center", width, availWidth, marginLeft);
2548
3319
  }
2549
- } else {
3320
+ } else if (Number.isFinite(opt.col)) {
2550
3321
  // Absolute column position
2551
3322
  col = opt.col;
3323
+ } else {
3324
+ col = this.#resolveAnchorCol(opt.anchor ?? "center", width, availWidth, marginLeft);
2552
3325
  }
2553
3326
  } else {
2554
3327
  // Anchor-based (default: center)
@@ -2557,8 +3330,8 @@ export class TUI extends Container {
2557
3330
  }
2558
3331
 
2559
3332
  // Apply offsets
2560
- if (opt.offsetY !== undefined) row += opt.offsetY;
2561
- if (opt.offsetX !== undefined) col += opt.offsetX;
3333
+ row += finiteNumber(opt.offsetY, 0);
3334
+ col += finiteNumber(opt.offsetX, 0);
2562
3335
 
2563
3336
  // Clamp to terminal bounds (respecting margins)
2564
3337
  row = Math.max(marginTop, Math.min(row, termHeight - marginBottom - effectiveHeight));
@@ -2648,6 +3421,9 @@ export class TUI extends Container {
2648
3421
  // than the current content. Padding to it can cause the renderer to output hundreds/thousands of blank
2649
3422
  // lines, effectively scrolling the terminal when an overlay is shown.
2650
3423
  const workingHeight = Math.max(result.length, minLinesNeeded);
3424
+ if (!Number.isFinite(workingHeight)) {
3425
+ throw new Error("Overlay layout produced a non-finite working height");
3426
+ }
2651
3427
 
2652
3428
  // Extend result with empty lines if content is too short for overlay placement
2653
3429
  while (result.length < workingHeight) {
@@ -3179,34 +3955,55 @@ export class TUI extends Container {
3179
3955
  { top: transcriptLineCount, bottom: transcriptLineCount + suffixLineCount },
3180
3956
  ]
3181
3957
  : [{ top: nextViewportTop, bottom: nextViewportTop + height }];
3182
- let buffer = deletePlan.output;
3183
- 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";
3184
3976
  const committedTranscriptRows: Array<number | null> = [];
3185
3977
  for (let screenRow = 0; screenRow < height; screenRow++) {
3186
- 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";
3187
3980
  const lineIndex = nextViewportTop + screenRow;
3188
- const suffixRow = screenRow - transcriptCapacity - noticeRows;
3189
- const line =
3190
- paintManual && screenRow === transcriptCapacity && noticeRows > 0
3191
- ? "New output — type to follow"
3192
- : paintManual && suffixRow >= 0
3193
- ? (lines[transcriptLineCount + suffixRow] ?? "")
3194
- : paintManual && lineIndex >= transcriptLineCount
3195
- ? ""
3196
- : (lines[lineIndex] ?? "");
3981
+ const line = visibleLines[screenRow]!;
3197
3982
  committedTranscriptRows.push(
3198
3983
  screenRow < transcriptCapacity && lineIndex < transcriptLineCount ? lineIndex : null,
3199
3984
  );
3200
3985
  const isImage = TERMINAL.isImageLine(line);
3201
- if (avoidScrollback && isImage) buffer += "\x1b7\x1b[2K";
3986
+ if (!preserveRasterLeases && avoidScrollback && isImage) buffer += "\x1b7\x1b[2K";
3202
3987
  if (!isImage && this.#visibleWidthForDifferentialGuard(line) > width) {
3203
3988
  let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
3204
3989
  truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
3205
- 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
+ }
3206
4003
  } else {
3207
4004
  buffer += this.#padLineToWidth(line, width);
3208
4005
  }
3209
- if (avoidScrollback && isImage) buffer += "\x1b8";
4006
+ if (!preserveRasterLeases && avoidScrollback && isImage) buffer += "\x1b8";
3210
4007
  }
3211
4008
  if (avoidScrollback) buffer += "\r";
3212
4009
 
@@ -3221,25 +4018,31 @@ export class TUI extends Container {
3221
4018
  buffer += cursorSeq;
3222
4019
  buffer = this.#frameSynchronizedOutput(buffer);
3223
4020
  let contentWritten = false;
3224
- const writeSucceeded = this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, lines.length, () => {
3225
- contentWritten = true;
3226
- this.#hardwareCursorRow = cursorToRow;
3227
- this.#committedTranscriptRows = committedTranscriptRows;
3228
- this.#cursorRow = Math.max(0, lines.length - 1);
3229
- this.#maxLinesRendered = lines.length;
3230
- this.#viewportTopRow = nextViewportTop;
3231
- if (paintManual) this.#manualViewportTop = nextViewportTop;
3232
- this.#kittyPlacementSpans = this.#kittyCommittedPlacementsAfterPaint(
3233
- placementsToClear,
3234
- placementsToPaint,
3235
- deletePlan,
3236
- emittedRegions,
3237
- );
3238
- onPainted?.();
3239
- this.#paintedManualOutputNotice = paintManual && this.#manualOutputNotice;
3240
- this.#recordPaintedViewportObservation(nextViewportTop, height, paintManual);
3241
- });
3242
- 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;
3243
4046
 
3244
4047
  if (this.#debugRedraw) {
3245
4048
  const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (lines=${lines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
@@ -3331,6 +4134,8 @@ export class TUI extends Container {
3331
4134
  };
3332
4135
 
3333
4136
  const renderTreeStart = renderMetrics.now();
4137
+ const renderScope = this.#renderScope;
4138
+ this.#renderScope = "full";
3334
4139
  const renderedLines: string[] = [];
3335
4140
  const renderedChildren = new Map<Component, string[]>();
3336
4141
  let anchorFrame: ViewportAnchorFrame | null = null;
@@ -3342,8 +4147,37 @@ export class TUI extends Container {
3342
4147
  const anchorRenderFailureCountBefore = viewportAnchorRenderFailureCount;
3343
4148
  for (let childIndex = 0; childIndex < this.children.length; childIndex++) {
3344
4149
  const child = this.children[childIndex];
3345
- const rendered = safeRenderComponentWithViewportAnchors(child, width, "tui-child");
3346
- 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
+ }
3347
4181
  renderedChildren.set(child, safeLines);
3348
4182
  const childStart = renderedLines.length;
3349
4183
  if (child === this.#viewportAnchorComponent && rendered.anchors.some(anchor => anchor !== null)) {
@@ -3351,11 +4185,10 @@ export class TUI extends Container {
3351
4185
  }
3352
4186
  const owner: KittyPlacementOwner = hasStickySuffix && childIndex >= pinnedChildIndex ? "suffix" : "transcript";
3353
4187
  for (let lineIndex = 0; lineIndex < rendered.lines.length; lineIndex++) {
3354
- const line = rendered.lines[lineIndex]!;
3355
- for (const placement of extractKittyPlacementReferences(line)) {
4188
+ for (const placement of kittyPlacements[lineIndex] ?? []) {
3356
4189
  placementOwners.set(this.#kittyPlacementKey(placement), owner);
3357
4190
  }
3358
- renderedLines.push(safeLines[lineIndex] ?? line);
4191
+ renderedLines.push(safeLines[lineIndex] ?? rendered.lines[lineIndex]!);
3359
4192
  }
3360
4193
  }
3361
4194
  const sourceTranscriptLineCount = hasStickySuffix
@@ -4034,25 +4867,17 @@ export class TUI extends Container {
4034
4867
  if (this.#writeCursorPosition(cursorPos, newLines.length)) this.#refreshPaintedLiveViewportObservation(height);
4035
4868
  return;
4036
4869
  }
4037
-
4038
4870
  const nextLiveViewportTop = Math.max(0, newLines.length - height);
4039
- if (newLines.length < this.#previousLines.length && nextLiveViewportTop !== prevViewportTop) {
4040
- viewportRepaint(`content contraction changed viewport top (${prevViewportTop} -> ${nextLiveViewportTop})`);
4041
- return;
4042
- }
4043
4871
  if (
4044
- appendedLines &&
4045
- nextLiveViewportTop > prevViewportTop &&
4046
- previousKittyPlacementSpans.some(placement =>
4047
- this.#kittyPlacementIntersectsRegion(placement, {
4048
- top: prevViewportTop,
4049
- bottom: prevViewportTop + height,
4050
- }),
4051
- )
4872
+ this.#rasterLeases.size > 0 &&
4873
+ this.#rasterCleanup.size === 0 &&
4874
+ !newLines.slice(Math.max(0, newLines.length - height)).some(line => TERMINAL.isImageLine(line))
4052
4875
  ) {
4053
- viewportRepaint(
4054
- `content append moved a Kitty placement viewport (${prevViewportTop} -> ${nextLiveViewportTop})`,
4055
- );
4876
+ viewportRepaint("changed frame with active raster lease");
4877
+ return;
4878
+ }
4879
+ if (newLines.length < this.#previousLines.length && nextLiveViewportTop !== prevViewportTop) {
4880
+ viewportRepaint(`content contraction changed viewport top (${prevViewportTop} -> ${nextLiveViewportTop})`);
4056
4881
  return;
4057
4882
  }
4058
4883
  if (distinctPostContractionRows) this.#scrollbackResumeViewportTop = undefined;
@@ -4263,6 +5088,7 @@ export class TUI extends Container {
4263
5088
  return;
4264
5089
  }
4265
5090
  // Render from first changed line to end
5091
+ const renderEnd = Math.min(lastChanged, newLines.length - 1);
4266
5092
  // Build buffer with all updates wrapped in synchronized output
4267
5093
  const deletePlan = this.#kittyPlacementDeletePlan(previousKittyPlacementSpans, nextKittyPlacementSpans, [
4268
5094
  { top: firstChanged, bottom: lastChanged + 1 },
@@ -4290,6 +5116,16 @@ export class TUI extends Container {
4290
5116
  : appendStart
4291
5117
  ? firstChanged - 1
4292
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
+ }
4293
5129
  if (moveTargetRow > prevViewportBottom) {
4294
5130
  if (nativeScrollbackAdmission) {
4295
5131
  // The logical cursor row can be one row ahead of the physical xterm
@@ -4325,12 +5161,16 @@ export class TUI extends Container {
4325
5161
 
4326
5162
  buffer += appendStart ? "\r\n" : "\r"; // Move to column 0
4327
5163
 
4328
- // Only render changed lines (firstChanged to lastChanged), not all lines to end
4329
- // This reduces flicker when only a single line changes (e.g., spinner animation)
4330
- 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));
4331
5171
  for (let i = firstChanged; i <= renderEnd; i++) {
4332
5172
  if (i > firstChanged) buffer += "\r\n";
4333
- buffer += "\x1b[2K";
5173
+ if (!preserveRasterLeases) buffer += "\x1b[2K";
4334
5174
  const line = newLines[i];
4335
5175
  let truncatedLine = line;
4336
5176
  const isImage = TERMINAL.isImageLine(line);
@@ -4357,8 +5197,18 @@ export class TUI extends Container {
4357
5197
  truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
4358
5198
  }
4359
5199
  // Non-image lines are pre-terminated/normalized by #applyLineResets;
4360
- // truncated lines re-append LINE_TERMINATOR above.
4361
- 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
+ }
4362
5212
  }
4363
5213
 
4364
5214
  // Track where cursor ended up after rendering
@@ -4418,25 +5268,31 @@ export class TUI extends Container {
4418
5268
  // frame and geometry are authoritative even when the optional IME cursor
4419
5269
  // write subsequently detaches the terminal.
4420
5270
  if (
4421
- !this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length, () => {
4422
- this.#hardwareCursorRow = toRow;
4423
- this.#cursorRow = Math.max(0, newLines.length - 1);
4424
- this.#maxLinesRendered = newLines.length;
4425
- this.#viewportTopRow = Math.max(0, newLines.length - height);
4426
- this.#nativeScrollbackViewportTop = Math.max(this.#nativeScrollbackViewportTop, this.#viewportTopRow);
4427
- this.#previousLines = newLines;
4428
- this.#previousWidth = width;
4429
- this.#previousHeight = height;
4430
- this.#kittyPlacementSpans = this.#kittyCommittedPlacementsAfterPaint(
4431
- previousKittyPlacementSpans,
4432
- nextKittyPlacementSpans,
4433
- deletePlan,
4434
- [{ top: firstChanged, bottom: renderEnd + 1 }],
4435
- );
4436
- this.#manualTranscriptLineCount = nextTranscriptLineCount;
4437
- this.#manualSuffixLineCount = nextSuffixLineCount;
4438
- this.#refreshPaintedLiveViewportObservation(height);
4439
- })
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
+ )
4440
5296
  )
4441
5297
  return;
4442
5298
  this.#latestRenderedLines = newLines;
@@ -4486,18 +5342,51 @@ export class TUI extends Container {
4486
5342
 
4487
5343
  return { seq, toRow: targetRow };
4488
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
+ }
4489
5353
 
4490
5354
  /** Retain terminal cleanup until a write succeeds, even after its component is disposed. */
4491
- queueTerminalCleanup(payload: string, onDelivered?: () => void): void {
4492
- this.#pendingTerminalCleanup.push({ payload, onDelivered });
4493
- 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
+ });
4494
5380
  }
4495
5381
 
4496
- /** Retry queued terminal cleanup after terminal recovery or before shutdown. */
4497
- flushTerminalCleanup(): void {
5382
+ /** Retry retained cleanup after recovery or before shutdown. */
5383
+ flushTerminalCleanup(restoreTerminalAvailability = false): void {
4498
5384
  while (this.#pendingTerminalCleanup.length > 0) {
4499
5385
  const pending = this.#pendingTerminalCleanup[0];
4500
- if (!this.#writeTerminal(pending.payload)) return;
5386
+ const written = restoreTerminalAvailability
5387
+ ? this.#writeLifecycleCleanup(pending.payload)
5388
+ : this.#writeTerminal(pending.payload);
5389
+ if (!written) return;
4501
5390
  this.#pendingTerminalCleanup.shift();
4502
5391
  pending.onDelivered?.();
4503
5392
  }
@@ -4508,44 +5397,46 @@ export class TUI extends Container {
4508
5397
  * transaction. The emitter is an exempt physical overlay: its bytes are
4509
5398
  * deliberately kept out of the shared transcript write.
4510
5399
  */
4511
- setPostRenderEmitter(emitter: (() => string | null) | undefined): void {
5400
+ setPostRenderEmitter(emitter: (() => string | PostRenderEmission | null) | undefined): void {
4512
5401
  this.#postRenderEmitter = emitter;
4513
5402
  }
4514
5403
 
4515
- #postRenderEmitter: (() => string | null) | undefined;
5404
+ #postRenderEmitter: (() => string | PostRenderEmission | null) | undefined;
4516
5405
 
4517
5406
  #writeRenderBufferAndReanchorImeCursor(
4518
5407
  buffer: string,
4519
5408
  cursorPos: { row: number; col: number } | null,
4520
5409
  totalLines: number,
4521
5410
  onBufferWritten?: () => void,
5411
+ preserveRasterLeases = false,
4522
5412
  ): boolean {
4523
- if (!this.#writeTerminal(buffer)) {
4524
- return false;
4525
- }
4526
- onBufferWritten?.();
4527
- this.#lastRenderWriteSucceeded = true;
4528
-
4529
- const overlay = this.#postRenderEmitter?.();
4530
- if (overlay) {
4531
- // DECSC/DECRC keep the hardware cursor stable; the dedicated
4532
- // synchronized block prevents visible tearing while the overlay
4533
- // area is cleared and redrawn.
4534
- const overlayBuffer = this.#frameSynchronizedOutput(`\x1b7${overlay}\x1b8`);
4535
- // Overlay delivery is outside shared transcript ownership. The
4536
- // shared write has already committed even when this exempt write
4537
- // fails, so do not make callers retry the shared bytes.
4538
- if (!this.#writeTerminal(overlayBuffer, true)) {
4539
- 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?.();
4540
5427
  }
4541
- }
4542
- if (!this.#imeCursorActive) return true;
4543
- // Cursor positioning is outside shared transcript ownership. A failure still
4544
- // makes the terminal unavailable, but cannot uncommit the shared frame. The
4545
- // onBufferWritten callback has already run; the return value propagates
4546
- // terminal availability so callers can detect the detach.
4547
- const cursorWritten = this.#writeCursorPosition(cursorPos, totalLines, true);
4548
- 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;
4549
5440
  }
4550
5441
 
4551
5442
  /**