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..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, {}) @@ -473,7 +469,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)