Compare commits

...

1 Commits

Author SHA1 Message Date
ethernet
a23bbe3348 gui(refactor): unify keybinding ui & types 2026-06-10 11:52:46 -04:00
14 changed files with 146 additions and 94 deletions

View File

@@ -38,6 +38,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { Tip } from '@/components/ui/tooltip' import { Tip } from '@/components/ui/tooltip'
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes' import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
import { useI18n } from '@/i18n' import { useI18n } from '@/i18n'
import { normalizeCombo } from '@/lib/keybinds/combo'
import { profileColor } from '@/lib/profile-color' import { profileColor } from '@/lib/profile-color'
import { sessionMatchesSearch } from '@/lib/session-search' import { sessionMatchesSearch } from '@/lib/session-search'
import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source' import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source'
@@ -111,8 +112,7 @@ const NON_SESSION_LOAD_STEP = 10
// Render the modifier key the user actually presses on this platform. The // Render the modifier key the user actually presses on this platform. The
// global accelerator is bound to both Cmd+N (macOS) and Ctrl+N (everywhere // global accelerator is bound to both Cmd+N (macOS) and Ctrl+N (everywhere
// else) in desktop-controller.tsx, but the hint should match muscle memory. // else) in desktop-controller.tsx, but the hint should match muscle memory.
const NEW_SESSION_KBD: readonly string[] = const NEW_SESSION_KBD: readonly string[] =normalizeCombo('mod+n')
typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac') ? ['⌘', 'N'] : ['Ctrl', 'N']
const SIDEBAR_NAV: SidebarNavItem[] = [ const SIDEBAR_NAV: SidebarNavItem[] = [
{ {

View File

@@ -10,6 +10,7 @@ import type { SessionInfo } from '@/hermes'
import { type Translations, useI18n } from '@/i18n' import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime' import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics' import { triggerHaptic } from '@/lib/haptics'
import { modKey } from '@/lib/keybinds/combo'
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source' import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { $attentionSessionIds } from '@/store/session' import { $attentionSessionIds } from '@/store/session'
@@ -133,11 +134,11 @@ export function SidebarSessionRow({
return return
} }
// ⌘-click (mac) / -click (win/linux) pops the chat into its own // ⌘-click (mac) / Ctrl-click (win/linux) pops the chat into its own
// window — the universal "open in a new window" gesture. Archive // window — the universal "open in a new window" gesture. Archive
// lives in the row's ⋯ and right-click menus. Falls through to a // lives in the row's ⋯ and right-click menus. Falls through to a
// normal resume when standalone windows aren't available (web embed). // normal resume when standalone windows aren't available (web embed).
if ((event.metaKey || event.ctrlKey) && canOpenSessionWindow()) { if (event[modKey] && canOpenSessionWindow()) {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
triggerHaptic('selection') triggerHaptic('selection')

View File

@@ -91,6 +91,7 @@ import { CommandPalette } from './command-palette'
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot' import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
import { useGatewayRequest } from './gateway/hooks/use-gateway-request' import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
import { useKeybinds } from './hooks/use-keybinds' import { useKeybinds } from './hooks/use-keybinds'
import { modKey } from '@/lib/keybinds/combo'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants' import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants'
import { ModelPickerOverlay } from './model-picker-overlay' import { ModelPickerOverlay } from './model-picker-overlay'
import { ModelVisibilityOverlay } from './model-visibility-overlay' import { ModelVisibilityOverlay } from './model-visibility-overlay'
@@ -271,7 +272,7 @@ export function DesktopController() {
return return
} }
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') { if (event[modKey] && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
closeActiveRightRailTab() closeActiveRightRailTab()

View File

@@ -69,7 +69,7 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
variant="secondary" variant="secondary"
> >
{t.rightSidebar.addToChat} {t.rightSidebar.addToChat}
<span className="ml-1 text-[0.6rem] text-(--ui-text-tertiary)">{addSelectionShortcutLabel()}</span> <span className="ml-1 text-[0.6rem] text-(--ui-text-tertiary)">{addSelectionShortcutLabel}</span>
</Button> </Button>
</div> </div>
)} )}

View File

@@ -1,6 +1,7 @@
import type { ITheme, Terminal } from '@xterm/xterm' import type { ITheme, Terminal } from '@xterm/xterm'
import type { CSSProperties } from 'react' import type { CSSProperties } from 'react'
import { formatCombo, modKey } from '@/lib/keybinds/combo'
import type { DesktopTerminalPalette } from '@/themes/types' import type { DesktopTerminalPalette } from '@/themes/types'
// VS Code's default integrated-terminal palette (terminalColorRegistry.ts) — a // VS Code's default integrated-terminal palette (terminalColorRegistry.ts) — a
@@ -97,12 +98,10 @@ export function resolveSurfaceColor(fallback: string): string {
return resolved && resolved !== 'rgba(0, 0, 0, 0)' ? resolved : fallback return resolved && resolved !== 'rgba(0, 0, 0, 0)' ? resolved : fallback
} }
export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac') export const addSelectionShortcutLabel = formatCombo('mod+l')
export const addSelectionShortcutLabel = () => (isMacPlatform() ? '⌘L' : 'Ctrl+L')
export function isAddSelectionShortcut(event: KeyboardEvent) { export function isAddSelectionShortcut(event: KeyboardEvent) {
const mod = isMacPlatform() ? event.metaKey : event.ctrlKey const mod = event[modKey]
return mod && !event.shiftKey && event.key.toLowerCase() === 'l' return mod && !event.shiftKey && event.key.toLowerCase() === 'l'
} }

