@gajae-code/tui 0.4.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/dist/types/components/editor.d.ts +2 -0
- package/dist/types/tui.d.ts +6 -0
- package/dist/types/utils.d.ts +1 -0
- package/package.json +3 -3
- package/src/components/editor.ts +116 -24
- package/src/components/markdown.ts +39 -22
- package/src/tui.ts +77 -16
- package/src/utils.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.5.0] - 2026-06-13
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added process-isolated deterministic render-golden capture fixtures and coverage for editor overlays, rich-text resize, multiplexer viewport repaint, sixel preservation, Termux height diffs, and transcript shrink/clear regressions.
|
|
10
|
+
- Added layout/rendering benchmarks for editor layout, markdown rendering, and frame rendering.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Tightened markdown/editor rendering and terminal repaint behavior for the compact tool-block spacing and render-golden stability work.
|
|
15
|
+
|
|
16
|
+
## [0.4.5] - 2026-06-12
|
|
17
|
+
|
|
18
|
+
- Version aligned with the 0.4.5 monorepo release; no functional changes in this package.
|
|
19
|
+
|
|
20
|
+
## [0.4.4] - 2026-06-10
|
|
21
|
+
|
|
22
|
+
- Version aligned with the 0.4.4 monorepo release; no functional changes in this package.
|
|
23
|
+
|
|
5
24
|
## [0.4.3] - 2026-06-10
|
|
6
25
|
|
|
7
26
|
### Fixed
|
|
@@ -78,6 +78,8 @@ export declare class Editor implements Component, Focusable {
|
|
|
78
78
|
invalidate(): void;
|
|
79
79
|
render(width: number): string[];
|
|
80
80
|
handleInput(data: string): void;
|
|
81
|
+
/** Test-only seam: current wrap-cache entry count (memory-bound assertions). */
|
|
82
|
+
get wrappedLineCacheSize(): number;
|
|
81
83
|
getText(): string;
|
|
82
84
|
/**
|
|
83
85
|
* Get text with paste markers expanded to their actual content.
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -159,4 +159,10 @@ export declare class TUI extends Container {
|
|
|
159
159
|
removeInputListener(listener: InputListener): void;
|
|
160
160
|
stop(): void;
|
|
161
161
|
requestRender(force?: boolean, source?: string): void;
|
|
162
|
+
getLineRenderCacheStats(): {
|
|
163
|
+
normalizationSize: number;
|
|
164
|
+
truncationSize: number;
|
|
165
|
+
normalizationLimit: number;
|
|
166
|
+
truncationLimit: number;
|
|
167
|
+
};
|
|
162
168
|
}
|
package/dist/types/utils.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Ellipsis, type ExtractSegmentsResult, type SliceResult } from "@gajae-code/natives";
|
|
2
2
|
export { Ellipsis } from "@gajae-code/natives";
|
|
3
3
|
export { getDefaultTabWidth, getIndentation } from "@gajae-code/utils";
|
|
4
|
+
export declare function isPrintableAscii(text: string): boolean;
|
|
4
5
|
export declare function sliceWithWidth(line: string, startCol: number, length: number, strict?: boolean | null): SliceResult;
|
|
5
6
|
export declare function truncateToWidth(text: string, maxWidth: number, ellipsisKind?: Ellipsis | null, pad?: boolean | null): string;
|
|
6
7
|
export declare function wrapTextWithAnsi(text: string, width: number): string[];
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/tui",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.5.0",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://gaebal-gajae.dev",
|
|
7
7
|
"author": "Yeachan-Heo",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"fmt": "biome format --write ."
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@gajae-code/natives": "0.
|
|
42
|
-
"@gajae-code/utils": "0.
|
|
41
|
+
"@gajae-code/natives": "0.5.0",
|
|
42
|
+
"@gajae-code/utils": "0.5.0",
|
|
43
43
|
"lru-cache": "11.3.6",
|
|
44
44
|
"marked": "^18.0.3"
|
|
45
45
|
},
|
package/src/components/editor.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { type Component, CURSOR_MARKER, type Focusable } from "../tui";
|
|
|
9
9
|
import {
|
|
10
10
|
getSegmenter,
|
|
11
11
|
getWordNavKind,
|
|
12
|
+
isPrintableAscii,
|
|
12
13
|
moveWordLeft,
|
|
13
14
|
moveWordRight,
|
|
14
15
|
padding,
|
|
@@ -53,6 +54,53 @@ interface TextChunk {
|
|
|
53
54
|
endIndex: number;
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
interface WrappedLine {
|
|
58
|
+
chunks: TextChunk[];
|
|
59
|
+
width: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface CachedWrappedLine extends WrappedLine {
|
|
63
|
+
lineRef: string;
|
|
64
|
+
contentWidth: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function wordWrapAsciiLine(line: string, maxWidth: number): TextChunk[] {
|
|
68
|
+
if (line.length <= maxWidth) {
|
|
69
|
+
return [{ text: line, startIndex: 0, endIndex: line.length }];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const chunks: TextChunk[] = [];
|
|
73
|
+
let chunkStart = 0;
|
|
74
|
+
while (chunkStart < line.length) {
|
|
75
|
+
let chunkEnd = Math.min(line.length, chunkStart + maxWidth);
|
|
76
|
+
if (chunkEnd < line.length) {
|
|
77
|
+
let breakAt = -1;
|
|
78
|
+
for (let i = chunkEnd; i > chunkStart; i--) {
|
|
79
|
+
const code = line.charCodeAt(i - 1);
|
|
80
|
+
if (code === 0x20 || code === 0x09) {
|
|
81
|
+
breakAt = i - 1;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (breakAt > chunkStart) chunkEnd = breakAt;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const raw = line.slice(chunkStart, chunkEnd);
|
|
89
|
+
const text = raw.trimEnd();
|
|
90
|
+
if (text || chunks.length === 0) {
|
|
91
|
+
chunks.push({ text, startIndex: chunkStart, endIndex: chunkStart + raw.length });
|
|
92
|
+
}
|
|
93
|
+
chunkStart = chunkEnd;
|
|
94
|
+
while (chunkStart < line.length) {
|
|
95
|
+
const code = line.charCodeAt(chunkStart);
|
|
96
|
+
if (code !== 0x20 && code !== 0x09) break;
|
|
97
|
+
chunkStart++;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return chunks.length > 0 ? chunks : [{ text: "", startIndex: 0, endIndex: 0 }];
|
|
102
|
+
}
|
|
103
|
+
|
|
56
104
|
/**
|
|
57
105
|
* Split a line into word-wrapped chunks.
|
|
58
106
|
* Wraps at word boundaries when possible, falling back to character-level
|
|
@@ -66,6 +114,9 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
|
|
|
66
114
|
if (!line || maxWidth <= 0) {
|
|
67
115
|
return [{ text: "", startIndex: 0, endIndex: 0 }];
|
|
68
116
|
}
|
|
117
|
+
if (isPrintableAscii(line)) {
|
|
118
|
+
return wordWrapAsciiLine(line, maxWidth);
|
|
119
|
+
}
|
|
69
120
|
|
|
70
121
|
const lineWidth = visibleWidth(line);
|
|
71
122
|
if (lineWidth <= maxWidth) {
|
|
@@ -349,6 +400,7 @@ export class Editor implements Component, Focusable {
|
|
|
349
400
|
#paddingXOverride: number | undefined;
|
|
350
401
|
#maxHeight?: number;
|
|
351
402
|
#scrollOffset: number = 0;
|
|
403
|
+
#wrappedLineCache: CachedWrappedLine[] = [];
|
|
352
404
|
|
|
353
405
|
// Emacs-style kill ring
|
|
354
406
|
#killRing = new KillRing();
|
|
@@ -566,10 +618,11 @@ export class Editor implements Component, Focusable {
|
|
|
566
618
|
if (this.onChange) {
|
|
567
619
|
this.onChange(this.getText());
|
|
568
620
|
}
|
|
621
|
+
this.#wrappedLineCache.length = 0;
|
|
569
622
|
}
|
|
570
623
|
|
|
571
624
|
invalidate(): void {
|
|
572
|
-
|
|
625
|
+
this.#wrappedLineCache.length = 0;
|
|
573
626
|
}
|
|
574
627
|
|
|
575
628
|
#getEditorPaddingX(): number {
|
|
@@ -692,14 +745,18 @@ export class Editor implements Component, Focusable {
|
|
|
692
745
|
return Math.max(1, visibleHeight - 1);
|
|
693
746
|
}
|
|
694
747
|
|
|
695
|
-
#
|
|
748
|
+
#findCurrentLayoutLine(layoutLines: LayoutLine[]): number {
|
|
749
|
+
const index = layoutLines.findIndex(line => line.hasCursor);
|
|
750
|
+
return index === -1 ? Math.max(0, layoutLines.length - 1) : index;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
#updateScrollOffset(layoutLines: LayoutLine[], visibleHeight: number): void {
|
|
696
754
|
if (layoutLines.length <= visibleHeight) {
|
|
697
755
|
this.#scrollOffset = 0;
|
|
698
756
|
return;
|
|
699
757
|
}
|
|
700
758
|
|
|
701
|
-
const
|
|
702
|
-
const cursorLine = this.#findCurrentVisualLine(visualLines);
|
|
759
|
+
const cursorLine = this.#findCurrentLayoutLine(layoutLines);
|
|
703
760
|
if (cursorLine < this.#scrollOffset) {
|
|
704
761
|
this.#scrollOffset = cursorLine;
|
|
705
762
|
} else if (cursorLine >= this.#scrollOffset + visibleHeight) {
|
|
@@ -730,7 +787,7 @@ export class Editor implements Component, Focusable {
|
|
|
730
787
|
// Layout the text
|
|
731
788
|
const layoutLines = this.#layoutText(layoutWidth);
|
|
732
789
|
const visibleContentHeight = this.#getVisibleContentHeight(layoutLines.length);
|
|
733
|
-
this.#updateScrollOffset(
|
|
790
|
+
this.#updateScrollOffset(layoutLines, visibleContentHeight);
|
|
734
791
|
const visibleLayoutLines = layoutLines.slice(this.#scrollOffset, this.#scrollOffset + visibleContentHeight);
|
|
735
792
|
|
|
736
793
|
const result: string[] = [];
|
|
@@ -1321,11 +1378,34 @@ export class Editor implements Component, Focusable {
|
|
|
1321
1378
|
}
|
|
1322
1379
|
}
|
|
1323
1380
|
|
|
1381
|
+
#getWrappedLine(lineIndex: number, contentWidth: number): WrappedLine {
|
|
1382
|
+
const line = this.#state.lines[lineIndex] || "";
|
|
1383
|
+
const cached = this.#wrappedLineCache[lineIndex];
|
|
1384
|
+
if (cached?.lineRef === line && cached.contentWidth === contentWidth) return cached;
|
|
1385
|
+
|
|
1386
|
+
const width = isPrintableAscii(line) ? line.length : visibleWidth(line);
|
|
1387
|
+
const chunks =
|
|
1388
|
+
width <= contentWidth
|
|
1389
|
+
? [{ text: line, startIndex: 0, endIndex: line.length }]
|
|
1390
|
+
: wordWrapLine(line, contentWidth);
|
|
1391
|
+
const wrapped = { lineRef: line, contentWidth, width, chunks };
|
|
1392
|
+
this.#wrappedLineCache[lineIndex] = wrapped;
|
|
1393
|
+
return wrapped;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/** Test-only seam: current wrap-cache entry count (memory-bound assertions). */
|
|
1397
|
+
get wrappedLineCacheSize(): number {
|
|
1398
|
+
return this.#wrappedLineCache.length;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1324
1401
|
#layoutText(contentWidth: number): LayoutLine[] {
|
|
1325
1402
|
const layoutLines: LayoutLine[] = [];
|
|
1326
1403
|
|
|
1327
1404
|
if (this.#state.lines.length === 0 || (this.#state.lines.length === 1 && this.#state.lines[0] === "")) {
|
|
1328
|
-
// Empty editor
|
|
1405
|
+
// Empty editor — keep the wrap cache bounded by document size like
|
|
1406
|
+
// the non-empty path below (stale entries from a previously large
|
|
1407
|
+
// buffer must not be retained).
|
|
1408
|
+
this.#wrappedLineCache.length = this.#state.lines.length;
|
|
1329
1409
|
layoutLines.push({
|
|
1330
1410
|
text: "",
|
|
1331
1411
|
hasCursor: true,
|
|
@@ -1338,9 +1418,9 @@ export class Editor implements Component, Focusable {
|
|
|
1338
1418
|
for (let i = 0; i < this.#state.lines.length; i++) {
|
|
1339
1419
|
const line = this.#state.lines[i] || "";
|
|
1340
1420
|
const isCurrentLine = i === this.#state.cursorLine;
|
|
1341
|
-
const
|
|
1421
|
+
const wrappedLine = this.#getWrappedLine(i, contentWidth);
|
|
1342
1422
|
|
|
1343
|
-
if (
|
|
1423
|
+
if (wrappedLine.width <= contentWidth) {
|
|
1344
1424
|
// Line fits in one layout line
|
|
1345
1425
|
if (isCurrentLine) {
|
|
1346
1426
|
layoutLines.push({
|
|
@@ -1356,7 +1436,7 @@ export class Editor implements Component, Focusable {
|
|
|
1356
1436
|
}
|
|
1357
1437
|
} else {
|
|
1358
1438
|
// Line needs wrapping - use word-aware wrapping
|
|
1359
|
-
const chunks =
|
|
1439
|
+
const chunks = wrappedLine.chunks;
|
|
1360
1440
|
|
|
1361
1441
|
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
|
1362
1442
|
const chunk = chunks[chunkIndex];
|
|
@@ -1406,6 +1486,7 @@ export class Editor implements Component, Focusable {
|
|
|
1406
1486
|
}
|
|
1407
1487
|
}
|
|
1408
1488
|
|
|
1489
|
+
this.#wrappedLineCache.length = this.#state.lines.length;
|
|
1409
1490
|
return layoutLines;
|
|
1410
1491
|
}
|
|
1411
1492
|
|
|
@@ -1749,6 +1830,7 @@ export class Editor implements Component, Focusable {
|
|
|
1749
1830
|
this.#historyIndex = -1;
|
|
1750
1831
|
this.#scrollOffset = 0;
|
|
1751
1832
|
this.#undoStack.length = 0;
|
|
1833
|
+
this.#wrappedLineCache.length = 0;
|
|
1752
1834
|
|
|
1753
1835
|
if (this.onChange) this.onChange("");
|
|
1754
1836
|
if (this.onSubmit) this.onSubmit(result);
|
|
@@ -2294,15 +2376,15 @@ export class Editor implements Component, Focusable {
|
|
|
2294
2376
|
|
|
2295
2377
|
for (let i = 0; i < this.#state.lines.length; i++) {
|
|
2296
2378
|
const line = this.#state.lines[i] || "";
|
|
2297
|
-
const
|
|
2379
|
+
const wrappedLine = this.#getWrappedLine(i, width);
|
|
2298
2380
|
if (line.length === 0) {
|
|
2299
2381
|
// Empty line still takes one visual line
|
|
2300
2382
|
visualLines.push({ logicalLine: i, startCol: 0, length: 0 });
|
|
2301
|
-
} else if (
|
|
2383
|
+
} else if (wrappedLine.width <= width) {
|
|
2302
2384
|
visualLines.push({ logicalLine: i, startCol: 0, length: line.length });
|
|
2303
2385
|
} else {
|
|
2304
2386
|
// Line needs wrapping - use word-aware wrapping
|
|
2305
|
-
const chunks =
|
|
2387
|
+
const chunks = wrappedLine.chunks;
|
|
2306
2388
|
for (const chunk of chunks) {
|
|
2307
2389
|
visualLines.push({
|
|
2308
2390
|
logicalLine: i,
|
|
@@ -2340,8 +2422,10 @@ export class Editor implements Component, Focusable {
|
|
|
2340
2422
|
|
|
2341
2423
|
#moveCursor(deltaLine: number, deltaCol: number): void {
|
|
2342
2424
|
this.#resetKillSequence();
|
|
2343
|
-
const
|
|
2344
|
-
const
|
|
2425
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
2426
|
+
const needsVisualLines = deltaLine !== 0 || (deltaCol > 0 && this.#state.cursorCol >= currentLine.length);
|
|
2427
|
+
const visualLines = needsVisualLines ? this.#buildVisualLineMap(this.#lastLayoutWidth) : [];
|
|
2428
|
+
const currentVisualLine = needsVisualLines ? this.#findCurrentVisualLine(visualLines) : -1;
|
|
2345
2429
|
|
|
2346
2430
|
if (deltaLine !== 0) {
|
|
2347
2431
|
const targetVisualLine = currentVisualLine + deltaLine;
|
|
@@ -2352,15 +2436,18 @@ export class Editor implements Component, Focusable {
|
|
|
2352
2436
|
}
|
|
2353
2437
|
|
|
2354
2438
|
if (deltaCol !== 0) {
|
|
2355
|
-
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
2356
|
-
|
|
2357
2439
|
if (deltaCol > 0) {
|
|
2358
2440
|
// Moving right - move by one grapheme (handles emojis, combining characters, etc.)
|
|
2359
2441
|
if (this.#state.cursorCol < currentLine.length) {
|
|
2360
|
-
const
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2442
|
+
const charCode = currentLine.charCodeAt(this.#state.cursorCol);
|
|
2443
|
+
if (charCode >= 0x20 && charCode <= 0x7e) {
|
|
2444
|
+
this.#setCursorCol(this.#state.cursorCol + 1);
|
|
2445
|
+
} else {
|
|
2446
|
+
const afterCursor = currentLine.slice(this.#state.cursorCol);
|
|
2447
|
+
const graphemes = [...segmenter.segment(afterCursor)];
|
|
2448
|
+
const firstGrapheme = graphemes[0];
|
|
2449
|
+
this.#setCursorCol(this.#state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
|
|
2450
|
+
}
|
|
2364
2451
|
} else if (this.#state.cursorLine < this.#state.lines.length - 1) {
|
|
2365
2452
|
// Wrap to start of next logical line
|
|
2366
2453
|
this.#state.cursorLine++;
|
|
@@ -2375,10 +2462,15 @@ export class Editor implements Component, Focusable {
|
|
|
2375
2462
|
} else {
|
|
2376
2463
|
// Moving left - move by one grapheme (handles emojis, combining characters, etc.)
|
|
2377
2464
|
if (this.#state.cursorCol > 0) {
|
|
2378
|
-
const
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2465
|
+
const charCode = currentLine.charCodeAt(this.#state.cursorCol - 1);
|
|
2466
|
+
if (charCode >= 0x20 && charCode <= 0x7e) {
|
|
2467
|
+
this.#setCursorCol(this.#state.cursorCol - 1);
|
|
2468
|
+
} else {
|
|
2469
|
+
const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
2470
|
+
const graphemes = [...segmenter.segment(beforeCursor)];
|
|
2471
|
+
const lastGrapheme = graphemes[graphemes.length - 1];
|
|
2472
|
+
this.#setCursorCol(this.#state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
|
|
2473
|
+
}
|
|
2382
2474
|
} else if (this.#state.cursorLine > 0) {
|
|
2383
2475
|
// Wrap to end of previous logical line
|
|
2384
2476
|
this.#state.cursorLine--;
|
|
@@ -39,11 +39,25 @@ markdownParser.setOptions({
|
|
|
39
39
|
// (Rust FFI) work for content/layout combinations already seen this session.
|
|
40
40
|
|
|
41
41
|
const RENDER_CACHE_MAX = 256; // sane cap: ~256 distinct message × width combos
|
|
42
|
-
const renderCache = new LRUCache<string, string[]>({ max: RENDER_CACHE_MAX });
|
|
42
|
+
const renderCache = new LRUCache<string, { source: string; lines: string[] }>({ max: RENDER_CACHE_MAX });
|
|
43
|
+
const PARSE_CACHE_MAX = 128;
|
|
44
|
+
const parseCache = new LRUCache<string, { source: string; tokens: Token[] }>({ max: PARSE_CACHE_MAX });
|
|
45
|
+
|
|
46
|
+
// Full-content 64-bit wyhash over every byte (no lossy sampling). Cache hits
|
|
47
|
+
// additionally verify entry.source against the normalized text, so even a
|
|
48
|
+
// hash collision can never return another message's render.
|
|
49
|
+
function markdownContentKey(text: string): string {
|
|
50
|
+
return `${text.length}:${Bun.hash(text).toString(36)}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function wrapTextIfNeeded(line: string, width: number): string[] {
|
|
54
|
+
return wrapTextWithAnsi(line, width);
|
|
55
|
+
}
|
|
43
56
|
|
|
44
57
|
/** Drop all L2 cache entries. Call on theme change to prevent stale styled output. */
|
|
45
58
|
export function clearRenderCache(): void {
|
|
46
59
|
renderCache.clear();
|
|
60
|
+
parseCache.clear();
|
|
47
61
|
}
|
|
48
62
|
|
|
49
63
|
// Stable numeric IDs for structural theme/style objects (no ID field on type).
|
|
@@ -195,32 +209,36 @@ export class Markdown implements Component {
|
|
|
195
209
|
// Replace tabs with 3 spaces for consistent rendering
|
|
196
210
|
const normalizedText = replaceTabs(this.#text);
|
|
197
211
|
|
|
212
|
+
const contentKey = markdownContentKey(normalizedText);
|
|
213
|
+
|
|
198
214
|
// L2: module-level LRU — survives component disposal/recreation across
|
|
199
215
|
// session-tree navigations. Key encodes every dimension that affects the
|
|
200
|
-
// render output so different configurations never collide.
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
// caller-supplied theme/bgColor functions that mutate their output without
|
|
204
|
-
// changing object identity invalidate the cache entry.
|
|
205
|
-
// bgColor probe uses \x01 (single non-printable byte): chalk/ANSI wrappers
|
|
206
|
-
// pass arbitrary bytes through verbatim, so this is safe and minimizes the
|
|
207
|
-
// risk of clashing with a function that returns text verbatim.
|
|
208
|
-
// theme.heading is used as the representative theme probe — it's required
|
|
209
|
-
// by MarkdownTheme and is one of the most styling-sensitive entries.
|
|
216
|
+
// render output so different configurations never collide. The markdown
|
|
217
|
+
// content dimension is a full-content hash; entries store the source text
|
|
218
|
+
// and verify it on hit so hash collisions can never serve wrong output.
|
|
210
219
|
const bgColorProbe = this.#defaultTextStyle?.bgColor ? this.#defaultTextStyle.bgColor("\x01") : "";
|
|
211
220
|
const headingProbe = this.#theme.heading("");
|
|
212
|
-
const cacheKey = `${
|
|
221
|
+
const cacheKey = `${contentKey}\x00${width}\x00${this.#paddingX}\x00${this.#paddingY}\x00${this.#codeBlockIndent}\x00${objectId(this.#theme)}\x00${this.#defaultTextStyle ? objectId(this.#defaultTextStyle) : -1}\x00${TERMINAL.imageProtocol ?? ""}\x00${TERMINAL.hyperlinks ? 1 : 0}\x00${bgColorProbe}\x00${headingProbe}`;
|
|
213
222
|
const cached = renderCache.get(cacheKey);
|
|
214
|
-
if (cached !== undefined) {
|
|
223
|
+
if (cached !== undefined && cached.source === normalizedText) {
|
|
215
224
|
// Populate L1 so subsequent calls from this instance are O(1) map lookup.
|
|
216
225
|
this.#cachedText = this.#text;
|
|
217
226
|
this.#cachedWidth = width;
|
|
218
|
-
this.#cachedLines = cached;
|
|
219
|
-
return cached;
|
|
227
|
+
this.#cachedLines = cached.lines;
|
|
228
|
+
return cached.lines;
|
|
220
229
|
}
|
|
221
230
|
|
|
222
|
-
// Parse markdown to
|
|
223
|
-
|
|
231
|
+
// Parse markdown to marked tokens. Parse cache is width/theme independent,
|
|
232
|
+
// so the same content can be reused across resize/layout renders even when
|
|
233
|
+
// final wrapped output must differ by width.
|
|
234
|
+
const cachedParse = parseCache.get(contentKey);
|
|
235
|
+
let tokens: Token[];
|
|
236
|
+
if (cachedParse !== undefined && cachedParse.source === normalizedText) {
|
|
237
|
+
tokens = cachedParse.tokens;
|
|
238
|
+
} else {
|
|
239
|
+
tokens = markdownParser.lexer(normalizedText);
|
|
240
|
+
parseCache.set(contentKey, { source: normalizedText, tokens });
|
|
241
|
+
}
|
|
224
242
|
|
|
225
243
|
// Convert tokens to styled terminal output
|
|
226
244
|
const renderedLines: string[] = [];
|
|
@@ -235,11 +253,10 @@ export class Markdown implements Component {
|
|
|
235
253
|
// Wrap lines (NO padding, NO background yet)
|
|
236
254
|
const wrappedLines: string[] = [];
|
|
237
255
|
for (const line of renderedLines) {
|
|
238
|
-
// Skip wrapping for image protocol lines (would corrupt escape sequences)
|
|
239
256
|
if (TERMINAL.isImageLine(line)) {
|
|
240
257
|
wrappedLines.push(line);
|
|
241
258
|
} else {
|
|
242
|
-
wrappedLines.push(...
|
|
259
|
+
wrappedLines.push(...wrapTextIfNeeded(line, contentWidth));
|
|
243
260
|
}
|
|
244
261
|
}
|
|
245
262
|
|
|
@@ -287,7 +304,7 @@ export class Markdown implements Component {
|
|
|
287
304
|
|
|
288
305
|
// Update L2 module-level LRU so future instances with the same key skip
|
|
289
306
|
// the marked.lexer + highlightCode (Rust FFI) work entirely.
|
|
290
|
-
renderCache.set(cacheKey, result);
|
|
307
|
+
renderCache.set(cacheKey, { source: normalizedText, lines: result });
|
|
291
308
|
|
|
292
309
|
return result;
|
|
293
310
|
}
|
|
@@ -495,7 +512,7 @@ export class Markdown implements Component {
|
|
|
495
512
|
|
|
496
513
|
for (const quoteLine of renderedQuoteLines) {
|
|
497
514
|
const styledLine = applyQuoteStyle(quoteLine);
|
|
498
|
-
const wrappedLines =
|
|
515
|
+
const wrappedLines = wrapTextIfNeeded(styledLine, quoteContentWidth);
|
|
499
516
|
for (const wrappedLine of wrappedLines) {
|
|
500
517
|
lines.push(this.#theme.quoteBorder(`${this.#theme.symbols.quoteBorder} `) + wrappedLine);
|
|
501
518
|
}
|
|
@@ -755,7 +772,7 @@ export class Markdown implements Component {
|
|
|
755
772
|
* consistently with the rest of the renderer.
|
|
756
773
|
*/
|
|
757
774
|
#wrapCellText(text: string, maxWidth: number): string[] {
|
|
758
|
-
return
|
|
775
|
+
return wrapTextIfNeeded(text, Math.max(1, maxWidth));
|
|
759
776
|
}
|
|
760
777
|
|
|
761
778
|
/**
|
package/src/tui.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { ImageProtocol, setCellDimensions, setTerminalImageProtocol, TERMINAL }
|
|
|
12
12
|
import {
|
|
13
13
|
Ellipsis,
|
|
14
14
|
extractSegments,
|
|
15
|
+
isPrintableAscii,
|
|
15
16
|
normalizeTerminalOutput,
|
|
16
17
|
sliceByColumn,
|
|
17
18
|
sliceWithWidth,
|
|
@@ -227,12 +228,21 @@ export class Container implements Component {
|
|
|
227
228
|
}
|
|
228
229
|
}
|
|
229
230
|
|
|
231
|
+
type LineNormalizationCacheEntry = {
|
|
232
|
+
normalized: string;
|
|
233
|
+
terminated: string;
|
|
234
|
+
};
|
|
235
|
+
|
|
230
236
|
/**
|
|
231
237
|
* TUI - Main class for managing terminal UI with differential rendering
|
|
232
238
|
*/
|
|
233
239
|
export class TUI extends Container {
|
|
234
240
|
terminal: Terminal;
|
|
235
241
|
#previousLines: string[] = [];
|
|
242
|
+
#lineNormalizationCache = new Map<string, LineNormalizationCacheEntry>();
|
|
243
|
+
#lineTruncationCache = new Map<string, string>();
|
|
244
|
+
#lineNormalizationCacheLimit = 0;
|
|
245
|
+
#lineTruncationCacheLimit = 0;
|
|
236
246
|
#previousWidth = 0;
|
|
237
247
|
#previousHeight = 0;
|
|
238
248
|
#focusedComponent: Component | null = null;
|
|
@@ -648,6 +658,8 @@ export class TUI extends Container {
|
|
|
648
658
|
// focus/listener state is intentionally preserved so input routing survives
|
|
649
659
|
// a resume.
|
|
650
660
|
this.#previousLines = [];
|
|
661
|
+
this.#lineNormalizationCache.clear();
|
|
662
|
+
this.#lineTruncationCache.clear();
|
|
651
663
|
this.#previousWidth = 0;
|
|
652
664
|
this.#previousHeight = 0;
|
|
653
665
|
}
|
|
@@ -660,8 +672,12 @@ export class TUI extends Container {
|
|
|
660
672
|
if (renderMetrics.enabled) renderMetrics.recordRequest(source);
|
|
661
673
|
if (force) {
|
|
662
674
|
this.#previousLines = [];
|
|
675
|
+
this.#lineNormalizationCache.clear();
|
|
676
|
+
this.#lineTruncationCache.clear();
|
|
663
677
|
this.#previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
|
|
664
678
|
this.#previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
|
|
679
|
+
this.#lineNormalizationCacheLimit = 0;
|
|
680
|
+
this.#lineTruncationCacheLimit = 0;
|
|
665
681
|
this.#cursorRow = 0;
|
|
666
682
|
this.#hardwareCursorRow = 0;
|
|
667
683
|
this.#viewportTopRow = 0;
|
|
@@ -1092,25 +1108,71 @@ export class TUI extends Container {
|
|
|
1092
1108
|
* written to the terminal — without this, the diff cache disagrees with
|
|
1093
1109
|
* emitted output and OSC 8 hyperlink state can leak across lines.
|
|
1094
1110
|
*/
|
|
1095
|
-
#
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1111
|
+
#normalizeLineForRender(line: string): LineNormalizationCacheEntry {
|
|
1112
|
+
const cached = this.#lineNormalizationCache.get(line);
|
|
1113
|
+
if (cached !== undefined) return cached;
|
|
1114
|
+
const normalized = normalizeTerminalOutput(line);
|
|
1115
|
+
const terminated = normalized + (normalized.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
|
|
1116
|
+
this.#lineNormalizationCache.set(line, { normalized, terminated });
|
|
1117
|
+
return { normalized, terminated };
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
#lineFitsWidth(normalizedLine: string, width: number): boolean {
|
|
1121
|
+
return isPrintableAscii(normalizedLine) && normalizedLine.length <= width
|
|
1122
|
+
? true
|
|
1123
|
+
: visibleWidth(normalizedLine) <= width;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
#truncateNormalizedLine(normalizedLine: string, width: number): string {
|
|
1127
|
+
const key = `${width}\0${normalizedLine}`;
|
|
1128
|
+
const cached = this.#lineTruncationCache.get(key);
|
|
1129
|
+
if (cached !== undefined) return cached;
|
|
1130
|
+
const truncated = truncateToWidth(normalizedLine, width, Ellipsis.Omit);
|
|
1131
|
+
const terminated = truncated + (truncated.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
|
|
1132
|
+
this.#lineTruncationCache.set(key, terminated);
|
|
1133
|
+
return terminated;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
#trimLineCachesForRender(lineCount: number): void {
|
|
1137
|
+
const limit = Math.max(1, lineCount * 2);
|
|
1138
|
+
this.#lineNormalizationCacheLimit = limit;
|
|
1139
|
+
this.#lineTruncationCacheLimit = limit;
|
|
1140
|
+
while (this.#lineNormalizationCache.size > limit) {
|
|
1141
|
+
const key = this.#lineNormalizationCache.keys().next().value;
|
|
1142
|
+
if (key === undefined) break;
|
|
1143
|
+
this.#lineNormalizationCache.delete(key);
|
|
1144
|
+
}
|
|
1145
|
+
while (this.#lineTruncationCache.size > limit) {
|
|
1146
|
+
const key = this.#lineTruncationCache.keys().next().value;
|
|
1147
|
+
if (key === undefined) break;
|
|
1148
|
+
this.#lineTruncationCache.delete(key);
|
|
1104
1149
|
}
|
|
1105
|
-
return lines;
|
|
1106
1150
|
}
|
|
1107
|
-
|
|
1151
|
+
|
|
1152
|
+
getLineRenderCacheStats(): {
|
|
1153
|
+
normalizationSize: number;
|
|
1154
|
+
truncationSize: number;
|
|
1155
|
+
normalizationLimit: number;
|
|
1156
|
+
truncationLimit: number;
|
|
1157
|
+
} {
|
|
1158
|
+
return {
|
|
1159
|
+
normalizationSize: this.#lineNormalizationCache.size,
|
|
1160
|
+
truncationSize: this.#lineTruncationCache.size,
|
|
1161
|
+
normalizationLimit: this.#lineNormalizationCacheLimit,
|
|
1162
|
+
truncationLimit: this.#lineTruncationCacheLimit,
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
#applyLineResetsAndTruncate(lines: string[], width: number): string[] {
|
|
1108
1167
|
for (let i = 0; i < lines.length; i++) {
|
|
1109
1168
|
const line = lines[i];
|
|
1110
|
-
if (TERMINAL.isImageLine(line)
|
|
1111
|
-
const
|
|
1112
|
-
lines[i] =
|
|
1169
|
+
if (TERMINAL.isImageLine(line)) continue;
|
|
1170
|
+
const { normalized, terminated } = this.#normalizeLineForRender(line);
|
|
1171
|
+
lines[i] = this.#lineFitsWidth(normalized, width)
|
|
1172
|
+
? terminated
|
|
1173
|
+
: this.#truncateNormalizedLine(normalized, width);
|
|
1113
1174
|
}
|
|
1175
|
+
this.#trimLineCachesForRender(lines.length);
|
|
1114
1176
|
return lines;
|
|
1115
1177
|
}
|
|
1116
1178
|
|
|
@@ -1144,8 +1206,7 @@ export class TUI extends Container {
|
|
|
1144
1206
|
// (closes SGR + OSC 8 hyperlink state). Must run after cursor extraction
|
|
1145
1207
|
// because the marker is embedded mid-line, and before any diff/full render
|
|
1146
1208
|
// path so cache comparisons stay byte-accurate.
|
|
1147
|
-
newLines = this.#
|
|
1148
|
-
newLines = this.#truncateLinesToWidth(newLines, width);
|
|
1209
|
+
newLines = this.#applyLineResetsAndTruncate(newLines, width);
|
|
1149
1210
|
|
|
1150
1211
|
// Width changed - need full re-render (line wrapping changes)
|
|
1151
1212
|
const widthChanged = this.#previousWidth !== 0 && this.#previousWidth !== width;
|
package/src/utils.ts
CHANGED
|
@@ -24,6 +24,14 @@ function recordTextHelper<T>(name: string, fn: () => T): T {
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
export function isPrintableAscii(text: string): boolean {
|
|
28
|
+
for (let i = 0; i < text.length; i++) {
|
|
29
|
+
const code = text.charCodeAt(i);
|
|
30
|
+
if (code < 0x20 || code > 0x7e) return false;
|
|
31
|
+
}
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
export function sliceWithWidth(line: string, startCol: number, length: number, strict?: boolean | null): SliceResult {
|
|
28
36
|
return nativeSliceWithWidth(line, startCol, length, strict ?? null, getDefaultTabWidth());
|
|
29
37
|
}
|