diff --git a/apps/desktop/src/app/chat/composer/help-hint.tsx b/apps/desktop/src/app/chat/composer/help-hint.tsx index 897c7f0e3a7..9b267e487b1 100644 --- a/apps/desktop/src/app/chat/composer/help-hint.tsx +++ b/apps/desktop/src/app/chat/composer/help-hint.tsx @@ -5,7 +5,7 @@ import { useI18n } from '@/i18n' import { COMPLETION_DRAWER_CLASS } from './completion-drawer' const COMMON_COMMAND_KEYS = ['/help', '/clear', '/resume', '/details', '/copy', '/quit'] -const HOTKEY_KEYS = ['@', '/', '?', 'Enter', 'Cmd/Ctrl+K', 'Cmd/Ctrl+L', 'Esc', '↑ / ↓'] +const HOTKEY_KEYS = ['@', '/', '?', 'Enter', 'Cmd/Ctrl+Shift+K', 'Cmd/Ctrl+/', 'Esc', '↑ / ↓'] export function HelpHint() { const { t } = useI18n() diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index fbd089a9ffb..3c183aaa4e7 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -34,6 +34,7 @@ import { cn } from '@/lib/utils' import { $activeGatewayProfile, $profileColors, + $profileCreateRequest, $profileOrder, $profiles, $profileScope, @@ -178,6 +179,20 @@ export function ProfileRail() { void refreshActiveProfile() }, []) + // Open the create dialog when the `profile.create` hotkey fires (the dialog + // state lives here, so the global keybind bumps a request atom we watch). + const createRequest = useStore($profileCreateRequest) + const lastCreateRef = useRef(createRequest) + + useEffect(() => { + if (createRequest === lastCreateRef.current) { + return + } + + lastCreateRef.current = createRequest + setCreateOpen(true) + }, [createRequest]) + return (
{/* One button toggles default ↔ all: home face when scoped to a profile, @@ -199,7 +214,12 @@ export function ProfileRail() { {/* Single-profile: the active default's home icon next to the create +. */} {!multiProfile && defaultProfile && ( - selectProfile(defaultProfile.name)} /> + selectProfile(defaultProfile.name)} + /> )}
{ - const onKeyDown = (event: KeyboardEvent) => { - if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) { - return - } - - const key = event.key.toLowerCase() - - if (key === 'k' || key === 'p') { - event.preventDefault() - toggleCommandPalette() - } else if (key === '.') { - event.preventDefault() - toggleCommandCenter() - } - } - - window.addEventListener('keydown', onKeyDown) - - return () => window.removeEventListener('keydown', onKeyDown) - }, [toggleCommandCenter]) - const refreshSessions = useCallback(async () => { const requestId = refreshSessionsRequestRef.current + 1 refreshSessionsRequestRef.current = requestId @@ -457,6 +432,8 @@ export function DesktopController() { updateSessionState }) + // Single-key "new session" convenience (Shift+N) when no input is focused. + // The rebindable accelerator (⌘N by default) is owned by the keybind runtime. useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { const target = event.target as HTMLElement | null @@ -467,17 +444,16 @@ export function DesktopController() { target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement - if (event.defaultPrevented || event.repeat || event.altKey || event.code !== 'KeyN') { - return - } - - // Two accelerators for "new session": - // - Cmd/Ctrl+N (browser-like, works while typing in any input) - // - Shift+N (single-key, only when no input is focused) - const accelerator = event.metaKey || event.ctrlKey - const singleKey = !accelerator && !editing && event.shiftKey - - if (!accelerator && !singleKey) { + if ( + event.defaultPrevented || + event.repeat || + event.altKey || + event.metaKey || + event.ctrlKey || + editing || + !event.shiftKey || + event.code !== 'KeyN' + ) { return } @@ -492,6 +468,14 @@ export function DesktopController() { return () => window.removeEventListener('keydown', onKeyDown) }, [startFreshSessionDraft]) + // Single global listener for every rebindable hotkey (incl. profile switching) + // plus the on-screen keybind editor's capture mode. + useKeybinds({ + startFreshSession: startFreshSessionDraft, + toggleCommandCenter, + toggleSelectedPin + }) + // A profile switch/create drops to a fresh new-session draft so the previously // open session doesn't bleed across contexts. Skip the initial value. const freshSessionRequest = useStore($freshSessionRequest) diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts new file mode 100644 index 00000000000..3f8528a2929 --- /dev/null +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -0,0 +1,144 @@ +import { useEffect, useRef } from 'react' +import { useNavigate } from 'react-router-dom' + +import { PROFILE_SLOT_COUNT } from '@/lib/keybinds/actions' +import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo' +import { toggleCommandPalette } from '@/store/command-palette' +import { $capture, $comboIndex, endCapture, setBinding, toggleKeybindPanel } from '@/store/keybinds' +import { toggleFileBrowserOpen, togglePanesFlipped, toggleSidebarOpen } from '@/store/layout' +import { + cycleProfile, + requestProfileCreate, + switchProfileToSlot, + switchToDefaultProfile, + toggleShowAllProfiles +} from '@/store/profile' +import { useTheme } from '@/themes/context' + +import { + AGENTS_ROUTE, + CRON_ROUTE, + MESSAGING_ROUTE, + PROFILES_ROUTE, + SETTINGS_ROUTE, + SKILLS_ROUTE +} from '../routes' + +export interface KeybindRuntimeDeps { + /** Open/close the command center overlay (sessions / system / usage). */ + toggleCommandCenter: () => void + /** Drop to a fresh new-session draft. */ + startFreshSession: () => void + /** Pin/unpin the active session. */ + toggleSelectedPin: () => void +} + +type HandlerMap = Record void> + +// Mount once near the top of the app. Owns the single global keydown listener +// for every rebindable hotkey: it runs the matched action, or — while capture +// mode is active (edit overlay / panel rebind) — records the pressed combo. +export function useKeybinds(deps: KeybindRuntimeDeps): void { + const navigate = useNavigate() + const { resolvedMode, setMode } = useTheme() + + // Keep the latest closures without re-subscribing the listener. + const handlersRef = useRef({}) + + const profileSwitchHandlers: HandlerMap = {} + + for (let slot = 1; slot <= PROFILE_SLOT_COUNT; slot += 1) { + profileSwitchHandlers[`profile.switch.${slot}`] = () => switchProfileToSlot(slot) + } + + handlersRef.current = { + 'keybinds.openPanel': toggleKeybindPanel, + + 'nav.commandPalette': toggleCommandPalette, + 'nav.commandCenter': deps.toggleCommandCenter, + 'nav.settings': () => navigate(SETTINGS_ROUTE), + 'nav.profiles': () => navigate(PROFILES_ROUTE), + 'nav.skills': () => navigate(SKILLS_ROUTE), + 'nav.messaging': () => navigate(MESSAGING_ROUTE), + 'nav.cron': () => navigate(CRON_ROUTE), + 'nav.agents': () => navigate(AGENTS_ROUTE), + + 'session.new': () => { + deps.startFreshSession() + window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut')) + }, + 'session.togglePin': deps.toggleSelectedPin, + + 'view.toggleSidebar': toggleSidebarOpen, + 'view.toggleRightSidebar': toggleFileBrowserOpen, + 'view.flipPanes': togglePanesFlipped, + + 'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'), + + 'profile.default': switchToDefaultProfile, + ...profileSwitchHandlers, + 'profile.next': () => cycleProfile(1), + 'profile.prev': () => cycleProfile(-1), + 'profile.toggleAll': toggleShowAllProfiles, + 'profile.create': requestProfileCreate + } + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + // Capture mode: the next real key becomes the binding. Swallow everything + // so e.g. ⌘K rebinds instead of opening the palette. + const capturing = $capture.get() + + if (capturing) { + event.preventDefault() + event.stopPropagation() + + if (event.key === 'Escape') { + endCapture() + + return + } + + const combo = comboFromEvent(event) + + if (!combo) { + return + } + + setBinding(capturing, [combo]) + endCapture() + + return + } + + const combo = comboFromEvent(event) + + if (!combo) { + return + } + + const actionId = $comboIndex.get().get(combo) + + if (!actionId) { + return + } + + if (isEditableTarget(event.target) && !comboAllowedInInput(combo)) { + return + } + + const handler = handlersRef.current[actionId] + + if (!handler) { + return + } + + event.preventDefault() + handler() + } + + window.addEventListener('keydown', onKeyDown, { capture: true }) + + return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) + }, []) +} diff --git a/apps/desktop/src/app/shell/app-shell.tsx b/apps/desktop/src/app/shell/app-shell.tsx index a683ed094e5..af9c75d6b7d 100644 --- a/apps/desktop/src/app/shell/app-shell.tsx +++ b/apps/desktop/src/app/shell/app-shell.tsx @@ -16,6 +16,7 @@ import { import { $paneWidthOverride } from '@/store/panes' import { $connection } from '@/store/session' +import { KeybindPanel } from './keybind-panel' import { StatusbarControls, type StatusbarItem } from './statusbar-controls' import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar' import { TitlebarControls, type TitlebarTool } from './titlebar-controls' @@ -155,6 +156,9 @@ export function AppShell({ {overlays} + {/* Keybind map dialog (titlebar ⌨ button / ⌘/). */} + + {/* Mounted at the shell root (after overlays) so success/error toasts surface above every route and overlay — not just the chat view. */} diff --git a/apps/desktop/src/app/shell/keybind-panel.tsx b/apps/desktop/src/app/shell/keybind-panel.tsx new file mode 100644 index 00000000000..835c966ad25 --- /dev/null +++ b/apps/desktop/src/app/shell/keybind-panel.tsx @@ -0,0 +1,218 @@ +import { useStore } from '@nanostores/react' +import { Dialog as DialogPrimitive } from 'radix-ui' +import { useState } from 'react' + +import { Codicon } from '@/components/ui/codicon' +import { DisclosureCaret } from '@/components/ui/disclosure-caret' +import { useI18n } from '@/i18n' +import { + KEYBIND_ACTIONS, + KEYBIND_CATEGORIES, + KEYBIND_PANEL_ACTION, + KEYBIND_READONLY, + type KeybindActionMeta, + type KeybindReadonly +} from '@/lib/keybinds/actions' +import { formatCombo } from '@/lib/keybinds/combo' +import { + $bindings, + $capture, + $keybindPanelOpen, + beginCapture, + closeKeybindPanel, + conflictsFor, + endCapture, + resetAllBindings, + resetBinding +} from '@/store/keybinds' + +// The full hotkey map. Quiet popover, click a row's chip to rebind. +export function KeybindPanel() { + const { t } = useI18n() + const open = useStore($keybindPanelOpen) + const bindings = useStore($bindings) + const k = t.keybinds + const [collapsed, setCollapsed] = useState>(new Set()) + + const openCombo = bindings[KEYBIND_PANEL_ACTION]?.[0] + + const toggleCategory = (category: string) => + setCollapsed(prev => { + const next = new Set(prev) + + if (next.has(category)) { + next.delete(category) + } else { + next.add(category) + } + + return next + }) + + return ( + !next && closeKeybindPanel()} open={open}> + + + + {/* Header */} +
+
+ {k.title} + + {k.subtitle(openCombo ? formatCombo(openCombo) : '')} + +
+ +
+ + {/* Body */} +
+ {KEYBIND_CATEGORIES.map(category => { + const actions = KEYBIND_ACTIONS.filter( + action => action.category === category && action.id !== KEYBIND_PANEL_ACTION + ) + + const readonly = KEYBIND_READONLY.filter(shortcut => shortcut.category === category) + + if (actions.length === 0 && readonly.length === 0) { + return null + } + + const sectionOpen = !collapsed.has(category) + + return ( +
+ toggleCategory(category)} + open={sectionOpen} + /> + {sectionOpen && actions.map(action => )} + {sectionOpen && readonly.map(shortcut => )} +
+ ) + })} +
+
+
+
+ ) +} + +// Collapsible category header — chevron fades in on hover, rotates when open +// (matches the sessions sidebar section pattern). +function CategoryHeader({ label, onToggle, open }: { label: string; onToggle: () => void; open: boolean }) { + return ( + + ) +} + +function HeaderButton({ icon, label, onClick }: { icon: string; label: string; onClick: () => void }) { + return ( + + ) +} + +function KeybindRow({ action }: { action: KeybindActionMeta }) { + const { t } = useI18n() + const k = t.keybinds + const bindings = useStore($bindings) + const capture = useStore($capture) + + const combos = bindings[action.id] ?? [] + const capturing = capture === action.id + const label = k.actions[action.id] ?? action.id + + const conflict = combos + .flatMap(combo => conflictsFor(action.id, combo).map(other => k.actions[other] ?? other)) + .find(Boolean) + + return ( +
+ {/* Mirrors the reset button's footprint on the right so rows stay uniform. */} + + {label} + + {conflict && ( + + + + )} + + {/* Click the caps to rebind — the on-screen editor does the same thing. */} + + + +
+ ) +} + +// Fixed shortcut: same layout as KeybindRow but the caps aren't interactive and +// the trailing reset slot stays empty (spacer keeps the columns aligned). +function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) { + const { t } = useI18n() + const k = t.keybinds + const label = k.actions[shortcut.id] ?? shortcut.id + + return ( +
+ + {label} +
+ {shortcut.keys.map(key => ( + + {formatCombo(key)} + + ))} +
+ +
+ ) +} diff --git a/apps/desktop/src/app/shell/titlebar-controls.tsx b/apps/desktop/src/app/shell/titlebar-controls.tsx index a939a180519..4b36fb62d5a 100644 --- a/apps/desktop/src/app/shell/titlebar-controls.tsx +++ b/apps/desktop/src/app/shell/titlebar-controls.tsx @@ -8,6 +8,7 @@ import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics' +import { toggleKeybindPanel } from '@/store/keybinds' import { $fileBrowserOpen, $panesFlipped, @@ -116,6 +117,15 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: label: hapticsMuted ? t.titlebar.unmuteHaptics : t.titlebar.muteHaptics, onSelect: toggleHaptics }, + { + icon: , + id: 'keybinds', + label: t.titlebar.openKeybinds, + onSelect: () => { + triggerHaptic('open') + toggleKeybindPanel() + } + }, { icon: , id: 'settings', diff --git a/apps/desktop/src/components/ui/sidebar.tsx b/apps/desktop/src/components/ui/sidebar.tsx index c6c3d5e97a0..539b0215440 100644 --- a/apps/desktop/src/components/ui/sidebar.tsx +++ b/apps/desktop/src/components/ui/sidebar.tsx @@ -20,7 +20,6 @@ const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 const SIDEBAR_WIDTH = '16rem' const SIDEBAR_WIDTH_MOBILE = '18rem' const SIDEBAR_WIDTH_ICON = '3rem' -const SIDEBAR_KEYBOARD_SHORTCUT = 'b' type SidebarContextProps = { state: 'expanded' | 'collapsed' @@ -87,19 +86,8 @@ function SidebarProvider({ return isMobile ? setOpenMobile(open => !open) : setOpen(open => !open) }, [isMobile, setOpen, setOpenMobile]) - // Adds a keyboard shortcut to toggle the sidebar. - React.useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { - event.preventDefault() - toggleSidebar() - } - } - - window.addEventListener('keydown', handleKeyDown) - - return () => window.removeEventListener('keydown', handleKeyDown) - }, [toggleSidebar]) + // The sidebar toggle (Cmd/Ctrl+B by default) is owned by the keybind runtime + // (`view.toggleSidebar`) so it appears in the hotkey map and is rebindable. // We add a state so that we can do data-state="expanded" or "collapsed". // This makes it easier to style the sidebar with Tailwind classes. diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index c7ff0385fd3..1f12134297c 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -145,7 +145,74 @@ export const en: Translations = { showRightSidebar: 'Show right sidebar', muteHaptics: 'Mute haptics', unmuteHaptics: 'Unmute haptics', - openSettings: 'Open settings' + openSettings: 'Open settings', + openKeybinds: 'Keyboard shortcuts' + }, + + keybinds: { + title: 'Keyboard shortcuts', + subtitle: open => `Click a shortcut to rebind it · ${open} reopens this panel.`, + rebind: 'Rebind', + reset: 'Reset to default', + resetAll: 'Reset all', + pressKey: 'Press a key…', + set: 'set', + conflictWith: label => `Also bound to “${label}”`, + categories: { + composer: 'Composer', + profiles: 'Profiles', + session: 'Session', + navigation: 'Navigation', + view: 'View' + }, + actions: { + 'keybinds.openPanel': 'Open keyboard shortcuts', + 'nav.commandPalette': 'Open command palette', + 'nav.commandCenter': 'Open command center', + 'nav.settings': 'Open settings', + 'nav.profiles': 'Open profiles', + 'nav.skills': 'Open skills', + 'nav.messaging': 'Open messaging', + 'nav.cron': 'Open scheduled jobs', + 'nav.agents': 'Open agents', + 'session.new': 'New session', + 'session.togglePin': 'Pin / unpin current session', + 'view.toggleSidebar': 'Toggle sessions sidebar', + 'view.toggleRightSidebar': 'Toggle file browser', + 'view.flipPanes': 'Swap sidebar sides', + 'appearance.toggleMode': 'Toggle light / dark', + 'profile.default': 'Switch to default profile', + 'profile.switch.1': 'Switch to profile 1', + 'profile.switch.2': 'Switch to profile 2', + 'profile.switch.3': 'Switch to profile 3', + 'profile.switch.4': 'Switch to profile 4', + 'profile.switch.5': 'Switch to profile 5', + 'profile.switch.6': 'Switch to profile 6', + 'profile.switch.7': 'Switch to profile 7', + 'profile.switch.8': 'Switch to profile 8', + 'profile.switch.9': 'Switch to profile 9', + 'profile.switch.10': 'Switch to profile 10', + 'profile.switch.11': 'Switch to profile 11', + 'profile.switch.12': 'Switch to profile 12', + 'profile.switch.13': 'Switch to profile 13', + 'profile.switch.14': 'Switch to profile 14', + 'profile.switch.15': 'Switch to profile 15', + 'profile.switch.16': 'Switch to profile 16', + 'profile.switch.17': 'Switch to profile 17', + 'profile.switch.18': 'Switch to profile 18', + 'profile.next': 'Next profile', + 'profile.prev': 'Previous profile', + 'profile.toggleAll': 'Toggle all-profiles view', + 'profile.create': 'Create profile', + 'composer.send': 'Send message', + 'composer.newline': 'Insert newline', + 'composer.sendQueued': 'Send next queued turn', + 'composer.mention': 'Reference files, folders, URLs', + 'composer.slash': 'Slash command palette', + 'composer.help': 'Quick help', + 'composer.history': 'Cycle popover / history', + 'composer.cancel': 'Close popover · cancel run' + } }, language: { @@ -244,8 +311,7 @@ export const en: Translations = { minAgo: count => `${count} min ago`, hoursAgo: count => `${count} hours ago`, daysAgo: count => `${count} days ago` - } - , + }, config: { none: 'None', noneParen: '(none)', @@ -292,7 +358,8 @@ export const en: Translations = { appliesTo: 'Applies to', allProfiles: 'All profiles', defaultConnection: 'Default connection for every profile that has no override of its own.', - profileConnection: profile => `Connection used only when “${profile}” is the active profile. Set it to Local to inherit the default.`, + profileConnection: profile => + `Connection used only when “${profile}” is the active profile. Set it to Local to inherit the default.`, envOverrideTitle: 'Environment variables are controlling this desktop session.', envOverrideDesc: 'Unset HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN to use the saved setting below.', @@ -316,8 +383,7 @@ export const en: Translations = { authNeedsPassword: 'This gateway uses a username and password. Sign in to authorize this desktop app.', authNeedsOauth: provider => `This gateway uses OAuth. Sign in with ${provider} to authorize this desktop app.`, tokenTitle: 'Session token', - tokenDesc: - 'The dashboard session token used for REST and WebSocket access. Leave blank to keep the saved token.', + tokenDesc: 'The dashboard session token used for REST and WebSocket access. Leave blank to keep the saved token.', existingToken: value => `Existing token ${value}`, savedToken: 'saved', pasteSessionToken: 'Paste session token', @@ -408,7 +474,8 @@ export const en: Translations = { providers: { connectAccount: 'Connect an account', haveApiKey: 'Have an API key instead?', - intro: 'Sign in with a subscription — no API key to copy. Hermes runs the browser sign-in for you, right here in the app.', + intro: + 'Sign in with a subscription — no API key to copy. Hermes runs the browser sign-in for you, right here in the app.', connected: 'Connected', collapse: 'Collapse', connectAnother: 'Connect another provider', @@ -464,7 +531,8 @@ export const en: Translations = { ready: 'Ready', nousIncluded: 'Included with a Nous subscription — sign in to Nous Portal to activate.', noApiKeyRequired: 'No API key required.', - postSetup: step => `This provider needs an extra setup step (${step}). Run it from the CLI with hermes tools for now.` + postSetup: step => + `This provider needs an extra setup step (${step}). Run it from the CLI with hermes tools for now.` } }, @@ -665,7 +733,10 @@ export const en: Translations = { label: 'Bot token', help: 'Create an application in the Discord Developer Portal, add a bot, then paste its token.' }, - DISCORD_ALLOWED_USERS: { label: 'Allowed Discord user IDs', help: 'Recommended. Comma-separated Discord user IDs.' }, + DISCORD_ALLOWED_USERS: { + label: 'Allowed Discord user IDs', + help: 'Recommended. Comma-separated Discord user IDs.' + }, DISCORD_REPLY_TO_MODE: { label: 'Reply style', help: 'first, all, or off.' }, DISCORD_ALLOW_ALL_USERS: { label: 'Allow all Discord users', @@ -679,7 +750,10 @@ export const en: Translations = { label: 'Home channel name', help: 'Display name for the home channel in logs and status output.' }, - BLUEBUBBLES_ALLOW_ALL_USERS: { label: 'Allow all iMessage users', help: 'When true, skip the BlueBubbles allowlist.' }, + BLUEBUBBLES_ALLOW_ALL_USERS: { + label: 'Allow all iMessage users', + help: 'When true, skip the BlueBubbles allowlist.' + }, MATTERMOST_ALLOW_ALL_USERS: { label: 'Allow all Mattermost users' }, MATTERMOST_HOME_CHANNEL: { label: 'Home channel' }, QQ_ALLOW_ALL_USERS: { label: 'Allow all QQ users' }, @@ -698,7 +772,10 @@ export const en: Translations = { SLACK_ALLOWED_USERS: { label: 'Allowed Slack user IDs', help: 'Recommended. Comma-separated Slack user IDs.' }, MATTERMOST_URL: { label: 'Server URL', placeholder: 'https://mattermost.example.com' }, MATTERMOST_TOKEN: { label: 'Bot token' }, - MATTERMOST_ALLOWED_USERS: { label: 'Allowed user IDs', help: 'Recommended. Comma-separated Mattermost user IDs.' }, + MATTERMOST_ALLOWED_USERS: { + label: 'Allowed user IDs', + help: 'Recommended. Comma-separated Mattermost user IDs.' + }, MATRIX_HOMESERVER: { label: 'Homeserver URL', placeholder: 'https://matrix.org' }, MATRIX_ACCESS_TOKEN: { label: 'Access token' }, MATRIX_USER_ID: { label: 'Bot user ID', placeholder: '@hermes:example.org' }, @@ -961,7 +1038,7 @@ export const en: Translations = { groupTitleGrouped: 'Ungroup sessions', groupTitleUngrouped: 'Group by workspace', allPinned: 'Everything here is pinned. Unpin a chat to show it in recents.', - shiftClickHint: 'Shift-click a chat to pin · drag to reorder', + shiftClickHint: 'Shift-click a chat to pin', noWorkspace: 'No workspace', newSessionIn: label => `New session in ${label}`, reorderWorkspace: label => `Reorder workspace ${label}`, @@ -1057,8 +1134,8 @@ export const en: Translations = { '/': 'slash command palette', '?': 'this quick help (delete to dismiss)', Enter: 'send · Shift+Enter for newline', - 'Cmd/Ctrl+K': 'send next queued turn', - 'Cmd/Ctrl+L': 'redraw', + 'Cmd/Ctrl+Shift+K': 'send next queued turn', + 'Cmd/Ctrl+/': 'all keyboard shortcuts', Esc: 'close popover · cancel run', '↑ / ↓': 'cycle popover / history' }, @@ -1212,7 +1289,10 @@ export const en: Translations = { featuredPitch: 'One subscription, 300+ frontier models — the recommended way to run Hermes', openRouterPitch: 'One key, hundreds of models — a solid default', apiKeyOptions: { - openrouter: { short: 'one key, many models', description: 'Hosts hundreds of models behind a single key. Good default for new installs.' }, + openrouter: { + short: 'one key, many models', + description: 'Hosts hundreds of models behind a single key. Good default for new installs.' + }, openai: { short: 'GPT-class models', description: 'Direct access to OpenAI models.' }, gemini: { short: 'Gemini models', description: 'Direct access to Google Gemini models.' }, xai: { short: 'Grok models', description: 'Direct access to xAI Grok models.' }, @@ -1459,8 +1539,7 @@ export const en: Translations = { showConsole: 'Show preview console', hideDevTools: 'Hide preview DevTools', openDevTools: 'Open preview DevTools', - finishedRestarting: message => - `Hermes finished restarting the preview server${message ? `: ${message}` : ''}`, + finishedRestarting: message => `Hermes finished restarting the preview server${message ? `: ${message}` : ''}`, failedRestarting: message => `Server restart failed: ${message}`, unknownError: 'unknown error', restartedTitle: 'Preview server restarted', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index b41be524a80..cc2281c366e 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -157,6 +157,20 @@ export interface Translations { muteHaptics: string unmuteHaptics: string openSettings: string + openKeybinds: string + } + + keybinds: { + title: string + subtitle: (open: string) => string + rebind: string + reset: string + resetAll: string + pressKey: string + set: string + conflictWith: (label: string) => string + categories: Record + actions: Record } language: { diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 78b3c2fea25..21f16f998aa 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -141,7 +141,74 @@ export const zh: Translations = { showRightSidebar: '显示右侧栏', muteHaptics: '关闭触感反馈', unmuteHaptics: '开启触感反馈', - openSettings: '打开设置' + openSettings: '打开设置', + openKeybinds: '键盘快捷键' + }, + + keybinds: { + title: '键盘快捷键', + subtitle: open => `点击快捷键即可重新绑定 · ${open} 可重新打开此面板。`, + rebind: '重新绑定', + reset: '恢复默认', + resetAll: '全部重置', + pressKey: '请按下按键…', + set: '设置', + conflictWith: label => `已绑定到“${label}”`, + categories: { + composer: '输入框', + profiles: '配置', + session: '会话', + navigation: '导航', + view: '视图' + }, + actions: { + 'keybinds.openPanel': '打开键盘快捷键', + 'nav.commandPalette': '打开命令面板', + 'nav.commandCenter': '打开命令中心', + 'nav.settings': '打开设置', + 'nav.profiles': '打开配置', + 'nav.skills': '打开技能', + 'nav.messaging': '打开消息', + 'nav.cron': '打开定时任务', + 'nav.agents': '打开智能体', + 'session.new': '新建会话', + 'session.togglePin': '固定/取消固定当前会话', + 'view.toggleSidebar': '切换会话侧边栏', + 'view.toggleRightSidebar': '切换文件浏览器', + 'view.flipPanes': '交换侧边栏位置', + 'appearance.toggleMode': '切换浅色/深色', + 'profile.default': '切换到默认配置', + 'profile.switch.1': '切换到配置 1', + 'profile.switch.2': '切换到配置 2', + 'profile.switch.3': '切换到配置 3', + 'profile.switch.4': '切换到配置 4', + 'profile.switch.5': '切换到配置 5', + 'profile.switch.6': '切换到配置 6', + 'profile.switch.7': '切换到配置 7', + 'profile.switch.8': '切换到配置 8', + 'profile.switch.9': '切换到配置 9', + 'profile.switch.10': '切换到配置 10', + 'profile.switch.11': '切换到配置 11', + 'profile.switch.12': '切换到配置 12', + 'profile.switch.13': '切换到配置 13', + 'profile.switch.14': '切换到配置 14', + 'profile.switch.15': '切换到配置 15', + 'profile.switch.16': '切换到配置 16', + 'profile.switch.17': '切换到配置 17', + 'profile.switch.18': '切换到配置 18', + 'profile.next': '下一个配置', + 'profile.prev': '上一个配置', + 'profile.toggleAll': '切换全部配置视图', + 'profile.create': '创建配置', + 'composer.send': '发送消息', + 'composer.newline': '插入换行', + 'composer.sendQueued': '发送下一条排队消息', + 'composer.mention': '引用文件、文件夹、网址', + 'composer.slash': '斜杠命令面板', + 'composer.help': '快速帮助', + 'composer.history': '切换弹窗/历史', + 'composer.cancel': '关闭弹窗·取消运行' + } }, language: { diff --git a/apps/desktop/src/lib/keybinds/actions.ts b/apps/desktop/src/lib/keybinds/actions.ts new file mode 100644 index 00000000000..313b48fd8ca --- /dev/null +++ b/apps/desktop/src/lib/keybinds/actions.ts @@ -0,0 +1,110 @@ +// The single source of truth for rebindable desktop hotkeys. +// +// Each entry is pure metadata: an id, a category, and the default combo(s). +// Handlers are wired separately in `use-keybinds.ts` (they need React context +// like navigate / theme); labels come from i18n (`t.keybinds.actions[id]`). To +// add a hotkey, add a row here and a handler there — nothing else. + +export type KeybindCategory = 'composer' | 'profiles' | 'session' | 'navigation' | 'view' + +// The self-referential opener — bound + dispatched like any action, but shown in +// the panel subtitle (not as its own row). +export const KEYBIND_PANEL_ACTION = 'keybinds.openPanel' + +// `composer` is read-only; the rest are rebindable. `view` is the catch-all for +// layout, appearance, and the panel-opener. +export const KEYBIND_CATEGORIES: readonly KeybindCategory[] = [ + 'composer', + 'profiles', + 'session', + 'navigation', + 'view' +] + +export interface KeybindActionMeta { + id: string + category: KeybindCategory + /** Default combos. Empty = shipped unbound (user can assign one). */ + defaults: readonly string[] +} + +// Positional switch slots for *named* profiles. The default profile lives on ⌘` +// (see `profile.default`), freeing ⌘1…⌘9 for profiles 1-9, then ⌘⌥1…⌘⌥9 for +// 10-18 — 18 native slots, none touching ⌘0 (reset zoom). +export const PROFILE_SLOT_COUNT = 18 + +function comboForSlot(slot: number): string { + return slot <= 9 ? `mod+${slot}` : `mod+alt+${slot - 9}` +} + +const PROFILE_SWITCH_ACTIONS: KeybindActionMeta[] = Array.from({ length: PROFILE_SLOT_COUNT }, (_, i) => ({ + id: `profile.switch.${i + 1}`, + category: 'profiles' as const, + defaults: [comboForSlot(i + 1)] +})) + +export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [ + // ── Profiles ───────────────────────────────────────────────────────────── + { id: 'profile.default', category: 'profiles', defaults: ['mod+`'] }, + ...PROFILE_SWITCH_ACTIONS, + { id: 'profile.next', category: 'profiles', defaults: ['mod+shift+]'] }, + { id: 'profile.prev', category: 'profiles', defaults: ['mod+shift+['] }, + { id: 'profile.toggleAll', category: 'profiles', defaults: ['mod+shift+0'] }, + { id: 'profile.create', category: 'profiles', defaults: [] }, + + // ── Session ────────────────────────────────────────────────────────────── + { id: 'session.new', category: 'session', defaults: ['mod+n'] }, + { id: 'session.togglePin', category: 'session', defaults: [] }, + + // ── Navigation ─────────────────────────────────────────────────────────── + { id: 'nav.commandPalette', category: 'navigation', defaults: ['mod+k', 'mod+p'] }, + { id: 'nav.commandCenter', category: 'navigation', defaults: ['mod+.'] }, + { id: 'nav.settings', category: 'navigation', defaults: ['mod+,'] }, + { id: 'nav.profiles', category: 'navigation', defaults: [] }, + { id: 'nav.skills', category: 'navigation', defaults: [] }, + { id: 'nav.messaging', category: 'navigation', defaults: [] }, + { id: 'nav.cron', category: 'navigation', defaults: [] }, + { id: 'nav.agents', category: 'navigation', defaults: [] }, + + // ── View (layout + appearance + the shortcuts panel itself) ─────────────── + { id: 'view.toggleSidebar', category: 'view', defaults: ['mod+b'] }, + { id: 'view.toggleRightSidebar', category: 'view', defaults: ['mod+j'] }, + { id: 'view.flipPanes', category: 'view', defaults: [] }, + { id: 'appearance.toggleMode', category: 'view', defaults: ['shift+x'] }, + { id: 'keybinds.openPanel', category: 'view', defaults: ['mod+/'] } +] + +export const KEYBIND_ACTION_IDS: readonly string[] = KEYBIND_ACTIONS.map(action => action.id) + +const ACTION_BY_ID = new Map(KEYBIND_ACTIONS.map(action => [action.id, action])) + +export function keybindAction(id: string): KeybindActionMeta | undefined { + return ACTION_BY_ID.get(id) +} + +export type KeybindBindings = Record + +export function defaultBindings(): KeybindBindings { + return Object.fromEntries(KEYBIND_ACTIONS.map(action => [action.id, [...action.defaults]])) +} + +// Fixed, non-rebindable shortcuts surfaced read-only in the panel so the map is +// complete. `keys` are canonical tokens run through `formatCombo` for display +// (single symbols like "@" / "/" pass through unchanged). Categories listed here +// render after the rebindable ones. +export interface KeybindReadonly { + id: string + category: KeybindCategory + keys: readonly string[] +} + +export const KEYBIND_READONLY: readonly KeybindReadonly[] = [ + { id: 'composer.send', category: 'composer', keys: ['enter'] }, + { id: 'composer.newline', category: 'composer', keys: ['shift+enter'] }, + { id: 'composer.sendQueued', category: 'composer', keys: ['mod+shift+k'] }, + { id: 'composer.mention', category: 'composer', keys: ['@'] }, + { id: 'composer.slash', category: 'composer', keys: ['/'] }, + { id: 'composer.help', category: 'composer', keys: ['?'] }, + { id: 'composer.history', category: 'composer', keys: ['up', 'down'] }, + { id: 'composer.cancel', category: 'composer', keys: ['escape'] } +] diff --git a/apps/desktop/src/lib/keybinds/combo.ts b/apps/desktop/src/lib/keybinds/combo.ts new file mode 100644 index 00000000000..a348ca4ee19 --- /dev/null +++ b/apps/desktop/src/lib/keybinds/combo.ts @@ -0,0 +1,169 @@ +// Keybind combo normalization + display. +// +// A combo is a canonical lowercase string like "mod+k", "mod+shift+]", "shift+x", +// or "r". `mod` is Cmd on macOS / Ctrl elsewhere, so a single binding works on +// both. We derive the base key from `event.code` (not `event.key`) so Shift never +// mutates it ("shift+/" stays "shift+/" instead of becoming "shift+?"). + +export const IS_MAC = + typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '') + +// event.code → canonical base token. Letters/digits map to their lowercase +// character; everything else uses an explicit name so combos read cleanly. +const CODE_TO_KEY: Record = { + Backquote: '`', + Backslash: '\\', + BracketLeft: '[', + BracketRight: ']', + Comma: ',', + Equal: '=', + Minus: '-', + Period: '.', + Quote: "'", + Semicolon: ';', + Slash: '/', + Space: 'space', + Enter: 'enter', + Escape: 'escape', + Backspace: 'backspace', + Tab: 'tab', + ArrowUp: 'up', + ArrowDown: 'down', + ArrowLeft: 'left', + ArrowRight: 'right' +} + +const MODIFIER_CODES = new Set([ + 'AltLeft', + 'AltRight', + 'ControlLeft', + 'ControlRight', + 'MetaLeft', + 'MetaRight', + 'ShiftLeft', + 'ShiftRight' +]) + +function baseKeyFromCode(code: string): string | null { + if (code.startsWith('Key')) { + return code.slice(3).toLowerCase() + } + + if (code.startsWith('Digit')) { + return code.slice(5) + } + + if (code.startsWith('Numpad')) { + const rest = code.slice(6) + + return /^[0-9]$/.test(rest) ? rest : null + } + + if (code.startsWith('F') && /^F\d{1,2}$/.test(code)) { + return code.toLowerCase() + } + + return CODE_TO_KEY[code] ?? null +} + +// Returns the canonical combo for a keydown, or null while only modifiers are +// held (so capture mode keeps waiting for a real key). +export function comboFromEvent(event: KeyboardEvent): string | null { + if (MODIFIER_CODES.has(event.code)) { + return null + } + + const base = baseKeyFromCode(event.code) + + if (!base) { + return null + } + + const parts: string[] = [] + + if (event.metaKey || event.ctrlKey) { + parts.push('mod') + } + + if (event.altKey) { + parts.push('alt') + } + + if (event.shiftKey) { + parts.push('shift') + } + + parts.push(base) + + return parts.join('+') +} + +const TOKEN_LABELS: Record = { + enter: '↵', + escape: 'Esc', + backspace: '⌫', + tab: '⇥', + space: 'Space', + up: '↑', + down: '↓', + left: '←', + right: '→' +} + +function labelForBase(base: string): string { + if (TOKEN_LABELS[base]) { + return TOKEN_LABELS[base] + } + + if (/^f\d{1,2}$/.test(base)) { + return base.toUpperCase() + } + + return base.length === 1 ? base.toUpperCase() : base +} + +// Human-readable label, e.g. "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere. +export function formatCombo(combo: string): string { + const parts = combo.split('+') + const base = parts.pop() ?? '' + const mods = parts + + const modLabels = mods.map(mod => { + if (mod === 'mod') { + return IS_MAC ? '⌘' : 'Ctrl' + } + + if (mod === 'alt') { + return IS_MAC ? '⌥' : 'Alt' + } + + if (mod === 'shift') { + return IS_MAC ? '⇧' : 'Shift' + } + + return mod + }) + + const tokens = [...modLabels, labelForBase(base)] + + return IS_MAC ? tokens.join('') : tokens.join('+') +} + +// True when focus is in a text-entry surface, so bare-key shortcuts don't fire +// while the user is typing. +export function isEditableTarget(target: EventTarget | null): boolean { + const el = target as HTMLElement | null + + return Boolean( + el?.isContentEditable || + el instanceof HTMLInputElement || + el instanceof HTMLTextAreaElement || + el instanceof HTMLSelectElement + ) +} + +// Combos with a primary modifier (Cmd/Ctrl) are safe to fire even while typing +// (e.g. ⌘K from the composer); bare/Shift-only combos are suppressed in inputs. +export function comboAllowedInInput(combo: string): boolean { + return combo.startsWith('mod+') || combo === 'mod' +} diff --git a/apps/desktop/src/store/keybinds.ts b/apps/desktop/src/store/keybinds.ts new file mode 100644 index 00000000000..bdbefed8682 --- /dev/null +++ b/apps/desktop/src/store/keybinds.ts @@ -0,0 +1,139 @@ +import { atom, computed } from 'nanostores' + +import { + defaultBindings, + KEYBIND_ACTION_IDS, + keybindAction, + type KeybindBindings +} from '@/lib/keybinds/actions' +import { arraysEqual, persistString, storedString } from '@/lib/storage' + +const STORAGE_KEY = 'hermes.desktop.keybinds' + +// Defaults overlaid with the user's stored overrides. Unknown / stale action ids +// are dropped; actions added in a later release pick up their shipped default. +function loadBindings(): KeybindBindings { + const base = defaultBindings() + const raw = storedString(STORAGE_KEY) + + if (!raw) { + return base + } + + try { + const parsed = JSON.parse(raw) as Record + + for (const id of KEYBIND_ACTION_IDS) { + const value = parsed[id] + + if (Array.isArray(value)) { + base[id] = value.filter((combo): combo is string => typeof combo === 'string') + } + } + } catch { + // Corrupt storage falls back to defaults. + } + + return base +} + +// Persist only the actions whose combos differ from their shipped default, so +// changing a default never gets shadowed by a stored snapshot. +function persistBindings(bindings: KeybindBindings): void { + const defaults = defaultBindings() + const diff: KeybindBindings = {} + + for (const id of KEYBIND_ACTION_IDS) { + const current = bindings[id] ?? [] + + if (!arraysEqual(current, defaults[id] ?? [])) { + diff[id] = current + } + } + + persistString(STORAGE_KEY, JSON.stringify(diff)) +} + +export const $bindings = atom(loadBindings()) + +$bindings.subscribe(persistBindings) + +// Reverse lookup combo → actionId for dispatch. First action wins on conflict; +// the panel/edit overlay surface conflicts so users can resolve them. +export const $comboIndex = computed($bindings, bindings => { + const index = new Map() + + for (const id of KEYBIND_ACTION_IDS) { + for (const combo of bindings[id] ?? []) { + if (!index.has(combo)) { + index.set(combo, id) + } + } + } + + return index +}) + +export function setBinding(actionId: string, combos: string[]): void { + if (!keybindAction(actionId)) { + return + } + + $bindings.set({ ...$bindings.get(), [actionId]: [...combos] }) +} + +export function resetBinding(actionId: string): void { + const action = keybindAction(actionId) + + if (!action) { + return + } + + $bindings.set({ ...$bindings.get(), [actionId]: [...action.defaults] }) +} + +export function resetAllBindings(): void { + $bindings.set(defaultBindings()) +} + +// Other actions that already use `combo` (excluding `actionId` itself). +export function conflictsFor(actionId: string, combo: string): string[] { + const bindings = $bindings.get() + + return KEYBIND_ACTION_IDS.filter(id => id !== actionId && (bindings[id] ?? []).includes(combo)) +} + +// ── Capture ───────────────────────────────────────────────────────────────── +// `$capture` is the action currently listening for its next keypress (a panel +// row armed for rebinding). Session-only — never persisted. + +export const $capture = atom(null) + +export function beginCapture(actionId: string): void { + $capture.set(actionId) +} + +export function endCapture(): void { + $capture.set(null) +} + +// ── Panel ─────────────────────────────────────────────────────────────────── + +export const $keybindPanelOpen = atom(false) + +export function openKeybindPanel(): void { + $keybindPanelOpen.set(true) +} + +export function closeKeybindPanel(): void { + $keybindPanelOpen.set(false) + $capture.set(null) +} + +export function toggleKeybindPanel(): void { + if ($keybindPanelOpen.get()) { + closeKeybindPanel() + } else { + openKeybindPanel() + } +} diff --git a/apps/desktop/src/store/profile.ts b/apps/desktop/src/store/profile.ts index e9342109d37..67b708fb219 100644 --- a/apps/desktop/src/store/profile.ts +++ b/apps/desktop/src/store/profile.ts @@ -288,6 +288,72 @@ export function setShowAllProfiles(value: boolean): void { $showAllProfiles.set(value) } +export function toggleShowAllProfiles(): void { + $showAllProfiles.set(!$showAllProfiles.get()) +} + +// ── Hotkey-driven profile switching ──────────────────────────────────────── +// Positional + relative navigation for the rail, used by the keybind runtime. +// The ordered list is [default, ...named-in-rail-order]; switching is a no-op +// when the slot is empty so unused ⌘N keys stay harmless. + +function orderedProfileKeys(): string[] { + const profiles = $profiles.get() + + const named = sortByProfileOrder( + profiles.filter(profile => !profile.is_default), + $profileOrder.get() + ).map(profile => normalizeProfileKey(profile.name)) + + const hasDefault = profiles.some(profile => profile.is_default) + + return hasDefault ? ['default', ...named] : named +} + +// Switch to the default (root ~/.hermes) profile — bound to ⌘1. +export function switchToDefaultProfile(): void { + const def = $profiles.get().find(profile => profile.is_default) + + selectProfile(def ? def.name : 'default') +} + +// Switch to the Nth named (non-default) profile in rail order (1-based). +export function switchProfileToSlot(slot: number): void { + const named = sortByProfileOrder( + $profiles.get().filter(profile => !profile.is_default), + $profileOrder.get() + ) + + const target = named[slot - 1] + + if (target) { + selectProfile(target.name) + } +} + +// Step to the next/previous profile in the rail, wrapping around. +export function cycleProfile(direction: 1 | -1): void { + const keys = orderedProfileKeys() + + if (keys.length < 2) { + return + } + + const current = $showAllProfiles.get() ? -1 : keys.indexOf(normalizeProfileKey($activeGatewayProfile.get())) + const start = current < 0 ? (direction === 1 ? -1 : 0) : current + const next = (start + direction + keys.length) % keys.length + + selectProfile(keys[next]) +} + +// Bumped to ask the rail to open its "create profile" dialog (the dialog state +// is local to the rail component; this lets a global hotkey trigger it). +export const $profileCreateRequest = atom(0) + +export function requestProfileCreate(): void { + $profileCreateRequest.set($profileCreateRequest.get() + 1) +} + // Keepalive ping for the active pool backend so the main-process idle reaper // (which can't see the direct renderer↔backend WS) spares it. No-op for the // primary/default backend, which is never pooled. diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 2ca8efd4437..34e987a5bf4 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -1188,3 +1188,42 @@ canvas { animation: none; } } + +/* ── Keybind panel / edit overlay: small key chips ────────────────────────── + A quiet `kbd`-style chip shared by the shortcuts panel and the on-screen + editor so both read as the same control. No animation, no glow. */ +.kbd-cap { + display: inline-grid; + place-items: center; + min-width: 1.5rem; + height: 1.4rem; + padding: 0 0.4rem; + border-radius: 0.375rem; + font-family: var(--dt-font-mono, ui-monospace, monospace); + font-size: 0.72rem; + font-weight: 500; + line-height: 1; + color: color-mix(in srgb, var(--dt-foreground) 82%, transparent); + background: color-mix(in srgb, var(--ui-bg-elevated) 70%, transparent); + border: 1px solid var(--ui-stroke-secondary); + box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--ui-stroke-tertiary) 50%, transparent); +} + +/* Unbound slot: a hollow dashed chip inviting a binding. */ +.kbd-cap--ghost { + color: color-mix(in srgb, var(--dt-foreground) 42%, transparent); + background: none; + border-style: dashed; + border-color: var(--ui-stroke-tertiary); + box-shadow: none; + font-style: italic; +} + +/* Waiting for a keypress: solid accent, no motion. */ +.kbd-capturing { + color: var(--theme-primary); + border-color: color-mix(in srgb, var(--theme-primary) 55%, var(--ui-stroke-secondary)) !important; + border-style: solid; + background: color-mix(in srgb, var(--theme-primary) 9%, var(--ui-bg-elevated)); + box-shadow: none; +} diff --git a/apps/desktop/src/themes/context.tsx b/apps/desktop/src/themes/context.tsx index 4ee3282450f..62d71869ba1 100644 --- a/apps/desktop/src/themes/context.tsx +++ b/apps/desktop/src/themes/context.tsx @@ -289,30 +289,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) { window.localStorage.setItem(MODE_KEY, next) }, []) - // Shift+X toggles light/dark anywhere outside an editable field. - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - const t = event.target as HTMLElement | null - - const editing = - t?.isContentEditable || - t instanceof HTMLInputElement || - t instanceof HTMLTextAreaElement || - t instanceof HTMLSelectElement - - if (editing || event.repeat || event.altKey || event.ctrlKey || event.metaKey) { - return - } - - if (event.shiftKey && event.code === 'KeyX') { - setMode(resolvedMode === 'dark' ? 'light' : 'dark') - } - } - - window.addEventListener('keydown', onKeyDown) - - return () => window.removeEventListener('keydown', onKeyDown) - }, [resolvedMode, setMode]) + // The light/dark toggle (Shift+X by default) is owned by the keybind runtime + // (`appearance.toggleMode`) so it shows up in the hotkey map and is rebindable. const value = useMemo( () => ({ theme: activeTheme, themeName, mode, resolvedMode, availableThemes: SKIN_LIST, setTheme, setMode }),