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:
@@ -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)
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user