Skip to content

Commit 4fee569

Browse files
committed
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
1 parent be83805 commit 4fee569

File tree

8 files changed

+96
-21
lines changed

8 files changed

+96
-21
lines changed

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: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@
285285
---@class OpencodeMessage
286286
---@field info MessageInfo Metadata about the message
287287
---@field parts OpencodeMessagePart[] Parts that make up the message
288+
---@field references CodeReference[]|nil Parsed file references from text parts (cached)
288289

289290
---@class MessageInfo
290291
---@field id string Unique message identifier
@@ -488,7 +489,8 @@
488489
---@field file_path string Relative or absolute file path
489490
---@field line number|nil Line number (1-indexed)
490491
---@field column number|nil Column number (optional)
491-
---@field context string Surrounding text for display in picker
492492
---@field message_id string ID of the message containing this reference
493493
---@field match_start number Start position of match in original text
494494
---@field match_end number End position of match in original text
495+
---@field file string Absolute file path (for Snacks picker preview)
496+
---@field pos number[]|nil Position as {line, col} for Snacks picker preview

lua/opencode/ui/formatter.lua

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -426,9 +426,39 @@ end
426426
---@param output Output Output object to write to
427427
---@param text string
428428
function M._format_assistant_message(output, text)
429-
-- Simply add the text as-is
430-
-- Reference detection is handled by the reference_picker module for navigation
431-
output:add_lines(vim.split(text, '\n'))
429+
local reference_picker = require('opencode.ui.reference_picker')
430+
local references = reference_picker.parse_references(text, '')
431+
432+
-- If no references, just add the text as-is
433+
if #references == 0 then
434+
output:add_lines(vim.split(text, '\n'))
435+
return
436+
end
437+
438+
-- Sort references by match_start position (ascending)
439+
table.sort(references, function(a, b)
440+
return a.match_start < b.match_start
441+
end)
442+
443+
-- Build a new text with icons inserted before each reference
444+
local result = ''
445+
local last_pos = 1
446+
local ref_icon = icons.get('reference')
447+
448+
for _, ref in ipairs(references) do
449+
-- Add text before this reference
450+
result = result .. text:sub(last_pos, ref.match_start - 1)
451+
-- Add the icon and the reference
452+
result = result .. ref_icon .. text:sub(ref.match_start, ref.match_end)
453+
last_pos = ref.match_end + 1
454+
end
455+
456+
-- Add any remaining text after the last reference
457+
if last_pos <= #text then
458+
result = result .. text:sub(last_pos)
459+
end
460+
461+
output:add_lines(vim.split(result, '\n'))
432462
end
433463

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

lua/opencode/ui/reference_picker.lua

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -243,15 +243,11 @@ function M.collect_references()
243243
local msg = state.messages[i]
244244

245245
-- Only process assistant messages
246-
if msg.info and msg.info.role == 'assistant' and msg.parts then
247-
for _, part in ipairs(msg.parts) do
248-
-- Only process text parts (not tool calls)
249-
if part.type == 'text' and part.text then
250-
local refs = M.parse_references(part.text, msg.info.id)
251-
for _, ref in ipairs(refs) do
252-
table.insert(all_references, ref)
253-
end
254-
end
246+
if msg.info and msg.info.role == 'assistant' then
247+
-- Use cached references if available, otherwise parse on-demand
248+
local refs = msg.references or M._parse_message_references(msg)
249+
for _, ref in ipairs(refs) do
250+
table.insert(all_references, ref)
255251
end
256252
end
257253
end
@@ -270,11 +266,57 @@ function M.collect_references()
270266
return deduplicated
271267
end
272268

273-
---Get references for a specific text (used by formatter for visual indicators)
274-
---@param text string
269+
---Parse references from a single message's text parts
270+
---@param msg OpencodeMessage
275271
---@return CodeReference[]
276-
function M.get_references_for_text(text)
277-
return M.parse_references(text, '')
272+
function M._parse_message_references(msg)
273+
local refs = {}
274+
if not msg.parts then
275+
return refs
276+
end
277+
278+
local message_id = msg.info and msg.info.id or ''
279+
for _, part in ipairs(msg.parts) do
280+
if part.type == 'text' and part.text then
281+
local part_refs = M.parse_references(part.text, message_id)
282+
for _, ref in ipairs(part_refs) do
283+
table.insert(refs, ref)
284+
end
285+
end
286+
end
287+
return refs
288+
end
289+
290+
---Parse and cache references for all assistant messages in the current session
291+
function M._parse_session_messages()
292+
if not state.messages then
293+
return
294+
end
295+
296+
for _, msg in ipairs(state.messages) do
297+
-- Only parse assistant messages that don't already have references cached
298+
if msg.info and msg.info.role == 'assistant' and not msg.references then
299+
msg.references = M._parse_message_references(msg)
300+
end
301+
end
302+
end
303+
304+
---Setup reference picker event subscriptions
305+
---Should be called once during plugin initialization
306+
function M.setup()
307+
-- Subscribe to session.idle to parse references when AI is done responding
308+
if state.event_manager then
309+
state.event_manager:subscribe('session.idle', function()
310+
M._parse_session_messages()
311+
end)
312+
end
313+
314+
-- Subscribe to messages changes to handle session loads
315+
state.subscribe('messages', function()
316+
-- Parse any messages that don't have cached references
317+
-- This handles loading previous sessions
318+
M._parse_session_messages()
319+
end)
278320
end
279321

