Skip to content

Commit 8508150

Browse files
committed
refactor(completion): implement lsp completion
1 parent 7ed41d1 commit 8508150

22 files changed

Lines changed: 1743 additions & 806 deletions

ftplugin/opencode.lua

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
if vim.b.did_ftplugin then
2+
return
3+
end
4+
vim.b.did_ftplugin = true
5+
-- This provides completion for files, subagents, commands, and context items
6+
-- Works with any LSP-compatible completion plugin (blink.cmp, nvim-cmp, etc.)
7+
8+
local bufnr = vim.api.nvim_get_current_buf()
9+
10+
local opencode_ls = require('opencode.lsp.opencode_ls')
11+
local client_id = opencode_ls.start(bufnr)
12+
local completion = require('opencode.ui.completion')
13+
if client_id and not completion.has_completion_engine() then
14+
pcall(function()
15+
vim.bo.completeopt = 'menu,menuone,noselect,fuzzy'
16+
vim.lsp.completion.enable(true, client_id, bufnr, { autotrigger = true })
17+
end)
18+
end

lua/opencode/api.lua

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -420,19 +420,19 @@ function M.mention()
420420
local char = config.get_key_for_function('input_window', 'mention')
421421

422422
ui.focus_input({ restore_position = false, start_insert = true })
423-
require('opencode.ui.completion').trigger_completion(char)()
423+
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(char, true, false, true), 'n', false)
424424
end
425425

426426
function M.context_items()
427427
local char = config.get_key_for_function('input_window', 'context_items')
428428
ui.focus_input({ restore_position = false, start_insert = true })
429-
require('opencode.ui.completion').trigger_completion(char)()
429+
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(char, true, false, true), 'n', false)
430430
end
431431

432432
function M.slash_commands()
433433
local char = config.get_key_for_function('input_window', 'slash_commands')
434434
ui.focus_input({ restore_position = false, start_insert = true })
435-
require('opencode.ui.completion').trigger_completion(char)()
435+
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(char, true, false, true), 'n', false)
436436
end
437437

438438
function M.focus_input()

lua/opencode/init.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ function M.setup(opts)
1111
require('opencode.ui.highlight').setup()
1212
require('opencode.core').setup()
1313
require('opencode.api').setup()
14-
require('opencode.keymap').setup(config.keymap)
1514
require('opencode.ui.completion').setup()
15+
require('opencode.keymap').setup(config.keymap)
1616
require('opencode.event_manager').setup()
1717
require('opencode.context').setup()
1818
require('opencode.ui.context_bar').setup()

lua/opencode/keymap.lua

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
local M = {}
22

33
local function is_completion_visible()
4-
local ok, completion = pcall(require, 'opencode.ui.completion')
5-
return ok and completion.is_visible()
4+
return require('opencode.ui.completion').is_completion_visible()
65
end
76

87
local function wrap_with_completion_check(key_binding, callback)

