@f5-sales-demo/pi-tui 21.6.1 → 21.6.3
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/package.json +3 -3
- package/src/components/input.ts +5 -0
- package/src/components/markdown.ts +208 -6
- package/src/index.ts +2 -0
- package/src/latex.ts +1444 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/pi-tui",
|
|
4
|
-
"version": "21.6.
|
|
4
|
+
"version": "21.6.3",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@f5-sales-demo/pi-natives": "21.6.
|
|
41
|
-
"@f5-sales-demo/pi-utils": "21.6.
|
|
40
|
+
"@f5-sales-demo/pi-natives": "21.6.3",
|
|
41
|
+
"@f5-sales-demo/pi-utils": "21.6.3",
|
|
42
42
|
"marked": "^18.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
package/src/components/input.ts
CHANGED
|
@@ -56,6 +56,11 @@ export class Input implements Component, Focusable {
|
|
|
56
56
|
this.#cursor = Math.min(this.#cursor, value.length);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/** Position the cursor after a programmatically supplied value. */
|
|
60
|
+
setCursorToEnd(): void {
|
|
61
|
+
this.#cursor = this.#value.length;
|
|
62
|
+
}
|
|
63
|
+
|
|
59
64
|
/**
|
|
60
65
|
* Obscure the rendered text — for secret entry such as API tokens and
|
|
61
66
|
* passwords. `getValue()` still returns the real text; only the on-screen
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Marked, type Token, type TokenizerExtension, type Tokens } from "marked";
|
|
2
|
+
import { renderLatex } from "../latex";
|
|
2
3
|
import type { SymbolTheme } from "../symbols";
|
|
3
4
|
import { getImageDimensions, imageFallback, renderImage, stableKittyImageId, TERMINAL } from "../terminal-capabilities";
|
|
4
5
|
import type { Component } from "../tui";
|
|
@@ -12,6 +13,140 @@ import {
|
|
|
12
13
|
wrapTextWithAnsi,
|
|
13
14
|
} from "../utils";
|
|
14
15
|
|
|
16
|
+
interface LatexToken extends Tokens.Generic {
|
|
17
|
+
type: "latex" | "latexBlock";
|
|
18
|
+
text: string;
|
|
19
|
+
pending?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isEscaped(source: string, index: number): boolean {
|
|
23
|
+
let backslashes = 0;
|
|
24
|
+
for (let position = index - 1; position >= 0 && source[position] === "\\"; position--) {
|
|
25
|
+
backslashes++;
|
|
26
|
+
}
|
|
27
|
+
return backslashes % 2 === 1;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function findClosingDelimiter(source: string, closing: string, start: number): number {
|
|
31
|
+
let index = source.indexOf(closing, start);
|
|
32
|
+
while (index >= 0 && isEscaped(source, index)) {
|
|
33
|
+
index = source.indexOf(closing, index + closing.length);
|
|
34
|
+
}
|
|
35
|
+
return index;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function looksLikePendingDollarMath(source: string): boolean {
|
|
39
|
+
return /\\[A-Za-z]+|[_^=+*/<>()[\]|±≤≥≠≈∈→⇒∞∫∑√-]/.test(source);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function startsWithLiteralDollarToken(source: string): boolean {
|
|
43
|
+
const match = /^\$(?:\d+(?:[.,]\d+)?|[A-Z_][A-Z0-9_]*|\{[A-Za-z_][A-Za-z0-9_]*\})/.exec(source);
|
|
44
|
+
if (match === null) return false;
|
|
45
|
+
const remainder = source.slice(match[0].length);
|
|
46
|
+
return !remainder.startsWith("$") && !/^\s*[=+*<>[\]|^]/.test(remainder);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function tokenizeInlineLatex(source: string): LatexToken | undefined {
|
|
50
|
+
let opening = "";
|
|
51
|
+
let closing = "";
|
|
52
|
+
if (source.startsWith("$$")) {
|
|
53
|
+
opening = "$$";
|
|
54
|
+
closing = "$$";
|
|
55
|
+
} else if (source.startsWith("\\(")) {
|
|
56
|
+
opening = "\\(";
|
|
57
|
+
closing = "\\)";
|
|
58
|
+
} else if (source.startsWith("\\[")) {
|
|
59
|
+
opening = "\\[";
|
|
60
|
+
closing = "\\]";
|
|
61
|
+
} else if (source.startsWith("$") && !/^\$\s/.test(source) && !startsWithLiteralDollarToken(source)) {
|
|
62
|
+
opening = "$";
|
|
63
|
+
closing = "$";
|
|
64
|
+
} else {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const closingIndex = findClosingDelimiter(source, closing, opening.length);
|
|
69
|
+
if (
|
|
70
|
+
closingIndex >= 0 &&
|
|
71
|
+
opening === "$" &&
|
|
72
|
+
(/\s$/.test(source.slice(opening.length, closingIndex)) ||
|
|
73
|
+
/^\d/.test(source.slice(closingIndex + 1)) ||
|
|
74
|
+
(/^[A-Z_][A-Z0-9_]*(?:[^A-Za-z0-9_\s])?$/.test(source.slice(opening.length, closingIndex)) &&
|
|
75
|
+
/^[A-Za-z_][A-Za-z0-9_]*/.test(source.slice(closingIndex + 1))) ||
|
|
76
|
+
source.slice(opening.length, closingIndex).includes(String.fromCharCode(96)))
|
|
77
|
+
) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (closingIndex < 0) {
|
|
82
|
+
const pendingSource = source.slice(opening.length);
|
|
83
|
+
if (opening.startsWith("\\") || looksLikePendingDollarMath(pendingSource)) {
|
|
84
|
+
return { type: "latex", raw: source, text: pendingSource, pending: true };
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const text = source.slice(opening.length, closingIndex);
|
|
90
|
+
if (!text || text.includes("\n")) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
type: "latex",
|
|
96
|
+
raw: source.slice(0, closingIndex + closing.length),
|
|
97
|
+
text,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function tokenizeBlockLatex(source: string): LatexToken | undefined {
|
|
102
|
+
const dollarMatch = /^ {0,3}\$\$[ \t]*(?:\n)?([\s\S]*?)\$\$[ \t]*(?:\n|$)/.exec(source);
|
|
103
|
+
if (dollarMatch?.[1]) {
|
|
104
|
+
return { type: "latexBlock", raw: dollarMatch[0], text: dollarMatch[1].trim() };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const bracketMatch = /^ {0,3}\\\[[ \t]*(?:\n)?([\s\S]*?)\\\][ \t]*(?:\n|$)/.exec(source);
|
|
108
|
+
if (bracketMatch?.[1]) {
|
|
109
|
+
return { type: "latexBlock", raw: bracketMatch[0], text: bracketMatch[1].trim() };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const pendingBracket = /^ {0,3}\\\[[ \t]*(?:\n)?([\s\S]*)$/.exec(source);
|
|
113
|
+
if (pendingBracket) {
|
|
114
|
+
return { type: "latexBlock", raw: pendingBracket[0], text: pendingBracket[1], pending: true };
|
|
115
|
+
}
|
|
116
|
+
const pendingDollar = /^ {0,3}\$\$[ \t]*(?:\n)?([\s\S]*)$/.exec(source);
|
|
117
|
+
if (pendingDollar?.[1] && looksLikePendingDollarMath(pendingDollar[1])) {
|
|
118
|
+
return { type: "latexBlock", raw: pendingDollar[0], text: pendingDollar[1], pending: true };
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const LATEX_MARKDOWN_EXTENSIONS: readonly TokenizerExtension[] = [
|
|
124
|
+
{
|
|
125
|
+
name: "latexBlock",
|
|
126
|
+
level: "block",
|
|
127
|
+
start(source) {
|
|
128
|
+
const match = /(?:^|\n) {0,3}(?:\$\$|\\\[)/.exec(source);
|
|
129
|
+
return match ? match.index + (match[0].startsWith("\n") ? 1 : 0) : undefined;
|
|
130
|
+
},
|
|
131
|
+
tokenizer: tokenizeBlockLatex,
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: "latex",
|
|
135
|
+
level: "inline",
|
|
136
|
+
start(source) {
|
|
137
|
+
const indices = [source.indexOf("$"), source.indexOf("\\("), source.indexOf("\\[")].filter(
|
|
138
|
+
index => index >= 0,
|
|
139
|
+
);
|
|
140
|
+
return indices.length > 0 ? Math.min(...indices) : undefined;
|
|
141
|
+
},
|
|
142
|
+
tokenizer: tokenizeInlineLatex,
|
|
143
|
+
},
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
const markdownParser = new Marked({ gfm: true, async: false }).use({
|
|
147
|
+
extensions: [...LATEX_MARKDOWN_EXTENSIONS],
|
|
148
|
+
});
|
|
149
|
+
|
|
15
150
|
/**
|
|
16
151
|
* Default text styling for markdown content.
|
|
17
152
|
* Applied to all text unless overridden by markdown formatting.
|
|
@@ -30,6 +165,15 @@ export interface MarkdownMediaOptions {
|
|
|
30
165
|
onInvalidate?: () => void;
|
|
31
166
|
}
|
|
32
167
|
|
|
168
|
+
export interface MarkdownOptions {
|
|
169
|
+
/** Render supported LaTeX expressions as Unicode text (default: true). */
|
|
170
|
+
renderLatex?: boolean;
|
|
171
|
+
/** Number of spaces used to indent fenced code content (default: 2). */
|
|
172
|
+
codeBlockIndent?: number;
|
|
173
|
+
/** Resolve and render Markdown image tokens. */
|
|
174
|
+
mediaOptions?: MarkdownMediaOptions;
|
|
175
|
+
}
|
|
176
|
+
|
|
33
177
|
/** Default text styling for markdown content. */
|
|
34
178
|
export interface DefaultTextStyle {
|
|
35
179
|
/** Foreground color function */
|
|
@@ -122,6 +266,7 @@ export class Markdown implements Component {
|
|
|
122
266
|
/** Number of spaces used to indent code block content. */
|
|
123
267
|
#codeBlockIndent: number;
|
|
124
268
|
#mediaOptions?: MarkdownMediaOptions;
|
|
269
|
+
#renderLatex: boolean;
|
|
125
270
|
#mediaCache = new Map<
|
|
126
271
|
string,
|
|
127
272
|
{ status: "pending" } | { status: "loaded"; media: ResolvedMarkdownMedia } | { status: "error"; message: string }
|
|
@@ -138,16 +283,41 @@ export class Markdown implements Component {
|
|
|
138
283
|
paddingY: number,
|
|
139
284
|
theme: MarkdownTheme,
|
|
140
285
|
defaultTextStyle?: DefaultTextStyle,
|
|
141
|
-
|
|
286
|
+
options?: MarkdownOptions,
|
|
287
|
+
);
|
|
288
|
+
constructor(
|
|
289
|
+
text: string,
|
|
290
|
+
paddingX: number,
|
|
291
|
+
paddingY: number,
|
|
292
|
+
theme: MarkdownTheme,
|
|
293
|
+
defaultTextStyle?: DefaultTextStyle,
|
|
294
|
+
codeBlockIndent?: number,
|
|
295
|
+
mediaOptions?: MarkdownMediaOptions,
|
|
296
|
+
);
|
|
297
|
+
constructor(
|
|
298
|
+
text: string,
|
|
299
|
+
paddingX: number,
|
|
300
|
+
paddingY: number,
|
|
301
|
+
theme: MarkdownTheme,
|
|
302
|
+
defaultTextStyle?: DefaultTextStyle,
|
|
303
|
+
optionsOrCodeBlockIndent: MarkdownOptions | number = 2,
|
|
142
304
|
mediaOptions?: MarkdownMediaOptions,
|
|
143
305
|
) {
|
|
306
|
+
const options =
|
|
307
|
+
typeof optionsOrCodeBlockIndent === "number"
|
|
308
|
+
? {
|
|
309
|
+
codeBlockIndent: optionsOrCodeBlockIndent,
|
|
310
|
+
mediaOptions,
|
|
311
|
+
}
|
|
312
|
+
: optionsOrCodeBlockIndent;
|
|
144
313
|
this.#text = text;
|
|
145
314
|
this.#paddingX = paddingX;
|
|
146
315
|
this.#paddingY = paddingY;
|
|
147
316
|
this.#theme = theme;
|
|
148
317
|
this.#defaultTextStyle = defaultTextStyle;
|
|
149
|
-
this.#codeBlockIndent = Math.max(0, Math.floor(codeBlockIndent));
|
|
150
|
-
this.#mediaOptions = mediaOptions;
|
|
318
|
+
this.#codeBlockIndent = Math.max(0, Math.floor(options.codeBlockIndent ?? 2));
|
|
319
|
+
this.#mediaOptions = options.mediaOptions;
|
|
320
|
+
this.#renderLatex = options.renderLatex !== false;
|
|
151
321
|
}
|
|
152
322
|
|
|
153
323
|
setText(text: string): void {
|
|
@@ -184,7 +354,7 @@ export class Markdown implements Component {
|
|
|
184
354
|
const normalizedText = replaceTabs(this.#text);
|
|
185
355
|
|
|
186
356
|
// Parse markdown to HTML-like tokens
|
|
187
|
-
const tokens =
|
|
357
|
+
const tokens = markdownParser.lexer(normalizedText);
|
|
188
358
|
|
|
189
359
|
// Convert tokens to styled terminal output
|
|
190
360
|
const renderedLines: string[] = [];
|
|
@@ -371,6 +541,21 @@ export class Markdown implements Component {
|
|
|
371
541
|
break;
|
|
372
542
|
}
|
|
373
543
|
|
|
544
|
+
case "latexBlock": {
|
|
545
|
+
const latexToken = token as LatexToken;
|
|
546
|
+
const rendered =
|
|
547
|
+
!latexToken.pending && this.#renderLatex
|
|
548
|
+
? (renderLatex(latexToken.text, { display: true }) ?? latexToken.raw.trimEnd())
|
|
549
|
+
: latexToken.raw.trimEnd();
|
|
550
|
+
for (const line of rendered.split("\n")) {
|
|
551
|
+
lines.push(this.#applyDefaultStyle(line));
|
|
552
|
+
}
|
|
553
|
+
if (nextTokenType && nextTokenType !== "space") {
|
|
554
|
+
lines.push("");
|
|
555
|
+
}
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
|
|
374
559
|
case "code": {
|
|
375
560
|
if (token.lang === "mermaid") {
|
|
376
561
|
// Preferred: render in the host's single F5 frame (themed + clipped),
|
|
@@ -592,6 +777,16 @@ export class Markdown implements Component {
|
|
|
592
777
|
|
|
593
778
|
for (const token of tokens) {
|
|
594
779
|
switch (token.type) {
|
|
780
|
+
case "latex": {
|
|
781
|
+
const latexToken = token as LatexToken;
|
|
782
|
+
const rendered =
|
|
783
|
+
!latexToken.pending && this.#renderLatex
|
|
784
|
+
? (renderLatex(latexToken.text) ?? latexToken.raw)
|
|
785
|
+
: latexToken.raw;
|
|
786
|
+
result += applyTextWithNewlines(rendered);
|
|
787
|
+
break;
|
|
788
|
+
}
|
|
789
|
+
|
|
595
790
|
case "text":
|
|
596
791
|
// Text tokens in list items can have nested tokens for inline formatting
|
|
597
792
|
if (token.tokens && token.tokens.length > 0) {
|
|
@@ -984,7 +1179,7 @@ export class Markdown implements Component {
|
|
|
984
1179
|
* Unlike the full Markdown component, this produces a single line with no block-level elements.
|
|
985
1180
|
*/
|
|
986
1181
|
export function renderInlineMarkdown(text: string, mdTheme: MarkdownTheme, baseColor?: (t: string) => string): string {
|
|
987
|
-
const tokens =
|
|
1182
|
+
const tokens = markdownParser.lexer(text);
|
|
988
1183
|
const applyText = baseColor ?? ((t: string) => t);
|
|
989
1184
|
let result = "";
|
|
990
1185
|
for (const token of tokens) {
|
|
@@ -1010,6 +1205,13 @@ function renderInlineTokens(tokens: Token[], mdTheme: MarkdownTheme, applyText:
|
|
|
1010
1205
|
const styleReset = applyText("");
|
|
1011
1206
|
for (const token of tokens) {
|
|
1012
1207
|
switch (token.type) {
|
|
1208
|
+
case "latex": {
|
|
1209
|
+
const latexToken = token as LatexToken;
|
|
1210
|
+
result += applyText(
|
|
1211
|
+
!latexToken.pending ? (renderLatex(latexToken.text) ?? latexToken.raw) : latexToken.raw,
|
|
1212
|
+
);
|
|
1213
|
+
break;
|
|
1214
|
+
}
|
|
1013
1215
|
case "text":
|
|
1014
1216
|
if (token.tokens && token.tokens.length > 0) {
|
|
1015
1217
|
result += renderInlineTokens(token.tokens, mdTheme, applyText);
|
package/src/index.ts
CHANGED
|
@@ -49,6 +49,8 @@ export {
|
|
|
49
49
|
export * from "./keybindings";
|
|
50
50
|
// Kitty keyboard protocol helpers
|
|
51
51
|
export * from "./keys";
|
|
52
|
+
// LaTeX rendering
|
|
53
|
+
export { type RenderLatexOptions, renderLatex } from "./latex";
|
|
52
54
|
// Media playback state machine
|
|
53
55
|
export * from "./media-playback";
|
|
54
56
|
// Mermaid diagram support
|