Skip to content
Open
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
81 changes: 60 additions & 21 deletions lua/opencode/ui/completion.lua
Original file line number Diff line number Diff line change
@@ -1,6 +1,48 @@
local M = {}

local completion_sources = {}
local _current_engine = nil
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both the local variable _current_engine (line 4) and the module field M._current_engine (line 70) are defined but only M._current_engine is used. The local variable declaration at the module level serves no purpose and could be confusing. Consider removing the local variable declaration and only using M._current_engine, or document why both exist.

Suggested change
local _current_engine = nil
M._current_engine = nil

Copilot uses AI. Check for mistakes.

-- Engine configuration mapping
local ENGINE_CONFIG = {
['nvim-cmp'] = {
module = 'opencode.ui.completion.engines.nvim_cmp',
constructor = 'new',
},
['blink'] = {
module = 'opencode.ui.completion.engines.blink_cmp',
constructor = 'create', -- Special case for blink
},
['vim_complete'] = {
module = 'opencode.ui.completion.engines.vim_complete',
constructor = 'new',
},
}

---Load and create an engine instance
---@param engine_name string
---@return table|nil engine
local function load_engine(engine_name)
local config = ENGINE_CONFIG[engine_name]
if not config then
vim.notify('Unknown completion engine: ' .. tostring(engine_name), vim.log.levels.WARN)
return nil
end

local ok, EngineClass = pcall(require, config.module)
if not ok then
vim.notify('Failed to load ' .. engine_name .. ' engine: ' .. tostring(EngineClass), vim.log.levels.ERROR)
return nil
end

local constructor = EngineClass[config.constructor]
if not constructor then
vim.notify('Engine ' .. engine_name .. ' missing ' .. config.constructor .. ' method', vim.log.levels.ERROR)
return nil
end

return constructor()
end

function M.setup()
local files_source = require('opencode.ui.completion.files')
Expand All @@ -17,23 +59,21 @@ function M.setup()
return (a.priority or 0) > (b.priority or 0)
end)

local engine_name = M.get_completion_engine()
local engine = load_engine(engine_name)
local setup_success = false

local engine = M.get_completion_engine()

if engine == 'nvim-cmp' then
require('opencode.ui.completion.engines.nvim_cmp').setup(completion_sources)
setup_success = true
elseif engine == 'blink' then
require('opencode.ui.completion.engines.blink_cmp').setup(completion_sources)
setup_success = true
elseif engine == 'vim_complete' then
require('opencode.ui.completion.engines.vim_complete').setup(completion_sources)
setup_success = true
if engine and engine.setup then
setup_success = engine:setup(completion_sources)
end

M._current_engine = engine

if not setup_success then
vim.notify('Opencode: No completion engine available', vim.log.levels.WARN)
vim.notify(
'Opencode: No completion engine available (engine: ' .. tostring(engine_name) .. ')',
vim.log.levels.WARN
)
end
end

Expand Down Expand Up @@ -83,17 +123,16 @@ end

function M.trigger_completion(trigger_char)
return function()
local engine = M.get_completion_engine()

if engine == 'vim_complete' then
require('opencode.ui.completion.engines.vim_complete').trigger(trigger_char)
elseif engine == 'blink' then
vim.api.nvim_feedkeys(trigger_char, 'in', true)
require('blink.cmp').show({ providers = { 'opencode_mentions' } })
else
vim.api.nvim_feedkeys(trigger_char, 'in', true)
if M._current_engine and M._current_engine.trigger then
M._current_engine:trigger(trigger_char)
end
end
end

function M.hide_completion()
if M._current_engine and M._current_engine.hide then
M._current_engine:hide()
end
end

return M
144 changes: 105 additions & 39 deletions lua/opencode/ui/completion/engines/blink_cmp.lua
Original file line number Diff line number Diff line change
@@ -1,6 +1,88 @@
local Promise = require('opencode.promise')
local M = {}
local state = require('opencode.state')
local CompletionEngine = require('opencode.ui.completion.engines.base')

