Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
300 changes: 299 additions & 1 deletion package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,15 @@
"@tauri-apps/plugin-updater": "^2.10.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"katex": "^0.16.44",
"lucide-react": "^0.562.0",
"prismjs": "^1.30.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"tauri-plugin-liquid-glass-api": "^0.1.6",
"vscode-material-icons": "^0.1.1"
},
Expand Down
11 changes: 11 additions & 0 deletions src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,11 @@ pub(crate) struct AppSettings {
rename = "showMessageFilePath"
)]
pub(crate) show_message_file_path: bool,
#[serde(
default = "default_math_rendering_enabled",
rename = "mathRenderingEnabled"
)]
pub(crate) math_rendering_enabled: bool,
#[serde(
default = "default_chat_history_scrollback_items",
rename = "chatHistoryScrollbackItems"
Expand Down Expand Up @@ -715,6 +720,10 @@ fn default_show_message_file_path() -> bool {
true
}

fn default_math_rendering_enabled() -> bool {
false
}

fn default_chat_history_scrollback_items() -> Option<u32> {
Some(200)
}
Expand Down Expand Up @@ -1157,6 +1166,7 @@ impl Default for AppSettings {
theme: default_theme(),
usage_show_remaining: default_usage_show_remaining(),
show_message_file_path: default_show_message_file_path(),
math_rendering_enabled: default_math_rendering_enabled(),
chat_history_scrollback_items: default_chat_history_scrollback_items(),
thread_title_autogeneration_enabled: false,
automatic_app_update_checks_enabled: true,
Expand Down Expand Up @@ -1323,6 +1333,7 @@ mod tests {
assert_eq!(settings.theme, "system");
assert!(!settings.usage_show_remaining);
assert!(settings.show_message_file_path);
assert!(!settings.math_rendering_enabled);
assert_eq!(settings.chat_history_scrollback_items, Some(200));
assert!(!settings.thread_title_autogeneration_enabled);
assert!(settings.automatic_app_update_checks_enabled);
Expand Down
1 change: 1 addition & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { lazy, Suspense } from "react";
import "katex/dist/katex.min.css";
import "./styles/base.css";
import "./styles/ds-tokens.css";
import "./styles/ds-modal.css";
Expand Down
1 change: 1 addition & 0 deletions src/features/app/components/MainApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1580,6 +1580,7 @@ export default function MainApp() {
composerCodeBlockCopyUseModifier:
appSettings.composerCodeBlockCopyUseModifier,
showMessageFilePath: appSettings.showMessageFilePath,
mathRenderingEnabled: appSettings.mathRenderingEnabled,
openAppTargets: appSettings.openAppTargets,
selectedOpenAppId: appSettings.selectedOpenAppId,
experimentalAppsEnabled: appSettings.experimentalAppsEnabled,
Expand Down
2 changes: 2 additions & 0 deletions src/features/app/hooks/useMainAppLayoutSurfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type UseMainAppLayoutSurfacesArgs = {
| "usageShowRemaining"
| "composerCodeBlockCopyUseModifier"
| "showMessageFilePath"
| "mathRenderingEnabled"
| "openAppTargets"
| "selectedOpenAppId"
| "experimentalAppsEnabled"
Expand Down Expand Up @@ -444,6 +445,7 @@ function buildPrimarySurface({
openTargets: appSettings.openAppTargets,
selectedOpenAppId: appSettings.selectedOpenAppId,
codeBlockCopyUseModifier: appSettings.composerCodeBlockCopyUseModifier,
enableMathRendering: appSettings.mathRenderingEnabled,
showMessageFilePath: appSettings.showMessageFilePath,
userInputRequests,
onUserInputSubmit,
Expand Down
60 changes: 60 additions & 0 deletions src/features/messages/components/Markdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -557,4 +557,64 @@ describe("Markdown file-like href behavior", () => {
expect(screen.getByText("Ready")).toBeTruthy();
});

it("renders inline dollar math when enabled", () => {
const { container } = render(
<Markdown
value="Euler identity: $e^{i\\pi}+1=0$"
className="markdown"
enableMathRendering
/>,
);

expect(container.querySelector(".katex")).toBeTruthy();
expect(container.textContent).toContain("Euler identity");
});

it("renders block math when enabled", () => {
const { container } = render(
<Markdown
value={["$$", "\\nabla \\cdot \\mathbf{E} = \\frac{\\rho}{\\varepsilon_0}", "$$"].join(
"\n",
)}
className="markdown"
enableMathRendering
/>,
);

expect(container.querySelector(".katex-display")).toBeTruthy();
});

it("supports \\(inline\\) and \\[block\\] LaTeX delimiters when enabled", () => {
const { container } = render(
<Markdown
value={[
"Inline: \\(x^2 + y^2\\)",
"",
"\\[",
"\\int_0^1 x^2\\,dx = \\frac{1}{3}",
"\\]",
].join("\n")}
className="markdown"
enableMathRendering
/>,
);

expect(container.querySelectorAll(".katex").length).toBeGreaterThanOrEqual(2);
expect(container.querySelector(".katex-display")).toBeTruthy();
});

it("does not render math inside fenced code blocks", () => {
const { container } = render(
<Markdown
value={["```text", "$e^{i\\pi}+1=0$", "\\(x^2\\)", "```"].join("\n")}
className="markdown"
enableMathRendering
/>,
);

expect(container.querySelector(".katex")).toBeNull();
expect(container.textContent).toContain("$e^{i\\pi}+1=0$");
expect(container.textContent).toContain("\\(x^2\\)");
});

});
97 changes: 94 additions & 3 deletions src/features/messages/components/Markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useEffect, useRef, useState, type ReactNode, type MouseEvent } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import rehypeKatex from "rehype-katex";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { openUrl } from "@tauri-apps/plugin-opener";
import {
describeFileTarget,
Expand All @@ -20,6 +22,7 @@ type MarkdownProps = {
codeBlock?: boolean;
codeBlockStyle?: "default" | "message";
codeBlockCopyUseModifier?: boolean;
enableMathRendering?: boolean;
showFilePath?: boolean;
workspacePath?: string | null;
onOpenFileLink?: (path: ParsedFileLocation) => void;
Expand Down Expand Up @@ -296,6 +299,84 @@ function normalizeListIndentation(value: string) {
return normalized.join("\n");
}

const MARKDOWN_FENCE_PATTERN = /^\s*(```|~~~)/;
const INLINE_CODE_PLACEHOLDER_PREFIX = "\u0000CODExINLINECODE";
const INLINE_CODE_PLACEHOLDER_SUFFIX = "\u0000";
const INLINE_CODE_PATTERN = /(`+)([\s\S]*?)\1/g;

function normalizeLatexMathDelimitersInChunk(value: string) {
const inlineCodeSpans: string[] = [];
const withMaskedInlineCode = value.replace(INLINE_CODE_PATTERN, (match) => {
const index = inlineCodeSpans.length;
inlineCodeSpans.push(match);
return `${INLINE_CODE_PLACEHOLDER_PREFIX}${index}${INLINE_CODE_PLACEHOLDER_SUFFIX}`;
});

const withBlockMath = withMaskedInlineCode.replace(
/\\\[([\s\S]*?)\\\]/g,
(match, body: string) => {
const trimmed = body.trim();
if (!trimmed) {
return match;
}
return `$$\n${trimmed}\n$$`;
},
);
const withInlineMath = withBlockMath.replace(
/\\\(([\s\S]*?)\\\)/g,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict LaTeX delimiter rewrite to message text only

With math rendering enabled, normalizeLatexMathDelimitersInChunk rewrites every \(...\) match before Markdown parsing, so it also mutates link destinations and other syntax content. For example, [wiki](https://en.wikipedia.org/wiki/Function_\(mathematics\)) is transformed to a URL containing $mathematics$, which breaks the original link target. This should be limited to plain text/math contexts (or done after parsing on text nodes) instead of applying a global regex over the raw Markdown source.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I pushed a follow-up fix that addresses this.

What changed:

  • Kept the fence handling fix (marker + minimum length from opener).
  • Updated delimiter normalization so \(...\) / \[...\] conversion no longer mutates markdown link destinations.
  • Specifically, link destination segments are masked before delimiter conversion and restored afterward, so URLs like:
    • [wiki](https://en.wikipedia.org/wiki/Function_\(mathematics\))
      remain unchanged.
  • Fenced and inline code are still protected from delimiter rewriting.

Tests added/updated:

  • Existing long-fence regression still passes.
  • Added regression test for escaped LaTeX delimiters inside link URLs to ensure no URL rewriting.
  • Markdown.test.tsx now passes with the new cases, and npm run typecheck is clean.

So this should close both concerns:

  1. fence marker length correctness
  2. no accidental rewriting of non-text markdown syntax (URL destinations).

(match, body: string) => {
const trimmed = body.trim();
if (!trimmed) {
return match;
}
return `$${trimmed}$`;
},
);

return withInlineMath.replace(
new RegExp(
`${INLINE_CODE_PLACEHOLDER_PREFIX}(\\d+)${INLINE_CODE_PLACEHOLDER_SUFFIX}`,
"g",
),
(_match, indexString: string) => {
const index = Number(indexString);
return inlineCodeSpans[index] ?? _match;
},
);
}

function normalizeLatexMathDelimiters(value: string) {
const lines = value.split(/\r?\n/);
const output: string[] = [];
let inFence = false;
let nonFenceChunk: string[] = [];

const flushNonFenceChunk = () => {
if (nonFenceChunk.length === 0) {
return;
}
output.push(normalizeLatexMathDelimitersInChunk(nonFenceChunk.join("\n")));
nonFenceChunk = [];
};

for (const line of lines) {
if (MARKDOWN_FENCE_PATTERN.test(line)) {
flushNonFenceChunk();
inFence = !inFence;
output.push(line);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track fence marker length when toggling fence state

The fence-skip logic flips inFence on any line that starts with or `~~~`, but Markdown fences only close when the marker character and length match the opener. With a 4-backtick fence (used to show literal triple backticks), an inner line containing incorrectly toggles out of fence mode, so later \(...\)/\[...\] text inside that code block is rewritten to $...$/$$...$$. That mutates code examples that should stay literal when math rendering is enabled.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed by matching fence marker + minimum length from opener, and added regression test for long-fence/nested-shorter-fence case.

continue;
}
if (inFence) {
output.push(line);
continue;
}
nonFenceChunk.push(line);
}

flushNonFenceChunk();
return output.join("\n");
}

function LinkBlock({ urls }: LinkBlockProps) {
return (
<div className="markdown-linkblock">
Expand Down Expand Up @@ -434,15 +515,20 @@ export function Markdown({
codeBlock,
codeBlockStyle = "default",
codeBlockCopyUseModifier = false,
enableMathRendering = false,
showFilePath = true,
workspacePath = null,
onOpenFileLink,
onOpenFileLinkMenu,
onOpenThreadLink,
}: MarkdownProps) {
const markdownValue = codeBlock ? value : normalizeListIndentation(value);
const mathNormalizedValue = !codeBlock && enableMathRendering
? normalizeLatexMathDelimiters(markdownValue)
: markdownValue;
const normalizedValue = codeBlock
? value
: normalizeStructuredReviewTables(normalizeListIndentation(value));
? mathNormalizedValue
: normalizeStructuredReviewTables(mathNormalizedValue);
const content = codeBlock
? `\`\`\`\n${normalizedValue}\n\`\`\``
: normalizedValue;
Expand Down Expand Up @@ -611,7 +697,12 @@ export function Markdown({
return (
<div className={className}>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkFileLinks]}
remarkPlugins={
enableMathRendering
? [remarkGfm, remarkMath, remarkFileLinks]
: [remarkGfm, remarkFileLinks]
}
rehypePlugins={enableMathRendering ? [rehypeKatex] : undefined}
urlTransform={(url) => {
const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url);
// Keep file-like hrefs intact before scheme sanitization runs, otherwise
Expand Down
10 changes: 10 additions & 0 deletions src/features/messages/components/MessageRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { Markdown } from "./Markdown";
import { isStandaloneMarkdownTable } from "./Markdown";

type MarkdownFileLinkProps = {
enableMathRendering?: boolean;
showMessageFilePath?: boolean;
workspacePath?: string | null;
onOpenFileLink?: (path: ParsedFileLocation) => void;
Expand Down Expand Up @@ -372,6 +373,7 @@ export const MessageRow = memo(function MessageRow({
onCopy,
onQuote,
codeBlockCopyUseModifier,
enableMathRendering = false,
showMessageFilePath,
workspacePath,
onOpenFileLink,
Expand Down Expand Up @@ -459,6 +461,7 @@ export const MessageRow = memo(function MessageRow({
className="markdown"
codeBlockStyle="message"
codeBlockCopyUseModifier={codeBlockCopyUseModifier}
enableMathRendering={enableMathRendering}
showFilePath={showMessageFilePath}
workspacePath={workspacePath}
onOpenFileLink={onOpenFileLink}
Expand Down Expand Up @@ -512,6 +515,7 @@ export const ReasoningRow = memo(function ReasoningRow({
parsed,
isExpanded,
onToggle,
enableMathRendering = false,
showMessageFilePath,
workspacePath,
onOpenFileLink,
Expand Down Expand Up @@ -549,6 +553,7 @@ export const ReasoningRow = memo(function ReasoningRow({
className={`reasoning-inline-detail markdown ${
isExpanded ? "" : "tool-inline-clamp"
}`}
enableMathRendering={enableMathRendering}
showFilePath={showMessageFilePath}
workspacePath={workspacePath}
onOpenFileLink={onOpenFileLink}
Expand All @@ -563,6 +568,7 @@ export const ReasoningRow = memo(function ReasoningRow({

export const ReviewRow = memo(function ReviewRow({
item,
enableMathRendering = false,
showMessageFilePath,
workspacePath,
onOpenFileLink,
Expand All @@ -584,6 +590,7 @@ export const ReviewRow = memo(function ReviewRow({
<Markdown
value={item.text}
className="item-text markdown"
enableMathRendering={enableMathRendering}
showFilePath={showMessageFilePath}
workspacePath={workspacePath}
onOpenFileLink={onOpenFileLink}
Expand Down Expand Up @@ -687,6 +694,7 @@ export const ToolRow = memo(function ToolRow({
item,
isExpanded,
onToggle,
enableMathRendering = false,
showMessageFilePath,
workspacePath,
onOpenFileLink,
Expand Down Expand Up @@ -860,6 +868,7 @@ export const ToolRow = memo(function ToolRow({
<Markdown
value={item.detail}
className="item-text markdown"
enableMathRendering={enableMathRendering}
showFilePath={showMessageFilePath}
workspacePath={workspacePath}
onOpenFileLink={onOpenFileLink}
Expand All @@ -873,6 +882,7 @@ export const ToolRow = memo(function ToolRow({
value={summary.output}
className="tool-inline-output markdown"
codeBlock={item.toolType !== "plan"}
enableMathRendering={enableMathRendering}
showFilePath={showMessageFilePath}
workspacePath={workspacePath}
onOpenFileLink={onOpenFileLink}
Expand Down
Loading