diff --git a/README.md b/README.md index 4bc8d086..1615f70a 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ require('opencode').setup({ ['op'] = { 'configure_provider' }, -- Quick provider and model switch from predefined list ['oV'] = { 'configure_variant' }, -- Switch model variant for the current model ['oy'] = { 'add_visual_selection', mode = {'v'} }, + ['oY'] = { 'add_visual_selection_inline', mode = {'v'} }, -- Insert visual selection as inline code block in the input buffer ['oz'] = { 'toggle_zoom' }, -- Zoom in/out on the Opencode windows ['ov'] = { 'paste_image'}, -- Paste image from clipboard into current session ['od'] = { 'diff_open' }, -- Opens a diff tab of a modified file since the last opencode prompt @@ -645,6 +646,7 @@ The plugin provides the following actions that can be triggered via keymaps, com | Toggle reasoning output (thinking steps) | `otr` | `:Opencode toggle_reasoning_output` | `require('opencode.api').toggle_reasoning_output()` | | Open a quick chat input with selection/current line context | `o/` | `:Opencode quick_chat` | `require('opencode.api').quick_chat()` | | Add visual selection to context | `oy` | `:Opencode add_visual_selection` | `require('opencode.api').add_visual_selection(opts?)` | +| Insert visual selection inline into input | `oY` | `:Opencode add_visual_selection_inline` | `require('opencode.api').add_visual_selection_inline(opts?)` | **add_visual_selection opts:** @@ -653,9 +655,21 @@ The plugin provides the following actions that can be triggered via keymaps, com Example keymap for silent add: ```lua -['oY'] = { 'add_visual_selection', { open_input = false }, mode = {'v'} } +['oy'] = { 'add_visual_selection', { open_input = false }, mode = {'v'} } ``` +**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. + ### Run opts You can pass additional options when running a prompt via command or API: diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 212c1aa8..41be4dde 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -1085,6 +1085,29 @@ M.add_visual_selection = Promise.async( end ) +M.add_visual_selection_inline = Promise.async( + ---@param opts? {open_input?: boolean} + ---@param range OpencodeSelectionRange + function(opts, range) + opts = vim.tbl_extend('force', { open_input = true }, opts or {}) + local context = require('opencode.context') + local text = context.build_inline_selection_text(range) + + if not text then + return + end + + M.open_input():await() + local input_window = require('opencode.ui.input_window') + input_window._append_to_input(text) + vim.schedule(function() + if vim.fn.mode() ~= 'n' then + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'n', false) + end + end) + end +) + ---@type table M.commands = { open = { @@ -1453,6 +1476,11 @@ M.commands = { desc = 'Add current visual selection to context', fn = M.add_visual_selection, }, + + add_visual_selection_inline = { + desc = 'Insert visual selection as inline code block in the input buffer', + fn = M.add_visual_selection_inline, + }, } M.slash_commands_map = { diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 5053848c..1ae97d38 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -40,6 +40,7 @@ M.defaults = { ['op'] = { 'configure_provider', desc = 'Configure provider' }, ['oV'] = { 'configure_variant', desc = 'Configure model variant' }, ['oy'] = { 'add_visual_selection', mode = { 'v' }, desc = 'Add visual selection to context' }, + ['oY'] = { 'add_visual_selection_inline', mode = { 'v' }, desc = 'Insert visual selection inline into input' }, ['oz'] = { 'toggle_zoom', desc = 'Toggle zoom' }, ['ov'] = { 'paste_image', desc = 'Paste image from clipboard' }, ['od'] = { 'diff_open', desc = 'Open diff view' }, diff --git a/lua/opencode/context.lua b/lua/opencode/context.lua index d7804e6c..4cdc73f1 100644 --- a/lua/opencode/context.lua +++ b/lua/opencode/context.lua @@ -165,6 +165,48 @@ function M.add_visual_selection(range) return true end +--- Captures the current visual selection and returns the text to be inserted inline +--- into the opencode input buffer, in the form: +--- +--- **`path/to/file`** +--- +--- ``` +--- +--- ``` +--- +---@param range? OpencodeSelectionRange +---@return string|nil text The formatted text to insert, or nil on failure +function M.build_inline_selection_text(range) + local buf = vim.api.nvim_get_current_buf() + + if not util.is_buf_a_file(buf) then + vim.notify('Cannot add selection: not a file buffer', vim.log.levels.WARN) + return nil + end + + local current_selection = BaseContext.get_current_selection(nil, range) + if not current_selection then + vim.notify('No visual selection found', vim.log.levels.WARN) + return nil + end + + local file = BaseContext.get_current_file_for_selection(buf) + if not file then + vim.notify('Cannot determine file for selection', vim.log.levels.WARN) + return nil + end + + local filetype = vim.bo[buf].filetype or '' + local text = string.format( + '**`%s`**\n\n```%s\n%s\n```', + file.path, + filetype, + current_selection.text + ) + + return text +end + function M.add_file(file) local is_file = vim.fn.filereadable(file) == 1 local is_dir = vim.fn.isdirectory(file) == 1 diff --git a/tests/unit/context_spec.lua b/tests/unit/context_spec.lua index 4da6c66d..7f12bb4e 100644 --- a/tests/unit/context_spec.lua +++ b/tests/unit/context_spec.lua @@ -1140,3 +1140,158 @@ describe('add_visual_selection API', function() vim.notify = original_notify end) end) + +describe('build_inline_selection_text', function() + local context + local BaseContext + local util + + before_each(function() + context = require('opencode.context') + BaseContext = require('opencode.context.base_context') + util = require('opencode.util') + end) + + it('should return formatted inline text for a visual selection', function() + local original_is_buf_a_file = util.is_buf_a_file + local original_get_current_selection = BaseContext.get_current_selection + local original_get_current_file_for_selection = BaseContext.get_current_file_for_selection + local original_get_current_buf = vim.api.nvim_get_current_buf + + util.is_buf_a_file = function() + return true + end + BaseContext.get_current_selection = function() + return { text = 'function foo()\n return 42\nend', lines = '10, 12' } + end + BaseContext.get_current_file_for_selection = function() + return { path = '/tmp/test.lua', name = 'test.lua', extension = 'lua' } + end + vim.api.nvim_get_current_buf = function() + return 5 + end + + -- Mock vim.bo to return a filetype + local original_bo = vim.bo + vim.bo = setmetatable({}, { + __index = function(_, buf) + return { filetype = 'lua' } + end, + }) + + local text = context.build_inline_selection_text() + + assert.is_not_nil(text) + assert.is_not_nil(text:match('%*%*`/tmp/test%.lua`%*%*')) + assert.is_not_nil(text:match('```lua')) + assert.is_not_nil(text:match('function foo%(%)')) + assert.is_not_nil(text:match('```$')) + + util.is_buf_a_file = original_is_buf_a_file + BaseContext.get_current_selection = original_get_current_selection + BaseContext.get_current_file_for_selection = original_get_current_file_for_selection + vim.api.nvim_get_current_buf = original_get_current_buf + vim.bo = original_bo + end) + + it('should return nil and notify when not a file buffer', function() + local original_is_buf_a_file = util.is_buf_a_file + local original_get_current_buf = vim.api.nvim_get_current_buf + + util.is_buf_a_file = function() + return false + end + vim.api.nvim_get_current_buf = function() + return 10 + end + + local original_notify = vim.notify + local notifications = {} + vim.notify = function(msg, level) + table.insert(notifications, { msg = msg, level = level }) + end + + local text = context.build_inline_selection_text() + + assert.is_nil(text) + assert.equal(1, #notifications) + assert.equal('Cannot add selection: not a file buffer', notifications[1].msg) + assert.equal(vim.log.levels.WARN, notifications[1].level) + + util.is_buf_a_file = original_is_buf_a_file + vim.api.nvim_get_current_buf = original_get_current_buf + vim.notify = original_notify + end) + + it('should return nil and notify when no visual selection found', function() + local original_is_buf_a_file = util.is_buf_a_file + local original_get_current_selection = BaseContext.get_current_selection + local original_get_current_buf = vim.api.nvim_get_current_buf + + util.is_buf_a_file = function() + return true + end + BaseContext.get_current_selection = function() + return nil + end + vim.api.nvim_get_current_buf = function() + return 11 + end + + local original_notify = vim.notify + local notifications = {} + vim.notify = function(msg, level) + table.insert(notifications, { msg = msg, level = level }) + end + + local text = context.build_inline_selection_text() + + assert.is_nil(text) + assert.equal(1, #notifications) + assert.equal('No visual selection found', notifications[1].msg) + assert.equal(vim.log.levels.WARN, notifications[1].level) + + util.is_buf_a_file = original_is_buf_a_file + BaseContext.get_current_selection = original_get_current_selection + vim.api.nvim_get_current_buf = original_get_current_buf + vim.notify = original_notify + end) + + it('should include the filetype in the code fence', function() + local original_is_buf_a_file = util.is_buf_a_file + local original_get_current_selection = BaseContext.get_current_selection + local original_get_current_file_for_selection = BaseContext.get_current_file_for_selection + local original_get_current_buf = vim.api.nvim_get_current_buf + + util.is_buf_a_file = function() + return true + end + BaseContext.get_current_selection = function() + return { text = 'const x = 1', lines = '1, 1' } + end + BaseContext.get_current_file_for_selection = function() + return { path = '/tmp/app.ts', name = 'app.ts', extension = 'ts' } + end + vim.api.nvim_get_current_buf = function() + return 6 + end + + local original_bo = vim.bo + vim.bo = setmetatable({}, { + __index = function(_, buf) + return { filetype = 'typescript' } + end, + }) + + local text = context.build_inline_selection_text() + + assert.is_not_nil(text) + assert.is_not_nil(text:match('```typescript')) + + util.is_buf_a_file = original_is_buf_a_file + BaseContext.get_current_selection = original_get_current_selection + BaseContext.get_current_file_for_selection = original_get_current_file_for_selection + vim.api.nvim_get_current_buf = original_get_current_buf + vim.bo = original_bo + end) +end)