@gajae-code/tui 0.18.11 → 0.18.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.18.12] - 2026-10-16
6
+
7
+ ### Added
8
+
9
+ - Render ```svg fenced code blocks as rasterized terminal images (Kitty, iTerm2, SIXEL). SVG figures stream inline with partial source repair, theme-aware token resolution through reserved `--gjc-<palette-key>` CSS variables, and automatic fallback to plain code blocks on rasterization failure. Ported from oh-my-pi.
10
+
11
+ ### Fixed
12
+
13
+ - TUI no longer goes blank after stop/restart cycles: `#hardwareCursorRow` is now properly reset during `stop()` to prevent stale cursor position state from corrupting viewport calculations on the next `start()`.
14
+
15
+ - With `clearOnShrink` enabled, a live block that shrinks after the transcript has scrolled into history no longer leaves blank spacer rows above the pinned composer until the next resize. The contraction now clears the screen and history and replays the frame, so the viewport is filled again and no row appears twice in history.
16
+
17
+ - Scrub credentials from TUI render debug dumps and write them mode 0600 beside the debug log, without following a symlink at the dump path.
18
+
5
19
  ## [0.18.11] - 2026-10-12
6
20
 
7
21
  ### Fixed
@@ -0,0 +1,26 @@
1
+ import { type ImageTheme } from "../components/image";
2
+ import type { Component } from "../tui";
3
+ export interface SvgFigureOptions {
4
+ /**
5
+ * Theme for rendering the image fallback text.
6
+ */
7
+ theme: ImageTheme;
8
+ /** CSS colors substituted for SVG theme variables and currentColor. */
9
+ palette: Readonly<Record<string, string>>;
10
+ /** The figure's rows changed outside a text update: a raster landed or rendering fell back to code. */
11
+ onChange: () => void;
12
+ }
13
+ export declare class SvgFigure implements Component {
14
+ #private;
15
+ constructor(options: SvgFigureOptions);
16
+ /** Feed the fence body; `final` stops streaming, while `closed` records its closing marker. */
17
+ update(source: string, final: boolean, closed: boolean, palette?: Readonly<Record<string, string>>): void;
18
+ render(width: number): string[];
19
+ invalidate(): void;
20
+ dispose(): void;
21
+ debugState(): Record<string, unknown>;
22
+ get pending(): boolean;
23
+ get failed(): boolean;
24
+ /** Whether this failure may recover when terminal raster limits change. */
25
+ get retryable(): boolean;
26
+ }
@@ -0,0 +1,32 @@
1
+ /** One run of an assistant text block: prose, or the body of a ```svg fence. */
2
+ export type FigureSegment = {
3
+ readonly kind: "markdown";
4
+ readonly text: string;
5
+ }
6
+ /** `closed` once the fence's closing line arrived. */
7
+ | {
8
+ readonly kind: "svg";
9
+ readonly source: string;
10
+ readonly closed: boolean;
11
+ };
12
+ /** Whether `markdown` holds a ```svg fence {@link splitSvgFences} would lift. */
13
+ export declare function hasSvgFence(markdown: string): boolean;
14
+ /**
15
+ * Split `markdown` into prose and ```svg fence bodies, in order. Only
16
+ * top-level fences lift; an svg fence nested in another fence, a list's
17
+ * indented block or a blockquote stays prose. Blank prose runs are dropped.
18
+ * An svg fence still open at the end is the last segment, `closed: false`.
19
+ */
20
+ export declare function splitSvgFences(markdown: string): FigureSegment[];
21
+ /** Whether a Marked SVG code token's raw source contains its closing fence. */
22
+ export declare function isClosedSvgFence(raw: string): boolean;
23
+ /**
24
+ * Turn a possibly truncated SVG source into a well-formed document: cut the
25
+ * trailing construct still being written (a tag without its `>`, an
26
+ * unterminated comment, CDATA section, processing instruction or declaration,
27
+ * a dangling `&entity`) and close every element left open, innermost first.
28
+ * A complete document passes through unchanged. Returns null until the root
29
+ * `<svg …>` start tag is complete — before that there is nothing to draw.
30
+ */
31
+ export declare function closePartialSvg(source: string): string | null;
32
+ export declare function prepareSvg(svg: string, palette: Readonly<Record<string, string>>): string;
@@ -21,6 +21,7 @@ export declare function resetMarkdownHighlightCallCount(): void;
21
21
  export declare const __markdownPerfCounters: {
22
22
  lexerInvocations: number;
23
23
  lexedBytes: number;
24
+ svgFenceDetectionCalls: number;
24
25
  highlightAdmissionScans: number;
25
26
  reset(): void;
26
27
  };
