@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.10",
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.10",
42
- "@sayknow-cli/utils": "0.3.10",
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
  },
@@ -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: string;
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
- if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
78
- this.#kittyImageId ??= kittyImageId(this.#base64Data);
79
- }
80
- const result = renderImage(this.#base64Data, this.#dimensions, {
81
- maxWidthCells: maxWidth,
82
- maxHeightCells: this.#options.maxHeightCells,
83
- imageId: this.#kittyImageId,
84
- placementId: this.#kittyPlacementId,
85
- });
86
-
87
- if (result) {
88
- // Return `rows` lines so the TUI accounts for the image height.
89
- if (result.cursorNeutral) {
90
- // Kitty a=p,C=1 placements neither move the cursor nor carry
91
- // pixel data, so the escape lives on the FIRST row — the image
92
- // anchors to that cell and no cursor-up trick is needed (the
93
- // old CUU approach clamped at the viewport top edge and placed
94
- // the image over transcript text when partially scrolled out).
95
- lines = [result.sequence];
96
- for (let i = 0; i < result.rows - 1; i++) {
97
- lines.push("");
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
- // iTerm2/SIXEL draw at the cursor and advance it: reserve
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
- const fallback = imageFallback(this.#mimeType, this.#dimensions, this.#options.filename);
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
- // Render HTML as plain text (escaped for terminal)
659
- if ("raw" in token && typeof token.raw === "string") {
660
- lines.push(this.#applyDefaultStyle(token.raw.trim()));
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
- // Render inline HTML as plain text
750
- if ("raw" in token && typeof token.raw === "string") {
751
- result += applyTextWithNewlines(token.raw);
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.#writeTerminal(buffer)) return false;
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.#writeTerminal(buffer)) return;
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.#writeTerminal(buffer)) return;
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.#writeTerminal(buffer)) return;
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.#writeTerminal(buffer)) return;
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): void {
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
  }