@gajae-code/tui 0.4.5 → 0.5.1

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,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.5.1] - 2026-06-14
6
+
7
+ ### Fixed
8
+
9
+ - Fixed Korean/Hangul input composition breaking in Android Termius, where typing `안녕하세요` produced duplicated jamo/syllable residue (`ㅇ아안ㄴ녀녕…`). GJC's startup keyboard reprogramming (Kitty keyboard protocol query `CSI ? u` / push `CSI > 7 u`, and the xterm modifyOtherKeys fallback `CSI > 4 ; 2 m`) disrupts the Android IME's syllable composition. Added a `GJC_TUI_KEYBOARD_PROTOCOL` opt-out (enabled by default): set `GJC_TUI_KEYBOARD_PROTOCOL=0` to leave the keyboard in its default mode so IME composition works, matching terminals/TUIs that never enable these enhanced input modes.
10
+
11
+ ## [0.5.0] - 2026-06-13
12
+
13
+ ### Added
14
+
15
+ - 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.
16
+ - Added layout/rendering benchmarks for editor layout, markdown rendering, and frame rendering.
17
+
18
+ ### Changed
19
+
20
+ - Tightened markdown/editor rendering and terminal repaint behavior for the compact tool-block spacing and render-golden stability work.
21
+
5
22
  ## [0.4.5] - 2026-06-12
6
23
 
7
24
  - Version aligned with the 0.4.5 monorepo release; no functional changes in this package.
