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..48566275 100644 --- a/gui/src/components/chat/markdown/parser.test.ts +++ b/gui/src/components/chat/markdown/parser.test.ts @@ -142,3 +142,80 @@ 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" }]); + // 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", () => { + 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" }]); + }); + + 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 é" }]); + }); +}); diff --git a/gui/src/components/chat/markdown/parser.ts b/gui/src/components/chat/markdown/parser.ts index c555db15..ed18fcbf 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,13 @@ 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 — +// 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 = /(? ({ + 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 +514,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 *));