Skip to content

Commit fb745d6

Browse files
committed
feat(keybinds): add custom keybind management with --global flag
- Add /keybind slash command with add, remove, list subcommands - Auto-complete partial matches (/key -> /keybind) - Show usage hint on bare /keybind instead of opening view - /keybind list opens KeybindsView showing all configured keybinds - --global flag forces operations on user-level settings - Cross-level duplicate detection (merged keybind lookup) - KeybindsView shows [global]/[local] origin indicator - Custom keybind matchers trigger slash actions on shortcut press - Status message timeout increased to 8s for readability
1 parent e0cc9e2 commit fb745d6

3 files changed

Lines changed: 143 additions & 30 deletions

File tree

packages/cli/src/ui/views/App.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { findExpandedThinkingId } from "../core/thinking-state";
1313
import { WelcomeScreen } from "./WelcomeScreen";
1414
import { AskUserQuestionPrompt } from "./AskUserQuestionPrompt";
1515
import { McpStatusList } from "./McpStatusList";
16+
import { KeybindsView } from "./KeybindsView";
1617
import { ProcessStdoutView } from "./ProcessStdoutView";
1718
import {
1819
type AskUserQuestionAnswers,
@@ -50,7 +51,7 @@ import { SessionManager } from "@vegamo/deepcode-core";
5051
import { getCompactPromptTokenThreshold } from "@vegamo/deepcode-core";
5152
import { writeStdout, writeStdoutLine } from "../../utils/stdio-helpers";
5253

53-
type View = "chat" | "session-list" | "undo" | "mcp-status";
54+
type View = "chat" | "session-list" | "undo" | "keybinds" | "mcp-status";
5455

5556
const STATUS_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
5657

@@ -361,6 +362,10 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp
361362
navigateToSubView("mcp-status");
362363
return;
363364
}
365+
if (submission.command === "keybind") {
366+
navigateToSubView("keybinds");
367+
return;
368+
}
364369

