Add rebindable actions for the high-frequency gaps: focus composer, open model picker, next/prev session, search sessions (⌘⇧F), show files/ terminal tab, and nav→artifacts. Reconcile the duplicate Shift+N new- session listener into session.new's defaults, and surface the remaining context-local shortcuts (⌘↵ steer, ⌘L terminal selection, ⌘W close preview) as read-only rows so the panel is the honest source of truth.
187 lines
5.5 KiB
TypeScript
187 lines
5.5 KiB
TypeScript
import { useEffect, useRef } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
|
|
import { setRightSidebarTab } from '@/app/right-sidebar/store'
|
|
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 {
|
|
requestSessionSearchFocus,
|
|
setFileBrowserOpen,
|
|
toggleFileBrowserOpen,
|
|
togglePanesFlipped,
|
|
toggleSidebarOpen
|
|
} from '@/store/layout'
|
|
import {
|
|
cycleProfile,
|
|
requestProfileCreate,
|
|
switchProfileToSlot,
|
|
switchToDefaultProfile,
|
|
toggleShowAllProfiles
|
|
} from '@/store/profile'
|
|
import { $activeSessionId, $sessions, setModelPickerOpen } from '@/store/session'
|
|
import { useTheme } from '@/themes/context'
|
|
|
|
import { requestComposerFocus } from '../chat/composer/focus'
|
|
import {
|
|
AGENTS_ROUTE,
|
|
ARTIFACTS_ROUTE,
|
|
CRON_ROUTE,
|
|
MESSAGING_ROUTE,
|
|
PROFILES_ROUTE,
|
|
sessionRoute,
|
|
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)
|
|
}
|
|
|
|
// Move to the adjacent session in recency order, wrapping at the ends.
|
|
const cycleSession = (direction: 1 | -1) => {
|
|
const sessions = $sessions.get()
|
|
|
|
if (sessions.length < 2) {
|
|
return
|
|
}
|
|
|
|
const current = sessions.findIndex(session => session.id === $activeSessionId.get())
|
|
const start = current === -1 ? (direction === 1 ? -1 : 0) : current
|
|
const next = sessions[(start + direction + sessions.length) % sessions.length]
|
|
|
|
if (next) {
|
|
navigate(sessionRoute(next.id))
|
|
}
|
|
}
|
|
|
|
const showRightSidebarTab = (tab: 'files' | 'terminal') => {
|
|
setFileBrowserOpen(true)
|
|
setRightSidebarTab(tab)
|
|
}
|
|
|
|
handlersRef.current = {
|
|
'keybinds.openPanel': toggleKeybindPanel,
|
|
|
|
'composer.focus': () => requestComposerFocus('main'),
|
|
'composer.modelPicker': () => setModelPickerOpen(true),
|
|
|
|
'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.artifacts': () => navigate(ARTIFACTS_ROUTE),
|
|
'nav.cron': () => navigate(CRON_ROUTE),
|
|
'nav.agents': () => navigate(AGENTS_ROUTE),
|
|
|
|
'session.new': () => {
|
|
deps.startFreshSession()
|
|
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
|
|
},
|
|
'session.next': () => cycleSession(1),
|
|
'session.prev': () => cycleSession(-1),
|
|
'session.focusSearch': requestSessionSearchFocus,
|
|
'session.togglePin': deps.toggleSelectedPin,
|
|
|
|
'view.toggleSidebar': toggleSidebarOpen,
|
|
'view.toggleRightSidebar': toggleFileBrowserOpen,
|
|
'view.showFiles': () => showRightSidebarTab('files'),
|
|
'view.showTerminal': () => showRightSidebarTab('terminal'),
|
|
'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 })
|
|
}, [])
|
|
}
|