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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
82 changes: 82 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,86 @@ 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\\)");
});

it("keeps math-like delimiters literal inside long fences with nested shorter fences", () => {
const { container } = render(
<Markdown
value={[
"````text",
"inner fence marker:",
"```",
"\\(x^2\\)",
"\\[x+y\\]",
"````",
"Outside: \\(z^2\\)",
].join("\n")}
className="markdown"
enableMathRendering
/>,
);

expect(container.querySelector(".katex")).toBeTruthy();
expect(container.textContent).toContain("\\(x^2\\)");
expect(container.textContent).toContain("\\[x+y\\]");
});

});
133 changes: 130 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,120 @@ function normalizeListIndentation(value: string) {
return normalized.join("\n");
}

type MarkdownFenceState = {
marker: "`" | "~";
length: number;
};

const MARKDOWN_FENCE_OPENER_PATTERN = /^ {0,3}(`{3,}|~{3,})(.*)$/;
const MARKDOWN_FENCE_CLOSER_PATTERN = /^ {0,3}(`{3,}|~{3,})[ \t]*$/;
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 Handle container-prefixed fences during math normalization

When math rendering is enabled, fence detection only matches lines that start with optional spaces followed by backticks/tilde. Valid Markdown fenced blocks inside containers (for example > ```...````, or list-item fences) are not recognized as code regions, so (...)and[...]inside those blocks are rewritten to$...$/$$...$$` before parsing. These sections still render as code, but their literal text is corrupted for users copying examples from quoted/listed code blocks.

Useful? React with 👍 / 👎.

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

function parseFenceOpener(line: string): MarkdownFenceState | null {
const match = line.match(MARKDOWN_FENCE_OPENER_PATTERN);
if (!match) {
return null;
}
const sequence = match[1];
const marker = sequence[0];
if (marker !== "`" && marker !== "~") {
return null;
}
return {
marker,
length: sequence.length,
};
}

function isFenceCloser(line: string, activeFence: MarkdownFenceState) {
const match = line.match(MARKDOWN_FENCE_CLOSER_PATTERN);
if (!match) {
return false;
}
const sequence = match[1];
return sequence[0] === activeFence.marker && sequence.length >= activeFence.length;
}

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 activeFence: MarkdownFenceState | null = null;
let nonFenceChunk: string[] = [];

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

for (const line of lines) {
if (activeFence) {
output.push(line);
if (isFenceCloser(line, activeFence)) {
activeFence = null;
}
continue;
}

const fenceOpener = parseFenceOpener(line);
if (fenceOpener) {
flushNonFenceChunk();
activeFence = fenceOpener;
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 Skip indented code blocks during delimiter normalization

normalizeLatexMathDelimiters only excludes fenced blocks, so any 4-space-indented code block is treated as normal text and passed through normalizeLatexMathDelimitersInChunk; with math rendering enabled this rewrites literals like \(x^2\) to $x^2$. The block still renders as code, but the source text is mutated, which corrupts copied examples for users who paste indented Markdown code blocks.

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.

Addressed in 2d48c9e. normalizeLatexMathDelimiters now skips indented code blocks (4-space/tab), so \\(...\\) / \\[...\\] inside indented code stays literal.

continue;
}
nonFenceChunk.push(line);
}

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

function LinkBlock({ urls }: LinkBlockProps) {
return (
<div className="markdown-linkblock">
Expand Down Expand Up @@ -434,15 +551,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 +733,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
Loading