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