@@ -41,6 +41,9 @@ export declare class Editor implements Component, Focusable {
41
41
  disableSubmit: boolean;
42
42
  constructor(theme: EditorTheme);
43
43
  setAutocompleteProvider(provider: AutocompleteProvider): void;
44
+ getAutocompleteProvider(): AutocompleteProvider | undefined;
45
+ /** Whether the autocomplete dropdown is currently open. */
46
+ isAutocompleteOpen(): boolean;
44
47
  /**
45
48
  * Set custom content for the top border (e.g., status line).
46
49
  * Pass undefined to use the default plain border.
@@ -78,6 +81,8 @@ export declare class Editor implements Component, Focusable {
78
81
  invalidate(): void;
79
82
  render(width: number): string[];
80
83
  handleInput(data: string): void;
84
+ /** Test-only seam: current wrap-cache entry count (memory-bound assertions). */
85
+ get wrappedLineCacheSize(): number;
81
86
  getText(): string;
82
87
  /**
83
88
  * Get text with paste markers expanded to their actual content.
@@ -1,3 +1,16 @@
1
+ /**
2
+ * Whether GJC may reprogram the keyboard with enhanced input protocols
3
+ * (the Kitty keyboard protocol and the xterm modifyOtherKeys fallback).
4
+ *
5
+ * Enabled by default. Set `GJC_TUI_KEYBOARD_PROTOCOL=0` to leave the keyboard in
6
+ * its default mode. Some terminals — notably Android Termius — break IME
7
+ * composition (e.g. Korean/Hangul syllable composition) while these enhanced
8
+ * modes are active, committing every intermediate composing jamo/syllable
9
+ * instead of only the final character. Disabling the protocol restores normal
10
+ * IME behavior, matching how other TUIs that leave the keyboard untouched render
11
+ * Korean correctly.
12
+ */
13
+ export declare function keyboardEnhancementEnabled(): boolean;
1
14
  /**
2
15
  * Emergency terminal restore - call this from signal/crash handlers
3
16
  * Resets terminal state without requiring access to the ProcessTerminal instance
@@ -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.5",
4
+ "version": "0.5.1",
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.5",
42
- "@gajae-code/utils": "0.4.5",
41
+ "@gajae-code/natives": "0.5.1",
42
+ "@gajae-code/utils": "0.5.1",
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();
@@ -412,6 +464,15 @@ export class Editor implements Component, Focusable {
412
464
  this.#autocompleteProvider = provider;
413
465
  }
414
466
 
467
+ getAutocompleteProvider(): AutocompleteProvider | undefined {
468
+ return this.#autocompleteProvider;
469
+ }
470
+
471
+ /** Whether the autocomplete dropdown is currently open. */
472
+ isAutocompleteOpen(): boolean {
473
+ return this.#autocompleteState !== null && this.#autocompleteList !== undefined;
474
+ }
475
+
415
476
  /**
416
477
  * Set custom content for the top border (e.g., status line).
417
478
  * Pass undefined to use the default plain border.
@@ -566,10 +627,11 @@ export class Editor implements Component, Focusable {
566
627
  if (this.onChange) {
567
628
  this.onChange(this.getText());
568
629
  }
630
+ this.#wrappedLineCache.length = 0;
569
631
  }
570
632
 
571
633
  invalidate(): void {
572
- // No cached state to invalidate currently
634
+ this.#wrappedLineCache.length = 0;
573
635
  }
574
636
 
575
637
  #getEditorPaddingX(): number {
@@ -692,14 +754,18 @@ export class Editor implements Component, Focusable {
692
754
  return Math.max(1, visibleHeight - 1);
693
755
  }
694
756
 
695
- #updateScrollOffset(layoutWidth: number, layoutLines: LayoutLine[], visibleHeight: number): void {
757
+ #findCurrentLayoutLine(layoutLines: LayoutLine[]): number {
758
+ const index = layoutLines.findIndex(line => line.hasCursor);
759
+ return index === -1 ? Math.max(0, layoutLines.length - 1) : index;
760
+ }
761
+
762
+ #updateScrollOffset(layoutLines: LayoutLine[], visibleHeight: number): void {
696
763
  if (layoutLines.length <= visibleHeight) {
697
764
  this.#scrollOffset = 0;
698
765
  return;
699
766
  }
700
767
 
701
- const visualLines = this.#buildVisualLineMap(layoutWidth);
702
- const cursorLine = this.#findCurrentVisualLine(visualLines);
768
+ const cursorLine = this.#findCurrentLayoutLine(layoutLines);
703
769
  if (cursorLine < this.#scrollOffset) {
704
770
  this.#scrollOffset = cursorLine;
705
771
  } else if (cursorLine >= this.#scrollOffset + visibleHeight) {
@@ -730,7 +796,7 @@ export class Editor implements Component, Focusable {
730
796
  // Layout the text
731
797
  const layoutLines = this.#layoutText(layoutWidth);
732
798
  const visibleContentHeight = this.#getVisibleContentHeight(layoutLines.length);
733
- this.#updateScrollOffset(layoutWidth, layoutLines, visibleContentHeight);
799
+ this.#updateScrollOffset(layoutLines, visibleContentHeight);
734
800
  const visibleLayoutLines = layoutLines.slice(this.#scrollOffset, this.#scrollOffset + visibleContentHeight);
735
801
 
736
802
  const result: string[] = [];
@@ -1321,11 +1387,34 @@ export class Editor implements Component, Focusable {
1321
1387
  }
1322
1388
  }
1323
1389
 
1390
+ #getWrappedLine(lineIndex: number, contentWidth: number): WrappedLine {
1391
+ const line = this.#state.lines[lineIndex] || "";
1392
+ const cached = this.#wrappedLineCache[lineIndex];
1393
+ if (cached?.lineRef === line && cached.contentWidth === contentWidth) return cached;
1394
+
1395
+ const width = isPrintableAscii(line) ? line.length : visibleWidth(line);
1396
+ const chunks =
1397
+ width <= contentWidth
1398
+ ? [{ text: line, startIndex: 0, endIndex: line.length }]
1399
+ : wordWrapLine(line, contentWidth);
1400
+ const wrapped = { lineRef: line, contentWidth, width, chunks };
1401
+ this.#wrappedLineCache[lineIndex] = wrapped;
1402
+ return wrapped;
1403
+ }
1404
+
1405
+ /** Test-only seam: current wrap-cache entry count (memory-bound assertions). */
1406
+ get wrappedLineCacheSize(): number {
1407
+ return this.#wrappedLineCache.length;
1408
+ }
1409
+
1324
1410
  #layoutText(contentWidth: number): LayoutLine[] {
1325
1411
  const layoutLines: LayoutLine[] = [];
1326
1412
 
1327
1413
  if (this.#state.lines.length === 0 || (this.#state.lines.length === 1 && this.#state.lines[0] === "")) {
1328
- // Empty editor
1414
+ // Empty editor — keep the wrap cache bounded by document size like
1415
+ // the non-empty path below (stale entries from a previously large
1416
+ // buffer must not be retained).
1417
+ this.#wrappedLineCache.length = this.#state.lines.length;
1329
1418
  layoutLines.push({
1330
1419
  text: "",
1331
1420
  hasCursor: true,
@@ -1338,9 +1427,9 @@ export class Editor implements Component, Focusable {
1338
1427
  for (let i = 0; i < this.#state.lines.length; i++) {
1339
1428
  const line = this.#state.lines[i] || "";
1340
1429
  const isCurrentLine = i === this.#state.cursorLine;
1341
- const lineVisibleWidth = visibleWidth(line);
1430
+ const wrappedLine = this.#getWrappedLine(i, contentWidth);
1342
1431
 
1343
- if (lineVisibleWidth <= contentWidth) {
1432
+ if (wrappedLine.width <= contentWidth) {
1344
1433
  // Line fits in one layout line
1345
1434
  if (isCurrentLine) {
1346
1435
  layoutLines.push({
@@ -1356,7 +1445,7 @@ export class Editor implements Component, Focusable {
1356
1445
  }
1357
1446
  } else {
1358
1447
  // Line needs wrapping - use word-aware wrapping
1359
- const chunks = wordWrapLine(line, contentWidth);
1448
+ const chunks = wrappedLine.chunks;
1360
1449
 
1361
1450
  for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
1362
1451
  const chunk = chunks[chunkIndex];
@@ -1406,6 +1495,7 @@ export class Editor implements Component, Focusable {
1406
1495
  }
1407
1496
  }
1408
1497
 
1498
+ this.#wrappedLineCache.length = this.#state.lines.length;
1409
1499
  return layoutLines;
1410
1500
  }
1411
1501
 
@@ -1749,6 +1839,7 @@ export class Editor implements Component, Focusable {
1749
1839
  this.#historyIndex = -1;
1750
1840
  this.#scrollOffset = 0;
1751
1841
  this.#undoStack.length = 0;
1842
+ this.#wrappedLineCache.length = 0;
1752
1843
 
1753
1844
  if (this.onChange) this.onChange("");
1754
1845
  if (this.onSubmit) this.onSubmit(result);
@@ -2294,15 +2385,15 @@ export class Editor implements Component, Focusable {
2294
2385
 
2295
2386
  for (let i = 0; i < this.#state.lines.length; i++) {
2296
2387
  const line = this.#state.lines[i] || "";
2297
- const lineVisWidth = visibleWidth(line);
2388
+ const wrappedLine = this.#getWrappedLine(i, width);
2298
2389
  if (line.length === 0) {
2299
2390
  // Empty line still takes one visual line
2300
2391
  visualLines.push({ logicalLine: i, startCol: 0, length: 0 });
2301
- } else if (lineVisWidth <= width) {
2392
+ } else if (wrappedLine.width <= width) {
2302
2393
  visualLines.push({ logicalLine: i, startCol: 0, length: line.length });
2303
2394
  } else {
2304
2395
  // Line needs wrapping - use word-aware wrapping
2305
- const chunks = wordWrapLine(line, width);
2396
+ const chunks = wrappedLine.chunks;
2306
2397
  for (const chunk of chunks) {
2307
2398
  visualLines.push({
2308
2399
  logicalLine: i,
@@ -2340,8 +2431,10 @@ export class Editor implements Component, Focusable {
2340
2431
 
2341
2432
  #moveCursor(deltaLine: number, deltaCol: number): void {
2342
2433
  this.#resetKillSequence();
2343
- const visualLines = this.#buildVisualLineMap(this.#lastLayoutWidth);
2344
- const currentVisualLine = this.#findCurrentVisualLine(visualLines);
2434
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2435
+ const needsVisualLines = deltaLine !== 0 || (deltaCol > 0 && this.#state.cursorCol >= currentLine.length);
2436
+ const visualLines = needsVisualLines ? this.#buildVisualLineMap(this.#lastLayoutWidth) : [];
2437
+ const currentVisualLine = needsVisualLines ? this.#findCurrentVisualLine(visualLines) : -1;
2345
2438
 
2346
2439
  if (deltaLine !== 0) {
2347
2440
  const targetVisualLine = currentVisualLine + deltaLine;
@@ -2352,15 +2445,18 @@ export class Editor implements Component, Focusable {
2352
2445
  }
2353
2446
 
2354
2447
  if (deltaCol !== 0) {
2355
- const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2356
-
2357
2448
  if (deltaCol > 0) {
2358
2449
  // Moving right - move by one grapheme (handles emojis, combining characters, etc.)
2359
2450
  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));
2451
+ const charCode = currentLine.charCodeAt(this.#state.cursorCol);
2452
+ if (charCode >= 0x20 && charCode <= 0x7e) {
2453
+ this.#setCursorCol(this.#state.cursorCol + 1);
2454
+ } else {
2455
+ const afterCursor = currentLine.slice(this.#state.cursorCol);
2456
+ const graphemes = [...segmenter.segment(afterCursor)];
2457
+ const firstGrapheme = graphemes[0];
2458
+ this.#setCursorCol(this.#state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
2459
+ }
2364
2460
  } else if (this.#state.cursorLine < this.#state.lines.length - 1) {
2365
2461
  // Wrap to start of next logical line
2366
2462
  this.#state.cursorLine++;
@@ -2375,10 +2471,15 @@ export class Editor implements Component, Focusable {
2375
2471
  } else {
2376
2472
  // Moving left - move by one grapheme (handles emojis, combining characters, etc.)
2377
2473
  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));
2474
+ const charCode = currentLine.charCodeAt(this.#state.cursorCol - 1);
2475
+ if (charCode >= 0x20 && charCode <= 0x7e) {
2476
+ this.#setCursorCol(this.#state.cursorCol - 1);
2477
+ } else {
2478
+ const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
2479
+ const graphemes = [...segmenter.segment(beforeCursor)];
2480
+ const lastGrapheme = graphemes[graphemes.length - 1];
2481
+ this.#setCursorCol(this.#state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
2482
+ }
2382
2483
  } else if (this.#state.cursorLine > 0) {
2383
2484
  // Wrap to end of previous logical line
2384
2485
  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/terminal.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { dlopen, FFIType, ptr } from "bun:ffi";
2
2
  import * as fs from "node:fs";
3
- import { $env } from "@gajae-code/utils";
3
+ import { $env, $flag } from "@gajae-code/utils";
4
4
  import { setKittyProtocolActive } from "./keys";
5
5
  import { StdinBuffer } from "./stdin-buffer";
6
6
 
@@ -8,6 +8,22 @@ const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000;
8
8
  const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
9
9
  const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07";
10
10
 
11
+ /**
12
+ * Whether GJC may reprogram the keyboard with enhanced input protocols
13
+ * (the Kitty keyboard protocol and the xterm modifyOtherKeys fallback).
14
+ *
15
+ * Enabled by default. Set `GJC_TUI_KEYBOARD_PROTOCOL=0` to leave the keyboard in
16
+ * its default mode. Some terminals — notably Android Termius — break IME
17
+ * composition (e.g. Korean/Hangul syllable composition) while these enhanced
18
+ * modes are active, committing every intermediate composing jamo/syllable
19
+ * instead of only the final character. Disabling the protocol restores normal
20
+ * IME behavior, matching how other TUIs that leave the keyboard untouched render
21
+ * Korean correctly.
22
+ */
23
+ export function keyboardEnhancementEnabled(): boolean {
24
+ return $flag("GJC_TUI_KEYBOARD_PROTOCOL", true);
25
+ }
26
+
11
27
  /**
12
28
  * Minimal terminal interface for TUI
13
29
  */
@@ -513,6 +529,14 @@ export class ProcessTerminal implements Terminal {
513
529
  #queryAndEnableKittyProtocol(): void {
514
530
  this.#setupStdinBuffer();
515
531
  process.stdin.on("data", this.#stdinDataHandler!);
532
+ // Leave the keyboard in its default mode when enhanced input protocols are
533
+ // disabled. Android Termius (and similar terminals) break IME/Hangul
534
+ // composition when the Kitty keyboard protocol or modifyOtherKeys is active,
535
+ // committing every intermediate composing jamo/syllable. Skipping the query
536
+ // and the modifyOtherKeys fallback restores normal IME composition.
537
+ if (!keyboardEnhancementEnabled()) {
538
+ return;
539
+ }
516
540
  this.#safeWrite("\x1b[?u");
517
541
  this.#modifyOtherKeysTimeout = setTimeout(() => {
518
542
  this.#modifyOtherKeysTimeout = undefined;
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;
@@ -244,6 +254,11 @@ export class TUI extends Container {
244
254
  #renderTimer: NodeJS.Timeout | undefined;
245
255
  #lastRenderAt = 0;
246
256
  static readonly #MIN_RENDER_INTERVAL_MS = 16;
257
+ // Input-priority scheduling: an input keystroke must never be starved behind a
258
+ // pending normal (frame-budget) render timer. When set, an input-priority render
259
+ // is queued for the next tick and supersedes any pending normal timer.
260
+ #inputRenderPending = false;
261
+
247
262
  #cursorRow = 0; // Logical cursor row (end of rendered content)
248
263
  #hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
249
264
  #viewportTopRow = 0; // Content row currently mapped to screen row 0
@@ -648,6 +663,8 @@ export class TUI extends Container {
648
663
  // focus/listener state is intentionally preserved so input routing survives
649
664
  // a resume.
650
665
  this.#previousLines = [];
666
+ this.#lineNormalizationCache.clear();
667
+ this.#lineTruncationCache.clear();
651
668
  this.#previousWidth = 0;
652
669
  this.#previousHeight = 0;
653
670
  }
@@ -659,9 +676,15 @@ export class TUI extends Container {
659
676
  }
660
677
  if (renderMetrics.enabled) renderMetrics.recordRequest(source);
661
678
  if (force) {
679
+ // A forced full redraw supersedes any queued input-priority render.
680
+ this.#inputRenderPending = false;
662
681
  this.#previousLines = [];
682
+ this.#lineNormalizationCache.clear();
683
+ this.#lineTruncationCache.clear();
663
684
  this.#previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
664
685
  this.#previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
686
+ this.#lineNormalizationCacheLimit = 0;
687
+ this.#lineTruncationCacheLimit = 0;
665
688
  this.#cursorRow = 0;
666
689
  this.#hardwareCursorRow = 0;
667
690
  this.#viewportTopRow = 0;
@@ -684,6 +707,19 @@ export class TUI extends Container {
684
707
  });
685
708
  return;
686
709
  }
710
+ // Input-priority path: expedite so the keystroke echoes within the next tick
711
+ // instead of waiting for (or behind) the frame-budget timer. Re-entrant input
712
+ // requests in the same turn coalesce via #inputRenderPending, so at most one
713
+ // expedited render commits per event-loop turn (no repaint storms). This only
714
+ // changes WHEN #doRender runs; the render output path is unchanged.
715
+ if (source === "input" || source === "editor.input") {
716
+ if (!this.#inputRenderPending) {
717
+ this.#inputRenderPending = true;
718
+ this.#renderRequested = true;
719
+ process.nextTick(() => this.#commitExpeditedRender());
720
+ }
721
+ return;
722
+ }
687
723
  if (this.#renderRequested) return;
688
724
  this.#renderRequested = true;
689
725
  process.nextTick(() => this.#scheduleRender());
@@ -713,6 +749,27 @@ export class TUI extends Container {
713
749
  if (renderMetrics.enabled) renderMetrics.setTimerGauge("tui.renderTimer", 1);
714
750
  }
715
751
 
752
+ // Commit a single input-priority render on the next tick, cancelling any normal
753
+ // frame-budget timer scheduled in the same turn. nextTick always precedes a
754
+ // pending setTimeout, so the keystroke is never starved behind streaming renders.
755
+ #commitExpeditedRender(): void {
756
+ if (!this.#inputRenderPending) return; // cancelled (e.g., by a forced render)
757
+ this.#inputRenderPending = false;
758
+ if (this.#stopped || !this.#renderRequested) {
759
+ return;
760
+ }
761
+ if (this.#renderTimer) {
762
+ clearTimeout(this.#renderTimer);
763
+ this.#renderTimer = undefined;
764
+ if (renderMetrics.enabled) renderMetrics.setTimerGauge("tui.renderTimer", 0);
765
+ }
766
+ this.#renderRequested = false;
767
+ this.#lastRenderAt = performance.now();
768
+ const t0 = renderMetrics.now();
769
+ this.#doRender();
770
+ if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0);
771
+ }
772
+
716
773
  #handleInput(data: string): void {
717
774
  if (this.#inputListeners.size > 0) {
718
775
  let current = data;
@@ -764,7 +821,7 @@ export class TUI extends Container {
764
821
  return;
765
822
  }
766
823
  this.#focusedComponent.handleInput(data);
767
- this.requestRender();
824
+ this.requestRender(false, "input");
768
825
  }
769
826
  }
770
827
 
@@ -1092,25 +1149,71 @@ export class TUI extends Container {
1092
1149
  * written to the terminal — without this, the diff cache disagrees with
1093
1150
  * emitted output and OSC 8 hyperlink state can leak across lines.
1094
1151
  */
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);
1152
+ #normalizeLineForRender(line: string): LineNormalizationCacheEntry {
1153
+ const cached = this.#lineNormalizationCache.get(line);
1154
+ if (cached !== undefined) return cached;
1155
+ const normalized = normalizeTerminalOutput(line);
1156
+ const terminated = normalized + (normalized.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
1157
+ this.#lineNormalizationCache.set(line, { normalized, terminated });
1158
+ return { normalized, terminated };
1159
+ }
1160
+
1161
+ #lineFitsWidth(normalizedLine: string, width: number): boolean {
1162
+ return isPrintableAscii(normalizedLine) && normalizedLine.length <= width
1163
+ ? true
1164
+ : visibleWidth(normalizedLine) <= width;
1165
+ }
1166
+
1167
+ #truncateNormalizedLine(normalizedLine: string, width: number): string {
1168
+ const key = `${width}\0${normalizedLine}`;
1169
+ const cached = this.#lineTruncationCache.get(key);
1170
+ if (cached !== undefined) return cached;
1171
+ const truncated = truncateToWidth(normalizedLine, width, Ellipsis.Omit);
1172
+ const terminated = truncated + (truncated.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
1173
+ this.#lineTruncationCache.set(key, terminated);
1174
+ return terminated;
1175
+ }
1176
+
1177
+ #trimLineCachesForRender(lineCount: number): void {
1178
+ const limit = Math.max(1, lineCount * 2);
1179
+ this.#lineNormalizationCacheLimit = limit;
1180
+ this.#lineTruncationCacheLimit = limit;
1181
+ while (this.#lineNormalizationCache.size > limit) {
1182
+ const key = this.#lineNormalizationCache.keys().next().value;
1183
+ if (key === undefined) break;
1184
+ this.#lineNormalizationCache.delete(key);
1104
1185
  }
1105
- return lines;
1186
+ while (this.#lineTruncationCache.size > limit) {
1187
+ const key = this.#lineTruncationCache.keys().next().value;
1188
+ if (key === undefined) break;
1189
+ this.#lineTruncationCache.delete(key);
1190
+ }
1191
+ }
1192
+
1193
+ getLineRenderCacheStats(): {
1194
+ normalizationSize: number;
1195
+ truncationSize: number;
1196
+ normalizationLimit: number;
1197
+ truncationLimit: number;
1198
+ } {
1199
+ return {
1200
+ normalizationSize: this.#lineNormalizationCache.size,
1201
+ truncationSize: this.#lineTruncationCache.size,
1202
+ normalizationLimit: this.#lineNormalizationCacheLimit,
1203
+ truncationLimit: this.#lineTruncationCacheLimit,
1204
+ };
1106
1205
  }
1107
- #truncateLinesToWidth(lines: string[], width: number): string[] {
1206
+
1207
+ #applyLineResetsAndTruncate(lines: string[], width: number): string[] {
1108
1208
  for (let i = 0; i < lines.length; i++) {
1109
1209
  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);
1210
+ if (TERMINAL.isImageLine(line)) continue;
1211
+ const { normalized, terminated } = this.#normalizeLineForRender(line);
1212
+ lines[i] = this.#lineFitsWidth(normalized, width)
1213
+ ? terminated
1214
+ : this.#truncateNormalizedLine(normalized, width);
1113
1215
  }
1216
+ this.#trimLineCachesForRender(lines.length);
1114
1217
  return lines;
1115
1218
  }
1116
1219
 
@@ -1144,8 +1247,7 @@ export class TUI extends Container {
1144
1247
  // (closes SGR + OSC 8 hyperlink state). Must run after cursor extraction
1145
1248
  // because the marker is embedded mid-line, and before any diff/full render
1146
1249
  // path so cache comparisons stay byte-accurate.
1147
- newLines = this.#applyLineResets(newLines);
1148
- newLines = this.#truncateLinesToWidth(newLines, width);
1250
+ newLines = this.#applyLineResetsAndTruncate(newLines, width);
1149
1251
 
1150
1252
  // Width changed - need full re-render (line wrapping changes)
1151
1253
  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
  }