Skip to content

Commit 8fda64b

Browse files
committed
feat(config): allow slash command keybinds in config
Replace Keybinds schema .strict() with .catchall() + .superRefine() to: - Allow custom keybind keys that start with '/' (slash command names) - Reject unknown keys that don't start with '/' with a clear error message - Preserve validation for all predefined keybind names Add tests for slash command keybind acceptance and rejection of invalid keys. Regenerate SDK types to include index signature for custom keybinds.
1 parent 2a15456 commit 8fda64b

File tree

4 files changed

+155
-2
lines changed

4 files changed

+155
-2
lines changed

packages/opencode/src/cli/cmd/tui/context/keybind.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
1515
const keybinds = createMemo(() => {
1616
return pipe(
1717
sync.data.config.keybinds ?? {},
18-
mapValues((value) => Keybind.parse(value)),
18+
mapValues((value) => (value ? Keybind.parse(value) : [])),
1919
)
2020
})
2121
const [store, setStore] = createStore({

packages/opencode/src/config/config.ts

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -753,7 +753,110 @@ export namespace Config {
753753
terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"),
754754
tips_toggle: z.string().optional().default("<leader>h").describe("Toggle tips on home screen"),
755755
})
756-
.strict()
756+
.catchall(z.string().optional())
757+
.superRefine((data, ctx) => {
758+
// Predefined keybind keys from the schema
759+
const predefinedKeys = new Set([
760+
"leader",
761+
"app_exit",
762+
"editor_open",
763+
"theme_list",
764+
"sidebar_toggle",
765+
"scrollbar_toggle",
766+
"username_toggle",
767+
"status_view",
768+
"session_export",
769+
"session_new",
770+
"session_list",
771+
"session_timeline",
772+
"session_fork",
773+
"session_rename",
774+
"session_share",
775+
"session_unshare",
776+
"session_interrupt",
777+
"session_compact",
778+
"messages_page_up",
779+
"messages_page_down",
780+
"messages_half_page_up",
781+
"messages_half_page_down",
782+
"messages_first",
783+
"messages_last",
784+
"messages_next",
785+
"messages_previous",
786+
"messages_last_user",
787+
"messages_copy",
788+
"messages_undo",
789+
"messages_redo",
790+
"messages_toggle_conceal",
791+
"tool_details",
792+
"model_list",
793+
"model_cycle_recent",
794+
"model_cycle_recent_reverse",
795+
"model_cycle_favorite",
796+
"model_cycle_favorite_reverse",
797+
"command_list",
798+
"agent_list",
799+
"agent_cycle",
800+
"agent_cycle_reverse",
801+
"variant_cycle",
802+
"input_clear",
803+
"input_paste",
804+
"input_submit",
805+
"input_newline",
806+
"input_move_left",
807+
"input_move_right",
808+
"input_move_up",
809+
"input_move_down",
810+
"input_select_left",
811+
"input_select_right",
812+
"input_select_up",
813+
"input_select_down",
814+
"input_line_home",
815+
"input_line_end",
816+
"input_select_line_home",
817+
"input_select_line_end",
818+
"input_visual_line_home",
819+
"input_visual_line_end",
820+
"input_select_visual_line_home",
821+
"input_select_visual_line_end",
822+
"input_buffer_home",
823+
"input_buffer_end",
824+
"input_select_buffer_home",
825+
"input_select_buffer_end",
826+
"input_delete_line",
827+
"input_delete_to_line_end",
828+
"input_delete_to_line_start",
829+
"input_backspace",
830+
"input_delete",
831+
"input_undo",
832+
"input_redo",
833+
"input_word_forward",
834+
"input_word_backward",
835+
"input_select_word_forward",
836+
"input_select_word_backward",
837+
"input_delete_word_forward",
838+
"input_delete_word_backward",
839+
"history_previous",
840+
"history_next",
841+
"session_child_cycle",
842+
"session_child_cycle_reverse",
843+
"session_parent",
844+
"terminal_suspend",
845+
"terminal_title_toggle",
846+
"tips_toggle",
847+
])
848+
849+
for (const key of Object.keys(data)) {
850+
if (!predefinedKeys.has(key) && !key.startsWith("/")) {
851+
ctx.addIssue({
852+
code: z.ZodIssueCode.unrecognized_keys,
853+
keys: [key],
854+
path: [],
855+
message: `Invalid keybind key '${key}'. Custom keybinds must start with '/' (slash command names).`,
856+
})
857+
}
858+
}
859+
})
757860
.meta({
758861
ref: "KeybindsConfig",
759862
})

packages/opencode/test/config/config.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,55 @@ test("validates config schema and throws on invalid fields", async () => {
170170
})
171171
})
172172

173+
test("accepts slash command keybinds in config", async () => {
174+
await using tmp = await tmpdir({
175+
init: async (dir) => {
176+
await Bun.write(
177+
path.join(dir, "opencode.json"),
178+
JSON.stringify({
179+
$schema: "https://opencode.ai/config.json",
180+
keybinds: {
181+
"/carry-on": "<leader>1",
182+
"/integrate-branches--proxy-opus": "<leader>2",
183+
"/fake_command": "<leader>3",
184+
},
185+
}),
186+
)
187+
},
188+
})
189+
await Instance.provide({
190+
directory: tmp.path,
191+
fn: async () => {
192+
const config = await Config.get()
193+
expect((config.keybinds as Record<string, string>)["/carry-on"]).toBe("<leader>1")
194+
expect((config.keybinds as Record<string, string>)["/integrate-branches--proxy-opus"]).toBe("<leader>2")
195+
expect((config.keybinds as Record<string, string>)["/fake_command"]).toBe("<leader>3")
196+
},
197+
})
198+
})
199+
200+
test("rejects invalid keybind keys without slash prefix", async () => {
201+
await using tmp = await tmpdir({
202+
init: async (dir) => {
203+
await Bun.write(
204+
path.join(dir, "opencode.json"),
205+
JSON.stringify({
206+
$schema: "https://opencode.ai/config.json",
207+
keybinds: {
208+
another_fake_command: "<leader>3",
209+
},
210+
}),
211+
)
212+
},
213+
})
214+
await Instance.provide({
215+
directory: tmp.path,
216+
fn: async () => {
217+
await expect(Config.get()).rejects.toThrow()
218+
},
219+
})
220+
})
221+
173222
test("throws error for invalid JSON", async () => {
174223
await using tmp = await tmpdir({
175224
init: async (dir) => {

packages/sdk/js/src/v2/gen/types.gen.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1253,6 +1253,7 @@ export type KeybindsConfig = {
12531253
* Toggle tips on home screen
12541254
*/
12551255
tips_toggle?: string
1256+
[key: string]: string | undefined
12561257
}
12571258

12581259
/**

0 commit comments

Comments
 (0)