Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions lua/opencode/ui/contextual_actions.lua
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
local state = require('opencode.state')
local keymap = require('opencode.keymap')
local output_window = require('opencode.ui.output_window')

local M = {}
Expand All @@ -26,6 +25,13 @@ function M.setup_contextual_actions(windows)
callback = function()
vim.schedule(function()
local line_num = vim.api.nvim_win_get_cursor(0)[1]

if not line_num or line_num <= 0 or not state.windows or not state.windows.output_buf then
return
end

line_num = line_num - 1 -- need api-indexing (e.g. 0 based line #), win_get_cursor returns 1 based line #

local actions = require('opencode.ui.renderer').get_actions_for_line(line_num)
last_line_num = line_num

Expand All @@ -34,7 +40,7 @@ function M.setup_contextual_actions(windows)

if actions and #actions > 0 then
dirty = true
M.show_contextual_actions_menu(state.windows.output_buf, line_num, actions, ns_id)
M.show_contextual_actions_menu(state.windows.output_buf, actions, ns_id)
end
end)
end,
Expand All @@ -48,6 +54,7 @@ function M.setup_contextual_actions(windows)
if not output_window.mounted() then
return
end
---@cast state.windows { output_buf: integer}
local line_num = vim.api.nvim_win_get_cursor(0)[1]
if last_line_num == line_num and not dirty then
return
Expand All @@ -61,15 +68,17 @@ function M.setup_contextual_actions(windows)
group = augroup,
buffer = windows.output_buf,
callback = function()
vim.api.nvim_buf_clear_namespace(state.windows.output_buf, ns_id, 0, -1)
clear_keymaps(state.windows.output_buf)
if state.windows and state.windows.output_buf then
vim.api.nvim_buf_clear_namespace(state.windows.output_buf, ns_id, 0, -1)
clear_keymaps(state.windows.output_buf)
end
last_line_num = nil
dirty = false
end,
})
end

function M.show_contextual_actions_menu(buf, line_num, actions, ns_id)
function M.show_contextual_actions_menu(buf, actions, ns_id)
clear_keymaps(buf)

for _, action in ipairs(actions) do
Expand All @@ -80,7 +89,7 @@ function M.show_contextual_actions_menu(buf, line_num, actions, ns_id)
hl_mode = 'combine',
}

vim.api.nvim_buf_set_extmark(buf, ns_id, action.display_line - 1, 0, mark)
vim.api.nvim_buf_set_extmark(buf, ns_id, action.display_line, 0, mark --[[@as vim.api.keyset.set_extmark]])
end
-- Setup key mappings for actions
for _, action in ipairs(actions) do
Expand Down
76 changes: 28 additions & 48 deletions lua/opencode/ui/formatter.lua
Original file line number Diff line number Diff line change
Expand Up @@ -154,66 +154,46 @@ function M._format_revert_message(session_data, start_idx)
return output
end

local function add_action(output, text, action_type, args, key, line)
-- actions use api-indexing (e.g. 0 indexed)
line = (line or output:get_line_count()) - 1
output:add_action({
text = text,
type = action_type,
args = args,
key = key,
display_line = line,
range = { from = line, to = line },
})
end

---@param output Output Output object to write to
---@param part OpencodeMessagePart
function M._format_patch(output, part)
if not part.hash then
return
end

local restore_points = snapshot.get_restore_points_by_parent(part.hash) or {}
M._format_action(output, icons.get('snapshot') .. ' Created Snapshot', vim.trim(part.hash:sub(1, 8)))
local snapshot_header_line = output:get_line_count()

-- Anchor all snapshot-level actions to the snapshot header line
output:add_action({
text = '[R]evert file',
type = 'diff_revert_selected_file',
args = { part.hash },
key = 'R',
display_line = snapshot_header_line,
range = { from = snapshot_header_line, to = snapshot_header_line },
})
output:add_action({
text = 'Revert [A]ll',
type = 'diff_revert_all',
args = { part.hash },
key = 'A',
display_line = snapshot_header_line,
range = { from = snapshot_header_line, to = snapshot_header_line },
})
output:add_action({
text = '[D]iff',
type = 'diff_open',
args = { part.hash },
key = 'D',
display_line = snapshot_header_line,
range = { from = snapshot_header_line, to = snapshot_header_line },
})
add_action(output, '[R]evert file', 'diff_revert_selected_file', { part.hash }, 'R')
add_action(output, 'Revert [A]ll', 'diff_revert_all', { part.hash }, 'A')
add_action(output, '[D]iff', 'diff_open', { part.hash }, 'D')

