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 (
+
+ {label}
+
+
+ )
+}
+
+function HeaderButton({ icon, label, onClick }: { icon: string; label: string; onClick: () => void }) {
+ return (
+
+
+ {label}
+
+ )
+}
+
+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. */}
+ (capturing ? endCapture() : beginCapture(action.id))}
+ title={k.rebind}
+ type="button"
+ >
+ {capturing ? (
+ {k.pressKey}
+ ) : combos.length > 0 ? (
+ combos.map(combo => (
+
+ {formatCombo(combo)}
+
+ ))
+ ) : (
+ {k.set}
+ )}
+
+
+ resetBinding(action.id)}
+ title={k.reset}
+ type="button"
+ >
+
+
+
+ )
+}
+
+// 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 }),