From e41e5bac91a4c7b69f726e5f132c4af76439f39c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 15 Jun 2026 23:37:24 +0300 Subject: [PATCH] perf(code): replace react-syntax-highlighter and prismjs with the Shiki worker Route all non-markdown code highlighting through the off-main-thread Shiki worker, removing react-syntax-highlighter and prismjs entirely. - Extend the worker with highlightLines: tokenize a whole block once and return per-line inner HTML, so per-line layouts (diffs, gutters, virtualization) make one worker call instead of one highlighter per line. - Add shared WorkerHighlightedCode (whole-block) and useWorkerHighlightedLines (per-line) primitives. Colors resolve via the --md-syntax-* CSS variables, so theme changes never re-highlight. - Migrate all 12 react-syntax-highlighter call sites: PermissionCard, ToolPart, ContextSidebarTab, ToolOutputDialog (whole block) and DiffPreview/WritePreview (per line). - Migrate VirtualizedCodeBlock off prismjs to the worker, keeping virtua virtualization; whole-block tokenization also restores cross-line syntax context that per-line highlighting lost. - Drop react-syntax-highlighter (+types) from ui and web, prismjs (+types) from ui, and the orphaned create-element type shim. --- bun.lock | 8 - packages/ui/package.json | 4 - .../ui/src/components/chat/DiffPreview.tsx | 152 +++++++------- .../ui/src/components/chat/PermissionCard.tsx | 49 ++--- .../chat/markdown/markdown-shiki.worker.ts | 68 +++++- .../chat/markdown/markdown-worker-protocol.ts | 8 +- .../chat/markdown/markdown-worker.ts | 37 +++- .../chat/message/ToolOutputDialog.tsx | 68 +++--- .../chat/message/parts/ToolPart.tsx | 34 ++- .../message/parts/VirtualizedCodeBlock.tsx | 198 +++--------------- .../components/code/WorkerHighlightedCode.tsx | 110 ++++++++++ .../code/useWorkerHighlightedLines.ts | 26 +++ .../components/layout/ContextSidebarTab.tsx | 26 +-- ...act-syntax-highlighter-create-element.d.ts | 14 -- packages/web/package.json | 2 - 15 files changed, 398 insertions(+), 406 deletions(-) create mode 100644 packages/ui/src/components/code/WorkerHighlightedCode.tsx create mode 100644 packages/ui/src/components/code/useWorkerHighlightedLines.ts delete mode 100644 packages/ui/src/types/react-syntax-highlighter-create-element.d.ts diff --git a/bun.lock b/bun.lock index bb9d661f..1a0cae52 100644 --- a/bun.lock +++ b/bun.lock @@ -149,7 +149,6 @@ "@pierre/diffs": "1.3.0-beta.4", "@replit/codemirror-vim": "^6.3.0", "@simplewebauthn/browser": "13.3.0", - "@types/react-syntax-highlighter": "^15.5.13", "@xenova/transformers": "^2.17.2", "@zumer/snapdom": "^2.12.0", "beautiful-mermaid": "^1.1.3", @@ -169,12 +168,10 @@ "morphdom": "^2.7.7", "motion": "^12.23.24", "next-themes": "^0.4.6", - "prismjs": "^1.30.0", "qrcode": "^1.5.4", "react": "^19.1.1", "react-dom": "^19.1.1", "react-drawio": "1.0.7", - "react-syntax-highlighter": "^15.6.6", "rehype-katex": "^7.0.1", "remark-math": "^6.0.0", "remend": "^1.2.1", @@ -193,7 +190,6 @@ "@remixicon/react": "^4.7.0", "@tailwindcss/postcss": "^4.0.0", "@types/node": "^24.3.1", - "@types/prismjs": "^1.26.6", "@types/qrcode": "^1.5.5", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", @@ -292,7 +288,6 @@ "@types/node": "^24.3.1", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", - "@types/react-syntax-highlighter": "^15.5.13", "@types/supertest": "^7.2.0", "@vitejs/plugin-react": "^5.0.0", "autoprefixer": "^10.4.21", @@ -312,7 +307,6 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "react-markdown": "^10.1.0", - "react-syntax-highlighter": "^15.6.6", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "strip-json-comments": "^5.0.3", @@ -1316,8 +1310,6 @@ "@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="], - "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], - "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], diff --git a/packages/ui/package.json b/packages/ui/package.json index 27789c2e..0cedffe7 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -44,7 +44,6 @@ "@pierre/diffs": "1.3.0-beta.4", "@replit/codemirror-vim": "^6.3.0", "@simplewebauthn/browser": "13.3.0", - "@types/react-syntax-highlighter": "^15.5.13", "@xenova/transformers": "^2.17.2", "@zumer/snapdom": "^2.12.0", "beautiful-mermaid": "^1.1.3", @@ -64,12 +63,10 @@ "morphdom": "^2.7.7", "motion": "^12.23.24", "next-themes": "^0.4.6", - "prismjs": "^1.30.0", "qrcode": "^1.5.4", "react": "^19.1.1", "react-dom": "^19.1.1", "react-drawio": "1.0.7", - "react-syntax-highlighter": "^15.6.6", "rehype-katex": "^7.0.1", "remark-math": "^6.0.0", "remend": "^1.2.1", @@ -87,7 +84,6 @@ "@eslint/js": "^9.33.0", "@tailwindcss/postcss": "^4.0.0", "@types/node": "^24.3.1", - "@types/prismjs": "^1.26.6", "@types/qrcode": "^1.5.5", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", diff --git a/packages/ui/src/components/chat/DiffPreview.tsx b/packages/ui/src/components/chat/DiffPreview.tsx index 4700fba9..295475c4 100644 --- a/packages/ui/src/components/chat/DiffPreview.tsx +++ b/packages/ui/src/components/chat/DiffPreview.tsx @@ -1,97 +1,101 @@ import React from 'react'; -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { cn } from '@/lib/utils'; import { getLanguageFromExtension } from '@/lib/toolHelpers'; +import { useThemeSystem } from '@/contexts/useThemeSystem'; +import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme'; +import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines'; import { parseDiffToUnified } from './message/toolRenderers'; -const DIFF_CUSTOM_STYLE: React.CSSProperties = { - margin: 0, - padding: 0, - fontSize: 'inherit', - background: 'transparent', - backgroundColor: 'transparent', - borderRadius: 0, - overflow: 'visible', - whiteSpace: 'pre-wrap', - wordBreak: 'break-all', - overflowWrap: 'anywhere', -}; - -const DIFF_CODE_TAG_PROPS = { - style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } as React.CSSProperties, -}; +// One highlighted line: swaps in worker-tokenized inner HTML when ready, falls +// back to plain text while loading or on failure. +const CodeLineContent: React.FC<{ content: string; html: string | undefined }> = ({ content, html }) => + html !== undefined ? ( + + ) : ( + {content} + ); interface DiffPreviewProps { diff: string; - syntaxTheme: { [key: string]: React.CSSProperties }; filePath?: string; } -export const DiffPreview: React.FC = ({ diff, syntaxTheme, filePath }) => ( -
- {parseDiffToUnified(diff).map((hunk, hunkIdx) => ( -
-
- {`${hunk.file || filePath?.split('/').pop() || 'file'} (line ${hunk.oldStart})`} -
+export const DiffPreview: React.FC = ({ diff, filePath }) => { + const { currentTheme } = useThemeSystem(); + const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]); + const hunks = React.useMemo(() => parseDiffToUnified(diff), [diff]); + const language = getLanguageFromExtension(filePath || hunks[0]?.file) || 'text'; -
- {hunk.lines.map((line, lineIdx) => ( -
- - {line.lineNumber || ''} - -
- hunks.flatMap((hunk) => hunk.lines.map((line) => line.content)).join('\n'), + [hunks], + ); + const highlighted = useWorkerHighlightedLines(flatContent, language); + + let lineCursor = 0; + return ( +
+ {hunks.map((hunk, hunkIdx) => ( +
+
+ {`${hunk.file || filePath?.split('/').pop() || 'file'} (line ${hunk.oldStart})`} +
+ +
+ {hunk.lines.map((line, lineIdx) => { + const html = highlighted?.[lineCursor]; + lineCursor += 1; + return ( +
- {line.content} - -
-
- ))} + + {line.lineNumber || ''} + +
+ +
+
+ ); + })} +
-
- ))} -
-); + ))} +
+ ); +}; interface WritePreviewProps { content: string; - syntaxTheme: { [key: string]: React.CSSProperties }; filePath?: string; } -export const WritePreview: React.FC = ({ content, syntaxTheme, filePath }) => { - const lines = content.split('\n'); +export const WritePreview: React.FC = ({ content, filePath }) => { + const { currentTheme } = useThemeSystem(); + const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]); + const lines = React.useMemo(() => content.split('\n'), [content]); const language = getLanguageFromExtension(filePath ?? '') || 'text'; const displayPath = filePath?.split('/').pop() || 'New file'; const lineCount = Math.max(lines.length, 1); const headerLineLabel = lineCount === 1 ? 'line 1' : `lines 1-${lineCount}`; + const highlighted = useWorkerHighlightedLines(content, language); return ( -
+
{`${displayPath} (${headerLineLabel})`}
@@ -102,17 +106,7 @@ export const WritePreview: React.FC = ({ content, syntaxTheme {lineIdx + 1}
- - {line || ' '} - +
))} diff --git a/packages/ui/src/components/chat/PermissionCard.tsx b/packages/ui/src/components/chat/PermissionCard.tsx index dc91234a..77061a3d 100644 --- a/packages/ui/src/components/chat/PermissionCard.tsx +++ b/packages/ui/src/components/chat/PermissionCard.tsx @@ -4,9 +4,7 @@ import type { PermissionRequest, PermissionResponse } from '@/types/permission'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessions } from '@/sync/sync-context'; import * as sessionActions from '@/sync/session-actions'; -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; -import { useThemeSystem } from '@/contexts/useThemeSystem'; -import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator'; +import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { Icon } from "@/components/icon/Icon"; import { DiffPreview, WritePreview } from './DiffPreview'; @@ -104,8 +102,6 @@ export const PermissionCard: React.FC = ({ const sourceSession = sessions.find((session) => session.id === permission.sessionID); return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId); }, [permission.sessionID, currentSessionId, sessions]); - const { currentTheme } = useThemeSystem(); - const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]); const handleResponse = async (response: PermissionResponse) => { setIsResponding(true); @@ -168,16 +164,13 @@ export const PermissionCard: React.FC = ({ {} {command && (
- - {command} - + code={command} + style={PERMISSION_BASH_CUSTOM_STYLE} + codeStyle={PERMISSION_BASH_CODE_TAG_PROPS.style} + wrap + />
)} @@ -198,7 +191,7 @@ export const PermissionCard: React.FC = ({ )} {changes && ( - + )} @@ -212,7 +205,7 @@ export const PermissionCard: React.FC = ({ if (content) { return ( - + ); } @@ -247,14 +240,12 @@ export const PermissionCard: React.FC = ({
{t('chat.permissionCard.headers')}
- - {JSON.stringify(headers, null, 2)} - + code={JSON.stringify(headers, null, 2)} + style={PERMISSION_JSON_CUSTOM_STYLE} + wrap + />
)} @@ -262,14 +253,12 @@ export const PermissionCard: React.FC = ({
{t('chat.permissionCard.body')}
- - {typeof body === 'object' ? JSON.stringify(body, null, 2) : String(body)} - + code={typeof body === 'object' ? JSON.stringify(body, null, 2) : String(body)} + style={PERMISSION_JSON_CUSTOM_STYLE} + wrap + />
)} diff --git a/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts b/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts index c1012556..98c11361 100644 --- a/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts +++ b/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts @@ -1,9 +1,31 @@ /// -import { bundledLanguages, createHighlighter, type BundledLanguage } from 'shiki'; +import { bundledLanguages, createHighlighter, type BundledLanguage, type ThemedToken } from 'shiki'; import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdownShikiThemeDefinition'; import type { MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol'; +// Shiki FontStyle bitmask (from @shikijs/types). Inlined to avoid an extra import. +const FONT_STYLE_ITALIC = 1; +const FONT_STYLE_BOLD = 2; +const FONT_STYLE_UNDERLINE = 4; + +const escapeHtml = (value: string): string => + value + .replace(/&/g, '&') + .replace(//g, '>'); + +const tokenSpan = (token: ThemedToken): string => { + const styles: string[] = []; + if (token.color) styles.push(`color:${token.color}`); + const fontStyle = token.fontStyle ?? 0; + if (fontStyle & FONT_STYLE_ITALIC) styles.push('font-style:italic'); + if (fontStyle & FONT_STYLE_BOLD) styles.push('font-weight:bold'); + if (fontStyle & FONT_STYLE_UNDERLINE) styles.push('text-decoration:underline'); + const style = styles.length ? ` style="${styles.join(';')}"` : ''; + return `${escapeHtml(token.content)}`; +}; + // Single shared highlighter for the worker. Languages load lazily on demand. let highlighter: ReturnType | undefined; @@ -25,20 +47,31 @@ self.onmessage = (event: MessageEvent) => { ensureHighlighter(); return; } - queue = queue.then(() => highlight(request)).catch(() => {}); + if (request.type === 'highlight') { + queue = queue.then(() => highlight(request)).catch(() => {}); + return; + } + queue = queue.then(() => highlightLines(request)).catch(() => {}); +}; + +type Instance = Awaited>; + +const resolveLanguage = async (instance: Instance, requested: string): Promise => { + let lang = requested in bundledLanguages ? requested : 'text'; + if (lang !== 'text' && !instance.getLoadedLanguages().includes(lang)) { + try { + await instance.loadLanguage(bundledLanguages[lang as BundledLanguage]); + } catch { + lang = 'text'; + } + } + return lang; }; async function highlight(request: Extract): Promise { try { const instance = await ensureHighlighter(); - let lang = request.lang in bundledLanguages ? request.lang : 'text'; - if (lang !== 'text' && !instance.getLoadedLanguages().includes(lang)) { - try { - await instance.loadLanguage(bundledLanguages[lang as BundledLanguage]); - } catch { - lang = 'text'; - } - } + const lang = await resolveLanguage(instance, request.lang); const html = instance.codeToHtml(request.code, { lang, theme: MARKDOWN_SHIKI_THEME, @@ -50,6 +83,21 @@ async function highlight(request: Extract): Promise { + try { + const instance = await ensureHighlighter(); + const lang = await resolveLanguage(instance, request.lang); + const { tokens } = instance.codeToTokens(request.code, { + lang: lang as BundledLanguage, + theme: MARKDOWN_SHIKI_THEME, + }); + const lines = tokens.map((line) => line.map(tokenSpan).join('')); + post({ type: 'highlightLines', id: request.id, lines }); + } catch (error) { + post({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error) }); + } +} + function post(response: MarkdownWorkerResponse): void { self.postMessage(response); } diff --git a/packages/ui/src/components/chat/markdown/markdown-worker-protocol.ts b/packages/ui/src/components/chat/markdown/markdown-worker-protocol.ts index ec9230cb..1aec6cfe 100644 --- a/packages/ui/src/components/chat/markdown/markdown-worker-protocol.ts +++ b/packages/ui/src/components/chat/markdown/markdown-worker-protocol.ts @@ -6,8 +6,14 @@ export type MarkdownWorkerRequest = | { type: 'init' } - | { type: 'highlight'; id: number; code: string; lang: string }; + // Highlight a whole block to ready-to-splice Shiki `
` HTML.
+  | { type: 'highlight'; id: number; code: string; lang: string }
+  // Highlight a whole block but return per-line inner HTML (one entry per line),
+  // so per-line layouts (diffs, gutters, virtualization) tokenize in ONE call
+  // instead of one worker round-trip per line.
+  | { type: 'highlightLines'; id: number; code: string; lang: string };
 
 export type MarkdownWorkerResponse =
   | { type: 'highlight'; id: number; html: string }
+  | { type: 'highlightLines'; id: number; lines: string[] }
   | { type: 'error'; id: number; message: string };
diff --git a/packages/ui/src/components/chat/markdown/markdown-worker.ts b/packages/ui/src/components/chat/markdown/markdown-worker.ts
index 3ed9b872..70411201 100644
--- a/packages/ui/src/components/chat/markdown/markdown-worker.ts
+++ b/packages/ui/src/components/chat/markdown/markdown-worker.ts
@@ -7,9 +7,11 @@ import type { MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-w
 // tokenization error) the promise resolves to `null` and the caller keeps the
 // escaped plain-text code — highlighting never falls back onto the main thread.
 
+type PendingResolver = (response: MarkdownWorkerResponse | null) => void;
+
 let worker: Worker | undefined;
 let nextId = 0;
-const pending = new Map void>();
+const pending = new Map();
 
 const failAll = (): void => {
   pending.forEach((resolve) => resolve(null));
@@ -30,7 +32,7 @@ const getWorker = (): Worker | undefined => {
     const resolve = pending.get(event.data.id);
     if (!resolve) return;
     pending.delete(event.data.id);
-    resolve(event.data.type === 'highlight' ? event.data.html : null);
+    resolve(event.data);
   };
   worker.onerror = failAll;
   worker.onmessageerror = failAll;
@@ -38,16 +40,31 @@ const getWorker = (): Worker | undefined => {
   return worker;
 };
 
+const request = (payload: (id: number) => MarkdownWorkerRequest): Promise => {
+  const instance = getWorker();
+  if (!instance) return Promise.resolve(null);
+  const id = ++nextId;
+  return new Promise((resolve) => {
+    pending.set(id, resolve);
+    instance.postMessage(payload(id));
+  });
+};
+
 /**
  * Highlight a complete code block in the worker. Resolves to Shiki `
` HTML,
  * or `null` if highlighting is unavailable or failed (caller keeps plain code).
  */
-export const highlightCodeInWorker = (code: string, lang: string): Promise => {
-  const instance = getWorker();
-  if (!instance) return Promise.resolve(null);
-  const id = ++nextId;
-  return new Promise((resolve) => {
-    pending.set(id, resolve);
-    instance.postMessage({ type: 'highlight', id, code, lang } satisfies MarkdownWorkerRequest);
-  });
+export const highlightCodeInWorker = async (code: string, lang: string): Promise => {
+  const response = await request((id) => ({ type: 'highlight', id, code, lang }));
+  return response?.type === 'highlight' ? response.html : null;
+};
+
+/**
+ * Highlight a whole block and return per-line inner HTML (one entry per source
+ * line). For per-line layouts (diffs, gutters, virtualization) — one worker
+ * round-trip instead of one per line. Resolves to `null` on failure.
+ */
+export const highlightLinesInWorker = async (code: string, lang: string): Promise => {
+  const response = await request((id) => ({ type: 'highlightLines', id, code, lang }));
+  return response?.type === 'highlightLines' ? response.lines : null;
 };
diff --git a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx
index ec28cdfa..45b0cc95 100644
--- a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx
+++ b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx
@@ -1,7 +1,7 @@
 import React from 'react';
 import { Dialog, DialogContent } from '@/components/ui/dialog';
 import { File as PierreFile, PatchDiff } from '@pierre/diffs/react';
-import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
+import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
 import { createPortal } from 'react-dom';
 
 import { cn } from '@/lib/utils';
@@ -550,9 +550,8 @@ DialogUnifiedDiff.displayName = 'DialogUnifiedDiff';
 
 const DialogReadContent: React.FC<{
     popup: ToolPopupContent;
-    syntaxTheme: Record;
     pierreThemeConfig: PierreThemeConfig;
-}> = React.memo(({ popup, syntaxTheme, pierreThemeConfig }) => {
+}> = React.memo(({ popup, pierreThemeConfig }) => {
     const parsedReadOutput = React.useMemo(() => parseReadToolOutput(popup.content), [popup.content]);
 
     const inputMeta = popup.metadata?.input;
@@ -627,7 +626,6 @@ const DialogReadContent: React.FC<{
         
     );
@@ -1055,16 +1053,13 @@ const ToolOutputDialog: React.FC = ({ popup, onOpenChange
                                     
{meta.tool === 'bash' && getInputValue('command') ? (
- - {getInputValue('command')!} - + code={getInputValue('command')!} + style={toolDisplayStyles.getPopupStyles()} + codeStyle={DIALOG_CODE_TAG_PROPS.style} + wrap + />
) : meta.tool === 'task' && getInputValue('prompt') ? (
= ({ popup, onOpenChange completed: t('chat.todo.completed'), cancelled: t('chat.todo.cancelled'), }) || ( - - {popup.content} - + code={popup.content} + style={toolDisplayStyles.getPopupContainerStyles()} + codeStyle={DIALOG_CODE_TAG_PROPS.style} + wrap + /> ) ); } @@ -1179,22 +1171,19 @@ const ToolOutputDialog: React.FC = ({ popup, onOpenChange if (tool === 'web-search' || tool === 'websearch' || tool === 'search_web') { return ( renderWebSearchOutput(popup.content, syntaxTheme) || ( - - {popup.content} - + code={popup.content} + style={toolDisplayStyles.getPopupContainerStyles()} + codeStyle={DIALOG_CODE_TAG_PROPS.style} + wrap + /> ) ); } if (tool === 'read') { - return ; + return ; } // JSON tree viewer for generic JSON outputs @@ -1210,16 +1199,13 @@ const ToolOutputDialog: React.FC = ({ popup, onOpenChange } return ( - - {popup.content} - + code={popup.content} + style={toolDisplayStyles.getPopupContainerStyles()} + codeStyle={DIALOG_CODE_TAG_PROPS.style} + wrap + /> ); })()}
diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 0fcf3b2b..7da0df7e 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -7,7 +7,7 @@ import { SimpleMarkdownRenderer } from '../../MarkdownRenderer'; import { getToolMetadata } from '@/lib/toolHelpers'; import type { ToolPart as ToolPartType, ToolState as ToolStateUnion } from '@opencode-ai/sdk/v2'; import { toolDisplayStyles } from '@/lib/typography'; -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode'; import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useSessionUIStore } from '@/sync/session-ui-store'; @@ -844,7 +844,7 @@ const ToolScrollableTextOutput: React.FC<{ metadata: Record | undefined; input: Record | undefined; syntaxTheme: { [key: string]: React.CSSProperties }; -}> = ({ output, part, metadata, input, syntaxTheme }) => { +}> = ({ output, part, metadata, input }) => { const { t } = useI18n(); const renderedOutput = getToolOutputText(output, part, metadata); const outputLanguage = getToolOutputLanguage(output, part, metadata, input); @@ -910,16 +910,13 @@ const ToolScrollableTextOutput: React.FC<{ /> ) : (
- - {renderedOutput} - + code={renderedOutput} + style={TOOL_COLLAPSED_CUSTOM_STYLE} + codeStyle={CODE_TAG_PROPS.style} + wrap + />
)}
@@ -928,16 +925,13 @@ const ToolScrollableTextOutput: React.FC<{ return (
- - {renderedOutput} - + code={renderedOutput} + style={TOOL_COLLAPSED_CUSTOM_STYLE} + codeStyle={CODE_TAG_PROPS.style} + wrap + />
); }; diff --git a/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx b/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx index b43b2284..028f889a 100644 --- a/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx +++ b/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx @@ -1,48 +1,20 @@ /** * VirtualizedCodeBlock — PERF-007 * - * Replaces per-line with: - * 1. ONE Prism.highlight() call to tokenize all code at once + * Renders large code/read outputs without mounting one highlighter per line: + * 1. ONE worker tokenization of the whole block (off the main thread) * 2. virtua to only render visible rows * - * This drops mount cost from O(N * Prism) to O(1 * Prism) + O(visible_rows). - * For a 2000-line file, ~2000 SyntaxHighlighter instances → ~30 plain
s. + * Tokenizing the whole block at once also preserves cross-line syntax context + * (multi-line strings/comments) that per-line highlighting loses. Colors resolve + * through the `--md-syntax-*` CSS variables on the container. */ import React from 'react'; import { Virtualizer } from 'virtua'; -import Prism from 'prismjs'; - -// Ensure common languages are loaded (react-syntax-highlighter lazy-loads them, -// but we call Prism directly so we need them registered). -import 'prismjs/components/prism-markup'; -import 'prismjs/components/prism-markup-templating'; -import 'prismjs/components/prism-typescript'; -import 'prismjs/components/prism-javascript'; -import 'prismjs/components/prism-jsx'; -import 'prismjs/components/prism-tsx'; -import 'prismjs/components/prism-css'; -import 'prismjs/components/prism-json'; -import 'prismjs/components/prism-bash'; -import 'prismjs/components/prism-python'; -import 'prismjs/components/prism-rust'; -import 'prismjs/components/prism-go'; -import 'prismjs/components/prism-java'; -import 'prismjs/components/prism-c'; -import 'prismjs/components/prism-cpp'; -import 'prismjs/components/prism-csharp'; -import 'prismjs/components/prism-ruby'; -import 'prismjs/components/prism-yaml'; -import 'prismjs/components/prism-toml'; -import 'prismjs/components/prism-markdown'; -import 'prismjs/components/prism-sql'; -import 'prismjs/components/prism-diff'; -import 'prismjs/components/prism-docker'; -import 'prismjs/components/prism-swift'; -import 'prismjs/components/prism-kotlin'; -import 'prismjs/components/prism-lua'; -import 'prismjs/components/prism-php'; -import 'prismjs/components/prism-scss'; +import { useThemeSystem } from '@/contexts/useThemeSystem'; +import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme'; +import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines'; // ── Threshold: files smaller than this render without virtualization ── const VIRTUALIZE_THRESHOLD = 80; @@ -60,7 +32,6 @@ export interface CodeLine { interface VirtualizedCodeBlockProps { lines: CodeLine[]; language: string; - syntaxTheme: Record; /** Max visible height in CSS (default: 60vh) */ maxHeight?: string; /** Show line numbers (default: true) */ @@ -69,123 +40,21 @@ interface VirtualizedCodeBlockProps { lineStyles?: (line: CodeLine) => React.CSSProperties | undefined; } -const toKebabCase = (value: string): string => value.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); - -const styleObjectToCss = (style: React.CSSProperties): string => { - return Object.entries(style) - .filter(([, v]) => v !== undefined && v !== null) - .map(([k, v]) => `${toKebabCase(k)}:${String(v)};`) - .join(''); -}; - -const buildSelectorList = (rawKey: string): string[] => { - return rawKey - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - .flatMap((selector) => { - if (selector.startsWith('.token')) { - return [`.oc-virtualized-prism ${selector}`]; - } - if (selector.startsWith('token.')) { - return [`.oc-virtualized-prism .${selector}`]; - } - if (/^[a-z0-9_-]+$/i.test(selector)) { - return [`.oc-virtualized-prism .token.${selector}`]; - } - if (selector.includes('token')) { - return [`.oc-virtualized-prism ${selector}`]; - } - return []; - }); -}; - -const buildPrismThemeCss = (theme: Record): string => { - const rules: string[] = []; - Object.entries(theme).forEach(([rawKey, style]) => { - const selectors = buildSelectorList(rawKey); - if (selectors.length === 0) { - return; - } - const css = styleObjectToCss(style); - if (!css) { - return; - } - rules.push(`${selectors.join(',')}{${css}}`); - }); - return rules.join('\n'); -}; - -const LANGUAGE_ALIASES: Record = { - text: 'plain', - plaintext: 'plain', - shell: 'bash', - sh: 'bash', - zsh: 'bash', - patch: 'diff', - dockerfile: 'docker', - js: 'javascript', - ts: 'typescript', -}; - -const normalizeLanguage = (language: string): string => { - const lower = language.toLowerCase(); - return LANGUAGE_ALIASES[lower] ?? lower; -}; - -const HIGHLIGHT_CACHE_MAX = 5000; -const highlightCache = new Map(); - -const highlightLine = (text: string, language: string): string => { - const normalizedLanguage = normalizeLanguage(language); - const cacheKey = `${normalizedLanguage}\n${text}`; - const cached = highlightCache.get(cacheKey); - if (cached !== undefined) { - return cached; - } - - const grammar = Prism.languages[normalizedLanguage] ?? Prism.languages.text; - if (!grammar) { - const escaped = escapeHtml(text); - highlightCache.set(cacheKey, escaped); - return escaped; - } - - try { - const highlighted = Prism.highlight(text, grammar, normalizedLanguage); - if (highlightCache.size >= HIGHLIGHT_CACHE_MAX) { - const oldestKey = highlightCache.keys().next().value; - if (typeof oldestKey === 'string') { - highlightCache.delete(oldestKey); - } - } - highlightCache.set(cacheKey, highlighted); - return highlighted; - } catch { - const escaped = escapeHtml(text); - highlightCache.set(cacheKey, escaped); - return escaped; - } -}; - -function escapeHtml(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>'); -} - // ── Component ──────────────────────────────────────────────────────── export const VirtualizedCodeBlock: React.FC = React.memo((props) => { const { lines, language, - syntaxTheme, maxHeight = '60vh', showLineNumbers = true, lineStyles, } = props; - const prismThemeCss = React.useMemo(() => buildPrismThemeCss(syntaxTheme), [syntaxTheme]); + + const { currentTheme } = useThemeSystem(); + const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]); + // Tokenize the whole block in one worker call; rows index into the result. + const fullText = React.useMemo(() => lines.map((line) => line.text).join('\n'), [lines]); + const highlighted = useWorkerHighlightedLines(fullText, language); const shouldVirtualize = lines.length > VIRTUALIZE_THRESHOLD; @@ -193,15 +62,14 @@ export const VirtualizedCodeBlock: React.FC = React.m if (!shouldVirtualize) { return (
- {prismThemeCss ? : null} {lines.map((line, idx) => ( @@ -214,8 +82,8 @@ export const VirtualizedCodeBlock: React.FC = React.m return ( ; maxHeight: string; showLineNumbers: boolean; lineStyles?: (line: CodeLine) => React.CSSProperties | undefined; @@ -237,8 +105,8 @@ interface VirtualizedRowsProps { const VirtualizedRows: React.FC = React.memo(({ lines, - language, - prismThemeCss, + highlighted, + syntaxVars, maxHeight, showLineNumbers, lineStyles, @@ -249,10 +117,9 @@ const VirtualizedRows: React.FC = React.memo(({ return (
- {prismThemeCss ? : null} = React.memo(({ @@ -278,14 +145,12 @@ VirtualizedRows.displayName = 'VirtualizedRows'; // ── Single row ─────────────────────────────────────────────────────── interface RowProps { line: CodeLine; - language: string; + html: string | undefined; showLineNumbers: boolean; style?: React.CSSProperties; } -const Row: React.FC = React.memo(({ line, language, showLineNumbers, style }) => { - const html = React.useMemo(() => highlightLine(line.text, language), [line.text, language]); - +const Row: React.FC = React.memo(({ line, html, showLineNumbers, style }) => { return (
= React.memo(({ line, language, showLineNumbers, s
{line.text}
+ ) : html !== undefined ? ( +
) : ( -
+
{line.text}
)}
diff --git a/packages/ui/src/components/code/WorkerHighlightedCode.tsx b/packages/ui/src/components/code/WorkerHighlightedCode.tsx new file mode 100644 index 00000000..441972d8 --- /dev/null +++ b/packages/ui/src/components/code/WorkerHighlightedCode.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; +import { useThemeSystem } from '@/contexts/useThemeSystem'; +import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme'; +import { highlightCodeInWorker } from '@/components/chat/markdown/markdown-worker'; + +// Shared static code highlighter backed by the markdown Shiki Web Worker. +// +// Replaces `react-syntax-highlighter` for non-streaming code surfaces (tool +// output, permission previews, diffs, sidebar file contents). The escaped code +// paints synchronously; the worker tokenizes off the main thread and swaps in +// the highlighted markup. Colors resolve through the `--md-syntax-*` CSS +// variables on the host, so theme changes never require re-highlighting. + +const escapeHtml = (text: string): string => + text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const styleString = (style?: React.CSSProperties): string => { + if (!style) return ''; + return Object.entries(style) + .map(([key, value]) => { + if (value == null) return ''; + const prop = key.startsWith('--') ? key : key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); + return `${prop}:${typeof value === 'number' ? `${value}px` : value};`; + }) + .join(''); +}; + +// Normalize a worker/plain `
` so it sits flush inside the host: drop
+// Shiki's own background/margin and apply wrap + caller code styles.
+const applyPreStyles = (host: HTMLElement, wrap: boolean, codeStyle?: React.CSSProperties): void => {
+  const pre = host.querySelector('pre');
+  if (pre) {
+    pre.style.margin = '0';
+    pre.style.background = 'transparent';
+    pre.style.whiteSpace = wrap ? 'pre-wrap' : 'pre';
+    if (wrap) {
+      pre.style.wordBreak = 'break-word';
+      pre.style.overflowWrap = 'break-word';
+    }
+  }
+  const code = host.querySelector('code');
+  if (code) {
+    const extra = styleString(codeStyle);
+    if (extra) code.setAttribute('style', `${code.getAttribute('style') ?? ''}${extra}`);
+  }
+};
+
+const plainHtml = (code: string): string => `
${escapeHtml(code)}
`; + +export interface WorkerHighlightedCodeProps { + code: string; + language: string; + className?: string; + /** Inline styles for the host container (mirrors react-syntax-highlighter `customStyle`). */ + style?: React.CSSProperties; + /** Inline styles applied to the `` element (mirrors `codeTagProps.style`). */ + codeStyle?: React.CSSProperties; + /** Wrap long lines instead of horizontal scroll. */ + wrap?: boolean; +} + +export const WorkerHighlightedCode: React.FC = ({ + code, + language, + className, + style, + codeStyle, + wrap = false, +}) => { + const { currentTheme } = useThemeSystem(); + const hostRef = React.useRef(null); + const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]); + + // Synchronous escaped first paint — no blank frame before highlighting lands. + React.useLayoutEffect(() => { + const host = hostRef.current; + if (!host) return; + host.innerHTML = plainHtml(code); + applyPreStyles(host, wrap, codeStyle); + }, [code, wrap, codeStyle]); + + // Highlight off the main thread, then swap in. Guarded against stale results. + React.useEffect(() => { + const host = hostRef.current; + if (!host) return; + let active = true; + void highlightCodeInWorker(code, (language || 'text').toLowerCase()).then((html) => { + if (!active || !host || !html) return; + host.innerHTML = html; + applyPreStyles(host, wrap, codeStyle); + }); + return () => { + active = false; + }; + }, [code, language, wrap, codeStyle]); + + return ( +
+ ); +}; diff --git a/packages/ui/src/components/code/useWorkerHighlightedLines.ts b/packages/ui/src/components/code/useWorkerHighlightedLines.ts new file mode 100644 index 00000000..2a27e2ee --- /dev/null +++ b/packages/ui/src/components/code/useWorkerHighlightedLines.ts @@ -0,0 +1,26 @@ +import React from 'react'; +import { highlightLinesInWorker } from '@/components/chat/markdown/markdown-worker'; + +// Tokenize a whole block ONCE in the Shiki worker and expose per-line inner +// HTML. For per-line layouts (diffs, gutters, virtualization) that would +// otherwise spawn one highlighter per row. Returns `null` until the first +// result lands (or permanently on failure) — callers render plain text then. +// +// Whole-block tokenization also restores cross-line syntax context (multi-line +// strings / comments) that independent per-line highlighting loses. +export const useWorkerHighlightedLines = (code: string, language: string): string[] | null => { + const [lines, setLines] = React.useState(null); + + React.useEffect(() => { + let active = true; + setLines(null); + void highlightLinesInWorker(code, (language || 'text').toLowerCase()).then((result) => { + if (active) setLines(result); + }); + return () => { + active = false; + }; + }, [code, language]); + + return lines; +}; diff --git a/packages/ui/src/components/layout/ContextSidebarTab.tsx b/packages/ui/src/components/layout/ContextSidebarTab.tsx index 149c4c13..4c5c3450 100644 --- a/packages/ui/src/components/layout/ContextSidebarTab.tsx +++ b/packages/ui/src/components/layout/ContextSidebarTab.tsx @@ -1,11 +1,9 @@ import React from 'react'; import type { Message, Part } from '@opencode-ai/sdk/v2'; -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode'; import { deriveMessageRole } from '@/components/chat/message/messageRole'; import { Icon } from "@/components/icon/Icon"; -import { useThemeSystem } from '@/contexts/useThemeSystem'; -import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator'; import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; @@ -272,9 +270,7 @@ const resolveProviderAndModel = ( export const ContextPanelContent: React.FC = () => { const { t } = useI18n(); - const { currentTheme } = useThemeSystem(); const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); - const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]); const [expandedRawMessages, setExpandedRawMessages] = React.useState>({}); const [copiedRawMessageId, setCopiedRawMessageId] = React.useState(null); const copyResetTimeoutRef = React.useRef(null); @@ -649,28 +645,18 @@ export const ContextPanelContent: React.FC = () => { {isCopied ? : }
- - {jsonValue} - + wrap + />
)} diff --git a/packages/ui/src/types/react-syntax-highlighter-create-element.d.ts b/packages/ui/src/types/react-syntax-highlighter-create-element.d.ts deleted file mode 100644 index 680ed65b..00000000 --- a/packages/ui/src/types/react-syntax-highlighter-create-element.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -declare module 'react-syntax-highlighter/create-element' { - import type { ReactNode } from 'react'; - - type CreateElementOptions = { - node: unknown; - stylesheet: unknown; - useInlineStyles: boolean; - key?: string | number; - }; - - const createElement: (options: CreateElementOptions) => ReactNode; - - export default createElement; -} diff --git a/packages/web/package.json b/packages/web/package.json index fee40ac7..f1ffc118 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -70,7 +70,6 @@ "@types/node": "^24.3.1", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", - "@types/react-syntax-highlighter": "^15.5.13", "@types/supertest": "^7.2.0", "@vitejs/plugin-react": "^5.0.0", "autoprefixer": "^10.4.21", @@ -90,7 +89,6 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "react-markdown": "^10.1.0", - "react-syntax-highlighter": "^15.6.6", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "strip-json-comments": "^5.0.3",