Skip to content

Commit b2ea509

Browse files
committed
Merge PR Dimillian#587: optional LaTeX math rendering
2 parents 0746096 + 2eff724 commit b2ea509

18 files changed

Lines changed: 1533 additions & 5 deletions

package-lock.json

Lines changed: 299 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,15 @@
4747
"@tauri-apps/plugin-updater": "^2.10.0",
4848
"@xterm/addon-fit": "^0.10.0",
4949
"@xterm/xterm": "^5.5.0",
50+
"katex": "^0.16.44",
5051
"lucide-react": "^0.562.0",
5152
"prismjs": "^1.30.0",
5253
"react": "^19.1.0",
5354
"react-dom": "^19.1.0",
5455
"react-markdown": "^10.1.0",
56+
"rehype-katex": "^7.0.1",
5557
"remark-gfm": "^4.0.1",
58+
"remark-math": "^6.0.0",
5659
"tauri-plugin-liquid-glass-api": "^0.1.6",
5760
"vscode-material-icons": "^0.1.1"
5861
},

src-tauri/src/types.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,11 @@ pub(crate) struct AppSettings {
499499
rename = "showMessageFilePath"
500500
)]
501501
pub(crate) show_message_file_path: bool,
502+
#[serde(
503+
default = "default_math_rendering_enabled",
504+
rename = "mathRenderingEnabled"
505+
)]
506+
pub(crate) math_rendering_enabled: bool,
502507
#[serde(
503508
default = "default_chat_history_scrollback_items",
504509
rename = "chatHistoryScrollbackItems"
@@ -715,6 +720,10 @@ fn default_show_message_file_path() -> bool {
715720
true
716721
}
717722

