feat(desktop): rebindable keyboard shortcuts panel

Add a central keybind registry + nanostore so desktop hotkeys are
discoverable and user-rebindable. A titlebar ⌨ button (and ⌘/) opens a
collapsible map grouped by Composer (read-only) / Profiles / Session /
Navigation / View; click any chip to capture a new combo. Overrides
persist to localStorage as a delta against shipped defaults, so future
default changes aren't shadowed by a stored snapshot.

Migrates the previously scattered inline listeners (palette, command
center, new session, sidebar, theme) into the registry, and adds profile
switch/cycle/create + default-profile hotkeys.
This commit is contained in:
Brooklyn Nicholson 2026-06-06 11:41:57 -05:00
parent 1c2189839d
commit 5e2b83a8ad
17 changed files with 1124 additions and 95 deletions

View file

@ -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()

View file

@ -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 (
<div aria-label="Profiles" className="flex items-center gap-0.5" role="tablist">
{/* 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 && (
<ProfilePill active glyph="home" label={defaultProfile.name} onSelect={() => selectProfile(defaultProfile.name)} />
<ProfilePill
active
glyph="home"
label={defaultProfile.name}
onSelect={() => selectProfile(defaultProfile.name)}
/>
)}
<div

View file

@ -13,7 +13,6 @@ import { useSkinCommand } from '@/themes/use-skin-command'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import { getSessionMessages, listAllProfileSessions, type SessionInfo } from '../hermes'
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
import { toggleCommandPalette } from '../store/command-palette'
import {
$panesFlipped,
$pinnedSessionIds,
@ -66,6 +65,7 @@ import { ChatSidebar } from './chat/sidebar'
import { CommandPalette } from './command-palette'
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
import { useKeybinds } from './hooks/use-keybinds'
import { ModelPickerOverlay } from './model-picker-overlay'
import { ModelVisibilityOverlay } from './model-visibility-overlay'
import { RightSidebarPane } from './right-sidebar'
@ -224,31 +224,6 @@ export function DesktopController() {
}
}, [])
// Global chrome shortcuts (plain Cmd/Ctrl, no alt/shift): Cmd+K / Cmd+P →
// command palette (the composer's "drain next queued" moved to Cmd+Shift+K),
// Cmd+. → command center (sessions / system / usage).
useEffect(() => {
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)

View file

@ -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<string, () => 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<HandlerMap>({})
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 })
}, [])
}

View file

@ -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 / ⌘/). */}
<KeybindPanel />
{/* Mounted at the shell root (after overlays) so success/error toasts
surface above every route and overlay not just the chat view. */}
<NotificationStack />

View file

@ -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<ReadonlySet<string>>(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 (
<DialogPrimitive.Root onOpenChange={next => !next && closeKeybindPanel()} open={open}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-black/25 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
aria-describedby={undefined}
className="fixed left-1/2 top-[9vh] z-[210] flex max-h-[82vh] w-[min(38rem,calc(100vw-2rem))] -translate-x-1/2 flex-col overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-[0_20px_48px_-24px_rgba(0,0,0,0.55)] duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
>
{/* Header */}
<div className="flex items-center justify-between gap-3 border-b border-(--ui-stroke-tertiary) px-4 py-3">
<div className="min-w-0">
<DialogPrimitive.Title className="text-sm font-semibold text-foreground">{k.title}</DialogPrimitive.Title>
<DialogPrimitive.Description className="mt-0.5 text-[0.72rem] text-muted-foreground">
{k.subtitle(openCombo ? formatCombo(openCombo) : '')}
</DialogPrimitive.Description>
</div>
<HeaderButton icon="discard" label={k.resetAll} onClick={resetAllBindings} />
</div>
{/* Body */}
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-1.5">
{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 (
<section key={category}>
<CategoryHeader
label={k.categories[category] ?? category}
onToggle={() => toggleCategory(category)}
open={sectionOpen}
/>
{sectionOpen && actions.map(action => <KeybindRow action={action} key={action.id} />)}
{sectionOpen && readonly.map(shortcut => <ReadonlyRow key={shortcut.id} shortcut={shortcut} />)}
</section>
)
})}
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
)
}
// 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 (
<button
className="group/kbd-cat flex w-fit items-center gap-1 px-2.5 pb-1 pt-3 text-left leading-none"
onClick={onToggle}
type="button"
>
<span className="text-[0.64rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70">{label}</span>
<DisclosureCaret
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/kbd-cat:opacity-100"
open={open}
size="0.6875rem"
/>
</button>
)
}
function HeaderButton({ icon, label, onClick }: { icon: string; label: string; onClick: () => void }) {
return (
<button
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-[0.72rem] font-medium text-muted-foreground transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground"
onClick={onClick}
type="button"
>
<Codicon name={icon} size="0.8125rem" />
{label}
</button>
)
}
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 (
<div className="group flex items-center gap-2.5 rounded-lg px-2.5 py-1 transition-colors hover:bg-(--chrome-action-hover)">
{/* Mirrors the reset button's footprint on the right so rows stay uniform. */}
<span aria-hidden className="size-6 shrink-0" />
<span className="min-w-0 flex-1 truncate text-[0.82rem] text-foreground/90">{label}</span>
{conflict && (
<span className="flex size-4 items-center justify-center text-amber-500/90" title={k.conflictWith(conflict)}>
<Codicon name="warning" size="0.8125rem" />
</span>
)}
{/* Click the caps to rebind — the on-screen editor does the same thing. */}
<button
aria-label={k.rebind}
className="flex shrink-0 items-center gap-1 rounded-lg outline-none"
onClick={() => (capturing ? endCapture() : beginCapture(action.id))}
title={k.rebind}
type="button"
>
{capturing ? (
<span className="kbd-cap kbd-capturing">{k.pressKey}</span>
) : combos.length > 0 ? (
combos.map(combo => (
<span className="kbd-cap" key={combo}>
{formatCombo(combo)}
</span>
))
) : (
<span className="kbd-cap kbd-cap--ghost">{k.set}</span>
)}
</button>
<button
aria-label={k.reset}
className="grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground/70 opacity-0 transition-all hover:bg-(--ui-control-active-background) hover:text-foreground group-hover:opacity-100"
onClick={() => resetBinding(action.id)}
title={k.reset}
type="button"
>
<Codicon name="discard" size="0.8125rem" />
</button>
</div>
)
}
// 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 (
<div className="flex items-center gap-2.5 rounded-lg px-2.5 py-1">
<span aria-hidden className="size-6 shrink-0" />
<span className="min-w-0 flex-1 truncate text-[0.82rem] text-foreground/75">{label}</span>
<div className="flex shrink-0 items-center gap-1">
{shortcut.keys.map(key => (
<span className="kbd-cap" key={key}>
{formatCombo(key)}
</span>
))}
</div>
<span aria-hidden className="size-6 shrink-0" />
</div>
)
}

View file

@ -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: <Codicon name="keyboard" />,
id: 'keybinds',
label: t.titlebar.openKeybinds,
onSelect: () => {
triggerHaptic('open')
toggleKeybindPanel()
}
},
{
icon: <Codicon name="settings-gear" />,
id: 'settings',

View file

@ -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.

View file

@ -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',

View file

@ -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<string, string>
actions: Record<string, string>
}
language: {

View file

@ -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: {

View file

@ -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<string, string[]>
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'] }
]

View file

@ -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<string, string> = {
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<string, string> = {
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'
}

View file

@ -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<string, unknown>
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<KeybindBindings>(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<string, string>()
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<string | null>(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()
}
}

View file

@ -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.

View file

@ -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;
}

View file

@ -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<ThemeContextValue>(
() => ({ theme: activeTheme, themeName, mode, resolvedMode, availableThemes: SKIN_LIST, setTheme, setMode }),