280322
---Format a reference for display in the picker
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"actions":[],"timestamp":1762903196,"lines":["----","","","what is this doing","","[lua/opencode/context.lua](lua/opencode/context.lua)","","Line 59:","`````lua","local is_enabled = vim.tbl_get(config, 'context', context_key, 'enabled')","`````","","----","","","**Explanation**","","- The line `local is_enabled = vim.tbl_get(config, 'context', context_key, 'enabled')` (in `lua/opencode/context.lua:58`) uses `vim.tbl_get` to safely read a nested field from a table.","- Concretely it attempts to read `config.context[context_key].enabled` but without throwing an error if `config.context` or `config.context[context_key]` is nil. If any intermediate key is missing it returns `nil`.","- In the surrounding function `M.is_context_enabled`, that value is the default config value for the given context key. The function then checks the state override:"," - If `state.current_context_config[context_key].enabled` is not `nil`, that state value (true/false) is returned."," - Otherwise the `is_enabled` value (from `config`) is returned.","- Example: if `config.context.selection.enabled == true` but `state.current_context_config.selection.enabled == false`, the function returns `false` (state overrides config). If the state value is `nil`, the config value is used.","- Why this matters: `vim.tbl_get` provides safe nested access; using it avoids runtime errors when some parts of the nested config are absent.","",""],"extmarks":[[1,1,0,{"virt_text_hide":false,"priority":10,"virt_text_repeat_linebreak":false,"virt_text_win_col":-3,"ns_id":3,"right_gravity":true,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2025-10-30 17:42:54)","OpencodeHint"],[" [msg_a3637244a001FDRDfoBYVPEGpd]","OpencodeHint"]],"virt_text_pos":"win_col"}],[2,2,0,{"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-3,"ns_id":3,"right_gravity":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[3,3,0,{"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-3,"ns_id":3,"right_gravity":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[4,4,0,{"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-3,"ns_id":3,"right_gravity":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[5,5,0,{"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-3,"ns_id":3,"right_gravity":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[6,6,0,{"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-3,"ns_id":3,"right_gravity":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[7,7,0,{"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-3,"ns_id":3,"right_gravity":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[8,8,0,{"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-3,"ns_id":3,"right_gravity":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[9,9,0,{"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-3,"ns_id":3,"right_gravity":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[10,10,0,{"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-3,"ns_id":3,"right_gravity":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[11,11,0,{"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-3,"ns_id":3,"right_gravity":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[12,13,0,{"virt_text_hide":false,"priority":10,"virt_text_repeat_linebreak":false,"virt_text_win_col":-3,"ns_id":3,"right_gravity":true,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-5-mini","OpencodeHint"],[" (2025-10-30 17:42:56)","OpencodeHint"],[" [msg_a36372b9a001M1lQEix4SK5QE5]","OpencodeHint"]],"virt_text_pos":"win_col"}]]}
1+
{"extmarks":[[1,1,0,{"ns_id":3,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":false,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2025-10-30 17:42:54)","OpencodeHint"],[" [msg_a3637244a001FDRDfoBYVPEGpd]","OpencodeHint"]],"virt_text_pos":"win_col","virt_text_win_col":-3,"priority":10}],[2,2,0,{"ns_id":3,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_win_col":-3,"priority":4096}],[3,3,0,{"ns_id":3,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_win_col":-3,"priority":4096}],[4,4,0,{"ns_id":3,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_win_col":-3,"priority":4096}],[5,5,0,{"ns_id":3,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_win_col":-3,"priority":4096}],[6,6,0,{"ns_id":3,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_win_col":-3,"priority":4096}],[7,7,0,{"ns_id":3,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_win_col":-3,"priority":4096}],[8,8,0,{"ns_id":3,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_win_col":-3,"priority":4096}],[9,9,0,{"ns_id":3,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_win_col":-3,"priority":4096}],[10,10,0,{"ns_id":3,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_win_col":-3,"priority":4096}],[11,11,0,{"ns_id":3,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_win_col":-3,"priority":4096}],[12,13,0,{"ns_id":3,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":false,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-5-mini","OpencodeHint"],[" (2025-10-30 17:42:56)","OpencodeHint"],[" [msg_a36372b9a001M1lQEix4SK5QE5]","OpencodeHint"]],"virt_text_pos":"win_col","virt_text_win_col":-3,"priority":10}]],"timestamp":1766369775,"actions":[],"lines":["----","","","what is this doing","","[lua/opencode/context.lua](lua/opencode/context.lua)","","Line 59:","`````lua","local is_enabled = vim.tbl_get(config, 'context', context_key, 'enabled')","`````","","----","","","**Explanation**","","- The line `local is_enabled = vim.tbl_get(config, 'context', context_key, 'enabled')` (in ` lua/opencode/context.lua:58`) uses `vim.tbl_get` to safely read a nested field from a table.","- Concretely it attempts to read `config.context[context_key].enabled` but without throwing an error if `config.context` or `config.context[context_key]` is nil. If any intermediate key is missing it returns `nil`.","- In the surrounding function `M.is_context_enabled`, that value is the default config value for the given context key. The function then checks the state override:"," - If `state.current_context_config[context_key].enabled` is not `nil`, that state value (true/false) is returned."," - Otherwise the `is_enabled` value (from `config`) is returned.","- Example: if `config.context.selection.enabled == true` but `state.current_context_config.selection.enabled == false`, the function returns `false` (state overrides config). If the state value is `nil`, the config value is used.","- Why this matters: `vim.tbl_get` provides safe nested access; using it avoids runtime errors when some parts of the nested config are absent.","",""]}

tests/data/diagnostics.expected.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)