@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 +15 -0
- package/dist/types/components/markdown.d.ts +15 -0
- package/dist/types/tui.d.ts +1 -0
- package/package.json +3 -3
- package/src/components/box.ts +3 -3
- package/src/components/editor.ts +3 -6
- package/src/components/input.ts +3 -6
- package/src/components/markdown.ts +173 -39
- package/src/components/select-list.ts +6 -3
- package/src/tui.ts +31 -16
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.
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -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
|
+
"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.
|
|
40
|
-
"@gajae-code/utils": "0.16.
|
|
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
|
},
|
package/src/components/box.ts
CHANGED
|
@@ -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
|
}
|
package/src/components/editor.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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) {
|
package/src/components/input.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
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[] }>({
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
174
|
+
export interface MarkdownCacheStats {
|
|
175
|
+
count: number;
|
|
176
|
+
accountedSize: number;
|
|
177
|
+
max: number;
|
|
178
|
+
maxSize: number;
|
|
179
|
+
maxEntrySize: number;
|
|
85
180
|
}
|
|
86
181
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
|
463
|
+
if (code.charCodeAt(i) === 10 && ++newlines >= MAX_HIGHLIGHT_LINES) return true;
|
|
352
464
|
}
|
|
353
|
-
|
|
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
|
-
//
|
|
434
|
-
const
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
176
|
-
sanitized += bytes.slice(
|
|
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
|
-
|
|
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
|
|
4064
|
-
const
|
|
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.
|
|
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
|
-
|
|
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 &&
|