lua/opencode/lsp/opencode_ls.lua

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
---In-process LSP server for opencode completion
2+
---Provides completion for files, subagents, commands, and context items
3+
---Works with any LSP-compatible completion plugin (blink.cmp, nvim-cmp, etc.)
4+
local M = { _completion_done_handled = false }
5+
6+
---@type table<vim.lsp.protocol.Method, fun(params: table, callback:fun(err: lsp.ResponseError?, result: any))>
7+
local handlers = {}
8+
local ms = vim.lsp.protocol.Methods
9+
10+
---Initialize handler - negotiates capabilities with the client
11+
---@param params lsp.InitializeParams
12+
---@param callback fun(err?: lsp.ResponseError, result: lsp.InitializeResult)
13+
handlers[ms.initialize] = function(params, callback)
14+
local completion = require('opencode.ui.completion')
15+
local triggers = completion.get_trigger_characters()
16+
17+
callback(nil, {
18+
capabilities = {
19+
completionProvider = {
20+
resolveProvider = false,
21+
triggerCharacters = triggers,
22+
},
23+
executeCommandProvider = {
24+
commands = { 'opencode.completion_done' },
25+
},
26+
},
27+
serverInfo = {
28+
name = 'opencode_ls',
29+
version = '1.0.0',
30+
},
31+
})
32+
end
33+
34+
handlers[ms.workspace_executeCommand] = function(params, callback)
35+
if params.command == 'opencode.completion_done' then
36+
if M._completion_done_handled then
37+
callback(nil, nil)
38+
M._completion_done_handled = false
39+
return
40+
end
41+
local item = params.arguments and params.arguments[1]
42+
if item then
43+
require('opencode.ui.completion').on_completion_done(item)
44+
end
45+
callback(nil, nil)
46+
else
47+
callback({
48+
code = -32601,
49+
message = 'Method not found: ' .. tostring(params.command),
50+
}, nil)
51+
end
52+
end
53+
54+
---Get word to complete from cursor position
55+
---@param params lsp.CompletionParams
56+
---@return string word_to_complete
57+
---@return string trigger_char
58+
---@return string full_line
59+
local function get_completion_context(params)
60+
local completion = require('opencode.ui.completion')
61+
local bufnr = vim.api.nvim_get_current_buf()
62+
local line_num = params.position.line + 1 -- LSP is 0-indexed
63+
local col = params.position.character
64+
65+
local lines = vim.api.nvim_buf_get_lines(bufnr, line_num - 1, line_num, false)
66+
local line = lines[1] or ''
67+
local line_to_cursor = line:sub(1, col)
68+
69+
local trigger_char = ''
70+
local triggers = completion.get_trigger_characters()
71+
for _, t in ipairs(triggers) do
72+
if t and line_to_cursor:match(vim.pesc(t) .. '[^%s]*$') then
73+
trigger_char = t
74+
break
75+
end
76+
end
77+
78+
-- Extract word after trigger
79+
local word = ''
80+
if trigger_char ~= '' then
81+
word = line_to_cursor:match(vim.pesc(trigger_char) .. '([^%s]*)$') or ''
82+
end
83+
84+
return word, trigger_char, line
85+
end
86+
87+
function M.supports_kind_icons()
88+
return require('opencode.ui.completion').supports_kind_icons()
89+
end
90+
91+
---Convert opencode CompletionItem to LSP CompletionItem
92+
---@param item CompletionItem
93+
---@param index integer
94+
---@return OpencodeLspItem
95+
local function to_lsp_item(item, index, params)
96+
local source = require('opencode.ui.completion').get_source_by_name(item.source_name)
97+
local kind = (source and source.custom_kind) or vim.lsp.protocol.CompletionItemKind.Function ---@type lsp.CompletionItemKind
98+
local priority = source and source.priority or 999
99+
local line = params.position.line
100+
local col = params.position.character
101+
102+
---@type OpencodeLspItem
103+
local lsp_item = {
104+
label = (M.supports_kind_icons() and '' or item.kind_icon .. ' ') .. item.label,
105+
kind = kind,
106+
kind_hl = item.kind_hl,
107+
kind_icon = M.supports_kind_icons() and item.kind_icon or '',
108+
detail = item.detail,
109+
documentation = item.documentation and {
110+
kind = 'plaintext',
111+
value = item.documentation,
112+
} or nil,
113+
insertText = item.insert_text and item.insert_text ~= '' and item.insert_text or item.label,
114+
filterText = item.label,
115+
sortText = string.format('%02d_%02d_%02d_%s', priority, item.priority or 999, index, item.label),
116+
textEdit = {
117+
range = {
118+
start = { line = line, character = col },
119+
['end'] = { line = line, character = col },
120+
},
121+
newText = item.insert_text,
122+
},
123+
command = {
124+
title = 'opencode.completion_done',
125+
command = 'opencode.completion_done',
126+
arguments = { item },
127+
},
128+
data = {
129+
source_name = item.source_name,
130+
original_data = item.data,
131+
_opencode_item = item,
132+
},
133+
}
134+
135+
return lsp_item
136+
end
137+
138+
---Completion handler - provides completion items
139+
---@param params lsp.CompletionParams
140+
---@param callback fun(err?: lsp.ResponseError, result: lsp.CompletionItem[] | lsp.CompletionList)
141+
handlers[ms.textDocument_completion] = function(params, callback)
142+
local word, trigger_char, line = get_completion_context(params)
143+
144+
-- Build completion context
145+
---@type CompletionContext
146+
local completion_context = {
147+
input = word,
148+
trigger_char = trigger_char,
149+
line = line,
150+
cursor_pos = params.position.character,
151+
}
152+
153+
local completion = require('opencode.ui.completion')
154+
local sources = completion.get_sources()
155+
156+
local Promise = require('opencode.promise')
157+
local promises = {}
158+
159+
for _, source in ipairs(sources) do
160+
table.insert(promises, source.complete(completion_context))
161+
end
162+
163+
Promise.all(promises)
164+
:and_then(function(results)
165+
---@type OpencodeLspItem[]
166+
local all_items = {}
167+
local is_incomplete = false
168+
169+
for i, items in ipairs(results) do
170+
for _, item in ipairs(items or {}) do
171+
local source = completion.get_source_by_name(item.source_name)
172+
if source and source.is_incomplete then
173+
is_incomplete = true
174+
end
175+
176+
table.insert(all_items, to_lsp_item(item, i, params))
177+
end
178+
end
179+
180+
callback(nil, { isIncomplete = is_incomplete, items = all_items })
181+
end)
182+
:catch(function(err)
183+
local log = require('opencode.log')
184+
log.error('Error in completion handler: ' .. tostring(err))
185+
callback(nil, { isIncomplete = false, items = {} })
186+
end)
187+
end
188+
189+
---Create the LSP server configuration
190+
---@return vim.lsp.ClientConfig
191+
function M.create_config()
192+
return {
193+
name = 'opencode_ls',
194+
cmd = function(dispatchers, config)
195+
return {
196+
request = function(method, params, callback)
197+
if handlers[method] then
198+
handlers[method](params, callback)
199+
return
200+
end
201+
-- Ensure every request receives a response to avoid hanging the client.
202+
-- Use JSON-RPC "MethodNotFound" error code (-32601).
203+
callback({
204+
code = -32601,
205+
message = 'Method not found: ' .. tostring(method),
206+
}, nil)
207+
end,
208+
notify = function() end,
209+
is_closing = function()
210+
return false
211+
end,
212+
terminate = function() end,
213+
}
214+
end,
215+
root_dir = vim.fn.getcwd(),
216+
}
217+
end
218+
219+
---Start the LSP server for a buffer
220+
---@param bufnr integer
221+
---@return integer? client_id
222+
function M.start(bufnr)
223+
local config = M.create_config()
224+
local augroup = vim.api.nvim_create_augroup('OpencodeLspCompletion_' .. bufnr, { clear = true })
225+
226+
-- Handle completion done to trigger the action, some completion plugins do not trigger the command callback, so we use this as a fallback
227+
vim.api.nvim_create_autocmd('CompleteDonePre', {
228+
group = augroup,
229+
buffer = bufnr,
230+
callback = function()
231+
M._completion_done_handled = true
232+
local completed_item = vim.v.completed_item
233+
if completed_item and completed_item.user_data then
234+
local data = vim.tbl_get(completed_item, 'user_data', 'nvim', 'lsp', 'completion_item', 'data')
235+
or vim.tbl_get(completed_item, 'user_data', 'lsp', 'item', 'data')
236+
237+
local item = data and data._opencode_item
238+
239+
if item then
240+
require('opencode.ui.completion').on_completion_done(item)
241+
end
242+
end
243+
end,
244+
})
245+
return vim.lsp.start(config, { bufnr = bufnr, silent = false })
246+
end
247+
248+
return M

