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.
This commit is contained in:
Bohdan Triapitsyn
2026-06-16 01:07:43 +03:00
parent 464c4ac0ca
commit e41e5bac91
15 changed files with 398 additions and 406 deletions
-8
View File
@@ -149,7 +149,6 @@
"@pierre/diffs": "1.3.0-beta.4", "@pierre/diffs": "1.3.0-beta.4",
"@replit/codemirror-vim": "^6.3.0", "@replit/codemirror-vim": "^6.3.0",
"@simplewebauthn/browser": "13.3.0", "@simplewebauthn/browser": "13.3.0",
"@types/react-syntax-highlighter": "^15.5.13",
"@xenova/transformers": "^2.17.2", "@xenova/transformers": "^2.17.2",
"@zumer/snapdom": "^2.12.0", "@zumer/snapdom": "^2.12.0",
"beautiful-mermaid": "^1.1.3", "beautiful-mermaid": "^1.1.3",
@@ -169,12 +168,10 @@
"morphdom": "^2.7.7", "morphdom": "^2.7.7",
"motion": "^12.23.24", "motion": "^12.23.24",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"prismjs": "^1.30.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-drawio": "1.0.7", "react-drawio": "1.0.7",
"react-syntax-highlighter": "^15.6.6",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"remend": "^1.2.1", "remend": "^1.2.1",
@@ -193,7 +190,6 @@
"@remixicon/react": "^4.7.0", "@remixicon/react": "^4.7.0",
"@tailwindcss/postcss": "^4.0.0", "@tailwindcss/postcss": "^4.0.0",
"@types/node": "^24.3.1", "@types/node": "^24.3.1",
"@types/prismjs": "^1.26.6",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/react": "^19.1.10", "@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.7",
@@ -292,7 +288,6 @@
"@types/node": "^24.3.1", "@types/node": "^24.3.1",
"@types/react": "^19.1.10", "@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.7",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/supertest": "^7.2.0", "@types/supertest": "^7.2.0",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
@@ -312,7 +307,6 @@
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.6",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"strip-json-comments": "^5.0.3", "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/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/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=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
-4
View File
@@ -44,7 +44,6 @@
"@pierre/diffs": "1.3.0-beta.4", "@pierre/diffs": "1.3.0-beta.4",
"@replit/codemirror-vim": "^6.3.0", "@replit/codemirror-vim": "^6.3.0",
"@simplewebauthn/browser": "13.3.0", "@simplewebauthn/browser": "13.3.0",
"@types/react-syntax-highlighter": "^15.5.13",
"@xenova/transformers": "^2.17.2", "@xenova/transformers": "^2.17.2",
"@zumer/snapdom": "^2.12.0", "@zumer/snapdom": "^2.12.0",
"beautiful-mermaid": "^1.1.3", "beautiful-mermaid": "^1.1.3",
@@ -64,12 +63,10 @@
"morphdom": "^2.7.7", "morphdom": "^2.7.7",
"motion": "^12.23.24", "motion": "^12.23.24",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"prismjs": "^1.30.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-drawio": "1.0.7", "react-drawio": "1.0.7",
"react-syntax-highlighter": "^15.6.6",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"remend": "^1.2.1", "remend": "^1.2.1",
@@ -87,7 +84,6 @@
"@eslint/js": "^9.33.0", "@eslint/js": "^9.33.0",
"@tailwindcss/postcss": "^4.0.0", "@tailwindcss/postcss": "^4.0.0",
"@types/node": "^24.3.1", "@types/node": "^24.3.1",
"@types/prismjs": "^1.26.6",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/react": "^19.1.10", "@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.7",
+73 -79
View File
@@ -1,97 +1,101 @@
import React from 'react'; import React from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { getLanguageFromExtension } from '@/lib/toolHelpers'; 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'; import { parseDiffToUnified } from './message/toolRenderers';
const DIFF_CUSTOM_STYLE: React.CSSProperties = { // One highlighted line: swaps in worker-tokenized inner HTML when ready, falls
margin: 0, // back to plain text while loading or on failure.
padding: 0, const CodeLineContent: React.FC<{ content: string; html: string | undefined }> = ({ content, html }) =>
fontSize: 'inherit', html !== undefined ? (
background: 'transparent', <span className="whitespace-pre-wrap break-all" dangerouslySetInnerHTML={{ __html: html }} />
backgroundColor: 'transparent', ) : (
borderRadius: 0, <span className="whitespace-pre-wrap break-all">{content}</span>
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,
};
interface DiffPreviewProps { interface DiffPreviewProps {
diff: string; diff: string;
syntaxTheme: { [key: string]: React.CSSProperties };
filePath?: string; filePath?: string;
} }
export const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, syntaxTheme, filePath }) => ( export const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, filePath }) => {
<div className="typography-code px-1 pb-1 pt-0 space-y-0"> const { currentTheme } = useThemeSystem();
{parseDiffToUnified(diff).map((hunk, hunkIdx) => ( const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
<div key={hunkIdx} className="-mx-1 px-1 border-b border-border/20 last:border-b-0"> const hunks = React.useMemo(() => parseDiffToUnified(diff), [diff]);
<div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground border-b border-border/10 break-words -mx-1"> const language = getLanguageFromExtension(filePath || hunks[0]?.file) || 'text';
{`${hunk.file || filePath?.split('/').pop() || 'file'} (line ${hunk.oldStart})`}
</div>
<div> // Flatten all rendered lines so the whole diff tokenizes in one worker call.
{hunk.lines.map((line, lineIdx) => ( const flatContent = React.useMemo(
<div () => hunks.flatMap((hunk) => hunk.lines.map((line) => line.content)).join('\n'),
key={lineIdx} [hunks],
className={cn( );
'typography-code font-mono px-2 py-0.5 flex -mx-2', const highlighted = useWorkerHighlightedLines(flatContent, language);
line.type === 'context' && 'bg-transparent',
line.type === 'removed' && 'bg-transparent', let lineCursor = 0;
line.type === 'added' && 'bg-transparent' return (
)} <div className="typography-code px-1 pb-1 pt-0 space-y-0" style={syntaxVars as React.CSSProperties}>
style={ {hunks.map((hunk, hunkIdx) => (
line.type === 'removed' <div key={hunkIdx} className="-mx-1 px-1 border-b border-border/20 last:border-b-0">
? { backgroundColor: 'var(--tools-edit-removed-bg)' } <div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground border-b border-border/10 break-words -mx-1">
: line.type === 'added' {`${hunk.file || filePath?.split('/').pop() || 'file'} (line ${hunk.oldStart})`}
? { backgroundColor: 'var(--tools-edit-added-bg)' } </div>
: {}
} <div>
> {hunk.lines.map((line, lineIdx) => {
<span className="w-10 flex-shrink-0 text-right pr-3 select-none border-r mr-3 -my-0.5 py-0.5" style={{ color: 'var(--tools-edit-line-number)', borderColor: 'var(--tools-border)' }}> const html = highlighted?.[lineCursor];
{line.lineNumber || ''} lineCursor += 1;
</span> return (
<div className="flex-1 min-w-0"> <div
<SyntaxHighlighter key={lineIdx}
style={syntaxTheme} className={cn(
language={getLanguageFromExtension(filePath || hunk.file) || 'text'} 'typography-code font-mono px-2 py-0.5 flex -mx-2',
PreTag="div" line.type === 'context' && 'bg-transparent',
wrapLines line.type === 'removed' && 'bg-transparent',
wrapLongLines line.type === 'added' && 'bg-transparent'
customStyle={DIFF_CUSTOM_STYLE} )}
codeTagProps={DIFF_CODE_TAG_PROPS} style={
line.type === 'removed'
? { backgroundColor: 'var(--tools-edit-removed-bg)' }
: line.type === 'added'
? { backgroundColor: 'var(--tools-edit-added-bg)' }
: {}
}
> >
{line.content} <span className="w-10 flex-shrink-0 text-right pr-3 select-none border-r mr-3 -my-0.5 py-0.5" style={{ color: 'var(--tools-edit-line-number)', borderColor: 'var(--tools-border)' }}>
</SyntaxHighlighter> {line.lineNumber || ''}
</div> </span>
</div> <div className="flex-1 min-w-0">
))} <CodeLineContent content={line.content} html={html} />
</div>
</div>
);
})}
</div>
</div> </div>
</div> ))}
))} </div>
</div> );
); };
interface WritePreviewProps { interface WritePreviewProps {
content: string; content: string;
syntaxTheme: { [key: string]: React.CSSProperties };
filePath?: string; filePath?: string;
} }
export const WritePreview: React.FC<WritePreviewProps> = ({ content, syntaxTheme, filePath }) => { export const WritePreview: React.FC<WritePreviewProps> = ({ content, filePath }) => {
const lines = content.split('\n'); const { currentTheme } = useThemeSystem();
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
const lines = React.useMemo(() => content.split('\n'), [content]);
const language = getLanguageFromExtension(filePath ?? '') || 'text'; const language = getLanguageFromExtension(filePath ?? '') || 'text';
const displayPath = filePath?.split('/').pop() || 'New file'; const displayPath = filePath?.split('/').pop() || 'New file';
const lineCount = Math.max(lines.length, 1); const lineCount = Math.max(lines.length, 1);
const headerLineLabel = lineCount === 1 ? 'line 1' : `lines 1-${lineCount}`; const headerLineLabel = lineCount === 1 ? 'line 1' : `lines 1-${lineCount}`;
const highlighted = useWorkerHighlightedLines(content, language);
return ( return (
<div className="w-full min-w-0"> <div className="w-full min-w-0" style={syntaxVars as React.CSSProperties}>
<div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground border border-border/10 rounded-lg mb-1"> <div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground border border-border/10 rounded-lg mb-1">
{`${displayPath} (${headerLineLabel})`} {`${displayPath} (${headerLineLabel})`}
</div> </div>
@@ -102,17 +106,7 @@ export const WritePreview: React.FC<WritePreviewProps> = ({ content, syntaxTheme
{lineIdx + 1} {lineIdx + 1}
</span> </span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<SyntaxHighlighter <CodeLineContent content={line || ' '} html={highlighted?.[lineIdx]} />
style={syntaxTheme}
language={language}
PreTag="div"
wrapLines
wrapLongLines
customStyle={DIFF_CUSTOM_STYLE}
codeTagProps={DIFF_CODE_TAG_PROPS}
>
{line || ' '}
</SyntaxHighlighter>
</div> </div>
</div> </div>
))} ))}
@@ -4,9 +4,7 @@ import type { PermissionRequest, PermissionResponse } from '@/types/permission';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessions } from '@/sync/sync-context'; import { useSessions } from '@/sync/sync-context';
import * as sessionActions from '@/sync/session-actions'; import * as sessionActions from '@/sync/session-actions';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import { DiffPreview, WritePreview } from './DiffPreview'; import { DiffPreview, WritePreview } from './DiffPreview';
@@ -104,8 +102,6 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
const sourceSession = sessions.find((session) => session.id === permission.sessionID); const sourceSession = sessions.find((session) => session.id === permission.sessionID);
return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId); return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId);
}, [permission.sessionID, currentSessionId, sessions]); }, [permission.sessionID, currentSessionId, sessions]);
const { currentTheme } = useThemeSystem();
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
const handleResponse = async (response: PermissionResponse) => { const handleResponse = async (response: PermissionResponse) => {
setIsResponding(true); setIsResponding(true);
@@ -168,16 +164,13 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
{} {}
{command && ( {command && (
<div> <div>
<SyntaxHighlighter <WorkerHighlightedCode
language="bash" language="bash"
style={syntaxTheme} code={command}
PreTag="div" style={PERMISSION_BASH_CUSTOM_STYLE}
customStyle={PERMISSION_BASH_CUSTOM_STYLE} codeStyle={PERMISSION_BASH_CODE_TAG_PROPS.style}
codeTagProps={PERMISSION_BASH_CODE_TAG_PROPS} wrap
wrapLongLines={true} />
>
{command}
</SyntaxHighlighter>
</div> </div>
)} )}
</> </>
@@ -198,7 +191,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
)} )}
{changes && ( {changes && (
<ScrollableOverlay outerClassName="max-h-[60vh]" className="tool-output-surface p-1 rounded-xl border border-border/20 bg-transparent"> <ScrollableOverlay outerClassName="max-h-[60vh]" className="tool-output-surface p-1 rounded-xl border border-border/20 bg-transparent">
<DiffPreview diff={changes} syntaxTheme={syntaxTheme} filePath={filePath} /> <DiffPreview diff={changes} filePath={filePath} />
</ScrollableOverlay> </ScrollableOverlay>
)} )}
</> </>
@@ -212,7 +205,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
if (content) { if (content) {
return ( return (
<ScrollableOverlay outerClassName="max-h-[60vh]" className="tool-output-surface p-1 rounded-xl border border-border/20 bg-transparent"> <ScrollableOverlay outerClassName="max-h-[60vh]" className="tool-output-surface p-1 rounded-xl border border-border/20 bg-transparent">
<WritePreview content={content} syntaxTheme={syntaxTheme} filePath={filePath} /> <WritePreview content={content} filePath={filePath} />
</ScrollableOverlay> </ScrollableOverlay>
); );
} }
@@ -247,14 +240,12 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
<div className="mb-2"> <div className="mb-2">
<div className="typography-meta text-muted-foreground mb-1">{t('chat.permissionCard.headers')}</div> <div className="typography-meta text-muted-foreground mb-1">{t('chat.permissionCard.headers')}</div>
<ScrollableOverlay outerClassName="max-h-24" className="p-0"> <ScrollableOverlay outerClassName="max-h-24" className="p-0">
<SyntaxHighlighter <WorkerHighlightedCode
language="json" language="json"
style={syntaxTheme} code={JSON.stringify(headers, null, 2)}
customStyle={PERMISSION_JSON_CUSTOM_STYLE} style={PERMISSION_JSON_CUSTOM_STYLE}
wrapLongLines={true} wrap
> />
{JSON.stringify(headers, null, 2)}
</SyntaxHighlighter>
</ScrollableOverlay> </ScrollableOverlay>
</div> </div>
)} )}
@@ -262,14 +253,12 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
<div className="mb-2"> <div className="mb-2">
<div className="typography-meta text-muted-foreground mb-1">{t('chat.permissionCard.body')}</div> <div className="typography-meta text-muted-foreground mb-1">{t('chat.permissionCard.body')}</div>
<ScrollableOverlay outerClassName="max-h-32" className="p-0"> <ScrollableOverlay outerClassName="max-h-32" className="p-0">
<SyntaxHighlighter <WorkerHighlightedCode
language={typeof body === 'object' ? 'json' : 'text'} language={typeof body === 'object' ? 'json' : 'text'}
style={syntaxTheme} code={typeof body === 'object' ? JSON.stringify(body, null, 2) : String(body)}
customStyle={PERMISSION_JSON_CUSTOM_STYLE} style={PERMISSION_JSON_CUSTOM_STYLE}
wrapLongLines={true} wrap
> />
{typeof body === 'object' ? JSON.stringify(body, null, 2) : String(body)}
</SyntaxHighlighter>
</ScrollableOverlay> </ScrollableOverlay>
</div> </div>
)} )}
@@ -1,9 +1,31 @@
/// <reference lib="webworker" /> /// <reference lib="webworker" />
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 { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdownShikiThemeDefinition';
import type { MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol'; 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
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 `<span${style}>${escapeHtml(token.content)}</span>`;
};
// Single shared highlighter for the worker. Languages load lazily on demand. // Single shared highlighter for the worker. Languages load lazily on demand.
let highlighter: ReturnType<typeof createHighlighter> | undefined; let highlighter: ReturnType<typeof createHighlighter> | undefined;
@@ -25,20 +47,31 @@ self.onmessage = (event: MessageEvent<MarkdownWorkerRequest>) => {
ensureHighlighter(); ensureHighlighter();
return; 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<ReturnType<typeof createHighlighter>>;
const resolveLanguage = async (instance: Instance, requested: string): Promise<string> => {
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<MarkdownWorkerRequest, { type: 'highlight' }>): Promise<void> { async function highlight(request: Extract<MarkdownWorkerRequest, { type: 'highlight' }>): Promise<void> {
try { try {
const instance = await ensureHighlighter(); const instance = await ensureHighlighter();
let lang = request.lang in bundledLanguages ? request.lang : 'text'; const lang = await resolveLanguage(instance, request.lang);
if (lang !== 'text' && !instance.getLoadedLanguages().includes(lang)) {
try {
await instance.loadLanguage(bundledLanguages[lang as BundledLanguage]);
} catch {
lang = 'text';
}
}
const html = instance.codeToHtml(request.code, { const html = instance.codeToHtml(request.code, {
lang, lang,
theme: MARKDOWN_SHIKI_THEME, theme: MARKDOWN_SHIKI_THEME,
@@ -50,6 +83,21 @@ async function highlight(request: Extract<MarkdownWorkerRequest, { type: 'highli
} }
} }
async function highlightLines(request: Extract<MarkdownWorkerRequest, { type: 'highlightLines' }>): Promise<void> {
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 { function post(response: MarkdownWorkerResponse): void {
self.postMessage(response); self.postMessage(response);
} }
@@ -6,8 +6,14 @@
export type MarkdownWorkerRequest = export type MarkdownWorkerRequest =
| { type: 'init' } | { type: 'init' }
| { type: 'highlight'; id: number; code: string; lang: string }; // Highlight a whole block to ready-to-splice Shiki `<pre>` 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 = export type MarkdownWorkerResponse =
| { type: 'highlight'; id: number; html: string } | { type: 'highlight'; id: number; html: string }
| { type: 'highlightLines'; id: number; lines: string[] }
| { type: 'error'; id: number; message: string }; | { type: 'error'; id: number; message: string };
@@ -7,9 +7,11 @@ import type { MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-w
// tokenization error) the promise resolves to `null` and the caller keeps the // tokenization error) the promise resolves to `null` and the caller keeps the
// escaped plain-text code — highlighting never falls back onto the main thread. // escaped plain-text code — highlighting never falls back onto the main thread.
type PendingResolver = (response: MarkdownWorkerResponse | null) => void;
let worker: Worker | undefined; let worker: Worker | undefined;
let nextId = 0; let nextId = 0;
const pending = new Map<number, (html: string | null) => void>(); const pending = new Map<number, PendingResolver>();
const failAll = (): void => { const failAll = (): void => {
pending.forEach((resolve) => resolve(null)); pending.forEach((resolve) => resolve(null));
@@ -30,7 +32,7 @@ const getWorker = (): Worker | undefined => {
const resolve = pending.get(event.data.id); const resolve = pending.get(event.data.id);
if (!resolve) return; if (!resolve) return;
pending.delete(event.data.id); pending.delete(event.data.id);
resolve(event.data.type === 'highlight' ? event.data.html : null); resolve(event.data);
}; };
worker.onerror = failAll; worker.onerror = failAll;
worker.onmessageerror = failAll; worker.onmessageerror = failAll;
@@ -38,16 +40,31 @@ const getWorker = (): Worker | undefined => {
return worker; return worker;
}; };
const request = (payload: (id: number) => MarkdownWorkerRequest): Promise<MarkdownWorkerResponse | null> => {
const instance = getWorker();
if (!instance) return Promise.resolve(null);
const id = ++nextId;
return new Promise<MarkdownWorkerResponse | null>((resolve) => {
pending.set(id, resolve);
instance.postMessage(payload(id));
});
};
/** /**
* Highlight a complete code block in the worker. Resolves to Shiki `<pre>` HTML, * Highlight a complete code block in the worker. Resolves to Shiki `<pre>` HTML,
* or `null` if highlighting is unavailable or failed (caller keeps plain code). * or `null` if highlighting is unavailable or failed (caller keeps plain code).
*/ */
export const highlightCodeInWorker = (code: string, lang: string): Promise<string | null> => { export const highlightCodeInWorker = async (code: string, lang: string): Promise<string | null> => {
const instance = getWorker(); const response = await request((id) => ({ type: 'highlight', id, code, lang }));
if (!instance) return Promise.resolve(null); return response?.type === 'highlight' ? response.html : null;
const id = ++nextId; };
return new Promise<string | null>((resolve) => {
pending.set(id, resolve); /**
instance.postMessage({ type: 'highlight', id, code, lang } satisfies MarkdownWorkerRequest); * 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<string[] | null> => {
const response = await request((id) => ({ type: 'highlightLines', id, code, lang }));
return response?.type === 'highlightLines' ? response.lines : null;
}; };
@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Dialog, DialogContent } from '@/components/ui/dialog'; import { Dialog, DialogContent } from '@/components/ui/dialog';
import { File as PierreFile, PatchDiff } from '@pierre/diffs/react'; 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 { createPortal } from 'react-dom';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -550,9 +550,8 @@ DialogUnifiedDiff.displayName = 'DialogUnifiedDiff';
const DialogReadContent: React.FC<{ const DialogReadContent: React.FC<{
popup: ToolPopupContent; popup: ToolPopupContent;
syntaxTheme: Record<string, React.CSSProperties>;
pierreThemeConfig: PierreThemeConfig; pierreThemeConfig: PierreThemeConfig;
}> = React.memo(({ popup, syntaxTheme, pierreThemeConfig }) => { }> = React.memo(({ popup, pierreThemeConfig }) => {
const parsedReadOutput = React.useMemo(() => parseReadToolOutput(popup.content), [popup.content]); const parsedReadOutput = React.useMemo(() => parseReadToolOutput(popup.content), [popup.content]);
const inputMeta = popup.metadata?.input; const inputMeta = popup.metadata?.input;
@@ -627,7 +626,6 @@ const DialogReadContent: React.FC<{
<VirtualizedCodeBlock <VirtualizedCodeBlock
lines={codeLines} lines={codeLines}
language={detectedLanguage} language={detectedLanguage}
syntaxTheme={syntaxTheme}
maxHeight="70vh" maxHeight="70vh"
/> />
); );
@@ -1055,16 +1053,13 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
</div> </div>
{meta.tool === 'bash' && getInputValue('command') ? ( {meta.tool === 'bash' && getInputValue('command') ? (
<div className="tool-input-surface bg-transparent rounded-xl border border-border/20 mx-3"> <div className="tool-input-surface bg-transparent rounded-xl border border-border/20 mx-3">
<SyntaxHighlighter <WorkerHighlightedCode
style={syntaxTheme}
language="bash" language="bash"
PreTag="div" code={getInputValue('command')!}
customStyle={toolDisplayStyles.getPopupStyles()} style={toolDisplayStyles.getPopupStyles()}
codeTagProps={DIALOG_CODE_TAG_PROPS} codeStyle={DIALOG_CODE_TAG_PROPS.style}
wrapLongLines wrap
> />
{getInputValue('command')!}
</SyntaxHighlighter>
</div> </div>
) : meta.tool === 'task' && getInputValue('prompt') ? ( ) : meta.tool === 'task' && getInputValue('prompt') ? (
<div <div
@@ -1124,16 +1119,13 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
completed: t('chat.todo.completed'), completed: t('chat.todo.completed'),
cancelled: t('chat.todo.cancelled'), cancelled: t('chat.todo.cancelled'),
}) || ( }) || (
<SyntaxHighlighter <WorkerHighlightedCode
style={syntaxTheme}
language="json" language="json"
PreTag="div" code={popup.content}
wrapLongLines style={toolDisplayStyles.getPopupContainerStyles()}
customStyle={toolDisplayStyles.getPopupContainerStyles()} codeStyle={DIALOG_CODE_TAG_PROPS.style}
codeTagProps={DIALOG_CODE_TAG_PROPS} wrap
> />
{popup.content}
</SyntaxHighlighter>
) )
); );
} }
@@ -1179,22 +1171,19 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
if (tool === 'web-search' || tool === 'websearch' || tool === 'search_web') { if (tool === 'web-search' || tool === 'websearch' || tool === 'search_web') {
return ( return (
renderWebSearchOutput(popup.content, syntaxTheme) || ( renderWebSearchOutput(popup.content, syntaxTheme) || (
<SyntaxHighlighter <WorkerHighlightedCode
style={syntaxTheme}
language="text" language="text"
PreTag="div" code={popup.content}
wrapLongLines style={toolDisplayStyles.getPopupContainerStyles()}
customStyle={toolDisplayStyles.getPopupContainerStyles()} codeStyle={DIALOG_CODE_TAG_PROPS.style}
codeTagProps={DIALOG_CODE_TAG_PROPS} wrap
> />
{popup.content}
</SyntaxHighlighter>
) )
); );
} }
if (tool === 'read') { if (tool === 'read') {
return <DialogReadContent popup={popup} syntaxTheme={syntaxTheme} pierreThemeConfig={pierreThemeConfig} />; return <DialogReadContent popup={popup} pierreThemeConfig={pierreThemeConfig} />;
} }
// JSON tree viewer for generic JSON outputs // JSON tree viewer for generic JSON outputs
@@ -1210,16 +1199,13 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
} }
return ( return (
<SyntaxHighlighter <WorkerHighlightedCode
style={syntaxTheme}
language={popup.language || 'text'} language={popup.language || 'text'}
PreTag="div" code={popup.content}
wrapLongLines style={toolDisplayStyles.getPopupContainerStyles()}
customStyle={toolDisplayStyles.getPopupContainerStyles()} codeStyle={DIALOG_CODE_TAG_PROPS.style}
codeTagProps={DIALOG_CODE_TAG_PROPS} wrap
> />
{popup.content}
</SyntaxHighlighter>
); );
})()} })()}
</div> </div>
@@ -7,7 +7,7 @@ import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
import { getToolMetadata } from '@/lib/toolHelpers'; import { getToolMetadata } from '@/lib/toolHelpers';
import type { ToolPart as ToolPartType, ToolState as ToolStateUnion } from '@opencode-ai/sdk/v2'; import type { ToolPart as ToolPartType, ToolState as ToolStateUnion } from '@opencode-ai/sdk/v2';
import { toolDisplayStyles } from '@/lib/typography'; 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 { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
@@ -844,7 +844,7 @@ const ToolScrollableTextOutput: React.FC<{
metadata: Record<string, unknown> | undefined; metadata: Record<string, unknown> | undefined;
input: Record<string, unknown> | undefined; input: Record<string, unknown> | undefined;
syntaxTheme: { [key: string]: React.CSSProperties }; syntaxTheme: { [key: string]: React.CSSProperties };
}> = ({ output, part, metadata, input, syntaxTheme }) => { }> = ({ output, part, metadata, input }) => {
const { t } = useI18n(); const { t } = useI18n();
const renderedOutput = getToolOutputText(output, part, metadata); const renderedOutput = getToolOutputText(output, part, metadata);
const outputLanguage = getToolOutputLanguage(output, part, metadata, input); const outputLanguage = getToolOutputLanguage(output, part, metadata, input);
@@ -910,16 +910,13 @@ const ToolScrollableTextOutput: React.FC<{
/> />
) : ( ) : (
<div className="typography-code pr-12 text-muted-foreground/90"> <div className="typography-code pr-12 text-muted-foreground/90">
<SyntaxHighlighter <WorkerHighlightedCode
style={syntaxTheme}
language="json" language="json"
PreTag="div" code={renderedOutput}
customStyle={TOOL_COLLAPSED_CUSTOM_STYLE} style={TOOL_COLLAPSED_CUSTOM_STYLE}
codeTagProps={CODE_TAG_PROPS} codeStyle={CODE_TAG_PROPS.style}
wrapLongLines wrap
> />
{renderedOutput}
</SyntaxHighlighter>
</div> </div>
)} )}
</div> </div>
@@ -928,16 +925,13 @@ const ToolScrollableTextOutput: React.FC<{
return ( return (
<div className={part.tool === 'bash' ? 'typography-code text-muted-foreground/90' : undefined}> <div className={part.tool === 'bash' ? 'typography-code text-muted-foreground/90' : undefined}>
<SyntaxHighlighter <WorkerHighlightedCode
style={syntaxTheme}
language={outputLanguage} language={outputLanguage}
PreTag="div" code={renderedOutput}
customStyle={TOOL_COLLAPSED_CUSTOM_STYLE} style={TOOL_COLLAPSED_CUSTOM_STYLE}
codeTagProps={CODE_TAG_PROPS} codeStyle={CODE_TAG_PROPS.style}
wrapLongLines wrap
> />
{renderedOutput}
</SyntaxHighlighter>
</div> </div>
); );
}; };
@@ -1,48 +1,20 @@
/** /**
* VirtualizedCodeBlock — PERF-007 * VirtualizedCodeBlock — PERF-007
* *
* Replaces per-line <SyntaxHighlighter> with: * Renders large code/read outputs without mounting one highlighter per line:
* 1. ONE Prism.highlight() call to tokenize all code at once * 1. ONE worker tokenization of the whole block (off the main thread)
* 2. virtua to only render visible rows * 2. virtua to only render visible rows
* *
* This drops mount cost from O(N * Prism) to O(1 * Prism) + O(visible_rows). * Tokenizing the whole block at once also preserves cross-line syntax context
* For a 2000-line file, ~2000 SyntaxHighlighter instances → ~30 plain <div>s. * (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 React from 'react';
import { Virtualizer } from 'virtua'; import { Virtualizer } from 'virtua';
import Prism from 'prismjs'; import { useThemeSystem } from '@/contexts/useThemeSystem';
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme';
// Ensure common languages are loaded (react-syntax-highlighter lazy-loads them, import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines';
// 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';
// ── Threshold: files smaller than this render without virtualization ── // ── Threshold: files smaller than this render without virtualization ──
const VIRTUALIZE_THRESHOLD = 80; const VIRTUALIZE_THRESHOLD = 80;
@@ -60,7 +32,6 @@ export interface CodeLine {
interface VirtualizedCodeBlockProps { interface VirtualizedCodeBlockProps {
lines: CodeLine[]; lines: CodeLine[];
language: string; language: string;
syntaxTheme: Record<string, React.CSSProperties>;
/** Max visible height in CSS (default: 60vh) */ /** Max visible height in CSS (default: 60vh) */
maxHeight?: string; maxHeight?: string;
/** Show line numbers (default: true) */ /** Show line numbers (default: true) */
@@ -69,123 +40,21 @@ interface VirtualizedCodeBlockProps {
lineStyles?: (line: CodeLine) => React.CSSProperties | undefined; 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, React.CSSProperties>): 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<string, string> = {
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<string, string>();
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// ── Component ──────────────────────────────────────────────────────── // ── Component ────────────────────────────────────────────────────────
export const VirtualizedCodeBlock: React.FC<VirtualizedCodeBlockProps> = React.memo((props) => { export const VirtualizedCodeBlock: React.FC<VirtualizedCodeBlockProps> = React.memo((props) => {
const { const {
lines, lines,
language, language,
syntaxTheme,
maxHeight = '60vh', maxHeight = '60vh',
showLineNumbers = true, showLineNumbers = true,
lineStyles, lineStyles,
} = props; } = 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; const shouldVirtualize = lines.length > VIRTUALIZE_THRESHOLD;
@@ -193,15 +62,14 @@ export const VirtualizedCodeBlock: React.FC<VirtualizedCodeBlockProps> = React.m
if (!shouldVirtualize) { if (!shouldVirtualize) {
return ( return (
<div <div
className="typography-code font-mono w-full min-w-0 oc-virtualized-prism" className="typography-code font-mono w-full min-w-0"
style={{ maxHeight, overflow: 'auto' }} style={{ ...(syntaxVars as React.CSSProperties), maxHeight, overflow: 'auto' }}
> >
{prismThemeCss ? <style>{prismThemeCss}</style> : null}
{lines.map((line, idx) => ( {lines.map((line, idx) => (
<Row <Row
key={idx} key={idx}
line={line} line={line}
language={language} html={highlighted?.[idx]}
showLineNumbers={showLineNumbers} showLineNumbers={showLineNumbers}
style={lineStyles?.(line)} style={lineStyles?.(line)}
/> />
@@ -214,8 +82,8 @@ export const VirtualizedCodeBlock: React.FC<VirtualizedCodeBlockProps> = React.m
return ( return (
<VirtualizedRows <VirtualizedRows
lines={lines} lines={lines}
language={language} highlighted={highlighted}
prismThemeCss={prismThemeCss} syntaxVars={syntaxVars}
maxHeight={maxHeight} maxHeight={maxHeight}
showLineNumbers={showLineNumbers} showLineNumbers={showLineNumbers}
lineStyles={lineStyles} lineStyles={lineStyles}
@@ -228,8 +96,8 @@ VirtualizedCodeBlock.displayName = 'VirtualizedCodeBlock';
// ── Virtualised container (extracted so the hook is top-level) ──────── // ── Virtualised container (extracted so the hook is top-level) ────────
interface VirtualizedRowsProps { interface VirtualizedRowsProps {
lines: CodeLine[]; lines: CodeLine[];
language: string; highlighted: string[] | null;
prismThemeCss: string; syntaxVars: Record<string, string>;
maxHeight: string; maxHeight: string;
showLineNumbers: boolean; showLineNumbers: boolean;
lineStyles?: (line: CodeLine) => React.CSSProperties | undefined; lineStyles?: (line: CodeLine) => React.CSSProperties | undefined;
@@ -237,8 +105,8 @@ interface VirtualizedRowsProps {
const VirtualizedRows: React.FC<VirtualizedRowsProps> = React.memo(({ const VirtualizedRows: React.FC<VirtualizedRowsProps> = React.memo(({
lines, lines,
language, highlighted,
prismThemeCss, syntaxVars,
maxHeight, maxHeight,
showLineNumbers, showLineNumbers,
lineStyles, lineStyles,
@@ -249,10 +117,9 @@ const VirtualizedRows: React.FC<VirtualizedRowsProps> = React.memo(({
return ( return (
<div <div
ref={parentRef} ref={parentRef}
className="typography-code font-mono w-full min-w-0 oc-virtualized-prism" className="typography-code font-mono w-full min-w-0"
style={{ height: viewportHeight, maxHeight, overflow: 'auto' }} style={{ ...(syntaxVars as React.CSSProperties), height: viewportHeight, maxHeight, overflow: 'auto' }}
> >
{prismThemeCss ? <style>{prismThemeCss}</style> : null}
<Virtualizer <Virtualizer
data={lines} data={lines}
itemSize={ROW_HEIGHT} itemSize={ROW_HEIGHT}
@@ -263,7 +130,7 @@ const VirtualizedRows: React.FC<VirtualizedRowsProps> = React.memo(({
<Row <Row
key={index} key={index}
line={line} line={line}
language={language} html={highlighted?.[index]}
showLineNumbers={showLineNumbers} showLineNumbers={showLineNumbers}
style={lineStyles?.(line)} style={lineStyles?.(line)}
/> />
@@ -278,14 +145,12 @@ VirtualizedRows.displayName = 'VirtualizedRows';
// ── Single row ─────────────────────────────────────────────────────── // ── Single row ───────────────────────────────────────────────────────
interface RowProps { interface RowProps {
line: CodeLine; line: CodeLine;
language: string; html: string | undefined;
showLineNumbers: boolean; showLineNumbers: boolean;
style?: React.CSSProperties; style?: React.CSSProperties;
} }
const Row: React.FC<RowProps> = React.memo(({ line, language, showLineNumbers, style }) => { const Row: React.FC<RowProps> = React.memo(({ line, html, showLineNumbers, style }) => {
const html = React.useMemo(() => highlightLine(line.text, language), [line.text, language]);
return ( return (
<div <div
className="typography-code font-mono flex w-full min-w-0" className="typography-code font-mono flex w-full min-w-0"
@@ -304,11 +169,10 @@ const Row: React.FC<RowProps> = React.memo(({ line, language, showLineNumbers, s
<div className="whitespace-pre-wrap break-words text-muted-foreground/70 italic"> <div className="whitespace-pre-wrap break-words text-muted-foreground/70 italic">
{line.text} {line.text}
</div> </div>
) : html !== undefined ? (
<div className="whitespace-pre" dangerouslySetInnerHTML={{ __html: html }} />
) : ( ) : (
<div <div className="whitespace-pre">{line.text}</div>
className="whitespace-pre"
dangerouslySetInnerHTML={{ __html: html }}
/>
)} )}
</div> </div>
</div> </div>
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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 `<pre><code>` 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 => `<pre><code>${escapeHtml(code)}</code></pre>`;
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 `<code>` element (mirrors `codeTagProps.style`). */
codeStyle?: React.CSSProperties;
/** Wrap long lines instead of horizontal scroll. */
wrap?: boolean;
}
export const WorkerHighlightedCode: React.FC<WorkerHighlightedCodeProps> = ({
code,
language,
className,
style,
codeStyle,
wrap = false,
}) => {
const { currentTheme } = useThemeSystem();
const hostRef = React.useRef<HTMLDivElement>(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 (
<div
ref={hostRef}
className={cn('typography-code', className)}
style={{ ...(syntaxVars as React.CSSProperties), ...style }}
/>
);
};
@@ -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<string[] | null>(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;
};
@@ -1,11 +1,9 @@
import React from 'react'; import React from 'react';
import type { Message, Part } from '@opencode-ai/sdk/v2'; 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 { deriveMessageRole } from '@/components/chat/message/messageRole';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
@@ -272,9 +270,7 @@ const resolveProviderAndModel = (
export const ContextPanelContent: React.FC = () => { export const ContextPanelContent: React.FC = () => {
const { t } = useI18n(); const { t } = useI18n();
const { currentTheme } = useThemeSystem();
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
const [expandedRawMessages, setExpandedRawMessages] = React.useState<Record<string, boolean>>({}); const [expandedRawMessages, setExpandedRawMessages] = React.useState<Record<string, boolean>>({});
const [copiedRawMessageId, setCopiedRawMessageId] = React.useState<string | null>(null); const [copiedRawMessageId, setCopiedRawMessageId] = React.useState<string | null>(null);
const copyResetTimeoutRef = React.useRef<number | null>(null); const copyResetTimeoutRef = React.useRef<number | null>(null);
@@ -649,28 +645,18 @@ export const ContextPanelContent: React.FC = () => {
{isCopied ? <Icon name="check" className="size-3.5" /> : <Icon name="file-copy" className="size-3.5" />} {isCopied ? <Icon name="check" className="size-3.5" /> : <Icon name="file-copy" className="size-3.5" />}
</button> </button>
</div> </div>
<SyntaxHighlighter <WorkerHighlightedCode
language="json" language="json"
style={syntaxTheme} code={jsonValue}
PreTag="div" style={{
customStyle={{
margin: 0, margin: 0,
padding: '0.75rem', padding: '0.75rem',
background: 'transparent', background: 'transparent',
fontSize: 'var(--text-micro)', fontSize: 'var(--text-micro)',
lineHeight: '1.35', lineHeight: '1.35',
}} }}
codeTagProps={{ wrap
style: { />
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overflowWrap: 'break-word',
},
}}
wrapLongLines
>
{jsonValue}
</SyntaxHighlighter>
</div> </div>
</div> </div>
)} )}
@@ -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;
}
-2
View File
@@ -70,7 +70,6 @@
"@types/node": "^24.3.1", "@types/node": "^24.3.1",
"@types/react": "^19.1.10", "@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.7",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/supertest": "^7.2.0", "@types/supertest": "^7.2.0",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
@@ -90,7 +89,6 @@
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.6",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"strip-json-comments": "^5.0.3", "strip-json-comments": "^5.0.3",