@sayknow-cli/tui 0.7.10 → 0.7.12

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,7 +2,7 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
- ## [0.7.10] - 2026-10-20
5
+ ## [0.7.12] - 2026-10-24
6
6
 
7
7
  ## [0.7.2] - 2026-09-29
8
8
 
@@ -203,6 +203,7 @@ export interface ImageRenderOptions {
203
203
  export declare function kittyImageId(base64Data: string): number;
204
204
  export declare function getCellDimensions(): CellDimensions;
205
205
  export declare function setCellDimensions(dims: CellDimensions): void;
206
+ export declare function isRfc4648Base64(value: string): boolean;
206
207
  export declare function encodeKitty(base64Data: string, options?: {
207
208
  columns?: number;
208
209
  rows?: number;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@sayknow-cli/tui",
4
- "version": "0.7.10",
4
+ "version": "0.7.12",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://sayknow-cli.com",
7
7
  "author": "jaybeyond",
@@ -38,8 +38,8 @@
38
38
  "fmt": "biome format --write ."
39
39
  },
40
40
  "dependencies": {
41
- "@sayknow-cli/natives": "0.7.10",
42
- "@sayknow-cli/utils": "0.7.10",
41
+ "@sayknow-cli/natives": "0.7.12",
42
+ "@sayknow-cli/utils": "0.7.12",
43
43
  "lru-cache": "11.3.6",
44
44
  "marked": "^18.0.3"
45
45
  },
@@ -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 type { SymbolTheme } from "../symbols";
@@ -216,17 +219,78 @@ type ListToken = Token & { items: Array<{ tokens?: Token[] }>; ordered: boolean;
216
219
  type TableCellToken = { tokens?: Token[] };
217
220
  type TableToken = Token & { header: TableCellToken[]; rows: TableCellToken[][]; raw?: string };
218
221
 
222
+ /** Relative markdown links follow the active session's process cwd and home. */
223
+ function markdownLinkContextKey(): string {
224
+ let cwd = "<unavailable>";
225
+ let home = "<unavailable>";
226
+ try {
227
+ cwd = process.cwd();
228
+ } catch {}
229
+ try {
230
+ home = os.homedir();
231
+ } catch {}
232
+ return `${cwd}\x00${home}`;
233
+ }
234
+
235
+ /** Decodes valid `%XX` runs; a `%` that does not start a valid UTF-8 escape stays a literal percent sign. */
236
+ function decodePercentEscapes(value: string): string {
237
+ return value.replace(/(?:%[0-9a-f]{2})+/giu, escapes => {
238
+ try {
239
+ return decodeURIComponent(escapes);
240
+ } catch {
241
+ return escapes;
242
+ }
243
+ });
244
+ }
245
+
246
+ /**
247
+ * Converts a local Markdown link destination to an absolute `file://` URI. The destination is a URI
248
+ * reference: its path may be percent-encoded, and `?` or `#` starts the query or fragment, which stay
249
+ * URI components instead of becoming part of the filename.
250
+ */
251
+ function localFileUri(target: string): string {
252
+ const suffixStart = target.search(/[?#]/u);
253
+ const pathPart = suffixStart === -1 ? target : target.slice(0, suffixStart);
254
+ const suffix = suffixStart === -1 ? "" : target.slice(suffixStart);
255
+ const filePath = decodePercentEscapes(pathPart);
256
+ let absolutePath: string;
257
+ if (path.isAbsolute(filePath)) {
258
+ absolutePath = filePath;
259
+ } else if (filePath === "~") {
260
+ absolutePath = os.homedir();
261
+ } else if (filePath.startsWith("~/")) {
262
+ absolutePath = path.resolve(os.homedir(), filePath.slice(2));
263
+ } else {
264
+ absolutePath = path.resolve(process.cwd(), filePath);
265
+ }
266
+ const fileUrl = url.pathToFileURL(absolutePath);
267
+ return suffix ? new URL(suffix, fileUrl).href : fileUrl.href;
268
+ }
269
+
219
270
  function formatHyperlink(text: string, target: string): string {
220
271
  if (!TERMINAL.hyperlinks || !target) {
221
272
  return text;
222
273
  }
223
274
 
224
- const safeTarget = target.replaceAll("\x1b", "").replaceAll("\x07", "");
225
- if (!safeTarget) {
275
+ if (/[\u0000-\u001f\u007f-\u009f]/u.test(target)) {
226
276
  return text;
227
277
  }
228
278
 
229
- return `\x1b]8;;${safeTarget}\x07${text}\x1b]8;;\x07`;
279
+ // `#fragment` and `//authority/path` (network-path reference) are URI references, not local paths.
280
+ let destination = target;
281
+ if (
282
+ !target.startsWith("#") &&
283
+ !target.startsWith("//") &&
284
+ (path.isAbsolute(target) || !/^[a-z][a-z\d+.-]*:/iu.test(target))
285
+ ) {
286
+ try {
287
+ destination = localFileUri(target);
288
+ } catch {
289
+ return text;
290
+ }
291
+ }
292
+
293
+ return `\x1b]8;;${destination}\x07${text}\x1b]8;;\x07`;
230
294
  }
231
295
 
232
296
  function stripHtmlComments(raw: string): string {
@@ -267,6 +331,7 @@ export class Markdown implements Component {
267
331
  #cachedWidth?: number;
268
332
  #cachedLines?: string[];
269
333
  #cachedAnchorSpans?: Array<ViewportAnchorSpan | null>;
334
+ #cachedLinkContextKey?: string;
270
335
 
271
336
  #streaming = false;
272
337
  #lastFullParseAt = 0;
@@ -343,6 +408,7 @@ export class Markdown implements Component {
343
408
  this.#cachedWidth = undefined;
344
409
  this.#cachedLines = undefined;
345
410
  this.#cachedAnchorSpans = undefined;
411
+ this.#cachedLinkContextKey = undefined;
346
412
  }
347
413
 
348
414
  #exceedsHighlightCap(code: string): boolean {
@@ -393,10 +459,13 @@ export class Markdown implements Component {
393
459
  #render(width: number, includeAnchors: boolean): { lines: string[]; spans?: Array<ViewportAnchorSpan | null> } {
394
460
  // L1: per-instance cache — fastest path for repeated renders of the same
395
461
  // instance at the same width (e.g. resize debounce, repeated redraws).
462
+ // Only bracketed Markdown can contain a link target; plain text stays shared.
463
+ const linkContextKey = this.#text.includes("]") ? markdownLinkContextKey() : "";
396
464
  if (
397
465
  this.#cachedLines &&
398
466
  this.#cachedText === this.#text &&
399
467
  this.#cachedWidth === width &&
468
+ this.#cachedLinkContextKey === linkContextKey &&
400
469
  (!includeAnchors || this.#cachedAnchorSpans !== undefined)
401
470
  ) {
402
471
  return { lines: this.#cachedLines, spans: this.#cachedAnchorSpans };
@@ -409,6 +478,7 @@ export class Markdown implements Component {
409
478
  this.#streaming &&
410
479
  this.#cachedLines &&
411
480
  this.#cachedWidth === width &&
481
+ this.#cachedLinkContextKey === linkContextKey &&
412
482
  this.#lastFullParseAt > 0 &&
413
483
  (!includeAnchors || this.#cachedAnchorSpans !== undefined)
414
484
  ) {
@@ -427,6 +497,7 @@ export class Markdown implements Component {
427
497
  this.#cachedWidth = width;
428
498
  this.#cachedLines = result;
429
499
  this.#cachedAnchorSpans = includeAnchors ? [] : undefined;
500
+ this.#cachedLinkContextKey = linkContextKey;
430
501
  return { lines: result, spans: this.#cachedAnchorSpans };
431
502
  }
432
503
 
@@ -442,7 +513,8 @@ export class Markdown implements Component {
442
513
  // and verify it on hit so hash collisions can never serve wrong output.
443
514
  const bgColorProbe = this.#defaultTextStyle?.bgColor ? this.#defaultTextStyle.bgColor("\x01") : "";
444
515
  const headingProbe = this.#theme.heading("");
445
- const cacheKey = `${contentKey}\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}`;
516
+ const linkContextSuffix = linkContextKey ? `\x00${linkContextKey}` : "";
517
+ const cacheKey = `${contentKey}\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}`;
446
518
  const cached = renderCache.get(cacheKey);
447
519
  if (
448
520
  cached !== undefined &&
@@ -454,6 +526,7 @@ export class Markdown implements Component {
454
526
  this.#cachedWidth = width;
455
527
  this.#cachedLines = cached.lines;
456
528
  this.#cachedAnchorSpans = cached.anchorSpans;
529
+ this.#cachedLinkContextKey = linkContextKey;
457
530
  return { lines: cached.lines, spans: cached.anchorSpans };
458
531
  }
459
532
 
@@ -620,6 +693,7 @@ export class Markdown implements Component {
620
693
  this.#cachedWidth = width;
621
694
  this.#cachedLines = result;
622
695
  this.#cachedAnchorSpans = anchorSpans;
696
+ this.#cachedLinkContextKey = linkContextKey;
623
697
  this.#lastFullParseAt = markdownNow();
624
698
 
625
699
  // Update L2 module-level LRU so future instances with the same key skip
@@ -627,6 +627,39 @@ export function setCellDimensions(dims: CellDimensions): void {
627
627
  cellDimensions = dims;
628
628
  }
629
629
 
630
+ /**
631
+ * RFC 4648 base64. The alphabet excludes ESC, ST, and BEL, so a payload cannot
632
+ * end an APC or OSC early and smuggle a terminal escape out of an image.
633
+ * A linear scan, not a repeating-group regex: that regex rejects some
634
+ * multi-megabyte valid payloads in this runtime.
635
+ */
636
+ function isBase64Alphabet(code: number): boolean {
637
+ return (
638
+ (code >= 65 && code <= 90) ||
639
+ (code >= 97 && code <= 122) ||
640
+ (code >= 48 && code <= 57) ||
641
+ code === 43 ||
642
+ code === 47
643
+ );
644
+ }
645
+
646
+ export function isRfc4648Base64(value: string): boolean {
647
+ const length = value.length;
648
+ if (length % 4 !== 0) return false;
649
+ let body = length;
650
+ if (length > 0 && value.charCodeAt(length - 1) === 61) {
651
+ body = value.charCodeAt(length - 2) === 61 ? length - 2 : length - 1;
652
+ }
653
+ for (let index = 0; index < body; index++) {
654
+ if (!isBase64Alphabet(value.charCodeAt(index))) return false;
655
+ }
656
+ return true;
657
+ }
658
+
659
+ function assertImageBase64(value: string): void {
660
+ if (!isRfc4648Base64(value)) throw new Error("Invalid RFC 4648 base64");
661
+ }
662
+
630
663
  export function encodeKitty(
631
664
  base64Data: string,
632
665
  options: {
@@ -636,6 +669,7 @@ export function encodeKitty(
636
669
  placementId?: number;
637
670
  } = {},
638
671
  ): string {
672
+ assertImageBase64(base64Data);
639
673
  const CHUNK_SIZE = 4096;
640
674
 
641
675
  const params: string[] = ["a=T", "f=100", "q=2"];
@@ -711,6 +745,7 @@ export function setKittyTransmitWriter(writer: (sequence: string) => void): void
711
745
  * {@link encodeKittyPlacement} only.
712
746
  */
713
747
  export function encodeKittyTransmit(base64Data: string, imageId: number): string {
748
+ assertImageBase64(base64Data);
714
749
  const CHUNK_SIZE = 4096;
715
750
  const params = ["a=t", "f=100", "q=2", `i=${imageId}`];
716
751
 
@@ -767,6 +802,7 @@ export function encodeITerm2(
767
802
  inline?: boolean;
768
803
  } = {},
769
804
  ): string {
805
+ assertImageBase64(base64Data);
770
806
  const params: string[] = [`inline=${options.inline !== false ? 1 : 0}`];
771
807
 
772
808
  if (options.width !== undefined) params.push(`width=${options.width}`);
@@ -984,6 +1020,8 @@ export function renderImage(
984
1020
  if (!TERMINAL.imageProtocol) {
985
1021
  return null;
986
1022
  }
1023
+ // An image that is not RFC 4648 base64 could close the escape early; render the text fallback.
1024
+ if (!isRfc4648Base64(base64Data)) return null;
987
1025
 
988
1026
  const cellDims = getCellDimensions();
989
1027
  const fit = calculateImageFit(imageDimensions, options, cellDims);
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 "@sayknow-cli/utils";
7
+ import {
8
+ $flag,
9
+ $pickflag,
10
+ getDebugLogPath,
11
+ logger,
12
+ onDefaultTabWidthChange,
13
+ redactCrashSecrets,
14
+ } from "@sayknow-cli/utils";
8
15
  import { getKeybindings } from "./keybindings";
9
16
  import { isKeyRelease } from "./keys";
10
17
  import { renderMetrics } from "./metrics";
@@ -685,6 +692,23 @@ export interface PostRenderFrameInfo {
685
692
  rows: number;
686
693
  }
687
694
 
695
+ function filesystemErrorCode(error: unknown): string | undefined {
696
+ if (error === null || typeof error !== "object" || !("code" in error)) return undefined;
697
+ return typeof error.code === "string" ? error.code : undefined;
698
+ }
699
+
700
+ // JSON.stringify writes ESC as "\\u001b", and a CSI final is a letter, so a
701
+ // following "Bearer" has no word boundary. Split that join, scrub, then restore.
702
+ function redactTuiRenderDump(debugData: string): string {
703
+ const separateCsiFinal = (match: string) => `${match}\u0000`;
704
+ const separated = debugData
705
+ .replace(/\u001b\[[0-9;?]*[A-Za-z]/g, separateCsiFinal)
706
+ .replace(/\\u001[bB]\[[0-9;?]*[A-Za-z]/g, separateCsiFinal);
707
+ return redactCrashSecrets(separated)
708
+ .replace(/\u001b\[[0-9;?]*[A-Za-z]\u0000/g, match => match.slice(0, -1))
709
+ .replace(/\\u001[bB]\[[0-9;?]*[A-Za-z]\u0000/g, match => match.slice(0, -1));
710
+ }
711
+
688
712
  export type PostRenderEmitter = (frame: PostRenderFrameInfo) => string | null;
689
713
 
690
714
  export class TUI extends Container {
@@ -821,6 +845,38 @@ export class TUI extends Container {
821
845
  fs.appendFileSync(getDebugLogPath(), message);
822
846
  }
823
847
 
848
+ // Sibling of the debug log. Create the parent only when it is missing.
849
+ // The dump file is opened exclusive and no-follow, then written through that fd.
850
+ #writeRenderDebugDump(debugData: string): void {
851
+ const debugDir = path.dirname(getDebugLogPath());
852
+ if (!fs.existsSync(debugDir)) {
853
+ fs.mkdirSync(debugDir, { recursive: true, mode: 0o700 });
854
+ fs.chmodSync(debugDir, 0o700);
855
+ }
856
+ // stat follows a symlink to the real agent directory; a directory we do
857
+ // not own is not a place for a session frame.
858
+ const dirStat = fs.statSync(debugDir);
859
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
860
+ if (!dirStat.isDirectory() || (uid !== undefined && dirStat.uid !== uid)) return;
861
+
862
+ const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
863
+ const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW ?? 0);
864
+ let fd: number;
865
+ try {
866
+ fd = fs.openSync(debugPath, flags, 0o600);
867
+ } catch (error) {
868
+ const code = filesystemErrorCode(error);
869
+ if (code === "EEXIST" || code === "ELOOP") return;
870
+ throw error;
871
+ }
872
+ try {
873
+ fs.writeFileSync(fd, redactTuiRenderDump(debugData));
874
+ fs.fchmodSync(fd, 0o600);
875
+ } finally {
876
+ fs.closeSync(fd);
877
+ }
878
+ }
879
+
824
880
  #visibleWidthForDifferentialGuard(line: string): number {
825
881
  const cached = this.#lineEmitWidthCache.get(line);
826
882
  if (cached !== undefined) return cached;
@@ -3620,9 +3676,6 @@ export class TUI extends Container {
3620
3676
  buffer += "\x1b[?2026l"; // End synchronized output
3621
3677
 
3622
3678
  if ($pickflag("SKC_TUI_DEBUG", "PI_TUI_DEBUG")) {
3623
- const debugDir = "/tmp/tui";
3624
- fs.mkdirSync(debugDir, { recursive: true });
3625
- const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
3626
3679
  const debugData = [
3627
3680
  `firstChanged: ${firstChanged}`,
3628
3681
  `viewportTop: ${viewportTop}`,
@@ -3646,7 +3699,7 @@ export class TUI extends Container {
3646
3699
  "=== buffer ===",
3647
3700
  JSON.stringify(buffer),
3648
3701
  ].join("\n");
3649
- fs.writeFileSync(debugPath, debugData);
3702
+ this.#writeRenderDebugDump(debugData);
3650
3703
  }
3651
3704
 
3652
3705
  // Write entire buffer at once. Once those bytes are accepted, the painted