From 2b7e33b316060e95c861206cefe1aecbf17e933a Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 19 Mar 2026 08:19:10 -0400 Subject: [PATCH 1/2] 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 --- README.md | 20 +++--- lua/opencode/config.lua | 40 ++++++------ lua/opencode/keymap.lua | 15 ++--- lua/opencode/types.lua | 1 + lua/opencode/ui/input_window.lua | 2 +- tests/unit/keymap_spec.lua | 105 +++++++++++++++++++++++++++++++ 6 files changed, 146 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index af2ff220..bbaa0c54 100644 --- a/README.md +++ b/README.md @@ -168,16 +168,16 @@ require('opencode').setup({ }, input_window = { [''] = { 'submit_input_prompt', mode = { 'n', 'i' } }, -- Submit prompt (normal mode and insert mode) - [''] = { 'close' }, -- Close UI windows - [''] = { 'cancel' }, -- Cancel opencode request while it is running + [''] = { 'close', defer_to_completion = true }, -- Close UI windows + [''] = { 'cancel', defer_to_completion = true }, -- Cancel opencode request while it is running ['~'] = { 'mention_file', mode = 'i' }, -- Pick a file and add to context. See File Mentions section ['@'] = { 'mention', mode = 'i' }, -- Insert mention (file/agent) ['/'] = { 'slash_commands', mode = 'i' }, -- Pick a command to run in the input window ['#'] = { 'context_items', mode = 'i' }, -- Manage context items (current file, selection, diagnostics, mentioned files) [''] = { 'paste_image', mode = 'i' }, -- Paste image from clipboard as attachment - [''] = { 'toggle_pane', mode = { 'n', 'i' } }, -- Toggle between input and output panes - [''] = { 'prev_prompt_history', mode = { 'n', 'i' } }, -- Navigate to previous prompt in history - [''] = { 'next_prompt_history', mode = { 'n', 'i' } }, -- Navigate to next prompt in history + [''] = { 'toggle_pane', mode = { 'n', 'i' }, defer_to_completion = true }, -- Toggle between input and output panes + [''] = { 'prev_prompt_history', mode = { 'n', 'i' }, defer_to_completion = true }, -- Navigate to previous prompt in history + [''] = { 'next_prompt_history', mode = { 'n', 'i' }, defer_to_completion = true }, -- Navigate to next prompt in history [''] = { 'switch_mode' }, -- Switch between modes (build/plan) [''] = { 'cycle_variant', mode = { 'n', 'i' } }, -- Cycle through available model variants }, @@ -368,6 +368,7 @@ Each keymap entry is a table consising of: - Or a custom function: `{ function() ... end }` - An optional mode: `{ 'toggle', mode = { 'n', 'i' } }` - An optional desc: `{'toggle', desc = 'Toggle Opencode' }` +- 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 #### Disabling Specific Keymaps @@ -661,13 +662,14 @@ Example keymap for silent add: **add_visual_selection_inline** inserts the visually selected code directly into the input buffer as a Markdown code block, prefixed with the file path: -``` +```` **`path/to/file.lua`** ```lua -``` -``` +```` + +```` The cursor is left in normal mode in the input buffer so you can type your prompt around the inserted snippet. @@ -692,7 +694,7 @@ Run a prompt in a new session using the Plan agent and disabling current file co ```vim :Opencode run new_session "Please help me plan a new feature" agent=plan context.current_file.enabled=false :Opencode run "Fix the bug in the current file" model=github-copilot/claude-sonnet-4 -``` +```` ## 👮 Permissions diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 9815756b..295b20de 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -74,26 +74,26 @@ M.defaults = { ['ods'] = { 'debug_session', desc = 'Open raw session debug view' }, }, input_window = { - [''] = { 'submit_input_prompt', mode = { 'n' }, desc = 'Submit prompt' }, - [''] = { 'submit_input_prompt', mode = { 'n', 'i' }, desc = 'Submit prompt' }, - [''] = { 'close', desc = 'Close Opencode windows' }, - [''] = { 'cancel', desc = 'Cancel running request' }, - ['~'] = { 'mention_file', mode = 'i', desc = 'Mention file in context' }, - ['@'] = { 'mention', mode = 'i', desc = 'Open mention picker' }, - ['/'] = { 'slash_commands', mode = 'i', desc = 'Open slash commands picker' }, - ['#'] = { 'context_items', mode = 'i', desc = 'Open context items picker' }, - [''] = { 'paste_image', mode = 'i', desc = 'Paste image from clipboard' }, - [''] = { 'toggle_pane', mode = { 'n' }, desc = 'Toggle input/output panes' }, - [''] = { 'prev_prompt_history', mode = { 'n', 'i' }, desc = 'Previous prompt history item' }, - [''] = { 'next_prompt_history', mode = { 'n', 'i' }, desc = 'Next prompt history item' }, - [''] = { 'switch_mode', mode = { 'n', 'i' }, desc = 'Switch agent mode' }, - [''] = { 'cycle_variant', mode = { 'n', 'i' }, desc = 'Cycle model variants' }, - [''] = { 'toggle_input', mode = { 'n', 'i' }, desc = 'Toggle input window' }, - ['gr'] = { 'references', desc = 'Browse code references' }, - ['oS'] = { 'select_child_session', desc = 'Select child session' }, - ['oD'] = { 'debug_message', desc = 'Open raw message debug view' }, - ['oO'] = { 'debug_output', desc = 'Open raw output debug view' }, - ['ods'] = { 'debug_session', desc = 'Open raw session debug view' }, + [''] = { 'submit_input_prompt', mode = { 'n' }, desc = 'Submit prompt' }, + [''] = { 'submit_input_prompt', mode = { 'n', 'i' }, desc = 'Submit prompt' }, + [''] = { 'close', desc = 'Close Opencode windows', defer_to_completion = true }, + [''] = { 'cancel', desc = 'Cancel running request' , defer_to_completion = true }, + ['~'] = { 'mention_file', mode = 'i', desc = 'Mention file in context' }, + ['@'] = { 'mention', mode = 'i', desc = 'Open mention picker' }, + ['/'] = { 'slash_commands', mode = 'i', desc = 'Open slash commands picker' }, + ['#'] = { 'context_items', mode = 'i', desc = 'Open context items picker' }, + [''] = { 'paste_image', mode = 'i', desc = 'Paste image from clipboard' }, + [''] = { 'toggle_pane', mode = { 'n' }, desc = 'Toggle input/output panes', defer_to_completion = true }, + [''] = { 'prev_prompt_history', mode = { 'n', 'i' }, desc = 'Previous prompt history item', defer_to_completion = true }, + [''] = { 'next_prompt_history', mode = { 'n', 'i' }, desc = 'Next prompt history item' , defer_to_completion = true }, + [''] = { 'switch_mode', mode = { 'n', 'i' }, desc = 'Switch agent mode' }, + [''] = { 'cycle_variant', mode = { 'n', 'i' }, desc = 'Cycle model variants' }, + [''] = { 'toggle_input', mode = { 'n', 'i' }, desc = 'Toggle input window' }, + ['gr'] = { 'references', desc = 'Browse code references' }, + ['oS'] = { 'select_child_session', desc = 'Select child session' }, + ['oD'] = { 'debug_message', desc = 'Open raw message debug view' }, + ['oO'] = { 'debug_output', desc = 'Open raw output debug view' }, + ['ods'] = { 'debug_session', desc = 'Open raw session debug view' }, }, session_picker = { rename_session = { '', desc = 'Rename selected session' }, diff --git a/lua/opencode/keymap.lua b/lua/opencode/keymap.lua index fa1f7fa4..a4a10fb7 100644 --- a/lua/opencode/keymap.lua +++ b/lua/opencode/keymap.lua @@ -1,9 +1,12 @@ +local config = require('opencode.config') local M = {} local function is_completion_visible() return require('opencode.ui.completion').is_completion_visible() end +---@param key_binding string The key binding to feed if completion is visible +---@param callback function The callback to execute if completion is not visible local function wrap_with_completion_check(key_binding, callback) return function() if is_completion_visible() then @@ -13,11 +16,10 @@ local function wrap_with_completion_check(key_binding, callback) end end ----@param keymap_config table The keymap configuration table +---@param keymap_config table The keymap configuration table ---@param default_modes table Default modes for these keymaps ---@param base_opts table Base options to use for all keymaps ----@param defer_to_completion boolean? Whether to defer to completion engine when visible -local function process_keymap_entry(keymap_config, default_modes, base_opts, defer_to_completion) +local function process_keymap_entry(keymap_config, default_modes, base_opts) local api = require('opencode.api') local cmds = api.commands @@ -41,7 +43,7 @@ local function process_keymap_entry(keymap_config, default_modes, base_opts, def opts.desc = config_entry.desc or cmds[func_name] and cmds[func_name].desc if callback then - if defer_to_completion then + if config_entry.defer_to_completion then callback = wrap_with_completion_check(key_binding, callback) end vim.keymap.set(modes, key_binding, callback, opts) @@ -61,13 +63,12 @@ end ---@param keymap_config table Window keymap configuration ---@param buf_id integer Buffer ID to set keymaps for ----@param defer_to_completion boolean? Whether to defer to completion engine when visible (default: false) -function M.setup_window_keymaps(keymap_config, buf_id, defer_to_completion) +function M.setup_window_keymaps(keymap_config, buf_id) if not vim.api.nvim_buf_is_valid(buf_id) then return end - process_keymap_entry(keymap_config or {}, { 'n' }, { silent = true, buffer = buf_id }, defer_to_completion) + process_keymap_entry(keymap_config or {}, { 'n' }, { silent = true, buffer = buf_id }) end return M diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 45d14afe..3129acaf 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -68,6 +68,7 @@ ---@field [1] string # Function name ---@field mode? string|string[] # Mode(s) for the keymap ---@field desc? string # Keymap description +---@field defer_to_completion? boolean # Whether to defer the keymap when completion menu is open ---@class OpencodeKeymapEditor : table ---@class OpencodeKeymapInputWindow : table diff --git a/lua/opencode/ui/input_window.lua b/lua/opencode/ui/input_window.lua index 05c6000e..c00697e2 100644 --- a/lua/opencode/ui/input_window.lua +++ b/lua/opencode/ui/input_window.lua @@ -473,7 +473,7 @@ function M.setup_keymaps(windows) keymaps_set_for_buf[windows.input_buf] = true local keymap = require('opencode.keymap') - keymap.setup_window_keymaps(config.keymap.input_window, windows.input_buf, true) + keymap.setup_window_keymaps(config.keymap.input_window, windows.input_buf) end function M.setup_autocmds(windows, group) diff --git a/tests/unit/keymap_spec.lua b/tests/unit/keymap_spec.lua index 9acedda2..9ef29399 100644 --- a/tests/unit/keymap_spec.lua +++ b/tests/unit/keymap_spec.lua @@ -15,11 +15,18 @@ describe('opencode.keymap', function() local mock_api local keymap + -- Mock completion module state (controlled per test) + local mock_completion + local original_nvim_feedkeys + local feedkeys_calls = {} + before_each(function() set_keymaps = {} cmd_calls = {} + feedkeys_calls = {} original_keymap_set = vim.keymap.set original_vim_cmd = vim.cmd + original_nvim_feedkeys = vim.api.nvim_feedkeys -- Mock the functions to capture calls vim.keymap.set = function(modes, key, callback, opts) @@ -35,6 +42,10 @@ describe('opencode.keymap', function() table.insert(cmd_calls, command) end + vim.api.nvim_feedkeys = function(keys, mode, escape_ks) + table.insert(feedkeys_calls, { keys = keys, mode = mode, escape_ks = escape_ks }) + end + -- Mock the API module before requiring keymap mock_api = { open_input = function() end, @@ -61,6 +72,14 @@ describe('opencode.keymap', function() local mock_config = {} package.loaded['opencode.config'] = mock_config + -- Mock the completion module (visible = false by default) + mock_completion = { + is_completion_visible = function() + return false + end, + } + package.loaded['opencode.ui.completion'] = mock_completion + -- Now require the keymap module keymap = require('opencode.keymap') end) @@ -69,12 +88,14 @@ describe('opencode.keymap', function() -- Restore original functions vim.keymap.set = original_keymap_set vim.cmd = original_vim_cmd + vim.api.nvim_feedkeys = original_nvim_feedkeys -- Clean up package loading package.loaded['opencode.keymap'] = nil package.loaded['opencode.api'] = nil package.loaded['opencode.state'] = nil package.loaded['opencode.config'] = nil + package.loaded['opencode.ui.completion'] = nil end) describe('normalize_keymap', function() @@ -226,4 +247,88 @@ describe('opencode.keymap', function() vim.api.nvim_buf_delete(bufnr, { force = true }) end) end) + + describe('defer_to_completion', function() + it('calls the callback directly when completion is not visible', function() + local callback_called = false + local test_keymap = { + editor = { + [''] = { + function() + callback_called = true + end, + defer_to_completion = true, + desc = 'Tab with completion defer', + }, + }, + } + + keymap.setup(test_keymap) + + assert.equal(1, #set_keymaps, 'Should register one keymap') + local registered = set_keymaps[1] + + registered.callback() + + assert.is_true(callback_called, 'Callback should be called when completion is not visible') + assert.equal(0, #feedkeys_calls, 'Should not feed keys when completion is not visible') + end) + + it('feeds the key binding when completion is visible instead of calling the callback', function() + mock_completion.is_completion_visible = function() + return true + end + + local callback_called = false + local test_keymap = { + editor = { + [''] = { + function() + callback_called = true + end, + defer_to_completion = true, + desc = 'Tab with completion defer', + }, + }, + } + + keymap.setup(test_keymap) + + assert.equal(1, #set_keymaps, 'Should register one keymap') + local registered = set_keymaps[1] + + registered.callback() + + assert.is_false(callback_called, 'Callback should NOT be called when completion is visible') + assert.equal(1, #feedkeys_calls, 'Should feed the key binding to the completion engine') + end) + + it('does not wrap with completion check when defer_to_completion is not set', function() + local callback_called = false + local test_keymap = { + editor = { + [''] = { + function() + callback_called = true + end, + desc = 'Tab without defer', + }, + }, + } + + mock_completion.is_completion_visible = function() + return true + end + + keymap.setup(test_keymap) + + assert.equal(1, #set_keymaps, 'Should register one keymap') + local registered = set_keymaps[1] + + registered.callback() + + assert.is_true(callback_called, 'Callback should be called directly without completion check') + assert.equal(0, #feedkeys_calls, 'Should not feed keys when defer_to_completion is not set') + end) + end) end) From 066dd1ca78637b34bc9e86c39e6176a6091d1bf2 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 20 Mar 2026 10:50:47 -0400 Subject: [PATCH 2/2] fix(ui/input_window): allow submission when completion popup is visible Remove guard that returned early when the completion menu was visible --- lua/opencode/ui/input_window.lua | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lua/opencode/ui/input_window.lua b/lua/opencode/ui/input_window.lua index c00697e2..d4af73ed 100644 --- a/lua/opencode/ui/input_window.lua +++ b/lua/opencode/ui/input_window.lua @@ -121,10 +121,6 @@ function M.handle_submit() return false end ---@cast windows { input_buf: integer } - local completion = require('opencode.ui.completion') - if completion.is_completion_visible() then - return false - end local input_content = table.concat(vim.api.nvim_buf_get_lines(windows.input_buf, 0, -1, false), '\n') vim.api.nvim_buf_set_lines(windows.input_buf, 0, -1, false, {})