@gajae-code/tui 0.16.4 → 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,21 @@
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
+
5
20
  ## [0.16.4] - 2026-09-05
6
21
 
7
22
  ### Added
@@ -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.
@@ -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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/tui",
4
- "version": "0.16.4",
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.4",
40
- "@gajae-code/utils": "0.16.4",
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
  }
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;
@@ -1176,6 +1182,7 @@ export class TUI extends Container {
1176
1182
  debugRedrawEnvReads: 0,
1177
1183
  debugRedrawAppendWrites: 0,
1178
1184
  differentialGuardVisibleWidthCalls: 0,
1185
+ widthReflowScanRows: 0,
1179
1186
  };
1180
1187
 
1181
1188
  static resetRenderCountersForTest(): void {
@@ -1183,6 +1190,7 @@ export class TUI extends Container {
1183
1190
  debugRedrawEnvReads: 0,
1184
1191
  debugRedrawAppendWrites: 0,
1185
1192
  differentialGuardVisibleWidthCalls: 0,
1193
+ widthReflowScanRows: 0,
1186
1194
  };
1187
1195
  }
1188
1196
 
@@ -4040,6 +4048,7 @@ export class TUI extends Container {
4040
4048
  #normalizeLinesForEmit(lines: string[], width: number, start = 0): string[] {
4041
4049
  const widthCheckIndexes: number[] = [];
4042
4050
  const widthCheckLines: string[] = [];
4051
+ const retainedWidths: (number | undefined)[] = [];
4043
4052
  for (let i = start; i < lines.length; i++) {
4044
4053
  const line = lines[i];
4045
4054
  if (TERMINAL.isImageLine(line)) continue;
@@ -4052,19 +4061,21 @@ export class TUI extends Container {
4052
4061
  continue;
4053
4062
  }
4054
4063
  widthCheckIndexes.push(i);
4055
- widthCheckLines.push(normalized);
4064
+ retainedWidths.push(entry.width);
4065
+ if (entry.width === undefined) widthCheckLines.push(normalized);
4056
4066
  }
4057
4067
 
4058
4068
  const widths = widthCheckLines.length === 0 ? [] : visibleWidths(widthCheckLines);
4059
4069
  const truncateIndexes: number[] = [];
4060
4070
  const truncateLines: string[] = [];
4071
+ let measuredIndex = 0;
4061
4072
  for (let i = 0; i < widthCheckIndexes.length; i++) {
4062
4073
  const lineIndex = widthCheckIndexes[i];
4063
- const normalized = widthCheckLines[i];
4064
- 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;
4065
4078
  if (measuredWidth <= width) {
4066
- const entry = this.#normalizeLineForRender(lines[lineIndex]);
4067
- entry.width = measuredWidth;
4068
4079
  this.#lineEmitWidthCache.set(entry.terminated, measuredWidth);
4069
4080
  lines[lineIndex] = entry.terminated;
4070
4081
  continue;
@@ -4073,7 +4084,9 @@ export class TUI extends Container {
4073
4084
  const key = `${width}\0${normalized}`;
4074
4085
  const cached = this.#lineTruncationCache.get(key);
4075
4086
  if (cached !== undefined) {
4076
- this.#lineEmitWidthCache.set(cached, visibleWidth(cached));
4087
+ if (!this.#lineEmitWidthCache.has(cached)) {
4088
+ this.#lineEmitWidthCache.set(cached, visibleWidth(cached));
4089
+ }
4077
4090
  lines[lineIndex] = cached;
4078
4091
  continue;
4079
4092
  }
@@ -5074,10 +5087,12 @@ export class TUI extends Container {
5074
5087
  }
5075
5088
  const useViewportRepaintPath = this.#viewportRepaintHost();
5076
5089
  const widthReflowRequired =
5090
+ widthChanged &&
5077
5091
  this.#previousWidth > 0 &&
5078
- rawLines.some(
5079
- line => !TERMINAL.isImageLine(line) && visibleWidth(line) > Math.min(this.#previousWidth, width),
5080
- );
5092
+ rawLines.some(line => {
5093
+ TUI.#renderCounters.widthReflowScanRows += 1;
5094
+ return !TERMINAL.isImageLine(line) && visibleWidth(line) > Math.min(this.#previousWidth, width);
5095
+ });
5081
5096
  if (
5082
5097
  widthChanged &&
5083
5098
  !this.#legacyMultiplexerFullRender &&