@gajae-code/tui 0.18.11 → 0.19.0
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 +24 -0
- package/dist/types/chat/svg-figure.d.ts +26 -0
- package/dist/types/chat/svg-source.d.ts +32 -0
- package/dist/types/components/gajae-pet.d.ts +12 -2
- package/dist/types/components/markdown.d.ts +20 -0
- package/dist/types/terminal-capabilities.d.ts +10 -1
- package/package.json +3 -3
- package/src/chat/svg-figure.ts +405 -0
- package/src/chat/svg-source.ts +837 -0
- package/src/components/gajae-pet.ts +72 -74
- package/src/components/markdown.ts +238 -11
- package/src/terminal-capabilities.ts +80 -61
- package/src/tui.ts +58 -5
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as zlib from "node:zlib";
|
|
2
2
|
|
|
3
|
-
import { encodeITerm2Multipart, wrapITerm2RecordsForTmux } from "../terminal-capabilities";
|
|
3
|
+
import { encodeITerm2Multipart, encodeKittyTransmission, wrapITerm2RecordsForTmux } from "../terminal-capabilities";
|
|
4
4
|
import {
|
|
5
5
|
OUROBOROS_HEART_STEPS,
|
|
6
6
|
OUROBOROS_IDLE_STEPS,
|
|
@@ -110,8 +110,9 @@ const OUROBOROS_PALETTE: Palette = {
|
|
|
110
110
|
|
|
111
111
|
// ------------------------------------------------------------------------
|
|
112
112
|
// Real-pixel frames (codex-pets style): the same grids encoded as terminal
|
|
113
|
-
// image escapes, for absolute-positioned overlay rendering.
|
|
114
|
-
//
|
|
113
|
+
// image escapes, for absolute-positioned overlay rendering. Sixel is encoded
|
|
114
|
+
// straight from the grids; kitty and iTerm2 both ship a losslessly rasterized
|
|
115
|
+
// PNG so the upload stays a single, small escape.
|
|
115
116
|
// ------------------------------------------------------------------------
|
|
116
117
|
|
|
117
118
|
// 16x16 full-body grids used by the pixel pet -----------------------------
|
|
@@ -737,7 +738,7 @@ function pngChunk(type: string, data: Uint8Array): Uint8Array {
|
|
|
737
738
|
return out;
|
|
738
739
|
}
|
|
739
740
|
|
|
740
|
-
function
|
|
741
|
+
function validatePixelGrid(
|
|
741
742
|
grid: string[],
|
|
742
743
|
scale: number,
|
|
743
744
|
topPaddingPx: number,
|
|
@@ -755,17 +756,16 @@ function validatePngGrid(
|
|
|
755
756
|
const gridHeight = grid.length;
|
|
756
757
|
const gridWidth = grid[0]?.length ?? 0;
|
|
757
758
|
if (gridHeight === 0 || gridWidth === 0 || grid.some(row => row.length !== gridWidth)) {
|
|
758
|
-
throw new Error("
|
|
759
|
+
throw new Error("pet grid must be non-empty and rectangular");
|
|
759
760
|
}
|
|
760
|
-
if (!Number.isFinite(scale) || scale <= 0) throw new Error("
|
|
761
|
+
if (!Number.isFinite(scale) || scale <= 0) throw new Error("pet scale must be finite and positive");
|
|
761
762
|
for (const [name, value] of [
|
|
762
763
|
["top padding", topPaddingPx],
|
|
763
764
|
["bottom padding", bottomPaddingPx],
|
|
764
765
|
["left padding", leftPaddingPx],
|
|
765
766
|
["right padding", rightPaddingPx],
|
|
766
767
|
] as const) {
|
|
767
|
-
if (!Number.isSafeInteger(value) || value < 0)
|
|
768
|
-
throw new Error(`iTerm2 pet ${name} must be a non-negative integer`);
|
|
768
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`pet ${name} must be a non-negative integer`);
|
|
769
769
|
}
|
|
770
770
|
const spriteWidth = Math.round(gridWidth * scale);
|
|
771
771
|
const spriteHeight = Math.round(gridHeight * scale);
|
|
@@ -783,46 +783,35 @@ function validatePngGrid(
|
|
|
783
783
|
width > MAX_PET_PNG_DIMENSION ||
|
|
784
784
|
height > MAX_PET_PNG_DIMENSION
|
|
785
785
|
) {
|
|
786
|
-
throw new Error("
|
|
786
|
+
throw new Error("pet PNG dimensions are out of bounds");
|
|
787
787
|
}
|
|
788
788
|
const stride = width * 4 + 1;
|
|
789
789
|
const rawBytes = stride * height;
|
|
790
790
|
if (!Number.isSafeInteger(rawBytes) || rawBytes > MAX_PET_PNG_RAW_BYTES) {
|
|
791
|
-
throw new Error("
|
|
791
|
+
throw new Error("pet PNG allocation is out of bounds");
|
|
792
792
|
}
|
|
793
793
|
return { gridWidth, gridHeight, spriteWidth, spriteHeight, width, height };
|
|
794
794
|
}
|
|
795
795
|
|
|
796
796
|
/**
|
|
797
|
-
*
|
|
797
|
+
* Rasterize a pet grid into a lossless PNG canvas.
|
|
798
798
|
*
|
|
799
|
-
*
|
|
800
|
-
*
|
|
801
|
-
*
|
|
802
|
-
*
|
|
803
|
-
*
|
|
804
|
-
* pixel box at half size.
|
|
799
|
+
* Both terminal pixel protocols consume PNG: iTerm2 inline images require it,
|
|
800
|
+
* and kitty's PNG transmission keeps a 36x36 sprite under ~300 base64
|
|
801
|
+
* characters — a single APC escape. Kitty raw-RGBA (`f=32`) instead needs
|
|
802
|
+
* multi-chunk transmissions of ~7 KB per frame, and a terminal that abandons a
|
|
803
|
+
* chunk mid-payload prints the remaining base64 into the transcript.
|
|
805
804
|
*/
|
|
806
|
-
|
|
805
|
+
function encodePixelPng(
|
|
807
806
|
grid: string[],
|
|
808
807
|
scale: number,
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
): string {
|
|
817
|
-
for (const [name, value] of [
|
|
818
|
-
["column count", columns],
|
|
819
|
-
["row count", rows],
|
|
820
|
-
] as const) {
|
|
821
|
-
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
822
|
-
throw new Error(`iTerm2 pet ${name} must be a positive integer`);
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
const { gridWidth, gridHeight, spriteWidth, spriteHeight, width, height } = validatePngGrid(
|
|
808
|
+
topPaddingPx: number,
|
|
809
|
+
bottomPaddingPx: number,
|
|
810
|
+
leftPaddingPx: number,
|
|
811
|
+
rightPaddingPx: number,
|
|
812
|
+
palette: Palette,
|
|
813
|
+
): Buffer {
|
|
814
|
+
const { gridWidth, gridHeight, spriteWidth, spriteHeight, width, height } = validatePixelGrid(
|
|
826
815
|
grid,
|
|
827
816
|
scale,
|
|
828
817
|
topPaddingPx,
|
|
@@ -863,6 +852,39 @@ export function encodeGridIterm2(
|
|
|
863
852
|
pngChunk("IDAT", compressed),
|
|
864
853
|
pngChunk("IEND", new Uint8Array()),
|
|
865
854
|
]);
|
|
855
|
+
return png;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Encode a grid as an iTerm2 inline PNG spanning a terminal cell block.
|
|
860
|
+
*
|
|
861
|
+
* The escape's `width`/`height` are the reserved cell-block footprint in
|
|
862
|
+
* character cells (unitless numbers per the iTerm2 inline-images protocol).
|
|
863
|
+
* iTerm2 resolves cells against its own live font metrics, so the sprite
|
|
864
|
+
* scales with the real terminal geometry — including Retina, where iTerm2
|
|
865
|
+
* divides `Npx` values by the backing-scale factor and would render a fixed
|
|
866
|
+
* pixel box at half size.
|
|
867
|
+
*/
|
|
868
|
+
export function encodeGridIterm2(
|
|
869
|
+
grid: string[],
|
|
870
|
+
scale: number,
|
|
871
|
+
columns: number,
|
|
872
|
+
rows: number,
|
|
873
|
+
topPaddingPx = 0,
|
|
874
|
+
bottomPaddingPx = 0,
|
|
875
|
+
leftPaddingPx = 0,
|
|
876
|
+
rightPaddingPx = 0,
|
|
877
|
+
palette: Palette = RED_PALETTE,
|
|
878
|
+
): string {
|
|
879
|
+
for (const [name, value] of [
|
|
880
|
+
["column count", columns],
|
|
881
|
+
["row count", rows],
|
|
882
|
+
] as const) {
|
|
883
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
884
|
+
throw new Error(`iTerm2 pet ${name} must be a positive integer`);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
const png = encodePixelPng(grid, scale, topPaddingPx, bottomPaddingPx, leftPaddingPx, rightPaddingPx, palette);
|
|
866
888
|
// Size in character cells, not pixels: iTerm2 applies its own cell metrics
|
|
867
889
|
// (and divides px values by the Retina backing scale), so cells keep the
|
|
868
890
|
// padded canvas 1:1 with the reserved block at every geometry.
|
|
@@ -872,7 +894,16 @@ export function encodeGridIterm2(
|
|
|
872
894
|
return `\x1b]1337;File=${params}:${png.toString("base64")}\x1b\\`;
|
|
873
895
|
}
|
|
874
896
|
|
|
875
|
-
/**
|
|
897
|
+
/**
|
|
898
|
+
* Encode a bottom-aligned grid as a kitty PNG transmission at `scale`.
|
|
899
|
+
*
|
|
900
|
+
* The canvas is padded to the full cell block (`cols * cellWidthPx`) so the
|
|
901
|
+
* square sprite renders 1:1 inside it. A PNG-encoded sprite is a few hundred
|
|
902
|
+
* base64 characters, so the upload stays inside one APC escape; raw RGBA
|
|
903
|
+
* (`f=32`) would need several ~4 KB chunk continuations per animation frame,
|
|
904
|
+
* and a terminal that drops one prints the remaining base64 into the
|
|
905
|
+
* transcript.
|
|
906
|
+
*/
|
|
876
907
|
export function encodeGridKitty(
|
|
877
908
|
grid: string[],
|
|
878
909
|
scale: number,
|
|
@@ -885,47 +916,13 @@ export function encodeGridKitty(
|
|
|
885
916
|
rightPaddingPx = 0,
|
|
886
917
|
palette: Palette = RED_PALETTE,
|
|
887
918
|
): string {
|
|
888
|
-
const
|
|
889
|
-
const gh = grid.length;
|
|
890
|
-
const spriteW = Math.round(gw * scale);
|
|
891
|
-
// Pad the canvas to the full cell block (cols*cellWidth) so the square sprite
|
|
892
|
-
// renders 1:1 within it.
|
|
893
|
-
const w = spriteW + leftPaddingPx + rightPaddingPx;
|
|
894
|
-
const h = Math.round(gh * scale) + topPaddingPx;
|
|
895
|
-
const rgba = new Uint8Array(w * h * 4);
|
|
896
|
-
for (let y = 0; y < h; y++) {
|
|
897
|
-
for (let x = 0; x < w; x++) {
|
|
898
|
-
const sourceX = x - leftPaddingPx;
|
|
899
|
-
const sourceY = y - topPaddingPx;
|
|
900
|
-
const rgb =
|
|
901
|
-
sourceX < 0 || sourceX >= spriteW || sourceY < 0
|
|
902
|
-
? null
|
|
903
|
-
: palette[
|
|
904
|
-
grid[Math.min(gh - 1, Math.floor(sourceY / scale))][Math.min(gw - 1, Math.floor(sourceX / scale))]
|
|
905
|
-
];
|
|
906
|
-
if (!rgb) continue;
|
|
907
|
-
const o = (y * w + x) * 4;
|
|
908
|
-
rgba[o] = rgb[0];
|
|
909
|
-
rgba[o + 1] = rgb[1];
|
|
910
|
-
rgba[o + 2] = rgb[2];
|
|
911
|
-
rgba[o + 3] = 255;
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
const data = Buffer.from(rgba).toString("base64");
|
|
915
|
-
const CHUNK = 4000;
|
|
919
|
+
const png = encodePixelPng(grid, scale, topPaddingPx, 0, leftPaddingPx, rightPaddingPx, palette);
|
|
916
920
|
// `Y=` offsets the sprite down by sub-cell pixels within the first cell — the
|
|
917
921
|
// kitty analogue of the sixel top-padding drop. `C=1` keeps the placement
|
|
918
922
|
// cursor-neutral so the overlay never nudges the composer's real cursor.
|
|
919
923
|
const yParam = cellYOffsetPx > 0 ? `,Y=${Math.round(cellYOffsetPx)}` : "";
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
const chunk = data.slice(off, off + CHUNK);
|
|
923
|
-
const more = off + CHUNK < data.length ? 1 : 0;
|
|
924
|
-
out += first
|
|
925
|
-
? `\x1b_Ga=T,f=32,s=${w},v=${h},c=${cols},r=${rows},i=${imageId},q=2,C=1${yParam},m=${more};${chunk}\x1b\\`
|
|
926
|
-
: `\x1b_Gm=${more};${chunk}\x1b\\`;
|
|
927
|
-
}
|
|
928
|
-
return out;
|
|
924
|
+
const control = `a=T,f=100,c=${cols},r=${rows},i=${imageId},q=2,C=1${yParam}`;
|
|
925
|
+
return `\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\${encodeKittyTransmission(control, png.toString("base64"))}`;
|
|
929
926
|
}
|
|
930
927
|
|
|
931
928
|
export interface GajaePixelFrames {
|
|
@@ -957,7 +954,8 @@ export interface GajaePixelFrames {
|
|
|
957
954
|
* iTerm2 receives unitless `width=columns;height=rasterRows`, so it resolves the
|
|
958
955
|
* padded block with its live cell metrics. The PNG has that block's pixel aspect
|
|
959
956
|
* ratio, allowing `preserveAspectRatio=0` without stretching the authored square
|
|
960
|
-
* sprite. Kitty
|
|
957
|
+
* sprite. Kitty transmits the same PNG through a single kitty escape; Sixel
|
|
958
|
+
* keeps its own encoder.
|
|
961
959
|
*/
|
|
962
960
|
export function buildGajaePixelFrames(options: {
|
|
963
961
|
protocol: "sixel" | "kitty" | "iterm2";
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import * as os from "node:os";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as url from "node:url";
|
|
1
4
|
import { LRUCache } from "lru-cache/raw";
|
|
2
5
|
import { Marked, marked, type Token, Tokenizer, type Tokens } from "marked";
|
|
6
|
+
import { hasSvgFence, isClosedSvgFence } from "../chat/svg-source";
|
|
3
7
|
import type { SymbolTheme } from "../symbols";
|
|
4
8
|
import { TERMINAL } from "../terminal-capabilities";
|
|
5
9
|
import type { Component } from "../tui";
|
|
@@ -327,10 +331,12 @@ export function resetMarkdownHighlightCallCount(): void {
|
|
|
327
331
|
export const __markdownPerfCounters = {
|
|
328
332
|
lexerInvocations: 0,
|
|
329
333
|
lexedBytes: 0,
|
|
334
|
+
svgFenceDetectionCalls: 0,
|
|
330
335
|
highlightAdmissionScans: 0,
|
|
331
336
|
reset(): void {
|
|
332
337
|
this.lexerInvocations = 0;
|
|
333
338
|
this.lexedBytes = 0;
|
|
339
|
+
this.svgFenceDetectionCalls = 0;
|
|
334
340
|
this.highlightAdmissionScans = 0;
|
|
335
341
|
},
|
|
336
342
|
};
|
|
@@ -420,9 +426,29 @@ export interface MarkdownTheme {
|
|
|
420
426
|
* Return null to fall back to fenced code rendering.
|
|
421
427
|
*/
|
|
422
428
|
resolveMermaidAscii?: (source: string) => string | null;
|
|
429
|
+
/**
|
|
430
|
+
* Resolve an SVG figure component by fenced block source text. Components
|
|
431
|
+
* are retained by fence order. Return null for a stable fenced-code fallback
|
|
432
|
+
* that disposes a previous component; return undefined for a fallback that
|
|
433
|
+
* preserves the previous component and should be retried on the next render.
|
|
434
|
+
*/
|
|
435
|
+
resolveSvgFigure?: (source: string, context: SvgFigureResolveContext) => Component | null | undefined;
|
|
423
436
|
symbols: SymbolTheme;
|
|
424
437
|
}
|
|
425
438
|
|
|
439
|
+
export interface SvgFigureResolveContext {
|
|
440
|
+
/** Whether the containing message is still streaming. */
|
|
441
|
+
streaming: boolean;
|
|
442
|
+
/** The individual code fence has received its closing marker. */
|
|
443
|
+
closed: boolean;
|
|
444
|
+
/** Request a repaint after the figure's asynchronous output changes. */
|
|
445
|
+
onChange: () => void;
|
|
446
|
+
/** Available render width in terminal cells. */
|
|
447
|
+
width: number;
|
|
448
|
+
/** The component previously resolved for this fence position, if any. */
|
|
449
|
+
previous?: Component;
|
|
450
|
+
}
|
|
451
|
+
|
|
426
452
|
interface InlineStyleContext {
|
|
427
453
|
applyText: (text: string) => string;
|
|
428
454
|
stylePrefix: string;
|
|
@@ -432,17 +458,78 @@ type ListToken = Token & { items: Array<{ tokens?: Token[] }>; ordered: boolean;
|
|
|
432
458
|
type TableCellToken = { tokens?: Token[] };
|
|
433
459
|
type TableToken = Token & { header: TableCellToken[]; rows: TableCellToken[][]; raw?: string };
|
|
434
460
|
|
|
461
|
+
/** Relative markdown links follow the active session's process cwd and home. */
|
|
462
|
+
function markdownLinkContextKey(): string {
|
|
463
|
+
let cwd = "<unavailable>";
|
|
464
|
+
let home = "<unavailable>";
|
|
465
|
+
try {
|
|
466
|
+
cwd = process.cwd();
|
|
467
|
+
} catch {}
|
|
468
|
+
try {
|
|
469
|
+
home = os.homedir();
|
|
470
|
+
} catch {}
|
|
471
|
+
return `${cwd}\x00${home}`;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Decodes valid `%XX` runs; a `%` that does not start a valid UTF-8 escape stays a literal percent sign. */
|
|
475
|
+
function decodePercentEscapes(value: string): string {
|
|
476
|
+
return value.replace(/(?:%[0-9a-f]{2})+/giu, escapes => {
|
|
477
|
+
try {
|
|
478
|
+
return decodeURIComponent(escapes);
|
|
479
|
+
} catch {
|
|
480
|
+
return escapes;
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Converts a local Markdown link destination to an absolute `file://` URI. The destination is a URI
|
|
487
|
+
* reference: its path may be percent-encoded, and `?` or `#` starts the query or fragment, which stay
|
|
488
|
+
* URI components instead of becoming part of the filename.
|
|
489
|
+
*/
|
|
490
|
+
function localFileUri(target: string): string {
|
|
491
|
+
const suffixStart = target.search(/[?#]/u);
|
|
492
|
+
const pathPart = suffixStart === -1 ? target : target.slice(0, suffixStart);
|
|
493
|
+
const suffix = suffixStart === -1 ? "" : target.slice(suffixStart);
|
|
494
|
+
const filePath = decodePercentEscapes(pathPart);
|
|
495
|
+
let absolutePath: string;
|
|
496
|
+
if (path.isAbsolute(filePath)) {
|
|
497
|
+
absolutePath = filePath;
|
|
498
|
+
} else if (filePath === "~") {
|
|
499
|
+
absolutePath = os.homedir();
|
|
500
|
+
} else if (filePath.startsWith("~/")) {
|
|
501
|
+
absolutePath = path.resolve(os.homedir(), filePath.slice(2));
|
|
502
|
+
} else {
|
|
503
|
+
absolutePath = path.resolve(process.cwd(), filePath);
|
|
504
|
+
}
|
|
505
|
+
const fileUrl = url.pathToFileURL(absolutePath);
|
|
506
|
+
return suffix ? new URL(suffix, fileUrl).href : fileUrl.href;
|
|
507
|
+
}
|
|
508
|
+
|
|
435
509
|
function formatHyperlink(text: string, target: string): string {
|
|
436
510
|
if (!TERMINAL.hyperlinks || !target) {
|
|
437
511
|
return text;
|
|
438
512
|
}
|
|
439
513
|
|
|
440
|
-
|
|
441
|
-
if (!safeTarget) {
|
|
514
|
+
if (/[\u0000-\u001f\u007f-\u009f]/u.test(target)) {
|
|
442
515
|
return text;
|
|
443
516
|
}
|
|
444
517
|
|
|
445
|
-
|
|
518
|
+
// `#fragment` and `//authority/path` (network-path reference) are URI references, not local paths.
|
|
519
|
+
let destination = target;
|
|
520
|
+
if (
|
|
521
|
+
!target.startsWith("#") &&
|
|
522
|
+
!target.startsWith("//") &&
|
|
523
|
+
(path.isAbsolute(target) || !/^[a-z][a-z\d+.-]*:/iu.test(target))
|
|
524
|
+
) {
|
|
525
|
+
try {
|
|
526
|
+
destination = localFileUri(target);
|
|
527
|
+
} catch {
|
|
528
|
+
return text;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return `\x1b]8;;${destination}\x07${text}\x1b]8;;\x07`;
|
|
446
533
|
}
|
|
447
534
|
|
|
448
535
|
function stripHtmlComments(raw: string): string {
|
|
@@ -496,6 +583,16 @@ export class Markdown implements Component {
|
|
|
496
583
|
#lastFullParseAt = 0;
|
|
497
584
|
#onStaleThrottle?: () => void;
|
|
498
585
|
#staleThrottleTimer?: ReturnType<typeof setTimeout>;
|
|
586
|
+
#mayHaveDynamicSvgFigures: boolean;
|
|
587
|
+
#svgFenceDetectionPending = false;
|
|
588
|
+
#svgResolverAvailable: boolean;
|
|
589
|
+
#hasActiveDynamicSvgFigures = false;
|
|
590
|
+
#hasRetryableSvgFigureFallback = false;
|
|
591
|
+
#cachedImageProtocol: typeof TERMINAL.imageProtocol | undefined;
|
|
592
|
+
#cachedLinkContextKey?: string;
|
|
593
|
+
#dynamicFigures = new Map<number, Component>();
|
|
594
|
+
#nextDynamicFigureIndex = 0;
|
|
595
|
+
#disposed = false;
|
|
499
596
|
|
|
500
597
|
constructor(
|
|
501
598
|
text: string,
|
|
@@ -506,6 +603,9 @@ export class Markdown implements Component {
|
|
|
506
603
|
codeBlockIndent: number = 2,
|
|
507
604
|
) {
|
|
508
605
|
this.#text = text;
|
|
606
|
+
this.#svgResolverAvailable = theme.resolveSvgFigure !== undefined;
|
|
607
|
+
if (this.#svgResolverAvailable) __markdownPerfCounters.svgFenceDetectionCalls++;
|
|
608
|
+
this.#mayHaveDynamicSvgFigures = this.#svgResolverAvailable && hasSvgFence(text);
|
|
509
609
|
this.#paddingX = paddingX;
|
|
510
610
|
this.#paddingY = paddingY;
|
|
511
611
|
this.#theme = theme;
|
|
@@ -522,7 +622,23 @@ export class Markdown implements Component {
|
|
|
522
622
|
this.setStreaming(options.streaming);
|
|
523
623
|
}
|
|
524
624
|
if (this.#text !== text) this.#rejectedParseKey = undefined;
|
|
625
|
+
const textChanged = this.#text !== text;
|
|
525
626
|
this.#text = text;
|
|
627
|
+
const svgResolverAvailable = this.#theme.resolveSvgFigure !== undefined;
|
|
628
|
+
const svgResolverChanged = svgResolverAvailable !== this.#svgResolverAvailable;
|
|
629
|
+
this.#svgResolverAvailable = svgResolverAvailable;
|
|
630
|
+
if (!svgResolverAvailable) {
|
|
631
|
+
this.#mayHaveDynamicSvgFigures = false;
|
|
632
|
+
this.#svgFenceDetectionPending = false;
|
|
633
|
+
} else if (this.#streaming) {
|
|
634
|
+
// Streaming writes already feed the throttled lexer; don't rescan the growing
|
|
635
|
+
// document here before that parse budget gets a chance to run.
|
|
636
|
+
if (textChanged || svgResolverChanged) this.#svgFenceDetectionPending = true;
|
|
637
|
+
} else {
|
|
638
|
+
__markdownPerfCounters.svgFenceDetectionCalls++;
|
|
639
|
+
this.#mayHaveDynamicSvgFigures = hasSvgFence(text);
|
|
640
|
+
this.#svgFenceDetectionPending = false;
|
|
641
|
+
}
|
|
526
642
|
if (this.#streaming) {
|
|
527
643
|
return;
|
|
528
644
|
}
|
|
@@ -560,9 +676,11 @@ export class Markdown implements Component {
|
|
|
560
676
|
}
|
|
561
677
|
|
|
562
678
|
dispose(): void {
|
|
679
|
+
this.#disposed = true;
|
|
563
680
|
this.#clearStaleThrottleTimer();
|
|
564
681
|
this.#currentParse = undefined;
|
|
565
682
|
this.#rejectedParseKey = undefined;
|
|
683
|
+
this.#disposeDynamicFiguresFrom(0);
|
|
566
684
|
}
|
|
567
685
|
|
|
568
686
|
invalidate(): void {
|
|
@@ -570,6 +688,48 @@ export class Markdown implements Component {
|
|
|
570
688
|
this.#cachedWidth = undefined;
|
|
571
689
|
this.#cachedLines = undefined;
|
|
572
690
|
this.#cachedAnchorSpans = undefined;
|
|
691
|
+
this.#lastFullParseAt = 0;
|
|
692
|
+
this.#clearStaleThrottleTimer();
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
#disposeDynamicFiguresFrom(startIndex: number): void {
|
|
696
|
+
for (const [index, component] of this.#dynamicFigures) {
|
|
697
|
+
if (index < startIndex) continue;
|
|
698
|
+
this.#dynamicFigures.delete(index);
|
|
699
|
+
component.dispose?.();
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
#resolveDynamicFigure(source: string, closed: boolean, width: number): Component | undefined {
|
|
704
|
+
const index = this.#nextDynamicFigureIndex++;
|
|
705
|
+
const previous = this.#dynamicFigures.get(index);
|
|
706
|
+
const component = this.#theme.resolveSvgFigure?.(source, {
|
|
707
|
+
streaming: this.#streaming,
|
|
708
|
+
closed,
|
|
709
|
+
width,
|
|
710
|
+
onChange: () => {
|
|
711
|
+
if (this.#disposed) return;
|
|
712
|
+
this.invalidate();
|
|
713
|
+
this.#onStaleThrottle?.();
|
|
714
|
+
},
|
|
715
|
+
previous,
|
|
716
|
+
});
|
|
717
|
+
if (component) {
|
|
718
|
+
if (previous && previous !== component) previous.dispose?.();
|
|
719
|
+
this.#dynamicFigures.set(index, component);
|
|
720
|
+
this.#hasActiveDynamicSvgFigures = true;
|
|
721
|
+
return component;
|
|
722
|
+
}
|
|
723
|
+
if (component === undefined) {
|
|
724
|
+
this.#hasRetryableSvgFigureFallback = true;
|
|
725
|
+
if (previous) this.#dynamicFigures.set(index, previous);
|
|
726
|
+
} else if (previous) {
|
|
727
|
+
// A stable fallback no longer needs the component or any raster data it
|
|
728
|
+
// retains. A future source change can create a fresh component.
|
|
729
|
+
this.#dynamicFigures.delete(index);
|
|
730
|
+
previous.dispose?.();
|
|
731
|
+
}
|
|
732
|
+
return undefined;
|
|
573
733
|
}
|
|
574
734
|
|
|
575
735
|
#exceedsHighlightCap(code: string): boolean {
|
|
@@ -635,13 +795,22 @@ export class Markdown implements Component {
|
|
|
635
795
|
}
|
|
636
796
|
|
|
637
797
|
#render(width: number, includeAnchors: boolean): { lines: string[]; spans?: Array<ViewportAnchorSpan | null> } {
|
|
798
|
+
if (!this.#mayHaveDynamicSvgFigures) this.#disposeDynamicFiguresFrom(0);
|
|
799
|
+
|
|
638
800
|
// L1: per-instance cache — fastest path for repeated renders of the same
|
|
639
801
|
// instance at the same width (e.g. resize debounce, repeated redraws).
|
|
640
802
|
const copyEnabled = copyAnnotationsEnabled();
|
|
803
|
+
// Only bracketed Markdown can contain a link target; plain text stays shared.
|
|
804
|
+
const linkContextKey = this.#text.includes("]") ? markdownLinkContextKey() : "";
|
|
641
805
|
if (
|
|
806
|
+
!this.#hasActiveDynamicSvgFigures &&
|
|
807
|
+
!this.#hasRetryableSvgFigureFallback &&
|
|
808
|
+
!this.#svgFenceDetectionPending &&
|
|
642
809
|
this.#cachedLines &&
|
|
643
810
|
this.#cachedText === this.#text &&
|
|
644
811
|
this.#cachedWidth === width &&
|
|
812
|
+
this.#cachedImageProtocol === TERMINAL.imageProtocol &&
|
|
813
|
+
this.#cachedLinkContextKey === linkContextKey &&
|
|
645
814
|
this.#cachedCopyEnabled === copyEnabled &&
|
|
646
815
|
(!includeAnchors || this.#cachedAnchorSpans !== undefined)
|
|
647
816
|
) {
|
|
@@ -652,9 +821,13 @@ export class Markdown implements Component {
|
|
|
652
821
|
const contentWidth = Math.max(1, width - this.#paddingX * 2);
|
|
653
822
|
|
|
654
823
|
if (
|
|
824
|
+
!this.#hasActiveDynamicSvgFigures &&
|
|
825
|
+
!this.#hasRetryableSvgFigureFallback &&
|
|
655
826
|
this.#streaming &&
|
|
656
827
|
this.#cachedLines &&
|
|
657
828
|
this.#cachedWidth === width &&
|
|
829
|
+
this.#cachedImageProtocol === TERMINAL.imageProtocol &&
|
|
830
|
+
this.#cachedLinkContextKey === linkContextKey &&
|
|
658
831
|
this.#cachedCopyEnabled === copyEnabled &&
|
|
659
832
|
this.#lastFullParseAt > 0 &&
|
|
660
833
|
(!includeAnchors || this.#cachedAnchorSpans !== undefined)
|
|
@@ -666,9 +839,15 @@ export class Markdown implements Component {
|
|
|
666
839
|
}
|
|
667
840
|
}
|
|
668
841
|
|
|
842
|
+
this.#hasActiveDynamicSvgFigures = false;
|
|
843
|
+
this.#hasRetryableSvgFigureFallback = false;
|
|
844
|
+
|
|
669
845
|
// Don't render anything if there's no actual text
|
|
670
846
|
if (!this.#text || this.#text.trim() === "") {
|
|
671
847
|
this.#currentParse = undefined;
|
|
848
|
+
this.#mayHaveDynamicSvgFigures = false;
|
|
849
|
+
this.#svgFenceDetectionPending = false;
|
|
850
|
+
this.#disposeDynamicFiguresFrom(0);
|
|
672
851
|
const result: string[] = [];
|
|
673
852
|
// Update per-instance cache
|
|
674
853
|
this.#cachedText = this.#text;
|
|
@@ -676,6 +855,8 @@ export class Markdown implements Component {
|
|
|
676
855
|
this.#cachedLines = result;
|
|
677
856
|
this.#cachedCopyEnabled = copyEnabled;
|
|
678
857
|
this.#cachedAnchorSpans = includeAnchors ? [] : undefined;
|
|
858
|
+
this.#cachedImageProtocol = TERMINAL.imageProtocol;
|
|
859
|
+
this.#cachedLinkContextKey = linkContextKey;
|
|
679
860
|
return { lines: result, spans: this.#cachedAnchorSpans };
|
|
680
861
|
}
|
|
681
862
|
|
|
@@ -701,9 +882,12 @@ export class Markdown implements Component {
|
|
|
701
882
|
// and verify it on hit so hash collisions can never serve wrong output.
|
|
702
883
|
const bgColorProbe = this.#defaultTextStyle?.bgColor ? this.#defaultTextStyle.bgColor("\x01") : "";
|
|
703
884
|
const headingProbe = this.#theme.heading("");
|
|
704
|
-
const
|
|
885
|
+
const linkContextSuffix = linkContextKey ? `\x00${linkContextKey}` : "";
|
|
886
|
+
const cacheKey = `${renderContentKey}\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}${linkContextSuffix}\x00${bgColorProbe}\x00${headingProbe}`;
|
|
705
887
|
const cached = renderCache.get(cacheKey);
|
|
706
888
|
if (
|
|
889
|
+
!this.#mayHaveDynamicSvgFigures &&
|
|
890
|
+
!this.#svgFenceDetectionPending &&
|
|
707
891
|
cached !== undefined &&
|
|
708
892
|
cached.source === renderCacheSource &&
|
|
709
893
|
(!includeAnchors || cached.anchorSpans !== undefined)
|
|
@@ -714,6 +898,8 @@ export class Markdown implements Component {
|
|
|
714
898
|
const lines = this.#bindCopySource(cached.lines, copyEnabled);
|
|
715
899
|
this.#cachedLines = lines;
|
|
716
900
|
this.#cachedAnchorSpans = cached.anchorSpans;
|
|
901
|
+
this.#cachedImageProtocol = TERMINAL.imageProtocol;
|
|
902
|
+
this.#cachedLinkContextKey = linkContextKey;
|
|
717
903
|
if (!this.#streaming || this.#currentParse?.source !== normalizedText) this.#currentParse = undefined;
|
|
718
904
|
return { lines, spans: cached.anchorSpans };
|
|
719
905
|
}
|
|
@@ -732,6 +918,13 @@ export class Markdown implements Component {
|
|
|
732
918
|
__markdownPerfCounters.lexedBytes += normalizedText.length;
|
|
733
919
|
tokens = markdownParser.lexer(normalizedText);
|
|
734
920
|
}
|
|
921
|
+
this.#mayHaveDynamicSvgFigures =
|
|
922
|
+
this.#theme.resolveSvgFigure !== undefined &&
|
|
923
|
+
tokens.some(token => {
|
|
924
|
+
if (token.type !== "code") return false;
|
|
925
|
+
return token.lang?.trim().split(/[ \t]/, 1)[0]?.toLowerCase() === "svg";
|
|
926
|
+
});
|
|
927
|
+
this.#svgFenceDetectionPending = false;
|
|
735
928
|
if (this.#streaming && this.#text === renderedText) {
|
|
736
929
|
this.#currentParse = { source: normalizedText, tokens };
|
|
737
930
|
}
|
|
@@ -778,6 +971,7 @@ export class Markdown implements Component {
|
|
|
778
971
|
codeSource?: string;
|
|
779
972
|
}> = [];
|
|
780
973
|
let srcOffset = 0;
|
|
974
|
+
this.#nextDynamicFigureIndex = 0;
|
|
781
975
|
|
|
782
976
|
for (let i = 0; i < tokens.length; i++) {
|
|
783
977
|
const token = tokens[i];
|
|
@@ -810,6 +1004,7 @@ export class Markdown implements Component {
|
|
|
810
1004
|
codeTexts.length > 0 && codeTexts.every(text => text !== undefined) ? codeTexts.join("\n") : undefined,
|
|
811
1005
|
});
|
|
812
1006
|
}
|
|
1007
|
+
this.#disposeDynamicFiguresFrom(this.#nextDynamicFigureIndex);
|
|
813
1008
|
|
|
814
1009
|
let wrappedLines: string[];
|
|
815
1010
|
let wrappedSpans: Array<ViewportAnchorSpan | null> | undefined;
|
|
@@ -1104,17 +1299,30 @@ export class Markdown implements Component {
|
|
|
1104
1299
|
}
|
|
1105
1300
|
if (!this.#streaming || this.#text !== renderedText) this.#currentParse = undefined;
|
|
1106
1301
|
|
|
1107
|
-
//
|
|
1108
|
-
this
|
|
1109
|
-
|
|
1302
|
+
// Active figures and retryable fallbacks must render again. A stable
|
|
1303
|
+
// fenced-code fallback is safe in this instance's L1 cache, but possible
|
|
1304
|
+
// SVG fences stay out of shared L2 because resolver state can vary by instance.
|
|
1110
1305
|
const copyBoundResult = this.#bindCopySource(result, copyEnabled);
|
|
1111
|
-
this.#
|
|
1112
|
-
|
|
1113
|
-
|
|
1306
|
+
if (this.#hasActiveDynamicSvgFigures || this.#hasRetryableSvgFigureFallback) {
|
|
1307
|
+
this.#cachedText = undefined;
|
|
1308
|
+
this.#cachedWidth = undefined;
|
|
1309
|
+
this.#cachedLines = undefined;
|
|
1310
|
+
this.#cachedAnchorSpans = undefined;
|
|
1311
|
+
this.#cachedImageProtocol = undefined;
|
|
1312
|
+
this.#cachedLinkContextKey = undefined;
|
|
1313
|
+
} else {
|
|
1314
|
+
this.#cachedText = renderedText;
|
|
1315
|
+
this.#cachedWidth = width;
|
|
1316
|
+
this.#cachedLines = copyBoundResult;
|
|
1317
|
+
this.#cachedAnchorSpans = anchorSpans;
|
|
1318
|
+
this.#cachedImageProtocol = TERMINAL.imageProtocol;
|
|
1319
|
+
this.#cachedLinkContextKey = linkContextKey;
|
|
1320
|
+
this.#lastFullParseAt = markdownNow();
|
|
1321
|
+
}
|
|
1114
1322
|
|
|
1115
1323
|
// Update L2 module-level LRU so future instances with the same key skip
|
|
1116
1324
|
// the marked.lexer + highlightCode (Rust FFI) work entirely.
|
|
1117
|
-
if (!this.#streaming) {
|
|
1325
|
+
if (!this.#streaming && !this.#mayHaveDynamicSvgFigures) {
|
|
1118
1326
|
renderCache.set(cacheKey, {
|
|
1119
1327
|
source: renderCacheSource,
|
|
1120
1328
|
lines: result,
|
|
@@ -1229,6 +1437,7 @@ export class Markdown implements Component {
|
|
|
1229
1437
|
nextTokenType?: string,
|
|
1230
1438
|
styleContext?: InlineStyleContext,
|
|
1231
1439
|
units?: number[],
|
|
1440
|
+
allowSvgFigure = true,
|
|
1232
1441
|
): string[] {
|
|
1233
1442
|
const lines: string[] = [];
|
|
1234
1443
|
|
|
@@ -1278,6 +1487,22 @@ export class Markdown implements Component {
|
|
|
1278
1487
|
}
|
|
1279
1488
|
}
|
|
1280
1489
|
|
|
1490
|
+
// Handle SVG figures when available. Marked keeps the complete info
|
|
1491
|
+
// string in token.lang, so match its normalized language token.
|
|
1492
|
+
const language = token.lang?.trim().split(/[ \t]/, 1)[0]?.toLowerCase();
|
|
1493
|
+
if (allowSvgFigure && language === "svg" && this.#theme.resolveSvgFigure) {
|
|
1494
|
+
const figure = this.#resolveDynamicFigure(token.text, isClosedSvgFence(token.raw), width);
|
|
1495
|
+
if (figure) {
|
|
1496
|
+
// SVG figures are components that render directly
|
|
1497
|
+
const figureLines = figure.render(width);
|
|
1498
|
+
lines.push(...figureLines);
|
|
1499
|
+
if (nextTokenType && nextTokenType !== "space") {
|
|
1500
|
+
lines.push("");
|
|
1501
|
+
}
|
|
1502
|
+
break;
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1281
1506
|
const codeIndent = padding(this.#codeBlockIndent);
|
|
1282
1507
|
this.#emitCodeBlock(lines, token, token.text, token.lang || "", codeIndent);
|
|
1283
1508
|
if (nextTokenType && nextTokenType !== "space") {
|
|
@@ -1334,6 +1559,8 @@ export class Markdown implements Component {
|
|
|
1334
1559
|
quoteContentWidth,
|
|
1335
1560
|
nextQuoteToken?.type,
|
|
1336
1561
|
quoteInlineStyleContext,
|
|
1562
|
+
undefined,
|
|
1563
|
+
false,
|
|
1337
1564
|
);
|
|
1338
1565
|
for (let c = 0; c < childLines.length; c++) quoteLineUnit.push(i);
|
|
1339
1566
|
renderedQuoteLines.push(...childLines);
|