Skip to content

Commit 33a6629

Browse files
authored
[feat]: Add contextual actions to user messages (#473)
* feat(ui): add contextual actions to user messages state.messages RenderState ├── message id ──> undo / fork └── user message range └── original text ──> copy └── R / C / F cursor line ──> actions in range ──> temporary mappings ──> opencode.api │ └── leave range ──> restore previous mappings Reuse the existing contextual action rendering and API dispatch paths to expose Revert, Copy, and Fork across the full user message range. Copy uses the original non-synthetic text parts, and temporary mappings restore the buffer mappings they replace. * refactor(ui): simplify contextual action lifecycle Derive attachment from the per-buffer lifecycle record and use the current action set to reject stale callbacks. Keep refresh and cleanup as the only state transitions while preserving mapping restoration and buffer isolation.
1 parent 3b10019 commit 33a6629

50 files changed

Lines changed: 2776 additions & 158 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

lua/opencode/api.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ local action_groups = {
5252
open_input_new_session_with_title = session.open_input_new_session_with_title,
5353
rename_session = session.rename_session,
5454
undo = session.undo,
55+
copy_message = session.copy_message,
5556
fork_session = session.fork_session,
5657
toggle_session_lock = session.toggle_session_lock,
5758
},

lua/opencode/commands/handlers/session.lua

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,36 @@ function M.actions.undo(message_id)
470470
end)
471471
end
472472

