mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
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:
parent
1c2189839d
commit
5e2b83a8ad
17 changed files with 1124 additions and 95 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
144
apps/desktop/src/app/hooks/use-keybinds.ts
Normal file
144
apps/desktop/src/app/hooks/use-keybinds.ts
Normal 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 })
|
||||
}, [])
|
||||
}
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
218
apps/desktop/src/app/shell/keybind-panel.tsx
Normal file
218
apps/desktop/src/app/shell/keybind-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
110
apps/desktop/src/lib/keybinds/actions.ts
Normal file
110
apps/desktop/src/lib/keybinds/actions.ts
Normal 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'] }
|
||||
]
|
||||
169
apps/desktop/src/lib/keybinds/combo.ts
Normal file
169
apps/desktop/src/lib/keybinds/combo.ts
Normal 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'
|
||||
}
|
||||
139
apps/desktop/src/store/keybinds.ts
Normal file
139
apps/desktop/src/store/keybinds.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue