import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import { useEffect, useState } from 'react' import { useDebounced } from '@/app/hooks/use-debounced' import { LanguageSwitcher } from '@/components/language-switcher' import { Button } from '@/components/ui/button' import { SegmentedControl } from '@/components/ui/segmented-control' import type { DesktopMarketplaceSearchItem } from '@/global' import { saveHermesConfig } from '@/hermes' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons' import { selectableCardClass } from '@/lib/selectable-card' import { normalize } from '@/lib/text' import { cn } from '@/lib/utils' import { $backdrop, setBackdrop } from '@/store/backdrop' import { $composerPopoutGesturesEnabled, setComposerPopoutGesturesEnabled } from '@/store/composer-popout' import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedMode } from '@/store/embed-consent' import { $introSplash, setIntroSplash } from '@/store/intro-splash' import { notifyError } from '@/store/notifications' import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile' import { $reactionsEnabled, setReactionsEnabled } from '@/store/reactions-enabled' import { $reasoningCollapsedByDefault, setReasoningCollapsedByDefault } from '@/store/reasoning-disclosure' import { $sessionListDensity, type SessionListDensity, setSessionListDensity } from '@/store/session-list-density' import { $tabStripDefault, setTabStripDefault, type TabStripDefault } from '@/store/tabstrip-prefs' import { $retiredTips, $tipsEnabled, resetTips, setTipsEnabled } from '@/store/tips' import { $toolViewMode, setToolViewMode } from '@/store/tool-view' import { $toursEnabled, setToursEnabled } from '@/store/tours' import { $translucency, beginTranslucencyPeek, endTranslucencyPeek, GLASS_IS_WINDOWS, GLASS_SCOPES, GLASS_SUPPORTED, glassMaterialForPicker, glassMaterialsFor, pulseTranslucencyPeek, resetTranslucencyPeek, setTranslucency, setTranslucencyFade, setTranslucencyMaterial, setTranslucencyMode, setTranslucencyScope, TRANSLUCENCY_MAX, TRANSLUCENCY_MIN, TRANSLUCENCY_STEP, TRANSLUCENCY_SUPPORTED } from '@/store/translucency' import { $vibeHeartsEnabled, setVibeHeartsEnabled } from '@/store/vibe-hearts-enabled' import { $zoomPercent, setZoomPercent } from '@/store/zoom' import { getBaseColors, useTheme } from '@/themes/context' import { installVscodeThemeFromMarketplace } from '@/themes/install' import type { DesktopTheme } from '@/themes/types' import { $marketplaceInstalls, isUserTheme, removeUserTheme } from '@/themes/user-themes' import { setHermesConfigCache, useHermesConfigRecord } from '../hooks/use-config-record' import { MODE_OPTIONS } from './constants' import { setNested } from './helpers' import { PetSettings } from './pet-settings' import { ListRow, SectionHeading, SettingsContent, ToggleRow } from './primitives' import { APPEARANCE_SETTING_IDS } from './settings-search' import { TerminalFontSetting } from './terminal-font-setting' import { useDeepLinkHighlight } from './use-deep-link-highlight' // display.resume_last_session lives in the backend config record (shared with // config.yaml and the cold-start restore in use-desktop-integrations), not a // renderer store. Saves write through the shared react-query cache so the // restore gate sees the new value on the next launch. function ResumeLastSessionSetting() { const { t } = useI18n() const a = t.settings.appearance const configQuery = useHermesConfigRecord() const config = configQuery.data const checked = (config?.display as { resume_last_session?: unknown } | undefined)?.resume_last_session !== false const update = (on: boolean) => { if (!config) { return } const next = setNested(config, 'display.resume_last_session', on) setHermesConfigCache(next) void saveHermesConfig(next) .then(result => { if (!result.ok) { throw new Error(t.settings.config.autosaveFailed) } }) .catch(error => { setHermesConfigCache(config) notifyError(error, t.settings.config.autosaveFailed) }) } return ( ) } function ThemePreview({ name, mode }: { name: string; mode: 'light' | 'dark' }) { // Preview in the *current* mode: the dark palette in Dark, and the light // palette in Light — synthesizing one for dark-only themes — so every card // tracks the Light/Dark toggle, exactly like the app itself does. const c = getBaseColors(name, mode) return (
) } // UI scale presets, as zoom percentages. 100 is Chromium's actual-size // baseline; the shipped default is the 90% preset. Ids double as the percent // values sent to the main process. A Cmd/Ctrl +/- step landing between // presets highlights nothing, and the row description keeps showing the // exact current percent. const UI_SCALE_PRESETS = ['90', '100', '110', '125', '150', '175'] as const const APPEARANCE_SEARCH_TARGETS = new Set(Object.values(APPEARANCE_SETTING_IDS)) const appearanceSettingElementId = (id: string) => `setting-field-${id}` type UiScalePreset = (typeof UI_SCALE_PRESETS)[number] function matchUiScalePreset(percent: number): UiScalePreset | null { return UI_SCALE_PRESETS.find(preset => Number(preset) === percent) ?? null } const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 }) /** * Live VS Code Marketplace theme search (the same backend as the Cmd-K "Install * theme…" page). Renders below the local grid when there's a query: each row * downloads + converts + installs via `installVscodeThemeFromMarketplace` and * activates it. Extensions already imported locally are marked installed. */ function MarketplaceThemeResults({ query, installs, onInstalled }: { query: string installs: ReadonlyMap onInstalled: (name: string) => void }) { const { t } = useI18n() const copy = t.commandCenter.installTheme const debounced = useDebounced(query.trim(), 300) const [installingId, setInstallingId] = useState(null) const [error, setError] = useState(null) const search = useQuery({ enabled: debounced.length > 0, queryFn: () => window.hermesDesktop?.themes?.searchMarketplace(debounced) ?? Promise.resolve([]), queryKey: ['marketplace-themes-settings', debounced], staleTime: 5 * 60 * 1000 }) // Already installed → just re-activate it; never re-download what we have. const select = (item: DesktopMarketplaceSearchItem) => { const owned = installs.get(item.extensionId) if (owned) { triggerHaptic('crisp') onInstalled(owned.name) return } void install(item) } const install = async (item: DesktopMarketplaceSearchItem) => { if (installingId) { return } setInstallingId(item.extensionId) setError(null) try { const theme = await installVscodeThemeFromMarketplace(item.extensionId) triggerHaptic('crisp') onInstalled(theme.name) } catch (e) { setError(e instanceof Error ? e.message : copy.error) } finally { setInstallingId(null) } } if (!debounced) { return null } const header = (

