`, display math as
+// ``. The sanitizer already allows `language-*`.
+export const texMathFromMarkdown: FromMarkdownExtension = {
+ enter: {
+ texMathText(token) {
+ this.enter({ type: "inlineMath", value: "", data: { hName: "code" } } as never, token);
+ this.buffer();
+ },
+ texMathFlow() {
+ this.buffer();
+ },
+ },
+ exit: {
+ texMathTextData(token) {
+ this.config.enter.data!.call(this, token);
+ this.config.exit.data!.call(this, token);
+ },
+ texMathFlowValue(token) {
+ this.config.enter.data!.call(this, token);
+ this.config.exit.data!.call(this, token);
+ },
+ texMathText(token) {
+ let value = this.resume().replace(/\r\n?/g, "\n");
+ // Like a code span, a formula in a table cell reads `\|` as a pipe.
+ if (this.data.inTable) {
+ value = value.replace(/\\([\\|])/g, (whole, escaped) => (escaped === "|" ? "|" : whole));
+ }
+ const node = this.stack.at(-1) as unknown as {
+ value: string;
+ data: Record;
+ };
+ node.value = value;
+ node.data.hProperties = { className: ["language-math-inline"] };
+ node.data.hChildren = [{ type: "text", value }];
+ this.exit(token);
+ },
+ texMathFlowClosingFence() {
+ this.data.texMathFlowClosed = true;
+ },
+ texMathFlow(token) {
+ const closed = this.data.texMathFlowClosed;
+ this.data.texMathFlowClosed = undefined;
+ const value = this.resume()
+ .replace(/\r\n?/g, "\n")
+ .replace(/^\n|\n$/g, "");
+ if (!closed) {
+ // Not a formula: a plain code node that keeps its opener line.
+ const opener = this.sliceSerialize(token).slice(0, 2);
+ this.enter(
+ { type: "code", lang: null, meta: null, value: value ? `${opener}\n${value}` : opener },
+ token,
+ );
+ this.exit(token);
+ return;
+ }
+ this.enter(
+ {
+ type: "math",
+ value,
+ data: {
+ hName: "pre",
+ hChildren: [
+ {
+ type: "element",
+ tagName: "code",
+ properties: { className: ["language-math-display"] },
+ children: [{ type: "text", value }],
+ },
+ ],
+ },
+ } as never,
+ token,
+ );
+ this.exit(token);
+ },
+ },
+};
+
+export const remarkTexMath = function (this: Processor) {
+ const data = this.data() as {
+ micromarkExtensions?: MicromarkExtension[];
+ fromMarkdownExtensions?: FromMarkdownExtension[];
+ };
+ (data.micromarkExtensions ??= []).push(texMathSyntax);
+ (data.fromMarkdownExtensions ??= []).push(texMathFromMarkdown);
+};
+
+/**
+ * Marks a formula for copying and citing (see `mathWrapperOf`), with its
+ * canonical TeX as the copy text. Set on the typeset formula and on the source
+ * shown in its place.
+ */
+export function texMathAttributes(tex: string, display: boolean) {
+ return {
+ "data-markdown-math": display ? "display" : "inline",
+ "data-markdown-copy": display ? `\\[\n${tex}\n\\]\n\n` : `\\(${tex}\\)`,
+ };
+}
diff --git a/docs/user/appearance.md b/docs/user/appearance.md
index ec02a28ed787..3c18095240b9 100644
--- a/docs/user/appearance.md
+++ b/docs/user/appearance.md
@@ -29,6 +29,14 @@ The controls retreat as the composer docks after you send the first message.
Turn on **Composer context** to keep those controls visible after the thread starts. This preference
applies to the web and desktop clients.
+## Math
+
+Turn on **Render math** to typeset TeX in chat messages and Markdown previews: `\(…\)` inline, and
+`\[` and `\]` or `$$` on their own lines for display. `$…$` and one-line `\[…\]` or `$$…$$` stay as
+written. In a table cell `\|` is a pipe, so use `\Vert` for a double bar. A formula that is invalid
+or unfinished shows its TeX, and copying a formula gives you its TeX. This preference applies to
+the web and desktop clients.
+
## Motion
The main sidebar, right panel, and terminal drawer open and close immediately by default. Move the
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index c9b3c00136dc..f647a4e135a7 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -504,6 +504,7 @@ export const ClientSettingsSchema = Schema.Struct({
snapShotFlash: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
snapShotAnimations: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
wordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
+ mathRenderingEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
});
export type ClientSettings = typeof ClientSettingsSchema.Type;
@@ -1828,5 +1829,6 @@ export const ClientSettingsPatch = Schema.Struct({
snapShotFlash: Schema.optionalKey(Schema.Boolean),
snapShotAnimations: Schema.optionalKey(Schema.Boolean),
wordWrap: Schema.optionalKey(Schema.Boolean),
+ mathRenderingEnabled: Schema.optionalKey(Schema.Boolean),
});
export type ClientSettingsPatch = typeof ClientSettingsPatch.Type;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b948baba7c65..3fc29d063c0f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -811,6 +811,9 @@ importers:
jszip:
specifier: 3.10.1
version: 3.10.1
+ katex:
+ specifier: ^0.16.47
+ version: 0.16.47
lucide:
specifier: ^0.564.0
version: 0.564.0
@@ -896,6 +899,12 @@ importers:
jsdom:
specifier: ^30.0.1
version: 30.1.0(@noble/hashes@1.8.0)
+ mdast-util-from-markdown:
+ specifier: ^2.0.3
+ version: 2.0.3
+ micromark-util-types:
+ specifier: 'catalog:'
+ version: 2.0.2
react-test-renderer:
specifier: 19.2.6
version: 19.2.6(react@19.2.6)