@gajae-code/tui 0.16.3 → 0.16.6

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,27 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.16.6] - 2026-09-07
6
+
7
+ ## [0.16.5] - 2026-09-07
8
+
9
+ ### Performance
10
+
11
+ - Skip full-transcript resize-width scans on unchanged-width render frames while preserving the existing resize and forced-redraw checks.
12
+ - Avoid copying unchanged terminal-control spans, reuse retained row widths, consume only the first grapheme for cursor operations, stop select-list width scans at their bounds, and avoid redundant background-row padding.
13
+
14
+ ### Fixed
15
+
16
+ - Markdown cache ownership now finalizes after styling callbacks, so reentrant text replacement cannot inherit stale rejection hints and streaming completion releases local parse tokens. Cache diagnostics measure insertion-time payload sizes; returned render arrays are borrowed and must not be mutated.
17
+ - Streaming Markdown retains only its current parse locally instead of publishing partial documents to the shared render/parse caches. Completed documents remain reusable, with 8 MiB render, 8 MiB parse and 4 MiB highlight accounted-payload budgets and per-entry limits. The render-cache diagnostic now includes UTF-16 keys, full token graphs, styled lines and anchors; it is not a live heap/RSS measurement.
18
+ - Markdown reflow avoids redundant parse-cache admissions and repeated accounting of already rejected normalized content, without retaining completed parse tokens. Render payload accounting uses its owned layout schema, and oversized highlighting inputs are rejected earlier while preserving byte/line limits and guard ordering.
19
+
20
+ ## [0.16.4] - 2026-09-05
21
+
22
+ ### Added
23
+
24
+ - 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.
25
+
5
26
  ## [0.16.3] - 2026-09-04
6
27
 
7
28
  ## [0.16.2] - 2026-09-04
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);
@@ -1,8 +1,19 @@
1
1
  import type { SymbolTheme } from "../symbols";
2
2
  import type { Component } from "../tui";
3
3
  import { type ViewportAnchorSpan } from "../utils";
4
+ /** Account retained payload, not live heap. Stop once admission is impossible. */
5
+ export declare function getMarkdownCacheEntryAccountedSize(key: string, value: unknown, cap: number): number;
4
6
  /** Test-only clock seam for streaming throttle tests. */
5
7
  export declare function __setMarkdownNowForTest(now: (() => number) | undefined): void;
8
+ export interface MarkdownCacheStats {
9
+ count: number;
10
+ accountedSize: number;
11
+ max: number;
12
+ maxSize: number;
13
+ maxEntrySize: number;
14
+ }
15
+ /** Detached diagnostics; exposes neither entries nor mutable budgets. */
16
+ export declare function getMarkdownCacheStats(): Record<"render" | "parse" | "highlight", MarkdownCacheStats>;
6
17
  /** Test/diagnostic seam: number of synchronous highlight invocations since the last reset. */
7
18
  export declare function getMarkdownHighlightCallCount(): number;
8
19
  export declare function resetMarkdownHighlightCallCount(): void;
@@ -14,6 +25,10 @@ export declare const __markdownPerfCounters: {
14
25
  };
15
26
  /** Drop all L2 cache entries. Call on theme change to prevent stale styled output. */
16
27
  export declare function clearRenderCache(): void;
28
+ /**
29
+ * Insertion-time UTF-16 accounting of global keys/tokens/lines/anchors, not heap or RSS.
30
+ * Rendered arrays are borrowed cache payloads: callers must not mutate them.
31
+ */
17
32
  export declare function getRenderCacheRetainedBytes(): number;
18
33
  /**
19
34
  * Default text styling for markdown content.
@@ -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
  /**
@@ -361,6 +361,7 @@ type TuiRenderCounterSnapshot = {
361
361
  debugRedrawEnvReads: number;
362
362
  debugRedrawAppendWrites: number;
363
363
  differentialGuardVisibleWidthCalls: number;
364
+ widthReflowScanRows: number;
364
365
  };
365
366
  /**
366
367
  * TUI - Main class for managing terminal UI with differential rendering
@@ -373,6 +374,11 @@ export declare class TUI extends Container {
373
374
  onDebug?: () => void;
374
375
  static resetRenderCountersForTest(): void;
375
376
  static getRenderCountersForTest(): TuiRenderCounterSnapshot;
377
+ getRenderPreparationStateForTest(): {
378
+ pending: number;
379
+ holes: number;
380
+ failedRanges: number;
381
+ };
376
382
  overlayStack: {
377
383
  component: Component;
378
384
  options?: OverlayOptions;
@@ -495,6 +501,13 @@ export declare class TUI extends Container {
495
501
  */
496
502
  requestResizeRender(): void;
497
503
  requestRender(force?: boolean, source?: string): void;
504
+ /** Queue one synchronous preparation on the existing frame clock. Cancellation is idempotent. */
505
+ enqueueBeforeRender(callback: () => void): () => void;
506
+ /** One owner only: replacing or clearing it invalidates all old preparation work. */
507
+ setRenderPreparationLifecycleCallbacks(callbacks: {
508
+ invalidate: () => void;
509
+ beforeStart: () => void;
510
+ } | undefined): void;
498
511
  /** Request a frame whose mutation is known to be outside the viewport-anchor subtree. */
499
512
  requestLayoutRender(source?: string): void;
500
513
  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.3",
4
+ "version": "0.16.6",
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.3",
40
- "@gajae-code/utils": "0.16.3",
39
+ "@gajae-code/natives": "0.16.6",
40
+ "@gajae-code/utils": "0.16.6",
41
41
  "lru-cache": "11.3.6",
42
42
  "marked": "18.0.6"
43
43
  },
@@ -161,13 +161,13 @@ export class Box implements Component {
161
161
  }
162
162
 
163
163
  #applyBg(line: string, width: number): string {
164
+ if (this.#bgFn) {
165
+ return applyBackgroundToLine(line, width, this.#bgFn);
166
+ }
164
167
  const visLen = visibleWidth(line);
165
168
  const padNeeded = Math.max(0, width - visLen);
166
169
  const padded = line + padding(padNeeded);
167
170
 
168
- if (this.#bgFn) {
169
- return applyBackgroundToLine(padded, width, this.#bgFn);
170
- }
171
171
  return padded;
172
172
  }
173
173
  }
