From 38154afc47149731e206a161f5204644d87cb060 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:56:46 +0100 Subject: [PATCH 1/2] fix(web/markdown): disable single-dollar inline math in remark-math MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default remark-math configuration treats $...$ as inline LaTeX, which turns any prose containing two currency amounts (e.g. "save $400 vs the $200 plan") into a KaTeX block — paragraphs collapse, whitespace is stripped, the running text is re-rendered as math symbols. Pass `singleDollarTextMath: false` to remarkMath so single $ is plain text. Block math `$$...$$` (on its own line) still renders, matching GitHub-flavored markdown semantics. Single source of truth: MARKDOWN_PLUGINS is shared by MarkdownText, Reasoning, and MarkdownRenderer — fix lands in all three surfaces. Adds 3 regression tests that drive the unified pipeline end-to-end: prose with multiple "$N" amounts produces no `class="katex"` and no `` element; `$$...$$` block math still does. Co-authored-by: Cursor --- .../assistant-ui/markdown-text.test.ts | 54 ++++++++++++++++++- .../components/assistant-ui/markdown-text.tsx | 9 +++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/web/src/components/assistant-ui/markdown-text.test.ts b/web/src/components/assistant-ui/markdown-text.test.ts index 9a7b2a329..df4adea72 100644 --- a/web/src/components/assistant-ui/markdown-text.test.ts +++ b/web/src/components/assistant-ui/markdown-text.test.ts @@ -1,8 +1,16 @@ import { describe, expect, it } from 'vitest' +import { unified } from 'unified' +import remarkParse from 'remark-parse' +import remarkRehype from 'remark-rehype' +import { toHtml } from 'hast-util-to-html' import remarkBreaks from 'remark-breaks' import remarkNonHttpsAutolink from '@/lib/remark-non-https-autolink' import remarkStripCjkAutolink from '@/lib/remark-strip-cjk-autolink' -import { MARKDOWN_PLUGINS, MARKDOWN_PLUGINS_WITH_BREAKS } from '@/components/assistant-ui/markdown-text' +import { + MARKDOWN_PLUGINS, + MARKDOWN_PLUGINS_WITH_BREAKS, + MARKDOWN_REHYPE_PLUGINS, +} from '@/components/assistant-ui/markdown-text' describe('MARKDOWN_PLUGINS integration', () => { it('includes remarkNonHttpsAutolink', () => { @@ -21,3 +29,47 @@ describe('MARKDOWN_PLUGINS integration', () => { expect(MARKDOWN_PLUGINS_WITH_BREAKS).toContain(remarkBreaks) }) }) + +function render(markdown: string): string { + const processor = unified() + .use(remarkParse) + .use(MARKDOWN_PLUGINS) + .use(remarkRehype) + .use(MARKDOWN_REHYPE_PLUGINS) + const tree = processor.runSync(processor.parse(markdown)) + return toHtml(tree as never) +} + +describe('MARKDOWN_PLUGINS — currency prose vs KaTeX', () => { + // Regression: prose with multiple "$N" amounts must NOT be eaten by KaTeX. + // remarkMath is configured with `singleDollarTextMath: false` so single + // dollar signs are treated as literal text, matching GitHub markdown. + + it('does not render single-$ currency amounts as KaTeX math', () => { + const md = "The plan is $200/mo and the bill is $80 — total $400 saved." + const html = render(md) + expect(html).not.toContain('class="katex"') + expect(html).not.toContain(' { + // Lifted (paraphrased) from the bug report: paragraph with multiple + // "$N" amounts and apostrophes that previously collapsed into a single + // KaTeX block and stripped whitespace from the running text. + const md = "Cursor's UI quotes the ratio: at least $400 of API usage on a $200 plan. That's 2:1." + const html = render(md) + expect(html).not.toContain('class="katex"') + expect(html).not.toContain(' { + const md = "Before\n\n$$\nE = mc^2\n$$\n\nAfter" + const html = render(md) + expect(html).toContain('class="katex"') + }) +}) diff --git a/web/src/components/assistant-ui/markdown-text.tsx b/web/src/components/assistant-ui/markdown-text.tsx index b07d6cc7a..4b7fe53d1 100644 --- a/web/src/components/assistant-ui/markdown-text.tsx +++ b/web/src/components/assistant-ui/markdown-text.tsx @@ -34,10 +34,17 @@ import type { MarkdownTextPrimitiveProps } from '@assistant-ui/react-markdown' // from them. Both must come before remarkMath (to avoid treating TeX as URI). // remarkFilePathLinks runs last to convert file paths → links after all other // transforms have settled. +// +// remarkMath is configured with `singleDollarTextMath: false` so that prose +// containing currency amounts (e.g. "$200/mo ... $80 bill") is not garbled +// into KaTeX output. Block math `$$...$$` (on its own line) still works. +// This matches GitHub-flavored markdown behavior. The option lives on the +// shared TAIL so both MARKDOWN_PLUGINS (default) and MARKDOWN_PLUGINS_WITH_BREAKS +// (user-prompt rendering with hard breaks) inherit the fix. const MARKDOWN_PLUGIN_TAIL = [ remarkNonHttpsAutolink, remarkStripCjkAutolink, - remarkMath, + [remarkMath, { singleDollarTextMath: false }], remarkDisableIndentedCode, remarkFilePathLinks, // upstream — file path → link conversion, runs last ] satisfies NonNullable From f4e9735e04d5308deb95739b63bc59cff9c144d1 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 6 Jun 2026 04:22:07 +0100 Subject: [PATCH 2/2] chore(web): declare unified/remark-parse/remark-rehype/hast-util-to-html MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new markdown-text regression test imports these directly to drive the unified pipeline end-to-end. They were resolving via transitive deps from remark-math and rehype-katex, which is fragile — a future dep upgrade can remove the transitives and break the test. Declare them explicitly under devDependencies. No code change; lockfile records the same versions that were already installed transitively (unified@11.0.5, remark-parse@11.0.0, remark-rehype@11.1.2, hast-util-to-html@9.0.5). Addresses the HAPI Bot review finding on PR #805. Co-authored-by: Cursor --- bun.lock | 6 ++++++ web/package.json | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/bun.lock b/bun.lock index 92fe93d1c..14b926e3c 100644 --- a/bun.lock +++ b/bun.lock @@ -139,10 +139,14 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.2", "autoprefixer": "^10.4.23", + "hast-util-to-html": "^9.0.5", "jsdom": "^26.1.0", "postcss": "^8.5.6", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", "tailwindcss": "^4.1.18", "typescript": "^5.9.3", + "unified": "^11.0.5", "vite": "^7.3.0", "vitest": "^4.0.16", }, @@ -1070,6 +1074,8 @@ "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.20.0", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-hmdAxSgsAxeLfRFn57u13xLc7jHGxKJR/HZUwKkFKe6vcTkGRWsSJToVhGZrUSCT+giYo8g0YQpyOWI/i8cBLA=="], + "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.20.0", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-1GWfncMeaZvBIfSB0RY4UI4ywiKUtOAi41nRHxqUI/VdWS9Rw3syCRa4bH2gFJzrdRtDdi0kfSib9YRHs1uQgg=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], diff --git a/web/package.json b/web/package.json index f9cfc56e0..ba28bffca 100644 --- a/web/package.json +++ b/web/package.json @@ -58,10 +58,14 @@ "@tailwindcss/postcss": "^4.1.18", "@vitejs/plugin-react": "^5.1.2", "autoprefixer": "^10.4.23", + "hast-util-to-html": "^9.0.5", "jsdom": "^26.1.0", "postcss": "^8.5.6", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", "tailwindcss": "^4.1.18", "typescript": "^5.9.3", + "unified": "^11.0.5", "vite": "^7.3.0", "vitest": "^4.0.16" }