mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-28 11:32:22 +00:00
# Conflicts: # apps/desktop/electron/main.cjs # apps/desktop/src/app/command-palette/index.tsx # apps/desktop/src/themes/context.tsx
343 lines
12 KiB
TypeScript
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 } : {})
|
|
}
|
|
}
|
|
}
|