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
1 change: 1 addition & 0 deletions lua/opencode/api.lua
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ local action_groups = {
open_input_new_session_with_title = session.open_input_new_session_with_title,
rename_session = session.rename_session,
undo = session.undo,
copy_message = session.copy_message,
fork_session = session.fork_session,
toggle_session_lock = session.toggle_session_lock,
},
Expand Down
30 changes: 30 additions & 0 deletions lua/opencode/commands/handlers/session.lua
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,36 @@ function M.actions.undo(message_id)
end)
end

---@param message_id string
function M.actions.copy_message(message_id)
return with_active_session('No active session to copy', function(state_obj)
local target = find_message_in_state(state_obj, message_id)
if not target or not target.info or target.info.role ~= 'user' then
vim.notify('No user message to copy', vim.log.levels.WARN)
return
end

local text_parts = {}
for _, part in ipairs(target.parts or {}) do
if
part.type == 'text'
and part.synthetic ~= true
and type(part.text) == 'string'
and vim.trim(part.text) ~= ''
then
text_parts[#text_parts + 1] = part.text
end
end

if #text_parts == 0 then
vim.notify('No message text to copy', vim.log.levels.WARN)
return
end

vim.fn.setreg('+', table.concat(text_parts, '\n\n'))
end)
end

---@param state_obj OpencodeState
---@return string|nil
local function find_next_message_for_redo(state_obj)
Expand Down
2 changes: 1 addition & 1 deletion lua/opencode/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -543,7 +543,7 @@

---@class OutputAction
---@field text string Action text
---@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'
---@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'
---@field args? string[] Optional arguments for the command
---@field key string keybinding for the action
---@field display_line number Line number to display the action
Expand Down
192 changes: 114 additions & 78 deletions lua/opencode/ui/contextual_actions.lua
Original file line number Diff line number Diff line change
@@ -1,110 +1,146 @@
local state = require('opencode.state')
local output_window = require('opencode.ui.output_window')

local M = {}

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

local function clear_keymaps(buf)
for key, _ in pairs(current_keymaps) do
vim.keymap.del('n', key, { buffer = buf })
local function buffer_mapping(buf, key)
for _, mapping in ipairs(vim.api.nvim_buf_get_keymap(buf, 'n')) do
if mapping.lhs == key then
return mapping
end
end
current_keymaps = {}
end

function M.setup_contextual_actions(windows)
local ns_id = vim.api.nvim_create_namespace('opencode_contextual_actions')
local augroup = vim.api.nvim_create_augroup('OpenCodeContextualActions', { clear = true })

vim.api.nvim_create_autocmd('CursorHold', {
group = augroup,
buffer = windows.output_buf,
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
local function restore_mapping(buf, mapping)
local options = {
desc = mapping.desc,
silent = mapping.silent == 1,
expr = mapping.expr == 1,
nowait = mapping.nowait == 1,
noremap = mapping.noremap ~= 0,
script = mapping.script == 1,
replace_keycodes = mapping.replace_keycodes == 1,
}
if mapping.callback then
options.callback = mapping.callback
end
vim.api.nvim_buf_set_keymap(buf, 'n', mapping.lhs, mapping.callback and '' or mapping.rhs, options)
end

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

local actions = require('opencode.ui.renderer').get_actions_for_line(line_num)
last_line_num = line_num
if vim.api.nvim_buf_is_valid(buf) then
vim.api.nvim_buf_clear_namespace(buf, namespace, 0, -1)
for key, mapping in pairs(lifecycle.saved_mappings) do
vim.keymap.del('n', key, { buffer = buf })
if mapping then
restore_mapping(buf, mapping)
end
end
end
lifecycle.saved_mappings = {}
lifecycle.actions = nil
end

vim.api.nvim_buf_clear_namespace(state.windows.output_buf, ns_id, 0, -1)
clear_keymaps(state.windows.output_buf)
local function ensure_lifecycle(buf)
local lifecycle = lifecycles[buf]
if lifecycle then
return lifecycle
end

if actions and #actions > 0 then
dirty = true
M.show_contextual_actions_menu(state.windows.output_buf, actions, ns_id)
end
end)
lifecycle = { saved_mappings = {}, actions = nil }
assert(vim.api.nvim_buf_attach(buf, false, {
on_lines = function()
clear_contextual_actions(buf)
end,
})

vim.api.nvim_create_autocmd('CursorMoved', {
group = augroup,
buffer = windows.output_buf,
callback = function()
vim.schedule(function()
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
end
vim.api.nvim_buf_clear_namespace(state.windows.output_buf, ns_id, 0, -1)
end)
on_reload = function()
clear_contextual_actions(buf)
end,
})

vim.api.nvim_create_autocmd({ 'BufLeave', 'BufDelete', 'BufHidden' }, {
group = augroup,
buffer = windows.output_buf,
callback = function()
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
on_detach = function()
clear_contextual_actions(buf)
lifecycles[buf] = nil
end,
})
}))
lifecycles[buf] = lifecycle
return lifecycle
end

