diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 337e0663..f446c3eb 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -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, }, diff --git a/lua/opencode/commands/handlers/session.lua b/lua/opencode/commands/handlers/session.lua index f6829212..f3b15ed6 100644 --- a/lua/opencode/commands/handlers/session.lua +++ b/lua/opencode/commands/handlers/session.lua @@ -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) diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index ede22729..e158a845 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -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 diff --git a/lua/opencode/ui/contextual_actions.lua b/lua/opencode/ui/contextual_actions.lua index ab2eb0b3..4ff844f0 100644 --- a/lua/opencode/ui/contextual_actions.lua +++ b/lua/opencode/ui/contextual_actions.lua @@ -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 diff --git a/lua/opencode/ui/formatter.lua b/lua/opencode/ui/formatter.lua index 3fba3257..ac3dea4e 100644 --- a/lua/opencode/ui/formatter.lua +++ b/lua/opencode/ui/formatter.lua @@ -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) diff --git a/lua/opencode/ui/render_state.lua b/lua/opencode/ui/render_state.lua index f222928a..67be73f6 100644 --- a/lua/opencode/ui/render_state.lua +++ b/lua/opencode/ui/render_state.lua @@ -2,6 +2,7 @@ ---@field message OpencodeMessage Direct reference to message in state.messages ---@field line_start integer? Line where message header starts ---@field line_end integer? Line where message header ends +---@field actions OutputAction[] Actions associated with this message ---@class RenderedPart ---@field part OpencodeMessagePart Direct reference to part in state.messages @@ -129,6 +130,22 @@ local function range_lookup(ranges, line) return nil end +local function range_before_or_at(ranges, line) + local lo, hi = 1, #ranges + local result = nil + while lo <= hi do + local mid = math.floor((lo + hi) / 2) + local range = ranges[mid] + if range[1] <= line then + result = range[3] + lo = mid + 1 + else + hi = mid - 1 + end + end + return result +end + function RenderState:_rebuild_ranges() local part_ranges = {} for part_id, part_data in pairs(self._parts) do @@ -345,18 +362,19 @@ end ---@return table[] function RenderState:get_actions_at_line(line) self:_ensure_ranges() - local part_id = range_lookup(self._part_ranges, line) - if not part_id then - return {} - end + local actions = {} - local part_data = self._parts[part_id] - if not part_data or not part_data.actions then - return {} + local message_id = range_before_or_at(self._message_ranges, line) + local message_data = message_id and self._messages[message_id] + for _, action in ipairs(message_data and message_data.actions or {}) do + if action.range and action.range.from <= line and action.range.to >= line then + actions[#actions + 1] = action + end end - local actions = {} - for _, action in ipairs(part_data.actions) do + local part_id = range_lookup(self._part_ranges, line) + local part_data = part_id and self._parts[part_id] + for _, action in ipairs(part_data and part_data.actions or {}) do if action.range and action.range.from <= line and action.range.to >= line then actions[#actions + 1] = action end @@ -478,6 +496,11 @@ end ---@return table[] function RenderState:get_all_actions() local all_actions = {} + for _, message_data in pairs(self._messages) do + for _, action in ipairs(message_data.actions or {}) do + all_actions[#all_actions + 1] = action + end + end for _, part_data in pairs(self._parts) do if part_data.actions then for _, action in ipairs(part_data.actions) do @@ -488,6 +511,59 @@ function RenderState:get_all_actions() return all_actions end +local function is_actionable_user_message(message) + local info = message and message.info + if not info or info.role ~= 'user' or type(info.id) ~= 'string' or info.id == '' then + return false + end + + for _, part in ipairs(message.parts or {}) do + if part.type == 'text' and part.synthetic ~= true and type(part.text) == 'string' and vim.trim(part.text) ~= '' then + return true + end + end + + return false +end + +function RenderState:_refresh_message_actions(message_id) + local message_data = self._messages[message_id] + if not message_data or not message_data.line_start or not message_data.line_end then + return + end + + if not is_actionable_user_message(message_data.message) then + message_data.actions = {} + return + end + + local line_end = message_data.line_end + for _, part in ipairs(message_data.message.parts or {}) do + local part_data = part.id and self._parts[part.id] + if part_data and part_data.line_end then + line_end = math.max(line_end, part_data.line_end) + end + end + + local id = message_data.message.info.id + local function action(text, action_type, key) + return { + text = text, + type = action_type, + args = { id }, + key = key, + display_line = line_end, + range = { from = message_data.line_start, to = line_end }, + } + end + + message_data.actions = { + action('[R]evert', 'undo', 'R'), + action('[C]opy', 'copy_message', 'C'), + action('[F]ork', 'fork_session', 'F'), + } +end + ---@param targets RenderedTarget[] ---@param delta integer local function shift_targets(targets, delta) @@ -511,6 +587,7 @@ function RenderState:set_message(message, line_start, line_end) message = message, line_start = line_start, line_end = line_end, + actions = {}, } else existing.message = message @@ -528,6 +605,7 @@ function RenderState:set_message(message, line_start, line_end) self._max_line_end = line_end end end + self:_refresh_message_actions(message_id) end ---@param part OpencodeMessagePart @@ -579,6 +657,7 @@ function RenderState:set_part(part, line_start, line_end) end self:_index_task_part_child_session(part_id, part) + self:_refresh_message_actions(message_id) end ---@param part_id string @@ -620,6 +699,8 @@ function RenderState:update_part_lines(part_id, new_line_start, new_line_end) self:shift_all(old_line_end + 1, delta) end + self:_refresh_message_actions(part_data.message_id) + return true end @@ -640,6 +721,7 @@ function RenderState:update_part_data(part_ref) end self:_index_task_part_child_session(part_ref.id, part_ref) + self:_refresh_message_actions(rendered_part.message_id) return rendered_part end @@ -659,6 +741,7 @@ function RenderState:remove_part(part_id) if not part_data.line_start or not part_data.line_end then self._parts[part_id] = nil + self:_refresh_message_actions(part_data.message_id) return true end @@ -672,6 +755,7 @@ function RenderState:remove_part(part_id) end self:shift_all(shift_from, -line_count) + self:_refresh_message_actions(part_data.message_id) return true end @@ -740,6 +824,7 @@ function RenderState:shift_all(from_line, delta) self:_rebuild_ranges() local shifted = false + local action_messages = {} local msg_ranges = self._message_ranges local first_msg = first_range_at_or_after(msg_ranges, from_line) @@ -749,6 +834,9 @@ function RenderState:shift_all(from_line, delta) msg_data.line_start = msg_data.line_start + delta msg_data.line_end = msg_data.line_end + delta shifted = true + for _, action in ipairs(msg_data.actions or {}) do + shift_action(action, delta) + end end end @@ -760,6 +848,7 @@ function RenderState:shift_all(from_line, delta) part_data.line_start = part_data.line_start + delta part_data.line_end = part_data.line_end + delta shifted = true + action_messages[part_data.message_id] = true for _, action in ipairs(part_data.actions) do shift_action(action, delta) end @@ -773,6 +862,10 @@ function RenderState:shift_all(from_line, delta) self._max_line_end = self._max_line_end + delta end end + + for message_id in pairs(action_messages) do + self:_refresh_message_actions(message_id) + end end return RenderState diff --git a/tests/data/api-abort.expected.json b/tests/data/api-abort.expected.json index 44cdec8e..fdf556ad 100644 --- a/tests/data/api-abort.expected.json +++ b/tests/data/api-abort.expected.json @@ -1,5 +1,45 @@ { - "actions": [], + "actions": [ + { + "args": [ + "msg_a27d8299d001nchmBunYlZcPyL" + ], + "display_line": 6, + "key": "R", + "range": { + "from": 0, + "to": 6 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_a27d8299d001nchmBunYlZcPyL" + ], + "display_line": 6, + "key": "C", + "range": { + "from": 0, + "to": 6 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_a27d8299d001nchmBunYlZcPyL" + ], + "display_line": 6, + "key": "F", + "range": { + "from": 0, + "to": 6 + }, + "text": "[F]ork", + "type": "fork_session" + } + ], "extmarks": [ [ 1, diff --git a/tests/data/api-error.expected.json b/tests/data/api-error.expected.json index c64ad247..146db955 100644 --- a/tests/data/api-error.expected.json +++ b/tests/data/api-error.expected.json @@ -1,5 +1,45 @@ { - "actions": [], + "actions": [ + { + "args": [ + "msg_9ffef0129001CoCrBKemk7DqcU" + ], + "display_line": 6, + "key": "R", + "range": { + "from": 0, + "to": 6 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_9ffef0129001CoCrBKemk7DqcU" + ], + "display_line": 6, + "key": "C", + "range": { + "from": 0, + "to": 6 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_9ffef0129001CoCrBKemk7DqcU" + ], + "display_line": 6, + "key": "F", + "range": { + "from": 0, + "to": 6 + }, + "text": "[F]ork", + "type": "fork_session" + } + ], "extmarks": [ [ 1, diff --git a/tests/data/compaction-after-messages.expected.json b/tests/data/compaction-after-messages.expected.json index 5b3f2d73..b19b1038 100644 --- a/tests/data/compaction-after-messages.expected.json +++ b/tests/data/compaction-after-messages.expected.json @@ -1,5 +1,45 @@ { - "actions": [], + "actions": [ + { + "args": [ + "msg_after_user" + ], + "display_line": 8, + "key": "R", + "range": { + "from": 4, + "to": 8 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_after_user" + ], + "display_line": 8, + "key": "C", + "range": { + "from": 4, + "to": 8 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_after_user" + ], + "display_line": 8, + "key": "F", + "range": { + "from": 4, + "to": 8 + }, + "text": "[F]ork", + "type": "fork_session" + } + ], "extmarks": [ [ 1, diff --git a/tests/data/cursor_data.expected.json b/tests/data/cursor_data.expected.json index 46429f66..fddf3970 100644 --- a/tests/data/cursor_data.expected.json +++ b/tests/data/cursor_data.expected.json @@ -1,5 +1,45 @@ { - "actions": [], + "actions": [ + { + "args": [ + "msg_a3637244a001FDRDfoBYVPEGpd" + ], + "display_line": 11, + "key": "R", + "range": { + "from": 0, + "to": 11 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_a3637244a001FDRDfoBYVPEGpd" + ], + "display_line": 11, + "key": "C", + "range": { + "from": 0, + "to": 11 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_a3637244a001FDRDfoBYVPEGpd" + ], + "display_line": 11, + "key": "F", + "range": { + "from": 0, + "to": 11 + }, + "text": "[F]ork", + "type": "fork_session" + } + ], "extmarks": [ [ 1, diff --git a/tests/data/diagnostics.expected.json b/tests/data/diagnostics.expected.json index d07cabcb..6c72d218 100644 --- a/tests/data/diagnostics.expected.json +++ b/tests/data/diagnostics.expected.json @@ -2,42 +2,42 @@ "actions": [ { "args": [ - "8e7903714919009004aad8754db0035fb47ecb24" + "msg_a49ed91d6001coTsjFq9x6FF5W" ], - "display_line": 57, + "display_line": 8, "key": "R", "range": { - "from": 57, - "to": 57 + "from": 0, + "to": 8 }, - "text": "[R]evert file", - "type": "diff_revert_selected_file" + "text": "[R]evert", + "type": "undo" }, { "args": [ - "8e7903714919009004aad8754db0035fb47ecb24" + "msg_a49ed91d6001coTsjFq9x6FF5W" ], - "display_line": 57, - "key": "A", + "display_line": 8, + "key": "C", "range": { - "from": 57, - "to": 57 + "from": 0, + "to": 8 }, - "text": "Revert [A]ll", - "type": "diff_revert_all" + "text": "[C]opy", + "type": "copy_message" }, { "args": [ - "8e7903714919009004aad8754db0035fb47ecb24" + "msg_a49ed91d6001coTsjFq9x6FF5W" ], - "display_line": 57, - "key": "D", + "display_line": 8, + "key": "F", "range": { - "from": 57, - "to": 57 + "from": 0, + "to": 8 }, - "text": "[D]iff", - "type": "diff_open" + "text": "[F]ork", + "type": "fork_session" }, { "args": [ @@ -77,6 +77,45 @@ }, "text": "[D]iff", "type": "diff_open" + }, + { + "args": [ + "8e7903714919009004aad8754db0035fb47ecb24" + ], + "display_line": 57, + "key": "R", + "range": { + "from": 57, + "to": 57 + }, + "text": "[R]evert file", + "type": "diff_revert_selected_file" + }, + { + "args": [ + "8e7903714919009004aad8754db0035fb47ecb24" + ], + "display_line": 57, + "key": "A", + "range": { + "from": 57, + "to": 57 + }, + "text": "Revert [A]ll", + "type": "diff_revert_all" + }, + { + "args": [ + "8e7903714919009004aad8754db0035fb47ecb24" + ], + "display_line": 57, + "key": "D", + "range": { + "from": 57, + "to": 57 + }, + "text": "[D]iff", + "type": "diff_open" } ], "extmarks": [ diff --git a/tests/data/diff.expected.json b/tests/data/diff.expected.json index 41322a43..d12ef602 100644 --- a/tests/data/diff.expected.json +++ b/tests/data/diff.expected.json @@ -1,5 +1,44 @@ { "actions": [ + { + "args": [ + "msg_9d7287269001C5gRusYfX7A1w1" + ], + "display_line": 6, + "key": "R", + "range": { + "from": 0, + "to": 6 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_9d7287269001C5gRusYfX7A1w1" + ], + "display_line": 6, + "key": "C", + "range": { + "from": 0, + "to": 6 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_9d7287269001C5gRusYfX7A1w1" + ], + "display_line": 6, + "key": "F", + "range": { + "from": 0, + "to": 6 + }, + "text": "[F]ork", + "type": "fork_session" + }, { "args": [ "1f593f7ed419c95d3995f8ef4b98d4e571c3a492" diff --git a/tests/data/explore.expected.json b/tests/data/explore.expected.json index 82dcbf3f..eef23998 100644 --- a/tests/data/explore.expected.json +++ b/tests/data/explore.expected.json @@ -1,5 +1,44 @@ { "actions": [ + { + "args": [ + "msg_cbe0c07af001FqFiaeygb5fC3O" + ], + "display_line": 4, + "key": "R", + "range": { + "from": 0, + "to": 4 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_cbe0c07af001FqFiaeygb5fC3O" + ], + "display_line": 4, + "key": "C", + "range": { + "from": 0, + "to": 4 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_cbe0c07af001FqFiaeygb5fC3O" + ], + "display_line": 4, + "key": "F", + "range": { + "from": 0, + "to": 4 + }, + "text": "[F]ork", + "type": "fork_session" + }, { "args": [ "ses_341f3e676ffez6WUF6zpok7dUZ" diff --git a/tests/data/markdown-codefence.expected.json b/tests/data/markdown-codefence.expected.json index 7649add7..f2732065 100644 --- a/tests/data/markdown-codefence.expected.json +++ b/tests/data/markdown-codefence.expected.json @@ -1,5 +1,44 @@ { "actions": [ + { + "args": [ + "msg_a2cf5ce65001YLvVsYxIboFcP4" + ], + "display_line": 36, + "key": "R", + "range": { + "from": 30, + "to": 36 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_a2cf5ce65001YLvVsYxIboFcP4" + ], + "display_line": 36, + "key": "C", + "range": { + "from": 30, + "to": 36 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_a2cf5ce65001YLvVsYxIboFcP4" + ], + "display_line": 36, + "key": "F", + "range": { + "from": 30, + "to": 36 + }, + "text": "[F]ork", + "type": "fork_session" + }, { "args": [ "c64ddee834f1b802020a8f880eafa689f0b99406" diff --git a/tests/data/mcp-tool.expected.json b/tests/data/mcp-tool.expected.json index 6be5668d..ad48b20b 100644 --- a/tests/data/mcp-tool.expected.json +++ b/tests/data/mcp-tool.expected.json @@ -1,5 +1,45 @@ { - "actions": [], + "actions": [ + { + "args": [ + "msg_mcp_user1" + ], + "display_line": 4, + "key": "R", + "range": { + "from": 0, + "to": 4 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_mcp_user1" + ], + "display_line": 4, + "key": "C", + "range": { + "from": 0, + "to": 4 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_mcp_user1" + ], + "display_line": 4, + "key": "F", + "range": { + "from": 0, + "to": 4 + }, + "text": "[F]ork", + "type": "fork_session" + } + ], "extmarks": [ [ 1, diff --git a/tests/data/mentions-with-ranges.expected.json b/tests/data/mentions-with-ranges.expected.json index 9b7ea15a..2748fba9 100644 --- a/tests/data/mentions-with-ranges.expected.json +++ b/tests/data/mentions-with-ranges.expected.json @@ -1,5 +1,45 @@ { - "actions": [], + "actions": [ + { + "args": [ + "msg_9daca16bf0017x95VD45mw3k8Q" + ], + "display_line": 22, + "key": "R", + "range": { + "from": 0, + "to": 22 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_9daca16bf0017x95VD45mw3k8Q" + ], + "display_line": 22, + "key": "C", + "range": { + "from": 0, + "to": 22 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_9daca16bf0017x95VD45mw3k8Q" + ], + "display_line": 22, + "key": "F", + "range": { + "from": 0, + "to": 22 + }, + "text": "[F]ork", + "type": "fork_session" + } + ], "extmarks": [ [ 1, diff --git a/tests/data/message-removal.expected.json b/tests/data/message-removal.expected.json index e5002d90..45a36f2f 100644 --- a/tests/data/message-removal.expected.json +++ b/tests/data/message-removal.expected.json @@ -1,5 +1,45 @@ { - "actions": [], + "actions": [ + { + "args": [ + "msg_001" + ], + "display_line": 10, + "key": "R", + "range": { + "from": 0, + "to": 10 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_001" + ], + "display_line": 10, + "key": "C", + "range": { + "from": 0, + "to": 10 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_001" + ], + "display_line": 10, + "key": "F", + "range": { + "from": 0, + "to": 10 + }, + "text": "[F]ork", + "type": "fork_session" + } + ], "extmarks": [ [ 1, diff --git a/tests/data/multiple-messages-synthetic.expected.json b/tests/data/multiple-messages-synthetic.expected.json index 3de3cb7f..ee8f0c0b 100644 --- a/tests/data/multiple-messages-synthetic.expected.json +++ b/tests/data/multiple-messages-synthetic.expected.json @@ -1,5 +1,123 @@ { - "actions": [], + "actions": [ + { + "args": [ + "msg_005" + ], + "display_line": 36, + "key": "R", + "range": { + "from": 32, + "to": 36 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_005" + ], + "display_line": 36, + "key": "C", + "range": { + "from": 32, + "to": 36 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_005" + ], + "display_line": 36, + "key": "F", + "range": { + "from": 32, + "to": 36 + }, + "text": "[F]ork", + "type": "fork_session" + }, + { + "args": [ + "msg_001" + ], + "display_line": 4, + "key": "R", + "range": { + "from": 0, + "to": 4 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_001" + ], + "display_line": 4, + "key": "C", + "range": { + "from": 0, + "to": 4 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_001" + ], + "display_line": 4, + "key": "F", + "range": { + "from": 0, + "to": 4 + }, + "text": "[F]ork", + "type": "fork_session" + }, + { + "args": [ + "msg_003" + ], + "display_line": 20, + "key": "R", + "range": { + "from": 16, + "to": 20 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_003" + ], + "display_line": 20, + "key": "C", + "range": { + "from": 16, + "to": 20 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_003" + ], + "display_line": 20, + "key": "F", + "range": { + "from": 16, + "to": 20 + }, + "text": "[F]ork", + "type": "fork_session" + } + ], "extmarks": [ [ 1, diff --git a/tests/data/multiple-messages.expected.json b/tests/data/multiple-messages.expected.json index b8dbc4bf..d067d663 100644 --- a/tests/data/multiple-messages.expected.json +++ b/tests/data/multiple-messages.expected.json @@ -1,5 +1,84 @@ { - "actions": [], + "actions": [ + { + "args": [ + "msg_a3d79a71f001dkQYw23QnYYElB" + ], + "display_line": 6, + "key": "R", + "range": { + "from": 0, + "to": 6 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_a3d79a71f001dkQYw23QnYYElB" + ], + "display_line": 6, + "key": "C", + "range": { + "from": 0, + "to": 6 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_a3d79a71f001dkQYw23QnYYElB" + ], + "display_line": 6, + "key": "F", + "range": { + "from": 0, + "to": 6 + }, + "text": "[F]ork", + "type": "fork_session" + }, + { + "args": [ + "msg_a3d79aa5b0014RYY4My2AeTokd" + ], + "display_line": 31, + "key": "R", + "range": { + "from": 25, + "to": 31 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_a3d79aa5b0014RYY4My2AeTokd" + ], + "display_line": 31, + "key": "C", + "range": { + "from": 25, + "to": 31 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_a3d79aa5b0014RYY4My2AeTokd" + ], + "display_line": 31, + "key": "F", + "range": { + "from": 25, + "to": 31 + }, + "text": "[F]ork", + "type": "fork_session" + } + ], "extmarks": [ [ 1, diff --git a/tests/data/multiple-question-ask-reply-all.expected.json b/tests/data/multiple-question-ask-reply-all.expected.json index f8bc8c3c..ce2a724b 100644 --- a/tests/data/multiple-question-ask-reply-all.expected.json +++ b/tests/data/multiple-question-ask-reply-all.expected.json @@ -1,5 +1,44 @@ { "actions": [ + { + "args": [ + "msg_bfab6dbf3001ZOVHTFKR1CMUE5" + ], + "display_line": 6, + "key": "R", + "range": { + "from": 0, + "to": 6 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_bfab6dbf3001ZOVHTFKR1CMUE5" + ], + "display_line": 6, + "key": "C", + "range": { + "from": 0, + "to": 6 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_bfab6dbf3001ZOVHTFKR1CMUE5" + ], + "display_line": 6, + "key": "F", + "range": { + "from": 0, + "to": 6 + }, + "text": "[F]ork", + "type": "fork_session" + }, { "args": [ "be64b6205f0da4c9240df2476dc35495429161b5" diff --git a/tests/data/multiple-question-ask.expected.json b/tests/data/multiple-question-ask.expected.json index 6ffc0953..992385f7 100644 --- a/tests/data/multiple-question-ask.expected.json +++ b/tests/data/multiple-question-ask.expected.json @@ -1,5 +1,45 @@ { - "actions": [], + "actions": [ + { + "args": [ + "msg_bfab6dbf3001ZOVHTFKR1CMUE5" + ], + "display_line": 6, + "key": "R", + "range": { + "from": 0, + "to": 6 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_bfab6dbf3001ZOVHTFKR1CMUE5" + ], + "display_line": 6, + "key": "C", + "range": { + "from": 0, + "to": 6 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_bfab6dbf3001ZOVHTFKR1CMUE5" + ], + "display_line": 6, + "key": "F", + "range": { + "from": 0, + "to": 6 + }, + "text": "[F]ork", + "type": "fork_session" + } + ], "extmarks": [ [ 1, diff --git a/tests/data/part-before-message-delta.expected.json b/tests/data/part-before-message-delta.expected.json index 8b47f8fd..19b5a44b 100644 --- a/tests/data/part-before-message-delta.expected.json +++ b/tests/data/part-before-message-delta.expected.json @@ -1,5 +1,45 @@ { - "actions": [], + "actions": [ + { + "args": [ + "msg_0000000000001" + ], + "display_line": 4, + "key": "R", + "range": { + "from": 0, + "to": 4 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_0000000000001" + ], + "display_line": 4, + "key": "C", + "range": { + "from": 0, + "to": 4 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_0000000000001" + ], + "display_line": 4, + "key": "F", + "range": { + "from": 0, + "to": 4 + }, + "text": "[F]ork", + "type": "fork_session" + } + ], "extmarks": [ [ 1, diff --git a/tests/data/perf.expected.json b/tests/data/perf.expected.json index 674ecde1..a38d8218 100644 --- a/tests/data/perf.expected.json +++ b/tests/data/perf.expected.json @@ -1,5 +1,45 @@ { - "actions": [], + "actions": [ + { + "args": [ + "msg_a17b4dc4c001x19oFZANB8CsEB" + ], + "display_line": 6, + "key": "R", + "range": { + "from": 0, + "to": 6 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_a17b4dc4c001x19oFZANB8CsEB" + ], + "display_line": 6, + "key": "C", + "range": { + "from": 0, + "to": 6 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_a17b4dc4c001x19oFZANB8CsEB" + ], + "display_line": 6, + "key": "F", + "range": { + "from": 0, + "to": 6 + }, + "text": "[F]ork", + "type": "fork_session" + } + ], "extmarks": [ [ 1, diff --git a/tests/data/permission-ask-new-approve.expected.json b/tests/data/permission-ask-new-approve.expected.json index 7f6fe2e7..dcd692cd 100644 --- a/tests/data/permission-ask-new-approve.expected.json +++ b/tests/data/permission-ask-new-approve.expected.json @@ -1,5 +1,44 @@ { "actions": [ + { + "args": [ + "msg_b8e7c60a2001Kisjwk2mVB4dye" + ], + "display_line": 8, + "key": "R", + "range": { + "from": 0, + "to": 8 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_b8e7c60a2001Kisjwk2mVB4dye" + ], + "display_line": 8, + "key": "C", + "range": { + "from": 0, + "to": 8 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_b8e7c60a2001Kisjwk2mVB4dye" + ], + "display_line": 8, + "key": "F", + "range": { + "from": 0, + "to": 8 + }, + "text": "[F]ork", + "type": "fork_session" + }, { "args": [ "5ba6e95774829e7f501299039b72b72dc32c6620" diff --git a/tests/data/permission-ask-new-deny.expected.json b/tests/data/permission-ask-new-deny.expected.json index bd6fdd9f..b85b9e6f 100644 --- a/tests/data/permission-ask-new-deny.expected.json +++ b/tests/data/permission-ask-new-deny.expected.json @@ -1,5 +1,45 @@ { - "actions": [], + "actions": [ + { + "args": [ + "msg_b8e7d8222001ZAc4G1t5RfHzou" + ], + "display_line": 6, + "key": "R", + "range": { + "from": 0, + "to": 6 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_b8e7d8222001ZAc4G1t5RfHzou" + ], + "display_line": 6, + "key": "C", + "range": { + "from": 0, + "to": 6 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_b8e7d8222001ZAc4G1t5RfHzou" + ], + "display_line": 6, + "key": "F", + "range": { + "from": 0, + "to": 6 + }, + "text": "[F]ork", + "type": "fork_session" + } + ], "extmarks": [ [ 1, diff --git a/tests/data/permission-ask-new.expected.json b/tests/data/permission-ask-new.expected.json index 296fecb6..a86179aa 100644 --- a/tests/data/permission-ask-new.expected.json +++ b/tests/data/permission-ask-new.expected.json @@ -1,5 +1,45 @@ { - "actions": [], + "actions": [ + { + "args": [ + "msg_b8e7c60a2001Kisjwk2mVB4dye" + ], + "display_line": 8, + "key": "R", + "range": { + "from": 0, + "to": 8 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_b8e7c60a2001Kisjwk2mVB4dye" + ], + "display_line": 8, + "key": "C", + "range": { + "from": 0, + "to": 8 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_b8e7c60a2001Kisjwk2mVB4dye" + ], + "display_line": 8, + "key": "F", + "range": { + "from": 0, + "to": 8 + }, + "text": "[F]ork", + "type": "fork_session" + } + ], "extmarks": [ [ 1, diff --git a/tests/data/permission.expected.json b/tests/data/permission.expected.json index 03f08feb..eb892490 100644 --- a/tests/data/permission.expected.json +++ b/tests/data/permission.expected.json @@ -1,5 +1,44 @@ { "actions": [ + { + "args": [ + "msg_9d6f253910015UFmkGkiWtUsRW" + ], + "display_line": 4, + "key": "R", + "range": { + "from": 0, + "to": 4 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_9d6f253910015UFmkGkiWtUsRW" + ], + "display_line": 4, + "key": "C", + "range": { + "from": 0, + "to": 4 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_9d6f253910015UFmkGkiWtUsRW" + ], + "display_line": 4, + "key": "F", + "range": { + "from": 0, + "to": 4 + }, + "text": "[F]ork", + "type": "fork_session" + }, { "args": [ "c78fb2dd2d533cfe530692cc3e3c8f92a0e4af1d" diff --git a/tests/data/planning.expected.json b/tests/data/planning.expected.json index 960ed8c6..085630a6 100644 --- a/tests/data/planning.expected.json +++ b/tests/data/planning.expected.json @@ -1,5 +1,45 @@ { - "actions": [], + "actions": [ + { + "args": [ + "msg_9d45d40c9001s7A1sP3Ew537QN" + ], + "display_line": 6, + "key": "R", + "range": { + "from": 0, + "to": 6 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_9d45d40c9001s7A1sP3Ew537QN" + ], + "display_line": 6, + "key": "C", + "range": { + "from": 0, + "to": 6 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_9d45d40c9001s7A1sP3Ew537QN" + ], + "display_line": 6, + "key": "F", + "range": { + "from": 0, + "to": 6 + }, + "text": "[F]ork", + "type": "fork_session" + } + ], "extmarks": [ [ 1, diff --git a/tests/data/provider-overloaded-status.expected.json b/tests/data/provider-overloaded-status.expected.json index 8b47f8fd..19b5a44b 100644 --- a/tests/data/provider-overloaded-status.expected.json +++ b/tests/data/provider-overloaded-status.expected.json @@ -1,5 +1,45 @@ { - "actions": [], + "actions": [ + { + "args": [ + "msg_0000000000001" + ], + "display_line": 4, + "key": "R", + "range": { + "from": 0, + "to": 4 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_0000000000001" + ], + "display_line": 4, + "key": "C", + "range": { + "from": 0, + "to": 4 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_0000000000001" + ], + "display_line": 4, + "key": "F", + "range": { + "from": 0, + "to": 4 + }, + "text": "[F]ork", + "type": "fork_session" + } + ], "extmarks": [ [ 1, diff --git a/tests/data/question-ask-other.expected.json b/tests/data/question-ask-other.expected.json index 9d055b51..c25effad 100644 --- a/tests/data/question-ask-other.expected.json +++ b/tests/data/question-ask-other.expected.json @@ -1,5 +1,45 @@ { - "actions": [], + "actions": [ + { + "args": [ + "msg_c595d93bb001YP4s8b1oxCvGev" + ], + "display_line": 6, + "key": "R", + "range": { + "from": 0, + "to": 6 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_c595d93bb001YP4s8b1oxCvGev" + ], + "display_line": 6, + "key": "C", + "range": { + "from": 0, + "to": 6 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_c595d93bb001YP4s8b1oxCvGev" + ], + "display_line": 6, + "key": "F", + "range": { + "from": 0, + "to": 6 + }, + "text": "[F]ork", + "type": "fork_session" + } + ], "extmarks": [ [ 1, diff --git a/tests/data/question-ask-replied.expected.json b/tests/data/question-ask-replied.expected.json index 04a0d2d7..808de14f 100644 --- a/tests/data/question-ask-replied.expected.json +++ b/tests/data/question-ask-replied.expected.json @@ -1,5 +1,45 @@ { - "actions": [], + "actions": [ + { + "args": [ + "msg_bfaafef41001700DjtCwvhFxse" + ], + "display_line": 6, + "key": "R", + "range": { + "from": 0, + "to": 6 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_bfaafef41001700DjtCwvhFxse" + ], + "display_line": 6, + "key": "C", + "range": { + "from": 0, + "to": 6 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_bfaafef41001700DjtCwvhFxse" + ], + "display_line": 6, + "key": "F", + "range": { + "from": 0, + "to": 6 + }, + "text": "[F]ork", + "type": "fork_session" + } + ], "extmarks": [ [ 1, diff --git a/tests/data/question-ask.expected.json b/tests/data/question-ask.expected.json index cc0a1ed4..627e0d0c 100644 --- a/tests/data/question-ask.expected.json +++ b/tests/data/question-ask.expected.json @@ -1,5 +1,45 @@ { - "actions": [], + "actions": [ + { + "args": [ + "msg_bfaa078a2001RUYhlKZhSlyWeF" + ], + "display_line": 6, + "key": "R", + "range": { + "from": 0, + "to": 6 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_bfaa078a2001RUYhlKZhSlyWeF" + ], + "display_line": 6, + "key": "C", + "range": { + "from": 0, + "to": 6 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_bfaa078a2001RUYhlKZhSlyWeF" + ], + "display_line": 6, + "key": "F", + "range": { + "from": 0, + "to": 6 + }, + "text": "[F]ork", + "type": "fork_session" + } + ], "extmarks": [ [ 1, diff --git a/tests/data/question-multiple-choices-answered.expected.json b/tests/data/question-multiple-choices-answered.expected.json index 7d186ac6..de0121c3 100644 --- a/tests/data/question-multiple-choices-answered.expected.json +++ b/tests/data/question-multiple-choices-answered.expected.json @@ -1,5 +1,45 @@ { - "actions": [], + "actions": [ + { + "args": [ + "msg_f5cc36bf6001DoYmNlAd9dSCzU" + ], + "display_line": 4, + "key": "R", + "range": { + "from": 0, + "to": 4 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_f5cc36bf6001DoYmNlAd9dSCzU" + ], + "display_line": 4, + "key": "C", + "range": { + "from": 0, + "to": 4 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_f5cc36bf6001DoYmNlAd9dSCzU" + ], + "display_line": 4, + "key": "F", + "range": { + "from": 0, + "to": 4 + }, + "text": "[F]ork", + "type": "fork_session" + } + ], "extmarks": [ [ 1, diff --git a/tests/data/question-multiple-choices.expected.json b/tests/data/question-multiple-choices.expected.json index a5b8e637..6ed05974 100644 --- a/tests/data/question-multiple-choices.expected.json +++ b/tests/data/question-multiple-choices.expected.json @@ -1,5 +1,45 @@ { - "actions": [], + "actions": [ + { + "args": [ + "msg_f5bf094d40010EarsFjD5WvW47" + ], + "display_line": 4, + "key": "R", + "range": { + "from": 0, + "to": 4 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_f5bf094d40010EarsFjD5WvW47" + ], + "display_line": 4, + "key": "C", + "range": { + "from": 0, + "to": 4 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_f5bf094d40010EarsFjD5WvW47" + ], + "display_line": 4, + "key": "F", + "range": { + "from": 0, + "to": 4 + }, + "text": "[F]ork", + "type": "fork_session" + } + ], "extmarks": [ [ 1, diff --git a/tests/data/question-multiple-other.expected.json b/tests/data/question-multiple-other.expected.json index d11b5715..16318624 100644 --- a/tests/data/question-multiple-other.expected.json +++ b/tests/data/question-multiple-other.expected.json @@ -1,5 +1,45 @@ { - "actions": [], + "actions": [ + { + "args": [ + "msg_f5ca5903d0014XOHFNqdkjZjV0" + ], + "display_line": 4, + "key": "R", + "range": { + "from": 0, + "to": 4 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_f5ca5903d0014XOHFNqdkjZjV0" + ], + "display_line": 4, + "key": "C", + "range": { + "from": 0, + "to": 4 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_f5ca5903d0014XOHFNqdkjZjV0" + ], + "display_line": 4, + "key": "F", + "range": { + "from": 0, + "to": 4 + }, + "text": "[F]ork", + "type": "fork_session" + } + ], "extmarks": [ [ 1, diff --git a/tests/data/reasoning.expected.json b/tests/data/reasoning.expected.json index 8216267d..57b47c18 100644 --- a/tests/data/reasoning.expected.json +++ b/tests/data/reasoning.expected.json @@ -1,5 +1,45 @@ { - "actions": [], + "actions": [ + { + "args": [ + "msg_reason_user1" + ], + "display_line": 4, + "key": "R", + "range": { + "from": 0, + "to": 4 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_reason_user1" + ], + "display_line": 4, + "key": "C", + "range": { + "from": 0, + "to": 4 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_reason_user1" + ], + "display_line": 4, + "key": "F", + "range": { + "from": 0, + "to": 4 + }, + "text": "[F]ork", + "type": "fork_session" + } + ], "extmarks": [ [ 1, diff --git a/tests/data/redo-all.expected.json b/tests/data/redo-all.expected.json index 70cbd73e..285d8338 100644 --- a/tests/data/redo-all.expected.json +++ b/tests/data/redo-all.expected.json @@ -2,42 +2,120 @@ "actions": [ { "args": [ - "d988cc85565b99017d40ad8baea20225165be9d5" + "msg_a0236fd1c001TlwqL8fwvq529i" ], - "display_line": 90, + "display_line": 67, "key": "R", "range": { - "from": 90, - "to": 90 + "from": 63, + "to": 67 }, - "text": "[R]evert file", - "type": "diff_revert_selected_file" + "text": "[R]evert", + "type": "undo" }, { "args": [ - "d988cc85565b99017d40ad8baea20225165be9d5" + "msg_a0236fd1c001TlwqL8fwvq529i" ], - "display_line": 90, - "key": "A", + "display_line": 67, + "key": "C", "range": { - "from": 90, - "to": 90 + "from": 63, + "to": 67 }, - "text": "Revert [A]ll", - "type": "diff_revert_all" + "text": "[C]opy", + "type": "copy_message" }, { "args": [ - "d988cc85565b99017d40ad8baea20225165be9d5" + "msg_a0236fd1c001TlwqL8fwvq529i" ], - "display_line": 90, - "key": "D", + "display_line": 67, + "key": "F", "range": { - "from": 90, - "to": 90 + "from": 63, + "to": 67 }, - "text": "[D]iff", - "type": "diff_open" + "text": "[F]ork", + "type": "fork_session" + }, + { + "args": [ + "msg_a0234e308001SKl5bQUibp5gtI" + ], + "display_line": 33, + "key": "R", + "range": { + "from": 29, + "to": 33 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_a0234e308001SKl5bQUibp5gtI" + ], + "display_line": 33, + "key": "C", + "range": { + "from": 29, + "to": 33 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_a0234e308001SKl5bQUibp5gtI" + ], + "display_line": 33, + "key": "F", + "range": { + "from": 29, + "to": 33 + }, + "text": "[F]ork", + "type": "fork_session" + }, + { + "args": [ + "msg_a0234c0b7001y2o9S1jMaNVZar" + ], + "display_line": 6, + "key": "R", + "range": { + "from": 0, + "to": 6 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_a0234c0b7001y2o9S1jMaNVZar" + ], + "display_line": 6, + "key": "C", + "range": { + "from": 0, + "to": 6 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_a0234c0b7001y2o9S1jMaNVZar" + ], + "display_line": 6, + "key": "F", + "range": { + "from": 0, + "to": 6 + }, + "text": "[F]ork", + "type": "fork_session" }, { "args": [ @@ -116,6 +194,45 @@ }, "text": "[D]iff", "type": "diff_open" + }, + { + "args": [ + "d988cc85565b99017d40ad8baea20225165be9d5" + ], + "display_line": 90, + "key": "R", + "range": { + "from": 90, + "to": 90 + }, + "text": "[R]evert file", + "type": "diff_revert_selected_file" + }, + { + "args": [ + "d988cc85565b99017d40ad8baea20225165be9d5" + ], + "display_line": 90, + "key": "A", + "range": { + "from": 90, + "to": 90 + }, + "text": "Revert [A]ll", + "type": "diff_revert_all" + }, + { + "args": [ + "d988cc85565b99017d40ad8baea20225165be9d5" + ], + "display_line": 90, + "key": "D", + "range": { + "from": 90, + "to": 90 + }, + "text": "[D]iff", + "type": "diff_open" } ], "extmarks": [ diff --git a/tests/data/redo-once.expected.json b/tests/data/redo-once.expected.json index f63cd5f9..7fae05ef 100644 --- a/tests/data/redo-once.expected.json +++ b/tests/data/redo-once.expected.json @@ -1,5 +1,83 @@ { "actions": [ + { + "args": [ + "msg_a0234c0b7001y2o9S1jMaNVZar" + ], + "display_line": 6, + "key": "R", + "range": { + "from": 0, + "to": 6 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_a0234c0b7001y2o9S1jMaNVZar" + ], + "display_line": 6, + "key": "C", + "range": { + "from": 0, + "to": 6 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_a0234c0b7001y2o9S1jMaNVZar" + ], + "display_line": 6, + "key": "F", + "range": { + "from": 0, + "to": 6 + }, + "text": "[F]ork", + "type": "fork_session" + }, + { + "args": [ + "msg_a0234e308001SKl5bQUibp5gtI" + ], + "display_line": 33, + "key": "R", + "range": { + "from": 29, + "to": 33 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_a0234e308001SKl5bQUibp5gtI" + ], + "display_line": 33, + "key": "C", + "range": { + "from": 29, + "to": 33 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_a0234e308001SKl5bQUibp5gtI" + ], + "display_line": 33, + "key": "F", + "range": { + "from": 29, + "to": 33 + }, + "text": "[F]ork", + "type": "fork_session" + }, { "args": [ "1b6ba655c6c0d899965adff278ac6320d5fc3b12" diff --git a/tests/data/revert.expected.json b/tests/data/revert.expected.json index 5d4ef945..8d5780e2 100644 --- a/tests/data/revert.expected.json +++ b/tests/data/revert.expected.json @@ -1,5 +1,83 @@ { "actions": [ + { + "args": [ + "msg_9fd985573001fk1Xlot7uyDgTo" + ], + "display_line": 6, + "key": "R", + "range": { + "from": 0, + "to": 6 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_9fd985573001fk1Xlot7uyDgTo" + ], + "display_line": 6, + "key": "C", + "range": { + "from": 0, + "to": 6 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_9fd985573001fk1Xlot7uyDgTo" + ], + "display_line": 6, + "key": "F", + "range": { + "from": 0, + "to": 6 + }, + "text": "[F]ork", + "type": "fork_session" + }, + { + "args": [ + "msg_9fd988c92001w0IZCVPQsN6xa9" + ], + "display_line": 29, + "key": "R", + "range": { + "from": 25, + "to": 29 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_9fd988c92001w0IZCVPQsN6xa9" + ], + "display_line": 29, + "key": "C", + "range": { + "from": 25, + "to": 29 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_9fd988c92001w0IZCVPQsN6xa9" + ], + "display_line": 29, + "key": "F", + "range": { + "from": 25, + "to": 29 + }, + "text": "[F]ork", + "type": "fork_session" + }, { "args": [ "c410b2b4024de020aea223c5248eec89216de53f" diff --git a/tests/data/selection.expected.json b/tests/data/selection.expected.json index 4f9128f7..d0a6c336 100644 --- a/tests/data/selection.expected.json +++ b/tests/data/selection.expected.json @@ -1,5 +1,45 @@ { - "actions": [], + "actions": [ + { + "args": [ + "msg_9f5b29fea001z6jYXF7CG9omHa" + ], + "display_line": 14, + "key": "R", + "range": { + "from": 0, + "to": 14 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_9f5b29fea001z6jYXF7CG9omHa" + ], + "display_line": 14, + "key": "C", + "range": { + "from": 0, + "to": 14 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_9f5b29fea001z6jYXF7CG9omHa" + ], + "display_line": 14, + "key": "F", + "range": { + "from": 0, + "to": 14 + }, + "text": "[F]ork", + "type": "fork_session" + } + ], "extmarks": [ [ 1, diff --git a/tests/data/shifting-and-multiple-perms.expected.json b/tests/data/shifting-and-multiple-perms.expected.json index cee6af7b..4815afb2 100644 --- a/tests/data/shifting-and-multiple-perms.expected.json +++ b/tests/data/shifting-and-multiple-perms.expected.json @@ -1,5 +1,123 @@ { - "actions": [], + "actions": [ + { + "args": [ + "msg_9efb39d68001J2h30a50B2774b" + ], + "display_line": 6, + "key": "R", + "range": { + "from": 0, + "to": 6 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_9efb39d68001J2h30a50B2774b" + ], + "display_line": 6, + "key": "C", + "range": { + "from": 0, + "to": 6 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_9efb39d68001J2h30a50B2774b" + ], + "display_line": 6, + "key": "F", + "range": { + "from": 0, + "to": 6 + }, + "text": "[F]ork", + "type": "fork_session" + }, + { + "args": [ + "msg_9efb50a0b001WFK7AMDV45cF8Z" + ], + "display_line": 86, + "key": "R", + "range": { + "from": 82, + "to": 86 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_9efb50a0b001WFK7AMDV45cF8Z" + ], + "display_line": 86, + "key": "C", + "range": { + "from": 82, + "to": 86 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_9efb50a0b001WFK7AMDV45cF8Z" + ], + "display_line": 86, + "key": "F", + "range": { + "from": 82, + "to": 86 + }, + "text": "[F]ork", + "type": "fork_session" + }, + { + "args": [ + "msg_9efb59d93001LSm9y0DS9p8cP6" + ], + "display_line": 114, + "key": "R", + "range": { + "from": 110, + "to": 114 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_9efb59d93001LSm9y0DS9p8cP6" + ], + "display_line": 114, + "key": "C", + "range": { + "from": 110, + "to": 114 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_9efb59d93001LSm9y0DS9p8cP6" + ], + "display_line": 114, + "key": "F", + "range": { + "from": 110, + "to": 114 + }, + "text": "[F]ork", + "type": "fork_session" + } + ], "extmarks": [ [ 1, diff --git a/tests/data/simple-session.expected.json b/tests/data/simple-session.expected.json index 088404d2..ef62aee4 100644 --- a/tests/data/simple-session.expected.json +++ b/tests/data/simple-session.expected.json @@ -1,5 +1,45 @@ { - "actions": [], + "actions": [ + { + "args": [ + "msg_9cf8f64de0016tbfTQqWMydbdr" + ], + "display_line": 8, + "key": "R", + "range": { + "from": 0, + "to": 8 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_9cf8f64de0016tbfTQqWMydbdr" + ], + "display_line": 8, + "key": "C", + "range": { + "from": 0, + "to": 8 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_9cf8f64de0016tbfTQqWMydbdr" + ], + "display_line": 8, + "key": "F", + "range": { + "from": 0, + "to": 8 + }, + "text": "[F]ork", + "type": "fork_session" + } + ], "extmarks": [ [ 1, diff --git a/tests/data/updating-text.expected.json b/tests/data/updating-text.expected.json index ec3f2848..f43b2aec 100644 --- a/tests/data/updating-text.expected.json +++ b/tests/data/updating-text.expected.json @@ -1,5 +1,45 @@ { - "actions": [], + "actions": [ + { + "args": [ + "msg_9d0297a630014CA5ly3Vvw8Kt5" + ], + "display_line": 6, + "key": "R", + "range": { + "from": 0, + "to": 6 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_9d0297a630014CA5ly3Vvw8Kt5" + ], + "display_line": 6, + "key": "C", + "range": { + "from": 0, + "to": 6 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_9d0297a630014CA5ly3Vvw8Kt5" + ], + "display_line": 6, + "key": "F", + "range": { + "from": 0, + "to": 6 + }, + "text": "[F]ork", + "type": "fork_session" + } + ], "extmarks": [ [ 1, diff --git a/tests/data/user-message-metadata-update.expected.json b/tests/data/user-message-metadata-update.expected.json index 53d331dd..b7c81e6c 100644 --- a/tests/data/user-message-metadata-update.expected.json +++ b/tests/data/user-message-metadata-update.expected.json @@ -1,5 +1,45 @@ { - "actions": [], + "actions": [ + { + "args": [ + "msg_user_metadata_update" + ], + "display_line": 4, + "key": "R", + "range": { + "from": 0, + "to": 4 + }, + "text": "[R]evert", + "type": "undo" + }, + { + "args": [ + "msg_user_metadata_update" + ], + "display_line": 4, + "key": "C", + "range": { + "from": 0, + "to": 4 + }, + "text": "[C]opy", + "type": "copy_message" + }, + { + "args": [ + "msg_user_metadata_update" + ], + "display_line": 4, + "key": "F", + "range": { + "from": 0, + "to": 4 + }, + "text": "[F]ork", + "type": "fork_session" + } + ], "extmarks": [ [ 1, diff --git a/tests/unit/api_spec.lua b/tests/unit/api_spec.lua index aec1284d..7fa85b7e 100644 --- a/tests/unit/api_spec.lua +++ b/tests/unit/api_spec.lua @@ -269,6 +269,39 @@ describe('opencode.api', function() assert.is_function(api.question_other) assert.is_function(api.open_input_new_session_with_title) + assert.is_function(api.copy_message) + end) + + it('routes copy_message through the command axis with its message id', function() + local original_active_session = state.active_session + local original_messages = state.messages + state.session.set_active(mk_session('session-copy')) + state.renderer.set_messages({ + { + info = { id = 'message-copy', role = 'user' }, + parts = { { type = 'text', text = 'copy source' } }, + }, + }) + + local build_stub = stub(commands, 'build_parsed_intent').invokes(function(name, args) + assert.equal('copy_message', name) + assert.same({ 'message-copy' }, args) + return { ok = true, intent = {} } + end) + local execute_stub = stub(commands, 'execute_parsed_intent').invokes(function(_, action) + return action({ 'message-copy' }) + end) + local setreg_stub = stub(vim.fn, 'setreg') + + api.copy_message('message-copy') + + assert.stub(execute_stub).was_called() + assert.stub(setreg_stub).was_called_with('+', 'copy source') + setreg_stub:revert() + execute_stub:revert() + build_stub:revert() + state.renderer.set_messages(original_messages) + state.session.set_active(original_active_session) end) end) diff --git a/tests/unit/commands_handlers_spec.lua b/tests/unit/commands_handlers_spec.lua index cd23f36b..4bd6d7a0 100644 --- a/tests/unit/commands_handlers_spec.lua +++ b/tests/unit/commands_handlers_spec.lua @@ -583,4 +583,88 @@ describe('opencode.commands.handlers', function() assert.is_true(called_with.wrap) assert.equal('noop', called_with.empty_policy) end) + + describe('copy_message', function() + local state + local active_session + local messages + + before_each(function() + state = require('opencode.state') + active_session = state.active_session + messages = state.messages + state.session.set_active({ id = 'session-copy' }) + end) + + after_each(function() + state.session.set_active(active_session) + state.renderer.set_messages(messages) + end) + + it('copies original non-synthetic text parts in order without trimming', function() + state.renderer.set_messages({ + { + info = { id = 'user-message', role = 'user' }, + parts = { + { type = 'text', text = ' first ' }, + { type = 'text', text = 'synthetic', synthetic = true }, + { type = 'tool', text = 'tool output' }, + { type = 'text', text = nil }, + { type = 'text', text = 1 }, + { type = 'text', text = 'second\nline' }, + { type = 'text', text = ' ' }, + }, + }, + }) + local setreg_stub = stub(vim.fn, 'setreg') + + require('opencode.commands.handlers.session').actions.copy_message('user-message') + + assert.stub(setreg_stub).was_called_with('+', ' first \n\nsecond\nline') + setreg_stub:revert() + end) + + it('does not replace the register when no valid message text exists', function() + state.renderer.set_messages({ + { + info = { id = 'empty-message', role = 'user' }, + parts = { + { type = 'text', text = ' ', synthetic = false }, + { type = 'text', text = nil }, + { type = 'text', text = false }, + { type = 'tool', text = 'tool output' }, + }, + }, + }) + local setreg_stub = stub(vim.fn, 'setreg') + local notify_stub = stub(vim, 'notify') + + require('opencode.commands.handlers.session').actions.copy_message('empty-message') + + assert.stub(setreg_stub).was_not_called() + assert.stub(notify_stub).was_called_with('No message text to copy', vim.log.levels.WARN) + notify_stub:revert() + setreg_stub:revert() + end) + + it('does not copy missing or non-user messages', function() + state.renderer.set_messages({ + { + info = { id = 'assistant-message', role = 'assistant' }, + parts = { { type = 'text', text = 'assistant text' } }, + }, + }) + local setreg_stub = stub(vim.fn, 'setreg') + local notify_stub = stub(vim, 'notify') + local session = require('opencode.commands.handlers.session') + + session.actions.copy_message('missing-message') + session.actions.copy_message('assistant-message') + + assert.stub(setreg_stub).was_not_called() + assert.stub(notify_stub).was_called_with('No user message to copy', vim.log.levels.WARN) + notify_stub:revert() + setreg_stub:revert() + end) + end) end) diff --git a/tests/unit/contextual_actions_spec.lua b/tests/unit/contextual_actions_spec.lua new file mode 100644 index 00000000..6b5f1a1a --- /dev/null +++ b/tests/unit/contextual_actions_spec.lua @@ -0,0 +1,381 @@ +local assert = require('luassert') +local stub = require('luassert.stub') +local state = require('opencode.state') +local contextual_actions = require('opencode.ui.contextual_actions') + +local function mapping(buf, key) + for _, value in ipairs(vim.api.nvim_buf_get_keymap(buf, 'n')) do + if value.lhs == key then + return value + end + end +end + +local function action(key, type, args, range) + return { + key = key, + text = key, + type = type, + args = args, + display_line = range and range.to or 0, + range = range, + } +end + +describe('contextual actions', function() + local buf + local windows + + before_each(function() + windows = state.store.get('windows') + buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'text' }) + vim.api.nvim_set_current_buf(buf) + state.store.set_raw('windows', { output_buf = buf }) + end) + + after_each(function() + state.store.set_raw('windows', windows) + if vim.api.nvim_buf_is_valid(buf) then + vim.api.nvim_buf_delete(buf, { force = true }) + end + end) + + it('reversibly overlays and restores buffer-local callback mappings', function() + local original = function() + return '' + end + vim.keymap.set('n', 'R', original, { + buffer = buf, + desc = 'Original R', + silent = true, + expr = true, + nowait = true, + remap = true, + replace_keycodes = false, + }) + + contextual_actions.show_contextual_actions_menu(buf, { action('R') }) + assert.equal('R', mapping(buf, 'R').desc) + + contextual_actions.show_contextual_actions_menu(buf, {}) + local restored = mapping(buf, 'R') + assert.equal(original, restored.callback) + assert.equal('Original R', restored.desc) + assert.equal(1, restored.silent) + assert.equal(1, restored.expr) + assert.equal(1, restored.nowait) + assert.equal(0, restored.noremap) + assert.not_equal(1, restored.replace_keycodes) + end) + + it('keeps an originally unmapped key unmapped after invalidation', function() + contextual_actions.show_contextual_actions_menu(buf, { action('C') }) + assert.equal('C', mapping(buf, 'C').desc) + + contextual_actions.show_contextual_actions_menu(buf, {}) + assert.is_nil(mapping(buf, 'C')) + end) + + it('restores buffer-local rhs mappings', function() + vim.keymap.set('n', 'C', "echo 'original'", { buffer = buf, desc = 'Original C' }) + + contextual_actions.show_contextual_actions_menu(buf, { action('C') }) + contextual_actions.show_contextual_actions_menu(buf, {}) + + assert.equal("echo 'original'", mapping(buf, 'C').rhs) + assert.equal('Original C', mapping(buf, 'C').desc) + end) + + it('preserves script-local rhs mappings through an action overlay', function() + local script = vim.fn.tempname() + vim.fn.writefile({ + 'function! s:contextual_action_probe() abort', + " let g:opencode_contextual_action_probe = get(g:, 'opencode_contextual_action_probe', 0) + 1", + 'endfunction', + 'nnoremap