# NousResearch/hermes-agent · Desktop theme and font customization features

## Open now

- Nothing open on GitHub.

These items relate to expanding desktop theming options, including installing themes from the VS Code Marketplace, adding specific theme presets (Nord, Solarized, Catppuccin), and broader styling and font customization capabilities.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #43292. Verify it addresses your case.
- **Recommended:** read PR #43292 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-43292`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #40399: \[Feature\]: Hermes Desktop — styling, theming, and font customization

## Prior & competing PRs

- **#43292: feat(desktop): install any VS Code theme from the Marketplace**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `apps/desktop/electron/main.cjs`, `apps/desktop/electron/preload.cjs`, `apps/desktop/electron/vscode-marketplace.cjs`, `apps/desktop/electron/vscode-marketplace.test.cjs`, `apps/desktop/src/app/command-palette/index.tsx`, `apps/desktop/src/app/command-palette/marketplace-theme-page.tsx`, `apps/desktop/src/app/floating-hud.ts`, `apps/desktop/src/app/right-sidebar/terminal/selection.ts`, `apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts`, `apps/desktop/src/app/session-switcher.tsx`, `apps/desktop/src/app/settings/appearance-settings.tsx`, `apps/desktop/src/global.d.ts`, `apps/desktop/src/i18n/en.ts`, `apps/desktop/src/i18n/ja.ts`, `apps/desktop/src/i18n/types.ts`, `apps/desktop/src/i18n/zh-hant.ts`, `apps/desktop/src/i18n/zh.ts`, `apps/desktop/src/themes/color.ts`, `apps/desktop/src/themes/context.tsx`, `apps/desktop/src/themes/install.test.ts` (+6 more)
  - fix hunks:
  - `apps/desktop/electron/main.cjs`

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -6079,6 +6080,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())
    ```
  - `apps/desktop/electron/preload.cjs`

    ```diff
    @@ -133,5 +133,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)
       }
     })
    ```
  - `apps/desktop/electron/vscode-marketplace.cjs`

    ```diff
    @@ -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 }
    +}
    ```
  - `apps/desktop/electron/vscode-marketplace.test.cjs`

    ```diff
    @@ -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)
    +})
    ```
  - `apps/desktop/src/app/command-palette/index.tsx`

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -18,6 +19,7 @@ import {
       ChevronRight,
       Clock,
       Cpu,
    +  Download,
       Globe,
       type IconComponent,
       Info,
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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<string, string> = { 'install-theme': 'theme' }
    +
     /** A nested page reachable from a root item via `to`. */
     interface PalettePage {
       groups: PaletteGroup[]
    ```

    ```diff
    @@ -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<string | null>(null)
     
    ```

    ```diff
    @@ -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]
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -472,9 +528,16 @@ 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, setMode, setTheme, t]
    +    [availableThemes, resolvedMode, setMode, setTheme, t, themeName]
       )
     
       const activePage = page ? subPages[page] : null
    ```

    ```diff
    @@ -499,17 +562,22 @@ export function CommandPalette() {
       return (
         <DialogPrimitive.Root onOpenChange={setCommandPaletteOpen} open={open}>
           <DialogPrimitive.Portal>
    -        <DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-black/15 backdrop-blur-[1px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
    +        {/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */}
    +        <DialogPrimitive.Overlay className="fixed inset-0 z-[200]" />
             <DialogPrimitive.Content
               aria-describedby={undefined}
    -          className="fixed left-1/2 top-[14vh] z-[210] w-[min(40rem,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-lg duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95"
    +          className={cn(
    +            HUD_POSITION,
    +            HUD_SURFACE,
    +            'z-[210] w-[min(34rem,calc(100vw-2rem))] overflow-hidden duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95'
    +          )}
             >
               <DialogPrimitive.Title className="sr-only">{t.commandCenter.paletteTitle}</DialogPrimitive.Title>
               <Command className="bg-transparent" filter={paletteFilter} loop>
                 {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" />
    ```

    ```diff
    @@ -519,6 +587,7 @@ export function CommandPalette() {
                   </button>
                 )}
                 <CommandInput
    +              className={HUD_TEXT}
                   onKeyDown={event => {
                     if (!activePage) {
                       return
    ```

    ```diff
    @@ -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}
                 />
    -            <CommandList className="max-h-[min(24rem,60vh)]">
    -              <CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
    -              {visibleGroups.map(group => (
    +            <CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
    +              {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"
    +                  className={HUD_HEADING}
                       heading={group.heading}
    -                  key={group.heading}
    +                  key={group.heading ?? `palette-group-${index}`}
                     >
                       {group.items.map(item => {
                         const Icon = item.icon
    ```

    ```diff
    @@ -551,18 +624,18 @@ export function CommandPalette() {
     
                         return (
                           <CommandItem
    -                        className="gap-2.5"
    +                        className={cn(HUD_ITEM, HUD_TEXT)}
                             key={item.id}
                             keywords={item.keywords}
                             onSelect={() => handleSelect(item)}
                             value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`}
                           >
    -                        <Icon className="size-4 shrink-0 text-muted-foreground" />
    +                        <Icon className="size-3.5 shrink-0 text-muted-foreground" />
                             <span className="truncate">{item.label}</span>
                             {keys && <KbdGroup className="ml-auto" keys={keys} />}
                             {item.to && (
                               <ChevronRight
    -                            className={cn('size-4 shrink-0 text-muted-foreground/70', !keys && 'ml-auto')}
    +                            className={cn('size-3.5 shrink-0 text-muted-foreground/70', !keys && 'ml-auto')}
                               />
                             )}
                           </CommandItem>
    ```
  - `apps/desktop/src/app/command-palette/marketplace-theme-page.tsx`

    ```diff
    @@ -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<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-3.5 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-2 pb-1 pt-1.5 text-[0.6875rem] text-(--ui-red)">{installError}</p>}
    +      {results.map(item => {
    +        const busy = installingId === item.extensionId
    +        const done = installed[item.extensionId]
    +
    +        return (
    +          <button
    +            className={cn(
    +              'flex w-full items-start rounded-md text-left transition-colors hover:bg-(--chrome-action-hover) disabled:opacity-60 aria-disabled:opacity-60',
    +              HUD_ITEM,
    +              HUD_TEXT
    +            )}
    +            disabled={Boolean(installingId) && !busy}
    +            key={item.extensionId}
    +            onClick={() => void install(item)}
    +            onMouseDown={event => event.preventDefault()}
    +            role="option"
    +            type="button"
    +          >
    +            <Palette className="mt-0.5 size-3.5 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.6875rem] text-muted-foreground/80">
    +                {item.publisher}
    +                {item.installs > 0 ? ` · ${copy.installs(compactNumber.format(item.installs))}` : ''}
    +              </span>
    +            </span>
    +            <span className="ml-auto mt-0.5 flex shrink-0 items-center gap-1 text-[0.6875rem] text-muted-foreground">
    +              {busy ? (
    +                <>
    +                  <Loader2 className="size-3 animate-spin" />
    +                  {copy.installing}
    +                </>
    +              ) : done ? (
    +                <>
    +                  <Check className="size-3 text-(--ui-green)" />
    +                  {copy.installed}
    +                </>
    +              ) : (
    +                <>
    +                  <Download className="size-3" />
    +                  {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-2 py-6 text-xs',
    +        tone === 'error' ? 'text-(--ui-red)' : 'text-muted-foreground'
    +      )}
    +    >
    +      {icon}
    +      {text}
    +    </div>
    +  )
    +}
    ```
  - `apps/desktop/src/app/floating-hud.ts`

    ```diff
    @@ -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)'
    ```
  - `apps/desktop/src/app/right-sidebar/terminal/selection.ts`

    ```diff
    @@ -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).
    ```

    ```diff
    @@ -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<string, string>
    +
    +  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
    ```
  - `apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts`

    ```diff
    @@ -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<HTMLDivElement | null>(null)
       const termRef = useRef<Terminal | null>(null)
    ```
  - `apps/desktop/src/app/session-switcher.tsx`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -39,22 +40,31 @@ export function SessionSwitcher() {
       }
     
       return createPortal(
    -    <div className="fixed inset-0 z-[220] flex select-none items-center justify-center">
    +    <>
    +      {/* Transparent click-catcher: click-away closes, but no dim/blur. */}
           <div
    -        className="absolute inset-0 bg-black/15 backdrop-blur-[1px]"
    +        className="fixed inset-0 z-[219]"
             onMouseDown={e => {
               e.preventDefault()
               closeSwitcher()
             }}
           />
    -      <div className="relative max-h-[min(26rem,70vh)] w-[min(20rem,calc(100vw-2rem))] overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) p-1 shadow-lg">
    +      <div
    +        className={cn(
    +          HUD_POSITION,
    +          HUD_SURFACE,
    +          'dt-portal-scrollbar z-[220] max-h-[min(22rem,64vh)] w-[min(19rem,calc(100vw-2rem))] select-none overflow-y-auto p-1'
    +        )}
    +      >
             {sessions.map((session, i) => {
               const selected = i === index
     
               return (
                 <div
                   className={cn(
    -                'flex cursor-pointer items-center gap-2 rounded px-1.5 py-1 text-xs leading-tight',
    +                'flex cursor-pointer items-center rounded leading-tight',
    +                HUD_ITEM,
    +                HUD_TEXT,
                     selected ? 'bg-accent text-accent-foreground' : 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background)'
                   )}
                   key={session.id}
    ```

    ```diff
    @@ -80,7 +90,7 @@ export function SessionSwitcher() {
               )
             })}
           </div>
    -    </div>,
    +    </>,
         document.body
       )
     }
    ```
  - `apps/desktop/src/app/settings/appearance-settings.tsx`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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 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)'
    +                          )}
    +                          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>
    -                          {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>
    +                        </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)}
    ```
  - `apps/desktop/src/global.d.ts`

    ```diff
    @@ -96,10 +96,40 @@ declare global {
             summary: () => Promise<DesktopUninstallSummary>
             run: (mode: DesktopUninstallMode) => Promise<DesktopUninstallResult>
           }
    +      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<DesktopMarketplaceThemeResult>
    +        // Search the Marketplace for color-theme extensions. An empty query
    +        // returns the most-installed themes.
    +        searchMarketplace: (query: string) => Promise<DesktopMarketplaceSearchItem[]>
    +      }
         }
       }
     }
     
    +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
    ```
  - `apps/desktop/src/i18n/en.ts`

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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',
    ```
  - `apps/desktop/src/i18n/ja.ts`

    ```diff
    @@ -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: 'デフォルトモデル',
    ```

    ```diff
    @@ -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: 'アーカイブ済みチャット',
    ```
  - `apps/desktop/src/i18n/types.ts`

    ```diff
    @@ -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<string, string>
         fieldDescriptions: Record<string, string>
    ```

    ```diff
    @@ -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
    ```
  - `apps/desktop/src/i18n/zh-hant.ts`

    ```diff
    @@ -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: '預設模型',
    ```

    ```diff
    @@ -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: '已封存聊天',
    ```
  - `apps/desktop/src/i18n/zh.ts`

    ```diff
    @@ -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: '默认模型',
    ```

    ```diff
    @@ -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: '已归档对话',
    ```
  - `apps/desktop/src/themes/color.ts`

    ```diff
    @@ -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
    +  ])
    +}
    ```
  - `apps/desktop/src/themes/context.tsx`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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())
       )
    ```

    ```diff
    @@ -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<ThemeContextValue>(
    -    () => ({
    -      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 <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
    ```
  - `apps/desktop/src/themes/install.test.ts`

    ```diff
    @@ -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)
    +  })
    +})
    ```
  - `apps/desktop/src/themes/install.ts`

    ```diff
    @@ -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<DesktopTheme> {
    +  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))
    +}
    ```
  - `apps/desktop/src/themes/types.ts`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -63,4 +94,8 @@ export interface DesktopTheme {
       /** Hand-tuned dark palette. Skins like `nous` ship one. */
       darkColors?: DesktopThemeColors
       typography?: Partial<DesktopThemeTypography>
    +  /** Light-variant terminal ANSI palette (also the fallback for dark). */
    +  terminal?: DesktopTerminalPalette
    +  /** Dark-variant terminal ANSI palette. Falls back to `terminal`. */
    +  darkTerminal?: DesktopTerminalPalette
     }
    ```
  - `apps/desktop/src/themes/user-themes.test.ts`

    ```diff
    @@ -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/)
    +  })
    +})
    ```
  - `apps/desktop/src/themes/user-themes.ts`

    ```diff
    @@ -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<keyof DesktopThemeColors> = ['background', 'foreground', 'primary']
    +
    +function isValidTheme(value: unknown): value is DesktopTheme {
    +  if (!value || typeof value !== 'object') {
    +    return false
    +  }
    +
    +  const theme = value as Partial<DesktopTheme>
    +
    +  if (typeof theme.name !== 'string' || typeof theme.label !== 'string' || !theme.colors) {
    +    return false
    +  }
    +
    +  const colors = theme.colors as unknown as Record<string, unknown>
    +
    +  return REQUIRED_COLOR_KEYS.every(key => typeof colors[key] === 'string')
    +}
    +
    +function readStored(): Record<string, DesktopTheme> {
    +  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<string, DesktopTheme> = {}
    +
    +    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<string, DesktopTheme>) {
    +  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<Record<string, DesktopTheme>>(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())]
    +}
    ```
  - `apps/desktop/src/themes/vscode.test.ts`

    ```diff
    @@ -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()
    +  })
    +})
    ```
  - `apps/desktop/src/themes/vscode.ts`

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

## Fix status

- Merged to main in PR #43292 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-10`.
- Subsystem: `apps/desktop`
