From bd25787b927ddc8fe771a9b2da09484ad99d877e Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 15 Dec 2025 02:29:39 +0200 Subject: [PATCH] feat: implement Shiki theme support for VS Code webview and enhance theme handling --- packages/ui/src/components/chat/ChatInput.tsx | 24 +- .../src/components/chat/MarkdownRenderer.tsx | 83 ++++++- packages/ui/src/lib/shiki/flexokiThemes.ts | 2 + packages/ui/src/lib/theme/vscode/adapter.ts | 82 ++++++- packages/ui/src/types/vscode.d.ts | 11 + packages/vscode/src/ChatViewProvider.ts | 11 +- packages/vscode/src/extension.ts | 15 ++ packages/vscode/src/shikiThemes.ts | 232 ++++++++++++++++++ packages/vscode/webview/api/bridge.ts | 8 +- packages/vscode/webview/main.tsx | 22 +- 10 files changed, 467 insertions(+), 23 deletions(-) create mode 100644 packages/ui/src/types/vscode.d.ts create mode 100644 packages/vscode/src/shikiThemes.ts diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 611e3a4d..20adb78f 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -19,6 +19,7 @@ import { useAssistantStatus } from '@/hooks/useAssistantStatus'; import { toast } from 'sonner'; import { useFileStore } from '@/stores/fileStore'; import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults'; +import { isVSCodeRuntime } from '@/lib/desktop'; const MAX_VISIBLE_TEXTAREA_LINES = 8; @@ -118,15 +119,28 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const chatInputAccent = React.useMemo(() => getEditModeColors(effectiveEditPermission), [effectiveEditPermission]); + // VS Code webviews tend to have stronger status border colors; in web/desktop themes the same + // border tokens can already be subtle, so avoid double-softening there. + const softenBorderColor = React.useCallback((color: string) => ( + isVSCodeRuntime() + ? `color-mix(in srgb, ${color} 55%, transparent)` + : color + ), []); + const chatInputWrapperStyle = React.useMemo(() => { + // Keep border width stable so toggling modes doesn't shift layout. + const baseBorderWidth = isVSCodeRuntime() ? 1 : 2; + if (!chatInputAccent) { - return undefined; + return { borderWidth: baseBorderWidth }; } + + const borderColor = chatInputAccent.border ?? chatInputAccent.text; return { - borderColor: chatInputAccent.border ?? chatInputAccent.text, - borderWidth: chatInputAccent.borderWidth ?? 1, + borderColor: softenBorderColor(borderColor), + borderWidth: baseBorderWidth, }; - }, [chatInputAccent]); + }, [chatInputAccent, softenBorderColor]); const hasContent = message.trim() || attachedFiles.length > 0; @@ -789,7 +803,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo
(value: T, id: string): T => { + const existingPrimitive = (value as Record)[Symbol.toPrimitive]; + if (typeof existingPrimitive === 'function') { + try { + if ((existingPrimitive as () => unknown)() === id) { + return value; + } + } catch { + // Ignore and attempt to define below. + } + } + + try { + Object.defineProperty(value, 'toString', { + value: () => id, + enumerable: false, + configurable: true, + }); + } catch { + // Ignore if non-configurable or frozen. + } + + try { + Object.defineProperty(value, Symbol.toPrimitive, { + value: () => id, + enumerable: false, + configurable: true, + }); + } catch { + // Ignore if non-configurable or frozen. + } + + return value; +}; + +const getMarkdownShikiThemes = (): readonly [string | object, string | object] => { + if (!isVSCodeRuntime() || typeof window === 'undefined') { + return flexokiStreamdownThemes; + } + + const provided = window.__OPENCHAMBER_VSCODE_SHIKI_THEMES__; + if (provided?.light && provided?.dark) { + const light = withStableStringId( + { ...(provided.light as Record) }, + `vscode-shiki-light:${String((provided.light as { name?: unknown })?.name ?? 'theme')}`, + ); + const dark = withStableStringId( + { ...(provided.dark as Record) }, + `vscode-shiki-dark:${String((provided.dark as { name?: unknown })?.name ?? 'theme')}`, + ); + return [light, dark] as const; + } + + return flexokiStreamdownThemes; +}; + +const useMarkdownShikiThemes = (): readonly [string | object, string | object] => { + const [themes, setThemes] = React.useState(getMarkdownShikiThemes); + + React.useEffect(() => { + if (!isVSCodeRuntime() || typeof window === 'undefined') return; + + const handler = (event: Event) => { + // Rely on the canonical `window.__OPENCHAMBER_VSCODE_SHIKI_THEMES__` that the webview updates + // before dispatching this event, so we always apply stable cache keys and avoid stale token reuse. + void event; + setThemes(getMarkdownShikiThemes()); + }; + + window.addEventListener('openchamber:vscode-shiki-themes', handler as EventListener); + return () => window.removeEventListener('openchamber:vscode-shiki-themes', handler as EventListener); + }, []); + + return themes; +}; // Table utility functions const extractTableData = (tableEl: HTMLTableElement): { headers: string[]; rows: string[][] } => { @@ -352,6 +427,7 @@ export const MarkdownRenderer: React.FC = ({ className, isStreaming = false, }) => { + const shikiThemes = useMarkdownShikiThemes(); const componentKey = React.useMemo(() => { const signature = part?.id ? `part-${part.id}` : `message-${messageId}`; return `markdown-${signature}`; @@ -361,7 +437,7 @@ export const MarkdownRenderer: React.FC = ({
= ({ content, className }) => { + const shikiThemes = useMarkdownShikiThemes(); return (
(value: T, id: string): T { Object.defineProperty(value, 'toString', { value: () => id, enumerable: false, + configurable: true, }); Object.defineProperty(value, Symbol.toPrimitive, { value: () => id, enumerable: false, + configurable: true, }); return value; diff --git a/packages/ui/src/lib/theme/vscode/adapter.ts b/packages/ui/src/lib/theme/vscode/adapter.ts index 4cf14e2a..aae83422 100644 --- a/packages/ui/src/lib/theme/vscode/adapter.ts +++ b/packages/ui/src/lib/theme/vscode/adapter.ts @@ -12,6 +12,10 @@ export type VSCodeThemeColorToken = | 'editor.lineHighlightBackground' | 'editorCursor.foreground' | 'focusBorder' + | 'diffEditor.insertedTextBackground' + | 'diffEditor.insertedTextBorder' + | 'diffEditor.insertedLineBackground' + | 'gitDecoration.addedResourceForeground' | 'sideBar.background' | 'sideBar.foreground' | 'panel.background' @@ -65,6 +69,10 @@ const VARIABLE_MAP: Record = { 'editor.lineHighlightBackground': '--vscode-editor-lineHighlightBackground', 'editorCursor.foreground': '--vscode-editorCursor-foreground', focusBorder: '--vscode-focusBorder', + 'diffEditor.insertedTextBackground': '--vscode-diffEditor-insertedTextBackground', + 'diffEditor.insertedTextBorder': '--vscode-diffEditor-insertedTextBorder', + 'diffEditor.insertedLineBackground': '--vscode-diffEditor-insertedLineBackground', + 'gitDecoration.addedResourceForeground': '--vscode-gitDecoration-addedResourceForeground', 'sideBar.background': '--vscode-sideBar-background', 'sideBar.foreground': '--vscode-sideBar-foreground', 'panel.background': '--vscode-panel-background', @@ -107,6 +115,43 @@ const normalizeColor = (value?: string | null): string | undefined => { return trimmed; }; +const applyAlpha = (color: string, opacity: number): string => { + const normalized = color.trim(); + if (!normalized) return color; + + // rgba()/rgb() + const rgbMatch = normalized.match( + /^rgba?\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})(?:\s*,\s*([0-9.]+))?\s*\)$/i, + ); + if (rgbMatch) { + const r = Math.min(255, Math.max(0, Number(rgbMatch[1]))); + const g = Math.min(255, Math.max(0, Number(rgbMatch[2]))); + const b = Math.min(255, Math.max(0, Number(rgbMatch[3]))); + return `rgba(${r}, ${g}, ${b}, ${opacity})`; + } + + // #RGB / #RRGGBB / #RRGGBBAA + const hex = normalized.replace(/^#/, ''); + if (hex.length === 3 || hex.length === 6 || hex.length === 8) { + const expanded = hex.length === 3 + ? hex.split('').map((c) => `${c}${c}`).join('') + : hex.length === 8 + ? hex.slice(0, 6) + : hex; + + const r = parseInt(expanded.slice(0, 2), 16); + const g = parseInt(expanded.slice(2, 4), 16); + const b = parseInt(expanded.slice(4, 6), 16); + if (Number.isFinite(r) && Number.isFinite(g) && Number.isFinite(b)) { + return `rgba(${r}, ${g}, ${b}, ${opacity})`; + } + } + + return color; +}; + +const forceOpaque = (color: string): string => applyAlpha(color, 1); + const readKind = (preferred?: VSCodeThemeKind): VSCodeThemeKind => { if (preferred === 'light' || preferred === 'dark' || preferred === 'high-contrast') { return preferred; @@ -129,12 +174,14 @@ export const readVSCodeThemePalette = ( return null; } - const styles = getComputedStyle(document.documentElement); + const rootStyles = getComputedStyle(document.documentElement); + const bodyStyles = document.body ? getComputedStyle(document.body) : null; const colors: Partial> = {}; (Object.keys(VARIABLE_MAP) as VSCodeThemeColorToken[]).forEach((token) => { const cssVar = VARIABLE_MAP[token]; - const value = normalizeColor(styles.getPropertyValue(cssVar)); + const value = normalizeColor(rootStyles.getPropertyValue(cssVar)) + ?? (bodyStyles ? normalizeColor(bodyStyles.getPropertyValue(cssVar)) : undefined); if (value) { colors[token] = value; } @@ -158,18 +205,33 @@ export const buildVSCodeThemeFromPalette = (palette: VSCodeThemePalette): Theme const panelFg = read('panel.foreground', read('editor.foreground', base.colors.surface.foreground)); const background = sidebarBg; const foreground = read('editor.foreground', base.colors.surface.foreground); - const accent = read('textLink.foreground', read('button.background', base.colors.primary.base)); + // Prefer VS Code's "added diff" color as our primary accent when available (users expect this to match their theme). + const diffInserted = palette.colors['diffEditor.insertedTextBorder'] + ?? palette.colors['diffEditor.insertedLineBackground'] + ?? palette.colors['diffEditor.insertedTextBackground'] + ?? palette.colors['gitDecoration.addedResourceForeground']; + const accent = diffInserted + ? forceOpaque(diffInserted) + : read('button.background', read('textLink.foreground', base.colors.primary.base)); const accentFg = read('button.foreground', base.colors.primary.foreground || base.colors.surface.background); const hoverBg = read('list.hoverBackground', read('editor.selectionBackground', base.colors.interactive.hover)); const activeBg = read('list.activeSelectionBackground', hoverBg); const selection = read('editor.selectionBackground', activeBg); const selectionFg = read('editor.selectionForeground', foreground); - const focus = read('focusBorder', selection); - const border = read('input.border', read('panel.border', base.colors.interactive.border)); + const focus = read('focusBorder', accent); + // Prefer panel border for a less prominent, more consistent border color in webviews. + const border = read('panel.border', read('input.border', base.colors.interactive.border)); + const focusRing = applyAlpha(focus, palette.kind === 'light' ? 0.35 : 0.45); const cursor = read('editorCursor.foreground', base.colors.interactive.cursor); const badgeBg = read('badge.background', accent); const badgeFg = read('badge.foreground', foreground); + const success = diffInserted + ? forceOpaque(diffInserted) + : read('testing.iconPassed', base.colors.status.success); + const successBg = applyAlpha(success, palette.kind === 'light' ? 0.12 : 0.16); + const successBorder = applyAlpha(success, palette.kind === 'light' ? 0.35 : 0.45); + const inlineCode = read('textPreformat.foreground', read('terminal.ansiGreen', base.colors.syntax.base.string)); // Tailwind's `--accent` drives hovered/selected menu items in Radix/shadcn; prefer VS Code list hover/selection. const subtle = hoverBg; @@ -215,7 +277,7 @@ export const buildVSCodeThemeFromPalette = (palette: VSCodeThemePalette): Theme selection, selectionForeground: selectionFg, focus, - focusRing: focus, + focusRing, cursor, hover: hoverBg, active: activeBg, @@ -230,10 +292,10 @@ export const buildVSCodeThemeFromPalette = (palette: VSCodeThemePalette): Theme warningForeground: read('editorWarning.foreground', base.colors.status.warningForeground), warningBackground: read('editorWarning.background', base.colors.status.warningBackground), warningBorder: read('editorWarning.foreground', base.colors.status.warningBorder), - success: read('testing.iconPassed', base.colors.status.success), - successForeground: read('testing.iconPassed', base.colors.status.successForeground), - successBackground: read('testing.iconPassed', base.colors.status.successBackground), - successBorder: read('testing.iconPassed', base.colors.status.successBorder), + success, + successForeground: success, + successBackground: successBg, + successBorder, info: read('editorInfo.foreground', base.colors.status.info), infoForeground: read('editorInfo.foreground', base.colors.status.infoForeground), infoBackground: read('editorInfo.background', base.colors.status.infoBackground), diff --git a/packages/ui/src/types/vscode.d.ts b/packages/ui/src/types/vscode.d.ts new file mode 100644 index 00000000..0bf881b4 --- /dev/null +++ b/packages/ui/src/types/vscode.d.ts @@ -0,0 +1,11 @@ +declare global { + interface Window { + __OPENCHAMBER_VSCODE_SHIKI_THEMES__?: { + light?: Record; + dark?: Record; + } | null; + } +} + +export {}; + diff --git a/packages/vscode/src/ChatViewProvider.ts b/packages/vscode/src/ChatViewProvider.ts index e69afef3..81691d24 100644 --- a/packages/vscode/src/ChatViewProvider.ts +++ b/packages/vscode/src/ChatViewProvider.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import { handleBridgeMessage, type BridgeRequest } from './bridge'; import { getThemeKindName } from './theme'; import type { OpenCodeManager, ConnectionStatus } from './opencode'; +import { getWebviewShikiThemes } from './shikiThemes'; export class ChatViewProvider implements vscode.WebviewViewProvider { public static readonly viewType = 'openchamber.chatView'; @@ -27,6 +28,8 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { }; webviewView.webview.html = this._getHtmlForWebview(webviewView.webview); + // Send theme payload (including optional Shiki theme JSON) after the webview is set up. + void this.updateTheme(vscode.window.activeColorTheme.kind); webviewView.webview.onDidReceiveMessage(async (message: BridgeRequest) => { if (message.type === 'restartApi') { @@ -44,9 +47,11 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { public updateTheme(kind: vscode.ColorThemeKind) { if (this._view) { const themeKind = getThemeKindName(kind); - this._view.webview.postMessage({ - type: 'themeChange', - theme: { kind: themeKind }, + void getWebviewShikiThemes().then((shikiThemes) => { + this._view?.webview.postMessage({ + type: 'themeChange', + theme: { kind: themeKind, shikiThemes }, + }); }); } } diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 24683ee7..0a1028b0 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -37,6 +37,21 @@ export function activate(context: vscode.ExtensionContext) { }) ); + // Theme changes can update the `workbench.colorTheme` setting slightly after the + // `activeColorTheme` event. Listen for config changes too so we can re-resolve + // the contributed theme JSON and update Shiki themes in the webview. + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((event) => { + if ( + event.affectsConfiguration('workbench.colorTheme') || + event.affectsConfiguration('workbench.preferredLightColorTheme') || + event.affectsConfiguration('workbench.preferredDarkColorTheme') + ) { + chatViewProvider?.updateTheme(vscode.window.activeColorTheme.kind); + } + }) + ); + // Subscribe to status changes context.subscriptions.push( openCodeManager.onStatusChange((status, error) => { diff --git a/packages/vscode/src/shikiThemes.ts b/packages/vscode/src/shikiThemes.ts new file mode 100644 index 00000000..d7cef1f9 --- /dev/null +++ b/packages/vscode/src/shikiThemes.ts @@ -0,0 +1,232 @@ +import * as vscode from 'vscode'; + +type VSCodeThemeContribution = { + label?: string; + uiTheme?: string; + path?: string; +}; + +export type WebviewShikiThemePayload = { + light?: Record; + dark?: Record; +}; + +const stripJsonc = (input: string): string => { + let output = ''; + let inString = false; + let stringQuote: '"' | '\'' | null = null; + let escaped = false; + let inLineComment = false; + let inBlockComment = false; + + for (let i = 0; i < input.length; i++) { + const ch = input[i]; + const next = input[i + 1]; + + if (inLineComment) { + if (ch === '\n') { + inLineComment = false; + output += ch; + } + continue; + } + + if (inBlockComment) { + if (ch === '*' && next === '/') { + inBlockComment = false; + i++; + } + continue; + } + + if (inString) { + output += ch; + if (escaped) { + escaped = false; + continue; + } + if (ch === '\\') { + escaped = true; + continue; + } + if (stringQuote && ch === stringQuote) { + inString = false; + stringQuote = null; + } + continue; + } + + if (ch === '/' && next === '/') { + inLineComment = true; + i++; + continue; + } + + if (ch === '/' && next === '*') { + inBlockComment = true; + i++; + continue; + } + + if (ch === '"' || ch === '\'') { + inString = true; + stringQuote = ch; + output += ch; + continue; + } + + output += ch; + } + + return output; +}; + +const stripTrailingCommas = (input: string): string => { + let output = ''; + let inString = false; + let stringQuote: '"' | '\'' | null = null; + let escaped = false; + + for (let i = 0; i < input.length; i++) { + const ch = input[i]; + + if (inString) { + output += ch; + if (escaped) { + escaped = false; + continue; + } + if (ch === '\\') { + escaped = true; + continue; + } + if (stringQuote && ch === stringQuote) { + inString = false; + stringQuote = null; + } + continue; + } + + if (ch === '"' || ch === '\'') { + inString = true; + stringQuote = ch; + output += ch; + continue; + } + + if (ch === ',') { + // If the next non-whitespace character is a closing brace/bracket, drop this comma. + let j = i + 1; + while (j < input.length && /\s/.test(input[j] ?? '')) j++; + const nextNonWs = input[j]; + if (nextNonWs === '}' || nextNonWs === ']') { + continue; + } + } + + output += ch; + } + + return output; +}; + +const parseJsoncLoose = (input: string): Record | null => { + try { + const noComments = stripJsonc(input); + const noTrailingCommas = stripTrailingCommas(noComments); + const parsed = JSON.parse(noTrailingCommas) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record; + } + return null; + } catch { + return null; + } +}; + +const getThemeLabelFromConfig = (key: string): string | undefined => { + return vscode.workspace.getConfiguration('workbench').get(key) || undefined; +}; + +const normalizeLabel = (value: string): string => value.trim().replace(/\s+/g, ' ').toLowerCase(); + +const labelVariants = (label: string): string[] => { + const trimmed = label.trim(); + const variants = new Set([trimmed]); + + // VS Code sometimes uses "Default …" in settings while theme contributions omit it. + if (trimmed.toLowerCase().startsWith('default ')) { + variants.add(trimmed.slice('default '.length)); + } + + return Array.from(variants); +}; + +const findContributedTheme = (label: string): { extension: vscode.Extension; theme: VSCodeThemeContribution } | null => { + const targets = labelVariants(label).map(normalizeLabel); + for (const extension of vscode.extensions.all) { + const contributes = (extension.packageJSON as { contributes?: { themes?: VSCodeThemeContribution[] } } | undefined)?.contributes; + const themes = contributes?.themes; + if (!Array.isArray(themes)) continue; + + const match = themes.find((theme) => theme?.label && targets.includes(normalizeLabel(theme.label))); + if (match?.path) { + return { extension, theme: match }; + } + } + return null; +}; + +const readThemeJsonByLabel = async (label: string): Promise | null> => { + const resolved = findContributedTheme(label); + if (!resolved) return null; + + try { + const uri = vscode.Uri.joinPath(resolved.extension.extensionUri, resolved.theme.path as string); + const bytes = await vscode.workspace.fs.readFile(uri); + const text = new TextDecoder('utf-8').decode(bytes); + return parseJsoncLoose(text); + } catch { + return null; + } +}; + +const ensureUniqueThemeName = (raw: Record, suffix: string): Record => { + const originalName = typeof raw.name === 'string' && raw.name.length > 0 ? raw.name : 'VSCode Theme'; + return { ...raw, name: `${originalName} (${suffix})` }; +}; + +export async function getWebviewShikiThemes(): Promise { + const current = getThemeLabelFromConfig('colorTheme'); + const preferredLight = getThemeLabelFromConfig('preferredLightColorTheme') || current; + const preferredDark = getThemeLabelFromConfig('preferredDarkColorTheme') || current; + + const themeVariant = + vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Light || + vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.HighContrastLight + ? 'light' + : 'dark'; + + // Use the actively selected theme for the current variant, and only fall back to preferred + // themes for the opposite variant (so we actually pick up user-selected theme changes). + const lightLabel = themeVariant === 'light' ? current : preferredLight; + const darkLabel = themeVariant === 'dark' ? current : preferredDark; + + const [lightRaw, darkRaw] = await Promise.all([ + lightLabel ? readThemeJsonByLabel(lightLabel) : Promise.resolve(null), + darkLabel ? readThemeJsonByLabel(darkLabel) : Promise.resolve(null), + ]); + + // If we only managed to resolve one side, use it for both. This still gives correct highlighting + // for the currently active VS Code theme, and avoids falling back to Flexoki. + const fallbackOneSide = lightRaw ?? darkRaw; + const effectiveLight = lightRaw ?? fallbackOneSide; + const effectiveDark = darkRaw ?? fallbackOneSide; + + return !effectiveLight && !effectiveDark + ? null + : { + light: effectiveLight ? ensureUniqueThemeName(effectiveLight, 'Light') : undefined, + dark: effectiveDark ? ensureUniqueThemeName(effectiveDark, 'Dark') : undefined, + }; +} diff --git a/packages/vscode/webview/api/bridge.ts b/packages/vscode/webview/api/bridge.ts index 3d6f2344..872b8833 100644 --- a/packages/vscode/webview/api/bridge.ts +++ b/packages/vscode/webview/api/bridge.ts @@ -97,7 +97,13 @@ window.addEventListener('message', (event: MessageEvent) => { } }); -type ThemeChangePayload = 'light' | 'dark' | { kind?: 'light' | 'dark' | 'high-contrast' }; +type ThemeChangePayload = + | 'light' + | 'dark' + | { + kind?: 'light' | 'dark' | 'high-contrast'; + shikiThemes?: { light?: Record; dark?: Record } | null; + }; type ThemeChangeHandler = (theme: ThemeChangePayload) => void; let themeChangeHandler: ThemeChangeHandler | null = null; diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index b27c3633..13426ba1 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -20,6 +20,7 @@ declare global { connectionStatus: string; }; __OPENCHAMBER_VSCODE_THEME__?: VSCodeThemePayload['theme']; + __OPENCHAMBER_VSCODE_SHIKI_THEMES__?: { light?: Record; dark?: Record } | null; __OPENCHAMBER_CONNECTION__?: { status: ConnectionStatus; error?: string }; } } @@ -83,13 +84,32 @@ const emitVSCodeTheme = (preferredKind?: VSCodeThemeKind) => { emitVSCodeTheme(window.__VSCODE_CONFIG__?.theme as VSCodeThemeKind | undefined); +const scheduleThemeRecompute = (kind?: VSCodeThemeKind) => { + // VS Code updates webview CSS variables asynchronously around theme changes. + // Re-read on the next frames so we don't snapshot the old palette. + requestAnimationFrame(() => { + emitVSCodeTheme(kind); + requestAnimationFrame(() => emitVSCodeTheme(kind)); + }); +}; + onThemeChange((payload) => { const kind = (typeof payload === 'string' ? payload : typeof payload === 'object' && payload ? payload.kind : undefined) as VSCodeThemeKind | undefined; - emitVSCodeTheme(kind); + + if (typeof payload === 'object' && payload?.shikiThemes !== undefined) { + window.__OPENCHAMBER_VSCODE_SHIKI_THEMES__ = payload.shikiThemes; + window.dispatchEvent( + new CustomEvent('openchamber:vscode-shiki-themes', { + detail: { shikiThemes: payload.shikiThemes }, + }), + ); + } + + scheduleThemeRecompute(kind); }); const workspaceFolder = window.__VSCODE_CONFIG__?.workspaceFolder;