= 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 ? : }