Skip to content

Commit b575131

Browse files
authored
feat: add code reference picker for navigating file references in AI responses (#151)
* feat: adds code reference picker to navigate file:line references in LLM responses * feat(config): add system prompt for file URI navigation - introduce a new configurable system prompt to customize assistant behavior - include guidance for file referencing to assist users with integration - modifies message sending logic to incorporate the system prompt if set - changes to reference detection streamline navigation for file-related responses * fix(reference_picker): clean up display formatting - Remove dead code referencing non-existent ref.context field - Only show line number in display when present (avoid 'path:?') * feat(references): parse references on message completion and display icons - Parse file references when session becomes idle (AI done responding) - Cache parsed references per-message for faster picker opening - Handle session load from previous sessions - Display reference icon before recognized file paths in output - Subscribe to 'messages' state changes to parse loaded sessions - Update test expected data files with new icon rendering * refactor(references): simplify to only support file:// URIs Remove legacy path:line and path-only pattern detection in favor of exclusively parsing file:// URIs. This simplifies the code significantly and avoids false positives from ambiguous path patterns. The system prompt instructs the LLM to use file:// URIs, making the legacy patterns unnecessary. * docs(config): clarify system prompt behavior in config.lua - refine explanation for system prompt usage in assistant responses - emphasize file URI scheme for better navigation and tooling integration * feat(config): update system prompt for file referencing in responses - remove old system prompt setting in configuration - replace it with a hardcoded prompt including URI format examples - modifies behavior to improve navigation and tooling integration * feat(ui): enhance file URI parsing to support line ranges - add support for line range format: file://path:start-end - modify regex pattern to accommodate optional end line numbers - update reference structure to include optional end_pos for highlighting * fix(core): update guidance for file reference formatting - enhance clarity in the instructions for referencing files - emphasize the use of the file:// URI scheme as critical - provide examples to illustrate correct and incorrect formats - ensure compatibility with the existing reference picker for navigation * fix(ui): enhance reference formatting in picker - improve reference location display by including line and column info - handle cases where end position is available for better accuracy - simplify display logic to ensure consistent output for references * feat(ui): add preview support to telescope picker - populate entry path, lnum, and col in telescope entries to support previewers - enable file previewer configuration when options request file preview - ensures code references can be previewed directly in the telescope picker UI * fix(core): mandate backticks for file references in system prompt - update system prompt to explicitly require backticks around file:// URIs - add examples distinguishing correct backticked format from incorrect ones - ensures file references are consistently formatted for UI and parsing * feat(readme): update key bindings and add functionality details - add key binding for timeline picker with `<leader>oT` - include new key binding for browsing code references with `gr` - clarify command usage for creating and selecting sessions - touches(config): update default keyboard shortcuts for new features * test: regenerate expected snapshots after upstream merge Icon characters changed in upstream, requiring test snapshot updates * feat(ui): fix icon merge * test: update snapshots after icon fix * feat: add telescope support for code range highlighting Telescope supports highlighting line ranges in the file previewer using the lnend field. Map end_pos[1] to lnend so that references with line ranges (file://path:start-end) get highlighted in Telescope previewer. * fix: use vim_buffer_vimgrep previewer for range highlighting The default file_previewer uses the 'cat' previewer which doesn't call jump_to_line, so lnend field is ignored. Switch to vim_buffer_vimgrep which properly highlights line ranges using the lnend field. * chore: trigger CI rerun * feat: add fzf-lua file preview and line navigation support Add builtin previewer support to fzf-lua picker backend with automatic line/column positioning for code references. Changes: - Enable 'builtin' previewer when preview='file' - Append file:line:col: format to entries for fzf-lua parsing - Update fn_fzf_index to strip position info before matching - Supports navigation to specific lines (range highlighting shows start line only) Implementation details: - Uses tab separator between display text and file position - fzf-lua's entry_to_file() automatically parses path:line:col: format - Cursorline highlights the target line in preview - Gracefully degrades when preview is disabled Comparison with other pickers: - Telescope: Full range highlighting (start-end lines) - Snacks: Full range highlighting (start-end lines) - fzf-lua: Single line highlighting (cursorline at start) - Mini.pick: No file preview support * fix: use nbsp separator for fzf-lua entries (not tab) fzf-lua uses nbsp (U+2002 EN SPACE) as the standard separator between display text and file path info, not tab. The builtin previewer parses entries by splitting on utils.nbsp and extracting the path:line:col: portion. From fzf-lua/path.lua entry_to_file(): local parts = utils.strsplit(entry, utils.nbsp) for i = 1, #parts - 1 do if s:match(".-:%d+:") then break end idx0 = idx0 + #s + #utils.nbsp end return entry:sub(idx0), idx0 Changes: - Use nbsp (\xe2\x80\x82) instead of \t as separator - Update fn_fzf_index to split by nbsp - Use file_path (relative) before file (absolute) for correct resolution This fixes 'Unable to stat file' errors in preview.
1 parent db91a85 commit b575131

36 files changed

+426
-35
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,8 @@ The plugin provides the following actions that can be triggered via keymaps, com
353353
| Close UI windows | `<leader>oq` | `:Opencode close` | `require('opencode.api').close()` |
354354
| Select and load session | `<leader>os` | `:Opencode session select` | `require('opencode.api').select_session()` |
355355
| **Select and load child session** | `<leader>oS` | `:Opencode session select_child` | `require('opencode.api').select_child_session()` |
356-
| Open timeline picker (navigate/undo/redo/fork to message) | - | `:Opencode timeline` | `require('opencode.api').timeline()` |
356+
| Open timeline picker (navigate/undo/redo/fork to message) | `<leader>oT` | `:Opencode timeline` | `require('opencode.api').timeline()` |
357+
| Browse code references from conversation | `gr` (window) | `:Opencode references` / `/references` | `require('opencode.api').references()` |
357358
| Configure provider and model | `<leader>op` | `:Opencode configure provider` | `require('opencode.api').configure_provider()` |
358359
| Open diff view of changes | `<leader>od` | `:Opencode diff open` | `require('opencode.api').diff_open()` |
359360
| Navigate to next file diff | `<leader>o]` | `:Opencode diff next` | `require('opencode.api').diff_next()` |

lua/opencode/api.lua

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,10 @@ function M.focus_input()
307307
ui.focus_input({ restore_position = true, start_insert = true })
308308
end
309309

310+
function M.references()
311+
require('opencode.ui.reference_picker').pick()
312+
end
313+
310314
function M.debug_output()
311315
if not config.debug.enabled then
312316
vim.notify('Debugging is not enabled in the config', vim.log.levels.WARN)
@@ -1228,6 +1232,10 @@ M.commands = {
12281232
desc = 'Paste image from clipboard and add to context',
12291233
fn = M.paste_image,
12301234
},
1235+
references = {
1236+
desc = 'Browse code references from conversation',
1237+
fn = M.references,
1238+
},
12311239
}
12321240

