diff --git a/README.md b/README.md index df5a8fd..1ce6527 100644 --- a/README.md +++ b/README.md @@ -505,6 +505,8 @@ _See [config.lua#L9](./lua/avante/config.lua) for the full config_ -- Examples: -- auto_approve_tool_permissions = false, -- Show permission prompts for all tools -- auto_approve_tool_permissions = {"bash", "replace_in_file"}, -- Auto-approve specific tools only + ---@type "popup" | "inline_buttons" + confirmation_ui_style = "inline_buttons", }, prompt_logger = { -- logs prompts to disk (timestamped, for replay/debugging) enabled = true, -- toggle logging entirely diff --git a/README_zh.md b/README_zh.md index ffefb0e..cede9ef 100644 --- a/README_zh.md +++ b/README_zh.md @@ -363,6 +363,8 @@ _请参见 [config.lua#L9](./lua/avante/config.lua) 以获取完整配置_ auto_add_current_file = true, -- 打开新聊天时是否自动添加当前文件。默认为 true。 enable_cursor_planning_mode = false, -- 是否启用 Cursor 规划模式。默认为 false。 enable_claude_text_editor_tool_mode = false, -- 是否启用 Claude 文本编辑器工具模式。 + ---@type "popup" | "inline_buttons" + confirmation_ui_style = "inline_buttons", }, mappings = { --- @class AvanteConflictMappings diff --git a/lua/avante/config.lua b/lua/avante/config.lua index 93e8604..a7ed1a9 100644 --- a/lua/avante/config.lua +++ b/lua/avante/config.lua @@ -517,6 +517,10 @@ M._defaults = { enable_fastapply = false, include_generated_by_commit_line = false, -- Controls if 'Generated-by: ' line is added to git commit message auto_add_current_file = true, -- Whether to automatically add the current file when opening a new chat + --- popup is the original yes,all,no in a floating window + --- inline_buttons is the new inline buttons in the sidebar + ---@type "popup" | "inline_buttons" + confirmation_ui_style = "inline_buttons", }, prompt_logger = { -- logs prompts to disk (timestamped, for replay/debugging) enabled = true, -- toggle logging entirely diff --git a/lua/avante/llm.lua b/lua/avante/llm.lua index c010b91..54962dc 100644 --- a/lua/avante/llm.lua +++ b/lua/avante/llm.lua @@ -14,7 +14,8 @@ local Providers = require("avante.providers") local LLMToolHelpers = require("avante.llm_tools.helpers") local LLMTools = require("avante.llm_tools") local History = require("avante.history") -local Highlights = require("avante.highlights") +local HistoryRender = require("avante.history.render") +local ACPConfirmAdapter = require("avante.ui.acp_confirm_adapter") ---@class avante.LLM local M = {} @@ -49,10 +50,9 @@ function M.summarize_memory(prev_memory, history_messages, cb) cb(nil) return end - local Render = require("avante.history.render") local conversation_items = vim .iter(history_messages) - :map(function(msg) return msg.message.role .. ": " .. Render.message_to_text(msg, history_messages) end) + :map(function(msg) return msg.message.role .. ": " .. HistoryRender.message_to_text(msg, history_messages) end) :totable() local conversation_text = table.concat(conversation_items, "\n") local user_prompt = "Here is the conversation so far:\n" @@ -1040,33 +1040,9 @@ function M._stream_acp(opts) Utils.error("Avante sidebar not found") return end + ---@cast tool_call avante.acp.ToolCall - local items = vim - .iter(options) - :map(function(item) - local icon = item.kind == "allow_once" and "" or "" - if item.kind == "allow_always" then icon = "" end - local hl = nil - if item.kind == "reject_once" or item.kind == "reject_always" then - hl = Highlights.BUTTON_DANGER_HOVER - end - return { - id = item.optionId, - name = item.name, - icon = icon, - hl = hl, - } - end) - :totable() - sidebar.permission_button_options = items - sidebar.permission_handler = function(id) - callback(id) - sidebar.scroll = true - sidebar.permission_button_options = nil - sidebar.permission_handler = nil - sidebar._history_cache_invalidated = true - sidebar:update_content("") - end + local message = tool_call_messages[tool_call.toolCallId] if not message then message = add_tool_call_message(tool_call) @@ -1076,7 +1052,29 @@ function M._stream_acp(opts) message.acp_tool_call = vim.tbl_deep_extend("force", message.acp_tool_call, tool_call) end end + on_messages_add({ message }) + + local description = HistoryRender.get_tool_display_name(message) + LLMToolHelpers.confirm(description, function(ok) + local acp_mapped_options = ACPConfirmAdapter.map_acp_options(options) + + if ok and opts.session_ctx and opts.session_ctx.always_yes then + callback(acp_mapped_options.all) + elseif ok then + callback(acp_mapped_options.yes) + else + callback(acp_mapped_options.no) + end + + sidebar.scroll = true + sidebar._history_cache_invalidated = true + sidebar:update_content("") + end, { + focus = true, + skip_reject_prompt = true, + permission_options = options, + }, opts.session_ctx, tool_call.kind) end, on_read_file = function(path, line, limit, callback) local abs_path = Utils.to_absolute_path(path) diff --git a/lua/avante/llm_tools/helpers.lua b/lua/avante/llm_tools/helpers.lua index 3f0be92..3e9b684 100644 --- a/lua/avante/llm_tools/helpers.lua +++ b/lua/avante/llm_tools/helpers.lua @@ -1,5 +1,7 @@ local Utils = require("avante.utils") local Path = require("plenary.path") +local Config = require("avante.config") +local ACPConfirmAdapter = require("avante.ui.acp_confirm_adapter") local M = {} @@ -19,9 +21,34 @@ function M.get_abs_path(rel_path) return p end +---@type avante.acp.PermissionOption[] +local default_permission_options = { + { optionId = "allow_always", name = "Allow Always", kind = "allow_always" }, + { optionId = "allow_once", name = "Allow", kind = "allow_once" }, + { optionId = "reject_once", name = "Reject", kind = "reject_once" }, +} + +---@param callback fun(option_id: string) +---@param confirm_opts avante.ui.ConfirmOptions +function M.confirm_inline(callback, confirm_opts) + local sidebar = require("avante").get() + local items = + ACPConfirmAdapter.generate_buttons_for_acp_options(confirm_opts.permission_options or default_permission_options) + + sidebar.permission_button_options = items + sidebar.permission_handler = function(id) + callback(id) + sidebar.scroll = true + sidebar.permission_button_options = nil + sidebar.permission_handler = nil + sidebar._history_cache_invalidated = true + sidebar:update_content("") + end +end + ---@param message string ----@param callback fun(yes: boolean, reason?: string) ----@param confirm_opts? { focus?: boolean } +---@param callback fun(response: boolean, reason?: string) +---@param confirm_opts? avante.ui.ConfirmOptions ---@param session_ctx? table ---@param tool_name? string -- Optional tool name to check against tool_permissions config ---@return avante.ui.Confirm | nil @@ -32,7 +59,6 @@ function M.confirm(message, callback, confirm_opts, session_ctx, tool_name) end -- Check behaviour.auto_approve_tool_permissions config for auto-approval - local Config = require("avante.config") local auto_approve = Config.behaviour.auto_approve_tool_permissions -- If auto_approve is true, auto-approve all tools @@ -46,6 +72,20 @@ function M.confirm(message, callback, confirm_opts, session_ctx, tool_name) callback(true) return end + + if Config.behaviour.confirmation_ui_style == "inline_buttons" then + M.confirm_inline(function(option_id) + if option_id == "allow" or option_id == "allow_once" or option_id == "allow_always" then + if option_id == "allow_always" and session_ctx then session_ctx.always_yes = true end + + callback(true) + else + callback(false, option_id) + end + end, confirm_opts or {}) + return + end + local Confirm = require("avante.ui.confirm") local sidebar = require("avante").get() if not sidebar or not sidebar.containers.input or not sidebar.containers.input.winid then diff --git a/lua/avante/types.lua b/lua/avante/types.lua index c03a5af..4b37c50 100644 --- a/lua/avante/types.lua +++ b/lua/avante/types.lua @@ -118,8 +118,6 @@ vim.g.avante_login = vim.g.avante_login ---@field is_calling boolean | nil ---@field original_content AvanteLLMMessageContent | nil ---@field acp_tool_call? avante.acp.ToolCall ----@field permission_options? avante.acp.PermissionOption[] ----@field is_permission_confirming? boolean ---@class AvanteLLMToolResult ---@field tool_name string diff --git a/lua/avante/ui/acp_confirm_adapter.lua b/lua/avante/ui/acp_confirm_adapter.lua new file mode 100644 index 0000000..64b2936 --- /dev/null +++ b/lua/avante/ui/acp_confirm_adapter.lua @@ -0,0 +1,65 @@ +local Highlights = require("avante.highlights") + +---@class avante.ui.ConfirmAdapter +local M = {} + +---@class avante.ui.ACPConfirmAdapter.ACPMappedOptions +---@field yes? string +---@field all? string +---@field no? string + +---Converts the ACP permission options to confirmation popup-compatible format (yes/all/no) +---@param options avante.acp.PermissionOption[] +---@return avante.ui.ACPConfirmAdapter.ACPMappedOptions +function M.map_acp_options(options) + local option_map = { yes = nil, all = nil, no = nil } + + for _, opt in ipairs(options) do + if opt.kind == "allow_once" then + option_map.yes = opt.optionId + elseif opt.kind == "allow_always" then + option_map.all = opt.optionId + elseif opt.kind == "reject_once" then + option_map.no = opt.optionId + + -- elseif opt.kind == "reject_always" then + -- ignore, no 4th option in the confirm popup yet + end + end + + return option_map +end + +---@class avante.ui.ACPConfirmAdapter.ButtonOption +---@field id string +---@field icon string +---@field name string +---@field hl? string + +---@param options avante.acp.PermissionOption[] +---@return avante.ui.ACPConfirmAdapter.ButtonOption[] +function M.generate_buttons_for_acp_options(options) + local items = vim + .iter(options) + :map(function(item) + ---@cast item avante.acp.PermissionOption + local icon = item.kind == "allow_once" and "" or "" + if item.kind == "allow_always" then icon = "" end + local hl = nil + if item.kind == "reject_once" or item.kind == "reject_always" then hl = Highlights.BUTTON_DANGER_HOVER end + ---@type avante.ui.ACPConfirmAdapter.ButtonOption + local button = { + id = item.optionId, + name = item.name, + icon = icon, + hl = hl, + } + return button + end) + :totable() + -- Sort to have "allow" first, then "allow always", then "reject" + table.sort(items, function(a, b) return a.name < b.name end) + return items +end + +return M diff --git a/lua/avante/ui/confirm.lua b/lua/avante/ui/confirm.lua index 143b7cf..edd45de 100644 --- a/lua/avante/ui/confirm.lua +++ b/lua/avante/ui/confirm.lua @@ -15,19 +15,27 @@ local Config = require("avante.config") ---@field _popup NuiPopup | nil ---@field _prev_winid number | nil ---@field _ns_id number | nil +---@field _skip_reject_prompt boolean | nil local M = {} M.__index = M +---@class avante.ui.ConfirmOptions +---@field container_winid? number +---@field focus? boolean | nil +---@field skip_reject_prompt? boolean ACP doesn't support reject reason +---@field permission_options? avante.acp.PermissionOption[] ACP permission options to show in the confirm popup + ---@param message string ---@param callback fun(type: "yes" | "all" | "no", reason?: string) ----@param opts { container_winid: number, focus?: boolean } +---@param opts avante.ui.ConfirmOptions ---@return avante.ui.Confirm function M:new(message, callback, opts) local this = setmetatable({}, M) - this.message = message + this.message = message or "" this.callback = callback this._container_winid = opts.container_winid or vim.api.nvim_get_current_win() this._focus = opts.focus + this._skip_reject_prompt = opts.skip_reject_prompt this._ns_id = vim.api.nvim_create_namespace("avante_confirm") return this end @@ -35,12 +43,12 @@ end function M:open() if self._popup then return end self._prev_winid = vim.api.nvim_get_current_win() - local message = self.message + local message = self.message or "" local callback = self.callback local win_width = 60 - local focus_index = 3 -- 1 = Yes, 2 = All Yes, 3 = No + local focus_index = 1 -- 1 = Yes, 2 = All Yes, 3 = No local BUTTON_NORMAL = Highlights.BUTTON_DEFAULT local BUTTON_FOCUS = Highlights.BUTTON_DEFAULT_HOVER @@ -61,21 +69,26 @@ function M:open() { " - input ", commentfg }, { " " }, }) + local buttons_line = Line:new({ - { " [Y]es ", function() return focus_index == 1 and BUTTON_FOCUS or BUTTON_NORMAL end }, + { "  [Y]es ", function() return focus_index == 1 and BUTTON_FOCUS or BUTTON_NORMAL end }, { " " }, - { " [A]ll yes ", function() return focus_index == 2 and BUTTON_FOCUS or BUTTON_NORMAL end }, + { "  [A]ll yes ", function() return focus_index == 2 and BUTTON_FOCUS or BUTTON_NORMAL end }, { " " }, - { " [N]o ", function() return focus_index == 3 and BUTTON_FOCUS or BUTTON_NORMAL end }, + { "  [N]o ", function() return focus_index == 3 and BUTTON_FOCUS or BUTTON_NORMAL end }, }) + local buttons_content = tostring(buttons_line) local buttons_start_col = math.floor((win_width - #buttons_content) / 2) + local yes_button_pos = buttons_line:get_section_pos(1, buttons_start_col) local all_button_pos = buttons_line:get_section_pos(3, buttons_start_col) local no_button_pos = buttons_line:get_section_pos(5, buttons_start_col) + local buttons_line_content = string.rep(" ", buttons_start_col) .. buttons_content local keybindings_line_num = 5 + #vim.split(message, "\n") local buttons_line_num = 2 + #vim.split(message, "\n") + local content = vim .iter({ "", @@ -157,12 +170,19 @@ function M:open() callback("yes") return end + if focus_index == 2 then self:close() - Utils.notify("Accept all") callback("all") return end + + if self._skip_reject_prompt then + self:close() + callback("no") + return + end + local prompt_input = PromptInput:new({ submit_callback = function(input) self:close() diff --git a/tests/ui/acp_confirm_adapter_spec.lua b/tests/ui/acp_confirm_adapter_spec.lua new file mode 100644 index 0000000..0fd2d0f --- /dev/null +++ b/tests/ui/acp_confirm_adapter_spec.lua @@ -0,0 +1,93 @@ +local ACPConfirmAdapter = require("avante.ui.acp_confirm_adapter") + +describe("ACPConfirmAdapter", function() + describe("map_acp_options", function() + it("should ignore reject_always", function() + local options = { { kind = "reject_always", optionId = "opt4" } } + local result = ACPConfirmAdapter.map_acp_options(options) + assert.is_nil(result.yes) + assert.is_nil(result.all) + assert.is_nil(result.no) + end) + + it("should map multiple options correctly", function() + local options = { + { kind = "allow_once", optionId = "yes_id" }, + { kind = "allow_always", optionId = "all_id" }, + { kind = "reject_once", optionId = "no_id" }, + { kind = "reject_always", optionId = "ignored_id" }, + } + local result = ACPConfirmAdapter.map_acp_options(options) + assert.equals("yes_id", result.yes) + assert.equals("all_id", result.all) + assert.equals("no_id", result.no) + end) + + it("should handle empty options", function() + local options = {} + local result = ACPConfirmAdapter.map_acp_options(options) + assert.is_nil(result.yes) + assert.is_nil(result.all) + assert.is_nil(result.no) + end) + end) + + describe("generate_buttons_for_acp_options", function() + it("should generate buttons with correct properties for each option kind", function() + local options = { + { kind = "allow_once", optionId = "opt1", name = "Allow" }, + { kind = "allow_always", optionId = "opt2", name = "Allow always" }, + { kind = "reject_once", optionId = "opt3", name = "Reject" }, + { kind = "reject_always", optionId = "opt4", name = "Reject always" }, + } + local result = ACPConfirmAdapter.generate_buttons_for_acp_options(options) + assert.equals(4, #result) + + for _, button in ipairs(result) do + assert.is_not_nil(button.id) + assert.is_not_nil(button.name) + assert.is_not_nil(button.icon) + assert.is_string(button.icon) + + if button.name == "Reject" or button.name == "Reject always" then + assert.is_not_nil(button.hl) + else + assert.is_nil(button.hl) + end + end + end) + + it("should handle multiple options and sort by name", function() + local options = { + { kind = "reject_once", optionId = "opt3", name = "Reject" }, + { kind = "allow_once", optionId = "opt1", name = "Allow" }, + { kind = "allow_always", optionId = "opt2", name = "Allow always" }, + } + local result = ACPConfirmAdapter.generate_buttons_for_acp_options(options) + assert.equals(3, #result) + assert.equals("Allow", result[1].name) + assert.equals("Allow always", result[2].name) + assert.equals("Reject", result[3].name) + end) + + it("should handle empty options", function() + local options = {} + local result = ACPConfirmAdapter.generate_buttons_for_acp_options(options) + assert.equals(0, #result) + end) + + it("should preserve all button properties", function() + local options = { + { kind = "allow_once", optionId = "id1", name = "Button 1" }, + { kind = "reject_once", optionId = "id2", name = "Button 2" }, + } + local result = ACPConfirmAdapter.generate_buttons_for_acp_options(options) + assert.equals(2, #result) + for _, button in ipairs(result) do + assert.is_not_nil(button.id) + assert.is_not_nil(button.name) + assert.is_not_nil(button.icon) + end + end) + end) +end)