agent_ui: Subagent permissions (#48005)

Cameron Mcloughlin and Ben Brandt created

Release Notes:

- N/A *or* Added/Fixed/Improved ...

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>

Change summary

crates/agent_ui/src/acp/thread_view.rs | 387 ++++++++++++++++++++++++++++
1 file changed, 387 insertions(+)

Detailed changes

crates/agent_ui/src/acp/thread_view.rs 🔗

@@ -2126,6 +2126,20 @@ impl AcpThreadView {
         };
     }
 
+    fn authorize_subagent_tool_call(
+        &mut self,
+        subagent_thread: Entity<AcpThread>,
+        tool_call_id: acp::ToolCallId,
+        option_id: acp::PermissionOptionId,
+        option_kind: acp::PermissionOptionKind,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        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<Self>) {
         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<AcpThread>,
+        tool_call: &ToolCall,
+        options: &PermissionOptions,
+        window: &Window,
+        cx: &Context<Self>,
+    ) -> 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<AcpThread>,
+        tool_call_id: acp::ToolCallId,
+        options: &PermissionOptions,
+        cx: &Context<Self>,
+    ) -> 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<AcpThread>,
+        tool_call_id: acp::ToolCallId,
+        options: &[acp::PermissionOption],
+        cx: &Context<Self>,
+    ) -> 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<AcpThread>,
+        tool_call_id: acp::ToolCallId,
+        choices: &[PermissionOptionChoice],
+        cx: &Context<Self>,
+    ) -> 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<Self>,
+    ) -> 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<Self>) -> AnyElement {
         let bar = |n: u64, width_class: &str| {
             let bg_color = cx.theme().colors().element_active;