Skip to content

Commit e274f74

Browse files
committed
feat(renderer): highlight mentions in output
This was fairly tricky for two reasons: 1. The part that needs highlighting has already been rendered when we get the part that contains the range so we have to rerender the first part. 2. Sometimes Opencode sends back a range that starts with 0. In that case, we fallback to searching for the string of the mention.
1 parent 24a772c commit e274f74

File tree

4 files changed

+114
-24
lines changed

4 files changed

+114
-24
lines changed

lua/opencode/ui/formatter.lua

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ local Output = require('opencode.ui.output')
55
local state = require('opencode.state')
66
local config = require('opencode.config')
77
local snapshot = require('opencode.snapshot')
8+
local mention = require('opencode.ui.mention')
89

910
local M = {}
1011

@@ -314,14 +315,37 @@ end
314315

315316
---@param output Output Output object to write to
316317
---@param text string
317-
function M._format_user_prompt(output, text)
318+
---@param message? OpencodeMessage Optional message object to extract mentions from
319+
function M._format_user_prompt(output, text, message)
318320
local start_line = output:get_line_count()
319321

320322
output:add_lines(vim.split(text, '\n'))
321323

322324
local end_line = output:get_line_count()
323-
require('opencode.ui.mention').highlight_mentions(output)
324-
M._add_vertical_border(output, start_line, end_line, 'OpencodeMessageRoleUser', -3)
325+
326+
local end_line_extmark_offset = 0
327+
328+
local mentions = {}
329+
if message and message.parts then
330+
-- message.parts will only be filled out on a re-render
331+
-- we need to collect the mentions here
332+
for _, part in ipairs(message.parts) do
333+
if part.type == 'file' then
334+
-- we're rerendering this part and we have files, the space after the user prompt
335+
-- also needs an extmark
336+
end_line_extmark_offset = 1
337+
if part.source and part.source.text then
338+
table.insert(mentions, part.source.text)
339+
end
340+
end
341+
end
342+
end
343+
344+
if #mentions > 0 then
345+
mention.highlight_mentions_in_output(output, text, mentions, start_line)
346+
end
347+
348+
M._add_vertical_border(output, start_line, end_line + end_line_extmark_offset, 'OpencodeMessageRoleUser', -3)
325349
end
326350

327351
---@param output Output Output object to write to
@@ -654,19 +678,20 @@ end
654678

655679
---Formats a single message part and returns the resulting output object
656680
---@param part MessagePart The part to format
657-
---@param role 'user'|'assistant'|'system' The role, user or assistant, that created this part
681+
---@param message? OpencodeMessage Optional message object to extract role and mentions from
658682
---@return Output
659-
function M.format_part(part, role)
683+
function M.format_part(part, message)
660684
local output = Output.new()
661685

662686
local content_added = false
687+
local role = message and message.info and message.info.role
663688

664689
if role == 'user' then
665690
if part.type == 'text' and part.text then
666691
if part.synthetic == true then
667692
M._format_selection_context(output, part)
668693
else
669-
M._format_user_prompt(output, vim.trim(part.text))
694+
M._format_user_prompt(output, vim.trim(part.text), message)
670695
content_added = true
671696
end
672697
elseif part.type == 'file' then

lua/opencode/ui/mention.lua

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
local config = require('opencode.config')
2+
13
local M = {}
24

35
local mentions_namespace = vim.api.nvim_create_namespace('OpencodeMentions')
@@ -40,24 +42,61 @@ function M.highlight_all_mentions(buf, callback)
4042
end
4143
end
4244