lua/opencode/promise.lua

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,4 +402,20 @@ function Promise.system(cmd, opts)
402402
return p
403403
end
404404

405+
---Wait for all promises to resolve
406+
---Returns a promise that resolves with a table of all results
407+
---If any promise rejects, the returned promise rejects with that error
408+
---@generic T
409+
---@param promises Promise<T>[]
410+
---@return Promise<T[]>
411+
function Promise.all(promises)
412+
return Promise.spawn(function()
413+
local results = {}
414+
for i, promise in ipairs(promises) do
415+
results[i] = promise:await()
416+
end
417+
return results
418+
end)
419+
end
420+
405421
return Promise

lua/opencode/types.lua

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,6 @@
198198

199199
---@class OpencodeConfig
200200
---@field preferred_picker 'telescope' | 'fzf' | 'mini.pick' | 'snacks' | 'select' | nil
201-
---@field preferred_completion 'blink' | 'nvim-cmp' | 'vim_complete' | nil -- Preferred completion strategy for mentons and commands
202201
---@field default_global_keymaps boolean
203202
---@field default_mode 'build' | 'plan' | string -- Default mode
204203
---@field default_system_prompt string | nil
@@ -414,6 +413,15 @@
414413
---@field priority number Priority for ordering sources
415414
---@field complete fun(context: CompletionContext): Promise<CompletionItem[]> Function to generate completion items
416415
---@field on_complete fun(item: CompletionItem): nil Optional callback when item is selected
416+
---@field is_incomplete? boolean Whether the completion results are incomplete (for sources that support pagination)
417+
---@field get_trigger_character? fun(): string|nil Optional function returning the trigger character for this source
418+
---@field custom_kind? integer Custom LSP CompletionItemKind registered for this source
419+
420+
---Extended LSP completion item with opencode-specific rendering fields
421+
---@class OpencodeLspItem : lsp.CompletionItem
422+
---@field kind lsp.CompletionItemKind
423+
---@field kind_hl? string Highlight group for the kind icon
424+
---@field kind_icon string Icon string for the kind
417425

418426
---@class OpencodeContext
419427
---@field current_file OpencodeContextFile|nil

0 commit comments

Comments
 (0)