1- -- Code reference picker for navigating to file:// URI references in LLM responses
1+ -- Code reference picker for navigating to file references in LLM responses
22local state = require (' opencode.state' )
33local config = require (' opencode.config' )
44local base_picker = require (' opencode.ui.base_picker' )
@@ -11,7 +11,6 @@ local M = {}
1111--- @return boolean
1212local function file_exists (file_path )
1313 local path = file_path
14- -- Make absolute if relative
1514 if not vim .startswith (path , ' /' ) then
1615 path = vim .fn .getcwd () .. ' /' .. path
1716 end
2928--- @field pos number[] | nil Position as {line , col } for Snacks picker preview
3029--- @field end_pos number[] | nil End position as {line , col } for Snacks picker range highlighting
3130
32- --- Parse file:// URI references from text
31+ --- Create absolute path from relative path
32+ --- @param path string
33+ --- @return string
34+ local function make_absolute_path (path )
35+ if not vim .startswith (path , ' /' ) then
36+ return vim .fn .getcwd () .. ' /' .. path
37+ end
38+ return path
39+ end
40+
41+ --- Create a CodeReference object from parsed components
42+ --- @param path string
43+ --- @param line number | nil
44+ --- @param column number | nil
45+ --- @param end_line number | nil
46+ --- @param message_id string
47+ --- @param match_start number
48+ --- @param match_end number
49+ --- @return CodeReference
50+ local function create_code_reference (path , line , column , end_line , message_id , match_start , match_end )
51+ local abs_path = make_absolute_path (path )
52+
53+ return {
54+ file_path = path ,
55+ line = line ,
56+ column = column ,
57+ message_id = message_id ,
58+ match_start = match_start ,
59+ match_end = match_end ,
60+ file = abs_path ,
61+ pos = line and { line , (column or 1 ) - 1 } or nil ,
62+ end_pos = end_line and { end_line , 0 } or nil ,
63+ }
64+ end
65+
66+ --- Parse line, column, and range information from pattern captures
67+ --- @param line_str string
68+ --- @param col_or_end_str string
69+ --- @param end_line_str string
70+ --- @return number | nil line , number | nil column , number | nil end_line
71+ local function parse_position_info (line_str , col_or_end_str , end_line_str )
72+ local line = line_str ~= ' ' and tonumber (line_str ) or nil
73+ local column = nil
74+ local end_line = nil
75+
76+ if end_line_str ~= ' ' then
77+ end_line = tonumber (end_line_str )
78+ elseif col_or_end_str ~= ' ' then
79+ column = tonumber (col_or_end_str )
80+ end
81+
82+ return line , column , end_line
83+ end
84+
85+ --- Generic function to parse file references using a given pattern
3386--- @param text string The text to parse
87+ --- @param pattern string Lua pattern to match
3488--- @param message_id string The message ID for tracking
3589--- @return CodeReference[]
36- function M . parse_references (text , message_id )
90+ local function parse_references_with_pattern (text , pattern , message_id )
3791 local references = {}
38-
39- -- Match file:// URIs with optional line and column numbers or line ranges
40- -- Formats: file://path/to/file or file://path/to/file:line or file://path/to/file:line:column or file://path/to/file:line-endline
41- local pattern = ' file://([%w_./%-]+):?(%d*):?(%d*)-?(%d*)'
4292 local search_start = 1
4393
4494 while search_start <= # text do
@@ -47,38 +97,11 @@ function M.parse_references(text, message_id)
4797 break
4898 end
4999
50- -- Only add if file exists
51100 if file_exists (path ) then
52- local line = line_str ~= ' ' and tonumber (line_str ) or nil
53- local column = nil
54- local end_line = nil
55-
56- -- Determine if we have a range or a column
57- if end_line_str ~= ' ' then
58- -- Range format: file://path:start-end
59- end_line = tonumber (end_line_str )
60- elseif col_or_end_str ~= ' ' then
61- -- Column format: file://path:line:col
62- column = tonumber (col_or_end_str )
63- end
64-
65- -- Create absolute path for Snacks preview
66- local abs_path = path
67- if not vim .startswith (path , ' /' ) then
68- abs_path = vim .fn .getcwd () .. ' /' .. path
69- end
101+ local line , column , end_line = parse_position_info (line_str , col_or_end_str , end_line_str )
70102
71- table.insert (references , {
72- file_path = path ,
73- line = line ,
74- column = column ,
75- message_id = message_id ,
76- match_start = match_start ,
77- match_end = match_end ,
78- file = abs_path ,
79- pos = line and { line , (column or 1 ) - 1 } or nil ,
80- end_pos = end_line and { end_line , 0 } or nil ,
81- })
103+ local ref = create_code_reference (path , line , column , end_line , message_id , match_start , match_end )
104+ table.insert (references , ref )
82105 end
83106
84107 search_start = match_end + 1
@@ -87,6 +110,72 @@ function M.parse_references(text, message_id)
87110 return references
88111end
89112
113+ --- Parse backtick-wrapped file references like `path/file.lua:42`
114+ --- @param text string
115+ --- @param message_id string
116+ --- @return CodeReference[]
117+ local function parse_backtick_refs (text , message_id )
118+ local pattern = ' `([^`]+%.[%w]+):?(%d*):?(%d*)-?(%d*)`'
119+ return parse_references_with_pattern (text , pattern , message_id )
120+ end
121+
122+ --- Parse file:// URI references (backward compatibility)
123+ --- @param text string
124+ --- @param message_id string
125+ --- @return CodeReference[]
126+ local function parse_file_uri_refs (text , message_id )
127+ local pattern = ' file://([%w_./%-]+):?(%d*):?(%d*)-?(%d*)'
128+ return parse_references_with_pattern (text , pattern , message_id )
129+ end
130+
131+ --- Parse plain file paths like path/file.lua:42 or path/file.lua
132+ --- @param text string
133+ --- @param message_id string
134+ --- @return CodeReference[]
135+ local function parse_plain_path_refs (text , message_id )
136+ -- Match file paths with optional extension and line/column info
137+ -- Relies on file_exists() to filter out false positives like URLs
138+ local pattern = ' ([%w_%./-]+%.[%w]+):?(%d*):?(%d*)-?(%d*)'
139+ return parse_references_with_pattern (text , pattern , message_id )
140+ end
141+
142+ --- Parse file references from text using multiple pattern strategies
143+ --- @param text string The text to parse
144+ --- @param message_id string The message ID for tracking
145+ --- @return CodeReference[]
146+ function M .parse_references (text , message_id )
147+ local all_refs = {}
148+
149+ local backtick_refs = parse_backtick_refs (text , message_id )
150+ local file_uri_refs = parse_file_uri_refs (text , message_id )
151+ local plain_refs = parse_plain_path_refs (text , message_id )
152+
153+ vim .list_extend (all_refs , backtick_refs )
154+ vim .list_extend (all_refs , file_uri_refs )
155+ vim .list_extend (all_refs , plain_refs )
156+
157+ table.sort (all_refs , function (a , b )
158+ return a .match_start < b .match_start
159+ end )
160+
161+ local deduplicated = {}
162+ for _ , ref in ipairs (all_refs ) do
163+ local overlaps = false
164+ for _ , existing in ipairs (deduplicated ) do
165+ if ref .match_start <= existing .match_end and existing .match_start <= ref .match_end then
166+ overlaps = true
167+ break
168+ end
169+ end
170+
171+ if not overlaps then
172+ table.insert (deduplicated , ref )
173+ end
174+ end
175+
176+ return deduplicated
177+ end
178+
90179--- Collect all references from assistant messages in the current session
91180--- Returns references in reverse order (most recent first)
92181--- @return CodeReference[]
@@ -97,25 +186,23 @@ function M.collect_references()
97186 return all_references
98187 end
99188
100- -- Process messages in reverse order (most recent first)
101189 for i = # state .messages , 1 , - 1 do
102190 local msg = state .messages [i ]
103191
104- -- Only process assistant messages
105192 if msg .info and msg .info .role == ' assistant' then
106- -- Use cached references if available, otherwise parse on-demand
107193 local refs = msg .references or M ._parse_message_references (msg )
108194 for _ , ref in ipairs (refs ) do
109195 table.insert (all_references , ref )
110196 end
111197 end
112198 end
113199
114- -- Deduplicate across all messages (keep first occurrence which is most recent)
200+ -- Keep first occurrence which is most recent due to reverse iteration
115201 local seen = {}
116202 local deduplicated = {}
117203 for _ , ref in ipairs (all_references ) do
118- local dedup_key = ref .file_path .. ' :' .. (ref .line or 0 )
204+ local relative_path = vim .fn .fnamemodify (ref .file_path , ' :~:.' )
205+ local dedup_key = relative_path .. ' :' .. (ref .line or 0 )
119206 if not seen [dedup_key ] then
120207 seen [dedup_key ] = true
121208 table.insert (deduplicated , ref )
@@ -142,6 +229,15 @@ function M._parse_message_references(msg)
142229 table.insert (refs , ref )
143230 end
144231 end
232+
233+ if part .type == ' tool' then
234+ local file_path = vim .tbl_get (part , ' state' , ' input' , ' filePath' )
235+ if file_path and file_exists (file_path ) then
236+ local relative_path = vim .fn .fnamemodify (file_path , ' :~:.' )
237+ local ref = create_code_reference (relative_path , nil , nil , nil , message_id , 0 , 0 )
238+ table.insert (refs , ref )
239+ end
240+ end
145241 end
146242 return refs
147243end
@@ -153,7 +249,6 @@ function M._parse_session_messages()
153249 end
154250
155251 for _ , msg in ipairs (state .messages ) do
156- -- Only parse assistant messages that don't already have references cached
157252 if msg .info and msg .info .role == ' assistant' and not msg .references then
158253 msg .references = M ._parse_message_references (msg )
159254 end
@@ -163,17 +258,13 @@ end
163258--- Setup reference picker event subscriptions
164259--- Should be called once during plugin initialization
165260function M .setup ()
166- -- Subscribe to session.idle to parse references when AI is done responding
167261 if state .event_manager then
168262 state .event_manager :subscribe (' session.idle' , function ()
169263 M ._parse_session_messages ()
170264 end )
171265 end
172266
173- -- Subscribe to messages changes to handle session loads
174267 state .subscribe (' messages' , function ()
175- -- Parse any messages that don't have cached references
176- -- This handles loading previous sessions
177268 M ._parse_session_messages ()
178269 end )
179270end
@@ -230,27 +321,19 @@ end
230321--- Navigate to a code reference
231322--- @param ref CodeReference
232323function M .navigate_to (ref )
233- local file_path = ref .file_path
234-
235- -- Make absolute if relative
236- if not vim .startswith (file_path , ' /' ) then
237- file_path = vim .fn .getcwd () .. ' /' .. file_path
238- end
324+ local file_path = make_absolute_path (ref .file_path )
239325
240- -- Open the file in a new tab
241326 vim .cmd (' tabedit ' .. vim .fn .fnameescape (file_path ))
242327
243- -- Jump to line if specified
244328 if ref .line then
245329 local line = math.max (1 , ref .line )
246330 local col = ref .column and math.max (0 , ref .column - 1 ) or 0
247331
248- -- Make sure we don't exceed buffer line count
249332 local line_count = vim .api .nvim_buf_line_count (0 )
250333 line = math.min (line , line_count )
251334
252335 vim .api .nvim_win_set_cursor (0 , { line , col })
253- vim .cmd (' normal! zz' ) -- Center the view
336+ vim .cmd (' normal! zz' )
254337 end
255338end
256339
0 commit comments