diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 75ff38f32260..b028a25433da 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -72,6 +72,7 @@ const clientSettings: ClientSettings = { pullRequestMergeMethodOverrides: {}, timestampFormat: "24-hour", wordWrap: true, + mathRenderingEnabled: false, }; const decodeClientSettingsJson = Schema.decodeEffect(Schema.fromJsonString(ClientSettingsSchema)); diff --git a/apps/web/package.json b/apps/web/package.json index ab2ae7b35ea3..9b4ff18d78f2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -55,6 +55,7 @@ "jose": "catalog:", "jsonc-parser": "3.3.1", "jszip": "3.10.1", + "katex": "^0.16.47", "lucide": "^0.564.0", "lucide-react": "^0.564.0", "mermaid": "^11.17.2", @@ -85,6 +86,8 @@ "babel-plugin-react-compiler": "1.0.0", "compression": "^1.8.1", "jsdom": "^30.0.1", + "mdast-util-from-markdown": "^2.0.3", + "micromark-util-types": "catalog:", "react-test-renderer": "19.2.6", "tailwindcss": "^4.0.0", "unified": "catalog:", diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 08fb61eac0bb..b0f508a70e17 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -12,7 +12,16 @@ import { renderToStaticMarkup } from "react-dom/server"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { describe, expect, it, vi } from "vite-plus/test"; +import { + captureAssistantTextSelection, + resolveAssistantCitationRange, +} from "../lib/assistantTextSelection"; import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; +import { + chatMarkdownClipboardPayload, + serializeTableElementToCsv, + serializeTableElementToMarkdown, +} from "../markdown-clipboard"; import { GitHubIcon } from "./Icons"; import { Button } from "./ui/button"; import { setMarkdownTaskChecked } from "./files/filePreviewMode"; @@ -23,13 +32,16 @@ vi.mock("./chat/MermaidDiagram", () => ({ MermaidDiagram: () => , })); vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); +const settingsOverrides = vi.hoisted(() => ({}) as Record); vi.mock("../hooks/useSettings", async (importOriginal) => { const actual = await importOriginal(); - const settings = actual.getClientSettings(); + const defaults = actual.getClientSettings(); return { ...actual, - useClientSettings: (select?: (value: typeof settings) => unknown) => - select ? select(settings) : settings, + useClientSettings: (select?: (value: typeof defaults) => unknown) => { + const settings = { ...defaults, ...settingsOverrides }; + return select ? select(settings) : settings; + }, }; }); vi.mock("./ui/tooltip", async () => { @@ -1045,6 +1057,564 @@ describe("ChatMarkdown Windows file links", () => { }); }); +type CitedParts = { prose: Text[]; formulas: Element[]; html: Element[] }; + +describe("ChatMarkdown math", () => { + // A real assistant reply. + const reply = [ + "**Your revised \\(G\\) value is reproducible.** Starting from the printed mean free path:", + "", + "\\[", + "r_1=3.88781\\times10^{-5}\\frac{5772}{6000}", + "=3.74007322\\times10^{-5}\\ \\mathrm m.", + "\\]", + "", + "| Quantity | Calculated | Accepted |", + "|---|---:|---:|", + "| \\(G\\) | \\(6.74731\\times10^{-11}\\) | \\(6.67430\\times10^{-11}\\) |", + "", + "Your equations imply", + "", + "\\[", + "\\sigma\\propto\\frac{1}{r_1T^4},\\qquad r_1\\propto T", + "\\quad\\Rightarrow\\quad \\sigma\\propto T^{-5}.", + "\\]", + ].join("\n"); + + async function renderMath( + text: string, + props: Partial> = {}, + ) { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + // Resolving the lazy KaTeX module first lets its Suspense boundaries settle. + await import("./chat/KatexMath"); + const container = document.createElement("div"); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const tex = (selector: string) => + [...container.querySelectorAll(selector)].map( + (node) => node.querySelector('annotation[encoding="application/x-tex"]')?.textContent, + ); + return { container, tex, unmount: () => act(async () => root.unmount()) }; + } + + it("renders \\(…\\) and \\[…\\] as math when math rendering is enabled", async () => { + settingsOverrides.mathRenderingEnabled = true; + const view = await renderMath(reply); + try { + expect(view.tex(".katex-display")).toEqual([ + "r_1=3.88781\\times10^{-5}\\frac{5772}{6000}\n=3.74007322\\times10^{-5}\\ \\mathrm m.", + "\\sigma\\propto\\frac{1}{r_1T^4},\\qquad r_1\\propto T\n\\quad\\Rightarrow\\quad \\sigma\\propto T^{-5}.", + ]); + expect(view.tex("td .katex")).toEqual([ + "G", + "6.74731\\times10^{-11}", + "6.67430\\times10^{-11}", + ]); + expect(view.container.querySelector("strong .katex")).not.toBeNull(); + } finally { + await view.unmount(); + delete settingsOverrides.mathRenderingEnabled; + } + }); + + it.each([true, false])("renders math with parseRawHtml=%s", async (parseRawHtml) => { + settingsOverrides.mathRenderingEnabled = true; + const view = await renderMath("Inline \\(a_1\\) here.\n\n\\[\nb^2\n\\]", { + parseRawHtml, + lineBreaks: !parseRawHtml, + }); + try { + expect(view.tex(".katex")).toEqual(["a_1", "b^2"]); + } finally { + await view.unmount(); + delete settingsOverrides.mathRenderingEnabled; + } + }); + + it("renders a $$ block as math when enabled and keeps it as written when disabled", async () => { + const source = "Before.\n\n$$\nb^2\n$$\n\nAfter."; + settingsOverrides.mathRenderingEnabled = true; + const enabled = await renderMath(source); + try { + expect(enabled.tex(".katex-display")).toEqual(["b^2"]); + expect(enabled.container.textContent).not.toContain("$$"); + } finally { + await enabled.unmount(); + delete settingsOverrides.mathRenderingEnabled; + } + const disabled = await renderMath(source); + try { + expect(disabled.container.querySelector(".katex")).toBeNull(); + expect(disabled.container.textContent).toContain("$$\nb^2\n$$"); + } finally { + await disabled.unmount(); + } + }); + + it("keeps code, escapes, currency, and unfinished formulas as written", async () => { + settingsOverrides.mathRenderingEnabled = true; + const view = await renderMath( + [ + "Costs $5 and $10, and `\\(x\\)` stays code, as does \\\\(y\\\\).", + "", + "```tex", + "\\[", + "z", + "\\]", + "```", + "", + "Still streaming \\(w and a [link](https://x.test/a_\\(b\\))", + ].join("\n"), + ); + try { + expect(view.container.querySelector(".katex")).toBeNull(); + expect(view.container.querySelector("code")?.textContent).toBe("\\(x\\)"); + expect(view.container.querySelector("pre code")?.textContent).toBe("\\[\nz\n\\]\n"); + expect(view.container.textContent).toContain("Costs $5 and $10"); + expect(view.container.querySelector("a")?.getAttribute("href")).toBe("https://x.test/a_(b)"); + } finally { + await view.unmount(); + delete settingsOverrides.mathRenderingEnabled; + } + }); + + it("shows an unfinished display formula as source until its closing line arrives", async () => { + settingsOverrides.mathRenderingEnabled = true; + const unfinished = "Intro\n\n$$\nx = 1"; + const streaming = await renderMath(unfinished); + try { + expect(streaming.container.querySelector(".katex")).toBeNull(); + expect(streaming.container.querySelector("[data-markdown-math]")).toBeNull(); + const block = streaming.container.querySelector("pre"); + expect(block?.textContent).toContain("$$"); + expect(block?.textContent).toContain("x = 1"); + } finally { + await streaming.unmount(); + } + const finished = await renderMath(`${unfinished}\n$$`); + try { + expect(finished.tex(".katex-display")).toEqual(["x = 1"]); + } finally { + await finished.unmount(); + delete settingsOverrides.mathRenderingEnabled; + } + }); + + it("shows the source of TeX that KaTeX rejects and copies formulas as TeX", async () => { + settingsOverrides.mathRenderingEnabled = true; + const view = await renderMath("Good \\(x^2\\), bad \\(\\frac{1\\)."); + try { + expect(view.tex(".katex")).toEqual(["x^2"]); + const copies = [...view.container.querySelectorAll("[data-markdown-copy]")].map((node) => + node.getAttribute("data-markdown-copy"), + ); + expect(copies).toEqual(["\\(x^2\\)", "\\(\\frac{1\\)"]); + expect(view.container.querySelector("code")?.textContent).toBe("\\frac{1"); + } finally { + await view.unmount(); + delete settingsOverrides.mathRenderingEnabled; + } + }); + + describe("clipboard", () => { + const displayTex = "r_1=3.88781\\times10^{-5}\\frac{5772}{6000}\n=3.74\\ \\mathrm m."; + const source = `Intro \\(a_1\\) text.\n\n\\[\n${displayTex}\n\\]\n\nOutro.`; + + async function copyFrom(select: (container: HTMLElement, range: Range) => void) { + settingsOverrides.mathRenderingEnabled = true; + const view = await renderMath(source); + const range = document.createRange(); + select(view.container, range); + const selection = { rangeCount: 1, getRangeAt: () => range } as unknown as Selection; + const payload = chatMarkdownClipboardPayload(selection); + await view.unmount(); + delete settingsOverrides.mathRenderingEnabled; + return payload; + } + + it.each([ + [ + "the whole formula", + (c: HTMLElement, r: Range) => + r.selectNode(c.querySelector(".katex-display")!.parentElement!), + ], + [ + "the formula's contents", + (c: HTMLElement, r: Range) => + r.selectNodeContents(c.querySelector(".katex-display")!.parentElement!), + ], + [ + "its visible .katex-html", + (c: HTMLElement, r: Range) => + r.selectNodeContents(c.querySelector(".katex-display .katex-html")!), + ], + [ + "one glyph run inside it", + (c: HTMLElement, r: Range) => + r.selectNodeContents(c.querySelector(".katex-display .katex-html .base")!), + ], + [ + "an inline formula's .katex-html", + (c: HTMLElement, r: Range) => r.selectNodeContents(c.querySelector("p .katex-html")!), + ], + ])("copies the TeX when %s is selected", async (name, select) => { + const payload = await copyFrom(select); + expect(payload?.text).toBe( + name === "an inline formula's .katex-html" ? "\\(a_1\\)" : `\\[\n${displayTex}\n\\]`, + ); + // The HTML flavor carries the TeX, not KaTeX's markup. + expect(payload?.html).not.toMatch(/katex|annotation|"); + }); + + it("keeps one coherent copy when a selection spans prose and formulas", async () => { + const payload = await copyFrom((container, range) => { + range.setStartBefore(container.querySelector("p")!); + range.setEndAfter(container.lastElementChild!); + }); + expect(payload?.text).toBe(`Intro \\(a_1\\) text.\n\n\\[\n${displayTex}\n\\]\n\nOutro.`); + expect(payload?.html).not.toMatch(/katex|annotation| { + const sentence = "Your revised \\(G\\) value is \\(6.7\\times10^{-11}\\) today."; + + // Browsers put selection endpoints in KaTeX's glyph text, so tests do too. + const textNodes = (node: Node) => { + const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT); + const found: Text[] = []; + while (walker.nextNode()) found.push(walker.currentNode as Text); + return found; + }; + + async function citable(text: string) { + settingsOverrides.mathRenderingEnabled = true; + const view = await renderMath(text); + view.container.dataset.assistantCitationSource = "assistant"; + const capture = (select: (range: Range) => void) => { + const range = document.createRange(); + select(range); + const selection = { + isCollapsed: range.collapsed, + rangeCount: 1, + getRangeAt: () => range, + } as unknown as Selection; + return captureAssistantTextSelection(view.container, selection); + }; + return { + ...view, + capture, + unmount: async () => { + await view.unmount(); + delete settingsOverrides.mathRenderingEnabled; + }, + }; + } + + it.each([ + { + name: "the whole paragraph", + quote: sentence, + select: (range: Range, { prose }: CitedParts) => + range.selectNodeContents(prose[0]!.parentElement!), + visible: (range: Range, { prose }: CitedParts) => { + range.setStart(prose[0]!, 0); + range.setEnd(prose.at(-1)!, prose.at(-1)!.length); + }, + }, + { + name: "a range starting inside a formula", + quote: "\\(G\\) value is \\(6.7\\times10^{-11}\\) today.", + select: (range: Range, { prose, html }: CitedParts) => { + range.setStart(textNodes(html[0]!)[0]!, 0); + range.setEnd(prose.at(-1)!, prose.at(-1)!.length); + }, + visible: (range: Range, { prose, formulas }: CitedParts) => { + range.setStartBefore(formulas[0]!); + range.setEnd(prose.at(-1)!, prose.at(-1)!.length); + }, + }, + { + name: "a range ending inside a formula", + quote: "revised \\(G\\) value is \\(6.7\\times10^{-11}\\)", + select: (range: Range, { prose, html }: CitedParts) => { + range.setStart(prose[0]!, 5); + range.setEnd(textNodes(html[1]!)[1]!, 1); + }, + visible: (range: Range, { prose, formulas }: CitedParts) => { + range.setStart(prose[0]!, 5); + range.setEndAfter(formulas[1]!); + }, + }, + { + name: "a range within one formula", + quote: "\\(6.7\\times10^{-11}\\)", + select: (range: Range, { html }: CitedParts) => { + const glyphs = textNodes(html[1]!); + range.setStart(glyphs[1]!, 1); + range.setEnd(glyphs.at(-1)!, 1); + }, + visible: (range: Range, { formulas }: CitedParts) => { + range.setStartBefore(formulas[1]!); + range.setEndAfter(formulas[1]!); + }, + }, + ])("quotes $name as its source text and finds it again", async ({ quote, select, visible }) => { + const view = await citable(sentence); + try { + const paragraph = view.container.querySelector("p")!; + const parts: CitedParts = { + prose: textNodes(paragraph).filter((node) => !node.parentElement!.closest(".katex")), + formulas: [...paragraph.querySelectorAll("[data-markdown-copy]")], + html: [...paragraph.querySelectorAll(".katex-html")], + }; + const captured = view.capture((range) => select(range, parts)); + expect(captured?.selector.text).toBe(quote); + + // The quote resolves to whole formulas, never KaTeX's glyph nodes. + const found = resolveAssistantCitationRange(view.container, captured!.selector); + const expected = document.createRange(); + visible(expected, parts); + expect(found?.compareBoundaryPoints(Range.START_TO_START, expected)).toBe(0); + expect(found?.compareBoundaryPoints(Range.END_TO_END, expected)).toBe(0); + } finally { + await view.unmount(); + } + }); + + it("quotes prose, a display formula, and a file link with math in its label once each", async () => { + const view = await citable( + [ + "Intro \\(a_1\\) text.", + "", + "\\[", + "r=1", + "\\]", + "", + "See [Report \\(x\\) notes](/tmp/project/src/main.ts) and `code`.", + "", + "Outro.", + ].join("\n"), + ); + try { + const whole = view.capture((range) => range.selectNodeContents(view.container)); + // The file chip is a control, so its label stays out. + expect(whole?.selector.text.replace(/\s+/g, " ")).toBe( + "Intro \\(a_1\\) text. \\[ r=1 \\] See Report \\(x\\) notes and code. Outro.", + ); + + const link = view.container.querySelector('[data-markdown-copy^="[Report"]')!; + const [first, last] = [ + (t: Text) => t.data === "Report ", + (t: Text) => t.data === " notes", + ].map((match) => textNodes(link).find(match)!); + const inLink = view.capture((range) => { + range.setStart(first!, 0); + range.setEnd(last!, last!.length); + }); + expect(inLink?.selector.text).toBe("Report \\(x\\) notes"); + const found = resolveAssistantCitationRange(view.container, inLink!.selector); + expect([found?.startContainer, found?.endContainer]).toEqual([first, last]); + } finally { + await view.unmount(); + } + }); + }); + + it("copies a file link with math in its label as the link, its label text as text, and the formula as TeX", async () => { + settingsOverrides.mathRenderingEnabled = true; + const view = await renderMath("See [Report \\(x\\) notes](/tmp/project/src/main.ts)."); + try { + const link = view.container.querySelector('[data-markdown-copy^="[Report"]')!; + const copy = (select: (range: Range) => void) => { + const range = document.createRange(); + select(range); + const selection = { rangeCount: 1, getRangeAt: () => range } as unknown as Selection; + return chatMarkdownClipboardPayload(selection); + }; + + const label = copy((range) => range.selectNodeContents(link.firstChild!)); + expect(label?.text).toBe("Report"); + expect(label?.html).not.toContain(""); + + const whole = copy((range) => range.selectNode(link)); + expect(whole?.text).toBe("[Report \\(x\\) notes](/tmp/project/src/main.ts)"); + expect(whole?.html).toContain("Report \\(x\\) notes"); + expect(whole?.html).not.toMatch(/katex|annotation| range.selectNodeContents(link.querySelector(".katex-html")!)); + expect(formula?.text).toBe("\\(x\\)"); + expect(formula?.html).toBe('\\(x\\)'); + } finally { + await view.unmount(); + delete settingsOverrides.mathRenderingEnabled; + } + }); + + it("copies a table cell's file link with math in its label as its label with the TeX", async () => { + settingsOverrides.mathRenderingEnabled = true; + const view = await renderMath( + "| File |\n|---|\n| [Report \\(x\\) notes](/tmp/project/src/main.ts) |", + ); + try { + expect(serializeTableElementToCsv(view.container.querySelector("table")!)).toBe( + "File\nReport \\(x\\) notes main.ts", + ); + } finally { + await view.unmount(); + delete settingsOverrides.mathRenderingEnabled; + } + }); + + it("copies table math as TeX in CSV and Markdown while ordinary cells stay as before", async () => { + settingsOverrides.mathRenderingEnabled = true; + const view = await renderMath( + "| Quantity | Value |\n|---|---|\n| \\(G\\) | \\(6.7\\times10^{-11}\\) |\n| **bold**, plain | so \\(\\sigma\\) here |", + ); + try { + const table = view.container.querySelector("table")!; + expect(serializeTableElementToCsv(table)).toBe( + 'Quantity,Value\n\\(G\\),\\(6.7\\times10^{-11}\\)\n"bold, plain",so \\(\\sigma\\) here', + ); + expect(serializeTableElementToMarkdown(table)).toContain( + "| \\(G\\) | \\(6.7\\times10^{-11}\\) |", + ); + } finally { + await view.unmount(); + delete settingsOverrides.mathRenderingEnabled; + } + }); + + it("reads an escaped pipe in a table cell's formula the way a table reads one in a code span", async () => { + settingsOverrides.mathRenderingEnabled = true; + const table = [ + "| Case | Formula |", + "| --- | --- |", + "| bars | \\(\\|x\\|\\) |", + "| norm | \\(\\Vert x\\Vert\\) |", + "| row break | \\(a\\\\\\|b\\) |", + ].join("\n"); + const view = await renderMath(`${table}\n\nOutside a table, \\(\\|x\\|\\) is a norm.`); + try { + // `\|` is the table's escape for a pipe; `\\\|` in the third cell is `\\` then that escape. + expect(view.tex("td .katex")).toEqual(["|x|", "\\Vert x\\Vert", "a\\\\|b"]); + expect(view.tex("p .katex")).toEqual(["\\|x\\|"]); + // Copying the table escapes the pipes again. + expect(serializeTableElementToMarkdown(view.container.querySelector("table")!)).toBe(table); + } finally { + await view.unmount(); + delete settingsOverrides.mathRenderingEnabled; + } + }); + + it("keeps code that only looks like math as code while math rendering is on", async () => { + settingsOverrides.mathRenderingEnabled = true; + const view = await renderMath( + [ + "```math-inline", + "a_1", + "```", + "", + "```math-display", + "b^2", + "```", + "", + 'c^3 and d^4', + "", + '
e^5
', + "", + "Real \\(f\\).", + ].join("\n"), + ); + try { + expect(view.tex(".katex")).toEqual(["f"]); + const code = view.container.textContent ?? ""; + for (const source of ["a_1", "b^2", "c^3", "d^4", "e^5"]) expect(code).toContain(source); + } finally { + await view.unmount(); + delete settingsOverrides.mathRenderingEnabled; + } + }); + + // Offsets in a blockquoted raw HTML block skip its `> ` prefixes, so authored + // code can land on a real opener. + it.each([ + ["inline", ">
\n> \\(hello\n>
"], + ["display", ">
\n> \\[
hello
\n>
"], + ])("keeps authored %s code as code when its offset lands on a math opener", async (_, source) => { + settingsOverrides.mathRenderingEnabled = true; + const view = await renderMath(`${source}\n\nReal \\(f\\).`); + try { + expect(view.tex(".katex")).toEqual(["f"]); + expect(view.container.querySelector("blockquote")?.textContent).toContain("hello"); + expect(view.container.querySelector("blockquote .katex")).toBeNull(); + } finally { + await view.unmount(); + delete settingsOverrides.mathRenderingEnabled; + } + }); + + it("does not let authored HTML pass itself off as a formula", async () => { + settingsOverrides.mathRenderingEnabled = true; + const view = await renderMath( + 'Real shown.', + { parseRawHtml: true, lineBreaks: false }, + ); + try { + const paragraph = view.container.querySelector("p")!; + expect(paragraph.querySelector("span")?.textContent).toBe("shown"); + const range = document.createRange(); + range.selectNodeContents(paragraph); + const selection = { rangeCount: 1, getRangeAt: () => range } as unknown as Selection; + expect(chatMarkdownClipboardPayload(selection)?.text).toBe("Real shown."); + } finally { + await view.unmount(); + delete settingsOverrides.mathRenderingEnabled; + } + }); + + it("points task checkboxes at their own source after math", async () => { + settingsOverrides.mathRenderingEnabled = true; + const text = "Do \\(a\\) first:\n\n\\[\nb\n\\]\n\n- [ ] one \\(c\\)\n- [x] two"; + const view = await renderMath(text); + try { + const markers = [...view.container.querySelectorAll("li")].map((item) => { + const offset = Number(item.getAttribute("data-task-marker-offset")); + return text.slice(offset, offset + 3); + }); + expect(markers).toEqual(["[ ]", "[x]"]); + } finally { + await view.unmount(); + delete settingsOverrides.mathRenderingEnabled; + } + }); + + it("does not typeset math-classed raw HTML while math rendering is off", async () => { + const view = await renderMath('x^2'); + try { + expect(view.container.querySelector(".katex")).toBeNull(); + expect(view.container.querySelector("code")?.textContent).toBe("x^2"); + } finally { + await view.unmount(); + } + }); + + it("leaves the source as ordinary Markdown while math rendering is off", async () => { + const view = await renderMath(reply); + try { + expect(view.container.querySelector(".katex")).toBeNull(); + expect(view.container.textContent).toContain("(G)"); + } finally { + await view.unmount(); + } + }); +}); + it("opens a disclosure only when find selects a match inside it", async () => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.stubGlobal( diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 42b4a0618b6b..1c31e542e46d 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -105,8 +105,10 @@ import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; import { AssistantCitationChip } from "./chat/AssistantCitationChip"; +import { MarkdownMath, isTexMath } from "./chat/MarkdownMath"; import { MarkdownThreadLink } from "./chat/MarkdownThreadLink"; import { isWindowsAbsolutePath } from "@t3tools/shared/path"; +import { remarkTexMath } from "../markdown-math"; import { artifactTemplateFromHastProperties, renderCodexFileCitationsAsMarkdown, @@ -2422,6 +2424,7 @@ function useChatMarkdownState({ // synchronously whether to intercept its `_blank`, and a subscription is what // makes a persisted "app" apply once settings hydrate after launch. const linkTargetPreference = useClientSettings((settings) => settings.browserLinkTarget); + const mathRendering = useClientSettings((settings) => settings.mathRenderingEnabled); const resolveThreadPullRequest = useCallback( (href: string): (ThreadPullRequestKey & { readonly url: string }) | null => { if ( @@ -2656,6 +2659,7 @@ function useChatMarkdownState({ isStreaming, linkTargetPreference, markdownFileLinkMetaByHref, + mathRendering, onTaskListChange, onUseArtifactTemplate, onRunShellCommand, @@ -2689,6 +2693,7 @@ function useChatMarkdownState({ isStreaming, linkTargetPreference, markdownFileLinkMetaByHref, + mathRendering, onTaskListChange, onUseArtifactTemplate, onRunShellCommand, @@ -3094,9 +3099,11 @@ const CHAT_MARKDOWN_COMPONENTS = { ); }, code: function MarkdownCode({ node, children, className, ...props }) { - const { cwd, imageBaseDir, inlineCodeFileLinkMetaByText, fileLinkChip } = use( - ChatMarkdownRendererContext, - ); + const { cwd, imageBaseDir, inlineCodeFileLinkMetaByText, fileLinkChip, mathRendering, text } = + use(ChatMarkdownRendererContext); + if (mathRendering && isTexMath(node, className, text, false)) { + return ; + } if (node?.properties?.dataInlineCode != null) { const codeText = nodeToPlainText(children); const fileLinkMeta = @@ -3252,13 +3259,22 @@ const CHAT_MARKDOWN_COMPONENTS = { return {children}; }, pre: function MarkdownPre({ node, children, ...props }) { - const { resolvedTheme, diffThemeName, expandMedia, isStreaming, onRunShellCommand, text } = use( - ChatMarkdownRendererContext, - ); + const { + resolvedTheme, + diffThemeName, + expandMedia, + isStreaming, + mathRendering, + onRunShellCommand, + text, + } = use(ChatMarkdownRendererContext); const codeBlock = extractCodeBlock(children); if (!codeBlock) { return
{children}
; } + if (mathRendering && isTexMath(node, codeBlock.className, text, true)) { + return ; + } const language = extractFenceLanguage(codeBlock.className); const fenceTitle = extractFenceTitle(extractPreCodeMeta(node)); @@ -3333,17 +3349,21 @@ function ChatMarkdown({ localMediaPreview, setLocalMediaPreview, } = useChatMarkdownState({ text, ...props }); + // Only text that can contain a math opener pays for the extra syntax. + const parseMath = componentState.mathRendering && /\\[([]|\$\$/.test(text); const incrementalParsing = props.isStreaming === true && extraRemarkPlugins.length === 0 && + !parseMath && /(?:^|\n) {0,3}(?:`{3}|~{3})/.test(text); const remarkPlugins = useMemo( () => [ ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS), ...extraRemarkPlugins, + ...(parseMath ? [remarkTexMath] : []), ...(incrementalParsing ? [createIncrementalMarkdownPlugin()] : []), ], - [extraRemarkPlugins, incrementalParsing, lineBreaks], + [extraRemarkPlugins, incrementalParsing, lineBreaks, parseMath], ); // react-markdown converts unparsed HTML nodes to text when skipHtml is false. diff --git a/apps/web/src/components/chat/KatexMath.tsx b/apps/web/src/components/chat/KatexMath.tsx new file mode 100644 index 000000000000..bcadb7982d1d --- /dev/null +++ b/apps/web/src/components/chat/KatexMath.tsx @@ -0,0 +1,38 @@ +import katex from "katex"; +import "katex/dist/katex.min.css"; +import type { ReactNode } from "react"; + +import { texMathAttributes } from "../../markdown-math"; + +// Loaded lazily with its stylesheet on first use. Input is model output, so trust stays off. +export default function KatexMath({ + tex, + display, + fallback, +}: { + tex: string; + display: boolean; + fallback: ReactNode; +}) { + let html: string; + try { + html = katex.renderToString(tex, { + displayMode: display, + trust: false, + strict: "ignore", + maxSize: 20, + }); + } catch { + return fallback; + } + const attributes = texMathAttributes(tex, display); + return display ? ( +
+ ) : ( + + ); +} diff --git a/apps/web/src/components/chat/MarkdownMath.test.tsx b/apps/web/src/components/chat/MarkdownMath.test.tsx new file mode 100644 index 000000000000..f938a5a6a6d5 --- /dev/null +++ b/apps/web/src/components/chat/MarkdownMath.test.tsx @@ -0,0 +1,166 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + captureAssistantTextSelection, + resolveAssistantCitationRange, +} from "../../lib/assistantTextSelection"; +import { chatMarkdownClipboardPayload, serializeTableElementToCsv } from "../../markdown-clipboard"; + +// React caches a lazy import's outcome, so each test needs its own KaTeX chunk. +async function loadMarkdownMath(chunk: () => Promise) { + vi.resetModules(); + vi.doMock("./KatexMath", chunk); + return (await import("./MarkdownMath")).MarkdownMath; +} + +describe("MarkdownMath", () => { + afterEach(() => { + vi.doUnmock("./KatexMath"); + vi.unstubAllGlobals(); + }); + + it("shows the TeX and leaves its neighbors alone when the KaTeX chunk fails to load", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const MarkdownMath = await loadMarkdownMath(async () => { + // A stale deploy or a dropped connection. + throw new Error("Failed to fetch dynamically imported module"); + }); + const caught = vi.spyOn(console, "error").mockImplementation(() => {}); + const container = document.createElement("div"); + const root = createRoot(container); + try { + await act(async () => { + root.render( + <> +

Before the formula.

+

+ Inline in prose. +

+ +
+              const kept = true;
+            
+ , + ); + }); + expect(container.querySelector("p")?.textContent).toBe("Before the formula."); + expect(container.querySelector("pre:not([data-markdown-copy])")?.textContent).toBe( + "const kept = true;", + ); + expect( + [...container.querySelectorAll("[data-markdown-copy]")].map((node) => [ + node.tagName, + node.textContent, + node.getAttribute("data-markdown-copy"), + ]), + ).toEqual([ + ["CODE", "E=mc^2", "\\(E=mc^2\\)"], + ["PRE", "a^2+b^2\n=c^2", "\\[\na^2+b^2\n=c^2\n\\]\n\n"], + ]); + } finally { + await act(async () => root.unmount()); + caught.mockRestore(); + } + }); + + it("quotes, copies, and exports a formula the same while KaTeX loads as once it is typeset", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const katex = await vi.importActual("./KatexMath"); + let release!: () => void; + const gate = new Promise((resolve) => (release = resolve)); + const MarkdownMath = await loadMarkdownMath(async () => { + await gate; + return katex; + }); + const container = document.createElement("div"); + container.dataset.assistantCitationSource = "assistant"; + const root = createRoot(container); + try { + act(() => { + root.render( + <> +

+ Energy here. +

+ + + + + + + +
+ Cell +
+ , + ); + }); + const paragraph = container.querySelector("p")!; + const [before, after] = [paragraph.firstChild as Text, paragraph.lastChild as Text]; + const formulaOf = () => paragraph.children[0]!; + const select = (pick: (range: Range) => void) => { + const range = document.createRange(); + pick(range); + return { + isCollapsed: range.collapsed, + rangeCount: 1, + getRangeAt: () => range, + } as unknown as Selection; + }; + const snapshot = () => ({ + quote: captureAssistantTextSelection( + container, + select((range) => range.selectNodeContents(paragraph)), + )?.selector, + formulaQuote: captureAssistantTextSelection( + container, + select((range) => range.selectNodeContents(formulaOf())), + )?.selector, + copy: chatMarkdownClipboardPayload(select((range) => range.selectNodeContents(container))), + // A drag over part of the formula's text. + partialCopy: chatMarkdownClipboardPayload( + select((range) => { + const glyph = document.createTreeWalker(formulaOf(), NodeFilter.SHOW_TEXT).nextNode()!; + range.setStart(glyph, 0); + range.setEnd(glyph, 1); + }), + ), + csv: serializeTableElementToCsv(container.querySelector("table")!), + }); + + // While loading, the TeX source stands in. + expect(container.querySelector(".katex")).toBeNull(); + const pending = snapshot(); + expect(pending.quote?.text).toBe("Energy \\(E=mc^2\\) here."); + expect(pending.formulaQuote?.text).toBe("\\(E=mc^2\\)"); + expect(pending.partialCopy).toEqual({ + text: "\\(E=mc^2\\)", + html: '\\(E=mc^2\\)', + }); + expect(pending.csv).toBe("Cell \\(x_1\\)"); + expect(pending.copy?.text).toBe( + "Energy \\(E=mc^2\\) here.\n\n\\[\na^2+b^2\n=c^2\n\\]\n\n| Cell \\(x_1\\) |\n| --- |", + ); + + await act(async () => release()); + expect(container.querySelectorAll(".katex")).toHaveLength(3); + + // The quote saved while loading matches the typeset formula. + expect(snapshot()).toEqual(pending); + const found = resolveAssistantCitationRange(container, pending.quote!); + expect([found?.startContainer, found?.startOffset]).toEqual([before, 0]); + expect([found?.endContainer, found?.endOffset]).toEqual([after, after.length]); + const formula = resolveAssistantCitationRange(container, pending.formulaQuote!); + const whole = document.createRange(); + whole.selectNode(formulaOf()); + expect(formula?.compareBoundaryPoints(Range.START_TO_START, whole)).toBe(0); + expect(formula?.compareBoundaryPoints(Range.END_TO_END, whole)).toBe(0); + } finally { + await act(async () => root.unmount()); + } + }); +}); diff --git a/apps/web/src/components/chat/MarkdownMath.tsx b/apps/web/src/components/chat/MarkdownMath.tsx new file mode 100644 index 000000000000..dae245d600dc --- /dev/null +++ b/apps/web/src/components/chat/MarkdownMath.tsx @@ -0,0 +1,46 @@ +import { lazy, Suspense } from "react"; + +import { texMathAttributes } from "../../markdown-math"; +import { RenderErrorBoundary } from "../RenderErrorBoundary"; + +const KatexMath = lazy(() => import("./KatexMath")); + +/** + * Math classes can also come from fences or authored HTML, so the source at the + * node's offset must start with a math opener. + */ +export function isTexMath( + node: { position?: { start: { offset?: number | undefined } } | undefined } | undefined, + className: string | undefined, + source: string, + display: boolean, +): boolean { + const offset = node?.position?.start.offset; + return ( + className?.split(/\s+/).includes(display ? "language-math-display" : "language-math-inline") === + true && + typeof offset === "number" && + (display + ? source.startsWith("\\[", offset) || source.startsWith("$$", offset) + : source.startsWith("\\(", offset)) + ); +} + +/** Shows the TeX source while KaTeX loads and when it cannot typeset it or load. */ +export function MarkdownMath({ tex, display }: { tex: string; display: boolean }) { + const attributes = texMathAttributes(tex, display); + const source = display ? ( +
+      {tex}
+    
+ ) : ( + {tex} + ); + return ( + + + + + + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index bee57d21b253..1c173342b215 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -591,6 +591,9 @@ export function useSettingsRestore(onRestored?: () => void) { ? ["Snooze limited threads"] : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), + ...(settings.mathRenderingEnabled !== DEFAULT_UNIFIED_SETTINGS.mathRenderingEnabled + ? ["Render math"] + : []), ...(settings.persistComposerContextStrip !== DEFAULT_UNIFIED_SETTINGS.persistComposerContextStrip ? ["Composer context"] @@ -719,6 +722,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.notificationMode, settings.inAppNotificationsEnabled, settings.wordWrap, + settings.mathRenderingEnabled, followSystem, theme, themeHalves, @@ -795,6 +799,7 @@ export function useSettingsRestore(onRestored?: () => void) { notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode, inAppNotificationsEnabled: DEFAULT_UNIFIED_SETTINGS.inAppNotificationsEnabled, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, + mathRenderingEnabled: DEFAULT_UNIFIED_SETTINGS.mathRenderingEnabled, persistComposerContextStrip: DEFAULT_UNIFIED_SETTINGS.persistComposerContextStrip, diffFilesCollapsed: DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, @@ -1736,6 +1741,38 @@ function WordWrapRow() { ); } +function MathRenderingRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + return ( + + updateSettings({ + mathRenderingEnabled: DEFAULT_UNIFIED_SETTINGS.mathRenderingEnabled, + }) + } + /> + ) : null + } + control={ + updateSettings({ mathRenderingEnabled: Boolean(checked) })} + aria-label="Render math in chat" + /> + } + /> + ); +} + function FontSettingsGroup() { return ( <> @@ -1834,6 +1871,7 @@ function TypographySection() { > {advanced ? : } + ); } diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 853886d81fcd..f1c4a99ecaa5 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -279,6 +279,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/appearance", searchTerms: ["long lines code blocks tables diffs file previews"], }, + { + id: "render-math", + title: "Render math", + to: "/settings/appearance", + searchTerms: ["latex tex katex equations formulas"], + }, { id: "composer-context", title: "Composer context", diff --git a/apps/web/src/lib/assistantTextSelection.ts b/apps/web/src/lib/assistantTextSelection.ts index f118555f3eac..f9f6f63b6724 100644 --- a/apps/web/src/lib/assistantTextSelection.ts +++ b/apps/web/src/lib/assistantTextSelection.ts @@ -1,5 +1,7 @@ import { ASSISTANT_CITATION_CONTEXT_LENGTH, type AssistantCitation } from "@t3tools/contracts"; +import { mathWrapperOf } from "../markdown-clipboard"; + export type AssistantTextSelector = { readonly text: string; readonly start: number; @@ -128,14 +130,17 @@ export function findAssistantCitationText( return match ?? (quoteCount === 1 ? onlyQuote : null); } -type TextChunk = { node: Text; start: number; end: number }; +/** A text node, or a whole rendered formula standing for its TeX. */ +type TextChunk = { node: Text | Element; start: number; end: number }; + +const isText = (node: Node): node is Text => node.nodeType === 3; /** * Uses DOM text order, with a line break between HTML blocks and at
. * Inline markup, including code and links, contributes its displayed text. - * Controls and subtrees marked hidden/aria-hidden do not contribute. No layout - * reads, CSS-generated content, or soft-wrap line breaks enter the stream, so - * reflow cannot move it. + * Controls and subtrees marked hidden/aria-hidden do not contribute. A rendered + * formula contributes its TeX as one unit. No layout reads, CSS-generated + * content, or soft-wrap line breaks enter the stream, so reflow cannot move it. */ function readAssistantText(root: HTMLElement) { const parts: string[] = []; @@ -143,18 +148,21 @@ function readAssistantText(root: HTMLElement) { let length = 0; let separator = false; + const append = (node: Text | Element, text: string) => { + if (separator && length > 0) { + parts.push("\n"); + length += 1; + } + separator = false; + chunks.push({ node, start: length, end: length + text.length }); + parts.push(text); + length += text.length; + }; + const visit = (node: Node) => { if (node.nodeType === 3) { const text = node as Text; - if (text.length === 0) return; - if (separator && length > 0) { - parts.push("\n"); - length += 1; - } - separator = false; - chunks.push({ node: text, start: length, end: length + text.length }); - parts.push(text.data); - length += text.length; + if (text.length > 0) append(text, text.data); return; } if (node.nodeType !== 1) return; @@ -162,7 +170,11 @@ function readAssistantText(root: HTMLElement) { if (element.matches(EXCLUDED_SELECTOR)) return; const block = element.matches(BLOCK_SELECTOR); if (block || element.tagName === "BR") separator = true; - for (const child of element.childNodes) visit(child); + if (element.matches("[data-markdown-math]")) { + append(element, element.getAttribute("data-markdown-copy")?.trim() ?? ""); + } else { + for (const child of element.childNodes) visit(child); + } if (block) separator = true; }; @@ -223,8 +235,13 @@ export function captureAssistantTextSelection( // Paragraph selection can end at the next block's offset 0 or a parent // boundary. Validate the text actually selected, not that empty endpoint. - range.setStart(first, first === range.startContainer ? range.startOffset : 0); - range.setEnd(last, last === range.endContainer ? range.endOffset : last.length); + // An endpoint inside a formula's glyphs takes in the whole formula. + const startFormula = mathWrapperOf(first.parentElement); + const endFormula = mathWrapperOf(last.parentElement); + if (startFormula) range.setStartBefore(startFormula); + else range.setStart(first, first === range.startContainer ? range.startOffset : 0); + if (endFormula) range.setEndAfter(endFormula); + else range.setEnd(last, last === range.endContainer ? range.endOffset : last.length); if (!isUsableRange(source, range)) return null; const stream = readAssistantText(source); @@ -233,7 +250,7 @@ export function captureAssistantTextSelection( for (const chunk of stream.chunks) { if (!range.intersectsNode(chunk.node)) continue; const start = range.startContainer === chunk.node ? range.startOffset : 0; - const end = range.endContainer === chunk.node ? range.endOffset : chunk.node.length; + const end = range.endContainer === chunk.node ? range.endOffset : chunk.end - chunk.start; if (start === end) continue; rawStart ??= chunk.start + start; rawEnd = chunk.start + end; @@ -276,7 +293,10 @@ export function resolveAssistantCitationRange( if (first === undefined || last === undefined) return null; const range = root.ownerDocument.createRange(); - range.setStart(first.node, Math.max(0, start - first.start)); - range.setEnd(last.node, Math.min(last.node.length, end - last.start)); + // A match that touches a formula covers the formula as rendered. + if (isText(first.node)) range.setStart(first.node, Math.max(0, start - first.start)); + else range.setStartBefore(first.node); + if (isText(last.node)) range.setEnd(last.node, Math.min(last.node.length, end - last.start)); + else range.setEndAfter(last.node); return isUsableRange(root, range) ? range : null; } diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index 39b941801638..ec02880f0d59 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -346,6 +346,24 @@ export function serializeTableElementToMarkdown(table: Element): string { return serializeTable(table).trim(); } +// The wrapper MarkdownMath puts on every formula it renders. The sanitizer drops +// the attribute from authored HTML. +const MATH_SELECTOR = "[data-markdown-math]"; + +export function mathWrapperOf(element: Element | null): Element | null { + return element?.closest(MATH_SELECTOR) ?? null; +} + +/** A cell's text, with each formula as its TeX. */ +function csvCellText(cell: Element): string { + if (!cell.querySelector(MATH_SELECTOR)) return cell.textContent ?? ""; + const copy = cell.cloneNode(true) as Element; + for (const wrapper of copy.querySelectorAll(MATH_SELECTOR)) { + wrapper.replaceWith(wrapper.getAttribute("data-markdown-copy")?.trim() ?? ""); + } + return copy.textContent ?? ""; +} + function csvCell(value: string): string { const normalized = value.replace(/\s+/g, " ").trim(); return /[",\n]/.test(normalized) ? `"${normalized.replaceAll('"', '""')}"` : normalized; @@ -359,12 +377,24 @@ export function serializeTableElementToCsv(table: Element): string { (cell) => cell.tagName === "TH" || cell.tagName === "TD", ); if (cells.length === 0) continue; - lines.push(cells.map((cell) => csvCell(cell.textContent ?? "")).join(",")); + lines.push(cells.map((cell) => csvCell(csvCellText(cell))).join(",")); } return lines.join("\n"); } +function mathSourceElement(wrapper: Element): Element { + const code = document.createElement("code"); + code.textContent = wrapper.getAttribute("data-markdown-copy")?.trim() ?? ""; + if (wrapper.getAttribute("data-markdown-math") !== "display") return code; + const pre = document.createElement("pre"); + pre.append(code); + return pre; +} + function sanitizedHtmlFrom(container: Element): string { + for (const wrapper of container.querySelectorAll(MATH_SELECTOR)) { + wrapper.replaceWith(mathSourceElement(wrapper)); + } for (const node of container.querySelectorAll(SANITIZED_HTML_SELECTOR)) { if ( node.classList.contains("chat-markdown-file-link") || @@ -393,6 +423,13 @@ export function chatMarkdownClipboardPayload( const ancestor = range.commonAncestorContainer; const ancestorElement = ancestor.nodeType === Node.ELEMENT_NODE ? (ancestor as Element) : ancestor.parentElement; + // A range inside a formula would clone only KaTeX's glyphs, not the wrapper holding the TeX. + const math = mathWrapperOf(ancestorElement); + if (math) { + texts.push(math.getAttribute("data-markdown-copy")?.trim() ?? ""); + htmls.push(`${mathSourceElement(math).outerHTML}`); + continue; + } if (ancestorElement?.closest("pre")) { const text = range.toString(); if (text) { diff --git a/apps/web/src/markdown-math.test.ts b/apps/web/src/markdown-math.test.ts new file mode 100644 index 000000000000..65d9bf8836ac --- /dev/null +++ b/apps/web/src/markdown-math.test.ts @@ -0,0 +1,154 @@ +import { fromMarkdown } from "mdast-util-from-markdown"; +import { describe, expect, it } from "vite-plus/test"; + +import { texMathFromMarkdown, texMathSyntax } from "./markdown-math"; + +type Node = { type: string; value?: string; children?: Node[] }; + +function parse(source: string): Node { + return fromMarkdown(source, { + extensions: [texMathSyntax], + mdastExtensions: [texMathFromMarkdown], + }) as Node; +} + +function nodesIn(source: string, ...types: string[]): Node[] { + const found: Node[] = []; + const visit = (node: Node) => { + if (types.includes(node.type)) found.push(node); + node.children?.forEach(visit); + }; + visit(parse(source)); + return found; +} + +/** Math nodes in document order, as `type:value`. */ +const mathIn = (source: string) => + nodesIn(source, "inlineMath", "math").map((node) => `${node.type}:${node.value}`); + +/** Values of the code nodes in document order. */ +const codeIn = (source: string) => nodesIn(source, "code").map((node) => node.value); + +const typesOf = (node: Node) => node.children?.map((child) => child.type); + +describe("TeX math syntax", () => { + it("reads \\(…\\) inline, including TeX backslashes and underscores", () => { + expect(mathIn("so \\(\\sigma\\propto r_1T^{-5}\\) holds")).toEqual([ + "inlineMath:\\sigma\\propto r_1T^{-5}", + ]); + // `\\` is a TeX line break, so the `)` after it does not close the formula. + expect(mathIn("\\(a\\\\ b\\) c")).toEqual(["inlineMath:a\\\\ b"]); + expect(mathIn("\\(a\\\\) c")).toEqual([]); + expect(mathIn("| \\(G\\) | \\(x\\) |\n|---|---|\n| \\(a_1\\) | b |")).toEqual([ + "inlineMath:G", + "inlineMath:x", + "inlineMath:a_1", + ]); + }); + + it("reads \\[ and \\] lines as display math, keeping lines that look like Markdown", () => { + expect(mathIn("\\[\na=b\n\\quad\\Rightarrow\\quad c\n\\]")).toEqual([ + "math:a=b\n\\quad\\Rightarrow\\quad c", + ]); + expect(mathIn("text\n\n\\[\n- a\n# b\n\n> c\n\\]\n\nafter")).toEqual(["math:- a\n# b\n\n> c"]); + expect(mathIn("> \\[\n> x\n> \\]")).toEqual(["math:x"]); + expect(mathIn("- item\n\n \\[\n x^2\n \\]")).toEqual(["math:x^2"]); + }); + + it("reads $$ lines as display math, keeping lines that look like Markdown or TeX", () => { + expect(mathIn("$$\nx\n$$")).toEqual(["math:x"]); + expect(mathIn("text\n\n$$\n- a\n\\[\n\n> c\n$$\n\nafter")).toEqual(["math:- a\n\\[\n\n> c"]); + expect(mathIn("> $$\n> x\n> $$")).toEqual(["math:x"]); + expect(mathIn("- item\n\n $$\n x^2\n $$")).toEqual(["math:x^2"]); + }); + + it.each([ + ["an unclosed inline opener", "costs \\(x and more"], + ["an empty formula", "\\(\\) text"], + ["an escaped backslash", "\\\\(x\\\\)"], + ["a code span", "`\\(x\\)` and \\(a `\\)` b"], + ["a fenced block", "```\n\\[\nx\n\\]\n```"], + ["an indented block", " \\[\n x\n \\]"], + ["a one-line display formula", "\\[ x \\]"], + ["a one-line $$ formula", "$$ x $$"], + ["inline $$ in text", "so $$x$$ holds"], + ["currency", "Costs $5 and $10"], + ["a single $", "$\nx\n$"], + ["a citation", "see \\[1\\] and \\[2\\]"], + ["a link target", "[a](https://x.test/\\(b\\))"], + ])("leaves %s alone", (_, source) => { + expect(mathIn(source)).toEqual([]); + }); + + it("closes an inline formula at its own closer when openers repeat", () => { + expect(mathIn("\\(a \\(b\\) c")).toEqual(["inlineMath:b"]); + }); + + it("scans a message of unmatched openers in linear time", () => { + const source = "\\( x ".repeat(20_000); + const start = performance.now(); + expect(mathIn(source)).toEqual([]); + expect(performance.now() - start).toBeLessThan(2000); + }); + + it("keeps an opener line inside a display block as formula content", () => { + expect(mathIn("\\[\na\n\\[\nb\n\\]")).toEqual(["math:a\n\\[\nb"]); + }); + + it.each([ + ["an unclosed \\[ block", "\\[\nx = 1\n", "\\[\nx = 1"], + ["an unclosed $$ block", "$$\nx = 1\n", "$$\nx = 1"], + ["a $$ block closed by \\]", "$$\na\n\\]", "$$\na\n\\]"], + ["a \\[ block closed by $$", "\\[\na\n$$", "\\[\na\n$$"], + ["a display block cut by a lazy line", "> \\[\n> x\nlazy\n> \\]", "\\[\nx"], + ["a display opener at the end", "text\n\n$$", "$$"], + ])("shows %s as source in a code node", (_, source, code) => { + expect(mathIn(source)).toEqual([]); + expect(codeIn(source)).toEqual([code]); + }); + + it.each([ + ["\\[", "\\]"], + ["$$", "$$"], + ])("keeps the structure after a %s block", (open, close) => { + const closed = parse(`Intro\n${open}\nx\n${close}\n\n- a\n- b\n\n> quote`); + expect(typesOf(closed)).toEqual(["paragraph", "math", "list", "blockquote"]); + }); + + it.each(["\\[", "$$"])("runs an unclosed %s block to the end of the document", (open) => { + const source = `Intro\n${open}\n\n- a\n- b\n\n> quote`; + expect(typesOf(parse(source))).toEqual(["paragraph", "code"]); + expect(codeIn(source)).toEqual([`${open}\n\n- a\n- b\n\n> quote`]); + expect(codeIn(`Intro\n${open}\nx`)).toEqual([`${open}\nx`]); + }); + + it("ends an unclosed block with its list item", () => { + const [list, quote] = parse("- item\n $$\n x\n- next\n\n> quote").children!; + expect(typesOf(list!)).toEqual(["listItem", "listItem"]); + expect(typesOf(list!.children![0]!)).toEqual(["paragraph", "code"]); + expect(list!.children![0]!.children![1]!.value).toBe("$$\nx"); + expect(typesOf(list!.children![1]!)).toEqual(["paragraph"]); + expect(quote?.type).toBe("blockquote"); + }); + + it("ends an unclosed block with its blockquote", () => { + const tree = parse("> $$\n> x\n\nafter"); + expect(typesOf(tree)).toEqual(["blockquote", "paragraph"]); + expect(typesOf(tree.children![0]!)).toEqual(["code"]); + expect(codeIn("> $$\n> x\n\nafter")).toEqual(["$$\nx"]); + }); + + it("scans a message of unmatched display openers in linear time", () => { + const source = "\\[\nx\n".repeat(3200); + const start = performance.now(); + expect(mathIn(source)).toEqual([]); + expect(performance.now() - start).toBeLessThan(1000); + }); + + it("does not rescan display openers inside an unclosed $$ block", () => { + const source = `$$\n${"\\[\nx\n".repeat(3200)}`; + const start = performance.now(); + expect(mathIn(source)).toEqual([]); + expect(performance.now() - start).toBeLessThan(1000); + }); +}); diff --git a/apps/web/src/markdown-math.ts b/apps/web/src/markdown-math.ts new file mode 100644 index 000000000000..965f58b55bfe --- /dev/null +++ b/apps/web/src/markdown-math.ts @@ -0,0 +1,348 @@ +import type { Extension as FromMarkdownExtension } from "mdast-util-from-markdown"; +import type { + Code, + Construct, + Extension as MicromarkExtension, + State, + TokenizeContext, + Tokenizer, +} from "micromark-util-types"; +import type { Processor } from "unified"; + +declare module "micromark-util-types" { + interface TokenTypeMap { + texMathText: "texMathText"; + texMathTextSequence: "texMathTextSequence"; + texMathTextData: "texMathTextData"; + texMathFlow: "texMathFlow"; + texMathFlowFence: "texMathFlowFence"; + texMathFlowClosingFence: "texMathFlowClosingFence"; + texMathFlowValue: "texMathFlowValue"; + } +} + +declare module "mdast-util-from-markdown" { + interface CompileData { + texMathFlowClosed?: boolean | undefined; + } +} + +// \(…\) inline, and \[ / \] or $$ on their own lines for display. These run before +// CommonMark's escape construct, which would otherwise consume \( and \[. A +// one-line \[x\] stays prose so \[1\] citations do. +const DOLLAR = 36; +const BACKSLASH = 92; +const LEFT_PAREN = 40; +const RIGHT_PAREN = 41; +const LEFT_BRACKET = 91; +const RIGHT_BRACKET = 93; +const GRAVE_ACCENT = 96; + +// micromark encodes tabs, virtual spaces, and line endings as negative codes. +const isLineEnding = (code: Code) => code !== null && code < -2; +const isSpace = (code: Code) => code === -2 || code === -1 || code === 32; + +/** + * `\(…\)` in text. Gives up at a backtick (code spans outrank math) or a second + * `\(`, so unmatched openers are scanned once. + */ +const texMathText: Construct = { + name: "texMathText", + tokenize(effects, ok, nok) { + let empty = true; + const closing: Construct = { + partial: true, + tokenize(effects, ok, nok) { + return function start(code) { + effects.enter("texMathTextSequence"); + effects.consume(code); + return function end(code) { + if (code !== RIGHT_PAREN || empty) return nok(code); + effects.consume(code); + effects.exit("texMathTextSequence"); + return ok; + }; + }; + }, + }; + + return function start(code) { + effects.enter("texMathText"); + effects.enter("texMathTextSequence"); + effects.consume(code); + return function open(code) { + if (code !== LEFT_PAREN) return nok(code); + effects.consume(code); + effects.exit("texMathTextSequence"); + return between; + }; + }; + + function between(code: Code): State | undefined { + if (code === null || code === GRAVE_ACCENT) return nok(code); + if (isLineEnding(code)) { + effects.enter("lineEnding"); + effects.consume(code); + effects.exit("lineEnding"); + return between; + } + if (code === BACKSLASH) { + return effects.attempt(closing, done, escape)(code); + } + effects.enter("texMathTextData"); + return data(code); + } + + function data(code: Code): State | undefined { + if (code === null || code === GRAVE_ACCENT || code === BACKSLASH || isLineEnding(code)) { + effects.exit("texMathTextData"); + return between(code); + } + empty = false; + effects.consume(code); + return data; + } + + // A backslash pair is one unit, so `\\)` is a line break then `)`, not a closer. + function escape(code: Code): State | undefined { + effects.enter("texMathTextData"); + empty = false; + effects.consume(code); + return function afterBackslash(code) { + if (code === LEFT_PAREN) return nok(code); + if (code === null || code === GRAVE_ACCENT || isLineEnding(code)) { + effects.exit("texMathTextData"); + return between(code); + } + effects.consume(code); + return data; + }; + } + + function done(code: Code): State | undefined { + effects.exit("texMathText"); + return ok(code); + } + }, +}; + +type Marker = readonly [first: number, second: number]; + +/** + * A line holding only the `open` marker, then lines up to one holding only the + * `close` marker. Like a fenced code block, it runs to its closing line or the + * end of its container; an unclosed block is shown as source. + */ +const tokenizeTexMathFlow = (open: Marker, close: Marker): Tokenizer => + function (effects, ok, nok) { + const interrupting = this.interrupt; + const nonLazyLine: Construct = { + partial: true, + tokenize(this: TokenizeContext, effects, ok, nok) { + return (code) => { + if (code === null) return nok(code); + effects.enter("lineEnding"); + effects.consume(code); + effects.exit("lineEnding"); + return (next) => (this.parser.lazy[this.now().line] ? nok(next) : ok(next)); + }; + }, + }; + const closingFence: Construct = { + partial: true, + tokenize(effects, ok, nok) { + return function start(code) { + effects.enter("texMathFlowClosingFence"); + return indent(code); + }; + + function indent(code: Code): State | undefined { + if (isSpace(code)) { + effects.consume(code); + return indent; + } + if (code !== close[0]) return nok(code); + effects.consume(code); + return function second(code) { + if (code !== close[1]) return nok(code); + effects.consume(code); + return fenceEnd; + }; + } + + function fenceEnd(code: Code): State | undefined { + if (isSpace(code)) { + effects.consume(code); + return fenceEnd; + } + if (code !== null && !isLineEnding(code)) return nok(code); + effects.exit("texMathFlowClosingFence"); + return ok(code); + } + }, + }; + + return function start(code) { + effects.enter("texMathFlow"); + effects.enter("texMathFlowFence"); + effects.consume(code); + return function second(code) { + if (code !== open[1]) return nok(code); + effects.consume(code); + return openEnd; + }; + }; + + // Only the opening line can fail. Past it the block ends in `ok`, closed or + // not. Checking whether the line interrupts a paragraph stops there too. + function openEnd(code: Code): State | undefined { + if (isSpace(code)) { + effects.consume(code); + return openEnd; + } + if (code !== null && !isLineEnding(code)) return nok(code); + effects.exit("texMathFlowFence"); + return interrupting ? ok(code) : lineEnd(code); + } + + function lineEnd(code: Code): State | undefined { + return effects.attempt(nonLazyLine, lineStart, finish)(code); + } + + function lineStart(code: Code): State | undefined { + return effects.attempt(closingFence, finish, content)(code); + } + + function content(code: Code): State | undefined { + if (code === null || isLineEnding(code)) return lineEnd(code); + effects.enter("texMathFlowValue"); + return value(code); + } + + function value(code: Code): State | undefined { + if (code === null || isLineEnding(code)) { + effects.exit("texMathFlowValue"); + return lineEnd(code); + } + effects.consume(code); + return value; + } + + function finish(code: Code): State | undefined { + effects.exit("texMathFlow"); + return ok(code); + } + }; + +const texMathFlow = (open: Marker, close: Marker): Construct => ({ + name: "texMathFlow", + tokenize: tokenizeTexMathFlow(open, close), + concrete: true, +}); + +export const texMathSyntax: MicromarkExtension = { + text: { [BACKSLASH]: texMathText }, + flow: { + [BACKSLASH]: texMathFlow([BACKSLASH, LEFT_BRACKET], [BACKSLASH, RIGHT_BRACKET]), + [DOLLAR]: texMathFlow([DOLLAR, DOLLAR], [DOLLAR, DOLLAR]), + }, +}; + +// Inline math renders as ``, display math as +// `
`. The sanitizer already allows `language-*`.
+export const texMathFromMarkdown: FromMarkdownExtension = {
+  enter: {
+    texMathText(token) {
+      this.enter({ type: "inlineMath", value: "", data: { hName: "code" } } as never, token);
+      this.buffer();
+    },
+    texMathFlow() {
+      this.buffer();
+    },
+  },
+  exit: {
+    texMathTextData(token) {
+      this.config.enter.data!.call(this, token);
+      this.config.exit.data!.call(this, token);
+    },
+    texMathFlowValue(token) {
+      this.config.enter.data!.call(this, token);
+      this.config.exit.data!.call(this, token);
+    },
+    texMathText(token) {
+      let value = this.resume().replace(/\r\n?/g, "\n");
+      // Like a code span, a formula in a table cell reads `\|` as a pipe.
+      if (this.data.inTable) {
+        value = value.replace(/\\([\\|])/g, (whole, escaped) => (escaped === "|" ? "|" : whole));
+      }
+      const node = this.stack.at(-1) as unknown as {
+        value: string;
+        data: Record;
+      };
+      node.value = value;
+      node.data.hProperties = { className: ["language-math-inline"] };
+      node.data.hChildren = [{ type: "text", value }];
+      this.exit(token);
+    },
+    texMathFlowClosingFence() {
+      this.data.texMathFlowClosed = true;
+    },
+    texMathFlow(token) {
+      const closed = this.data.texMathFlowClosed;
+      this.data.texMathFlowClosed = undefined;
+      const value = this.resume()
+        .replace(/\r\n?/g, "\n")
+        .replace(/^\n|\n$/g, "");
+      if (!closed) {
+        // Not a formula: a plain code node that keeps its opener line.
+        const opener = this.sliceSerialize(token).slice(0, 2);
+        this.enter(
+          { type: "code", lang: null, meta: null, value: value ? `${opener}\n${value}` : opener },
+          token,
+        );
+        this.exit(token);
+        return;
+      }
+      this.enter(
+        {
+          type: "math",
+          value,
+          data: {
+            hName: "pre",
+            hChildren: [
+              {
+                type: "element",
+                tagName: "code",
+                properties: { className: ["language-math-display"] },
+                children: [{ type: "text", value }],
+              },
+            ],
+          },
+        } as never,
+        token,
+      );
+      this.exit(token);
+    },
+  },
+};
+
+export const remarkTexMath = function (this: Processor) {
+  const data = this.data() as {
+    micromarkExtensions?: MicromarkExtension[];
+    fromMarkdownExtensions?: FromMarkdownExtension[];
+  };
+  (data.micromarkExtensions ??= []).push(texMathSyntax);
+  (data.fromMarkdownExtensions ??= []).push(texMathFromMarkdown);
+};
+
+/**
+ * Marks a formula for copying and citing (see `mathWrapperOf`), with its
+ * canonical TeX as the copy text. Set on the typeset formula and on the source
+ * shown in its place.
+ */
+export function texMathAttributes(tex: string, display: boolean) {
+  return {
+    "data-markdown-math": display ? "display" : "inline",
+    "data-markdown-copy": display ? `\\[\n${tex}\n\\]\n\n` : `\\(${tex}\\)`,
+  };
+}
diff --git a/docs/user/appearance.md b/docs/user/appearance.md
index ec02a28ed787..3c18095240b9 100644
--- a/docs/user/appearance.md
+++ b/docs/user/appearance.md
@@ -29,6 +29,14 @@ The controls retreat as the composer docks after you send the first message.
 Turn on **Composer context** to keep those controls visible after the thread starts. This preference
 applies to the web and desktop clients.
 
+## Math
+
+Turn on **Render math** to typeset TeX in chat messages and Markdown previews: `\(…\)` inline, and
+`\[` and `\]` or `$$` on their own lines for display. `$…$` and one-line `\[…\]` or `$$…$$` stay as
+written. In a table cell `\|` is a pipe, so use `\Vert` for a double bar. A formula that is invalid
+or unfinished shows its TeX, and copying a formula gives you its TeX. This preference applies to
+the web and desktop clients.
+
 ## Motion
 
 The main sidebar, right panel, and terminal drawer open and close immediately by default. Move the
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index c9b3c00136dc..f647a4e135a7 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -504,6 +504,7 @@ export const ClientSettingsSchema = Schema.Struct({
   snapShotFlash: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
   snapShotAnimations: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
   wordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
+  mathRenderingEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
 });
 export type ClientSettings = typeof ClientSettingsSchema.Type;
 
@@ -1828,5 +1829,6 @@ export const ClientSettingsPatch = Schema.Struct({
   snapShotFlash: Schema.optionalKey(Schema.Boolean),
   snapShotAnimations: Schema.optionalKey(Schema.Boolean),
   wordWrap: Schema.optionalKey(Schema.Boolean),
+  mathRenderingEnabled: Schema.optionalKey(Schema.Boolean),
 });
 export type ClientSettingsPatch = typeof ClientSettingsPatch.Type;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b948baba7c65..3fc29d063c0f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -811,6 +811,9 @@ importers:
       jszip:
         specifier: 3.10.1
         version: 3.10.1
+      katex:
+        specifier: ^0.16.47
+        version: 0.16.47
       lucide:
         specifier: ^0.564.0
         version: 0.564.0
@@ -896,6 +899,12 @@ importers:
       jsdom:
         specifier: ^30.0.1
         version: 30.1.0(@noble/hashes@1.8.0)
+      mdast-util-from-markdown:
+        specifier: ^2.0.3
+        version: 2.0.3
+      micromark-util-types:
+        specifier: 'catalog:'
+        version: 2.0.2
       react-test-renderer:
         specifier: 19.2.6
         version: 19.2.6(react@19.2.6)