Skip to content

Commit 2b7e33b

Browse files
committed
feat(keymap): defer keymaps to completion menu
Add support for deferring keymap actions to the completion engine when the completion menu is visible. Introduces defer_to_completion field for keymap entries, wraps callbacks to feed the original key sequence to the completion module when visible, updates defaults and README, and adds unit tests and types for the new behavior. This should fix #332
1 parent 2a3203f commit 2b7e33b

File tree

6 files changed

+146
-37
lines changed

6 files changed

+146
-37
lines changed

README.md

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -168,16 +168,16 @@ require('opencode').setup({
168168
},
169169
input_window = {
170170
['<S-cr>'] = { 'submit_input_prompt', mode = { 'n', 'i' } }, -- Submit prompt (normal mode and insert mode)
171-
['<esc>'] = { 'close' }, -- Close UI windows
172-
['<C-c>'] = { 'cancel' }, -- Cancel opencode request while it is running
171+
['<esc>'] = { 'close', defer_to_completion = true }, -- Close UI windows
172+
['<C-c>'] = { 'cancel', defer_to_completion = true }, -- Cancel opencode request while it is running
173173
['~'] = { 'mention_file', mode = 'i' }, -- Pick a file and add to context. See File Mentions section
174174
['@'] = { 'mention', mode = 'i' }, -- Insert mention (file/agent)
175175
['/'] = { 'slash_commands', mode = 'i' }, -- Pick a command to run in the input window
176176
['#'] = { 'context_items', mode = 'i' }, -- Manage context items (current file, selection, diagnostics, mentioned files)
177177
['<M-v>'] = { 'paste_image', mode = 'i' }, -- Paste image from clipboard as attachment
178-
['<tab>'] = { 'toggle_pane', mode = { 'n', 'i' } }, -- Toggle between input and output panes
179-
['<up>'] = { 'prev_prompt_history', mode = { 'n', 'i' } }, -- Navigate to previous prompt in history
180-
['<down>'] = { 'next_prompt_history', mode = { 'n', 'i' } }, -- Navigate to next prompt in history
178+
['<tab>'] = { 'toggle_pane', mode = { 'n', 'i' }, defer_to_completion = true }, -- Toggle between input and output panes
179+
['<up>'] = { 'prev_prompt_history', mode = { 'n', 'i' }, defer_to_completion = true }, -- Navigate to previous prompt in history
180+
['<down>'] = { 'next_prompt_history', mode = { 'n', 'i' }, defer_to_completion = true }, -- Navigate to next prompt in history
181181
['<M-m>'] = { 'switch_mode' }, -- Switch between modes (build/plan)
182182
['<M-r>'] = { 'cycle_variant', mode = { 'n', 'i' } }, -- Cycle through available model variants
183183
},
@@ -368,6 +368,7 @@ Each keymap entry is a table consising of:
368368
- Or a custom function: `{ function() ... end }`
369369
- An optional mode: `{ 'toggle', mode = { 'n', 'i' } }`
370370
- An optional desc: `{'toggle', desc = 'Toggle Opencode' }`
371+
- An optional defer_to_completion: `{'toggle', defer_to_completion = true }` if true, when completion menu is open, it will defer to the completion keymaps instead of triggering the action
371372

372373
#### Disabling Specific Keymaps
373374

@@ -661,13 +662,14 @@ Example keymap for silent add:
661662

662663
**add_visual_selection_inline** inserts the visually selected code directly into the input buffer as a Markdown code block, prefixed with the file path:
663664

664-
```
665+
````
665666
**`path/to/file.lua`**
666667
667668
```lua
668669
<selected text>
669-
```
670-
```
670+
````
671+
672+
````
671673
672674
The cursor is left in normal mode in the input buffer so you can type your prompt around the inserted snippet.
673675
@@ -692,7 +694,7 @@ Run a prompt in a new session using the Plan agent and disabling current file co
692694
```vim
693695
:Opencode run new_session "Please help me plan a new feature" agent=plan context.current_file.enabled=false
694696
:Opencode run "Fix the bug in the current file" model=github-copilot/claude-sonnet-4
695-
```
697+
````
696698

697699
## 👮 Permissions
698700

lua/opencode/config.lua

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -74,26 +74,26 @@ M.defaults = {
7474
['<leader>ods'] = { 'debug_session', desc = 'Open raw session debug view' },
7575
},
7676
input_window = {
77-
['<cr>'] = { 'submit_input_prompt', mode = { 'n' }, desc = 'Submit prompt' },
78-
['<S-cr>'] = { 'submit_input_prompt', mode = { 'n', 'i' }, desc = 'Submit prompt' },
79-
['<esc>'] = { 'close', desc = 'Close Opencode windows' },
80-
['<C-c>'] = { 'cancel', desc = 'Cancel running request' },
81-
['~'] = { 'mention_file', mode = 'i', desc = 'Mention file in context' },
82-
['@'] = { 'mention', mode = 'i', desc = 'Open mention picker' },
83-
['/'] = { 'slash_commands', mode = 'i', desc = 'Open slash commands picker' },
84-
['#'] = { 'context_items', mode = 'i', desc = 'Open context items picker' },
85-
['<M-v>'] = { 'paste_image', mode = 'i', desc = 'Paste image from clipboard' },
86-
['<tab>'] = { 'toggle_pane', mode = { 'n' }, desc = 'Toggle input/output panes' },
87-
['<up>'] = { 'prev_prompt_history', mode = { 'n', 'i' }, desc = 'Previous prompt history item' },
88-
['<down>'] = { 'next_prompt_history', mode = { 'n', 'i' }, desc = 'Next prompt history item' },
89-
['<M-m>'] = { 'switch_mode', mode = { 'n', 'i' }, desc = 'Switch agent mode' },
90-
['<M-r>'] = { 'cycle_variant', mode = { 'n', 'i' }, desc = 'Cycle model variants' },
91-
['<M-i>'] = { 'toggle_input', mode = { 'n', 'i' }, desc = 'Toggle input window' },
92-
['gr'] = { 'references', desc = 'Browse code references' },
93-
['<leader>oS'] = { 'select_child_session', desc = 'Select child session' },
94-
['<leader>oD'] = { 'debug_message', desc = 'Open raw message debug view' },
95-
['<leader>oO'] = { 'debug_output', desc = 'Open raw output debug view' },
96-
['<leader>ods'] = { 'debug_session', desc = 'Open raw session debug view' },
77+
['<cr>'] = { 'submit_input_prompt', mode = { 'n' }, desc = 'Submit prompt' },
78+
['<S-cr>'] = { 'submit_input_prompt', mode = { 'n', 'i' }, desc = 'Submit prompt' },
79+
['<esc>'] = { 'close', desc = 'Close Opencode windows', defer_to_completion = true },
80+
['<C-c>'] = { 'cancel', desc = 'Cancel running request' , defer_to_completion = true },
81+
['~'] = { 'mention_file', mode = 'i', desc = 'Mention file in context' },
82+
['@'] = { 'mention', mode = 'i', desc = 'Open mention picker' },
83+
['/'] = { 'slash_commands', mode = 'i', desc = 'Open slash commands picker' },
84+
['#'] = { 'context_items', mode = 'i', desc = 'Open context items picker' },
85+
['<M-v>'] = { 'paste_image', mode = 'i', desc = 'Paste image from clipboard' },
86+
['<tab>'] = { 'toggle_pane', mode = { 'n' }, desc = 'Toggle input/output panes', defer_to_completion = true },
87+
['<up>'] = { 'prev_prompt_history', mode = { 'n', 'i' }, desc = 'Previous prompt history item', defer_to_completion = true },
88+
['<down>'] = { 'next_prompt_history', mode = { 'n', 'i' }, desc = 'Next prompt history item' , defer_to_completion = true },
89+
['<M-m>'] = { 'switch_mode', mode = { 'n', 'i' }, desc = 'Switch agent mode' },
90+
['<M-r>'] = { 'cycle_variant', mode = { 'n', 'i' }, desc = 'Cycle model variants' },
91+
['<M-i>'] = { 'toggle_input', mode = { 'n', 'i' }, desc = 'Toggle input window' },
92+
['gr'] = { 'references', desc = 'Browse code references' },
93+
['<leader>oS'] = { 'select_child_session', desc = 'Select child session' },
94+
['<leader>oD'] = { 'debug_message', desc = 'Open raw message debug view' },
95+
['<leader>oO'] = { 'debug_output', desc = 'Open raw output debug view' },
96+
['<leader>ods'] = { 'debug_session', desc = 'Open raw session debug view' },
9797
},
9898
session_picker = {
9999
rename_session = { '<C-r>', desc = 'Rename selected session' },

lua/opencode/keymap.lua

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
local config = require('opencode.config')
12
local M = {}
23

34
local function is_completion_visible()
45
return require('opencode.ui.completion').is_completion_visible()
56
end
67

8+
---@param key_binding string The key binding to feed if completion is visible
9+
---@param callback function The callback to execute if completion is not visible
710
local function wrap_with_completion_check(key_binding, callback)
811
return function()
912
if is_completion_visible() then
@@ -13,11 +16,10 @@ local function wrap_with_completion_check(key_binding, callback)
1316
end
1417
end
1518

16-
---@param keymap_config table The keymap configuration table
19+
---@param keymap_config table<string, OpencodeKeymapEntry> The keymap configuration table
1720
---@param default_modes table Default modes for these keymaps
1821
---@param base_opts table Base options to use for all keymaps
19-
---@param defer_to_completion boolean? Whether to defer to completion engine when visible
20-
local function process_keymap_entry(keymap_config, default_modes, base_opts, defer_to_completion)
22+
local function process_keymap_entry(keymap_config, default_modes, base_opts)
2123
local api = require('opencode.api')
2224
local cmds = api.commands
2325

@@ -41,7 +43,7 @@ local function process_keymap_entry(keymap_config, default_modes, base_opts, def
4143
opts.desc = config_entry.desc or cmds[func_name] and cmds[func_name].desc
4244

4345
if callback then
44-
if defer_to_completion then
46+
if config_entry.defer_to_completion then
4547
callback = wrap_with_completion_check(key_binding, callback)
4648
end
4749
vim.keymap.set(modes, key_binding, callback, opts)
@@ -61,13 +63,12 @@ end
6163

6264
---@param keymap_config table Window keymap configuration
6365
---@param buf_id integer Buffer ID to set keymaps for
64-
---@param defer_to_completion boolean? Whether to defer to completion engine when visible (default: false)
65-
function M.setup_window_keymaps(keymap_config, buf_id, defer_to_completion)
66+
function M.setup_window_keymaps(keymap_config, buf_id)
6667
if not vim.api.nvim_buf_is_valid(buf_id) then
6768
return
6869
end
6970

70-
process_keymap_entry(keymap_config or {}, { 'n' }, { silent = true, buffer = buf_id }, defer_to_completion)
71+
process_keymap_entry(keymap_config or {}, { 'n' }, { silent = true, buffer = buf_id })
7172
end
7273

7374
return M

lua/opencode/types.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
---@field [1] string # Function name
6969
---@field mode? string|string[] # Mode(s) for the keymap
7070
---@field desc? string # Keymap description
71+
---@field defer_to_completion? boolean # Whether to defer the keymap when completion menu is open
7172

7273
---@class OpencodeKeymapEditor : table<string, OpencodeKeymapEntry>
7374
---@class OpencodeKeymapInputWindow : table<string, OpencodeKeymapEntry>

lua/opencode/ui/input_window.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,7 @@ function M.setup_keymaps(windows)
473473
keymaps_set_for_buf[windows.input_buf] = true
474474

475475
local keymap = require('opencode.keymap')
476-
keymap.setup_window_keymaps(config.keymap.input_window, windows.input_buf, true)
476+
keymap.setup_window_keymaps(config.keymap.input_window, windows.input_buf)
477477
end
478478

479479
function M.setup_autocmds(windows, group)

tests/unit/keymap_spec.lua

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,18 @@ describe('opencode.keymap', function()
1515
local mock_api
1616
local keymap
1717

18+
-- Mock completion module state (controlled per test)
19+
local mock_completion
20+
local original_nvim_feedkeys
21+
local feedkeys_calls = {}
22+
1823
before_each(function()
1924
set_keymaps = {}
2025
cmd_calls = {}
26+
feedkeys_calls = {}
2127
original_keymap_set = vim.keymap.set
2228
original_vim_cmd = vim.cmd
29+
original_nvim_feedkeys = vim.api.nvim_feedkeys
2330

2431
-- Mock the functions to capture calls
2532
vim.keymap.set = function(modes, key, callback, opts)
@@ -35,6 +42,10 @@ describe('opencode.keymap', function()
3542
table.insert(cmd_calls, command)
3643
end
3744

45+
vim.api.nvim_feedkeys = function(keys, mode, escape_ks)
46+
table.insert(feedkeys_calls, { keys = keys, mode = mode, escape_ks = escape_ks })
47+
end
48+
3849
-- Mock the API module before requiring keymap
3950
mock_api = {
4051
open_input = function() end,
@@ -61,6 +72,14 @@ describe('opencode.keymap', function()
6172
local mock_config = {}
6273
package.loaded['opencode.config'] = mock_config
6374

75+
-- Mock the completion module (visible = false by default)
76+
mock_completion = {
77+
is_completion_visible = function()
78+
return false
79+
end,
80+
}
81+
package.loaded['opencode.ui.completion'] = mock_completion
82+
6483
-- Now require the keymap module
6584
keymap = require('opencode.keymap')
6685
end)
@@ -69,12 +88,14 @@ describe('opencode.keymap', function()
6988
-- Restore original functions
7089
vim.keymap.set = original_keymap_set
7190
vim.cmd = original_vim_cmd
91+
vim.api.nvim_feedkeys = original_nvim_feedkeys
7292

7393
-- Clean up package loading
7494
package.loaded['opencode.keymap'] = nil
7595
package.loaded['opencode.api'] = nil
7696
package.loaded['opencode.state'] = nil
7797
package.loaded['opencode.config'] = nil
98+
package.loaded['opencode.ui.completion'] = nil
7899
end)
79100

80101
describe('normalize_keymap', function()
@@ -226,4 +247,88 @@ describe('opencode.keymap', function()
226247
vim.api.nvim_buf_delete(bufnr, { force = true })
227248
end)
228249
end)
250+
251+
describe('defer_to_completion', function()
252+
it('calls the callback directly when completion is not visible', function()
253+
local callback_called = false
254+
local test_keymap = {
255+
editor = {
256+
['<tab>'] = {
257+
function()
258+
callback_called = true
259+
end,
260+
defer_to_completion = true,
261+
desc = 'Tab with completion defer',
262+
},
263+
},
264+
}
265+
266+
keymap.setup(test_keymap)
267+
268+
assert.equal(1, #set_keymaps, 'Should register one keymap')
269+
local registered = set_keymaps[1]
270+
271+
registered.callback()
272+
273+
assert.is_true(callback_called, 'Callback should be called when completion is not visible')
274+
assert.equal(0, #feedkeys_calls, 'Should not feed keys when completion is not visible')
275+
end)
276+
277+
it('feeds the key binding when completion is visible instead of calling the callback', function()
278+
mock_completion.is_completion_visible = function()
279+
return true
280+
end
281+
282+
local callback_called = false
283+
local test_keymap = {
284+
editor = {
285+
['<tab>'] = {
286+
function()
287+
callback_called = true
288+
end,
289+
defer_to_completion = true,
290+
desc = 'Tab with completion defer',
291+
},
292+
},
293+
}
294+
295+
keymap.setup(test_keymap)
296+
297+
assert.equal(1, #set_keymaps, 'Should register one keymap')
298+
local registered = set_keymaps[1]
299+
300+
registered.callback()
301+
302+
assert.is_false(callback_called, 'Callback should NOT be called when completion is visible')
303+
assert.equal(1, #feedkeys_calls, 'Should feed the key binding to the completion engine')
304+
end)
305+
306+
it('does not wrap with completion check when defer_to_completion is not set', function()
307+
local callback_called = false
308+
local test_keymap = {
309+
editor = {
310+
['<tab>'] = {
311+
function()
312+
callback_called = true
313+
end,
314+
desc = 'Tab without defer',
315+
},
316+
},
317+
}
318+
319+
mock_completion.is_completion_visible = function()
320+
return true
321+
end
322+
323+
keymap.setup(test_keymap)
324+
325+
assert.equal(1, #set_keymaps, 'Should register one keymap')
326+
local registered = set_keymaps[1]
327+
328+
registered.callback()
329+
330+
assert.is_true(callback_called, 'Callback should be called directly without completion check')
331+
assert.equal(0, #feedkeys_calls, 'Should not feed keys when defer_to_completion is not set')
332+
end)
333+
end)
229334
end)

0 commit comments

Comments
 (0)