From 83637aad09161b44689ab43255ea50f31bfd984c Mon Sep 17 00:00:00 2001
From: Rach Pradhan <54503978+justrach@users.noreply.github.com>
Date: Tue, 14 Jul 2026 13:55:02 +0800
Subject: [PATCH 1/3] feat(gui): render LaTeX math in chat via KaTeX (#208)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Agent responses emit LaTeX, but the dependency-free chat Markdown parser had no
math node, so display formulas like `\[ G \approx 1+\alpha \]` and inline `$...$`
rendered as raw source with visible backslashes and delimiters.
Parser (types.ts, parser.ts): adds an inline and a display `math` AST node and
recognizes all four agent-emitted forms — display `\[...\]` / `$$...$$` (block,
multi-line, checked before other blocks) and inline `\(...\)` / `$...$`. Inline
code keeps top precedence so a `$` inside backticks stays code, and fences are
captured before math so `$$` inside a fence stays code. The `$...$` form is
deliberately narrow (opener/closer hug the content, never against a digit, never
after a backslash) so prose currency like "$5 and $10" and an escaped `\$` are
left as text. An unclosed delimiter (still streaming) degrades to open math /
literal text instead of throwing or swallowing the rest of the message.
Renderer (KatexMath.tsx, MarkdownRenderer.tsx): a new component typesets the
captured LaTeX with KaTeX. renderToString is pure (no DOM), so it stays
server-renderable and unit-testable; throwOnError:false makes malformed/streaming
input render as a visible error rather than throw; trust:false keeps \href and
raw-HTML commands from injecting anything unsafe. katex.min.css is bundled via the
existing @import block in styles/index.css (mirroring shadcn/tailwind.css) so the
component needs no CSS import the test runner can't resolve.
Tests: parser.test.ts (+7: code/fence protection, escaped `$`, currency,
streaming degradation) and MarkdownRenderer.test.tsx (+4 full-pipeline
renderToStaticMarkup: display/inline typeset, code stays code, unsafe \href emits
no anchor or javascript: href). gui `bun test` 146/146; eslint clean; the new
files typecheck clean under strict tsc.
Note: the full Tauri/Vite build (`bun run build`) is not exercised here (its
gen:desktop-contracts step needs cargo), so the bundled-CSS visual result was not
run in this environment; the @import follows the repo's existing pattern.
Refs #208
Co-Authored-By: blackfloofie <265516171+blackfloofie@users.noreply.github.com>
---
bun.lock | 16 +++--
gui/package.json | 2 +
.../components/chat/markdown/KatexMath.tsx | 52 ++++++++++++++
.../chat/markdown/MarkdownRenderer.test.tsx | 32 +++++++++
.../chat/markdown/MarkdownRenderer.tsx | 5 ++
.../components/chat/markdown/parser.test.ts | 50 ++++++++++++++
gui/src/components/chat/markdown/parser.ts | 69 ++++++++++++++++++-
gui/src/components/chat/markdown/types.ts | 3 +
gui/src/styles/index.css | 1 +
9 files changed, 224 insertions(+), 6 deletions(-)
create mode 100644 gui/src/components/chat/markdown/KatexMath.tsx
diff --git a/bun.lock b/bun.lock
index f4d55ebd..b0f8e055 100644
--- a/bun.lock
+++ b/bun.lock
@@ -7,7 +7,7 @@
},
"gui": {
"name": "codegraff-gui",
- "version": "0.0.162",
+ "version": "0.0.166",
"dependencies": {
"@base-ui/react": "^1.4.0",
"@codegraff/diffs": "workspace:*",
@@ -23,6 +23,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dockview-react": "^5.2.0",
+ "katex": "^0.17.0",
"lucide-react": "^1.8.0",
"mermaid": "^11.15.0",
"react": "^19.2.4",
@@ -40,6 +41,7 @@
"@rolldown/plugin-babel": "^0.2.3",
"@tailwindcss/vite": "^4.2.2",
"@tauri-apps/cli": "^2.10.1",
+ "@types/katex": "^0.16.8",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -489,6 +491,8 @@
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
+ "@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="],
+
"@types/node": ["@types/node@24.13.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="],
"@types/q": ["@types/q@1.5.8", "", {}, "sha512-hroOstUScF6zhIi+5+x0dzqrHA1EJi+Irri6b1fxolMTqqHIV/Cg77EtnQcZqZCu8hR3mX2BzIxN4/GzI68Kfw=="],
@@ -621,7 +625,7 @@
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
- "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
+ "commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
@@ -1093,7 +1097,7 @@
"jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
- "katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
+ "katex": ["katex@0.17.0", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
@@ -1601,12 +1605,12 @@
"is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
- "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
-
"log-symbols/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
+ "mermaid/katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
+
"mermaid/uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="],
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
@@ -1625,6 +1629,8 @@
"restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
+ "shadcn/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
+
"shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"svgo/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="],
diff --git a/gui/package.json b/gui/package.json
index cffe8273..bc7c78f4 100644
--- a/gui/package.json
+++ b/gui/package.json
@@ -30,6 +30,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dockview-react": "^5.2.0",
+ "katex": "^0.17.0",
"lucide-react": "^1.8.0",
"mermaid": "^11.15.0",
"react": "^19.2.4",
@@ -47,6 +48,7 @@
"@rolldown/plugin-babel": "^0.2.3",
"@tailwindcss/vite": "^4.2.2",
"@tauri-apps/cli": "^2.10.1",
+ "@types/katex": "^0.16.8",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
diff --git a/gui/src/components/chat/markdown/KatexMath.tsx b/gui/src/components/chat/markdown/KatexMath.tsx
new file mode 100644
index 00000000..f037aac0
--- /dev/null
+++ b/gui/src/components/chat/markdown/KatexMath.tsx
@@ -0,0 +1,52 @@
+import { useMemo } from "react";
+import katex from "katex";
+
+// KaTeX renders math the local Markdown parser captured as `\[...\]`/`$$...$$`
+// (display) or `\(...\)`/`$...$` (inline). renderToString is pure (no DOM), so
+// this stays server-renderable and unit-testable. katex.min.css is bundled
+// globally via src/styles/index.css (@import), mirroring how shadcn/tailwind.css
+// is pulled in, so this component carries no CSS import the (non-Vite) test
+// runner would fail to resolve.
+//
+// throwOnError:false makes an incomplete (still-streaming) or malformed formula
+// render as a visible KaTeX error instead of throwing; trust:false keeps
+// \href/\includegraphics and raw-HTML commands from injecting anything unsafe.
+interface KatexMathProps {
+ latex: string;
+ display?: boolean;
+}
+
+export function KatexMath({ latex, display = false }: KatexMathProps) {
+ const html = useMemo(() => {
+ try {
+ return katex.renderToString(latex, {
+ displayMode: display,
+ throwOnError: false,
+ output: "htmlAndMathml",
+ trust: false,
+ strict: "ignore",
+ });
+ } catch {
+ return null;
+ }
+ }, [latex, display]);
+
+ if (html == null) {
+ // renderToString unexpectedly threw: show the raw source rather than nothing.
+ return display ? (
+
+ {latex}
+
+ ) : (
+ {latex}
+ );
+ }
+
+ if (display) {
+ return (
+
+ );
+ }
+
+ return ;
+}
diff --git a/gui/src/components/chat/markdown/MarkdownRenderer.test.tsx b/gui/src/components/chat/markdown/MarkdownRenderer.test.tsx
index dbb2116e..6e4f331d 100644
--- a/gui/src/components/chat/markdown/MarkdownRenderer.test.tsx
+++ b/gui/src/components/chat/markdown/MarkdownRenderer.test.tsx
@@ -49,3 +49,35 @@ test("allows markdown links to workspace file paths", () => {
expect(html).toContain('href="src/app.ts"');
expect(html).toContain('href="/Users/example/project/src/app.ts"');
});
+
+// #208: agent responses emit LaTeX; the renderer must typeset it via KaTeX
+// rather than showing raw source, while keeping code and unsafe input safe.
+test("renders display math as typeset KaTeX, not raw source", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("katex-display");
+ expect(html).toContain('class="katex"');
+});
+
+test("renders inline $...$ as KaTeX while keeping surrounding text", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('class="katex"');
+ expect(html).toContain("converges");
+});
+
+test("leaves a $ inside inline code as code, not math", () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain("$x$");
+ expect(html).not.toContain("katex");
+});
+
+test("sanitizes unsafe LaTeX: no javascript href or anchor is emitted", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).not.toContain(";
}
return ;
+ case "math":
+ return ;
case "thematicBreak":
return
;
case "blockquote":
@@ -149,6 +152,8 @@ function renderInline(nodes: MdInline[], workspacePath?: string | null): ReactNo
return {renderInline(node.children, workspacePath)};
case "code":
return {node.value};
+ case "math":
+ return ;
case "break":
return
;
case "link":
diff --git a/gui/src/components/chat/markdown/parser.test.ts b/gui/src/components/chat/markdown/parser.test.ts
index 878ebf73..eaa0d8ec 100644
--- a/gui/src/components/chat/markdown/parser.test.ts
+++ b/gui/src/components/chat/markdown/parser.test.ts
@@ -142,3 +142,53 @@ describe("dropRedundantCodeHeadings", () => {
expect(blocks.map((b) => b.type)).toEqual(["heading", "paragraph"]);
});
});
+
+describe("parseMarkdown math (#208)", () => {
+ test("parses a multi-line display block \\[ ... \\]", () => {
+ const [block] = parseMarkdown("\\[\nG \\approx 1+\\alpha\n\\]");
+ expect(block).toMatchObject({ type: "math", value: "G \\approx 1+\\alpha", closed: true });
+ });
+
+ test("parses single-line $$...$$ and \\[...\\] display blocks", () => {
+ expect(parseMarkdown("$$x^2$$")[0]).toMatchObject({ type: "math", value: "x^2", closed: true });
+ expect(parseMarkdown("\\[x^2\\]")[0]).toMatchObject({ type: "math", value: "x^2", closed: true });
+ });
+
+ test("renders an unclosed (streaming) display block as open math without throwing", () => {
+ // Why: chat streams tokens; a half-arrived formula must degrade to open math
+ // rather than swallow the rest of the message as a paragraph of backslashes.
+ const [block] = parseMarkdown("\\[\nx^2");
+ expect(block).toMatchObject({ type: "math", value: "x^2", closed: false });
+ });
+
+ test("parses inline \\( ... \\) and $ ... $ between text", () => {
+ expect(parseInline("before \\(x^2\\) after")).toEqual([
+ { type: "text", value: "before " },
+ { type: "math", value: "x^2" },
+ { type: "text", value: " after" },
+ ]);
+ expect(parseInline("before $x^2$ after")).toEqual([
+ { type: "text", value: "before " },
+ { type: "math", value: "x^2" },
+ { type: "text", value: " after" },
+ ]);
+ });
+
+ test("does not treat an escaped \\$ or prose currency as math", () => {
+ expect(parseInline("costs \\$5 today")).toEqual([{ type: "text", value: "costs \\$5 today" }]);
+ expect(parseInline("$5 and $10 total")).toEqual([{ type: "text", value: "$5 and $10 total" }]);
+ });
+
+ test("keeps a $ inside inline code and $$ inside a fence as code", () => {
+ expect(parseInline("use `$x$` here")).toEqual([
+ { type: "text", value: "use " },
+ { type: "code", value: "$x$" },
+ { type: "text", value: " here" },
+ ]);
+ expect(parseMarkdown("```\n$$x$$\n```")[0]).toMatchObject({ type: "code", value: "$$x$$" });
+ });
+
+ test("degrades an unclosed inline $ to literal text", () => {
+ expect(parseInline("open $x math")).toEqual([{ type: "text", value: "open $x math" }]);
+ });
+});
diff --git a/gui/src/components/chat/markdown/parser.ts b/gui/src/components/chat/markdown/parser.ts
index c555db15..cd19a2c7 100644
--- a/gui/src/components/chat/markdown/parser.ts
+++ b/gui/src/components/chat/markdown/parser.ts
@@ -13,6 +13,8 @@ const BLOCKQUOTE_RE = /^ {0,3}> ?(.*)$/;
const UNORDERED_RE = /^(\s*)([-*+])\s+(.*)$/;
const ORDERED_RE = /^(\s*)(\d{1,9})([.)])\s+(.*)$/;
const TABLE_DELIM_RE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
+// Display-math openers: \[ ... \] and $$ ... $$ (recognized at block level).
+const MATH_BLOCK_OPEN_RE = /^\s*(\$\$|\\\[)/;
export function parseMarkdown(input: string): MdBlock[] {
const text = input.replace(/\r\n?/g, "\n");
@@ -38,6 +40,13 @@ function parseBlocks(lines: string[]): MdBlock[] {
continue;
}
+ // Display math (\[...\] / $$...$$) is checked before other blocks so its
+ // delimiters are never mistaken for a paragraph or thematic break.
+ if (MATH_BLOCK_OPEN_RE.test(line)) {
+ i = pushMathBlock(blocks, lines, i);
+ continue;
+ }
+
if (THEMATIC_BREAK_RE.test(line)) {
blocks.push({ type: "thematicBreak" });
i += 1;
@@ -119,6 +128,46 @@ function pushFence(
return [i];
}
+function pushMathBlock(blocks: MdBlock[], lines: string[], start: number): number {
+ const opener = lines[start].match(MATH_BLOCK_OPEN_RE)![1];
+ const closer = opener === "$$" ? "$$" : "\\]";
+ const afterOpener = lines[start].slice(lines[start].indexOf(opener) + opener.length);
+
+ // Opener and closer on the same line, e.g. `\[ x^2 \]`.
+ const sameLine = afterOpener.indexOf(closer);
+ if (sameLine >= 0) {
+ blocks.push({ type: "math", value: afterOpener.slice(0, sameLine).trim(), closed: true });
+ return start + 1;
+ }
+
+ // Otherwise collect until a line containing the closer. An unclosed run (still
+ // streaming) degrades to an open math block rather than swallowing the rest of
+ // the message as a paragraph.
+ const body: string[] = [];
+ if (afterOpener.trim() !== "") {
+ body.push(afterOpener);
+ }
+ let i = start + 1;
+ let closed = false;
+ while (i < lines.length) {
+ const closeIndex = lines[i].indexOf(closer);
+ if (closeIndex >= 0) {
+ const before = lines[i].slice(0, closeIndex);
+ if (before.trim() !== "") {
+ body.push(before);
+ }
+ closed = true;
+ i += 1;
+ break;
+ }
+ body.push(lines[i]);
+ i += 1;
+ }
+
+ blocks.push({ type: "math", value: body.join("\n").trim(), closed });
+ return i;
+}
+
function pushBlockquote(blocks: MdBlock[], lines: string[], start: number): number {
const inner: string[] = [];
let i = start;
@@ -321,6 +370,7 @@ function startsNewBlock(lines: string[], i: number): boolean {
const line = lines[i];
return (
FENCE_RE.test(line) ||
+ MATH_BLOCK_OPEN_RE.test(line) ||
THEMATIC_BREAK_RE.test(line) ||
HEADING_RE.test(line) ||
BLOCKQUOTE_RE.test(line) ||
@@ -352,6 +402,7 @@ function inlineToPlainText(nodes: MdInline[]): string {
switch (node.type) {
case "text":
case "code":
+ case "math":
return node.value;
case "strong":
case "em":
@@ -381,6 +432,11 @@ const DEL_RE = /~~([\s\S]+?)~~/g;
const EM_STAR_RE = /\*([\s\S]+?)\*/g;
const EM_UNDER_RE = /(?]*>|[^)\s]+)(?:\s+"[^"]*")?\s*\)/g;
+// Inline math: \( ... \) and $ ... $. The dollar form is deliberately narrow —
+// the opener/closer hug the content and must not sit against a digit — so prose
+// currency like "$5 and $10" stays text; an escaped \$ never opens either form.
+const MATH_PAREN_RE = /\\\(([\s\S]+?)\\\)/g;
+const MATH_DOLLAR_RE = /(? ({
+ type: "math",
+ value: m[1].trim(),
+ }));
+ pushCandidate(candidates, MATH_DOLLAR_RE, text, from, (m) => ({
+ type: "math",
+ value: m[1].trim(),
+ }));
pushCandidate(candidates, HARD_BREAK_RE, text, from, () => ({ type: "break" }));
pushCandidate(candidates, LINK_RE, text, from, (m) => ({
type: "link",
@@ -445,7 +512,7 @@ function nextInlineMatch(text: string, from: number): InlineMatch | null {
}
// Earliest position wins; on ties the earlier-pushed (higher precedence:
- // code, break, link, strong, del, em) candidate is kept.
+ // code, math, break, link, strong, del, em) candidate is kept.
candidates.sort((a, b) => a.index - b.index);
return candidates[0];
}
diff --git a/gui/src/components/chat/markdown/types.ts b/gui/src/components/chat/markdown/types.ts
index f67d4f7f..1bae916a 100644
--- a/gui/src/components/chat/markdown/types.ts
+++ b/gui/src/components/chat/markdown/types.ts
@@ -8,6 +8,7 @@ export type MdInline =
| { type: "em"; children: MdInline[] }
| { type: "del"; children: MdInline[] }
| { type: "code"; value: string }
+ | { type: "math"; value: string }
| { type: "link"; href: string; children: MdInline[] }
| { type: "break" };
@@ -22,6 +23,8 @@ export type MdBlock =
| { type: "paragraph"; children: MdInline[] }
// `closed` is false while a fence is still being streamed (no closing ```).
| { type: "code"; lang: string | null; value: string; closed: boolean }
+ // Display math (\[...\] or $$...$$). `closed` is false while still streaming.
+ | { type: "math"; value: string; closed: boolean }
| { type: "blockquote"; children: MdBlock[] }
| { type: "list"; ordered: boolean; start: number; items: MdListItem[] }
| {
diff --git a/gui/src/styles/index.css b/gui/src/styles/index.css
index a4520260..de3d728a 100644
--- a/gui/src/styles/index.css
+++ b/gui/src/styles/index.css
@@ -160,6 +160,7 @@
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
+@import "katex/dist/katex.min.css";
@custom-variant dark (&:is(.dark *));
From ece7768e1c494e07cb24826a9d5db745702dff26 Mon Sep 17 00:00:00 2001
From: Rach Pradhan <54503978+justrach@users.noreply.github.com>
Date: Tue, 14 Jul 2026 14:22:40 +0800
Subject: [PATCH 2/3] fix(gui): don't let prose "$5 and $word" span into inline
math (#208)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Self-review follow-up on the inline `$...$` matcher: it let the closing `$` sit
against a space, so a price followed by a later `$word` ("I have $5 and $funds")
matched "5 and" as math. Require the closer to hug its content — (?
---
gui/src/components/chat/markdown/parser.test.ts | 11 +++++++++++
gui/src/components/chat/markdown/parser.ts | 8 +++++---
2 files changed, 16 insertions(+), 3 deletions(-)
diff --git a/gui/src/components/chat/markdown/parser.test.ts b/gui/src/components/chat/markdown/parser.test.ts
index eaa0d8ec..534fdc12 100644
--- a/gui/src/components/chat/markdown/parser.test.ts
+++ b/gui/src/components/chat/markdown/parser.test.ts
@@ -177,6 +177,17 @@ describe("parseMarkdown math (#208)", () => {
test("does not treat an escaped \\$ or prose currency as math", () => {
expect(parseInline("costs \\$5 today")).toEqual([{ type: "text", value: "costs \\$5 today" }]);
expect(parseInline("$5 and $10 total")).toEqual([{ type: "text", value: "$5 and $10 total" }]);
+ // A price followed by a later `$word` must not span into inline math: the
+ // closing `$` sits against a space, so it never closes a formula.
+ expect(parseInline("I have $5 and $funds now")).toEqual([
+ { type: "text", value: "I have $5 and $funds now" },
+ ]);
+ // Real inline math still parses (the closer hugs the content).
+ expect(parseInline("math $x = 5$ here")).toEqual([
+ { type: "text", value: "math " },
+ { type: "math", value: "x = 5" },
+ { type: "text", value: " here" },
+ ]);
});
test("keeps a $ inside inline code and $$ inside a fence as code", () => {
diff --git a/gui/src/components/chat/markdown/parser.ts b/gui/src/components/chat/markdown/parser.ts
index cd19a2c7..ed18fcbf 100644
--- a/gui/src/components/chat/markdown/parser.ts
+++ b/gui/src/components/chat/markdown/parser.ts
@@ -433,10 +433,12 @@ const EM_STAR_RE = /\*([\s\S]+?)\*/g;
const EM_UNDER_RE = /(?]*>|[^)\s]+)(?:\s+"[^"]*")?\s*\)/g;
// Inline math: \( ... \) and $ ... $. The dollar form is deliberately narrow —
-// the opener/closer hug the content and must not sit against a digit — so prose
-// currency like "$5 and $10" stays text; an escaped \$ never opens either form.
+// Inline math: \( ... \) and $ ... $. The dollar form is deliberately narrow —
+// the opener/closer hug the content and must not sit against a digit or (for the
+// closer) a space — so prose currency like "$5 and $10" or "$5 and $funds" stays
+// text; an escaped \$ never opens either form.
const MATH_PAREN_RE = /\\\(([\s\S]+?)\\\)/g;
-const MATH_DOLLAR_RE = /(?
Date: Tue, 14 Jul 2026 14:39:50 +0800
Subject: [PATCH 3/3] =?UTF-8?q?test(gui):=20adversarial=20math=20parsing?=
=?UTF-8?q?=20=E2=80=94=20empty/nested/multi-byte=20(#208)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Locks in the parser's degrade-safely behavior against inputs the streaming chat
will hit: empty/malformed delimiters ($$, \(\), lone $) stay literal instead of
emitting an empty formula or throwing; a literal $ inside \( ... \) reaches KaTeX
verbatim rather than being treated as a nested delimiter; and multi-byte glyphs
inside math content are preserved.
Refs #208
Co-Authored-By: blackfloofie <265516171+blackfloofie@users.noreply.github.com>
---
gui/src/components/chat/markdown/parser.test.ts | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/gui/src/components/chat/markdown/parser.test.ts b/gui/src/components/chat/markdown/parser.test.ts
index 534fdc12..48566275 100644
--- a/gui/src/components/chat/markdown/parser.test.ts
+++ b/gui/src/components/chat/markdown/parser.test.ts
@@ -202,4 +202,20 @@ describe("parseMarkdown math (#208)", () => {
test("degrades an unclosed inline $ to literal text", () => {
expect(parseInline("open $x math")).toEqual([{ type: "text", value: "open $x math" }]);
});
+
+ test("degrades empty/malformed delimiters to text instead of throwing", () => {
+ // Empty content matches no math (the capture requires ≥1 char), so these stay
+ // literal rather than producing an empty formula or crashing a stream.
+ expect(parseInline("a $$ b")).toEqual([{ type: "text", value: "a $$ b" }]);
+ expect(parseInline("a \\(\\) b")).toEqual([{ type: "text", value: "a \\(\\) b" }]);
+ expect(parseInline("price is $")).toEqual([{ type: "text", value: "price is $" }]);
+ });
+
+ test("passes a literal $ inside \\( ... \\) to KaTeX verbatim, not as a nested delimiter", () => {
+ expect(parseInline("\\(a $ b\\)")).toEqual([{ type: "math", value: "a $ b" }]);
+ });
+
+ test("preserves multi-byte characters inside math content", () => {
+ expect(parseInline("$\\alpha é$")).toEqual([{ type: "math", value: "\\alpha é" }]);
+ });
});