365370
const prompt: UserPromptContent = {
366371
text: submission.text,
@@ -945,6 +950,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp
945950
setView("chat");
946951
}}
947952
/>
953+
) : view === "keybinds" ? (
954+
<KeybindsView keybinds={resolvedSettings.keybinds} projectRoot={projectRoot} onCancel={() => setView("chat")} />
948955
) : view === "mcp-status" ? (
949956
<McpStatusList
950957
statuses={mcpStatuses}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import React from "react";
2+
import { Box, Text, useInput } from "ink";
3+
import { readSettings, readProjectSettings } from "@vegamo/deepcode-core";
4+
import type { KeybindMap, DeepcodingSettings } from "@vegamo/deepcode-core";
5+
6+
type Props = {
7+
keybinds: KeybindMap;
8+
projectRoot: string;
9+
onCancel: () => void;
10+
};
11+
12+
function getKeybindLevel(
13+
shortcut: string,
14+
userSettings: DeepcodingSettings | null,
15+
projectSettings: DeepcodingSettings | null
16+
): "local" | "global" {
17+
if (projectSettings?.keybinds?.[shortcut] !== undefined) return "local";
18+
if (userSettings?.keybinds?.[shortcut] !== undefined) return "global";
19+
return "global";
20+
}
21+
22+
export function KeybindsView({ keybinds, projectRoot, onCancel }: Props): React.ReactElement {
23+
const entries = Object.entries(keybinds);
24+
const userSettings = readSettings();
25+
const projectSettings = readProjectSettings(projectRoot);
26+
27+
useInput((_input, key) => {
28+
if (key.escape) {
29+
onCancel();
30+
}
31+
});
32+
33+
return (
34+
<Box flexDirection="column" marginLeft={1} paddingX={1} gap={1} borderStyle="round" borderDimColor>
35+
<Box>
36+
<Text color="#229ac3" bold>
37+
/keybind
38+
</Text>
39+
</Box>
40+
41+
{entries.length === 0 ? (
42+
<Box>
43+
<Text dimColor>(no keybinds configured)</Text>
44+
</Box>
45+
) : (
46+
<Box flexDirection="column">
47+
{entries.map(([shortcut, action]) => {
48+
const level = getKeybindLevel(shortcut, userSettings, projectSettings);
49+
return (
50+
<Box key={shortcut} gap={1}>
51+
<Text>{shortcut}</Text>
52+
<Text dimColor>→ /{action}</Text>
53+
<Text color={level === "local" ? "yellow" : "blue"} dimColor>
54+
[{level}]
55+
</Text>
56+
</Box>
57+
);
58+
})}
59+
</Box>
60+
)}
61+
62+
<Box flexDirection="column">
63+
<Text dimColor>/keybind add &lt;shortcut&gt; &lt;action&gt; to add</Text>
64+
<Text dimColor>/keybind --global add &lt;shortcut&gt; &lt;action&gt; (user-level)</Text>
65+
<Text dimColor>/keybind remove &lt;shortcut&gt; to remove</Text>
66+
<Text dimColor>/keybind --global remove &lt;shortcut&gt; (user-level)</Text>
67+
</Box>
68+
69+
<Text dimColor>Esc to close</Text>
70+
</Box>
71+
);
72+
}

packages/cli/src/ui/views/PromptInput.tsx

Lines changed: 63 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export type PromptSubmission = {
7474
selectedSkills?: SkillInfo[];
7575
permissions?: UserToolPermission[];
7676
alwaysAllows?: PermissionScope[];
77-
command?: "new" | "resume" | "continue" | "undo" | "mcp" | "exit";
77+
command?: "new" | "resume" | "continue" | "undo" | "mcp" | "keybind" | "exit";
7878
};
7979

8080
export type PromptDraft = {
@@ -293,7 +293,7 @@ export const PromptInput = React.memo(function PromptInput({
293293
if (!statusMessage) {
294294
return;
295295
}
296-
const timer = setTimeout(() => setStatusMessage(null), 2500);
296+
const timer = setTimeout(() => setStatusMessage(null), 8000);
297297
return () => clearTimeout(timer);
298298
}, [statusMessage]);
299299

@@ -738,7 +738,18 @@ export const PromptInput = React.memo(function PromptInput({
738738
return;
739739
}
740740
if (item.kind === "keybind") {
741-
handleKeybindCommand();
741+
const parts = buffer.text.trim().split(/\s+/);
742+
if (parts.length > 1) {
743+
// Has args: process add/remove/list subcommand
744+
handleKeybindCommand();
745+
} else if (parts[0] && parts[0] !== "/keybind") {
746+
// Partial match from menu (e.g. /key): auto-complete to /keybind
747+
setBuffer({ text: "/keybind ", cursor: "/keybind ".length });
748+
clearUndoRedoStacks();
749+
} else {
750+
// Exact match with no args: show usage hint
751+
setStatusMessage("Usage: /keybind add <shortcut> <action> | remove <shortcut> | list");
752+
}
742753
return;
743754
}
744755
if (item.kind === "exit") {
@@ -750,20 +761,19 @@ export const PromptInput = React.memo(function PromptInput({
750761
}
751762

752763
function handleKeybindCommand(): void {
753-
const parts = buffer.text.trim().split(/\s+/);
764+
const rawParts = buffer.text.trim().split(/\s+/);
765+
const isGlobal = rawParts.includes("--global");
766+
const parts = rawParts.filter((p) => p !== "--global");
754767
const subcommand = parts[1] ?? "list";
755768
const existingProjectSettings = readProjectSettings(projectRoot);
769+
const useProject = existingProjectSettings !== null;
770+
const userSettings = readSettings();
771+
const kbs = keybinds ?? {};
772+
const levelLabel = isGlobal ? " (global)" : useProject ? " (project)" : " (global)";
756773

757774
if (subcommand === "list") {
758-
clearSlashToken();
759-
const kbs = keybinds ?? {};
760-
const entries = Object.entries(kbs);
761-
if (entries.length === 0) {
762-
setStatusMessage("No custom keybinds configured. Use /keybind add <shortcut> <action>");
763-
} else {
764-
const lines = entries.map(([shortcut, action]) => ` ${shortcut} → /${action}`);
765-
setStatusMessage(`Keybinds:\n${lines.join("\n")}`);
766-
}
775+
onSubmit({ text: "/keybind list", imageUrls: [], command: "keybind" });
776+
resetPromptInput();
767777
return;
768778
}
769779

@@ -786,52 +796,76 @@ export const PromptInput = React.memo(function PromptInput({
786796
// Validate action is a known slash command or skill
787797
const knownNames = new Set(slashItems.map((s) => s.name));
788798
if (!knownNames.has(action)) {
789-
setStatusMessage(`Unknown action "/${action}". Use a slash command or skill name.`);
799+
const available = slashItems
800+
.filter((s) => s.kind !== "skill")
801+
.map((s) => s.name)
802+
.slice(0, 10)
803+
.join(", ");
804+
setStatusMessage(`Unknown action "/${action}". Available: ${available}…`);
790805
clearSlashToken();
791806
return;
792807
}
793808

794-
const useProject = existingProjectSettings !== null;
795-
const rawSettings = useProject ? existingProjectSettings : readSettings();
809+
// Check duplicates across all levels (merged)
810+
const existingMerged = kbs[shortcut];
811+
if (existingMerged === action) {
812+
setStatusMessage(`Keybind already set: ${shortcut} → /${action}`);
813+
clearSlashToken();
814+
return;
815+
}
816+
817+
// Determine write target
818+
const rawSettings = isGlobal ? userSettings : useProject ? existingProjectSettings : userSettings;
796819
const current: Record<string, string> = { ...(rawSettings?.keybinds ?? {}) };
797820
current[shortcut] = action;
798821
const updated = { ...(rawSettings ?? {}), keybinds: current };
799-
if (useProject) {
800-
writeProjectSettings(updated, projectRoot);
801-
} else {
822+
if (isGlobal || !useProject) {
802823
writeSettings(updated);
824+
} else {
825+
writeProjectSettings(updated, projectRoot);
803826
}
804827
onKeybindsChanged?.();
805-
setStatusMessage(`Keybind added: ${shortcut} → /${action}`);
828+
if (existingMerged) {
829+
setStatusMessage(`Keybind updated: ${shortcut} → /${action} (was /${existingMerged})${levelLabel}`);
830+
} else {
831+
setStatusMessage(`Keybind added: ${shortcut} → /${action}${levelLabel}`);
832+
}
806833
clearSlashToken();
807834
return;
808835
}
809836

810837
if (subcommand === "remove") {
811838
const shortcut = parts[2];
839+
const rawSettings = isGlobal ? userSettings : useProject ? existingProjectSettings : userSettings;
840+
const current: Record<string, string> = { ...(rawSettings?.keybinds ?? {}) };
841+
const existingShortcuts = Object.keys(kbs);
812842
if (!shortcut) {
813-
setStatusMessage("Usage: /keybind remove <shortcut>");
843+
if (existingShortcuts.length === 0) {
844+
setStatusMessage("No custom keybinds to remove.");
845+
} else {
846+
const list = existingShortcuts.map((s) => `${s} → /${kbs[s]}`).join(", ");
847+
setStatusMessage(`Usage: /keybind remove <shortcut>\nExisting: ${list}`);
848+
}
814849
clearSlashToken();
815850
return;
816851
}
817852

818-
const useProject = existingProjectSettings !== null;
819-
const rawSettings = useProject ? existingProjectSettings : readSettings();
820-
const current: Record<string, string> = { ...(rawSettings?.keybinds ?? {}) };
821853
if (!(shortcut in current)) {
822-
setStatusMessage(`Keybind "${shortcut}" not found.`);
854+
const list =
855+
existingShortcuts.length > 0 ? existingShortcuts.map((s) => `${s} → /${kbs[s]}`).join(", ") : "(none)";
856+
setStatusMessage(`Keybind "${shortcut}" not found${levelLabel}.\nExisting: ${list}`);
823857
clearSlashToken();
824858
return;
825859
}
826860
delete current[shortcut];
827861
const updated = { ...(rawSettings ?? {}), keybinds: current };
828-
if (useProject) {
829-
writeProjectSettings(updated, projectRoot);
830-
} else {
862+
if (isGlobal || !useProject) {
831863
writeSettings(updated);
864+
} else {
865+
writeProjectSettings(updated, projectRoot);
832866
}
833867
onKeybindsChanged?.();
834-
setStatusMessage(`Keybind removed: ${shortcut}`);
868+
setStatusMessage(`Keybind removed: ${shortcut}${levelLabel}`);
835869
clearSlashToken();
836870
return;
837871
}

0 commit comments

Comments
 (0)