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:
co-authored by
Agus Zubiaga
parent
df2d097dc5
commit
2cf3def716
Generated
+1
@@ -355,6 +355,7 @@ dependencies = [
|
||||
"agent_settings",
|
||||
"ai_onboarding",
|
||||
"anyhow",
|
||||
"arrayvec",
|
||||
"assistant_context",
|
||||
"assistant_slash_command",
|
||||
"assistant_slash_commands",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user