Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ require('opencode').setup({
['<leader>op'] = { 'configure_provider' }, -- Quick provider and model switch from predefined list
['<leader>oV'] = { 'configure_variant' }, -- Switch model variant for the current model
['<leader>oy'] = { 'add_visual_selection', mode = {'v'} },
['<leader>oY'] = { 'add_visual_selection_inline', mode = {'v'} }, -- Insert visual selection as inline code block in the input buffer
['<leader>oz'] = { 'toggle_zoom' }, -- Zoom in/out on the Opencode windows
['<leader>ov'] = { 'paste_image'}, -- Paste image from clipboard into current session
['<leader>od'] = { 'diff_open' }, -- Opens a diff tab of a modified file since the last opencode prompt
Expand Down Expand Up @@ -645,6 +646,7 @@ The plugin provides the following actions that can be triggered via keymaps, com
| Toggle reasoning output (thinking steps) | `<leader>otr` | `:Opencode toggle_reasoning_output` | `require('opencode.api').toggle_reasoning_output()` |
| Open a quick chat input with selection/current line context | `<leader>o/` | `:Opencode quick_chat` | `require('opencode.api').quick_chat()` |
| Add visual selection to context | `<leader>oy` | `:Opencode add_visual_selection` | `require('opencode.api').add_visual_selection(opts?)` |
| Insert visual selection inline into input | `<leader>oY` | `:Opencode add_visual_selection_inline` | `require('opencode.api').add_visual_selection_inline(opts?)` |

**add_visual_selection opts:**

Expand All @@ -653,9 +655,21 @@ The plugin provides the following actions that can be triggered via keymaps, com
Example keymap for silent add:

```lua
['<leader>oY'] = { 'add_visual_selection', { open_input = false }, mode = {'v'} }
['<leader>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
<selected text>
```
```

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:
Expand Down
28 changes: 28 additions & 0 deletions lua/opencode/api.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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('<Esc>', true, false, true), 'n', false)
end
end)
end
)

---@type table<string, OpencodeUICommand>
M.commands = {
open = {
Expand Down Expand Up @@ -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 = {
Expand Down
1 change: 1 addition & 0 deletions lua/opencode/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ M.defaults = {
['<leader>op'] = { 'configure_provider', desc = 'Configure provider' },
['<leader>oV'] = { 'configure_variant', desc = 'Configure model variant' },
['<leader>oy'] = { 'add_visual_selection', mode = { 'v' }, desc = 'Add visual selection to context' },
['<leader>oY'] = { 'add_visual_selection_inline', mode = { 'v' }, desc = 'Insert visual selection inline into input' },
['<leader>oz'] = { 'toggle_zoom', desc = 'Toggle zoom' },
['<leader>ov'] = { 'paste_image', desc = 'Paste image from clipboard' },
['<leader>od'] = { 'diff_open', desc = 'Open diff view' },
Expand Down
42 changes: 42 additions & 0 deletions lua/opencode/context.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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`**
---
--- ```<filetype>
--- <selected text>
--- ```
---
---@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
Expand Down
155 changes: 155 additions & 0 deletions tests/unit/context_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading