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 { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
import { useI18n } from '@/i18n'
import { normalizeCombo } from '@/lib/keybinds/combo'
import { profileColor } from '@/lib/profile-color'
import { sessionMatchesSearch } from '@/lib/session-search'
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
// 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.
const NEW_SESSION_KBD: readonly string[] =
typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac') ? ['⌘', 'N'] : ['Ctrl', 'N']
const NEW_SESSION_KBD: readonly string[] =normalizeCombo('mod+n')
const SIDEBAR_NAV: SidebarNavItem[] = [
{

View File

@@ -10,6 +10,7 @@ import type { SessionInfo } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { modKey } from '@/lib/keybinds/combo'
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { cn } from '@/lib/utils'
import { $attentionSessionIds } from '@/store/session'
@@ -133,11 +134,11 @@ export function SidebarSessionRow({
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
// lives in the row's ⋯ and right-click menus. Falls through to a
// normal resume when standalone windows aren't available (web embed).
if ((event.metaKey || event.ctrlKey) && canOpenSessionWindow()) {
if (event[modKey] && canOpenSessionWindow()) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')

View File

@@ -91,6 +91,7 @@ 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 { modKey } from '@/lib/keybinds/combo'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants'
import { ModelPickerOverlay } from './model-picker-overlay'
import { ModelVisibilityOverlay } from './model-visibility-overlay'
@@ -271,7 +272,7 @@ export function DesktopController() {
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.stopPropagation()
closeActiveRightRailTab()

View File

@@ -69,7 +69,7 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
variant="secondary"
>
{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>
</div>
)}

View File

@@ -1,6 +1,7 @@
import type { ITheme, Terminal } from '@xterm/xterm'
import type { CSSProperties } from 'react'
import { formatCombo, modKey } from '@/lib/keybinds/combo'
import type { DesktopTerminalPalette } from '@/themes/types'
// 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
}
export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac')
export const addSelectionShortcutLabel = () => (isMacPlatform() ? '⌘L' : 'Ctrl+L')
export const addSelectionShortcutLabel = formatCombo('mod+l')
export function isAddSelectionShortcut(event: KeyboardEvent) {
const mod = isMacPlatform() ? event.metaKey : event.ctrlKey
const mod = event[modKey]
return mod && !event.shiftKey && event.key.toLowerCase() === 'l'
}

View File

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

View File

@@ -16,6 +16,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { ChevronDown, Loader2 } from '@/lib/icons'
import { formatCombo } from '@/lib/keybinds/combo'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts'
@@ -50,8 +51,6 @@ export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => {
return <ApprovalBar request={request} />
}
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform)
const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
const { t } = useI18n()
const copy = t.assistant.approval
@@ -127,7 +126,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
variant="ghost"
>
{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>
<span aria-hidden className="w-px self-stretch bg-primary/20" />
<DropdownMenu>

View File

@@ -1,4 +1,5 @@
import { FIELD_DESCRIPTIONS, FIELD_LABELS } from '@/app/settings/constants'
import { formatCombo } from '@/lib/keybinds/combo'
import type { Translations } from './types'
@@ -518,7 +519,7 @@ export const en: Translations = {
loading: 'Loading archived sessions…',
archivedTitle: 'Archived sessions',
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',
emptyArchivedDesc: 'Archive a chat to hide it here.',
unarchive: 'Unarchive',
@@ -529,7 +530,7 @@ export const en: Translations = {
defaultDirTitle: 'Default project directory',
defaultDirDesc:
'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}.`,
change: 'Change',
choose: 'Choose',
@@ -1677,7 +1678,7 @@ export const en: Translations = {
loadingQuestion: 'Loading question…',
other: 'Other (type your answer)',
placeholder: 'Type your answer…',
shortcut: '⌘/Ctrl + Enter to send',
shortcut: `${formatCombo('mod+enter')} to send`,
back: 'Back',
skip: 'Skip',
send: 'Send'

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,9 @@
// 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.
import type { Combo, FakeCombo } from "./combo";
export type KeybindCategory = 'composer' | 'profiles' | 'session' | 'navigation' | 'view'
// 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.
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) => {
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) => ({
id: `profile.switch.${i + 1}`,
category: 'profiles' as const,
defaults: [comboForSlot(i + 1)]
}))
return ({
id: `profile.switch.${i + 1}`,
category: 'profiles' as const,
defaults: [combo]
})
})
// ⌘` on macOS / Ctrl+` elsewhere (the `~` key), plus the Shift/tilde variant.
// `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)
}
export type KeybindBindings = Record<string, string[]>
export type KeybindBindings = Record<string, Combo[]>
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
@@ -117,7 +123,7 @@ export function defaultBindings(): KeybindBindings {
export interface KeybindReadonly {
id: string
category: KeybindCategory
keys: readonly string[]
keys: readonly FakeCombo[]
}
export const KEYBIND_READONLY: readonly KeybindReadonly[] = [

View File

@@ -10,11 +10,13 @@
// Control+Tab. Off macOS, Control already *is* `mod`, so `canonicalizeCombo`
// 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
// character; everything else uses an explicit name so combos read cleanly.
const CODE_TO_KEY: Record<string, string> = {
const CODE_TO_KEY = {
Backquote: '`',
Backslash: '\\',
BracketLeft: '[',
@@ -35,8 +37,50 @@ const CODE_TO_KEY: Record<string, string> = {
ArrowDown: 'down',
ArrowLeft: 'left',
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([
'AltLeft',
'AltRight',
@@ -48,42 +92,20 @@ const MODIFIER_CODES = new Set([
'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 {
export function comboFromEvent(event: KeyboardEvent): Combo | null {
if (MODIFIER_CODES.has(event.code)) {
return null
}
const base = baseKeyFromCode(event.code)
const base = baseKeyFromCode(event.code as KeyCode)
if (!base) {
return null
}
const parts: string[] = []
const parts: Combo[] = []
// macOS reports Cmd (`mod`) and Control (`ctrl`) separately; elsewhere
// Control IS the accelerator, so it folds into `mod`.
@@ -105,7 +127,7 @@ export function comboFromEvent(event: KeyboardEvent): string | null {
parts.push(base)
return parts.join('+')
return parts.join('+') as Combo
}
// 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')
}
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: '↵',
escape: 'Esc',
backspace: '⌫',
@@ -124,39 +153,47 @@ const TOKEN_LABELS: Record<string, string> = {
up: '↑',
down: '↓',
left: '←',
right: '→'
right: '→',
} as const
const TOKEN_LABELS: Record<string, string> = {
...MOD_LABELS,
...FANCY_KEY_LABELS
}
function labelForBase(base: string): string {
if (TOKEN_LABELS[base]) {
return TOKEN_LABELS[base]
function labelForToken(token: string): string {
if (TOKEN_LABELS[token]) {
return TOKEN_LABELS[token]
}
if (/^f\d{1,2}$/.test(base)) {
return base.toUpperCase()
if (/^f\d{1,2}$/.test(token)) {
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') {
return IS_MAC ? '⌃' : 'Ctrl'
}
type ModKey = keyof typeof MOD_LABELS
if (mod === 'alt') {
return IS_MAC ? '⌥' : 'Alt'
}
type ModPrefix = `${'mod+'|''}${'alt+'|''}${'shift+'|''}`
if (mod === 'shift') {
return IS_MAC ? '⇧' : 'Shift'
}
type ModPrefixedCombo<Suffix extends string> =
| `${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 —
@@ -165,14 +202,18 @@ export function comboTokens(combo: string): string[] {
const parts = combo.split('+')
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.
export function formatCombo(combo: string): string {
const tokens = comboTokens(combo)
// Human-readable label, e.g. "mod+shift+k" returns "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere.
export function formatCombo(combo: Combo): string {
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
@@ -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
// ⌃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)
}

View File

@@ -7,6 +7,7 @@ import {
type KeybindBindings
} from '@/lib/keybinds/actions'
import { canonicalizeCombo } from '@/lib/keybinds/combo'
import type { Combo } from '@/lib/keybinds/combo'
import { arraysEqual, persistString, storedString } from '@/lib/storage'
const STORAGE_KEY = 'hermes.desktop.keybinds'
@@ -28,7 +29,7 @@ function loadBindings(): KeybindBindings {
const value = parsed[id]
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 {
@@ -78,7 +79,7 @@ export const $comboIndex = computed($bindings, bindings => {
return index
})
export function setBinding(actionId: string, combos: string[]): void {
export function setBinding(actionId: string, combos: Combo[]): void {
if (!keybindAction(actionId)) {
return
}
@@ -101,7 +102,7 @@ export function resetAllBindings(): void {
}
// 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()
return KEYBIND_ACTION_IDS.filter(id => id !== actionId && (bindings[id] ?? []).includes(combo))