Skip to content

Commit ee47b2b

Browse files
committed
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
1 parent 7daa6ef commit ee47b2b

File tree

4 files changed

+105
-146
lines changed

4 files changed

+105
-146
lines changed

lua/opencode/config.lua

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,9 @@ M.defaults = {
200200
on_done_thinking = nil,
201201
on_permission_requested = nil,
202202
},
203+
-- Additional system prompt appended to every message
204+
-- Used to customize assistant behavior (e.g., file reference formatting)
205+
system_prompt = 'When referencing files in your responses, always use the file:// URI scheme (e.g., file://path/to/file.lua or file://path/to/file.lua:42 for a specific line). This helps with navigation and tooling integration.',
203206
}
204207

205208
M.values = vim.deepcopy(M.defaults)

lua/opencode/core.lua

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

170+
-- Add system prompt if configured
171+
if config.system_prompt and config.system_prompt ~= '' then
172+
params.system = config.system_prompt
173+
end
174+
170175
params.parts = context.format_message(prompt, opts.context)
171176
M.before_run(opts)
172177

lua/opencode/ui/formatter.lua

Lines changed: 3 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -426,63 +426,9 @@ end
426426
---@param output Output Output object to write to
427427
---@param text string
428428
function M._format_assistant_message(output, text)
429-
local reference_picker = require('opencode.ui.reference_picker')
430-
local references = reference_picker.get_references_for_text(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-
local lines = vim.split(result, '\n')
462-
local start_line = output:get_line_count()
463-
output:add_lines(lines)
464-
465-
-- Add highlighting for reference icons
466-
-- We need to find the icon positions in the rendered lines and add extmarks
467-
for i, line in ipairs(lines) do
468-
local line_num = start_line + i - 1
469-
local search_start = 1
470-
while true do
471-
local icon_start, icon_end = line:find(ref_icon, search_start, true)
472-
if not icon_start then
473-
break
474-
end
475-
-- Add extmark for the reference icon
476-
output:add_extmark(line_num, {
477-
virt_text = { { ref_icon, 'OpencodeReference' } },
478-
virt_text_pos = 'overlay',
479-
end_col = icon_end,
480-
hl_group = 'OpencodeReference',
481-
priority = 100,
482-
} --[[@as OutputExtmark]])
483-
search_start = icon_end + 1
484-
end
485-
end
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'))
486432
end
487433

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

lua/opencode/ui/reference_picker.lua

Lines changed: 94 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -100,60 +100,10 @@ local function file_exists(file_path)
100100
return vim.fn.filereadable(path) == 1
101101
end
102102

103-
---Extract surrounding context for display
104-
---@param text string Full text
105-
---@param match_start number Start position of match
106-
---@param match_end number End position of match
107-
---@param max_len number Maximum context length
108-
---@return string
109-
local function extract_context(text, match_start, match_end, max_len)
110-
max_len = max_len or 60
111-
local half = math.floor(max_len / 2)
112-
113-
-- Find line boundaries for better context
114-
local line_start = match_start
115-
local line_end = match_end
116-
117-
-- Go back to find start of line or limit
118-
while line_start > 1 and text:sub(line_start - 1, line_start - 1) ~= '\n' and (match_start - line_start) < half do
119-
line_start = line_start - 1
120-
end
121-
122-
-- Go forward to find end of line or limit
123-
while line_end < #text and text:sub(line_end + 1, line_end + 1) ~= '\n' and (line_end - match_end) < half do
124-
line_end = line_end + 1
125-
end
126-
127-
local context = text:sub(line_start, line_end)
128-
129-
-- Clean up: remove extra whitespace
130-
context = context:gsub('%s+', ' ')
131-
context = vim.trim(context)
132-
133-
-- Truncate if still too long
134-
if #context > max_len then
135-
-- Try to center around the match
136-
local match_in_context = match_start - line_start + 1
137-
local ctx_start = math.max(1, match_in_context - half)
138-
local ctx_end = math.min(#context, match_in_context + half)
139-
context = context:sub(ctx_start, ctx_end)
140-
141-
if ctx_start > 1 then
142-
context = '...' .. context
143-
end
144-
if ctx_end < #context then
145-
context = context .. '...'
146-
end
147-
end
148-
149-
return context
150-
end
151-
152103
---@class CodeReference
153104
---@field file_path string Relative or absolute file path
154105
---@field line number|nil Line number (1-indexed)
155106
---@field column number|nil Column number (optional)
156-
---@field context string Surrounding text for display
157107
---@field message_id string ID of the message containing this reference
158108
---@field match_start number Start position in original text
159109
---@field match_end number End position in original text
@@ -166,60 +116,115 @@ end
166116
---@return CodeReference[]
167117
function M.parse_references(text, message_id)
168118
local references = {}
169-
local seen = {} -- For deduplication: file_path:line
119+
local covered_ranges = {} -- Track which character ranges we've already matched
120+
121+
-- Helper to check if a range overlaps with any covered range
122+
local function is_covered(start_pos, end_pos)
123+
for _, range in ipairs(covered_ranges) do
124+
-- Check if ranges overlap
125+
if not (end_pos < range[1] or start_pos > range[2]) then
126+
return true
127+
end
128+
end
129+
return false
130+
end
170131

171-
-- Pattern: path/to/file.ext:line or path/to/file.ext:line:column
172-
-- Must have a valid file extension before the colon
173-
-- The path can contain: alphanumeric, underscore, dot, slash, hyphen
174-
local pattern = '([%w_./%-]+%.([%w]+)):(%d+):?(%d*)'
132+
-- Helper to add a reference
133+
local function add_reference(path, ext, match_start, match_end, line, column)
134+
if not is_valid_extension(ext) then
135+
return false
136+
end
137+
if not file_exists(path) then
138+
return false
139+
end
140+
if is_covered(match_start, match_end) then
141+
return false
142+
end
143+
144+
-- Mark this range as covered
145+
table.insert(covered_ranges, { match_start, match_end })
146+
147+
-- Create absolute path for Snacks preview
148+
local abs_path = path
149+
if not vim.startswith(path, '/') then
150+
abs_path = vim.fn.getcwd() .. '/' .. path
151+
end
175152

153+
table.insert(references, {
154+
file_path = path,
155+
line = line,
156+
column = column,
157+
message_id = message_id,
158+
match_start = match_start,
159+
match_end = match_end,
160+
file = abs_path,
161+
pos = line and { line, (column or 1) - 1 } or nil,
162+
})
163+
return true
164+
end
165+
166+
-- First pass: find file:// URI references (preferred format)
167+
-- Matches: file://path/to/file.ext or file://path/to/file.ext:line or file://path/to/file.ext:line:column
168+
local pattern_file_uri = 'file://([%w_./%-]+%.([%w]+)):?(%d*):?(%d*)'
176169
local search_start = 1
177170
while search_start <= #text do
178-
local match_start, match_end, path, ext, line_str, col_str = text:find(pattern, search_start)
179-
171+
local match_start, match_end, path, ext, line_str, col_str = text:find(pattern_file_uri, search_start)
180172
if not match_start then
181173
break
182174
end
183175

184-
-- Validate extension
185-
if is_valid_extension(ext) and not is_url_context(text, match_start) then
186-
-- Check if file exists
187-
if file_exists(path) then
188-
local line = tonumber(line_str)
189-
local column = col_str ~= '' and tonumber(col_str) or nil
190-
191-
-- Deduplication key
192-
local dedup_key = path .. ':' .. (line or 0)
193-
if not seen[dedup_key] then
194-
seen[dedup_key] = true
176+
local line = line_str ~= '' and tonumber(line_str) or nil
177+
local column = col_str ~= '' and tonumber(col_str) or nil
178+
add_reference(path, ext, match_start, match_end, line, column)
179+
search_start = match_end + 1
180+
end
195181

196-
local context = extract_context(text, match_start, match_end --[[@as number]], 60)
182+
-- Second pass: find path:line[:column] references (legacy format, more specific)
183+
local pattern_with_line = '([%w_./%-]+%.([%w]+)):(%d+):?(%d*)'
184+
search_start = 1
185+
while search_start <= #text do
186+
local match_start, match_end, path, ext, line_str, col_str = text:find(pattern_with_line, search_start)
187+
if not match_start then
188+
break
189+
end
197190

198-
-- Create absolute path for Snacks preview
199-
local abs_path = path
200-
if not vim.startswith(path, '/') then
201-
abs_path = vim.fn.getcwd() .. '/' .. path
202-
end
191+
-- Skip if this looks like a URL (http://, https://, file://, etc.)
192+
if is_url_context(text, match_start) then
193+
search_start = match_end + 1
194+
else
195+
local line = tonumber(line_str)
196+
local column = col_str ~= '' and tonumber(col_str) or nil
197+
add_reference(path, ext, match_start, match_end, line, column)
198+
search_start = match_end + 1
199+
end
200+
end
203201

204-
table.insert(references, {
205-
file_path = path,
206-
line = line,
207-
column = column,
208-
context = context,
209-
message_id = message_id,
210-
match_start = match_start,
211-
match_end = match_end,
212-
-- Fields for Snacks picker file preview
213-
file = abs_path,
214-
pos = line and { line, (column or 1) - 1 } or nil,
215-
})
216-
end
217-
end
202+
-- Third pass: find path-only references (must contain a slash to be a path)
203+
local pattern_no_line = '([%w_%-]+/[%w_./%-]+%.([%w]+))'
204+
search_start = 1
205+
while search_start <= #text do
206+
local match_start, match_end, path, ext = text:find(pattern_no_line, search_start)
207+
if not match_start then
208+
break
218209
end
219210

220-
search_start = match_end + 1
211+
-- Skip if preceded by file:// or other URL scheme
212+
if is_url_context(text, match_start) then
213+
search_start = match_end + 1
214+
-- Only add if not followed by a colon and digit (which would be caught by second pattern)
215+
elseif text:sub(match_end + 1, match_end + 1) ~= ':' then
216+
add_reference(path, ext, match_start, match_end, nil, nil)
217+
search_start = match_end + 1
218+
else
219+
search_start = match_end + 1
220+
end
221221
end
222222

223+
-- Sort by match position for consistent ordering
224+
table.sort(references, function(a, b)
225+
return a.match_start < b.match_start
226+
end)
227+
223228
return references
224229
end
225230

0 commit comments

Comments
 (0)