Skip to content
21 changes: 21 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)服务器配置。值是键值对,键为服务名称,值为服务器配置对象。
Expand Down
21 changes: 21 additions & 0 deletions docs/configuration_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
121 changes: 121 additions & 0 deletions packages/cli/src/tests/keybinds.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
});
1 change: 1 addition & 0 deletions packages/cli/src/tests/slash-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ test("buildSlashCommands prefixes skills before built-ins", () => {
"undo",
"mcp",
"raw",
"keybind",
"exit",
]);
});
Expand Down
42 changes: 42 additions & 0 deletions packages/cli/src/ui/core/keybinds.ts
Original file line number Diff line number Diff line change
@@ -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,
}));
}
8 changes: 8 additions & 0 deletions packages/cli/src/ui/core/slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type SlashCommandKind =
| "undo"
| "mcp"
| "raw"
| "keybind"
| "exit";

export type SlashCommandItem = {
Expand Down Expand Up @@ -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 <shortcut> <action>", "remove <shortcut>", "list"],
description: "Add, remove, or list custom keybinds",
},
{
kind: "exit",
name: "exit",
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export {
type SlashCommandKind,
type SlashCommandItem,
} from "./core/slash-commands";
export { matchKeybind, buildKeybindMatchers, type KeybindMatcher } from "./core/keybinds";
export {
filterFileMentionItems,
formatFileMentionPath,
Expand Down
16 changes: 15 additions & 1 deletion packages/cli/src/ui/views/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -940,6 +950,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp
setView("chat");
}}
/>
) : view === "keybinds" ? (
<KeybindsView keybinds={resolvedSettings.keybinds} projectRoot={projectRoot} onCancel={() => setView("chat")} />
) : view === "mcp-status" ? (
<McpStatusList
statuses={mcpStatuses}
Expand Down Expand Up @@ -983,6 +995,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp
onInterrupt={handleInterrupt}
onToggleProcessStdout={handleToggleProcessStdout}
onExitShortcut={handleExitShortcut}
onKeybindsChanged={handleKeybindsChanged}
keybinds={resolvedSettings.keybinds}
placeholder="Type your message..."
statusLineSegments={statusLineSegments}
statusLineSeparator={resolvedSettings.statusline.separator}
Expand Down
72 changes: 72 additions & 0 deletions packages/cli/src/ui/views/KeybindsView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import React from "react";
import { Box, Text, useInput } from "ink";
import { readSettings, readProjectSettings } from "@vegamo/deepcode-core";
import type { KeybindMap, DeepcodingSettings } from "@vegamo/deepcode-core";

type Props = {
keybinds: KeybindMap;
projectRoot: string;
onCancel: () => 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 (
<Box flexDirection="column" marginLeft={1} paddingX={1} gap={1} borderStyle="round" borderDimColor>
<Box>
<Text color="#229ac3" bold>
/keybind
</Text>
</Box>

{entries.length === 0 ? (
<Box>
<Text dimColor>(no keybinds configured)</Text>
</Box>
) : (
<Box flexDirection="column">
{entries.map(([shortcut, action]) => {
const level = getKeybindLevel(shortcut, userSettings, projectSettings);
return (
<Box key={shortcut} gap={1}>
<Text>{shortcut}</Text>
<Text dimColor>→ /{action}</Text>
<Text color={level === "local" ? "yellow" : "blue"} dimColor>
[{level}]
</Text>
</Box>
);
})}
</Box>
)}

<Box flexDirection="column">
<Text dimColor>/keybind add &lt;shortcut&gt; &lt;action&gt; to add</Text>
<Text dimColor>/keybind --global add &lt;shortcut&gt; &lt;action&gt; (user-level)</Text>
<Text dimColor>/keybind remove &lt;shortcut&gt; to remove</Text>
<Text dimColor>/keybind --global remove &lt;shortcut&gt; (user-level)</Text>
</Box>

<Text dimColor>Esc to close</Text>
</Box>
);
}
Loading