Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions gui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
52 changes: 52 additions & 0 deletions gui/src/components/chat/markdown/KatexMath.tsx
Original file line number Diff line number Diff line change
@@ -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 ? (
<pre className="overflow-x-auto">
<code>{latex}</code>
</pre>
) : (
<code>{latex}</code>
);
}

if (display) {
return (
<div className="my-2 overflow-x-auto" dangerouslySetInnerHTML={{ __html: html }} />
);
}

return <span dangerouslySetInnerHTML={{ __html: html }} />;
}
32 changes: 32 additions & 0 deletions gui/src/components/chat/markdown/MarkdownRenderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MarkdownRenderer text={"\\[\nG \\approx 1+\\alpha\n\\]"} />,
);
expect(html).toContain("katex-display");
expect(html).toContain('class="katex"');
});

test("renders inline $...$ as KaTeX while keeping surrounding text", () => {
const html = renderToStaticMarkup(
<MarkdownRenderer text={"The ratio $G = 1+\\alpha$ converges."} />,
);
expect(html).toContain('class="katex"');
expect(html).toContain("converges");
});

test("leaves a $ inside inline code as code, not math", () => {
const html = renderToStaticMarkup(<MarkdownRenderer text={"use `$x$` please"} />);
expect(html).toContain("<code>$x$</code>");
expect(html).not.toContain("katex");
});

test("sanitizes unsafe LaTeX: no javascript href or anchor is emitted", () => {
const html = renderToStaticMarkup(
<MarkdownRenderer text={"$\\href{javascript:alert(1)}{x}$"} />,
);
expect(html).not.toContain("<a ");
expect(html).not.toMatch(/href\s*=\s*"javascript:/i);
});
5 changes: 5 additions & 0 deletions gui/src/components/chat/markdown/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { openFilePathFromChat, openUrlFromChat } from "../utils/chatOpen";
import { ChatTable } from "./ChatTable";
import { CodeBlock } from "./CodeBlock";
import { KatexMath } from "./KatexMath";
import { MermaidDiagram } from "./MermaidDiagram";
import { dropRedundantCodeHeadings, parseMarkdown } from "./parser";
import type { MdBlock, MdInline, MdListItem } from "./types";
Expand Down Expand Up @@ -60,6 +61,8 @@ function renderBlock(block: MdBlock, workspacePath?: string | null): ReactNode {
return <MermaidDiagram code={block.value} />;
}
return <CodeBlock value={block.value} lang={block.lang} />;
case "math":
return <KatexMath latex={block.value} display />;
case "thematicBreak":
return <hr className="border-border/70" />;
case "blockquote":
Expand Down Expand Up @@ -149,6 +152,8 @@ function renderInline(nodes: MdInline[], workspacePath?: string | null): ReactNo
return <del key={index}>{renderInline(node.children, workspacePath)}</del>;
case "code":
return <code key={index}>{node.value}</code>;
case "math":
return <KatexMath key={index} latex={node.value} />;
case "break":
return <br key={index} />;
case "link":
Expand Down
77 changes: 77 additions & 0 deletions gui/src/components/chat/markdown/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 é" }]);
});
});
Loading
Loading