{t.settings.runtime.marketplaceThemes}

) if (search.isLoading) { return ( <> {header}

{copy.loading}

) } if (search.isError) { return ( <> {header}

{copy.error}

) } const results = search.data ?? [] if (results.length === 0) { return ( <> {header}

{copy.empty}

) } return ( <> {header} {error &&

{error}

}
{results.map(item => { const busy = installingId === item.extensionId const done = installs.has(item.extensionId) return ( ) })}
) } // Keys a range input treats as a step, so the peek can flash the live window // for keyboard adjustment the way a pointer drag holds it open. const SLIDER_STEP_KEYS = new Set([ 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'End', 'Home', 'PageDown', 'PageUp' ]) interface TranslucencySliderProps { label: string onChange: (value: number) => void value: number } /** * One 0–100 lever, used up to twice: Clear's window opacity, and under Glass * the tint plus an optional native fade. * * Peek while the hand is on it — the overlay (scrim + near-opaque card) ghosts * so the window behind IS the live preview. The pointer pair covers * mouse/touch drags; the keyboard path pulses per step instead, and blur ends * any residual hold. */ function TranslucencySlider({ label, onChange, value }: TranslucencySliderProps) { return ( <> { triggerHaptic('selection') onChange(Number(event.target.value)) }} onKeyDown={event => { if (SLIDER_STEP_KEYS.has(event.key)) { pulseTranslucencyPeek() } }} onLostPointerCapture={endTranslucencyPeek} onPointerDown={beginTranslucencyPeek} onPointerUp={endTranslucencyPeek} step={TRANSLUCENCY_STEP} style={{ accentColor: 'var(--dt-primary)' }} type="range" value={value} /> {value}% ) } interface GlassRowProps { children: React.ReactNode label: string } /** A labelled control in the Glass sub-panel: tint, fade, frost, area. */ function GlassRow({ children, label }: GlassRowProps) { return (
{label} {children}
) } export function AppearanceSettings() { const { t, isSavingLocale } = useI18n() const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme() const toolViewMode = useStore($toolViewMode) const reasoningCollapsedByDefault = useStore($reasoningCollapsedByDefault) const sessionListDensity = useStore($sessionListDensity) const tabStripDefault = useStore($tabStripDefault) const zoomPercent = useStore($zoomPercent) const embedMode = useStore($embedMode) const embedAllowed = useStore($embedAllowed) const composerPopoutGesturesEnabled = useStore($composerPopoutGesturesEnabled) const translucency = useStore($translucency) const glassMode = translucency.mode === 'glass' && GLASS_SUPPORTED const reactionsEnabled = useStore($reactionsEnabled) const tipsEnabled = useStore($tipsEnabled) const toursEnabled = useStore($toursEnabled) const retiredTips = useStore($retiredTips) const vibeHeartsEnabled = useStore($vibeHeartsEnabled) const backdrop = useStore($backdrop) const introSplash = useStore($introSplash) const installs = useStore($marketplaceInstalls) const profiles = useStore($profiles) const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile)) const a = t.settings.appearance // A pointer held on the intensity slider when this overlay closes (Escape // mid-drag) never delivers its pointerup here, which would strand the peek // counter above zero and ghost the NEXT settings overlay. Unmount drops // every outstanding hold. useEffect(() => resetTranslucencyPeek, []) // Shared by the mode/frost/area pickers: apply the choice, then show it // through the overlay it just altered (a pulse, not a hold — see the peek // notes on the slider itself). const pickTranslucency = (set: (value: T) => void) => (value: T) => { triggerHaptic('selection') set(value) if (translucency.intensity > 0) { pulseTranslucencyPeek() } } const [query, setQuery] = useState('') useDeepLinkHighlight({ elementId: appearanceSettingElementId, param: 'setting', ready: id => APPEARANCE_SEARCH_TARGETS.has(id) }) // One box does double duty: filter installed themes live (below), and run a // name search against the VS Code Marketplace (the Cmd-K "Install theme…" // backend) for anything not already installed. const needle = normalize(query) const filteredThemes = availableThemes .filter( theme => !needle || theme.label.toLowerCase().includes(needle) || theme.name.toLowerCase().includes(needle) || theme.description.toLowerCase().includes(needle) ) // Active theme first; stable sort keeps the rest in their original order. .sort((a, b) => Number(b.name === themeName) - Number(a.name === themeName)) // Themes save per profile. Surface that only when the user actually has more // than one profile (single-profile installs never see the distinction). const showProfileNote = profiles.length > 1 const activeProfileName = profiles.find(profile => normalizeProfileKey(profile.name) === activeProfileKey)?.name ?? activeProfileKey const modeOptions = MODE_OPTIONS.map(({ id, icon }) => ({ icon, id, label: t.settings.modeOptions[id].label })) const toolOptions = [ { id: 'product', label: a.product }, { id: 'technical', label: a.technical } ] as const const sessionDensityOptions = [ { id: 'compact', label: a.sessionDensityCompact }, { id: 'comfortable', label: a.sessionDensityComfortable }, { id: 'detailed', label: a.sessionDensityDetailed } ] as const satisfies readonly { id: SessionListDensity; label: string }[] const tabStripOptions = [ { id: 'auto', label: a.tabStripAuto }, { id: 'always', label: a.tabStripAlways }, { id: 'never', label: a.tabStripNever } ] as const satisfies readonly { id: TabStripDefault; label: string }[] const embedOptions = [ { id: 'ask', label: a.embedsAsk }, { id: 'always', label: a.embedsAlways }, { id: 'off', label: a.embedsOff } ] as const satisfies readonly { id: EmbedMode; label: string }[] const uiScaleOptions = UI_SCALE_PRESETS.map(preset => ({ id: preset, label: `${preset}%` })) const matchedScalePreset = matchUiScalePreset(zoomPercent) return (