function M.show_contextual_actions_menu(buf, actions, ns_id)
clear_keymaps(buf)
function M.show_contextual_actions_menu(buf, actions)
local lifecycle = ensure_lifecycle(buf)
actions = actions and #actions > 0 and actions or nil

if vim.deep_equal(lifecycle.actions, actions) then
return
end

clear_contextual_actions(buf)
if not actions then
return
end
lifecycle.actions = actions

for _, action in ipairs(actions) do
---@type OutputExtmark
local mark = {
vim.api.nvim_buf_set_extmark(buf, namespace, action.display_line, 0, {
virt_text = { { '⋮ ' .. action.text .. ' ', 'OpencodeContextualActions' } },
virt_text_pos = 'right_align',
hl_mode = 'combine',
}
})

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
if action.key then
current_keymaps[action.key] = true
if action.key and lifecycle.saved_mappings[action.key] == nil then
lifecycle.saved_mappings[action.key] = buffer_mapping(buf, action.key) or false
vim.keymap.set('n', action.key, function()
if lifecycles[buf] ~= lifecycle or not vim.tbl_contains(lifecycle.actions or {}, action) then
return
end

clear_contextual_actions(buf)
if action.type and action.args then
vim.api.nvim_buf_clear_namespace(buf, ns_id, 0, -1)
clear_keymaps(buf)
local api = require('opencode.api')
api[action.type](unpack(action.args))
require('opencode.api')[action.type](unpack(action.args))
end
end, { buffer = buf, silent = true, desc = action.text })
end
end
end

local function refresh_contextual_actions(buf)
local lifecycle = lifecycles[buf]
if not lifecycle then
return
end

if not state.windows or state.windows.output_buf ~= buf or vim.api.nvim_get_current_buf() ~= buf then
clear_contextual_actions(buf)
return
end

local line = vim.api.nvim_win_get_cursor(0)[1] - 1
M.show_contextual_actions_menu(buf, require('opencode.ui.renderer').get_actions_for_line(line))
end

function M.setup_contextual_actions(windows)
ensure_lifecycle(windows.output_buf)
refresh_contextual_actions(windows.output_buf)
end

vim.api.nvim_create_autocmd({ 'CursorHold', 'CursorMoved', 'BufEnter', 'WinEnter' }, {
group = augroup,
callback = function(event)
refresh_contextual_actions(event.buf)
end,
})

vim.api.nvim_create_autocmd({ 'BufLeave', 'BufDelete', 'BufHidden' }, {
group = augroup,
callback = function(event)
clear_contextual_actions(event.buf)
end,
})

return M
2 changes: 1 addition & 1 deletion lua/opencode/ui/formatter.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1012,7 +1012,7 @@ function M.format_part(part, message, is_last_part, context)
if is_compaction_part(part) then
format_compaction_divider(output)
content_added = true
elseif part.type == 'text' and part.text then
elseif part.type == 'text' and type(part.text) == 'string' then
if part.synthetic == true then
part._message_context = message
M._format_selection_context(output, part)
Expand Down
Loading
Loading