12331241
M.slash_commands_map = {
@@ -1245,6 +1253,7 @@ M.slash_commands_map = {
12451253
['/sessions'] = { fn = M.select_session, desc = 'Select session' },
12461254
['/share'] = { fn = M.share, desc = 'Share current session' },
12471255
['/timeline'] = { fn = M.timeline, desc = 'Open timeline picker' },
1256+
['/references'] = { fn = M.references, desc = 'Browse code references from conversation' },
12481257
['/undo'] = { fn = M.undo, desc = 'Undo last action' },
12491258
['/unshare'] = { fn = M.unshare, desc = 'Unshare current session' },
12501259
['/rename'] = { fn = M.rename_session, desc = 'Rename current session' },

lua/opencode/config.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ M.defaults = {
5151
['[['] = { 'prev_message' },
5252
['<tab>'] = { 'toggle_pane', mode = { 'n', 'i' } },
5353
['i'] = { 'focus_input' },
54+
['gr'] = { 'references', desc = 'Browse code references' },
5455
['<leader>oS'] = { 'select_child_session' },
5556
['<leader>oD'] = { 'debug_message' },
5657
['<leader>oO'] = { 'debug_output' },
@@ -69,6 +70,7 @@ M.defaults = {
6970
['<up>'] = { 'prev_prompt_history', mode = { 'n', 'i' } },
7071
['<down>'] = { 'next_prompt_history', mode = { 'n', 'i' } },
7172
['<M-m>'] = { 'switch_mode', mode = { 'n', 'i' } },
73+
['gr'] = { 'references', desc = 'Browse code references' },
7274
['<leader>oS'] = { 'select_child_session' },
7375
['<leader>oD'] = { 'debug_message' },
7476
['<leader>oO'] = { 'debug_output' },

lua/opencode/core.lua

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,21 @@ M.send_message = Promise.async(function(prompt, opts)
167167
state.current_mode = opts.agent
168168
end
169169

170+
params.system = [[
171+
# Code References
172+
173+
**CRITICAL: Always use the file:// URI scheme when referencing files in responses AND wrap them in backticks.**
174+
175+
Format: `file://path/to/file.lua`, `file://path/to/file.lua:42`, or `file://path/to/file.lua:42-50`
176+
177+
Examples:
178+
- CORRECT: "The error is in `file://src/services/process.ts:712`"
179+
- INCORRECT: "The error is in file://src/services/process.ts:712"
180+
- INCORRECT: "The error is in src/services/process.ts:712"
181+
182+
This matches the file:// URI format that the reference picker already parses from your responses, enabling automatic navigation.
183+
]]
184+
170185
params.parts = context.format_message(prompt, opts.context)
171186
M.before_run(opts)
172187

lua/opencode/init.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ function M.setup(opts)
1616
require('opencode.event_manager').setup()
1717
require('opencode.context').setup()
1818
require('opencode.ui.context_bar').setup()
19+
require('opencode.ui.reference_picker').setup()
1920
end
2021

2122
return M

lua/opencode/types.lua

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@
289289
---@class OpencodeMessage
290290
---@field info MessageInfo Metadata about the message
291291
---@field parts OpencodeMessagePart[] Parts that make up the message
292+
---@field references CodeReference[]|nil Parsed file references from text parts (cached)
292293

293294
---@class MessageInfo
294295
---@field id string Unique message identifier
@@ -488,3 +489,13 @@
488489
---@field messages number Number of messages reverted
489490
---@field tool_calls number Number of tool calls reverted
490491
---@field files table<string, {additions: number, deletions: number}> Summary of file changes reverted
492+
493+
---@class CodeReference
494+
---@field file_path string Relative or absolute file path
495+
---@field line number|nil Line number (1-indexed)
496+
---@field column number|nil Column number (optional)
497+
---@field message_id string ID of the message containing this reference
498+
---@field match_start number Start position of match in original text
499+
---@field match_end number End position of match in original text
500+
---@field file string Absolute file path (for Snacks picker preview)
501+
---@field pos number[]|nil Position as {line, col} for Snacks picker preview

lua/opencode/ui/base_picker.lua

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ local Promise = require('opencode.promise')
1717
---@field title string|fun(): string The picker title
1818
---@field width? number Optional width for the picker (defaults to config or current window width)
1919
---@field multi_selection? table<string, boolean> Actions that support multi-selection
20+
---@field preview? "file"|"none"|false Preview mode: "file" for file preview, "none" or false to disable
2021

2122
---@class TelescopeEntry
2223
---@field value any
@@ -89,14 +90,28 @@ local function telescope_ui(opts)
8990
---@param item any
9091
---@return TelescopeEntry
9192
local function make_entry(item)
92-
return {
93+
local entry = {
9394
value = item,
9495
display = function(entry)
9596
local formatted = opts.format_fn(entry.value):to_formatted_text()
9697
return displayer(formatted)
9798
end,
9899
ordinal = opts.format_fn(item):to_string(),
99100
}
101+
102+
if type(item) == 'table' then
103+
entry.path = item.file or item.file_path or item.path or item.filename
104+
entry.lnum = item.line or item.lnum
105+
entry.col = item.column or item.col
106+
-- Support line ranges for preview highlighting
107+
if item.end_pos and type(item.end_pos) == 'table' and item.end_pos[1] then
108+
entry.lnend = item.end_pos[1]
109+
end
110+
elseif type(item) == 'string' then
111+
entry.path = item
112+
end
113+
114+
return entry
100115
end
101116

102117
local function refresh_picker()
@@ -111,6 +126,7 @@ local function telescope_ui(opts)
111126
prompt_title = opts.title,
112127
finder = finders.new_table({ results = opts.items, entry_maker = make_entry }),
113128
sorter = conf.generic_sorter({}),
129+
previewer = opts.preview == 'file' and require('telescope.previewers').vim_buffer_vimgrep.new({}) or nil,
114130
layout_config = opts.width and {
115131
width = opts.width + 7, -- extra space for telescope UI
116132
} or nil,
@@ -193,9 +209,15 @@ local function fzf_ui(opts)
193209
['--multi'] = has_multi_action and true or nil,
194210
},
195211
_headers = { 'actions' },
212+
-- Enable builtin previewer for file preview support
213+
previewer = opts.preview == 'file' and 'builtin' or nil,
196214
fn_fzf_index = function(line)
215+
-- Strip the appended file:line:col info before matching
216+
-- fzf-lua uses nbsp (U+2002 EN SPACE) as separator
217+
local nbsp = '\xe2\x80\x82'
218+
local display_part = line:match('^([^' .. nbsp .. ']+)') or line
197219
for i, item in ipairs(opts.items) do
198-
if opts.format_fn(item):to_string() == line then
220+
if opts.format_fn(item):to_string() == display_part then
199221
return i
200222
end
201223
end
@@ -207,7 +229,33 @@ local function fzf_ui(opts)
207229
local function create_finder()
208230
return function(fzf_cb)
209231
for _, item in ipairs(opts.items) do
210-
fzf_cb(opts.format_fn(item):to_string())
232+
local line_str = opts.format_fn(item):to_string()
233+
234+
-- For file preview support, append file:line:col format
235+
-- fzf-lua's builtin previewer automatically parses this format
236+
if opts.preview == 'file' and type(item) == 'table' then
237+
local file_path = item.file_path or item.path or item.filename or item.file
238+
local line = item.line or item.lnum
239+
local col = item.column or item.col
240+
241+
if file_path then
242+
-- fzf-lua parses "path:line:col:" format for preview positioning
243+
local pos_info = file_path
244+
if line then
245+
pos_info = pos_info .. ':' .. tostring(line)
246+
if col then
247+
pos_info = pos_info .. ':' .. tostring(col)
248+
end
249+
pos_info = pos_info .. ':'
250+
end
251+
-- Append position info after nbsp separator (fzf-lua standard)
252+
-- nbsp is U+2002 EN SPACE, not regular tab
253+
local nbsp = '\xe2\x80\x82'
254+
line_str = line_str .. nbsp .. pos_info
255+
end
256+
end
257+
258+
fzf_cb(line_str)
211259
end
212260
fzf_cb()
213261
end
@@ -341,15 +389,23 @@ end
341389
local function snacks_picker_ui(opts)
342390
local Snacks = require('snacks')
343391

392+
-- Determine if preview is enabled
393+
local has_preview = opts.preview == 'file'
394+
395+
-- Choose layout preset based on preview
396+
local layout_preset = has_preview and 'default' or 'select'
397+
344398
local snack_opts = {
345399
title = opts.title,
346400
layout = {
347-
preset = 'select',
401+
preset = layout_preset,
348402
config = function(layout)
349403
local width = opts.width and (opts.width + 3) or nil -- extra space for snacks UI
350-
layout.layout.width = width
351-
layout.layout.max_width = width
352-
layout.layout.min_width = width
404+
if not has_preview then
405+
layout.layout.width = width
406+
layout.layout.max_width = width
407+
layout.layout.min_width = width
408+
end
353409
return layout
354410
end,
355411
},
@@ -378,6 +434,11 @@ local function snacks_picker_ui(opts)
378434
},
379435
}
380436

437+
-- Add file preview if enabled
438+
if has_preview then
439+
snack_opts.preview = 'file'
440+
end
441+
381442
snack_opts.win = snack_opts.win or {}
382443
snack_opts.win.input = snack_opts.win.input or { keys = {} }
383444

lua/opencode/ui/formatter.lua

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -466,8 +466,39 @@ end
466466
---@param output Output Output object to write to
467467
---@param text string
468468
function M._format_assistant_message(output, text)
469-
-- output:add_empty_line()
470-
output:add_lines(vim.split(text, '\n'))
469+
local reference_picker = require('opencode.ui.reference_picker')
470+
local references = reference_picker.parse_references(text, '')
471+
472+
-- If no references, just add the text as-is
473+
if #references == 0 then
474+
output:add_lines(vim.split(text, '\n'))
475+
return
476+
end
477+
478+
-- Sort references by match_start position (ascending)
479+
table.sort(references, function(a, b)
480+
return a.match_start < b.match_start
481+
end)
482+
483+
-- Build a new text with icons inserted before each reference
484+
local result = ''
485+
local last_pos = 1
486+
local ref_icon = icons.get('reference')
487+
488+
for _, ref in ipairs(references) do
489+
-- Add text before this reference
490+
result = result .. text:sub(last_pos, ref.match_start - 1)
491+
-- Add the icon and the reference
492+
result = result .. ref_icon .. text:sub(ref.match_start, ref.match_end)
493+
last_pos = ref.match_end + 1
494+
end
495+
496+
-- Add any remaining text after the last reference
497+
if last_pos <= #text then
498+
result = result .. text:sub(last_pos)
499+
end
500+
501+
output:add_lines(vim.split(result, '\n'))
471502
end
472503

473504
---@param output Output Output object to write to

lua/opencode/ui/highlight.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ function M.setup()
3434
vim.api.nvim_set_hl(0, 'OpencodeContextSwitchOn', { link = '@label', default = true })
3535
vim.api.nvim_set_hl(0, 'OpencodePickerTime', { link = 'Comment', default = true })
3636
vim.api.nvim_set_hl(0, 'OpencodeDebugText', { link = 'Comment', default = true })
37+
vim.api.nvim_set_hl(0, 'OpencodeReference', { fg = '#1976D2', default = true })
3738
vim.api.nvim_set_hl(0, 'OpencodeReasoningText', { link = 'Comment', default = true })
3839
else
3940
vim.api.nvim_set_hl(0, 'OpencodeBorder', { fg = '#616161', default = true })
@@ -65,6 +66,7 @@ function M.setup()
6566
vim.api.nvim_set_hl(0, 'OpencodeContextSwitchOn', { link = '@label', default = true })
6667
vim.api.nvim_set_hl(0, 'OpencodePickerTime', { link = 'Comment', default = true })
6768
vim.api.nvim_set_hl(0, 'OpencodeDebugText', { link = 'Comment', default = true })
69+
vim.api.nvim_set_hl(0, 'OpencodeReference', { fg = '#7AA2F7', default = true })
6870
vim.api.nvim_set_hl(0, 'OpencodeReasoningText', { link = 'Comment', default = true })
6971
end
7072
end

lua/opencode/ui/icons.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ local presets = {
2525
folder = '',
2626
attached_file = '󰌷 ',
2727
agent = '󰚩 ',
28+
reference = '',
2829
reasoning = '󰧑 ',
2930
-- statuses
3031
status_on = '',
@@ -61,6 +62,7 @@ local presets = {
6162
folder = '[@]',
6263
attached_file = '@',
6364
agent = '@',
65+
reference = '@',
6466
-- statuses
6567
status_on = 'ON',
6668
status_off = 'OFF',

0 commit comments

Comments
 (0)