@@ -74,8 +75,27 @@ export interface MarkdownTheme {
74
75
  * Return null to fall back to fenced code rendering.
75
76
  */
76
77
  resolveMermaidAscii?: (source: string) => string | null;
78
+ /**
79
+ * Resolve an SVG figure component by fenced block source text. Components
80
+ * are retained by fence order. Return null for a stable fenced-code fallback
81
+ * that disposes a previous component; return undefined for a fallback that
82
+ * preserves the previous component and should be retried on the next render.
83
+ */
84
+ resolveSvgFigure?: (source: string, context: SvgFigureResolveContext) => Component | null | undefined;
77
85
  symbols: SymbolTheme;
78
86
  }
87
+ export interface SvgFigureResolveContext {
88
+ /** Whether the containing message is still streaming. */
89
+ streaming: boolean;
90
+ /** The individual code fence has received its closing marker. */
91
+ closed: boolean;
92
+ /** Request a repaint after the figure's asynchronous output changes. */
93
+ onChange: () => void;
94
+ /** Available render width in terminal cells. */
95
+ width: number;
96
+ /** The component previously resolved for this fence position, if any. */
97
+ previous?: Component;
98
+ }
79
99
  export declare class Markdown implements Component {
80
100
  #private;
81
101
  constructor(text: string, paddingX: number, paddingY: number, theme: MarkdownTheme, defaultTextStyle?: DefaultTextStyle, codeBlockIndent?: number);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/tui",
4
- "version": "0.18.11",
4
+ "version": "0.18.12",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -36,8 +36,8 @@
36
36
  "fmt": "biome format --write ."
37
37
  },
38
38
  "dependencies": {
39
- "@gajae-code/natives": "0.18.11",
40
- "@gajae-code/utils": "0.18.11",
39
+ "@gajae-code/natives": "0.18.12",
40
+ "@gajae-code/utils": "0.18.12",
41
41
  "lru-cache": "11.3.6",
42
42
  "marked": "18.0.6"
43
43
  },
