diff --git a/app/e2e/math.spec.ts b/app/e2e/math.spec.ts index 1b8ab012..1113f58c 100644 --- a/app/e2e/math.spec.ts +++ b/app/e2e/math.spec.ts @@ -46,3 +46,39 @@ test("a reply's math draws as typeset math, and a single $ stays text", async ({ // A formula KaTeX can't read is its source, and the rest still draws. await expect(md.locator(".katex-error")).toHaveText("\\frac{1}{"); }); + +const BLOCKS_REPLY = [ + "## Rigid bodies, step by step", + "", + "Semi-implicit Euler, velocity first:", + "", + "```math", + "\\begin{aligned} \\mathbf{v}_{t+\\Delta t} &= \\mathbf{v}_t + \\Delta t \\, M^{-1} \\mathbf{F} \\\\ \\mathbf{x}_{t+\\Delta t} &= \\mathbf{x}_t + \\Delta t \\, \\mathbf{v}_{t+\\Delta t} \\end{aligned}", + "```", + "", + "The contact impulse, clamped so bodies only push:", + "", + "```latex", + "\\lambda = \\max\\left(0, -\\frac{J\\mathbf{v} + b}{J M^{-1} J^{\\top}}\\right)", + "```", + "", + "And the file to compile, which stays code:", + "", + "```latex", + "\\documentclass{article}", + "\\begin{document}", + "Rigid bodies.", + "\\end{document}", + "```", +].join("\n"); + +test("a ```math block and a ```latex one of equations draw; a LaTeX document stays code", async ({ app }) => { + mockOnly("lands a reply on the demo's agent"); + await app.open({ params: { view: "conversation" } }); + await app.openWorktree("gpu/judge-v2"); + await landReply(app.page, BLOCKS_REPLY); + const md = app.chat.locator("[data-kind=text] .cv-md").filter({ hasText: "Rigid bodies, step by step" }); + await expect(md.locator(".cv-math-block .katex-display")).toHaveCount(2); + await expect(md.locator(".katex-error")).toHaveCount(0); + await expect(md.locator("pre code", { hasText: "\\documentclass{article}" })).toBeVisible(); +}); diff --git a/app/src/lib/markdown-html.test.ts b/app/src/lib/markdown-html.test.ts index 0c7cef78..5c2c27ec 100644 --- a/app/src/lib/markdown-html.test.ts +++ b/app/src/lib/markdown-html.test.ts @@ -53,6 +53,32 @@ test("math in code stays as written", () => { assert.equal(read("[docs](https://example.com/a\\(b\\))"), '
'); }); +test("a ```math block, or a ```latex one of equations, is a display", () => { + const block = (tex: string) => `Energy:
\n${block("E = mc^2")}\nas Einstein had it.
`); +}); + +test("a LaTeX document, other code and a block still being written stay code", () => { + // A document is for pdflatex. + const doc = "```latex\n\\documentclass{article}\n\\begin{document}\n$x$\n\\end{document}\n```"; + assert.equal(markdownHtml(doc, tex).math, false); + assert.match(read(doc), /\\documentclass/);
+ assert.match(read("```tex\n\\section{Forces}\nF = ma\n```"), //);
+ // Other languages and unnamed blocks are untouched.
+ assert.equal(read("```python\nx = 1\n```"), 'x = 1\n
');
+ // A draft's block that hasn't closed: code until it does.
+ assert.equal(markdownHtml("```math\nE = m", tex).math, false);
+ // Only the open one, when one before it closed.
+ assert.equal(read("```math\na\n```\n\n```math\nb"), '[display:a]\nb\n
\n');
+});
+
test("a formula KaTeX can't read shows as its source, and nothing throws", () => {
const out = html("A \\(\\frac{1}{\\) and $$\\badcommand$$ here.");
assert.match(out, /katex-error/);
diff --git a/app/src/lib/markdown-html.ts b/app/src/lib/markdown-html.ts
index 16e20a19..c6ae8fa0 100644
--- a/app/src/lib/markdown-html.ts
+++ b/app/src/lib/markdown-html.ts
@@ -106,6 +106,16 @@ const MARK = (n: number) => `${n}`;
const MARKS = /(\d+)/g;
const escapeHtml = (s: string) => s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
+const unescapeHtml = (s: string) => s.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/&/g, "&");
+
+// A code block of math: ```math, as GitHub writes a display, or ```latex
+// and ```tex holding only equations. One that is a document (to compile
+// with pdflatex, not to read here) stays code, as does one still open in
+// a draft: a half-written formula would only show KaTeX's errors.
+const MATH_LANGS = "math|latex|tex|katex";
+const MATH_CODE = new RegExp(`([\\s\\S]*?)<\\/code><\\/pre>`, "gi");
+const MATH_FENCE_CLOSED = new RegExp(`^ {0,3}(\`{3,}|~{3,})[ \\t]*(?:${MATH_LANGS})\\b[^\\n]*\\n[\\s\\S]*?\\n {0,3}\\1[ \\t]*$`, "gim");
+const TEX_DOCUMENT = /\\(documentclass|usepackage|begin\{document\}|section|subsection|title|maketitle|author|chapter|item)\b/;
interface Formula {
tex: string;
@@ -194,6 +204,14 @@ export function markdownHtml(text: string, tex?: TexRender, shape: (html: string
if (!unclosed.length || unclosed[0] < 0) break;
source = `${source.slice(0, unclosed[0])}\\${source.slice(unclosed[0])}`;
}
+ let closed = (source.match(MATH_FENCE_CLOSED) ?? []).length;
+ html = html.replace(MATH_CODE, (all, lang: string, body: string) => {
+ if (closed-- <= 0) return all;
+ const src = unescapeHtml(body).replace(/\n$/, "");
+ if (!src.trim() || (lang.toLowerCase() !== "math" && TEX_DOCUMENT.test(src))) return all;
+ formulas.push({ tex: src, display: true, source: `\`\`\`${lang}\n${src}\n\`\`\`` });
+ return `${MARK(formulas.length - 1)}`;
+ });
if (!formulas.length) return { html: shape(html), math: false };
// $$…$$ on a line of its own is a display, as a $$ block is.
html = html.replace(/(\d+)<\/p>/g, (all, n) => {
diff --git a/docs/changelog.mdx b/docs/changelog.mdx
index 96f8757a..7f3d4423 100644
--- a/docs/changelog.mdx
+++ b/docs/changelog.mdx
@@ -11,6 +11,10 @@ again.
## Unreleased
+- **Math in code blocks draws too.** A ` ```math ` block, as GitHub writes a
+ formula, and a ` ```latex ` or ` ```tex ` block of equations now show as
+ typeset math. A whole LaTeX document (with `\documentclass`, meant for
+ `pdflatex`) stays code, as does a block still being written.
- **Agents started by other agents follow Structured agents too.** With
*Structured agents* on in Settings › Labs, an agent that another agent
starts with the berth tools, one started with `berth task new` or