@sayknow-cli/tui 0.7.11 → 0.7.13
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 +1 -1
- package/dist/types/terminal-capabilities.d.ts +1 -0
- package/package.json +3 -3
- package/src/components/markdown.ts +78 -4
- package/src/terminal-capabilities.ts +38 -0
- package/src/tui.ts +58 -5
package/CHANGELOG.md
CHANGED
|
@@ -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.
|
|
4
|
+
"version": "0.7.13",
|
|
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.
|
|
42
|
-
"@sayknow-cli/utils": "0.7.
|
|
41
|
+
"@sayknow-cli/natives": "0.7.13",
|
|
42
|
+
"@sayknow-cli/utils": "0.7.13",
|
|
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
|
-
|
|
225
|
-
if (!safeTarget) {
|
|
275
|
+
if (/[\u0000-\u001f\u007f-\u009f]/u.test(target)) {
|
|
226
276
|
return text;
|
|
227
277
|
}
|
|
228
278
|
|
|
229
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
3702
|
+
this.#writeRenderDebugDump(debugData);
|
|
3650
3703
|
}
|
|
3651
3704
|
|
|
3652
3705
|
// Write entire buffer at once. Once those bytes are accepted, the painted
|