From 281e58be7c28445023a5c52cd3b31dbc17cb8bb6 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Wed, 17 Dec 2025 12:53:18 -0500 Subject: [PATCH 1/4] feat: add keybind support for custom slash commands Allow users to bind custom slash commands to keystrokes by adding entries to the keybinds config with keys starting with '/'. Changes: - Update Config.Keybinds schema to accept arbitrary keys using catchall - Add keyboard handler in TUI to match and execute command keybinds - Regenerate TypeScript SDK to reflect updated KeybindsConfig type When a command keybind is pressed, the prompt is set to the command text and immediately submitted, executing the custom command. Example usage in config.json: { "keybinds": { "/my-command": "ctrl+shift+m" } } Resolves: #47 --- packages/opencode/src/cli/cmd/tui/app.tsx | 50 ++++++++++++++++++++++- packages/opencode/src/config/config.ts | 2 +- packages/sdk/js/src/v2/gen/types.gen.ts | 1 + 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index a1a8a5e80d1..80f01b91145 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -19,7 +19,7 @@ import { DialogHelp } from "./ui/dialog-help" import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" import { DialogAgent } from "@tui/component/dialog-agent" import { DialogSessionList } from "@tui/component/dialog-session-list" -import { KeybindProvider } from "@tui/context/keybind" +import { KeybindProvider, useKeybind } from "@tui/context/keybind" import { ThemeProvider, useTheme } from "@tui/context/theme" import { Home } from "@tui/routes/home" import { Session } from "@tui/routes/session" @@ -34,6 +34,7 @@ import { Provider } from "@/provider/provider" import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" import { PromptRefProvider, usePromptRef } from "./context/prompt" +import { Keybind } from "@/util/keybind" async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { // can't set raw mode if not a TTY @@ -167,6 +168,8 @@ function App() { const sync = useSync() const exit = useExit() const promptRef = usePromptRef() + const keybind = useKeybind() + const sdk = useSDK() createEffect(() => { console.log(JSON.stringify(route.data)) @@ -445,6 +448,51 @@ function App() { }, ]) + // Handle custom command keybinds + useKeyboard((evt) => { + if (command.suspended()) return + if (dialog.stack.length > 0) return + if (evt.defaultPrevented) return + + const keybinds = sync.data.config.keybinds ?? {} + for (const [key, value] of Object.entries(keybinds)) { + if (!key.startsWith("/")) continue + + const commandName = key.slice(1) + const commandKeybinds = Keybind.parse(value) + const parsed = keybind.parse(evt) + + for (const kb of commandKeybinds) { + if (Keybind.match(kb, parsed)) { + evt.preventDefault() + + // Find the command to verify it exists + const cmd = sync.data.command.find((c) => c.name === commandName) + if (!cmd) { + toast.show({ + variant: "error", + message: `Command not found: ${commandName}`, + duration: 3000, + }) + return + } + + // Set prompt to command and submit + const current = promptRef.current + if (current) { + current.set({ + input: `/${commandName}`, + parts: [], + }) + current.submit() + } + + return + } + } + } + }) + createEffect(() => { const currentModel = local.model.current() if (!currentModel) return diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 9086f70ce62..1a22811ba9d 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -560,7 +560,7 @@ export namespace Config { session_child_cycle_reverse: z.string().optional().default("left").describe("Previous child session"), terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"), }) - .strict() + .catchall(z.string()) .meta({ ref: "KeybindsConfig", }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 00f209c6d88..1bc704f0e43 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1110,6 +1110,7 @@ export type KeybindsConfig = { * Suspend terminal */ terminal_suspend?: string + [key: string]: string | undefined } export type AgentConfig = { From 326718da05f57bece3e22a062a7def1305f61f70 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Wed, 17 Dec 2025 12:54:00 -0500 Subject: [PATCH 2/4] fix: handle undefined values in command keybind parsing Add null checks to prevent TypeScript errors when keybind values are undefined in the config. --- packages/opencode/src/cli/cmd/tui/app.tsx | 1 + packages/opencode/src/cli/cmd/tui/context/keybind.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 80f01b91145..7f5c1fd9dbd 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -457,6 +457,7 @@ function App() { const keybinds = sync.data.config.keybinds ?? {} for (const [key, value] of Object.entries(keybinds)) { if (!key.startsWith("/")) continue + if (!value) continue const commandName = key.slice(1) const commandKeybinds = Keybind.parse(value) diff --git a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx index 4c82e594c3e..10766115a8c 100644 --- a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx @@ -15,7 +15,7 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex const keybinds = createMemo(() => { return pipe( sync.data.config.keybinds ?? {}, - mapValues((value) => Keybind.parse(value)), + mapValues((value) => (value ? Keybind.parse(value) : [])), ) }) const [store, setStore] = createStore({ From ac4ba7f1986f908f44a2d7e7a8a65345afc1969d Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Thu, 18 Dec 2025 08:16:11 -0500 Subject: [PATCH 3/4] feat: preserve prompt text as command arguments in keybind execution When a command keybind is triggered, preserve any existing text in the prompt input box and append it as arguments to the command. Example: If the prompt contains 'blah blah abrahadabra' and the user presses a key bound to /foo, the prompt becomes '/foo blah blah abrahadabra' before submission, passing the original text as arguments to the command. This makes command keybinds more flexible and allows users to quickly apply commands to text they've already typed. --- packages/opencode/src/cli/cmd/tui/app.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 7f5c1fd9dbd..db099a18102 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -478,12 +478,17 @@ function App() { return } - // Set prompt to command and submit + // Preserve existing prompt text as command arguments const current = promptRef.current if (current) { + const existingInput = current.current.input.trim() + const commandInput = existingInput + ? `/${commandName} ${existingInput}` + : `/${commandName}` + current.set({ - input: `/${commandName}`, - parts: [], + input: commandInput, + parts: current.current.parts, }) current.submit() } From 7a283f583b03042a99d49765d3e8bb32e43a81fa Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Sun, 21 Dec 2025 11:04:47 -0500 Subject: [PATCH 4/4] fix: remove unused sdk variable in app.tsx --- packages/opencode/src/cli/cmd/tui/app.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 650676be706..302b1b70445 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -177,7 +177,6 @@ function App() { const exit = useExit() const promptRef = usePromptRef() const keybind = useKeybind() - const sdk = useSDK() const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))