43-
---@param output Output
44-
function M.highlight_mentions(output)
45-
local mention_pattern = '@[%w_%-%./][%w_%-%./]*'
46-
for i, line in pairs(output:get_lines()) do
47-
for mention in line:gmatch(mention_pattern) do
48-
local col_start, col_end = line:find(mention, 1)
49-
if col_start and col_end then
50-
output:add_extmark(i, {
51-
start_col = col_start - 1,
52-
end_col = col_end,
53-
hl_group = 'OpencodeMention',
54-
priority = 1000,
55-
})
45+
---Apply mention highlights from source.text data
46+
---@param output Output Output object to write to
47+
---@param text string The full text content
48+
---@param mentions OpencodeMessagePartSourceText[] Mention data with character offsets
49+
---@param start_line number The starting line index in the output (1-indexed)
50+
function M.highlight_mentions_in_output(output, text, mentions, start_line)
51+
for _, mention in ipairs(mentions) do
52+
local char_start = mention.start
53+
local char_end = mention['end']
54+
55+
local char_count = 0
56+
57+
for i, line in ipairs(vim.split(text, '\n')) do
58+
local line_start = char_count
59+
local line_end = char_count + #line
60+
61+
if char_start == 0 and string.sub(text, 0, 1) ~= '@' then
62+
-- Work around Opencode bug? where mentions sometimes have a 0 start
63+
if config.debug.enabled then
64+
vim.notify('Mention bug, falling back to search')
65+
end
66+
67+
local start_pos, end_pos = string.find(line, mention.value, 1, true)
68+
69+
if start_pos then
70+
output:add_extmark(start_line + i, {
71+
start_col = start_pos - 1,
72+
end_col = end_pos,
73+
hl_group = 'OpencodeMention',
74+
priority = 1000,
75+
})
76+
break
77+
end
78+
else
79+
if char_start >= line_start and char_start < line_end then
80+
local col_start = char_start - line_start
81+
local col_end = math.min(char_end - line_start + 1, #line)
82+
83+
-- vim.notify('adding extmark, col_start: ' .. col_start .. ', col_end: ' .. col_end)
84+
vim.notify('char: ' .. string.sub(line, col_start, col_start + 10))
85+
86+
output:add_extmark(start_line + i, {
87+
start_col = col_start,
88+
end_col = col_end,
89+
hl_group = 'OpencodeMention',
90+
priority = 1000,
91+
})
92+
break
93+
end
94+
95+
char_count = line_end + 1
5696
end
5797
end
5898
end
5999
end
60-
61100
local function insert_mention(windows, row, col, name)
62101
local current_line = vim.api.nvim_buf_get_lines(windows.input_buf, row - 1, row, false)[1]
63102

lua/opencode/ui/renderer.lua

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ function M._replace_part_in_buffer(part_id, formatted_data)
272272

273273
M._render_state:clear_actions(part_id)
274274

275-
output_window.clear_extmarks(cached.line_start, cached.line_end + 1)
275+
output_window.clear_extmarks(cached.line_start - 1, cached.line_end + 1)
276276
output_window.set_lines(new_lines, cached.line_start, cached.line_end + 1)
277277

278278
local new_line_end = cached.line_start + new_line_count - 1
@@ -465,7 +465,7 @@ function M.on_part_updated(properties, revert_index)
465465
M._render_state:update_part_data(part)
466466
end
467467

468-
local formatted = formatter.format_part(part, message.info.role)
468+
local formatted = formatter.format_part(part, message)
469469

470470
if revert_index and is_new_part then
471471
return
@@ -477,6 +477,15 @@ function M.on_part_updated(properties, revert_index)
477477
M._replace_part_in_buffer(part.id, formatted)
478478
end
479479

480+
if part.type == 'file' and part.source and part.source.text then
481+
-- we have a mention, we need to rerender the early part to highlight
482+
-- the mention.
483+
local text_part_id = M._find_text_part_for_message(message)
484+
if text_part_id then
485+
M._rerender_part(text_part_id)
486+
end
487+
end
488+
480489
M._scroll_to_bottom()
481490
end
482491

@@ -657,6 +666,23 @@ function M._find_part_by_call_id(call_id, message_id)
657666
return M._render_state:get_part_by_call_id(call_id, message_id)
658667
end
659668

669+
---Find the text part in a message
670+
---@param message OpencodeMessage The message containing the parts
671+
---@return string? text_part_id The ID of the text part
672+
function M._find_text_part_for_message(message)
673+
if not message or not message.parts then
674+
return nil
675+
end
676+
677+
for _, part in ipairs(message.parts) do
678+
if part.type == 'text' and not part.synthetic then
679+
return part.id
680+
end
681+
end
682+
683+
return nil
684+
end
685+
660686
---Re-render existing part with current state
661687
---Used for permission updates and other dynamic changes
662688
---@param part_id string Part ID to re-render
@@ -673,7 +699,7 @@ function M._rerender_part(part_id)
673699
end
674700

675701
local message = rendered_message.message
676-
local formatted = formatter.format_part(part, message.info.role)
702+
local formatted = formatter.format_part(part, message)
677703

678704
M._replace_part_in_buffer(part_id, formatted)
679705
end

tests/data/diff.expected.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"actions":[{"display_line":20,"text":"[R]evert file","type":"diff_revert_selected_file","key":"R","args":["1f593f7ed419c95d3995f8ef4b98d4e571c3a492"],"range":{"from":20,"to":20}},{"display_line":20,"text":"Revert [A]ll","type":"diff_revert_all","key":"A","args":["1f593f7ed419c95d3995f8ef4b98d4e571c3a492"],"range":{"from":20,"to":20}},{"display_line":20,"text":"[D]iff","type":"diff_open","key":"D","args":["1f593f7ed419c95d3995f8ef4b98d4e571c3a492"],"range":{"from":20,"to":20}}],"extmarks":[[1,2,0,{"right_gravity":true,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2025-10-12 06:42:56)","OpencodeHint"],[" [msg_9d7287269001C5gRusYfX7A1w1]","OpencodeHint"]],"virt_text_hide":false,"virt_text_win_col":-3,"virt_text_repeat_linebreak":false,"ns_id":3,"virt_text_pos":"win_col","priority":10}],[2,3,0,{"right_gravity":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_hide":false,"virt_text_win_col":-3,"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text_pos":"win_col","priority":4096}],[3,4,0,{"right_gravity":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_hide":false,"virt_text_win_col":-3,"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text_pos":"win_col","priority":4096}],[4,5,0,{"right_gravity":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_hide":false,"virt_text_win_col":-3,"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text_pos":"win_col","priority":4096}],[5,6,0,{"right_gravity":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_hide":false,"virt_text_win_col":-3,"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text_pos":"win_col","priority":4096}],[6,9,0,{"right_gravity":true,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" claude-sonnet-4.5","OpencodeHint"],[" (2025-10-12 06:42:56)","OpencodeHint"],[" [msg_9d7287287001HVwpPaH7WkRVdN]","OpencodeHint"]],"virt_text_hide":false,"virt_text_win_col":-3,"virt_text_repeat_linebreak":false,"ns_id":3,"virt_text_pos":"win_col","priority":10}],[7,11,0,{"right_gravity":true,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"virt_text_win_col":-1,"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text_pos":"win_col","priority":4096}],[8,12,0,{"right_gravity":true,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"virt_text_win_col":-1,"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text_pos":"win_col","priority":4096}],[9,13,0,{"right_gravity":true,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"virt_text_win_col":-1,"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text_pos":"win_col","priority":4096}],[10,14,0,{"virt_text":[["-","OpencodeDiffDelete"]],"priority":5000,"end_col":0,"end_row":15,"hl_eol":true,"right_gravity":true,"end_right_gravity":false,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"ns_id":3,"hl_group":"OpencodeDiffDelete","virt_text_pos":"overlay"}],[11,14,0,{"right_gravity":true,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"virt_text_win_col":-1,"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text_pos":"win_col","priority":4096}],[12,15,0,{"virt_text":[["+","OpencodeDiffAdd"]],"priority":5000,"end_col":0,"end_row":16,"hl_eol":true,"right_gravity":true,"end_right_gravity":false,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"ns_id":3,"hl_group":"OpencodeDiffAdd","virt_text_pos":"overlay"}],[13,15,0,{"right_gravity":true,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"virt_text_win_col":-1,"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text_pos":"win_col","priority":4096}],[14,16,0,{"right_gravity":true,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"virt_text_win_col":-1,"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text_pos":"win_col","priority":4096}],[15,17,0,{"right_gravity":true,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"virt_text_win_col":-1,"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text_pos":"win_col","priority":4096}],[16,22,0,{"right_gravity":true,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" claude-sonnet-4.5","OpencodeHint"],[" (2025-10-12 06:43:03)","OpencodeHint"],[" [msg_9d7288f2f001hW6NqqhtBc72UU]","OpencodeHint"]],"virt_text_hide":false,"virt_text_win_col":-3,"virt_text_repeat_linebreak":false,"ns_id":3,"virt_text_pos":"win_col","priority":10}]],"lines":["","----","","","can you add \"great\" before \"string\" in @diff-test.txt?","","[diff-test.txt](diff-test.txt)","","----","","","** edit** `diff-test.txt`","","```txt"," this is a string"," this is a great string","","```","","**󰻛 Created Snapshot** `1f593f7e`","","----","",""],"timestamp":1760658597}
1+
{"lines":["","----","","","can you add \"great\" before \"string\" in @diff-test.txt?","","[diff-test.txt](diff-test.txt)","","----","","","** edit** `diff-test.txt`","","```txt"," this is a string"," this is a great string","","```","","**󰻛 Created Snapshot** `1f593f7e`","","----","",""],"timestamp":1761108013,"actions":[{"type":"diff_revert_selected_file","key":"R","text":"[R]evert file","args":["1f593f7ed419c95d3995f8ef4b98d4e571c3a492"],"range":{"from":20,"to":20},"display_line":20},{"type":"diff_revert_all","key":"A","text":"Revert [A]ll","args":["1f593f7ed419c95d3995f8ef4b98d4e571c3a492"],"range":{"from":20,"to":20},"display_line":20},{"type":"diff_open","key":"D","text":"[D]iff","args":["1f593f7ed419c95d3995f8ef4b98d4e571c3a492"],"range":{"from":20,"to":20},"display_line":20}],"extmarks":[[1,2,0,{"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":false,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2025-10-12 06:42:56)","OpencodeHint"],[" [msg_9d7287269001C5gRusYfX7A1w1]","OpencodeHint"]],"priority":10,"right_gravity":true,"ns_id":3,"virt_text_win_col":-3}],[2,3,0,{"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"priority":4096,"right_gravity":true,"ns_id":3,"virt_text_win_col":-3}],[3,4,0,{"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"priority":4096,"right_gravity":true,"ns_id":3,"virt_text_win_col":-3}],[4,4,39,{"end_row":4,"ns_id":3,"hl_group":"OpencodeMention","end_col":53,"priority":1000,"hl_eol":false,"right_gravity":true,"end_right_gravity":false}],[5,5,0,{"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"priority":4096,"right_gravity":true,"ns_id":3,"virt_text_win_col":-3}],[6,6,0,{"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"priority":4096,"right_gravity":true,"ns_id":3,"virt_text_win_col":-3}],[7,9,0,{"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":false,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" claude-sonnet-4.5","OpencodeHint"],[" (2025-10-12 06:42:56)","OpencodeHint"],[" [msg_9d7287287001HVwpPaH7WkRVdN]","OpencodeHint"]],"priority":10,"right_gravity":true,"ns_id":3,"virt_text_win_col":-3}],[8,11,0,{"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"priority":4096,"right_gravity":true,"ns_id":3,"virt_text_win_col":-1}],[9,12,0,{"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"priority":4096,"right_gravity":true,"ns_id":3,"virt_text_win_col":-1}],[10,13,0,{"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"priority":4096,"right_gravity":true,"ns_id":3,"virt_text_win_col":-1}],[11,14,0,{"end_col":0,"end_row":15,"hl_group":"OpencodeDiffDelete","right_gravity":true,"end_right_gravity":false,"virt_text_hide":false,"virt_text_pos":"overlay","virt_text_repeat_linebreak":false,"virt_text":[["-","OpencodeDiffDelete"]],"priority":5000,"ns_id":3,"hl_eol":true}],[12,14,0,{"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"priority":4096,"right_gravity":true,"ns_id":3,"virt_text_win_col":-1}],[13,15,0,{"end_col":0,"end_row":16,"hl_group":"OpencodeDiffAdd","right_gravity":true,"end_right_gravity":false,"virt_text_hide":false,"virt_text_pos":"overlay","virt_text_repeat_linebreak":false,"virt_text":[["+","OpencodeDiffAdd"]],"priority":5000,"ns_id":3,"hl_eol":true}],[14,15,0,{"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"priority":4096,"right_gravity":true,"ns_id":3,"virt_text_win_col":-1}],[15,16,0,{"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"priority":4096,"right_gravity":true,"ns_id":3,"virt_text_win_col":-1}],[16,17,0,{"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"priority":4096,"right_gravity":true,"ns_id":3,"virt_text_win_col":-1}],[17,22,0,{"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":false,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" claude-sonnet-4.5","OpencodeHint"],[" (2025-10-12 06:43:03)","OpencodeHint"],[" [msg_9d7288f2f001hW6NqqhtBc72UU]","OpencodeHint"]],"priority":10,"right_gravity":true,"ns_id":3,"virt_text_win_col":-3}]]}

0 commit comments

Comments
 (0)