{"repo":"NousResearch/hermes-agent","public_id":"40399","display_label":"Desktop theme and font customization features","summary":"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.","subsystem":"apps/desktop","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":43292,"merged_at":"2026-06-10T04:54:00+00:00"},"resolution":{"state":"resolved","by":"pr-43292"},"freshness":{"state":"verified","last_verified_at":"2026-06-10T04:54:00+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":40399,"pr_number":43292,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-10T04:54:00+00:00"}],"members":[{"kind":"issue","number":40399,"title":"[Feature]: Hermes Desktop — styling, theming, and font customization","url":null,"state":"closed"},{"kind":"pr","number":40450,"title":"feat(desktop): add Nord, Solarized, and Catppuccin theme presets","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#43292","number":43292}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":43292,"title":"feat(desktop): install any VS Code theme from the Marketplace","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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","apps/desktop/src/themes/install.ts","apps/desktop/src/themes/types.ts","apps/desktop/src/themes/user-themes.test.ts","apps/desktop/src/themes/user-themes.ts","apps/desktop/src/themes/vscode.test.ts","apps/desktop/src/themes/vscode.ts"],"fix_hunks":[{"file":"apps/desktop/electron/main.cjs","hunks":["@@ -30,6 +30,7 @@ const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./sessio\n const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')\n const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')\n const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')\n+const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')\n const {\n   buildPosixCleanupScript,\n   buildWindowsCleanupScript,","@@ -6079,6 +6080,13 @@ ipcMain.handle('hermes:uninstall:run', async (_event, payload) => {\n   return runDesktopUninstall(String(mode || ''))\n })\n \n+// Download a VS Code Marketplace extension and return the raw color-theme JSON\n+// it contributes. No theme code is executed — we only read JSON from the .vsix.\n+ipcMain.handle('hermes:vscode-theme:fetch', async (_event, id) => fetchMarketplaceThemes(String(id || '')))\n+\n+// Search the Marketplace for color-theme extensions (empty query = top installs).\n+ipcMain.handle('hermes:vscode-theme:search', async (_event, query) => searchMarketplaceThemes(String(query || ''), 20))\n+\n app.whenReady().then(() => {\n   if (IS_MAC) {\n     Menu.setApplicationMenu(buildApplicationMenu())"]},{"file":"apps/desktop/electron/preload.cjs","hunks":["@@ -133,5 +133,9 @@ contextBridge.exposeInMainWorld('hermesDesktop', {\n       ipcRenderer.on('hermes:updates:progress', listener)\n       return () => ipcRenderer.removeListener('hermes:updates:progress', listener)\n     }\n+  },\n+  themes: {\n+    fetchMarketplace: id => ipcRenderer.invoke('hermes:vscode-theme:fetch', id),\n+    searchMarketplace: query => ipcRenderer.invoke('hermes:vscode-theme:search', query)\n   }\n })"]},{"file":"apps/desktop/electron/vscode-marketplace.cjs","hunks":["@@ -0,0 +1,331 @@\n+'use strict'\n+\n+/**\n+ * VS Code Marketplace color-theme fetcher (main process).\n+ *\n+ * Resolves an extension's latest version via the (undocumented but stable)\n+ * gallery ExtensionQuery API, downloads the `.vsix` (a zip), and extracts the\n+ * color-theme JSON files it contributes. No theme code is ever executed — we\n+ * only read `package.json` + the referenced `*.json` theme files out of the\n+ * archive and hand their text back to the renderer to convert.\n+ *\n+ * Dependency-free on purpose: a `.vsix` is a plain zip, so we parse the central\n+ * directory and inflate just the entries we need with `zlib`. Avoids pulling a\n+ * zip library into the desktop bundle for a feature this small.\n+ */\n+\n+const https = require('node:https')\n+const zlib = require('node:zlib')\n+\n+const GALLERY_QUERY_URL = 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery'\n+const VSIX_ASSET_TYPE = 'Microsoft.VisualStudio.Services.VSIXPackage'\n+const MAX_VSIX_BYTES = 40 * 1024 * 1024 // 40 MB — themes are tiny; this is paranoia.\n+const MAX_REDIRECTS = 5\n+const REQUEST_TIMEOUT_MS = 20_000\n+\n+const ID_RE = /^[\\w-]+\\.[\\w-]+$/\n+\n+/** Minimal HTTPS helper with redirect-following, timeout, and a size cap. */\n+function request(url, { method = 'GET', headers = {}, body = null, maxBytes = MAX_VSIX_BYTES } = {}, redirectsLeft = MAX_REDIRECTS) {\n+  return new Promise((resolve, reject) => {\n+    const req = https.request(url, { method, headers }, res => {\n+      const status = res.statusCode ?? 0\n+\n+      if (status >= 300 && status < 400 && res.headers.location) {\n+        if (redirectsLeft <= 0) {\n+          res.resume()\n+          reject(new Error('Too many redirects.'))\n+\n+          return\n+        }\n+\n+        const next = new URL(res.headers.location, url).toString()\n+        res.resume()\n+        // Redirects to the CDN are plain GETs (drop the POST body).\n+        resolve(request(next, { method: 'GET', headers: { 'User-Agent': headers['User-Agent'] }, maxBytes }, redirectsLeft - 1))\n+\n+        return\n+      }\n+\n+      if (status < 200 || status >= 300) {\n+        res.resume()\n+        reject(new Error(`Request failed (${status}) for ${url}`))\n+\n+        return\n+      }\n+\n+      const chunks = []\n+      let total = 0\n+\n+      res.on('data', chunk => {\n+        total += chunk.length\n+\n+        if (total > maxBytes) {\n+          req.destroy()\n+          reject(new Error('Response exceeded the size limit.'))\n+\n+          return\n+        }\n+\n+        chunks.push(chunk)\n+      })\n+      res.on('end', () => resolve(Buffer.concat(chunks)))\n+    })\n+\n+    req.on('error', reject)\n+    req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error('Request timed out.')))\n+\n+    if (body) {\n+      req.write(body)\n+    }\n+\n+    req.end()\n+  })\n+}\n+\n+/** Resolve `{ displayName, vsixUrl }` for the latest version of `id`. */\n+async function resolveExtension(id) {\n+  const json = await queryGallery({\n+    // FilterType 7 = ExtensionName (the full publisher.extension id).\n+    filters: [{ criteria: [{ filterType: 7, value: id }], pageNumber: 1, pageSize: 1 }],\n+    // Flags: IncludeFiles | IncludeVersionProperties | IncludeAssetUri |\n+    // IncludeCategoryAndTags | IncludeLatestVersionOnly = 914.\n+    flags: 914\n+  })\n+  const extension = json?.results?.[0]?.extensions?.[0]\n+\n+  if (!extension) {\n+    throw new Error(`Extension \"${id}\" was not found on the Marketplace.`)\n+  }\n+\n+  const version = extension.versions?.[0]\n+\n+  if (!version) {\n+    throw new Error(`Extension \"${id}\" has no published versions.`)\n+  }\n+\n+  const asset = (version.files ?? []).find(file => file.assetType === VSIX_ASSET_TYPE)\n+  const vsixUrl = asset?.source\n+\n+  if (!vsixUrl) {\n+    throw new Error(`Could not find a downloadable package for \"${id}\".`)\n+  }\n+\n+  return { displayName: extension.displayName || id, vsixUrl }\n+}\n+\n+/** POST an ExtensionQuery payload and return the parsed gallery response. */\n+async function queryGallery(payload, { maxBytes = 4 * 1024 * 1024 } = {}) {\n+  const body = JSON.stringify(payload)\n+  const raw = await request(GALLERY_QUERY_URL, {\n+    method: 'POST',\n+    headers: {\n+      Accept: 'application/json;api-version=3.0-preview.1',\n+      'Content-Type': 'application/json',\n+      'Content-Length': Buffer.byteLength(body),\n+      'User-Agent': 'Hermes-Desktop'\n+    },\n+    body,\n+    maxBytes\n+  })\n+\n+  return JSON.parse(raw.toString('utf8'))\n+}\n+\n+/**\n+ * Search the Marketplace for color-theme extensions. With an empty query this\n+ * returns the most-installed themes; with a query it's a full-text search\n+ * scoped to the Themes category. Returns lightweight cards (no download).\n+ */\n+/**\n+ * The \"Themes\" category also contains file-icon and product-icon themes (the\n+ * gallery has no color-only category). We can't see an extension's actual\n+ * contributions without downloading it, so filter the obvious icon packs out by\n+ * tag + name/description. Color themes that also ship icons are rare; worst case\n+ * a user installs them by exact id from settings.\n+ */\n+function looksLikeIconTheme(extension) {\n+  const tags = (extension.tags ?? []).map(tag => String(tag).toLowerCase())\n+\n+  if (tags.includes('icon-theme') || tags.includes('product-icon-theme')) {\n+    return true\n+  }\n+\n+  const text = `${extension.displayName ?? ''} ${extension.shortDescription ?? ''}`.toLowerCase()\n+\n+  return /\\b(icon theme|file icons?|product icons?|icon pack|fileicons)\\b/.test(text)\n+}\n+\n+async function searchMarketplaceThemes(query, limit = 20) {\n+  const text = String(query || '').trim()\n+  const pageSize = Math.min(Math.max(Number(limit) || 20, 1), 50)\n+\n+  // FilterType: 8=Target, 5=Category, 10=SearchText, 12=ExcludeWithFlags.\n+  const criteria = [\n+    { filterType: 8, value: 'Microsoft.VisualStudio.Code' },\n+    { filterType: 5, value: 'Themes' },\n+    { filterType: 12, value: '4096' } // Exclude unpublished (Unpublished = 0x1000).\n+  ]\n+\n+  if (text) {\n+    criteria.push({ filterType: 10, value: text })\n+  }\n+\n+  const json = await queryGallery({\n+    // Over-fetch so the icon-theme filter below still leaves a full page.\n+    filters: [{ criteria, pageNumber: 1, pageSize: Math.min(pageSize * 2, 50), sortBy: 4, sortOrder: 0 }],\n+    // IncludeStatistics (0x100) | IncludeLatestVersionOnly (0x200) | IncludeCategoryAndTags (0x4).\n+    flags: 772\n+  })\n+\n+  const extensions = json?.results?.[0]?.extensions ?? []\n+\n+  return extensions\n+    .filter(extension => !looksLikeIconTheme(extension))\n+    .slice(0, pageSize)\n+    .map(extension => {\n+      const publisherName = extension.publisher?.publisherName ?? ''\n+      const installStat = (extension.statistics ?? []).find(stat => stat.statisticName === 'install')\n+\n+      return {\n+        extensionId: `${publisherName}.${extension.extensionName}`,\n+        displayName: extension.displayName || extension.extensionName,\n+        publisher: extension.publisher?.displayName || publisherName,\n+        description: extension.shortDescription || '',\n+        installs: Math.round(installStat?.value ?? 0)\n+      }\n+    })\n+}\n+\n+// ─── Minimal zip reader ─────────────────────────────────────────────────────\n+\n+function findEndOfCentralDirectory(buf) {\n+  // EOCD signature 0x06054b50, scanning back from the end (comment is rare).\n+  for (let i = buf.length - 22; i >= 0; i--) {\n+    if (buf.readUInt32LE(i) === 0x06054b50) {\n+      return i\n+    }\n+  }\n+\n+  throw new Error('Not a valid zip archive (no end-of-central-directory).')\n+}\n+\n+/** Parse the central directory into a name → record map. */\n+function readCentralDirectory(buf) {\n+  const eocd = findEndOfCentralDirectory(buf)\n+  const count = buf.readUInt16LE(eocd + 10)\n+  let offset = buf.readUInt32LE(eocd + 16)\n+  const records = new Map()\n+\n+  for (let i = 0; i < count; i++) {\n+    if (buf.readUInt32LE(offset) !== 0x02014b50) {\n+      break\n+    }\n+\n+    const method = buf.readUInt16LE(offset + 10)\n+    const compressedSize = buf.readUInt32LE(offset + 20)\n+    const nameLen = buf.readUInt16LE(offset + 28)\n+    const extraLen = buf.readUInt16LE(offset + 30)\n+    const commentLen = buf.readUInt16LE(offset + 32)\n+    const localOffset = buf.readUInt32LE(offset + 42)\n+    const name = buf.toString('utf8', offset + 46, offset + 46 + nameLen)\n+\n+    records.set(name, { method, compressedSize, localOffset })\n+    offset += 46 + nameLen + extraLen + commentLen\n+  }\n+\n+  return records\n+}\n+\n+/** Inflate a single entry to a string. */\n+function extractEntry(buf, record) {\n+  // The local header's name/extra lengths can differ from the central record,\n+  // so re-read them here to locate the compressed payload.\n+  if (buf.readUInt32LE(record.localOffset) !== 0x04034b50) {\n+    throw new Error('Corrupt zip: bad local file header.')\n+  }\n+\n+  const nameLen = buf.readUInt16LE(record.localOffset + 26)\n+  const extraLen = buf.readUInt16LE(record.localOffset + 28)\n+  const dataStart = record.localOffset + 30 + nameLen + extraLen\n+  const data = buf.subarray(dataStart, dataStart + record.compressedSize)\n+\n+  // 0 = stored, 8 = deflate. Theme files are one or the other.\n+  return record.method === 0 ? data.toString('utf8') : zlib.inflateRawSync(data).toString('utf8')\n+}\n+\n+/** Normalize a package.json theme path to its zip entry name. */\n+function themeEntryName(themePath) {\n+  const clean = String(themePath).replace(/^\\.\\//, '').replace(/^\\//, '')\n+\n+  return `extension/${clean}`\n+}\n+\n+/** Extract every contributed color theme from a `.vsix` buffer. */\n+function extractThemes(vsixBuffer) {\n+  const records = readCentralDirectory(vsixBuffer)\n+  const pkgRecord = records.get('extension/package.json')\n+\n+  if (!pkgRecord) {\n+    throw new Error('Package manifest missing from the extension.')\n+  }\n+\n+  const pkg = JSON.parse(extractEntry(vsixBuffer, pkgRecord))\n+  const contributed = pkg?.contributes?.themes\n+\n+  if (!Array.isArray(contributed) || contributed.length === 0) {\n+    return []\n+  }\n+\n+  const themes = []\n+\n+  for (const entry of contributed) {\n+    if (!entry?.path) {\n+      continue\n+    }\n+\n+    const record = records.get(themeEntryName(entry.path))\n+\n+    if (!record) {\n+      continue\n+    }\n+\n+    try {\n+      themes.push({\n+        label: entry.label || entry.id || pkg.displayName || pkg.name || 'VS Code Theme',\n+        uiTheme: entry.uiTheme,\n+        contents: extractEntry(vsixBuffer, record)\n+      })\n+    } catch {\n+      // Skip an entry we can't inflate rather than failing the whole install.\n+    }\n+  }\n+\n+  return themes\n+}\n+\n+/**\n+ * Public entry: resolve, download, and extract color themes for `id`\n+ * (`publisher.extension`). Returns `{ extensionId, displayName, themes }`.\n+ */\n+async function fetchMarketplaceThemes(id) {\n+  const trimmed = String(id || '').trim()\n+\n+  if (!ID_RE.test(trimmed)) {\n+    throw new Error('Expected a Marketplace id like \"publisher.extension\".')\n+  }\n+\n+  const { displayName, vsixUrl } = await resolveExtension(trimmed)\n+  const vsix = await request(vsixUrl, { headers: { 'User-Agent': 'Hermes-Desktop' } })\n+  const themes = extractThemes(vsix)\n+\n+  return { extensionId: trimmed, displayName, themes }\n+}\n+\n+module.exports = {\n+  fetchMarketplaceThemes,\n+  searchMarketplaceThemes,\n+  extractThemes,\n+  readCentralDirectory,\n+  __testing: { themeEntryName, looksLikeIconTheme }\n+}"]},{"file":"apps/desktop/electron/vscode-marketplace.test.cjs","hunks":["@@ -0,0 +1,113 @@\n+'use strict'\n+\n+const assert = require('node:assert')\n+const test = require('node:test')\n+\n+const { __testing, extractThemes, readCentralDirectory } = require('./vscode-marketplace.cjs')\n+\n+// Build a minimal zip with stored (uncompressed) entries so the test controls\n+// the bytes exactly — exercises the central-directory reader + theme extraction\n+// without a deflate dependency.\n+function makeZip(entries) {\n+  const locals = []\n+  const centrals = []\n+  let offset = 0\n+\n+  for (const { name, data } of entries) {\n+    const nameBuf = Buffer.from(name, 'utf8')\n+    const body = Buffer.from(data, 'utf8')\n+\n+    const local = Buffer.alloc(30 + nameBuf.length)\n+    local.writeUInt32LE(0x04034b50, 0)\n+    local.writeUInt16LE(0, 8) // method: stored\n+    local.writeUInt32LE(body.length, 18) // compressed size\n+    local.writeUInt32LE(body.length, 22) // uncompressed size\n+    local.writeUInt16LE(nameBuf.length, 26)\n+    nameBuf.copy(local, 30)\n+\n+    locals.push(local, body)\n+\n+    const central = Buffer.alloc(46 + nameBuf.length)\n+    central.writeUInt32LE(0x02014b50, 0)\n+    central.writeUInt16LE(0, 10) // method: stored\n+    central.writeUInt32LE(body.length, 20)\n+    central.writeUInt32LE(body.length, 24)\n+    central.writeUInt16LE(nameBuf.length, 28)\n+    central.writeUInt32LE(offset, 42) // local header offset\n+    nameBuf.copy(central, 46)\n+\n+    centrals.push(central)\n+    offset += local.length + body.length\n+  }\n+\n+  const centralStart = offset\n+  const centralBuf = Buffer.concat(centrals)\n+\n+  const eocd = Buffer.alloc(22)\n+  eocd.writeUInt32LE(0x06054b50, 0)\n+  eocd.writeUInt16LE(entries.length, 8)\n+  eocd.writeUInt16LE(entries.length, 10)\n+  eocd.writeUInt32LE(centralBuf.length, 12)\n+  eocd.writeUInt32LE(centralStart, 16)\n+\n+  return Buffer.concat([...locals, centralBuf, eocd])\n+}\n+\n+test('readCentralDirectory finds every entry', () => {\n+  const zip = makeZip([\n+    { name: 'extension/package.json', data: '{}' },\n+    { name: 'extension/themes/x.json', data: '{}' }\n+  ])\n+\n+  const records = readCentralDirectory(zip)\n+  assert.ok(records.has('extension/package.json'))\n+  assert.ok(records.has('extension/themes/x.json'))\n+})\n+\n+test('extractThemes reads contributed color themes (resolving ./ paths)', () => {\n+  const pkg = JSON.stringify({\n+    name: 'theme-dracula',\n+    displayName: 'Dracula',\n+    contributes: {\n+      themes: [{ label: 'Dracula', uiTheme: 'vs-dark', path: './themes/dracula.json' }]\n+    }\n+  })\n+  const themeJson = JSON.stringify({ name: 'Dracula', type: 'dark', colors: { 'editor.background': '#282a36' } })\n+\n+  const zip = makeZip([\n+    { name: 'extension/package.json', data: pkg },\n+    { name: 'extension/themes/dracula.json', data: themeJson }\n+  ])\n+\n+  const themes = extractThemes(zip)\n+  assert.strictEqual(themes.length, 1)\n+  assert.strictEqual(themes[0].label, 'Dracula')\n+  assert.strictEqual(themes[0].uiTheme, 'vs-dark')\n+  assert.match(themes[0].contents, /editor\\.background/)\n+})\n+\n+test('extractThemes returns empty when the extension contributes no themes', () => {\n+  const zip = makeZip([{ name: 'extension/package.json', data: JSON.stringify({ name: 'x', contributes: {} }) }])\n+  assert.deepStrictEqual(extractThemes(zip), [])\n+})\n+\n+test('extractThemes throws when the manifest is missing', () => {\n+  const zip = makeZip([{ name: 'extension/other.txt', data: 'hi' }])\n+  assert.throws(() => extractThemes(zip), /manifest missing/i)\n+})\n+\n+test('looksLikeIconTheme filters icon/product-icon packs out of theme search', () => {\n+  const { looksLikeIconTheme } = __testing\n+\n+  // Tagged contribution points are the strongest signal.\n+  assert.strictEqual(looksLikeIconTheme({ tags: ['theme', 'icon-theme'] }), true)\n+  assert.strictEqual(looksLikeIconTheme({ tags: ['product-icon-theme'] }), true)\n+\n+  // Name/description fallback for packs that don't tag themselves.\n+  assert.strictEqual(looksLikeIconTheme({ displayName: 'Material Icon Theme' }), true)\n+  assert.strictEqual(looksLikeIconTheme({ shortDescription: 'A pack of file icons.' }), true)\n+\n+  // Real color themes survive.\n+  assert.strictEqual(looksLikeIconTheme({ displayName: 'Dracula Official', tags: ['theme', 'color-theme'] }), false)\n+  assert.strictEqual(looksLikeIconTheme({ displayName: 'One Dark Pro' }), false)\n+})"]},{"file":"apps/desktop/src/app/command-palette/index.tsx","hunks":["@@ -4,6 +4,7 @@ import { Dialog as DialogPrimitive } from 'radix-ui'\n import { useCallback, useEffect, useMemo, useState } from 'react'\n import { useNavigate } from 'react-router-dom'\n \n+import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud'\n import { setTerminalTakeover } from '@/app/right-sidebar/store'\n import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'\n import { KbdGroup } from '@/components/ui/kbd'","@@ -18,6 +19,7 @@ import {\n   ChevronRight,\n   Clock,\n   Cpu,\n+  Download,\n   Globe,\n   type IconComponent,\n   Info,","@@ -40,7 +42,9 @@ import { comboTokens } from '@/lib/keybinds/combo'\n import { cn } from '@/lib/utils'\n import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'\n import { $bindings } from '@/store/keybinds'\n+import { luminance } from '@/themes/color'\n import { type ThemeMode, useTheme } from '@/themes/context'\n+import { isUserTheme, resolveTheme } from '@/themes/user-themes'\n \n import {\n   AGENTS_ROUTE,","@@ -58,6 +62,8 @@ import { FIELD_LABELS, SECTIONS } from '../settings/constants'\n import { fieldCopyForSchemaKey } from '../settings/field-copy'\n import { prettyName } from '../settings/helpers'\n \n+import { MarketplaceThemePage } from './marketplace-theme-page'\n+\n interface PaletteItem {\n   /** Keybind action id — its live combo renders as a hotkey hint. */\n   action?: string","@@ -74,10 +80,16 @@ interface PaletteItem {\n }\n \n interface PaletteGroup {\n-  heading: string\n+  /** Optional: a headingless group renders as a bare action row (e.g. the\n+   *  \"Install theme…\" entry pinned atop the theme picker). */\n+  heading?: string\n   items: PaletteItem[]\n }\n \n+// Nested page → its parent, so Back / Esc step up one level instead of closing\n+// the palette. Pages absent here go straight back to the root list.\n+const PAGE_PARENTS: Record<string, string> = { 'install-theme': 'theme' }\n+\n /** A nested page reachable from a root item via `to`. */\n interface PalettePage {\n   groups: PaletteGroup[]","@@ -167,12 +179,32 @@ const THEME_MODES: ReadonlyArray<{ icon: IconComponent; mode: ThemeMode }> = [\n   { icon: Monitor, mode: 'system' }\n ]\n \n+// Which Light/Dark groups a theme belongs in. Built-ins render in both modes\n+// (the engine synthesises the missing side). Imported VS Code themes only carry\n+// the variant(s) the extension shipped — a single dark theme like Dracula lives\n+// under Dark only, while a GitHub/Solarized family (light + dark) lives in both.\n+function themeSupportsMode(name: string, target: 'light' | 'dark'): boolean {\n+  if (!isUserTheme(name)) {\n+    return true\n+  }\n+\n+  const resolved = resolveTheme(name)\n+\n+  if (!resolved) {\n+    return true\n+  }\n+\n+  const background = target === 'dark' ? (resolved.darkColors ?? resolved.colors).background : resolved.colors.background\n+\n+  return target === 'dark' ? luminance(background) <= 0.5 : luminance(background) > 0.5\n+}\n+\n export function CommandPalette() {\n   const { t } = useI18n()\n   const open = useStore($commandPaletteOpen)\n   const bindings = useStore($bindings)\n   const navigate = useNavigate()\n-  const { availableThemes, setMode, setTheme } = useTheme()\n+  const { availableThemes, resolvedMode, setMode, setTheme, themeName } = useTheme()\n   const [search, setSearch] = useState('')\n   const [page, setPage] = useState<string | null>(null)\n ","@@ -217,6 +249,13 @@ export function CommandPalette() {\n \n   const go = useCallback((path: string) => () => navigate(path), [navigate])\n \n+  // Step up one nested page (or back to the root list), clearing the filter so\n+  // the parent page doesn't reopen mid-search.\n+  const goBack = useCallback(() => {\n+    setSearch('')\n+    setPage(prev => (prev ? (PAGE_PARENTS[prev] ?? null) : null))\n+  }, [])\n+\n   const settingsSectionLabel = useCallback(\n     (section: (typeof SECTIONS)[number]) => t.settings.sections[section.id] ?? section.label,\n     [t.settings.sections]","@@ -438,23 +477,40 @@ export function CommandPalette() {\n       theme: {\n         title: t.settings.appearance.themeTitle,\n         placeholder: t.settings.appearance.themeDesc,\n-        // Skins aren't inherently light/dark — the same skin renders in either\n-        // mode. Group by appearance so picking an entry sets skin + mode at\n-        // once, and keep the palette open so each pick previews live.\n-        groups: (['light', 'dark'] as const).map(groupMode => ({\n-          heading: groupMode === 'light' ? t.settings.modeOptions.light.label : t.settings.modeOptions.dark.label,\n-          items: availableThemes.map(theme => ({\n-            icon: groupMode === 'light' ? Sun : Moon,\n-            id: `theme-${theme.name}-${groupMode}`,\n-            keepOpen: true,\n-            keywords: ['theme', 'appearance', 'palette', groupMode, theme.label, theme.description ?? ''],\n-            label: theme.label,\n-            run: () => {\n-              setTheme(theme.name)\n-              setMode(groupMode)\n-            }\n+        groups: [\n+          // Pinned at the top: drills into the Marketplace browser.\n+          {\n+            items: [\n+              {\n+                icon: Download,\n+                id: 'theme-install',\n+                keywords: ['install', 'marketplace', 'vscode', 'vs code', 'download', 'new', 'color'],\n+                label: t.commandCenter.installTheme.title,\n+                to: 'install-theme'\n+              }\n+            ]\n+          },\n+          // Built-ins and imported families list under the mode(s) they support;\n+          // picking sets skin + mode at once. A multi-variant import (GitHub,\n+          // Solarized) appears in both groups and switches variants with the mode.\n+          ...(['light', 'dark'] as const).map(groupMode => ({\n+            heading: groupMode === 'light' ? t.settings.modeOptions.light.label : t.settings.modeOptions.dark.label,\n+            items: availableThemes\n+              .filter(theme => themeSupportsMode(theme.name, groupMode))\n+              .map(theme => ({\n+                active: themeName === theme.name && resolvedMode === groupMode,\n+                icon: groupMode === 'light' ? Sun : Moon,\n+                id: `theme-${theme.name}-${groupMode}`,\n+                keepOpen: true,\n+                keywords: ['theme', 'appearance', 'palette', groupMode, theme.label, theme.description ?? ''],\n+                label: theme.label,\n+                run: () => {\n+                  setTheme(theme.name)\n+                  setMode(groupMode)\n+                }\n+              }))\n           }))\n-        }))\n+        ]\n       },\n       'color-mode': {\n         title: t.settings.appearance.colorMode,","@@ -472,9 +528,16 @@ export function CommandPalette() {\n             }))\n           }\n         ]\n+      },\n+      // Server-driven page: items come from the Marketplace, rendered by\n+      // <MarketplaceThemePage> (loader + live search + per-row install).\n+      'install-theme': {\n+        title: t.commandCenter.installTheme.title,\n+        placeholder: t.commandCenter.installTheme.placeholder,\n+        groups: []\n       }\n     }),\n-    [availableThemes, setMode, setTheme, t]\n+    [availableThemes, resolvedMode, setMode, setTheme, t, themeName]\n   )\n \n   const activePage = page ? subPages[page] : null","@@ -499,17 +562,22 @@ export function CommandPalette() {\n   return (\n     <DialogPrimitive.Root onOpenChange={setCommandPaletteOpen} open={open}>\n       <DialogPrimitive.Portal>\n-        <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\" />\n+        {/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */}\n+        <DialogPrimitive.Overlay className=\"fixed inset-0 z-[200]\" />\n         <DialogPrimitive.Content\n           aria-describedby={undefined}\n-          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\"\n+          className={cn(\n+            HUD_POSITION,\n+            HUD_SURFACE,\n+            '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'\n+          )}\n         >\n           <DialogPrimitive.Title className=\"sr-only\">{t.commandCenter.paletteTitle}</DialogPrimitive.Title>\n           <Command className=\"bg-transparent\" filter={paletteFilter} loop>\n             {activePage && (\n               <button\n                 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\"\n-                onClick={() => setPage(null)}\n+                onClick={goBack}\n                 type=\"button\"\n               >\n                 <ChevronLeft className=\"size-3.5\" />","@@ -519,6 +587,7 @@ export function CommandPalette() {\n               </button>\n             )}\n             <CommandInput\n+              className={HUD_TEXT}\n               onKeyDown={event => {\n                 if (!activePage) {\n                   return","@@ -529,20 +598,24 @@ export function CommandPalette() {\n                 if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) {\n                   event.preventDefault()\n                   event.stopPropagation()\n-                  setPage(null)\n+                  goBack()\n                 }\n               }}\n               onValueChange={setSearch}\n               placeholder={placeholder}\n               value={search}\n             />\n-            <CommandList className=\"max-h-[min(24rem,60vh)]\">\n-              <CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>\n-              {visibleGroups.map(group => (\n+            <CommandList className=\"dt-portal-scrollbar max-h-[min(20rem,56vh)]\">\n+              {page === 'install-theme' ? (\n+                <MarketplaceThemePage onPickTheme={setTheme} search={search} />\n+              ) : (\n+                <CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>\n+              )}\n+              {visibleGroups.map((group, index) => (\n                 <CommandGroup\n-                  className=\"**:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-wider **:[[cmdk-group-heading]]:text-[0.6875rem] **:[[cmdk-group-heading]]:text-muted-foreground/70\"\n+                  className={HUD_HEADING}\n                   heading={group.heading}\n-                  key={group.heading}\n+                  key={group.heading ?? `palette-group-${index}`}\n                 >\n                   {group.items.map(item => {\n                     const Icon = item.icon","@@ -551,18 +624,18 @@ export function CommandPalette() {\n \n                     return (\n                       <CommandItem\n-                        className=\"gap-2.5\"\n+                        className={cn(HUD_ITEM, HUD_TEXT)}\n                         key={item.id}\n                         keywords={item.keywords}\n                         onSelect={() => handleSelect(item)}\n                         value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`}\n                       >\n-                        <Icon className=\"size-4 shrink-0 text-muted-foreground\" />\n+                        <Icon className=\"size-3.5 shrink-0 text-muted-foreground\" />\n                         <span className=\"truncate\">{item.label}</span>\n                         {keys && <KbdGroup className=\"ml-auto\" keys={keys} />}\n                         {item.to && (\n                           <ChevronRight\n-                            className={cn('size-4 shrink-0 text-muted-foreground/70', !keys && 'ml-auto')}\n+                            className={cn('size-3.5 shrink-0 text-muted-foreground/70', !keys && 'ml-auto')}\n                           />\n                         )}\n                       </CommandItem>"]},{"file":"apps/desktop/src/app/command-palette/marketplace-theme-page.tsx","hunks":["@@ -0,0 +1,157 @@\n+/**\n+ * Cmd-K \"Install theme…\" page.\n+ *\n+ * Browses the VS Code Marketplace for color themes: an empty query shows the\n+ * most-installed themes, typing runs a live (debounced) search against the\n+ * Marketplace. Selecting a row downloads + converts + installs it via the same\n+ * pipeline as the settings importer, then activates it — and stays open so the\n+ * user can grab several.\n+ */\n+\n+import { useQuery } from '@tanstack/react-query'\n+import { useEffect, useState } from 'react'\n+\n+import { HUD_ITEM, HUD_TEXT } from '@/app/floating-hud'\n+import type { DesktopMarketplaceSearchItem } from '@/global'\n+import { useI18n } from '@/i18n'\n+import { triggerHaptic } from '@/lib/haptics'\n+import { Check, Download, Loader2, Palette } from '@/lib/icons'\n+import { cn } from '@/lib/utils'\n+import { installVscodeThemeFromMarketplace } from '@/themes/install'\n+\n+const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 })\n+\n+function useDebounced<T>(value: T, delayMs: number): T {\n+  const [debounced, setDebounced] = useState(value)\n+\n+  useEffect(() => {\n+    const handle = setTimeout(() => setDebounced(value), delayMs)\n+\n+    return () => clearTimeout(handle)\n+  }, [value, delayMs])\n+\n+  return debounced\n+}\n+\n+interface MarketplaceThemePageProps {\n+  search: string\n+  /** Activate a freshly installed theme by slug. */\n+  onPickTheme: (name: string) => void\n+}\n+\n+export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePageProps) {\n+  const { t } = useI18n()\n+  const copy = t.commandCenter.installTheme\n+  const debouncedSearch = useDebounced(search.trim(), 300)\n+  const [installingId, setInstallingId] = useState<string | null>(null)\n+  const [installed, setInstalled] = useState<Record<string, true>>({})\n+  const [installError, setInstallError] = useState<string | null>(null)\n+\n+  const query = useQuery({\n+    queryKey: ['marketplace-themes', debouncedSearch],\n+    queryFn: () => window.hermesDesktop?.themes?.searchMarketplace(debouncedSearch) ?? Promise.resolve([]),\n+    staleTime: 5 * 60 * 1000\n+  })\n+\n+  const install = async (item: DesktopMarketplaceSearchItem) => {\n+    if (installingId) {\n+      return\n+    }\n+\n+    setInstallingId(item.extensionId)\n+    setInstallError(null)\n+\n+    try {\n+      const theme = await installVscodeThemeFromMarketplace(item.extensionId)\n+\n+      triggerHaptic('crisp')\n+      setInstalled(prev => ({ ...prev, [item.extensionId]: true }))\n+      onPickTheme(theme.name)\n+    } catch (error) {\n+      setInstallError(error instanceof Error ? error.message : copy.error)\n+    } finally {\n+      setInstallingId(null)\n+    }\n+  }\n+\n+  if (query.isLoading) {\n+    return <Status icon={<Loader2 className=\"size-3.5 animate-spin\" />} text={copy.loading} />\n+  }\n+\n+  if (query.isError) {\n+    return <Status text={copy.error} tone=\"error\" />\n+  }\n+\n+  const results = query.data ?? []\n+\n+  if (results.length === 0) {\n+    return <Status text={copy.empty} />\n+  }\n+\n+  return (\n+    <div role=\"listbox\">\n+      {installError && <p className=\"px-2 pb-1 pt-1.5 text-[0.6875rem] text-(--ui-red)\">{installError}</p>}\n+      {results.map(item => {\n+        const busy = installingId === item.extensionId\n+        const done = installed[item.extensionId]\n+\n+        return (\n+          <button\n+            className={cn(\n+              'flex w-full items-start rounded-md text-left transition-colors hover:bg-(--chrome-action-hover) disabled:opacity-60 aria-disabled:opacity-60',\n+              HUD_ITEM,\n+              HUD_TEXT\n+            )}\n+            disabled={Boolean(installingId) && !busy}\n+            key={item.extensionId}\n+            onClick={() => void install(item)}\n+            onMouseDown={event => event.preventDefault()}\n+            role=\"option\"\n+            type=\"button\"\n+          >\n+            <Palette className=\"mt-0.5 size-3.5 shrink-0 text-muted-foreground\" />\n+            <span className=\"flex min-w-0 flex-col\">\n+              <span className=\"truncate font-medium\">{item.displayName}</span>\n+              <span className=\"truncate text-[0.6875rem] text-muted-foreground/80\">\n+                {item.publisher}\n+                {item.installs > 0 ? ` · ${copy.installs(compactNumber.format(item.installs))}` : ''}\n+              </span>\n+            </span>\n+            <span className=\"ml-auto mt-0.5 flex shrink-0 items-center gap-1 text-[0.6875rem] text-muted-foreground\">\n+              {busy ? (\n+                <>\n+                  <Loader2 className=\"size-3 animate-spin\" />\n+                  {copy.installing}\n+                </>\n+              ) : done ? (\n+                <>\n+                  <Check className=\"size-3 text-(--ui-green)\" />\n+                  {copy.installed}\n+                </>\n+              ) : (\n+                <>\n+                  <Download className=\"size-3\" />\n+                  {copy.install}\n+                </>\n+              )}\n+            </span>\n+          </button>\n+        )\n+      })}\n+    </div>\n+  )\n+}\n+\n+function Status({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) {\n+  return (\n+    <div\n+      className={cn(\n+        'flex items-center justify-center gap-2 px-2 py-6 text-xs',\n+        tone === 'error' ? 'text-(--ui-red)' : 'text-muted-foreground'\n+      )}\n+    >\n+      {icon}\n+      {text}\n+    </div>\n+  )\n+}"]},{"file":"apps/desktop/src/app/floating-hud.ts","hunks":["@@ -0,0 +1,22 @@\n+// Shared chrome for the top-center floating HUDs (command palette + session\n+// switcher). They pin just under the title bar, centered, and lean on a crisp\n+// border + shadow to separate from the app — no dimming/blurring backdrop.\n+// Each caller layers on its own z-index, width, and overflow.\n+export const HUD_POSITION = 'fixed left-1/2 top-3 -translate-x-1/2'\n+\n+// Matches the app's borderless-overlay surface (dialog, keybind panel, …):\n+// hairline `--stroke-nous` paired with the soft `--shadow-nous` float.\n+export const HUD_SURFACE = 'rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous'\n+\n+// One row/text size for both HUDs (compact — two notches under `text-sm`).\n+export const HUD_TEXT = 'text-xs'\n+\n+// Shared item layout + padding for both HUDs. Tight vertical rhythm so rows\n+// don't feel chunky; overrides the shadcn `CommandItem` default (`px-2 py-1.5`).\n+export const HUD_ITEM = 'gap-2 px-2 py-1'\n+\n+// Section headings styled like the sidebar panel labels: brand-tinted, uppercase,\n+// tightly tracked — plain text, no sticky chrome bar. Targets the cmdk group\n+// heading via the universal-descendant variant.\n+export const HUD_HEADING =\n+  '**:[[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)'"]},{"file":"apps/desktop/src/app/right-sidebar/terminal/selection.ts","hunks":["@@ -1,6 +1,8 @@\n import type { ITheme, Terminal } from '@xterm/xterm'\n import type { CSSProperties } from 'react'\n \n+import type { DesktopTerminalPalette } from '@/themes/types'\n+\n // VS Code's default integrated-terminal palette (terminalColorRegistry.ts) — a\n // fixed table per theme type, not luminance-derived. Light/dark diverge on\n // purpose so each stays legible (e.g. mustard yellow on white).","@@ -52,9 +54,29 @@ const LIGHT_THEME: ITheme = {\n   brightWhite: '#a5a5a5'\n }\n \n-// Palette by painted mode. `background` is only a fallback — withSurface swaps\n-// in the live skin surface at runtime; minimumContrastRatio keeps colors crisp.\n-export const terminalTheme = (mode: 'light' | 'dark'): ITheme => (mode === 'dark' ? DARK_THEME : LIGHT_THEME)\n+// Palette by painted mode, optionally overlaid with an imported theme's ANSI\n+// palette (Solarized terminal for the Solarized skin, etc.). `palette` only\n+// fills the slots it defines, so a partial import keeps the mode defaults for\n+// the rest. `background` is a fallback only — withSurface swaps in the live skin\n+// surface at runtime (keeping transparency); minimumContrastRatio keeps colors\n+// crisp against it.\n+export function terminalTheme(mode: 'light' | 'dark', palette?: DesktopTerminalPalette): ITheme {\n+  const base = mode === 'dark' ? DARK_THEME : LIGHT_THEME\n+\n+  if (!palette) {\n+    return base\n+  }\n+\n+  const overlay = { ...base } as Record<string, string>\n+\n+  for (const [slot, value] of Object.entries(palette)) {\n+    if (value) {\n+      overlay[slot] = value\n+    }\n+  }\n+\n+  return overlay as ITheme\n+}\n \n // Resolve --ui-editor-surface-background (a color-mix on the skin seed) to a\n // concrete rgb for the WebGL renderer + contrast clamp. Custom props don't"]},{"file":"apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts","hunks":["@@ -223,8 +223,13 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes\n   // clicked switch) — a skin can keep a light surface in \"dark\" mode, and we\n   // must match the surface or the ANSI palette inverts against it. themeName\n   // re-resolves the canvas surface on skin switches (same mode, new tint).\n-  const { renderedMode, themeName } = useTheme()\n-  const activeTheme = useMemo(() => terminalTheme(renderedMode), [renderedMode])\n+  const { renderedMode, theme, themeName } = useTheme()\n+  // Adopt the skin's ANSI palette when it ships one (imported VS Code themes do),\n+  // matched to the painted variant; built-in skins carry none, so the terminal\n+  // keeps its VS Code defaults. withSurface still owns the background, so this\n+  // never touches transparency.\n+  const ansiPalette = renderedMode === 'dark' ? (theme.darkTerminal ?? theme.terminal) : theme.terminal\n+  const activeTheme = useMemo(() => terminalTheme(renderedMode, ansiPalette), [renderedMode, ansiPalette])\n   const initialThemeRef = useRef(activeTheme)\n   const hostRef = useRef<HTMLDivElement | null>(null)\n   const termRef = useRef<Terminal | null>(null)"]},{"file":"apps/desktop/src/app/session-switcher.tsx","hunks":["@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils'\n import { $attentionSessionIds, $workingSessionIds } from '@/store/session'\n import { $switcherIndex, $switcherOpen, $switcherSessions, closeSwitcher } from '@/store/session-switcher'\n \n+import { HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from './floating-hud'\n import { sessionRoute } from './routes'\n \n // Compact session-switcher HUD — keyboard-driven from `use-keybinds`, rows","@@ -39,22 +40,31 @@ export function SessionSwitcher() {\n   }\n \n   return createPortal(\n-    <div className=\"fixed inset-0 z-[220] flex select-none items-center justify-center\">\n+    <>\n+      {/* Transparent click-catcher: click-away closes, but no dim/blur. */}\n       <div\n-        className=\"absolute inset-0 bg-black/15 backdrop-blur-[1px]\"\n+        className=\"fixed inset-0 z-[219]\"\n         onMouseDown={e => {\n           e.preventDefault()\n           closeSwitcher()\n         }}\n       />\n-      <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\">\n+      <div\n+        className={cn(\n+          HUD_POSITION,\n+          HUD_SURFACE,\n+          'dt-portal-scrollbar z-[220] max-h-[min(22rem,64vh)] w-[min(19rem,calc(100vw-2rem))] select-none overflow-y-auto p-1'\n+        )}\n+      >\n         {sessions.map((session, i) => {\n           const selected = i === index\n \n           return (\n             <div\n               className={cn(\n-                'flex cursor-pointer items-center gap-2 rounded px-1.5 py-1 text-xs leading-tight',\n+                'flex cursor-pointer items-center rounded leading-tight',\n+                HUD_ITEM,\n+                HUD_TEXT,\n                 selected ? 'bg-accent text-accent-foreground' : 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background)'\n               )}\n               key={session.id}","@@ -80,7 +90,7 @@ export function SessionSwitcher() {\n           )\n         })}\n       </div>\n-    </div>,\n+    </>,\n     document.body\n   )\n }"]},{"file":"apps/desktop/src/app/settings/appearance-settings.tsx","hunks":["@@ -1,21 +1,23 @@\n import { useStore } from '@nanostores/react'\n+import { useState } from 'react'\n \n import { LanguageSwitcher } from '@/components/language-switcher'\n import { SegmentedControl } from '@/components/ui/segmented-control'\n import { useI18n } from '@/i18n'\n import { triggerHaptic } from '@/lib/haptics'\n-import { Check, Palette } from '@/lib/icons'\n+import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons'\n import { cn } from '@/lib/utils'\n import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'\n import { $toolViewMode, setToolViewMode } from '@/store/tool-view'\n import { useTheme } from '@/themes/context'\n-import { BUILTIN_THEMES } from '@/themes/presets'\n+import { installVscodeThemeFromMarketplace } from '@/themes/install'\n+import { isUserTheme, removeUserTheme, resolveTheme } from '@/themes/user-themes'\n \n import { MODE_OPTIONS } from './constants'\n import { ListRow, SectionHeading, SettingsContent } from './primitives'\n \n function ThemePreview({ name }: { name: string }) {\n-  const t = BUILTIN_THEMES[name]\n+  const t = resolveTheme(name)\n \n   if (!t) {\n     return null","@@ -54,6 +56,81 @@ function ThemePreview({ name }: { name: string }) {\n   )\n }\n \n+function VscodeThemeInstaller() {\n+  const { t } = useI18n()\n+  const { setTheme } = useTheme()\n+  const a = t.settings.appearance\n+  const [id, setId] = useState('')\n+  const [busy, setBusy] = useState(false)\n+  const [status, setStatus] = useState<{ kind: 'error' | 'success'; text: string } | null>(null)\n+\n+  const install = async () => {\n+    const trimmed = id.trim()\n+\n+    if (!trimmed || busy) {\n+      return\n+    }\n+\n+    setBusy(true)\n+    setStatus(null)\n+\n+    try {\n+      const theme = await installVscodeThemeFromMarketplace(trimmed)\n+\n+      triggerHaptic('crisp')\n+      setTheme(theme.name)\n+      setStatus({ kind: 'success', text: a.installed(theme.label) })\n+      setId('')\n+    } catch (error) {\n+      setStatus({ kind: 'error', text: error instanceof Error ? error.message : a.installError })\n+    } finally {\n+      setBusy(false)\n+    }\n+  }\n+\n+  return (\n+    <div className=\"mt-3\">\n+      <div className=\"flex flex-wrap items-center gap-2\">\n+        <input\n+          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)\"\n+          disabled={busy}\n+          onChange={event => {\n+            setId(event.target.value)\n+            setStatus(null)\n+          }}\n+          onKeyDown={event => {\n+            if (event.key === 'Enter') {\n+              void install()\n+            }\n+          }}\n+          placeholder={a.installPlaceholder}\n+          spellCheck={false}\n+          value={id}\n+        />\n+        <button\n+          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\"\n+          disabled={busy || !id.trim()}\n+          onClick={() => void install()}\n+          type=\"button\"\n+        >\n+          {busy ? <Loader2 className=\"size-3.5 animate-spin\" /> : <Download className=\"size-3.5\" />}\n+          {busy ? a.installing : a.installButton}\n+        </button>\n+      </div>\n+      {status && (\n+        <p\n+          className={cn(\n+            'mt-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height)',\n+            status.kind === 'error' ? 'text-(--ui-red)' : 'text-(--ui-text-tertiary)'\n+          )}\n+        >\n+          {status.text}\n+        </p>\n+      )}\n+    </div>\n+  )\n+}\n+\n export function AppearanceSettings() {\n   const { t, isSavingLocale } = useI18n()\n   const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()","@@ -112,40 +189,62 @@ export function AppearanceSettings() {\n                 <div className=\"mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3\">\n                   {availableThemes.map(theme => {\n                     const active = themeName === theme.name\n+                    const removable = isUserTheme(theme.name)\n \n                     return (\n-                      <button\n-                        className={cn(\n-                          'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',\n-                          active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'\n-                        )}\n-                        key={theme.name}\n-                        onClick={() => {\n-                          triggerHaptic('crisp')\n-                          setTheme(theme.name)\n-                        }}\n-                        type=\"button\"\n-                      >\n-                        <ThemePreview name={theme.name} />\n-                        <div className=\"mt-3 flex items-start justify-between gap-3 px-1\">\n-                          <div className=\"min-w-0\">\n-                            <div className=\"truncate text-[length:var(--conversation-text-font-size)] font-medium\">\n-                              {theme.label}\n-                            </div>\n-                            <div className=\"mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)\">\n-                              {theme.description}\n+                      <div className=\"group relative\" key={theme.name}>\n+                        <button\n+                          className={cn(\n+                            'w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',\n+                            active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'\n+                          )}\n+                          onClick={() => {\n+                            triggerHaptic('crisp')\n+                            setTheme(theme.name)\n+                          }}\n+                          type=\"button\"\n+                        >\n+                          <ThemePreview name={theme.name} />\n+                          <div className=\"mt-3 flex items-start justify-between gap-3 px-1\">\n+                            <div className=\"min-w-0\">\n+                              <div className=\"truncate text-[length:var(--conversation-text-font-size)] font-medium\">\n+                                {theme.label}\n+                              </div>\n+                              <div className=\"mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)\">\n+                                {theme.description}\n+                              </div>\n                             </div>\n+                            {active && (\n+                              <span className=\"mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground\">\n+                                <Check className=\"size-3.5\" />\n+                              </span>\n+                            )}\n                           </div>\n-                          {active && (\n-                            <span className=\"mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground\">\n-                              <Check className=\"size-3.5\" />\n-                            </span>\n-                          )}\n-                        </div>\n-                      </button>\n+                        </button>\n+                        {removable && (\n+                          <button\n+                            aria-label={a.removeTheme}\n+                            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\"\n+                            onClick={() => {\n+                              triggerHaptic('crisp')\n+                              removeUserTheme(theme.name)\n+\n+                              // Re-normalize off the now-missing skin → default.\n+                              if (active) {\n+                                setTheme(theme.name)\n+                              }\n+                            }}\n+                            title={a.removeTheme}\n+                            type=\"button\"\n+                          >\n+                            <Trash2 className=\"size-3.5\" />\n+                          </button>\n+                        )}\n+                      </div>\n                     )\n                   })}\n                 </div>\n+                <VscodeThemeInstaller />\n                 {showProfileNote && (\n                   <p className=\"mt-3 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)\">\n                     {a.themeProfileNote(activeProfileName)}"]},{"file":"apps/desktop/src/global.d.ts","hunks":["@@ -96,10 +96,40 @@ declare global {\n         summary: () => Promise<DesktopUninstallSummary>\n         run: (mode: DesktopUninstallMode) => Promise<DesktopUninstallResult>\n       }\n+      themes: {\n+        // Download a VS Code Marketplace extension and return the raw color\n+        // theme files it contributes. The renderer converts + persists them.\n+        fetchMarketplace: (id: string) => Promise<DesktopMarketplaceThemeResult>\n+        // Search the Marketplace for color-theme extensions. An empty query\n+        // returns the most-installed themes.\n+        searchMarketplace: (query: string) => Promise<DesktopMarketplaceSearchItem[]>\n+      }\n     }\n   }\n }\n \n+export interface DesktopMarketplaceSearchItem {\n+  extensionId: string\n+  displayName: string\n+  publisher: string\n+  description: string\n+  installs: number\n+}\n+\n+export interface DesktopMarketplaceThemeFile {\n+  label: string\n+  /** VS Code's `uiTheme` for this entry (vs-dark / vs / hc-black). */\n+  uiTheme?: string\n+  /** Raw theme JSON (JSONC) text, parsed + converted by the renderer. */\n+  contents: string\n+}\n+\n+export interface DesktopMarketplaceThemeResult {\n+  extensionId: string\n+  displayName: string\n+  themes: DesktopMarketplaceThemeFile[]\n+}\n+\n export interface HermesTerminalSession {\n   cwd: string\n   id: string"]},{"file":"apps/desktop/src/i18n/en.ts","hunks":["@@ -302,7 +302,17 @@ export const en: Translations = {\n       technicalDesc: 'Include raw tool args/results and low-level details.',\n       themeTitle: 'Theme',\n       themeDesc: 'Desktop palettes only. The selected mode is applied on top.',\n-      themeProfileNote: profile => `Saved for the ${profile} profile — each profile keeps its own theme.`\n+      themeProfileNote: profile => `Saved for the ${profile} profile — each profile keeps its own theme.`,\n+      installTitle: 'Install from VS Code',\n+      installDesc:\n+        'Paste a Marketplace extension id (e.g. dracula-theme.theme-dracula) to convert its color theme into a desktop palette.',\n+      installPlaceholder: 'publisher.extension',\n+      installButton: 'Install',\n+      installing: 'Installing…',\n+      installError: 'Could not install that theme.',\n+      installed: name => `Installed “${name}”.`,\n+      removeTheme: 'Remove theme',\n+      importedBadge: 'Imported'\n     },\n     fieldLabels: FIELD_LABELS,\n     fieldDescriptions: FIELD_DESCRIPTIONS,","@@ -636,6 +646,17 @@ export const en: Translations = {\n     settings: 'Settings',\n     changeTheme: 'Change theme...',\n     changeColorMode: 'Change color mode...',\n+    installTheme: {\n+      title: 'Install theme...',\n+      placeholder: 'Search the VS Code Marketplace...',\n+      loading: 'Searching the Marketplace...',\n+      error: 'Could not reach the Marketplace.',\n+      empty: 'No matching themes.',\n+      install: 'Install',\n+      installing: 'Installing...',\n+      installed: 'Installed',\n+      installs: count => `${count} installs`\n+    },\n     settingsFields: 'Settings fields',\n     mcpServers: 'MCP servers',\n     archivedChats: 'Archived chats',"]},{"file":"apps/desktop/src/i18n/ja.ts","hunks":["@@ -216,7 +216,16 @@ export const ja = defineLocale({\n       technicalDesc: '生のツール引数、結果、低レベルの詳細を含めます。',\n       themeTitle: 'テーマ',\n       themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。',\n-      themeProfileNote: profile => `「${profile}」プロファイルに保存されます。プロファイルごとに個別のテーマを保持します。`\n+      themeProfileNote: profile => `「${profile}」プロファイルに保存されます。プロファイルごとに個別のテーマを保持します。`,\n+      installTitle: 'VS Code から導入',\n+      installDesc: 'Marketplace の拡張機能 ID（例: dracula-theme.theme-dracula）を貼り付けると、その配色テーマをデスクトップ用パレットに変換します。',\n+      installPlaceholder: 'publisher.extension',\n+      installButton: 'インストール',\n+      installing: 'インストール中…',\n+      installError: 'そのテーマをインストールできませんでした。',\n+      installed: name => `「${name}」をインストールしました。`,\n+      removeTheme: 'テーマを削除',\n+      importedBadge: 'インポート済み'\n     },\n     fieldLabels: defineFieldCopy({\n       model: 'デフォルトモデル',","@@ -762,6 +771,17 @@ export const ja = defineLocale({\n     settings: '設定',\n     changeTheme: 'テーマを変更...',\n     changeColorMode: 'カラーモードを変更...',\n+    installTheme: {\n+      title: 'テーマをインストール...',\n+      placeholder: 'VS Code Marketplace を検索...',\n+      loading: 'Marketplace を検索中...',\n+      error: 'Marketplace に接続できませんでした。',\n+      empty: '一致するテーマがありません。',\n+      install: 'インストール',\n+      installing: 'インストール中...',\n+      installed: 'インストール済み',\n+      installs: count => `${count} 回インストール`\n+    },\n     settingsFields: '設定フィールド',\n     mcpServers: 'MCP サーバー',\n     archivedChats: 'アーカイブ済みチャット',"]},{"file":"apps/desktop/src/i18n/types.ts","hunks":["@@ -220,6 +220,15 @@ export interface Translations {\n       themeTitle: string\n       themeDesc: string\n       themeProfileNote: (profile: string) => string\n+      installTitle: string\n+      installDesc: string\n+      installPlaceholder: string\n+      installButton: string\n+      installing: string\n+      installError: string\n+      installed: (name: string) => string\n+      removeTheme: string\n+      importedBadge: string\n     }\n     fieldLabels: Record<string, string>\n     fieldDescriptions: Record<string, string>","@@ -534,6 +543,17 @@ export interface Translations {\n     settings: string\n     changeTheme: string\n     changeColorMode: string\n+    installTheme: {\n+      title: string\n+      placeholder: string\n+      loading: string\n+      error: string\n+      empty: string\n+      install: string\n+      installing: string\n+      installed: string\n+      installs: (count: string) => string\n+    }\n     settingsFields: string\n     mcpServers: string\n     archivedChats: string"]},{"file":"apps/desktop/src/i18n/zh-hant.ts","hunks":["@@ -210,7 +210,16 @@ export const zhHant = defineLocale({\n       technicalDesc: '包含原始工具參數、結果與底層細節。',\n       themeTitle: '主題',\n       themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。',\n-      themeProfileNote: profile => `已為「${profile}」設定檔儲存——每個設定檔保留各自的主題。`\n+      themeProfileNote: profile => `已為「${profile}」設定檔儲存——每個設定檔保留各自的主題。`,\n+      installTitle: '從 VS Code 安裝',\n+      installDesc: '貼上 Marketplace 擴充功能 ID（例如 dracula-theme.theme-dracula），將其配色主題轉換為桌面調色盤。',\n+      installPlaceholder: 'publisher.extension',\n+      installButton: '安裝',\n+      installing: '安裝中…',\n+      installError: '無法安裝該主題。',\n+      installed: name => `已安裝「${name}」。`,\n+      removeTheme: '移除主題',\n+      importedBadge: '已匯入'\n     },\n     fieldLabels: defineFieldCopy({\n       model: '預設模型',","@@ -745,6 +754,17 @@ export const zhHant = defineLocale({\n     settings: '設定',\n     changeTheme: '變更主題...',\n     changeColorMode: '變更色彩模式...',\n+    installTheme: {\n+      title: '安裝主題...',\n+      placeholder: '搜尋 VS Code Marketplace...',\n+      loading: '正在搜尋 Marketplace...',\n+      error: '無法連接到 Marketplace。',\n+      empty: '沒有符合的主題。',\n+      install: '安裝',\n+      installing: '安裝中...',\n+      installed: '已安裝',\n+      installs: count => `${count} 次安裝`\n+    },\n     settingsFields: '設定欄位',\n     mcpServers: 'MCP 伺服器',\n     archivedChats: '已封存聊天',"]},{"file":"apps/desktop/src/i18n/zh.ts","hunks":["@@ -297,7 +297,16 @@ export const zh: Translations = {\n       technicalDesc: '包含原始工具参数/结果及底层细节。',\n       themeTitle: '主题',\n       themeDesc: '仅桌面端调色板。所选模式叠加其上。',\n-      themeProfileNote: profile => `已为「${profile}」配置文件保存——每个配置文件保留各自的主题。`\n+      themeProfileNote: profile => `已为「${profile}」配置文件保存——每个配置文件保留各自的主题。`,\n+      installTitle: '从 VS Code 安装',\n+      installDesc: '粘贴 Marketplace 扩展 ID（例如 dracula-theme.theme-dracula），将其配色主题转换为桌面调色板。',\n+      installPlaceholder: 'publisher.extension',\n+      installButton: '安装',\n+      installing: '安装中…',\n+      installError: '无法安装该主题。',\n+      installed: name => `已安装「${name}」。`,\n+      removeTheme: '移除主题',\n+      importedBadge: '已导入'\n     },\n     fieldLabels: defineFieldCopy({\n       model: '默认模型',","@@ -829,6 +838,17 @@ export const zh: Translations = {\n     settings: '设置',\n     changeTheme: '更改主题...',\n     changeColorMode: '更改颜色模式...',\n+    installTheme: {\n+      title: '安装主题...',\n+      placeholder: '搜索 VS Code Marketplace...',\n+      loading: '正在搜索 Marketplace...',\n+      error: '无法连接到 Marketplace。',\n+      empty: '没有匹配的主题。',\n+      install: '安装',\n+      installing: '安装中...',\n+      installed: '已安装',\n+      installs: count => `${count} 次安装`\n+    },\n     settingsFields: '设置字段',\n     mcpServers: 'MCP 服务器',\n     archivedChats: '已归档对话',"]},{"file":"apps/desktop/src/themes/color.ts","hunks":["@@ -0,0 +1,142 @@\n+/**\n+ * Small color helpers shared by the theme context (synthesised light variants)\n+ * and the VS Code theme converter (token → seed mapping).\n+ *\n+ * Everything works in 6-digit `#rrggbb`. `normalizeHex` is the front door for\n+ * untrusted input (VS Code themes use `#rgb`, `#rgba`, `#rrggbbaa`, and named\n+ * tokens), flattening alpha over a backdrop so downstream math stays simple.\n+ */\n+\n+export function hexToRgb(hex: string): [number, number, number] | null {\n+  const clean = hex.trim().replace(/^#/, '')\n+\n+  if (!/^[0-9a-f]{6}$/i.test(clean)) {\n+    return null\n+  }\n+\n+  return [0, 2, 4].map(i => parseInt(clean.slice(i, i + 2), 16)) as [number, number, number]\n+}\n+\n+export const rgbToHex = ([r, g, b]: [number, number, number]): string =>\n+  `#${[r, g, b].map(n => Math.round(Math.min(255, Math.max(0, n))).toString(16).padStart(2, '0')).join('')}`\n+\n+export function mix(a: string, b: string, amount: number): string {\n+  const ar = hexToRgb(a)\n+  const br = hexToRgb(b)\n+\n+  return ar && br\n+    ? rgbToHex([ar[0] + (br[0] - ar[0]) * amount, ar[1] + (br[1] - ar[1]) * amount, ar[2] + (br[2] - ar[2]) * amount])\n+    : a\n+}\n+\n+const linearize = (channel: number): number =>\n+  channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4\n+\n+/** WCAG relative luminance (gamma-corrected), 0..1. */\n+export function relativeLuminance(hex: string): number {\n+  const rgb = hexToRgb(hex)\n+\n+  if (!rgb) {\n+    return 0\n+  }\n+\n+  const [r, g, b] = rgb.map(v => linearize(v / 255))\n+\n+  return 0.2126 * r + 0.7152 * g + 0.0722 * b\n+}\n+\n+/** WCAG contrast ratio (1..21) between two hex colors. */\n+export function contrastRatio(a: string, b: string): number {\n+  const la = relativeLuminance(a)\n+  const lb = relativeLuminance(b)\n+\n+  return la >= lb ? (la + 0.05) / (lb + 0.05) : (lb + 0.05) / (la + 0.05)\n+}\n+\n+/** Returns a readable foreground (#161616 or #ffffff) for a background hex. */\n+export function readableOn(hex: string): string {\n+  return relativeLuminance(hex) > 0.58 ? '#161616' : '#ffffff'\n+}\n+\n+/**\n+ * Guarantee `color` reads against `bg`: if it's below `min` contrast, mix it\n+ * toward white (on a dark bg) or black (on a light bg) in steps until it clears,\n+ * keeping the hue as much as possible. Used so imported accents never collapse\n+ * into a near-background sidebar (the \"invisible label\" case).\n+ */\n+export function ensureContrast(color: string, bg: string, min: number): string {\n+  if (contrastRatio(color, bg) >= min) {\n+    return color\n+  }\n+\n+  const towards = relativeLuminance(bg) < 0.5 ? '#ffffff' : '#000000'\n+  let best = color\n+\n+  for (let amount = 0.2; amount <= 1.0001; amount += 0.2) {\n+    best = mix(color, towards, Math.min(amount, 1))\n+\n+    if (contrastRatio(best, bg) >= min) {\n+      return best\n+    }\n+  }\n+\n+  return best\n+}\n+\n+/** Perceptual-ish luminance in 0..1 (naive, for light/dark bucketing). */\n+export function luminance(hex: string): number {\n+  const rgb = hexToRgb(hex)\n+\n+  if (!rgb) {\n+    return 0\n+  }\n+\n+  const [r, g, b] = rgb.map(v => v / 255)\n+\n+  return 0.2126 * r + 0.7152 * g + 0.0722 * b\n+}\n+\n+/**\n+ * Coerce any CSS hex color VS Code themes throw at us into a flat 6-digit\n+ * `#rrggbb`, compositing alpha over `backdrop`. Accepts `#rgb`, `#rgba`,\n+ * `#rrggbb`, `#rrggbbaa` (with or without the leading `#`). Returns null for\n+ * non-hex values (named colors, `rgb()`, etc.) so callers can fall back.\n+ */\n+export function normalizeHex(input: string | undefined | null, backdrop = '#000000'): string | null {\n+  if (typeof input !== 'string') {\n+    return null\n+  }\n+\n+  let clean = input.trim().replace(/^#/, '')\n+\n+  // Expand shorthand (#rgb / #rgba) to full width.\n+  if (clean.length === 3 || clean.length === 4) {\n+    clean = clean\n+      .split('')\n+      .map(ch => ch + ch)\n+      .join('')\n+  }\n+\n+  if (!/^[0-9a-f]{6}([0-9a-f]{2})?$/i.test(clean)) {\n+    return null\n+  }\n+\n+  const rgb = hexToRgb(`#${clean.slice(0, 6)}`)\n+\n+  if (!rgb) {\n+    return null\n+  }\n+\n+  if (clean.length === 6) {\n+    return rgbToHex(rgb)\n+  }\n+\n+  const alpha = parseInt(clean.slice(6, 8), 16) / 255\n+  const base = hexToRgb(backdrop) ?? [0, 0, 0]\n+\n+  return rgbToHex([\n+    base[0] + (rgb[0] - base[0]) * alpha,\n+    base[1] + (rgb[1] - base[1]) * alpha,\n+    base[2] + (rgb[2] - base[2]) * alpha\n+  ])\n+}"]},{"file":"apps/desktop/src/themes/context.tsx","hunks":["@@ -16,8 +16,10 @@ import { matchesQuery, useMediaQuery } from '@/hooks/use-media-query'\n import { persistString, persistStringRecord, storedString, storedStringRecord } from '@/lib/storage'\n import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'\n \n+import { hexToRgb, mix, readableOn } from './color'\n import { BUILTIN_THEME_LIST, BUILTIN_THEMES, DEFAULT_SKIN_NAME, DEFAULT_TYPOGRAPHY, nousTheme } from './presets'\n import type { DesktopTheme, DesktopThemeColors } from './types'\n+import { $userThemes, resolveTheme } from './user-themes'\n \n // Legacy global skin (pre per-profile themes). Still the inheritance fallback\n // for any profile without its own assignment, so single-profile users and old","@@ -41,7 +43,7 @@ const resolveMode = (mode: ThemeMode, systemDark = matchesQuery('(prefers-color-\n   mode === 'system' ? (systemDark ? 'dark' : 'light') : mode\n \n const normalizeSkin = (name: string | null): string =>\n-  name && BUILTIN_THEMES[name] && !RETIRED_SKINS.has(name) ? name : DEFAULT_SKIN_NAME\n+  name && resolveTheme(name) && !RETIRED_SKINS.has(name) ? name : DEFAULT_SKIN_NAME\n \n const normalizeMode = (value: string | null): ThemeMode =>\n   value === 'light' || value === 'dark' || value === 'system' ? value : 'light'","@@ -71,44 +73,8 @@ const readBootProfileKey = () => normalizeProfileKey(storedString(LAST_PROFILE_K\n const rememberActiveProfileKey = (profile: string) => persistString(LAST_PROFILE_KEY, profile)\n \n // ─── Color math (for synthesised light variants of dark-only skins) ────────\n-\n-function hexToRgb(hex: string): [number, number, number] | null {\n-  const clean = hex.trim().replace(/^#/, '')\n-\n-  if (!/^[0-9a-f]{6}$/i.test(clean)) {\n-    return null\n-  }\n-\n-  return [0, 2, 4].map(i => parseInt(clean.slice(i, i + 2), 16)) as [number, number, number]\n-}\n-\n-const rgbToHex = ([r, g, b]: [number, number, number]) =>\n-  `#${[r, g, b].map(n => Math.round(n).toString(16).padStart(2, '0')).join('')}`\n-\n-function mix(a: string, b: string, amount: number): string {\n-  const ar = hexToRgb(a)\n-  const br = hexToRgb(b)\n-\n-  return ar && br\n-    ? rgbToHex([ar[0] + (br[0] - ar[0]) * amount, ar[1] + (br[1] - ar[1]) * amount, ar[2] + (br[2] - ar[2]) * amount])\n-    : a\n-}\n-\n-function readableOn(hex: string): string {\n-  const rgb = hexToRgb(hex)\n-\n-  if (!rgb) {\n-    return '#ffffff'\n-  }\n-\n-  const [r, g, b] = rgb.map(v => {\n-    const c = v / 255\n-\n-    return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4\n-  })\n-\n-  return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.58 ? '#161616' : '#ffffff'\n-}\n+// hexToRgb / mix / readableOn live in ./color so the VS Code converter shares\n+// the exact same math.\n \n function synthLightColors(seed: DesktopTheme): DesktopThemeColors {\n   const accent = seed.colors.ring || seed.colors.primary","@@ -148,7 +114,7 @@ function synthLightColors(seed: DesktopTheme): DesktopThemeColors {\n \n /** Returns the seed palette for a given skin + mode (no overrides applied). */\n export function getBaseColors(skinName: string, mode: 'light' | 'dark'): DesktopThemeColors {\n-  const seed = BUILTIN_THEMES[skinName] ?? nousTheme\n+  const seed = resolveTheme(skinName) ?? nousTheme\n \n   if (mode === 'dark') {\n     return seed.darkColors ?? seed.colors","@@ -158,7 +124,7 @@ export function getBaseColors(skinName: string, mode: 'light' | 'dark'): Desktop\n }\n \n function deriveTheme(skinName: string, mode: 'light' | 'dark'): DesktopTheme {\n-  const seed = BUILTIN_THEMES[skinName] ?? nousTheme\n+  const seed = resolveTheme(skinName) ?? nousTheme\n \n   return {\n     ...seed,","@@ -319,6 +285,20 @@ export function ThemeProvider({ children }: { children: ReactNode }) {\n   // behavior is unchanged.\n   const profileKey = normalizeProfileKey(useStore($activeGatewayProfile))\n \n+  // Built-ins + user-installed themes. Reactive so an import shows up live in\n+  // the palette, settings grid, and `/skin` without a reload.\n+  const userThemes = useStore($userThemes)\n+\n+  const availableThemes = useMemo(\n+    () =>\n+      [...Object.values(BUILTIN_THEMES), ...Object.values(userThemes)].map(({ name, label, description }) => ({\n+        name,\n+        label,\n+        description\n+      })),\n+    [userThemes]\n+  )\n+\n   const [themeName, setThemeNameState] = useState(() =>\n     typeof window === 'undefined' ? DEFAULT_SKIN_NAME : skinPref.resolve(readBootProfileKey())\n   )","@@ -366,17 +346,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) {\n   // (`appearance.toggleMode`) so it shows up in the hotkey map and is rebindable.\n \n   const value = useMemo<ThemeContextValue>(\n-    () => ({\n-      theme: activeTheme,\n-      themeName,\n-      mode,\n-      resolvedMode,\n-      renderedMode,\n-      availableThemes: SKIN_LIST,\n-      setTheme,\n-      setMode\n-    }),\n-    [activeTheme, themeName, mode, resolvedMode, renderedMode, setTheme, setMode]\n+    () => ({ theme: activeTheme, themeName, mode, resolvedMode, renderedMode, availableThemes, setTheme, setMode }),\n+    [activeTheme, themeName, mode, resolvedMode, renderedMode, availableThemes, setTheme, setMode]\n   )\n \n   return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>"]},{"file":"apps/desktop/src/themes/install.test.ts","hunks":["@@ -0,0 +1,119 @@\n+import { describe, expect, it } from 'vitest'\n+\n+import type { DesktopMarketplaceThemeResult } from '@/global'\n+\n+import { luminance } from './color'\n+import { buildThemeFromMarketplace } from './install'\n+\n+const themeJson = (type: 'light' | 'dark', background: string, foreground: string) =>\n+  JSON.stringify({ type, colors: { 'editor.background': background, 'editor.foreground': foreground } })\n+\n+// A full base-8 ANSI set keyed off `red` so each variant is distinguishable.\n+const ansiColors = (red: string) => ({\n+  'terminal.ansiBlack': '#000000',\n+  'terminal.ansiRed': red,\n+  'terminal.ansiGreen': '#00aa00',\n+  'terminal.ansiYellow': '#aaaa00',\n+  'terminal.ansiBlue': '#0000aa',\n+  'terminal.ansiMagenta': '#aa00aa',\n+  'terminal.ansiCyan': '#00aaaa',\n+  'terminal.ansiWhite': '#aaaaaa'\n+})\n+\n+const themeJsonWithAnsi = (type: 'light' | 'dark', background: string, foreground: string, red: string) =>\n+  JSON.stringify({ type, colors: { 'editor.background': background, 'editor.foreground': foreground, ...ansiColors(red) } })\n+\n+describe('buildThemeFromMarketplace', () => {\n+  it('folds a light + dark variant into one family with both slots', () => {\n+    const result: DesktopMarketplaceThemeResult = {\n+      extensionId: 'ryanolsonx.solarized',\n+      displayName: 'Solarized',\n+      themes: [\n+        { label: 'Solarized Light', uiTheme: 'vs', contents: themeJson('light', '#fdf6e3', '#586e75') },\n+        { label: 'Solarized Dark', uiTheme: 'vs-dark', contents: themeJson('dark', '#002b36', '#93a1a1') }\n+      ]\n+    }\n+\n+    const theme = buildThemeFromMarketplace(result)\n+\n+    expect(theme.label).toBe('Solarized')\n+    expect(theme.name).toBe('vsc-solarized')\n+    // colors = the light variant, darkColors = the dark variant → the toggle works.\n+    expect(theme.colors.background).toBe('#fdf6e3')\n+    expect(theme.darkColors?.background).toBe('#002b36')\n+    expect(luminance(theme.colors.background)).toBeGreaterThan(0.5)\n+    expect(luminance(theme.darkColors!.background)).toBeLessThan(0.5)\n+  })\n+\n+  it('orders variants by contribution regardless of light/dark sequence', () => {\n+    const result: DesktopMarketplaceThemeResult = {\n+      extensionId: 'github.github-vscode-theme',\n+      displayName: 'GitHub Theme',\n+      themes: [\n+        { label: 'GitHub Dark Default', uiTheme: 'vs-dark', contents: themeJson('dark', '#0d1117', '#e6edf3') },\n+        { label: 'GitHub Light Default', uiTheme: 'vs', contents: themeJson('light', '#ffffff', '#1f2328') }\n+      ]\n+    }\n+\n+    const theme = buildThemeFromMarketplace(result)\n+    expect(theme.colors.background).toBe('#ffffff')\n+    expect(theme.darkColors?.background).toBe('#0d1117')\n+  })\n+\n+  it('fills both slots with the sole palette for a single-variant extension', () => {\n+    const result: DesktopMarketplaceThemeResult = {\n+      extensionId: 'dracula-theme.theme-dracula',\n+      displayName: 'Dracula',\n+      themes: [{ label: 'Dracula', uiTheme: 'vs-dark', contents: themeJson('dark', '#282a36', '#f8f8f2') }]\n+    }\n+\n+    const theme = buildThemeFromMarketplace(result)\n+    expect(theme.colors.background).toBe('#282a36')\n+    expect(theme.darkColors).toBe(theme.colors)\n+  })\n+\n+  it('keys each variant terminal palette to its mode (terminal / darkTerminal)', () => {\n+    const result: DesktopMarketplaceThemeResult = {\n+      extensionId: 'ryanolsonx.solarized',\n+      displayName: 'Solarized',\n+      themes: [\n+        { label: 'Solarized Light', uiTheme: 'vs', contents: themeJsonWithAnsi('light', '#fdf6e3', '#586e75', '#dc322f') },\n+        { label: 'Solarized Dark', uiTheme: 'vs-dark', contents: themeJsonWithAnsi('dark', '#002b36', '#93a1a1', '#ff5f56') }\n+      ]\n+    }\n+\n+    const theme = buildThemeFromMarketplace(result)\n+    expect(theme.terminal?.red).toBe('#dc322f')\n+    expect(theme.darkTerminal?.red).toBe('#ff5f56')\n+  })\n+\n+  it('reuses the sole variant terminal palette for both modes', () => {\n+    const result: DesktopMarketplaceThemeResult = {\n+      extensionId: 'dracula-theme.theme-dracula',\n+      displayName: 'Dracula',\n+      themes: [{ label: 'Dracula', uiTheme: 'vs-dark', contents: themeJsonWithAnsi('dark', '#282a36', '#f8f8f2', '#ff5555') }]\n+    }\n+\n+    const theme = buildThemeFromMarketplace(result)\n+    expect(theme.terminal?.red).toBe('#ff5555')\n+    expect(theme.darkTerminal?.red).toBe('#ff5555')\n+  })\n+\n+  it('leaves terminal slots unset when no variant ships an ANSI palette', () => {\n+    const result: DesktopMarketplaceThemeResult = {\n+      extensionId: 'x.plain',\n+      displayName: 'Plain',\n+      themes: [{ label: 'Plain', uiTheme: 'vs-dark', contents: themeJson('dark', '#101010', '#fafafa') }]\n+    }\n+\n+    const theme = buildThemeFromMarketplace(result)\n+    expect(theme.terminal).toBeUndefined()\n+    expect(theme.darkTerminal).toBeUndefined()\n+  })\n+\n+  it('throws when the extension contributes no themes', () => {\n+    expect(() =>\n+      buildThemeFromMarketplace({ extensionId: 'x.y', displayName: 'X', themes: [] })\n+    ).toThrow(/does not contribute/i)\n+  })\n+})"]},{"file":"apps/desktop/src/themes/install.ts","hunks":["@@ -0,0 +1,95 @@\n+/**\n+ * Install desktop themes from external sources.\n+ *\n+ * The heavy lifting (network + .vsix unzip) lives in the Electron main process\n+ * (`electron/vscode-marketplace.cjs`), reached via `window.hermesDesktop.themes`.\n+ * Main hands back the raw theme JSON; we parse + convert + persist here so the\n+ * conversion stays in one unit-testable place.\n+ */\n+\n+import type { DesktopMarketplaceThemeResult } from '@/global'\n+\n+import type { DesktopTheme } from './types'\n+import { installUserTheme } from './user-themes'\n+import { convertVscodeColorTheme, parseVscodeTheme, vscodeThemeSlug } from './vscode'\n+\n+/** A `publisher.extension` id, e.g. `dracula-theme.theme-dracula`. */\n+export const MARKETPLACE_ID_RE = /^[\\w-]+\\.[\\w-]+$/\n+\n+/** Parse + convert + persist a pasted VS Code theme JSON. */\n+export function installVscodeThemeFromText(\n+  text: string,\n+  opts?: { label?: string; source?: string }\n+): DesktopTheme {\n+  const raw = parseVscodeTheme(text)\n+  const { theme } = convertVscodeColorTheme(raw, opts)\n+\n+  return installUserTheme(theme)\n+}\n+\n+/**\n+ * Fold every color theme an extension contributes into ONE desktop theme family.\n+ *\n+ * Many extensions ship a light *and* a dark variant (GitHub, Solarized, Winter\n+ * is Coming…). Rather than install them as separate flat entries — which made\n+ * the light/dark toggle a no-op and let \"install in dark mode\" land on the light\n+ * variant — we map the first light variant onto `colors` and the first dark\n+ * variant onto `darkColors`. The result is a single picker entry whose light/dark\n+ * toggle switches between the real variants. A single-variant extension fills\n+ * both slots with its one palette (the toggle is a no-op, as it must be).\n+ */\n+export function buildThemeFromMarketplace(result: DesktopMarketplaceThemeResult): DesktopTheme {\n+  if (!result.themes.length) {\n+    throw new Error(`\"${result.extensionId}\" does not contribute any color themes.`)\n+  }\n+\n+  const variants = result.themes.map(file => {\n+    const raw = parseVscodeTheme(file.contents)\n+    const label = file.label || raw.name || result.displayName\n+    const { mode, theme } = convertVscodeColorTheme(raw, { label, source: result.extensionId })\n+\n+    return { mode, palette: theme.colors, terminal: theme.terminal }\n+  })\n+\n+  const fallback = variants[0]\n+  const light = variants.find(variant => variant.mode === 'light') ?? fallback\n+  const dark = variants.find(variant => variant.mode === 'dark') ?? fallback\n+\n+  // The terminal ANSI palette tracks the painted variant the same way colors do\n+  // (light → terminal, dark → darkTerminal); each falls back to the other so a\n+  // single-variant import still themes the terminal in both modes.\n+  const terminal = light.terminal ?? dark.terminal\n+  const darkTerminal = dark.terminal ?? light.terminal\n+\n+  return {\n+    name: vscodeThemeSlug(result.displayName),\n+    label: result.displayName,\n+    description: `VS Code · ${result.extensionId}`,\n+    colors: light.palette,\n+    darkColors: dark.palette,\n+    ...(terminal ? { terminal } : {}),\n+    ...(darkTerminal ? { darkTerminal } : {})\n+  }\n+}\n+\n+/**\n+ * Download a Marketplace extension and install the theme family it contributes\n+ * (see `buildThemeFromMarketplace`). Returns the single installed theme.\n+ */\n+export async function installVscodeThemeFromMarketplace(id: string): Promise<DesktopTheme> {\n+  const trimmed = id.trim()\n+\n+  if (!MARKETPLACE_ID_RE.test(trimmed)) {\n+    throw new Error('Expected a Marketplace id like \"publisher.extension\".')\n+  }\n+\n+  const api = window.hermesDesktop?.themes\n+\n+  if (!api?.fetchMarketplace) {\n+    throw new Error('Marketplace install is only available in the desktop app.')\n+  }\n+\n+  const result = await api.fetchMarketplace(trimmed)\n+\n+  return installUserTheme(buildThemeFromMarketplace(result))\n+}"]},{"file":"apps/desktop/src/themes/types.ts","hunks":["@@ -54,6 +54,37 @@ export interface DesktopThemeTypography {\n   fontUrl?: string\n }\n \n+/**\n+ * Integrated-terminal ANSI palette (xterm `ITheme`, minus `background`).\n+ *\n+ * Populated only when a converted VS Code theme ships a full `terminal.ansi*`\n+ * set; otherwise the terminal keeps its built-in VS Code default palette.\n+ * `background` is intentionally absent — the pane always paints the live skin\n+ * surface so it stays translucent.\n+ */\n+export interface DesktopTerminalPalette {\n+  foreground?: string\n+  cursor?: string\n+  /** Keeps its source alpha — xterm blends it over the surface. */\n+  selectionBackground?: string\n+  black?: string\n+  red?: string\n+  green?: string\n+  yellow?: string\n+  blue?: string\n+  magenta?: string\n+  cyan?: string\n+  white?: string\n+  brightBlack?: string\n+  brightRed?: string\n+  brightGreen?: string\n+  brightYellow?: string\n+  brightBlue?: string\n+  brightMagenta?: string\n+  brightCyan?: string\n+  brightWhite?: string\n+}\n+\n export interface DesktopTheme {\n   name: string\n   label: string","@@ -63,4 +94,8 @@ export interface DesktopTheme {\n   /** Hand-tuned dark palette. Skins like `nous` ship one. */\n   darkColors?: DesktopThemeColors\n   typography?: Partial<DesktopThemeTypography>\n+  /** Light-variant terminal ANSI palette (also the fallback for dark). */\n+  terminal?: DesktopTerminalPalette\n+  /** Dark-variant terminal ANSI palette. Falls back to `terminal`. */\n+  darkTerminal?: DesktopTerminalPalette\n }"]},{"file":"apps/desktop/src/themes/user-themes.test.ts","hunks":["@@ -0,0 +1,63 @@\n+import { beforeEach, describe, expect, it } from 'vitest'\n+\n+import { BUILTIN_THEMES, DEFAULT_SKIN_NAME } from './presets'\n+import { $userThemes, installUserTheme, isUserTheme, listAllThemes, removeUserTheme, resolveTheme } from './user-themes'\n+import { convertVscodeColorTheme } from './vscode'\n+\n+const makeTheme = (label: string) =>\n+  convertVscodeColorTheme({\n+    name: label,\n+    type: 'dark',\n+    colors: { 'editor.background': '#101014', 'editor.foreground': '#fafafa', focusBorder: '#7aa2f7' }\n+  }).theme\n+\n+describe('user theme registry', () => {\n+  beforeEach(() => {\n+    window.localStorage.clear()\n+    $userThemes.set({})\n+  })\n+\n+  it('installs a theme into the merged registry and persists it', () => {\n+    const theme = installUserTheme(makeTheme('Tokyo Night'))\n+\n+    expect(isUserTheme(theme.name)).toBe(true)\n+    expect(resolveTheme(theme.name)).toEqual(theme)\n+    expect(listAllThemes().map(t => t.name)).toContain(theme.name)\n+    expect(window.localStorage.getItem('hermes-desktop-user-themes-v1')).toContain(theme.name)\n+  })\n+\n+  it('lists built-ins before user themes', () => {\n+    installUserTheme(makeTheme('Custom'))\n+    const names = listAllThemes().map(t => t.name)\n+\n+    expect(names.slice(0, Object.keys(BUILTIN_THEMES).length)).toEqual(Object.keys(BUILTIN_THEMES))\n+    expect(names.at(-1)).toBe('vsc-custom')\n+  })\n+\n+  it('removes a theme', () => {\n+    const theme = installUserTheme(makeTheme('Throwaway'))\n+    removeUserTheme(theme.name)\n+\n+    expect(isUserTheme(theme.name)).toBe(false)\n+    expect(resolveTheme(theme.name)).toBeUndefined()\n+  })\n+\n+  it('resolves built-ins through the same lookup', () => {\n+    expect(resolveTheme(DEFAULT_SKIN_NAME)).toBe(BUILTIN_THEMES[DEFAULT_SKIN_NAME])\n+  })\n+\n+  it('refuses to shadow a built-in name', () => {\n+    const builtinName = makeTheme('x')\n+    builtinName.name = DEFAULT_SKIN_NAME\n+\n+    expect(() => installUserTheme(builtinName)).toThrow(/built-in/)\n+  })\n+\n+  it('rejects a theme missing required colors', () => {\n+    const broken = makeTheme('Broken')\n+    // @ts-expect-error — intentionally corrupt the palette for the test.\n+    broken.colors = { background: '#000000' }\n+\n+    expect(() => installUserTheme(broken)).toThrow(/colors/)\n+  })\n+})"]},{"file":"apps/desktop/src/themes/user-themes.ts","hunks":["@@ -0,0 +1,122 @@\n+/**\n+ * User-installed desktop themes (currently: converted VS Code themes).\n+ *\n+ * This is the extensibility seam. The theme context reads the *merged* registry\n+ * (built-ins + user themes) for `availableThemes` and for every skin lookup, so\n+ * an installed theme shows up everywhere a built-in does — the Cmd-K palette,\n+ * the Appearance settings grid, and `/skin` — with no per-surface wiring.\n+ *\n+ * Stored as a localStorage record so the boot-time paint (which runs before\n+ * React mounts) can resolve a user theme synchronously, same as built-ins.\n+ */\n+\n+import { atom } from 'nanostores'\n+\n+import { BUILTIN_THEMES } from './presets'\n+import type { DesktopTheme, DesktopThemeColors } from './types'\n+\n+const USER_THEMES_KEY = 'hermes-desktop-user-themes-v1'\n+\n+// The minimal set of color keys a stored theme must carry to be usable. We keep\n+// this loose — `applyTheme` tolerates missing optionals via fallbacks — but a\n+// theme with no background/foreground/primary is junk and gets dropped.\n+const REQUIRED_COLOR_KEYS: ReadonlyArray<keyof DesktopThemeColors> = ['background', 'foreground', 'primary']\n+\n+function isValidTheme(value: unknown): value is DesktopTheme {\n+  if (!value || typeof value !== 'object') {\n+    return false\n+  }\n+\n+  const theme = value as Partial<DesktopTheme>\n+\n+  if (typeof theme.name !== 'string' || typeof theme.label !== 'string' || !theme.colors) {\n+    return false\n+  }\n+\n+  const colors = theme.colors as unknown as Record<string, unknown>\n+\n+  return REQUIRED_COLOR_KEYS.every(key => typeof colors[key] === 'string')\n+}\n+\n+function readStored(): Record<string, DesktopTheme> {\n+  try {\n+    const raw = window.localStorage.getItem(USER_THEMES_KEY)\n+\n+    if (!raw) {\n+      return {}\n+    }\n+\n+    const parsed: unknown = JSON.parse(raw)\n+\n+    if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n+      return {}\n+    }\n+\n+    const out: Record<string, DesktopTheme> = {}\n+\n+    for (const [key, value] of Object.entries(parsed)) {\n+      // Never let a stored theme shadow a built-in name.\n+      if (!BUILTIN_THEMES[key] && isValidTheme(value)) {\n+        out[key] = value\n+      }\n+    }\n+\n+    return out\n+  } catch {\n+    return {}\n+  }\n+}\n+\n+function persist(record: Record<string, DesktopTheme>) {\n+  try {\n+    window.localStorage.setItem(USER_THEMES_KEY, JSON.stringify(record))\n+  } catch {\n+    // Best-effort: a restricted storage context shouldn't break theming.\n+  }\n+}\n+\n+/** Reactive map of installed user themes, keyed by slug. */\n+export const $userThemes = atom<Record<string, DesktopTheme>>(typeof window === 'undefined' ? {} : readStored())\n+\n+/** Install (or replace) a user theme. Returns the stored theme. */\n+export function installUserTheme(theme: DesktopTheme): DesktopTheme {\n+  if (BUILTIN_THEMES[theme.name]) {\n+    throw new Error(`\"${theme.name}\" collides with a built-in theme.`)\n+  }\n+\n+  if (!isValidTheme(theme)) {\n+    throw new Error('Theme is missing required colors.')\n+  }\n+\n+  const next = { ...$userThemes.get(), [theme.name]: theme }\n+  $userThemes.set(next)\n+  persist(next)\n+\n+  return theme\n+}\n+\n+/** Remove a user theme by slug. No-op for unknown / built-in names. */\n+export function removeUserTheme(name: string): void {\n+  const current = $userThemes.get()\n+\n+  if (!current[name]) {\n+    return\n+  }\n+\n+  const next = { ...current }\n+  delete next[name]\n+  $userThemes.set(next)\n+  persist(next)\n+}\n+\n+export const isUserTheme = (name: string): boolean => Boolean($userThemes.get()[name])\n+\n+/** Resolve a theme by name across the merged registry (built-in + user). */\n+export function resolveTheme(name: string): DesktopTheme | undefined {\n+  return BUILTIN_THEMES[name] ?? $userThemes.get()[name]\n+}\n+\n+/** Built-ins first (stable order), then user themes by install order. */\n+export function listAllThemes(): DesktopTheme[] {\n+  return [...Object.values(BUILTIN_THEMES), ...Object.values($userThemes.get())]\n+}"]},{"file":"apps/desktop/src/themes/vscode.test.ts","hunks":["@@ -0,0 +1,171 @@\n+import { describe, expect, it } from 'vitest'\n+\n+import { contrastRatio } from './color'\n+import { convertVscodeColorTheme, parseVscodeTheme, vscodeThemeSlug } from './vscode'\n+\n+describe('vscodeThemeSlug', () => {\n+  it('namespaces, lowercases, and dashes', () => {\n+    expect(vscodeThemeSlug('Dracula Soft')).toBe('vsc-dracula-soft')\n+    expect(vscodeThemeSlug('  One Dark Pro!! ')).toBe('vsc-one-dark-pro')\n+  })\n+\n+  it('falls back when the name has no usable characters', () => {\n+    expect(vscodeThemeSlug('—')).toBe('vsc-theme')\n+  })\n+})\n+\n+describe('parseVscodeTheme (JSONC tolerance)', () => {\n+  it('strips comments and trailing commas', () => {\n+    const text = `{\n+      // a line comment\n+      \"name\": \"Demo\",\n+      /* block comment */\n+      \"type\": \"dark\",\n+      \"colors\": {\n+        \"editor.background\": \"#1e1e2e\", // inline\n+      },\n+    }`\n+\n+    const parsed = parseVscodeTheme(text)\n+    expect(parsed.name).toBe('Demo')\n+    expect(parsed.colors?.['editor.background']).toBe('#1e1e2e')\n+  })\n+\n+  it('throws on a non-object', () => {\n+    expect(() => parseVscodeTheme('42')).toThrow()\n+  })\n+})\n+\n+describe('convertVscodeColorTheme', () => {\n+  const dracula = {\n+    name: 'Dracula',\n+    type: 'dark',\n+    colors: {\n+      'editor.background': '#282a36',\n+      'editor.foreground': '#f8f8f2',\n+      focusBorder: '#6272a4',\n+      'editorWidget.background': '#21222c',\n+      'sideBar.background': '#21222c',\n+      errorForeground: '#ff5555',\n+      // 8-digit hex (alpha) — must flatten over the background.\n+      'panel.border': '#bd93f900'\n+    }\n+  }\n+\n+  it('maps the load-bearing tokens onto the palette', () => {\n+    const { theme } = convertVscodeColorTheme(dracula, { source: 'dracula-theme.theme-dracula' })\n+\n+    expect(theme.name).toBe('vsc-dracula')\n+    expect(theme.label).toBe('Dracula')\n+    expect(theme.description).toContain('dracula-theme.theme-dracula')\n+    expect(theme.colors.background).toBe('#282a36')\n+    expect(theme.colors.foreground).toBe('#f8f8f2')\n+    // One accent drives primary + ring + midground together...\n+    expect(theme.colors.ring).toBe(theme.colors.primary)\n+    expect(theme.colors.midground).toBe(theme.colors.primary)\n+    // ...and it's nudged until it reads on the sidebar it labels (the dim\n+    // focusBorder #6272a4 sits below AA, so it's lifted).\n+    expect(contrastRatio(theme.colors.primary, theme.colors.sidebarBackground!)).toBeGreaterThanOrEqual(4.5)\n+    expect(theme.colors.popover).toBe('#21222c')\n+    expect(theme.colors.sidebarBackground).toBe('#21222c')\n+    expect(theme.colors.destructive).toBe('#ff5555')\n+  })\n+\n+  it('flattens alpha hex over the background (no #rrggbbaa leaks)', () => {\n+    const { theme } = convertVscodeColorTheme(dracula)\n+    expect(theme.colors.border).toMatch(/^#[0-9a-f]{6}$/)\n+    // 00 alpha over the bg means the border collapses to the background.\n+    expect(theme.colors.border).toBe('#282a36')\n+  })\n+\n+  it('renders identically in both modes (single palette in both slots)', () => {\n+    const { theme } = convertVscodeColorTheme(dracula)\n+    expect(theme.darkColors).toBe(theme.colors)\n+  })\n+\n+  it('records derived fallbacks for omitted tokens', () => {\n+    const { derived } = convertVscodeColorTheme({\n+      name: 'Sparse',\n+      type: 'dark',\n+      colors: { 'editor.background': '#101010', 'editor.foreground': '#fafafa' }\n+    })\n+\n+    // No accent/elevated/sidebar/error tokens → all derived. The accent records\n+    // its first candidate (button.background) when none of the family is present.\n+    expect(derived).toContain('button.background')\n+    expect(derived).toContain('editorWidget.background')\n+    expect(derived).toContain('editorError.foreground')\n+  })\n+\n+  it('buckets light vs dark from background luminance when type is absent', () => {\n+    const light = convertVscodeColorTheme({\n+      name: 'Bright',\n+      colors: { 'editor.background': '#ffffff', 'editor.foreground': '#1a1a1a' }\n+    }).theme\n+\n+    // A light background should keep a near-white background, not synth dark.\n+    expect(light.colors.background).toBe('#ffffff')\n+  })\n+\n+  it('throws when there is no colors map', () => {\n+    expect(() => convertVscodeColorTheme({ name: 'Empty' })).toThrow(/colors/)\n+  })\n+\n+  const fullAnsi = {\n+    'terminal.ansiBlack': '#073642',\n+    'terminal.ansiRed': '#dc322f',\n+    'terminal.ansiGreen': '#859900',\n+    'terminal.ansiYellow': '#b58900',\n+    'terminal.ansiBlue': '#268bd2',\n+    'terminal.ansiMagenta': '#d33682',\n+    'terminal.ansiCyan': '#2aa198',\n+    'terminal.ansiWhite': '#eee8d5',\n+    'terminal.ansiBrightBlack': '#002b36',\n+    'terminal.ansiBrightRed': '#cb4b16',\n+    'terminal.ansiBrightGreen': '#586e75',\n+    'terminal.ansiBrightYellow': '#657b83',\n+    'terminal.ansiBrightBlue': '#839496',\n+    'terminal.ansiBrightMagenta': '#6c71c4',\n+    'terminal.ansiBrightCyan': '#93a1a1',\n+    'terminal.ansiBrightWhite': '#fdf6e3'\n+  }\n+\n+  it('lifts the ANSI palette when the full base-8 set is present', () => {\n+    const { theme } = convertVscodeColorTheme({\n+      name: 'Solarized Dark',\n+      type: 'dark',\n+      colors: {\n+        'editor.background': '#002b36',\n+        'editor.foreground': '#93a1a1',\n+        'terminal.foreground': '#839496',\n+        'terminalCursor.foreground': '#93a1a1',\n+        // Alpha selection must survive un-flattened — xterm blends it.\n+        'terminal.selectionBackground': '#073642aa',\n+        ...fullAnsi\n+      }\n+    })\n+\n+    expect(theme.terminal?.red).toBe('#dc322f')\n+    expect(theme.terminal?.brightWhite).toBe('#fdf6e3')\n+    expect(theme.terminal?.foreground).toBe('#839496')\n+    expect(theme.terminal?.cursor).toBe('#93a1a1')\n+    expect(theme.terminal?.selectionBackground).toBe('#073642aa')\n+    // No background slot — the pane keeps the live surface (transparency).\n+    expect('background' in (theme.terminal ?? {})).toBe(false)\n+  })\n+\n+  it('keeps the default palette (no terminal slot) when the ANSI set is partial', () => {\n+    const { theme } = convertVscodeColorTheme({\n+      name: 'Half',\n+      type: 'dark',\n+      colors: {\n+        'editor.background': '#101010',\n+        'editor.foreground': '#fafafa',\n+        'terminal.ansiRed': '#ff0000',\n+        'terminal.ansiGreen': '#00ff00'\n+      }\n+    })\n+\n+    expect(theme.terminal).toBeUndefined()\n+  })\n+})"]},{"file":"apps/desktop/src/themes/vscode.ts","hunks":["@@ -0,0 +1,343 @@\n+/**\n+ * VS Code color-theme → DesktopTheme converter.\n+ *\n+ * VS Code themes carry ~hundreds of `workbench.colorCustomization` keys, but the\n+ * desktop theme model only needs a `DesktopThemeColors` struct — `applyTheme`\n+ * derives every glass/shadcn token from a small seed chain via `color-mix()`.\n+ * In practice ~6 workbench keys carry the whole look (background, foreground,\n+ * accent, elevated surface, sidebar, error); everything else we derive by mixing\n+ * those toward the background/foreground. That's the \"naive token converter\".\n+ *\n+ * A VS Code theme is single-mode (light OR dark). Rather than synthesise the\n+ * opposite mode, we set both `colors` and `darkColors` to the converted palette\n+ * so the imported theme renders faithfully no matter where the light/dark toggle\n+ * sits — `renderedModeFor` still picks the `.dark` class from the real\n+ * background luminance, so surface-bound UI matches what's on screen.\n+ */\n+\n+import { ensureContrast, luminance, mix, normalizeHex, readableOn } from './color'\n+import type { DesktopTerminalPalette, DesktopTheme, DesktopThemeColors } from './types'\n+\n+// Section headers / sidebar labels render in --theme-primary directly on the\n+// sidebar surface as small (~10px) uppercase text, so the accent has to clear\n+// WCAG AA for normal text (4.5:1) or it's unreadable — the \"invisible purple\n+// label\" case. Imported accents below this get nudged lighter/darker.\n+const ACCENT_MIN_CONTRAST = 4.5\n+\n+/** The shape of a VS Code `*-color-theme.json` (only the fields we read). */\n+export interface VscodeColorTheme {\n+  name?: string\n+  type?: string\n+  /** Relative path to a base theme this one extends. We don't follow it. */\n+  include?: string\n+  colors?: Record<string, unknown>\n+  tokenColors?: unknown\n+}\n+\n+export interface ConvertOptions {\n+  /** Stable id (slug). Defaults to a slug of `raw.name`. */\n+  slug?: string\n+  /** Display label. Defaults to `raw.name`. */\n+  label?: string\n+  /** Shown under the label in the picker (e.g. the marketplace extension id). */\n+  source?: string\n+}\n+\n+export interface ConvertResult {\n+  theme: DesktopTheme\n+  /** The source theme's own light/dark (from `type`, else background luminance). */\n+  mode: 'light' | 'dark'\n+  /** Workbench keys we wanted but the theme omitted (we derived fallbacks). */\n+  derived: string[]\n+}\n+\n+/** Tolerant slug: lowercase, alnum + dashes, deduped, `vsc-` namespaced. */\n+export function vscodeThemeSlug(name: string): string {\n+  const base = name\n+    .trim()\n+    .toLowerCase()\n+    .replace(/[^a-z0-9]+/g, '-')\n+    .replace(/^-+|-+$/g, '')\n+    .slice(0, 48)\n+\n+  return `vsc-${base || 'theme'}`\n+}\n+\n+/**\n+ * Parse a VS Code theme file. These ship as JSONC (line/block comments and\n+ * trailing commas), so a plain `JSON.parse` rejects most real-world files.\n+ * Strips comments + trailing commas, then parses. Throws on hard syntax errors.\n+ */\n+export function parseVscodeTheme(text: string): VscodeColorTheme {\n+  const stripped = text\n+    // Block comments.\n+    .replace(/\\/\\*[\\s\\S]*?\\*\\//g, '')\n+    // Line comments (not inside strings — naive but fine for theme files).\n+    .replace(/(^|[^:\"'\\\\])\\/\\/[^\\n\\r]*/g, '$1')\n+    // Trailing commas before } or ].\n+    .replace(/,(\\s*[}\\]])/g, '$1')\n+\n+  const parsed: unknown = JSON.parse(stripped)\n+\n+  if (!parsed || typeof parsed !== 'object') {\n+    throw new Error('Theme file is not a JSON object.')\n+  }\n+\n+  return parsed as VscodeColorTheme\n+}\n+\n+const isDarkType = (raw: VscodeColorTheme, background: string): boolean => {\n+  const type = (raw.type ?? '').toLowerCase()\n+\n+  if (type.includes('light')) {\n+    return false\n+  }\n+\n+  if (type === 'dark' || type === 'hc' || type === 'hc-black' || type.includes('dark')) {\n+    return true\n+  }\n+\n+  // No usable `type` — bucket by background luminance.\n+  return luminance(background) < 0.4\n+}\n+\n+// xterm ITheme ANSI slots ← VS Code `terminal.ansi*` tokens. Background is\n+// deliberately excluded — the pane keeps the live skin surface (transparency).\n+const ANSI_TOKENS: [redacted:secret-assignment] [keyof DesktopTerminalPalette, string]> = [\n+  ['black', 'terminal.ansiBlack'],\n+  ['red', 'terminal.ansiRed'],\n+  ['green', 'terminal.ansiGreen'],\n+  ['yellow', 'terminal.ansiYellow'],\n+  ['blue', 'terminal.ansiBlue'],\n+  ['magenta', 'terminal.ansiMagenta'],\n+  ['cyan', 'terminal.ansiCyan'],\n+  ['white', 'terminal.ansiWhite'],\n+  ['brightBlack', 'terminal.ansiBrightBlack'],\n+  ['brightRed', 'terminal.ansiBrightRed'],\n+  ['brightGreen', 'terminal.ansiBrightGreen'],\n+  ['brightYellow', 'terminal.ansiBrightYellow'],\n+  ['brightBlue', 'terminal.ansiBrightBlue'],\n+  ['brightMagenta', 'terminal.ansiBrightMagenta'],\n+  ['brightCyan', 'terminal.ansiBrightCyan'],\n+  ['brightWhite', 'terminal.ansiBrightWhite']\n+]\n+\n+const BASE_ANSI: ReadonlyArray<keyof DesktopTerminalPalette> = [\n+  'black',\n+  'red',\n+  'green',\n+  'yellow',\n+  'blue',\n+  'magenta',\n+  'cyan',\n+  'white'\n+]\n+\n+const HEX_RE = /^#[0-9a-f]{3,8}$/i\n+\n+/**\n+ * Lift a theme's integrated-terminal ANSI palette, if it ships one.\n+ *\n+ * All-or-nothing on the base-8 colors: a half-filled palette mixed with our\n+ * defaults reads worse than just keeping the defaults, so we adopt the theme's\n+ * palette only when the full base set is present. ANSI slots flatten alpha over\n+ * the editor background; selection keeps its alpha so xterm can blend it.\n+ */\n+function extractTerminalPalette(colors: Record<string, unknown>, background: string): DesktopTerminalPalette | undefined {\n+  const hex = (key: string): string | undefined =>\n+    normalizeHex(typeof colors[key] === 'string' ? (colors[key] as string) : null, background) ?? undefined\n+\n+  const palette: DesktopTerminalPalette = {}\n+\n+  for (const [slot, token] of ANSI_TOKENS) {\n+    const value = hex(token)\n+\n+    if (value) {\n+      palette[slot] = value\n+    }\n+  }\n+\n+  if (!BASE_ANSI.every(slot => palette[slot])) {\n+    return undefined\n+  }\n+\n+  const foreground = hex('terminal.foreground')\n+  const cursor = hex('terminalCursor.foreground') ?? hex('terminalCursor.background')\n+  const selection = typeof colors['terminal.selectionBackground'] === 'string' ? colors['terminal.selectionBackground'].trim() : ''\n+\n+  if (foreground) {\n+    palette.foreground = foreground\n+  }\n+\n+  if (cursor) {\n+    palette.cursor = cursor\n+  }\n+\n+  if (HEX_RE.test(selection)) {\n+    palette.selectionBackground = selection\n+  }\n+\n+  return palette\n+}\n+\n+/** First normalizable hex among `keys`, composited over `backdrop`. */\n+const pick = (\n+  colors: Record<string, unknown>,\n+  keys: string[],\n+  backdrop: string\n+): { key: string; value: string } | null => {\n+  for (const key of keys) {\n+    const value = normalizeHex(typeof colors[key] === 'string' ? (colors[key] as string) : null, backdrop)\n+\n+    if (value) {\n+      return { key, value }\n+    }\n+  }\n+\n+  return null\n+}\n+\n+export function convertVscodeColorTheme(raw: VscodeColorTheme, opts: ConvertOptions = {}): ConvertResult {\n+  const colors = raw.colors && typeof raw.colors === 'object' ? (raw.colors as Record<string, unknown>) : null\n+\n+  if (!colors) {\n+    throw new Error('Theme has no \"colors\" map — not a VS Code color theme.')\n+  }\n+\n+  const derived: string[] = []\n+\n+  // Background first: it's the backdrop every other token flattens alpha over.\n+  const backgroundHit = pick(colors, ['editor.background', 'editorPane.background', 'editorGroup.background'], '#000000')\n+  const dark = isDarkType(raw, backgroundHit?.value ?? '#1e1e1e')\n+  const background = backgroundHit?.value ?? (dark ? '#1e1e1e' : '#ffffff')\n+\n+  if (!backgroundHit) {\n+    derived.push('editor.background')\n+  }\n+\n+  // `take` records a derived fallback when the theme omits the key.\n+  const take = (keys: string[], fallback: string): string => {\n+    const hit = pick(colors, keys, background)\n+\n+    if (hit) {\n+      return hit.value\n+    }\n+\n+    derived.push(keys[0])\n+\n+    return fallback\n+  }\n+\n+  const foreground = take(['editor.foreground', 'foreground'], dark ? '#d4d4d4' : '#1f1f1f')\n+\n+  // Brand accent — the single most load-bearing token. Drives primary buttons,\n+  // focus rings, the streaming cursor, active-session pills, and sidebar labels.\n+  // Prefer the saturated \"brand\" tokens (button / link / badge) over focusBorder,\n+  // which many themes set to a muted gray — picking it first made imported\n+  // accents look like the desktop defaults. We enforce contrast below regardless.\n+  const accentSource = take(\n+    [\n+      'button.background',\n+      'textLink.activeForeground',\n+      'textLink.foreground',\n+      'activityBarBadge.background',\n+      'badge.background',\n+      'progressBar.background',\n+      'pickerGroup.foreground',\n+      'list.highlightForeground',\n+      'editorLink.activeForeground',\n+      'focusBorder',\n+      'tab.activeBorder',\n+      'statusBarItem.remoteBackground'\n+    ],\n+    mix(foreground, background, 0.55)\n+  )\n+\n+  const elevated = take(\n+    ['editorWidget.background', 'dropdown.background', 'menu.background', 'quickInput.background', 'editorSuggestWidget.background'],\n+    mix(background, foreground, dark ? 0.08 : 0.05)\n+  )\n+\n+  const card = take(\n+    ['sideBarSectionHeader.background', 'tab.inactiveBackground', 'editorGroupHeader.tabsBackground'],\n+    mix(background, foreground, dark ? 0.04 : 0.025)\n+  )\n+\n+  const sidebar = take(['sideBar.background', 'activityBar.background'], mix(background, foreground, dark ? 0.02 : 0.012))\n+\n+  // The accent labels the sidebar (--theme-primary), so guarantee it reads\n+  // there — otherwise low-contrast brand colors leave invisible section headers.\n+  const accent = ensureContrast(accentSource, sidebar, ACCENT_MIN_CONTRAST)\n+\n+  const border = take(\n+    ['panel.border', 'editorGroup.border', 'sideBar.border', 'contrastBorder', 'widget.border', 'input.border'],\n+    mix(background, foreground, dark ? 0.16 : 0.14)\n+  )\n+\n+  const input = take(['input.background', 'dropdown.background', 'quickInput.background'], mix(background, foreground, dark ? 0.1 : 0.06))\n+\n+  const mutedForeground = take(\n+    ['descriptionForeground', 'editorLineNumber.foreground', 'tab.inactiveForeground', 'disabledForeground'],\n+    mix(foreground, background, 0.45)\n+  )\n+\n+  const destructive = take(\n+    ['editorError.foreground', 'errorForeground', 'editorOverviewRuler.errorForeground', 'notificationsErrorIcon.foreground'],\n+    '#e25563'\n+  )\n+\n+  const muted = mix(background, foreground, dark ? 0.06 : 0.04)\n+  const accentSoft = mix(accent, background, dark ? 0.82 : 0.88)\n+  const secondary = mix(accent, background, dark ? 0.72 : 0.86)\n+\n+  const palette: DesktopThemeColors = {\n+    background,\n+    foreground,\n+    card,\n+    cardForeground: foreground,\n+    muted,\n+    mutedForeground,\n+    popover: elevated,\n+    popoverForeground: foreground,\n+    primary: accent,\n+    primaryForeground: readableOn(accent),\n+    secondary,\n+    secondaryForeground: foreground,\n+    accent: accentSoft,\n+    accentForeground: foreground,\n+    border,\n+    input,\n+    ring: accent,\n+    midground: accent,\n+    midgroundForeground: readableOn(accent),\n+    composerRing: accent,\n+    destructive,\n+    destructiveForeground: readableOn(destructive),\n+    sidebarBackground: sidebar,\n+    sidebarBorder: border,\n+    userBubble: mix(card, accent, dark ? 0.18 : 0.12),\n+    userBubbleBorder: border\n+  }\n+\n+  const label = (opts.label ?? raw.name ?? 'VS Code Theme').trim()\n+  const slug = opts.slug ?? vscodeThemeSlug(label)\n+  const terminal = extractTerminalPalette(colors, background)\n+\n+  return {\n+    derived,\n+    mode: dark ? 'dark' : 'light',\n+    theme: {\n+      name: slug,\n+      label,\n+      description: opts.source ? `VS Code · ${opts.source}` : 'Imported from VS Code',\n+      // Single palette in both slots. A lone VS Code theme is one-mode; callers\n+      // that have both a light and dark variant (a Marketplace extension family)\n+      // recombine them into proper colors/darkColors via buildThemeFromMarketplace.\n+      colors: palette,\n+      darkColors: palette,\n+      // Only set when the theme ships a full ANSI palette — the terminal keeps\n+      // its built-in VS Code defaults otherwise.\n+      ...(terminal ? { terminal } : {})\n+    }\n+  }\n+}\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}