723+
fn default_math_rendering_enabled() -> bool {
724+
false
725+
}
726+
718727
fn default_chat_history_scrollback_items() -> Option<u32> {
719728
Some(200)
720729
}
@@ -1157,6 +1166,7 @@ impl Default for AppSettings {
11571166
theme: default_theme(),
11581167
usage_show_remaining: default_usage_show_remaining(),
11591168
show_message_file_path: default_show_message_file_path(),
1169+
math_rendering_enabled: default_math_rendering_enabled(),
11601170
chat_history_scrollback_items: default_chat_history_scrollback_items(),
11611171
thread_title_autogeneration_enabled: false,
11621172
automatic_app_update_checks_enabled: true,
@@ -1323,6 +1333,7 @@ mod tests {
13231333
assert_eq!(settings.theme, "system");
13241334
assert!(!settings.usage_show_remaining);
13251335
assert!(settings.show_message_file_path);
1336+
assert!(!settings.math_rendering_enabled);
13261337
assert_eq!(settings.chat_history_scrollback_items, Some(200));
13271338
assert!(!settings.thread_title_autogeneration_enabled);
13281339
assert!(settings.automatic_app_update_checks_enabled);

src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { lazy, Suspense } from "react";
2+
import "katex/dist/katex.min.css";
23
import "./styles/base.css";
34
import "./styles/ds-tokens.css";
45
import "./styles/ds-modal.css";

src/features/app/components/MainApp.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1580,6 +1580,7 @@ export default function MainApp() {
15801580
composerCodeBlockCopyUseModifier:
15811581
appSettings.composerCodeBlockCopyUseModifier,
15821582
showMessageFilePath: appSettings.showMessageFilePath,
1583+
mathRenderingEnabled: appSettings.mathRenderingEnabled,
15831584
openAppTargets: appSettings.openAppTargets,
15841585
selectedOpenAppId: appSettings.selectedOpenAppId,
15851586
experimentalAppsEnabled: appSettings.experimentalAppsEnabled,

src/features/app/hooks/useMainAppLayoutSurfaces.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type UseMainAppLayoutSurfacesArgs = {
2222
| "usageShowRemaining"
2323
| "composerCodeBlockCopyUseModifier"
2424
| "showMessageFilePath"
25+
| "mathRenderingEnabled"
2526
| "openAppTargets"
2627
| "selectedOpenAppId"
2728
| "experimentalAppsEnabled"
@@ -444,6 +445,7 @@ function buildPrimarySurface({
444445
openTargets: appSettings.openAppTargets,
445446
selectedOpenAppId: appSettings.selectedOpenAppId,
446447
codeBlockCopyUseModifier: appSettings.composerCodeBlockCopyUseModifier,
448+
enableMathRendering: appSettings.mathRenderingEnabled,
447449
showMessageFilePath: appSettings.showMessageFilePath,
448450
userInputRequests,
449451
onUserInputSubmit,

src/features/messages/components/Markdown.test.tsx

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,4 +557,258 @@ describe("Markdown file-like href behavior", () => {
557557
expect(screen.getByText("Ready")).toBeTruthy();
558558
});
559559

560+
it("renders inline dollar math when enabled", () => {
561+
const { container } = render(
562+
<Markdown
563+
value="Euler identity: $e^{i\\pi}+1=0$"
564+
className="markdown"
565+
enableMathRendering
566+
/>,
567+
);
568+
569+
expect(container.querySelector(".katex")).toBeTruthy();
570+
expect(container.textContent).toContain("Euler identity");
571+
});
572+
573+
it("renders block math when enabled", () => {
574+
const { container } = render(
575+
<Markdown
576+
value={["$$", "\\nabla \\cdot \\mathbf{E} = \\frac{\\rho}{\\varepsilon_0}", "$$"].join(
577+
"\n",
578+
)}
579+
className="markdown"
580+
enableMathRendering
581+
/>,
582+
);
583+
584+
expect(container.querySelector(".katex-display")).toBeTruthy();
585+
});
586+
587+
it("supports \\(inline\\) and \\[block\\] LaTeX delimiters when enabled", () => {
588+
const { container } = render(
589+
<Markdown
590+
value={[
591+
"Inline: \\(x^2 + y^2\\)",
592+
"",
593+
"\\[",
594+
"\\int_0^1 x^2\\,dx = \\frac{1}{3}",
595+
"\\]",
596+
].join("\n")}
597+
className="markdown"
598+
enableMathRendering
599+
/>,
600+
);
601+
602+
expect(container.querySelectorAll(".katex").length).toBeGreaterThanOrEqual(2);
603+
expect(container.querySelector(".katex-display")).toBeTruthy();
604+
});
605+
606+
it("does not render math inside fenced code blocks", () => {
607+
const { container } = render(
608+
<Markdown
609+
value={["```text", "$e^{i\\pi}+1=0$", "\\(x^2\\)", "```"].join("\n")}
610+
className="markdown"
611+
enableMathRendering
612+
/>,
613+
);
614+
615+
expect(container.querySelector(".katex")).toBeNull();
616+
expect(container.textContent).toContain("$e^{i\\pi}+1=0$");
617+
expect(container.textContent).toContain("\\(x^2\\)");
618+
});
619+
620+
it("does not render math inside container-prefixed fenced code blocks", () => {
621+
const { container } = render(
622+
<Markdown
623+
value={[
624+
"> ```text",
625+
"> \\(x^2\\)",
626+
"> \\[x+y\\]",
627+
"> ```",
628+
"",
629+
"Outside: \\(z^2\\)",
630+
].join("\n")}
631+
className="markdown"
632+
enableMathRendering
633+
/>,
634+
);
635+
636+
expect(container.querySelectorAll(".katex").length).toBe(1);
637+
expect(container.textContent).toContain("\\(x^2\\)");
638+
expect(container.textContent).toContain("\\[x+y\\]");
639+
});
640+
641+
it("does not render math inside indented code blocks", () => {
642+
const { container } = render(
643+
<Markdown
644+
value={[
645+
" \\(x^2\\)",
646+
" \\[",
647+
" x+y",
648+
" \\]",
649+
"",
650+
"Outside: \\(z^2\\)",
651+
].join("\n")}
652+
className="markdown"
653+
enableMathRendering
654+
/>,
655+
);
656+
657+
expect(container.querySelectorAll(".katex").length).toBe(1);
658+
expect(container.textContent).toContain("\\(x^2\\)");
659+
expect(container.textContent).toContain("\\[");
660+
expect(container.textContent).toContain("\\]");
661+
});
662+
663+
it("does not rewrite escaped \\(inline\\) delimiters", () => {
664+
const { container } = render(
665+
<Markdown
666+
value={["Literal: \\\\(x\\\\)", "Outside: \\(z^2\\)"].join("\n")}
667+
className="markdown"
668+
enableMathRendering
669+
/>,
670+
);
671+
672+
expect(container.querySelectorAll(".katex").length).toBe(1);
673+
expect(container.textContent).toContain("\\(x\\)");
674+
});
675+
676+
it("renders \\(inline\\) math when adjacent to percent and slash punctuation", () => {
677+
const { container } = render(
678+
<Markdown
679+
value={[
680+
"Success: \\(p\\)%",
681+
"Ratio: \\(a\\)/\\(b\\)",
682+
].join("\n")}
683+
className="markdown"
684+
enableMathRendering
685+
/>,
686+
);
687+
688+
expect(container.querySelectorAll(".katex").length).toBe(3);
689+
expect(container.textContent).toContain("Success:");
690+
expect(container.textContent).toContain("Ratio:");
691+
});
692+
693+
it("does not render math inside blockquote-indented code blocks", () => {
694+
const { container } = render(
695+
<Markdown
696+
value={[
697+
"> \\(x^2\\)",
698+
"> \\[x+y\\]",
699+
"",
700+
"Outside: \\(z^2\\)",
701+
].join("\n")}
702+
className="markdown"
703+
enableMathRendering
704+
/>,
705+
);
706+
707+
expect(container.querySelectorAll(".katex").length).toBe(1);
708+
expect(container.textContent).toContain("\\(x^2\\)");
709+
expect(container.textContent).toContain("\\[x+y\\]");
710+
});
711+
712+
it("keeps math-like delimiters literal inside long fences with nested shorter fences", () => {
713+
const { container } = render(
714+
<Markdown
715+
value={[
716+
"````text",
717+
"inner fence marker:",
718+
"```",
719+
"\\(x^2\\)",
720+
"\\[x+y\\]",
721+
"````",
722+
"Outside: \\(z^2\\)",
723+
].join("\n")}
724+
className="markdown"
725+
enableMathRendering
726+
/>,
727+
);
728+
729+
expect(container.querySelector(".katex")).toBeTruthy();
730+
expect(container.textContent).toContain("\\(x^2\\)");
731+
expect(container.textContent).toContain("\\[x+y\\]");
732+
});
733+
734+
it("preserves nested list and blockquote structure for \\\\[...\\\\] block delimiters", () => {
735+
const { container } = render(
736+
<Markdown
737+
value={[
738+
"- List item with block math:",
739+
" \\[",
740+
" x^2 + y^2 = r^2",
741+
" \\]",
742+
"",
743+
"> \\[",
744+
"> E = mc^2",
745+
"> \\]",
746+
].join("\n")}
747+
className="markdown"
748+
enableMathRendering
749+
/>,
750+
);
751+
752+
expect(container.querySelector("li .katex-display")).toBeTruthy();
753+
expect(container.querySelector("blockquote .katex-display")).toBeTruthy();
754+
});
755+
756+
it("does not rewrite escaped latex delimiters in link destinations", () => {
757+
render(
758+
<Markdown
759+
value="[wiki](https://en.wikipedia.org/wiki/Function_\\(mathematics\\))"
760+
className="markdown"
761+
enableMathRendering
762+
/>,
763+
);
764+
765+
const link = screen.getByText("wiki").closest("a");
766+
expect(link?.getAttribute("href")).toBe(
767+
"https://en.wikipedia.org/wiki/Function_%5C(mathematics%5C)",
768+
);
769+
});
770+
771+
it("does not rewrite escaped latex delimiters in plain URL literals and references", () => {
772+
const { container } = render(
773+
<Markdown
774+
value={[
775+
"https://example.com/\\(foo\\)",
776+
"[ref]: https://example.com/\\(bar\\)",
777+
].join("\n")}
778+
className="markdown"
779+
enableMathRendering
780+
/>,
781+
);
782+
783+
const plainUrlLink = screen.getByText("https://example.com/\\(foo\\)").closest("a");
784+
expect(plainUrlLink?.getAttribute("href")).toBe("https://example.com/%5C(foo%5C)");
785+
const referenceUrlLink = screen.getByText("https://example.com/\\(bar\\)").closest("a");
786+
expect(referenceUrlLink?.getAttribute("href")).toBe("https://example.com/%5C(bar%5C)");
787+
expect(container.textContent).toContain("[ref]:");
788+
});
789+
790+
it("matches blockquote block-math closer when quote spacing differs", () => {
791+
const { container } = render(
792+
<Markdown
793+
value={["> \\[", "> E = mc^2", ">\\]"].join("\n")}
794+
className="markdown"
795+
enableMathRendering
796+
/>,
797+
);
798+
799+
expect(container.querySelector("blockquote .katex-display")).toBeTruthy();
800+
});
801+
802+
it("matches blockquote block-math closer when quote indentation differs", () => {
803+
const { container } = render(
804+
<Markdown
805+
value={[" > \\[", "> E = mc^2", ">\\]"].join("\n")}
806+
className="markdown"
807+
enableMathRendering
808+
/>,
809+
);
810+
811+
expect(container.querySelector("blockquote .katex-display")).toBeTruthy();
812+
});
813+
560814
});

0 commit comments

Comments
 (0)