@@ -1061,8 +1061,7 @@ export class Editor implements Component, Focusable {
1061
1061
  if (after.length > 0) {
1062
1062
  // Cursor is on a character (grapheme) - replace it with highlighted version
1063
1063
  // Get the first grapheme from 'after'
1064
- const afterGraphemes = [...segmenter.segment(after)];
1065
- const firstGrapheme = afterGraphemes[0]?.segment || "";
1064
+ const firstGrapheme = segmenter.segment(after)[Symbol.iterator]().next().value?.segment || "";
1066
1065
  const restAfter = after.slice(firstGrapheme.length);
1067
1066
  const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
1068
1067
  displayText = before + marker + cursor + restAfter;
@@ -2643,8 +2642,7 @@ export class Editor implements Component, Focusable {
2643
2642
  const afterCursor = currentLine.slice(this.#state.cursorCol);
2644
2643
 
2645
2644
  // Find the first grapheme at cursor
2646
- const graphemes = [...segmenter.segment(afterCursor)];
2647
- const firstGrapheme = graphemes[0];
2645
+ const firstGrapheme = segmenter.segment(afterCursor)[Symbol.iterator]().next().value;
2648
2646
  const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
2649
2647
 
2650
2648
  const before = currentLine.slice(0, this.#state.cursorCol);
@@ -2762,8 +2760,7 @@ export class Editor implements Component, Focusable {
2762
2760
  this.#setCursorCol(this.#state.cursorCol + 1);
2763
2761
  } else {
2764
2762
  const afterCursor = currentLine.slice(this.#state.cursorCol);
2765
- const graphemes = [...segmenter.segment(afterCursor)];
2766
- const firstGrapheme = graphemes[0];
2763
+ const firstGrapheme = segmenter.segment(afterCursor)[Symbol.iterator]().next().value;
2767
2764
  this.#setCursorCol(this.#state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
2768
2765
  }
2769
2766
  } else if (this.#state.cursorLine < this.#state.lines.length - 1) {
@@ -156,8 +156,7 @@ export class Input implements Component, Focusable {
156
156
  this.#lastAction = null;
157
157
  if (this.#cursor < this.#value.length) {
158
158
  const afterCursor = this.#value.slice(this.#cursor);
159
- const graphemes = [...segmenter.segment(afterCursor)];
160
- const firstGrapheme = graphemes[0];
159
+ const firstGrapheme = segmenter.segment(afterCursor)[Symbol.iterator]().next().value;
161
160
  this.#cursor += firstGrapheme ? firstGrapheme.segment.length : 1;
162
161
  }
163
162
  return;
@@ -231,8 +230,7 @@ export class Input implements Component, Focusable {
231
230
  this.#pushUndo();
232
231
 
233
232
  const afterCursor = this.#value.slice(this.#cursor);
234
- const graphemes = [...segmenter.segment(afterCursor)];
235
- const firstGrapheme = graphemes[0];
233
+ const firstGrapheme = segmenter.segment(afterCursor)[Symbol.iterator]().next().value;
236
234
  const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
237
235
 
238
236
  this.#value = this.#value.slice(0, this.#cursor) + this.#value.slice(this.#cursor + graphemeLength);
@@ -442,8 +440,7 @@ export class Input implements Component, Focusable {
442
440
 
443
441
  // Build line with fake cursor
444
442
  // Insert cursor character at cursor position
445
- const graphemes = [...segmenter.segment(visibleText.slice(cursorDisplay))];
446
- const cursorGrapheme = graphemes[0];
443
+ const cursorGrapheme = segmenter.segment(visibleText.slice(cursorDisplay))[Symbol.iterator]().next().value;
447
444
 
448
445
  const beforeCursor = visibleText.slice(0, cursorDisplay);
449
446
  const atCursor = cursorGrapheme?.segment ?? " ";
@@ -46,14 +46,95 @@ markdownParser.setOptions({
46
46
  // (Rust FFI) work for content/layout combinations already seen this session.
47
47
 
48
48
  const RENDER_CACHE_MAX = 256; // sane cap: ~256 distinct message × width combos
49
- const renderCache = new LRUCache<
50
- string,
51
- { source: string; lines: string[]; anchorSpans?: Array<ViewportAnchorSpan | null> }
52
- >({
49
+ const DOCUMENT_CACHE_SIZE = 8 * 1024 * 1024;
50
+ const DOCUMENT_ENTRY_SIZE = 1024 * 1024;
51
+
52
+ /** Account retained payload, not live heap. Stop once admission is impossible. */
53
+ export function getMarkdownCacheEntryAccountedSize(key: string, value: unknown, cap: number): number {
54
+ let size = 64 + key.length * 2;
55
+ const visited = new Set<object>();
56
+ const pending: Iterator<unknown>[] = [[value][Symbol.iterator]()];
57
+ function* objects(object: object, array: boolean): Generator<object> {
58
+ for (const name in object) {
59
+ if (!Object.hasOwn(object, name)) continue;
60
+ // Array indices are canonical uint32 strings, excluding 2^32 - 1.
61
+ const index = array ? Number(name) >>> 0 : 0;
62
+ if (!array || index === 0xffffffff || String(index) !== name) {
63
+ size += 16 + name.length * 2;
64
+ if (size > cap) return;
65
+ }
66
+ const child = (object as Record<string, unknown>)[name];
67
+ if (typeof child === "string") size += child.length * 2;
68
+ else if (typeof child === "number") size += 8;
69
+ else if (typeof child === "boolean") size += 4;
70
+ else if (child !== null && typeof child === "object") yield child;
71
+ if (size > cap) return;
72
+ }
73
+ }
74
+ while (pending.length && size <= cap) {
75
+ const next = pending[pending.length - 1].next();
76
+ if (next.done) {
77
+ pending.pop();
78
+ continue;
79
+ }
80
+ const item = next.value;
81
+ if (typeof item === "string") size += item.length * 2;
82
+ else if (typeof item === "number") size += 8;
83
+ else if (typeof item === "boolean") size += 4;
84
+ else if (item !== null && typeof item === "object" && !visited.has(item)) {
85
+ visited.add(item);
86
+ const array = Array.isArray(item);
87
+ size += array ? 24 + item.length * 8 : 32;
88
+ if (size <= cap) pending.push(objects(item, array));
89
+ }
90
+ }
91
+ return size > cap ? cap + 1 : size;
92
+ }
93
+
94
+ interface MarkdownRenderCacheEntry {
95
+ source: string;
96
+ lines: string[];
97
+ anchorSpans?: Array<ViewportAnchorSpan | null>;
98
+ }
99
+
100
+ const RENDER_ENTRY_BASE_SIZE = 64 + 32 + 16 + "source".length * 2 + 16 + "lines".length * 2 + 24;
101
+ const ANCHOR_SPAN_SIZE =
102
+ 32 + 4 * 16 + 4 * 8 + 2 * ("graphemeStart".length + "graphemeEnd".length + "cellStart".length + "cellEnd".length);
103
+
104
+ function renderCacheEntrySize(entry: MarkdownRenderCacheEntry, key: string): number {
105
+ // These arrays and distinct span records are constructed exclusively by #render,
106
+ // with no custom properties, sharing or cycles. Account that owned schema without
107
+ // allocating the generic token-graph traversal machinery.
108
+ let size = RENDER_ENTRY_BASE_SIZE + key.length * 2 + entry.source.length * 2 + entry.lines.length * 8;
109
+ if (size > DOCUMENT_ENTRY_SIZE) return DOCUMENT_ENTRY_SIZE + 1;
110
+ for (const line of entry.lines) {
111
+ size += line.length * 2;
112
+ if (size > DOCUMENT_ENTRY_SIZE) return DOCUMENT_ENTRY_SIZE + 1;
113
+ }
114
+ if (entry.anchorSpans) {
115
+ size += 16 + "anchorSpans".length * 2 + 24 + entry.anchorSpans.length * 8;
116
+ if (size > DOCUMENT_ENTRY_SIZE) return DOCUMENT_ENTRY_SIZE + 1;
117
+ for (const span of entry.anchorSpans) {
118
+ if (span) size += ANCHOR_SPAN_SIZE;
119
+ if (size > DOCUMENT_ENTRY_SIZE) return DOCUMENT_ENTRY_SIZE + 1;
120
+ }
121
+ }
122
+ return size;
123
+ }
124
+
125
+ const renderCache = new LRUCache<string, MarkdownRenderCacheEntry>({
53
126
  max: RENDER_CACHE_MAX,
127
+ maxSize: DOCUMENT_CACHE_SIZE,
128
+ maxEntrySize: DOCUMENT_ENTRY_SIZE,
129
+ sizeCalculation: renderCacheEntrySize,
54
130
  });
55
131
  const PARSE_CACHE_MAX = 128;
56
- const parseCache = new LRUCache<string, { source: string; tokens: Token[] }>({ max: PARSE_CACHE_MAX });
132
+ const parseCache = new LRUCache<string, { source: string; tokens: Token[] }>({
133
+ max: PARSE_CACHE_MAX,
134
+ maxSize: DOCUMENT_CACHE_SIZE,
135
+ maxEntrySize: DOCUMENT_ENTRY_SIZE,
136
+ sizeCalculation: (value, key) => getMarkdownCacheEntryAccountedSize(key, value, DOCUMENT_ENTRY_SIZE),
137
+ });
57
138
  const MARKDOWN_STREAM_THROTTLE_MS = 64;
58
139
  let markdownNow = (): number => performance.now();
59
140
 
@@ -76,20 +157,44 @@ export function __setMarkdownNowForTest(now: (() => number) | undefined): void {
76
157
  // appends only highlight new/changed blocks instead of re-highlighting the whole
77
158
  // prefix on every chunk. Bounded LRU; cleared on theme change via clearRenderCache().
78
159
  const HIGHLIGHT_CACHE_MAX = 512;
79
- const highlightCache = new LRUCache<string, string[]>({ max: HIGHLIGHT_CACHE_MAX });
160
+ const HIGHLIGHT_ENTRY_SIZE = 512 * 1024;
161
+ interface MarkdownHighlightCacheEntry {
162
+ lang: string;
163
+ code: string;
164
+ lines: string[];
165
+ }
166
+
167
+ const highlightCache = new LRUCache<string, MarkdownHighlightCacheEntry>({
168
+ max: HIGHLIGHT_CACHE_MAX,
169
+ maxSize: 4 * 1024 * 1024,
170
+ maxEntrySize: HIGHLIGHT_ENTRY_SIZE,
171
+ sizeCalculation: (value, key) => getMarkdownCacheEntryAccountedSize(key, value, HIGHLIGHT_ENTRY_SIZE),
172
+ });
80
173
 
81
- function renderedLinesBytes(lines: readonly string[]): number {
82
- let bytes = 0;
83
- for (const line of lines) bytes += Buffer.byteLength(line, "utf8");
84
- return bytes;
174
+ export interface MarkdownCacheStats {
175
+ count: number;
176
+ accountedSize: number;
177
+ max: number;
178
+ maxSize: number;
179
+ maxEntrySize: number;
85
180
  }
86
181
 
87
- function anchorSpansBytes(spans: readonly (ViewportAnchorSpan | null)[]): number {
88
- let bytes = spans.length * 8; // Array element references
89
- for (const span of spans) {
90
- if (span) bytes += 4 * 8; // Four numeric offsets
91
- }
92
- return bytes;
182
+ /** Detached diagnostics; exposes neither entries nor mutable budgets. */
183
+ export function getMarkdownCacheStats(): Record<"render" | "parse" | "highlight", MarkdownCacheStats> {
184
+ const snapshot = (cache: {
185
+ size: number;
186
+ calculatedSize: number;
187
+ max: number;
188
+ maxSize: number;
189
+ maxEntrySize: number;
190
+ }): MarkdownCacheStats => ({
191
+ count: cache.size,
192
+ accountedSize: cache.calculatedSize,
193
+ max: cache.max,
194
+ maxSize: cache.maxSize,
195
+ maxEntrySize: cache.maxEntrySize,
196
+ });
197
+ return { render: snapshot(renderCache), parse: snapshot(parseCache), highlight: snapshot(highlightCache) };
93
198
  }
94
199
 
95
200
  // F18: cap synchronous (Rust FFI) syntax highlighting so a single huge fenced block
@@ -133,16 +238,12 @@ export function clearRenderCache(): void {
133
238
  highlightCache.clear();
134
239
  }
135
240
 
241
+ /**
242
+ * Insertion-time UTF-16 accounting of global keys/tokens/lines/anchors, not heap or RSS.
243
+ * Rendered arrays are borrowed cache payloads: callers must not mutate them.
244
+ */
136
245
  export function getRenderCacheRetainedBytes(): number {
137
- let bytes = 0;
138
- for (const entry of renderCache.values()) {
139
- bytes += Buffer.byteLength(entry.source, "utf8");
140
- bytes += renderedLinesBytes(entry.lines);
141
- if (entry.anchorSpans) bytes += anchorSpansBytes(entry.anchorSpans);
142
- }
143
- for (const entry of parseCache.values()) bytes += Buffer.byteLength(entry.source, "utf8");
144
- for (const lines of highlightCache.values()) bytes += renderedLinesBytes(lines);
145
- return bytes;
246
+ return renderCache.calculatedSize + parseCache.calculatedSize + highlightCache.calculatedSize;
146
247
  }
147
248
 
148
249
  // Stable numeric IDs for structural theme/style objects (no ID field on type).
@@ -267,6 +368,8 @@ export class Markdown implements Component {
267
368
  #cachedWidth?: number;
268
369
  #cachedLines?: string[];
269
370
  #cachedAnchorSpans?: Array<ViewportAnchorSpan | null>;
371
+ #currentParse?: { source: string; tokens: Token[] };
372
+ #rejectedParseKey?: string;
270
373
 
271
374
  #streaming = false;
272
375
  #lastFullParseAt = 0;
@@ -297,6 +400,7 @@ export class Markdown implements Component {
297
400
  if (options?.streaming !== undefined) {
298
401
  this.setStreaming(options.streaming);
299
402
  }
403
+ if (this.#text !== text) this.#rejectedParseKey = undefined;
300
404
  this.#text = text;
301
405
  if (this.#streaming) {
302
406
  return;
@@ -336,6 +440,8 @@ export class Markdown implements Component {
336
440
 
337
441
  dispose(): void {
338
442
  this.#clearStaleThrottleTimer();
443
+ this.#currentParse = undefined;
444
+ this.#rejectedParseKey = undefined;
339
445
  }
340
446
 
341
447
  invalidate(): void {
@@ -346,14 +452,17 @@ export class Markdown implements Component {
346
452
  }
347
453
 
348
454
  #exceedsHighlightCap(code: string): boolean {
455
+ // UTF-8 requires at least one byte per UTF-16 code unit, including lone
456
+ // surrogates. Reject obviously oversized input without scanning or hashing it.
457
+ if (code.length > MAX_HIGHLIGHT_BYTES) return true;
458
+ // Check UTF-8 bytes before the JavaScript line scan so oversized Unicode
459
+ // blocks do not pay for a scan whose result cannot change rejection.
460
+ if (Buffer.byteLength(code, "utf8") > MAX_HIGHLIGHT_BYTES) return true;
349
461
  let newlines = 0;
350
462
  for (let i = 0; i < code.length; i++) {
351
- if (code.charCodeAt(i) === 10) newlines += 1;
463
+ if (code.charCodeAt(i) === 10 && ++newlines >= MAX_HIGHLIGHT_LINES) return true;
352
464
  }
353
- if (newlines + 1 > MAX_HIGHLIGHT_LINES) return true;
354
- // UTF-8 byte length (not UTF-16 code-unit count) so a non-ASCII block cannot
355
- // exceed the advertised byte cap and still reach the synchronous highlighter.
356
- return Buffer.byteLength(code, "utf8") > MAX_HIGHLIGHT_BYTES;
465
+ return false;
357
466
  }
358
467
 
359
468
  #highlightCodeBlock(code: string, lang: string): string[] | null {
@@ -361,10 +470,10 @@ export class Markdown implements Component {
361
470
  if (this.#exceedsHighlightCap(code)) return null;
362
471
  const key = `${objectId(this.#theme)}\x00${lang}\x00${code}`;
363
472
  const cached = highlightCache.get(key);
364
- if (cached) return cached;
473
+ if (cached?.lang === lang && cached.code === code) return cached.lines;
365
474
  highlightCallCount += 1;
366
475
  const result = this.#theme.highlightCode(code, lang || undefined);
367
- highlightCache.set(key, result);
476
+ highlightCache.set(key, { lang, code, lines: result });
368
477
  return result;
369
478
  }
370
479
 
@@ -421,6 +530,7 @@ export class Markdown implements Component {
421
530
 
422
531
  // Don't render anything if there's no actual text
423
532
  if (!this.#text || this.#text.trim() === "") {
533
+ this.#currentParse = undefined;
424
534
  const result: string[] = [];
425
535
  // Update per-instance cache
426
536
  this.#cachedText = this.#text;
@@ -430,8 +540,9 @@ export class Markdown implements Component {
430
540
  return { lines: result, spans: this.#cachedAnchorSpans };
431
541
  }
432
542
 
433
- // Replace tabs with 3 spaces for consistent rendering
434
- const normalizedText = replaceTabs(this.#text);
543
+ // Normalize tabs using the current configured indentation width.
544
+ const renderedText = this.#text;
545
+ const normalizedText = replaceTabs(renderedText);
435
546
  this.#clearStaleThrottleTimer();
436
547
  const contentKey = markdownContentKey(normalizedText);
437
548
 
@@ -450,10 +561,11 @@ export class Markdown implements Component {
450
561
  (!includeAnchors || cached.anchorSpans !== undefined)
451
562
  ) {
452
563
  // Populate L1 so subsequent calls from this instance are O(1) map lookup.
453
- this.#cachedText = this.#text;
564
+ this.#cachedText = renderedText;
454
565
  this.#cachedWidth = width;
455
566
  this.#cachedLines = cached.lines;
456
567
  this.#cachedAnchorSpans = cached.anchorSpans;
568
+ if (!this.#streaming || this.#currentParse?.source !== normalizedText) this.#currentParse = undefined;
457
569
  return { lines: cached.lines, spans: cached.anchorSpans };
458
570
  }
459
571
 
@@ -462,13 +574,17 @@ export class Markdown implements Component {
462
574
  // final wrapped output must differ by width.
463
575
  const cachedParse = parseCache.get(contentKey);
464
576
  let tokens: Token[];
465
- if (cachedParse !== undefined && cachedParse.source === normalizedText) {
577
+ if (this.#currentParse?.source === normalizedText) {
578
+ tokens = this.#currentParse.tokens;
579
+ } else if (cachedParse !== undefined && cachedParse.source === normalizedText) {
466
580
  tokens = cachedParse.tokens;
467
581
  } else {
468
582
  __markdownPerfCounters.lexerInvocations += 1;
469
583
  __markdownPerfCounters.lexedBytes += normalizedText.length;
470
584
  tokens = markdownParser.lexer(normalizedText);
471
- parseCache.set(contentKey, { source: normalizedText, tokens });
585
+ }
586
+ if (this.#streaming && this.#text === renderedText) {
587
+ this.#currentParse = { source: normalizedText, tokens };
472
588
  }
473
589
 
474
590
  // Convert tokens to styled terminal output. When anchoring, record each
@@ -615,8 +731,24 @@ export class Markdown implements Component {
615
731
  const result = rawResult.length > 0 ? rawResult : [""];
616
732
  const anchorSpans = rawResult.length > 0 ? rawAnchorSpans : wrappedSpans ? [null] : undefined;
617
733
 
734
+ // Styling callbacks may replace text or end streaming. Publish ownership
735
+ // only after they finish, and never attach an old rejection to new text.
736
+ if (!this.#streaming && this.#text === renderedText) {
737
+ const completedParse = parseCache.get(contentKey);
738
+ // Reparse rejected documents on reflow without retaining their tokens.
739
+ // Occupied collisions still retry admission to preserve replacement rules.
740
+ if (
741
+ completedParse?.source !== normalizedText &&
742
+ (this.#rejectedParseKey !== contentKey || completedParse !== undefined)
743
+ ) {
744
+ parseCache.set(contentKey, { source: normalizedText, tokens });
745
+ this.#rejectedParseKey = parseCache.has(contentKey) ? undefined : contentKey;
746
+ }
747
+ }
748
+ if (!this.#streaming || this.#text !== renderedText) this.#currentParse = undefined;
749
+
618
750
  // Update L1 per-instance cache
619
- this.#cachedText = this.#text;
751
+ this.#cachedText = renderedText;
620
752
  this.#cachedWidth = width;
621
753
  this.#cachedLines = result;
622
754
  this.#cachedAnchorSpans = anchorSpans;
@@ -624,7 +756,9 @@ export class Markdown implements Component {
624
756
 
625
757
  // Update L2 module-level LRU so future instances with the same key skip
626
758
  // the marked.lexer + highlightCode (Rust FFI) work entirely.
627
- renderCache.set(cacheKey, { source: normalizedText, lines: result, ...(anchorSpans ? { anchorSpans } : {}) });
759
+ if (!this.#streaming) {
760
+ renderCache.set(cacheKey, { source: normalizedText, lines: result, ...(anchorSpans ? { anchorSpans } : {}) });
761
+ }
628
762
 
629
763
  return { lines: result, spans: anchorSpans };
630
764
  }
@@ -241,9 +241,12 @@ export class SelectList implements Component {
241
241
 
242
242
  #getPrimaryColumnWidth(): number {
243
243
  const { min, max } = this.#getPrimaryColumnBounds();
244
- const widestPrimary = this.#filteredItems.reduce((widest, item) => {
245
- return Math.max(widest, visibleWidth(this.#getDisplayValue(item)) + PRIMARY_COLUMN_GAP);
246
- }, 0);
244
+ if (min === max) return min;
245
+ let widestPrimary = 0;
246
+ for (const item of this.#filteredItems) {
247
+ widestPrimary = Math.max(widestPrimary, visibleWidth(this.#getDisplayValue(item)) + PRIMARY_COLUMN_GAP);
248
+ if (widestPrimary >= max) return max;
249
+ }
247
250
 
248
251
  return clamp(widestPrimary, min, max);
249
252
  }
@@ -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
@@ -147,15 +147,17 @@ function csiEnd(bytes: string, start: number): number | undefined {
147
147
  */
148
148
  function stripTerminalEraseControls(bytes: string): string {
149
149
  let sanitized = "";
150
+ let spanStart = 0;
150
151
  for (let index = 0; index < bytes.length; index += 1) {
151
152
  const value = bytes.charCodeAt(index);
152
153
  const isEscapeCsi = value === 0x1b && bytes.charCodeAt(index + 1) === 0x5b;
153
154
  const isEightBitCsi = value === 0x9b;
154
- if (value === 0x1b && index === bytes.length - 1) break;
155
- if (!isEscapeCsi && !isEightBitCsi) {
156
- sanitized += bytes[index];
157
- continue;
155
+ if (value === 0x1b && index === bytes.length - 1) {
156
+ sanitized += bytes.slice(spanStart, index);
157
+ spanStart = bytes.length;
158
+ break;
158
159
  }
160
+ if (!isEscapeCsi && !isEightBitCsi) continue;
159
161
 
160
162
  const start = isEightBitCsi ? index + 1 : index + 2;
161
163
  const end = csiEnd(bytes, start);
@@ -168,16 +170,19 @@ function stripTerminalEraseControls(bytes: string): string {
168
170
  if (!CSI_PARAMETER(nextValue) && !CSI_INTERMEDIATE(nextValue)) break;
169
171
  next += 1;
170
172
  }
173
+ sanitized += bytes.slice(spanStart, index);
174
+ spanStart = next;
171
175
  index = next - 1;
172
176
  continue;
173
177
  }
174
178
  const final = bytes.charCodeAt(end);
175
- if (final !== 0x4a && final !== 0x4b) {
176
- sanitized += bytes.slice(index, end + 1);
179
+ if (final === 0x4a || final === 0x4b) {
180
+ sanitized += bytes.slice(spanStart, index);
181
+ spanStart = end + 1;
177
182
  }
178
183
  index = end;
179
184
  }
180
- return sanitized;
185
+ return spanStart === 0 ? bytes : sanitized + bytes.slice(spanStart);
181
186
  }
182
187
  type InputListenerResult = { consume?: boolean; data?: string } | undefined;
183
188
  type InputListener = (data: string) => InputListenerResult;
@@ -813,6 +818,7 @@ type TuiRenderCounterSnapshot = {
813
818
  debugRedrawEnvReads: number;
814
819
  debugRedrawAppendWrites: number;
815
820
  differentialGuardVisibleWidthCalls: number;
821
+ widthReflowScanRows: number;
816
822
  };
817
823
  type RenderCommitWaiter = {
818
824
  resolve: (committed: boolean) => void;
@@ -960,6 +966,20 @@ function hasDistinctPostContractionRows(
960
966
  return false;
961
967
  }
962
968
 
969
+ interface RenderPreparation {
970
+ callback?: () => void;
971
+ generation: number;
972
+ cancel?: () => void;
973
+ }
974
+
975
+ // Keep the public handle outside the enqueue scope: it must capture only the entry,
976
+ // not the TUI or the original callback after the entry has been released.
977
+ function createPreparationCancellation(entry: RenderPreparation): () => void {
978
+ return () => entry.cancel?.();
979
+ }
980
+
981
+ function cancelNoPreparation(): void {}
982
+
963
983
  /**
964
984
  * TUI - Main class for managing terminal UI with differential rendering
965
985
  */
@@ -1009,6 +1029,21 @@ export class TUI extends Container {
1009
1029
  #nextRenderGeneration = 0;
1010
1030
  #renderRequestedGeneration = 0;
1011
1031
  #committedRenderGeneration = 0;
1032
+ #preparations = new Set<RenderPreparation>();
1033
+ #preparationSnapshot: Set<RenderPreparation> | undefined;
1034
+ #preparationEpoch = 0;
1035
+ #preparationDrainEpoch = -1;
1036
+ #preparationDisposed = false;
1037
+ #preparationInvalidating = false;
1038
+ #preparationLifecycle: { invalidate: () => void; beforeStart: () => void } | undefined;
1039
+ // A newer ordinary request may commit while an older nested preparation is still pending.
1040
+ // Keep holes in the high-water mark, including for waiters registered after that commit.
1041
+ #preparationBlocked = new Set<number>();
1042
+ #commitHoles = new Set<number>();
1043
+ // Terminal failures are sorted, disjoint inclusive ranges, not per-frame exclusions.
1044
+ #failedPreparationRanges: Array<[number, number]> = [];
1045
+ // Captured by queued terminal writes; never consult the mutable queue at write settlement.
1046
+ #frameCommitExclusions = new Set<number>();
1012
1047
  #renderCommitWaiters = new Map<number, Set<RenderCommitWaiter>>();
1013
1048
  #lastRenderWriteSucceeded = false;
1014
1049
  /** Generation whose render path is currently capturing terminal output. */
@@ -1147,6 +1182,7 @@ export class TUI extends Container {
1147
1182
  debugRedrawEnvReads: 0,
1148
1183
  debugRedrawAppendWrites: 0,
1149
1184
  differentialGuardVisibleWidthCalls: 0,
1185
+ widthReflowScanRows: 0,
1150
1186
  };
1151
1187
 
1152
1188
  static resetRenderCountersForTest(): void {
@@ -1154,6 +1190,7 @@ export class TUI extends Container {
1154
1190
  debugRedrawEnvReads: 0,
1155
1191
  debugRedrawAppendWrites: 0,
1156
1192
  differentialGuardVisibleWidthCalls: 0,
1193
+ widthReflowScanRows: 0,
1157
1194
  };
1158
1195
  }
1159
1196
 
@@ -1161,6 +1198,14 @@ export class TUI extends Container {
1161
1198
  return { ...TUI.#renderCounters };
1162
1199
  }
1163
1200
 
1201
+ getRenderPreparationStateForTest(): { pending: number; holes: number; failedRanges: number } {
1202
+ return {
1203
+ pending: this.#preparationBlocked.size,
1204
+ holes: this.#commitHoles.size,
1205
+ failedRanges: this.#failedPreparationRanges.length,
1206
+ };
1207
+ }
1208
+
1164
1209
  static #readDebugRedrawFlag(): boolean {
1165
1210
  TUI.#renderCounters.debugRedrawEnvReads += 1;
1166
1211
  return $pickflag("GJC_DEBUG_REDRAW", "PI_DEBUG_REDRAW");
@@ -1249,6 +1294,29 @@ export class TUI extends Container {
1249
1294
  }
1250
1295
 
1251
1296
  override dispose(): void {
1297
+ if (this.#preparationDisposed) return;
1298
+ this.#preparationDisposed = true;
1299
+ this.#renderRequested = false;
1300
+ this.#renderRequestedGeneration = 0;
1301
+ this.#inputRenderPending = false;
1302
+ this.#resizeRenderQueued = false;
1303
+ this.#resizeRenderMutationQueued = false;
1304
+ this.#renderMutationQueued = false;
1305
+ this.#widthSettleRenderQueued = false;
1306
+ this.#forcedRenderQueued = false;
1307
+ this.#clearSixelProbeState();
1308
+ if (this.#renderTimer) {
1309
+ clearTimeout(this.#renderTimer);
1310
+ this.#renderTimer = undefined;
1311
+ if (renderMetrics.enabled) renderMetrics.setTimerGauge("tui.renderTimer", 0);
1312
+ }
1313
+ if (this.#widthSettleTimer) {
1314
+ clearTimeout(this.#widthSettleTimer);
1315
+ this.#widthSettleTimer = undefined;
1316
+ }
1317
+ this.#invalidatePreparations();
1318
+ this.#preparationLifecycle = undefined;
1319
+ this.#settleRenderCommitWaiters(false);
1252
1320
  this.#unsubscribeTabWidthChange?.();
1253
1321
  this.#unsubscribeTabWidthChange = undefined;
1254
1322
  this.#finalizeRasterLeases("terminal-loss");
@@ -2314,6 +2382,7 @@ export class TUI extends Container {
2314
2382
  return true;
2315
2383
  }
2316
2384
  start(): void {
2385
+ if (this.#preparationDisposed) return;
2317
2386
  this.#stopped = false;
2318
2387
  this.#terminalUnavailable = false;
2319
2388
  this.#clearMouseSelection();
@@ -2321,31 +2390,36 @@ export class TUI extends Container {
2321
2390
  // Seed the observed width so a spurious post-start resize event (iTerm2 tab
2322
2391
  // activation, the self-sent SIGWINCH after resume) is not read as a reflow.
2323
2392
  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
- );
2393
+ try {
2394
+ this.terminal.setMouseEnabled?.(this.options.enableMouse === true);
2395
+ this.terminal.start(
2396
+ data => this.#handleInput(data),
2397
+ () => {
2398
+ const hadRasterLease = this.#rasterLeases.size > 0;
2399
+ this.#revokeRasterLeases("resize");
2400
+ // Only a pet raster lease needs refreshed cell metrics on resize; a
2401
+ // plain resize keeps the historical byte stream (no cell query).
2402
+ if (hadRasterLease) this.#queryCellSize(true);
2403
+ this.invalidate();
2404
+ if (this.#pendingTerminalCleanup.length > 0 || this.#rasterCleanup.size > 0) {
2405
+ // Retained pet/cleanup output must be flushed before the resize
2406
+ // repaint so it cannot interleave behind the new frame.
2407
+ this.notifyTerminalLifecycle({
2408
+ kind: "explicit-cleanup",
2409
+ source: "tui",
2410
+ terminalGeneration: this.#terminalGeneration,
2411
+ }).then(result => {
2412
+ if (result.stillPending === 0) this.requestResizeRender();
2413
+ });
2414
+ } else {
2415
+ this.requestResizeRender();
2416
+ }
2417
+ },
2418
+ );
2419
+ } catch (error) {
2420
+ this.#markTerminalUnavailable();
2421
+ throw error;
2422
+ }
2349
2423
  if (this.#pendingTerminalCleanup.length > 0 || this.#rasterCleanup.size > 0) {
2350
2424
  void this.notifyTerminalLifecycle({
2351
2425
  kind: "availability-restored",
@@ -2356,6 +2430,14 @@ export class TUI extends Container {
2356
2430
  this.#hideCursor();
2357
2431
  this.#querySixelSupport();
2358
2432
  this.#queryCellSize();
2433
+ if (this.#stopped || !this.terminalAvailable) return;
2434
+ const epoch = this.#preparationEpoch;
2435
+ if (!this.#callPreparation(this.#preparationLifecycle?.beforeStart, "beforeStart")) {
2436
+ this.#invalidatePreparations();
2437
+ if (!this.#stopped && this.terminalAvailable && !this.#preparationDisposed) this.requestRender(true);
2438
+ return;
2439
+ }
2440
+ if (epoch !== this.#preparationEpoch || this.#stopped || !this.terminalAvailable) return;
2359
2441
  this.requestRender(true);
2360
2442
  }
2361
2443
 
@@ -2368,8 +2450,10 @@ export class TUI extends Container {
2368
2450
  * holding a session operation behind a dead renderer.
2369
2451
  */
2370
2452
  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);
2453
+ if (this.#isFailedPreparation(generation)) return Promise.resolve(false);
2454
+ if (generation <= 0 || (generation <= this.#committedRenderGeneration && !this.#commitHoles.has(generation)))
2455
+ return Promise.resolve(true);
2456
+ if (this.#stopped || !this.terminalAvailable || this.#preparationDisposed) return Promise.resolve(false);
2373
2457
  return new Promise<boolean>(resolve => {
2374
2458
  const waiter: RenderCommitWaiter = {
2375
2459
  resolve,
@@ -2392,14 +2476,31 @@ export class TUI extends Container {
2392
2476
  });
2393
2477
  }
2394
2478
 
2395
- #settleRenderCommitWaiters(committed: boolean, generation = Number.POSITIVE_INFINITY): void {
2396
- if (committed) this.#committedRenderGeneration = Math.max(this.#committedRenderGeneration, generation);
2479
+ #settleRenderCommitWaiters(
2480
+ committed: boolean,
2481
+ generation = Number.POSITIVE_INFINITY,
2482
+ exclusions = this.#frameCommitExclusions,
2483
+ ): void {
2484
+ if (committed) {
2485
+ for (const blocked of exclusions) {
2486
+ if (
2487
+ blocked <= generation &&
2488
+ blocked > this.#committedRenderGeneration &&
2489
+ !this.#isFailedPreparation(blocked)
2490
+ )
2491
+ this.#commitHoles.add(blocked);
2492
+ }
2493
+ for (const hole of this.#commitHoles) {
2494
+ if (hole <= generation && !exclusions.has(hole)) this.#commitHoles.delete(hole);
2495
+ }
2496
+ this.#committedRenderGeneration = Math.max(this.#committedRenderGeneration, generation);
2497
+ }
2397
2498
  for (const [waiterGeneration, waiters] of this.#renderCommitWaiters) {
2398
- if (committed && waiterGeneration > generation) continue;
2499
+ if (committed && (waiterGeneration > generation || exclusions.has(waiterGeneration))) continue;
2399
2500
  this.#renderCommitWaiters.delete(waiterGeneration);
2400
2501
  for (const waiter of waiters) {
2401
2502
  clearTimeout(waiter.timer);
2402
- waiter.resolve(committed);
2503
+ waiter.resolve(committed && !this.#isFailedPreparation(waiterGeneration));
2403
2504
  }
2404
2505
  }
2405
2506
  }
@@ -2440,6 +2541,7 @@ export class TUI extends Container {
2440
2541
  this.#rasterLeases.clear();
2441
2542
  }
2442
2543
  #markTerminalUnavailable(settleRenderWaiters = true): void {
2544
+ this.#invalidatePreparations();
2443
2545
  this.#terminalGeneration++;
2444
2546
  for (const record of this.#rasterCleanup.values()) record.terminalGeneration = this.#terminalGeneration;
2445
2547
  this.#revokeRasterLeases("terminal-loss");
@@ -2676,6 +2778,7 @@ export class TUI extends Container {
2676
2778
  }
2677
2779
 
2678
2780
  stop(): void {
2781
+ this.#invalidatePreparations();
2679
2782
  // Invalidate every raster-queue body captured under the running epoch
2680
2783
  // before any teardown: nothing queued before stop may write after
2681
2784
  // restoration. Synchronous stop cleanup below writes directly.
@@ -2785,6 +2888,7 @@ export class TUI extends Container {
2785
2888
  * the last committed frame.
2786
2889
  */
2787
2890
  requestResizeRender(): void {
2891
+ if (this.#preparationDisposed) return;
2788
2892
  // Width is tracked against the last OBSERVED terminal width, not against
2789
2893
  // #previousWidth (the last committed frame). Those diverge whenever resize
2790
2894
  // events coalesce inside one frame budget: a 100->90->100 burst would leave
@@ -2844,8 +2948,180 @@ export class TUI extends Container {
2844
2948
  this.requestRenderWithGeneration(force, source);
2845
2949
  }
2846
2950
 
2951
+ /** Queue one synchronous preparation on the existing frame clock. Cancellation is idempotent. */
2952
+ enqueueBeforeRender(callback: () => void): () => void {
2953
+ if (this.#preparationDisposed || this.#preparationInvalidating || this.#stopped || !this.terminalAvailable)
2954
+ return cancelNoPreparation;
2955
+ const entry: RenderPreparation = { callback, generation: this.#nextRenderGeneration + 1 };
2956
+ entry.cancel = () => {
2957
+ entry.callback = undefined;
2958
+ entry.cancel = undefined;
2959
+ this.#preparations.delete(entry);
2960
+ this.#preparationSnapshot?.delete(entry);
2961
+ this.#preparationBlocked.delete(entry.generation);
2962
+ };
2963
+ this.#preparations.add(entry);
2964
+ this.#preparationBlocked.add(entry.generation);
2965
+ this.requestRenderWithGeneration(false, "preparation");
2966
+ return createPreparationCancellation(entry);
2967
+ }
2968
+
2969
+ /** One owner only: replacing or clearing it invalidates all old preparation work. */
2970
+ setRenderPreparationLifecycleCallbacks(
2971
+ callbacks: { invalidate: () => void; beforeStart: () => void } | undefined,
2972
+ ): void {
2973
+ if (this.#preparationLifecycle === callbacks) return;
2974
+ this.#invalidatePreparations();
2975
+ this.#preparationLifecycle = this.#preparationDisposed ? undefined : callbacks;
2976
+ }
2977
+
2978
+ #callPreparation(callback: (() => void) | undefined, where: string): boolean {
2979
+ try {
2980
+ callback?.();
2981
+ return true;
2982
+ } catch (error) {
2983
+ logger.error("Render preparation failed", {
2984
+ where,
2985
+ error: error instanceof Error ? error.message : String(error),
2986
+ stack: error instanceof Error ? error.stack : undefined,
2987
+ });
2988
+ return false;
2989
+ }
2990
+ }
2991
+
2992
+ #invalidatePreparations(): void {
2993
+ this.#preparationEpoch++;
2994
+ for (const generation of this.#preparationBlocked) this.#retirePreparation(generation);
2995
+ for (const entry of this.#preparations) {
2996
+ entry.callback = undefined;
2997
+ entry.cancel = undefined;
2998
+ }
2999
+ for (const entry of this.#preparationSnapshot ?? []) {
3000
+ entry.callback = undefined;
3001
+ entry.cancel = undefined;
3002
+ }
3003
+ this.#preparations.clear();
3004
+ this.#preparationSnapshot?.clear();
3005
+ if (this.#preparationInvalidating) return;
3006
+ this.#preparationInvalidating = true;
3007
+ try {
3008
+ this.#callPreparation(this.#preparationLifecycle?.invalidate, "invalidate");
3009
+ } finally {
3010
+ this.#preparationInvalidating = false;
3011
+ }
3012
+ }
3013
+
3014
+ #isFailedPreparation(generation: number): boolean {
3015
+ let low = 0;
3016
+ let high = this.#failedPreparationRanges.length;
3017
+ while (low < high) {
3018
+ const middle = (low + high) >>> 1;
3019
+ const [start, end] = this.#failedPreparationRanges[middle];
3020
+ if (generation < start) high = middle;
3021
+ else if (generation > end) low = middle + 1;
3022
+ else return true;
3023
+ }
3024
+ return false;
3025
+ }
3026
+
3027
+ #retirePreparation(generation: number): void {
3028
+ this.#preparationBlocked.delete(generation);
3029
+ this.#commitHoles.delete(generation);
3030
+ const ranges = this.#failedPreparationRanges;
3031
+ let low = 0;
3032
+ let high = ranges.length;
3033
+ while (low < high) {
3034
+ const middle = (low + high) >>> 1;
3035
+ if (ranges[middle][1] < generation - 1) low = middle + 1;
3036
+ else high = middle;
3037
+ }
3038
+ let start = generation;
3039
+ let end = generation;
3040
+ let next = low;
3041
+ while (next < ranges.length && ranges[next][0] <= end + 1) {
3042
+ start = Math.min(start, ranges[next][0]);
3043
+ end = Math.max(end, ranges[next][1]);
3044
+ next++;
3045
+ }
3046
+ ranges.splice(low, next - low, [start, end]);
3047
+ const waiters = this.#renderCommitWaiters.get(generation);
3048
+ this.#renderCommitWaiters.delete(generation);
3049
+ for (const waiter of waiters ?? []) {
3050
+ clearTimeout(waiter.timer);
3051
+ waiter.resolve(false);
3052
+ }
3053
+ }
3054
+
3055
+ /** The only preparation boundary, before generation capture and all renderer reads. */
3056
+ #renderPreparedFrame(): void {
3057
+ if (this.#stopped || this.#preparationDisposed) return;
3058
+ if (!this.terminalAvailable) {
3059
+ this.#markTerminalUnavailable();
3060
+ return;
3061
+ }
3062
+ const epoch = this.#preparationEpoch;
3063
+ const snapshot = this.#preparations;
3064
+ this.#preparations = new Set();
3065
+ this.#preparationSnapshot = snapshot;
3066
+ this.#preparationDrainEpoch = epoch;
3067
+ try {
3068
+ for (const entry of snapshot) {
3069
+ if (epoch !== this.#preparationEpoch || this.#stopped || !this.terminalAvailable) break;
3070
+ const callback = entry.callback;
3071
+ entry.callback = undefined;
3072
+ entry.cancel = undefined;
3073
+ if (
3074
+ callback &&
3075
+ this.#callPreparation(callback, "beforeRender") &&
3076
+ epoch === this.#preparationEpoch &&
3077
+ !this.#stopped &&
3078
+ this.terminalAvailable
3079
+ ) {
3080
+ this.#preparationBlocked.delete(entry.generation);
3081
+ } else if (callback) {
3082
+ this.#retirePreparation(entry.generation);
3083
+ }
3084
+ }
3085
+ } finally {
3086
+ for (const entry of snapshot) {
3087
+ entry.callback = undefined;
3088
+ entry.cancel = undefined;
3089
+ }
3090
+ snapshot.clear();
3091
+ this.#preparationSnapshot = undefined;
3092
+ }
3093
+ if (!this.terminalAvailable && !this.#stopped) this.#markTerminalUnavailable();
3094
+ if (epoch !== this.#preparationEpoch || this.#stopped || !this.terminalAvailable || this.#preparationDisposed) {
3095
+ if (!this.#stopped && this.terminalAvailable && !this.#preparationDisposed) this.#scheduleRender();
3096
+ return;
3097
+ }
3098
+ const requestedGeneration = this.#renderRequestedGeneration;
3099
+ this.#renderRequestedGeneration = 0;
3100
+ this.#renderRequested = false;
3101
+ this.#lastRenderAt = performance.now();
3102
+ this.#lastRenderWriteSucceeded = false;
3103
+ this.#frameCommitExclusions = new Set(this.#preparationBlocked);
3104
+ const t0 = renderMetrics.now();
3105
+ try {
3106
+ this.#renderGenerationInProgress = requestedGeneration;
3107
+ this.#doRender();
3108
+ this.#commitRenderGeneration(requestedGeneration);
3109
+ } finally {
3110
+ this.#renderGenerationInProgress = 0;
3111
+ this.#frameCommitExclusions = new Set();
3112
+ if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
3113
+ if (this.#preparations.size > 0 && epoch === this.#preparationEpoch) {
3114
+ for (const entry of this.#preparations)
3115
+ this.#renderRequestedGeneration = Math.max(this.#renderRequestedGeneration, entry.generation);
3116
+ this.#renderRequested = true;
3117
+ this.#scheduleRender();
3118
+ }
3119
+ }
3120
+ }
3121
+
2847
3122
  #requestRenderWithScope(force: boolean, source: string, scope: "full" | "layout"): number {
2848
3123
  const generation = ++this.#nextRenderGeneration;
3124
+ if (this.#preparationDisposed) return generation;
2849
3125
  this.#renderRequestedGeneration = Math.max(this.#renderRequestedGeneration, generation);
2850
3126
  if (scope === "full") this.#renderScope = "full";
2851
3127
  this.#requestRenderCore(force, source, generation);
@@ -2863,6 +3139,7 @@ export class TUI extends Container {
2863
3139
  }
2864
3140
 
2865
3141
  #requestRenderCore(force: boolean, source: string, generation: number): void {
3142
+ if (this.#preparationDisposed) return;
2866
3143
  if (!this.terminalAvailable) {
2867
3144
  this.#markTerminalUnavailable();
2868
3145
  return;
@@ -2905,6 +3182,7 @@ export class TUI extends Container {
2905
3182
  this.#viewportTopRow = 0;
2906
3183
  this.#maxLinesRendered = 0;
2907
3184
  }
3185
+ if (this.#preparationSnapshot && this.#preparationDrainEpoch === this.#preparationEpoch) return;
2908
3186
  if (this.#renderTimer) {
2909
3187
  clearTimeout(this.#renderTimer);
2910
3188
  this.#renderTimer = undefined;
@@ -2916,20 +3194,12 @@ export class TUI extends Container {
2916
3194
  this.#settleRenderCommitWaiters(false, generation);
2917
3195
  return;
2918
3196
  }
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);
3197
+ this.#renderPreparedFrame();
2930
3198
  });
2931
3199
  return;
2932
3200
  }
3201
+ // Preparation mutations join this frame; nested registrations get the next normal frame.
3202
+ if (this.#preparationSnapshot && this.#preparationDrainEpoch === this.#preparationEpoch) return;
2933
3203
  // Input-priority path: expedite so the keystroke echoes within the next tick
2934
3204
  // instead of waiting for (or behind) the frame-budget timer. Re-entrant input
2935
3205
  // requests in the same turn coalesce via #inputRenderPending, so at most one
@@ -2950,7 +3220,7 @@ export class TUI extends Container {
2950
3220
  }
2951
3221
 
2952
3222
  #scheduleRender(): void {
2953
- if (this.#stopped || this.#renderTimer || !this.#renderRequested) {
3223
+ if (this.#preparationDisposed || this.#stopped || this.#renderTimer || !this.#renderRequested) {
2954
3224
  return;
2955
3225
  }
2956
3226
  const elapsed = performance.now() - this.#lastRenderAt;
@@ -2961,17 +3231,7 @@ export class TUI extends Container {
2961
3231
  if (this.#stopped || !this.#renderRequested) {
2962
3232
  return;
2963
3233
  }
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);
3234
+ this.#renderPreparedFrame();
2975
3235
  if (this.#renderRequested) {
2976
3236
  this.#scheduleRender();
2977
3237
  }
@@ -2993,17 +3253,7 @@ export class TUI extends Container {
2993
3253
  this.#renderTimer = undefined;
2994
3254
  if (renderMetrics.enabled) renderMetrics.setTimerGauge("tui.renderTimer", 0);
2995
3255
  }
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);
3256
+ this.#renderPreparedFrame();
3007
3257
  }
3008
3258
 
3009
3259
  #handleInput(data: string): void {
@@ -3798,6 +4048,7 @@ export class TUI extends Container {
3798
4048
  #normalizeLinesForEmit(lines: string[], width: number, start = 0): string[] {
3799
4049
  const widthCheckIndexes: number[] = [];
3800
4050
  const widthCheckLines: string[] = [];
4051
+ const retainedWidths: (number | undefined)[] = [];
3801
4052
  for (let i = start; i < lines.length; i++) {
3802
4053
  const line = lines[i];
3803
4054
  if (TERMINAL.isImageLine(line)) continue;
@@ -3810,19 +4061,21 @@ export class TUI extends Container {
3810
4061
  continue;
3811
4062
  }
3812
4063
  widthCheckIndexes.push(i);
3813
- widthCheckLines.push(normalized);
4064
+ retainedWidths.push(entry.width);
4065
+ if (entry.width === undefined) widthCheckLines.push(normalized);
3814
4066
  }
3815
4067
 
3816
4068
  const widths = widthCheckLines.length === 0 ? [] : visibleWidths(widthCheckLines);
3817
4069
  const truncateIndexes: number[] = [];
3818
4070
  const truncateLines: string[] = [];
4071
+ let measuredIndex = 0;
3819
4072
  for (let i = 0; i < widthCheckIndexes.length; i++) {
3820
4073
  const lineIndex = widthCheckIndexes[i];
3821
- const normalized = widthCheckLines[i];
3822
- const measuredWidth = widths[i] ?? 0;
4074
+ const entry = this.#normalizeLineForRender(lines[lineIndex]);
4075
+ const { normalized } = entry;
4076
+ const measuredWidth = retainedWidths[i] ?? widths[measuredIndex++] ?? 0;
4077
+ entry.width = measuredWidth;
3823
4078
  if (measuredWidth <= width) {
3824
- const entry = this.#normalizeLineForRender(lines[lineIndex]);
3825
- entry.width = measuredWidth;
3826
4079
  this.#lineEmitWidthCache.set(entry.terminated, measuredWidth);
3827
4080
  lines[lineIndex] = entry.terminated;
3828
4081
  continue;
@@ -3831,7 +4084,9 @@ export class TUI extends Container {
3831
4084
  const key = `${width}\0${normalized}`;
3832
4085
  const cached = this.#lineTruncationCache.get(key);
3833
4086
  if (cached !== undefined) {
3834
- this.#lineEmitWidthCache.set(cached, visibleWidth(cached));
4087
+ if (!this.#lineEmitWidthCache.has(cached)) {
4088
+ this.#lineEmitWidthCache.set(cached, visibleWidth(cached));
4089
+ }
3835
4090
  lines[lineIndex] = cached;
3836
4091
  continue;
3837
4092
  }
@@ -4832,10 +5087,12 @@ export class TUI extends Container {
4832
5087
  }
4833
5088
  const useViewportRepaintPath = this.#viewportRepaintHost();
4834
5089
  const widthReflowRequired =
5090
+ widthChanged &&
4835
5091
  this.#previousWidth > 0 &&
4836
- rawLines.some(
4837
- line => !TERMINAL.isImageLine(line) && visibleWidth(line) > Math.min(this.#previousWidth, width),
4838
- );
5092
+ rawLines.some(line => {
5093
+ TUI.#renderCounters.widthReflowScanRows += 1;
5094
+ return !TERMINAL.isImageLine(line) && visibleWidth(line) > Math.min(this.#previousWidth, width);
5095
+ });
4839
5096
  if (
4840
5097
  widthChanged &&
4841
5098
  !this.#legacyMultiplexerFullRender &&
@@ -5607,11 +5864,12 @@ export class TUI extends Container {
5607
5864
  ? (bytes: string) => this.#writeRasterPreservingRenderIngress(bytes)
5608
5865
  : (bytes: string) => this.#writeProtectedRenderIngress(bytes);
5609
5866
  const renderGeneration = this.#renderGenerationInProgress;
5867
+ const commitExclusions = this.#frameCommitExclusions;
5610
5868
  const write = () => {
5611
5869
  if (!writeIngress(buffer)) return false;
5612
5870
  onBufferWritten?.();
5613
5871
  this.#lastRenderWriteSucceeded = true;
5614
- if (renderGeneration > 0) this.#settleRenderCommitWaiters(true, renderGeneration);
5872
+ if (renderGeneration > 0) this.#settleRenderCommitWaiters(true, renderGeneration, commitExclusions);
5615
5873
  const emission = this.#postRenderEmitter?.();
5616
5874
  if (emission) {
5617
5875
  const overlay = typeof emission === "string" ? emission : emission.payload;