From d14ba817c95738448439aacc3d4e743e4676ff91 Mon Sep 17 00:00:00 2001 From: jensenojs Date: Wed, 1 Jul 2026 10:24:46 +0800 Subject: [PATCH 1/2] Add inline message actions for user messages --- lua/opencode/commands/handlers/session.lua | 15 + lua/opencode/config.lua | 2 + lua/opencode/types.lua | 1 + lua/opencode/ui/dialog.lua | 137 +++- lua/opencode/ui/formatter.lua | 6 +- lua/opencode/ui/message_actions.lua | 309 +++++++++ lua/opencode/ui/permission_window.lua | 7 + lua/opencode/ui/question_window.lua | 8 + lua/opencode/ui/renderer.lua | 1 + lua/opencode/ui/renderer/buffer.lua | 9 +- lua/opencode/ui/renderer/events.lua | 33 + lua/opencode/ui/ui.lua | 3 + tests/unit/commands_handlers_spec.lua | 40 ++ tests/unit/config_spec.lua | 24 + tests/unit/dialog_spec.lua | 192 ++++++ tests/unit/formatter_spec.lua | 24 + tests/unit/keymap_spec.lua | 47 ++ tests/unit/message_actions_spec.lua | 738 +++++++++++++++++++++ tests/unit/permission_window_spec.lua | 105 +++ tests/unit/question_window_spec.lua | 86 +++ tests/unit/renderer_buffer_spec.lua | 25 + 21 files changed, 1794 insertions(+), 18 deletions(-) create mode 100644 lua/opencode/ui/message_actions.lua create mode 100644 tests/unit/message_actions_spec.lua diff --git a/lua/opencode/commands/handlers/session.lua b/lua/opencode/commands/handlers/session.lua index 3c7c3aeb..9dc5d172 100644 --- a/lua/opencode/commands/handlers/session.lua +++ b/lua/opencode/commands/handlers/session.lua @@ -522,6 +522,15 @@ function M.actions.timeline() end) end +---@param source? string +function M.actions.message_actions(source) + local message_actions = require('opencode.ui.message_actions') + if source == 'mouse' then + return message_actions.open_from_mouse() + end + return message_actions.open_at_cursor() +end + ---@param message_id? string function M.actions.fork_session(message_id) return with_active_session('No active session to fork', function(state_obj) @@ -668,6 +677,12 @@ M.command_defs = { desc = 'Open timeline picker to navigate/undo/redo/fork to message', execute = M.actions.timeline, }, + message_actions = { + desc = 'Open message actions', + execute = function(args) + return M.actions.message_actions(args[1]) + end, + }, } return M diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index cc1b4b46..416f5d0a 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -76,6 +76,8 @@ M.defaults = { ['gr'] = { 'references', desc = 'Browse code references' }, ['gf'] = { 'jump_to_file', desc = 'Jump to file at cursor' }, [''] = { 'jump_to_target_at_cursor', desc = 'Jump to target at cursor' }, + [''] = { 'message_actions', { 'mouse' }, desc = 'Open message actions' }, + ['o'] = { 'message_actions', desc = 'Open message actions' }, [''] = { 'toggle_input', mode = { 'n' }, desc = 'Toggle input window' }, [''] = { 'cycle_variant', mode = { 'n' }, desc = 'Cycle model variants' }, ['oS'] = { 'navigate_session_tree', { 'child', 'picker' }, desc = 'Select child session' }, diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 7c01caec..62783ea9 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -148,6 +148,7 @@ ---@class OpencodeKeymapEntry ---@field [1] string # Function name +---@field [2]? string|string[] # Preset command arguments ---@field mode? string|string[] # Mode(s) for the keymap ---@field desc? string # Keymap description ---@field defer_to_completion? boolean # Whether to defer the keymap when completion menu is open diff --git a/lua/opencode/ui/dialog.lua b/lua/opencode/ui/dialog.lua index 78cfc238..7085e634 100644 --- a/lua/opencode/ui/dialog.lua +++ b/lua/opencode/ui/dialog.lua @@ -10,6 +10,8 @@ ---@field keymaps? DialogKeymaps Custom keymap configuration ---@field namespace_prefix? string Prefix for vim.on_key namespace (default: 'opencode_dialog') ---@field hide_input? boolean Whether to hide the input window when dialog is active (default: true) +---@field render_part_id? string Rendered part ID used to resolve mouse clicks against output lines +---@field mouse_select? boolean Whether Dialog should register for option selection (default: false) ---@class DialogKeymaps ---@field up? string[] Keys for navigating up (default: {'k', ''}) @@ -17,7 +19,7 @@ ---@field left? string[] Keys for navigating left between groups ---@field right? string[] Keys for navigating right between groups ---@field select? string Key for selecting current option (default: '') ----@field dismiss? string Key for dismissing dialog (default: '') +---@field dismiss? string|string[] Keys for dismissing dialog (default: {'', ''}) ---@field number_shortcuts? boolean Enable 1-9 number shortcuts (default: true) ---@class Dialog @@ -27,9 +29,38 @@ ---@field private _selected_index integer Currently selected option index ---@field private _active boolean Whether dialog is currently active ---@field private _group_index integer Currently selected group index +---@field private _option_local_lines integer[] 0-based output-local lines for each option local Dialog = {} Dialog.__index = Dialog +---@param keymap string|string[]|nil +---@return string[] +local function keymap_list(keymap) + local keymaps = {} + if type(keymap) == 'string' then + if keymap ~= '' then + table.insert(keymaps, keymap) + end + elseif type(keymap) == 'table' then + for _, key in ipairs(keymap) do + if key and key ~= '' then + table.insert(keymaps, key) + end + end + end + return keymaps +end + +---@param keymaps string[] +---@return string +local function keymap_legend(keymaps) + local labels = {} + for _, key in ipairs(keymaps) do + table.insert(labels, '`' .. key .. '`') + end + return table.concat(labels, ' or ') +end + ---Create a new dialog instance ---@param config DialogConfig Dialog configuration ---@return Dialog @@ -43,7 +74,7 @@ function Dialog.new(config) left = {}, right = {}, select = '', - dismiss = '', + dismiss = { '', '' }, number_shortcuts = true, } @@ -54,6 +85,7 @@ function Dialog.new(config) return true end, hide_input = true, + mouse_select = false, } --[[@as DialogConfig]], config) self._keymaps = {} @@ -61,6 +93,7 @@ function Dialog.new(config) self._selected_index = 1 self._group_index = 1 self._active = false + self._option_local_lines = {} return self end @@ -248,8 +281,9 @@ function Dialog:format_legend(output, options) local line = output:add_line(select_text) end - if keymaps.dismiss and keymaps.dismiss ~= '' then - local line = output:add_line('Close: ``') + local dismiss_keymaps = keymap_list(keymaps.dismiss) + if #dismiss_keymaps > 0 then + local line = output:add_line('Close: ' .. keymap_legend(dismiss_keymaps)) end else local message = options.unfocused_message or 'Focus Opencode window to interact' @@ -268,6 +302,7 @@ end --- - progress?: string - Progress indicator (e.g., "(1/3)") --- - content?: string[] - Array of lines to render before options --- - render_content?: function(output: Output) - Custom function to render content before options +--- - hide_legend?: boolean - Whether to hide movement/select/dismiss instructions function Dialog:format_dialog(output, config) if not self._active then return @@ -301,9 +336,10 @@ function Dialog:format_dialog(output, config) self:format_options(output, config.options or {}) - output:add_line('') - - self:format_legend(output, { unfocused_message = config.unfocused_message }) + if not config.hide_legend then + output:add_line('') + self:format_legend(output, { unfocused_message = config.unfocused_message }) + end local end_line = output:get_line_count() @@ -322,6 +358,8 @@ end ---@param output Output Output object to write to ---@param options table[] Array of option objects with {label: string, description?: string} function Dialog:format_options(output, options) + self._option_local_lines = {} + for i, option in ipairs(options) do local label = option.label if option.description and option.description ~= '' then @@ -339,11 +377,12 @@ function Dialog:format_options(output, options) -- add_line returns a 1-based line index; Output extmarks use 0-based -- keys, so subtract 1 to get the correct extmark key. local added_idx = output:add_line(line_text) + local option_local_line = added_idx - 1 + self._option_local_lines[i] = option_local_line if is_selected then - local extmark_idx = added_idx - 1 - output:add_extmark(extmark_idx, { line_hl_group = 'OpencodeDialogOptionHover' } --[[@as OutputExtmark]]) - output:add_extmark(extmark_idx, { + output:add_extmark(option_local_line, { line_hl_group = 'OpencodeDialogOptionHover' } --[[@as OutputExtmark]]) + output:add_extmark(option_local_line, { start_col = 2, virt_text = { { '› ', 'OpencodeDialogOptionHover' } }, virt_text_pos = 'overlay', @@ -352,6 +391,58 @@ function Dialog:format_options(output, options) end end +---@return integer|nil +function Dialog:_mouse_option_index() + local render_part_id = self._config.render_part_id + if not render_part_id or render_part_id == '' then + return nil + end + + local mouse = vim.fn.getmousepos() + local winid = mouse and mouse.winid + if not winid or winid == 0 or not vim.api.nvim_win_is_valid(winid) then + return nil + end + + local buf = self._config.buffer + if not buf or vim.api.nvim_win_get_buf(winid) ~= buf then + return nil + end + + local rendered_part = require('opencode.ui.renderer.ctx').render_state:get_part(render_part_id) + if not rendered_part or rendered_part.line_start == nil then + return nil + end + + local mouse_line = mouse.line + if not mouse_line or mouse_line <= 0 then + return nil + end + + local clicked_output_line = mouse_line - 1 + for option_index, option_local_line in ipairs(self._option_local_lines) do + if clicked_output_line == rendered_part.line_start + option_local_line then + return option_index + end + end +end + +---@return boolean selected +function Dialog:select_mouse_option() + if not self._active or not self._config.check_focused() then + return false + end + + local option_index = self:_mouse_option_index() + if not option_index then + return false + end + + self._selected_index = option_index + self._config.on_select(option_index) + return true +end + ---Set up buffer-scoped keymaps function Dialog:_setup_keymaps() self:_clear_keymaps() @@ -450,18 +541,34 @@ function Dialog:_setup_keymaps() table.insert(self._keymaps, keymaps.select) end - if keymaps.dismiss and keymaps.dismiss ~= '' then + if self._config.mouse_select ~= false and self._config.render_part_id and self._config.render_part_id ~= '' then vim.keymap.set( 'n', - keymaps.dismiss, + '', function() - self:dismiss() + self:select_mouse_option() end, vim.tbl_extend('force', keymap_opts, { - desc = 'Dialog: dismiss', + desc = 'Dialog: select clicked option', }) ) - table.insert(self._keymaps, keymaps.dismiss) + table.insert(self._keymaps, '') + end + + for _, key in ipairs(keymap_list(keymaps.dismiss)) do + if key and key ~= '' then + vim.keymap.set( + 'n', + key, + function() + self:dismiss() + end, + vim.tbl_extend('force', keymap_opts, { + desc = 'Dialog: dismiss', + }) + ) + table.insert(self._keymaps, key) + end end if keymaps.number_shortcuts then diff --git a/lua/opencode/ui/formatter.lua b/lua/opencode/ui/formatter.lua index 1a20f821..bf93c283 100644 --- a/lua/opencode/ui/formatter.lua +++ b/lua/opencode/ui/formatter.lua @@ -719,7 +719,11 @@ function M.format_part(part, message, is_last_part, get_child_parts) local role = message.info.role - if role == 'user' then + if part.type == 'message-actions-display' then + local message_actions = require('opencode.ui.message_actions') + message_actions.format_display(output) + content_added = true + elseif role == 'user' then if is_compaction_part(part) then format_compaction_divider(output) content_added = true diff --git a/lua/opencode/ui/message_actions.lua b/lua/opencode/ui/message_actions.lua new file mode 100644 index 00000000..afb46e9d --- /dev/null +++ b/lua/opencode/ui/message_actions.lua @@ -0,0 +1,309 @@ +local Dialog = require('opencode.ui.dialog') +local state = require('opencode.state') +local ctx = require('opencode.ui.renderer.ctx') + +local M = {} + +local DISPLAY_PART_ID_PREFIX = 'message-actions-display-part:' + +local ACTIONS = { + { + label = 'Revert', + run = function(message_id) + require('opencode.api').undo(message_id) + end, + }, + { + label = 'Copy', + run = function(_, message) + local text = M.collect_user_text(message) + if text == '' then + vim.notify('No message text to copy', vim.log.levels.WARN) + return + end + vim.fn.setreg('+', text) + end, + }, + { + label = 'Fork', + run = function(message_id) + require('opencode.api').fork_session(message_id) + end, + }, +} + +M._target_message = nil +M._dialog = nil +M._display_part_id = nil + +---@param message_id string +---@return string +local function display_part_id(message_id) + return DISPLAY_PART_ID_PREFIX .. message_id +end + +---@return integer|nil output_win +---@return integer|nil output_buf +local function output_window() + local windows = state.windows + local output_win = windows and windows.output_win + local output_buf = windows and windows.output_buf + + if not output_win or not output_buf then + return nil, nil + end + if not vim.api.nvim_win_is_valid(output_win) or not vim.api.nvim_buf_is_valid(output_buf) then + return nil, nil + end + if vim.api.nvim_win_get_buf(output_win) ~= output_buf then + return nil, nil + end + + return output_win, output_buf +end + +---@param output_win integer +---@param output_buf integer +---@param mouse table +local function move_cursor_to_mouse(output_win, output_buf, mouse) + local line_count = vim.api.nvim_buf_line_count(output_buf) + local line = math.max(1, math.min(mouse.line or 1, line_count)) + local col = math.max(0, (mouse.column or 1) - 1) + pcall(vim.api.nvim_win_set_cursor, output_win, { line, col }) +end + +---@return boolean +local function can_open_message_actions() + local question_window = require('opencode.ui.question_window') + local permission_window = require('opencode.ui.permission_window') + + if question_window.has_question() or permission_window.has_permissions() then + vim.notify('Finish the active dialog first', vim.log.levels.WARN) + return false + end + + return true +end + +---@return boolean handled +local function select_active_dialog_from_mouse() + local question_window = require('opencode.ui.question_window') + if question_window.has_question() then + question_window.select_mouse_option() + return true + end + + local permission_window = require('opencode.ui.permission_window') + if permission_window.has_permissions() then + permission_window.select_mouse_option() + return true + end + + return false +end + +local function render_display() + local target_id = M._target_message and M._target_message.info and M._target_message.info.id or nil + require('opencode.ui.renderer.events').render_message_actions_display( + M._dialog ~= nil and target_id or nil, + M._display_part_id + ) +end + +---@param message OpencodeMessage|nil +---@return boolean +function M.is_actionable_user_message(message) + if not message or not message.info or message.info.role ~= 'user' then + return false + end + + local message_id = message.info.id + if not message_id or message_id == '' then + return false + end + + local parts = message.parts or {} + if #parts == 0 then + return true + end + + for _, part in ipairs(parts) do + if part.synthetic ~= true then + return true + end + end + + return false +end + +---@param message OpencodeMessage|nil +---@return string +function M.collect_user_text(message) + local chunks = {} + + for _, part in ipairs((message and message.parts) or {}) do + if part.type == 'text' and part.synthetic ~= true and type(part.text) == 'string' and vim.trim(part.text) ~= '' then + chunks[#chunks + 1] = part.text + end + end + + return table.concat(chunks, '\n\n') +end + +---@param line integer +---@return OpencodeMessage|nil +local function actionable_message_at_line(line) + local rendered_message = ctx.render_state:get_message_at_line(line) + if not rendered_message then + local rendered_part = ctx.render_state:get_part_at_line(line) + if rendered_part and rendered_part.part and rendered_part.part.type ~= 'message-actions-display' then + rendered_message = ctx.render_state:get_message(rendered_part.message_id) + end + end + + local message = rendered_message and rendered_message.message + if not M.is_actionable_user_message(message) then + return nil + end + return message +end + +---@param message OpencodeMessage +---@param output_buf integer +local function open_for_message(message, output_buf) + if not can_open_message_actions() then + return + end + + M.clear() + M._target_message = message + M._display_part_id = display_part_id(message.info.id) + + local function is_active_target() + local ui = require('opencode.ui.ui') + return ui.is_opencode_focused() and M._target_message ~= nil + end + + M._dialog = Dialog.new({ + buffer = output_buf, + render_part_id = M._display_part_id, + mouse_select = false, + namespace_prefix = 'opencode_message_actions', + check_focused = is_active_target, + get_option_count = function() + return M._target_message and #ACTIONS or 0 + end, + on_navigate = render_display, + on_dismiss = M.clear, + on_select = function(index) + if not M._target_message then + return + end + + local action = ACTIONS[index] + if not action then + return + end + + local selected_message = M._target_message + local message_id = selected_message.info.id + M.clear() + action.run(message_id, selected_message) + end, + }) + + M._dialog:setup() + render_display() +end + +function M.open_at_cursor() + local output_win, output_buf = output_window() + if not output_win or not output_buf then + return + end + + local cursor_line = vim.api.nvim_win_get_cursor(output_win)[1] - 1 + local message = actionable_message_at_line(cursor_line) + if not message then + return + end + + open_for_message(message, output_buf) +end + +function M.open_from_mouse() + local output_win, output_buf = output_window() + if not output_win or not output_buf then + return + end + + local mouse = vim.fn.getmousepos() + if not mouse or mouse.winid ~= output_win then + return + end + if not vim.api.nvim_win_is_valid(mouse.winid) or vim.api.nvim_win_get_buf(mouse.winid) ~= output_buf then + return + end + if not mouse.line or mouse.line <= 0 then + return + end + + move_cursor_to_mouse(output_win, output_buf, mouse) + + if select_active_dialog_from_mouse() then + return + end + + if M._dialog then + if M._dialog:select_mouse_option() then + return + end + M.clear() + return + end + + local message = actionable_message_at_line(mouse.line - 1) + if not message then + return + end + + open_for_message(message, output_buf) +end + +function M.clear() + if M._dialog then + M._dialog:teardown() + M._dialog = nil + end + local display_id = M._display_part_id + M._target_message = nil + M._display_part_id = nil + require('opencode.ui.renderer.events').render_message_actions_display(nil, display_id) +end + +function M.teardown() + if M._dialog then + M._dialog:teardown() + end + M._dialog = nil + M._target_message = nil + M._display_part_id = nil +end + +---@param output Output +function M.format_display(output) + if not M._target_message or not M._dialog then + return + end + + M._dialog:format_dialog(output, { + title = 'Message Actions', + title_hl = 'OpencodeQuestionTitle', + border_hl = 'OpencodeQuestionBorder', + options = ACTIONS, + hide_legend = true, + unfocused_message = 'Focus Opencode window to choose message action', + }) +end + +return M diff --git a/lua/opencode/ui/permission_window.lua b/lua/opencode/ui/permission_window.lua index 70f2f2bb..e5364950 100644 --- a/lua/opencode/ui/permission_window.lua +++ b/lua/opencode/ui/permission_window.lua @@ -322,6 +322,8 @@ function M._setup_dialog() get_option_count = get_option_count, check_focused = check_focused, namespace_prefix = 'opencode_permission', + render_part_id = 'permission-display-part', + mouse_select = false, keymaps = { dismiss = '', -- Disable dismiss keymap and legend }, @@ -341,6 +343,11 @@ function M._clear_dialog() end end +---@return boolean selected +function M.select_mouse_option() + return M._dialog ~= nil and M._dialog:select_mouse_option() or false +end + ---Query the server for pending permissions and restore any that belong ---to the active session. Mirrors question_window.restore_pending_question. ---@param session_id string|nil diff --git a/lua/opencode/ui/question_window.lua b/lua/opencode/ui/question_window.lua index a790b0fc..7b09f0a4 100644 --- a/lua/opencode/ui/question_window.lua +++ b/lua/opencode/ui/question_window.lua @@ -504,7 +504,10 @@ function M._setup_dialog() end, check_focused = check_focused, namespace_prefix = 'opencode_question', + render_part_id = 'question-display-part', + mouse_select = false, keymaps = { + dismiss = '', left = { 'h', '' }, right = { 'l', '' }, }, @@ -514,6 +517,11 @@ function M._setup_dialog() M._dialog:setup() end +---@return boolean selected +function M.select_mouse_option() + return M._dialog ~= nil and M._dialog:select_mouse_option() or false +end + ---Tear down the active question dialog, if any. function M._clear_dialog() if M._dialog then diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index 986f66e1..7b4aee04 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -283,6 +283,7 @@ end ---Reset all renderer state and clear the output buffer function M.reset() + require('opencode.ui.message_actions').teardown() ctx:reset() output_window.clear() permission_window.clear_all() diff --git a/lua/opencode/ui/renderer/buffer.lua b/lua/opencode/ui/renderer/buffer.lua index e6cd3566..e5451bbf 100644 --- a/lua/opencode/ui/renderer/buffer.lua +++ b/lua/opencode/ui/renderer/buffer.lua @@ -9,6 +9,11 @@ local pinned_bottom_message_ids = { ['question-display-message'] = true, } +local pinned_bottom_message_order = { + 'permission-display-message', + 'question-display-message', +} + local pinned_top_message_ids = { ['__opencode_hidden_messages_notice__'] = true, } @@ -336,7 +341,7 @@ local function get_message_insert_line(message_id) return append_at end - for _, pinned_message_id in ipairs({ 'permission-display-message', 'question-display-message' }) do + for _, pinned_message_id in ipairs(pinned_bottom_message_order) do local pinned_rendered = ctx.render_state:get_message(pinned_message_id) if pinned_rendered and pinned_rendered.line_start then return pinned_rendered.line_start @@ -367,7 +372,7 @@ local function get_message_insert_line(message_id) end end - for _, pinned_message_id in ipairs({ 'permission-display-message', 'question-display-message' }) do + for _, pinned_message_id in ipairs(pinned_bottom_message_order) do local pinned_rendered = ctx.render_state:get_message(pinned_message_id) if pinned_rendered and pinned_rendered.line_start then return pinned_rendered.line_start diff --git a/lua/opencode/ui/renderer/events.lua b/lua/opencode/ui/renderer/events.lua index 46e7bd0d..0587f410 100644 --- a/lua/opencode/ui/renderer/events.lua +++ b/lua/opencode/ui/renderer/events.lua @@ -157,6 +157,39 @@ function M.render_question_display() end end +---@param part_id string +local function remove_rendered_part(part_id) + local cached = ctx.render_state:get_part(part_id) + M.on_part_removed({ + sessionID = state.active_session and state.active_session.id or '', + messageID = cached and cached.message_id or nil, + partID = part_id, + }) +end + +---Render the active message actions dialog as a synthetic part under its target message +---@param target_message_id string|nil +---@param part_id string|nil +function M.render_message_actions_display(target_message_id, part_id) + if not part_id or part_id == '' then + return + end + + if not target_message_id or target_message_id == '' then + remove_rendered_part(part_id) + return + end + + local fake_part = { + id = part_id, + messageID = target_message_id, + sessionID = state.active_session and state.active_session.id or '', + type = 'message-actions-display', + synthetic = true, + } + M.on_part_updated({ part = fake_part }) +end + ---Remove the question display from the buffer function M.clear_question_display() local use_vim_ui = config.ui.questions and config.ui.questions.use_vim_ui_select diff --git a/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index 52c04f64..1d0f7aa3 100644 --- a/lua/opencode/ui/ui.lua +++ b/lua/opencode/ui/ui.lua @@ -137,6 +137,9 @@ function M.hide_visible_windows(windows) return M.teardown_visible_windows(windows) end + require('opencode.ui.message_actions').clear() + require('opencode.ui.renderer.flush').flush() + local snapshot = capture_hidden_snapshot(windows) -- Only save width ratio for split modes (not dialog/current mode) diff --git a/tests/unit/commands_handlers_spec.lua b/tests/unit/commands_handlers_spec.lua index 70cf5914..74b4285c 100644 --- a/tests/unit/commands_handlers_spec.lua +++ b/tests/unit/commands_handlers_spec.lua @@ -16,6 +16,7 @@ describe('opencode.commands.handlers', function() 'opencode.commands.handlers.session', 'opencode.commands.handlers.diff', 'opencode.commands.handlers.permission', + 'opencode.ui.message_actions', } local original_loaded = {} @@ -96,6 +97,7 @@ describe('opencode.commands.handlers', function() assert.same({ 'new', 'select', 'navigate', 'compact', 'share', 'unshare', 'agents_init', 'rename', 'toggle_lock' }, defs.session.completions) assert.same({ allow_empty = false }, defs.session.nested_subcommand) + assert.equal('Open message actions', defs.message_actions.desc) assert.same({ 'input', 'output' }, defs.open.completions) assert.equal('user_commands', defs.command.completion_provider_id) @@ -141,6 +143,44 @@ describe('opencode.commands.handlers', function() }, err_diff) end) + it('routes message_actions mouse to the mouse opener without requiring it at module load', function() + local session = require('opencode.commands.handlers.session') + local calls = {} + + assert.is_nil(package.loaded['opencode.ui.message_actions']) + + package.loaded['opencode.ui.message_actions'] = { + open_from_mouse = function() + calls[#calls + 1] = 'mouse' + end, + open_at_cursor = function() + calls[#calls + 1] = 'cursor' + end, + } + + session.command_defs.message_actions.execute({ 'mouse' }) + + assert.same({ 'mouse' }, calls) + end) + + it('routes message_actions without mouse arg to the cursor opener', function() + local session = require('opencode.commands.handlers.session') + local calls = {} + + package.loaded['opencode.ui.message_actions'] = { + open_from_mouse = function() + calls[#calls + 1] = 'mouse' + end, + open_at_cursor = function() + calls[#calls + 1] = 'cursor' + end, + } + + session.command_defs.message_actions.execute({}) + + assert.same({ 'cursor' }, calls) + end) + it('keeps help rendering stable in narrow output windows', function() local surface = require('opencode.commands.handlers.surface') local window = require('opencode.commands.handlers.window') diff --git a/tests/unit/config_spec.lua b/tests/unit/config_spec.lua index 96a2ce18..999e81f3 100644 --- a/tests/unit/config_spec.lua +++ b/tests/unit/config_spec.lua @@ -45,6 +45,30 @@ describe('opencode.config', function() assert.equal('jump_to_target_at_cursor', output_keymap[''][1]) end) + it('maps output message actions to the prefixed enter key by default', function() + local output_keymap = config.defaults.keymap.output_window + + assert.equal('message_actions', output_keymap['o'][1]) + end) + + it('maps output single click to message actions by default', function() + local output_keymap = config.defaults.keymap.output_window + + assert.equal('message_actions', output_keymap[''][1]) + assert.same({ 'mouse' }, output_keymap[''][2]) + assert.is_nil(output_keymap['<2-LeftMouse>']) + end) + + it('moves output message actions with keymap_prefix while leaving bare enter unchanged', function() + config.setup({ keymap_prefix = 'x' }) + + local output_keymap = config.values.keymap.output_window + + assert.equal('message_actions', output_keymap['x'][1]) + assert.is_nil(output_keymap['o']) + assert.equal('jump_to_target_at_cursor', output_keymap[''][1]) + end) + describe('update_keymap_prefix', function() local function test_prefix_update(opts) config.values.keymap = vim.deepcopy(opts.given) diff --git a/tests/unit/dialog_spec.lua b/tests/unit/dialog_spec.lua index a1f186b2..a95434e9 100644 --- a/tests/unit/dialog_spec.lua +++ b/tests/unit/dialog_spec.lua @@ -7,6 +7,28 @@ describe('Dialog', function() local input_buf, output_buf, input_win, output_win local original_auto_hide + local function keymap_callback(buf, lhs) + for _, keymap in ipairs(vim.api.nvim_buf_get_keymap(buf, 'n')) do + if keymap.lhs:lower() == lhs:lower() then + return keymap.callback + end + end + end + + local function with_mousepos(mousepos, callback) + local original_getmousepos = vim.fn.getmousepos + ---@diagnostic disable-next-line: duplicate-set-field + vim.fn.getmousepos = function() + return mousepos + end + + local ok, err = pcall(callback) + vim.fn.getmousepos = original_getmousepos + if not ok then + error(err) + end + end + before_each(function() -- Save original config original_auto_hide = config.ui.input.auto_hide @@ -51,9 +73,159 @@ describe('Dialog', function() pcall(vim.api.nvim_buf_delete, output_buf, { force = true }) state.ui.clear_windows() + require('opencode.ui.renderer.ctx'):reset() package.loaded['opencode.ui.input_window'] = nil end) + describe('mouse option selection', function() + it('selects an option by rendered part line plus tracked option line', function() + local selected = nil + local dialog = Dialog.new({ + buffer = output_buf, + render_part_id = 'dialog-test-part', + mouse_select = true, + on_select = function(index) + selected = index + end, + get_option_count = function() + return 3 + end, + hide_input = false, + }) + + dialog:setup() + + local output = Output.new() + dialog:format_options(output, { + { label = 'First' }, + { label = 'Second' }, + { label = 'Third' }, + }) + + require('opencode.ui.renderer.ctx').render_state:set_part({ id = 'dialog-test-part' }, 20, 30) + + with_mousepos({ + winid = output_win, + line = 20 + dialog._option_local_lines[2] + 1, + column = 1, + }, function() + keymap_callback(output_buf, '')() + end) + + assert.are.equal(2, selected) + assert.are.equal(2, dialog:get_selection()) + + dialog:teardown() + end) + + it('ignores clicks outside tracked option lines', function() + local selected = nil + local dialog = Dialog.new({ + buffer = output_buf, + render_part_id = 'dialog-test-part', + mouse_select = true, + on_select = function(index) + selected = index + end, + get_option_count = function() + return 2 + end, + hide_input = false, + }) + + dialog:setup() + + local output = Output.new() + dialog:format_options(output, { + { label = 'First' }, + { label = 'Second' }, + }) + + require('opencode.ui.renderer.ctx').render_state:set_part({ id = 'dialog-test-part' }, 20, 30) + + with_mousepos({ + winid = output_win, + line = 20 + 9 + 1, + column = 1, + }, function() + keymap_callback(output_buf, '')() + end) + + assert.is_nil(selected) + assert.are.equal(1, dialog:get_selection()) + + dialog:teardown() + end) + end) + + describe('dismiss keymaps', function() + it('maps both default dismiss keys', function() + local dismiss_count = 0 + local dialog = Dialog.new({ + buffer = output_buf, + on_select = function() end, + on_dismiss = function() + dismiss_count = dismiss_count + 1 + end, + get_option_count = function() + return 1 + end, + hide_input = false, + }) + + dialog:setup() + + keymap_callback(output_buf, '')() + keymap_callback(output_buf, '')() + + assert.are.equal(2, dismiss_count) + + dialog:teardown() + end) + + it('does not map dismiss when dismiss is an empty string', function() + local dialog = Dialog.new({ + buffer = output_buf, + on_select = function() end, + get_option_count = function() + return 1 + end, + hide_input = false, + keymaps = { + dismiss = '', + }, + }) + + dialog:setup() + + assert.is_nil(keymap_callback(output_buf, '')) + assert.is_nil(keymap_callback(output_buf, '')) + + dialog:teardown() + end) + + it('does not map dismiss when dismiss is an empty list', function() + local dialog = Dialog.new({ + buffer = output_buf, + on_select = function() end, + get_option_count = function() + return 1 + end, + hide_input = false, + keymaps = { + dismiss = {}, + }, + }) + + dialog:setup() + + assert.is_nil(keymap_callback(output_buf, '')) + assert.is_nil(keymap_callback(output_buf, '')) + + dialog:teardown() + end) + end) + describe('teardown with hide_input enabled', function() it('should show input window when auto_hide is disabled', function() config.ui.input.auto_hide = false @@ -294,6 +466,26 @@ describe('Dialog', function() end) describe('Dialog formatting', function() + it('tracks option lines from the line returned by add_line', function() + local dialog = Dialog.new({ + buffer = 0, + on_select = function() end, + get_option_count = function() + return 2 + end, + }) + + local output = Output.new() + output:add_line('content before options') + + dialog:format_options(output, { + { label = 'First' }, + { label = 'Second' }, + }) + + assert.are.same({ 1, 2 }, dialog._option_local_lines) + end) + it('places selection extmarks on the selected option line', function() local dialog = Dialog.new({ buffer = 0, diff --git a/tests/unit/formatter_spec.lua b/tests/unit/formatter_spec.lua index 5a7620e6..6bb8fe83 100644 --- a/tests/unit/formatter_spec.lua +++ b/tests/unit/formatter_spec.lua @@ -1,4 +1,5 @@ local assert = require('luassert') +local stub = require('luassert.stub') local config = require('opencode.config') local formatter = require('opencode.ui.formatter') local Output = require('opencode.ui.output') @@ -475,6 +476,29 @@ describe('formatter', function() assert.is_nil(output.extmarks[0]) end) + it('dispatches message-actions-display system parts to message_actions.format_display', function() + local message_actions = require('opencode.ui.message_actions') + local format_stub = stub(message_actions, 'format_display') + + local output = formatter.format_part({ + id = 'message-actions-display-part:msg_user', + type = 'message-actions-display', + messageID = 'msg_user', + sessionID = 'ses_1', + }, { + info = { + id = 'msg_user', + role = 'user', + sessionID = 'ses_1', + }, + parts = {}, + }, true) + + assert.stub(format_stub).was_called() + assert.are.same({ '' }, output.lines) + format_stub:revert() + end) + it('does not add a spacing-only block for hidden same-mode assistant messages', function() config.setup({ ui = { diff --git a/tests/unit/keymap_spec.lua b/tests/unit/keymap_spec.lua index af16d4ea..2976d013 100644 --- a/tests/unit/keymap_spec.lua +++ b/tests/unit/keymap_spec.lua @@ -62,6 +62,8 @@ describe('opencode.keymap', function() open_input = { desc = 'Open input window', execute = function() end }, toggle = { desc = 'Toggle opencode windows', execute = function() end }, submit_input_prompt = { desc = 'Submit input prompt', execute = function() end }, + message_actions = { desc = 'Open message actions', execute = function() end }, + jump_to_target_at_cursor = { desc = 'Jump to target at cursor', execute = function() end }, } end, build_parsed_intent = function(name, args) @@ -198,6 +200,51 @@ describe('opencode.keymap', function() vim.api.nvim_buf_delete(bufnr, { force = true }) end) + it('routes output single-click to message actions from mouse', function() + local bufnr = vim.api.nvim_create_buf(false, true) + keymap.setup_window_keymaps({ [''] = { 'message_actions', { 'mouse' } } }, bufnr) + + assert.equal(1, #set_keymaps) + set_keymaps[1].callback() + + assert.equal(1, #built_parsed) + assert.same('message_actions', built_parsed[1].intent.name) + assert.same({ 'mouse' }, built_parsed[1].intent.args) + assert.equal(1, #executed_parsed) + + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('routes output prefixed enter to message actions without args', function() + local bufnr = vim.api.nvim_create_buf(false, true) + keymap.setup_window_keymaps({ ['o'] = { 'message_actions' } }, bufnr) + + assert.equal(1, #set_keymaps) + set_keymaps[1].callback() + + assert.equal(1, #built_parsed) + assert.same('message_actions', built_parsed[1].intent.name) + assert.same({}, built_parsed[1].intent.args) + assert.equal(1, #executed_parsed) + + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('keeps bare output enter on jump_to_target_at_cursor', function() + local bufnr = vim.api.nvim_create_buf(false, true) + keymap.setup_window_keymaps({ [''] = { 'jump_to_target_at_cursor' } }, bufnr) + + assert.equal(1, #set_keymaps) + set_keymaps[1].callback() + + assert.equal(1, #built_parsed) + assert.same('jump_to_target_at_cursor', built_parsed[1].intent.name) + assert.same({}, built_parsed[1].intent.args) + assert.equal(1, #executed_parsed) + + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + it('does not mutate configured table args across repeated key presses', function() local original_execute_parsed_intent = mock_commands.execute_parsed_intent mock_commands.execute_parsed_intent = function(parsed) diff --git a/tests/unit/message_actions_spec.lua b/tests/unit/message_actions_spec.lua new file mode 100644 index 00000000..e044df08 --- /dev/null +++ b/tests/unit/message_actions_spec.lua @@ -0,0 +1,738 @@ +local assert = require('luassert') +local stub = require('luassert.stub') +local helpers = require('tests.helpers') +local state = require('opencode.state') +local ctx = require('opencode.ui.renderer.ctx') + +local function user_message(id, parts) + return { + info = { + id = id, + role = 'user', + sessionID = 'ses_1', + }, + parts = parts or { + { id = id .. '_text', type = 'text', text = 'hello', messageID = id, sessionID = 'ses_1' }, + }, + } +end + +local function message(role, id, parts) + return { + info = { + id = id, + role = role, + sessionID = 'ses_1', + }, + parts = parts or {}, + } +end + +describe('message_actions', function() + local message_actions + local input_buf, output_buf, other_buf + local input_win, output_win, other_win + local render_display_stub + local notify_mock + + local function with_mousepos(mousepos, callback) + local original_getmousepos = vim.fn.getmousepos + ---@diagnostic disable-next-line: duplicate-set-field + vim.fn.getmousepos = function() + return mousepos + end + + local ok, err = pcall(callback) + vim.fn.getmousepos = original_getmousepos + if not ok then + error(err) + end + end + + local function set_output_cursor(line) + vim.api.nvim_win_set_cursor(output_win, { line, 0 }) + end + + local function has_buffer_key(lhs) + for _, map in ipairs(vim.api.nvim_buf_get_keymap(output_buf, 'n')) do + if map.lhs == lhs then + return true + end + end + return false + end + + local function render_message_at(message_to_render, line_start, line_end) + ctx.render_state:set_message(message_to_render, line_start, line_end) + end + + local function render_part_at(part, line_start, line_end) + ctx.render_state:set_part(part, line_start, line_end) + end + + local function attach_rendered_actions_part(line_start) + local Output = require('opencode.ui.output') + local output = Output.new() + message_actions.format_display(output) + render_part_at({ + id = 'message-actions-display-part:msg_user', + type = 'message-actions-display', + messageID = 'msg_user', + sessionID = 'ses_1', + synthetic = true, + }, line_start, line_start + #output.lines - 1) + end + + before_each(function() + package.loaded['opencode.ui.message_actions'] = nil + package.loaded['opencode.api'] = nil + message_actions = require('opencode.ui.message_actions') + + input_buf = vim.api.nvim_create_buf(false, true) + output_buf = vim.api.nvim_create_buf(false, true) + other_buf = vim.api.nvim_create_buf(false, true) + input_win = vim.api.nvim_open_win(input_buf, false, { + relative = 'editor', + width = 80, + height = 5, + row = 0, + col = 0, + }) + output_win = vim.api.nvim_open_win(output_buf, true, { + relative = 'editor', + width = 80, + height = 10, + row = 6, + col = 0, + }) + other_win = vim.api.nvim_open_win(other_buf, false, { + relative = 'editor', + width = 80, + height = 5, + row = 17, + col = 0, + }) + + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { 'one', 'two', 'three', 'four' }) + state.ui.set_windows({ + input_buf = input_buf, + input_win = input_win, + output_buf = output_buf, + output_win = output_win, + }) + state.session.set_active({ id = 'ses_1' }) + state.renderer.set_messages({}) + ctx:reset() + + package.loaded['opencode.ui.input_window'] = { + _hide = function() end, + _show = function() end, + } + + render_display_stub = stub(require('opencode.ui.renderer.events'), 'render_message_actions_display') + notify_mock = helpers.mock_notify() + end) + + after_each(function() + notify_mock.reset() + if render_display_stub then + render_display_stub:revert() + end + pcall(message_actions.clear) + ctx:reset() + state.renderer.set_messages({}) + state.session.set_active(nil) + state.ui.clear_windows() + package.loaded['opencode.ui.input_window'] = nil + package.loaded['opencode.api'] = nil + require('opencode.ui.question_window')._current_question = nil + require('opencode.ui.question_window')._current_question_index = 1 + require('opencode.ui.permission_window')._permission_queue = {} + require('opencode.ui.permission_window')._dialog = nil + pcall(vim.api.nvim_win_close, input_win, true) + pcall(vim.api.nvim_win_close, output_win, true) + pcall(vim.api.nvim_win_close, other_win, true) + pcall(vim.api.nvim_buf_delete, input_buf, { force = true }) + pcall(vim.api.nvim_buf_delete, output_buf, { force = true }) + pcall(vim.api.nvim_buf_delete, other_buf, { force = true }) + end) + + it('does not require opencode.api at module load time', function() + package.loaded['opencode.ui.message_actions'] = nil + package.loaded['opencode.api'] = nil + + require('opencode.ui.message_actions') + + assert.is_nil(package.loaded['opencode.api']) + end) + + it('open_at_cursor finds the user message from the output cursor line', function() + local target = user_message('msg_user') + render_message_at(target, 0, 0) + set_output_cursor(1) + + message_actions.open_at_cursor() + + assert.are.equal(target, message_actions._target_message) + assert.is_not_nil(message_actions._dialog) + assert.stub(render_display_stub).was_called_with('msg_user', 'message-actions-display-part:msg_user') + end) + + it('open_at_cursor finds the user message from a rendered user text part line', function() + local target = user_message('msg_user') + local text_part = target.parts[1] + render_message_at(target, 0, 0) + render_part_at(text_part, 1, 1) + set_output_cursor(2) + + message_actions.open_at_cursor() + + assert.are.equal(target, message_actions._target_message) + assert.stub(render_display_stub).was_called_with('msg_user', 'message-actions-display-part:msg_user') + end) + + it('open_at_cursor does not open from assistant part or message actions part lines', function() + local assistant = message('assistant', 'msg_assistant', { + { id = 'part_assistant', type = 'text', text = 'assistant', messageID = 'msg_assistant', sessionID = 'ses_1' }, + }) + render_message_at(assistant, 0, 0) + render_part_at(assistant.parts[1], 1, 1) + set_output_cursor(2) + + message_actions.open_at_cursor() + + assert.is_nil(message_actions._target_message) + assert.stub(render_display_stub).was_not_called() + + local target = user_message('msg_user') + render_message_at(target, 2, 2) + render_part_at({ + id = 'message-actions-display-part:msg_user', + type = 'message-actions-display', + messageID = 'msg_user', + sessionID = 'ses_1', + synthetic = true, + }, 3, 3) + set_output_cursor(4) + + message_actions.open_at_cursor() + + assert.is_nil(message_actions._target_message) + assert.stub(render_display_stub).was_not_called() + end) + + it('open_from_mouse uses the mouse line instead of the cursor line', function() + local assistant = message('assistant', 'msg_assistant') + local target = user_message('msg_user') + render_message_at(assistant, 0, 0) + render_message_at(target, 1, 1) + set_output_cursor(1) + + with_mousepos({ winid = output_win, line = 2, column = 1 }, function() + message_actions.open_from_mouse() + end) + + assert.are.equal(target, message_actions._target_message) + end) + + it('open_from_mouse finds the user message from a rendered user text part line', function() + local target = user_message('msg_user') + render_message_at(target, 0, 0) + render_part_at(target.parts[1], 1, 1) + + with_mousepos({ winid = output_win, line = 2, column = 1 }, function() + message_actions.open_from_mouse() + end) + + assert.are.equal(target, message_actions._target_message) + assert.stub(render_display_stub).was_called_with('msg_user', 'message-actions-display-part:msg_user') + end) + + it('normalizes Neovim 1-based cursor and mouse lines to render-state 0-based lines', function() + local first = user_message('msg_first') + local second = user_message('msg_second') + render_message_at(first, 0, 0) + render_message_at(second, 1, 1) + + set_output_cursor(1) + message_actions.open_at_cursor() + assert.are.equal(first, message_actions._target_message) + + with_mousepos({ winid = output_win, line = 2, column = 1 }, function() + message_actions.open_from_mouse() + end) + assert.is_nil(message_actions._target_message) + + with_mousepos({ winid = output_win, line = 2, column = 1 }, function() + message_actions.open_from_mouse() + end) + assert.are.equal(second, message_actions._target_message) + end) + + it('open_from_mouse silently ignores mouse positions outside the output window', function() + render_message_at(user_message('msg_user'), 0, 0) + + with_mousepos({ winid = other_win, line = 1, column = 1 }, function() + message_actions.open_from_mouse() + end) + + assert.is_nil(message_actions._target_message) + assert.stub(render_display_stub).was_not_called() + end) + + it('open_from_mouse silently ignores output-window ids whose buffer does not match output_buf', function() + render_message_at(user_message('msg_user'), 0, 0) + vim.api.nvim_win_set_buf(output_win, other_buf) + + with_mousepos({ winid = output_win, line = 1, column = 1 }, function() + message_actions.open_from_mouse() + end) + + assert.is_nil(message_actions._target_message) + assert.stub(render_display_stub).was_not_called() + end) + + it('open_from_mouse routes active question clicks before message actions', function() + local question_window = require('opencode.ui.question_window') + local permission_window = require('opencode.ui.permission_window') + local original_has_question = question_window.has_question + local original_select_question = question_window.select_mouse_option + local original_has_permissions = permission_window.has_permissions + local selected = false + + question_window.has_question = function() + return true + end + question_window.select_mouse_option = function() + selected = true + return true + end + permission_window.has_permissions = function() + return false + end + render_message_at(user_message('msg_user'), 0, 0) + + with_mousepos({ winid = output_win, line = 1, column = 1 }, function() + message_actions.open_from_mouse() + end) + + question_window.has_question = original_has_question + question_window.select_mouse_option = original_select_question + permission_window.has_permissions = original_has_permissions + + assert.is_true(selected) + assert.is_nil(message_actions._target_message) + assert.stub(render_display_stub).was_not_called() + end) + + it('open_from_mouse routes active permission clicks before message actions', function() + local question_window = require('opencode.ui.question_window') + local permission_window = require('opencode.ui.permission_window') + local original_has_question = question_window.has_question + local original_has_permissions = permission_window.has_permissions + local original_select_permission = permission_window.select_mouse_option + local selected = false + + question_window.has_question = function() + return false + end + permission_window.has_permissions = function() + return true + end + permission_window.select_mouse_option = function() + selected = true + return true + end + render_message_at(user_message('msg_user'), 0, 0) + + with_mousepos({ winid = output_win, line = 1, column = 1 }, function() + message_actions.open_from_mouse() + end) + + question_window.has_question = original_has_question + permission_window.has_permissions = original_has_permissions + permission_window.select_mouse_option = original_select_permission + + assert.is_true(selected) + assert.is_nil(message_actions._target_message) + assert.stub(render_display_stub).was_not_called() + end) + + it('only allows non-synthetic user messages with ids', function() + assert.is_true(message_actions.is_actionable_user_message(user_message('msg_user'))) + assert.is_false(message_actions.is_actionable_user_message(message('assistant', 'msg_assistant'))) + assert.is_false(message_actions.is_actionable_user_message(message('tool', 'msg_tool'))) + assert.is_false(message_actions.is_actionable_user_message(message('system', 'msg_system'))) + assert.is_false(message_actions.is_actionable_user_message(user_message('', {}))) + assert.is_false(message_actions.is_actionable_user_message(user_message('msg_synthetic', { + { type = 'text', text = 'synthetic', synthetic = true }, + }))) + end) + + it('refuses to open while a question dialog is active', function() + local question_window = require('opencode.ui.question_window') + question_window._current_question = { + id = 'question_1', + questions = { + { question = 'Choose', options = {} }, + }, + } + question_window._current_question_index = 1 + render_message_at(user_message('msg_user'), 0, 0) + set_output_cursor(1) + + message_actions.open_at_cursor() + + assert.is_nil(message_actions._target_message) + assert.are.equal('Finish the active dialog first', notify_mock.get_notifications()[1].msg) + end) + + it('refuses to open while a permission dialog is active', function() + require('opencode.ui.permission_window')._permission_queue = { { id = 'permission_1' } } + render_message_at(user_message('msg_user'), 0, 0) + set_output_cursor(1) + + message_actions.open_at_cursor() + + assert.is_nil(message_actions._target_message) + assert.are.equal('Finish the active dialog first', notify_mock.get_notifications()[1].msg) + end) + + it('does not add an active-dialog registry or coordinator surface', function() + local source = table.concat(vim.fn.readfile('lua/opencode/ui/message_actions.lua'), '\n') + + assert.is_nil(source:find('registry', 1, true)) + assert.is_nil(source:find('allowlist', 1, true)) + assert.is_nil(source:find('coordinator', 1, true)) + assert.is_nil(source:find('wrapper', 1, true)) + end) + + it('clear tears down the active dialog and requests synthetic display removal', function() + render_message_at(user_message('msg_user'), 0, 0) + set_output_cursor(1) + message_actions.open_at_cursor() + render_display_stub:clear() + + message_actions.clear() + + assert.is_nil(message_actions._target_message) + assert.is_nil(message_actions._dialog) + assert.stub(render_display_stub).was_called_with(nil, 'message-actions-display-part:msg_user') + end) + + it('does not let Dialog override the output single-click mapping while active', function() + render_message_at(user_message('msg_user'), 0, 0) + set_output_cursor(1) + message_actions.open_at_cursor() + + assert.is_false(has_buffer_key('')) + end) + + it('active mouse click on an action option selects that option', function() + local setreg_name, setreg_value + local original_setreg = vim.fn.setreg + ---@diagnostic disable-next-line: duplicate-set-field + vim.fn.setreg = function(name, value) + setreg_name = name + setreg_value = value + end + + render_message_at(user_message('msg_user', { + { id = 'part_text', type = 'text', text = 'copy me', messageID = 'msg_user', sessionID = 'ses_1' }, + }), 0, 0) + set_output_cursor(1) + message_actions.open_at_cursor() + attach_rendered_actions_part(20) + local copy_line = 20 + message_actions._dialog._option_local_lines[2] + 1 + + with_mousepos({ winid = output_win, line = copy_line, column = 1 }, function() + message_actions.open_from_mouse() + end) + vim.fn.setreg = original_setreg + + assert.are.equal('+', setreg_name) + assert.are.equal('copy me', setreg_value) + assert.is_nil(message_actions._target_message) + end) + + it('active mouse click outside action options clears the dialog', function() + render_message_at(user_message('msg_user'), 0, 0) + set_output_cursor(1) + message_actions.open_at_cursor() + attach_rendered_actions_part(20) + render_display_stub:clear() + + with_mousepos({ winid = output_win, line = 1, column = 1 }, function() + message_actions.open_from_mouse() + end) + + assert.is_nil(message_actions._target_message) + assert.is_nil(message_actions._dialog) + assert.stub(render_display_stub).was_called_with(nil, 'message-actions-display-part:msg_user') + end) + + it('active mouse click on another message clears instead of switching targets', function() + render_message_at(user_message('msg_user'), 0, 0) + render_message_at(user_message('msg_other'), 2, 2) + set_output_cursor(1) + message_actions.open_at_cursor() + attach_rendered_actions_part(20) + + with_mousepos({ winid = output_win, line = 3, column = 1 }, function() + message_actions.open_from_mouse() + end) + + assert.is_nil(message_actions._target_message) + assert.is_nil(message_actions._dialog) + end) + + it('teardown clears state and keymaps without requesting renderer removal', function() + local target = user_message('msg_user') + render_message_at(target, 0, 0) + set_output_cursor(1) + message_actions.open_at_cursor() + assert.is_true(has_buffer_key('1')) + + render_display_stub:clear() + message_actions.teardown() + + assert.is_nil(message_actions._target_message) + assert.is_nil(message_actions._dialog) + assert.is_nil(message_actions._display_part_id) + assert.is_false(has_buffer_key('1')) + assert.stub(render_display_stub).was_not_called() + end) + + it('renderer reset tears down the active message actions dialog', function() + local target = user_message('msg_user') + render_message_at(target, 0, 0) + set_output_cursor(1) + message_actions.open_at_cursor() + assert.is_true(has_buffer_key('1')) + + require('opencode.ui.renderer').reset() + + assert.is_nil(message_actions._target_message) + assert.is_nil(message_actions._dialog) + assert.is_nil(message_actions._display_part_id) + assert.is_false(has_buffer_key('1')) + end) + + it('persist hide tears down the active message actions dialog', function() + local config = require('opencode.config') + config.values.ui.persist_state = true + local target = user_message('msg_user') + render_message_at(target, 0, 0) + set_output_cursor(1) + message_actions.open_at_cursor() + assert.is_true(has_buffer_key('1')) + render_display_stub:clear() + + require('opencode.ui.ui').hide_visible_windows(state.windows) + + assert.is_nil(message_actions._target_message) + assert.is_nil(message_actions._dialog) + assert.is_nil(message_actions._display_part_id) + assert.is_false(has_buffer_key('1')) + assert.stub(render_display_stub).was_called_with(nil, 'message-actions-display-part:msg_user') + end) + + it('selecting Revert calls opencode.api.undo with the target message id', function() + local undo_id = nil + package.loaded['opencode.api'] = { + undo = function(message_id) + undo_id = message_id + end, + } + render_message_at(user_message('msg_user'), 0, 0) + set_output_cursor(1) + message_actions.open_at_cursor() + + message_actions._dialog:set_selection(1) + message_actions._dialog:select() + + assert.are.equal('msg_user', undo_id) + end) + + it('selecting Fork calls opencode.api.fork_session with the target message id', function() + local fork_id = nil + package.loaded['opencode.api'] = { + fork_session = function(message_id) + fork_id = message_id + end, + } + render_message_at(user_message('msg_user'), 0, 0) + set_output_cursor(1) + message_actions.open_at_cursor() + + message_actions._dialog:set_selection(3) + message_actions._dialog:select() + + assert.are.equal('msg_user', fork_id) + end) + + it('collect_user_text joins only non-synthetic text parts without trimming copied text', function() + local text = message_actions.collect_user_text(user_message('msg_user', { + { type = 'text', text = ' first ' }, + { type = 'tool', text = 'tool output' }, + { type = 'text', text = 'synthetic', synthetic = true }, + { type = 'text', text = ' ' }, + { type = 'text', text = 'second\nline' }, + })) + + assert.are.equal(' first \n\nsecond\nline', text) + end) + + it('selecting Copy writes only the + register with the collected user text', function() + local setreg_name, setreg_value + local original_setreg = vim.fn.setreg + ---@diagnostic disable-next-line: duplicate-set-field + vim.fn.setreg = function(name, value) + setreg_name = name + setreg_value = value + end + + render_message_at(user_message('msg_user', { + { type = 'text', text = 'first' }, + { type = 'text', text = 'synthetic', synthetic = true }, + { type = 'text', text = 'second' }, + }), 0, 0) + set_output_cursor(1) + message_actions.open_at_cursor() + + message_actions._dialog:set_selection(2) + message_actions._dialog:select() + vim.fn.setreg = original_setreg + + assert.are.equal('+', setreg_name) + assert.are.equal('first\n\nsecond', setreg_value) + end) + + it('selecting Copy notifies and does not write a register when there is no text', function() + local setreg_called = false + local original_setreg = vim.fn.setreg + ---@diagnostic disable-next-line: duplicate-set-field + vim.fn.setreg = function() + setreg_called = true + end + + render_message_at(user_message('msg_user', { + { type = 'text', text = ' ' }, + { type = 'text', text = 'synthetic', synthetic = true }, + }), 0, 0) + set_output_cursor(1) + message_actions.open_at_cursor() + + message_actions._dialog:set_selection(2) + message_actions._dialog:select() + vim.fn.setreg = original_setreg + + assert.is_false(setreg_called) + assert.are.equal('No message text to copy', notify_mock.get_notifications()[1].msg) + end) + + it('formats only the action choices without the generic dialog legend', function() + local Output = require('opencode.ui.output') + render_message_at(user_message('msg_user'), 0, 0) + set_output_cursor(1) + message_actions.open_at_cursor() + + local output = Output.new() + message_actions.format_display(output) + local lines = table.concat(output.lines, '\n') + + assert.is_not_nil(lines:find('Message Actions', 1, true)) + assert.is_not_nil(lines:find('Revert', 1, true)) + assert.is_not_nil(lines:find('Copy', 1, true)) + assert.is_not_nil(lines:find('Fork', 1, true)) + assert.is_nil(lines:find('Move:', 1, true)) + assert.is_nil(lines:find('Select:', 1, true)) + assert.is_nil(lines:find('Close:', 1, true)) + end) +end) + +describe('renderer.events message actions display', function() + local events = require('opencode.ui.renderer.events') + local message_actions + local part_stub + local remove_part_stub + + before_each(function() + package.loaded['opencode.ui.message_actions'] = nil + message_actions = require('opencode.ui.message_actions') + state.session.set_active({ id = 'ses_1' }) + state.renderer.set_messages({ + user_message('msg_user'), + user_message('msg_other'), + }) + ctx:reset() + part_stub = stub(events, 'on_part_updated') + remove_part_stub = stub(events, 'on_part_removed') + end) + + after_each(function() + part_stub:revert() + remove_part_stub:revert() + state.session.set_active(nil) + state.renderer.set_messages({}) + ctx:reset() + end) + + it('creates the synthetic part under the target message', function() + events.render_message_actions_display('msg_user', 'message-actions-display-part:msg_user') + + assert.stub(part_stub).was_called_with({ + part = { + id = 'message-actions-display-part:msg_user', + messageID = 'msg_user', + sessionID = 'ses_1', + type = 'message-actions-display', + synthetic = true, + }, + }) + end) + + it('removes the synthetic part when asked not to show it', function() + events.render_message_actions_display(nil, 'message-actions-display-part:msg_user') + + assert.stub(remove_part_stub).was_called_with({ + sessionID = 'ses_1', + messageID = nil, + partID = 'message-actions-display-part:msg_user', + }) + end) + + it('uses separate synthetic part ids when the target message changes', function() + ctx.render_state:set_part({ + id = 'message-actions-display-part:msg_other', + messageID = 'msg_other', + sessionID = 'ses_1', + type = 'message-actions-display', + synthetic = true, + }, 10, 12) + + events.render_message_actions_display(nil, 'message-actions-display-part:msg_other') + events.render_message_actions_display('msg_user', 'message-actions-display-part:msg_user') + + assert.stub(remove_part_stub).was_called_with({ + sessionID = 'ses_1', + messageID = 'msg_other', + partID = 'message-actions-display-part:msg_other', + }) + assert.stub(part_stub).was_called_with({ + part = { + id = 'message-actions-display-part:msg_user', + messageID = 'msg_user', + sessionID = 'ses_1', + type = 'message-actions-display', + synthetic = true, + }, + }) + end) + + it('does not read message_actions state from renderer events', function() + local source = table.concat(vim.fn.readfile('lua/opencode/ui/renderer/events.lua'), '\n') + + assert.is_nil(source:find("require('opencode.ui.message_actions')", 1, true)) + end) +end) diff --git a/tests/unit/permission_window_spec.lua b/tests/unit/permission_window_spec.lua index bf6d569f..3aefe41a 100644 --- a/tests/unit/permission_window_spec.lua +++ b/tests/unit/permission_window_spec.lua @@ -1,12 +1,44 @@ local permission_window = require('opencode.ui.permission_window') local Output = require('opencode.ui.output') local stub = require('luassert.stub') +local helpers = require('tests.helpers') +local state = require('opencode.state') +local config = require('opencode.config') describe('permission_window', function() + local original_auto_hide + + before_each(function() + original_auto_hide = config.ui.input.auto_hide + end) + + local function with_mousepos(mousepos, callback) + local original_getmousepos = vim.fn.getmousepos + ---@diagnostic disable-next-line: duplicate-set-field + vim.fn.getmousepos = function() + return mousepos + end + + local ok, err = pcall(callback) + vim.fn.getmousepos = original_getmousepos + if not ok then + error(err) + end + end + after_each(function() + config.ui.input.auto_hide = true + pcall(permission_window.clear_all) + config.ui.input.auto_hide = original_auto_hide permission_window._permission_queue = {} permission_window._dialog = nil permission_window._processing = false + state.jobs.set_api_client(nil) + state.renderer.set_messages({}) + state.session.set_active(nil) + if state.windows then + pcall(require('opencode.ui.ui').close_windows, state.windows) + end end) describe('format_display', function() @@ -635,4 +667,77 @@ describe('permission_window', function() assert.is_nil(permission_window._permission_queue[1]._call_id) end) end) + + describe('Dialog integration', function() + it('passes permission display part to Dialog and selects clicked option', function() + helpers.replay_setup() + config.ui.input.auto_hide = true + state.session.set_active({ id = 'sess1' }) + vim.api.nvim_set_current_win(state.windows.output_win) + + local original_schedule = vim.schedule + ---@diagnostic disable-next-line: duplicate-set-field + vim.schedule = function(fn) + fn() + end + + local api = require('opencode.api') + local original_permission_deny = api.permission_deny + local denied = {} + api.permission_deny = function(permission) + table.insert(denied, permission.id) + end + + local ok, err = pcall(function() + permission_window.add_permission({ + id = 'perm-mouse', + permission = 'bash', + title = 'Run command', + }) + + local dialog = permission_window._dialog + assert.are.equal('permission-display-part', dialog._config.render_part_id) + assert.is_false(dialog._config.mouse_select) + + require('opencode.ui.renderer.events').render_permissions_display() + require('opencode.ui.renderer.flush').flush() + + local rendered_part = require('opencode.ui.renderer.ctx').render_state:get_part('permission-display-part') + assert.is_not_nil(rendered_part) + + with_mousepos({ + winid = state.windows.output_win, + line = rendered_part.line_start + dialog._option_local_lines[2] + 1, + column = 1, + }, function() + dialog:select_mouse_option() + end) + + assert.are.same({ 'perm-mouse' }, denied) + assert.are.equal(0, permission_window.get_permission_count()) + end) + + api.permission_deny = original_permission_deny + vim.schedule = original_schedule + if not ok then + error(err) + end + end) + + it('keeps Esc and Ctrl-C unavailable as dismiss keys', function() + helpers.replay_setup() + state.session.set_active({ id = 'sess1' }) + vim.api.nvim_set_current_win(state.windows.output_win) + + permission_window.add_permission({ + id = 'perm-dismiss', + permission = 'bash', + title = 'Run command', + }) + + assert.is_false(vim.tbl_contains(permission_window._dialog._keymaps, '')) + assert.is_false(vim.tbl_contains(permission_window._dialog._keymaps, '')) + assert.are.equal(1, permission_window.get_permission_count()) + end) + end) end) diff --git a/tests/unit/question_window_spec.lua b/tests/unit/question_window_spec.lua index bfa6ed2f..21e3e7e9 100644 --- a/tests/unit/question_window_spec.lua +++ b/tests/unit/question_window_spec.lua @@ -6,6 +6,20 @@ local stub = require('luassert.stub') local helpers = require('tests.helpers') describe('question_window', function() + local function with_mousepos(mousepos, callback) + local original_getmousepos = vim.fn.getmousepos + ---@diagnostic disable-next-line: duplicate-set-field + vim.fn.getmousepos = function() + return mousepos + end + + local ok, err = pcall(callback) + vim.fn.getmousepos = original_getmousepos + if not ok then + error(err) + end + end + after_each(function() question_window._current_question = nil question_window._current_question_index = 1 @@ -15,6 +29,9 @@ describe('question_window', function() state.renderer.set_messages({}) state.session.set_active(nil) state.jobs.set_api_client(nil) + if state.windows then + pcall(require('opencode.ui.ui').close_windows, state.windows) + end end) it('tracks answers by question index and waits until all are answered', function() @@ -326,4 +343,73 @@ describe('question_window', function() require('opencode.ui.ui').close_windows(state.windows) end end) + + it('passes question display part to Dialog and answers clicked option', function() + helpers.replay_setup() + state.session.set_active({ id = 'sess1' }) + vim.api.nvim_set_current_win(state.windows.output_win) + + local original_defer_fn = vim.defer_fn + ---@diagnostic disable-next-line: duplicate-set-field + vim.defer_fn = function(fn, _delay) + fn() + end + + local ok, err = pcall(function() + local replies = {} + state.jobs.set_api_client({ + reply_question = function(_, request_id, answers) + table.insert(replies, { request_id = request_id, answers = answers }) + return Promise.new():resolve({}) + end, + reject_question = function() + return Promise.new():resolve({}) + end, + }) + + question_window.show_question({ + id = 'q-mouse', + sessionID = 'sess1', + questions = { + { + question = 'Pick one', + options = { + { label = 'One' }, + { label = 'Two' }, + }, + }, + }, + }) + + local dialog = question_window._dialog + assert.are.equal('question-display-part', dialog._config.render_part_id) + assert.is_false(dialog._config.mouse_select) + assert.are.equal('', dialog._config.keymaps.dismiss) + + require('opencode.ui.renderer.flush').flush() + local rendered_part = require('opencode.ui.renderer.ctx').render_state:get_part('question-display-part') + assert.is_not_nil(rendered_part) + + with_mousepos({ + winid = state.windows.output_win, + line = rendered_part.line_start + dialog._option_local_lines[2] + 1, + column = 1, + }, function() + dialog:select_mouse_option() + end) + + assert.are.same({ + { + request_id = 'q-mouse', + answers = { { 'Two' } }, + }, + }, replies) + assert.is_nil(question_window._current_question) + end) + + vim.defer_fn = original_defer_fn + if not ok then + error(err) + end + end) end) diff --git a/tests/unit/renderer_buffer_spec.lua b/tests/unit/renderer_buffer_spec.lua index 6dcebbb3..7f809270 100644 --- a/tests/unit/renderer_buffer_spec.lua +++ b/tests/unit/renderer_buffer_spec.lua @@ -1,6 +1,7 @@ local buffer = require('opencode.ui.renderer.buffer') local ctx = require('opencode.ui.renderer.ctx') local output_window = require('opencode.ui.output_window') +local state = require('opencode.state') local stub = require('luassert.stub') local function assert_called_before(call_order, first_name, second_name) @@ -46,6 +47,7 @@ describe('renderer.buffer extmarks', function() clear_extmarks_stub:revert() set_extmarks_stub:revert() highlight_changed_lines_stub:revert() + state.renderer.set_messages({}) ctx:reset() end) @@ -126,6 +128,29 @@ describe('renderer.buffer extmarks', function() assert_called_before(call_order, 'clear_extmarks', 'set_lines') end) + it('inserts regular messages above pinned bottom displays', function() + local get_line_count_stub = stub(output_window, 'get_buf_line_count').returns(14) + local shift_folds_stub = stub(output_window, 'shift_folds') + state.renderer.set_messages({ + { info = { id = 'msg_1' } }, + }) + ctx.render_state:set_message({ info = { id = 'question-display-message' } }, 10, 12) + ctx.render_state:set_message({ info = { id = 'msg_1' } }) + + buffer.upsert_message_now('msg_1', { + lines = { 'regular message' }, + extmarks = {}, + actions = {}, + fold_ranges = {}, + }) + + assert.stub(set_lines_stub).was_called_with({ 'regular message' }, 10, 10) + + get_line_count_stub:revert() + shift_folds_stub:revert() + state.renderer.set_messages({}) + end) + it('only clears and reapplies appended extmarks during append-only updates', function() ctx.render_state:set_part({ id = 'part_1', messageID = 'msg_1', type = 'text' }, 10, 11) ctx.formatted_parts['part_1'] = { From b18eb9462d943957f973a1bb164e1687ce7a2dbb Mon Sep 17 00:00:00 2001 From: jensenojs Date: Thu, 2 Jul 2026 17:24:36 +0800 Subject: [PATCH 2/2] Clean up message action tests --- tests/unit/message_actions_spec.lua | 62 ++++++++++++++++++----------- 1 file changed, 38 insertions(+), 24 deletions(-) diff --git a/tests/unit/message_actions_spec.lua b/tests/unit/message_actions_spec.lua index e044df08..7e61d96c 100644 --- a/tests/unit/message_actions_spec.lua +++ b/tests/unit/message_actions_spec.lua @@ -398,15 +398,6 @@ describe('message_actions', function() assert.are.equal('Finish the active dialog first', notify_mock.get_notifications()[1].msg) end) - it('does not add an active-dialog registry or coordinator surface', function() - local source = table.concat(vim.fn.readfile('lua/opencode/ui/message_actions.lua'), '\n') - - assert.is_nil(source:find('registry', 1, true)) - assert.is_nil(source:find('allowlist', 1, true)) - assert.is_nil(source:find('coordinator', 1, true)) - assert.is_nil(source:find('wrapper', 1, true)) - end) - it('clear tears down the active dialog and requests synthetic display removal', function() render_message_at(user_message('msg_user'), 0, 0) set_output_cursor(1) @@ -437,9 +428,13 @@ describe('message_actions', function() setreg_value = value end - render_message_at(user_message('msg_user', { - { id = 'part_text', type = 'text', text = 'copy me', messageID = 'msg_user', sessionID = 'ses_1' }, - }), 0, 0) + render_message_at( + user_message('msg_user', { + { id = 'part_text', type = 'text', text = 'copy me', messageID = 'msg_user', sessionID = 'ses_1' }, + }), + 0, + 0 + ) set_output_cursor(1) message_actions.open_at_cursor() attach_rendered_actions_part(20) @@ -592,11 +587,15 @@ describe('message_actions', function() setreg_value = value end - render_message_at(user_message('msg_user', { - { type = 'text', text = 'first' }, - { type = 'text', text = 'synthetic', synthetic = true }, - { type = 'text', text = 'second' }, - }), 0, 0) + render_message_at( + user_message('msg_user', { + { type = 'text', text = 'first' }, + { type = 'text', text = 'synthetic', synthetic = true }, + { type = 'text', text = 'second' }, + }), + 0, + 0 + ) set_output_cursor(1) message_actions.open_at_cursor() @@ -616,10 +615,14 @@ describe('message_actions', function() setreg_called = true end - render_message_at(user_message('msg_user', { - { type = 'text', text = ' ' }, - { type = 'text', text = 'synthetic', synthetic = true }, - }), 0, 0) + render_message_at( + user_message('msg_user', { + { type = 'text', text = ' ' }, + { type = 'text', text = 'synthetic', synthetic = true }, + }), + 0, + 0 + ) set_output_cursor(1) message_actions.open_at_cursor() @@ -730,9 +733,20 @@ describe('renderer.events message actions display', function() }) end) - it('does not read message_actions state from renderer events', function() - local source = table.concat(vim.fn.readfile('lua/opencode/ui/renderer/events.lua'), '\n') + it('renders synthetic parts without loading message_actions', function() + package.loaded['opencode.ui.message_actions'] = nil + + events.render_message_actions_display('msg_user', 'message-actions-display-part:msg_user') - assert.is_nil(source:find("require('opencode.ui.message_actions')", 1, true)) + assert.is_nil(package.loaded['opencode.ui.message_actions']) + assert.stub(part_stub).was_called_with({ + part = { + id = 'message-actions-display-part:msg_user', + messageID = 'msg_user', + sessionID = 'ses_1', + type = 'message-actions-display', + synthetic = true, + }, + }) end) end)