if #restore_points > 0 then
for _, restore_point in ipairs(restore_points) do
output:add_line(
string.format(
' %s Restore point `%s` - %s',
' %s Restore point `%s` - %s ',
icons.get('restore_point'),
restore_point.id:sub(1, 8),
vim.trim(restore_point.id:sub(1, 8)),
util.format_time(restore_point.created_at)
)
)
local restore_line = output:get_line_count()
output:add_action({
text = 'Restore [A]ll',
type = 'diff_restore_snapshot_all',
args = { restore_point.id },
key = 'A',
display_line = restore_line,
range = { from = restore_line, to = restore_line },
})
output:add_action({
text = '[R]estore file',
type = 'diff_restore_snapshot_file',
args = { restore_point.id },
key = 'R',
display_line = restore_line,
range = { from = restore_line, to = restore_line },
})
add_action(output, 'Restore [A]ll', 'diff_restore_snapshot_all', { restore_point.id }, 'A')
add_action(output, '[R]estore file', 'diff_restore_snapshot_file', { restore_point.id }, 'R')
end
end
end
Expand Down Expand Up @@ -282,10 +262,10 @@ function M.format_message_header(message)
and (not message.parts or #message.parts == 0)
then
local error = message.info.error
local error_messgage = error.data and error.data.message or vim.inspect(error)
local error_message = error.data and error.data.message or vim.inspect(error)

output:add_line('')
M._format_callout(output, 'ERROR', error_messgage)
M._format_callout(output, 'ERROR', error_message)
end

output:add_line('')
Expand Down Expand Up @@ -797,8 +777,8 @@ function M.format_part(part, message, is_last_part)

if is_last_part and role == 'assistant' and message.info.error and message.info.error ~= '' then
local error = message.info.error
local error_messgage = error.data and error.data.message or vim.inspect(error)
M._format_callout(output, 'ERROR', error_messgage)
local error_message = error.data and error.data.message or vim.inspect(error)
M._format_callout(output, 'ERROR', error_message)
output:add_empty_line()
end

Expand Down
6 changes: 3 additions & 3 deletions lua/opencode/ui/render_state.lua
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ end
---@param snapshot_id string Call ID
---@return OpencodeMessagePart? part Part if found
function RenderState:get_part_by_snapshot_id(snapshot_id)
for _, rendered_message in pairs(self._messages) do
for _, part in ipairs(rendered_message.message.parts) do
for _, rendered_message in pairs(self._messages or {}) do
for _, part in ipairs(rendered_message.message.parts or {}) do
if part.type == 'patch' and part.hash == snapshot_id then
return part
end
Expand Down Expand Up @@ -119,7 +119,7 @@ function RenderState:get_message_at_line(line)
end

---Get actions at specific line
---@param line integer Line number (1-indexed)
---@param line integer Line number (0-indexed)
---@return table[] List of actions at that line
function RenderState:get_actions_at_line(line)
self:_ensure_line_index()
Expand Down
17 changes: 11 additions & 6 deletions lua/opencode/ui/renderer.lua
Original file line number Diff line number Diff line change
Expand Up @@ -275,10 +275,6 @@ function M._write_formatted_data(formatted_data, part_id, start_line)
return nil
end

if part_id and formatted_data.actions then
M._render_state:add_actions(part_id, formatted_data.actions, target_line)
end

if is_insertion then
output_window.set_lines(new_lines, target_line, target_line)
else
Expand All @@ -287,6 +283,15 @@ function M._write_formatted_data(formatted_data, part_id, start_line)
target_line = target_line - 1
output_window.set_lines(extra_newline, target_line)
end

-- update actions and extmarks after the insertion because that may
-- adjust target_line (e.g. when we we're replacing the double newline at
-- the end)

if part_id and formatted_data.actions then
M._render_state:add_actions(part_id, formatted_data.actions, target_line)
end

output_window.set_extmarks(extmarks, target_line)

return {
Expand Down Expand Up @@ -413,7 +418,7 @@ function M._replace_part_in_buffer(part_id, formatted_data)
output_window.set_extmarks(formatted_data.extmarks, cached.line_start)

if formatted_data.actions then
M._render_state:add_actions(part_id, formatted_data.actions, cached.line_start)
M._render_state:add_actions(part_id, formatted_data.actions, cached.line_start + 1)
end

M._render_state:update_part_lines(part_id, cached.line_start, new_line_end)
Expand Down Expand Up @@ -959,7 +964,7 @@ function M.on_session_changed(_, new, _)
end

---Get all actions available at a specific line
---@param line integer 1-indexed line number
---@param line integer 0-indexed line number
---@return table[] List of actions available at that line
function M.get_actions_for_line(line)
return M._render_state:get_actions_at_line(line)
Expand Down
2 changes: 1 addition & 1 deletion tests/data/diagnostics.expected.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion tests/data/diff.expected.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"extmarks":[[1,1,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-3,"ns_id":3,"virt_text_repeat_linebreak":false,"priority":10,"right_gravity":true,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2025-10-12 06:42:56)","OpencodeHint"],[" [msg_9d7287269001C5gRusYfX7A1w1]","OpencodeHint"]]}],[2,2,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-3,"ns_id":3,"virt_text_repeat_linebreak":true,"priority":4096,"right_gravity":true,"virt_text":[["▌","OpencodeMessageRoleUser"]]}],[3,3,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-3,"ns_id":3,"virt_text_repeat_linebreak":true,"priority":4096,"right_gravity":true,"virt_text":[["▌","OpencodeMessageRoleUser"]]}],[4,3,39,{"right_gravity":true,"end_row":3,"end_col":53,"hl_group":"OpencodeMention","ns_id":3,"priority":1000,"hl_eol":false,"end_right_gravity":false}],[5,4,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-3,"ns_id":3,"virt_text_repeat_linebreak":true,"priority":4096,"right_gravity":true,"virt_text":[["▌","OpencodeMessageRoleUser"]]}],[6,5,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-3,"ns_id":3,"virt_text_repeat_linebreak":true,"priority":4096,"right_gravity":true,"virt_text":[["▌","OpencodeMessageRoleUser"]]}],[7,8,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-3,"ns_id":3,"virt_text_repeat_linebreak":false,"priority":10,"right_gravity":true,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" claude-sonnet-4.5","OpencodeHint"],[" (2025-10-12 06:42:56)","OpencodeHint"],[" [msg_9d7287287001HVwpPaH7WkRVdN]","OpencodeHint"]]}],[8,10,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1,"ns_id":3,"virt_text_repeat_linebreak":true,"priority":4096,"right_gravity":true,"virt_text":[["▌","OpencodeToolBorder"]]}],[9,11,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1,"ns_id":3,"virt_text_repeat_linebreak":true,"priority":4096,"right_gravity":true,"virt_text":[["▌","OpencodeToolBorder"]]}],[10,12,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1,"ns_id":3,"virt_text_repeat_linebreak":true,"priority":4096,"right_gravity":true,"virt_text":[["▌","OpencodeToolBorder"]]}],[11,13,0,{"virt_text_pos":"overlay","virt_text_hide":false,"end_col":0,"ns_id":3,"end_right_gravity":false,"virt_text_repeat_linebreak":false,"end_row":14,"hl_group":"OpencodeDiffDelete","right_gravity":true,"priority":5000,"hl_eol":true,"virt_text":[["-","OpencodeDiffDelete"]]}],[12,13,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1,"ns_id":3,"virt_text_repeat_linebreak":true,"priority":4096,"right_gravity":true,"virt_text":[["▌","OpencodeToolBorder"]]}],[13,14,0,{"virt_text_pos":"overlay","virt_text_hide":false,"end_col":0,"ns_id":3,"end_right_gravity":false,"virt_text_repeat_linebreak":false,"end_row":15,"hl_group":"OpencodeDiffAdd","right_gravity":true,"priority":5000,"hl_eol":true,"virt_text":[["+","OpencodeDiffAdd"]]}],[14,14,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1,"ns_id":3,"virt_text_repeat_linebreak":true,"priority":4096,"right_gravity":true,"virt_text":[["▌","OpencodeToolBorder"]]}],[15,15,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1,"ns_id":3,"virt_text_repeat_linebreak":true,"priority":4096,"right_gravity":true,"virt_text":[["▌","OpencodeToolBorder"]]}],[16,16,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-1,"ns_id":3,"virt_text_repeat_linebreak":true,"priority":4096,"right_gravity":true,"virt_text":[["▌","OpencodeToolBorder"]]}],[17,21,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_win_col":-3,"ns_id":3,"virt_text_repeat_linebreak":false,"priority":10,"right_gravity":true,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" claude-sonnet-4.5","OpencodeHint"],[" (2025-10-12 06:43:03)","OpencodeHint"],[" [msg_9d7288f2f001hW6NqqhtBc72UU]","OpencodeHint"]]}]],"timestamp":1762908306,"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`","","----","","",""],"actions":[{"key":"R","text":"[R]evert file","display_line":20,"args":["1f593f7ed419c95d3995f8ef4b98d4e571c3a492"],"range":{"from":20,"to":20},"type":"diff_revert_selected_file"},{"key":"A","text":"Revert [A]ll","display_line":20,"args":["1f593f7ed419c95d3995f8ef4b98d4e571c3a492"],"range":{"from":20,"to":20},"type":"diff_revert_all"},{"key":"D","text":"[D]iff","display_line":20,"args":["1f593f7ed419c95d3995f8ef4b98d4e571c3a492"],"range":{"from":20,"to":20},"type":"diff_open"}]}
{"timestamp":1763498558,"actions":[{"text":"[R]evert file","display_line":18,"type":"diff_revert_selected_file","args":["1f593f7ed419c95d3995f8ef4b98d4e571c3a492"],"key":"R","range":{"to":18,"from":18}},{"text":"Revert [A]ll","display_line":18,"type":"diff_revert_all","args":["1f593f7ed419c95d3995f8ef4b98d4e571c3a492"],"key":"A","range":{"to":18,"from":18}},{"text":"[D]iff","display_line":18,"type":"diff_open","args":["1f593f7ed419c95d3995f8ef4b98d4e571c3a492"],"key":"D","range":{"to":18,"from":18}}],"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`","","----","","",""],"extmarks":[[1,1,0,{"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2025-10-12 06:42:56)","OpencodeHint"],[" [msg_9d7287269001C5gRusYfX7A1w1]","OpencodeHint"]],"right_gravity":true,"virt_text_win_col":-3,"priority":10,"ns_id":3,"virt_text_pos":"win_col","virt_text_repeat_linebreak":false,"virt_text_hide":false}],[2,2,0,{"virt_text":[["▌","OpencodeMessageRoleUser"]],"right_gravity":true,"virt_text_win_col":-3,"priority":4096,"ns_id":3,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text_hide":false}],[3,3,0,{"virt_text":[["▌","OpencodeMessageRoleUser"]],"right_gravity":true,"virt_text_win_col":-3,"priority":4096,"ns_id":3,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text_hide":false}],[4,3,39,{"priority":1000,"right_gravity":true,"end_right_gravity":false,"end_col":53,"end_row":3,"hl_eol":false,"hl_group":"OpencodeMention","ns_id":3}],[5,4,0,{"virt_text":[["▌","OpencodeMessageRoleUser"]],"right_gravity":true,"virt_text_win_col":-3,"priority":4096,"ns_id":3,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text_hide":false}],[6,5,0,{"virt_text":[["▌","OpencodeMessageRoleUser"]],"right_gravity":true,"virt_text_win_col":-3,"priority":4096,"ns_id":3,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text_hide":false}],[7,8,0,{"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" claude-sonnet-4.5","OpencodeHint"],[" (2025-10-12 06:42:56)","OpencodeHint"],[" [msg_9d7287287001HVwpPaH7WkRVdN]","OpencodeHint"]],"right_gravity":true,"virt_text_win_col":-3,"priority":10,"ns_id":3,"virt_text_pos":"win_col","virt_text_repeat_linebreak":false,"virt_text_hide":false}],[8,10,0,{"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"virt_text_win_col":-1,"priority":4096,"ns_id":3,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text_hide":false}],[9,11,0,{"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"virt_text_win_col":-1,"priority":4096,"ns_id":3,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text_hide":false}],[10,12,0,{"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"virt_text_win_col":-1,"priority":4096,"ns_id":3,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text_hide":false}],[11,13,0,{"virt_text":[["-","OpencodeDiffDelete"]],"virt_text_repeat_linebreak":false,"priority":5000,"ns_id":3,"end_right_gravity":false,"right_gravity":true,"virt_text_pos":"overlay","end_col":0,"end_row":14,"hl_eol":true,"hl_group":"OpencodeDiffDelete","virt_text_hide":false}],[12,13,0,{"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"virt_text_win_col":-1,"priority":4096,"ns_id":3,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text_hide":false}],[13,14,0,{"virt_text":[["+","OpencodeDiffAdd"]],"virt_text_repeat_linebreak":false,"priority":5000,"ns_id":3,"end_right_gravity":false,"right_gravity":true,"virt_text_pos":"overlay","end_col":0,"end_row":15,"hl_eol":true,"hl_group":"OpencodeDiffAdd","virt_text_hide":false}],[14,14,0,{"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"virt_text_win_col":-1,"priority":4096,"ns_id":3,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text_hide":false}],[15,15,0,{"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"virt_text_win_col":-1,"priority":4096,"ns_id":3,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text_hide":false}],[16,16,0,{"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"virt_text_win_col":-1,"priority":4096,"ns_id":3,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text_hide":false}],[17,21,0,{"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" claude-sonnet-4.5","OpencodeHint"],[" (2025-10-12 06:43:03)","OpencodeHint"],[" [msg_9d7288f2f001hW6NqqhtBc72UU]","OpencodeHint"]],"right_gravity":true,"virt_text_win_col":-3,"priority":10,"ns_id":3,"virt_text_pos":"win_col","virt_text_repeat_linebreak":false,"virt_text_hide":false}]]}
Loading