Skip to content

Commit e31450c

Browse files
committed
refactor(code-references): remove reliance on system prompt
- update the reference parser to be more generic instead of relying on a system prompt - add edit/read files to the references This should fix #194
1 parent 5e9142a commit e31450c

14 files changed

Lines changed: 191 additions & 84 deletions

AGENTS.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,32 @@
3232
- **Tests:** Place in `tests/minimal/`, `tests/unit/`, or `tests/replay/`. Manual/visual tests in `tests/manual/`.
3333

3434
_Agentic coding agents must follow these conventions strictly for consistency and reliability._
35+
36+
## File Reference Detection
37+
38+
The plugin automatically detects file references in LLM responses and makes them navigable via the reference picker (`<leader>or` or `:Opencode references`).
39+
40+
### Supported Formats
41+
42+
The reference picker recognizes these file reference patterns:
43+
44+
1. **Backtick-wrapped** (recommended by LLMs naturally):
45+
- `` `path/to/file.lua` ``
46+
- `` `path/to/file.lua:42` ``
47+
- `` `path/to/file.lua:42:10` `` (with column)
48+
- `` `path/to/file.lua:42-50` `` (line range)
49+
50+
2. **file:// URIs** (backward compatibility):
51+
- `file://path/to/file.lua`
52+
- `file://path/to/file.lua:42`
53+
- `file://path/to/file.lua:42-50`
54+
55+
3. **Plain paths** (natural format):
56+
- `path/to/file.lua`
57+
- `path/to/file.lua:42`
58+
- `./relative/path.lua:42`
59+
- `/absolute/path.lua:42`
60+
61+
All formats support both relative and absolute paths. Files must exist to be recognized (validation prevents false positives).
62+
63+
**No system prompt configuration is required** - the parser works with all LLM providers, including those without system prompt support.

lua/opencode/core.lua

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

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

lua/opencode/ui/formatter.lua

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -499,7 +499,17 @@ end
499499
---@param input FileToolInput data for the tool
500500
---@param metadata FileToolMetadata Metadata for the tool use
501501
function M._format_file_tool(output, tool_type, input, metadata)
502-
local file_name = input and vim.fn.fnamemodify(input.filePath, ':t') or ''
502+
local file_name = ''
503+
if input then
504+
local relative = vim.fn.fnamemodify(input.filePath, ':.')
505+
local absolute = vim.fn.fnamemodify(input.filePath, ':p')
506+
if relative == absolute then
507+
file_name = vim.fn.fnamemodify(input.filePath, ':~')
508+
else
509+
file_name = relative
510+
end
511+
end
512+
503513
local file_type = input and util.get_markdown_filetype(input.filePath) or ''
504514
local tool_action_icons = { read = icons.get('read'), edit = icons.get('edit'), write = icons.get('write') }
505515

lua/opencode/ui/reference_picker.lua

Lines changed: 141 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
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
22
local state = require('opencode.state')
33
local config = require('opencode.config')
44
local base_picker = require('opencode.ui.base_picker')
@@ -11,7 +11,6 @@ local M = {}
1111
---@return boolean
1212
local 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
@@ -29,16 +28,67 @@ 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
88111
end
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
147243
end
@@ -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
165260
function 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)
179270
end
@@ -230,27 +321,19 @@ end
230321
---Navigate to a code reference
231322
---@param ref CodeReference
232323
function 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
255338
end
256339

0 commit comments

Comments
 (0)