feat: implement Shiki theme support for VS Code webview and enhance theme handling

This commit is contained in:
Bohdan Triapitsyn
2025-12-15 02:29:39 +02:00
parent 453ac9c706
commit bd25787b92
10 changed files with 467 additions and 23 deletions
+19 -5
View File
@@ -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<ChatInputProps> = ({ 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<React.CSSProperties | undefined>(() => {
// 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<ChatInputProps> = ({ onOpenSettings, scrollToBo
<AttachedFilesList />
<div
className={cn(
"rounded-xl border border-border/20 bg-input/10 dark:bg-input/30",
"rounded-xl border border-border/80 bg-input/10 dark:bg-input/30",
"flex flex-col relative overflow-visible"
)}
style={chatInputWrapperStyle}
@@ -6,8 +6,83 @@ import { cn } from '@/lib/utils';
import { RiFileCopyLine, RiCheckLine, RiDownloadLine } from '@remixicon/react';
import { flexokiStreamdownThemes } from '@/lib/shiki/flexokiThemes';
import { isVSCodeRuntime } from '@/lib/desktop';
const SHIKI_THEMES = flexokiStreamdownThemes;
const withStableStringId = <T extends object>(value: T, id: string): T => {
const existingPrimitive = (value as Record<symbol, unknown>)[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<string, unknown>) },
`vscode-shiki-light:${String((provided.light as { name?: unknown })?.name ?? 'theme')}`,
);
const dark = withStableStringId(
{ ...(provided.dark as Record<string, unknown>) },
`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<MarkdownRendererProps> = ({
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<MarkdownRendererProps> = ({
<div className={cn('break-words', className)}>
<Streamdown
mode={isStreaming ? 'streaming' : 'static'}
shikiTheme={SHIKI_THEMES}
shikiTheme={shikiThemes}
className="streamdown-content"
controls={{ code: false, table: false }}
components={streamdownComponents}
@@ -386,11 +462,12 @@ export const SimpleMarkdownRenderer: React.FC<{
content: string;
className?: string;
}> = ({ content, className }) => {
const shikiThemes = useMarkdownShikiThemes();
return (
<div className={cn('break-words', className)}>
<Streamdown
mode="static"
shikiTheme={SHIKI_THEMES}
shikiTheme={shikiThemes}
className="streamdown-content"
controls={{ code: false, table: false }}
components={streamdownComponents}
@@ -32,11 +32,13 @@ function withStableStringId<T extends object>(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;
+72 -10
View File
@@ -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<VSCodeThemeColorToken, string> = {
'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<Record<VSCodeThemeColorToken, string>> = {};
(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),
+11
View File
@@ -0,0 +1,11 @@
declare global {
interface Window {
__OPENCHAMBER_VSCODE_SHIKI_THEMES__?: {
light?: Record<string, unknown>;
dark?: Record<string, unknown>;
} | null;
}
}
export {};