@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 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.
@@ -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
  }
@@ -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.4",
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.4.4",
42
- "@gajae-code/utils": "0.4.4",
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
  },
@@ -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
- // No cached state to invalidate currently
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
- #updateScrollOffset(layoutWidth: number, layoutLines: LayoutLine[], visibleHeight: number): void {
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 visualLines = this.#buildVisualLineMap(layoutWidth);
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(layoutWidth, layoutLines, visibleContentHeight);
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 lineVisibleWidth = visibleWidth(line);
1421
+ const wrappedLine = this.#getWrappedLine(i, contentWidth);
1342
1422
 
1343
- if (lineVisibleWidth <= contentWidth) {
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 = wordWrapLine(line, contentWidth);
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 lineVisWidth = visibleWidth(line);
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 (lineVisWidth <= width) {
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 = wordWrapLine(line, width);
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 visualLines = this.#buildVisualLineMap(this.#lastLayoutWidth);
2344
- const currentVisualLine = this.#findCurrentVisualLine(visualLines);
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 afterCursor = currentLine.slice(this.#state.cursorCol);
2361
- const graphemes = [...segmenter.segment(afterCursor)];
2362
- const firstGrapheme = graphemes[0];
2363
- this.#setCursorCol(this.#state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
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 beforeCursor = currentLine.slice(0, this.#state.cursorCol);
2379
- const graphemes = [...segmenter.segment(beforeCursor)];
2380
- const lastGrapheme = graphemes[graphemes.length - 1];
2381
- this.#setCursorCol(this.#state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
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
- // Encode terminal capability state and theme/style function output samples
202
- // so that capability shifts (image protocol changes, hyperlink toggle) or
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 = `${normalizedText}\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}`;
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 HTML-like tokens
223
- const tokens = markdownParser.lexer(normalizedText);
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(...wrapTextWithAnsi(line, contentWidth));
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 = wrapTextWithAnsi(styledLine, quoteContentWidth);
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 wrapTextWithAnsi(text, Math.max(1, maxWidth));
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
- #applyLineResets(lines: string[]): string[] {
1096
- for (let i = 0; i < lines.length; i++) {
1097
- const line = lines[i];
1098
- if (TERMINAL.isImageLine(line)) continue;
1099
- const normalized = normalizeTerminalOutput(line);
1100
- // Only close OSC 8 hyperlinks when the line actually opened one;
1101
- // emitting `\x1b]8;;\x07` on every line just feeds the terminal's OSC
1102
- // parser for no reason (measurable cost in xterm.js parse loop).
1103
- lines[i] = normalized + (normalized.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
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
- #truncateLinesToWidth(lines: string[], width: number): string[] {
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) || visibleWidth(line) <= width) continue;
1111
- const truncated = truncateToWidth(line, width, Ellipsis.Omit);
1112
- lines[i] = truncated + (truncated.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
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.#applyLineResets(newLines);
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
  }