diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 2fd5e043e76bb26dfccc96199cd7a740863dbd5a..1969c45c44e2546da4baaaa4931724eeda32f1a9 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -2126,6 +2126,20 @@ impl AcpThreadView { }; } + fn authorize_subagent_tool_call( + &mut self, + subagent_thread: Entity, + tool_call_id: acp::ToolCallId, + option_id: acp::PermissionOptionId, + option_kind: acp::PermissionOptionKind, + _window: &mut Window, + cx: &mut Context, + ) { + subagent_thread.update(cx, |thread, cx| { + thread.authorize_tool_call(tool_call_id, option_id, option_kind, cx); + }); + } + fn restore_checkpoint(&mut self, message_id: &UserMessageId, cx: &mut Context) { if let Some(active) = self.as_active_thread_mut() { active.restore_checkpoint(message_id, cx); @@ -3497,6 +3511,25 @@ impl AcpThreadView { self.render_subagent_expanded_content(entry_ix, context_ix, thread, window, cx), ) }) + .children( + thread_read + .first_tool_awaiting_confirmation() + .and_then(|tc| { + if let ToolCallStatus::WaitingForConfirmation { options, .. } = &tc.status { + Some(self.render_subagent_pending_tool_call( + entry_ix, + context_ix, + thread.clone(), + tc, + options, + window, + cx, + )) + } else { + None + } + }), + ) .into_any_element() } @@ -4068,6 +4101,360 @@ impl AcpThreadView { })) } + fn render_subagent_pending_tool_call( + &self, + entry_ix: usize, + context_ix: usize, + subagent_thread: Entity, + tool_call: &ToolCall, + options: &PermissionOptions, + window: &Window, + cx: &Context, + ) -> Div { + let tool_call_id = tool_call.id.clone(); + let is_edit = + matches!(tool_call.kind, acp::ToolKind::Edit) || tool_call.diffs().next().is_some(); + let has_image_content = tool_call.content.iter().any(|c| c.image().is_some()); + + v_flex() + .w_full() + .border_t_1() + .border_color(self.tool_card_border_color(cx)) + .child( + self.render_tool_call_label( + entry_ix, tool_call, is_edit, false, // has_failed + false, // has_revealed_diff + true, // use_card_layout + window, cx, + ) + .py_1(), + ) + .children( + tool_call + .content + .iter() + .enumerate() + .map(|(content_ix, content)| { + self.render_tool_call_content( + entry_ix, + content, + content_ix, + tool_call, + true, // card_layout + has_image_content, + false, // has_failed + window, + cx, + ) + }), + ) + .child(self.render_subagent_permission_buttons( + entry_ix, + context_ix, + subagent_thread, + tool_call_id, + options, + cx, + )) + } + + fn render_subagent_permission_buttons( + &self, + entry_ix: usize, + context_ix: usize, + subagent_thread: Entity, + tool_call_id: acp::ToolCallId, + options: &PermissionOptions, + cx: &Context, + ) -> Div { + match options { + PermissionOptions::Flat(options) => self.render_subagent_permission_buttons_flat( + entry_ix, + context_ix, + subagent_thread, + tool_call_id, + options, + cx, + ), + PermissionOptions::Dropdown(options) => self + .render_subagent_permission_buttons_dropdown( + entry_ix, + context_ix, + subagent_thread, + tool_call_id, + options, + cx, + ), + } + } + + fn render_subagent_permission_buttons_flat( + &self, + entry_ix: usize, + context_ix: usize, + subagent_thread: Entity, + tool_call_id: acp::ToolCallId, + options: &[acp::PermissionOption], + cx: &Context, + ) -> Div { + div() + .p_1() + .border_t_1() + .border_color(self.tool_card_border_color(cx)) + .w_full() + .v_flex() + .gap_0p5() + .children(options.iter().map(move |option| { + let option_id = SharedString::from(format!( + "subagent-{}-{}-{}", + entry_ix, context_ix, option.option_id.0 + )); + 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 + | 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 subagent_thread = subagent_thread.clone(); + let tool_call_id = tool_call_id.clone(); + let option_id = option.option_id.clone(); + let option_kind = option.kind; + move |this, _, window, cx| { + this.authorize_subagent_tool_call( + subagent_thread.clone(), + tool_call_id.clone(), + option_id.clone(), + option_kind, + window, + cx, + ); + } + })) + })) + } + + fn render_subagent_permission_buttons_dropdown( + &self, + entry_ix: usize, + context_ix: usize, + subagent_thread: Entity, + tool_call_id: acp::ToolCallId, + choices: &[PermissionOptionChoice], + cx: &Context, + ) -> Div { + let selected_index = if let Some(active) = self.as_active_thread() { + active + .selected_permission_granularity + .get(&tool_call_id) + .copied() + .unwrap_or_else(|| choices.len().saturating_sub(1)) + } else { + choices.len().saturating_sub(1) + }; + + let selected_choice = choices.get(selected_index).or(choices.last()); + + let dropdown_label: SharedString = selected_choice + .map(|choice| choice.label()) + .unwrap_or_else(|| "Only this time".into()); + + let (allow_option_id, allow_option_kind, deny_option_id, deny_option_kind) = + if let Some(choice) = selected_choice { + ( + choice.allow.option_id.clone(), + choice.allow.kind, + choice.deny.option_id.clone(), + choice.deny.kind, + ) + } else { + ( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + acp::PermissionOptionId::new("deny"), + acp::PermissionOptionKind::RejectOnce, + ) + }; + + h_flex() + .w_full() + .p_1() + .gap_2() + .justify_between() + .border_t_1() + .border_color(self.tool_card_border_color(cx)) + .child( + h_flex() + .gap_0p5() + .child( + Button::new( + ( + SharedString::from(format!( + "subagent-allow-btn-{}-{}", + entry_ix, context_ix + )), + entry_ix, + ), + "Allow", + ) + .icon(IconName::Check) + .icon_color(Color::Success) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .label_size(LabelSize::Small) + .on_click(cx.listener({ + let subagent_thread = subagent_thread.clone(); + let tool_call_id = tool_call_id.clone(); + let option_id = allow_option_id; + let option_kind = allow_option_kind; + move |this, _, window, cx| { + this.authorize_subagent_tool_call( + subagent_thread.clone(), + tool_call_id.clone(), + option_id.clone(), + option_kind, + window, + cx, + ); + } + })), + ) + .child( + Button::new( + ( + SharedString::from(format!( + "subagent-deny-btn-{}-{}", + entry_ix, context_ix + )), + entry_ix, + ), + "Deny", + ) + .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 = deny_option_id; + let option_kind = deny_option_kind; + move |this, _, window, cx| { + this.authorize_subagent_tool_call( + subagent_thread.clone(), + tool_call_id.clone(), + option_id.clone(), + option_kind, + window, + cx, + ); + } + })), + ), + ) + .child(self.render_subagent_permission_granularity_dropdown( + choices, + dropdown_label, + entry_ix, + context_ix, + tool_call_id, + selected_index, + cx, + )) + } + + fn render_subagent_permission_granularity_dropdown( + &self, + choices: &[PermissionOptionChoice], + current_label: SharedString, + entry_ix: usize, + context_ix: usize, + tool_call_id: acp::ToolCallId, + selected_index: usize, + _cx: &Context, + ) -> AnyElement { + let menu_options: Vec<(usize, SharedString)> = choices + .iter() + .enumerate() + .map(|(i, choice)| (i, choice.label())) + .collect(); + + let permission_dropdown_handle = match &self.thread_state { + ThreadState::Active(ActiveThreadState { + permission_dropdown_handle, + .. + }) => permission_dropdown_handle.clone(), + _ => return div().into_any_element(), + }; + + PopoverMenu::new(( + SharedString::from(format!( + "subagent-permission-granularity-{}-{}", + entry_ix, context_ix + )), + entry_ix, + )) + .with_handle(permission_dropdown_handle) + .trigger( + Button::new( + ( + SharedString::from(format!( + "subagent-granularity-trigger-{}-{}", + entry_ix, context_ix + )), + entry_ix, + ), + current_label, + ) + .icon(IconName::ChevronDown) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .label_size(LabelSize::Small), + ) + .menu(move |window, cx| { + let tool_call_id = tool_call_id.clone(); + let options = menu_options.clone(); + + Some(ContextMenu::build(window, cx, move |mut menu, _, _| { + for (index, display_name) in options.iter() { + let display_name = display_name.clone(); + let index = *index; + let tool_call_id_for_entry = tool_call_id.clone(); + let is_selected = index == selected_index; + + menu = menu.toggleable_entry( + display_name, + is_selected, + IconPosition::End, + None, + move |window, cx| { + window.dispatch_action( + SelectPermissionGranularity { + tool_call_id: tool_call_id_for_entry.0.to_string(), + index, + } + .boxed_clone(), + cx, + ); + }, + ); + } + + menu + })) + }) + .into_any_element() + } + fn render_diff_loading(&self, cx: &Context) -> AnyElement { let bar = |n: u64, width_class: &str| { let bg_color = cx.theme().colors().element_active;