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:
@@ -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'] }
|
||||
]
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user