feat(desktop): rebindable keyboard shortcuts panel

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

Migrates the previously scattered inline listeners (palette, command
center, new session, sidebar, theme) into the registry, and adds profile
switch/cycle/create + default-profile hotkeys.
This commit is contained in:
Brooklyn Nicholson
2026-06-06 11:41:57 -05:00
parent 1c2189839d
commit 5e2b83a8ad
17 changed files with 1124 additions and 95 deletions
+110
View File
@@ -0,0 +1,110 @@
// The single source of truth for rebindable desktop hotkeys.
//
// Each entry is pure metadata: an id, a category, and the default combo(s).
// Handlers are wired separately in `use-keybinds.ts` (they need React context
// like navigate / theme); labels come from i18n (`t.keybinds.actions[id]`). To
// add a hotkey, add a row here and a handler there — nothing else.
export type KeybindCategory = 'composer' | 'profiles' | 'session' | 'navigation' | 'view'
// The self-referential opener — bound + dispatched like any action, but shown in
// the panel subtitle (not as its own row).
export const KEYBIND_PANEL_ACTION = 'keybinds.openPanel'
// `composer` is read-only; the rest are rebindable. `view` is the catch-all for
// layout, appearance, and the panel-opener.
export const KEYBIND_CATEGORIES: readonly KeybindCategory[] = [
'composer',
'profiles',
'session',
'navigation',
'view'
]
export interface KeybindActionMeta {
id: string
category: KeybindCategory
/** Default combos. Empty = shipped unbound (user can assign one). */
defaults: readonly string[]
}
// Positional switch slots for *named* profiles. The default profile lives on ⌘`
// (see `profile.default`), freeing ⌘1…⌘9 for profiles 1-9, then ⌘⌥1…⌘⌥9 for
// 10-18 — 18 native slots, none touching ⌘0 (reset zoom).
export const PROFILE_SLOT_COUNT = 18
function comboForSlot(slot: number): string {
return slot <= 9 ? `mod+${slot}` : `mod+alt+${slot - 9}`
}
const PROFILE_SWITCH_ACTIONS: KeybindActionMeta[] = Array.from({ length: PROFILE_SLOT_COUNT }, (_, i) => ({
id: `profile.switch.${i + 1}`,
category: 'profiles' as const,
defaults: [comboForSlot(i + 1)]
}))
export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
// ── Profiles ─────────────────────────────────────────────────────────────
{ id: 'profile.default', category: 'profiles', defaults: ['mod+`'] },
...PROFILE_SWITCH_ACTIONS,
{ id: 'profile.next', category: 'profiles', defaults: ['mod+shift+]'] },
{ id: 'profile.prev', category: 'profiles', defaults: ['mod+shift+['] },
{ id: 'profile.toggleAll', category: 'profiles', defaults: ['mod+shift+0'] },
{ id: 'profile.create', category: 'profiles', defaults: [] },
// ── Session ──────────────────────────────────────────────────────────────
{ id: 'session.new', category: 'session', defaults: ['mod+n'] },
{ id: 'session.togglePin', category: 'session', defaults: [] },
// ── Navigation ───────────────────────────────────────────────────────────
{ id: 'nav.commandPalette', category: 'navigation', defaults: ['mod+k', 'mod+p'] },
{ id: 'nav.commandCenter', category: 'navigation', defaults: ['mod+.'] },
{ id: 'nav.settings', category: 'navigation', defaults: ['mod+,'] },
{ id: 'nav.profiles', category: 'navigation', defaults: [] },
{ id: 'nav.skills', category: 'navigation', defaults: [] },
{ id: 'nav.messaging', category: 'navigation', defaults: [] },
{ id: 'nav.cron', category: 'navigation', defaults: [] },
{ id: 'nav.agents', category: 'navigation', defaults: [] },
// ── View (layout + appearance + the shortcuts panel itself) ───────────────
{ id: 'view.toggleSidebar', category: 'view', defaults: ['mod+b'] },
{ id: 'view.toggleRightSidebar', category: 'view', defaults: ['mod+j'] },
{ id: 'view.flipPanes', category: 'view', defaults: [] },
{ id: 'appearance.toggleMode', category: 'view', defaults: ['shift+x'] },
{ id: 'keybinds.openPanel', category: 'view', defaults: ['mod+/'] }
]
export const KEYBIND_ACTION_IDS: readonly string[] = KEYBIND_ACTIONS.map(action => action.id)
const ACTION_BY_ID = new Map(KEYBIND_ACTIONS.map(action => [action.id, action]))
export function keybindAction(id: string): KeybindActionMeta | undefined {
return ACTION_BY_ID.get(id)
}
export type KeybindBindings = Record<string, string[]>
export function defaultBindings(): KeybindBindings {
return Object.fromEntries(KEYBIND_ACTIONS.map(action => [action.id, [...action.defaults]]))
}
// Fixed, non-rebindable shortcuts surfaced read-only in the panel so the map is
// complete. `keys` are canonical tokens run through `formatCombo` for display
// (single symbols like "@" / "/" pass through unchanged). Categories listed here
// render after the rebindable ones.
export interface KeybindReadonly {
id: string
category: KeybindCategory
keys: readonly string[]
}
export const KEYBIND_READONLY: readonly KeybindReadonly[] = [
{ id: 'composer.send', category: 'composer', keys: ['enter'] },
{ id: 'composer.newline', category: 'composer', keys: ['shift+enter'] },
{ id: 'composer.sendQueued', category: 'composer', keys: ['mod+shift+k'] },
{ id: 'composer.mention', category: 'composer', keys: ['@'] },
{ id: 'composer.slash', category: 'composer', keys: ['/'] },
{ id: 'composer.help', category: 'composer', keys: ['?'] },
{ id: 'composer.history', category: 'composer', keys: ['up', 'down'] },
{ id: 'composer.cancel', category: 'composer', keys: ['escape'] }
]
+169
View File
@@ -0,0 +1,169 @@
// Keybind combo normalization + display.
//
// A combo is a canonical lowercase string like "mod+k", "mod+shift+]", "shift+x",
// or "r". `mod` is Cmd on macOS / Ctrl elsewhere, so a single binding works on
// both. We derive the base key from `event.code` (not `event.key`) so Shift never
// mutates it ("shift+/" stays "shift+/" instead of becoming "shift+?").
export const IS_MAC =
typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '')
// event.code → canonical base token. Letters/digits map to their lowercase
// character; everything else uses an explicit name so combos read cleanly.
const CODE_TO_KEY: Record<string, string> = {
Backquote: '`',
Backslash: '\\',
BracketLeft: '[',
BracketRight: ']',
Comma: ',',
Equal: '=',
Minus: '-',
Period: '.',
Quote: "'",
Semicolon: ';',
Slash: '/',
Space: 'space',
Enter: 'enter',
Escape: 'escape',
Backspace: 'backspace',
Tab: 'tab',
ArrowUp: 'up',
ArrowDown: 'down',
ArrowLeft: 'left',
ArrowRight: 'right'
}
const MODIFIER_CODES = new Set([
'AltLeft',
'AltRight',
'ControlLeft',
'ControlRight',
'MetaLeft',
'MetaRight',
'ShiftLeft',
'ShiftRight'
])
function baseKeyFromCode(code: string): string | null {
if (code.startsWith('Key')) {
return code.slice(3).toLowerCase()
}
if (code.startsWith('Digit')) {
return code.slice(5)
}
if (code.startsWith('Numpad')) {
const rest = code.slice(6)
return /^[0-9]$/.test(rest) ? rest : null
}
if (code.startsWith('F') && /^F\d{1,2}$/.test(code)) {
return code.toLowerCase()
}
return CODE_TO_KEY[code] ?? null
}
// Returns the canonical combo for a keydown, or null while only modifiers are
// held (so capture mode keeps waiting for a real key).
export function comboFromEvent(event: KeyboardEvent): string | null {
if (MODIFIER_CODES.has(event.code)) {
return null
}
const base = baseKeyFromCode(event.code)
if (!base) {
return null
}
const parts: string[] = []
if (event.metaKey || event.ctrlKey) {
parts.push('mod')
}
if (event.altKey) {
parts.push('alt')
}
if (event.shiftKey) {
parts.push('shift')
}
parts.push(base)
return parts.join('+')
}
const TOKEN_LABELS: Record<string, string> = {
enter: '↵',
escape: 'Esc',
backspace: '⌫',
tab: '⇥',
space: 'Space',
up: '↑',
down: '↓',
left: '←',
right: '→'
}
function labelForBase(base: string): string {
if (TOKEN_LABELS[base]) {
return TOKEN_LABELS[base]
}
if (/^f\d{1,2}$/.test(base)) {
return base.toUpperCase()
}
return base.length === 1 ? base.toUpperCase() : base
}
// Human-readable label, e.g. "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere.
export function formatCombo(combo: string): string {
const parts = combo.split('+')
const base = parts.pop() ?? ''
const mods = parts
const modLabels = mods.map(mod => {
if (mod === 'mod') {
return IS_MAC ? '⌘' : 'Ctrl'
}
if (mod === 'alt') {
return IS_MAC ? '⌥' : 'Alt'
}
if (mod === 'shift') {
return IS_MAC ? '⇧' : 'Shift'
}
return mod
})
const tokens = [...modLabels, labelForBase(base)]
return IS_MAC ? tokens.join('') : tokens.join('+')
}
// True when focus is in a text-entry surface, so bare-key shortcuts don't fire
// while the user is typing.
export function isEditableTarget(target: EventTarget | null): boolean {
const el = target as HTMLElement | null
return Boolean(
el?.isContentEditable ||
el instanceof HTMLInputElement ||
el instanceof HTMLTextAreaElement ||
el instanceof HTMLSelectElement
)
}
// Combos with a primary modifier (Cmd/Ctrl) are safe to fire even while typing
// (e.g. ⌘K from the composer); bare/Shift-only combos are suppressed in inputs.
export function comboAllowedInInput(combo: string): boolean {
return combo.startsWith('mod+') || combo === 'mod'
}