@f5-sales-demo/pi-tui 21.6.0 → 21.6.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/pi-tui",
4
- "version": "21.6.0",
4
+ "version": "21.6.2",
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.0",
41
- "@f5-sales-demo/pi-utils": "21.6.0",
40
+ "@f5-sales-demo/pi-natives": "21.6.2",
41
+ "@f5-sales-demo/pi-utils": "21.6.2",
42
42
  "marked": "^18.0"
43
43
  },
44
44
  "devDependencies": {
@@ -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 { marked, type Token, type Tokens } from "marked";
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
- codeBlockIndent: number = 2,
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 = marked.lexer(normalizedText);
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 = marked.lexer(text);
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