diff --git a/lua/opencode/ui/completion.lua b/lua/opencode/ui/completion.lua index 2c589345..4877368d 100644 --- a/lua/opencode/ui/completion.lua +++ b/lua/opencode/ui/completion.lua @@ -1,6 +1,48 @@ local M = {} local completion_sources = {} +M._current_engine = nil + +-- 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') @@ -17,23 +59,22 @@ 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 - if not setup_success then - vim.notify('Opencode: No completion engine available', vim.log.levels.WARN) + if setup_success then + M._current_engine = engine + else + M._current_engine = nil + vim.notify( + 'Opencode: No completion engine available (engine: ' .. tostring(engine_name) .. ')', + vim.log.levels.WARN + ) end end @@ -83,17 +124,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 diff --git a/lua/opencode/ui/completion/engines/base.lua b/lua/opencode/ui/completion/engines/base.lua index 9062d535..b12fda9d 100644 --- a/lua/opencode/ui/completion/engines/base.lua +++ b/lua/opencode/ui/completion/engines/base.lua @@ -61,7 +61,7 @@ end function CompletionEngine:parse_trigger(before_cursor) local triggers = self: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 = before_cursor:match('([' .. trigger_chars .. '])([%w_/%-%.]*)$') return trigger_char, trigger_match end diff --git a/lua/opencode/ui/completion/engines/blink_cmp.lua b/lua/opencode/ui/completion/engines/blink_cmp.lua index 651af736..6b293bb2 100644 --- a/lua/opencode/ui/completion/engines/blink_cmp.lua +++ b/lua/opencode/ui/completion/engines/blink_cmp.lua @@ -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 @@ -10,19 +92,11 @@ 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 '', - } + return CompletionEngine.get_trigger_characters() end function Source:enabled() - return vim.bo.filetype == 'opencode' + return CompletionEngine.is_available() end function Source:get_completions(ctx, callback) @@ -34,24 +108,24 @@ 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 trigger_char, trigger_match = before_cursor:match('([' .. trigger_chars .. '])([%w_/%-%.]*)$') + local trigger_char, trigger_match = CompletionEngine.parse_trigger(self, before_cursor) if not trigger_match then callback({ is_incomplete_forward = false, items = {} }) return end + ---@type CompletionContext 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, + trigger_char = trigger_char or '', } 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, { @@ -61,18 +135,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, }, @@ -88,27 +154,21 @@ 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) + CompletionEngine.on_complete(self, item.data.original_item) 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 return M diff --git a/lua/opencode/ui/completion/engines/nvim_cmp.lua b/lua/opencode/ui/completion/engines/nvim_cmp.lua index 8d6b6480..ae929f10 100644 --- a/lua/opencode/ui/completion/engines/nvim_cmp.lua +++ b/lua/opencode/ui/completion/engines/nvim_cmp.lua @@ -1,11 +1,36 @@ local Promise = require('opencode.promise') -local M = {} +local CompletionEngine = require('opencode.ui.completion.engines.base') -function M.setup(completion_sources) +---@class NvimCmpEngine : CompletionEngine +local NvimCmpEngine = setmetatable({}, { __index = CompletionEngine }) +NvimCmpEngine.__index = NvimCmpEngine + +---Create a new nvim-cmp completion engine +---@return NvimCmpEngine +function NvimCmpEngine.new() + local self = CompletionEngine.new('nvim_cmp') + return setmetatable(self, NvimCmpEngine) +end + +---Check if nvim-cmp is available +---@return boolean +function NvimCmpEngine:is_available() + local ok = pcall(require, 'cmp') + return ok and CompletionEngine.is_available() +end + +---Setup nvim-cmp completion engine +---@param completion_sources table[] +---@return boolean +function NvimCmpEngine:setup(completion_sources) local ok, cmp = pcall(require, 'cmp') if not ok then return false end + + CompletionEngine.setup(self, completion_sources) + + local engine = self local source = {} function source.new() @@ -13,30 +38,20 @@ function M.setup(completion_sources) 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 '', - } + return engine:get_trigger_characters() end function source:is_available() - return vim.bo.filetype == 'opencode' + return engine:is_available() end function source:complete(params, callback) Promise.spawn(function() local line = params.context.cursor_line - local col = params.context.cursor.col local before_cursor = line:sub(1, col - 1) - local trigger_chars = table.concat(vim.tbl_map(vim.pesc, self:get_trigger_characters()), '') - local trigger_char, trigger_match = before_cursor:match('.*([' .. trigger_chars .. '])([%w_%-%.]*)') + local trigger_char, trigger_match = engine:parse_trigger(before_cursor) if not trigger_match then callback({ items = {}, isIncomplete = false }) @@ -50,32 +65,32 @@ function M.setup(completion_sources) trigger_char = trigger_char, } + local wrapped_items = engine:get_completion_items(context):await() local items = {} - for _, completion_source in ipairs(completion_sources) do - local source_items = completion_source.complete(context):await() - for j, item in ipairs(source_items) do - table.insert(items, { - label = item.label, - kind = 1, - cmp = { - kind_text = item.kind_icon, - }, - kind_hl_group = item.kind_hl, - detail = item.detail, - documentation = item.documentation, - insertText = item.insert_text or '', - sortText = string.format( - '%02d_%02d_%02d_%s', - completion_source.priority or 999, - item.priority or 999, - j, - item.label - ), - data = { - original_item = item, - }, - }) - end + + for _, wrapped_item in ipairs(wrapped_items) do + local item = wrapped_item.original_item + table.insert(items, { + label = item.label, + kind = 1, + cmp = { + kind_text = item.kind_icon, + }, + kind_hl_group = item.kind_hl, + detail = item.detail, + documentation = item.documentation, + insertText = item.insert_text or '', + sortText = string.format( + '%02d_%02d_%02d_%s', + wrapped_item.source_priority, + wrapped_item.item_priority, + wrapped_item.index, + item.label + ), + data = { + original_item = item, + }, + }) end callback({ items = items, isIncomplete = true }) @@ -102,8 +117,7 @@ function M.setup(completion_sources) if entry and entry.source.name == 'opencode_mentions' then local item_data = entry:get_completion_item().data if item_data and item_data.original_item then - local completion = require('opencode.ui.completion') - completion.on_complete(item_data.original_item) + engine:on_complete(item_data.original_item) end end end) @@ -111,4 +125,15 @@ function M.setup(completion_sources) return true end -return M +---Trigger completion manually for nvim-cmp +---@param trigger_char string +function NvimCmpEngine:trigger(trigger_char) + vim.api.nvim_feedkeys(trigger_char, 'in', true) + local cmp = require('cmp') + if cmp.visible() then + cmp.close() + end + cmp.complete() +end + +return NvimCmpEngine diff --git a/lua/opencode/ui/completion/engines/vim_complete.lua b/lua/opencode/ui/completion/engines/vim_complete.lua index 891b0353..d679ec47 100644 --- a/lua/opencode/ui/completion/engines/vim_complete.lua +++ b/lua/opencode/ui/completion/engines/vim_complete.lua @@ -1,64 +1,81 @@ local Promise = require('opencode.promise') -local M = {} +local CompletionEngine = require('opencode.ui.completion.engines.base') + +---@class VimCompleteEngine : CompletionEngine +local VimCompleteEngine = setmetatable({}, { __index = CompletionEngine }) +VimCompleteEngine.__index = VimCompleteEngine local completion_active = false -function M.setup(completion_sources) +---Create a new vim completion engine +---@return VimCompleteEngine +function VimCompleteEngine.new() + local self = CompletionEngine.new('vim_complete') + return setmetatable(self, VimCompleteEngine) +end + +---Setup vim completion engine +---@param completion_sources table[] +---@return boolean +function VimCompleteEngine:setup(completion_sources) + -- Call parent setup + CompletionEngine.setup(self, completion_sources) + + local group = vim.api.nvim_create_augroup('OpencodeVimComplete', { clear = true }) vim.api.nvim_create_autocmd('FileType', { + group = group, pattern = 'opencode', callback = function(args) local buf = args.buf - vim.api.nvim_create_autocmd('TextChangedI', { buffer = buf, callback = M._update }) - vim.api.nvim_create_autocmd('CompleteDone', { buffer = buf, callback = M.on_complete }) + vim.api.nvim_create_autocmd('TextChangedI', { + buffer = buf, + callback = function() + self:_update() + end, + }) + vim.api.nvim_create_autocmd('CompleteDone', { + buffer = buf, + callback = function() + self:_on_complete_done() + end, + }) end, }) - M._completion_sources = completion_sources - return true end -function M._fake_feed_key(trigger_char) - local cursor_pos = vim.api.nvim_win_get_cursor(0) - local row = cursor_pos[1] - 1 - local col = cursor_pos[2] - - vim.api.nvim_buf_set_text(0, row, col, row, col, { trigger_char }) - vim.api.nvim_win_set_cursor(0, { row + 1, col + 1 }) -end - -function M._get_trigger(before_cursor) - 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') - local triggers = { - slash_key or '', - mention_key or '', - context_key or '', - } - local trigger_chars = table.concat(vim.tbl_map(vim.pesc, triggers), '') - local trigger_char, trigger_match = before_cursor:match('.*([' .. trigger_chars .. '])([%w_%-%.]*)') - return trigger_char, trigger_match -end - -function M.trigger(trigger_char) - M._fake_feed_key(trigger_char) +---Trigger completion manually for vim +---@param trigger_char string +function VimCompleteEngine:trigger(trigger_char) + self:_fake_feed_key(trigger_char) local line = vim.api.nvim_get_current_line() local col = vim.api.nvim_win_get_cursor(0)[2] local before_cursor = line:sub(1, col) - local _, trigger_match = M._get_trigger(before_cursor) + local _, trigger_match = self:parse_trigger(before_cursor) if not trigger_match then return end completion_active = true - M._update() + self:_update() end -function M._update() +---Insert trigger character at cursor position +---@param trigger_char string +function VimCompleteEngine:_fake_feed_key(trigger_char) + local cursor_pos = vim.api.nvim_win_get_cursor(0) + local row = cursor_pos[1] - 1 + local col = cursor_pos[2] + + vim.api.nvim_buf_set_text(0, row, col, row, col, { trigger_char }) + vim.api.nvim_win_set_cursor(0, { row + 1, col + 1 }) +end + +---Update completion items based on current cursor position +function VimCompleteEngine:_update() Promise.spawn(function() if not completion_active then return @@ -67,7 +84,7 @@ function M._update() local line = vim.api.nvim_get_current_line() local col = vim.api.nvim_win_get_cursor(0)[2] local before_cursor = line:sub(1, col) - local trigger_char, trigger_match = M._get_trigger(before_cursor) + local trigger_char, trigger_match = self:parse_trigger(before_cursor) if not trigger_char then completion_active = false @@ -81,24 +98,32 @@ function M._update() trigger_char = trigger_char, } + local wrapped_items = self:get_completion_items(context):await() local items = {} - for _, source in ipairs(M._completion_sources or {}) do - local source_items = source.complete(context):await() - for i, item in ipairs(source_items) do - if vim.startswith(item.insert_text or '', trigger_char) then - item.insert_text = item.insert_text:sub(2) - end - local source_priority = source.priority or 999 - local item_priority = item.priority or 999 - table.insert(items, { - word = #item.insert_text > 0 and item.insert_text or item.label, - abbr = (item.kind_icon or '') .. item.label, - menu = source.name, - kind = item.kind:sub(1, 1):upper(), - user_data = item, - _sort_text = string.format('%02d_%02d_%02d_%s', source_priority, item_priority, i, item.label), - }) + + for _, wrapped_item in ipairs(wrapped_items) do + local item = wrapped_item.original_item + local insert_text = item.insert_text or '' + + -- Remove trigger character if it's part of the insert text + if vim.startswith(insert_text, trigger_char) then + insert_text = insert_text:sub(2) end + + table.insert(items, { + word = #insert_text > 0 and insert_text or item.label, + abbr = (item.kind_icon or '') .. item.label, + menu = wrapped_item.source_name, + kind = item.kind:sub(1, 1):upper(), + user_data = item, + _sort_text = string.format( + '%02d_%02d_%02d_%s', + wrapped_item.source_priority, + wrapped_item.item_priority, + wrapped_item.index, + item.label + ), + }) end table.sort(items, function(a, b) @@ -116,13 +141,14 @@ function M._update() end) end -M.on_complete = function() +---Handle completion selection +function VimCompleteEngine:_on_complete_done() local completed_item = vim.v.completed_item if completed_item and completed_item.word and completed_item.user_data then completion_active = false - local completion = require('opencode.ui.completion') - completion.on_complete(completed_item.user_data) + self:on_complete(completed_item.user_data) end end -return M +return VimCompleteEngine + diff --git a/lua/opencode/ui/input_window.lua b/lua/opencode/ui/input_window.lua index 6c7a44b0..d567b9b3 100644 --- a/lua/opencode/ui/input_window.lua +++ b/lua/opencode/ui/input_window.lua @@ -141,6 +141,7 @@ M._append_to_input = function(text) if not M.mounted() then return end + ---@cast state.windows -nil local current_lines = vim.api.nvim_buf_get_lines(state.windows.input_buf, 0, -1, false) local new_lines = vim.split(text, '\n') @@ -190,7 +191,7 @@ function M.setup(windows) vim.api.nvim_set_option_value('relativenumber', false, { win = windows.input_win }) vim.api.nvim_set_option_value('buftype', 'nofile', { buf = windows.input_buf }) vim.api.nvim_set_option_value('swapfile', false, { buf = windows.input_buf }) - -- vim.b[windows.input_buf].completion = false + if config.ui.position ~= 'current' then vim.api.nvim_set_option_value('winfixbuf', true, { win = windows.input_win }) end