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
66 changes: 66 additions & 0 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<CompletionItem[]>` 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
Expand Down
1 change: 1 addition & 0 deletions docs/recipes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion docs/recipes/TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
157 changes: 157 additions & 0 deletions docs/recipes/completion-source.md
Original file line number Diff line number Diff line change
@@ -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 `><number>` 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.
13 changes: 13 additions & 0 deletions lua/opencode/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lua/opencode/lsp/opencode_ls.lua
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
---Initialize handler - negotiates capabilities with the client
---@param params lsp.InitializeParams
---@param callback fun(err?: lsp.ResponseError, result: lsp.InitializeResult)
handlers[ms.initialize] = function(params, callback)

Check notice on line 13 in lua/opencode/lsp/opencode_ls.lua

View workflow job for this annotation

GitHub Actions / typecheck

params is never used, if this is intentional, prefix it with an underscore: _params
local completion = require('opencode.ui.completion')
local triggers = completion.get_trigger_characters()

Expand Down Expand Up @@ -162,7 +162,7 @@
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)
Expand Down Expand Up @@ -196,7 +196,7 @@
function M.create_config()
return {
name = 'opencode_ls',
cmd = function(dispatchers, config)

Check notice on line 199 in lua/opencode/lsp/opencode_ls.lua

View workflow job for this annotation

GitHub Actions / typecheck

dispatchers is never used, if this is intentional, prefix it with an underscore: _dispatchers

Check notice on line 199 in lua/opencode/lsp/opencode_ls.lua

View workflow job for this annotation

GitHub Actions / typecheck

config is never used, if this is intentional, prefix it with an underscore: _config
return {
request = function(method, params, callback)
if handlers[method] then
Expand Down
4 changes: 2 additions & 2 deletions lua/opencode/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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<CompletionItem[]> Function to generate completion items
---@field priority? number Priority for ordering sources
---@field complete fun(context: CompletionContext): Promise<CompletionItem[]>|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
Expand Down
Loading
Loading