View File

@@ -14,7 +14,7 @@ import {
type KeybindActionMeta, type KeybindActionMeta,
type KeybindReadonly type KeybindReadonly
} from '@/lib/keybinds/actions' } from '@/lib/keybinds/actions'
import { formatCombo } from '@/lib/keybinds/combo' import { formatCombo, formatFakeCombo } from '@/lib/keybinds/combo'
import { arraysEqual } from '@/lib/storage' import { arraysEqual } from '@/lib/storage'
import { import {
$bindings, $bindings,
@@ -210,7 +210,7 @@ function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) {
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
{shortcut.keys.map(key => ( {shortcut.keys.map(key => (
<span className="kbd-cap" key={key}> <span className="kbd-cap" key={key}>
{formatCombo(key)} {formatFakeCombo(key)}
</span> </span>
))} ))}
</div> </div>

View File

@@ -16,6 +16,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { useI18n } from '@/i18n' import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics' import { triggerHaptic } from '@/lib/haptics'
import { ChevronDown, Loader2 } from '@/lib/icons' import { ChevronDown, Loader2 } from '@/lib/icons'
import { formatCombo } from '@/lib/keybinds/combo'
import { $gateway } from '@/store/gateway' import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications' import { notifyError } from '@/store/notifications'
import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts' import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts'
@@ -50,8 +51,6 @@ export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => {
return <ApprovalBar request={request} /> return <ApprovalBar request={request} />
} }
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform)
const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => { const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
const { t } = useI18n() const { t } = useI18n()
const copy = t.assistant.approval const copy = t.assistant.approval
@@ -127,7 +126,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
variant="ghost" variant="ghost"
> >
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run} {submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run}
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>} {submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{formatCombo('mod+enter')}</span>}
</Button> </Button>
<span aria-hidden className="w-px self-stretch bg-primary/20" /> <span aria-hidden className="w-px self-stretch bg-primary/20" />
<DropdownMenu> <DropdownMenu>

View File

