@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.
@@ -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. No PNG round
114
- // trip — sixel and kitty raw-RGBA are generated straight from the grids.
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 validatePngGrid(
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("iTerm2 pet grid must be non-empty and rectangular");
759
+ throw new Error("pet grid must be non-empty and rectangular");
759
760
  }
760
- if (!Number.isFinite(scale) || scale <= 0) throw new Error("iTerm2 pet scale must be finite and positive");
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("iTerm2 pet PNG dimensions are out of bounds");
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("iTerm2 pet PNG allocation is out of bounds");
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
- * Encode a grid as an iTerm2 inline PNG spanning a terminal cell block.
797
+ * Rasterize a pet grid into a lossless PNG canvas.
798
798
  *
799
- * The escape's `width`/`height` are the reserved cell-block footprint in
800
- * character cells (unitless numbers per the iTerm2 inline-images protocol).
801
- * iTerm2 resolves cells against its own live font metrics, so the sprite
802
- * scales with the real terminal geometry — including Retina, where iTerm2
803
- * divides `Npx` values by the backing-scale factor and would render a fixed
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
- export function encodeGridIterm2(
805
+ function encodePixelPng(
807
806
  grid: string[],
808
807
  scale: number,
809
- columns: number,
810
- rows: number,
811
- topPaddingPx = 0,
812
- bottomPaddingPx = 0,
813
- leftPaddingPx = 0,
814
- rightPaddingPx = 0,
815
- palette: Palette = RED_PALETTE,
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
- /** Encode a bottom-aligned grid as kitty raw RGBA at `scale`. */
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 gw = grid[0].length;
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
- let out = `\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`;
921
- for (let off = 0, first = true; off < data.length; off += CHUNK, first = false) {
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 and Sixel retain their protocol-specific paths.
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
- const safeTarget = target.replaceAll("\x1b", "").replaceAll("\x07", "");
441
- if (!safeTarget) {
514
+ if (/[\u0000-\u001f\u007f-\u009f]/u.test(target)) {
442
515
  return text;
443
516
  }
444
517
 
445
- return `\x1b]8;;${safeTarget}\x07${text}\x1b]8;;\x07`;
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 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}\x00${bgColorProbe}\x00${headingProbe}`;
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
- // Update L1 per-instance cache
1108
- this.#cachedText = renderedText;
1109
- this.#cachedWidth = width;
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.#cachedLines = copyBoundResult;
1112
- this.#cachedAnchorSpans = anchorSpans;
1113
- this.#lastFullParseAt = markdownNow();
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);