@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.
@@ -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 } = {},
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 { $flag, $pickflag, getDebugLogPath, logger, onDefaultTabWidthChange } from "@gajae-code/utils";
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
- fs.writeFileSync(debugPath, debugData);
6442
+ this.#writeRenderDebugDump(debugData);
6390
6443
  }
6391
6444
 
6392
6445
  // Write entire buffer at once. Once those bytes are accepted, the painted