@gajae-code/tui 0.18.12 → 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 CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.19.0] - 2026-10-22
6
+
7
+ ### Fixed
8
+
9
+ - Kitty and iTerm2 image encoders now reject a payload that is not RFC 4648 base64 before writing the terminal escape, so an early APC or OSC terminator in the image data cannot inject further control sequences.
10
+
11
+ - Normalize local Markdown links to absolute, percent-encoded `file://` OSC 8 destinations, resolving relative paths from the active working directory and `~/` paths from the home directory. Link destinations are read as URI references, so percent-escapes are decoded and `#fragment` / `?query` suffixes (for example `src/file.ts#L12`) stay URI components. Network-path references (`//host/path`) are left unchanged rather than read as local absolute paths. Targets containing control characters remain non-clickable.
12
+
13
+ - The Gajae Pet sprite is uploaded as a single-escape lossless PNG instead of chunked kitty raw RGBA (`f=32`), and every kitty escape is bounded to the protocol's 4096-byte limit in both the pet and inline-image paths. A pet animation frame used to emit several ~4 KB raw-RGBA chunk continuations per tick; when the terminal dropped one mid-payload the remaining base64 was painted into the transcript as text and the frame desynchronized. A sprite frame now costs a few hundred payload characters inside one complete escape.
14
+
5
15
  ## [0.18.12] - 2026-10-16
6
16
 
7
17
  ### Added
