@gajae-code/tui 0.16.1 → 0.16.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.16.4] - 2026-09-05
6
+
7
+ ### Added
8
+
9
+ - Added cancellable, one-shot `enqueueBeforeRender` preparation on the existing frame scheduler and a single-owner `setRenderPreparationLifecycleCallbacks` seam for invalidation and restart preparation. Stop, terminal loss, and disposal cancel stale work; restart preparation runs before the first forced frame without adding another streaming timer.
10
+
11
+ ## [0.16.3] - 2026-09-04
12
+
13
+ ## [0.16.2] - 2026-09-04
14
+
5
15
  ## [0.16.1] - 2026-09-03
6
16
 
7
17
  - Skill slash-token autocomplete now recognizes subsequent `/skill:` and `/skill-` tokens on the same or later lines without broadening ordinary inline slash-command completion; completion remains suppressed inside inline code and replaces only the active token.
package/README.md CHANGED
@@ -44,6 +44,60 @@ tui.start();
44
44
 
45
45
  Main container that manages components and rendering.
46
46
 
47
+ #### Frame preparation
48
+
49
+ `tui.enqueueBeforeRender(callback: () => void): () => void` registers one synchronous,
50
+ one-shot preparation and returns an idempotent cancellation function. It requests a
51
+ normal full-mutation render on the existing 16 ms frame clock; it adds no timer and
52
+ does not force or expedite a frame. Normal, forced, and input-priority frames drain
53
+ the same snapshot before capturing render generations or reading layout, components,
54
+ viewport sources, or caches. Ordinary render requests made by preparation join that
55
+ frame without a request-only follow-up paint. Registrations made while draining run
56
+ in a later frame; their generations cannot commit before that preparation executes,
57
+ even when a newer ordinary request commits first or terminal output is queued.
58
+
59
+ Cancellation removes callback references, including entries already captured but
60
+ not yet invoked. Retained cancellation handles release their reference to the TUI
61
+ and callback on cancellation, invocation (including failure), or lifecycle
62
+ invalidation. Later cancellation calls are no-ops; an executing callback remains
63
+ live until its synchronous invocation returns. Preparation exceptions are logged, unrelated callbacks continue,
64
+ and the failed preparation's generation is not reported as successfully committed.
65
+ Throwing or lifecycle-invalidated preparations immediately resolve their existing
66
+ commit waiters `false`; late waiters for those generations also resolve `false`,
67
+ even after a newer successful frame. Retired generations are stored as merged,
68
+ sorted failure ranges, separate from live pending-frame exclusions. Contiguous
69
+ failures compact into one range; separated failures retain distinct ranges to
70
+ preserve exact outcomes. Historical failure ranges are not copied or scanned on
71
+ each frame (waiter lookup uses binary search).
72
+ History has no arbitrary expiry: its numeric storage is O(disjoint failed ranges),
73
+ not bounded over an indefinitely alternating failure/success session. Empty active
74
+ pending and hole sets do not imply bounded historical storage or prove leak freedom.
75
+ Callbacks must be synchronous (do not pass async functions).
76
+
77
+ The cancellation lifetime regression inspects Bun's heap snapshot for outgoing
78
+ local closure/entry paths to the owner, callback and payload, while all targets
79
+ remain deliberately rooted. Pending handles and an intentionally retaining arrow
80
+ are positive controls; retired handles must lose those paths. This checks reference
81
+ release without assuming when GC collects an object. It does not prove global leak
82
+ freedom or exclude unrelated module, runtime or test-runner roots.
83
+
84
+ `tui.setRenderPreparationLifecycleCallbacks(callbacks: { invalidate: () => void;
85
+ beforeStart: () => void } | undefined): void` installs a **single** preparation owner.
86
+ Replacing or clearing the owner invalidates its old queued and captured work and
87
+ calls its `invalidate` synchronously. Stop, terminal loss, and disposal do the same;
88
+ exceptions are logged without preventing cancellation. Disposal also clears the
89
+ owner. Enqueue while stopped, unavailable, invalidating, or disposed retains no work.
90
+ Disposal cancels pending render/width-settle timers and render requests; later normal,
91
+ forced, input-priority, and resize requests cannot rearm the disposed renderer.
92
+
93
+ After successful terminal setup, each `start()` calls `beforeStart` synchronously
94
+ before its first forced render request. The owner can enqueue fresh preparation
95
+ from its current authoritative state there, so restarting does not require another
96
+ provider event. Failed setup does not rearm work; a throwing `beforeStart` is logged
97
+ and its queued work is invalidated. Stop/start during a drain cannot revive that
98
+ drain's old snapshot. This API is only a presentation-preparation lifecycle seam,
99
+ not a general lifecycle event bus.
100
+
47
101
  ```typescript
48
102
  const tui = new TUI(terminal);
49
103
  tui.addChild(component);
@@ -23,6 +23,7 @@ export interface SettingsListTheme {
23
23
  export declare class SettingsList implements Component {
24
24
  #private;
25
25
  constructor(items: SettingItem[], maxVisible: number, theme: SettingsListTheme, onChange: (id: string, newValue: string) => void, onCancel: () => void, onSelectionChange?: (item: SettingItem | undefined) => void, descriptionRows?: number);
26
+ get navigationLocked(): boolean;
26
27
  /** Update an item's currentValue */
27
28
  updateValue(id: string, newValue: string): void;
28
29
  /**
@@ -373,6 +373,11 @@ export declare class TUI extends Container {
373
373
  onDebug?: () => void;
374
374
  static resetRenderCountersForTest(): void;
375
375
  static getRenderCountersForTest(): TuiRenderCounterSnapshot;
376
+ getRenderPreparationStateForTest(): {
377
+ pending: number;
378
+ holes: number;
379
+ failedRanges: number;
380
+ };
376
381
  overlayStack: {
377
382
  component: Component;
378
383
  options?: OverlayOptions;
@@ -495,6 +500,13 @@ export declare class TUI extends Container {
495
500
  */
496
501
  requestResizeRender(): void;
497
502
  requestRender(force?: boolean, source?: string): void;
503
+ /** Queue one synchronous preparation on the existing frame clock. Cancellation is idempotent. */
504
+ enqueueBeforeRender(callback: () => void): () => void;
505
+ /** One owner only: replacing or clearing it invalidates all old preparation work. */
506
+ setRenderPreparationLifecycleCallbacks(callbacks: {
507
+ invalidate: () => void;
508
+ beforeStart: () => void;
509
+ } | undefined): void;
498
510
  /** Request a frame whose mutation is known to be outside the viewport-anchor subtree. */
499
511
  requestLayoutRender(source?: string): void;
500
512
  requestRenderWithGeneration(force?: boolean, source?: string): number;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/tui",
4
- "version": "0.16.1",
4
+ "version": "0.16.4",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -36,8 +36,8 @@
36
36
  "fmt": "biome format --write ."
37
37
  },
38
38
  "dependencies": {
39
- "@gajae-code/natives": "0.16.1",
40
- "@gajae-code/utils": "0.16.1",
39
+ "@gajae-code/natives": "0.16.4",
40
+ "@gajae-code/utils": "0.16.4",
41
41
  "lru-cache": "11.3.6",
42
42
  "marked": "18.0.6"
43
43
  },
@@ -62,6 +62,11 @@ export class SettingsList implements Component {
62
62
  this.#notifySelectionChange();
63
63
  }
64
64
 
65
+ get navigationLocked(): boolean {
66
+ const submenu = this.#submenuComponent as (Component & { navigationLocked?: boolean }) | null;
67
+ return submenu?.navigationLocked === true;
68
+ }
69
+
65
70
  #clampSelectedIndex(): void {
66
71
  if (this.#items.length === 0) {
67
72
  this.#selectedIndex = 0;
package/src/tui.ts CHANGED
@@ -960,6 +960,20 @@ function hasDistinctPostContractionRows(
960
960
  return false;
961
961
  }
962
962
 
963
+ interface RenderPreparation {
964
+ callback?: () => void;
965
+ generation: number;
966
+ cancel?: () => void;
967
+ }
968
+
969
+ // Keep the public handle outside the enqueue scope: it must capture only the entry,
970
+ // not the TUI or the original callback after the entry has been released.
971
+ function createPreparationCancellation(entry: RenderPreparation): () => void {
972
+ return () => entry.cancel?.();
973
+ }
974
+
975
+ function cancelNoPreparation(): void {}
976
+
963
977
  /**
964
978
  * TUI - Main class for managing terminal UI with differential rendering
965
979
  */
@@ -1009,6 +1023,21 @@ export class TUI extends Container {
1009
1023
  #nextRenderGeneration = 0;
1010
1024
  #renderRequestedGeneration = 0;
1011
1025
  #committedRenderGeneration = 0;
1026
+ #preparations = new Set<RenderPreparation>();
1027
+ #preparationSnapshot: Set<RenderPreparation> | undefined;
1028
+ #preparationEpoch = 0;
1029
+ #preparationDrainEpoch = -1;
1030
+ #preparationDisposed = false;
1031
+ #preparationInvalidating = false;
1032
+ #preparationLifecycle: { invalidate: () => void; beforeStart: () => void } | undefined;
1033
+ // A newer ordinary request may commit while an older nested preparation is still pending.
1034
+ // Keep holes in the high-water mark, including for waiters registered after that commit.
1035
+ #preparationBlocked = new Set<number>();
1036
+ #commitHoles = new Set<number>();
1037
+ // Terminal failures are sorted, disjoint inclusive ranges, not per-frame exclusions.
1038
+ #failedPreparationRanges: Array<[number, number]> = [];
1039
+ // Captured by queued terminal writes; never consult the mutable queue at write settlement.
1040
+ #frameCommitExclusions = new Set<number>();
1012
1041
  #renderCommitWaiters = new Map<number, Set<RenderCommitWaiter>>();
1013
1042
  #lastRenderWriteSucceeded = false;
1014
1043
  /** Generation whose render path is currently capturing terminal output. */
@@ -1161,6 +1190,14 @@ export class TUI extends Container {
1161
1190
  return { ...TUI.#renderCounters };
1162
1191
  }
1163
1192
 
1193
+ getRenderPreparationStateForTest(): { pending: number; holes: number; failedRanges: number } {
1194
+ return {
1195
+ pending: this.#preparationBlocked.size,
1196
+ holes: this.#commitHoles.size,
1197
+ failedRanges: this.#failedPreparationRanges.length,
1198
+ };
1199
+ }
1200
+
1164
1201
  static #readDebugRedrawFlag(): boolean {
1165
1202
  TUI.#renderCounters.debugRedrawEnvReads += 1;
1166
1203
  return $pickflag("GJC_DEBUG_REDRAW", "PI_DEBUG_REDRAW");
@@ -1249,6 +1286,29 @@ export class TUI extends Container {
1249
1286
  }
1250
1287
 
1251
1288
  override dispose(): void {
1289
+ if (this.#preparationDisposed) return;
1290
+ this.#preparationDisposed = true;
1291
+ this.#renderRequested = false;
1292
+ this.#renderRequestedGeneration = 0;
1293
+ this.#inputRenderPending = false;
1294
+ this.#resizeRenderQueued = false;
1295
+ this.#resizeRenderMutationQueued = false;
1296
+ this.#renderMutationQueued = false;
1297
+ this.#widthSettleRenderQueued = false;
1298
+ this.#forcedRenderQueued = false;
1299
+ this.#clearSixelProbeState();
1300
+ if (this.#renderTimer) {
1301
+ clearTimeout(this.#renderTimer);
1302
+ this.#renderTimer = undefined;
1303
+ if (renderMetrics.enabled) renderMetrics.setTimerGauge("tui.renderTimer", 0);
1304
+ }
1305
+ if (this.#widthSettleTimer) {
1306
+ clearTimeout(this.#widthSettleTimer);
1307
+ this.#widthSettleTimer = undefined;
1308
+ }
1309
+ this.#invalidatePreparations();
1310
+ this.#preparationLifecycle = undefined;
1311
+ this.#settleRenderCommitWaiters(false);
1252
1312
  this.#unsubscribeTabWidthChange?.();
1253
1313
  this.#unsubscribeTabWidthChange = undefined;
1254
1314
  this.#finalizeRasterLeases("terminal-loss");
@@ -2314,6 +2374,7 @@ export class TUI extends Container {
2314
2374
  return true;
2315
2375
  }
2316
2376
  start(): void {
2377
+ if (this.#preparationDisposed) return;
2317
2378
  this.#stopped = false;
2318
2379
  this.#terminalUnavailable = false;
2319
2380
  this.#clearMouseSelection();
@@ -2321,31 +2382,36 @@ export class TUI extends Container {
2321
2382
  // Seed the observed width so a spurious post-start resize event (iTerm2 tab
2322
2383
  // activation, the self-sent SIGWINCH after resume) is not read as a reflow.
2323
2384
  this.#lastObservedWidth = this.terminal.columns;
2324
- this.terminal.setMouseEnabled?.(this.options.enableMouse === true);
2325
- this.terminal.start(
2326
- data => this.#handleInput(data),
2327
- () => {
2328
- const hadRasterLease = this.#rasterLeases.size > 0;
2329
- this.#revokeRasterLeases("resize");
2330
- // Only a pet raster lease needs refreshed cell metrics on resize; a
2331
- // plain resize keeps the historical byte stream (no cell query).
2332
- if (hadRasterLease) this.#queryCellSize(true);
2333
- this.invalidate();
2334
- if (this.#pendingTerminalCleanup.length > 0 || this.#rasterCleanup.size > 0) {
2335
- // Retained pet/cleanup output must be flushed before the resize
2336
- // repaint so it cannot interleave behind the new frame.
2337
- this.notifyTerminalLifecycle({
2338
- kind: "explicit-cleanup",
2339
- source: "tui",
2340
- terminalGeneration: this.#terminalGeneration,
2341
- }).then(result => {
2342
- if (result.stillPending === 0) this.requestResizeRender();
2343
- });
2344
- } else {
2345
- this.requestResizeRender();
2346
- }
2347
- },
2348
- );
2385
+ try {
2386
+ this.terminal.setMouseEnabled?.(this.options.enableMouse === true);
2387
+ this.terminal.start(
2388
+ data => this.#handleInput(data),
2389
+ () => {
2390
+ const hadRasterLease = this.#rasterLeases.size > 0;
2391
+ this.#revokeRasterLeases("resize");
2392
+ // Only a pet raster lease needs refreshed cell metrics on resize; a
2393
+ // plain resize keeps the historical byte stream (no cell query).
2394
+ if (hadRasterLease) this.#queryCellSize(true);
2395
+ this.invalidate();
2396
+ if (this.#pendingTerminalCleanup.length > 0 || this.#rasterCleanup.size > 0) {
2397
+ // Retained pet/cleanup output must be flushed before the resize
2398
+ // repaint so it cannot interleave behind the new frame.
2399
+ this.notifyTerminalLifecycle({
2400
+ kind: "explicit-cleanup",
2401
+ source: "tui",
2402
+ terminalGeneration: this.#terminalGeneration,
2403
+ }).then(result => {
2404
+ if (result.stillPending === 0) this.requestResizeRender();
2405
+ });
2406
+ } else {
2407
+ this.requestResizeRender();
2408
+ }
2409
+ },
2410
+ );
2411
+ } catch (error) {
2412
+ this.#markTerminalUnavailable();
2413
+ throw error;
2414
+ }
2349
2415
  if (this.#pendingTerminalCleanup.length > 0 || this.#rasterCleanup.size > 0) {
2350
2416
  void this.notifyTerminalLifecycle({
2351
2417
  kind: "availability-restored",
@@ -2356,6 +2422,14 @@ export class TUI extends Container {
2356
2422
  this.#hideCursor();
2357
2423
  this.#querySixelSupport();
2358
2424
  this.#queryCellSize();
2425
+ if (this.#stopped || !this.terminalAvailable) return;
2426
+ const epoch = this.#preparationEpoch;
2427
+ if (!this.#callPreparation(this.#preparationLifecycle?.beforeStart, "beforeStart")) {
2428
+ this.#invalidatePreparations();
2429
+ if (!this.#stopped && this.terminalAvailable && !this.#preparationDisposed) this.requestRender(true);
2430
+ return;
2431
+ }
2432
+ if (epoch !== this.#preparationEpoch || this.#stopped || !this.terminalAvailable) return;
2359
2433
  this.requestRender(true);
2360
2434
  }
2361
2435
 
@@ -2368,8 +2442,10 @@ export class TUI extends Container {
2368
2442
  * holding a session operation behind a dead renderer.
2369
2443
  */
2370
2444
  waitForRenderCommit(generation: number, timeoutMs = 250): Promise<boolean> {
2371
- if (generation <= 0 || generation <= this.#committedRenderGeneration) return Promise.resolve(true);
2372
- if (this.#stopped || !this.terminalAvailable) return Promise.resolve(false);
2445
+ if (this.#isFailedPreparation(generation)) return Promise.resolve(false);
2446
+ if (generation <= 0 || (generation <= this.#committedRenderGeneration && !this.#commitHoles.has(generation)))
2447
+ return Promise.resolve(true);
2448
+ if (this.#stopped || !this.terminalAvailable || this.#preparationDisposed) return Promise.resolve(false);
2373
2449
  return new Promise<boolean>(resolve => {
2374
2450
  const waiter: RenderCommitWaiter = {
2375
2451
  resolve,
@@ -2392,14 +2468,31 @@ export class TUI extends Container {
2392
2468
  });
2393
2469
  }
2394
2470
 
2395
- #settleRenderCommitWaiters(committed: boolean, generation = Number.POSITIVE_INFINITY): void {
2396
- if (committed) this.#committedRenderGeneration = Math.max(this.#committedRenderGeneration, generation);
2471
+ #settleRenderCommitWaiters(
2472
+ committed: boolean,
2473
+ generation = Number.POSITIVE_INFINITY,
2474
+ exclusions = this.#frameCommitExclusions,
2475
+ ): void {
2476
+ if (committed) {
2477
+ for (const blocked of exclusions) {
2478
+ if (
2479
+ blocked <= generation &&
2480
+ blocked > this.#committedRenderGeneration &&
2481
+ !this.#isFailedPreparation(blocked)
2482
+ )
2483
+ this.#commitHoles.add(blocked);
2484
+ }
2485
+ for (const hole of this.#commitHoles) {
2486
+ if (hole <= generation && !exclusions.has(hole)) this.#commitHoles.delete(hole);
2487
+ }
2488
+ this.#committedRenderGeneration = Math.max(this.#committedRenderGeneration, generation);
2489
+ }
2397
2490
  for (const [waiterGeneration, waiters] of this.#renderCommitWaiters) {
2398
- if (committed && waiterGeneration > generation) continue;
2491
+ if (committed && (waiterGeneration > generation || exclusions.has(waiterGeneration))) continue;
2399
2492
  this.#renderCommitWaiters.delete(waiterGeneration);
2400
2493
  for (const waiter of waiters) {
2401
2494
  clearTimeout(waiter.timer);
2402
- waiter.resolve(committed);
2495
+ waiter.resolve(committed && !this.#isFailedPreparation(waiterGeneration));
2403
2496
  }
2404
2497
  }
2405
2498
  }
@@ -2440,6 +2533,7 @@ export class TUI extends Container {
2440
2533
  this.#rasterLeases.clear();
2441
2534
  }
2442
2535
  #markTerminalUnavailable(settleRenderWaiters = true): void {
2536
+ this.#invalidatePreparations();
2443
2537
  this.#terminalGeneration++;
2444
2538
  for (const record of this.#rasterCleanup.values()) record.terminalGeneration = this.#terminalGeneration;
2445
2539
  this.#revokeRasterLeases("terminal-loss");
@@ -2676,6 +2770,7 @@ export class TUI extends Container {
2676
2770
  }
2677
2771
 
2678
2772
  stop(): void {
2773
+ this.#invalidatePreparations();
2679
2774
  // Invalidate every raster-queue body captured under the running epoch
2680
2775
  // before any teardown: nothing queued before stop may write after
2681
2776
  // restoration. Synchronous stop cleanup below writes directly.
@@ -2785,6 +2880,7 @@ export class TUI extends Container {
2785
2880
  * the last committed frame.
2786
2881
  */
2787
2882
  requestResizeRender(): void {
2883
+ if (this.#preparationDisposed) return;
2788
2884
  // Width is tracked against the last OBSERVED terminal width, not against
2789
2885
  // #previousWidth (the last committed frame). Those diverge whenever resize
2790
2886
  // events coalesce inside one frame budget: a 100->90->100 burst would leave
@@ -2844,8 +2940,180 @@ export class TUI extends Container {
2844
2940
  this.requestRenderWithGeneration(force, source);
2845
2941
  }
2846
2942
 
2943
+ /** Queue one synchronous preparation on the existing frame clock. Cancellation is idempotent. */
2944
+ enqueueBeforeRender(callback: () => void): () => void {
2945
+ if (this.#preparationDisposed || this.#preparationInvalidating || this.#stopped || !this.terminalAvailable)
2946
+ return cancelNoPreparation;
2947
+ const entry: RenderPreparation = { callback, generation: this.#nextRenderGeneration + 1 };
2948
+ entry.cancel = () => {
2949
+ entry.callback = undefined;
2950
+ entry.cancel = undefined;
2951
+ this.#preparations.delete(entry);
2952
+ this.#preparationSnapshot?.delete(entry);
2953
+ this.#preparationBlocked.delete(entry.generation);
2954
+ };
2955
+ this.#preparations.add(entry);
2956
+ this.#preparationBlocked.add(entry.generation);
2957
+ this.requestRenderWithGeneration(false, "preparation");
2958
+ return createPreparationCancellation(entry);
2959
+ }
2960
+
2961
+ /** One owner only: replacing or clearing it invalidates all old preparation work. */
2962
+ setRenderPreparationLifecycleCallbacks(
2963
+ callbacks: { invalidate: () => void; beforeStart: () => void } | undefined,
2964
+ ): void {
2965
+ if (this.#preparationLifecycle === callbacks) return;
2966
+ this.#invalidatePreparations();
2967
+ this.#preparationLifecycle = this.#preparationDisposed ? undefined : callbacks;
2968
+ }
2969
+
2970
+ #callPreparation(callback: (() => void) | undefined, where: string): boolean {
2971
+ try {
2972
+ callback?.();
2973
+ return true;
2974
+ } catch (error) {
2975
+ logger.error("Render preparation failed", {
2976
+ where,
2977
+ error: error instanceof Error ? error.message : String(error),
2978
+ stack: error instanceof Error ? error.stack : undefined,
2979
+ });
2980
+ return false;
2981
+ }
2982
+ }
2983
+
2984
+ #invalidatePreparations(): void {
2985
+ this.#preparationEpoch++;
2986
+ for (const generation of this.#preparationBlocked) this.#retirePreparation(generation);
2987
+ for (const entry of this.#preparations) {
2988
+ entry.callback = undefined;
2989
+ entry.cancel = undefined;
2990
+ }
2991
+ for (const entry of this.#preparationSnapshot ?? []) {
2992
+ entry.callback = undefined;
2993
+ entry.cancel = undefined;
2994
+ }
2995
+ this.#preparations.clear();
2996
+ this.#preparationSnapshot?.clear();
2997
+ if (this.#preparationInvalidating) return;
2998
+ this.#preparationInvalidating = true;
2999
+ try {
3000
+ this.#callPreparation(this.#preparationLifecycle?.invalidate, "invalidate");
3001
+ } finally {
3002
+ this.#preparationInvalidating = false;
3003
+ }
3004
+ }
3005
+
3006
+ #isFailedPreparation(generation: number): boolean {
3007
+ let low = 0;
3008
+ let high = this.#failedPreparationRanges.length;
3009
+ while (low < high) {
3010
+ const middle = (low + high) >>> 1;
3011
+ const [start, end] = this.#failedPreparationRanges[middle];
3012
+ if (generation < start) high = middle;
3013
+ else if (generation > end) low = middle + 1;
3014
+ else return true;
3015
+ }
3016
+ return false;
3017
+ }
3018
+
3019
+ #retirePreparation(generation: number): void {
3020
+ this.#preparationBlocked.delete(generation);
3021
+ this.#commitHoles.delete(generation);
3022
+ const ranges = this.#failedPreparationRanges;
3023
+ let low = 0;
3024
+ let high = ranges.length;
3025
+ while (low < high) {
3026
+ const middle = (low + high) >>> 1;
3027
+ if (ranges[middle][1] < generation - 1) low = middle + 1;
3028
+ else high = middle;
3029
+ }
3030
+ let start = generation;
3031
+ let end = generation;
3032
+ let next = low;
3033
+ while (next < ranges.length && ranges[next][0] <= end + 1) {
3034
+ start = Math.min(start, ranges[next][0]);
3035
+ end = Math.max(end, ranges[next][1]);
3036
+ next++;
3037
+ }
3038
+ ranges.splice(low, next - low, [start, end]);
3039
+ const waiters = this.#renderCommitWaiters.get(generation);
3040
+ this.#renderCommitWaiters.delete(generation);
3041
+ for (const waiter of waiters ?? []) {
3042
+ clearTimeout(waiter.timer);
3043
+ waiter.resolve(false);
3044
+ }
3045
+ }
3046
+
3047
+ /** The only preparation boundary, before generation capture and all renderer reads. */
3048
+ #renderPreparedFrame(): void {
3049
+ if (this.#stopped || this.#preparationDisposed) return;
3050
+ if (!this.terminalAvailable) {
3051
+ this.#markTerminalUnavailable();
3052
+ return;
3053
+ }
3054
+ const epoch = this.#preparationEpoch;
3055
+ const snapshot = this.#preparations;
3056
+ this.#preparations = new Set();
3057
+ this.#preparationSnapshot = snapshot;
3058
+ this.#preparationDrainEpoch = epoch;
3059
+ try {
3060
+ for (const entry of snapshot) {
3061
+ if (epoch !== this.#preparationEpoch || this.#stopped || !this.terminalAvailable) break;
3062
+ const callback = entry.callback;
3063
+ entry.callback = undefined;
3064
+ entry.cancel = undefined;
3065
+ if (
3066
+ callback &&
3067
+ this.#callPreparation(callback, "beforeRender") &&
3068
+ epoch === this.#preparationEpoch &&
3069
+ !this.#stopped &&
3070
+ this.terminalAvailable
3071
+ ) {
3072
+ this.#preparationBlocked.delete(entry.generation);
3073
+ } else if (callback) {
3074
+ this.#retirePreparation(entry.generation);
3075
+ }
3076
+ }
3077
+ } finally {
3078
+ for (const entry of snapshot) {
3079
+ entry.callback = undefined;
3080
+ entry.cancel = undefined;
3081
+ }
3082
+ snapshot.clear();
3083
+ this.#preparationSnapshot = undefined;
3084
+ }
3085
+ if (!this.terminalAvailable && !this.#stopped) this.#markTerminalUnavailable();
3086
+ if (epoch !== this.#preparationEpoch || this.#stopped || !this.terminalAvailable || this.#preparationDisposed) {
3087
+ if (!this.#stopped && this.terminalAvailable && !this.#preparationDisposed) this.#scheduleRender();
3088
+ return;
3089
+ }
3090
+ const requestedGeneration = this.#renderRequestedGeneration;
3091
+ this.#renderRequestedGeneration = 0;
3092
+ this.#renderRequested = false;
3093
+ this.#lastRenderAt = performance.now();
3094
+ this.#lastRenderWriteSucceeded = false;
3095
+ this.#frameCommitExclusions = new Set(this.#preparationBlocked);
3096
+ const t0 = renderMetrics.now();
3097
+ try {
3098
+ this.#renderGenerationInProgress = requestedGeneration;
3099
+ this.#doRender();
3100
+ this.#commitRenderGeneration(requestedGeneration);
3101
+ } finally {
3102
+ this.#renderGenerationInProgress = 0;
3103
+ this.#frameCommitExclusions = new Set();
3104
+ if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
3105
+ if (this.#preparations.size > 0 && epoch === this.#preparationEpoch) {
3106
+ for (const entry of this.#preparations)
3107
+ this.#renderRequestedGeneration = Math.max(this.#renderRequestedGeneration, entry.generation);
3108
+ this.#renderRequested = true;
3109
+ this.#scheduleRender();
3110
+ }
3111
+ }
3112
+ }
3113
+
2847
3114
  #requestRenderWithScope(force: boolean, source: string, scope: "full" | "layout"): number {
2848
3115
  const generation = ++this.#nextRenderGeneration;
3116
+ if (this.#preparationDisposed) return generation;
2849
3117
  this.#renderRequestedGeneration = Math.max(this.#renderRequestedGeneration, generation);
2850
3118
  if (scope === "full") this.#renderScope = "full";
2851
3119
  this.#requestRenderCore(force, source, generation);
@@ -2863,6 +3131,7 @@ export class TUI extends Container {
2863
3131
  }
2864
3132
 
2865
3133
  #requestRenderCore(force: boolean, source: string, generation: number): void {
3134
+ if (this.#preparationDisposed) return;
2866
3135
  if (!this.terminalAvailable) {
2867
3136
  this.#markTerminalUnavailable();
2868
3137
  return;
@@ -2905,6 +3174,7 @@ export class TUI extends Container {
2905
3174
  this.#viewportTopRow = 0;
2906
3175
  this.#maxLinesRendered = 0;
2907
3176
  }
3177
+ if (this.#preparationSnapshot && this.#preparationDrainEpoch === this.#preparationEpoch) return;
2908
3178
  if (this.#renderTimer) {
2909
3179
  clearTimeout(this.#renderTimer);
2910
3180
  this.#renderTimer = undefined;
@@ -2916,20 +3186,12 @@ export class TUI extends Container {
2916
3186
  this.#settleRenderCommitWaiters(false, generation);
2917
3187
  return;
2918
3188
  }
2919
- const requestedGeneration = this.#renderRequestedGeneration;
2920
- this.#renderRequestedGeneration = 0;
2921
- this.#renderRequested = false;
2922
- this.#lastRenderAt = performance.now();
2923
- this.#lastRenderWriteSucceeded = false;
2924
- const t0 = renderMetrics.now();
2925
- this.#renderGenerationInProgress = requestedGeneration;
2926
- this.#doRender();
2927
- this.#renderGenerationInProgress = 0;
2928
- this.#commitRenderGeneration(requestedGeneration);
2929
- if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
3189
+ this.#renderPreparedFrame();
2930
3190
  });
2931
3191
  return;
2932
3192
  }
3193
+ // Preparation mutations join this frame; nested registrations get the next normal frame.
3194
+ if (this.#preparationSnapshot && this.#preparationDrainEpoch === this.#preparationEpoch) return;
2933
3195
  // Input-priority path: expedite so the keystroke echoes within the next tick
2934
3196
  // instead of waiting for (or behind) the frame-budget timer. Re-entrant input
2935
3197
  // requests in the same turn coalesce via #inputRenderPending, so at most one
@@ -2950,7 +3212,7 @@ export class TUI extends Container {
2950
3212
  }
2951
3213
 
2952
3214
  #scheduleRender(): void {
2953
- if (this.#stopped || this.#renderTimer || !this.#renderRequested) {
3215
+ if (this.#preparationDisposed || this.#stopped || this.#renderTimer || !this.#renderRequested) {
2954
3216
  return;
2955
3217
  }
2956
3218
  const elapsed = performance.now() - this.#lastRenderAt;
@@ -2961,17 +3223,7 @@ export class TUI extends Container {
2961
3223
  if (this.#stopped || !this.#renderRequested) {
2962
3224
  return;
2963
3225
  }
2964
- const requestedGeneration = this.#renderRequestedGeneration;
2965
- this.#renderRequestedGeneration = 0;
2966
- this.#renderRequested = false;
2967
- this.#lastRenderAt = performance.now();
2968
- this.#lastRenderWriteSucceeded = false;
2969
- const t0 = renderMetrics.now();
2970
- this.#renderGenerationInProgress = requestedGeneration;
2971
- this.#doRender();
2972
- this.#renderGenerationInProgress = 0;
2973
- this.#commitRenderGeneration(requestedGeneration);
2974
- if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
3226
+ this.#renderPreparedFrame();
2975
3227
  if (this.#renderRequested) {
2976
3228
  this.#scheduleRender();
2977
3229
  }
@@ -2993,17 +3245,7 @@ export class TUI extends Container {
2993
3245
  this.#renderTimer = undefined;
2994
3246
  if (renderMetrics.enabled) renderMetrics.setTimerGauge("tui.renderTimer", 0);
2995
3247
  }
2996
- const requestedGeneration = this.#renderRequestedGeneration;
2997
- this.#renderRequestedGeneration = 0;
2998
- this.#renderRequested = false;
2999
- this.#lastRenderAt = performance.now();
3000
- this.#lastRenderWriteSucceeded = false;
3001
- const t0 = renderMetrics.now();
3002
- this.#renderGenerationInProgress = requestedGeneration;
3003
- this.#doRender();
3004
- this.#renderGenerationInProgress = 0;
3005
- this.#commitRenderGeneration(requestedGeneration);
3006
- if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
3248
+ this.#renderPreparedFrame();
3007
3249
  }
3008
3250
 
3009
3251
  #handleInput(data: string): void {
@@ -5607,11 +5849,12 @@ export class TUI extends Container {
5607
5849
  ? (bytes: string) => this.#writeRasterPreservingRenderIngress(bytes)
5608
5850
  : (bytes: string) => this.#writeProtectedRenderIngress(bytes);
5609
5851
  const renderGeneration = this.#renderGenerationInProgress;
5852
+ const commitExclusions = this.#frameCommitExclusions;
5610
5853
  const write = () => {
5611
5854
  if (!writeIngress(buffer)) return false;
5612
5855
  onBufferWritten?.();
5613
5856
  this.#lastRenderWriteSucceeded = true;
5614
- if (renderGeneration > 0) this.#settleRenderCommitWaiters(true, renderGeneration);
5857
+ if (renderGeneration > 0) this.#settleRenderCommitWaiters(true, renderGeneration, commitExclusions);
5615
5858
  const emission = this.#postRenderEmitter?.();
5616
5859
  if (emission) {
5617
5860
  const overlay = typeof emission === "string" ? emission : emission.payload;