diff --git a/docs/configuration.md b/docs/configuration.md index d942dc04..809043ed 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -120,6 +120,27 @@ Deep Code 内置免费可用的 Web Search 工具。如果需要自定义搜索 - 将某个 skill 设置为 `false` 后,所有项目级和用户级目录中解析名称相同的 skill 都会被隐藏。 - 项目设置会按 skill 覆盖用户设置。如果项目设置没有配置某个 skill,则使用用户设置。 +#### `keybinds` — 自定义快捷键 + +将键盘快捷键绑定到斜杠命令(如 `/exit`、`/new`、`/skills` 等)或 skill 名称,无需输入 `/` 即可触发操作。 + +```json +{ + "keybinds": { + "ctrl+e": "exit", + "ctrl+n": "new", + "ctrl+s": "skills", + "ctrl+m": "model" + } +} +``` + +- 快捷键格式:`ctrl+key`、`ctrl+shift+key` 或 `meta+key`(例如 `ctrl+e`、`ctrl+shift+i`、`meta+b`)。 +- 键名区分大小写匹配(`ctrl+e` 需按 `Ctrl+E`)。 +- 项目设置会按快捷键覆盖用户设置。如果项目设置中存在同名快捷键,则使用项目设置的值。 +- 自定义快捷键仅在无下拉菜单/弹窗打开时生效,以避免与菜单导航冲突。 +- 也可通过 `/keybind add|remove|list` 命令在运行时管理快捷键。 + #### `mcpServers` — MCP 服务器 MCP(Model Context Protocol)服务器配置。值是键值对,键为服务名称,值为服务器配置对象。 diff --git a/docs/configuration_en.md b/docs/configuration_en.md index a078c428..f3fab5cc 100644 --- a/docs/configuration_en.md +++ b/docs/configuration_en.md @@ -120,6 +120,27 @@ Controls whether skills are included during skill scanning. Keys are resolved sk - Setting a skill to `false` hides every skill with that resolved `name`, across project and user skill roots. - Project settings override user settings per skill. If the project setting omits a skill, the user setting is used. +#### `keybinds` — Custom Keybinds + +Bind keyboard shortcuts to slash commands (e.g. `/exit`, `/new`, `/skills`) or skill names, triggering actions without typing `/`. + +```json +{ + "keybinds": { + "ctrl+e": "exit", + "ctrl+n": "new", + "ctrl+s": "skills", + "ctrl+m": "model" + } +} +``` + +- Shortcut format: `ctrl+key`, `ctrl+shift+key`, or `meta+key` (e.g. `ctrl+e`, `ctrl+shift+i`, `meta+b`). +- Letter matching is case-insensitive (`ctrl+e` matches both `Ctrl+E` and `Ctrl+Shift+E` with exact modifier match). +- Project settings override user settings per shortcut. If a shortcut exists in both, the project value wins. +- Custom keybinds only fire when no dropdown/menu is open, to avoid conflicts with menu navigation. +- You can also manage keybinds at runtime via `/keybind add|remove|list`. + #### `mcpServers` — MCP Servers Configuration for MCP (Model Context Protocol) servers. The value is a key-value pair, where the key is the service name and the value is a server configuration object. diff --git a/packages/cli/src/tests/keybinds.test.ts b/packages/cli/src/tests/keybinds.test.ts new file mode 100644 index 00000000..ff691d5d --- /dev/null +++ b/packages/cli/src/tests/keybinds.test.ts @@ -0,0 +1,121 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { matchKeybind, buildKeybindMatchers } from "../ui"; +import type { InputKey } from "../ui/hooks/useTerminalInput"; + +function key(overrides: Partial = {}): InputKey { + return { + upArrow: false, + downArrow: false, + leftArrow: false, + rightArrow: false, + home: false, + end: false, + pageDown: false, + pageUp: false, + return: false, + escape: false, + ctrl: false, + shift: false, + tab: false, + backspace: false, + delete: false, + meta: false, + focusIn: false, + focusOut: false, + paste: false, + ...overrides, + }; +} + +// --------------- matchKeybind --------------- + +test("matchKeybind: ctrl+e matches with ctrl modifier", () => { + assert.equal(matchKeybind("ctrl+e", "e", key({ ctrl: true })), true); + assert.equal(matchKeybind("ctrl+e", "E", key({ ctrl: true })), true); +}); + +test("matchKeybind: ctrl+e does not match with extra shift", () => { + assert.equal(matchKeybind("ctrl+e", "E", key({ ctrl: true, shift: true })), false); +}); + +test("matchKeybind: ctrl+e does not match without ctrl", () => { + assert.equal(matchKeybind("ctrl+e", "e", key()), false); +}); + +test("matchKeybind: ctrl+e does not match wrong key", () => { + assert.equal(matchKeybind("ctrl+e", "f", key({ ctrl: true })), false); +}); + +test("matchKeybind: ctrl+shift+g matches with both modifiers", () => { + assert.equal(matchKeybind("ctrl+shift+g", "g", key({ ctrl: true, shift: true })), true); + assert.equal(matchKeybind("ctrl+shift+g", "G", key({ ctrl: true, shift: true })), true); +}); + +test("matchKeybind: ctrl+shift+g does not match with only ctrl", () => { + assert.equal(matchKeybind("ctrl+shift+g", "g", key({ ctrl: true })), false); +}); + +test("matchKeybind: ctrl+shift+g does not match extra meta", () => { + assert.equal(matchKeybind("ctrl+shift+g", "g", key({ ctrl: true, shift: true, meta: true })), false); +}); + +test("matchKeybind: meta+b matches with meta modifier", () => { + assert.equal(matchKeybind("meta+b", "b", key({ meta: true })), true); + assert.equal(matchKeybind("meta+b", "B", key({ meta: true })), true); +}); + +test("matchKeybind: meta+b does not match with extra shift", () => { + assert.equal(matchKeybind("meta+b", "B", key({ meta: true, shift: true })), false); +}); + +test("matchKeybind: meta+b does not match without meta", () => { + assert.equal(matchKeybind("meta+b", "b", key()), false); +}); + +test("matchKeybind: handles non-alpha keys like minus", () => { + assert.equal(matchKeybind("ctrl+-", "-", key({ ctrl: true })), true); +}); + +test("matchKeybind: handles digit keys", () => { + assert.equal(matchKeybind("ctrl+1", "1", key({ ctrl: true })), true); +}); + +test("matchKeybind: empty shortcut returns false", () => { + assert.equal(matchKeybind("", "e", key({ ctrl: true })), false); +}); + +test("matchKeybind: shortcut with only modifier returns false", () => { + assert.equal(matchKeybind("ctrl", "", key({ ctrl: true })), false); +}); + +test("matchKeybind: shortcut without modifier returns false", () => { + assert.equal(matchKeybind("e", "e", key()), false); +}); + +test("matchKeybind: case-insensitive shortcut definition", () => { + assert.equal(matchKeybind("Ctrl+E", "e", key({ ctrl: true })), true); + assert.equal(matchKeybind("CTRL+SHIFT+G", "g", key({ ctrl: true, shift: true })), true); +}); + +// --------------- buildKeybindMatchers --------------- + +test("buildKeybindMatchers: builds matchers from KeybindMap", () => { + const keybinds = { "ctrl+e": "exit", "ctrl+n": "new" }; + const matchers = buildKeybindMatchers(keybinds); + assert.equal(matchers.length, 2); + assert.equal(matchers[0]!.action, "exit"); + assert.equal(matchers[1]!.action, "new"); +}); + +test("buildKeybindMatchers: matchers work correctly", () => { + const keybinds = { "ctrl+s": "skills" }; + const matchers = buildKeybindMatchers(keybinds); + assert.equal(matchers[0]!.match("s", key({ ctrl: true })), true); + assert.equal(matchers[0]!.match("s", key()), false); +}); + +test("buildKeybindMatchers: empty map returns empty array", () => { + const matchers = buildKeybindMatchers({}); + assert.equal(matchers.length, 0); +}); diff --git a/packages/cli/src/tests/slash-commands.test.ts b/packages/cli/src/tests/slash-commands.test.ts index 420e5a48..e570f98d 100644 --- a/packages/cli/src/tests/slash-commands.test.ts +++ b/packages/cli/src/tests/slash-commands.test.ts @@ -29,6 +29,7 @@ test("buildSlashCommands prefixes skills before built-ins", () => { "undo", "mcp", "raw", + "keybind", "exit", ]); }); diff --git a/packages/cli/src/ui/core/keybinds.ts b/packages/cli/src/ui/core/keybinds.ts new file mode 100644 index 00000000..7a29c5f9 --- /dev/null +++ b/packages/cli/src/ui/core/keybinds.ts @@ -0,0 +1,42 @@ +import type { InputKey } from "../hooks"; +import type { KeybindMap } from "@vegamo/deepcode-core"; + +/** + * Match a keybind shortcut string (e.g. "ctrl+shift+g") against + * the raw input character and parsed InputKey. + * + * Supported modifiers: ctrl, shift, meta. + * The key part is case-insensitive. + */ +export function matchKeybind(shortcut: string, input: string, key: InputKey): boolean { + const parts = shortcut.toLowerCase().split("+"); + const keyChar = parts.pop()!; + + // Must have at least one modifier + a key + if (parts.length === 0 || !keyChar) return false; + + const modifiers = new Set(parts); + + // Each modifier in the shortcut must be present in the key event. + // Modifiers NOT in the shortcut must NOT be present (exact match). + if (modifiers.has("ctrl") !== key.ctrl) return false; + if (modifiers.has("shift") !== key.shift) return false; + if (modifiers.has("meta") !== key.meta) return false; + + return input.toLowerCase() === keyChar; +} + +export type KeybindMatcher = { + match: (input: string, key: InputKey) => boolean; + action: string; +}; + +/** + * Pre-compile a KeybindMap into an array of matchers for fast per-keystroke lookup. + */ +export function buildKeybindMatchers(keybinds: KeybindMap): KeybindMatcher[] { + return Object.entries(keybinds).map(([shortcut, action]) => ({ + match: (input: string, key: InputKey) => matchKeybind(shortcut, input, key), + action, + })); +} diff --git a/packages/cli/src/ui/core/slash-commands.ts b/packages/cli/src/ui/core/slash-commands.ts index ba5ae6ec..94b75b30 100644 --- a/packages/cli/src/ui/core/slash-commands.ts +++ b/packages/cli/src/ui/core/slash-commands.ts @@ -11,6 +11,7 @@ export type SlashCommandKind = | "undo" | "mcp" | "raw" + | "keybind" | "exit"; export type SlashCommandItem = { @@ -78,6 +79,13 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ args: ["lite", "normal", "raw-scrollback"], description: "Toggle display mode for viewing or collapsing reasoning content", }, + { + kind: "keybind", + name: "keybind", + label: "/keybind", + args: ["add ", "remove ", "list"], + description: "Add, remove, or list custom keybinds", + }, { kind: "exit", name: "exit", diff --git a/packages/cli/src/ui/index.ts b/packages/cli/src/ui/index.ts index 65415464..6169d345 100644 --- a/packages/cli/src/ui/index.ts +++ b/packages/cli/src/ui/index.ts @@ -80,6 +80,7 @@ export { type SlashCommandKind, type SlashCommandItem, } from "./core/slash-commands"; +export { matchKeybind, buildKeybindMatchers, type KeybindMatcher } from "./core/keybinds"; export { filterFileMentionItems, formatFileMentionPath, diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index 3b2886cd..54be297b 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -13,6 +13,7 @@ import { findExpandedThinkingId } from "../core/thinking-state"; import { WelcomeScreen } from "./WelcomeScreen"; import { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; import { McpStatusList } from "./McpStatusList"; +import { KeybindsView } from "./KeybindsView"; import { ProcessStdoutView } from "./ProcessStdoutView"; import { type AskUserQuestionAnswers, @@ -50,7 +51,7 @@ import { SessionManager } from "@vegamo/deepcode-core"; import { getCompactPromptTokenThreshold } from "@vegamo/deepcode-core"; import { writeStdout, writeStdoutLine } from "../../utils/stdio-helpers"; -type View = "chat" | "session-list" | "undo" | "mcp-status"; +type View = "chat" | "session-list" | "undo" | "keybinds" | "mcp-status"; const STATUS_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; @@ -361,6 +362,10 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp navigateToSubView("mcp-status"); return; } + if (submission.command === "keybind") { + navigateToSubView("keybinds"); + return; + } const prompt: UserPromptContent = { text: submission.text, @@ -500,6 +505,11 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp handleExit({ showCommand: false, showSummary: false }); }, [handleExit]); + const handleKeybindsChanged = useCallback(() => { + const next = resolveCurrentSettings(projectRoot); + setResolvedSettings(next); + }, [projectRoot]); + const reloadActiveSessionView = useCallback( (sessionId: string): void => { resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true }); @@ -940,6 +950,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp setView("chat"); }} /> + ) : view === "keybinds" ? ( + setView("chat")} /> ) : view === "mcp-status" ? ( void; +}; + +function getKeybindLevel( + shortcut: string, + userSettings: DeepcodingSettings | null, + projectSettings: DeepcodingSettings | null +): "local" | "global" { + if (projectSettings?.keybinds?.[shortcut] !== undefined) return "local"; + if (userSettings?.keybinds?.[shortcut] !== undefined) return "global"; + return "global"; +} + +export function KeybindsView({ keybinds, projectRoot, onCancel }: Props): React.ReactElement { + const entries = Object.entries(keybinds); + const userSettings = readSettings(); + const projectSettings = readProjectSettings(projectRoot); + + useInput((_input, key) => { + if (key.escape) { + onCancel(); + } + }); + + return ( + + + + /keybind + + + + {entries.length === 0 ? ( + + (no keybinds configured) + + ) : ( + + {entries.map(([shortcut, action]) => { + const level = getKeybindLevel(shortcut, userSettings, projectSettings); + return ( + + {shortcut} + → /{action} + + [{level}] + + + ); + })} + + )} + + + /keybind add <shortcut> <action> to add + /keybind --global add <shortcut> <action> (user-level) + /keybind remove <shortcut> to remove + /keybind --global remove <shortcut> (user-level) + + + Esc to close + + ); +} diff --git a/packages/cli/src/ui/views/PromptInput.tsx b/packages/cli/src/ui/views/PromptInput.tsx index 3f548def..c3e02599 100644 --- a/packages/cli/src/ui/views/PromptInput.tsx +++ b/packages/cli/src/ui/views/PromptInput.tsx @@ -36,6 +36,7 @@ import { } from "../core/prompt-undo-redo"; import { buildSlashCommands, filterSlashCommands, findExactSlashCommand } from "../core/slash-commands"; import type { SlashCommandItem } from "../core/slash-commands"; +import { buildKeybindMatchers } from "../core/keybinds"; import { filterFileMentionItems, getCurrentFileMentionToken, @@ -60,7 +61,8 @@ import { useTerminalFocusReporting, } from "../hooks"; import SlashCommandMenu, { isSkillSelected } from "./SlashCommandMenu"; -import type { ModelConfigSelection, PermissionScope } from "@vegamo/deepcode-core"; +import type { ModelConfigSelection, PermissionScope, KeybindMap } from "@vegamo/deepcode-core"; +import { readSettings, writeSettings, readProjectSettings, writeProjectSettings } from "@vegamo/deepcode-core"; import { FileMentionMenu, ModelsDropdown, RawModelDropdown, SkillsDropdown } from "../components"; import type { SessionEntry, SkillInfo } from "@vegamo/deepcode-core"; import type { UserToolPermission } from "@vegamo/deepcode-core"; @@ -72,7 +74,7 @@ export type PromptSubmission = { selectedSkills?: SkillInfo[]; permissions?: UserToolPermission[]; alwaysAllows?: PermissionScope[]; - command?: "new" | "resume" | "continue" | "undo" | "mcp" | "exit"; + command?: "new" | "resume" | "continue" | "undo" | "mcp" | "keybind" | "exit"; }; export type PromptDraft = { @@ -102,6 +104,8 @@ type Props = { onInterrupt: () => void; onToggleProcessStdout?: () => void; onExitShortcut?: () => void; + onKeybindsChanged?: () => void; + keybinds?: KeybindMap; }; const PROMPT_PREFIX_WIDTH = 2; @@ -135,6 +139,8 @@ export const PromptInput = React.memo(function PromptInput({ onToggleProcessStdout, onExitShortcut, onRawModeChange, + onKeybindsChanged, + keybinds, }: Props): React.ReactElement { const { stdout } = useStdout(); const inputTextRef = useRef(null); @@ -179,6 +185,10 @@ export const PromptInput = React.memo(function PromptInput({ fileMentionToken !== null && fileMentionKey !== dismissedFileMentionKey; const slashItems = React.useMemo(() => buildSlashCommands(skills), [skills]); + const keybindMatchers = React.useMemo( + () => (keybinds && Object.keys(keybinds).length > 0 ? buildKeybindMatchers(keybinds) : []), + [keybinds] + ); const slashToken = getCurrentSlashToken(buffer); const slashMenu = React.useMemo( () => @@ -321,6 +331,20 @@ export const PromptInput = React.memo(function PromptInput({ return; } + // Check custom keybinds before hardcoded shortcuts. + // Only match when no dropdown/menu is open to avoid conflicts. + if (!openRawModelDropdown && !showSkillsDropdown && !showModelDropdown && !showMenu && !showFileMentionMenu) { + for (const { match, action } of keybindMatchers) { + if (match(input, key)) { + const item = slashItems.find((s) => s.name === action); + if (item) { + handleSlashSelection(item); + return; + } + } + } + } + if (key.escape) { if (openRawModelDropdown) { return; @@ -713,6 +737,21 @@ export const PromptInput = React.memo(function PromptInput({ resetPromptInput(); return; } + if (item.kind === "keybind") { + const parts = buffer.text.trim().split(/\s+/); + if (parts.length > 1) { + // Has args: process add/remove/list subcommand + handleKeybindCommand(); + } else if (parts[0] && parts[0] !== "/keybind") { + // Partial match from menu (e.g. /key): auto-complete to /keybind + setBuffer({ text: "/keybind ", cursor: "/keybind ".length }); + clearUndoRedoStacks(); + } else { + // Exact match with no args: show usage hint + setStatusMessage("Usage: /keybind add | remove | list"); + } + return; + } if (item.kind === "exit") { onSubmit({ text: "/exit", imageUrls: [], command: "exit" }); setBuffer(EMPTY_BUFFER); @@ -721,6 +760,120 @@ export const PromptInput = React.memo(function PromptInput({ } } + function handleKeybindCommand(): void { + const rawParts = buffer.text.trim().split(/\s+/); + const isGlobal = rawParts.includes("--global"); + const parts = rawParts.filter((p) => p !== "--global"); + const subcommand = parts[1] ?? "list"; + const existingProjectSettings = readProjectSettings(projectRoot); + const useProject = existingProjectSettings !== null; + const userSettings = readSettings(); + const kbs = keybinds ?? {}; + const levelLabel = isGlobal ? " (global)" : useProject ? " (project)" : " (global)"; + + if (subcommand === "list") { + onSubmit({ text: "/keybind list", imageUrls: [], command: "keybind" }); + resetPromptInput(); + return; + } + + if (subcommand === "add") { + const shortcut = parts[2]; + const action = parts[3]; + if (!shortcut || !action) { + setStatusMessage("Usage: /keybind add (e.g. /keybind add ctrl+e exit)"); + clearSlashToken(); + return; + } + // Validate the shortcut string + if (!/^(ctrl|shift|meta)(\+(ctrl|shift|meta))*\+[a-z0-9-]$/i.test(shortcut)) { + setStatusMessage( + `Invalid shortcut "${shortcut}". Format: ctrl+key, ctrl+shift+key (modifiers: ctrl, shift, meta)` + ); + clearSlashToken(); + return; + } + // Validate action is a known slash command or skill + const knownNames = new Set(slashItems.map((s) => s.name)); + if (!knownNames.has(action)) { + const available = slashItems + .filter((s) => s.kind !== "skill") + .map((s) => s.name) + .slice(0, 10) + .join(", "); + setStatusMessage(`Unknown action "/${action}". Available: ${available}…`); + clearSlashToken(); + return; + } + + // Check duplicates across all levels (merged) + const existingMerged = kbs[shortcut]; + if (existingMerged === action) { + setStatusMessage(`Keybind already set: ${shortcut} → /${action}`); + clearSlashToken(); + return; + } + + // Determine write target + const rawSettings = isGlobal ? userSettings : useProject ? existingProjectSettings : userSettings; + const current: Record = { ...(rawSettings?.keybinds ?? {}) }; + current[shortcut] = action; + const updated = { ...(rawSettings ?? {}), keybinds: current }; + if (isGlobal || !useProject) { + writeSettings(updated); + } else { + writeProjectSettings(updated, projectRoot); + } + onKeybindsChanged?.(); + if (existingMerged) { + setStatusMessage(`Keybind updated: ${shortcut} → /${action} (was /${existingMerged})${levelLabel}`); + } else { + setStatusMessage(`Keybind added: ${shortcut} → /${action}${levelLabel}`); + } + clearSlashToken(); + return; + } + + if (subcommand === "remove") { + const shortcut = parts[2]; + const rawSettings = isGlobal ? userSettings : useProject ? existingProjectSettings : userSettings; + const current: Record = { ...(rawSettings?.keybinds ?? {}) }; + const existingShortcuts = Object.keys(kbs); + if (!shortcut) { + if (existingShortcuts.length === 0) { + setStatusMessage("No custom keybinds to remove."); + } else { + const list = existingShortcuts.map((s) => `${s} → /${kbs[s]}`).join(", "); + setStatusMessage(`Usage: /keybind remove \nExisting: ${list}`); + } + clearSlashToken(); + return; + } + + if (!(shortcut in current)) { + const list = + existingShortcuts.length > 0 ? existingShortcuts.map((s) => `${s} → /${kbs[s]}`).join(", ") : "(none)"; + setStatusMessage(`Keybind "${shortcut}" not found${levelLabel}.\nExisting: ${list}`); + clearSlashToken(); + return; + } + delete current[shortcut]; + const updated = { ...(rawSettings ?? {}), keybinds: current }; + if (isGlobal || !useProject) { + writeSettings(updated); + } else { + writeProjectSettings(updated, projectRoot); + } + onKeybindsChanged?.(); + setStatusMessage(`Keybind removed: ${shortcut}${levelLabel}`); + clearSlashToken(); + return; + } + + setStatusMessage(`Unknown subcommand "${subcommand}". Use: add, remove, list`); + clearSlashToken(); + } + function submitCurrentBuffer(): void { if (busy) { setStatusMessage("wait for the current response or press esc to interrupt"); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 832d2444..8be5bcb3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -29,6 +29,7 @@ export type { StatusLineSettings, ResolvedStatusLineSettings, StatusLineProviderConfig, + KeybindMap, } from "./settings"; // Session diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index 5dab3b5a..7d7d7086 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -45,6 +45,8 @@ export type PermissionSettings = { export type EnabledSkillsSettings = Record; +export type KeybindMap = Record; + export type StatusLineProviderConfig = | { type: "command"; @@ -94,6 +96,7 @@ export type DeepcodingSettings = { permissions?: PermissionSettings; enabledSkills?: EnabledSkillsSettings; statusline?: StatusLineSettings; + keybinds?: KeybindMap; }; export type ResolvedDeepcodingSettings = { @@ -112,6 +115,7 @@ export type ResolvedDeepcodingSettings = { permissions: Required; enabledSkills: EnabledSkillsSettings; statusline: ResolvedStatusLineSettings; + keybinds: KeybindMap; }; export type ModelConfigSelection = { @@ -253,6 +257,30 @@ function mergeEnabledSkills( }; } +function normalizeKeybinds(value: unknown): KeybindMap { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + const result: KeybindMap = {}; + for (const [shortcut, action] of Object.entries(value)) { + if (!shortcut || typeof action !== "string" || !action) { + continue; + } + result[shortcut] = action; + } + return result; +} + +function mergeKeybinds( + userSettings: DeepcodingSettings | null | undefined, + projectSettings: DeepcodingSettings | null | undefined +): KeybindMap { + return { + ...normalizeKeybinds(userSettings?.keybinds), + ...normalizeKeybinds(projectSettings?.keybinds), + }; +} + const DEFAULT_STATUSLINE_REFRESH_MS = 2000; const MIN_STATUSLINE_REFRESH_MS = 500; const DEFAULT_STATUSLINE_SEPARATOR = " · "; @@ -547,6 +575,7 @@ export function resolveSettingsSources( permissions: mergePermissions(userSettings, projectSettings), enabledSkills: mergeEnabledSkills(userSettings, projectSettings), statusline: mergeStatusLine(userSettings, projectSettings), + keybinds: mergeKeybinds(userSettings, projectSettings), }; } diff --git a/packages/core/src/tests/settings-and-notify.test.ts b/packages/core/src/tests/settings-and-notify.test.ts index ceddc43e..c57c963c 100644 --- a/packages/core/src/tests/settings-and-notify.test.ts +++ b/packages/core/src/tests/settings-and-notify.test.ts @@ -482,6 +482,60 @@ test("applyModelConfigSelection leaves settings untouched when the effective sel assert.equal(result.settings.model, undefined); }); +// ------ keybinds ------ + +test("resolveSettingsSources defaults keybinds to empty object", () => { + const resolved = resolveSettingsSources( + {}, + null, + { model: "default-model", baseURL: "https://default.example.com" }, + TEST_PROCESS_ENV + ); + assert.deepEqual(resolved.keybinds, {}); +}); + +test("resolveSettingsSources reads keybinds from user settings", () => { + const resolved = resolveSettingsSources( + { keybinds: { "ctrl+e": "exit", "ctrl+n": "new" } }, + null, + { model: "default-model", baseURL: "https://default.example.com" }, + TEST_PROCESS_ENV + ); + assert.deepEqual(resolved.keybinds, { "ctrl+e": "exit", "ctrl+n": "new" }); +}); + +test("resolveSettingsSources merges keybinds with project precedence", () => { + const resolved = resolveSettingsSources( + { keybinds: { "ctrl+e": "exit", "ctrl+s": "skills" } }, + { keybinds: { "ctrl+e": "new", "ctrl+p": "plan" } }, + { model: "default-model", baseURL: "https://default.example.com" }, + TEST_PROCESS_ENV + ); + assert.deepEqual(resolved.keybinds, { + "ctrl+e": "new", + "ctrl+s": "skills", + "ctrl+p": "plan", + }); +}); + +test("resolveSettingsSources filters invalid keybind entries", () => { + const resolved = resolveSettingsSources( + { + keybinds: { + "ctrl+e": "exit", + "": "exit", + invalid: "" as never, + "ctrl+x": 123 as never, + "ctrl+g": null as never, + }, + }, + null, + { model: "default-model", baseURL: "https://default.example.com" }, + TEST_PROCESS_ENV + ); + assert.deepEqual(resolved.keybinds, { "ctrl+e": "exit" }); +}); + test("formatDurationSeconds preserves sub-second precision and trims trailing zeros", () => { assert.equal(formatDurationSeconds(0), "0"); assert.equal(formatDurationSeconds(1250), "1");