{a.intro}

} description={isSavingLocale ? t.language.saving : t.language.description} id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.language)} title={t.language.label} /> {/* One search box: filters your installed themes (the grid) and live-searches the VS Code Marketplace below. */}
setQuery(event.target.value)} placeholder={t.settings.runtime.themeSearch} spellCheck={false} value={query} />
{/* Fixed-height scroll area so the (growing) theme list never runs the page long; the grid scrolls inside it. */}
{filteredThemes.length === 0 ? ( needle ? (

{t.settings.runtime.noMatchingThemes(query.trim())}

) : null ) : (
{filteredThemes.map(theme => { const active = themeName === theme.name const removable = isUserTheme(theme.name) return (
{removable && ( )}
) })}
)} setTheme(name)} query={query} />
{showProfileNote && (

{a.themeProfileNote(activeProfileName)}

)} } description={a.themeDesc} id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.theme)} title={
{a.themeTitle} { triggerHaptic('crisp') setMode(id) }} options={modeOptions} value={mode} />
} wide /> { triggerHaptic('selection') setZoomPercent(Number(id)) }} options={uiScaleOptions} value={matchedScalePreset ?? ('' as UiScalePreset)} /> } description={a.uiScaleDesc(zoomPercent)} id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.uiScale)} title={a.uiScaleTitle} /> { triggerHaptic('selection') setSessionListDensity(id) }} options={sessionDensityOptions} value={sessionListDensity} /> } description={a.sessionDensityDesc} title={a.sessionDensityTitle} /> { triggerHaptic('selection') setTabStripDefault(id) }} options={tabStripOptions} value={tabStripDefault} /> } description={a.tabStripDesc} title={a.tabStripTitle} /> {/* Linux has neither half of this setting (see TRANSLUCENCY_SUPPORTED), so the row is absent there rather than offering a dead lever. */} {TRANSLUCENCY_SUPPORTED && ( {GLASS_SUPPORTED && ( )} {/* Clear has one lever and it belongs beside the mode. Glass has four controls, so they move into the labelled panel below rather than crowding this line with an unlabelled slider that means something different. */} {!glassMode && ( )}
} below={ glassMode ? (
({ id: material, label: a.translucencyFrost[material] }))} value={glassMaterialForPicker(translucency.material, GLASS_IS_WINDOWS)} /> ({ id: scope, label: a.translucencyScope[scope] }))} value={translucency.scope} />
) : undefined } description={glassMode ? a.translucencyGlassDesc : a.translucencyDesc} id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.translucency)} title={a.translucencyTitle} /> )} { triggerHaptic('selection') setBackdrop(id === 'on') }} options={[ { id: 'off', label: t.common.off }, { id: 'on', label: t.common.on } ]} value={backdrop ? 'on' : 'off'} /> } description={a.backdropDesc} id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.backdrop)} title={a.backdropTitle} /> { triggerHaptic('selection') setIntroSplash(id === 'on') }} options={[ { id: 'off', label: t.common.off }, { id: 'on', label: t.common.on } ]} value={introSplash ? 'on' : 'off'} /> } description={a.introSplashDesc} id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.introSplash)} title={a.introSplashTitle} /> { triggerHaptic('selection') setReactionsEnabled(id === 'on') }} options={[ { id: 'off', label: t.common.off }, { id: 'on', label: t.common.on } ]} value={reactionsEnabled ? 'on' : 'off'} /> } description={a.reactionsDesc} title={a.reactionsTitle} /> { triggerHaptic('selection') setTipsEnabled(id === 'on') }} options={[ { id: 'off', label: t.common.off }, { id: 'on', label: t.common.on } ]} value={tipsEnabled ? 'on' : 'off'} /> {/* The ✕ on a tip is permanent, so this is the only way back. It appears once there is something to bring back. */} {retiredTips.length > 0 && ( )}
} description={a.tipsDesc} title={a.tipsTitle} /> { triggerHaptic('selection') setToursEnabled(id === 'on') }} options={[ { id: 'off', label: t.common.off }, { id: 'on', label: t.common.on } ]} value={toursEnabled ? 'on' : 'off'} /> } description={a.toursDesc} title={a.toursTitle} /> { triggerHaptic('selection') setVibeHeartsEnabled(id === 'on') }} options={[ { id: 'off', label: t.common.off }, { id: 'on', label: t.common.on } ]} value={vibeHeartsEnabled ? 'on' : 'off'} /> } description={a.vibeHeartsDesc} title={a.vibeHeartsTitle} /> { triggerHaptic('selection') setToolViewMode(id) }} options={toolOptions} value={toolViewMode} /> } description={a.toolViewDesc} id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.toolView)} title={a.toolViewTitle} /> { triggerHaptic('selection') setReasoningCollapsedByDefault(id === 'on') }} options={[ { id: 'off', label: t.common.off }, { id: 'on', label: t.common.on } ]} value={reasoningCollapsedByDefault ? 'on' : 'off'} /> } description={a.reasoningCollapsedDesc} title={a.reasoningCollapsedTitle} /> { triggerHaptic('selection') setEmbedMode(id) }} options={embedOptions} value={embedMode} /> {embedAllowed.length > 0 && ( )}
} description={a.embedsDesc} id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.embeds)} title={a.embedsTitle} />
) }