From d0f883916a34202282ed64f9be3d071090645db9 Mon Sep 17 00:00:00 2001 From: Malcolm Riddoch Date: Mon, 8 Jun 2026 14:05:03 +1200 Subject: [PATCH 1/3] feat(vscode): render KaTeX math in chat messages Wire marked-katex-extension into the existing Marked parser in TextPartView so $inline$ and $$display$$ math is rendered at parse time. Wrap the markdown output in
instead of so block-level elements produced by marked (and .katex-display) are valid HTML. - Add katex and marked-katex-extension to vscode webview deps. - Import katex/dist/katex.min.css from main.tsx (bundled by Vite) rather than styles.css to keep CSS @import semantics out of the app stylesheet. - Loosen the webview CSP: split style-src / style-src-attr / style-src-elem and permit 'unsafe-inline' on style-src-attr so KaTeX's per-element style="height:...; vertical-align:...;" attributes are honored (stacked fraction layout). - Add scoped KaTeX rules to MessageItem.module.css: .katex sizing and line-height, .katex-display block layout with horizontal scroll, vertical-align: baseline on .katex-display > .katex to prevent the inline-math rule from shifting display math, and a direct-child p > .katex selector for inline math. - Update TextPartView test to assert the .markdown container element. --- packages/platforms/vscode/package.json | 2 + .../vscode/src/chat-view-provider.ts | 2 +- .../molecules/TextPartView.test.tsx | 2 +- .../molecules/TextPartView/TextPartView.tsx | 9 +++- .../MessageItem/MessageItem.module.css | 29 +++++++++++ packages/platforms/vscode/webview/main.tsx | 1 + pnpm-lock.yaml | 49 ++++++++++++------- 7 files changed, 73 insertions(+), 21 deletions(-) diff --git a/packages/platforms/vscode/package.json b/packages/platforms/vscode/package.json index 5e4c283..e2ae7f2 100644 --- a/packages/platforms/vscode/package.json +++ b/packages/platforms/vscode/package.json @@ -98,6 +98,8 @@ "diff": "^8.0.0", "dompurify": "^3.3.1", "highlight.js": "^11.11.1", + "katex": "^0.17.0", + "marked-katex-extension": "^5.1.10", "react-icons": "^5.5.0" } } diff --git a/packages/platforms/vscode/src/chat-view-provider.ts b/packages/platforms/vscode/src/chat-view-provider.ts index 0dc4f21..718ac8a 100644 --- a/packages/platforms/vscode/src/chat-view-provider.ts +++ b/packages/platforms/vscode/src/chat-view-provider.ts @@ -380,7 +380,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { + content="default-src 'none'; style-src ${webview.cspSource} 'nonce-${nonce}'; style-src-attr 'unsafe-inline'; style-src-elem ${webview.cspSource} 'nonce-${nonce}'; script-src 'nonce-${nonce}';" /> diff --git a/packages/platforms/vscode/webview/__tests__/components/molecules/TextPartView.test.tsx b/packages/platforms/vscode/webview/__tests__/components/molecules/TextPartView.test.tsx index f6e0dd8..77b9b47 100644 --- a/packages/platforms/vscode/webview/__tests__/components/molecules/TextPartView.test.tsx +++ b/packages/platforms/vscode/webview/__tests__/components/molecules/TextPartView.test.tsx @@ -12,7 +12,7 @@ describe("TextPartView", () => { it("HTML コンテンツをレンダリングすること", () => { const part = createTextPart("Hello world"); const { container } = render(); - expect(container.querySelector("span")).toBeInTheDocument(); + expect(container.querySelector(".markdown")).toBeInTheDocument(); }); // renders the text diff --git a/packages/platforms/vscode/webview/components/molecules/TextPartView/TextPartView.tsx b/packages/platforms/vscode/webview/components/molecules/TextPartView/TextPartView.tsx index f0fa4f4..5e8cd5b 100644 --- a/packages/platforms/vscode/webview/components/molecules/TextPartView/TextPartView.tsx +++ b/packages/platforms/vscode/webview/components/molecules/TextPartView/TextPartView.tsx @@ -2,6 +2,7 @@ import type { TextPart } from "@opencodegui/core"; import DOMPurify from "dompurify"; import hljs from "highlight.js/lib/common"; import { Marked, type Renderer, type Tokens } from "marked"; +import markedKatex from "marked-katex-extension"; import { createElement, useCallback, useMemo } from "react"; import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; @@ -154,7 +155,11 @@ function linkifyAbsolutePaths(html: string): string { } // marked インスタンス(グローバル状態を汚染しない) -const markdownParser = new Marked({ breaks: true }, { renderer: { ...codeRenderer, ...linkRenderer } }); +const markdownParser = new Marked( + { breaks: true }, + { renderer: { ...codeRenderer, ...linkRenderer } }, + markedKatex({ throwOnError: false, nonStandard: true }), +); type Props = { part: TextPart; @@ -206,5 +211,5 @@ export function TextPartView({ part }: Props) { // biome-ignore lint/security/noDangerouslySetInnerHtml: DOMPurify でサニタイズ済みの HTML を描画する // biome-ignore lint/a11y/useKeyWithClickEvents: コピーボタンとファイルリンクのイベント委譲 - return ; + return
; } diff --git a/packages/platforms/vscode/webview/components/organisms/MessageItem/MessageItem.module.css b/packages/platforms/vscode/webview/components/organisms/MessageItem/MessageItem.module.css index 4749904..af042bd 100644 --- a/packages/platforms/vscode/webview/components/organisms/MessageItem/MessageItem.module.css +++ b/packages/platforms/vscode/webview/components/organisms/MessageItem/MessageItem.module.css @@ -223,6 +223,35 @@ margin-top: 0; } +/* --- KaTeX (math) --- */ + +.content :global(.markdown .katex) { + font-size: 1.05em; + line-height: 1.25; + text-rendering: auto; +} + +.content :global(.markdown .katex-display) { + display: block; + margin: 10px 0; + padding: 4px 0 10px; + overflow-x: auto; + overflow-y: visible; + line-height: 1.25; + text-align: center; +} + +.content :global(.markdown .katex-display > .katex) { + display: inline-block; + max-width: 100%; + white-space: nowrap; + vertical-align: baseline; +} + +.content :global(.markdown p > .katex) { + vertical-align: -0.08em; +} + /* --- Code block wrapper with header & copy button --- */ .content :global(.code-block-wrapper) { diff --git a/packages/platforms/vscode/webview/main.tsx b/packages/platforms/vscode/webview/main.tsx index 9891129..49fa970 100644 --- a/packages/platforms/vscode/webview/main.tsx +++ b/packages/platforms/vscode/webview/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App"; +import "katex/dist/katex.min.css"; import "./styles.css"; createRoot(document.getElementById("root")!).render( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b59ff2..fba353b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,12 @@ importers: highlight.js: specifier: ^11.11.1 version: 11.11.1 + katex: + specifier: ^0.17.0 + version: 0.17.0 + marked-katex-extension: + specifier: ^5.1.10 + version: 5.1.10(katex@0.17.0)(marked@17.0.4) react-icons: specifier: ^5.5.0 version: 5.6.0(react@19.2.4) @@ -287,28 +293,24 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] '@biomejs/cli-linux-arm64@2.4.4': resolution: {integrity: sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.4': resolution: {integrity: sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [musl] '@biomejs/cli-linux-x64@2.4.4': resolution: {integrity: sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] '@biomejs/cli-win32-arm64@2.4.4': resolution: {integrity: sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q==} @@ -594,79 +596,66 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -1100,6 +1089,10 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -1528,6 +1521,10 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + katex@0.17.0: + resolution: {integrity: sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==} + hasBin: true + keytar@7.9.0: resolution: {integrity: sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==} @@ -1590,6 +1587,12 @@ packages: resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} hasBin: true + marked-katex-extension@5.1.10: + resolution: {integrity: sha512-TuqrzguLeXXm6iBaf16leL3+dVmMj8KrBdunMVVzxMS/bwcjtQ0YG0sNytl1j7uUo8yClsXJqBbVjH1yOPurwQ==} + peerDependencies: + katex: '>=0.16 <0.18' + marked: '>=4 <19' + marked@17.0.4: resolution: {integrity: sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==} engines: {node: '>= 20'} @@ -2118,6 +2121,7 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true validate-npm-package-license@3.0.4: @@ -3248,6 +3252,8 @@ snapshots: commander@12.1.0: {} + commander@8.3.0: {} + concat-map@0.0.1: {} convert-source-map@2.0.0: {} @@ -3719,6 +3725,10 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 + katex@0.17.0: + dependencies: + commander: 8.3.0 + keytar@7.9.0: dependencies: node-addon-api: 4.3.0 @@ -3776,6 +3786,11 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 + marked-katex-extension@5.1.10(katex@0.17.0)(marked@17.0.4): + dependencies: + katex: 0.17.0 + marked: 17.0.4 + marked@17.0.4: {} math-intrinsics@1.1.0: {} From 5e8d2553f2471c1ab2ddbe0bab6600daf1b98fe6 Mon Sep 17 00:00:00 2001 From: Malcolm Riddoch Date: Mon, 15 Jun 2026 10:43:08 +1200 Subject: [PATCH 2/3] fix(vscode): prevent KaTeX tag overlap --- .../components/molecules/TextPartView.test.tsx | 15 +++++++++++++++ .../organisms/MessageItem/MessageItem.module.css | 10 ++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/platforms/vscode/webview/__tests__/components/molecules/TextPartView.test.tsx b/packages/platforms/vscode/webview/__tests__/components/molecules/TextPartView.test.tsx index 77b9b47..e936938 100644 --- a/packages/platforms/vscode/webview/__tests__/components/molecules/TextPartView.test.tsx +++ b/packages/platforms/vscode/webview/__tests__/components/molecules/TextPartView.test.tsx @@ -150,4 +150,19 @@ describe("TextPartView", () => { spy.mockRestore(); }); }); + + // KaTeX display math with \tag{} renders a .tag element inside .katex-html + context("\\tag{} 付きディスプレイ数式の場合", () => { + it("\\tag{} 要素がレンダリングされること", () => { + const spy = vi.spyOn(Marked.prototype, "parse").mockReturnValueOnce( + '', + ); + const part = createTextPart("$$\\tag{1} x+y^{2x}$$"); + const { container } = render(); + expect(container.querySelector(".katex-display")).toBeInTheDocument(); + expect(container.querySelector(".katex-display .katex .katex-html .tag")).toBeInTheDocument(); + expect(container.querySelector(".tag")!.textContent).toBe("(1)"); + spy.mockRestore(); + }); + }); }); diff --git a/packages/platforms/vscode/webview/components/organisms/MessageItem/MessageItem.module.css b/packages/platforms/vscode/webview/components/organisms/MessageItem/MessageItem.module.css index af042bd..fa16693 100644 --- a/packages/platforms/vscode/webview/components/organisms/MessageItem/MessageItem.module.css +++ b/packages/platforms/vscode/webview/components/organisms/MessageItem/MessageItem.module.css @@ -242,12 +242,18 @@ } .content :global(.markdown .katex-display > .katex) { - display: inline-block; - max-width: 100%; + display: block; + max-width: none; white-space: nowrap; + text-align: center; vertical-align: baseline; } +.content :global(.markdown .katex-display > .katex > .katex-html) { + width: max-content; + min-width: 100%; +} + .content :global(.markdown p > .katex) { vertical-align: -0.08em; } From db746ba057832e10b17be08da9cd93efd02e7f49 Mon Sep 17 00:00:00 2001 From: Malcolm Riddoch Date: Mon, 15 Jun 2026 10:51:58 +1200 Subject: [PATCH 3/3] style(vscode): format KaTeX tag test --- .../__tests__/components/molecules/TextPartView.test.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/platforms/vscode/webview/__tests__/components/molecules/TextPartView.test.tsx b/packages/platforms/vscode/webview/__tests__/components/molecules/TextPartView.test.tsx index e936938..d85a0e7 100644 --- a/packages/platforms/vscode/webview/__tests__/components/molecules/TextPartView.test.tsx +++ b/packages/platforms/vscode/webview/__tests__/components/molecules/TextPartView.test.tsx @@ -154,9 +154,11 @@ describe("TextPartView", () => { // KaTeX display math with \tag{} renders a .tag element inside .katex-html context("\\tag{} 付きディスプレイ数式の場合", () => { it("\\tag{} 要素がレンダリングされること", () => { - const spy = vi.spyOn(Marked.prototype, "parse").mockReturnValueOnce( - '', - ); + const spy = vi + .spyOn(Marked.prototype, "parse") + .mockReturnValueOnce( + '', + ); const part = createTextPart("$$\\tag{1} x+y^{2x}$$"); const { container } = render(); expect(container.querySelector(".katex-display")).toBeInTheDocument();