feat: Allow inline buttons and popup confirmation for both ACP and normal Providers (#2760)

Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
This commit is contained in:
Carlos Gomes
2025-10-15 12:08:51 +02:00
committed by GitHub
parent 178b62a180
commit cecd0de6fc
9 changed files with 264 additions and 42 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -517,6 +517,10 @@ M._defaults = {
enable_fastapply = false,
include_generated_by_commit_line = false, -- Controls if 'Generated-by: <provider/model>' 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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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)