diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 019b16a9a68..2772295cab7 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -30,6 +30,7 @@ const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./sessio const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs') const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs') +const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs') const { buildPosixCleanupScript, buildWindowsCleanupScript, @@ -6128,6 +6129,13 @@ ipcMain.handle('hermes:uninstall:run', async (_event, payload) => { return runDesktopUninstall(String(mode || '')) }) +// Download a VS Code Marketplace extension and return the raw color-theme JSON +// it contributes. No theme code is executed — we only read JSON from the .vsix. +ipcMain.handle('hermes:vscode-theme:fetch', async (_event, id) => fetchMarketplaceThemes(String(id || ''))) + +// Search the Marketplace for color-theme extensions (empty query = top installs). +ipcMain.handle('hermes:vscode-theme:search', async (_event, query) => searchMarketplaceThemes(String(query || ''), 20)) + app.whenReady().then(() => { if (IS_MAC) { Menu.setApplicationMenu(buildApplicationMenu()) diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index c981d0437b1..d39bc88fb68 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -134,5 +134,9 @@ contextBridge.exposeInMainWorld('hermesDesktop', { ipcRenderer.on('hermes:updates:progress', listener) return () => ipcRenderer.removeListener('hermes:updates:progress', listener) } + }, + themes: { + fetchMarketplace: id => ipcRenderer.invoke('hermes:vscode-theme:fetch', id), + searchMarketplace: query => ipcRenderer.invoke('hermes:vscode-theme:search', query) } }) diff --git a/apps/desktop/electron/vscode-marketplace.cjs b/apps/desktop/electron/vscode-marketplace.cjs new file mode 100644 index 00000000000..829182a1f0f --- /dev/null +++ b/apps/desktop/electron/vscode-marketplace.cjs @@ -0,0 +1,331 @@ +'use strict' + +/** + * VS Code Marketplace color-theme fetcher (main process). + * + * Resolves an extension's latest version via the (undocumented but stable) + * gallery ExtensionQuery API, downloads the `.vsix` (a zip), and extracts the + * color-theme JSON files it contributes. No theme code is ever executed — we + * only read `package.json` + the referenced `*.json` theme files out of the + * archive and hand their text back to the renderer to convert. + * + * Dependency-free on purpose: a `.vsix` is a plain zip, so we parse the central + * directory and inflate just the entries we need with `zlib`. Avoids pulling a + * zip library into the desktop bundle for a feature this small. + */ + +const https = require('node:https') +const zlib = require('node:zlib') + +const GALLERY_QUERY_URL = 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery' +const VSIX_ASSET_TYPE = 'Microsoft.VisualStudio.Services.VSIXPackage' +const MAX_VSIX_BYTES = 40 * 1024 * 1024 // 40 MB — themes are tiny; this is paranoia. +const MAX_REDIRECTS = 5 +const REQUEST_TIMEOUT_MS = 20_000 + +const ID_RE = /^[\w-]+\.[\w-]+$/ + +/** Minimal HTTPS helper with redirect-following, timeout, and a size cap. */ +function request(url, { method = 'GET', headers = {}, body = null, maxBytes = MAX_VSIX_BYTES } = {}, redirectsLeft = MAX_REDIRECTS) { + return new Promise((resolve, reject) => { + const req = https.request(url, { method, headers }, res => { + const status = res.statusCode ?? 0 + + if (status >= 300 && status < 400 && res.headers.location) { + if (redirectsLeft <= 0) { + res.resume() + reject(new Error('Too many redirects.')) + + return + } + + const next = new URL(res.headers.location, url).toString() + res.resume() + // Redirects to the CDN are plain GETs (drop the POST body). + resolve(request(next, { method: 'GET', headers: { 'User-Agent': headers['User-Agent'] }, maxBytes }, redirectsLeft - 1)) + + return + } + + if (status < 200 || status >= 300) { + res.resume() + reject(new Error(`Request failed (${status}) for ${url}`)) + + return + } + + const chunks = [] + let total = 0 + + res.on('data', chunk => { + total += chunk.length + + if (total > maxBytes) { + req.destroy() + reject(new Error('Response exceeded the size limit.')) + + return + } + + chunks.push(chunk) + }) + res.on('end', () => resolve(Buffer.concat(chunks))) + }) + + req.on('error', reject) + req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error('Request timed out.'))) + + if (body) { + req.write(body) + } + + req.end() + }) +} + +/** Resolve `{ displayName, vsixUrl }` for the latest version of `id`. */ +async function resolveExtension(id) { + const json = await queryGallery({ + // FilterType 7 = ExtensionName (the full publisher.extension id). + filters: [{ criteria: [{ filterType: 7, value: id }], pageNumber: 1, pageSize: 1 }], + // Flags: IncludeFiles | IncludeVersionProperties | IncludeAssetUri | + // IncludeCategoryAndTags | IncludeLatestVersionOnly = 914. + flags: 914 + }) + const extension = json?.results?.[0]?.extensions?.[0] + + if (!extension) { + throw new Error(`Extension "${id}" was not found on the Marketplace.`) + } + + const version = extension.versions?.[0] + + if (!version) { + throw new Error(`Extension "${id}" has no published versions.`) + } + + const asset = (version.files ?? []).find(file => file.assetType === VSIX_ASSET_TYPE) + const vsixUrl = asset?.source + + if (!vsixUrl) { + throw new Error(`Could not find a downloadable package for "${id}".`) + } + + return { displayName: extension.displayName || id, vsixUrl } +} + +/** POST an ExtensionQuery payload and return the parsed gallery response. */ +async function queryGallery(payload, { maxBytes = 4 * 1024 * 1024 } = {}) { + const body = JSON.stringify(payload) + const raw = await request(GALLERY_QUERY_URL, { + method: 'POST', + headers: { + Accept: 'application/json;api-version=3.0-preview.1', + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + 'User-Agent': 'Hermes-Desktop' + }, + body, + maxBytes + }) + + return JSON.parse(raw.toString('utf8')) +} + +/** + * Search the Marketplace for color-theme extensions. With an empty query this + * returns the most-installed themes; with a query it's a full-text search + * scoped to the Themes category. Returns lightweight cards (no download). + */ +/** + * The "Themes" category also contains file-icon and product-icon themes (the + * gallery has no color-only category). We can't see an extension's actual + * contributions without downloading it, so filter the obvious icon packs out by + * tag + name/description. Color themes that also ship icons are rare; worst case + * a user installs them by exact id from settings. + */ +function looksLikeIconTheme(extension) { + const tags = (extension.tags ?? []).map(tag => String(tag).toLowerCase()) + + if (tags.includes('icon-theme') || tags.includes('product-icon-theme')) { + return true + } + + const text = `${extension.displayName ?? ''} ${extension.shortDescription ?? ''}`.toLowerCase() + + return /\b(icon theme|file icons?|product icons?|icon pack|fileicons)\b/.test(text) +} + +async function searchMarketplaceThemes(query, limit = 20) { + const text = String(query || '').trim() + const pageSize = Math.min(Math.max(Number(limit) || 20, 1), 50) + + // FilterType: 8=Target, 5=Category, 10=SearchText, 12=ExcludeWithFlags. + const criteria = [ + { filterType: 8, value: 'Microsoft.VisualStudio.Code' }, + { filterType: 5, value: 'Themes' }, + { filterType: 12, value: '4096' } // Exclude unpublished (Unpublished = 0x1000). + ] + + if (text) { + criteria.push({ filterType: 10, value: text }) + } + + const json = await queryGallery({ + // Over-fetch so the icon-theme filter below still leaves a full page. + filters: [{ criteria, pageNumber: 1, pageSize: Math.min(pageSize * 2, 50), sortBy: 4, sortOrder: 0 }], + // IncludeStatistics (0x100) | IncludeLatestVersionOnly (0x200) | IncludeCategoryAndTags (0x4). + flags: 772 + }) + + const extensions = json?.results?.[0]?.extensions ?? [] + + return extensions + .filter(extension => !looksLikeIconTheme(extension)) + .slice(0, pageSize) + .map(extension => { + const publisherName = extension.publisher?.publisherName ?? '' + const installStat = (extension.statistics ?? []).find(stat => stat.statisticName === 'install') + + return { + extensionId: `${publisherName}.${extension.extensionName}`, + displayName: extension.displayName || extension.extensionName, + publisher: extension.publisher?.displayName || publisherName, + description: extension.shortDescription || '', + installs: Math.round(installStat?.value ?? 0) + } + }) +} + +// ─── Minimal zip reader ───────────────────────────────────────────────────── + +function findEndOfCentralDirectory(buf) { + // EOCD signature 0x06054b50, scanning back from the end (comment is rare). + for (let i = buf.length - 22; i >= 0; i--) { + if (buf.readUInt32LE(i) === 0x06054b50) { + return i + } + } + + throw new Error('Not a valid zip archive (no end-of-central-directory).') +} + +/** Parse the central directory into a name → record map. */ +function readCentralDirectory(buf) { + const eocd = findEndOfCentralDirectory(buf) + const count = buf.readUInt16LE(eocd + 10) + let offset = buf.readUInt32LE(eocd + 16) + const records = new Map() + + for (let i = 0; i < count; i++) { + if (buf.readUInt32LE(offset) !== 0x02014b50) { + break + } + + const method = buf.readUInt16LE(offset + 10) + const compressedSize = buf.readUInt32LE(offset + 20) + const nameLen = buf.readUInt16LE(offset + 28) + const extraLen = buf.readUInt16LE(offset + 30) + const commentLen = buf.readUInt16LE(offset + 32) + const localOffset = buf.readUInt32LE(offset + 42) + const name = buf.toString('utf8', offset + 46, offset + 46 + nameLen) + + records.set(name, { method, compressedSize, localOffset }) + offset += 46 + nameLen + extraLen + commentLen + } + + return records +} + +/** Inflate a single entry to a string. */ +function extractEntry(buf, record) { + // The local header's name/extra lengths can differ from the central record, + // so re-read them here to locate the compressed payload. + if (buf.readUInt32LE(record.localOffset) !== 0x04034b50) { + throw new Error('Corrupt zip: bad local file header.') + } + + const nameLen = buf.readUInt16LE(record.localOffset + 26) + const extraLen = buf.readUInt16LE(record.localOffset + 28) + const dataStart = record.localOffset + 30 + nameLen + extraLen + const data = buf.subarray(dataStart, dataStart + record.compressedSize) + + // 0 = stored, 8 = deflate. Theme files are one or the other. + return record.method === 0 ? data.toString('utf8') : zlib.inflateRawSync(data).toString('utf8') +} + +/** Normalize a package.json theme path to its zip entry name. */ +function themeEntryName(themePath) { + const clean = String(themePath).replace(/^\.\//, '').replace(/^\//, '') + + return `extension/${clean}` +} + +/** Extract every contributed color theme from a `.vsix` buffer. */ +function extractThemes(vsixBuffer) { + const records = readCentralDirectory(vsixBuffer) + const pkgRecord = records.get('extension/package.json') + + if (!pkgRecord) { + throw new Error('Package manifest missing from the extension.') + } + + const pkg = JSON.parse(extractEntry(vsixBuffer, pkgRecord)) + const contributed = pkg?.contributes?.themes + + if (!Array.isArray(contributed) || contributed.length === 0) { + return [] + } + + const themes = [] + + for (const entry of contributed) { + if (!entry?.path) { + continue + } + + const record = records.get(themeEntryName(entry.path)) + + if (!record) { + continue + } + + try { + themes.push({ + label: entry.label || entry.id || pkg.displayName || pkg.name || 'VS Code Theme', + uiTheme: entry.uiTheme, + contents: extractEntry(vsixBuffer, record) + }) + } catch { + // Skip an entry we can't inflate rather than failing the whole install. + } + } + + return themes +} + +/** + * Public entry: resolve, download, and extract color themes for `id` + * (`publisher.extension`). Returns `{ extensionId, displayName, themes }`. + */ +async function fetchMarketplaceThemes(id) { + const trimmed = String(id || '').trim() + + if (!ID_RE.test(trimmed)) { + throw new Error('Expected a Marketplace id like "publisher.extension".') + } + + const { displayName, vsixUrl } = await resolveExtension(trimmed) + const vsix = await request(vsixUrl, { headers: { 'User-Agent': 'Hermes-Desktop' } }) + const themes = extractThemes(vsix) + + return { extensionId: trimmed, displayName, themes } +} + +module.exports = { + fetchMarketplaceThemes, + searchMarketplaceThemes, + extractThemes, + readCentralDirectory, + __testing: { themeEntryName, looksLikeIconTheme } +} diff --git a/apps/desktop/electron/vscode-marketplace.test.cjs b/apps/desktop/electron/vscode-marketplace.test.cjs new file mode 100644 index 00000000000..45169044bfa --- /dev/null +++ b/apps/desktop/electron/vscode-marketplace.test.cjs @@ -0,0 +1,113 @@ +'use strict' + +const assert = require('node:assert') +const test = require('node:test') + +const { __testing, extractThemes, readCentralDirectory } = require('./vscode-marketplace.cjs') + +// Build a minimal zip with stored (uncompressed) entries so the test controls +// the bytes exactly — exercises the central-directory reader + theme extraction +// without a deflate dependency. +function makeZip(entries) { + const locals = [] + const centrals = [] + let offset = 0 + + for (const { name, data } of entries) { + const nameBuf = Buffer.from(name, 'utf8') + const body = Buffer.from(data, 'utf8') + + const local = Buffer.alloc(30 + nameBuf.length) + local.writeUInt32LE(0x04034b50, 0) + local.writeUInt16LE(0, 8) // method: stored + local.writeUInt32LE(body.length, 18) // compressed size + local.writeUInt32LE(body.length, 22) // uncompressed size + local.writeUInt16LE(nameBuf.length, 26) + nameBuf.copy(local, 30) + + locals.push(local, body) + + const central = Buffer.alloc(46 + nameBuf.length) + central.writeUInt32LE(0x02014b50, 0) + central.writeUInt16LE(0, 10) // method: stored + central.writeUInt32LE(body.length, 20) + central.writeUInt32LE(body.length, 24) + central.writeUInt16LE(nameBuf.length, 28) + central.writeUInt32LE(offset, 42) // local header offset + nameBuf.copy(central, 46) + + centrals.push(central) + offset += local.length + body.length + } + + const centralStart = offset + const centralBuf = Buffer.concat(centrals) + + const eocd = Buffer.alloc(22) + eocd.writeUInt32LE(0x06054b50, 0) + eocd.writeUInt16LE(entries.length, 8) + eocd.writeUInt16LE(entries.length, 10) + eocd.writeUInt32LE(centralBuf.length, 12) + eocd.writeUInt32LE(centralStart, 16) + + return Buffer.concat([...locals, centralBuf, eocd]) +} + +test('readCentralDirectory finds every entry', () => { + const zip = makeZip([ + { name: 'extension/package.json', data: '{}' }, + { name: 'extension/themes/x.json', data: '{}' } + ]) + + const records = readCentralDirectory(zip) + assert.ok(records.has('extension/package.json')) + assert.ok(records.has('extension/themes/x.json')) +}) + +test('extractThemes reads contributed color themes (resolving ./ paths)', () => { + const pkg = JSON.stringify({ + name: 'theme-dracula', + displayName: 'Dracula', + contributes: { + themes: [{ label: 'Dracula', uiTheme: 'vs-dark', path: './themes/dracula.json' }] + } + }) + const themeJson = JSON.stringify({ name: 'Dracula', type: 'dark', colors: { 'editor.background': '#282a36' } }) + + const zip = makeZip([ + { name: 'extension/package.json', data: pkg }, + { name: 'extension/themes/dracula.json', data: themeJson } + ]) + + const themes = extractThemes(zip) + assert.strictEqual(themes.length, 1) + assert.strictEqual(themes[0].label, 'Dracula') + assert.strictEqual(themes[0].uiTheme, 'vs-dark') + assert.match(themes[0].contents, /editor\.background/) +}) + +test('extractThemes returns empty when the extension contributes no themes', () => { + const zip = makeZip([{ name: 'extension/package.json', data: JSON.stringify({ name: 'x', contributes: {} }) }]) + assert.deepStrictEqual(extractThemes(zip), []) +}) + +test('extractThemes throws when the manifest is missing', () => { + const zip = makeZip([{ name: 'extension/other.txt', data: 'hi' }]) + assert.throws(() => extractThemes(zip), /manifest missing/i) +}) + +test('looksLikeIconTheme filters icon/product-icon packs out of theme search', () => { + const { looksLikeIconTheme } = __testing + + // Tagged contribution points are the strongest signal. + assert.strictEqual(looksLikeIconTheme({ tags: ['theme', 'icon-theme'] }), true) + assert.strictEqual(looksLikeIconTheme({ tags: ['product-icon-theme'] }), true) + + // Name/description fallback for packs that don't tag themselves. + assert.strictEqual(looksLikeIconTheme({ displayName: 'Material Icon Theme' }), true) + assert.strictEqual(looksLikeIconTheme({ shortDescription: 'A pack of file icons.' }), true) + + // Real color themes survive. + assert.strictEqual(looksLikeIconTheme({ displayName: 'Dracula Official', tags: ['theme', 'color-theme'] }), false) + assert.strictEqual(looksLikeIconTheme({ displayName: 'One Dark Pro' }), false) +}) diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 82fb7c7fad8..3872d24d5f9 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -4,6 +4,7 @@ import { Dialog as DialogPrimitive } from 'radix-ui' import { useCallback, useEffect, useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' +import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud' import { setTerminalTakeover } from '@/app/right-sidebar/store' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' import { KbdGroup } from '@/components/ui/kbd' @@ -18,6 +19,7 @@ import { ChevronRight, Clock, Cpu, + Download, Globe, type IconComponent, Info, @@ -40,7 +42,9 @@ import { comboTokens } from '@/lib/keybinds/combo' import { cn } from '@/lib/utils' import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette' import { $bindings } from '@/store/keybinds' +import { luminance } from '@/themes/color' import { type ThemeMode, useTheme } from '@/themes/context' +import { isUserTheme, resolveTheme } from '@/themes/user-themes' import { AGENTS_ROUTE, @@ -58,6 +62,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 { /** Keybind action id — its live combo renders as a hotkey hint. */ action?: string @@ -74,10 +80,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 = { 'install-theme': 'theme' } + /** A nested page reachable from a root item via `to`. */ interface PalettePage { groups: PaletteGroup[] @@ -167,12 +179,32 @@ 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) const bindings = useStore($bindings) const navigate = useNavigate() - const { availableThemes, setMode, setTheme } = useTheme() + const { availableThemes, resolvedMode, setMode, setTheme, themeName } = useTheme() const [search, setSearch] = useState('') const [page, setPage] = useState(null) @@ -217,6 +249,13 @@ export function CommandPalette() { 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] @@ -438,23 +477,40 @@ 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 => ({ - 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. + { + 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 list under the mode(s) they support; + // picking sets skin + mode at once. 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, @@ -472,9 +528,16 @@ export function CommandPalette() { })) } ] + }, + // Server-driven page: items come from the Marketplace, rendered by + // (loader + live search + per-row install). + 'install-theme': { + title: t.commandCenter.installTheme.title, + placeholder: t.commandCenter.installTheme.placeholder, + groups: [] } }), - [availableThemes, setMode, setTheme, t] + [availableThemes, resolvedMode, setMode, setTheme, t, themeName] ) const activePage = page ? subPages[page] : null @@ -499,17 +562,22 @@ export function CommandPalette() { return ( - + {/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */} + {t.commandCenter.paletteTitle} {activePage && ( )} { if (!activePage) { return @@ -529,20 +598,24 @@ export function CommandPalette() { if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) { event.preventDefault() event.stopPropagation() - setPage(null) + goBack() } }} onValueChange={setSearch} placeholder={placeholder} value={search} /> - - {t.commandCenter.noResults} - {visibleGroups.map(group => ( + + {page === 'install-theme' ? ( + + ) : ( + {t.commandCenter.noResults} + )} + {visibleGroups.map((group, index) => ( {group.items.map(item => { const Icon = item.icon @@ -551,18 +624,18 @@ export function CommandPalette() { return ( handleSelect(item)} value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`} > - + {item.label} {keys && } {item.to && ( )} diff --git a/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx b/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx new file mode 100644 index 00000000000..eb175fdcb72 --- /dev/null +++ b/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx @@ -0,0 +1,157 @@ +/** + * 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 { HUD_ITEM, HUD_TEXT } from '@/app/floating-hud' +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(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(null) + const [installed, setInstalled] = useState>({}) + const [installError, setInstallError] = useState(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 } text={copy.loading} /> + } + + if (query.isError) { + return + } + + const results = query.data ?? [] + + if (results.length === 0) { + return + } + + return ( +
+ {installError &&

{installError}

} + {results.map(item => { + const busy = installingId === item.extensionId + const done = installed[item.extensionId] + + return ( + + ) + })} +
+ ) +} + +function Status({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) { + return ( +
+ {icon} + {text} +
+ ) +} diff --git a/apps/desktop/src/app/floating-hud.ts b/apps/desktop/src/app/floating-hud.ts new file mode 100644 index 00000000000..1c499b4a08a --- /dev/null +++ b/apps/desktop/src/app/floating-hud.ts @@ -0,0 +1,22 @@ +// Shared chrome for the top-center floating HUDs (command palette + session +// switcher). They pin just under the title bar, centered, and lean on a crisp +// border + shadow to separate from the app — no dimming/blurring backdrop. +// Each caller layers on its own z-index, width, and overflow. +export const HUD_POSITION = 'fixed left-1/2 top-3 -translate-x-1/2' + +// Matches the app's borderless-overlay surface (dialog, keybind panel, …): +// hairline `--stroke-nous` paired with the soft `--shadow-nous` float. +export const HUD_SURFACE = 'rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous' + +// One row/text size for both HUDs (compact — two notches under `text-sm`). +export const HUD_TEXT = 'text-xs' + +// Shared item layout + padding for both HUDs. Tight vertical rhythm so rows +// don't feel chunky; overrides the shadcn `CommandItem` default (`px-2 py-1.5`). +export const HUD_ITEM = 'gap-2 px-2 py-1' + +// Section headings styled like the sidebar panel labels: brand-tinted, uppercase, +// tightly tracked — plain text, no sticky chrome bar. Targets the cmdk group +// heading via the universal-descendant variant. +export const HUD_HEADING = + '**:[[cmdk-group-heading]]:static **:[[cmdk-group-heading]]:bg-transparent **:[[cmdk-group-heading]]:px-2.5 **:[[cmdk-group-heading]]:pb-1 **:[[cmdk-group-heading]]:pt-2.5 **:[[cmdk-group-heading]]:text-[0.64rem] **:[[cmdk-group-heading]]:font-semibold **:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-[0.16em] **:[[cmdk-group-heading]]:text-(--theme-primary)' diff --git a/apps/desktop/src/app/right-sidebar/terminal/selection.ts b/apps/desktop/src/app/right-sidebar/terminal/selection.ts index 04be824b11b..955a9ea1f18 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/selection.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/selection.ts @@ -1,6 +1,8 @@ import type { ITheme, Terminal } from '@xterm/xterm' import type { CSSProperties } from 'react' +import type { DesktopTerminalPalette } from '@/themes/types' + // VS Code's default integrated-terminal palette (terminalColorRegistry.ts) — a // fixed table per theme type, not luminance-derived. Light/dark diverge on // purpose so each stays legible (e.g. mustard yellow on white). @@ -52,9 +54,29 @@ const LIGHT_THEME: ITheme = { brightWhite: '#a5a5a5' } -// Palette by painted mode. `background` is only a fallback — withSurface swaps -// in the live skin surface at runtime; minimumContrastRatio keeps colors crisp. -export const terminalTheme = (mode: 'light' | 'dark'): ITheme => (mode === 'dark' ? DARK_THEME : LIGHT_THEME) +// Palette by painted mode, optionally overlaid with an imported theme's ANSI +// palette (Solarized terminal for the Solarized skin, etc.). `palette` only +// fills the slots it defines, so a partial import keeps the mode defaults for +// the rest. `background` is a fallback only — withSurface swaps in the live skin +// surface at runtime (keeping transparency); minimumContrastRatio keeps colors +// crisp against it. +export function terminalTheme(mode: 'light' | 'dark', palette?: DesktopTerminalPalette): ITheme { + const base = mode === 'dark' ? DARK_THEME : LIGHT_THEME + + if (!palette) { + return base + } + + const overlay = { ...base } as Record + + for (const [slot, value] of Object.entries(palette)) { + if (value) { + overlay[slot] = value + } + } + + return overlay as ITheme +} // Resolve --ui-editor-surface-background (a color-mix on the skin seed) to a // concrete rgb for the WebGL renderer + contrast clamp. Custom props don't diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts index ae272e68b77..1e0b5f93134 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts @@ -223,8 +223,13 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes // clicked switch) — a skin can keep a light surface in "dark" mode, and we // must match the surface or the ANSI palette inverts against it. themeName // re-resolves the canvas surface on skin switches (same mode, new tint). - const { renderedMode, themeName } = useTheme() - const activeTheme = useMemo(() => terminalTheme(renderedMode), [renderedMode]) + const { renderedMode, theme, themeName } = useTheme() + // Adopt the skin's ANSI palette when it ships one (imported VS Code themes do), + // matched to the painted variant; built-in skins carry none, so the terminal + // keeps its VS Code defaults. withSurface still owns the background, so this + // never touches transparency. + const ansiPalette = renderedMode === 'dark' ? (theme.darkTerminal ?? theme.terminal) : theme.terminal + const activeTheme = useMemo(() => terminalTheme(renderedMode, ansiPalette), [renderedMode, ansiPalette]) const initialThemeRef = useRef(activeTheme) const hostRef = useRef(null) const termRef = useRef(null) diff --git a/apps/desktop/src/app/session-switcher.tsx b/apps/desktop/src/app/session-switcher.tsx index fe4bf8e9236..c2e272f173a 100644 --- a/apps/desktop/src/app/session-switcher.tsx +++ b/apps/desktop/src/app/session-switcher.tsx @@ -8,6 +8,7 @@ import { cn } from '@/lib/utils' import { $attentionSessionIds, $workingSessionIds } from '@/store/session' import { $switcherIndex, $switcherOpen, $switcherSessions, closeSwitcher } from '@/store/session-switcher' +import { HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from './floating-hud' import { sessionRoute } from './routes' // Compact session-switcher HUD — keyboard-driven from `use-keybinds`, rows @@ -39,22 +40,31 @@ export function SessionSwitcher() { } return createPortal( -
+ <> + {/* Transparent click-catcher: click-away closes, but no dim/blur. */}
{ e.preventDefault() closeSwitcher() }} /> -
+
{sessions.map((session, i) => { const selected = i === index return (
-
, + , document.body ) } diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index ae145c8c612..c4cb31c0c01 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -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 ( +
+
+ { + setId(event.target.value) + setStatus(null) + }} + onKeyDown={event => { + if (event.key === 'Enter') { + void install() + } + }} + placeholder={a.installPlaceholder} + spellCheck={false} + value={id} + /> + +
+ {status && ( +

+ {status.text} +

+ )} +
+ ) +} + export function AppearanceSettings() { const { t, isSavingLocale } = useI18n() const { themeName, mode, availableThemes, setTheme, setMode } = useTheme() @@ -112,40 +189,62 @@ export function AppearanceSettings() {
{availableThemes.map(theme => { const active = themeName === theme.name + const removable = isUserTheme(theme.name) return ( - + onClick={() => { + triggerHaptic('crisp') + setTheme(theme.name) + }} + type="button" + > + +
+
+
+ {theme.label} +
+
+ {theme.description} +
+
+ {active && ( + + + + )} +
+ + {removable && ( + + )} +
) })}
+ {showProfileNote && (

{a.themeProfileNote(activeProfileName)} diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 04b22cc2e63..68e104212e9 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -97,10 +97,40 @@ declare global { summary: () => Promise run: (mode: DesktopUninstallMode) => Promise } + themes: { + // Download a VS Code Marketplace extension and return the raw color + // theme files it contributes. The renderer converts + persists them. + fetchMarketplace: (id: string) => Promise + // Search the Marketplace for color-theme extensions. An empty query + // returns the most-installed themes. + searchMarketplace: (query: string) => Promise + } } } } +export interface DesktopMarketplaceSearchItem { + extensionId: string + displayName: string + publisher: string + description: string + installs: number +} + +export interface DesktopMarketplaceThemeFile { + label: string + /** VS Code's `uiTheme` for this entry (vs-dark / vs / hc-black). */ + uiTheme?: string + /** Raw theme JSON (JSONC) text, parsed + converted by the renderer. */ + contents: string +} + +export interface DesktopMarketplaceThemeResult { + extensionId: string + displayName: string + themes: DesktopMarketplaceThemeFile[] +} + export interface HermesTerminalSession { cwd: string id: string diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 619402ae50a..5aaf090d7e6 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -302,7 +302,17 @@ export const en: Translations = { technicalDesc: 'Include raw tool args/results and low-level details.', themeTitle: 'Theme', themeDesc: 'Desktop palettes only. The selected mode is applied on top.', - themeProfileNote: profile => `Saved for the ${profile} profile — each profile keeps its own theme.` + themeProfileNote: profile => `Saved for the ${profile} profile — each profile keeps its own theme.`, + installTitle: 'Install from VS Code', + installDesc: + 'Paste a Marketplace extension id (e.g. dracula-theme.theme-dracula) to convert its color theme into a desktop palette.', + installPlaceholder: 'publisher.extension', + installButton: 'Install', + installing: 'Installing…', + installError: 'Could not install that theme.', + installed: name => `Installed “${name}”.`, + removeTheme: 'Remove theme', + importedBadge: 'Imported' }, fieldLabels: FIELD_LABELS, fieldDescriptions: FIELD_DESCRIPTIONS, @@ -636,6 +646,17 @@ export const en: Translations = { settings: 'Settings', changeTheme: 'Change theme...', changeColorMode: 'Change color mode...', + installTheme: { + title: 'Install theme...', + placeholder: 'Search the VS Code Marketplace...', + loading: 'Searching the Marketplace...', + error: 'Could not reach the Marketplace.', + empty: 'No matching themes.', + install: 'Install', + installing: 'Installing...', + installed: 'Installed', + installs: count => `${count} installs` + }, settingsFields: 'Settings fields', mcpServers: 'MCP servers', archivedChats: 'Archived chats', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 37c3bca878c..956788067ed 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -216,7 +216,16 @@ export const ja = defineLocale({ technicalDesc: '生のツール引数、結果、低レベルの詳細を含めます。', themeTitle: 'テーマ', themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。', - themeProfileNote: profile => `「${profile}」プロファイルに保存されます。プロファイルごとに個別のテーマを保持します。` + themeProfileNote: profile => `「${profile}」プロファイルに保存されます。プロファイルごとに個別のテーマを保持します。`, + installTitle: 'VS Code から導入', + installDesc: 'Marketplace の拡張機能 ID(例: dracula-theme.theme-dracula)を貼り付けると、その配色テーマをデスクトップ用パレットに変換します。', + installPlaceholder: 'publisher.extension', + installButton: 'インストール', + installing: 'インストール中…', + installError: 'そのテーマをインストールできませんでした。', + installed: name => `「${name}」をインストールしました。`, + removeTheme: 'テーマを削除', + importedBadge: 'インポート済み' }, fieldLabels: defineFieldCopy({ model: 'デフォルトモデル', @@ -762,6 +771,17 @@ export const ja = defineLocale({ settings: '設定', changeTheme: 'テーマを変更...', changeColorMode: 'カラーモードを変更...', + installTheme: { + title: 'テーマをインストール...', + placeholder: 'VS Code Marketplace を検索...', + loading: 'Marketplace を検索中...', + error: 'Marketplace に接続できませんでした。', + empty: '一致するテーマがありません。', + install: 'インストール', + installing: 'インストール中...', + installed: 'インストール済み', + installs: count => `${count} 回インストール` + }, settingsFields: '設定フィールド', mcpServers: 'MCP サーバー', archivedChats: 'アーカイブ済みチャット', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 8206a80f9bd..77424e426ac 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -220,6 +220,15 @@ export interface Translations { themeTitle: string themeDesc: string themeProfileNote: (profile: string) => string + installTitle: string + installDesc: string + installPlaceholder: string + installButton: string + installing: string + installError: string + installed: (name: string) => string + removeTheme: string + importedBadge: string } fieldLabels: Record fieldDescriptions: Record @@ -534,6 +543,17 @@ export interface Translations { settings: string changeTheme: string changeColorMode: string + installTheme: { + title: string + placeholder: string + loading: string + error: string + empty: string + install: string + installing: string + installed: string + installs: (count: string) => string + } settingsFields: string mcpServers: string archivedChats: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 1e19ec3b9ed..9f045c4d022 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -210,7 +210,16 @@ export const zhHant = defineLocale({ technicalDesc: '包含原始工具參數、結果與底層細節。', themeTitle: '主題', themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。', - themeProfileNote: profile => `已為「${profile}」設定檔儲存——每個設定檔保留各自的主題。` + themeProfileNote: profile => `已為「${profile}」設定檔儲存——每個設定檔保留各自的主題。`, + installTitle: '從 VS Code 安裝', + installDesc: '貼上 Marketplace 擴充功能 ID(例如 dracula-theme.theme-dracula),將其配色主題轉換為桌面調色盤。', + installPlaceholder: 'publisher.extension', + installButton: '安裝', + installing: '安裝中…', + installError: '無法安裝該主題。', + installed: name => `已安裝「${name}」。`, + removeTheme: '移除主題', + importedBadge: '已匯入' }, fieldLabels: defineFieldCopy({ model: '預設模型', @@ -745,6 +754,17 @@ export const zhHant = defineLocale({ settings: '設定', changeTheme: '變更主題...', changeColorMode: '變更色彩模式...', + installTheme: { + title: '安裝主題...', + placeholder: '搜尋 VS Code Marketplace...', + loading: '正在搜尋 Marketplace...', + error: '無法連接到 Marketplace。', + empty: '沒有符合的主題。', + install: '安裝', + installing: '安裝中...', + installed: '已安裝', + installs: count => `${count} 次安裝` + }, settingsFields: '設定欄位', mcpServers: 'MCP 伺服器', archivedChats: '已封存聊天', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index d3f24c95e7d..f6b119a2777 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -297,7 +297,16 @@ export const zh: Translations = { technicalDesc: '包含原始工具参数/结果及底层细节。', themeTitle: '主题', themeDesc: '仅桌面端调色板。所选模式叠加其上。', - themeProfileNote: profile => `已为「${profile}」配置文件保存——每个配置文件保留各自的主题。` + themeProfileNote: profile => `已为「${profile}」配置文件保存——每个配置文件保留各自的主题。`, + installTitle: '从 VS Code 安装', + installDesc: '粘贴 Marketplace 扩展 ID(例如 dracula-theme.theme-dracula),将其配色主题转换为桌面调色板。', + installPlaceholder: 'publisher.extension', + installButton: '安装', + installing: '安装中…', + installError: '无法安装该主题。', + installed: name => `已安装「${name}」。`, + removeTheme: '移除主题', + importedBadge: '已导入' }, fieldLabels: defineFieldCopy({ model: '默认模型', @@ -829,6 +838,17 @@ export const zh: Translations = { settings: '设置', changeTheme: '更改主题...', changeColorMode: '更改颜色模式...', + installTheme: { + title: '安装主题...', + placeholder: '搜索 VS Code Marketplace...', + loading: '正在搜索 Marketplace...', + error: '无法连接到 Marketplace。', + empty: '没有匹配的主题。', + install: '安装', + installing: '安装中...', + installed: '已安装', + installs: count => `${count} 次安装` + }, settingsFields: '设置字段', mcpServers: 'MCP 服务器', archivedChats: '已归档对话', diff --git a/apps/desktop/src/themes/color.ts b/apps/desktop/src/themes/color.ts new file mode 100644 index 00000000000..8bb4e9ca3aa --- /dev/null +++ b/apps/desktop/src/themes/color.ts @@ -0,0 +1,142 @@ +/** + * Small color helpers shared by the theme context (synthesised light variants) + * and the VS Code theme converter (token → seed mapping). + * + * Everything works in 6-digit `#rrggbb`. `normalizeHex` is the front door for + * untrusted input (VS Code themes use `#rgb`, `#rgba`, `#rrggbbaa`, and named + * tokens), flattening alpha over a backdrop so downstream math stays simple. + */ + +export function hexToRgb(hex: string): [number, number, number] | null { + const clean = hex.trim().replace(/^#/, '') + + if (!/^[0-9a-f]{6}$/i.test(clean)) { + return null + } + + return [0, 2, 4].map(i => parseInt(clean.slice(i, i + 2), 16)) as [number, number, number] +} + +export const rgbToHex = ([r, g, b]: [number, number, number]): string => + `#${[r, g, b].map(n => Math.round(Math.min(255, Math.max(0, n))).toString(16).padStart(2, '0')).join('')}` + +export function mix(a: string, b: string, amount: number): string { + const ar = hexToRgb(a) + const br = hexToRgb(b) + + return ar && br + ? rgbToHex([ar[0] + (br[0] - ar[0]) * amount, ar[1] + (br[1] - ar[1]) * amount, ar[2] + (br[2] - ar[2]) * amount]) + : a +} + +const linearize = (channel: number): number => + channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4 + +/** WCAG relative luminance (gamma-corrected), 0..1. */ +export function relativeLuminance(hex: string): number { + const rgb = hexToRgb(hex) + + if (!rgb) { + return 0 + } + + const [r, g, b] = rgb.map(v => linearize(v / 255)) + + return 0.2126 * r + 0.7152 * g + 0.0722 * b +} + +/** WCAG contrast ratio (1..21) between two hex colors. */ +export function contrastRatio(a: string, b: string): number { + const la = relativeLuminance(a) + const lb = relativeLuminance(b) + + return la >= lb ? (la + 0.05) / (lb + 0.05) : (lb + 0.05) / (la + 0.05) +} + +/** Returns a readable foreground (#161616 or #ffffff) for a background hex. */ +export function readableOn(hex: string): string { + return relativeLuminance(hex) > 0.58 ? '#161616' : '#ffffff' +} + +/** + * Guarantee `color` reads against `bg`: if it's below `min` contrast, mix it + * toward white (on a dark bg) or black (on a light bg) in steps until it clears, + * keeping the hue as much as possible. Used so imported accents never collapse + * into a near-background sidebar (the "invisible label" case). + */ +export function ensureContrast(color: string, bg: string, min: number): string { + if (contrastRatio(color, bg) >= min) { + return color + } + + const towards = relativeLuminance(bg) < 0.5 ? '#ffffff' : '#000000' + let best = color + + for (let amount = 0.2; amount <= 1.0001; amount += 0.2) { + best = mix(color, towards, Math.min(amount, 1)) + + if (contrastRatio(best, bg) >= min) { + return best + } + } + + return best +} + +/** Perceptual-ish luminance in 0..1 (naive, for light/dark bucketing). */ +export function luminance(hex: string): number { + const rgb = hexToRgb(hex) + + if (!rgb) { + return 0 + } + + const [r, g, b] = rgb.map(v => v / 255) + + return 0.2126 * r + 0.7152 * g + 0.0722 * b +} + +/** + * Coerce any CSS hex color VS Code themes throw at us into a flat 6-digit + * `#rrggbb`, compositing alpha over `backdrop`. Accepts `#rgb`, `#rgba`, + * `#rrggbb`, `#rrggbbaa` (with or without the leading `#`). Returns null for + * non-hex values (named colors, `rgb()`, etc.) so callers can fall back. + */ +export function normalizeHex(input: string | undefined | null, backdrop = '#000000'): string | null { + if (typeof input !== 'string') { + return null + } + + let clean = input.trim().replace(/^#/, '') + + // Expand shorthand (#rgb / #rgba) to full width. + if (clean.length === 3 || clean.length === 4) { + clean = clean + .split('') + .map(ch => ch + ch) + .join('') + } + + if (!/^[0-9a-f]{6}([0-9a-f]{2})?$/i.test(clean)) { + return null + } + + const rgb = hexToRgb(`#${clean.slice(0, 6)}`) + + if (!rgb) { + return null + } + + if (clean.length === 6) { + return rgbToHex(rgb) + } + + const alpha = parseInt(clean.slice(6, 8), 16) / 255 + const base = hexToRgb(backdrop) ?? [0, 0, 0] + + return rgbToHex([ + base[0] + (rgb[0] - base[0]) * alpha, + base[1] + (rgb[1] - base[1]) * alpha, + base[2] + (rgb[2] - base[2]) * alpha + ]) +} diff --git a/apps/desktop/src/themes/context.tsx b/apps/desktop/src/themes/context.tsx index de920bd7d3d..f7bc07c3b7e 100644 --- a/apps/desktop/src/themes/context.tsx +++ b/apps/desktop/src/themes/context.tsx @@ -16,8 +16,10 @@ import { matchesQuery, useMediaQuery } from '@/hooks/use-media-query' import { persistString, persistStringRecord, storedString, storedStringRecord } from '@/lib/storage' import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' +import { hexToRgb, mix, readableOn } from './color' import { BUILTIN_THEME_LIST, BUILTIN_THEMES, DEFAULT_SKIN_NAME, DEFAULT_TYPOGRAPHY, nousTheme } from './presets' import type { DesktopTheme, DesktopThemeColors } from './types' +import { $userThemes, resolveTheme } from './user-themes' // Legacy global skin (pre per-profile themes). Still the inheritance fallback // for any profile without its own assignment, so single-profile users and old @@ -41,7 +43,7 @@ const resolveMode = (mode: ThemeMode, systemDark = matchesQuery('(prefers-color- mode === 'system' ? (systemDark ? 'dark' : 'light') : mode const normalizeSkin = (name: string | null): string => - name && BUILTIN_THEMES[name] && !RETIRED_SKINS.has(name) ? name : DEFAULT_SKIN_NAME + name && resolveTheme(name) && !RETIRED_SKINS.has(name) ? name : DEFAULT_SKIN_NAME const normalizeMode = (value: string | null): ThemeMode => value === 'light' || value === 'dark' || value === 'system' ? value : 'light' @@ -71,44 +73,8 @@ const readBootProfileKey = () => normalizeProfileKey(storedString(LAST_PROFILE_K const rememberActiveProfileKey = (profile: string) => persistString(LAST_PROFILE_KEY, profile) // ─── Color math (for synthesised light variants of dark-only skins) ──────── - -function hexToRgb(hex: string): [number, number, number] | null { - const clean = hex.trim().replace(/^#/, '') - - if (!/^[0-9a-f]{6}$/i.test(clean)) { - return null - } - - return [0, 2, 4].map(i => parseInt(clean.slice(i, i + 2), 16)) as [number, number, number] -} - -const rgbToHex = ([r, g, b]: [number, number, number]) => - `#${[r, g, b].map(n => Math.round(n).toString(16).padStart(2, '0')).join('')}` - -function mix(a: string, b: string, amount: number): string { - const ar = hexToRgb(a) - const br = hexToRgb(b) - - return ar && br - ? rgbToHex([ar[0] + (br[0] - ar[0]) * amount, ar[1] + (br[1] - ar[1]) * amount, ar[2] + (br[2] - ar[2]) * amount]) - : a -} - -function readableOn(hex: string): string { - const rgb = hexToRgb(hex) - - if (!rgb) { - return '#ffffff' - } - - const [r, g, b] = rgb.map(v => { - const c = v / 255 - - return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 - }) - - return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.58 ? '#161616' : '#ffffff' -} +// hexToRgb / mix / readableOn live in ./color so the VS Code converter shares +// the exact same math. function synthLightColors(seed: DesktopTheme): DesktopThemeColors { const accent = seed.colors.ring || seed.colors.primary @@ -148,7 +114,7 @@ function synthLightColors(seed: DesktopTheme): DesktopThemeColors { /** Returns the seed palette for a given skin + mode (no overrides applied). */ export function getBaseColors(skinName: string, mode: 'light' | 'dark'): DesktopThemeColors { - const seed = BUILTIN_THEMES[skinName] ?? nousTheme + const seed = resolveTheme(skinName) ?? nousTheme if (mode === 'dark') { return seed.darkColors ?? seed.colors @@ -158,7 +124,7 @@ export function getBaseColors(skinName: string, mode: 'light' | 'dark'): Desktop } function deriveTheme(skinName: string, mode: 'light' | 'dark'): DesktopTheme { - const seed = BUILTIN_THEMES[skinName] ?? nousTheme + const seed = resolveTheme(skinName) ?? nousTheme return { ...seed, @@ -319,6 +285,20 @@ export function ThemeProvider({ children }: { children: ReactNode }) { // behavior is unchanged. const profileKey = normalizeProfileKey(useStore($activeGatewayProfile)) + // Built-ins + user-installed themes. Reactive so an import shows up live in + // the palette, settings grid, and `/skin` without a reload. + const userThemes = useStore($userThemes) + + const availableThemes = useMemo( + () => + [...Object.values(BUILTIN_THEMES), ...Object.values(userThemes)].map(({ name, label, description }) => ({ + name, + label, + description + })), + [userThemes] + ) + const [themeName, setThemeNameState] = useState(() => typeof window === 'undefined' ? DEFAULT_SKIN_NAME : skinPref.resolve(readBootProfileKey()) ) @@ -366,17 +346,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) { // (`appearance.toggleMode`) so it shows up in the hotkey map and is rebindable. const value = useMemo( - () => ({ - theme: activeTheme, - themeName, - mode, - resolvedMode, - renderedMode, - availableThemes: SKIN_LIST, - setTheme, - setMode - }), - [activeTheme, themeName, mode, resolvedMode, renderedMode, setTheme, setMode] + () => ({ theme: activeTheme, themeName, mode, resolvedMode, renderedMode, availableThemes, setTheme, setMode }), + [activeTheme, themeName, mode, resolvedMode, renderedMode, availableThemes, setTheme, setMode] ) return {children} diff --git a/apps/desktop/src/themes/install.test.ts b/apps/desktop/src/themes/install.test.ts new file mode 100644 index 00000000000..42b777681b3 --- /dev/null +++ b/apps/desktop/src/themes/install.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' + +import type { DesktopMarketplaceThemeResult } from '@/global' + +import { luminance } from './color' +import { buildThemeFromMarketplace } from './install' + +const themeJson = (type: 'light' | 'dark', background: string, foreground: string) => + JSON.stringify({ type, colors: { 'editor.background': background, 'editor.foreground': foreground } }) + +// A full base-8 ANSI set keyed off `red` so each variant is distinguishable. +const ansiColors = (red: string) => ({ + 'terminal.ansiBlack': '#000000', + 'terminal.ansiRed': red, + 'terminal.ansiGreen': '#00aa00', + 'terminal.ansiYellow': '#aaaa00', + 'terminal.ansiBlue': '#0000aa', + 'terminal.ansiMagenta': '#aa00aa', + 'terminal.ansiCyan': '#00aaaa', + 'terminal.ansiWhite': '#aaaaaa' +}) + +const themeJsonWithAnsi = (type: 'light' | 'dark', background: string, foreground: string, red: string) => + JSON.stringify({ type, colors: { 'editor.background': background, 'editor.foreground': foreground, ...ansiColors(red) } }) + +describe('buildThemeFromMarketplace', () => { + it('folds a light + dark variant into one family with both slots', () => { + const result: DesktopMarketplaceThemeResult = { + extensionId: 'ryanolsonx.solarized', + displayName: 'Solarized', + themes: [ + { label: 'Solarized Light', uiTheme: 'vs', contents: themeJson('light', '#fdf6e3', '#586e75') }, + { label: 'Solarized Dark', uiTheme: 'vs-dark', contents: themeJson('dark', '#002b36', '#93a1a1') } + ] + } + + const theme = buildThemeFromMarketplace(result) + + expect(theme.label).toBe('Solarized') + expect(theme.name).toBe('vsc-solarized') + // colors = the light variant, darkColors = the dark variant → the toggle works. + expect(theme.colors.background).toBe('#fdf6e3') + expect(theme.darkColors?.background).toBe('#002b36') + expect(luminance(theme.colors.background)).toBeGreaterThan(0.5) + expect(luminance(theme.darkColors!.background)).toBeLessThan(0.5) + }) + + it('orders variants by contribution regardless of light/dark sequence', () => { + const result: DesktopMarketplaceThemeResult = { + extensionId: 'github.github-vscode-theme', + displayName: 'GitHub Theme', + themes: [ + { label: 'GitHub Dark Default', uiTheme: 'vs-dark', contents: themeJson('dark', '#0d1117', '#e6edf3') }, + { label: 'GitHub Light Default', uiTheme: 'vs', contents: themeJson('light', '#ffffff', '#1f2328') } + ] + } + + const theme = buildThemeFromMarketplace(result) + expect(theme.colors.background).toBe('#ffffff') + expect(theme.darkColors?.background).toBe('#0d1117') + }) + + it('fills both slots with the sole palette for a single-variant extension', () => { + const result: DesktopMarketplaceThemeResult = { + extensionId: 'dracula-theme.theme-dracula', + displayName: 'Dracula', + themes: [{ label: 'Dracula', uiTheme: 'vs-dark', contents: themeJson('dark', '#282a36', '#f8f8f2') }] + } + + const theme = buildThemeFromMarketplace(result) + expect(theme.colors.background).toBe('#282a36') + expect(theme.darkColors).toBe(theme.colors) + }) + + it('keys each variant terminal palette to its mode (terminal / darkTerminal)', () => { + const result: DesktopMarketplaceThemeResult = { + extensionId: 'ryanolsonx.solarized', + displayName: 'Solarized', + themes: [ + { label: 'Solarized Light', uiTheme: 'vs', contents: themeJsonWithAnsi('light', '#fdf6e3', '#586e75', '#dc322f') }, + { label: 'Solarized Dark', uiTheme: 'vs-dark', contents: themeJsonWithAnsi('dark', '#002b36', '#93a1a1', '#ff5f56') } + ] + } + + const theme = buildThemeFromMarketplace(result) + expect(theme.terminal?.red).toBe('#dc322f') + expect(theme.darkTerminal?.red).toBe('#ff5f56') + }) + + it('reuses the sole variant terminal palette for both modes', () => { + const result: DesktopMarketplaceThemeResult = { + extensionId: 'dracula-theme.theme-dracula', + displayName: 'Dracula', + themes: [{ label: 'Dracula', uiTheme: 'vs-dark', contents: themeJsonWithAnsi('dark', '#282a36', '#f8f8f2', '#ff5555') }] + } + + const theme = buildThemeFromMarketplace(result) + expect(theme.terminal?.red).toBe('#ff5555') + expect(theme.darkTerminal?.red).toBe('#ff5555') + }) + + it('leaves terminal slots unset when no variant ships an ANSI palette', () => { + const result: DesktopMarketplaceThemeResult = { + extensionId: 'x.plain', + displayName: 'Plain', + themes: [{ label: 'Plain', uiTheme: 'vs-dark', contents: themeJson('dark', '#101010', '#fafafa') }] + } + + const theme = buildThemeFromMarketplace(result) + expect(theme.terminal).toBeUndefined() + expect(theme.darkTerminal).toBeUndefined() + }) + + it('throws when the extension contributes no themes', () => { + expect(() => + buildThemeFromMarketplace({ extensionId: 'x.y', displayName: 'X', themes: [] }) + ).toThrow(/does not contribute/i) + }) +}) diff --git a/apps/desktop/src/themes/install.ts b/apps/desktop/src/themes/install.ts new file mode 100644 index 00000000000..792552f9af7 --- /dev/null +++ b/apps/desktop/src/themes/install.ts @@ -0,0 +1,95 @@ +/** + * Install desktop themes from external sources. + * + * The heavy lifting (network + .vsix unzip) lives in the Electron main process + * (`electron/vscode-marketplace.cjs`), reached via `window.hermesDesktop.themes`. + * Main hands back the raw theme JSON; we parse + convert + persist here so the + * conversion stays in one unit-testable place. + */ + +import type { DesktopMarketplaceThemeResult } from '@/global' + +import type { DesktopTheme } from './types' +import { installUserTheme } from './user-themes' +import { convertVscodeColorTheme, parseVscodeTheme, vscodeThemeSlug } from './vscode' + +/** A `publisher.extension` id, e.g. `dracula-theme.theme-dracula`. */ +export const MARKETPLACE_ID_RE = /^[\w-]+\.[\w-]+$/ + +/** Parse + convert + persist a pasted VS Code theme JSON. */ +export function installVscodeThemeFromText( + text: string, + opts?: { label?: string; source?: string } +): DesktopTheme { + const raw = parseVscodeTheme(text) + const { theme } = convertVscodeColorTheme(raw, opts) + + return installUserTheme(theme) +} + +/** + * Fold every color theme an extension contributes into ONE desktop theme family. + * + * Many extensions ship a light *and* a dark variant (GitHub, Solarized, Winter + * is Coming…). Rather than install them as separate flat entries — which made + * the light/dark toggle a no-op and let "install in dark mode" land on the light + * variant — we map the first light variant onto `colors` and the first dark + * variant onto `darkColors`. The result is a single picker entry whose light/dark + * toggle switches between the real variants. A single-variant extension fills + * both slots with its one palette (the toggle is a no-op, as it must be). + */ +export function buildThemeFromMarketplace(result: DesktopMarketplaceThemeResult): DesktopTheme { + if (!result.themes.length) { + throw new Error(`"${result.extensionId}" does not contribute any color themes.`) + } + + const variants = result.themes.map(file => { + const raw = parseVscodeTheme(file.contents) + const label = file.label || raw.name || result.displayName + const { mode, theme } = convertVscodeColorTheme(raw, { label, source: result.extensionId }) + + return { mode, palette: theme.colors, terminal: theme.terminal } + }) + + const fallback = variants[0] + const light = variants.find(variant => variant.mode === 'light') ?? fallback + const dark = variants.find(variant => variant.mode === 'dark') ?? fallback + + // The terminal ANSI palette tracks the painted variant the same way colors do + // (light → terminal, dark → darkTerminal); each falls back to the other so a + // single-variant import still themes the terminal in both modes. + const terminal = light.terminal ?? dark.terminal + const darkTerminal = dark.terminal ?? light.terminal + + return { + name: vscodeThemeSlug(result.displayName), + label: result.displayName, + description: `VS Code · ${result.extensionId}`, + colors: light.palette, + darkColors: dark.palette, + ...(terminal ? { terminal } : {}), + ...(darkTerminal ? { darkTerminal } : {}) + } +} + +/** + * Download a Marketplace extension and install the theme family it contributes + * (see `buildThemeFromMarketplace`). Returns the single installed theme. + */ +export async function installVscodeThemeFromMarketplace(id: string): Promise { + const trimmed = id.trim() + + if (!MARKETPLACE_ID_RE.test(trimmed)) { + throw new Error('Expected a Marketplace id like "publisher.extension".') + } + + const api = window.hermesDesktop?.themes + + if (!api?.fetchMarketplace) { + throw new Error('Marketplace install is only available in the desktop app.') + } + + const result = await api.fetchMarketplace(trimmed) + + return installUserTheme(buildThemeFromMarketplace(result)) +} diff --git a/apps/desktop/src/themes/types.ts b/apps/desktop/src/themes/types.ts index 09bff38ca59..3aefda3eaa3 100644 --- a/apps/desktop/src/themes/types.ts +++ b/apps/desktop/src/themes/types.ts @@ -54,6 +54,37 @@ export interface DesktopThemeTypography { fontUrl?: string } +/** + * Integrated-terminal ANSI palette (xterm `ITheme`, minus `background`). + * + * Populated only when a converted VS Code theme ships a full `terminal.ansi*` + * set; otherwise the terminal keeps its built-in VS Code default palette. + * `background` is intentionally absent — the pane always paints the live skin + * surface so it stays translucent. + */ +export interface DesktopTerminalPalette { + foreground?: string + cursor?: string + /** Keeps its source alpha — xterm blends it over the surface. */ + selectionBackground?: string + black?: string + red?: string + green?: string + yellow?: string + blue?: string + magenta?: string + cyan?: string + white?: string + brightBlack?: string + brightRed?: string + brightGreen?: string + brightYellow?: string + brightBlue?: string + brightMagenta?: string + brightCyan?: string + brightWhite?: string +} + export interface DesktopTheme { name: string label: string @@ -63,4 +94,8 @@ export interface DesktopTheme { /** Hand-tuned dark palette. Skins like `nous` ship one. */ darkColors?: DesktopThemeColors typography?: Partial + /** Light-variant terminal ANSI palette (also the fallback for dark). */ + terminal?: DesktopTerminalPalette + /** Dark-variant terminal ANSI palette. Falls back to `terminal`. */ + darkTerminal?: DesktopTerminalPalette } diff --git a/apps/desktop/src/themes/user-themes.test.ts b/apps/desktop/src/themes/user-themes.test.ts new file mode 100644 index 00000000000..53db3ce1d25 --- /dev/null +++ b/apps/desktop/src/themes/user-themes.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { BUILTIN_THEMES, DEFAULT_SKIN_NAME } from './presets' +import { $userThemes, installUserTheme, isUserTheme, listAllThemes, removeUserTheme, resolveTheme } from './user-themes' +import { convertVscodeColorTheme } from './vscode' + +const makeTheme = (label: string) => + convertVscodeColorTheme({ + name: label, + type: 'dark', + colors: { 'editor.background': '#101014', 'editor.foreground': '#fafafa', focusBorder: '#7aa2f7' } + }).theme + +describe('user theme registry', () => { + beforeEach(() => { + window.localStorage.clear() + $userThemes.set({}) + }) + + it('installs a theme into the merged registry and persists it', () => { + const theme = installUserTheme(makeTheme('Tokyo Night')) + + expect(isUserTheme(theme.name)).toBe(true) + expect(resolveTheme(theme.name)).toEqual(theme) + expect(listAllThemes().map(t => t.name)).toContain(theme.name) + expect(window.localStorage.getItem('hermes-desktop-user-themes-v1')).toContain(theme.name) + }) + + it('lists built-ins before user themes', () => { + installUserTheme(makeTheme('Custom')) + const names = listAllThemes().map(t => t.name) + + expect(names.slice(0, Object.keys(BUILTIN_THEMES).length)).toEqual(Object.keys(BUILTIN_THEMES)) + expect(names.at(-1)).toBe('vsc-custom') + }) + + it('removes a theme', () => { + const theme = installUserTheme(makeTheme('Throwaway')) + removeUserTheme(theme.name) + + expect(isUserTheme(theme.name)).toBe(false) + expect(resolveTheme(theme.name)).toBeUndefined() + }) + + it('resolves built-ins through the same lookup', () => { + expect(resolveTheme(DEFAULT_SKIN_NAME)).toBe(BUILTIN_THEMES[DEFAULT_SKIN_NAME]) + }) + + it('refuses to shadow a built-in name', () => { + const builtinName = makeTheme('x') + builtinName.name = DEFAULT_SKIN_NAME + + expect(() => installUserTheme(builtinName)).toThrow(/built-in/) + }) + + it('rejects a theme missing required colors', () => { + const broken = makeTheme('Broken') + // @ts-expect-error — intentionally corrupt the palette for the test. + broken.colors = { background: '#000000' } + + expect(() => installUserTheme(broken)).toThrow(/colors/) + }) +}) diff --git a/apps/desktop/src/themes/user-themes.ts b/apps/desktop/src/themes/user-themes.ts new file mode 100644 index 00000000000..cb2cd34b384 --- /dev/null +++ b/apps/desktop/src/themes/user-themes.ts @@ -0,0 +1,122 @@ +/** + * User-installed desktop themes (currently: converted VS Code themes). + * + * This is the extensibility seam. The theme context reads the *merged* registry + * (built-ins + user themes) for `availableThemes` and for every skin lookup, so + * an installed theme shows up everywhere a built-in does — the Cmd-K palette, + * the Appearance settings grid, and `/skin` — with no per-surface wiring. + * + * Stored as a localStorage record so the boot-time paint (which runs before + * React mounts) can resolve a user theme synchronously, same as built-ins. + */ + +import { atom } from 'nanostores' + +import { BUILTIN_THEMES } from './presets' +import type { DesktopTheme, DesktopThemeColors } from './types' + +const USER_THEMES_KEY = 'hermes-desktop-user-themes-v1' + +// The minimal set of color keys a stored theme must carry to be usable. We keep +// this loose — `applyTheme` tolerates missing optionals via fallbacks — but a +// theme with no background/foreground/primary is junk and gets dropped. +const REQUIRED_COLOR_KEYS: ReadonlyArray = ['background', 'foreground', 'primary'] + +function isValidTheme(value: unknown): value is DesktopTheme { + if (!value || typeof value !== 'object') { + return false + } + + const theme = value as Partial + + if (typeof theme.name !== 'string' || typeof theme.label !== 'string' || !theme.colors) { + return false + } + + const colors = theme.colors as unknown as Record + + return REQUIRED_COLOR_KEYS.every(key => typeof colors[key] === 'string') +} + +function readStored(): Record { + try { + const raw = window.localStorage.getItem(USER_THEMES_KEY) + + if (!raw) { + return {} + } + + const parsed: unknown = JSON.parse(raw) + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {} + } + + const out: Record = {} + + for (const [key, value] of Object.entries(parsed)) { + // Never let a stored theme shadow a built-in name. + if (!BUILTIN_THEMES[key] && isValidTheme(value)) { + out[key] = value + } + } + + return out + } catch { + return {} + } +} + +function persist(record: Record) { + try { + window.localStorage.setItem(USER_THEMES_KEY, JSON.stringify(record)) + } catch { + // Best-effort: a restricted storage context shouldn't break theming. + } +} + +/** Reactive map of installed user themes, keyed by slug. */ +export const $userThemes = atom>(typeof window === 'undefined' ? {} : readStored()) + +/** Install (or replace) a user theme. Returns the stored theme. */ +export function installUserTheme(theme: DesktopTheme): DesktopTheme { + if (BUILTIN_THEMES[theme.name]) { + throw new Error(`"${theme.name}" collides with a built-in theme.`) + } + + if (!isValidTheme(theme)) { + throw new Error('Theme is missing required colors.') + } + + const next = { ...$userThemes.get(), [theme.name]: theme } + $userThemes.set(next) + persist(next) + + return theme +} + +/** Remove a user theme by slug. No-op for unknown / built-in names. */ +export function removeUserTheme(name: string): void { + const current = $userThemes.get() + + if (!current[name]) { + return + } + + const next = { ...current } + delete next[name] + $userThemes.set(next) + persist(next) +} + +export const isUserTheme = (name: string): boolean => Boolean($userThemes.get()[name]) + +/** Resolve a theme by name across the merged registry (built-in + user). */ +export function resolveTheme(name: string): DesktopTheme | undefined { + return BUILTIN_THEMES[name] ?? $userThemes.get()[name] +} + +/** Built-ins first (stable order), then user themes by install order. */ +export function listAllThemes(): DesktopTheme[] { + return [...Object.values(BUILTIN_THEMES), ...Object.values($userThemes.get())] +} diff --git a/apps/desktop/src/themes/vscode.test.ts b/apps/desktop/src/themes/vscode.test.ts new file mode 100644 index 00000000000..ac7cc9f9bd9 --- /dev/null +++ b/apps/desktop/src/themes/vscode.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vitest' + +import { contrastRatio } from './color' +import { convertVscodeColorTheme, parseVscodeTheme, vscodeThemeSlug } from './vscode' + +describe('vscodeThemeSlug', () => { + it('namespaces, lowercases, and dashes', () => { + expect(vscodeThemeSlug('Dracula Soft')).toBe('vsc-dracula-soft') + expect(vscodeThemeSlug(' One Dark Pro!! ')).toBe('vsc-one-dark-pro') + }) + + it('falls back when the name has no usable characters', () => { + expect(vscodeThemeSlug('—')).toBe('vsc-theme') + }) +}) + +describe('parseVscodeTheme (JSONC tolerance)', () => { + it('strips comments and trailing commas', () => { + const text = `{ + // a line comment + "name": "Demo", + /* block comment */ + "type": "dark", + "colors": { + "editor.background": "#1e1e2e", // inline + }, + }` + + const parsed = parseVscodeTheme(text) + expect(parsed.name).toBe('Demo') + expect(parsed.colors?.['editor.background']).toBe('#1e1e2e') + }) + + it('throws on a non-object', () => { + expect(() => parseVscodeTheme('42')).toThrow() + }) +}) + +describe('convertVscodeColorTheme', () => { + const dracula = { + name: 'Dracula', + type: 'dark', + colors: { + 'editor.background': '#282a36', + 'editor.foreground': '#f8f8f2', + focusBorder: '#6272a4', + 'editorWidget.background': '#21222c', + 'sideBar.background': '#21222c', + errorForeground: '#ff5555', + // 8-digit hex (alpha) — must flatten over the background. + 'panel.border': '#bd93f900' + } + } + + it('maps the load-bearing tokens onto the palette', () => { + const { theme } = convertVscodeColorTheme(dracula, { source: 'dracula-theme.theme-dracula' }) + + expect(theme.name).toBe('vsc-dracula') + expect(theme.label).toBe('Dracula') + expect(theme.description).toContain('dracula-theme.theme-dracula') + expect(theme.colors.background).toBe('#282a36') + expect(theme.colors.foreground).toBe('#f8f8f2') + // One accent drives primary + ring + midground together... + expect(theme.colors.ring).toBe(theme.colors.primary) + expect(theme.colors.midground).toBe(theme.colors.primary) + // ...and it's nudged until it reads on the sidebar it labels (the dim + // focusBorder #6272a4 sits below AA, so it's lifted). + expect(contrastRatio(theme.colors.primary, theme.colors.sidebarBackground!)).toBeGreaterThanOrEqual(4.5) + expect(theme.colors.popover).toBe('#21222c') + expect(theme.colors.sidebarBackground).toBe('#21222c') + expect(theme.colors.destructive).toBe('#ff5555') + }) + + it('flattens alpha hex over the background (no #rrggbbaa leaks)', () => { + const { theme } = convertVscodeColorTheme(dracula) + expect(theme.colors.border).toMatch(/^#[0-9a-f]{6}$/) + // 00 alpha over the bg means the border collapses to the background. + expect(theme.colors.border).toBe('#282a36') + }) + + it('renders identically in both modes (single palette in both slots)', () => { + const { theme } = convertVscodeColorTheme(dracula) + expect(theme.darkColors).toBe(theme.colors) + }) + + it('records derived fallbacks for omitted tokens', () => { + const { derived } = convertVscodeColorTheme({ + name: 'Sparse', + type: 'dark', + colors: { 'editor.background': '#101010', 'editor.foreground': '#fafafa' } + }) + + // No accent/elevated/sidebar/error tokens → all derived. The accent records + // its first candidate (button.background) when none of the family is present. + expect(derived).toContain('button.background') + expect(derived).toContain('editorWidget.background') + expect(derived).toContain('editorError.foreground') + }) + + it('buckets light vs dark from background luminance when type is absent', () => { + const light = convertVscodeColorTheme({ + name: 'Bright', + colors: { 'editor.background': '#ffffff', 'editor.foreground': '#1a1a1a' } + }).theme + + // A light background should keep a near-white background, not synth dark. + expect(light.colors.background).toBe('#ffffff') + }) + + it('throws when there is no colors map', () => { + expect(() => convertVscodeColorTheme({ name: 'Empty' })).toThrow(/colors/) + }) + + const fullAnsi = { + 'terminal.ansiBlack': '#073642', + 'terminal.ansiRed': '#dc322f', + 'terminal.ansiGreen': '#859900', + 'terminal.ansiYellow': '#b58900', + 'terminal.ansiBlue': '#268bd2', + 'terminal.ansiMagenta': '#d33682', + 'terminal.ansiCyan': '#2aa198', + 'terminal.ansiWhite': '#eee8d5', + 'terminal.ansiBrightBlack': '#002b36', + 'terminal.ansiBrightRed': '#cb4b16', + 'terminal.ansiBrightGreen': '#586e75', + 'terminal.ansiBrightYellow': '#657b83', + 'terminal.ansiBrightBlue': '#839496', + 'terminal.ansiBrightMagenta': '#6c71c4', + 'terminal.ansiBrightCyan': '#93a1a1', + 'terminal.ansiBrightWhite': '#fdf6e3' + } + + it('lifts the ANSI palette when the full base-8 set is present', () => { + const { theme } = convertVscodeColorTheme({ + name: 'Solarized Dark', + type: 'dark', + colors: { + 'editor.background': '#002b36', + 'editor.foreground': '#93a1a1', + 'terminal.foreground': '#839496', + 'terminalCursor.foreground': '#93a1a1', + // Alpha selection must survive un-flattened — xterm blends it. + 'terminal.selectionBackground': '#073642aa', + ...fullAnsi + } + }) + + expect(theme.terminal?.red).toBe('#dc322f') + expect(theme.terminal?.brightWhite).toBe('#fdf6e3') + expect(theme.terminal?.foreground).toBe('#839496') + expect(theme.terminal?.cursor).toBe('#93a1a1') + expect(theme.terminal?.selectionBackground).toBe('#073642aa') + // No background slot — the pane keeps the live surface (transparency). + expect('background' in (theme.terminal ?? {})).toBe(false) + }) + + it('keeps the default palette (no terminal slot) when the ANSI set is partial', () => { + const { theme } = convertVscodeColorTheme({ + name: 'Half', + type: 'dark', + colors: { + 'editor.background': '#101010', + 'editor.foreground': '#fafafa', + 'terminal.ansiRed': '#ff0000', + 'terminal.ansiGreen': '#00ff00' + } + }) + + expect(theme.terminal).toBeUndefined() + }) +}) diff --git a/apps/desktop/src/themes/vscode.ts b/apps/desktop/src/themes/vscode.ts new file mode 100644 index 00000000000..67c36983a0e --- /dev/null +++ b/apps/desktop/src/themes/vscode.ts @@ -0,0 +1,343 @@ +/** + * 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 + 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 = [ + ['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 = [ + '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, 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, + 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) : 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 } : {}) + } + } +}