diff --git a/Cargo.lock b/Cargo.lock index 8aaea1a1f81ff18d367cc1448e5c8b146536f8bf..0adb517ad0635d7bd3e59b3b87e2038962a519dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -355,6 +355,7 @@ dependencies = [ "agent_settings", "ai_onboarding", "anyhow", + "arrayvec", "assistant_context", "assistant_slash_command", "assistant_slash_commands", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 6ad2244fa8a4a17d37d76831ec16ef68df8aaf60..700c7e8d8c441aabc11f61ca4de62d2a3f83245e 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -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" } }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 9f13fc30798db2dea34dfbf778a904b6c2301cb3..7c85e6e582a8c0b89586d7ae3ee573271a457b38 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -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" } }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 9d040372c6e808d3de60a2035a561bc535785181..0e9f193bd1a11cd4804878648e4690545fd7ce27 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -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" } }, { diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 085ddfc50de3416142d79eae66db207aa261e536..c7279abdc6d63ff77644549bb64db160abc446bf 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -813,7 +813,6 @@ impl EventEmitter 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 { diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 6c8b9528800041d8920d935a8f75867d03719a9d..eaa058467f44638db4f0a446444424d706f76608 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -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 diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 5c21ab81ea3b03b33173310713fa577756aab761..dc427dfd37ccd84a0d1ae3eacc9eb78ba7c86441 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -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, ) -> 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 = 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) -> AnyElement { @@ -3978,6 +3996,42 @@ impl AcpThreadView { .detach(); } + fn allow_always(&mut self, _: &AllowAlways, window: &mut Window, cx: &mut Context) { + self.authorize_pending_tool_call(acp::PermissionOptionKind::AllowAlways, window, cx); + } + + fn allow_once(&mut self, _: &AllowOnce, window: &mut Window, cx: &mut Context) { + self.authorize_pending_tool_call(acp::PermissionOptionKind::AllowOnce, window, cx); + } + + fn reject_once(&mut self, _: &RejectOnce, window: &mut Window, cx: &mut Context) { + 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, + ) -> 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) -> Option { 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 { diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 67330e5ea05349b9a36866a3297e29a1863f7551..09d2179fc3a2ec4ff4288da4062365c51ad4444f 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -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.