---@class BlinkCmpEngine : CompletionEngine
local BlinkCmpEngine = setmetatable({}, { __index = CompletionEngine })
BlinkCmpEngine.__index = BlinkCmpEngine

---Create a new blink-cmp completion engine
---@return BlinkCmpEngine
function BlinkCmpEngine.new()
local self = CompletionEngine.new('blink_cmp')
return setmetatable(self, BlinkCmpEngine)
end

---Check if blink-cmp is available
---@return boolean
function BlinkCmpEngine:is_available()
local ok = pcall(require, 'blink.cmp')
return ok and CompletionEngine.is_available()
end

---Setup blink-cmp completion engine
---@param completion_sources table[]
---@return boolean
function BlinkCmpEngine:setup(completion_sources)
local ok, blink = pcall(require, 'blink.cmp')
if not ok then
return false
end

CompletionEngine.setup(self, completion_sources)

blink.add_source_provider('opencode_mentions', {
module = 'opencode.ui.completion.engines.blink_cmp',
async = true,
})

-- Hide blink-cmp menu on certain trigger characters when opened via other completion sources
vim.api.nvim_create_autocmd('User', {
group = vim.api.nvim_create_augroup('OpencodeBlinkCmp', { clear = true }),
pattern = 'BlinkCmpMenuOpen',
callback = function()
local current_buf = vim.api.nvim_get_current_buf()
local input_buf = vim.tbl_get(state, 'windows', 'input_buf')
if not state.windows or current_buf ~= input_buf then
return
end

local blink = require('blink.cmp')
local ctx = blink.get_context()

local triggers = CompletionEngine.get_trigger_characters()
if ctx.trigger.initial_kind == 'trigger_character' and vim.tbl_contains(triggers, ctx.trigger.character) then
blink.hide()
end
end,
})
return true
end

---Trigger completion manually for blink-cmp
---@param trigger_char string
function BlinkCmpEngine:trigger(trigger_char)
local blink = require('blink.cmp')

vim.api.nvim_feedkeys(trigger_char, 'in', true)
if blink.is_visible() then
blink.hide()
end

blink.show({
providers = { 'opencode_mentions' },
trigger_character = trigger_char,
})
end

function BlinkCmpEngine:hide()
local blink = require('blink.cmp')
if blink.is_visible() then
blink.hide()
end
end

-- Source implementation for blink-cmp provider (when this module is loaded by blink.cmp)
local Source = {}
Source.__index = Source

Expand All @@ -10,19 +92,13 @@ function Source.new()
end

function Source:get_trigger_characters()
local config = require('opencode.config')
local mention_key = config.get_key_for_function('input_window', 'mention')
local slash_key = config.get_key_for_function('input_window', 'slash_commands')
local context_key = config.get_key_for_function('input_window', 'context_items')
return {
slash_key or '',
mention_key or '',
context_key or '',
}
local CompletionEngine = require('opencode.ui.completion.engines.base')
return CompletionEngine.get_trigger_characters()
end

function Source:enabled()
return vim.bo.filetype == 'opencode'
local CompletionEngine = require('opencode.ui.completion.engines.base')
return CompletionEngine.is_available()
end

function Source:get_completions(ctx, callback)
Expand All @@ -34,7 +110,9 @@ function Source:get_completions(ctx, callback)
local col = ctx.cursor[2] + 1
local before_cursor = line:sub(1, col - 1)

local trigger_chars = table.concat(vim.tbl_map(vim.pesc, self:get_trigger_characters()), '')
local CompletionEngine = require('opencode.ui.completion.engines.base')
local triggers = CompletionEngine.get_trigger_characters()
local trigger_chars = table.concat(vim.tbl_map(vim.pesc, triggers), '')
local trigger_char, trigger_match = before_cursor:match('([' .. trigger_chars .. '])([%w_/%-%.]*)$')