473+
---@param message_id string
474+
function M.actions.copy_message(message_id)
475+
return with_active_session('No active session to copy', function(state_obj)
476+
local target = find_message_in_state(state_obj, message_id)
477+
if not target or not target.info or target.info.role ~= 'user' then
478+
vim.notify('No user message to copy', vim.log.levels.WARN)
479+
return
480+
end
481+
482+
local text_parts = {}
483+
for _, part in ipairs(target.parts or {}) do
484+
if
485+
part.type == 'text'
486+
and part.synthetic ~= true
487+
and type(part.text) == 'string'
488+
and vim.trim(part.text) ~= ''
489+
then
490+
text_parts[#text_parts + 1] = part.text
491+
end
492+
end
493+
494+
if #text_parts == 0 then
495+
vim.notify('No message text to copy', vim.log.levels.WARN)
496+
return
497+
end
498+
499+
vim.fn.setreg('+', table.concat(text_parts, '\n\n'))
500+
end)
501+
end
502+
473503
---@param state_obj OpencodeState
474504
---@return string|nil
475505
local function find_next_message_for_redo(state_obj)

lua/opencode/types.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -543,7 +543,7 @@
543543

544544
---@class OutputAction
545545
---@field text string Action text
546-
---@field type 'diff_revert_all'|'diff_revert_selected_file'|'diff_open'|'diff_restore_snapshot_file'|'diff_restore_snapshot_all'|'navigate_session_tree'|'toggle_max_messages'
546+
---@field type 'diff_revert_all'|'diff_revert_selected_file'|'diff_open'|'diff_restore_snapshot_file'|'diff_restore_snapshot_all'|'navigate_session_tree'|'toggle_max_messages'|'undo'|'copy_message'|'fork_session'
547547
---@field args? string[] Optional arguments for the command
548548
---@field key string keybinding for the action
549549
---@field display_line number Line number to display the action
Lines changed: 114 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,110 +1,146 @@
11
local state = require('opencode.state')
2-
local output_window = require('opencode.ui.output_window')
32

43
local M = {}
54

6-
-- Module state
7-
local last_line_num = nil
8-
local dirty = false
9-
local current_keymaps = {}
5+
local namespace = vim.api.nvim_create_namespace('opencode_contextual_actions')
6+
local augroup = vim.api.nvim_create_augroup('OpenCodeContextualActions', { clear = true })
7+
local lifecycles = {}
108

11-
local function clear_keymaps(buf)
12-
for key, _ in pairs(current_keymaps) do
13-
vim.keymap.del('n', key, { buffer = buf })
9+
local function buffer_mapping(buf, key)
10+
for _, mapping in ipairs(vim.api.nvim_buf_get_keymap(buf, 'n')) do
11+
if mapping.lhs == key then
12+
return mapping
13+
end
1414
end
15-
current_keymaps = {}
1615
end
1716

18-
function M.setup_contextual_actions(windows)
19-
local ns_id = vim.api.nvim_create_namespace('opencode_contextual_actions')
20-
local augroup = vim.api.nvim_create_augroup('OpenCodeContextualActions', { clear = true })
21-
22-
vim.api.nvim_create_autocmd('CursorHold', {
23-
group = augroup,
24-
buffer = windows.output_buf,
25-
callback = function()
26-
vim.schedule(function()
27-
local line_num = vim.api.nvim_win_get_cursor(0)[1]
28-
29-
if not line_num or line_num <= 0 or not state.windows or not state.windows.output_buf then
30-
return
31-
end
17+
local function restore_mapping(buf, mapping)
18+
local options = {
19+
desc = mapping.desc,
20+
silent = mapping.silent == 1,
21+
expr = mapping.expr == 1,
22+
nowait = mapping.nowait == 1,
23+
noremap = mapping.noremap ~= 0,
24+
script = mapping.script == 1,
25+
replace_keycodes = mapping.replace_keycodes == 1,
26+
}
27+
if mapping.callback then
28+
options.callback = mapping.callback
29+
end
30+
vim.api.nvim_buf_set_keymap(buf, 'n', mapping.lhs, mapping.callback and '' or mapping.rhs, options)
31+
end
3232

33-
line_num = line_num - 1 -- need api-indexing (e.g. 0 based line #), win_get_cursor returns 1 based line #
33+
local function clear_contextual_actions(buf)
34+
local lifecycle = lifecycles[buf]
35+
if not lifecycle then
36+
return
37+
end
3438

35-
local actions = require('opencode.ui.renderer').get_actions_for_line(line_num)
36-
last_line_num = line_num
39+
if vim.api.nvim_buf_is_valid(buf) then
40+
vim.api.nvim_buf_clear_namespace(buf, namespace, 0, -1)
41+
for key, mapping in pairs(lifecycle.saved_mappings) do
42+
vim.keymap.del('n', key, { buffer = buf })
43+
if mapping then
44+
restore_mapping(buf, mapping)
45+
end
46+
end
47+
end
48+
lifecycle.saved_mappings = {}
49+
lifecycle.actions = nil
50+
end
3751

38-
vim.api.nvim_buf_clear_namespace(state.windows.output_buf, ns_id, 0, -1)
39-
clear_keymaps(state.windows.output_buf)
52+
local function ensure_lifecycle(buf)
53+
local lifecycle = lifecycles[buf]
54+
if lifecycle then
55+
return lifecycle
56+
end
4057

41-
if actions and #actions > 0 then
42-
dirty = true
43-
M.show_contextual_actions_menu(state.windows.output_buf, actions, ns_id)
44-
end
45-
end)
58+
lifecycle = { saved_mappings = {}, actions = nil }
59+
assert(vim.api.nvim_buf_attach(buf, false, {
60+
on_lines = function()
61+
clear_contextual_actions(buf)
4662
end,
47-
})
48-
49-
vim.api.nvim_create_autocmd('CursorMoved', {
50-
group = augroup,
51-
buffer = windows.output_buf,
52-
callback = function()
53-
vim.schedule(function()
54-
if not output_window.mounted() then
55-
return
56-
end
57-
---@cast state.windows { output_buf: integer}
58-
local line_num = vim.api.nvim_win_get_cursor(0)[1]
59-
if last_line_num == line_num and not dirty then
60-
return
61-
end
62-
vim.api.nvim_buf_clear_namespace(state.windows.output_buf, ns_id, 0, -1)
63-
end)
63+
on_reload = function()
64+
clear_contextual_actions(buf)
6465
end,
65-
})
66-
67-
vim.api.nvim_create_autocmd({ 'BufLeave', 'BufDelete', 'BufHidden' }, {
68-
group = augroup,
69-
buffer = windows.output_buf,
70-
callback = function()
71-
if state.windows and state.windows.output_buf then
72-
vim.api.nvim_buf_clear_namespace(state.windows.output_buf, ns_id, 0, -1)
73-
clear_keymaps(state.windows.output_buf)
74-
end
75-
last_line_num = nil
76-
dirty = false
66+
on_detach = function()
67+
clear_contextual_actions(buf)
68+
lifecycles[buf] = nil
7769
end,
78-
})
70+
}))
71+
lifecycles[buf] = lifecycle
72+
return lifecycle
7973
end
8074

81-
function M.show_contextual_actions_menu(buf, actions, ns_id)
82-
clear_keymaps(buf)
75+
function M.show_contextual_actions_menu(buf, actions)
76+
local lifecycle = ensure_lifecycle(buf)
77+
actions = actions and #actions > 0 and actions or nil
78+
79+
if vim.deep_equal(lifecycle.actions, actions) then
80+
return
81+
end
82+
83+
clear_contextual_actions(buf)
84+
if not actions then
85+
return
86+
end
87+
lifecycle.actions = actions
8388

8489
for _, action in ipairs(actions) do
85-
---@type OutputExtmark
86-
local mark = {
90+
vim.api.nvim_buf_set_extmark(buf, namespace, action.display_line, 0, {
8791
virt_text = { { '' .. action.text .. ' ', 'OpencodeContextualActions' } },
8892
virt_text_pos = 'right_align',
8993
hl_mode = 'combine',
90-
}
94+
})
9195

92-
vim.api.nvim_buf_set_extmark(buf, ns_id, action.display_line, 0, mark --[[@as vim.api.keyset.set_extmark]])
93-
end
94-
-- Setup key mappings for actions
95-
for _, action in ipairs(actions) do
96-
if action.key then
97-
current_keymaps[action.key] = true
96+
if action.key and lifecycle.saved_mappings[action.key] == nil then
97+
lifecycle.saved_mappings[action.key] = buffer_mapping(buf, action.key) or false
9898
vim.keymap.set('n', action.key, function()
99+
if lifecycles[buf] ~= lifecycle or not vim.tbl_contains(lifecycle.actions or {}, action) then
100+
return
101+
end
102+
103+
clear_contextual_actions(buf)
99104
if action.type and action.args then
100-
vim.api.nvim_buf_clear_namespace(buf, ns_id, 0, -1)
101-
clear_keymaps(buf)
102-
local api = require('opencode.api')
103-
api[action.type](unpack(action.args))
105+
require('opencode.api')[action.type](unpack(action.args))
104106
end
105107
end, { buffer = buf, silent = true, desc = action.text })
106108
end
107109
end
108110
end
109111

112+
local function refresh_contextual_actions(buf)
113+
local lifecycle = lifecycles[buf]
114+
if not lifecycle then
115+
return
116+
end
117+
118+
if not state.windows or state.windows.output_buf ~= buf or vim.api.nvim_get_current_buf() ~= buf then
119+
clear_contextual_actions(buf)
120+
return
121+
end
122+
123+
local line = vim.api.nvim_win_get_cursor(0)[1] - 1
124+
M.show_contextual_actions_menu(buf, require('opencode.ui.renderer').get_actions_for_line(line))
125+
end
126+
127+
function M.setup_contextual_actions(windows)
128+
ensure_lifecycle(windows.output_buf)
129+
refresh_contextual_actions(windows.output_buf)
130+
end
131+
132+
vim.api.nvim_create_autocmd({ 'CursorHold', 'CursorMoved', 'BufEnter', 'WinEnter' }, {
133+
group = augroup,
134+
callback = function(event)
135+
refresh_contextual_actions(event.buf)
136+
end,
137+
})
138+
139+
vim.api.nvim_create_autocmd({ 'BufLeave', 'BufDelete', 'BufHidden' }, {
140+
group = augroup,
141+
callback = function(event)
142+
clear_contextual_actions(event.buf)
143+
end,
144+
})
145+
110146
return M

lua/opencode/ui/formatter.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1012,7 +1012,7 @@ function M.format_part(part, message, is_last_part, context)
10121012
if is_compaction_part(part) then
10131013
format_compaction_divider(output)
10141014
content_added = true
1015-
elseif part.type == 'text' and part.text then
1015+
elseif part.type == 'text' and type(part.text) == 'string' then
10161016
if part.synthetic == true then
10171017
part._message_context = message
10181018
M._format_selection_context(output, part)

0 commit comments

Comments
 (0)