@@ -1,4 +1,5 @@
import { FIELD_DESCRIPTIONS, FIELD_LABELS } from '@/app/settings/constants' import { FIELD_DESCRIPTIONS, FIELD_LABELS } from '@/app/settings/constants'
import { formatCombo } from '@/lib/keybinds/combo'
import type { Translations } from './types' import type { Translations } from './types'
@@ -518,7 +519,7 @@ export const en: Translations = {
loading: 'Loading archived sessions…', loading: 'Loading archived sessions…',
archivedTitle: 'Archived sessions', archivedTitle: 'Archived sessions',
archivedIntro: archivedIntro:
'Archived chats are hidden from the sidebar but keep all their messages. Ctrl/⌘-click a chat in the sidebar to archive it.', `Archived chats are hidden from the sidebar but keep all their messages. ${formatCombo('mod')}-click a chat in the sidebar to archive it.`,
emptyArchivedTitle: 'Nothing archived', emptyArchivedTitle: 'Nothing archived',
emptyArchivedDesc: 'Archive a chat to hide it here.', emptyArchivedDesc: 'Archive a chat to hide it here.',
unarchive: 'Unarchive', unarchive: 'Unarchive',
@@ -529,7 +530,7 @@ export const en: Translations = {
defaultDirTitle: 'Default project directory', defaultDirTitle: 'Default project directory',
defaultDirDesc: defaultDirDesc:
'New sessions start in this folder unless you pick another. Leave it unset to use your home directory.', 'New sessions start in this folder unless you pick another. Leave it unset to use your home directory.',
defaultDirUpdated: 'Default project directory updated — start a new chat (Ctrl/⌘+N) for it to take effect', defaultDirUpdated: `Default project directory updated — start a new chat (${formatCombo('mod+n')}) for it to take effect`,
defaultsTo: label => `Defaults to ${label}.`, defaultsTo: label => `Defaults to ${label}.`,
change: 'Change', change: 'Change',
choose: 'Choose', choose: 'Choose',
@@ -1677,7 +1678,7 @@ export const en: Translations = {
loadingQuestion: 'Loading question…', loadingQuestion: 'Loading question…',
other: 'Other (type your answer)', other: 'Other (type your answer)',
placeholder: 'Type your answer…', placeholder: 'Type your answer…',
shortcut: '⌘/Ctrl + Enter to send', shortcut: `${formatCombo('mod+enter')} to send`,
back: 'Back', back: 'Back',
skip: 'Skip', skip: 'Skip',
send: 'Send' send: 'Send'

View File

@@ -1,4 +1,5 @@
import { defineFieldCopy } from '@/app/settings/field-copy' import { defineFieldCopy } from '@/app/settings/field-copy'
import { formatCombo } from '@/lib/keybinds/combo'
import { defineLocale } from './define-locale' import { defineLocale } from './define-locale'
@@ -642,7 +643,7 @@ export const ja = defineLocale({
loading: 'アーカイブ済みセッションを読み込み中…', loading: 'アーカイブ済みセッションを読み込み中…',
archivedTitle: 'アーカイブ済みセッション', archivedTitle: 'アーカイブ済みセッション',
archivedIntro: archivedIntro:
'アーカイブ済みチャットはサイドバーでは非表示になりますが、すべてのメッセージは保持されます。サイドバーのチャットを Ctrl/⌘ クリックするとアーカイブできます。', `アーカイブ済みチャットはサイドバーでは非表示になりますが、すべてのメッセージは保持されます。サイドバーのチャットを ${formatCombo('mod')} クリックするとアーカイブできます。`,
emptyArchivedTitle: 'アーカイブがありません', emptyArchivedTitle: 'アーカイブがありません',
emptyArchivedDesc: 'チャットをアーカイブするとここに表示されます。', emptyArchivedDesc: 'チャットをアーカイブするとここに表示されます。',
unarchive: 'アーカイブを解除', unarchive: 'アーカイブを解除',
@@ -1811,7 +1812,7 @@ export const ja = defineLocale({
loadingQuestion: '質問を読み込み中…', loadingQuestion: '質問を読み込み中…',
other: 'その他(回答を入力)', other: 'その他(回答を入力)',
placeholder: '回答を入力…', placeholder: '回答を入力…',
shortcut: '⌘/Ctrl + Enter で送信', shortcut: `${formatCombo('mod+enter')} で送信`,
back: '戻る', back: '戻る',
skip: 'スキップ', skip: 'スキップ',
send: '送信' send: '送信'

View File

@@ -1,4 +1,5 @@
import { defineFieldCopy } from '@/app/settings/field-copy' import { defineFieldCopy } from '@/app/settings/field-copy'
import { formatCombo } from '@/lib/keybinds/combo'
import { defineLocale } from './define-locale' import { defineLocale } from './define-locale'
@@ -627,7 +628,7 @@ export const zhHant = defineLocale({
loading: '正在載入已封存工作階段…', loading: '正在載入已封存工作階段…',
archivedTitle: '已封存工作階段', archivedTitle: '已封存工作階段',
archivedIntro: archivedIntro:
'已封存的聊天會從側邊欄隱藏,但保留全部訊息。在側邊欄 Ctrl/⌘ 點擊聊天即可封存。', `已封存的聊天會從側邊欄隱藏,但保留全部訊息。在側邊欄 ${formatCombo('mod')} 點擊聊天即可封存。`,
emptyArchivedTitle: '暫無封存', emptyArchivedTitle: '暫無封存',
emptyArchivedDesc: '封存一個聊天後會顯示在這裡。', emptyArchivedDesc: '封存一個聊天後會顯示在這裡。',
unarchive: '取消封存', unarchive: '取消封存',
@@ -1772,7 +1773,7 @@ export const zhHant = defineLocale({
loadingQuestion: '正在載入問題…', loadingQuestion: '正在載入問題…',
other: '其他(輸入您的答案)', other: '其他(輸入您的答案)',
placeholder: '輸入您的答案…', placeholder: '輸入您的答案…',
shortcut: '⌘/Ctrl + Enter 傳送', shortcut: `${formatCombo('mod+enter')} 傳送`,
back: '返回', back: '返回',
skip: '略過', skip: '略過',
send: '傳送' send: '傳送'

View File

@@ -1,4 +1,5 @@
import { defineFieldCopy } from '@/app/settings/field-copy' import { defineFieldCopy } from '@/app/settings/field-copy'
import { formatCombo } from '@/lib/keybinds/combo'
import type { Translations } from './types' import type { Translations } from './types'
@@ -712,7 +713,7 @@ export const zh: Translations = {
sessions: { sessions: {
loading: '正在加载已归档会话…', loading: '正在加载已归档会话…',
archivedTitle: '已归档会话', archivedTitle: '已归档会话',
archivedIntro: '已归档对话会从侧边栏隐藏,但会保留全部消息。在侧边栏 Ctrl/⌘ 点击对话即可归档。', archivedIntro: `已归档对话会从侧边栏隐藏,但会保留全部消息。在侧边栏 ${formatCombo('mod')} 点击对话即可归档。`,
emptyArchivedTitle: '暂无归档', emptyArchivedTitle: '暂无归档',
emptyArchivedDesc: '归档一个对话后会显示在这里。', emptyArchivedDesc: '归档一个对话后会显示在这里。',
unarchive: '取消归档', unarchive: '取消归档',
@@ -1856,7 +1857,7 @@ export const zh: Translations = {
loadingQuestion: '正在加载问题…', loadingQuestion: '正在加载问题…',
other: '其他 (输入你的答案)', other: '其他 (输入你的答案)',
placeholder: '输入你的答案…', placeholder: '输入你的答案…',
shortcut: '⌘/Ctrl + Enter 发送', shortcut: `${formatCombo('mod+enter')} 发送`,
back: '返回', back: '返回',
skip: '跳过', skip: '跳过',
send: '发送' send: '发送'

View File

@@ -5,6 +5,9 @@
// like navigate / theme); labels come from i18n (`t.keybinds.actions[id]`). To // 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. // add a hotkey, add a row here and a handler there — nothing else.
import type { Combo, FakeCombo } from "./combo";
export type KeybindCategory = 'composer' | 'profiles' | 'session' | 'navigation' | 'view' export type KeybindCategory = 'composer' | 'profiles' | 'session' | 'navigation' | 'view'
// The self-referential opener — bound + dispatched like any action, but shown in // The self-referential opener — bound + dispatched like any action, but shown in
@@ -27,15 +30,16 @@ export interface KeybindActionMeta {
// `profile.default`) — ⌘` is macOS-reserved (window cycling) and ⌘0 is reset-zoom. // `profile.default`) — ⌘` is macOS-reserved (window cycling) and ⌘0 is reset-zoom.
export const PROFILE_SLOT_COUNT = 18 export const PROFILE_SLOT_COUNT = 18
function comboForSlot(slot: number): string { const PROFILE_SWITCH_ACTIONS: KeybindActionMeta[] = Array.from({ length: PROFILE_SLOT_COUNT }, (_, i) => {
return slot <= 9 ? `mod+${slot}` : `mod+alt+${slot - 9}` const slot = i+1
} const combo = (slot <= 9 ? `mod+${slot}` : `mod+alt+${slot - 9}`) as Combo
const PROFILE_SWITCH_ACTIONS: KeybindActionMeta[] = Array.from({ length: PROFILE_SLOT_COUNT }, (_, i) => ({ return ({
id: `profile.switch.${i + 1}`, id: `profile.switch.${i + 1}`,
category: 'profiles' as const, category: 'profiles' as const,
defaults: [comboForSlot(i + 1)] defaults: [combo]
})) })
})
// ⌘` on macOS / Ctrl+` elsewhere (the `~` key), plus the Shift/tilde variant. // ⌘` on macOS / Ctrl+` elsewhere (the `~` key), plus the Shift/tilde variant.
// `mod` keeps one binding cross-platform; on macOS this shadows the system // `mod` keeps one binding cross-platform; on macOS this shadows the system
@@ -104,10 +108,12 @@ export function keybindAction(id: string): KeybindActionMeta | undefined {
return ACTION_BY_ID.get(id) return ACTION_BY_ID.get(id)
} }
export type KeybindBindings = Record<string, string[]> export type KeybindBindings = Record<string, Combo[]>
export function defaultBindings(): KeybindBindings { export function defaultBindings(): KeybindBindings {
return Object.fromEntries(KEYBIND_ACTIONS.map(action => [action.id, [...action.defaults]])) return Object.fromEntries<string, Combo[]>(
KEYBIND_ACTIONS.map(action => [action.id, [...action.defaults] as Combo[]])
)
} }
// Fixed, non-rebindable shortcuts surfaced read-only in the panel so the map is // Fixed, non-rebindable shortcuts surfaced read-only in the panel so the map is
@@ -117,7 +123,7 @@ export function defaultBindings(): KeybindBindings {
export interface KeybindReadonly { export interface KeybindReadonly {
id: string id: string
category: KeybindCategory category: KeybindCategory
keys: readonly string[] keys: readonly FakeCombo[]
} }
export const KEYBIND_READONLY: readonly KeybindReadonly[] = [ export const KEYBIND_READONLY: readonly KeybindReadonly[] = [

View File

@@ -10,11 +10,13 @@
// Control+Tab. Off macOS, Control already *is* `mod`, so `canonicalizeCombo` // Control+Tab. Off macOS, Control already *is* `mod`, so `canonicalizeCombo`
// folds `ctrl` → `mod`. // folds `ctrl` → `mod`.
export const IS_MAC = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '') const IS_MAC = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '')
export const modKey = IS_MAC ? 'metaKey' as const : 'ctrlKey' as const
// event.code → canonical base token. Letters/digits map to their lowercase // event.code → canonical base token. Letters/digits map to their lowercase
// character; everything else uses an explicit name so combos read cleanly. // character; everything else uses an explicit name so combos read cleanly.
const CODE_TO_KEY: Record<string, string> = { const CODE_TO_KEY = {
Backquote: '`', Backquote: '`',
Backslash: '\\', Backslash: '\\',
BracketLeft: '[', BracketLeft: '[',
@@ -35,8 +37,50 @@ const CODE_TO_KEY: Record<string, string> = {
ArrowDown: 'down', ArrowDown: 'down',
ArrowLeft: 'left', ArrowLeft: 'left',
ArrowRight: 'right' ArrowRight: 'right'
} as const satisfies Record<Capitalize<string>, Lowercase<string>>
type SpecialKey = typeof CODE_TO_KEY[keyof typeof CODE_TO_KEY]
type Alpha = 'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'|'i'|'j'|'k'|'l'|'m'
| 'n'|'o'|'p'|'q'|'r'|'s'|'t'|'u'|'v'|'w'|'x'|'y'|'z'
export type Digit = '0'|'1'|'2'|'3'|'4'|'5'|'6'|'7'|'8'|'9'
type FKey =
| 'f1' | 'f2' | 'f3' | 'f4' | 'f5' | 'f6'
| 'f7' | 'f8' | 'f9' | 'f10' | 'f11' | 'f12'
| 'f13' | 'f14' | 'f15' | 'f16' | 'f17' | 'f18'
| 'f19' | 'f20' | 'f21' | 'f22' | 'f23' | 'f24'
type BaseKey = Alpha | Digit | FKey | SpecialKey
// subset of https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values
type KeyCode = Uppercase<FKey> | `Digit${Digit}` | `Key${Uppercase<Alpha>}` | keyof typeof CODE_TO_KEY
function baseKeyFromCode(code: KeyCode): BaseKey | null {
if (code.startsWith('Key')) {
return code.slice(3).toLowerCase() as Alpha
}
if (code.startsWith('Digit')) {
return code.slice(5) as Digit
}
if (code.startsWith('Numpad')) {
const rest = code.slice(6)
return /^[0-9]$/.test(rest) ? rest as Digit : null
}
if (code.startsWith('F') && /^F\d{1,2}$/.test(code)) {
return code.toLowerCase() as FKey
}
return CODE_TO_KEY[code as keyof typeof CODE_TO_KEY] ?? null
} }
const MODIFIER_CODES = new Set([ const MODIFIER_CODES = new Set([
'AltLeft', 'AltLeft',
'AltRight', 'AltRight',
@@ -48,42 +92,20 @@ const MODIFIER_CODES = new Set([
'ShiftRight' '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 // Returns the canonical combo for a keydown, or null while only modifiers are
// held (so capture mode keeps waiting for a real key). // held (so capture mode keeps waiting for a real key).
export function comboFromEvent(event: KeyboardEvent): string | null { export function comboFromEvent(event: KeyboardEvent): Combo | null {
if (MODIFIER_CODES.has(event.code)) { if (MODIFIER_CODES.has(event.code)) {
return null return null
} }
const base = baseKeyFromCode(event.code) const base = baseKeyFromCode(event.code as KeyCode)
if (!base) { if (!base) {
return null return null
} }
const parts: string[] = [] const parts: Combo[] = []
// macOS reports Cmd (`mod`) and Control (`ctrl`) separately; elsewhere // macOS reports Cmd (`mod`) and Control (`ctrl`) separately; elsewhere
// Control IS the accelerator, so it folds into `mod`. // Control IS the accelerator, so it folds into `mod`.
@@ -105,7 +127,7 @@ export function comboFromEvent(event: KeyboardEvent): string | null {
parts.push(base) parts.push(base)
return parts.join('+') return parts.join('+') as Combo
} }
// Rewrites a binding to the form `comboFromEvent` emits, so it indexes under // Rewrites a binding to the form `comboFromEvent` emits, so it indexes under
@@ -115,7 +137,14 @@ export function canonicalizeCombo(combo: string): string {
return IS_MAC ? combo : combo.replace(/\bctrl\b/g, 'mod') return IS_MAC ? combo : combo.replace(/\bctrl\b/g, 'mod')
} }
const TOKEN_LABELS: Record<string, string> = { const MOD_LABELS = {
mod: IS_MAC ? '⌘' : 'Ctrl',
ctrl: IS_MAC ? '⌃' : 'Ctrl',
alt: IS_MAC ? '⌥' : 'Alt',
shift: IS_MAC ? '⇧' : 'Shift'
} as const
const FANCY_KEY_LABELS = {
enter: '↵', enter: '↵',
escape: 'Esc', escape: 'Esc',
backspace: '⌫', backspace: '⌫',
@@ -124,39 +153,47 @@ const TOKEN_LABELS: Record<string, string> = {
up: '↑', up: '↑',
down: '↓', down: '↓',
left: '←', left: '←',
right: '→' right: '→',
} as const
const TOKEN_LABELS: Record<string, string> = {
...MOD_LABELS,
...FANCY_KEY_LABELS
} }
function labelForBase(base: string): string { function labelForToken(token: string): string {
if (TOKEN_LABELS[base]) { if (TOKEN_LABELS[token]) {
return TOKEN_LABELS[base] return TOKEN_LABELS[token]
} }
if (/^f\d{1,2}$/.test(base)) { if (/^f\d{1,2}$/.test(token)) {
return base.toUpperCase() return token.toUpperCase()
} }
return base.length === 1 ? base.toUpperCase() : base return token.length === 1 ? token.toUpperCase() : token
} }
function labelForMod(mod: string): string { //
if (mod === 'mod') {
return IS_MAC ? '⌘' : 'Ctrl'
}
if (mod === 'ctrl') { type ModKey = keyof typeof MOD_LABELS
return IS_MAC ? '⌃' : 'Ctrl'
}
if (mod === 'alt') { type ModPrefix = `${'mod+'|''}${'alt+'|''}${'shift+'|''}`
return IS_MAC ? '⌥' : 'Alt'
}
if (mod === 'shift') { type ModPrefixedCombo<Suffix extends string> =
return IS_MAC ? '⇧' : 'Shift' | `${ModPrefix}${Suffix}`
} | ModKey
| 'mod+alt' | 'mod+shift' | 'alt+shift' | 'mod+alt+shift'
| 'ctrl+tab' | 'ctrl+shift+tab'
| `ctrl+${Digit}`
return mod export type Combo = ModPrefixedCombo<BaseKey>
export type FakeCombo = ModPrefixedCombo<BaseKey | '@' | '?'>
// Human-readable keys, e.g. "mod+shift+k" returns ["⌘","⇧","K"] on macos, ["Ctrl","Shift","K"] elsewhere.
export function normalizeCombo(combo: Combo): string[] {
const parts = combo.split('+')
return parts.map(p => labelForToken(p.trim()))
} }
// Per-key display tokens, e.g. ["⌘", "K"] on macOS, ["Ctrl", "K"] elsewhere — // Per-key display tokens, e.g. ["⌘", "K"] on macOS, ["Ctrl", "K"] elsewhere —
@@ -165,14 +202,18 @@ export function comboTokens(combo: string): string[] {
const parts = combo.split('+') const parts = combo.split('+')
const base = parts.pop() ?? '' const base = parts.pop() ?? ''
return [...parts.map(labelForMod), labelForBase(base)] return [...parts.map(labelForToken), labelForToken(base)]
} }
// Human-readable label, e.g. "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere. // Human-readable label, e.g. "mod+shift+k" returns "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere.
export function formatCombo(combo: string): string { export function formatCombo(combo: Combo): string {
const tokens = comboTokens(combo) return normalizeCombo(combo).join(IS_MAC ? '' : '+')
}
return IS_MAC ? tokens.join('') : tokens.join('+')
// like `formatCombo` but allows any input like `@`
export function formatFakeCombo(combo: FakeCombo): string {
return normalizeCombo(combo as Combo).join(IS_MAC ? '' : '+')
} }
// True when focus is in a text-entry surface, so bare-key shortcuts don't fire // True when focus is in a text-entry surface, so bare-key shortcuts don't fire
@@ -190,6 +231,6 @@ export function isEditableTarget(target: EventTarget | null): boolean {
// A primary modifier (Cmd/Ctrl/Control) fires even while typing (e.g. ⌘K or // A primary modifier (Cmd/Ctrl/Control) fires even while typing (e.g. ⌘K or
// ⌃Tab from the composer); bare/Shift-only combos are suppressed in inputs. // ⌃Tab from the composer); bare/Shift-only combos are suppressed in inputs.
export function comboAllowedInInput(combo: string): boolean { export function comboAllowedInInput(combo: Combo): boolean {
return /^(?:mod|ctrl)(?:\+|$)/.test(combo) return /^(?:mod|ctrl)(?:\+|$)/.test(combo)
} }

View File

@@ -7,6 +7,7 @@ import {
type KeybindBindings type KeybindBindings
} from '@/lib/keybinds/actions' } from '@/lib/keybinds/actions'
import { canonicalizeCombo } from '@/lib/keybinds/combo' import { canonicalizeCombo } from '@/lib/keybinds/combo'
import type { Combo } from '@/lib/keybinds/combo'
import { arraysEqual, persistString, storedString } from '@/lib/storage' import { arraysEqual, persistString, storedString } from '@/lib/storage'
const STORAGE_KEY = 'hermes.desktop.keybinds' const STORAGE_KEY = 'hermes.desktop.keybinds'
@@ -28,7 +29,7 @@ function loadBindings(): KeybindBindings {
const value = parsed[id] const value = parsed[id]
if (Array.isArray(value)) { if (Array.isArray(value)) {
base[id] = value.filter((combo): combo is string => typeof combo === 'string') base[id] = value.filter((combo): combo is string => typeof combo === 'string') as Combo[]
} }
} }
} catch { } catch {
@@ -78,7 +79,7 @@ export const $comboIndex = computed($bindings, bindings => {
return index return index
}) })
export function setBinding(actionId: string, combos: string[]): void { export function setBinding(actionId: string, combos: Combo[]): void {
if (!keybindAction(actionId)) { if (!keybindAction(actionId)) {
return return
} }
@@ -101,7 +102,7 @@ export function resetAllBindings(): void {
} }
// Other actions that already use `combo` (excluding `actionId` itself). // Other actions that already use `combo` (excluding `actionId` itself).
export function conflictsFor(actionId: string, combo: string): string[] { export function conflictsFor(actionId: string, combo: Combo): string[] {
const bindings = $bindings.get() const bindings = $bindings.get()
return KEYBIND_ACTION_IDS.filter(id => id !== actionId && (bindings[id] ?? []).includes(combo)) return KEYBIND_ACTION_IDS.filter(id => id !== actionId && (bindings[id] ?? []).includes(combo))