Comment on lines +114 to 117
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trigger matching pattern here differs from the pattern used in the base CompletionEngine.parse_trigger method. The base class uses .*([...])([%w_%-%.]*) (note the trailing *) while this uses ([...])([%w_/%-%.]*) $ (with $ anchor and includes /). This inconsistency could lead to different behavior between engines. Consider using the base class's parse_trigger method for consistency, or if the differences are intentional (adding / for file paths), document why this pattern is needed specifically for blink.

Suggested change
local triggers = CompletionEngine.get_trigger_characters()
local trigger_chars = table.concat(vim.tbl_map(vim.pesc, triggers), '')
local trigger_char, trigger_match = before_cursor:match('([' .. trigger_chars .. '])([%w_/%-%.]*)$')
local trigger_char, trigger_match = CompletionEngine.parse_trigger(before_cursor)

Copilot uses AI. Check for mistakes.
if not trigger_match then
Expand All @@ -43,15 +121,15 @@ function Source:get_completions(ctx, callback)
end

local context = {
input = trigger_match, -- Pass input for search-based sources (e.g., files)
input = trigger_match,
cursor_pos = col,
line = line,
trigger_char = trigger_char,
}

local items = {}
for _, completion_source in ipairs(completion_sources) do
local source_items = completion_source.complete(context):await()
for _, source in ipairs(completion_sources) do
local source_items = source.complete(context):await()
for i, item in ipairs(source_items) do
local insert_text = item.insert_text or item.label
table.insert(items, {
Expand All @@ -61,18 +139,10 @@ function Source:get_completions(ctx, callback)
kind_hl = item.kind_hl,
detail = item.detail,
documentation = item.documentation,
-- Use filterText for fuzzy matching against the typed text after trigger char
filterText = item.filter_text or item.label,
insertText = insert_text,
sortText = string.format(
'%02d_%02d_%02d_%s',
completion_source.priority or 999,
item.priority or 999,
i,
item.label
),
score_offset = -(completion_source.priority or 999) * 1000 + (item.priority or 999),

sortText = string.format('%02d_%02d_%02d_%s', source.priority or 999, item.priority or 999, i, item.label),
score_offset = -(source.priority or 999) * 1000 + (item.priority or 999),
data = {
original_item = item,
},
Expand All @@ -88,27 +158,23 @@ function Source:execute(ctx, item, callback, default_implementation)
default_implementation()

if item.data and item.data.original_item then
local completion = require('opencode.ui.completion')
completion.on_complete(item.data.original_item)
local CompletionEngine = require('opencode.ui.completion.engines.base')
local engine = CompletionEngine.new('blink_cmp')
engine:on_complete(item.data.original_item)
Comment on lines +161 to +163
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating a new engine instance here is incorrect. This creates a fresh engine with no completion sources, which means on_complete won't be able to find the appropriate source to call. The engine should be passed or accessed as a shared instance (similar to how nvim_cmp.lua handles this with the 'engine' variable captured in the closure). This could lead to completion callbacks not working properly.

Copilot uses AI. Check for mistakes.
end

callback()
end

function M.setup(completion_sources)
local ok, blink = pcall(require, 'blink.cmp')
if not ok then
return false
end
-- Export module with dual interface:
-- - For our engine system: use BlinkCmpEngine methods
-- - For blink.cmp provider system: override 'new' to return Source instance
local M = BlinkCmpEngine

blink.add_source_provider('opencode_mentions', {
module = 'opencode.ui.completion.engines.blink_cmp',
async = true,
})

return true
end
-- Save the engine constructor before overriding
M.create = BlinkCmpEngine.new

-- Override 'new' for blink.cmp compatibility (when blink loads this as a source)
M.new = Source.new
Comment on lines +169 to 178
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The module export pattern here is confusing and could lead to unexpected behavior. Setting M = BlinkCmpEngine makes the module return the class itself, then overriding M.new changes the class's new method. This means when someone imports this module and calls .new(), they get a Source instance instead of a BlinkCmpEngine instance, unless they use the .create() method. This dual interface is documented in the comment but is error-prone. Consider using a more explicit pattern like returning a table with separate engine and source fields, or documenting this pattern more prominently.

Copilot uses AI. Check for mistakes.

return M
Loading