acp: Add keybindings for authorizing tool calls (#37876)

TODO:
- [x] Double-check if we like the naming of the new actions
- [x] Only show keybinding hint once per option (e.g. if there are two
`allow_once` buttons only show it on the first one)
- [x] If there are multiple tool calls that need authorisation, only
show keybindings on the first tool call
- [x] Figure out which keybindings to use
- [x] Add linux keybindings
- [x] Add windows keybindings
- [x] Bug: long keybindings can make the buttons overflow


Release Notes:

- Add keybindings for authorizing tool calls (`agent: Allow once`,
`agent: Allow always`, `agent: Reject once`) in the agent panel

---------

Co-authored-by: Agus Zubiaga <agus@zed.dev>
This commit is contained in:
Bennet Bo Fenner
2025-09-10 13:23:46 +00:00
committed by GitHub
co-authored by Agus Zubiaga
parent df2d097dc5
commit 2cf3def716
8 changed files with 151 additions and 81 deletions
Generated
+1
View File
@@ -355,6 +355,7 @@ dependencies = [
"agent_settings",
"ai_onboarding",
"anyhow",
"arrayvec",
"assistant_context",
"assistant_slash_command",
"assistant_slash_commands",
+4 -1
View File
@@ -247,7 +247,10 @@
"ctrl-shift-e": "project_panel::ToggleFocus",
"ctrl-shift-enter": "agent::ContinueThread",
"super-ctrl-b": "agent::ToggleBurnMode",
"alt-enter": "agent::ContinueWithBurnMode"
"alt-enter": "agent::ContinueWithBurnMode",
"ctrl-y": "agent::AllowOnce",
"ctrl-alt-y": "agent::AllowAlways",
"ctrl-d": "agent::RejectOnce"
}
},
{
+5 -2
View File
@@ -218,7 +218,7 @@
}
},
{
"context": "Editor && !agent_diff",
"context": "Editor && !agent_diff && !AgentPanel",
"use_key_equivalents": true,
"bindings": {
"cmd-alt-z": "git::Restore",
@@ -286,7 +286,10 @@
"cmd-shift-e": "project_panel::ToggleFocus",
"cmd-ctrl-b": "agent::ToggleBurnMode",
"cmd-shift-enter": "agent::ContinueThread",
"alt-enter": "agent::ContinueWithBurnMode"
"alt-enter": "agent::ContinueWithBurnMode",
"cmd-y": "agent::AllowOnce",
"cmd-alt-y": "agent::AllowAlways",
"cmd-d": "agent::RejectOnce"
}
},
{
+4 -1
View File
@@ -249,7 +249,10 @@
"ctrl-shift-e": "project_panel::ToggleFocus",
"ctrl-shift-enter": "agent::ContinueThread",
"super-ctrl-b": "agent::ToggleBurnMode",
"alt-enter": "agent::ContinueWithBurnMode"
"alt-enter": "agent::ContinueWithBurnMode",
"ctrl-y": "agent::AllowOnce",
"ctrl-alt-y": "agent::AllowAlways",
"ctrl-d": "agent::RejectOnce"
}
},
{
+16 -20
View File
@@ -813,7 +813,6 @@ impl EventEmitter<AcpThreadEvent> for AcpThread {}
#[derive(PartialEq, Eq, Debug)]
pub enum ThreadStatus {
Idle,
WaitingForToolConfirmation,
Generating,
}
@@ -936,11 +935,7 @@ impl AcpThread {
pub fn status(&self) -> ThreadStatus {
if self.send_task.is_some() {
if self.waiting_for_tool_confirmation() {
ThreadStatus::WaitingForToolConfirmation
} else {
ThreadStatus::Generating
}
ThreadStatus::Generating
} else {
ThreadStatus::Idle
}
@@ -1382,26 +1377,27 @@ impl AcpThread {
cx.emit(AcpThreadEvent::EntryUpdated(ix));
}
/// Returns true if the last turn is awaiting tool authorization
pub fn waiting_for_tool_confirmation(&self) -> bool {
pub fn first_tool_awaiting_confirmation(&self) -> Option<&ToolCall> {
let mut first_tool_call = None;
for entry in self.entries.iter().rev() {
match &entry {
AgentThreadEntry::ToolCall(call) => match call.status {
ToolCallStatus::WaitingForConfirmation { .. } => return true,
ToolCallStatus::Pending
| ToolCallStatus::InProgress
| ToolCallStatus::Completed
| ToolCallStatus::Failed
| ToolCallStatus::Rejected
| ToolCallStatus::Canceled => continue,
},
AgentThreadEntry::ToolCall(call) => {
if let ToolCallStatus::WaitingForConfirmation { .. } = call.status {
first_tool_call = Some(call);
} else {
continue;
}
}
AgentThreadEntry::UserMessage(_) | AgentThreadEntry::AssistantMessage(_) => {
// Reached the beginning of the turn
return false;
// Reached the beginning of the turn.
// If we had pending permission requests in the previous turn, they have been cancelled.
break;
}
}
}
false
first_tool_call
}
pub fn plan(&self) -> &Plan {
+1
View File
@@ -25,6 +25,7 @@ agent_servers.workspace = true
agent_settings.workspace = true
ai_onboarding.workspace = true
anyhow.workspace = true
arrayvec.workspace = true
assistant_context.workspace = true
assistant_slash_command.workspace = true
assistant_slash_commands.workspace = true
+114 -57
View File
@@ -10,6 +10,7 @@ use agent_servers::{AgentServer, AgentServerDelegate};
use agent_settings::{AgentProfileId, AgentSettings, CompletionMode, NotifyWhenAgentWaiting};
use agent2::{DbThreadMetadata, HistoryEntry, HistoryEntryId, HistoryStore, NativeAgentServer};
use anyhow::{Context as _, Result, anyhow, bail};
use arrayvec::ArrayVec;
use audio::{Audio, Sound};
use buffer_diff::BufferDiff;
use client::zed_urls;
@@ -65,9 +66,9 @@ use crate::ui::{
AgentNotification, AgentNotificationEvent, BurnModeTooltip, UnavailableEditingTooltip,
};
use crate::{
AgentDiffPane, AgentPanel, ContinueThread, ContinueWithBurnMode, CycleModeSelector,
ExpandMessageEditor, Follow, KeepAll, OpenAgentDiff, OpenHistory, RejectAll, ToggleBurnMode,
ToggleProfileSelector,
AgentDiffPane, AgentPanel, AllowAlways, AllowOnce, ContinueThread, ContinueWithBurnMode,
CycleModeSelector, ExpandMessageEditor, Follow, KeepAll, OpenAgentDiff, OpenHistory, RejectAll,
RejectOnce, ToggleBurnMode, ToggleProfileSelector,
};
pub const MIN_EDITOR_LINES: usize = 4;
@@ -2167,6 +2168,7 @@ impl AcpThreadView {
options,
entry_ix,
tool_call.id.clone(),
window,
cx,
))
.into_any(),
@@ -2469,69 +2471,85 @@ impl AcpThreadView {
options: &[acp::PermissionOption],
entry_ix: usize,
tool_call_id: acp::ToolCallId,
window: &Window,
cx: &Context<Self>,
) -> Div {
h_flex()
.py_1()
.px_1()
.gap_1()
.justify_between()
.flex_wrap()
let is_first = self.thread().is_some_and(|thread| {
thread
.read(cx)
.first_tool_awaiting_confirmation()
.is_some_and(|call| call.id == tool_call_id)
});
let mut seen_kinds: ArrayVec<acp::PermissionOptionKind, 3> = ArrayVec::new();
div()
.p_1()
.border_t_1()
.border_color(self.tool_card_border_color(cx))
.when(kind != acp::ToolKind::SwitchMode, |this| {
this.pl_2().child(
div().min_w(rems_from_px(145.)).child(
LoadingLabel::new("Waiting for Confirmation").size(LabelSize::Small),
),
)
.w_full()
.map(|this| {
if kind == acp::ToolKind::SwitchMode {
this.v_flex()
} else {
this.h_flex().justify_end().flex_wrap()
}
})
.child({
div()
.gap_0p5()
.children(options.iter().map(move |option| {
let option_id = SharedString::from(option.id.0.clone());
Button::new((option_id, entry_ix), option.name.clone())
.map(|this| {
if kind == acp::ToolKind::SwitchMode {
this.w_full().v_flex()
} else {
this.h_flex()
let (this, action) = match option.kind {
acp::PermissionOptionKind::AllowOnce => (
this.icon(IconName::Check).icon_color(Color::Success),
Some(&AllowOnce as &dyn Action),
),
acp::PermissionOptionKind::AllowAlways => (
this.icon(IconName::CheckDouble).icon_color(Color::Success),
Some(&AllowAlways as &dyn Action),
),
acp::PermissionOptionKind::RejectOnce => (
this.icon(IconName::Close).icon_color(Color::Error),
Some(&RejectOnce as &dyn Action),
),
acp::PermissionOptionKind::RejectAlways => {
(this.icon(IconName::Close).icon_color(Color::Error), None)
}
};
let Some(action) = action else {
return this;
};
if !is_first || seen_kinds.contains(&option.kind) {
return this;
}
seen_kinds.push(option.kind);
this.key_binding(
KeyBinding::for_action_in(action, &self.focus_handle, window, cx)
.map(|kb| kb.size(rems_from_px(10.))),
)
})
.gap_0p5()
.children(options.iter().map(|option| {
let option_id = SharedString::from(option.id.0.clone());
Button::new((option_id, entry_ix), option.name.clone())
.map(|this| match option.kind {
acp::PermissionOptionKind::AllowOnce => {
this.icon(IconName::Check).icon_color(Color::Success)
}
acp::PermissionOptionKind::AllowAlways => {
this.icon(IconName::CheckDouble).icon_color(Color::Success)
}
acp::PermissionOptionKind::RejectOnce => {
this.icon(IconName::Close).icon_color(Color::Error)
}
acp::PermissionOptionKind::RejectAlways => {
this.icon(IconName::Close).icon_color(Color::Error)
}
})
.icon_position(IconPosition::Start)
.icon_size(IconSize::XSmall)
.label_size(LabelSize::Small)
.on_click(cx.listener({
let tool_call_id = tool_call_id.clone();
let option_id = option.id.clone();
let option_kind = option.kind;
move |this, _, window, cx| {
this.authorize_tool_call(
tool_call_id.clone(),
option_id.clone(),
option_kind,
window,
cx,
);
}
}))
.icon_position(IconPosition::Start)
.icon_size(IconSize::XSmall)
.label_size(LabelSize::Small)
.on_click(cx.listener({
let tool_call_id = tool_call_id.clone();
let option_id = option.id.clone();
let option_kind = option.kind;
move |this, _, window, cx| {
this.authorize_tool_call(
tool_call_id.clone(),
option_id.clone(),
option_kind,
window,
cx,
);
}
}))
})
}))
}
fn render_diff_loading(&self, cx: &Context<Self>) -> AnyElement {
@@ -3978,6 +3996,42 @@ impl AcpThreadView {
.detach();
}
fn allow_always(&mut self, _: &AllowAlways, window: &mut Window, cx: &mut Context<Self>) {
self.authorize_pending_tool_call(acp::PermissionOptionKind::AllowAlways, window, cx);
}
fn allow_once(&mut self, _: &AllowOnce, window: &mut Window, cx: &mut Context<Self>) {
self.authorize_pending_tool_call(acp::PermissionOptionKind::AllowOnce, window, cx);
}
fn reject_once(&mut self, _: &RejectOnce, window: &mut Window, cx: &mut Context<Self>) {
self.authorize_pending_tool_call(acp::PermissionOptionKind::RejectOnce, window, cx);
}
fn authorize_pending_tool_call(
&mut self,
kind: acp::PermissionOptionKind,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<()> {
let thread = self.thread()?.read(cx);
let tool_call = thread.first_tool_awaiting_confirmation()?;
let ToolCallStatus::WaitingForConfirmation { options, .. } = &tool_call.status else {
return None;
};
let option = options.iter().find(|o| o.kind == kind)?;
self.authorize_tool_call(
tool_call.id.clone(),
option.id.clone(),
option.kind,
window,
cx,
);
Some(())
}
fn render_burn_mode_toggle(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let thread = self.as_native_thread(cx)?.read(cx);
@@ -5299,6 +5353,9 @@ impl Render for AcpThreadView {
.on_action(cx.listener(Self::toggle_burn_mode))
.on_action(cx.listener(Self::keep_all))
.on_action(cx.listener(Self::reject_all))
.on_action(cx.listener(Self::allow_always))
.on_action(cx.listener(Self::allow_once))
.on_action(cx.listener(Self::reject_once))
.track_focus(&self.focus_handle)
.bg(cx.theme().colors().panel_background)
.child(match &self.thread_state {
+6
View File
@@ -116,6 +116,12 @@ actions!(
RejectAll,
/// Keeps all suggestions or changes.
KeepAll,
/// Allow this operation only this time.
AllowOnce,
/// Allow this operation and remember the choice.
AllowAlways,
/// Reject this operation only this time.
RejectOnce,
/// Follows the agent's suggestions.
Follow,
/// Resets the trial upsell notification.