@oh-my-pi/pi-tui 16.0.1 → 16.0.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/CHANGELOG.md CHANGED
@@ -2,6 +2,43 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.0.3] - 2026-06-16
6
+
7
+ ### Added
8
+
9
+ - Added `\tfrac` support to stacked display-math rendering so it now displays as a vertical fraction in `latexToBlock` output
10
+ - Added markdown parsing for own-line display-math blocks (`$$...$$` and `\[...\]`) and delimiter-free `\begin{...}...\end{...}` math environments so block equations render via LaTeX-to-Unicode
11
+ - Added stacked rendering of display-math fractions (`\frac`, `\dfrac`, `\cfrac`): the numerator is drawn over a horizontal bar over the denominator, with surrounding terms and `align`/`equation`-style environment rows aligned to the bar. Triggered for own-line `$$`/`\[` blocks, bare `\begin{...}` environments, and a paragraph whose sole content is a single display-math span; inline `$...$` fractions stay single-line (`½`, `(a+b)/c`)
12
+ - Added bare math auto-rendering in `renderMathInText` for math-shaped lines and math environment blocks that omit `$`/`\(` delimiters
13
+ - Added LaTeX-to-Unicode rendering for markdown math spans, converting `$$...$$`, `$...$`, `\(...\)`, and `\[...\]` into readable Unicode in Markdown output
14
+ - Exported LaTeX conversion helpers from the package entrypoint so consumers can call `latexToUnicode`, `latexToBlock`, `renderMathInText`, `inlineMathSpanEnd`, and `isBareMathEnvironment` directly
15
+ - Expanded LaTeX-to-Unicode conversion coverage for additional math fonts, delimiters, extensible arrows, layout environments, cancel/brace annotations, references, and AMS symbols
16
+ - Added ANSI color rendering for LaTeX `\textcolor`, scoped `\color`, `\colorbox`, and `\fcolorbox`, including xcolor/CSS color parsing and truecolor/256-color terminal output
17
+ - Added an optional `maxWidth` parameter to `MarkdownTheme.resolveMermaidAscii` to allow diagram resolvers to fit ASCII output to the available content width
18
+
19
+ ### Changed
20
+
21
+ - Changed markdown math rendering to preserve multiline layout for display equations, keeping `\\` row breaks as separate output lines (including inside list items)
22
+
23
+ ### Fixed
24
+
25
+ - Fixed `alignat`/`alignedat`/`gatheredat` rendering in `latexToBlock` so the required `{n}` preamble is not rendered as visible math content
26
+ - Fixed math parsing to leave non-math LaTeX snippets (for example `\begin{itemize}`) and fenced code blocks as literal text instead of rendering them as math
27
+ - Fixed `renderInlineMarkdown` to handle top-level display-math tokens so raw `$$...$$` delimiters are no longer leaked
28
+ - Fixed inline math span detection so escaped dollars and currency-like patterns (such as `$5` and `$10`) are not converted as math
29
+ - Fixed Mermaid diagram rendering in Markdown code blocks to clip each ASCII line to content width before wrapping, preventing preformatted diagram rows from fragmenting
30
+ - Fixed fullscreen overlays losing keyboard focus to hidden prompt surfaces, which could make settings unresponsive while a background approval request was pending ([#2789](https://github.com/can1357/oh-my-pi/issues/2789)).
31
+ - Fixed `bun test` runs inside a real terminal leaking TUI output: `ProcessTerminal` now honors a headless test-runtime default, so frame paints, `start()` capability probes (OSC 11 / DA1 / kitty), the progress keepalive, notifications, and teardown escapes no longer reach the developer's terminal, and stdin raw mode is never engaged. Previously `#safeWrite` only skipped on `!process.stdout.isTTY`, so a developer running the suite in an interactive terminal saw stray status/editor boxes and probe queries. Terminal-contract suites opt back into real I/O via `setTerminalHeadless(false)`
32
+
33
+ ## [16.0.2] - 2026-06-16
34
+
35
+ ### Fixed
36
+
37
+ - Fixed VS Code integrated terminal keypad digit CSI-u input being handled as navigation instead of text.
38
+ - Fixed xterm-compatible terminals scrolling the native viewport to the bottom on prompt-editor keypresses by disabling `?1010`/`?1011` while the TUI owns the TTY and restoring the prior set modes on exit ([#2732](https://github.com/can1357/oh-my-pi/issues/2732)).
39
+ - Fixed CMUX sessions being treated as direct terminals during resize/reset because they do not set `TMUX`/`STY`/`ZELLIJ` and may run with `TERM=dumb`; the renderer now treats CMUX workspace/surface env markers as multiplexer signals and preserves pane scrollback instead of emitting ED3 (`CSI 3 J`).
40
+ - Fixed a self-sustaining resize-redraw storm in Warp: the non-multiplexer resize fast path borrows the alternate screen, and Warp re-reports a one-row-different size whenever the alt buffer is toggled, so each drag frame fed back a fresh resize event and the TUI flooded ED3 full repaints with stable geometry. Resize now repaints in place (no alt-screen borrow, no ED3 rewrap) on terminals that re-report size on alt-screen toggles, matching the multiplexer path. Overridable with `PI_TUI_RESIZE_IN_PLACE=1|0`.
41
+
5
42
  ## [16.0.1] - 2026-06-15
6
43
 
7
44
  ### Added
@@ -44,7 +44,7 @@ export interface MarkdownTheme {
44
44
  * Resolve a mermaid ASCII rendering by fenced block source text.
45
45
  * Return null to fall back to fenced code rendering.
46
46
  */
47
- resolveMermaidAscii?: (source: string) => string | null;
47
+ resolveMermaidAscii?: (source: string, maxWidth?: number) => string | null;
48
48
  symbols: SymbolTheme;
49
49
  }
50
50
  export declare class Markdown implements Component {
@@ -19,6 +19,8 @@ export * from "./fuzzy";
19
19
  export * from "./keybindings";
20
20
  export * from "./keys";
21
21
  export * from "./kitty-graphics";
22
+ export * from "./latex-block";
23
+ export * from "./latex-to-unicode";
22
24
  export * from "./mouse";
23
25
  export * from "./stdin-buffer";
24
26
  export type * from "./symbols";
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Render a display LaTeX math fragment to lines, stacking `\frac` vertically.
3
+ * Top-level source newlines become vertical rows (so a `lhs =` line stays above
4
+ * its block); each row stacks fractions via `parseExpr`. Inline math should use
5
+ * `latexToUnicode` instead — fractions there stay single-line.
6
+ */
7
+ export declare function latexToBlock(src: string): string[];
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Convert a bare LaTeX math fragment (no surrounding `$`/`\(` delimiters) to its
3
+ * best-effort Unicode rendering. Unknown commands degrade to their bare name;
4
+ * `\\` becomes a newline. Always returns a string (never throws).
5
+ */
6
+ export declare function latexToUnicode(src: string): string;
7
+ /**
8
+ * True when `env` is a math environment safe to auto-render without `$`/`\[`
9
+ * delimiters. The trailing `*` of starred variants (`align*`, `equation*`) is
10
+ * ignored; text-mode environments (`tabular`, `itemize`, …) return false.
11
+ */
12
+ export declare function isBareMathEnvironment(env: string): boolean;
13
+ /**
14
+ * Scan prose for math spans — `$$…$$`, `\[…\]` (display) and `$…$`, `\(…\)`
15
+ * (inline) — and replace each with its Unicode rendering, leaving everything
16
+ * else verbatim. Newlines inside a span collapse to spaces so the result stays
17
+ * single-line-safe.
18
+ *
19
+ * Inline `$…$` uses pandoc's anti-currency heuristics: the opener must not be
20
+ * followed by whitespace, the closer must not be preceded by whitespace nor
21
+ * followed by a digit, and `\$` is treated as a literal dollar — so "$5 and
22
+ * $10" is left untouched.
23
+ */
24
+ export declare function renderMathInText(text: string): string;
25
+ /**
26
+ * Index of the `$` that closes an inline math span opened at `open` (the index
27
+ * of the opening `$`), or -1 when the run is not inline math. Applies pandoc's
28
+ * anti-currency heuristics: the opener must not be followed by whitespace, the
29
+ * closer must not be preceded by whitespace nor followed by a digit, `\$` is a
30
+ * literal dollar, and the span may not span a newline. Shared by
31
+ * `renderMathInText` and the markdown math tokenizer so the rule has one home.
32
+ */
33
+ export declare function inlineMathSpanEnd(text: string, open: number): number;
@@ -66,6 +66,11 @@ export interface Component {
66
66
  */
67
67
  dispose?(): void;
68
68
  }
69
+ /** Lets an overlay root delegate keyboard focus to components it owns. */
70
+ export interface OverlayFocusOwner {
71
+ /** Returns true when `component` is a focus target inside this overlay. */
72
+ ownsOverlayFocusTarget(component: Component): boolean;
73
+ }
69
74
  /**
70
75
  * Component seam for append-only native-scrollback commits. A component that
71
76
  * renders a finalized prefix followed by a live/mutating suffix reports the
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "16.0.1",
4
+ "version": "16.0.3",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "16.0.1",
41
- "@oh-my-pi/pi-utils": "16.0.1",
40
+ "@oh-my-pi/pi-natives": "16.0.3",
41
+ "@oh-my-pi/pi-utils": "16.0.3",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.5"
44
44
  },
@@ -1,14 +1,18 @@
1
1
  import { LRUCache } from "lru-cache/raw";
2
- import { Marked, marked, type Token, Tokenizer, type Tokens } from "marked";
2
+ import { Marked, type Token, Tokenizer, type TokenizerAndRendererExtension, type Tokens } from "marked";
3
+ import { latexToBlock } from "../latex-block";
4
+ import { inlineMathSpanEnd, isBareMathEnvironment, latexToUnicode } from "../latex-to-unicode";
3
5
  import type { SymbolTheme } from "../symbols";
4
6
  import { TERMINAL } from "../terminal-capabilities";
5
7
  import type { Component } from "../tui";
6
8
  import {
7
9
  applyBackgroundToLine,
10
+ Ellipsis,
8
11
  encodeTextSized,
9
12
  getSegmenter,
10
13
  padding,
11
14
  replaceTabs,
15
+ truncateToWidth,
12
16
  visibleWidth,
13
17
  wrapTextWithAnsi,
14
18
  } from "../utils";
@@ -48,6 +52,126 @@ markdownParser.setOptions({
48
52
  tokenizer: new StrictStrikethroughTokenizer(),
49
53
  });
50
54
 
55
+ // Math spans (`$$…$$`, `\[…\]`, `$…$`, `\(…\)`) are tokenized as a dedicated
56
+ // `math` inline token before markdown's escape/emphasis/link rules run, so
57
+ // backslash commands (`\frac`, `\alpha`) and intraword underscores (`x_i`)
58
+ // survive intact instead of being mangled or split. The `$…$` form uses
59
+ // pandoc's anti-currency heuristic (`inlineMathSpanEnd`) so "$5 and $10" is
60
+ // never math. Inline extensions run before marked's escape tokenizer, so
61
+ // `\(…\)` becomes math while a genuinely escaped `\$` is left to `escape` and
62
+ // renders as a literal dollar.
63
+ const mathExtension: TokenizerAndRendererExtension = {
64
+ name: "math",
65
+ level: "inline",
66
+ start(src) {
67
+ const m = /\$|\\\(|\\\[/.exec(src);
68
+ return m ? m.index : undefined;
69
+ },
70
+ tokenizer(src) {
71
+ if (src.startsWith("$$")) {
72
+ const end = src.indexOf("$$", 2);
73
+ if (end !== -1 && src.slice(2, end).trim().length > 0) {
74
+ return { type: "math", raw: src.slice(0, end + 2), text: src.slice(2, end), display: true };
75
+ }
76
+ return undefined;
77
+ }
78
+ if (src.startsWith("\\[")) {
79
+ const end = src.indexOf("\\]", 2);
80
+ if (end !== -1) return { type: "math", raw: src.slice(0, end + 2), text: src.slice(2, end), display: true };
81
+ return undefined;
82
+ }
83
+ if (src.startsWith("\\(")) {
84
+ const end = src.indexOf("\\)", 2);
85
+ if (end !== -1) return { type: "math", raw: src.slice(0, end + 2), text: src.slice(2, end), display: false };
86
+ return undefined;
87
+ }
88
+ if (src.charCodeAt(0) === 0x24 /* $ */) {
89
+ const end = inlineMathSpanEnd(src, 0);
90
+ if (end !== -1) return { type: "math", raw: src.slice(0, end + 1), text: src.slice(1, end), display: false };
91
+ }
92
+ return undefined;
93
+ },
94
+ renderer(token) {
95
+ return (token as { text?: string }).text ?? "";
96
+ },
97
+ };
98
+
99
+ // Display math blocks: opening `$$` / `\[` and closing `$$` / `\]` each alone on
100
+ // their own line (≤3 leading spaces). Matched at the block level — before
101
+ // paragraph/list parsing — so a multi-line equation (e.g. a matrix with `\\`
102
+ // row breaks) renders across several lines instead of being collapsed onto one,
103
+ // and blank lines inside the block don't split it. The own-line requirement
104
+ // keeps inline `$$…$$` inside prose for the inline tokenizer above.
105
+ const MATH_BLOCK_DOLLAR = /^ {0,3}\$\$[ \t]*\n([\s\S]+?)\n {0,3}\$\$[ \t]*(?:\n|$)/;
106
+ const MATH_BLOCK_BRACKET = /^ {0,3}\\\[[ \t]*\n([\s\S]+?)\n {0,3}\\\][ \t]*(?:\n|$)/;
107
+ const MATH_BLOCK_START = /(?:^|\n) {0,3}(?:\$\$|\\\[)[ \t]*\n/;
108
+ const mathBlockExtension: TokenizerAndRendererExtension = {
109
+ name: "mathBlock",
110
+ level: "block",
111
+ start(src) {
112
+ const m = MATH_BLOCK_START.exec(src);
113
+ return m ? m.index : undefined;
114
+ },
115
+ tokenizer(src) {
116
+ const m = MATH_BLOCK_DOLLAR.exec(src) ?? MATH_BLOCK_BRACKET.exec(src);
117
+ if (!m || m[1].trim().length === 0) return undefined;
118
+ return { type: "math", raw: m[0], text: m[1], display: true };
119
+ },
120
+ renderer(token) {
121
+ return (token as { text?: string }).text ?? "";
122
+ },
123
+ };
124
+
125
+ // Bare (delimiter-less) display-math environments: `\begin{<mathenv>}…\end{…}`
126
+ // written without `$$`/`\[` fences (common in raw model output). Captured at the
127
+ // block level as a whole unit — including any immediately preceding `lhs =`
128
+ // line — so marked never splits it on inline `\\` row breaks. Restricted to math
129
+ // environments (isBareMathEnvironment), and the `≤3 leading spaces` + "block
130
+ // starts at offset 0" guards keep fenced/indented `\begin{cases}` code blocks
131
+ // for marked's own code rules.
132
+ const BARE_ENV_BEGIN = /(?:^|\n)[ \t]{0,3}\\begin\{([A-Za-z]+\*?)\}/;
133
+ function bareMathEnvBlock(src: string): readonly [number, number] | null {
134
+ const bm = BARE_ENV_BEGIN.exec(src);
135
+ if (!bm || !isBareMathEnvironment(bm[1])) return null;
136
+ const beginLineStart = bm.index === 0 ? 0 : bm.index + 1; // skip the matched leading `\n`
137
+ const endToken = `\\end{${bm[1]}}`;
138
+ const endAt = src.indexOf(endToken, bm.index);
139
+ if (endAt === -1) return null;
140
+ // The `\end` must close before any blank line (i.e. within the same block).
141
+ if (/\n[ \t]*\n/.test(src.slice(beginLineStart, endAt))) return null;
142
+ let blockEnd = endAt + endToken.length;
143
+ while (src[blockEnd] === " " || src[blockEnd] === "\t") blockEnd++;
144
+ if (src[blockEnd] === "\n") blockEnd++;
145
+ // Pull in one immediately-preceding `lhs =`/open-delimiter line (e.g. `f(x) =`).
146
+ let start = beginLineStart;
147
+ if (start > 0 && src[start - 1] === "\n") {
148
+ const prevStart = src.lastIndexOf("\n", start - 2) + 1;
149
+ const prevLine = src.slice(prevStart, start - 1);
150
+ if (/[=([{]\s*$/.test(prevLine)) start = prevStart;
151
+ }
152
+ return [start, blockEnd];
153
+ }
154
+ const mathEnvBlockExtension: TokenizerAndRendererExtension = {
155
+ name: "mathEnvBlock",
156
+ level: "block",
157
+ start(src) {
158
+ const r = bareMathEnvBlock(src);
159
+ return r ? r[0] : undefined;
160
+ },
161
+ tokenizer(src) {
162
+ const r = bareMathEnvBlock(src);
163
+ if (r?.[0] !== 0) return undefined; // only consume when the block starts at offset 0
164
+ const raw = src.slice(0, r[1]);
165
+ const text = raw.replace(/\n[ \t]*$/, "");
166
+ if (text.trim().length === 0) return undefined;
167
+ return { type: "math", raw, text, display: true };
168
+ },
169
+ renderer(token) {
170
+ return (token as { text?: string }).text ?? "";
171
+ },
172
+ };
173
+ markdownParser.use({ extensions: [mathBlockExtension, mathEnvBlockExtension, mathExtension] });
174
+
51
175
  // ---------------------------------------------------------------------------
52
176
  // Module-level LRU render cache
53
177
  // ---------------------------------------------------------------------------
@@ -131,7 +255,7 @@ export interface MarkdownTheme {
131
255
  * Resolve a mermaid ASCII rendering by fenced block source text.
132
256
  * Return null to fall back to fenced code rendering.
133
257
  */
134
- resolveMermaidAscii?: (source: string) => string | null;
258
+ resolveMermaidAscii?: (source: string, maxWidth?: number) => string | null;
135
259
  symbols: SymbolTheme;
136
260
  }
137
261
 
@@ -186,9 +310,46 @@ function encodeTextSizedHeading(text: string, scale: 1 | 2 | 3): string {
186
310
  return out;
187
311
  }
188
312
 
313
+ const MATH_NEWLINES = /\n+/g;
314
+
315
+ /** True for the custom inline `math` token produced by the math extension. */
316
+ function isMathToken(token: Token): token is Token & { text: string; display: boolean } {
317
+ return (token as { type: string }).type === "math";
318
+ }
319
+
320
+ /** Convert a `math` token's LaTeX to single-line Unicode for inline rendering. */
321
+ function renderMathToken(text: string): string {
322
+ return latexToUnicode(text).replace(MATH_NEWLINES, " ");
323
+ }
324
+
325
+ /**
326
+ * When a paragraph's only meaningful content is a single display math token
327
+ * (`$$…$$` / `\[…\]`), return it so the paragraph can be stacked multi-line
328
+ * instead of flattened inline. Models routinely write display math on one line,
329
+ * which marked captures as an inline `display:true` math token inside a
330
+ * paragraph; without this it would flatten through `renderMathToken`.
331
+ */
332
+ function soleDisplayMath(tokens?: Token[]): (Token & { text: string }) | null {
333
+ if (!tokens) return null;
334
+ let math: (Token & { text: string; display: boolean }) | null = null;
335
+ for (const token of tokens) {
336
+ if (isMathToken(token) && token.display) {
337
+ if (math) return null;
338
+ math = token;
339
+ } else if (!(token.type === "text" && typeof token.text === "string" && token.text.trim() === "")) {
340
+ return null;
341
+ }
342
+ }
343
+ return math;
344
+ }
345
+
189
346
  function plainInlineTokens(tokens: Token[]): string {
190
347
  let result = "";
191
348
  for (const token of tokens) {
349
+ if (isMathToken(token)) {
350
+ result += renderMathToken(token.text);
351
+ continue;
352
+ }
192
353
  switch (token.type) {
193
354
  case "text":
194
355
  result += token.tokens && token.tokens.length > 0 ? plainInlineTokens(token.tokens) : token.text;
@@ -658,6 +819,14 @@ export class Markdown implements Component {
658
819
  #renderToken(token: Token, width: number, nextTokenType?: string, styleContext?: InlineStyleContext): string[] {
659
820
  const lines: string[] = [];
660
821
 
822
+ // Display math block (own-line `$$…$$` / `\[…\]`): stack `\frac` vertically
823
+ // and keep `\\` row breaks, so fractions and matrices span multiple lines.
824
+ if (isMathToken(token)) {
825
+ for (const mathLine of latexToBlock(token.text)) lines.push(this.#applyDefaultStyle(mathLine));
826
+ if (nextTokenType && nextTokenType !== "space") lines.push("");
827
+ return lines;
828
+ }
829
+
661
830
  switch (token.type) {
662
831
  case "heading": {
663
832
  const headingLevel = token.depth;
@@ -692,6 +861,12 @@ export class Markdown implements Component {
692
861
  }
693
862
 
694
863
  case "paragraph": {
864
+ const displayMath = soleDisplayMath(token.tokens);
865
+ if (displayMath) {
866
+ for (const mathLine of latexToBlock(displayMath.text)) lines.push(this.#applyDefaultStyle(mathLine));
867
+ if (nextTokenType && nextTokenType !== "list" && nextTokenType !== "space") lines.push("");
868
+ break;
869
+ }
695
870
  const paragraphText = this.#renderInlineTokens(token.tokens || [], styleContext);
696
871
  lines.push(paragraphText);
697
872
  // Don't add spacing if next token is space or list
@@ -702,13 +877,18 @@ export class Markdown implements Component {
702
877
  }
703
878
 
704
879
  case "code": {
705
- // Handle mermaid diagrams with ASCII rendering when available
880
+ // Mermaid diagrams render as ASCII art when the theme supplies a
881
+ // resolver. The art is preformatted, so clip each row to the content
882
+ // width: the later wrap pass would otherwise fragment the box-drawing
883
+ // canvas. truncateToWidth is ANSI- and wide-char-aware, and the
884
+ // resolver already re-fits over-wide horizontal graphs top-down.
706
885
  if (token.lang === "mermaid" && this.#theme.resolveMermaidAscii) {
707
- const ascii = this.#theme.resolveMermaidAscii(token.text);
708
-
886
+ const ascii = this.#theme.resolveMermaidAscii(token.text, width);
709
887
  if (ascii) {
710
- for (const asciiLine of Bun.stripANSI(ascii).split("\n")) {
711
- lines.push(asciiLine);
888
+ for (const asciiLine of ascii.split("\n")) {
889
+ lines.push(
890
+ visibleWidth(asciiLine) > width ? truncateToWidth(asciiLine, width, Ellipsis.Omit) : asciiLine,
891
+ );
712
892
  }
713
893
  if (nextTokenType && nextTokenType !== "space") {
714
894
  lines.push("");
@@ -839,6 +1019,10 @@ export class Markdown implements Component {
839
1019
  const swatchGlyph = this.#theme.symbols.colorSwatch || DEFAULT_COLOR_SWATCH_GLYPH;
840
1020
 
841
1021
  for (const token of tokens) {
1022
+ if (isMathToken(token)) {
1023
+ result += applyTextWithNewlines(renderMathToken(token.text));
1024
+ continue;
1025
+ }
842
1026
  switch (token.type) {
843
1027
  case "text":
844
1028
  // Text tokens in list items can have nested tokens for inline formatting
@@ -996,16 +1180,29 @@ export class Markdown implements Component {
996
1180
  lines.push({ text: nestedLine, nested: true });
997
1181
  }
998
1182
  } else if (token.type === "text") {
999
- // Text content (may have inline tokens)
1000
- const text =
1001
- token.tokens && token.tokens.length > 0
1002
- ? this.#renderInlineTokens(token.tokens, styleContext)
1003
- : token.text || "";
1004
- lines.push({ text, nested: false });
1183
+ // Text content (may have inline tokens, or a sole display-math token)
1184
+ const displayMath = soleDisplayMath(token.tokens);
1185
+ if (displayMath) {
1186
+ const apply = styleContext?.applyText ?? ((t: string) => this.#applyDefaultStyle(t));
1187
+ for (const mathLine of latexToBlock(displayMath.text))
1188
+ lines.push({ text: apply(mathLine), nested: false });
1189
+ } else {
1190
+ const text =
1191
+ token.tokens && token.tokens.length > 0
1192
+ ? this.#renderInlineTokens(token.tokens, styleContext)
1193
+ : token.text || "";
1194
+ lines.push({ text, nested: false });
1195
+ }
1005
1196
  } else if (token.type === "paragraph") {
1006
1197
  // Paragraph in list item
1007
- const text = this.#renderInlineTokens(token.tokens || [], styleContext);
1008
- lines.push({ text, nested: false });
1198
+ const apply = styleContext?.applyText ?? ((t: string) => this.#applyDefaultStyle(t));
1199
+ const displayMath = soleDisplayMath(token.tokens);
1200
+ if (displayMath) {
1201
+ for (const mathLine of latexToBlock(displayMath.text))
1202
+ lines.push({ text: apply(mathLine), nested: false });
1203
+ } else {
1204
+ lines.push({ text: this.#renderInlineTokens(token.tokens || [], styleContext), nested: false });
1205
+ }
1009
1206
  } else if (token.type === "code") {
1010
1207
  // Code block in list item
1011
1208
  const codeIndent = padding(this.#codeBlockIndent);
@@ -1022,6 +1219,10 @@ export class Markdown implements Component {
1022
1219
  }
1023
1220
  }
1024
1221
  lines.push({ text: this.#theme.codeBlockBorder("```"), nested: false });
1222
+ } else if (isMathToken(token)) {
1223
+ // Display math block inside a list item: stack fractions / matrix rows.
1224
+ const apply = styleContext?.applyText ?? ((t: string) => this.#applyDefaultStyle(t));
1225
+ for (const mathLine of latexToBlock(token.text)) lines.push({ text: apply(mathLine), nested: false });
1025
1226
  } else {
1026
1227
  // Other token types - try to render as inline
1027
1228
  const text = this.#renderInlineTokens([token], styleContext);
@@ -1249,10 +1450,14 @@ export class Markdown implements Component {
1249
1450
  export function renderInlineMarkdown(text: string, mdTheme: MarkdownTheme, baseColor?: (t: string) => string): string {
1250
1451
  // Guard against undefined/null during streaming — partial JSON can leave fields unpopulated.
1251
1452
  if (typeof text !== "string") return (baseColor ?? (t => t))(text != null ? String(text) : "");
1252
- const tokens = marked.lexer(text);
1453
+ const tokens = markdownParser.lexer(text);
1253
1454
  const applyText = baseColor ?? ((t: string) => t);
1254
1455
  let result = "";
1255
1456
  for (const token of tokens) {
1457
+ if (isMathToken(token)) {
1458
+ result += applyText(renderMathToken(token.text));
1459
+ continue;
1460
+ }
1256
1461
  if (token.type === "paragraph" && token.tokens) {
1257
1462
  result += renderInlineTokens(token.tokens, mdTheme, applyText);
1258
1463
  } else if (token.type === "list") {
@@ -1274,6 +1479,10 @@ function renderInlineTokens(tokens: Token[], mdTheme: MarkdownTheme, applyText:
1274
1479
  let result = "";
1275
1480
  const styleReset = applyText("");
1276
1481
  for (const token of tokens) {
1482
+ if (isMathToken(token)) {
1483
+ result += applyText(renderMathToken(token.text));
1484
+ continue;
1485
+ }
1277
1486
  switch (token.type) {
1278
1487
  case "text":
1279
1488
  if (token.tokens && token.tokens.length > 0) {
package/src/index.ts CHANGED
@@ -29,6 +29,9 @@ export * from "./keybindings";
29
29
  export * from "./keys";
30
30
  // Kitty graphics: Unicode placeholders
31
31
  export * from "./kitty-graphics";
32
+ // LaTeX → Unicode/ANSI math rendering
33
+ export * from "./latex-block";
34
+ export * from "./latex-to-unicode";
32
35
  // SGR mouse report parsing
33
36
  export * from "./mouse";
34
37
  // Mermaid diagram support
package/src/keys.ts CHANGED
@@ -303,7 +303,7 @@ const KITTY_MOD_ALT = 2;
303
303
  const KITTY_MOD_CTRL = 4;
304
304
  const KITTY_MOD_SUPER = 8;
305
305
  const KITTY_MOD_NUM_LOCK = 128;
306
- const KITTY_LOCK_MASK = 64 + 128; // Caps Lock + Num Lock
306
+ const KITTY_LOCK_MASK = 64 + KITTY_MOD_NUM_LOCK; // Caps Lock + Num Lock
307
307
  const MODIFY_OTHER_KEYS_PATTERN = /^\x1b\[27;(\d+);(\d+)~$/;
308
308
  const KITTY_KEYPAD_OPERATOR_TEXT: Record<number, string> = {
309
309
  57410: "/",
@@ -409,7 +409,7 @@ function decodeKittyPrintable(data: string): string | undefined {
409
409
  .split(":")
410
410
  .filter(Boolean)
411
411
  .map(value => Number.parseInt(value, 10))
412
- .filter(value => Number.isFinite(value) && value >= 32);
412
+ .filter(value => Number.isFinite(value) && value >= 32 && value !== 127);
413
413
  if (codepoints.length > 0) {
414
414
  try {
415
415
  return String.fromCodePoint(...codepoints);
@@ -421,7 +421,7 @@ function decodeKittyPrintable(data: string): string | undefined {
421
421
  const keypadOperatorText = KITTY_KEYPAD_OPERATOR_TEXT[codepoint];
422
422
  if (keypadOperatorText) return keypadOperatorText;
423
423
 
424
- if (effectiveMod === 0 && modifier & KITTY_MOD_NUM_LOCK) {
424
+ if (effectiveMod === 0) {
425
425
  const numpadText = KITTY_NUMPAD_TEXT[codepoint];
426
426
  if (numpadText) return numpadText;
427
427
  }
@@ -435,7 +435,7 @@ function decodeKittyPrintable(data: string): string | undefined {
435
435
  return undefined;
436
436
  }
437
437
 
438
- if (!Number.isFinite(effectiveCodepoint) || effectiveCodepoint < 32) return undefined;
438
+ if (!Number.isFinite(effectiveCodepoint) || effectiveCodepoint < 32 || effectiveCodepoint === 127) return undefined;
439
439
 
440
440
  try {
441
441
  return String.fromCodePoint(effectiveCodepoint);
@@ -486,7 +486,7 @@ function decodeModifyOtherKeysPrintable(data: string): string | undefined {
486
486
  if (!parsed) return undefined;
487
487
  const modifier = parsed.modifier & ~KITTY_LOCK_MASK;
488
488
  if ((modifier & ~KITTY_MOD_SHIFT) !== 0) return undefined;
489
- if (!Number.isFinite(parsed.codepoint) || parsed.codepoint < 32) return undefined;
489
+ if (!Number.isFinite(parsed.codepoint) || parsed.codepoint < 32 || parsed.codepoint === 127) return undefined;
490
490
  try {
491
491
  return String.fromCodePoint(parsed.codepoint);
492
492
  } catch {
@@ -504,6 +504,30 @@ export function decodePrintableKey(data: string): string | undefined {
504
504
  return decodeKittyPrintable(data) ?? decodeModifyOtherKeysPrintable(data);
505
505
  }
506
506
 
507
+ /**
508
+ * Decode a Kitty CSI-u keypad sequence (numpad digits / keypad operators) into the
509
+ * text it produces, or `undefined` for any non-keypad sequence.
510
+ *
511
+ * The native key matcher classifies bare numpad codepoints (those without a NumLock
512
+ * modifier bit) as navigation keys, but terminals such as the VS Code integrated
513
+ * terminal emit those codepoints for real digit input. Restricting the fast path to
514
+ * keypad codepoints keeps canonical named keys (space, backspace, shifted keys, and
515
+ * modifyOtherKeys sequences) flowing through native normalization.
516
+ */
517
+ function decodeKittyKeypadText(data: string): string | undefined {
518
+ const match = data.match(KITTY_CSI_U_PATTERN);
519
+ if (!match) return undefined;
520
+ const codepoint = Number.parseInt(match[1] ?? "", 10);
521
+ if (!(codepoint in KITTY_NUMPAD_TEXT) && !(codepoint in KITTY_KEYPAD_OPERATOR_TEXT)) return undefined;
522
+ return decodeKittyPrintable(data);
523
+ }
524
+
525
+ function matchesKeypadKey(data: string, keyId: KeyId): boolean | undefined {
526
+ const printable = decodeKittyKeypadText(data);
527
+ if (printable === undefined) return undefined;
528
+ return printable === keyId;
529
+ }
530
+
507
531
  /**
508
532
  * Match input data against a key identifier string.
509
533
  *
@@ -521,7 +545,7 @@ export function decodePrintableKey(data: string): string | undefined {
521
545
  * @param keyId - Key identifier (e.g., "ctrl+c", "escape", Key.ctrl("c"))
522
546
  */
523
547
  export function matchesKey(data: string, keyId: KeyId): boolean {
524
- return matchesKeyNative(data, keyId, kittyProtocolActive);
548
+ return matchesKeypadKey(data, keyId) ?? matchesKeyNative(data, keyId, kittyProtocolActive);
525
549
  }
526
550
 
527
551
  /**
@@ -533,5 +557,5 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
533
557
  * @param data - Raw input data from terminal
534
558
  */
535
559
  export function parseKey(data: string): string | undefined {
536
- return parseKeyNative(data, kittyProtocolActive) ?? undefined;
560
+ return decodeKittyKeypadText(data) ?? parseKeyNative(data, kittyProtocolActive) ?? undefined;
537
561
  }