From 3f306f40fd32ad6964f443e268eacb5c247f9979 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 10 Apr 2026 21:46:58 -0500 Subject: [PATCH] Add hotkey settings editor and keybinding reset support - Centralize keybinding parsing and encoding in shared utilities - Add settings UI to edit, add, and restore built-in hotkeys - Expose server/API support to replace all rules for a command --- apps/server/src/keybindings.test.ts | 46 ++ apps/server/src/keybindings.ts | 186 ++---- apps/server/src/wsServer.ts | 9 + apps/web/src/components/ChatView.tsx | 8 +- .../components/KeybindingRecorderField.tsx | 98 +++ .../src/components/ProjectScriptsControl.tsx | 76 +-- .../settings/HotkeysSettingsSection.tsx | 563 ++++++++++++++++++ apps/web/src/lib/projectScriptKeybindings.ts | 22 +- apps/web/src/routes/_chat.settings.tsx | 68 ++- apps/web/src/wsNativeApi.ts | 2 + packages/contracts/src/ipc.ts | 5 + packages/contracts/src/server.ts | 16 +- packages/contracts/src/ws.ts | 3 + packages/shared/package.json | 4 + packages/shared/src/keybindings.test.ts | 155 +++++ packages/shared/src/keybindings.ts | 279 +++++++++ 16 files changed, 1290 insertions(+), 250 deletions(-) create mode 100644 apps/web/src/components/KeybindingRecorderField.tsx create mode 100644 apps/web/src/components/settings/HotkeysSettingsSection.tsx create mode 100644 packages/shared/src/keybindings.test.ts create mode 100644 packages/shared/src/keybindings.ts diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 07407b4df..2df8bd305 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -348,6 +348,52 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(makeKeybindingsLayer())), ); + it.effect("replaces every rule for a command and restores defaults when cleared", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "mod+j", command: "terminal.toggle" }, + { key: "ctrl+`", command: "terminal.toggle" }, + { key: "mod+r", command: "script.run-tests.run" }, + ]); + + const keybindings = yield* Keybindings; + const replaced = yield* keybindings.replaceKeybindingRules("terminal.toggle", [ + { key: "mod+shift+t", command: "terminal.toggle" }, + { key: "ctrl+shift+`", command: "terminal.toggle" }, + ]); + + const persistedAfterReplace = yield* readKeybindingsConfig(keybindingsConfigPath); + assert.deepEqual( + persistedAfterReplace.map(({ key, command }) => ({ key, command })), + [ + { key: "mod+r", command: "script.run-tests.run" }, + { key: "mod+shift+t", command: "terminal.toggle" }, + { key: "ctrl+shift+`", command: "terminal.toggle" }, + ], + ); + assert.deepEqual( + replaced + .filter((entry) => entry.command === "terminal.toggle") + .map((entry) => Schema.encodeSync(ResolvedKeybindingFromConfig)(entry).key), + ["mod+shift+t", "ctrl+shift+`"], + ); + + const restored = yield* keybindings.replaceKeybindingRules("terminal.toggle", []); + const persistedAfterRestore = yield* readKeybindingsConfig(keybindingsConfigPath); + assert.deepEqual( + persistedAfterRestore.map(({ key, command }) => ({ key, command })), + [{ key: "mod+r", command: "script.run-tests.run" }], + ); + assert.deepEqual( + restored + .filter((entry) => entry.command === "terminal.toggle") + .map((entry) => Schema.encodeSync(ResolvedKeybindingFromConfig)(entry).key), + ["mod+j", "ctrl+`"], + ); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + it.effect("refuses to overwrite malformed keybindings config", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 2f5ca071a..dd399731f 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -17,6 +17,11 @@ import { ResolvedKeybindingsConfig, type ServerConfigIssue, } from "@okcode/contracts"; +import { + DEFAULT_KEYBINDINGS as SHARED_DEFAULT_KEYBINDINGS, + encodeKeybindingShortcut, + parseKeybindingShortcut as parseSharedKeybindingShortcut, +} from "@okcode/shared/keybindings"; import { Mutable } from "effect/Types"; import { Array, @@ -64,90 +69,10 @@ type WhenToken = | { type: "lparen" } | { type: "rparen" }; -export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ - { key: "mod+j", command: "terminal.toggle" }, - { key: "ctrl+`", command: "terminal.toggle" }, - { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, - { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, - { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, - { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, - { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, - { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, - { key: "mod+down", command: "git.pullRequest", when: "!terminalFocus" }, - { key: "mod+shift+p", command: "git.pullRequest", when: "!terminalFocus" }, - { key: "mod+o", command: "editor.openFavorite" }, -]; - -function normalizeKeyToken(token: string): string { - if (token === "space") return " "; - if (token === "esc") return "escape"; - return token; -} +export const DEFAULT_KEYBINDINGS = SHARED_DEFAULT_KEYBINDINGS; /** @internal - Exported for testing */ -export function parseKeybindingShortcut(value: string): KeybindingShortcut | null { - const rawTokens = value - .toLowerCase() - .split("+") - .map((token) => token.trim()); - const tokens = [...rawTokens]; - let trailingEmptyCount = 0; - while (tokens[tokens.length - 1] === "") { - trailingEmptyCount += 1; - tokens.pop(); - } - if (trailingEmptyCount > 0) { - tokens.push("+"); - } - if (tokens.some((token) => token.length === 0)) { - return null; - } - if (tokens.length === 0) return null; - - let key: string | null = null; - let metaKey = false; - let ctrlKey = false; - let shiftKey = false; - let altKey = false; - let modKey = false; - - for (const token of tokens) { - switch (token) { - case "cmd": - case "meta": - metaKey = true; - break; - case "ctrl": - case "control": - ctrlKey = true; - break; - case "shift": - shiftKey = true; - break; - case "alt": - case "option": - altKey = true; - break; - case "mod": - modKey = true; - break; - default: { - if (key !== null) return null; - key = normalizeKeyToken(token); - } - } - } - - if (key === null) return null; - return { - key, - metaKey, - ctrlKey, - shiftKey, - altKey, - modKey, - }; -} +export const parseKeybindingShortcut = parseSharedKeybindingShortcut; function tokenizeWhenExpression(expression: string): WhenToken[] | null { const tokens: WhenToken[] = []; @@ -383,16 +308,7 @@ function hasSameShortcutContext(left: KeybindingRule, right: KeybindingRule): bo } function encodeShortcut(shortcut: KeybindingShortcut): string | null { - const modifiers: string[] = []; - if (shortcut.modKey) modifiers.push("mod"); - if (shortcut.metaKey) modifiers.push("meta"); - if (shortcut.ctrlKey) modifiers.push("ctrl"); - if (shortcut.altKey) modifiers.push("alt"); - if (shortcut.shiftKey) modifiers.push("shift"); - if (!shortcut.key) return null; - if (shortcut.key !== "+" && shortcut.key.includes("+")) return null; - const key = shortcut.key === " " ? "space" : shortcut.key; - return [...modifiers, key].join("+"); + return encodeKeybindingShortcut(shortcut); } function encodeWhenAst(node: KeybindingWhenNode): string { @@ -521,6 +437,17 @@ export interface KeybindingsShape { readonly upsertKeybindingRule: ( rule: KeybindingRule, ) => Effect.Effect; + + /** + * Replace every persisted rule for a command with the provided rules. + * + * Passing an empty array removes custom rules for the command so defaults + * can flow through again for built-in commands. + */ + readonly replaceKeybindingRules: ( + command: KeybindingRule["command"], + rules: readonly KeybindingRule[], + ) => Effect.Effect; } /** @@ -854,6 +781,46 @@ const makeKeybindings = Effect.gen(function* () { yield* Deferred.succeed(startedDeferred, undefined).pipe(Effect.orDie); }); + const replaceKeybindingRules = ( + command: KeybindingRule["command"], + rules: readonly KeybindingRule[], + ) => + upsertSemaphore.withPermits(1)( + Effect.gen(function* () { + if (rules.some((rule) => rule.command !== command)) { + return yield* new KeybindingsConfigError({ + configPath: keybindingsConfigPath, + detail: `received mismatched command rules for ${command}`, + }); + } + const customConfig = yield* loadWritableCustomKeybindingsConfig(); + const nextConfig = [...customConfig.filter((entry) => entry.command !== command), ...rules]; + const cappedConfig = + nextConfig.length > MAX_KEYBINDINGS_COUNT + ? nextConfig.slice(-MAX_KEYBINDINGS_COUNT) + : nextConfig; + if (nextConfig.length > MAX_KEYBINDINGS_COUNT) { + yield* Effect.logWarning("truncating keybindings config to max entries", { + path: keybindingsConfigPath, + maxEntries: MAX_KEYBINDINGS_COUNT, + }); + } + yield* writeConfigAtomically(cappedConfig); + const nextResolved = mergeWithDefaultKeybindings( + compileResolvedKeybindingsConfig(cappedConfig), + ); + yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, { + keybindings: nextResolved, + issues: [], + }); + yield* emitChange({ + keybindings: nextResolved, + issues: [], + }); + return nextResolved; + }), + ); + return { start, ready: Deferred.await(startedDeferred), @@ -863,39 +830,8 @@ const makeKeybindings = Effect.gen(function* () { get streamChanges() { return Stream.fromPubSub(changesPubSub); }, - upsertKeybindingRule: (rule) => - upsertSemaphore.withPermits(1)( - Effect.gen(function* () { - const customConfig = yield* loadWritableCustomKeybindingsConfig(); - const nextConfig = [ - ...customConfig.filter((entry) => entry.command !== rule.command), - rule, - ]; - const cappedConfig = - nextConfig.length > MAX_KEYBINDINGS_COUNT - ? nextConfig.slice(-MAX_KEYBINDINGS_COUNT) - : nextConfig; - if (nextConfig.length > MAX_KEYBINDINGS_COUNT) { - yield* Effect.logWarning("truncating keybindings config to max entries", { - path: keybindingsConfigPath, - maxEntries: MAX_KEYBINDINGS_COUNT, - }); - } - yield* writeConfigAtomically(cappedConfig); - const nextResolved = mergeWithDefaultKeybindings( - compileResolvedKeybindingsConfig(cappedConfig), - ); - yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, { - keybindings: nextResolved, - issues: [], - }); - yield* emitChange({ - keybindings: nextResolved, - issues: [], - }); - return nextResolved; - }), - ), + replaceKeybindingRules, + upsertKeybindingRule: (rule) => replaceKeybindingRules(rule.command, [rule]), } satisfies KeybindingsShape; }); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 8c34aef95..b44b3ae57 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -1620,6 +1620,15 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return { keybindings: keybindingsConfig, issues: [] }; } + case WS_METHODS.serverReplaceKeybindingRules: { + const body = stripRequestTag(request.body); + const keybindingsConfig = yield* keybindingsManager.replaceKeybindingRules( + body.command, + body.rules, + ); + return { keybindings: keybindingsConfig, issues: [] }; + } + case WS_METHODS.serverGetGlobalEnvironmentVariables: return yield* environmentVariables.getGlobal(); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6710c5f2b..aa833d054 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1979,8 +1979,12 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) { }) : null; - if (isElectron && keybindingRule) { - await api.server.upsertKeybinding(keybindingRule); + if (isElectron && input.keybindingCommand) { + await api.server.replaceKeybindingRules( + keybindingRule + ? { command: input.keybindingCommand, rules: [keybindingRule] } + : { command: input.keybindingCommand, rules: [] }, + ); await queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); } }, diff --git a/apps/web/src/components/KeybindingRecorderField.tsx b/apps/web/src/components/KeybindingRecorderField.tsx new file mode 100644 index 000000000..97ce8837b --- /dev/null +++ b/apps/web/src/components/KeybindingRecorderField.tsx @@ -0,0 +1,98 @@ +import { + keybindingValueFromShortcutEvent, + parseKeybindingShortcut, +} from "@okcode/shared/keybindings"; +import type * as React from "react"; +import { useMemo, useState } from "react"; + +import { formatShortcutLabel } from "~/keybindings"; +import { cn } from "~/lib/utils"; + +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; + +function resolvePlatform(): string { + return typeof navigator === "undefined" ? "unknown" : navigator.platform; +} + +export function formatRecordedKeybindingValue(value: string, platform = resolvePlatform()): string { + const shortcut = parseKeybindingShortcut(value); + return shortcut ? formatShortcutLabel(shortcut, platform) : value; +} + +export interface KeybindingRecorderFieldProps extends Omit< + React.ComponentProps, + "onChange" | "readOnly" | "value" +> { + readonly value: string; + readonly onChange: (nextValue: string) => void; + readonly clearLabel?: string; +} + +export function KeybindingRecorderField({ + value, + onChange, + placeholder = "Press shortcut", + className, + disabled, + clearLabel = "Clear", + onFocus, + onBlur, + onKeyDown, + ...props +}: KeybindingRecorderFieldProps) { + const [isFocused, setIsFocused] = useState(false); + const platform = resolvePlatform(); + const displayValue = useMemo( + () => (value ? formatRecordedKeybindingValue(value, platform) : ""), + [platform, value], + ); + + const handleKeyDown = (event: React.KeyboardEvent) => { + onKeyDown?.(event); + if (event.defaultPrevented) return; + if (event.key === "Tab") return; + + event.preventDefault(); + const hasModifier = event.metaKey || event.ctrlKey || event.altKey || event.shiftKey; + if ((event.key === "Backspace" || event.key === "Delete") && !hasModifier) { + onChange(""); + return; + } + + const nextValue = keybindingValueFromShortcutEvent(event, platform); + if (!nextValue) return; + onChange(nextValue); + }; + + return ( +
+ { + setIsFocused(false); + onBlur?.(event); + }} + onFocus={(event) => { + setIsFocused(true); + onFocus?.(event); + }} + onKeyDown={handleKeyDown} + /> + +
+ ); +} diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 10be45115..5f192fa7d 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -14,7 +14,7 @@ import { SettingsIcon, WrenchIcon, } from "lucide-react"; -import React, { type FormEvent, type KeyboardEvent, useCallback, useMemo, useState } from "react"; +import React, { type FormEvent, useCallback, useMemo, useState } from "react"; import { ensureNativeApi } from "~/nativeApi"; import { @@ -35,7 +35,6 @@ import { resolvePackageManagerResolution, } from "~/projectScriptDefaults"; import { shortcutLabelForCommand } from "~/keybindings"; -import { isMacPlatform } from "~/lib/utils"; import { AlertDialog, AlertDialogClose, @@ -46,6 +45,7 @@ import { AlertDialogTitle, } from "./ui/alert-dialog"; import { Button } from "./ui/button"; +import { KeybindingRecorderField } from "./KeybindingRecorderField"; import { Dialog, DialogDescription, @@ -108,57 +108,6 @@ interface ProjectScriptsControlProps { onImportScripts: (scripts: ProjectScriptDraft[]) => Promise | void; } -function normalizeShortcutKeyToken(key: string): string | null { - const normalized = key.toLowerCase(); - if ( - normalized === "meta" || - normalized === "control" || - normalized === "ctrl" || - normalized === "shift" || - normalized === "alt" || - normalized === "option" - ) { - return null; - } - if (normalized === " ") return "space"; - if (normalized === "escape") return "esc"; - if (normalized === "arrowup") return "arrowup"; - if (normalized === "arrowdown") return "arrowdown"; - if (normalized === "arrowleft") return "arrowleft"; - if (normalized === "arrowright") return "arrowright"; - if (normalized.length === 1) return normalized; - if (normalized.startsWith("f") && normalized.length <= 3) return normalized; - if (normalized === "enter" || normalized === "tab" || normalized === "backspace") { - return normalized; - } - if (normalized === "delete" || normalized === "home" || normalized === "end") { - return normalized; - } - if (normalized === "pageup" || normalized === "pagedown") return normalized; - return null; -} - -function keybindingFromEvent(event: KeyboardEvent): string | null { - const keyToken = normalizeShortcutKeyToken(event.key); - if (!keyToken) return null; - - const parts: string[] = []; - if (isMacPlatform(navigator.platform)) { - if (event.metaKey) parts.push("mod"); - if (event.ctrlKey) parts.push("ctrl"); - } else { - if (event.ctrlKey) parts.push("mod"); - if (event.metaKey) parts.push("meta"); - } - if (event.altKey) parts.push("alt"); - if (event.shiftKey) parts.push("shift"); - if (parts.length === 0) { - return null; - } - parts.push(keyToken); - return parts.join("+"); -} - export default function ProjectScriptsControl({ projectCwd, scripts, @@ -210,18 +159,6 @@ export default function ProjectScriptsControl({ }); }, [importInventory, selectedPackageManager]); - const captureKeybinding = (event: KeyboardEvent) => { - if (event.key === "Tab") return; - event.preventDefault(); - if (event.key === "Backspace" || event.key === "Delete") { - setKeybinding(""); - return; - } - const next = keybindingFromEvent(event); - if (!next) return; - setKeybinding(next); - }; - const submitAddScript = async (event: FormEvent) => { event.preventDefault(); const trimmedName = name.trim(); @@ -523,15 +460,14 @@ export default function ProjectScriptsControl({
-

- Press a shortcut. Use Backspace to clear. + Focus the field and press a shortcut. Use at least one modifier. Plain{" "} + Backspace or Delete clears it.

diff --git a/apps/web/src/components/settings/HotkeysSettingsSection.tsx b/apps/web/src/components/settings/HotkeysSettingsSection.tsx new file mode 100644 index 000000000..d600c037d --- /dev/null +++ b/apps/web/src/components/settings/HotkeysSettingsSection.tsx @@ -0,0 +1,563 @@ +import type { + KeybindingCommand, + KeybindingRule, + ResolvedKeybindingsConfig, + ServerConfigIssue, +} from "@okcode/contracts"; +import { + defaultKeybindingRulesForCommand, + HOTKEY_COMMAND_DEFINITIONS, + type HotkeyCommandDefinition, + keybindingValuesForCommand, + parseKeybindingShortcut, +} from "@okcode/shared/keybindings"; +import { AlertTriangleIcon, KeyboardIcon, PlusIcon, RotateCcwIcon, XIcon } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { + KeybindingRecorderField, + formatRecordedKeybindingValue, +} from "~/components/KeybindingRecorderField"; +import { Alert, AlertAction, AlertDescription, AlertTitle } from "~/components/ui/alert"; +import { Badge } from "~/components/ui/badge"; +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "~/components/ui/dialog"; + +const MAX_EDITABLE_SHORTCUTS = 4; + +interface DraftShortcutSlot { + readonly id: string; + readonly value: string; +} + +function resolvePlatform(): string { + return typeof navigator === "undefined" ? "unknown" : navigator.platform; +} + +function normalizeShortcutValues(values: readonly string[]): string[] { + const seenValues = new Set(); + const normalizedValues: string[] = []; + + for (const value of values) { + const trimmedValue = value.trim(); + if (trimmedValue.length === 0) continue; + if (!parseKeybindingShortcut(trimmedValue)) { + throw new Error(`Invalid shortcut: ${trimmedValue}`); + } + if (seenValues.has(trimmedValue)) continue; + seenValues.add(trimmedValue); + normalizedValues.push(trimmedValue); + } + + return normalizedValues; +} + +function haveSameShortcutValues(left: readonly string[], right: readonly string[]): boolean { + const normalizeForCompare = (values: readonly string[]) => + [...normalizeShortcutValues(values)].toSorted((first, second) => first.localeCompare(second)); + + const normalizedLeft = normalizeForCompare(left); + const normalizedRight = normalizeForCompare(right); + return JSON.stringify(normalizedLeft) === JSON.stringify(normalizedRight); +} + +function buildRulesForCommand( + command: KeybindingCommand, + values: readonly string[], + when: string | undefined, +): KeybindingRule[] { + return normalizeShortcutValues(values).map( + (value) => + Object.assign({ key: value, command }, when ? { when } : {}) satisfies KeybindingRule, + ); +} + +function labelsForValues(values: readonly string[], platform: string): string[] { + return normalizeShortcutValues(values).map((value) => + formatRecordedKeybindingValue(value, platform), + ); +} + +function createDraftShortcutSlot(value = ""): DraftShortcutSlot { + const id = + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : Math.random().toString(36).slice(2); + return { id, value }; +} + +interface HotkeysSettingsSectionProps { + readonly keybindings: ResolvedKeybindingsConfig; + readonly issues: readonly ServerConfigIssue[]; + readonly keybindingsConfigPath: string | null; + readonly isOpeningKeybindings: boolean; + readonly openKeybindingsError: string | null; + readonly onOpenKeybindingsFile: () => void; + readonly onReplaceKeybindingRules: ( + command: KeybindingCommand, + rules: readonly KeybindingRule[], + ) => Promise; +} + +interface HotkeyCommandState { + readonly definition: HotkeyCommandDefinition; + readonly currentValues: string[]; + readonly defaultValues: string[]; +} + +export function HotkeysSettingsSection({ + keybindings, + issues, + keybindingsConfigPath, + isOpeningKeybindings, + openKeybindingsError, + onOpenKeybindingsFile, + onReplaceKeybindingRules, +}: HotkeysSettingsSectionProps) { + const platform = resolvePlatform(); + const [editingCommand, setEditingCommand] = useState(null); + const [draftShortcutSlots, setDraftShortcutSlots] = useState([]); + const [saveError, setSaveError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + const hasMalformedConfig = issues.some((issue) => issue.kind === "keybindings.malformed-config"); + const invalidEntryCount = issues.filter( + (issue) => issue.kind === "keybindings.invalid-entry", + ).length; + const isEditingDisabled = hasMalformedConfig || isSaving; + + const commandStates = useMemo( + () => + HOTKEY_COMMAND_DEFINITIONS.map((definition) => { + const currentValues = keybindingValuesForCommand(keybindings, definition.command); + const defaultValues = defaultKeybindingRulesForCommand(definition.command).map( + (rule) => rule.key, + ); + return { + definition, + currentValues, + defaultValues, + }; + }), + [keybindings], + ); + + const groupedCommandStates = useMemo( + () => ({ + Workspace: commandStates.filter((state) => state.definition.group === "Workspace"), + Terminal: commandStates.filter((state) => state.definition.group === "Terminal"), + }), + [commandStates], + ); + + const openEditor = (state: HotkeyCommandState) => { + setEditingCommand(state); + setDraftShortcutSlots( + (state.currentValues.length > 0 ? state.currentValues : [""]).map((value) => + createDraftShortcutSlot(value), + ), + ); + setSaveError(null); + }; + + const closeEditor = () => { + setEditingCommand(null); + setDraftShortcutSlots([]); + setSaveError(null); + }; + + const replaceShortcutValue = (slotId: string, nextValue: string) => { + setDraftShortcutSlots((currentSlots) => + currentSlots.map((slot) => (slot.id === slotId ? { id: slot.id, value: nextValue } : slot)), + ); + }; + + const removeShortcutSlot = (slotId: string) => { + setDraftShortcutSlots((currentSlots) => currentSlots.filter((slot) => slot.id !== slotId)); + }; + + const addShortcutSlot = () => { + setDraftShortcutSlots((currentSlots) => + currentSlots.length >= MAX_EDITABLE_SHORTCUTS + ? currentSlots + : [...currentSlots, createDraftShortcutSlot()], + ); + }; + + const saveShortcutRules = async () => { + if (!editingCommand) return; + setIsSaving(true); + setSaveError(null); + try { + const rules = buildRulesForCommand( + editingCommand.definition.command, + draftShortcutSlots.map((slot) => slot.value), + editingCommand.definition.when, + ); + await onReplaceKeybindingRules(editingCommand.definition.command, rules); + closeEditor(); + } catch (error) { + setSaveError(error instanceof Error ? error.message : "Unable to save hotkeys."); + } finally { + setIsSaving(false); + } + }; + + const restoreDefaults = async (definition: HotkeyCommandDefinition) => { + setIsSaving(true); + setSaveError(null); + try { + await onReplaceKeybindingRules(definition.command, []); + closeEditor(); + } catch (error) { + setSaveError(error instanceof Error ? error.message : "Unable to restore defaults."); + } finally { + setIsSaving(false); + } + }; + + const renderShortcutBadges = (values: readonly string[], emptyLabel: string) => { + const labels = labelsForValues(values, platform); + if (labels.length === 0) { + return ( + + {emptyLabel} + + ); + } + return ( +
+ {labels.map((label) => ( + + {label} + + ))} +
+ ); + }; + + return ( +
+
+
+

Hotkeys

+

+ Record, review, and restore the built-in keyboard shortcuts used across OK Code. +

+
+
+ +
+
+
+
+
+ + Recording +
+

+ Click Edit, focus a shortcut + field, and press the combination you want. Use at least one modifier. Plain{" "} + Backspace or Delete clears a slot. +

+
+
+
+ + Context rules +
+

+ Each command here keeps the same focus rules as the built-in defaults. Use{" "} + keybindings.json below if you need custom when expressions + or project-action bindings. +

+
+
+
+ + {hasMalformedConfig ? ( +
+ + + Hotkey editing is blocked + + + keybindings.json is malformed, so OK Code can only fall back to the + built-in shortcuts right now. + + Fix the file first, then return here to record shortcuts. + + + + + +
+ ) : null} + + {!hasMalformedConfig && invalidEntryCount > 0 ? ( +
+ + + Some custom keybindings are invalid + + + {invalidEntryCount} invalid entr{invalidEntryCount === 1 ? "y is" : "ies are"}{" "} + being ignored. + + + Saving from this screen keeps the valid rules and discards the broken ones. + + + +
+ ) : null} + + {(["Workspace", "Terminal"] as const).map((group) => ( +
+
+ + {group} + +
+ {groupedCommandStates[group].map((state) => { + const currentLabels = labelsForValues(state.currentValues, platform); + const defaultLabels = labelsForValues(state.defaultValues, platform); + const isCustomized = !haveSameShortcutValues( + state.currentValues, + state.defaultValues, + ); + return ( +
+
+
+
+

+ {state.definition.title} +

+ + {isCustomized ? "Custom" : "Default"} + + {state.definition.contextLabel} +
+

+ {state.definition.description} +

+
+ {currentLabels.length > 0 ? ( + currentLabels.map((label) => ( + + {label} + + )) + ) : ( + + No shortcut assigned. + + )} +
+

+ {isCustomized + ? `Defaults: ${defaultLabels.join(", ")}` + : "Using the built-in defaults."} +

+
+ +
+ {isCustomized ? ( + + ) : null} + +
+
+
+ ); + })} +
+ ))} + +
+
+
+
+

Advanced JSON

+ Manual editing +
+

+ Open the persisted keybindings.json file to manage project-action + bindings, custom when expressions, or anything more advanced than this + screen supports. +

+
+ + {keybindingsConfigPath ?? "Resolving keybindings path..."} + + {openKeybindingsError ? ( + {openKeybindingsError} + ) : ( + Opens in your preferred editor. + )} +
+
+ +
+ +
+
+
+
+ + (!open ? closeEditor() : null)} + > + + + {editingCommand?.definition.title ?? "Edit hotkey"} + + {editingCommand?.definition.description} + {editingCommand ? ( + + Context: {editingCommand.definition.contextLabel} + + ) : null} + + + +
+ {draftShortcutSlots.map((slot, index) => ( +
+
+
+ {index === 0 ? "Primary shortcut" : `Alternate ${index}`} +
+ {draftShortcutSlots.length > 1 ? ( + + ) : null} +
+ replaceShortcutValue(slot.id, nextValue)} + /> +
+ ))} +
+ +
+

+ Add up to {MAX_EDITABLE_SHORTCUTS} alternate shortcuts here. Use the JSON file for + anything more complex. +

+ +
+ + {editingCommand ? ( +
+
+ Defaults +
+
+ {renderShortcutBadges(editingCommand.defaultValues, "No default shortcut")} +
+
+ ) : null} + + {saveError ?

{saveError}

: null} +
+ + {editingCommand ? ( + + ) : null} + + + +
+
+
+ ); +} diff --git a/apps/web/src/lib/projectScriptKeybindings.ts b/apps/web/src/lib/projectScriptKeybindings.ts index ef3201071..3401485fa 100644 --- a/apps/web/src/lib/projectScriptKeybindings.ts +++ b/apps/web/src/lib/projectScriptKeybindings.ts @@ -4,6 +4,7 @@ import { type KeybindingRule, type ResolvedKeybindingsConfig, } from "@okcode/contracts"; +import { keybindingValuesForCommand } from "@okcode/shared/keybindings"; import { Schema } from "effect"; export const PROJECT_SCRIPT_KEYBINDING_INVALID_MESSAGE = "Invalid keybinding."; @@ -36,24 +37,5 @@ export function keybindingValueForCommand( keybindings: ResolvedKeybindingsConfig, command: KeybindingCommand, ): string | null { - for (let index = keybindings.length - 1; index >= 0; index -= 1) { - const binding = keybindings[index]; - if (!binding || binding.command !== command) continue; - - const parts: string[] = []; - if (binding.shortcut.modKey) parts.push("mod"); - if (binding.shortcut.ctrlKey) parts.push("ctrl"); - if (binding.shortcut.metaKey) parts.push("meta"); - if (binding.shortcut.altKey) parts.push("alt"); - if (binding.shortcut.shiftKey) parts.push("shift"); - const keyToken = - binding.shortcut.key === " " - ? "space" - : binding.shortcut.key === "escape" - ? "esc" - : binding.shortcut.key; - parts.push(keyToken); - return parts.join("+"); - } - return null; + return keybindingValuesForCommand(keybindings, command)[0] ?? null; } diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index d97acea04..e40214c97 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -6,6 +6,7 @@ import { CpuIcon, GitBranchIcon, ImportIcon, + KeyboardIcon, Loader2Icon, PaletteIcon, PlusIcon, @@ -22,6 +23,8 @@ import { type ReactNode, useCallback, useEffect, useState } from "react"; import type { TestOpenclawGatewayHostKind, TestOpenclawGatewayResult } from "@okcode/contracts"; import { type BuildMetadata, + type KeybindingCommand, + type KeybindingRule, type ProjectId, type ProviderKind, DEFAULT_GIT_TEXT_GENERATION_MODEL, @@ -53,6 +56,7 @@ import { APP_BUILD_INFO } from "../branding"; import { Button } from "../components/ui/button"; import { Collapsible, CollapsibleContent } from "../components/ui/collapsible"; import { EnvironmentVariablesEditor } from "../components/EnvironmentVariablesEditor"; +import { HotkeysSettingsSection } from "../components/settings/HotkeysSettingsSection"; import { Input } from "../components/ui/input"; import { Select, @@ -91,7 +95,7 @@ import { setStoredRadiusOverride, type CustomThemeData, } from "../lib/customTheme"; -import { serverConfigQueryOptions } from "../lib/serverReactQuery"; +import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; import { cn } from "../lib/utils"; import { ensureNativeApi, readNativeApi } from "../nativeApi"; import { useStore } from "../store"; @@ -100,7 +104,14 @@ import { PairingLink } from "../components/mobile/PairingLink"; // --------------------------------------------------------------------------- // Settings navigation sections // --------------------------------------------------------------------------- -type SettingsSectionId = "general" | "environment" | "git" | "models" | "mobile" | "advanced"; +type SettingsSectionId = + | "general" + | "hotkeys" + | "environment" + | "git" + | "models" + | "mobile" + | "advanced"; interface SettingsNavItem { id: SettingsSectionId; @@ -112,6 +123,7 @@ interface SettingsNavItem { function useSettingsNavItems(): SettingsNavItem[] { return [ { id: "general", label: "General", icon: }, + { id: "hotkeys", label: "Hotkeys", icon: }, { id: "environment", label: "Environment", icon: }, { id: "git", label: "Git", icon: }, { id: "models", label: "Models", icon: }, @@ -787,6 +799,15 @@ function SettingsRouteView() { }); }, [availableEditors, keybindingsConfigPath]); + const replaceKeybindingRules = useCallback( + async (command: KeybindingCommand, rules: readonly KeybindingRule[]) => { + const api = ensureNativeApi(); + await api.server.replaceKeybindingRules({ command, rules: [...rules] }); + await queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); + }, + [queryClient], + ); + const saveGlobalEnvironmentVariables = useCallback( async (entries: ReadonlyArray<{ key: string; value: string }>) => { const api = ensureNativeApi(); @@ -2122,6 +2143,18 @@ function SettingsRouteView() { )} + {activeSection === "hotkeys" && ( + + )} + {activeSection === "environment" && ( - - - {keybindingsConfigPath ?? "Resolving keybindings path..."} - - {openKeybindingsError ? ( - - {openKeybindingsError} - - ) : ( - Opens in your preferred editor. - )} - - } - control={ - - } - /> - transport.request(WS_METHODS.serverSaveProjectEnvironmentVariables, input), upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), + replaceKeybindingRules: (input) => + transport.request(WS_METHODS.serverReplaceKeybindingRules, input), testOpenclawGateway: (input) => transport.request(WS_METHODS.serverTestOpenclawGateway, input), }, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 049eedf87..4cebaec84 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -90,6 +90,8 @@ import type { TerminalWriteInput, } from "./terminal"; import type { + ServerReplaceKeybindingRulesInput, + ServerReplaceKeybindingRulesResult, ServerUpsertKeybindingInput, ServerUpsertKeybindingResult, ServerUpdateInfo, @@ -476,6 +478,9 @@ export interface NativeApi { input: SaveProjectEnvironmentVariablesInput, ) => Promise; upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; + replaceKeybindingRules: ( + input: ServerReplaceKeybindingRulesInput, + ) => Promise; testOpenclawGateway: (input: TestOpenclawGatewayInput) => Promise; }; orchestration: { diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index ed8ef3ee3..c433820a0 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,7 +1,12 @@ import { Schema } from "effect"; import { DeviceId, IsoDateTime, PairingId, TrimmedNonEmptyString } from "./baseSchemas"; import { BuildMetadata } from "./buildInfo"; -import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; +import { + KeybindingCommand, + KeybindingRule, + MAX_KEYBINDINGS_COUNT, + ResolvedKeybindingsConfig, +} from "./keybindings"; import { EditorId } from "./editor"; import { ProviderKind } from "./orchestration"; @@ -66,6 +71,15 @@ export const ServerUpsertKeybindingResult = Schema.Struct({ }); export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.Type; +export const ServerReplaceKeybindingRulesInput = Schema.Struct({ + command: KeybindingCommand, + rules: Schema.Array(KeybindingRule).check(Schema.isMaxLength(MAX_KEYBINDINGS_COUNT)), +}); +export type ServerReplaceKeybindingRulesInput = typeof ServerReplaceKeybindingRulesInput.Type; + +export const ServerReplaceKeybindingRulesResult = ServerUpsertKeybindingResult; +export type ServerReplaceKeybindingRulesResult = typeof ServerReplaceKeybindingRulesResult.Type; + export const ServerConfigUpdatedPayload = Schema.Struct({ issues: ServerConfigIssues, providers: ServerProviderStatuses, diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 21bf9c6d7..cd4b4a9d5 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -87,6 +87,7 @@ import { RevokePairedDeviceInput, RevokeTokenInput, ServerConfigUpdatedPayload, + ServerReplaceKeybindingRulesInput, TestOpenclawGatewayInput, } from "./server"; import { GitHubGetIssueInput, GitHubListIssuesInput, GitHubPostCommentInput } from "./github"; @@ -209,6 +210,7 @@ export const WS_METHODS = { serverGetProjectEnvironmentVariables: "server.getProjectEnvironmentVariables", serverSaveProjectEnvironmentVariables: "server.saveProjectEnvironmentVariables", serverUpsertKeybinding: "server.upsertKeybinding", + serverReplaceKeybindingRules: "server.replaceKeybindingRules", serverPickFolder: "server.pickFolder", // Token management (legacy) @@ -390,6 +392,7 @@ const WebSocketRequestBody = Schema.Union([ SaveProjectEnvironmentVariablesInput, ), tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule), + tagRequestBody(WS_METHODS.serverReplaceKeybindingRules, ServerReplaceKeybindingRulesInput), tagRequestBody(WS_METHODS.serverPickFolder, Schema.Struct({})), // Token management (legacy) diff --git a/packages/shared/package.json b/packages/shared/package.json index 243cf2083..5951bd026 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -55,6 +55,10 @@ "./redaction": { "types": "./src/redaction.ts", "import": "./src/redaction.ts" + }, + "./keybindings": { + "types": "./src/keybindings.ts", + "import": "./src/keybindings.ts" } }, "scripts": { diff --git a/packages/shared/src/keybindings.test.ts b/packages/shared/src/keybindings.test.ts new file mode 100644 index 000000000..0e7b8e804 --- /dev/null +++ b/packages/shared/src/keybindings.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; + +import type { ResolvedKeybindingsConfig } from "@okcode/contracts"; + +import { + DEFAULT_KEYBINDINGS, + defaultKeybindingRulesForCommand, + encodeKeybindingShortcut, + keybindingValueFromShortcutEvent, + keybindingValuesForCommand, + normalizeRecordedShortcutKeyToken, + parseKeybindingShortcut, +} from "./keybindings"; + +describe("parseKeybindingShortcut", () => { + it("parses plus-key shortcuts", () => { + expect(parseKeybindingShortcut("mod++")).toEqual({ + key: "+", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }); + }); + + it("normalizes esc and space tokens", () => { + expect(parseKeybindingShortcut("mod+esc")?.key).toBe("escape"); + expect(parseKeybindingShortcut("mod+space")?.key).toBe(" "); + }); +}); + +describe("encodeKeybindingShortcut", () => { + it("encodes escape with the shared token format", () => { + expect( + encodeKeybindingShortcut({ + key: "escape", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }), + ).toBe("mod+esc"); + }); +}); + +describe("normalizeRecordedShortcutKeyToken", () => { + it("ignores modifier-only keys", () => { + expect(normalizeRecordedShortcutKeyToken("Meta")).toBeNull(); + expect(normalizeRecordedShortcutKeyToken("Shift")).toBeNull(); + }); + + it("accepts punctuation and navigation keys", () => { + expect(normalizeRecordedShortcutKeyToken("+")).toBe("+"); + expect(normalizeRecordedShortcutKeyToken("ArrowDown")).toBe("arrowdown"); + }); +}); + +describe("keybindingValueFromShortcutEvent", () => { + it("records mod on macOS using the meta key", () => { + expect( + keybindingValueFromShortcutEvent( + { + key: "k", + metaKey: true, + ctrlKey: false, + shiftKey: false, + altKey: false, + }, + "MacIntel", + ), + ).toBe("mod+k"); + }); + + it("records mod on Windows using control and preserves meta separately", () => { + expect( + keybindingValueFromShortcutEvent( + { + key: "k", + metaKey: true, + ctrlKey: true, + shiftKey: false, + altKey: false, + }, + "Win32", + ), + ).toBe("mod+meta+k"); + }); + + it("requires at least one modifier", () => { + expect( + keybindingValueFromShortcutEvent( + { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + }, + "Linux", + ), + ).toBeNull(); + }); +}); + +describe("defaultKeybindingRulesForCommand", () => { + it("returns the built-in alternate defaults for terminal.toggle", () => { + expect(defaultKeybindingRulesForCommand("terminal.toggle")).toEqual( + DEFAULT_KEYBINDINGS.filter((rule) => rule.command === "terminal.toggle"), + ); + }); +}); + +describe("keybindingValuesForCommand", () => { + it("returns encoded values from resolved keybindings without duplicates", () => { + const bindings = [ + { + command: "chat.new", + shortcut: { + key: "n", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + }, + { + command: "chat.new", + shortcut: { + key: "n", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + }, + { + command: "chat.new", + shortcut: { + key: "o", + metaKey: false, + ctrlKey: false, + shiftKey: true, + altKey: false, + modKey: true, + }, + }, + ] satisfies ResolvedKeybindingsConfig; + + expect(keybindingValuesForCommand(bindings, "chat.new")).toEqual(["mod+n", "mod+shift+o"]); + }); +}); diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts new file mode 100644 index 000000000..0e8a79abc --- /dev/null +++ b/packages/shared/src/keybindings.ts @@ -0,0 +1,279 @@ +import type { + KeybindingCommand, + KeybindingRule, + KeybindingShortcut, + ResolvedKeybindingsConfig, +} from "@okcode/contracts"; + +export interface HotkeyCommandDefinition { + readonly command: KeybindingCommand; + readonly title: string; + readonly description: string; + readonly group: "Workspace" | "Terminal"; + readonly when?: string; + readonly contextLabel: string; +} + +export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ + { key: "mod+j", command: "terminal.toggle" }, + { key: "ctrl+`", command: "terminal.toggle" }, + { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, + { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, + { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, + { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, + { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, + { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, + { key: "mod+down", command: "git.pullRequest", when: "!terminalFocus" }, + { key: "mod+shift+p", command: "git.pullRequest", when: "!terminalFocus" }, + { key: "mod+o", command: "editor.openFavorite" }, +] as const; + +export const HOTKEY_COMMAND_DEFINITIONS = [ + { + command: "chat.new", + title: "New thread", + description: "Start a fresh conversation in the current project.", + group: "Workspace", + when: "!terminalFocus", + contextLabel: "When the terminal is not focused", + }, + { + command: "chat.newLocal", + title: "New local thread", + description: "Start a local or worktree-backed thread without leaving the current project.", + group: "Workspace", + when: "!terminalFocus", + contextLabel: "When the terminal is not focused", + }, + { + command: "git.pullRequest", + title: "Pull request", + description: "Open the active pull request or start the PR flow for the current branch.", + group: "Workspace", + when: "!terminalFocus", + contextLabel: "When the terminal is not focused", + }, + { + command: "editor.openFavorite", + title: "Favorite editor", + description: "Jump straight to the editor you last used for this project.", + group: "Workspace", + contextLabel: "Available anywhere", + }, + { + command: "terminal.toggle", + title: "Toggle terminal", + description: "Show or hide the terminal drawer from anywhere in the app.", + group: "Terminal", + contextLabel: "Available anywhere", + }, + { + command: "terminal.split", + title: "Split terminal", + description: "Open another terminal pane beside the active one.", + group: "Terminal", + when: "terminalFocus", + contextLabel: "When the terminal is focused", + }, + { + command: "terminal.new", + title: "New terminal", + description: "Create a fresh terminal session while you stay in context.", + group: "Terminal", + when: "terminalFocus", + contextLabel: "When the terminal is focused", + }, + { + command: "terminal.close", + title: "Close terminal", + description: "Close the active terminal session.", + group: "Terminal", + when: "terminalFocus", + contextLabel: "When the terminal is focused", + }, +] as const satisfies ReadonlyArray; + +export interface ShortcutRecordingEventLike { + readonly key: string; + readonly metaKey: boolean; + readonly ctrlKey: boolean; + readonly shiftKey: boolean; + readonly altKey: boolean; +} + +function isMacLikePlatform(platform: string): boolean { + return /mac|iphone|ipad|ipod/i.test(platform); +} + +function normalizeKeyToken(token: string): string { + if (token === "space") return " "; + if (token === "esc") return "escape"; + return token; +} + +export function parseKeybindingShortcut(value: string): KeybindingShortcut | null { + const rawTokens = value + .toLowerCase() + .split("+") + .map((token) => token.trim()); + const tokens = [...rawTokens]; + let trailingEmptyCount = 0; + while (tokens[tokens.length - 1] === "") { + trailingEmptyCount += 1; + tokens.pop(); + } + if (trailingEmptyCount > 0) { + tokens.push("+"); + } + if (tokens.some((token) => token.length === 0)) { + return null; + } + if (tokens.length === 0) return null; + + let key: string | null = null; + let metaKey = false; + let ctrlKey = false; + let shiftKey = false; + let altKey = false; + let modKey = false; + + for (const token of tokens) { + switch (token) { + case "cmd": + case "meta": + metaKey = true; + break; + case "ctrl": + case "control": + ctrlKey = true; + break; + case "shift": + shiftKey = true; + break; + case "alt": + case "option": + altKey = true; + break; + case "mod": + modKey = true; + break; + default: { + if (key !== null) return null; + key = normalizeKeyToken(token); + } + } + } + + if (key === null) return null; + return { + key, + metaKey, + ctrlKey, + shiftKey, + altKey, + modKey, + }; +} + +export function encodeKeybindingShortcut(shortcut: KeybindingShortcut): string | null { + const modifiers: string[] = []; + if (shortcut.modKey) modifiers.push("mod"); + if (shortcut.metaKey) modifiers.push("meta"); + if (shortcut.ctrlKey) modifiers.push("ctrl"); + if (shortcut.altKey) modifiers.push("alt"); + if (shortcut.shiftKey) modifiers.push("shift"); + if (!shortcut.key) return null; + if (shortcut.key !== "+" && shortcut.key.includes("+")) return null; + const key = shortcut.key === " " ? "space" : shortcut.key === "escape" ? "esc" : shortcut.key; + return [...modifiers, key].join("+"); +} + +export function normalizeRecordedShortcutKeyToken(key: string): string | null { + const normalized = key.toLowerCase(); + if ( + normalized === "meta" || + normalized === "os" || + normalized === "control" || + normalized === "ctrl" || + normalized === "shift" || + normalized === "alt" || + normalized === "option" || + normalized === "capslock" || + normalized === "dead" + ) { + return null; + } + if (normalized === " " || normalized === "spacebar") return "space"; + if (normalized === "escape" || normalized === "esc") return "esc"; + if (normalized === "up") return "arrowup"; + if (normalized === "down") return "arrowdown"; + if (normalized === "left") return "arrowleft"; + if (normalized === "right") return "arrowright"; + if ( + normalized === "arrowup" || + normalized === "arrowdown" || + normalized === "arrowleft" || + normalized === "arrowright" || + normalized === "enter" || + normalized === "tab" || + normalized === "backspace" || + normalized === "delete" || + normalized === "home" || + normalized === "end" || + normalized === "pageup" || + normalized === "pagedown" + ) { + return normalized; + } + if (/^f\d{1,2}$/.test(normalized)) return normalized; + if (normalized.length === 1 && !/\s/.test(normalized)) return normalized; + return null; +} + +export function keybindingValueFromShortcutEvent( + event: ShortcutRecordingEventLike, + platform: string, +): string | null { + const keyToken = normalizeRecordedShortcutKeyToken(event.key); + if (!keyToken) return null; + + const parts: string[] = []; + if (isMacLikePlatform(platform)) { + if (event.metaKey) parts.push("mod"); + if (event.ctrlKey) parts.push("ctrl"); + } else { + if (event.ctrlKey) parts.push("mod"); + if (event.metaKey) parts.push("meta"); + } + if (event.altKey) parts.push("alt"); + if (event.shiftKey) parts.push("shift"); + if (parts.length === 0) { + return null; + } + parts.push(keyToken); + return parts.join("+"); +} + +export function defaultKeybindingRulesForCommand( + command: KeybindingCommand, +): ReadonlyArray { + return DEFAULT_KEYBINDINGS.filter((binding) => binding.command === command); +} + +export function keybindingValuesForCommand( + keybindings: ResolvedKeybindingsConfig, + command: KeybindingCommand, +): string[] { + const values: string[] = []; + const seenValues = new Set(); + + for (const binding of keybindings) { + if (!binding || binding.command !== command) continue; + const encoded = encodeKeybindingShortcut(binding.shortcut); + if (!encoded || seenValues.has(encoded)) continue; + seenValues.add(encoded); + values.push(encoded); + } + + return values; +}