@gajae-code/tui 0.18.11 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/dist/types/chat/svg-figure.d.ts +26 -0
- package/dist/types/chat/svg-source.d.ts +32 -0
- package/dist/types/components/gajae-pet.d.ts +12 -2
- package/dist/types/components/markdown.d.ts +20 -0
- package/dist/types/terminal-capabilities.d.ts +10 -1
- package/package.json +3 -3
- package/src/chat/svg-figure.ts +405 -0
- package/src/chat/svg-source.ts +837 -0
- package/src/components/gajae-pet.ts +72 -74
- package/src/components/markdown.ts +238 -11
- package/src/terminal-capabilities.ts +80 -61
- package/src/tui.ts +58 -5
|
@@ -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 } = {},
|
package/src/tui.ts
CHANGED
|
@@ -4,7 +4,14 @@
|
|
|
4
4
|
import * as fs from "node:fs";
|
|
5
5
|
import * as path from "node:path";
|
|
6
6
|
import { performance } from "node:perf_hooks";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
$flag,
|
|
9
|
+
$pickflag,
|
|
10
|
+
getDebugLogPath,
|
|
11
|
+
logger,
|
|
12
|
+
onDefaultTabWidthChange,
|
|
13
|
+
redactCrashSecrets,
|
|
14
|
+
} from "@gajae-code/utils";
|
|
8
15
|
import { getKeybindings } from "./keybindings";
|
|
9
16
|
import { isKeyRelease } from "./keys";
|
|
10
17
|
import { renderMetrics } from "./metrics";
|
|
@@ -999,6 +1006,23 @@ function createPreparationCancellation(entry: RenderPreparation): () => void {
|
|
|
999
1006
|
|
|
1000
1007
|
function cancelNoPreparation(): void {}
|
|
1001
1008
|
|
|
1009
|
+
function filesystemErrorCode(error: unknown): string | undefined {
|
|
1010
|
+
if (error === null || typeof error !== "object" || !("code" in error)) return undefined;
|
|
1011
|
+
return typeof error.code === "string" ? error.code : undefined;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// JSON.stringify writes ESC as "\\u001b", and a CSI final is a letter, so a
|
|
1015
|
+
// following "Bearer" has no word boundary. Split that join, scrub, then restore.
|
|
1016
|
+
function redactTuiRenderDump(debugData: string): string {
|
|
1017
|
+
const separateCsiFinal = (match: string) => `${match}\u0000`;
|
|
1018
|
+
const separated = debugData
|
|
1019
|
+
.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, separateCsiFinal)
|
|
1020
|
+
.replace(/\\u001[bB]\[[0-9;?]*[A-Za-z]/g, separateCsiFinal);
|
|
1021
|
+
return redactCrashSecrets(separated)
|
|
1022
|
+
.replace(/\u001b\[[0-9;?]*[A-Za-z]\u0000/g, match => match.slice(0, -1))
|
|
1023
|
+
.replace(/\\u001[bB]\[[0-9;?]*[A-Za-z]\u0000/g, match => match.slice(0, -1));
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1002
1026
|
/**
|
|
1003
1027
|
* TUI - Main class for managing terminal UI with differential rendering
|
|
1004
1028
|
*/
|
|
@@ -1274,6 +1298,38 @@ export class TUI extends Container {
|
|
|
1274
1298
|
fs.appendFileSync(getDebugLogPath(), message);
|
|
1275
1299
|
}
|
|
1276
1300
|
|
|
1301
|
+
// Sibling of the debug log. Create the parent only when it is missing.
|
|
1302
|
+
// The dump file is opened exclusive and no-follow, then written through that fd.
|
|
1303
|
+
#writeRenderDebugDump(debugData: string): void {
|
|
1304
|
+
const debugDir = path.dirname(getDebugLogPath());
|
|
1305
|
+
if (!fs.existsSync(debugDir)) {
|
|
1306
|
+
fs.mkdirSync(debugDir, { recursive: true, mode: 0o700 });
|
|
1307
|
+
fs.chmodSync(debugDir, 0o700);
|
|
1308
|
+
}
|
|
1309
|
+
// stat follows a symlink to the real agent directory; a directory we do
|
|
1310
|
+
// not own is not a place for a session frame.
|
|
1311
|
+
const dirStat = fs.statSync(debugDir);
|
|
1312
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
1313
|
+
if (!dirStat.isDirectory() || (uid !== undefined && dirStat.uid !== uid)) return;
|
|
1314
|
+
|
|
1315
|
+
const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
|
|
1316
|
+
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW ?? 0);
|
|
1317
|
+
let fd: number;
|
|
1318
|
+
try {
|
|
1319
|
+
fd = fs.openSync(debugPath, flags, 0o600);
|
|
1320
|
+
} catch (error) {
|
|
1321
|
+
const code = filesystemErrorCode(error);
|
|
1322
|
+
if (code === "EEXIST" || code === "ELOOP") return;
|
|
1323
|
+
throw error;
|
|
1324
|
+
}
|
|
1325
|
+
try {
|
|
1326
|
+
fs.writeFileSync(fd, redactTuiRenderDump(debugData));
|
|
1327
|
+
fs.fchmodSync(fd, 0o600);
|
|
1328
|
+
} finally {
|
|
1329
|
+
fs.closeSync(fd);
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1277
1333
|
#visibleWidthForDifferentialGuard(line: string): number {
|
|
1278
1334
|
if (line.length === 0) return 0;
|
|
1279
1335
|
const cached = this.#lineEmitWidthCache.get(line);
|
|
@@ -6360,9 +6416,6 @@ export class TUI extends Container {
|
|
|
6360
6416
|
buffer = this.#frameSynchronizedOutput(buffer);
|
|
6361
6417
|
|
|
6362
6418
|
if ($pickflag("GJC_TUI_DEBUG", "PI_TUI_DEBUG")) {
|
|
6363
|
-
const debugDir = "/tmp/tui";
|
|
6364
|
-
fs.mkdirSync(debugDir, { recursive: true });
|
|
6365
|
-
const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
|
|
6366
6419
|
const debugData = [
|
|
6367
6420
|
`firstChanged: ${firstChanged}`,
|
|
6368
6421
|
`viewportTop: ${viewportTop}`,
|
|
@@ -6386,7 +6439,7 @@ export class TUI extends Container {
|
|
|
6386
6439
|
"=== buffer ===",
|
|
6387
6440
|
JSON.stringify(buffer),
|
|
6388
6441
|
].join("\n");
|
|
6389
|
-
|
|
6442
|
+
this.#writeRenderDebugDump(debugData);
|
|
6390
6443
|
}
|
|
6391
6444
|
|
|
6392
6445
|
// Write entire buffer at once. Once those bytes are accepted, the painted
|