@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
|
-
/**
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
40
|
-
"@gajae-code/utils": "0.
|
|
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.
|
|
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,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
|
-
|
|
464
|
-
if (!safeTarget) {
|
|
514
|
+
if (/[\u0000-\u001f\u007f-\u009f]/u.test(target)) {
|
|
465
515
|
return text;
|
|
466
516
|
}
|
|
467
517
|
|
|
468
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 } = {},
|