@sayknow-cli/tui 0.3.10 → 0.3.12
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.
|
@@ -7,10 +7,12 @@ export interface ImageOptions {
|
|
|
7
7
|
maxWidthCells?: number;
|
|
8
8
|
maxHeightCells?: number;
|
|
9
9
|
filename?: string;
|
|
10
|
+
refetch?: () => string;
|
|
10
11
|
}
|
|
11
12
|
export declare class Image implements Component {
|
|
12
13
|
#private;
|
|
13
14
|
constructor(base64Data: string, mimeType: string, theme: ImageTheme, options?: ImageOptions, dimensions?: ImageDimensions);
|
|
14
15
|
invalidate(): void;
|
|
16
|
+
get retainedBase64DataForTest(): string | undefined;
|
|
15
17
|
render(width: number): string[];
|
|
16
18
|
}
|
|
@@ -13,6 +13,7 @@ export declare const __markdownPerfCounters: {
|
|
|
13
13
|
};
|
|
14
14
|
/** Drop all L2 cache entries. Call on theme change to prevent stale styled output. */
|
|
15
15
|
export declare function clearRenderCache(): void;
|
|
16
|
+
export declare function getRenderCacheRetainedBytes(): number;
|
|
16
17
|
/**
|
|
17
18
|
* Default text styling for markdown content.
|
|
18
19
|
* Applied to all text unless overridden by markdown formatting.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@sayknow-cli/tui",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.12",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://sayknow-cli.com",
|
|
7
7
|
"author": "jaybeyond",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"fmt": "biome format --write ."
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@sayknow-cli/natives": "0.3.
|
|
42
|
-
"@sayknow-cli/utils": "0.3.
|
|
41
|
+
"@sayknow-cli/natives": "0.3.12",
|
|
42
|
+
"@sayknow-cli/utils": "0.3.12",
|
|
43
43
|
"lru-cache": "11.3.6",
|
|
44
44
|
"marked": "^18.0.3"
|
|
45
45
|
},
|
package/src/components/image.ts
CHANGED
|
@@ -28,10 +28,11 @@ export interface ImageOptions {
|
|
|
28
28
|
maxWidthCells?: number;
|
|
29
29
|
maxHeightCells?: number;
|
|
30
30
|
filename?: string;
|
|
31
|
+
refetch?: () => string;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
export class Image implements Component {
|
|
34
|
-
#base64Data
|
|
35
|
+
#base64Data?: string;
|
|
35
36
|
#mimeType: string;
|
|
36
37
|
#dimensions: ImageDimensions;
|
|
37
38
|
#theme: ImageTheme;
|
|
@@ -63,6 +64,22 @@ export class Image implements Component {
|
|
|
63
64
|
this.#cachedWidth = undefined;
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
get retainedBase64DataForTest(): string | undefined {
|
|
68
|
+
return this.#base64Data;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
#fallbackLines(): string[] {
|
|
72
|
+
const fallback = imageFallback(this.#mimeType, this.#dimensions, this.#options.filename);
|
|
73
|
+
return [this.#theme.fallbackColor(fallback)];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#getBase64Data(): string | undefined {
|
|
77
|
+
if (this.#base64Data) return this.#base64Data;
|
|
78
|
+
const refetched = this.#options.refetch?.();
|
|
79
|
+
if (refetched) this.#base64Data = refetched;
|
|
80
|
+
return this.#base64Data;
|
|
81
|
+
}
|
|
82
|
+
|
|
66
83
|
render(width: number): string[] {
|
|
67
84
|
if (this.#cachedLines && this.#cachedWidth === width) {
|
|
68
85
|
return this.#cachedLines;
|
|
@@ -74,46 +91,50 @@ export class Image implements Component {
|
|
|
74
91
|
let lines: string[];
|
|
75
92
|
|
|
76
93
|
if (TERMINAL.imageProtocol) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
94
|
+
const base64Data = this.#getBase64Data();
|
|
95
|
+
if (!base64Data) {
|
|
96
|
+
lines = this.#fallbackLines();
|
|
97
|
+
} else {
|
|
98
|
+
if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
|
|
99
|
+
this.#kittyImageId ??= kittyImageId(base64Data);
|
|
100
|
+
}
|
|
101
|
+
const result = renderImage(base64Data, this.#dimensions, {
|
|
102
|
+
maxWidthCells: maxWidth,
|
|
103
|
+
maxHeightCells: this.#options.maxHeightCells,
|
|
104
|
+
imageId: this.#kittyImageId,
|
|
105
|
+
placementId: this.#kittyPlacementId,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
if (result) {
|
|
109
|
+
// Return `rows` lines so the TUI accounts for the image height.
|
|
110
|
+
if (result.cursorNeutral) {
|
|
111
|
+
// Kitty a=p,C=1 placements neither move the cursor nor carry
|
|
112
|
+
// pixel data, so the escape lives on the FIRST row — the image
|
|
113
|
+
// anchors to that cell and no cursor-up trick is needed (the
|
|
114
|
+
// old CUU approach clamped at the viewport top edge and placed
|
|
115
|
+
// the image over transcript text when partially scrolled out).
|
|
116
|
+
lines = [result.sequence];
|
|
117
|
+
for (let i = 0; i < result.rows - 1; i++) {
|
|
118
|
+
lines.push("");
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
// iTerm2/SIXEL draw at the cursor and advance it: reserve
|
|
122
|
+
// rows-1 blank lines (TUI clears them), then move the cursor
|
|
123
|
+
// up and draw from the last line.
|
|
124
|
+
lines = [];
|
|
125
|
+
for (let i = 0; i < result.rows - 1; i++) {
|
|
126
|
+
lines.push("");
|
|
127
|
+
}
|
|
128
|
+
const moveUp = result.rows > 1 ? `\x1b[${result.rows - 1}A` : "";
|
|
129
|
+
lines.push(moveUp + result.sequence);
|
|
98
130
|
}
|
|
131
|
+
this.#base64Data = undefined;
|
|
99
132
|
} else {
|
|
100
|
-
|
|
101
|
-
// rows-1 blank lines (TUI clears them), then move the cursor
|
|
102
|
-
// up and draw from the last line.
|
|
103
|
-
lines = [];
|
|
104
|
-
for (let i = 0; i < result.rows - 1; i++) {
|
|
105
|
-
lines.push("");
|
|
106
|
-
}
|
|
107
|
-
const moveUp = result.rows > 1 ? `\x1b[${result.rows - 1}A` : "";
|
|
108
|
-
lines.push(moveUp + result.sequence);
|
|
133
|
+
lines = this.#fallbackLines();
|
|
109
134
|
}
|
|
110
|
-
} else {
|
|
111
|
-
const fallback = imageFallback(this.#mimeType, this.#dimensions, this.#options.filename);
|
|
112
|
-
lines = [this.#theme.fallbackColor(fallback)];
|
|
113
135
|
}
|
|
114
136
|
} else {
|
|
115
|
-
|
|
116
|
-
lines = [this.#theme.fallbackColor(fallback)];
|
|
137
|
+
lines = this.#fallbackLines();
|
|
117
138
|
}
|
|
118
139
|
|
|
119
140
|
this.#cachedLines = lines;
|
|
@@ -55,6 +55,13 @@ export function __setMarkdownNowForTest(now: (() => number) | undefined): void {
|
|
|
55
55
|
// prefix on every chunk. Bounded LRU; cleared on theme change via clearRenderCache().
|
|
56
56
|
const HIGHLIGHT_CACHE_MAX = 512;
|
|
57
57
|
const highlightCache = new LRUCache<string, string[]>({ max: HIGHLIGHT_CACHE_MAX });
|
|
58
|
+
|
|
59
|
+
function renderedLinesBytes(lines: readonly string[]): number {
|
|
60
|
+
let bytes = 0;
|
|
61
|
+
for (const line of lines) bytes += Buffer.byteLength(line, "utf8");
|
|
62
|
+
return bytes;
|
|
63
|
+
}
|
|
64
|
+
|
|
58
65
|
// F18: cap synchronous (Rust FFI) syntax highlighting so a single huge fenced block
|
|
59
66
|
// cannot stall the UI thread; oversized blocks render plain with a sanitized marker.
|
|
60
67
|
const MAX_HIGHLIGHT_BYTES = 200_000;
|
|
@@ -96,6 +103,17 @@ export function clearRenderCache(): void {
|
|
|
96
103
|
highlightCache.clear();
|
|
97
104
|
}
|
|
98
105
|
|
|
106
|
+
export function getRenderCacheRetainedBytes(): number {
|
|
107
|
+
let bytes = 0;
|
|
108
|
+
for (const entry of renderCache.values()) {
|
|
109
|
+
bytes += Buffer.byteLength(entry.source, "utf8");
|
|
110
|
+
bytes += renderedLinesBytes(entry.lines);
|
|
111
|
+
}
|
|
112
|
+
for (const entry of parseCache.values()) bytes += Buffer.byteLength(entry.source, "utf8");
|
|
113
|
+
for (const lines of highlightCache.values()) bytes += renderedLinesBytes(lines);
|
|
114
|
+
return bytes;
|
|
115
|
+
}
|
|
116
|
+
|
|
99
117
|
// Stable numeric IDs for structural theme/style objects (no ID field on type).
|
|
100
118
|
// Symbol-keyed so the id travels with the object and is invisible to consumers.
|
|
101
119
|
const kObjectId = Symbol("markdown.objectId");
|
|
@@ -180,6 +198,29 @@ function formatHyperlink(text: string, target: string): string {
|
|
|
180
198
|
return `\x1b]8;;${safeTarget}\x07${text}\x1b]8;;\x07`;
|
|
181
199
|
}
|
|
182
200
|
|
|
201
|
+
function stripHtmlComments(raw: string): string {
|
|
202
|
+
let result = "";
|
|
203
|
+
let index = 0;
|
|
204
|
+
|
|
205
|
+
while (index < raw.length) {
|
|
206
|
+
const start = raw.indexOf("<!--", index);
|
|
207
|
+
if (start < 0) {
|
|
208
|
+
result += raw.slice(index);
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
result += raw.slice(index, start);
|
|
213
|
+
const end = raw.indexOf("-->", start + 4);
|
|
214
|
+
if (end < 0) {
|
|
215
|
+
result += raw.slice(start);
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
index = end + 3;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
223
|
+
|
|
183
224
|
export class Markdown implements Component {
|
|
184
225
|
#text: string;
|
|
185
226
|
#paddingX: number; // Left/right padding
|
|
@@ -654,12 +695,15 @@ export class Markdown implements Component {
|
|
|
654
695
|
}
|
|
655
696
|
break;
|
|
656
697
|
|
|
657
|
-
case "html":
|
|
658
|
-
//
|
|
659
|
-
|
|
660
|
-
|
|
698
|
+
case "html": {
|
|
699
|
+
// HTML comments are invisible markup (React/SSR text separators often emit "<!-- -->").
|
|
700
|
+
// Keep other HTML-like model text visible as plain terminal text.
|
|
701
|
+
const visibleHtml = "raw" in token && typeof token.raw === "string" ? stripHtmlComments(token.raw) : "";
|
|
702
|
+
if (visibleHtml.trim().length > 0) {
|
|
703
|
+
lines.push(this.#applyDefaultStyle(visibleHtml.trim()));
|
|
661
704
|
}
|
|
662
705
|
break;
|
|
706
|
+
}
|
|
663
707
|
|
|
664
708
|
case "space":
|
|
665
709
|
// Space tokens represent blank lines in markdown
|
|
@@ -745,12 +789,14 @@ export class Markdown implements Component {
|
|
|
745
789
|
break;
|
|
746
790
|
}
|
|
747
791
|
|
|
748
|
-
case "html":
|
|
749
|
-
//
|
|
750
|
-
|
|
751
|
-
|
|
792
|
+
case "html": {
|
|
793
|
+
// HTML comments are markup-only separators; keep other inline HTML visible as text.
|
|
794
|
+
const visibleHtml = "raw" in token && typeof token.raw === "string" ? stripHtmlComments(token.raw) : "";
|
|
795
|
+
if (visibleHtml.trim().length > 0) {
|
|
796
|
+
result += applyTextWithNewlines(visibleHtml);
|
|
752
797
|
}
|
|
753
798
|
break;
|
|
799
|
+
}
|
|
754
800
|
|
|
755
801
|
default:
|
|
756
802
|
// Handle any other inline token types as plain text
|
|
@@ -1091,6 +1137,11 @@ export function renderInlineMarkdown(text: string, mdTheme: MarkdownTheme, baseC
|
|
|
1091
1137
|
return `${applyText(prefix)}${content}`;
|
|
1092
1138
|
})
|
|
1093
1139
|
.join(applyText(" "));
|
|
1140
|
+
} else if (token.type === "html" && "raw" in token && typeof token.raw === "string") {
|
|
1141
|
+
const visibleHtml = stripHtmlComments(token.raw);
|
|
1142
|
+
if (visibleHtml.trim().length > 0) {
|
|
1143
|
+
result += applyText(visibleHtml);
|
|
1144
|
+
}
|
|
1094
1145
|
} else if ("text" in token && typeof token.text === "string") {
|
|
1095
1146
|
result += applyText(token.text);
|
|
1096
1147
|
}
|
|
@@ -1127,6 +1178,13 @@ function renderInlineTokens(tokens: Token[], mdTheme: MarkdownTheme, applyText:
|
|
|
1127
1178
|
result += mdTheme.link(mdTheme.underline(linkText)) + styleReset;
|
|
1128
1179
|
break;
|
|
1129
1180
|
}
|
|
1181
|
+
case "html": {
|
|
1182
|
+
const visibleHtml = "raw" in token && typeof token.raw === "string" ? stripHtmlComments(token.raw) : "";
|
|
1183
|
+
if (visibleHtml.trim().length > 0) {
|
|
1184
|
+
result += applyText(visibleHtml);
|
|
1185
|
+
}
|
|
1186
|
+
break;
|
|
1187
|
+
}
|
|
1130
1188
|
default:
|
|
1131
1189
|
if ("text" in token && typeof token.text === "string") {
|
|
1132
1190
|
result += applyText(token.text);
|
package/src/tui.ts
CHANGED
|
@@ -1616,7 +1616,7 @@ export class TUI extends Container {
|
|
|
1616
1616
|
this.#hardwareCursorRow = cursorToRow;
|
|
1617
1617
|
buffer += cursorSeq;
|
|
1618
1618
|
buffer += "\x1b[?2026l";
|
|
1619
|
-
if (!this.#
|
|
1619
|
+
if (!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, lines.length)) return false;
|
|
1620
1620
|
|
|
1621
1621
|
if (this.#debugRedraw) {
|
|
1622
1622
|
const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (lines=${lines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
|
|
@@ -1761,7 +1761,7 @@ export class TUI extends Container {
|
|
|
1761
1761
|
this.#hardwareCursorRow = toRow;
|
|
1762
1762
|
buffer += seq;
|
|
1763
1763
|
buffer += "\x1b[?2026l"; // End synchronized output
|
|
1764
|
-
if (!this.#
|
|
1764
|
+
if (!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length)) return;
|
|
1765
1765
|
// Reset max lines when clearing, otherwise track growth
|
|
1766
1766
|
if (clear) {
|
|
1767
1767
|
this.#maxLinesRendered = newLines.length;
|
|
@@ -1811,7 +1811,7 @@ export class TUI extends Container {
|
|
|
1811
1811
|
this.#hardwareCursorRow = cursorToRow;
|
|
1812
1812
|
buffer += cursorSeq;
|
|
1813
1813
|
buffer += "\x1b[?2026l";
|
|
1814
|
-
if (!this.#
|
|
1814
|
+
if (!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length)) return;
|
|
1815
1815
|
|
|
1816
1816
|
if (this.#debugRedraw) {
|
|
1817
1817
|
const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
|
|
@@ -1963,7 +1963,7 @@ export class TUI extends Container {
|
|
|
1963
1963
|
this.#hardwareCursorRow = toRow;
|
|
1964
1964
|
buffer += seq;
|
|
1965
1965
|
buffer += "\x1b[?2026l";
|
|
1966
|
-
if (!this.#
|
|
1966
|
+
if (!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length)) return;
|
|
1967
1967
|
}
|
|
1968
1968
|
this.#previousLines = newLines;
|
|
1969
1969
|
this.#previousWidth = width;
|
|
@@ -2108,7 +2108,7 @@ export class TUI extends Container {
|
|
|
2108
2108
|
}
|
|
2109
2109
|
|
|
2110
2110
|
// Write entire buffer at once
|
|
2111
|
-
if (!this.#
|
|
2111
|
+
if (!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length)) return;
|
|
2112
2112
|
|
|
2113
2113
|
// Track cursor position for next render.
|
|
2114
2114
|
// cursorRow tracks end of content (for viewport calculation).
|
|
@@ -2163,19 +2163,28 @@ export class TUI extends Container {
|
|
|
2163
2163
|
return { seq, toRow: targetRow };
|
|
2164
2164
|
}
|
|
2165
2165
|
|
|
2166
|
+
#writeRenderBufferAndReanchorImeCursor(
|
|
2167
|
+
buffer: string,
|
|
2168
|
+
cursorPos: { row: number; col: number } | null,
|
|
2169
|
+
totalLines: number,
|
|
2170
|
+
): boolean {
|
|
2171
|
+
if (!this.#writeTerminal(buffer)) return false;
|
|
2172
|
+
if (!this.#imeCursorActive) return true;
|
|
2173
|
+
return this.#writeCursorPosition(cursorPos, totalLines);
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2166
2176
|
/**
|
|
2167
2177
|
* Write the hardware cursor position to the terminal as a standalone
|
|
2168
2178
|
* synchronized output block. Use when there is no surrounding render buffer
|
|
2169
2179
|
* to embed the sequences into.
|
|
2170
2180
|
*/
|
|
2171
|
-
#writeCursorPosition(cursorPos: { row: number; col: number } | null, totalLines: number):
|
|
2181
|
+
#writeCursorPosition(cursorPos: { row: number; col: number } | null, totalLines: number): boolean {
|
|
2172
2182
|
if (!cursorPos || totalLines <= 0) {
|
|
2173
|
-
this.#hideCursor();
|
|
2174
|
-
return;
|
|
2183
|
+
return this.#hideCursor();
|
|
2175
2184
|
}
|
|
2176
2185
|
const { seq, toRow } = this.#cursorControlSequence(cursorPos, totalLines, this.#hardwareCursorRow);
|
|
2177
2186
|
this.#hardwareCursorRow = toRow;
|
|
2178
2187
|
// No \x1b[?2026h/l wrapper: synchronized output flushes terminal state and discards macOS IME composition.
|
|
2179
|
-
this.#writeTerminal(seq);
|
|
2188
|
+
return this.#writeTerminal(seq);
|
|
2180
2189
|
}
|
|
2181
2190
|
}
|