@@ -174,7 +174,16 @@ export declare function encodeGridSixel(grid: string[], scale: number, topPaddin
174
174
  * pixel box at half size.
175
175
  */
176
176
  export declare function encodeGridIterm2(grid: string[], scale: number, columns: number, rows: number, topPaddingPx?: number, bottomPaddingPx?: number, leftPaddingPx?: number, rightPaddingPx?: number, palette?: Palette): string;
177
- /** Encode a bottom-aligned grid as kitty raw RGBA at `scale`. */
177
+ /**
178
+ * Encode a bottom-aligned grid as a kitty PNG transmission at `scale`.
179
+ *
180
+ * The canvas is padded to the full cell block (`cols * cellWidthPx`) so the
181
+ * square sprite renders 1:1 inside it. A PNG-encoded sprite is a few hundred
182
+ * base64 characters, so the upload stays inside one APC escape; raw RGBA
183
+ * (`f=32`) would need several ~4 KB chunk continuations per animation frame,
184
+ * and a terminal that drops one prints the remaining base64 into the
185
+ * transcript.
186
+ */
178
187
  export declare function encodeGridKitty(grid: string[], scale: number, imageId: number, cols: number, rows: number, topPaddingPx?: number, cellYOffsetPx?: number, leftPaddingPx?: number, rightPaddingPx?: number, palette?: Palette): string;
179
188
  export interface GajaePixelFrames {
180
189
  /** escape payload per logical frame (drawn at the current cursor cell) */
@@ -204,7 +213,8 @@ export interface GajaePixelFrames {
204
213
  * iTerm2 receives unitless `width=columns;height=rasterRows`, so it resolves the
205
214
  * padded block with its live cell metrics. The PNG has that block's pixel aspect
206
215
  * ratio, allowing `preserveAspectRatio=0` without stretching the authored square
207
- * sprite. Kitty and Sixel retain their protocol-specific paths.
216
+ * sprite. Kitty transmits the same PNG through a single kitty escape; Sixel
217
+ * keeps its own encoder.
208
218
  */
209
219
  export declare function buildGajaePixelFrames(options: {
210
220
  protocol: "sixel" | "kitty" | "iterm2";
@@ -120,6 +120,14 @@ export interface ImageRenderOptions {
120
120
  export declare function kittyImageId(base64Data: string): number;
121
121
  export declare function getCellDimensions(): CellDimensions;
122
122
  export declare function setCellDimensions(dims: CellDimensions): void;
123
+ /**
124
+ * Encode one kitty transmission behind `header` (the control string without a
125
+ * chunk flag), splitting the base64 payload so each escape fits
126
+ * {@link KITTY_MAX_ESCAPE_CHARS} and each non-final chunk keeps the protocol's
127
+ * 4-byte base64 quantum. A payload that fits in one escape carries no `m=`
128
+ * field at all.
129
+ */
130
+ export declare function encodeKittyTransmission(header: string, base64Data: string): string;
123
131
  export declare function encodeKitty(base64Data: string, options?: {
124
132
  columns?: number;
125
133
  rows?: number;
@@ -136,7 +144,8 @@ export declare function resetKittyTransmissions(): void;
136
144
  export declare function setKittyTransmitWriter(writer: (sequence: string) => void): void;
137
145
  /**
138
146
  * Encode a kitty transmit-only (`a=t`) escape: uploads image data under a
139
- * stable id without creating a placement. Chunked at 4096 bytes per spec.
147
+ * stable id without creating a placement. Chunked so every escape stays within
148
+ * {@link KITTY_MAX_ESCAPE_CHARS}.
140
149
  *
141
150
  * This is deliberately separate from placement: re-sending data (`a=t`/`a=T`)
142
151
  * for an existing image id deletes the image and ALL of its placements, so
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/tui",
4
- "version": "0.18.12",
4
+ "version": "0.19.0",
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.12",
40
- "@gajae-code/utils": "0.18.12",
39
+ "@gajae-code/natives": "0.19.0",
40
+ "@gajae-code/utils": "0.19.0",
41
41
  "lru-cache": "11.3.6",
42
42
  "marked": "18.0.6"
43
43
  },
@@ -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,3 +1,6 @@
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";
3
6
  import { hasSvgFence, isClosedSvgFence } from "../chat/svg-source";
@@ -455,17 +458,78 @@ type ListToken = Token & { items: Array<{ tokens?: Token[] }>; ordered: boolean;
455
458
  type TableCellToken = { tokens?: Token[] };
456
459
  type TableToken = Token & { header: TableCellToken[]; rows: TableCellToken[][]; raw?: string };
457
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
+
458
509
  function formatHyperlink(text: string, target: string): string {
459
510
  if (!TERMINAL.hyperlinks || !target) {
460
511
  return text;
461
512
  }
462
513
 
463
- const safeTarget = target.replaceAll("\x1b", "").replaceAll("\x07", "");
464
- if (!safeTarget) {
514
+ if (/[\u0000-\u001f\u007f-\u009f]/u.test(target)) {
465
515
  return text;
466
516
  }
467
517
 
468
- 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`;
469
533
  }
470
534
 
471
535
  function stripHtmlComments(raw: string): string {
@@ -525,6 +589,7 @@ export class Markdown implements Component {
525
589
  #hasActiveDynamicSvgFigures = false;
526
590
  #hasRetryableSvgFigureFallback = false;
527
591
  #cachedImageProtocol: typeof TERMINAL.imageProtocol | undefined;
592
+ #cachedLinkContextKey?: string;
528
593
  #dynamicFigures = new Map<number, Component>();
529
594
  #nextDynamicFigureIndex = 0;
530
595
  #disposed = false;
@@ -735,6 +800,8 @@ export class Markdown implements Component {
735
800
  // L1: per-instance cache — fastest path for repeated renders of the same
736
801
  // instance at the same width (e.g. resize debounce, repeated redraws).
737
802
  const copyEnabled = copyAnnotationsEnabled();
803
+ // Only bracketed Markdown can contain a link target; plain text stays shared.
804
+ const linkContextKey = this.#text.includes("]") ? markdownLinkContextKey() : "";
738
805
  if (
739
806
  !this.#hasActiveDynamicSvgFigures &&
740
807
  !this.#hasRetryableSvgFigureFallback &&
@@ -743,6 +810,7 @@ export class Markdown implements Component {
743
810
  this.#cachedText === this.#text &&
744
811
  this.#cachedWidth === width &&
745
812
  this.#cachedImageProtocol === TERMINAL.imageProtocol &&
813
+ this.#cachedLinkContextKey === linkContextKey &&
746
814
  this.#cachedCopyEnabled === copyEnabled &&
747
815
  (!includeAnchors || this.#cachedAnchorSpans !== undefined)
748
816
  ) {
@@ -759,6 +827,7 @@ export class Markdown implements Component {
759
827
  this.#cachedLines &&
760
828
  this.#cachedWidth === width &&
761
829
  this.#cachedImageProtocol === TERMINAL.imageProtocol &&
830
+ this.#cachedLinkContextKey === linkContextKey &&
762
831
  this.#cachedCopyEnabled === copyEnabled &&
763
832
  this.#lastFullParseAt > 0 &&
764
833
  (!includeAnchors || this.#cachedAnchorSpans !== undefined)
@@ -787,6 +856,7 @@ export class Markdown implements Component {
787
856
  this.#cachedCopyEnabled = copyEnabled;
788
857
  this.#cachedAnchorSpans = includeAnchors ? [] : undefined;
789
858
  this.#cachedImageProtocol = TERMINAL.imageProtocol;
859
+ this.#cachedLinkContextKey = linkContextKey;
790
860
  return { lines: result, spans: this.#cachedAnchorSpans };
791
861
  }
792
862
 
@@ -812,7 +882,8 @@ export class Markdown implements Component {
812
882
  // and verify it on hit so hash collisions can never serve wrong output.
813
883
  const bgColorProbe = this.#defaultTextStyle?.bgColor ? this.#defaultTextStyle.bgColor("\x01") : "";
814
884
  const headingProbe = this.#theme.heading("");
815
- 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}`;
816
887
  const cached = renderCache.get(cacheKey);
817
888
  if (
818
889
  !this.#mayHaveDynamicSvgFigures &&
@@ -828,6 +899,7 @@ export class Markdown implements Component {
828
899
  this.#cachedLines = lines;
829
900
  this.#cachedAnchorSpans = cached.anchorSpans;
830
901
  this.#cachedImageProtocol = TERMINAL.imageProtocol;
902
+ this.#cachedLinkContextKey = linkContextKey;
831
903
  if (!this.#streaming || this.#currentParse?.source !== normalizedText) this.#currentParse = undefined;
832
904
  return { lines, spans: cached.anchorSpans };
833
905
  }
@@ -1237,12 +1309,14 @@ export class Markdown implements Component {
1237
1309
  this.#cachedLines = undefined;
1238
1310
  this.#cachedAnchorSpans = undefined;
1239
1311
  this.#cachedImageProtocol = undefined;
1312
+ this.#cachedLinkContextKey = undefined;
1240
1313
  } else {
1241
1314
  this.#cachedText = renderedText;
1242
1315
  this.#cachedWidth = width;
1243
1316
  this.#cachedLines = copyBoundResult;
1244
1317
  this.#cachedAnchorSpans = anchorSpans;
1245
1318
  this.#cachedImageProtocol = TERMINAL.imageProtocol;
1319
+ this.#cachedLinkContextKey = linkContextKey;
1246
1320
  this.#lastFullParseAt = markdownNow();
1247
1321
  }
1248
1322
 
@@ -404,6 +404,79 @@ export function setCellDimensions(dims: CellDimensions): void {
404
404
  cellDimensions = dims;
405
405
  }
406
406
 
407
+ /**
408
+ * Kitty caps a single APC escape at 4096 bytes, and every chunk except the last
409
+ * must carry a payload that is a multiple of 4 bytes (each chunk is decoded as
410
+ * its own base64 group). An escape that the terminal abandons mid-payload
411
+ * surfaces the remaining base64 as printable text in the transcript, so chunks
412
+ * are sized against the whole escape (introducer + control + `;` + data + ST)
413
+ * rather than against the data alone.
414
+ */
415
+ const KITTY_MAX_ESCAPE_CHARS = 4096;
416
+ /** `\x1b_G` + `;` + `\x1b\\` — the bytes around one chunk's control and data. */
417
+ const KITTY_ESCAPE_FRAME_CHARS = 3 + 1 + 2;
418
+ const KITTY_CHUNK_FLAG_CHARS = ",m=1".length;
419
+
420
+ /**
421
+ * RFC 4648 base64, the same guard `encodeITerm2Multipart` already applies.
422
+ * The alphabet excludes ESC, ST, and BEL, so a payload cannot end an APC or OSC early.
423
+ * A linear scan, not a repeating-group regex: that regex rejects some multi-megabyte
424
+ * valid payloads in this runtime.
425
+ */
426
+ function isBase64Alphabet(code: number): boolean {
427
+ return (
428
+ (code >= 65 && code <= 90) ||
429
+ (code >= 97 && code <= 122) ||
430
+ (code >= 48 && code <= 57) ||
431
+ code === 43 ||
432
+ code === 47
433
+ );
434
+ }
435
+
436
+ function isRfc4648Base64(value: string): boolean {
437
+ const length = value.length;
438
+ if (length % 4 !== 0) return false;
439
+ let body = length;
440
+ if (length > 0 && value.charCodeAt(length - 1) === 61) {
441
+ body = value.charCodeAt(length - 2) === 61 ? length - 2 : length - 1;
442
+ }
443
+ for (let index = 0; index < body; index++) {
444
+ if (!isBase64Alphabet(value.charCodeAt(index))) return false;
445
+ }
446
+ return true;
447
+ }
448
+
449
+ function assertIterm2Base64(value: string): void {
450
+ if (!isRfc4648Base64(value)) throw new Error("Invalid RFC 4648 base64");
451
+ }
452
+
453
+ /**
454
+ * Encode one kitty transmission behind `header` (the control string without a
455
+ * chunk flag), splitting the base64 payload so each escape fits
456
+ * {@link KITTY_MAX_ESCAPE_CHARS} and each non-final chunk keeps the protocol's
457
+ * 4-byte base64 quantum. A payload that fits in one escape carries no `m=`
458
+ * field at all.
459
+ */
460
+ export function encodeKittyTransmission(header: string, base64Data: string): string {
461
+ assertIterm2Base64(base64Data);
462
+ const whole = `\x1b_G${header};${base64Data}\x1b\\`;
463
+ if (whole.length <= KITTY_MAX_ESCAPE_CHARS) return whole;
464
+ const chunks: string[] = [];
465
+ let offset = 0;
466
+ let first = true;
467
+ while (offset < base64Data.length) {
468
+ const budget =
469
+ KITTY_MAX_ESCAPE_CHARS - KITTY_ESCAPE_FRAME_CHARS - KITTY_CHUNK_FLAG_CHARS - (first ? header.length : 0);
470
+ const chunkSize = Math.max(4, budget - (budget % 4));
471
+ const chunk = base64Data.slice(offset, offset + chunkSize);
472
+ offset += chunk.length;
473
+ const more = offset < base64Data.length ? 1 : 0;
474
+ chunks.push(`\x1b_G${first ? `${header},m=${more}` : `m=${more}`};${chunk}\x1b\\`);
475
+ first = false;
476
+ }
477
+ return chunks.join("");
478
+ }
479
+
407
480
  export function encodeKitty(
408
481
  base64Data: string,
409
482
  options: {
@@ -413,8 +486,6 @@ export function encodeKitty(
413
486
  placementId?: number;
414
487
  } = {},
415
488
  ): string {
416
- const CHUNK_SIZE = 4096;
417
-
418
489
  const params: string[] = ["a=T", "f=100", "q=2"];
419
490
 
420
491
  if (options.columns) params.push(`c=${options.columns}`);
@@ -427,31 +498,7 @@ export function encodeKitty(
427
498
  if (options.placementId) params.push(`p=${options.placementId}`);
428
499
  }
429
500
 
430
- if (base64Data.length <= CHUNK_SIZE) {
431
- return `\x1b_G${params.join(",")};${base64Data}\x1b\\`;
432
- }
433
-
434
- const chunks: string[] = [];
435
- let offset = 0;
436
- let isFirst = true;
437
-
438
- while (offset < base64Data.length) {
439
- const chunk = base64Data.slice(offset, offset + CHUNK_SIZE);
440
- const isLast = offset + CHUNK_SIZE >= base64Data.length;
441
-
442
- if (isFirst) {
443
- chunks.push(`\x1b_G${params.join(",")},m=1;${chunk}\x1b\\`);
444
- isFirst = false;
445
- } else if (isLast) {
446
- chunks.push(`\x1b_Gm=0;${chunk}\x1b\\`);
447
- } else {
448
- chunks.push(`\x1b_Gm=1;${chunk}\x1b\\`);
449
- }
450
-
451
- offset += CHUNK_SIZE;
452
- }
453
-
454
- return chunks.join("");
501
+ return encodeKittyTransmission(params.join(","), base64Data);
455
502
  }
456
503
 
457
504
  /** Kitty image ids already uploaded to the terminal in this process. */
@@ -477,7 +524,8 @@ export function setKittyTransmitWriter(writer: (sequence: string) => void): void
477
524
 
478
525
  /**
479
526
  * Encode a kitty transmit-only (`a=t`) escape: uploads image data under a
480
- * stable id without creating a placement. Chunked at 4096 bytes per spec.
527
+ * stable id without creating a placement. Chunked so every escape stays within
528
+ * {@link KITTY_MAX_ESCAPE_CHARS}.
481
529
  *
482
530
  * This is deliberately separate from placement: re-sending data (`a=t`/`a=T`)
483
531
  * for an existing image id deletes the image and ALL of its placements, so
@@ -485,34 +533,7 @@ export function setKittyTransmitWriter(writer: (sequence: string) => void): void
485
533
  * {@link encodeKittyPlacement} only.
486
534
  */
487
535
  export function encodeKittyTransmit(base64Data: string, imageId: number): string {
488
- const CHUNK_SIZE = 4096;
489
- const params = ["a=t", "f=100", "q=2", `i=${imageId}`];
490
-
491
- if (base64Data.length <= CHUNK_SIZE) {
492
- return `\x1b_G${params.join(",")};${base64Data}\x1b\\`;
493
- }
494
-
495
- const chunks: string[] = [];
496
- let offset = 0;
497
- let isFirst = true;
498
-
499
- while (offset < base64Data.length) {
500
- const chunk = base64Data.slice(offset, offset + CHUNK_SIZE);
501
- const isLast = offset + CHUNK_SIZE >= base64Data.length;
502
-
503
- if (isFirst) {
504
- chunks.push(`\x1b_G${params.join(",")},m=1;${chunk}\x1b\\`);
505
- isFirst = false;
506
- } else if (isLast) {
507
- chunks.push(`\x1b_Gm=0;${chunk}\x1b\\`);
508
- } else {
509
- chunks.push(`\x1b_Gm=1;${chunk}\x1b\\`);
510
- }
511
-
512
- offset += CHUNK_SIZE;
513
- }
514
-
515
- return chunks.join("");
536
+ return encodeKittyTransmission(["a=t", "f=100", "q=2", `i=${imageId}`].join(","), base64Data);
516
537
  }
517
538
 
518
539
  /**
@@ -603,6 +624,7 @@ export function encodeITerm2(
603
624
  inline?: boolean;
604
625
  } = {},
605
626
  ): string {
627
+ assertIterm2Base64(base64Data);
606
628
  const params: string[] = [`inline=${options.inline !== false ? 1 : 0}`];
607
629
 
608
630
  if (options.width !== undefined) params.push(`width=${options.width}`);
@@ -825,6 +847,7 @@ export function renderImage(
825
847
  const fit = calculateImageFit(imageDimensions, options, cellDims);
826
848
 
827
849
  if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
850
+ if (!isRfc4648Base64(base64Data)) return null;
828
851
  const imageId = options.imageId ?? kittyImageId(base64Data);
829
852
  const placementId = options.placementId ?? 1;
830
853
  // Upload data once per image id (out-of-band; the transmit escape is
@@ -853,6 +876,7 @@ export function renderImage(
853
876
  }
854
877
  }
855
878
  if (TERMINAL.imageProtocol === ImageProtocol.Iterm2) {
879
+ if (!isRfc4648Base64(base64Data)) return null;
856
880
  const sequence = encodeITerm2(base64Data, {
857
881
  width: fit.columns,
858
882
  height: "auto",
@@ -896,13 +920,8 @@ export function parseITerm2CapabilityReply(input: Uint8Array | string): Iterm2Ca
896
920
  return complete ? "missing-f" : undefined;
897
921
  }
898
922
 
899
- const ITERM2_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;
900
923
  const ITERM2_MAX_CAPABILITY_BYTES = 4096;
901
924
 
902
- function assertIterm2Base64(value: string): void {
903
- if (value.length % 4 !== 0 || !ITERM2_BASE64.test(value)) throw new Error("Invalid RFC 4648 base64");
904
- }
905
-
906
925
  export function encodeITerm2Multipart(
907
926
  base64Data: string,
908
927
  options: { width?: number | string; height?: number | string } = {},