@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 +17 -0
- package/dist/types/components/editor.d.ts +5 -0
- package/dist/types/terminal.d.ts +13 -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 +125 -24
- package/src/components/markdown.ts +39 -22
- package/src/terminal.ts +25 -1
- package/src/tui.ts +119 -17
- package/src/utils.ts +8 -0
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.
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -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
|
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.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.
|
|
42
|
-
"@gajae-code/utils": "0.
|
|
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
|
},
|
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();
|
|
@@ -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
|
-
|
|
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
|
-
#
|
|
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
|
|
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(
|
|
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
|
|
1430
|
+
const wrappedLine = this.#getWrappedLine(i, contentWidth);
|
|
1342
1431
|
|
|
1343
|
-
if (
|
|
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 =
|
|
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
|
|
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 (
|
|
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 =
|
|
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
|
|
2344
|
-
const
|
|
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
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
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
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
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
|
-
//
|
|
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/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
|
-
#
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
1111
|
-
const
|
|
1112
|
-
lines[i] =
|
|
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.#
|
|
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
|
}
|