feat(desktop): install any VS Code theme from the Marketplace

Browse + install color themes from the VS Code Marketplace straight from
Cmd-K and Settings → Appearance. The Electron main process resolves the
extension, unzips the .vsix with a hand-rolled zip reader (zlib only, no
new deps), and hands back the raw theme JSON; the renderer converts it to
a DesktopTheme with a small seed → color-mix mapping.

- Folds an extension's light + dark variants into one theme family, so the
  light/dark toggle switches Solarized/GitHub variants and installing in
  dark mode stays dark.
- Guarantees accent contrast (WCAG AA) so imported sidebar labels read
  instead of vanishing into the surface.
- Filters icon/product-icon packs out of the Themes-category search.
- "Install theme…" lives atop the Cmd-K theme picker; imports fold into
  the Light/Dark groups by the modes they support.
This commit is contained in:
Brooklyn Nicholson
2026-06-09 23:06:44 -05:00
parent 5cf6e28a2f
commit 27a3211579
21 changed files with 1843 additions and 101 deletions
+93 -23
View File
@@ -17,6 +17,7 @@ import {
ChevronRight,
Clock,
Cpu,
Download,
Globe,
type IconComponent,
Info,
@@ -36,7 +37,9 @@ import {
} from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
import { luminance } from '@/themes/color'
import { type ThemeMode, useTheme } from '@/themes/context'
import { isUserTheme, resolveTheme } from '@/themes/user-themes'
import {
AGENTS_ROUTE,
@@ -54,6 +57,8 @@ import { FIELD_LABELS, SECTIONS } from '../settings/constants'
import { fieldCopyForSchemaKey } from '../settings/field-copy'
import { prettyName } from '../settings/helpers'
import { MarketplaceThemePage } from './marketplace-theme-page'
interface PaletteItem {
active?: boolean
icon: IconComponent
@@ -69,10 +74,16 @@ interface PaletteItem {
}
interface PaletteGroup {
heading: string
/** Optional: a headingless group renders as a bare action row (e.g. the
* "Install theme…" entry pinned atop the theme picker). */
heading?: string
items: PaletteItem[]
}
// Nested page → its parent, so Back / Esc step up one level instead of closing
// the palette. Pages absent here go straight back to the root list.
const PAGE_PARENTS: Record<string, string> = { 'install-theme': 'theme' }
/** A nested page reachable from a root item via `to`. */
interface PalettePage {
groups: PaletteGroup[]
@@ -146,6 +157,26 @@ const THEME_MODES: ReadonlyArray<{ icon: IconComponent; mode: ThemeMode }> = [
{ icon: Monitor, mode: 'system' }
]
// Which Light/Dark groups a theme belongs in. Built-ins render in both modes
// (the engine synthesises the missing side). Imported VS Code themes only carry
// the variant(s) the extension shipped — a single dark theme like Dracula lives
// under Dark only, while a GitHub/Solarized family (light + dark) lives in both.
function themeSupportsMode(name: string, target: 'light' | 'dark'): boolean {
if (!isUserTheme(name)) {
return true
}
const resolved = resolveTheme(name)
if (!resolved) {
return true
}
const background = target === 'dark' ? (resolved.darkColors ?? resolved.colors).background : resolved.colors.background
return target === 'dark' ? luminance(background) <= 0.5 : luminance(background) > 0.5
}
export function CommandPalette() {
const { t } = useI18n()
const open = useStore($commandPaletteOpen)
@@ -194,10 +225,19 @@ export function CommandPalette() {
}, [open])
const go = useCallback((path: string) => () => navigate(path), [navigate])
// Step up one nested page (or back to the root list), clearing the filter so
// the parent page doesn't reopen mid-search.
const goBack = useCallback(() => {
setSearch('')
setPage(prev => (prev ? (PAGE_PARENTS[prev] ?? null) : null))
}, [])
const settingsSectionLabel = useCallback(
(section: (typeof SECTIONS)[number]) => t.settings.sections[section.id] ?? section.label,
[t.settings.sections]
)
const configFieldLabel = useCallback(
(key: string) =>
fieldCopyForSchemaKey(t.settings.fieldLabels, key) ??
@@ -373,24 +413,43 @@ export function CommandPalette() {
theme: {
title: t.settings.appearance.themeTitle,
placeholder: t.settings.appearance.themeDesc,
// Skins aren't inherently light/dark — the same skin renders in either
// mode. Group by appearance so picking an entry sets skin + mode at
// once, and keep the palette open so each pick previews live.
groups: (['light', 'dark'] as const).map(groupMode => ({
heading: groupMode === 'light' ? t.settings.modeOptions.light.label : t.settings.modeOptions.dark.label,
items: availableThemes.map(theme => ({
active: themeName === theme.name && resolvedMode === groupMode,
icon: groupMode === 'light' ? Sun : Moon,
id: `theme-${theme.name}-${groupMode}`,
keepOpen: true,
keywords: ['theme', 'appearance', 'palette', groupMode, theme.label, theme.description ?? ''],
label: theme.label,
run: () => {
setTheme(theme.name)
setMode(groupMode)
}
groups: [
// Pinned at the top: drills into the Marketplace browser. Activating an
// import only sets the skin (never the mode), so the current light/dark
// preference is preserved.
{
items: [
{
icon: Download,
id: 'theme-install',
keywords: ['install', 'marketplace', 'vscode', 'vs code', 'download', 'new', 'color'],
label: t.commandCenter.installTheme.title,
to: 'install-theme'
}
]
},
// Built-ins and imported families both list under the mode(s) they
// support; picking one sets skin + mode at once. Keep the palette open
// to preview. A multi-variant import (GitHub, Solarized) appears in
// both groups and switches variants with the mode.
...(['light', 'dark'] as const).map(groupMode => ({
heading: groupMode === 'light' ? t.settings.modeOptions.light.label : t.settings.modeOptions.dark.label,
items: availableThemes
.filter(theme => themeSupportsMode(theme.name, groupMode))
.map(theme => ({
active: themeName === theme.name && resolvedMode === groupMode,
icon: groupMode === 'light' ? Sun : Moon,
id: `theme-${theme.name}-${groupMode}`,
keepOpen: true,
keywords: ['theme', 'appearance', 'palette', groupMode, theme.label, theme.description ?? ''],
label: theme.label,
run: () => {
setTheme(theme.name)
setMode(groupMode)
}
}))
}))
}))
]
},
'color-mode': {
title: t.settings.appearance.colorMode,
@@ -409,6 +468,13 @@ export function CommandPalette() {
}))
}
]
},
// Server-driven page: items come from the Marketplace, rendered by
// <MarketplaceThemePage> (loader + live search + per-row install).
'install-theme': {
title: t.commandCenter.installTheme.title,
placeholder: t.commandCenter.installTheme.placeholder,
groups: []
}
}),
[availableThemes, mode, resolvedMode, setMode, setTheme, t, themeName]
@@ -446,7 +512,7 @@ export function CommandPalette() {
{activePage && (
<button
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
onClick={() => setPage(null)}
onClick={goBack}
type="button"
>
<ChevronLeft className="size-3.5" />
@@ -466,7 +532,7 @@ export function CommandPalette() {
if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) {
event.preventDefault()
event.stopPropagation()
setPage(null)
goBack()
}
}}
onValueChange={setSearch}
@@ -474,12 +540,16 @@ export function CommandPalette() {
value={search}
/>
<CommandList className="max-h-[min(24rem,60vh)]">
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
{visibleGroups.map(group => (
{page === 'install-theme' ? (
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
) : (
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
)}
{visibleGroups.map((group, index) => (
<CommandGroup
className="**:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-wider **:[[cmdk-group-heading]]:text-[0.6875rem] **:[[cmdk-group-heading]]:text-muted-foreground/70"
heading={group.heading}
key={group.heading}
key={group.heading ?? `palette-group-${index}`}
>
{group.items.map(item => {
const Icon = item.icon
@@ -0,0 +1,154 @@
/**
* Cmd-K "Install theme…" page.
*
* Browses the VS Code Marketplace for color themes: an empty query shows the
* most-installed themes, typing runs a live (debounced) search against the
* Marketplace. Selecting a row downloads + converts + installs it via the same
* pipeline as the settings importer, then activates it — and stays open so the
* user can grab several.
*/
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import type { DesktopMarketplaceSearchItem } from '@/global'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Download, Loader2, Palette } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { installVscodeThemeFromMarketplace } from '@/themes/install'
const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 })
function useDebounced<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const handle = setTimeout(() => setDebounced(value), delayMs)
return () => clearTimeout(handle)
}, [value, delayMs])
return debounced
}
interface MarketplaceThemePageProps {
search: string
/** Activate a freshly installed theme by slug. */
onPickTheme: (name: string) => void
}
export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePageProps) {
const { t } = useI18n()
const copy = t.commandCenter.installTheme
const debouncedSearch = useDebounced(search.trim(), 300)
const [installingId, setInstallingId] = useState<string | null>(null)
const [installed, setInstalled] = useState<Record<string, true>>({})
const [installError, setInstallError] = useState<string | null>(null)
const query = useQuery({
queryKey: ['marketplace-themes', debouncedSearch],
queryFn: () => window.hermesDesktop?.themes?.searchMarketplace(debouncedSearch) ?? Promise.resolve([]),
staleTime: 5 * 60 * 1000
})
const install = async (item: DesktopMarketplaceSearchItem) => {
if (installingId) {
return
}
setInstallingId(item.extensionId)
setInstallError(null)
try {
const theme = await installVscodeThemeFromMarketplace(item.extensionId)
triggerHaptic('crisp')
setInstalled(prev => ({ ...prev, [item.extensionId]: true }))
onPickTheme(theme.name)
} catch (error) {
setInstallError(error instanceof Error ? error.message : copy.error)
} finally {
setInstallingId(null)
}
}
if (query.isLoading) {
return <Status icon={<Loader2 className="size-4 animate-spin" />} text={copy.loading} />
}
if (query.isError) {
return <Status text={copy.error} tone="error" />
}
const results = query.data ?? []
if (results.length === 0) {
return <Status text={copy.empty} />
}
return (
<div role="listbox">
{installError && (
<p className="px-3 pb-1.5 pt-2 text-[0.75rem] text-(--ui-red)">{installError}</p>
)}
{results.map(item => {
const busy = installingId === item.extensionId
const done = installed[item.extensionId]
return (
<button
className="flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-(--chrome-action-hover) disabled:opacity-60 aria-disabled:opacity-60"
disabled={Boolean(installingId) && !busy}
key={item.extensionId}
onClick={() => void install(item)}
onMouseDown={event => event.preventDefault()}
role="option"
type="button"
>
<Palette className="size-4 shrink-0 text-muted-foreground" />
<span className="flex min-w-0 flex-col">
<span className="truncate font-medium">{item.displayName}</span>
<span className="truncate text-[0.75rem] text-muted-foreground/80">
{item.publisher}
{item.installs > 0 ? ` · ${copy.installs(compactNumber.format(item.installs))}` : ''}
</span>
</span>
<span className="ml-auto flex shrink-0 items-center gap-1 text-[0.75rem] text-muted-foreground">
{busy ? (
<>
<Loader2 className="size-3.5 animate-spin" />
{copy.installing}
</>
) : done ? (
<>
<Check className="size-3.5 text-(--ui-green)" />
{copy.installed}
</>
) : (
<>
<Download className="size-3.5" />
{copy.install}
</>
)}
</span>
</button>
)
})}
</div>
)
}
function Status({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) {
return (
<div
className={cn(
'flex items-center justify-center gap-2 px-3 py-8 text-sm',
tone === 'error' ? 'text-(--ui-red)' : 'text-muted-foreground'
)}
>
{icon}
{text}
</div>
)
}
@@ -1,21 +1,23 @@
import { useStore } from '@nanostores/react'
import { useState } from 'react'
import { LanguageSwitcher } from '@/components/language-switcher'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Palette } from '@/lib/icons'
import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
import { useTheme } from '@/themes/context'
import { BUILTIN_THEMES } from '@/themes/presets'
import { installVscodeThemeFromMarketplace } from '@/themes/install'
import { isUserTheme, removeUserTheme, resolveTheme } from '@/themes/user-themes'
import { MODE_OPTIONS } from './constants'
import { ListRow, SectionHeading, SettingsContent } from './primitives'
function ThemePreview({ name }: { name: string }) {
const t = BUILTIN_THEMES[name]
const t = resolveTheme(name)
if (!t) {
return null
@@ -54,6 +56,81 @@ function ThemePreview({ name }: { name: string }) {
)
}
function VscodeThemeInstaller() {
const { t } = useI18n()
const { setTheme } = useTheme()
const a = t.settings.appearance
const [id, setId] = useState('')
const [busy, setBusy] = useState(false)
const [status, setStatus] = useState<{ kind: 'error' | 'success'; text: string } | null>(null)
const install = async () => {
const trimmed = id.trim()
if (!trimmed || busy) {
return
}
setBusy(true)
setStatus(null)
try {
const theme = await installVscodeThemeFromMarketplace(trimmed)
triggerHaptic('crisp')
setTheme(theme.name)
setStatus({ kind: 'success', text: a.installed(theme.label) })
setId('')
} catch (error) {
setStatus({ kind: 'error', text: error instanceof Error ? error.message : a.installError })
} finally {
setBusy(false)
}
}
return (
<div className="mt-3">
<div className="flex flex-wrap items-center gap-2">
<input
className="min-w-0 flex-1 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 font-mono text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
disabled={busy}
onChange={event => {
setId(event.target.value)
setStatus(null)
}}
onKeyDown={event => {
if (event.key === 'Enter') {
void install()
}
}}
placeholder={a.installPlaceholder}
spellCheck={false}
value={id}
/>
<button
className="inline-flex items-center gap-1.5 rounded-lg border border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] font-medium transition hover:bg-(--chrome-action-hover) disabled:opacity-50"
disabled={busy || !id.trim()}
onClick={() => void install()}
type="button"
>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
{busy ? a.installing : a.installButton}
</button>
</div>
{status && (
<p
className={cn(
'mt-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height)',
status.kind === 'error' ? 'text-(--ui-red)' : 'text-(--ui-text-tertiary)'
)}
>
{status.text}
</p>
)}
</div>
)
}
export function AppearanceSettings() {
const { t, isSavingLocale } = useI18n()
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
@@ -112,40 +189,62 @@ export function AppearanceSettings() {
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{availableThemes.map(theme => {
const active = themeName === theme.name
const removable = isUserTheme(theme.name)
return (
<button
className={cn(
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={theme.name}
onClick={() => {
triggerHaptic('crisp')
setTheme(theme.name)
}}
type="button"
>
<ThemePreview name={theme.name} />
<div className="mt-3 flex items-start justify-between gap-3 px-1">
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label}
</div>
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{theme.description}
</div>
</div>
{active && (
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
<div className="group relative" key={theme.name}>
<button
className={cn(
'w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
</div>
</button>
onClick={() => {
triggerHaptic('crisp')
setTheme(theme.name)
}}
type="button"
>
<ThemePreview name={theme.name} />
<div className="mt-3 flex items-start justify-between gap-3 px-1">
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label}
</div>
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{theme.description}
</div>
</div>
{active && (
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
</button>
{removable && (
<button
aria-label={a.removeTheme}
className="absolute right-1.5 top-1.5 grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) opacity-0 backdrop-blur-sm transition hover:text-(--ui-red) focus-visible:opacity-100 group-hover:opacity-100"
onClick={() => {
triggerHaptic('crisp')
removeUserTheme(theme.name)
// Re-normalize off the now-missing skin → default.
if (active) {
setTheme(theme.name)
}
}}
title={a.removeTheme}
type="button"
>
<Trash2 className="size-3.5" />
</button>
)}
</div>
)
})}
</div>
<VscodeThemeInstaller />
{showProfileNote && (
<p className="mt-3 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{a.themeProfileNote(activeProfileName)}