From 8f6725cf47743d0ec1bf0b893b2db61184dc93bf Mon Sep 17 00:00:00 2001 From: Julio Garcia Date: Tue, 17 Mar 2026 10:40:52 +0100 Subject: [PATCH 1/3] feat(context): add inline visual selection insertion Adds a new command and API to insert the current visual selection as a formatted code block directly into the input buffer. This includes a new `add_visual_selection_inline` function, a corresponding keymap, and a helper to build the formatted text. Unit tests are provided to ensure correct formatting and error handling for edge cases. --- lua/opencode/api.lua | 28 +++++++ lua/opencode/config.lua | 1 + lua/opencode/context.lua | 42 ++++++++++ tests/unit/context_spec.lua | 155 ++++++++++++++++++++++++++++++++++++ 4 files changed, 226 insertions(+) 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..6f14a665 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: +--- +--- Here is some code from : +--- +--- ``` +--- +--- ``` +--- +---@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( + 'Here is some code from %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..905b49fd 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('Here is some code from /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) From 3cc37a3be990281eab2a37413df1a2ef2e6b6c52 Mon Sep 17 00:00:00 2001 From: Julio Garcia Date: Tue, 17 Mar 2026 13:19:59 +0100 Subject: [PATCH 2/3] feat(context): improve inline selection formatting Update the inline visual selection text to use bolded file path formatting (**`path/to/file`**) instead of the previous "Here is some code from" prefix. Adjust unit tests to match the new output format. This change makes the inserted context more visually distinct and concise. --- lua/opencode/context.lua | 4 ++-- tests/unit/context_spec.lua | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lua/opencode/context.lua b/lua/opencode/context.lua index 6f14a665..4cdc73f1 100644 --- a/lua/opencode/context.lua +++ b/lua/opencode/context.lua @@ -168,7 +168,7 @@ end --- Captures the current visual selection and returns the text to be inserted inline --- into the opencode input buffer, in the form: --- ---- Here is some code from : +--- **`path/to/file`** --- --- ``` --- @@ -198,7 +198,7 @@ function M.build_inline_selection_text(range) local filetype = vim.bo[buf].filetype or '' local text = string.format( - 'Here is some code from %s:\n\n```%s\n%s\n```', + '**`%s`**\n\n```%s\n%s\n```', file.path, filetype, current_selection.text diff --git a/tests/unit/context_spec.lua b/tests/unit/context_spec.lua index 905b49fd..7f12bb4e 100644 --- a/tests/unit/context_spec.lua +++ b/tests/unit/context_spec.lua @@ -1182,7 +1182,7 @@ describe('build_inline_selection_text', function() local text = context.build_inline_selection_text() assert.is_not_nil(text) - assert.is_not_nil(text:match('Here is some code from /tmp/test%.lua:')) + 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('```$')) From 252f4af618b5c510d96bc1b71088638cf9c96c39 Mon Sep 17 00:00:00 2001 From: Julio Garcia Date: Tue, 17 Mar 2026 14:12:15 +0100 Subject: [PATCH 3/3] feat: add inline visual selection insertion action Add new action `add_visual_selection_inline` with keymap `oY` to insert visually selected code directly into the input buffer as a Markdown code block, prefixed with the file path. Updates documentation with usage details and example. Leaves cursor in normal mode for prompt editing. --- README.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) 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: