From 7101af0d71aad447cc2ba5aa92edd036d546cd71 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Mon, 26 Oct 2026 14:53:56 -0400 Subject: [PATCH] feat: expose custom completion sources Add public registration for recipe-owned completion sources with asynchronous results, metadata, selection callbacks, and per-source failure isolation. Document a GitHub issue completion recipe. Refs #554 --- docs/extensions.md | 66 ++++++++++++ docs/recipes/README.md | 1 + docs/recipes/TEMPLATE.md | 4 +- docs/recipes/completion-source.md | 157 +++++++++++++++++++++++++++++ lua/opencode/init.lua | 13 +++ lua/opencode/lsp/opencode_ls.lua | 2 +- lua/opencode/types.lua | 4 +- lua/opencode/ui/completion.lua | 108 +++++++++++++++++--- tests/unit/completion_lsp_spec.lua | 130 ++++++++++++++++++++++++ tests/unit/init_spec.lua | 18 ++++ 10 files changed, 487 insertions(+), 16 deletions(-) create mode 100644 docs/recipes/completion-source.md diff --git a/docs/extensions.md b/docs/extensions.md index 92bd031b1..b3d3e7602 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -80,6 +80,72 @@ the command and returns whether it existed. See [Worktree sessions](recipes/worktree.md) for a practical command. +## Completion sources + +Recipes can add completion sources without modifying built-in sources or the +completion engine. Register sources before `setup()` or before opening the +first `opencode` buffer so custom trigger characters are advertised to the +completion client: + +```lua +local Promise = require('opencode.promise') +local opencode = require('opencode') + +opencode.register_completion_source({ + name = 'issue_references', + priority = 10, + get_trigger_character = function() + return '>' + end, + complete = Promise.async(function(ctx) + if ctx.trigger_char ~= '>' then + return {} + end + + return { + { + label = '>123 Fix login timeout', + kind = 'issue', + kind_icon = '', + detail = 'GitHub issue', + documentation = 'Insert an issue reference.', + insert_text = '123', + data = { number = 123 }, + }, + } + end), + on_complete = function(item) + vim.notify('Selected issue #' .. item.data.number) + end, +}) + +opencode.setup({}) +``` + +`name` must be unique. Built-in names (`commands`, `context`, `files`, +`skills`, and `subagents`) are reserved. `complete` receives a +`CompletionContext` with `input`, `trigger_char`, `line`, and `cursor_pos`, and +may return either a `Promise` or an item array. The registry +adds `source_name` to returned items so `on_complete` can be called after +selection. Item metadata includes `kind`, `kind_icon`, `kind_hl`, `detail`, +`documentation`, `insert_text`, `priority`, and arbitrary `data`. Set +source-level `custom_kind` to a numeric LSP completion kind when the default +function kind is not suitable. `insert_text` replaces text after the trigger +character; include the trigger in `label` but not in `insert_text`. + +Completion-source errors are logged and that source contributes no items; +other sources continue working. Selection callback errors are isolated the +same way. Set `is_incomplete = true` when a source expects the client to ask +for more results. Remove a recipe with: + +```lua +require('opencode').unregister_completion_source('issue_references') +``` + +The function returns `true` when a source was removed and `false` otherwise. +For a practical selection callback, see the [GitHub issue references recipe](recipes/completion-source.md), +which expands the selected issue into the visible draft. + ## Prompt guard `prompt_guard(mentioned_files)` must return a boolean. It receives a list of diff --git a/docs/recipes/README.md b/docs/recipes/README.md index d7e7e9a75..06a180e7e 100644 --- a/docs/recipes/README.md +++ b/docs/recipes/README.md @@ -13,6 +13,7 @@ plugin; read [Usage](../usage.md) first if you are new. | [Change-by-change review](change-by-change-review/README.md) | You use V1 snapshots and prefer diffview-plus and gitsigns | | [Review a session diff](review-session-diff.md) | You want to comment on V2 changes and send the comments with your next prompt | | [Quiet Blink completion](quiet-blink.md) | The completion menu pops up while you type a prompt | +| [GitHub issue references](completion-source.md) | You want issue references available from a custom completion source | ## Share a recipe diff --git a/docs/recipes/TEMPLATE.md b/docs/recipes/TEMPLATE.md index 35d6df47c..65e9de83a 100644 --- a/docs/recipes/TEMPLATE.md +++ b/docs/recipes/TEMPLATE.md @@ -30,7 +30,9 @@ Add a short recording here if it helps; otherwise leave it out. ## Caveats and undo Explain limitations, any destructive behavior, and how to remove the setup. -Link to relevant guides or API contracts rather than repeating them. +For extension recipes, include registration timing, cleanup, callback failure +behavior, and the relevant API contract instead of reaching into plugin +internals. ## Related diff --git a/docs/recipes/completion-source.md b/docs/recipes/completion-source.md new file mode 100644 index 000000000..f45477be7 --- /dev/null +++ b/docs/recipes/completion-source.md @@ -0,0 +1,157 @@ +# GitHub issue references + +[Recipes](README.md) / GitHub issue references + +Use a recipe-owned completion source to insert GitHub issue references while +writing an OpenCode prompt. + +## When to use it + +Use this when prompts frequently refer to issues in the current repository. +The built-in completion sources do not query GitHub, so this recipe adds a + small `>`-triggered source without changing built-in completion behavior. + +## Prerequisites + +- `gh` authenticated for the repository. +- OpenCode V1 or V2; completion runs before either protocol is used. +- An LSP-compatible completion client, or Neovim's native completion. + +## Setup + +Place this after `require('opencode')` is available and before the first +`opencode` buffer opens. `gh issue list` runs in the current working directory. + +```lua +local Promise = require('opencode.promise') +local opencode = require('opencode') + +local function issue_source() + return { + name = 'github_issue_references', + priority = 10, + get_trigger_character = function() + return '>' + end, + complete = Promise.async(function(ctx) + if ctx.trigger_char ~= '>' then + return {} + end + + local result = Promise.system({ + 'gh', + 'issue', + 'list', + '--state', + 'all', + '--limit', + '50', + '--json', + 'number,title', + }, { text = true }):await() + local issues = vim.json.decode(result.stdout) + local input = ctx.input:lower() + local items = {} + + for _, issue in ipairs(issues) do + local label = string.format('>%d %s', issue.number, issue.title) + if input == '' or label:lower():find(input, 1, true) then + table.insert(items, { + label = label, + kind = 'issue', + kind_icon = '', + detail = 'GitHub issue', + documentation = 'Insert #' .. issue.number .. ' into the prompt.', + insert_text = tostring(issue.number), + data = { number = issue.number }, + }) + end + end + + return items + end), + on_complete = function(item) + vim.schedule(function() + local buf = vim.api.nvim_get_current_buf() + local win = vim.api.nvim_get_current_win() + local cursor = vim.api.nvim_win_get_cursor(win) + local row, col = unpack(cursor) + local reference = '>' .. item.data.number + local line = vim.api.nvim_buf_get_lines(buf, row - 1, row, false)[1] + local start_col = col - #reference + if line:sub(col - #reference + 2, col + 1) == reference then + col = col + 1 + start_col = col - #reference + end + if start_col < 0 or line:sub(start_col + 1, col) ~= reference then + vim.notify('Could not locate selected issue reference', vim.log.levels.WARN) + return + end + + local tick = vim.api.nvim_buf_get_changedtick(buf) + vim.system({ 'gh', 'issue', 'view', tostring(item.data.number), '--json', 'number,title,body,url' }, { + text = true, + cwd = vim.fn.getcwd(), + }, vim.schedule_wrap(function(result) + if result.code ~= 0 then + vim.notify('Could not load GitHub issue: ' .. (result.stderr or ''), vim.log.levels.WARN) + return + end + local ok, issue = pcall(vim.json.decode, result.stdout) + if not ok then + vim.notify('Could not decode GitHub issue', vim.log.levels.WARN) + return + end + if not vim.api.nvim_buf_is_valid(buf) or vim.api.nvim_buf_get_changedtick(buf) ~= tick then + vim.notify('Issue not expanded: draft changed while loading', vim.log.levels.WARN) + return + end + + local body = issue.body or '' + if vim.fn.strchars(body) > 10000 then + body = vim.fn.strcharpart(body, 0, 10000) .. '\n\n[Issue description truncated]' + end + local text = ('GitHub issue #%s: %s\n%s\n\n%s'):format(issue.number, issue.title, issue.url, body) + local lines = vim.split(text, '\n', { plain = true }) + local move_cursor = vim.api.nvim_get_current_win() == win + and vim.api.nvim_win_is_valid(win) + and vim.api.nvim_win_get_buf(win) == buf + and vim.deep_equal(vim.api.nvim_win_get_cursor(win), cursor) + vim.api.nvim_buf_set_text(buf, row - 1, start_col, row - 1, col, lines) + if move_cursor then + vim.api.nvim_win_set_cursor(win, { row + #lines - 1, #lines[#lines] }) + end + end)) + end) + end, + } +end + +opencode.register_completion_source(issue_source()) +opencode.setup({}) +``` + +## Try it + +1. Open an `opencode` input buffer in a GitHub checkout. +2. Type `>` or `>login`. +3. Select an issue. Its title, URL, and description replace `>` in the + draft. Review the text before sending. + +## Caveats and undo + +An unavailable or failed `gh` command is isolated by the completion registry, +so the source returns no items while built-in sources remain available. The +source is queried on each completion request and is intentionally limited to +the first 50 issues. Descriptions longer than 10,000 characters are truncated. +If issue loading fails or the draft changes while loading, the reference stays +in place and a warning is shown; no context is attached silently. + +Remove it with: + +```lua +require('opencode').unregister_completion_source('github_issue_references') +``` + +See [Completion sources](../extensions.md#completion-sources) for the stable +source contract. diff --git a/lua/opencode/init.lua b/lua/opencode/init.lua index 8f1037e19..e140d5061 100644 --- a/lua/opencode/init.lua +++ b/lua/opencode/init.lua @@ -9,6 +9,19 @@ local slash_registry = require('opencode.services.slash_registry') M.register_slash_command = slash_registry.register M.unregister_slash_command = slash_registry.unregister +---Register a recipe-owned completion source. +---@param source CompletionSource +function M.register_completion_source(source) + return require('opencode.ui.completion').register_source(source) +end + +---Unregister a recipe-owned completion source. +---@param name string +---@return boolean +function M.unregister_completion_source(name) + return require('opencode.ui.completion').unregister_source(name) +end + local function on_opencode_server() require('opencode.ui.permission_window').clear_all() end diff --git a/lua/opencode/lsp/opencode_ls.lua b/lua/opencode/lsp/opencode_ls.lua index f0f12b261..2564166c4 100644 --- a/lua/opencode/lsp/opencode_ls.lua +++ b/lua/opencode/lsp/opencode_ls.lua @@ -162,7 +162,7 @@ handlers[ms.textDocument_completion] = function(params, callback) local promises = {} for _, source in ipairs(sources) do - table.insert(promises, source.complete(completion_context)) + table.insert(promises, completion.complete_source(source, completion_context)) end Promise.all(promises) diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index f1a2c0f9b..0a18bb772 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -591,8 +591,8 @@ ---@class CompletionSource ---@field name string Name of the completion source ----@field priority number Priority for ordering sources ----@field complete fun(context: CompletionContext): Promise Function to generate completion items +---@field priority? number Priority for ordering sources +---@field complete fun(context: CompletionContext): Promise|CompletionItem[] Function to generate completion items ---@field on_complete? fun(item: CompletionItem): nil Callback when item is selected ---@field is_incomplete? boolean Whether the completion results are incomplete (for sources that support pagination) ---@field get_trigger_character? fun(): string|nil Optional function returning the trigger character for this source diff --git a/lua/opencode/ui/completion.lua b/lua/opencode/ui/completion.lua index 17c51df7e..32aa3f12d 100644 --- a/lua/opencode/ui/completion.lua +++ b/lua/opencode/ui/completion.lua @@ -3,6 +3,48 @@ local M = { _sources = {}, } +local builtin_source_names = { + commands = true, + context = true, + files = true, + skills = true, + subagents = true, +} + +local function sort_sources() + table.sort(M._sources, function(a, b) + return (a.priority or 0) > (b.priority or 0) + end) +end + +---@param source CompletionSource +---@param allow_builtin boolean +local function register_source(source, allow_builtin) + if not source.name or source.name == '' then + error('Completion source name is required') + end + if not source.complete then + error('Completion source must provide a complete function') + end + if not allow_builtin and builtin_source_names[source.name] then + error('Completion source already exists: ' .. source.name) + end + if M.get_source_by_name(source.name) then + if allow_builtin then + return + end + error('Completion source already exists: ' .. source.name) + end + + table.insert(M._sources, source) + sort_sources() +end + +---@param source CompletionSource +local function report_source_error(source, err) + require('opencode.log').error('Completion source "' .. source.name .. '" failed: ' .. tostring(err)) +end + ---@param opts? { execute_slash_command?: fun(slash_cmd: string, args: string[]|nil): any } function M.setup(opts) local files_source = require('opencode.ui.completion.files') @@ -11,22 +53,23 @@ function M.setup(opts) local context_source = require('opencode.ui.completion.context') local skills_source = require('opencode.ui.completion.skills') - M.register_source(files_source.get_source()) - M.register_source(subagents_source.get_source()) - M.register_source(commands_source.get_source(opts and opts.execute_slash_command)) - M.register_source(context_source.get_source()) - M.register_source(skills_source.get_source()) - - table.sort(M._sources, function(a, b) - return (a.priority or 0) > (b.priority or 0) - end) + register_source(files_source.get_source(), true) + register_source(subagents_source.get_source(), true) + register_source(commands_source.get_source(opts and opts.execute_slash_command), true) + register_source(context_source.get_source(), true) + register_source(skills_source.get_source(), true) end function M.get_trigger_characters() local triggers = {} for _, source in ipairs(M._sources) do if source.get_trigger_character then - table.insert(triggers, source.get_trigger_character()) + local ok, trigger = pcall(source.get_trigger_character) + if ok then + table.insert(triggers, trigger) + else + report_source_error(source, trigger) + end end end return triggers @@ -35,7 +78,20 @@ end ---Register a completion source ---@param source CompletionSource function M.register_source(source) - table.insert(M._sources, source) + register_source(source, false) +end + +---Unregister a completion source. +---@param name string +---@return boolean +function M.unregister_source(name) + for index, source in ipairs(M._sources) do + if source.name == name then + table.remove(M._sources, index) + return true + end + end + return false end ---@return CompletionSource[] @@ -54,6 +110,31 @@ function M.get_source_by_name(name) return nil end +---Run one completion source without allowing it to affect other sources. +---@param source CompletionSource +---@param context CompletionContext +---@return Promise +function M.complete_source(source, context) + local Promise = require('opencode.promise') + local ok, result = pcall(source.complete, context) + if not ok then + report_source_error(source, result) + return Promise.new():resolve({}) + end + + return Promise.wrap(result) + :and_then(function(items) + for _, item in ipairs(items) do + item.source_name = source.name + end + return items + end) + :catch(function(err) + report_source_error(source, err) + return {} + end) +end + ---Call the on_completion_done method for a completion item ---@param item CompletionItem function M.on_completion_done(item) @@ -63,7 +144,10 @@ function M.on_completion_done(item) for _, source in ipairs(M._sources) do if source.name == item.source_name and source.on_complete then - source.on_complete(item) + local ok, err = pcall(source.on_complete, item) + if not ok then + report_source_error(source, err) + end break end end diff --git a/tests/unit/completion_lsp_spec.lua b/tests/unit/completion_lsp_spec.lua index f682e3d24..ae8955537 100644 --- a/tests/unit/completion_lsp_spec.lua +++ b/tests/unit/completion_lsp_spec.lua @@ -89,6 +89,26 @@ describe('opencode LSP completion', function() local sources = completion.get_sources() assert.are.equal(2, #sources) end) + + it('rejects duplicate and builtin source names', function() + local source = { name = 'source1', priority = 1, complete = function() end } + completion.register_source(source) + + assert.has_error(function() + completion.register_source({ name = 'source1', priority = 2, complete = function() end }) + end) + assert.has_error(function() + completion.register_source({ name = 'files', priority = 2, complete = function() end }) + end) + end) + + it('unregisters sources and reports whether they existed', function() + completion.register_source({ name = 'source1', priority = 1, complete = function() end }) + + assert.is_true(completion.unregister_source('source1')) + assert.is_false(completion.unregister_source('source1')) + assert.is_nil(completion.get_source_by_name('source1')) + end) end) describe('get_source_by_name', function() @@ -229,6 +249,24 @@ describe('opencode LSP completion', function() completion.on_completion_done(item) end) end) + + it('isolates selection callback failures', function() + package.loaded['opencode.log'] = { error = function() end } + completion._sources = {} + completion.register_source({ + name = 'failing_callback', + priority = 1, + complete = function() end, + on_complete = function() + error('selection failed') + end, + }) + + assert.has_no.errors(function() + completion.on_completion_done({ source_name = 'failing_callback', label = 'test' }) + end) + package.loaded['opencode.log'] = nil + end) end) end) @@ -586,6 +624,98 @@ describe('opencode LSP completion', function() assert.is_not_nil(callback_result) package.loaded['opencode.log'] = nil end) + + it('keeps healthy sources when another source fails', function() + package.loaded['opencode.log'] = { error = function() end } + completion.register_source({ + name = 'error_source', + priority = 2, + complete = function() + return Promise.new():reject('test error') + end, + get_trigger_character = function() + return '@' + end, + }) + completion.register_source({ + name = 'healthy_source', + priority = 1, + complete = function() + return Promise.new():resolve({ + { + label = 'HealthyItem', + kind = 'reference', + kind_icon = '', + insert_text = 'HealthyItem', + detail = 'Healthy source', + documentation = 'Healthy item', + data = {}, + }, + }) + end, + get_trigger_character = function() + return '@' + end, + }) + + local config_obj = ls.create_config() + local server = config_obj.cmd({}, {}) + local callback_result + server.request('textDocument/completion', { + position = { line = 0, character = 5 }, + }, function(err, result) + callback_result = result + end) + + vim.wait(200, function() + return callback_result ~= nil + end) + + assert.is_not_nil(callback_result) + assert.are.equal(1, #callback_result.items) + assert.are.equal('HealthyItem', callback_result.items[1].filterText) + package.loaded['opencode.log'] = nil + end) + + it('assigns source names to returned items', function() + completion.register_source({ + name = 'metadata_source', + priority = 1, + complete = function() + return Promise.new():resolve({ + { + label = 'MetadataItem', + kind = 'reference', + kind_icon = '', + insert_text = 'MetadataItem', + detail = 'Metadata source', + documentation = 'Metadata item', + data = { id = 42 }, + }, + }) + end, + get_trigger_character = function() + return '@' + end, + }) + + local config_obj = ls.create_config() + local server = config_obj.cmd({}, {}) + local callback_result + server.request('textDocument/completion', { + position = { line = 0, character = 5 }, + }, function(err, result) + callback_result = result + end) + + vim.wait(200, function() + return callback_result ~= nil + end) + + local item = callback_result.items[1].data._opencode_item + assert.are.equal('metadata_source', item.source_name) + assert.are.equal(42, item.data.id) + end) end) describe('workspace_executeCommand handler', function() diff --git a/tests/unit/init_spec.lua b/tests/unit/init_spec.lua index 68173164a..2c66339de 100644 --- a/tests/unit/init_spec.lua +++ b/tests/unit/init_spec.lua @@ -8,6 +8,24 @@ describe('opencode', function() assert.is_function(opencode.setup) end) + it('exposes completion source registration in the public API', function() + assert.is_function(opencode.register_completion_source) + assert.is_function(opencode.unregister_completion_source) + end) + + it('registers and unregisters completion sources through the public API', function() + local source = { + name = 'public_api_source', + complete = function() + return {} + end, + } + + opencode.register_completion_source(source) + assert.are.equal(source, require('opencode.ui.completion').get_source_by_name(source.name)) + assert.is_true(opencode.unregister_completion_source(source.name)) + end) + -- The old opencode_command function has been replaced, so we're just testing -- that the main module exists and can be required it('main module can be required without errors', function()