feat: implement Shiki theme support for VS Code webview and enhance theme handling
This commit is contained in:
@@ -19,6 +19,7 @@ import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useFileStore } from '@/stores/fileStore';
|
import { useFileStore } from '@/stores/fileStore';
|
||||||
import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults';
|
import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults';
|
||||||
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
|
|
||||||
const MAX_VISIBLE_TEXTAREA_LINES = 8;
|
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]);
|
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>(() => {
|
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) {
|
if (!chatInputAccent) {
|
||||||
return undefined;
|
return { borderWidth: baseBorderWidth };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const borderColor = chatInputAccent.border ?? chatInputAccent.text;
|
||||||
return {
|
return {
|
||||||
borderColor: chatInputAccent.border ?? chatInputAccent.text,
|
borderColor: softenBorderColor(borderColor),
|
||||||
borderWidth: chatInputAccent.borderWidth ?? 1,
|
borderWidth: baseBorderWidth,
|
||||||
};
|
};
|
||||||
}, [chatInputAccent]);
|
}, [chatInputAccent, softenBorderColor]);
|
||||||
|
|
||||||
const hasContent = message.trim() || attachedFiles.length > 0;
|
const hasContent = message.trim() || attachedFiles.length > 0;
|
||||||
|
|
||||||
@@ -789,7 +803,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
<AttachedFilesList />
|
<AttachedFilesList />
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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"
|
"flex flex-col relative overflow-visible"
|
||||||
)}
|
)}
|
||||||
style={chatInputWrapperStyle}
|
style={chatInputWrapperStyle}
|
||||||
|
|||||||
@@ -6,8 +6,83 @@ import { cn } from '@/lib/utils';
|
|||||||
import { RiFileCopyLine, RiCheckLine, RiDownloadLine } from '@remixicon/react';
|
import { RiFileCopyLine, RiCheckLine, RiDownloadLine } from '@remixicon/react';
|
||||||
|
|
||||||
import { flexokiStreamdownThemes } from '@/lib/shiki/flexokiThemes';
|
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
|
// Table utility functions
|
||||||
const extractTableData = (tableEl: HTMLTableElement): { headers: string[]; rows: string[][] } => {
|
const extractTableData = (tableEl: HTMLTableElement): { headers: string[]; rows: string[][] } => {
|
||||||
@@ -352,6 +427,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
|||||||
className,
|
className,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const shikiThemes = useMarkdownShikiThemes();
|
||||||
const componentKey = React.useMemo(() => {
|
const componentKey = React.useMemo(() => {
|
||||||
const signature = part?.id ? `part-${part.id}` : `message-${messageId}`;
|
const signature = part?.id ? `part-${part.id}` : `message-${messageId}`;
|
||||||
return `markdown-${signature}`;
|
return `markdown-${signature}`;
|
||||||
@@ -361,7 +437,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
|||||||
<div className={cn('break-words', className)}>
|
<div className={cn('break-words', className)}>
|
||||||
<Streamdown
|
<Streamdown
|
||||||
mode={isStreaming ? 'streaming' : 'static'}
|
mode={isStreaming ? 'streaming' : 'static'}
|
||||||
shikiTheme={SHIKI_THEMES}
|
shikiTheme={shikiThemes}
|
||||||
className="streamdown-content"
|
className="streamdown-content"
|
||||||
controls={{ code: false, table: false }}
|
controls={{ code: false, table: false }}
|
||||||
components={streamdownComponents}
|
components={streamdownComponents}
|
||||||
@@ -386,11 +462,12 @@ export const SimpleMarkdownRenderer: React.FC<{
|
|||||||
content: string;
|
content: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}> = ({ content, className }) => {
|
}> = ({ content, className }) => {
|
||||||
|
const shikiThemes = useMarkdownShikiThemes();
|
||||||
return (
|
return (
|
||||||
<div className={cn('break-words', className)}>
|
<div className={cn('break-words', className)}>
|
||||||
<Streamdown
|
<Streamdown
|
||||||
mode="static"
|
mode="static"
|
||||||
shikiTheme={SHIKI_THEMES}
|
shikiTheme={shikiThemes}
|
||||||
className="streamdown-content"
|
className="streamdown-content"
|
||||||
controls={{ code: false, table: false }}
|
controls={{ code: false, table: false }}
|
||||||
components={streamdownComponents}
|
components={streamdownComponents}
|
||||||
|
|||||||
@@ -32,11 +32,13 @@ function withStableStringId<T extends object>(value: T, id: string): T {
|
|||||||
Object.defineProperty(value, 'toString', {
|
Object.defineProperty(value, 'toString', {
|
||||||
value: () => id,
|
value: () => id,
|
||||||
enumerable: false,
|
enumerable: false,
|
||||||
|
configurable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
Object.defineProperty(value, Symbol.toPrimitive, {
|
Object.defineProperty(value, Symbol.toPrimitive, {
|
||||||
value: () => id,
|
value: () => id,
|
||||||
enumerable: false,
|
enumerable: false,
|
||||||
|
configurable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ export type VSCodeThemeColorToken =
|
|||||||
| 'editor.lineHighlightBackground'
|
| 'editor.lineHighlightBackground'
|
||||||
| 'editorCursor.foreground'
|
| 'editorCursor.foreground'
|
||||||
| 'focusBorder'
|
| 'focusBorder'
|
||||||
|
| 'diffEditor.insertedTextBackground'
|
||||||
|
| 'diffEditor.insertedTextBorder'
|
||||||
|
| 'diffEditor.insertedLineBackground'
|
||||||
|
| 'gitDecoration.addedResourceForeground'
|
||||||
| 'sideBar.background'
|
| 'sideBar.background'
|
||||||
| 'sideBar.foreground'
|
| 'sideBar.foreground'
|
||||||
| 'panel.background'
|
| 'panel.background'
|
||||||
@@ -65,6 +69,10 @@ const VARIABLE_MAP: Record<VSCodeThemeColorToken, string> = {
|
|||||||
'editor.lineHighlightBackground': '--vscode-editor-lineHighlightBackground',
|
'editor.lineHighlightBackground': '--vscode-editor-lineHighlightBackground',
|
||||||
'editorCursor.foreground': '--vscode-editorCursor-foreground',
|
'editorCursor.foreground': '--vscode-editorCursor-foreground',
|
||||||
focusBorder: '--vscode-focusBorder',
|
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.background': '--vscode-sideBar-background',
|
||||||
'sideBar.foreground': '--vscode-sideBar-foreground',
|
'sideBar.foreground': '--vscode-sideBar-foreground',
|
||||||
'panel.background': '--vscode-panel-background',
|
'panel.background': '--vscode-panel-background',
|
||||||
@@ -107,6 +115,43 @@ const normalizeColor = (value?: string | null): string | undefined => {
|
|||||||
return trimmed;
|
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 => {
|
const readKind = (preferred?: VSCodeThemeKind): VSCodeThemeKind => {
|
||||||
if (preferred === 'light' || preferred === 'dark' || preferred === 'high-contrast') {
|
if (preferred === 'light' || preferred === 'dark' || preferred === 'high-contrast') {
|
||||||
return preferred;
|
return preferred;
|
||||||
@@ -129,12 +174,14 @@ export const readVSCodeThemePalette = (
|
|||||||
return null;
|
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>> = {};
|
const colors: Partial<Record<VSCodeThemeColorToken, string>> = {};
|
||||||
|
|
||||||
(Object.keys(VARIABLE_MAP) as VSCodeThemeColorToken[]).forEach((token) => {
|
(Object.keys(VARIABLE_MAP) as VSCodeThemeColorToken[]).forEach((token) => {
|
||||||
const cssVar = VARIABLE_MAP[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) {
|
if (value) {
|
||||||
colors[token] = 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 panelFg = read('panel.foreground', read('editor.foreground', base.colors.surface.foreground));
|
||||||
const background = sidebarBg;
|
const background = sidebarBg;
|
||||||
const foreground = read('editor.foreground', base.colors.surface.foreground);
|
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 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 hoverBg = read('list.hoverBackground', read('editor.selectionBackground', base.colors.interactive.hover));
|
||||||
const activeBg = read('list.activeSelectionBackground', hoverBg);
|
const activeBg = read('list.activeSelectionBackground', hoverBg);
|
||||||
const selection = read('editor.selectionBackground', activeBg);
|
const selection = read('editor.selectionBackground', activeBg);
|
||||||
const selectionFg = read('editor.selectionForeground', foreground);
|
const selectionFg = read('editor.selectionForeground', foreground);
|
||||||
const focus = read('focusBorder', selection);
|
const focus = read('focusBorder', accent);
|
||||||
const border = read('input.border', read('panel.border', base.colors.interactive.border));
|
// 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 cursor = read('editorCursor.foreground', base.colors.interactive.cursor);
|
||||||
const badgeBg = read('badge.background', accent);
|
const badgeBg = read('badge.background', accent);
|
||||||
const badgeFg = read('badge.foreground', foreground);
|
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));
|
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.
|
// Tailwind's `--accent` drives hovered/selected menu items in Radix/shadcn; prefer VS Code list hover/selection.
|
||||||
const subtle = hoverBg;
|
const subtle = hoverBg;
|
||||||
@@ -215,7 +277,7 @@ export const buildVSCodeThemeFromPalette = (palette: VSCodeThemePalette): Theme
|
|||||||
selection,
|
selection,
|
||||||
selectionForeground: selectionFg,
|
selectionForeground: selectionFg,
|
||||||
focus,
|
focus,
|
||||||
focusRing: focus,
|
focusRing,
|
||||||
cursor,
|
cursor,
|
||||||
hover: hoverBg,
|
hover: hoverBg,
|
||||||
active: activeBg,
|
active: activeBg,
|
||||||
@@ -230,10 +292,10 @@ export const buildVSCodeThemeFromPalette = (palette: VSCodeThemePalette): Theme
|
|||||||
warningForeground: read('editorWarning.foreground', base.colors.status.warningForeground),
|
warningForeground: read('editorWarning.foreground', base.colors.status.warningForeground),
|
||||||
warningBackground: read('editorWarning.background', base.colors.status.warningBackground),
|
warningBackground: read('editorWarning.background', base.colors.status.warningBackground),
|
||||||
warningBorder: read('editorWarning.foreground', base.colors.status.warningBorder),
|
warningBorder: read('editorWarning.foreground', base.colors.status.warningBorder),
|
||||||
success: read('testing.iconPassed', base.colors.status.success),
|
success,
|
||||||
successForeground: read('testing.iconPassed', base.colors.status.successForeground),
|
successForeground: success,
|
||||||
successBackground: read('testing.iconPassed', base.colors.status.successBackground),
|
successBackground: successBg,
|
||||||
successBorder: read('testing.iconPassed', base.colors.status.successBorder),
|
successBorder,
|
||||||
info: read('editorInfo.foreground', base.colors.status.info),
|
info: read('editorInfo.foreground', base.colors.status.info),
|
||||||
infoForeground: read('editorInfo.foreground', base.colors.status.infoForeground),
|
infoForeground: read('editorInfo.foreground', base.colors.status.infoForeground),
|
||||||
infoBackground: read('editorInfo.background', base.colors.status.infoBackground),
|
infoBackground: read('editorInfo.background', base.colors.status.infoBackground),
|
||||||
|
|||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__OPENCHAMBER_VSCODE_SHIKI_THEMES__?: {
|
||||||
|
light?: Record<string, unknown>;
|
||||||
|
dark?: Record<string, unknown>;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
||||||
@@ -2,6 +2,7 @@ import * as vscode from 'vscode';
|
|||||||
import { handleBridgeMessage, type BridgeRequest } from './bridge';
|
import { handleBridgeMessage, type BridgeRequest } from './bridge';
|
||||||
import { getThemeKindName } from './theme';
|
import { getThemeKindName } from './theme';
|
||||||
import type { OpenCodeManager, ConnectionStatus } from './opencode';
|
import type { OpenCodeManager, ConnectionStatus } from './opencode';
|
||||||
|
import { getWebviewShikiThemes } from './shikiThemes';
|
||||||
|
|
||||||
export class ChatViewProvider implements vscode.WebviewViewProvider {
|
export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||||
public static readonly viewType = 'openchamber.chatView';
|
public static readonly viewType = 'openchamber.chatView';
|
||||||
@@ -27,6 +28,8 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
|||||||
};
|
};
|
||||||
|
|
||||||
webviewView.webview.html = this._getHtmlForWebview(webviewView.webview);
|
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) => {
|
webviewView.webview.onDidReceiveMessage(async (message: BridgeRequest) => {
|
||||||
if (message.type === 'restartApi') {
|
if (message.type === 'restartApi') {
|
||||||
@@ -44,9 +47,11 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
|||||||
public updateTheme(kind: vscode.ColorThemeKind) {
|
public updateTheme(kind: vscode.ColorThemeKind) {
|
||||||
if (this._view) {
|
if (this._view) {
|
||||||
const themeKind = getThemeKindName(kind);
|
const themeKind = getThemeKindName(kind);
|
||||||
this._view.webview.postMessage({
|
void getWebviewShikiThemes().then((shikiThemes) => {
|
||||||
type: 'themeChange',
|
this._view?.webview.postMessage({
|
||||||
theme: { kind: themeKind },
|
type: 'themeChange',
|
||||||
|
theme: { kind: themeKind, shikiThemes },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// Subscribe to status changes
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
openCodeManager.onStatusChange((status, error) => {
|
openCodeManager.onStatusChange((status, error) => {
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
|
||||||
|
type VSCodeThemeContribution = {
|
||||||
|
label?: string;
|
||||||
|
uiTheme?: string;
|
||||||
|
path?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WebviewShikiThemePayload = {
|
||||||
|
light?: Record<string, unknown>;
|
||||||
|
dark?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string, unknown> | 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<string, unknown>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getThemeLabelFromConfig = (key: string): string | undefined => {
|
||||||
|
return vscode.workspace.getConfiguration('workbench').get<string>(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<string>([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<unknown>; 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<Record<string, unknown> | 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<string, unknown>, suffix: string): Record<string, unknown> => {
|
||||||
|
const originalName = typeof raw.name === 'string' && raw.name.length > 0 ? raw.name : 'VSCode Theme';
|
||||||
|
return { ...raw, name: `${originalName} (${suffix})` };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getWebviewShikiThemes(): Promise<WebviewShikiThemePayload | null> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<string, unknown>; dark?: Record<string, unknown> } | null;
|
||||||
|
};
|
||||||
type ThemeChangeHandler = (theme: ThemeChangePayload) => void;
|
type ThemeChangeHandler = (theme: ThemeChangePayload) => void;
|
||||||
let themeChangeHandler: ThemeChangeHandler | null = null;
|
let themeChangeHandler: ThemeChangeHandler | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ declare global {
|
|||||||
connectionStatus: string;
|
connectionStatus: string;
|
||||||
};
|
};
|
||||||
__OPENCHAMBER_VSCODE_THEME__?: VSCodeThemePayload['theme'];
|
__OPENCHAMBER_VSCODE_THEME__?: VSCodeThemePayload['theme'];
|
||||||
|
__OPENCHAMBER_VSCODE_SHIKI_THEMES__?: { light?: Record<string, unknown>; dark?: Record<string, unknown> } | null;
|
||||||
__OPENCHAMBER_CONNECTION__?: { status: ConnectionStatus; error?: string };
|
__OPENCHAMBER_CONNECTION__?: { status: ConnectionStatus; error?: string };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,13 +84,32 @@ const emitVSCodeTheme = (preferredKind?: VSCodeThemeKind) => {
|
|||||||
|
|
||||||
emitVSCodeTheme(window.__VSCODE_CONFIG__?.theme as VSCodeThemeKind | undefined);
|
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) => {
|
onThemeChange((payload) => {
|
||||||
const kind = (typeof payload === 'string'
|
const kind = (typeof payload === 'string'
|
||||||
? payload
|
? payload
|
||||||
: typeof payload === 'object' && payload
|
: typeof payload === 'object' && payload
|
||||||
? payload.kind
|
? payload.kind
|
||||||
: undefined) as VSCodeThemeKind | undefined;
|
: 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;
|
const workspaceFolder = window.__VSCODE_CONFIG__?.workspaceFolder;
|
||||||
|
|||||||
Reference in New Issue
Block a user