@@ -0,0 +1,405 @@
1
+ /**
2
+ * A ```svg fence drawn as an inline terminal image. The source is rasterized
3
+ * off the JS thread and shown through {@link Image}; while the fence streams,
4
+ * each throttled revision repairs the partial source and replaces the
5
+ * previous raster, which stays on screen until the next one lands.
6
+ *
7
+ * Sizing is natural rather than preview-capped: the figure takes as many columns as its
8
+ * drawing needs — up to the full width, and a viewport-relative height. The
9
+ * raster is drawn at the terminal's device pixels per unit, padded to whole
10
+ * cells, and redrawn when the room it is shown in changes, so the terminal
11
+ * places it 1:1: any resampling, even 0.99×, blurs every glyph edge.
12
+ *
13
+ * Source: oh-my-pi (https://github.com/can1357/oh-my-pi)
14
+ */
15
+ import type { rasterizeSvg as rasterizeSvgFn } from "@gajae-code/natives";
16
+ import { logger } from "@gajae-code/utils";
17
+ import { Image, type ImageTheme } from "../components/image";
18
+ import { type CellDimensions, getCellDimensions, getImageDimensions, TERMINAL } from "../terminal-capabilities";
19
+ import type { Component } from "../tui";
20
+ import { closePartialSvg, prepareSvg } from "./svg-source";
21
+
22
+ type NativeRasterizeSvg = typeof rasterizeSvgFn;
23
+ let nativeRasterizeSvg: NativeRasterizeSvg | undefined;
24
+
25
+ function rasterizeSvg(
26
+ input: Uint8Array,
27
+ maxWidthPx: number,
28
+ maxHeightPx: number,
29
+ cellWidthPx: number,
30
+ cellHeightPx: number,
31
+ ): Promise<Uint8Array> {
32
+ nativeRasterizeSvg ??= (require("@gajae-code/natives") as { rasterizeSvg: NativeRasterizeSvg }).rasterizeSvg;
33
+ return nativeRasterizeSvg(input, maxWidthPx, maxHeightPx, cellWidthPx, cellHeightPx);
34
+ }
35
+
36
+ /** Least time between rasters of a fence that is still streaming. */
37
+ const STREAM_INTERVAL_MS = 200;
38
+ /** Longest raster edge; the native rasterizer caps the surface at 4096² pixels. */
39
+ const MAX_EDGE_PX = 4096;
40
+ /** Share of the terminal's rows one figure may fill, so it fits on screen whole. */
41
+ const MAX_VIEWPORT_SHARE = 0.8;
42
+
43
+ /** Cells a figure may fill. */
44
+ interface Limits {
45
+ columns: number;
46
+ rows: number;
47
+ }
48
+
49
+ /** One rasterized revision and the inputs that produced it. */
50
+ interface Raster {
51
+ source: string;
52
+ /** Cell size the raster was padded to; its pixels are whole cells of it. */
53
+ cell: CellDimensions;
54
+ limits: Limits;
55
+ /** Base64 PNG. */
56
+ data: string;
57
+ widthPx: number;
58
+ heightPx: number;
59
+ }
60
+
61
+ /** Inputs of a raster attempt, successful or not. */
62
+ interface Attempt {
63
+ source: string;
64
+ palette: Readonly<Record<string, string>>;
65
+ cell: CellDimensions;
66
+ limits: Limits;
67
+ final: boolean;
68
+ closed: boolean;
69
+ }
70
+
71
+ interface Layout {
72
+ width: number;
73
+ cell: CellDimensions;
74
+ limits: Limits;
75
+ }
76
+
77
+ /** Cells a figure rendered at `width` may fill: the image's width, a share of the viewport, and the raster edge cap. */
78
+ function limitsFor(width: number, cell: CellDimensions): Limits {
79
+ const viewportRows = Math.floor((process.stdout.rows || 24) * MAX_VIEWPORT_SHARE);
80
+ return {
81
+ columns: Math.max(1, Math.min(Math.max(1, width - 2), Math.floor(MAX_EDGE_PX / cell.widthPx))),
82
+ rows: Math.max(1, Math.min(viewportRows, Math.floor(MAX_EDGE_PX / cell.heightPx))),
83
+ };
84
+ }
85
+
86
+ export interface SvgFigureOptions {
87
+ /**
88
+ * Theme for rendering the image fallback text.
89
+ */
90
+ theme: ImageTheme;
91
+ /** CSS colors substituted for SVG theme variables and currentColor. */
92
+ palette: Readonly<Record<string, string>>;
93
+ /** The figure's rows changed outside a text update: a raster landed or rendering fell back to code. */
94
+ onChange: () => void;
95
+ }
96
+
97
+ export class SvgFigure implements Component {
98
+ readonly #options: SvgFigureOptions;
99
+ #palette: Readonly<Record<string, string>>;
100
+ #source = "";
101
+ /** No more text arrives for this fence: no throttling, and a failed raster falls back to code. */
102
+ #final = false;
103
+ /** Whether the fence had its closing marker; only open fences are repaired. */
104
+ #closed = false;
105
+ #raster: Raster | undefined;
106
+ #attempt: Attempt | undefined;
107
+ /** Final source that could not be drawn; rendered as plaintext. */
108
+ #failed: string | undefined;
109
+ /** Whether retrying the failed source at different pixel limits could succeed. */
110
+ #failedLayoutDependent = false;
111
+ #rasterizing = false;
112
+ #startedAt = Number.NEGATIVE_INFINITY;
113
+ #timer: NodeJS.Timeout | undefined;
114
+ /** A {@link #schedule} is queued for the end of the current task. */
115
+ #queued = false;
116
+ #disposed = false;
117
+ /** Last render width; rasters wait for the first render to know their room. */
118
+ #width: number | undefined;
119
+ #layout: Layout | undefined;
120
+ #image: { raster: Raster; cell: CellDimensions; columns: number; rows: number; component: Image } | undefined;
121
+ #graphicsUnavailableNotified = false;
122
+
123
+ constructor(options: SvgFigureOptions) {
124
+ this.#options = options;
125
+ this.#palette = options.palette;
126
+ }
127
+
128
+ /** Feed the fence body; `final` stops streaming, while `closed` records its closing marker. */
129
+ update(source: string, final: boolean, closed: boolean, palette = this.#palette): void {
130
+ const sourceChanged = source !== this.#source;
131
+ const paletteChanged = palette !== this.#palette;
132
+ const closedChanged = closed !== this.#closed;
133
+ if (!sourceChanged && !paletteChanged && final === this.#final && !closedChanged) return;
134
+ if (sourceChanged || paletteChanged || closedChanged) {
135
+ this.#failed = undefined;
136
+ this.#failedLayoutDependent = false;
137
+ }
138
+ this.#source = source;
139
+ this.#final = final;
140
+ this.#closed = closed;
141
+ this.#palette = palette;
142
+ // Deferred: a block built mid-stream is marked streaming right after it
143
+ // is mounted, when render() was called already (e.g. by diffing); a
144
+ // schedule() queued before then is dropped, so the next render() starts
145
+ // a new one.
146
+ if (!this.#queued) {
147
+ this.#queued = true;
148
+ queueMicrotask(() => {
149
+ this.#queued = false;
150
+ this.#schedule();
151
+ });
152
+ }
153
+ }
154
+
155
+ render(width: number): string[] {
156
+ if (!TERMINAL.imageProtocol) {
157
+ clearTimeout(this.#timer);
158
+ this.#timer = undefined;
159
+ if (!this.#graphicsUnavailableNotified) {
160
+ this.#graphicsUnavailableNotified = true;
161
+ this.#options.onChange();
162
+ }
163
+ return [];
164
+ }
165
+ // Source updates may arrive while graphics are unavailable, so a restored
166
+ // protocol must retry even when the visible layout did not change.
167
+ const graphicsRestored = this.#graphicsUnavailableNotified;
168
+ this.#graphicsUnavailableNotified = false;
169
+ const cell = getCellDimensions();
170
+ const limits = limitsFor(width, cell);
171
+ const previousLayout = this.#layout;
172
+ const cellChanged =
173
+ previousLayout?.cell.widthPx !== cell.widthPx || previousLayout.cell.heightPx !== cell.heightPx;
174
+ const layoutChanged =
175
+ previousLayout === undefined ||
176
+ previousLayout.width !== width ||
177
+ cellChanged ||
178
+ previousLayout.limits.columns !== limits.columns ||
179
+ previousLayout.limits.rows !== limits.rows;
180
+ if (layoutChanged) {
181
+ this.#width = width;
182
+ this.#layout = { width, cell, limits };
183
+ if (cellChanged) this.#image?.component.invalidate();
184
+ this.#schedule();
185
+ }
186
+ if (graphicsRestored && !layoutChanged) this.#schedule();
187
+ if (!this.#raster && !this.#failed) return [];
188
+ if (this.failed) return [];
189
+ if (this.#raster) {
190
+ const image = this.#imageFor(this.#raster, limits, cell);
191
+ return image.render(width);
192
+ }
193
+ return [];
194
+ }
195
+
196
+ invalidate(): void {
197
+ this.#image?.component.invalidate();
198
+ }
199
+
200
+ dispose(): void {
201
+ if (this.#disposed) return;
202
+ this.#disposed = true;
203
+ clearTimeout(this.#timer);
204
+ this.#timer = undefined;
205
+ this.#source = "";
206
+ this.#attempt = undefined;
207
+ this.#raster = undefined;
208
+ this.#failed = undefined;
209
+ this.#failedLayoutDependent = false;
210
+ this.#layout = undefined;
211
+ this.#width = undefined;
212
+ this.#image = undefined;
213
+ }
214
+
215
+ debugState(): Record<string, unknown> {
216
+ return {
217
+ final: this.#final,
218
+ failed: this.#failed === this.#source,
219
+ raster: this.#raster ? `${this.#raster.widthPx}x${this.#raster.heightPx}` : null,
220
+ current: this.#raster?.source === this.#source,
221
+ };
222
+ }
223
+
224
+ /** Whether the current source and cell size have not been attempted yet. */
225
+ #stale(width: number): boolean {
226
+ const attempt = this.#attempt;
227
+ const cell = getCellDimensions();
228
+ const limits = limitsFor(width, cell);
229
+ if (this.#failed === this.#source) {
230
+ if (!this.#failedLayoutDependent || !attempt) return false;
231
+ return (
232
+ attempt.cell.widthPx !== cell.widthPx ||
233
+ attempt.cell.heightPx !== cell.heightPx ||
234
+ attempt.limits.columns !== limits.columns ||
235
+ attempt.limits.rows !== limits.rows
236
+ );
237
+ }
238
+ return (
239
+ attempt === undefined ||
240
+ attempt.source !== this.#source ||
241
+ attempt.palette !== this.#palette ||
242
+ attempt.closed !== this.#closed ||
243
+ attempt.cell.widthPx !== cell.widthPx ||
244
+ attempt.cell.heightPx !== cell.heightPx ||
245
+ // A streaming attempt that failed is retried once as final.
246
+ (this.#final && !attempt.final && this.#raster?.source !== this.#source) ||
247
+ ((attempt.limits.columns !== limits.columns || attempt.limits.rows !== limits.rows) && this.#outgrown(limits))
248
+ );
249
+ }
250
+
251
+ /**
252
+ * Whether the current raster would come out differently in `limits`. One
253
+ * below both of its limits drew at its natural size and redraws only once
254
+ * it no longer fits; one that filled a limit redraws whenever they change.
255
+ */
256
+ #outgrown(limits: Limits): boolean {
257
+ const raster = this.#raster;
258
+ if (raster?.source !== this.#source) return false;
259
+ const columns = raster.widthPx / raster.cell.widthPx;
260
+ const rows = raster.heightPx / raster.cell.heightPx;
261
+ if (columns < raster.limits.columns && rows < raster.limits.rows) {
262
+ return columns > limits.columns || rows > limits.rows;
263
+ }
264
+ return raster.limits.columns !== limits.columns || raster.limits.rows !== limits.rows;
265
+ }
266
+
267
+ /** Start a raster when one is due: single-flight, throttled while streaming, immediate once final. */
268
+ #schedule(): void {
269
+ const width = this.#width;
270
+ if (this.#disposed || !TERMINAL.imageProtocol || this.#rasterizing || width === undefined) return;
271
+ if (this.#final && this.#timer) {
272
+ clearTimeout(this.#timer);
273
+ this.#timer = undefined;
274
+ }
275
+ if (this.#timer || !this.#stale(width)) return;
276
+ const wait = this.#final ? 0 : this.#startedAt + STREAM_INTERVAL_MS - performance.now();
277
+ if (wait > 0) {
278
+ this.#timer = setTimeout(() => {
279
+ this.#timer = undefined;
280
+ this.#schedule();
281
+ }, wait);
282
+ this.#timer.unref?.();
283
+ return;
284
+ }
285
+ void this.#rasterize(width);
286
+ }
287
+
288
+ async #rasterize(width: number): Promise<void> {
289
+ const cell = getCellDimensions();
290
+ const attempt: Attempt = {
291
+ source: this.#source,
292
+ palette: this.#palette,
293
+ cell,
294
+ limits: limitsFor(width, cell),
295
+ final: this.#final,
296
+ closed: this.#closed,
297
+ };
298
+ this.#attempt = attempt;
299
+ const svg = attempt.closed ? attempt.source : closePartialSvg(attempt.source);
300
+ let png: Uint8Array | undefined;
301
+ let layoutDependentFailure = false;
302
+ if (svg !== null) {
303
+ this.#rasterizing = true;
304
+ this.#startedAt = performance.now();
305
+ try {
306
+ const prepared = prepareSvg(svg, attempt.palette);
307
+ try {
308
+ png = await rasterizeSvg(
309
+ new TextEncoder().encode(prepared),
310
+ attempt.limits.columns * cell.widthPx,
311
+ attempt.limits.rows * cell.heightPx,
312
+ cell.widthPx,
313
+ cell.heightPx,
314
+ );
315
+ } catch (error) {
316
+ const message = error instanceof Error ? error.message : String(error);
317
+ layoutDependentFailure = !message.includes("Failed to parse SVG:");
318
+ throw error;
319
+ }
320
+ } catch (error) {
321
+ // A streaming prefix that does not parse yet keeps the previous raster.
322
+ if (attempt.final) logger.debug("SVG figure did not render", { error: String(error) });
323
+ } finally {
324
+ this.#rasterizing = false;
325
+ }
326
+ }
327
+ if (this.#disposed) return;
328
+ const data = png ? Buffer.from(png).toString("base64") : undefined;
329
+ const size = data ? getImageDimensions(data, "image/png") : null;
330
+ if (data && size) {
331
+ this.#show(attempt, data, size.widthPx, size.heightPx);
332
+ } else if (
333
+ attempt.final &&
334
+ attempt.source === this.#source &&
335
+ attempt.palette === this.#palette &&
336
+ attempt.closed === this.#closed
337
+ ) {
338
+ this.#failed = attempt.source;
339
+ this.#failedLayoutDependent = layoutDependentFailure;
340
+ // Clear the raster and image on terminal failure to keep preview only while a replacement is in flight.
341
+ this.#raster = undefined;
342
+ this.#image = undefined;
343
+ this.#options.onChange();
344
+ }
345
+ // The source, cell size, or room moved on while this raster ran.
346
+ this.#schedule();
347
+ }
348
+
349
+ #show(attempt: Attempt, data: string, widthPx: number, heightPx: number): void {
350
+ if (this.#failed === attempt.source) {
351
+ this.#failed = undefined;
352
+ this.#failedLayoutDependent = false;
353
+ }
354
+ this.#raster = {
355
+ source: attempt.source,
356
+ cell: attempt.cell,
357
+ limits: attempt.limits,
358
+ data,
359
+ widthPx,
360
+ heightPx,
361
+ };
362
+ this.#options.onChange();
363
+ }
364
+
365
+ /**
366
+ * The image for `raster` over the whole cells it was padded to. At the
367
+ * cell size and room it was drawn for that box is its pixels exactly; a
368
+ * raster outdated by a resize is scaled by {@link Image} until its redraw lands.
369
+ */
370
+ #imageFor(raster: Raster, limits: Limits, cell: CellDimensions): Image {
371
+ const current = this.#image;
372
+ const columns = Math.min(limits.columns, Math.ceil(raster.widthPx / cell.widthPx));
373
+ const rows = Math.min(limits.rows, Math.ceil(raster.heightPx / cell.heightPx));
374
+ if (
375
+ current?.raster === raster &&
376
+ current.cell.widthPx === cell.widthPx &&
377
+ current.cell.heightPx === cell.heightPx &&
378
+ current.columns === columns &&
379
+ current.rows === rows
380
+ ) {
381
+ return current.component;
382
+ }
383
+ const component = new Image(raster.data, "image/png", this.#options.theme, {
384
+ maxWidthCells: columns,
385
+ maxHeightCells: rows,
386
+ filename: "svg",
387
+ refetch: () => raster.data,
388
+ });
389
+ this.#image = { raster, cell, columns, rows, component };
390
+ return component;
391
+ }
392
+
393
+ get pending(): boolean {
394
+ return this.#rasterizing || (this.#final === false && this.#raster === undefined && this.#failed === undefined);
395
+ }
396
+
397
+ get failed(): boolean {
398
+ return this.#failed === this.#source;
399
+ }
400
+
401
+ /** Whether this failure may recover when terminal raster limits change. */
402
+ get retryable(): boolean {
403
+ return this.failed && this.#failedLayoutDependent;
404
+ }
405
+ }