diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 4b177e292cf..643b4470cf5 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -18,7 +18,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" @@ -36,6 +36,7 @@ import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" import { writeHeapSnapshot } from "v8" 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 @@ -195,6 +196,7 @@ function App() { const sync = useSync() const exit = useExit() const promptRef = usePromptRef() + const keybind = useKeybind() // Wire up console copy-to-clipboard via opentui's onCopySelection callback renderer.console.onCopySelection = async (text: string) => { @@ -572,6 +574,57 @@ 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 + if (!value) 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 + } + + // 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: commandInput, + parts: current.current.parts, + }) + current.submit() + } + + return + } + } + } + }) + createEffect(() => { const currentModel = local.model.current() if (!currentModel) return 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({ diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 020e626cba8..71234484497 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -793,7 +793,7 @@ export namespace Config { terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"), tips_toggle: z.string().optional().default("h").describe("Toggle tips on home screen"), }) - .strict() + .catchall(z.string()) .meta({ ref: "KeybindsConfig", }) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index decd18446c1..521158d36f2 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1078,11 +1078,14 @@ test("permission config preserves key order", async () => { fn: async () => { const config = await Config.get() expect(Object.keys(config.permission!)).toEqual([ + "doom_loop", + "external_directory", + "read", + "webfetch", + "bash", "*", "edit", "write", - "external_directory", - "read", "todowrite", "todoread", "thoughts_*", diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 67e7ac80cb9..4fab38bd749 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -2518,17 +2518,7 @@ export class Control extends HeyApiClient { }, options?: Options, ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { key: "body", map: "body" }, - ], - }, - ], - ) + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }, { in: "body" }] }]) return (options?.client ?? this.client).post({ url: "/tui/control/response", ...options, @@ -2780,17 +2770,7 @@ export class Tui extends HeyApiClient { }, options?: Options, ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { key: "body", map: "body" }, - ], - }, - ], - ) + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }, { in: "body" }] }]) return (options?.client ?? this.client).post({ url: "/tui/publish", ...options, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 38a52b325ad..b0acbd1f3d6 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1321,6 +1321,7 @@ export type KeybindsConfig = { * Toggle tips on home screen */ tips_toggle?: string + [key: string]: string | undefined } /**