diff --git a/lua/opencode/api_client.lua b/lua/opencode/api_client.lua index 127bd4b4..223b2fbc 100644 --- a/lua/opencode/api_client.lua +++ b/lua/opencode/api_client.lua @@ -385,7 +385,7 @@ end --- Respond to a permission request --- @param id string Session ID (required) --- @param permissionID string Permission ID (required) ---- @param response_data {response: "once"|"always"|"reject"} Response data +--- @param response_data {response: "once"|"always"|"reject", message?: string} Response data --- @param directory string|nil Directory path --- @return Promise function OpencodeApiClient:respond_to_permission(id, permissionID, response_data, directory) @@ -397,6 +397,15 @@ function OpencodeApiClient:respond_to_permission(id, permissionID, response_data ) end +--- Reply to a permission (accept/reject) +--- @param requestID string Permission request ID (prefixed with "per") +--- @param response_data {reply: "once"|"always"|"reject", message?: string} Response data +--- @param directory string|nil Directory path +--- @return Promise +function OpencodeApiClient:reply_to_permission(requestID, response_data, directory) + return self:_call('/permission/' .. requestID .. '/reply', 'POST', response_data, { directory = directory }) +end + --- List all commands --- @param directory string|nil Directory path --- @return Promise diff --git a/lua/opencode/commands/handlers/permission.lua b/lua/opencode/commands/handlers/permission.lua index 74885fe3..0f0ec252 100644 --- a/lua/opencode/commands/handlers/permission.lua +++ b/lua/opencode/commands/handlers/permission.lua @@ -17,7 +17,7 @@ end ---@param answer? 'once'|'always'|'reject' ---@param permission? OpencodePermission -function M.actions.respond_to_permission(answer, permission) +function M.actions.respond_to_permission(answer, permission, message) answer = answer or 'once' local permission_window = require('opencode.ui.permission_window') @@ -27,8 +27,13 @@ function M.actions.respond_to_permission(answer, permission) return end + local data = { reply = answer } + if message and message ~= '' then + data.message = message + end + state.api_client - :respond_to_permission(current_permission.sessionID, current_permission.id, { response = answer }) + :reply_to_permission(current_permission.id, data) :catch(function(err) vim.schedule(function() vim.notify('Failed to reply to permission: ' .. vim.inspect(err), vim.log.levels.ERROR) @@ -47,8 +52,9 @@ function M.actions.permission_accept_all(permission) end ---@param permission? OpencodePermission -function M.actions.permission_deny(permission) - M.actions.respond_to_permission('reject', permission) +---@param message? string +function M.actions.permission_deny(permission, message) + M.actions.respond_to_permission('reject', permission, message) end function M.actions.question_answer() diff --git a/lua/opencode/services/session_runtime.lua b/lua/opencode/services/session_runtime.lua index 8183b94a..452596b3 100644 --- a/lua/opencode/services/session_runtime.lua +++ b/lua/opencode/services/session_runtime.lua @@ -262,7 +262,7 @@ M.cancel = Promise.async(function() local permissions = state.pending_permissions or {} if #permissions > 0 and state.api_client then for _, permission in ipairs(permissions) do - state.api_client:respond_to_permission(permission.sessionID, permission.id, { response = 'reject' }) + state.api_client:reply_to_permission(permission.id, { reply = 'reject' }) end end diff --git a/lua/opencode/ui/dialog.lua b/lua/opencode/ui/dialog.lua index f012defe..29cb85e2 100644 --- a/lua/opencode/ui/dialog.lua +++ b/lua/opencode/ui/dialog.lua @@ -12,6 +12,7 @@ ---@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 show_dismiss_legend? boolean Whether to render the generic dismiss hint (default: true) ---@class DialogKeymaps ---@field up? string[] Keys for navigating up (default: {'k', ''}) @@ -58,10 +59,11 @@ function Dialog.new(config) self._config = vim.tbl_deep_extend('force', { keymaps = default_keymaps, namespace_prefix = 'opencode_dialog', - check_focused = function() - return true - end, - hide_input = true, + check_focused = function() + return true + end, + hide_input = true, + show_dismiss_legend = true, } --[[@as DialogConfig]], config) self._keymaps = {} @@ -292,9 +294,15 @@ function Dialog:format_legend(output, options) output:add_line(select_text) end - if keymaps.dismiss and keymaps.dismiss ~= '' then + if self._config.show_dismiss_legend and keymaps.dismiss and keymaps.dismiss ~= '' then output:add_line('Close: ``') end + + if options.legend_lines then + for _, line in ipairs(options.legend_lines) do + output:add_line(line) + end + end else local message = options.unfocused_message or 'Focus Opencode window to interact' output:add_line(message) @@ -346,7 +354,7 @@ function Dialog:format_dialog(output, config) output:add_line('') - self:format_legend(output, { unfocused_message = config.unfocused_message }) + self:format_legend(output, { unfocused_message = config.unfocused_message, legend_lines = config.legend_lines }) local end_line = output:get_line_count() diff --git a/lua/opencode/ui/permission_window.lua b/lua/opencode/ui/permission_window.lua index fd00f2f0..a3b1a90f 100644 --- a/lua/opencode/ui/permission_window.lua +++ b/lua/opencode/ui/permission_window.lua @@ -8,6 +8,56 @@ local M = {} M._permission_queue = {} M._dialog = nil M._processing = false +M._interaction = nil + +local function is_current_permission(permission_id) + local permission = M._permission_queue[1] + return permission ~= nil and permission.id == permission_id +end + +local function stop_timer(timer) + timer:stop() + timer:close() +end + +local function clear_interaction() + local interaction = M._interaction + M._interaction = nil + if not interaction then + return + end + + M._processing = false + if interaction.timer then + stop_timer(interaction.timer) + end + if interaction.feedback then + interaction.feedback.close() + end +end + +local function interaction_for(permission) + if M._interaction and M._interaction.permission_id == permission.id then + return M._interaction + end + + clear_interaction() + M._interaction = { + permission_id = permission.id, + deny_armed = false, + timer = nil, + feedback = nil, + } + return M._interaction +end + +local function clear_deny_timer(interaction) + interaction.deny_armed = false + if interaction.timer then + stop_timer(interaction.timer) + interaction.timer = nil + end +end ---Get the tool identifiers from a permission (nested or root-level). ---@param permission OpencodePermission|nil @@ -62,6 +112,19 @@ local function get_permission_part(permission) end end +---@param permission OpencodePermission|nil +---@return string|nil +local function get_child_session_id(permission) + local session_id = permission and permission.sessionID + local active_session = state.active_session + if not session_id or session_id == '' or (active_session and active_session.id == session_id) then + return nil + end + + local render_state = require('opencode.ui.renderer.ctx').render_state + return render_state:get_task_part_by_child_session(session_id) and session_id or nil +end + ---Check whether a permission has already been resolved (completed, error, etc.) ---by inspecting the corresponding message part's status. ---@param permission OpencodePermission|nil @@ -149,6 +212,10 @@ end ---Remove permission from queue ---@param permission_id string function M.remove_permission(permission_id) + if M._interaction and M._interaction.permission_id == permission_id then + clear_interaction() + end + for i, permission in ipairs(M._permission_queue) do if permission.id == permission_id then table.remove(M._permission_queue, i) @@ -174,7 +241,7 @@ end ---Get permission display lines to append to output ---@param output Output function M.format_display(output) - if #M._permission_queue == 0 or not M._dialog or M._processing then + if #M._permission_queue == 0 or not M._dialog then return end @@ -185,6 +252,7 @@ function M.format_display(output) local icons = require('opencode.ui.icons') local formatter_utils = require('opencode.ui.formatter.utils') + local dialog_start_line = output:get_line_count() local progress = '' if #M._permission_queue > 1 then @@ -226,6 +294,10 @@ function M.format_display(output) { label = 'Allow always' }, } + local interaction = interaction_for(permission) + local legend_lines = interaction.deny_armed and { 'Release `Esc` to cancel, press again to deny' } + or { 'Double `Esc` to deny and stop' } + local render_content = nil if perm_type == 'edit' and permission.metadata and permission.metadata.diff then render_content = function(out) @@ -248,7 +320,20 @@ function M.format_display(output) render_content = render_content, options = options, unfocused_message = 'Focus Opencode window to respond to permission', + legend_lines = legend_lines, }) + + local child_session_id = get_child_session_id(permission) + if child_session_id then + output:add_action({ + text = '[S] Open this Session', + type = 'navigate_session_tree', + args = { child_session_id }, + key = 'S', + display_line = dialog_start_line, + range = { from = dialog_start_line, to = math.max(dialog_start_line, output:get_line_count() - 1) }, + }) + end end function M._setup_dialog() @@ -257,12 +342,15 @@ function M._setup_dialog() return end + local current_permission = M.get_current_permission() + local interaction = interaction_for(current_permission) + local saved_selection = nil if M._dialog then saved_selection = M._dialog:get_selection() end - M._clear_dialog() + M._clear_dialog(true) if not state.windows or not state.windows.output_buf then return @@ -275,6 +363,12 @@ function M._setup_dialog() return ui.is_opencode_focused() and #M._permission_queue > 0 end + local function is_active_permission(permission_id) + return M._processing + and is_current_permission(permission_id) + and M._interaction == interaction + end + local function on_select(index) if M._processing then return @@ -289,21 +383,69 @@ function M._setup_dialog() return end - M._processing = true + local permission_id = permission.id + if not is_current_permission(permission_id) or M._interaction ~= interaction then + return + end local api = require('opencode.api') local actions = { 'accept', 'deny', 'accept_all' } local action = actions[index] + if not action then + return + end + + M._processing = true vim.schedule(function() - if action then + if not is_active_permission(permission_id) then + return + end + + if action == 'deny' then + local pos = M._dialog and M._dialog:get_option_position(index) + local part_data = require('opencode.ui.renderer.ctx').render_state:get_part('permission-display-part') + local output_win = state.windows and state.windows.output_win + + if output_win and vim.api.nvim_win_is_valid(output_win) then + clear_deny_timer(interaction) + local cursor = vim.api.nvim_win_get_cursor(output_win) + local row = part_data and part_data.line_start and pos and (part_data.line_start + pos.line) + or math.max(0, cursor[1] - 1) + local col = pos and pos.col or 0 + interaction.feedback = require('opencode.ui.inline_input').open({ + win = output_win, + row = row, + col = col, + title = 'Tell OpenCode what to do differently', + on_submit = function(text) + if not is_active_permission(permission_id) then + return + end + interaction.feedback = nil + api.permission_deny(permission, (text ~= '') and text or nil) + M.remove_permission(permission_id) + end, + on_cancel = function() + if M._interaction == interaction then + interaction.feedback = nil + clear_deny_timer(interaction) + M._processing = false + end + end, + }) + else + clear_deny_timer(interaction) + M._processing = false + vim.notify('Cannot open permission feedback without an output window', vim.log.levels.ERROR) + end + else local api_func = api['permission_' .. action] if api_func then api_func(permission) end + M.remove_permission(permission_id) end - M._processing = false - M.remove_permission(permission.id) end) end @@ -318,12 +460,38 @@ function M._setup_dialog() M._dialog = Dialog.new({ buffer = buf, on_select = on_select, + on_dismiss = function() + if M._processing or not check_focused() or not is_current_permission(interaction.permission_id) then + return + end + + if interaction.deny_armed then + clear_deny_timer(interaction) + M._processing = true + require('opencode.api').permission_deny(current_permission, nil) + M.remove_permission(interaction.permission_id) + return + end + + interaction.deny_armed = true + require('opencode.ui.renderer.events').render_permissions_display() + local timer + timer = vim.defer_fn(function() + if M._interaction == interaction and interaction.timer == timer then + interaction.deny_armed = false + interaction.timer = nil + require('opencode.ui.renderer.events').render_permissions_display() + end + end, 2000) + interaction.timer = timer + end, on_navigate = on_navigate, get_option_count = get_option_count, check_focused = check_focused, namespace_prefix = 'opencode_permission', + show_dismiss_legend = false, keymaps = { - dismiss = '', -- Disable dismiss keymap and legend + dismiss = '', }, }) @@ -334,11 +502,15 @@ function M._setup_dialog() end end -function M._clear_dialog() +---@param preserve_interaction? boolean +function M._clear_dialog(preserve_interaction) if M._dialog then M._dialog:teardown() M._dialog = nil end + if not preserve_interaction then + clear_interaction() + end end ---Query the server for pending permissions and restore any that belong diff --git a/tests/data/permission-ask-new.expected.json b/tests/data/permission-ask-new.expected.json index 296fecb6..40c52022 100644 --- a/tests/data/permission-ask-new.expected.json +++ b/tests/data/permission-ask-new.expected.json @@ -733,6 +733,26 @@ "virt_text_repeat_linebreak": true, "virt_text_win_col": -2 } + ], + [ + 36, + 36, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodePermissionBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -2 + } ] ], "lines": [ @@ -772,16 +792,17 @@ "", "Move: `j/k` or `↑/↓`", "Select: `` or `1-3`", + "Double `Esc` to deny and stop", "", "" ], "window": { "cursor": [ - 37, + 38, 0 ], - "effective_bottom": 37, - "line_count": 38, - "visible_bottom": 38 + "effective_bottom": 38, + "line_count": 39, + "visible_bottom": 39 } } diff --git a/tests/data/permission-prompt.expected.json b/tests/data/permission-prompt.expected.json index 0f0764ca..349ebc29 100644 --- a/tests/data/permission-prompt.expected.json +++ b/tests/data/permission-prompt.expected.json @@ -536,6 +536,26 @@ "virt_text_repeat_linebreak": true, "virt_text_win_col": -2 } + ], + [ + 27, + 29, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodePermissionBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -2 + } ] ], "lines": [ @@ -568,17 +588,18 @@ "", "Move: `j/k` or `↑/↓`", "Select: `` or `1-3`", + "Double `Esc` to deny and stop", "", "" ], "timestamp": 1779709441, "window": { "cursor": [ - 30, + 31, 0 ], - "effective_bottom": 30, - "line_count": 31, - "visible_bottom": 31 + "effective_bottom": 31, + "line_count": 32, + "visible_bottom": 32 } } diff --git a/tests/data/shifting-and-multiple-perms.expected.json b/tests/data/shifting-and-multiple-perms.expected.json index cee6af7b..6c0805a4 100644 --- a/tests/data/shifting-and-multiple-perms.expected.json +++ b/tests/data/shifting-and-multiple-perms.expected.json @@ -1333,6 +1333,26 @@ "virt_text_repeat_linebreak": true, "virt_text_win_col": -2 } + ], + [ + 58, + 151, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodePermissionBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -2 + } ] ], "lines": [ @@ -1487,17 +1507,18 @@ "", "Move: `j/k` or `↑/↓`", "Select: `` or `1-3`", + "Double `Esc` to deny and stop", "", "" ], "timestamp": 1779709441, "window": { "cursor": [ - 152, + 153, 0 ], - "effective_bottom": 152, - "line_count": 153, - "visible_bottom": 153 + "effective_bottom": 153, + "line_count": 154, + "visible_bottom": 154 } } diff --git a/tests/unit/permission_window_spec.lua b/tests/unit/permission_window_spec.lua index bf6d569f..c3ddddc1 100644 --- a/tests/unit/permission_window_spec.lua +++ b/tests/unit/permission_window_spec.lua @@ -7,9 +7,16 @@ describe('permission_window', function() permission_window._permission_queue = {} permission_window._dialog = nil permission_window._processing = false + permission_window._interaction = nil end) describe('format_display', function() + local state = require('opencode.state') + + after_each(function() + state.session.clear_active() + end) + local function setup_mock_dialog() local captured_opts = nil permission_window._dialog = { @@ -226,6 +233,99 @@ describe('permission_window', function() assert.are.equal("echo 'line2'", captured_opts.content[command_start + 2]) assert.are.equal('```', captured_opts.content[command_start + 3]) end) + + it('adds the existing session action for a child-session permission', function() + local renderer_ctx = require('opencode.ui.renderer.ctx') + local child_lookup = stub(renderer_ctx.render_state, 'get_task_part_by_child_session').returns('task-part') + state.session.set_active({ id = 'ses_parent' }) + setup_mock_dialog() + permission_window._permission_queue = { + { id = 'per_child', sessionID = 'ses_child', permission = 'bash' }, + } + + local output = Output.new() + permission_window.format_display(output) + + assert.are.same({ + text = '[S] Open this Session', + type = 'navigate_session_tree', + args = { 'ses_child' }, + key = 'S', + display_line = 0, + range = { from = 0, to = 0 }, + }, output.actions[1]) + child_lookup:revert() + end) + + it('covers every line produced by the permission dialog', function() + local Dialog = require('opencode.ui.dialog') + local input_window = require('opencode.ui.input_window') + local renderer_ctx = require('opencode.ui.renderer.ctx') + local child_lookup = stub(renderer_ctx.render_state, 'get_task_part_by_child_session').returns('task-part') + local hide = stub(input_window, '_hide') + local show = stub(input_window, '_show') + local buf = vim.api.nvim_create_buf(false, true) + state.session.set_active({ id = 'ses_parent' }) + permission_window._dialog = Dialog.new({ + buffer = buf, + on_select = function() end, + get_option_count = function() + return 3 + end, + check_focused = function() + return true + end, + keymaps = { dismiss = '' }, + }) + permission_window._dialog:setup() + permission_window._permission_queue = { + { id = 'per_child', sessionID = 'ses_child', permission = 'bash' }, + } + + local output = Output.new() + permission_window.format_display(output) + local action = output.actions[1] + + assert.are.equal(0, action.display_line) + assert.are.same({ from = 0, to = output:get_line_count() - 1 }, action.range) + permission_window._dialog:teardown() + vim.api.nvim_buf_delete(buf, { force = true }) + child_lookup:revert() + hide:revert() + show:revert() + end) + + it('does not add a session action for the active-session permission', function() + local renderer_ctx = require('opencode.ui.renderer.ctx') + local child_lookup = stub(renderer_ctx.render_state, 'get_task_part_by_child_session').returns('task-part') + state.session.set_active({ id = 'ses_main' }) + setup_mock_dialog() + permission_window._permission_queue = { + { id = 'per_main', sessionID = 'ses_main', permission = 'bash' }, + } + + local output = Output.new() + permission_window.format_display(output) + + assert.are.equal(0, #output.actions) + child_lookup:revert() + end) + + it('does not add a session action without a matching child task', function() + local renderer_ctx = require('opencode.ui.renderer.ctx') + local child_lookup = stub(renderer_ctx.render_state, 'get_task_part_by_child_session').returns(nil) + state.session.set_active({ id = 'ses_parent' }) + setup_mock_dialog() + permission_window._permission_queue = { + { id = 'per_other', sessionID = 'ses_other', permission = 'bash' }, + } + + local output = Output.new() + permission_window.format_display(output) + + assert.are.equal(0, #output.actions) + child_lookup:revert() + end) end) describe('update_permission_from_part', function() @@ -635,4 +735,225 @@ describe('permission_window', function() assert.is_nil(permission_window._permission_queue[1]._call_id) end) end) + + describe('interaction lifecycle', function() + local state = require('opencode.state') + local ui = require('opencode.ui.ui') + local input_window = require('opencode.ui.input_window') + local original_windows + local original_schedule + local original_defer_fn + local output_buf + local output_win + + before_each(function() + original_windows = state.windows + original_schedule = vim.schedule + original_defer_fn = vim.defer_fn + output_buf = vim.api.nvim_create_buf(false, true) + output_win = vim.api.nvim_open_win(output_buf, true, { + relative = 'editor', + row = 0, + col = 0, + width = 40, + height = 10, + style = 'minimal', + }) + state.ui.set_windows({ output_buf = output_buf, output_win = output_win }) + stub(ui, 'is_opencode_focused').returns(true) + stub(input_window, '_hide') + stub(input_window, '_show') + end) + + after_each(function() + permission_window.clear_all() + state.ui.set_windows(original_windows) + vim.schedule = original_schedule + vim.defer_fn = original_defer_fn + if ui.is_opencode_focused.revert then + ui.is_opencode_focused:revert() + end + if input_window._hide.revert then + input_window._hide:revert() + end + if input_window._show.revert then + input_window._show:revert() + end + if vim.api.nvim_win_is_valid(output_win) then + vim.api.nvim_win_close(output_win, true) + end + if vim.api.nvim_buf_is_valid(output_buf) then + vim.api.nvim_buf_delete(output_buf, { force = true }) + end + end) + + it('responds once when the same choice is triggered repeatedly', function() + local api = require('opencode.api') + local accept = stub(api, 'permission_accept') + local scheduled = {} + vim.schedule = function(callback) + table.insert(scheduled, callback) + end + + permission_window.add_permission({ id = 'per_once', permission = 'bash' }) + local dialog = permission_window._dialog + dialog:select() + dialog:select() + + for _, callback in ipairs(scheduled) do + callback() + end + + assert.stub(accept).was_called(1) + accept:revert() + end) + + it('keeps a permission pending when feedback input is cancelled', function() + local api = require('opencode.api') + local inline_input = require('opencode.ui.inline_input') + local deny = stub(api, 'permission_deny') + local cancel + local open = stub(inline_input, 'open').invokes(function(opts) + cancel = opts.on_cancel + return { close = function() end } + end) + + vim.schedule = function(fn) + fn() + end + permission_window.add_permission({ id = 'per_cancelled_feedback', permission = 'bash' }) + permission_window._dialog:set_selection(2) + permission_window._dialog:select() + cancel() + + assert.stub(deny).was_not_called() + assert.are.equal('per_cancelled_feedback', permission_window.get_current_permission().id) + open:revert() + deny:revert() + end) + + it('closes feedback and rejects its stale submit callback when permission disappears', function() + local api = require('opencode.api') + local inline_input = require('opencode.ui.inline_input') + local renderer_ctx = require('opencode.ui.renderer.ctx') + local deny = stub(api, 'permission_deny') + local submit + local closed = 0 + local open = stub(inline_input, 'open').invokes(function(opts) + submit = opts.on_submit + return { + close = function() + closed = closed + 1 + end, + } + end) + local part = stub(renderer_ctx.render_state, 'get_part').returns({ line_start = 0 }) + + vim.schedule = function(fn) + fn() + end + permission_window.add_permission({ id = 'per_inline', permission = 'bash' }) + permission_window.format_display(Output.new()) + permission_window._dialog:set_selection(2) + permission_window._dialog:select() + permission_window.remove_permission('per_inline') + submit('use a safer command') + + assert.are.equal(1, closed) + assert.stub(deny).was_not_called() + part:revert() + open:revert() + deny:revert() + end) + + it('stops the old double-escape timer before showing the next permission', function() + local timer_callback + local stopped = 0 + vim.defer_fn = function(callback) + timer_callback = callback + return { + stop = function() + stopped = stopped + 1 + end, + close = function() end, + } + end + + permission_window.add_permission({ id = 'per_first', permission = 'bash' }) + permission_window._dialog:dismiss() + permission_window.add_permission({ id = 'per_second', permission = 'bash' }) + permission_window.remove_permission('per_first') + timer_callback() + + assert.are.equal(1, stopped) + assert.are.equal('per_second', permission_window._interaction.permission_id) + assert.is_false(permission_window._interaction.deny_armed) + end) + + it('ignores an expired timer callback after feedback starts', function() + local inline_input = require('opencode.ui.inline_input') + local renderer_events = require('opencode.ui.renderer.events') + local timer_callback + local timer + local render_count = 0 + local renders = stub(renderer_events, 'render_permissions_display').invokes(function() + render_count = render_count + 1 + end) + local open = stub(inline_input, 'open').returns({ close = function() end }) + vim.defer_fn = function(callback) + timer_callback = callback + timer = { + stop = function() end, + close = function() end, + } + return timer + end + vim.schedule = function(fn) + fn() + end + + permission_window.add_permission({ id = 'per_timer_feedback', permission = 'bash' }) + permission_window._dialog:dismiss() + permission_window._dialog:set_selection(2) + permission_window._dialog:select() + local renders_before_stale_callback = render_count + timer_callback() + + assert.are.equal(renders_before_stale_callback, render_count) + assert.is_nil(permission_window._interaction.timer) + local output = Output.new() + permission_window.format_display(output) + assert.is_true(output:get_line_count() > 0) + open:revert() + renders:revert() + end) + + it('rejects the current permission once on the second escape', function() + local api = require('opencode.api') + local deny = stub(api, 'permission_deny') + vim.defer_fn = function() + return { + stop = function() end, + close = function() end, + } + end + + permission_window.add_permission({ id = 'per_double_escape', permission = 'bash' }) + permission_window._dialog:dismiss() + permission_window._dialog:dismiss() + + assert.stub(deny).was_called(1) + assert.is_nil(permission_window.get_current_permission()) + deny:revert() + end) + + it('removes the permission escape mapping with its dialog', function() + permission_window.add_permission({ id = 'per_mapping', permission = 'bash' }) + assert.is_not_nil(vim.fn.maparg('', 'n', false, true).callback) + + permission_window.clear_all() + + assert.is_nil(vim.fn.maparg('', 'n', false, true).callback) + end) + end) end) diff --git a/tests/unit/services_session_runtime_spec.lua b/tests/unit/services_session_runtime_spec.lua index 694f4ab2..9cebc615 100644 --- a/tests/unit/services_session_runtime_spec.lua +++ b/tests/unit/services_session_runtime_spec.lua @@ -409,6 +409,29 @@ describe('opencode.services.session_runtime', function() end) + describe('cancel', function() + after_each(function() + state.renderer.set_pending_permissions({}) + vim.g.opencode_abort_count = nil + end) + + it('rejects pending permissions with the reply payload expected by the API', function() + local replies = {} + state.session.set_active({ id = 'session_with_permission' }) + state.renderer.set_pending_permissions({ { id = 'per_cancel' } }) + vim.g.opencode_abort_count = 0 + state.api_client.reply_to_permission = function(_, permission_id, payload) + table.insert(replies, { permission_id = permission_id, payload = payload }) + end + + session_runtime.cancel():wait() + + assert.same({ + { permission_id = 'per_cancel', payload = { reply = 'reject' } }, + }, replies) + end) + end) + describe('child session UI guards', function() local input_window = require('opencode.ui.input_window')