@@ -612,7 +612,7 @@ impl ToolPermissionContext {
///
/// This is the canonical source for permission option generation.
/// Tests should use this function rather than manually constructing options.
- pub fn build_permission_options(&self) -> Vec<acp::PermissionOption> {
+ pub fn build_permission_options(&self) -> acp_thread::PermissionOptions {
use crate::pattern_extraction::*;
let tool_name = &self.tool_name;
@@ -634,11 +634,30 @@ impl ToolPermissionContext {
_ => (None, None),
};
- let mut options = vec![acp::PermissionOption::new(
- acp::PermissionOptionId::new(format!("always:{}", tool_name)),
+ let mut choices = Vec::new();
+
+ let mut push_choice = |label: String, allow_id, deny_id, allow_kind, deny_kind| {
+ choices.push(acp_thread::PermissionOptionChoice {
+ allow: acp::PermissionOption::new(
+ acp::PermissionOptionId::new(allow_id),
+ label.clone(),
+ allow_kind,
+ ),
+ deny: acp::PermissionOption::new(
+ acp::PermissionOptionId::new(deny_id),
+ label,
+ deny_kind,
+ ),
+ });
+ };
+
+ push_choice(
format!("Always for {}", tool_name.replace('_', " ")),
+ format!("always_allow:{}", tool_name),
+ format!("always_deny:{}", tool_name),
acp::PermissionOptionKind::AllowAlways,
- )];
+ acp::PermissionOptionKind::RejectAlways,
+ );
if let (Some(pattern), Some(display)) = (pattern, pattern_display) {
let button_text = match tool_name.as_str() {
@@ -646,27 +665,31 @@ impl ToolPermissionContext {
"fetch" => format!("Always for `{}`", display),
_ => format!("Always for `{}`", display),
};
- options.push(acp::PermissionOption::new(
- acp::PermissionOptionId::new(format!("always_pattern:{}:{}", tool_name, pattern)),
+ push_choice(
button_text,
+ format!("always_allow_pattern:{}:{}", tool_name, pattern),
+ format!("always_deny_pattern:{}:{}", tool_name, pattern),
acp::PermissionOptionKind::AllowAlways,
- ));
+ acp::PermissionOptionKind::RejectAlways,
+ );
}
- options.push(acp::PermissionOption::new(
- acp::PermissionOptionId::new("once"),
- "Only this time",
+ push_choice(
+ "Only this time".to_string(),
+ "allow".to_string(),
+ "deny".to_string(),
acp::PermissionOptionKind::AllowOnce,
- ));
+ acp::PermissionOptionKind::RejectOnce,
+ );
- options
+ acp_thread::PermissionOptions::Dropdown(choices)
}
}
#[derive(Debug)]
pub struct ToolCallAuthorization {
pub tool_call: acp::ToolCallUpdate,
- pub options: Vec<acp::PermissionOption>,
+ pub options: acp_thread::PermissionOptions,
pub response: oneshot::Sender<acp::PermissionOptionId>,
pub context: Option<ToolPermissionContext>,
}
@@ -2937,10 +2960,9 @@ impl ToolCallEventStream {
/// Unlike built-in tools, third-party tools don't support pattern-based permissions.
/// They only support `default_mode` (allow/deny/confirm) per tool.
///
- /// Shows 3 buttons:
- /// - "Always allow <display_name> MCP tool" → sets `tools.<tool_id>.default_mode = "allow"`
- /// - "Allow" → approve once
- /// - "Deny" → reject once
+ /// Uses the dropdown authorization flow with two granularities:
+ /// - "Always for <display_name> MCP tool" → sets `tools.<tool_id>.default_mode = "allow"` or "deny"
+ /// - "Only this time" → allow/deny once
pub fn authorize_third_party_tool(
&self,
title: impl Into<String>,
@@ -2967,23 +2989,38 @@ impl ToolCallEventStream {
self.tool_use_id.to_string(),
acp::ToolCallUpdateFields::new().title(title.into()),
),
- options: vec![
- acp::PermissionOption::new(
- acp::PermissionOptionId::new(format!("always_allow_mcp:{}", tool_id)),
- format!("Always allow {} MCP tool", display_name),
- acp::PermissionOptionKind::AllowAlways,
- ),
- acp::PermissionOption::new(
- acp::PermissionOptionId::new("allow"),
- "Allow once",
- acp::PermissionOptionKind::AllowOnce,
- ),
- acp::PermissionOption::new(
- acp::PermissionOptionId::new("deny"),
- "Deny",
- acp::PermissionOptionKind::RejectOnce,
- ),
- ],
+ options: acp_thread::PermissionOptions::Dropdown(vec![
+ acp_thread::PermissionOptionChoice {
+ allow: acp::PermissionOption::new(
+ acp::PermissionOptionId::new(format!(
+ "always_allow_mcp:{}",
+ tool_id
+ )),
+ format!("Always for {} MCP tool", display_name),
+ acp::PermissionOptionKind::AllowAlways,
+ ),
+ deny: acp::PermissionOption::new(
+ acp::PermissionOptionId::new(format!(
+ "always_deny_mcp:{}",
+ tool_id
+ )),
+ format!("Always for {} MCP tool", display_name),
+ acp::PermissionOptionKind::RejectAlways,
+ ),
+ },
+ acp_thread::PermissionOptionChoice {
+ allow: acp::PermissionOption::new(
+ acp::PermissionOptionId::new("allow"),
+ "Only this time",
+ acp::PermissionOptionKind::AllowOnce,
+ ),
+ deny: acp::PermissionOption::new(
+ acp::PermissionOptionId::new("deny"),
+ "Only this time",
+ acp::PermissionOptionKind::RejectOnce,
+ ),
+ },
+ ]),
response: response_tx,
context: None,
},
@@ -3007,6 +3044,19 @@ impl ToolCallEventStream {
}
return Ok(());
}
+ if response_str == format!("always_deny_mcp:{}", tool_id) {
+ if let Some(fs) = fs.clone() {
+ cx.update(|cx| {
+ update_settings_file(fs, cx, move |settings, _| {
+ settings
+ .agent
+ .get_or_insert_default()
+ .set_tool_default_mode(&tool_id, ToolPermissionMode::Deny);
+ });
+ });
+ }
+ return Err(anyhow!("Permission to run tool denied by user"));
+ }
if response_str == "allow" {
return Ok(());
@@ -1,7 +1,8 @@
use acp_thread::{
AcpThread, AcpThreadEvent, AgentSessionInfo, AgentThreadEntry, AssistantMessage,
- AssistantMessageChunk, AuthRequired, LoadError, MentionUri, RetryStatus, ThreadStatus,
- ToolCall, ToolCallContent, ToolCallStatus, UserMessageId,
+ AssistantMessageChunk, AuthRequired, LoadError, MentionUri, PermissionOptionChoice,
+ PermissionOptions, RetryStatus, ThreadStatus, ToolCall, ToolCallContent, ToolCallStatus,
+ UserMessageId,
};
use acp_thread::{AgentConnection, Plan};
use action_log::{ActionLog, ActionLogTelemetry};
@@ -3121,7 +3122,6 @@ impl AcpThreadView {
)
})
.child(self.render_permission_buttons(
- tool_call.kind,
options,
entry_ix,
tool_call.id.clone(),
@@ -3907,8 +3907,24 @@ impl AcpThreadView {
fn render_permission_buttons(
&self,
- kind: acp::ToolKind,
- options: &[acp::PermissionOption],
+ options: &PermissionOptions,
+ entry_ix: usize,
+ tool_call_id: acp::ToolCallId,
+ cx: &Context<Self>,
+ ) -> Div {
+ match options {
+ PermissionOptions::Flat(options) => {
+ self.render_permission_buttons_flat(options, entry_ix, tool_call_id, cx)
+ }
+ PermissionOptions::Dropdown(options) => {
+ self.render_permission_buttons_dropdown(options, entry_ix, tool_call_id, cx)
+ }
+ }
+ }
+
+ fn render_permission_buttons_dropdown(
+ &self,
+ choices: &[PermissionOptionChoice],
entry_ix: usize,
tool_call_id: acp::ToolCallId,
cx: &Context<Self>,
@@ -3920,77 +3936,26 @@ impl AcpThreadView {
.is_some_and(|call| call.id == tool_call_id)
});
- // For SwitchMode, use the old layout with all buttons
- if kind == acp::ToolKind::SwitchMode {
- return self.render_permission_buttons_legacy(options, entry_ix, tool_call_id, cx);
- }
-
- let granularity_options: Vec<_> = options
- .iter()
- .filter(|o| {
- matches!(
- o.kind,
- acp::PermissionOptionKind::AllowOnce | acp::PermissionOptionKind::AllowAlways
- )
- })
- .collect();
-
// Get the selected granularity index, defaulting to the last option ("Only this time")
let selected_index = self
.selected_permission_granularity
.get(&tool_call_id)
.copied()
- .unwrap_or_else(|| granularity_options.len().saturating_sub(1));
+ .unwrap_or_else(|| choices.len().saturating_sub(1));
- let selected_option = granularity_options
- .get(selected_index)
- .or(granularity_options.last())
- .copied();
+ let selected_choice = choices.get(selected_index).or(choices.last());
- let dropdown_label: SharedString = selected_option
- .map(|o| o.name.clone().into())
+ 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(option) = selected_option {
- let option_id_str = option.option_id.0.to_string();
-
- // Transform option_id for allow: "always:tool" -> "always_allow:tool", "once" -> "allow"
- let allow_id = if option_id_str == "once" {
- "allow".to_string()
- } else if let Some(rest) = option_id_str.strip_prefix("always:") {
- format!("always_allow:{}", rest)
- } else if let Some(rest) = option_id_str.strip_prefix("always_pattern:") {
- format!("always_allow_pattern:{}", rest)
- } else {
- option_id_str.clone()
- };
-
- // Transform option_id for deny: "always:tool" -> "always_deny:tool", "once" -> "deny"
- let deny_id = if option_id_str == "once" {
- "deny".to_string()
- } else if let Some(rest) = option_id_str.strip_prefix("always:") {
- format!("always_deny:{}", rest)
- } else if let Some(rest) = option_id_str.strip_prefix("always_pattern:") {
- format!("always_deny_pattern:{}", rest)
- } else {
- option_id_str.replace("allow", "deny")
- };
-
- let allow_kind = option.kind;
- let deny_kind = match option.kind {
- acp::PermissionOptionKind::AllowOnce => acp::PermissionOptionKind::RejectOnce,
- acp::PermissionOptionKind::AllowAlways => {
- acp::PermissionOptionKind::RejectAlways
- }
- other => other,
- };
-
+ if let Some(choice) = selected_choice {
(
- acp::PermissionOptionId::new(allow_id),
- allow_kind,
- acp::PermissionOptionId::new(deny_id),
- deny_kind,
+ choice.allow.option_id.clone(),
+ choice.allow.kind,
+ choice.deny.option_id.clone(),
+ choice.deny.kind,
)
} else {
(
@@ -4077,7 +4042,7 @@ impl AcpThreadView {
),
)
.child(self.render_permission_granularity_dropdown(
- &granularity_options,
+ choices,
dropdown_label,
entry_ix,
tool_call_id,
@@ -4089,7 +4054,7 @@ impl AcpThreadView {
fn render_permission_granularity_dropdown(
&self,
- granularity_options: &[&acp::PermissionOption],
+ choices: &[PermissionOptionChoice],
current_label: SharedString,
entry_ix: usize,
tool_call_id: acp::ToolCallId,
@@ -4097,10 +4062,10 @@ impl AcpThreadView {
is_first: bool,
cx: &Context<Self>,
) -> impl IntoElement {
- let menu_options: Vec<(usize, SharedString)> = granularity_options
+ let menu_options: Vec<(usize, SharedString)> = choices
.iter()
.enumerate()
- .map(|(i, o)| (i, o.name.clone().into()))
+ .map(|(i, choice)| (i, choice.label()))
.collect();
PopoverMenu::new(("permission-granularity", entry_ix))
@@ -4156,7 +4121,7 @@ impl AcpThreadView {
})
}
- fn render_permission_buttons_legacy(
+ fn render_permission_buttons_flat(
&self,
options: &[acp::PermissionOption],
entry_ix: usize,
@@ -6286,62 +6251,37 @@ impl AcpThreadView {
};
let tool_call_id = tool_call.id.clone();
- // Get granularity options (all options except old deny option)
- let granularity_options: Vec<_> = options
- .iter()
- .filter(|o| {
- matches!(
- o.kind,
- acp::PermissionOptionKind::AllowOnce | acp::PermissionOptionKind::AllowAlways
- )
- })
- .collect();
+ let PermissionOptions::Dropdown(choices) = options else {
+ let kind = if is_allow {
+ acp::PermissionOptionKind::AllowOnce
+ } else {
+ acp::PermissionOptionKind::RejectOnce
+ };
+ return self.authorize_pending_tool_call(kind, window, cx);
+ };
// Get selected index, defaulting to last option ("Only this time")
let selected_index = self
.selected_permission_granularity
.get(&tool_call_id)
.copied()
- .unwrap_or_else(|| granularity_options.len().saturating_sub(1));
-
- let selected_option = granularity_options
- .get(selected_index)
- .or(granularity_options.last())
- .copied()?;
-
- let option_id_str = selected_option.option_id.0.to_string();
-
- // Transform option_id based on allow/deny
- let (final_option_id, final_option_kind) = if is_allow {
- let allow_id = if option_id_str == "once" {
- "allow".to_string()
- } else if let Some(rest) = option_id_str.strip_prefix("always:") {
- format!("always_allow:{}", rest)
- } else if let Some(rest) = option_id_str.strip_prefix("always_pattern:") {
- format!("always_allow_pattern:{}", rest)
- } else {
- option_id_str
- };
- (acp::PermissionOptionId::new(allow_id), selected_option.kind)
+ .unwrap_or_else(|| choices.len().saturating_sub(1));
+
+ let selected_choice = choices.get(selected_index).or(choices.last())?;
+
+ let selected_option = if is_allow {
+ &selected_choice.allow
} else {
- let deny_id = if option_id_str == "once" {
- "deny".to_string()
- } else if let Some(rest) = option_id_str.strip_prefix("always:") {
- format!("always_deny:{}", rest)
- } else if let Some(rest) = option_id_str.strip_prefix("always_pattern:") {
- format!("always_deny_pattern:{}", rest)
- } else {
- option_id_str.replace("allow", "deny")
- };
- let deny_kind = match selected_option.kind {
- acp::PermissionOptionKind::AllowOnce => acp::PermissionOptionKind::RejectOnce,
- acp::PermissionOptionKind::AllowAlways => acp::PermissionOptionKind::RejectAlways,
- other => other,
- };
- (acp::PermissionOptionId::new(deny_id), deny_kind)
+ &selected_choice.deny
};
- self.authorize_tool_call(tool_call_id, final_option_id, final_option_kind, window, cx);
+ self.authorize_tool_call(
+ tool_call_id,
+ selected_option.option_id.clone(),
+ selected_option.kind,
+ window,
+ cx,
+ );
Some(())
}
@@ -6397,7 +6337,7 @@ impl AcpThreadView {
let ToolCallStatus::WaitingForConfirmation { options, .. } = &tool_call.status else {
return None;
};
- let option = options.iter().find(|o| o.kind == kind)?;
+ let option = options.first_option_of_kind(kind)?;
self.authorize_tool_call(
tool_call.id.clone(),
@@ -8463,11 +8403,11 @@ pub(crate) mod tests {
let connection =
StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
tool_call_id,
- vec![acp::PermissionOption::new(
+ PermissionOptions::Flat(vec![acp::PermissionOption::new(
"1",
"Allow",
acp::PermissionOptionKind::AllowOnce,
- )],
+ )]),
)]));
connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
@@ -9866,14 +9806,21 @@ pub(crate) mod tests {
if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
&tool_call.status
{
+ let PermissionOptions::Dropdown(choices) = options else {
+ panic!("Expected dropdown permission options");
+ };
+
assert_eq!(
- options.len(),
+ choices.len(),
3,
"Expected 3 permission options (granularity only)"
);
// Verify specific button labels (now using neutral names)
- let labels: Vec<&str> = options.iter().map(|o| o.name.as_ref()).collect();
+ let labels: Vec<&str> = choices
+ .iter()
+ .map(|choice| choice.allow.name.as_ref())
+ .collect();
assert!(
labels.contains(&"Always for terminal"),
"Missing 'Always for terminal' option"
@@ -9952,7 +9899,14 @@ pub(crate) mod tests {
if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
&tool_call.status
{
- let labels: Vec<&str> = options.iter().map(|o| o.name.as_ref()).collect();
+ let PermissionOptions::Dropdown(choices) = options else {
+ panic!("Expected dropdown permission options");
+ };
+
+ let labels: Vec<&str> = choices
+ .iter()
+ .map(|choice| choice.allow.name.as_ref())
+ .collect();
assert!(
labels.contains(&"Always for edit file"),
"Missing 'Always for edit file' option"
@@ -10029,7 +9983,14 @@ pub(crate) mod tests {
if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
&tool_call.status
{
- let labels: Vec<&str> = options.iter().map(|o| o.name.as_ref()).collect();
+ let PermissionOptions::Dropdown(choices) = options else {
+ panic!("Expected dropdown permission options");
+ };
+
+ let labels: Vec<&str> = choices
+ .iter()
+ .map(|choice| choice.allow.name.as_ref())
+ .collect();
assert!(
labels.contains(&"Always for fetch"),
"Missing 'Always for fetch' option"
@@ -10107,13 +10068,20 @@ pub(crate) mod tests {
if let acp_thread::ToolCallStatus::WaitingForConfirmation { options, .. } =
&tool_call.status
{
+ let PermissionOptions::Dropdown(choices) = options else {
+ panic!("Expected dropdown permission options");
+ };
+
assert_eq!(
- options.len(),
+ choices.len(),
2,
"Expected 2 permission options (no pattern option)"
);
- let labels: Vec<&str> = options.iter().map(|o| o.name.as_ref()).collect();
+ let labels: Vec<&str> = choices
+ .iter()
+ .map(|choice| choice.allow.name.as_ref())
+ .collect();
assert!(
labels.contains(&"Always for terminal"),
"Missing 'Always for terminal' option"
@@ -10258,10 +10226,20 @@ pub(crate) mod tests {
cx.run_until_parked();
// Find the pattern option ID
- let pattern_option = permission_options
- .iter()
- .find(|o| o.option_id.0.starts_with("always_pattern:"))
- .expect("Should have a pattern option for npm command");
+ let pattern_option = match &permission_options {
+ PermissionOptions::Dropdown(choices) => choices
+ .iter()
+ .find(|choice| {
+ choice
+ .allow
+ .option_id
+ .0
+ .starts_with("always_allow_pattern:")
+ })
+ .map(|choice| &choice.allow)
+ .expect("Should have a pattern option for npm command"),
+ _ => panic!("Expected dropdown permission options"),
+ };
// Dispatch action with the pattern option (simulating "Always allow `npm` commands")
thread_view.update_in(cx, |_, window, cx| {
@@ -10379,20 +10357,26 @@ pub(crate) mod tests {
ToolPermissionContext::new("terminal", "npm install").build_permission_options();
// Verify we have the expected options
- assert_eq!(permission_options.len(), 3);
+ let PermissionOptions::Dropdown(choices) = &permission_options else {
+ panic!("Expected dropdown permission options");
+ };
+
+ assert_eq!(choices.len(), 3);
assert!(
- permission_options[0]
+ choices[0]
+ .allow
.option_id
.0
- .contains("always:terminal")
+ .contains("always_allow:terminal")
);
assert!(
- permission_options[1]
+ choices[1]
+ .allow
.option_id
.0
- .contains("always_pattern:terminal")
+ .contains("always_allow_pattern:terminal")
);
- assert_eq!(permission_options[2].option_id.0.as_ref(), "once");
+ assert_eq!(choices[2].allow.option_id.0.as_ref(), "allow");
let connection =
StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
@@ -10525,71 +10509,49 @@ pub(crate) mod tests {
#[gpui::test]
async fn test_option_id_transformation_for_allow() {
- // Test the option_id transformation logic directly
- // "once" -> "allow"
- // "always:terminal" -> "always_allow:terminal"
- // "always_pattern:terminal:^cargo\s" -> "always_allow_pattern:terminal:^cargo\s"
-
- let test_cases = vec![
- ("once", "allow"),
- ("always:terminal", "always_allow:terminal"),
- (
- "always_pattern:terminal:^cargo\\s",
- "always_allow_pattern:terminal:^cargo\\s",
- ),
- ("always:fetch", "always_allow:fetch"),
- (
- "always_pattern:fetch:^https?://docs\\.rs",
- "always_allow_pattern:fetch:^https?://docs\\.rs",
- ),
- ];
+ let permission_options = ToolPermissionContext::new("terminal", "cargo build --release")
+ .build_permission_options();
- for (input, expected) in test_cases {
- let result = if input == "once" {
- "allow".to_string()
- } else if let Some(rest) = input.strip_prefix("always:") {
- format!("always_allow:{}", rest)
- } else if let Some(rest) = input.strip_prefix("always_pattern:") {
- format!("always_allow_pattern:{}", rest)
- } else {
- input.to_string()
- };
- assert_eq!(result, expected, "Failed for input: {}", input);
- }
+ let PermissionOptions::Dropdown(choices) = permission_options else {
+ panic!("Expected dropdown permission options");
+ };
+
+ let allow_ids: Vec<String> = choices
+ .iter()
+ .map(|choice| choice.allow.option_id.0.to_string())
+ .collect();
+
+ assert!(allow_ids.contains(&"always_allow:terminal".to_string()));
+ assert!(allow_ids.contains(&"allow".to_string()));
+ assert!(
+ allow_ids
+ .iter()
+ .any(|id| id.starts_with("always_allow_pattern:terminal:")),
+ "Missing allow pattern option"
+ );
}
#[gpui::test]
async fn test_option_id_transformation_for_deny() {
- // Test the option_id transformation logic for deny
- // "once" -> "deny"
- // "always:terminal" -> "always_deny:terminal"
- // "always_pattern:terminal:^cargo\s" -> "always_deny_pattern:terminal:^cargo\s"
-
- let test_cases = vec![
- ("once", "deny"),
- ("always:terminal", "always_deny:terminal"),
- (
- "always_pattern:terminal:^cargo\\s",
- "always_deny_pattern:terminal:^cargo\\s",
- ),
- ("always:fetch", "always_deny:fetch"),
- (
- "always_pattern:fetch:^https?://docs\\.rs",
- "always_deny_pattern:fetch:^https?://docs\\.rs",
- ),
- ];
+ let permission_options = ToolPermissionContext::new("terminal", "cargo build --release")
+ .build_permission_options();
- for (input, expected) in test_cases {
- let result = if input == "once" {
- "deny".to_string()
- } else if let Some(rest) = input.strip_prefix("always:") {
- format!("always_deny:{}", rest)
- } else if let Some(rest) = input.strip_prefix("always_pattern:") {
- format!("always_deny_pattern:{}", rest)
- } else {
- input.replace("allow", "deny")
- };
- assert_eq!(result, expected, "Failed for input: {}", input);
- }
+ let PermissionOptions::Dropdown(choices) = permission_options else {
+ panic!("Expected dropdown permission options");
+ };
+
+ let deny_ids: Vec<String> = choices
+ .iter()
+ .map(|choice| choice.deny.option_id.0.to_string())
+ .collect();
+
+ assert!(deny_ids.contains(&"always_deny:terminal".to_string()));
+ assert!(deny_ids.contains(&"deny".to_string()));
+ assert!(
+ deny_ids
+ .iter()
+ .any(|id| id.starts_with("always_deny_pattern:terminal:")),
+ "Missing deny pattern option"
+ );
}
}