hermes-agent/apps/desktop/src/themes/vscode.ts
Brooklyn Nicholson 33a5bfa3c4 Merge remote-tracking branch 'origin/main' into bb/vscode-marketplace-themes
# Conflicts:
#	apps/desktop/electron/main.cjs
#	apps/desktop/src/app/command-palette/index.tsx
#	apps/desktop/src/themes/context.tsx
2026-06-09 23:22:36 -05:00

343 lines
12 KiB
TypeScript

/**
* VS Code color-theme → DesktopTheme converter.
*
* VS Code themes carry ~hundreds of `workbench.colorCustomization` keys, but the
* desktop theme model only needs a `DesktopThemeColors` struct — `applyTheme`
* derives every glass/shadcn token from a small seed chain via `color-mix()`.
* In practice ~6 workbench keys carry the whole look (background, foreground,
* accent, elevated surface, sidebar, error); everything else we derive by mixing
* those toward the background/foreground. That's the "naive token converter".
*
* A VS Code theme is single-mode (light OR dark). Rather than synthesise the
* opposite mode, we set both `colors` and `darkColors` to the converted palette
* so the imported theme renders faithfully no matter where the light/dark toggle
* sits — `renderedModeFor` still picks the `.dark` class from the real
* background luminance, so surface-bound UI matches what's on screen.
*/
import { ensureContrast, luminance, mix, normalizeHex, readableOn } from './color'
import type { DesktopTerminalPalette, DesktopTheme, DesktopThemeColors } from './types'
// Section headers / sidebar labels render in --theme-primary directly on the
// sidebar surface as small (~10px) uppercase text, so the accent has to clear
// WCAG AA for normal text (4.5:1) or it's unreadable — the "invisible purple
// label" case. Imported accents below this get nudged lighter/darker.
const ACCENT_MIN_CONTRAST = 4.5
/** The shape of a VS Code `*-color-theme.json` (only the fields we read). */
export interface VscodeColorTheme {
name?: string
type?: string
/** Relative path to a base theme this one extends. We don't follow it. */
include?: string
colors?: Record<string, unknown>
tokenColors?: unknown
}
export interface ConvertOptions {
/** Stable id (slug). Defaults to a slug of `raw.name`. */
slug?: string
/** Display label. Defaults to `raw.name`. */
label?: string
/** Shown under the label in the picker (e.g. the marketplace extension id). */
source?: string
}
export interface ConvertResult {
theme: DesktopTheme
/** The source theme's own light/dark (from `type`, else background luminance). */
mode: 'light' | 'dark'
/** Workbench keys we wanted but the theme omitted (we derived fallbacks). */
derived: string[]
}
/** Tolerant slug: lowercase, alnum + dashes, deduped, `vsc-` namespaced. */
export function vscodeThemeSlug(name: string): string {
const base = name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48)
return `vsc-${base || 'theme'}`
}
/**
* Parse a VS Code theme file. These ship as JSONC (line/block comments and
* trailing commas), so a plain `JSON.parse` rejects most real-world files.
* Strips comments + trailing commas, then parses. Throws on hard syntax errors.
*/
export function parseVscodeTheme(text: string): VscodeColorTheme {
const stripped = text
// Block comments.
.replace(/\/\*[\s\S]*?\*\//g, '')
// Line comments (not inside strings — naive but fine for theme files).
.replace(/(^|[^:"'\\])\/\/[^\n\r]*/g, '$1')
// Trailing commas before } or ].
.replace(/,(\s*[}\]])/g, '$1')
const parsed: unknown = JSON.parse(stripped)
if (!parsed || typeof parsed !== 'object') {
throw new Error('Theme file is not a JSON object.')
}
return parsed as VscodeColorTheme
}
const isDarkType = (raw: VscodeColorTheme, background: string): boolean => {
const type = (raw.type ?? '').toLowerCase()
if (type.includes('light')) {
return false
}
if (type === 'dark' || type === 'hc' || type === 'hc-black' || type.includes('dark')) {
return true
}
// No usable `type` — bucket by background luminance.
return luminance(background) < 0.4
}
// xterm ITheme ANSI slots ← VS Code `terminal.ansi*` tokens. Background is
// deliberately excluded — the pane keeps the live skin surface (transparency).
const ANSI_TOKENS: ReadonlyArray<readonly [keyof DesktopTerminalPalette, string]> = [
['black', 'terminal.ansiBlack'],
['red', 'terminal.ansiRed'],
['green', 'terminal.ansiGreen'],
['yellow', 'terminal.ansiYellow'],
['blue', 'terminal.ansiBlue'],
['magenta', 'terminal.ansiMagenta'],
['cyan', 'terminal.ansiCyan'],
['white', 'terminal.ansiWhite'],
['brightBlack', 'terminal.ansiBrightBlack'],
['brightRed', 'terminal.ansiBrightRed'],
['brightGreen', 'terminal.ansiBrightGreen'],
['brightYellow', 'terminal.ansiBrightYellow'],
['brightBlue', 'terminal.ansiBrightBlue'],
['brightMagenta', 'terminal.ansiBrightMagenta'],
['brightCyan', 'terminal.ansiBrightCyan'],
['brightWhite', 'terminal.ansiBrightWhite']
]
const BASE_ANSI: ReadonlyArray<keyof DesktopTerminalPalette> = [
'black',
'red',
'green',
'yellow',
'blue',
'magenta',
'cyan',
'white'
]
const HEX_RE = /^#[0-9a-f]{3,8}$/i
/**
* Lift a theme's integrated-terminal ANSI palette, if it ships one.
*
* All-or-nothing on the base-8 colors: a half-filled palette mixed with our
* defaults reads worse than just keeping the defaults, so we adopt the theme's
* palette only when the full base set is present. ANSI slots flatten alpha over
* the editor background; selection keeps its alpha so xterm can blend it.
*/
function extractTerminalPalette(colors: Record<string, unknown>, background: string): DesktopTerminalPalette | undefined {
const hex = (key: string): string | undefined =>
normalizeHex(typeof colors[key] === 'string' ? (colors[key] as string) : null, background) ?? undefined
const palette: DesktopTerminalPalette = {}
for (const [slot, token] of ANSI_TOKENS) {
const value = hex(token)
if (value) {
palette[slot] = value
}
}
if (!BASE_ANSI.every(slot => palette[slot])) {
return undefined
}
const foreground = hex('terminal.foreground')
const cursor = hex('terminalCursor.foreground') ?? hex('terminalCursor.background')
const selection = typeof colors['terminal.selectionBackground'] === 'string' ? colors['terminal.selectionBackground'].trim() : ''
if (foreground) {
palette.foreground = foreground
}
if (cursor) {
palette.cursor = cursor
}
if (HEX_RE.test(selection)) {
palette.selectionBackground = selection
}
return palette
}
/** First normalizable hex among `keys`, composited over `backdrop`. */
const pick = (
colors: Record<string, unknown>,
keys: string[],
backdrop: string
): { key: string; value: string } | null => {
for (const key of keys) {
const value = normalizeHex(typeof colors[key] === 'string' ? (colors[key] as string) : null, backdrop)
if (value) {
return { key, value }
}
}
return null
}
export function convertVscodeColorTheme(raw: VscodeColorTheme, opts: ConvertOptions = {}): ConvertResult {
const colors = raw.colors && typeof raw.colors === 'object' ? (raw.colors as Record<string, unknown>) : null
if (!colors) {
throw new Error('Theme has no "colors" map — not a VS Code color theme.')
}
const derived: string[] = []
// Background first: it's the backdrop every other token flattens alpha over.
const backgroundHit = pick(colors, ['editor.background', 'editorPane.background', 'editorGroup.background'], '#000000')
const dark = isDarkType(raw, backgroundHit?.value ?? '#1e1e1e')
const background = backgroundHit?.value ?? (dark ? '#1e1e1e' : '#ffffff')
if (!backgroundHit) {
derived.push('editor.background')
}
// `take` records a derived fallback when the theme omits the key.
const take = (keys: string[], fallback: string): string => {
const hit = pick(colors, keys, background)
if (hit) {
return hit.value
}
derived.push(keys[0])
return fallback
}
const foreground = take(['editor.foreground', 'foreground'], dark ? '#d4d4d4' : '#1f1f1f')
// Brand accent — the single most load-bearing token. Drives primary buttons,
// focus rings, the streaming cursor, active-session pills, and sidebar labels.
// Prefer the saturated "brand" tokens (button / link / badge) over focusBorder,
// which many themes set to a muted gray — picking it first made imported
// accents look like the desktop defaults. We enforce contrast below regardless.
const accentSource = take(
[
'button.background',
'textLink.activeForeground',
'textLink.foreground',
'activityBarBadge.background',
'badge.background',
'progressBar.background',
'pickerGroup.foreground',
'list.highlightForeground',
'editorLink.activeForeground',
'focusBorder',
'tab.activeBorder',
'statusBarItem.remoteBackground'
],
mix(foreground, background, 0.55)
)
const elevated = take(
['editorWidget.background', 'dropdown.background', 'menu.background', 'quickInput.background', 'editorSuggestWidget.background'],
mix(background, foreground, dark ? 0.08 : 0.05)
)
const card = take(
['sideBarSectionHeader.background', 'tab.inactiveBackground', 'editorGroupHeader.tabsBackground'],
mix(background, foreground, dark ? 0.04 : 0.025)
)
const sidebar = take(['sideBar.background', 'activityBar.background'], mix(background, foreground, dark ? 0.02 : 0.012))
// The accent labels the sidebar (--theme-primary), so guarantee it reads
// there — otherwise low-contrast brand colors leave invisible section headers.
const accent = ensureContrast(accentSource, sidebar, ACCENT_MIN_CONTRAST)
const border = take(
['panel.border', 'editorGroup.border', 'sideBar.border', 'contrastBorder', 'widget.border', 'input.border'],
mix(background, foreground, dark ? 0.16 : 0.14)
)
const input = take(['input.background', 'dropdown.background', 'quickInput.background'], mix(background, foreground, dark ? 0.1 : 0.06))
const mutedForeground = take(
['descriptionForeground', 'editorLineNumber.foreground', 'tab.inactiveForeground', 'disabledForeground'],
mix(foreground, background, 0.45)
)
const destructive = take(
['editorError.foreground', 'errorForeground', 'editorOverviewRuler.errorForeground', 'notificationsErrorIcon.foreground'],
'#e25563'
)
const muted = mix(background, foreground, dark ? 0.06 : 0.04)
const accentSoft = mix(accent, background, dark ? 0.82 : 0.88)
const secondary = mix(accent, background, dark ? 0.72 : 0.86)
const palette: DesktopThemeColors = {
background,
foreground,
card,
cardForeground: foreground,
muted,
mutedForeground,
popover: elevated,
popoverForeground: foreground,
primary: accent,
primaryForeground: readableOn(accent),
secondary,
secondaryForeground: foreground,
accent: accentSoft,
accentForeground: foreground,
border,
input,
ring: accent,
midground: accent,
midgroundForeground: readableOn(accent),
composerRing: accent,
destructive,
destructiveForeground: readableOn(destructive),
sidebarBackground: sidebar,
sidebarBorder: border,
userBubble: mix(card, accent, dark ? 0.18 : 0.12),
userBubbleBorder: border
}
const label = (opts.label ?? raw.name ?? 'VS Code Theme').trim()
const slug = opts.slug ?? vscodeThemeSlug(label)
const terminal = extractTerminalPalette(colors, background)
return {
derived,
mode: dark ? 'dark' : 'light',
theme: {
name: slug,
label,
description: opts.source ? `VS Code · ${opts.source}` : 'Imported from VS Code',
// Single palette in both slots. A lone VS Code theme is one-mode; callers
// that have both a light and dark variant (a Marketplace extension family)
// recombine them into proper colors/darkColors via buildThemeFromMarketplace.
colors: palette,
darkColors: palette,
// Only set when the theme ships a full ANSI palette — the terminal keeps
// its built-in VS Code defaults otherwise.
...(terminal ? { terminal } : {})
}
}
}