Detailed changes
@@ -494,6 +494,58 @@ impl From<&ResolvedLocation> for AgentLocation {
}
}
+#[derive(Debug, Clone)]
+pub enum SelectedPermissionParams {
+ Terminal { patterns: Vec<String> },
+}
+
+#[derive(Debug)]
+pub struct SelectedPermissionOutcome {
+ pub option_id: acp::PermissionOptionId,
+ pub params: Option<SelectedPermissionParams>,
+}
+
+impl SelectedPermissionOutcome {
+ pub fn new(option_id: acp::PermissionOptionId) -> Self {
+ Self {
+ option_id,
+ params: None,
+ }
+ }
+
+ pub fn params(mut self, params: Option<SelectedPermissionParams>) -> Self {
+ self.params = params;
+ self
+ }
+}
+
+impl From<acp::PermissionOptionId> for SelectedPermissionOutcome {
+ fn from(option_id: acp::PermissionOptionId) -> Self {
+ Self::new(option_id)
+ }
+}
+
+impl From<SelectedPermissionOutcome> for acp::SelectedPermissionOutcome {
+ fn from(value: SelectedPermissionOutcome) -> Self {
+ Self::new(value.option_id)
+ }
+}
+
+#[derive(Debug)]
+pub enum RequestPermissionOutcome {
+ Cancelled,
+ Selected(SelectedPermissionOutcome),
+}
+
+impl From<RequestPermissionOutcome> for acp::RequestPermissionOutcome {
+ fn from(value: RequestPermissionOutcome) -> Self {
+ match value {
+ RequestPermissionOutcome::Cancelled => Self::Cancelled,
+ RequestPermissionOutcome::Selected(outcome) => Self::Selected(outcome.into()),
+ }
+ }
+}
+
#[derive(Debug)]
pub enum ToolCallStatus {
/// The tool call hasn't started running yet, but we start showing it to
@@ -502,7 +554,7 @@ pub enum ToolCallStatus {
/// The tool call is waiting for confirmation from the user.
WaitingForConfirmation {
options: PermissionOptions,
- respond_tx: oneshot::Sender<acp::PermissionOptionId>,
+ respond_tx: oneshot::Sender<SelectedPermissionOutcome>,
},
/// The tool call is currently running.
InProgress,
@@ -1929,7 +1981,7 @@ impl AcpThread {
tool_call: acp::ToolCallUpdate,
options: PermissionOptions,
cx: &mut Context<Self>,
- ) -> Result<Task<acp::RequestPermissionOutcome>> {
+ ) -> Result<Task<RequestPermissionOutcome>> {
let (tx, rx) = oneshot::channel();
let status = ToolCallStatus::WaitingForConfirmation {
@@ -1945,10 +1997,8 @@ impl AcpThread {
Ok(cx.spawn(async move |this, cx| {
let outcome = match rx.await {
- Ok(option) => acp::RequestPermissionOutcome::Selected(
- acp::SelectedPermissionOutcome::new(option),
- ),
- Err(oneshot::Canceled) => acp::RequestPermissionOutcome::Cancelled,
+ Ok(outcome) => RequestPermissionOutcome::Selected(outcome),
+ Err(oneshot::Canceled) => RequestPermissionOutcome::Cancelled,
};
this.update(cx, |_this, cx| {
cx.emit(AcpThreadEvent::ToolAuthorizationReceived(tool_call_id))
@@ -1961,7 +2011,7 @@ impl AcpThread {
pub fn authorize_tool_call(
&mut self,
id: acp::ToolCallId,
- option_id: acp::PermissionOptionId,
+ outcome: SelectedPermissionOutcome,
option_kind: acp::PermissionOptionKind,
cx: &mut Context<Self>,
) {
@@ -1982,7 +2032,7 @@ impl AcpThread {
let curr_status = mem::replace(&mut call.status, new_status);
if let ToolCallStatus::WaitingForConfirmation { respond_tx, .. } = curr_status {
- respond_tx.send(option_id).log_err();
+ respond_tx.send(outcome).log_err();
} else if cfg!(debug_assertions) {
panic!("tried to authorize an already authorized tool call");
}
@@ -470,6 +470,7 @@ impl AgentModelList {
pub struct PermissionOptionChoice {
pub allow: acp::PermissionOption,
pub deny: acp::PermissionOption,
+ pub sub_patterns: Vec<String>,
}
impl PermissionOptionChoice {
@@ -478,10 +479,26 @@ impl PermissionOptionChoice {
}
}
+/// Pairs a tool's permission pattern with its display name
+///
+/// For example, a pattern of `^cargo\\s+build(\\s|$)` would display as `cargo
+/// build`. It's handy to keep these together rather than trying to derive
+/// one from the other.
+#[derive(Debug, Clone, PartialEq)]
+pub struct PermissionPattern {
+ pub pattern: String,
+ pub display_name: String,
+}
+
#[derive(Debug, Clone)]
pub enum PermissionOptions {
Flat(Vec<acp::PermissionOption>),
Dropdown(Vec<PermissionOptionChoice>),
+ DropdownWithPatterns {
+ choices: Vec<PermissionOptionChoice>,
+ patterns: Vec<PermissionPattern>,
+ tool_name: String,
+ },
}
impl PermissionOptions {
@@ -489,6 +506,7 @@ impl PermissionOptions {
match self {
PermissionOptions::Flat(options) => options.is_empty(),
PermissionOptions::Dropdown(options) => options.is_empty(),
+ PermissionOptions::DropdownWithPatterns { choices, .. } => choices.is_empty(),
}
}
@@ -507,6 +525,17 @@ impl PermissionOptions {
None
}
}),
+ PermissionOptions::DropdownWithPatterns { choices, .. } => {
+ choices.iter().find_map(|choice| {
+ if choice.allow.kind == kind {
+ Some(&choice.allow)
+ } else if choice.deny.kind == kind {
+ Some(&choice.deny)
+ } else {
+ None
+ }
+ })
+ }
}
}
@@ -1198,12 +1198,11 @@ impl NativeAgentConnection {
thread.request_tool_call_authorization(tool_call, options, cx)
})??;
cx.background_spawn(async move {
- if let acp::RequestPermissionOutcome::Selected(
- acp::SelectedPermissionOutcome { option_id, .. },
- ) = outcome_task.await
+ if let acp_thread::RequestPermissionOutcome::Selected(outcome) =
+ outcome_task.await
{
response
- .send(option_id)
+ .send(outcome)
.map(|_| anyhow!("authorization receiver was dropped"))
.log_err();
}
@@ -1,4 +1,5 @@
-use shell_command_parser::extract_terminal_command_prefix;
+use acp_thread::PermissionPattern;
+use shell_command_parser::{extract_commands, extract_terminal_command_prefix};
use std::path::{Path, PathBuf};
use url::Url;
@@ -42,12 +43,21 @@ fn extract_command_prefix(command: &str) -> Option<CommandPrefix> {
})
}
-/// Extracts a regex pattern from a terminal command based on the first token (command name).
+/// Extracts a regex pattern and display name from a terminal command.
///
/// Returns `None` for commands starting with `./`, `/`, or other path-like prefixes.
/// This is a deliberate security decision: we only allow pattern-based "always allow"
/// rules for well-known command names (like `cargo`, `npm`, `git`), not for arbitrary
/// scripts or absolute paths which could be manipulated by an attacker.
+pub fn extract_terminal_permission_pattern(command: &str) -> Option<PermissionPattern> {
+ let pattern = extract_terminal_pattern(command)?;
+ let display_name = extract_terminal_pattern_display(command)?;
+ Some(PermissionPattern {
+ pattern,
+ display_name,
+ })
+}
+
pub fn extract_terminal_pattern(command: &str) -> Option<String> {
let prefix = extract_command_prefix(command)?;
let tokens = prefix.normalized_tokens;
@@ -71,6 +81,35 @@ pub fn extract_terminal_pattern_display(command: &str) -> Option<String> {
Some(prefix.display)
}
+/// Extracts patterns for ALL commands in a pipeline, not just the first one.
+///
+/// For a command like `"cargo test 2>&1 | tail"`, this returns patterns for
+/// both `cargo` and `tail`. Path-based commands (e.g. `./script.sh`) are
+/// filtered out, and duplicate command names are deduplicated while preserving
+/// order.
+pub fn extract_all_terminal_patterns(command: &str) -> Vec<PermissionPattern> {
+ let commands = match extract_commands(command) {
+ Some(commands) => commands,
+ None => return Vec::new(),
+ };
+
+ let mut results = Vec::new();
+
+ for cmd in &commands {
+ let Some(permission_pattern) = extract_terminal_permission_pattern(cmd) else {
+ continue;
+ };
+
+ if results.contains(&permission_pattern) {
+ continue;
+ }
+
+ results.push(permission_pattern);
+ }
+
+ results
+}
+
pub fn extract_path_pattern(path: &str) -> Option<String> {
let parent = Path::new(path).parent()?;
let parent_str = normalize_separators(parent.to_str()?);
@@ -273,6 +312,39 @@ mod tests {
);
}
+ #[test]
+ fn test_extract_all_terminal_patterns_pipeline() {
+ assert_eq!(
+ extract_all_terminal_patterns("cargo test 2>&1 | tail"),
+ vec![
+ PermissionPattern {
+ pattern: "^cargo\\s+test(\\s|$)".to_string(),
+ display_name: "cargo test".to_string(),
+ },
+ PermissionPattern {
+ pattern: "^tail\\b".to_string(),
+ display_name: "tail".to_string(),
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn test_extract_all_terminal_patterns_with_path_commands() {
+ assert_eq!(
+ extract_all_terminal_patterns("./script.sh | grep foo"),
+ vec![PermissionPattern {
+ pattern: "^grep\\s+foo(\\s|$)".to_string(),
+ display_name: "grep foo".to_string(),
+ }]
+ );
+ }
+
+ #[test]
+ fn test_extract_all_terminal_patterns_all_paths() {
+ assert_eq!(extract_all_terminal_patterns("./a.sh | /usr/bin/b"), vec![]);
+ }
+
#[test]
fn test_extract_path_pattern() {
assert_eq!(
@@ -841,14 +841,14 @@ async fn test_tool_authorization(cx: &mut TestAppContext) {
// Approve the first - send "allow" option_id (UI transforms "once" to "allow")
tool_call_auth_1
.response
- .send(acp::PermissionOptionId::new("allow"))
+ .send(acp::PermissionOptionId::new("allow").into())
.unwrap();
cx.run_until_parked();
// Reject the second - send "deny" option_id directly since Deny is now a button
tool_call_auth_2
.response
- .send(acp::PermissionOptionId::new("deny"))
+ .send(acp::PermissionOptionId::new("deny").into())
.unwrap();
cx.run_until_parked();
@@ -892,9 +892,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) {
let tool_call_auth_3 = next_tool_call_authorization(&mut events).await;
tool_call_auth_3
.response
- .send(acp::PermissionOptionId::new(
- "always_allow:tool_requiring_permission",
- ))
+ .send(acp::PermissionOptionId::new("always_allow:tool_requiring_permission").into())
.unwrap();
cx.run_until_parked();
let completion = fake_model.pending_completions().pop().unwrap();
@@ -1183,32 +1181,88 @@ fn test_permission_option_ids_for_terminal() {
panic!("Expected dropdown permission options");
};
- let allow_ids: Vec<String> = choices
- .iter()
- .map(|choice| choice.allow.option_id.0.to_string())
- .collect();
- let deny_ids: Vec<String> = choices
- .iter()
- .map(|choice| choice.deny.option_id.0.to_string())
- .collect();
+ // Expect 3 choices: always-tool, always-pattern, once
+ assert_eq!(choices.len(), 3);
- 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\n")),
- "Missing allow pattern option"
+ // First two choices both use the tool-level option IDs
+ assert_eq!(
+ choices[0].allow.option_id.0.as_ref(),
+ "always_allow:terminal"
);
+ assert_eq!(choices[0].deny.option_id.0.as_ref(), "always_deny:terminal");
+ assert!(choices[0].sub_patterns.is_empty());
- 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\n")),
- "Missing deny pattern option"
+ assert_eq!(
+ choices[1].allow.option_id.0.as_ref(),
+ "always_allow:terminal"
);
+ assert_eq!(choices[1].deny.option_id.0.as_ref(), "always_deny:terminal");
+ assert_eq!(choices[1].sub_patterns, vec!["^cargo\\s+build(\\s|$)"]);
+
+ // Third choice is the one-time allow/deny
+ assert_eq!(choices[2].allow.option_id.0.as_ref(), "allow");
+ assert_eq!(choices[2].deny.option_id.0.as_ref(), "deny");
+ assert!(choices[2].sub_patterns.is_empty());
+}
+
+#[test]
+fn test_permission_options_terminal_pipeline_produces_dropdown_with_patterns() {
+ let permission_options = ToolPermissionContext::new(
+ TerminalTool::NAME,
+ vec!["cargo test 2>&1 | tail".to_string()],
+ )
+ .build_permission_options();
+
+ let PermissionOptions::DropdownWithPatterns {
+ choices,
+ patterns,
+ tool_name,
+ } = permission_options
+ else {
+ panic!("Expected DropdownWithPatterns permission options for pipeline command");
+ };
+
+ assert_eq!(tool_name, TerminalTool::NAME);
+
+ // Should have "Always for terminal" and "Only this time" choices
+ assert_eq!(choices.len(), 2);
+ let labels: Vec<&str> = choices
+ .iter()
+ .map(|choice| choice.allow.name.as_ref())
+ .collect();
+ assert!(labels.contains(&"Always for terminal"));
+ assert!(labels.contains(&"Only this time"));
+
+ // Should have per-command patterns for "cargo test" and "tail"
+ assert_eq!(patterns.len(), 2);
+ let pattern_names: Vec<&str> = patterns.iter().map(|cp| cp.display_name.as_str()).collect();
+ assert!(pattern_names.contains(&"cargo test"));
+ assert!(pattern_names.contains(&"tail"));
+
+ // Verify patterns are valid regex patterns
+ let regex_patterns: Vec<&str> = patterns.iter().map(|cp| cp.pattern.as_str()).collect();
+ assert!(regex_patterns.contains(&"^cargo\\s+test(\\s|$)"));
+ assert!(regex_patterns.contains(&"^tail\\b"));
+}
+
+#[test]
+fn test_permission_options_terminal_pipeline_with_chaining() {
+ let permission_options = ToolPermissionContext::new(
+ TerminalTool::NAME,
+ vec!["npm install && npm test | tail".to_string()],
+ )
+ .build_permission_options();
+
+ let PermissionOptions::DropdownWithPatterns { patterns, .. } = permission_options else {
+ panic!("Expected DropdownWithPatterns for chained pipeline command");
+ };
+
+ // With subcommand-aware patterns, "npm install" and "npm test" are distinct
+ assert_eq!(patterns.len(), 3);
+ let pattern_names: Vec<&str> = patterns.iter().map(|cp| cp.display_name.as_str()).collect();
+ assert!(pattern_names.contains(&"npm install"));
+ assert!(pattern_names.contains(&"npm test"));
+ assert!(pattern_names.contains(&"tail"));
}
#[gpui::test]
@@ -758,6 +758,48 @@ impl ToolPermissionContext {
true
};
+ // For terminal commands with multiple pipeline commands, use DropdownWithPatterns
+ // to let users individually select which command patterns to always allow.
+ if tool_name == TerminalTool::NAME && shell_supports_always_allow {
+ if let Some(input) = input_values.first() {
+ let all_patterns = extract_all_terminal_patterns(input);
+ if all_patterns.len() > 1 {
+ let mut choices = Vec::new();
+ choices.push(acp_thread::PermissionOptionChoice {
+ allow: acp::PermissionOption::new(
+ acp::PermissionOptionId::new(format!("always_allow:{}", tool_name)),
+ format!("Always for {}", tool_name.replace('_', " ")),
+ acp::PermissionOptionKind::AllowAlways,
+ ),
+ deny: acp::PermissionOption::new(
+ acp::PermissionOptionId::new(format!("always_deny:{}", tool_name)),
+ format!("Always for {}", tool_name.replace('_', " ")),
+ acp::PermissionOptionKind::RejectAlways,
+ ),
+ sub_patterns: vec![],
+ });
+ choices.push(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,
+ ),
+ sub_patterns: vec![],
+ });
+ return acp_thread::PermissionOptions::DropdownWithPatterns {
+ choices,
+ patterns: all_patterns,
+ tool_name: tool_name.clone(),
+ };
+ }
+ }
+ }
+
let extract_for_value = |value: &str| -> (Option<String>, Option<String>) {
if tool_name == TerminalTool::NAME {
(
@@ -806,20 +848,22 @@ impl ToolPermissionContext {
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,
- ),
- });
- };
+ let mut push_choice =
+ |label: String, allow_id, deny_id, allow_kind, deny_kind, sub_patterns: Vec<String>| {
+ 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,
+ ),
+ sub_patterns,
+ });
+ };
if shell_supports_always_allow {
push_choice(
@@ -828,6 +872,7 @@ impl ToolPermissionContext {
format!("always_deny:{}", tool_name),
acp::PermissionOptionKind::AllowAlways,
acp::PermissionOptionKind::RejectAlways,
+ vec![],
);
if let (Some(pattern), Some(display)) = (pattern, pattern_display) {
@@ -838,10 +883,11 @@ impl ToolPermissionContext {
};
push_choice(
button_text,
- format!("always_allow_pattern:{}\n{}", tool_name, pattern),
- format!("always_deny_pattern:{}\n{}", tool_name, pattern),
+ format!("always_allow:{}", tool_name),
+ format!("always_deny:{}", tool_name),
acp::PermissionOptionKind::AllowAlways,
acp::PermissionOptionKind::RejectAlways,
+ vec![pattern],
);
}
}
@@ -852,6 +898,7 @@ impl ToolPermissionContext {
"deny".to_string(),
acp::PermissionOptionKind::AllowOnce,
acp::PermissionOptionKind::RejectOnce,
+ vec![],
);
acp_thread::PermissionOptions::Dropdown(choices)
@@ -862,7 +909,7 @@ impl ToolPermissionContext {
pub struct ToolCallAuthorization {
pub tool_call: acp::ToolCallUpdate,
pub options: acp_thread::PermissionOptions,
- pub response: oneshot::Sender<acp::PermissionOptionId>,
+ pub response: oneshot::Sender<acp_thread::SelectedPermissionOutcome>,
pub context: Option<ToolPermissionContext>,
}
@@ -3617,6 +3664,7 @@ impl ToolCallEventStream {
format!("Always for {} MCP tool", display_name),
acp::PermissionOptionKind::RejectAlways,
),
+ sub_patterns: vec![],
},
acp_thread::PermissionOptionChoice {
allow: acp::PermissionOption::new(
@@ -3629,6 +3677,7 @@ impl ToolCallEventStream {
"Only this time",
acp::PermissionOptionKind::RejectOnce,
),
+ sub_patterns: vec![],
},
]),
response: response_tx,
@@ -3644,40 +3693,13 @@ impl ToolCallEventStream {
let fs = self.fs.clone();
cx.spawn(async move |cx| {
- let response_str = response_rx.await?.0.to_string();
-
- if response_str == format!("always_allow_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_permission(&tool_id, ToolPermissionMode::Allow);
- });
- });
- }
- 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_permission(&tool_id, ToolPermissionMode::Deny);
- });
- });
- }
- return Err(anyhow!("Permission to run tool denied by user"));
- }
-
- if response_str == "allow" {
- return Ok(());
+ let outcome = response_rx.await?;
+ let is_allow = Self::persist_permission_outcome(&outcome, fs, &cx);
+ if is_allow {
+ Ok(())
+ } else {
+ Err(anyhow!("Permission to run tool denied by user"))
}
-
- Err(anyhow!("Permission to run tool denied by user"))
})
}
@@ -3687,8 +3709,6 @@ impl ToolCallEventStream {
context: ToolPermissionContext,
cx: &mut App,
) -> Task<Result<()>> {
- use settings::ToolPermissionMode;
-
let options = context.build_permission_options();
let (response_tx, response_rx) = oneshot::channel();
@@ -3715,90 +3735,118 @@ impl ToolCallEventStream {
let fs = self.fs.clone();
cx.spawn(async move |cx| {
- let response_str = response_rx.await?.0.to_string();
-
- // Handle "always allow tool" - e.g., "always_allow:terminal"
- if let Some(tool) = response_str.strip_prefix("always_allow:") {
- if let Some(fs) = fs.clone() {
- let tool = tool.to_string();
- cx.update(|cx| {
- update_settings_file(fs, cx, move |settings, _| {
- settings
- .agent
- .get_or_insert_default()
- .set_tool_default_permission(&tool, ToolPermissionMode::Allow);
- });
- });
- }
- return Ok(());
+ let outcome = response_rx.await?;
+ let is_allow = Self::persist_permission_outcome(&outcome, fs, &cx);
+ if is_allow {
+ Ok(())
+ } else {
+ Err(anyhow!("Permission to run tool denied by user"))
}
+ })
+ }
- // Handle "always deny tool" - e.g., "always_deny:terminal"
- if let Some(tool) = response_str.strip_prefix("always_deny:") {
- if let Some(fs) = fs.clone() {
- let tool = tool.to_string();
- cx.update(|cx| {
- update_settings_file(fs, cx, move |settings, _| {
- settings
- .agent
- .get_or_insert_default()
- .set_tool_default_permission(&tool, ToolPermissionMode::Deny);
- });
- });
- }
- return Err(anyhow!("Permission to run tool denied by user"));
- }
+ /// Interprets a `SelectedPermissionOutcome` and persists any settings changes.
+ /// Returns `true` if the tool call should be allowed, `false` if denied.
+ fn persist_permission_outcome(
+ outcome: &acp_thread::SelectedPermissionOutcome,
+ fs: Option<Arc<dyn Fs>>,
+ cx: &AsyncApp,
+ ) -> bool {
+ let option_id = outcome.option_id.0.as_ref();
+
+ let always_permission = option_id
+ .strip_prefix("always_allow:")
+ .map(|tool| (tool, ToolPermissionMode::Allow))
+ .or_else(|| {
+ option_id
+ .strip_prefix("always_deny:")
+ .map(|tool| (tool, ToolPermissionMode::Deny))
+ })
+ .or_else(|| {
+ option_id
+ .strip_prefix("always_allow_mcp:")
+ .map(|tool| (tool, ToolPermissionMode::Allow))
+ })
+ .or_else(|| {
+ option_id
+ .strip_prefix("always_deny_mcp:")
+ .map(|tool| (tool, ToolPermissionMode::Deny))
+ });
- // Handle "always allow pattern" - e.g., "always_allow_pattern:mcp:server:tool\n^cargo\s"
- if let Some(rest) = response_str.strip_prefix("always_allow_pattern:") {
- if let Some((pattern_tool_name, pattern)) = rest.split_once('\n') {
- let pattern_tool_name = pattern_tool_name.to_string();
- let pattern = pattern.to_string();
- if let Some(fs) = fs.clone() {
- cx.update(|cx| {
- update_settings_file(fs, cx, move |settings, _| {
- settings
- .agent
- .get_or_insert_default()
- .add_tool_allow_pattern(&pattern_tool_name, pattern);
- });
- });
- }
- } else {
- log::error!("Failed to parse always allow pattern: missing newline separator in '{rest}'");
- }
- return Ok(());
- }
+ if let Some((tool, mode)) = always_permission {
+ let params = outcome.params.as_ref();
+ Self::persist_always_permission(tool, mode, params, fs, cx);
+ return mode == ToolPermissionMode::Allow;
+ }
- // Handle "always deny pattern" - e.g., "always_deny_pattern:mcp:server:tool\n^cargo\s"
- if let Some(rest) = response_str.strip_prefix("always_deny_pattern:") {
- if let Some((pattern_tool_name, pattern)) = rest.split_once('\n') {
- let pattern_tool_name = pattern_tool_name.to_string();
- let pattern = pattern.to_string();
- if let Some(fs) = fs.clone() {
- cx.update(|cx| {
- update_settings_file(fs, cx, move |settings, _| {
- settings
- .agent
- .get_or_insert_default()
- .add_tool_deny_pattern(&pattern_tool_name, pattern);
- });
- });
- }
- } else {
- log::error!("Failed to parse always deny pattern: missing newline separator in '{rest}'");
- }
- return Err(anyhow!("Permission to run tool denied by user"));
- }
+ // Handle simple "allow" / "deny" (once, no persistence)
+ if option_id == "allow" || option_id == "deny" {
+ debug_assert!(
+ outcome.params.is_none(),
+ "unexpected params for once-only permission"
+ );
+ return option_id == "allow";
+ }
- // Handle simple "allow" (allow once)
- if response_str == "allow" {
- return Ok(());
- }
+ debug_assert!(false, "unexpected permission option_id: {option_id}");
+ false
+ }
- // Handle simple "deny" (deny once)
- Err(anyhow!("Permission to run tool denied by user"))
- })
+ /// Persists an "always allow" or "always deny" permission, using sub_patterns
+ /// from params when present.
+ fn persist_always_permission(
+ tool: &str,
+ mode: ToolPermissionMode,
+ params: Option<&acp_thread::SelectedPermissionParams>,
+ fs: Option<Arc<dyn Fs>>,
+ cx: &AsyncApp,
+ ) {
+ let Some(fs) = fs else {
+ return;
+ };
+
+ match params {
+ Some(acp_thread::SelectedPermissionParams::Terminal {
+ patterns: sub_patterns,
+ }) => {
+ debug_assert!(
+ !sub_patterns.is_empty(),
+ "empty sub_patterns for tool {tool} β callers should pass None instead"
+ );
+ let tool = tool.to_string();
+ let sub_patterns = sub_patterns.clone();
+ cx.update(|cx| {
+ update_settings_file(fs, cx, move |settings, _| {
+ let agent = settings.agent.get_or_insert_default();
+ for pattern in sub_patterns {
+ match mode {
+ ToolPermissionMode::Allow => {
+ agent.add_tool_allow_pattern(&tool, pattern);
+ }
+ ToolPermissionMode::Deny => {
+ agent.add_tool_deny_pattern(&tool, pattern);
+ }
+ // If there's no matching pattern this will
+ // default to confirm, so falling through is
+ // fine here.
+ ToolPermissionMode::Confirm => (),
+ }
+ }
+ });
+ });
+ }
+ None => {
+ let tool = tool.to_string();
+ cx.update(|cx| {
+ update_settings_file(fs, cx, move |settings, _| {
+ settings
+ .agent
+ .get_or_insert_default()
+ .set_tool_default_permission(&tool, mode);
+ });
+ });
+ }
+ }
}
}
@@ -266,7 +266,7 @@ mod tests {
);
auth.response
- .send(acp::PermissionOptionId::new("allow"))
+ .send(acp::PermissionOptionId::new("allow").into())
.unwrap();
let result = task.await;
@@ -372,7 +372,7 @@ mod tests {
);
auth.response
- .send(acp::PermissionOptionId::new("allow"))
+ .send(acp::PermissionOptionId::new("allow").into())
.unwrap();
assert!(
@@ -241,7 +241,7 @@ mod tests {
);
auth.response
- .send(acp::PermissionOptionId::new("allow"))
+ .send(acp::PermissionOptionId::new("allow").into())
.unwrap();
let result = task.await;
@@ -359,7 +359,7 @@ mod tests {
);
auth.response
- .send(acp::PermissionOptionId::new("allow"))
+ .send(acp::PermissionOptionId::new("allow").into())
.unwrap();
assert!(
@@ -301,7 +301,7 @@ mod tests {
);
auth.response
- .send(acp::PermissionOptionId::new("allow"))
+ .send(acp::PermissionOptionId::new("allow").into())
.unwrap();
let result = task.await;
@@ -428,7 +428,7 @@ mod tests {
);
auth.response
- .send(acp::PermissionOptionId::new("allow"))
+ .send(acp::PermissionOptionId::new("allow").into())
.unwrap();
assert!(
@@ -1374,7 +1374,7 @@ mod tests {
event
.response
- .send(acp::PermissionOptionId::new("allow"))
+ .send(acp::PermissionOptionId::new("allow").into())
.unwrap();
authorize_task.await.unwrap();
}
@@ -848,7 +848,7 @@ mod tests {
);
auth.response
- .send(acp::PermissionOptionId::new("allow"))
+ .send(acp::PermissionOptionId::new("allow").into())
.unwrap();
let result = task.await;
@@ -273,7 +273,7 @@ mod tests {
);
auth.response
- .send(acp::PermissionOptionId::new("allow"))
+ .send(acp::PermissionOptionId::new("allow").into())
.unwrap();
let result = task.await;
@@ -379,7 +379,7 @@ mod tests {
);
auth.response
- .send(acp::PermissionOptionId::new("allow"))
+ .send(acp::PermissionOptionId::new("allow").into())
.unwrap();
assert!(
@@ -896,7 +896,7 @@ mod test {
);
authorization
.response
- .send(acp::PermissionOptionId::new("allow"))
+ .send(acp::PermissionOptionId::new("allow").into())
.unwrap();
let result = read_task.await;
@@ -1185,7 +1185,7 @@ mod test {
);
auth.response
- .send(acp::PermissionOptionId::new("allow"))
+ .send(acp::PermissionOptionId::new("allow").into())
.unwrap();
let result = task.await;
@@ -523,7 +523,7 @@ mod tests {
);
auth.response
- .send(acp::PermissionOptionId::new("allow"))
+ .send(acp::PermissionOptionId::new("allow").into())
.unwrap();
let _result = task.await;
@@ -651,7 +651,7 @@ mod tests {
);
auth.response
- .send(acp::PermissionOptionId::new("allow"))
+ .send(acp::PermissionOptionId::new("allow").into())
.unwrap();
assert!(
@@ -518,7 +518,7 @@ mod tests {
);
auth.response
- .send(acp::PermissionOptionId::new("allow"))
+ .send(acp::PermissionOptionId::new("allow").into())
.unwrap();
let _result = task.await;
@@ -646,7 +646,7 @@ mod tests {
);
auth.response
- .send(acp::PermissionOptionId::new("allow"))
+ .send(acp::PermissionOptionId::new("allow").into())
.unwrap();
assert!(
@@ -727,7 +727,7 @@ mod tests {
let auth = event_rx.expect_authorization().await;
auth.response
- .send(acp::PermissionOptionId::new("deny"))
+ .send(acp::PermissionOptionId::new("deny").into())
.unwrap();
let output = task.await.unwrap();
@@ -2581,7 +2581,7 @@ mod tests {
event
.response
- .send(acp::PermissionOptionId::new("allow"))
+ .send(acp::PermissionOptionId::new("allow").into())
.unwrap();
authorize_task.await.unwrap();
}
@@ -1470,7 +1470,7 @@ impl acp::Client for ClientDelegate {
let outcome = task.await;
- Ok(acp::RequestPermissionResponse::new(outcome))
+ Ok(acp::RequestPermissionResponse::new(outcome.into()))
}
async fn write_text_file(
@@ -208,7 +208,7 @@ pub async fn test_tool_call_with_permission<T, F>(
thread.update(cx, |thread, cx| {
thread.authorize_tool_call(
tool_call_id,
- allow_option_id,
+ allow_option_id.into(),
acp::PermissionOptionKind::AllowOnce,
cx,
);
@@ -194,6 +194,29 @@ pub struct AuthorizeToolCall {
pub option_kind: String,
}
+/// Action to select a permission granularity option from the dropdown.
+/// This updates the selected granularity without triggering authorization.
+#[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)]
+#[action(namespace = agent)]
+#[serde(deny_unknown_fields)]
+pub struct SelectPermissionGranularity {
+ /// The tool call ID for which to select the granularity.
+ pub tool_call_id: String,
+ /// The index of the selected granularity option.
+ pub index: usize,
+}
+
+/// Action to toggle a command pattern checkbox in the permission dropdown.
+#[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)]
+#[action(namespace = agent)]
+#[serde(deny_unknown_fields)]
+pub struct ToggleCommandPattern {
+ /// The tool call ID for which to toggle the pattern.
+ pub tool_call_id: String,
+ /// The index of the command pattern to toggle.
+ pub pattern_index: usize,
+}
+
/// Creates a new conversation thread, optionally based on an existing thread.
#[derive(Default, Clone, PartialEq, Deserialize, JsonSchema, Action)]
#[action(namespace = agent)]
@@ -1,7 +1,8 @@
use acp_thread::{
AcpThread, AcpThreadEvent, AgentSessionInfo, AgentThreadEntry, AssistantMessage,
AssistantMessageChunk, AuthRequired, LoadError, MentionUri, PermissionOptionChoice,
- PermissionOptions, RetryStatus, ThreadStatus, ToolCall, ToolCallContent, ToolCallStatus,
+ PermissionOptions, PermissionPattern, RetryStatus, SelectedPermissionOutcome,
+ SelectedPermissionParams, ThreadStatus, ToolCall, ToolCallContent, ToolCallStatus,
UserMessageId,
};
use acp_thread::{AgentConnection, Plan};
@@ -164,9 +165,6 @@ pub(crate) struct Conversation {
threads: HashMap<acp::SessionId, Entity<AcpThread>>,
permission_requests: IndexMap<acp::SessionId, Vec<acp::ToolCallId>>,
subscriptions: Vec<Subscription>,
- /// Tracks the selected granularity index for each tool call's permission dropdown.
- /// The index corresponds to the position in the allow_options list.
- selected_permission_granularity: HashMap<acp::SessionId, HashMap<acp::ToolCallId, usize>>,
updated_at: Option<Instant>,
}
@@ -212,29 +210,6 @@ impl Conversation {
.insert(thread.read(cx).session_id().clone(), thread);
}
- pub fn selected_permission_granularity(
- &self,
- session_id: &acp::SessionId,
- tool_call_id: &acp::ToolCallId,
- ) -> Option<usize> {
- self.selected_permission_granularity
- .get(session_id)
- .and_then(|map| map.get(tool_call_id))
- .copied()
- }
-
- pub fn set_selected_permission_granularity(
- &mut self,
- session_id: acp::SessionId,
- tool_call_id: acp::ToolCallId,
- granularity: usize,
- ) {
- self.selected_permission_granularity
- .entry(session_id)
- .or_default()
- .insert(tool_call_id, granularity);
- }
-
pub fn pending_tool_call<'a>(
&'a self,
session_id: &acp::SessionId,
@@ -274,7 +249,7 @@ impl Conversation {
self.authorize_tool_call(
session_id.clone(),
tool_call_id,
- option.option_id.clone(),
+ option.option_id.clone().into(),
option.kind,
cx,
);
@@ -285,7 +260,7 @@ impl Conversation {
&mut self,
session_id: acp::SessionId,
tool_call_id: acp::ToolCallId,
- option_id: acp::PermissionOptionId,
+ outcome: SelectedPermissionOutcome,
option_kind: acp::PermissionOptionKind,
cx: &mut Context<Self>,
) {
@@ -302,7 +277,7 @@ impl Conversation {
);
thread.update(cx, |thread, cx| {
- thread.authorize_tool_call(tool_call_id, option_id, option_kind, cx);
+ thread.authorize_tool_call(tool_call_id, outcome, option_kind, cx);
});
cx.notify();
}
@@ -5779,17 +5754,11 @@ pub(crate) mod tests {
cx.run_until_parked();
- // Find the pattern option ID
+ // Find the pattern option ID (the choice with non-empty sub_patterns)
let pattern_option = match &permission_options {
PermissionOptions::Dropdown(choices) => choices
.iter()
- .find(|choice| {
- choice
- .allow
- .option_id
- .0
- .starts_with("always_allow_pattern:")
- })
+ .find(|choice| !choice.sub_patterns.is_empty())
.map(|choice| &choice.allow)
.expect("Should have a pattern option for npm command"),
_ => panic!("Expected dropdown permission options"),
@@ -5820,6 +5789,181 @@ pub(crate) mod tests {
});
}
+ #[gpui::test]
+ async fn test_granularity_selection_updates_state(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let tool_call_id = acp::ToolCallId::new("granularity-test-1");
+ let tool_call =
+ acp::ToolCall::new(tool_call_id.clone(), "Run `cargo build`").kind(acp::ToolKind::Edit);
+
+ let permission_options =
+ ToolPermissionContext::new(TerminalTool::NAME, vec!["cargo build".to_string()])
+ .build_permission_options();
+
+ let connection =
+ StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
+ tool_call_id.clone(),
+ permission_options.clone(),
+ )]));
+
+ connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
+
+ let (thread_view, cx) = setup_conversation_view(StubAgentServer::new(connection), cx).await;
+ add_to_workspace(thread_view.clone(), cx);
+
+ cx.update(|_window, cx| {
+ AgentSettings::override_global(
+ AgentSettings {
+ notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
+ ..AgentSettings::get_global(cx).clone()
+ },
+ cx,
+ );
+ });
+
+ let message_editor = message_editor(&thread_view, cx);
+ message_editor.update_in(cx, |editor, window, cx| {
+ editor.set_text("Build the project", window, cx);
+ });
+
+ active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
+
+ cx.run_until_parked();
+
+ // Verify default granularity is the last option (index 2 = "Only this time")
+ thread_view.read_with(cx, |thread_view, cx| {
+ let state = thread_view.active_thread().unwrap();
+ let selected = state.read(cx).permission_selections.get(&tool_call_id);
+ assert!(
+ selected.is_none(),
+ "Should have no selection initially (defaults to last)"
+ );
+ });
+
+ // Select the first option (index 0 = "Always for terminal")
+ thread_view.update_in(cx, |_, window, cx| {
+ window.dispatch_action(
+ crate::SelectPermissionGranularity {
+ tool_call_id: "granularity-test-1".to_string(),
+ index: 0,
+ }
+ .boxed_clone(),
+ cx,
+ );
+ });
+
+ cx.run_until_parked();
+
+ // Verify the selection was updated
+ thread_view.read_with(cx, |thread_view, cx| {
+ let state = thread_view.active_thread().unwrap();
+ let selected = state.read(cx).permission_selections.get(&tool_call_id);
+ assert_eq!(
+ selected.and_then(|s| s.choice_index()),
+ Some(0),
+ "Should have selected index 0"
+ );
+ });
+ }
+
+ #[gpui::test]
+ async fn test_allow_button_uses_selected_granularity(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let tool_call_id = acp::ToolCallId::new("allow-granularity-test-1");
+ let tool_call =
+ acp::ToolCall::new(tool_call_id.clone(), "Run `npm install`").kind(acp::ToolKind::Edit);
+
+ let permission_options =
+ ToolPermissionContext::new(TerminalTool::NAME, vec!["npm install".to_string()])
+ .build_permission_options();
+
+ // Verify we have the expected options
+ let PermissionOptions::Dropdown(choices) = &permission_options else {
+ panic!("Expected dropdown permission options");
+ };
+
+ assert_eq!(choices.len(), 3);
+ assert!(
+ choices[0]
+ .allow
+ .option_id
+ .0
+ .contains("always_allow:terminal")
+ );
+ assert!(
+ choices[1]
+ .allow
+ .option_id
+ .0
+ .contains("always_allow:terminal")
+ );
+ assert!(!choices[1].sub_patterns.is_empty());
+ assert_eq!(choices[2].allow.option_id.0.as_ref(), "allow");
+
+ let connection =
+ StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
+ tool_call_id.clone(),
+ permission_options.clone(),
+ )]));
+
+ connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
+
+ let (thread_view, cx) = setup_conversation_view(StubAgentServer::new(connection), cx).await;
+ add_to_workspace(thread_view.clone(), cx);
+
+ cx.update(|_window, cx| {
+ AgentSettings::override_global(
+ AgentSettings {
+ notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
+ ..AgentSettings::get_global(cx).clone()
+ },
+ cx,
+ );
+ });
+
+ let message_editor = message_editor(&thread_view, cx);
+ message_editor.update_in(cx, |editor, window, cx| {
+ editor.set_text("Install dependencies", window, cx);
+ });
+
+ active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
+
+ cx.run_until_parked();
+
+ // Select the pattern option (index 1 = "Always for `npm` commands")
+ thread_view.update_in(cx, |_, window, cx| {
+ window.dispatch_action(
+ crate::SelectPermissionGranularity {
+ tool_call_id: "allow-granularity-test-1".to_string(),
+ index: 1,
+ }
+ .boxed_clone(),
+ cx,
+ );
+ });
+
+ cx.run_until_parked();
+
+ // Simulate clicking the Allow button by dispatching AllowOnce action
+ // which should use the selected granularity
+ active_thread(&thread_view, cx).update_in(cx, |view, window, cx| {
+ view.allow_once(&AllowOnce, window, cx)
+ });
+
+ cx.run_until_parked();
+
+ // Verify tool call was authorized
+ thread_view.read_with(cx, |thread_view, cx| {
+ let tool_call = thread_view.pending_tool_call(cx);
+ assert!(
+ tool_call.is_none(),
+ "Tool call should be authorized after Allow with pattern granularity"
+ );
+ });
+ }
+
#[gpui::test]
async fn test_deny_button_uses_selected_granularity(cx: &mut TestAppContext) {
init_test(cx);
@@ -5899,13 +6043,14 @@ pub(crate) mod tests {
.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!(
+ assert_eq!(
allow_ids
.iter()
- .any(|id| id.starts_with("always_allow_pattern:terminal\n")),
- "Missing allow pattern option"
+ .filter(|id| *id == "always_allow:terminal")
+ .count(),
+ 2,
+ "Expected two always_allow:terminal IDs (one whole-tool, one pattern with sub_patterns)"
);
}
@@ -5926,13 +6071,14 @@ pub(crate) mod tests {
.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!(
+ assert_eq!(
deny_ids
.iter()
- .any(|id| id.starts_with("always_deny_pattern:terminal\n")),
- "Missing deny pattern option"
+ .filter(|id| *id == "always_deny:terminal")
+ .count(),
+ 2,
+ "Expected two always_deny:terminal IDs (one whole-tool, one pattern with sub_patterns)"
);
}
@@ -6067,7 +6213,7 @@ pub(crate) mod tests {
tool_call_id: &str,
option_id: &str,
cx: &mut TestAppContext,
- ) -> Task<acp::RequestPermissionOutcome> {
+ ) -> Task<acp_thread::RequestPermissionOutcome> {
let tool_call_id = acp::ToolCallId::new(tool_call_id);
let label = format!("Tool {tool_call_id}");
let option_id = acp::PermissionOptionId::new(option_id);
@@ -6126,7 +6272,7 @@ pub(crate) mod tests {
conversation.authorize_tool_call(
acp::SessionId::new("session-1"),
acp::ToolCallId::new("tc-1"),
- acp::PermissionOptionId::new("allow-1"),
+ acp::PermissionOptionId::new("allow-1").into(),
acp::PermissionOptionKind::AllowOnce,
cx,
);
@@ -6149,7 +6295,7 @@ pub(crate) mod tests {
conversation.authorize_tool_call(
acp::SessionId::new("session-1"),
acp::ToolCallId::new("tc-2"),
- acp::PermissionOptionId::new("allow-2"),
+ acp::PermissionOptionId::new("allow-2").into(),
acp::PermissionOptionKind::AllowOnce,
cx,
);
@@ -6288,7 +6434,7 @@ pub(crate) mod tests {
conversation.authorize_tool_call(
acp::SessionId::new("thread-a"),
acp::ToolCallId::new("tc-a"),
- acp::PermissionOptionId::new("allow-a"),
+ acp::PermissionOptionId::new("allow-a").into(),
acp::PermissionOptionKind::AllowOnce,
cx,
);
@@ -1,3 +1,4 @@
+use crate::SelectPermissionGranularity;
use std::cell::RefCell;
use acp_thread::ContentBlock;
@@ -165,6 +166,56 @@ pub enum AcpThreadViewEvent {
impl EventEmitter<AcpThreadViewEvent> for ThreadView {}
+/// Tracks the user's permission dropdown selection state for a specific tool call.
+///
+/// Default (no entry in the map) means the last dropdown choice is selected,
+/// which is typically "Only this time".
+#[derive(Clone)]
+pub(crate) enum PermissionSelection {
+ /// A specific choice from the dropdown (e.g., "Always for terminal", "Only this time").
+ /// The index corresponds to the position in the `choices` list from `PermissionOptions`.
+ Choice(usize),
+ /// "Select optionsβ¦" mode where individual command patterns can be toggled.
+ /// Contains the indices of checked patterns in the `patterns` list.
+ /// All patterns start checked when this mode is first activated.
+ SelectedPatterns(Vec<usize>),
+}
+
+impl PermissionSelection {
+ /// Returns the choice index if a specific dropdown choice is selected,
+ /// or `None` if in per-command pattern mode.
+ pub(crate) fn choice_index(&self) -> Option<usize> {
+ match self {
+ Self::Choice(index) => Some(*index),
+ Self::SelectedPatterns(_) => None,
+ }
+ }
+
+ fn is_pattern_checked(&self, index: usize) -> bool {
+ match self {
+ Self::SelectedPatterns(checked) => checked.contains(&index),
+ _ => false,
+ }
+ }
+
+ fn has_any_checked_patterns(&self) -> bool {
+ match self {
+ Self::SelectedPatterns(checked) => !checked.is_empty(),
+ _ => false,
+ }
+ }
+
+ fn toggle_pattern(&mut self, index: usize) {
+ if let Self::SelectedPatterns(checked) = self {
+ if let Some(pos) = checked.iter().position(|&i| i == index) {
+ checked.swap_remove(pos);
+ } else {
+ checked.push(index);
+ }
+ }
+ }
+}
+
pub struct ThreadView {
pub id: acp::SessionId,
pub parent_id: Option<acp::SessionId>,
@@ -213,6 +264,9 @@ pub struct ThreadView {
pub is_loading_contents: bool,
pub new_server_version_available: Option<SharedString>,
pub resumed_without_history: bool,
+ pub(crate) permission_selections:
+ HashMap<agent_client_protocol::ToolCallId, PermissionSelection>,
+ pub resume_thread_metadata: Option<AgentSessionInfo>,
pub _cancel_task: Option<Task<()>>,
_save_task: Option<Task<()>>,
_draft_resolve_task: Option<Task<()>>,
@@ -446,6 +500,8 @@ impl ThreadView {
discarded_partial_edits: HashSet::default(),
is_loading_contents: false,
new_server_version_available: None,
+ permission_selections: HashMap::default(),
+ resume_thread_metadata: None,
_cancel_task: None,
_save_task: None,
_draft_resolve_task: None,
@@ -1518,13 +1574,13 @@ impl ThreadView {
&mut self,
session_id: acp::SessionId,
tool_call_id: acp::ToolCallId,
- option_id: acp::PermissionOptionId,
+ outcome: SelectedPermissionOutcome,
option_kind: acp::PermissionOptionKind,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.conversation.update(cx, |conversation, cx| {
- conversation.authorize_tool_call(session_id, tool_call_id, option_id, option_kind, cx);
+ conversation.authorize_tool_call(session_id, tool_call_id, outcome, option_kind, cx);
});
if self.should_be_following {
self.workspace
@@ -1587,13 +1643,77 @@ impl ThreadView {
self.authorize_tool_call(
self.id.clone(),
tool_call_id,
- option_id,
+ option_id.into(),
option_kind,
window,
cx,
);
}
+ pub fn handle_select_permission_granularity(
+ &mut self,
+ action: &SelectPermissionGranularity,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let tool_call_id = acp::ToolCallId::new(action.tool_call_id.clone());
+ self.permission_selections
+ .insert(tool_call_id, PermissionSelection::Choice(action.index));
+
+ cx.notify();
+ }
+
+ pub fn handle_toggle_command_pattern(
+ &mut self,
+ action: &crate::ToggleCommandPattern,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let tool_call_id = acp::ToolCallId::new(action.tool_call_id.clone());
+
+ match self.permission_selections.get_mut(&tool_call_id) {
+ Some(PermissionSelection::SelectedPatterns(checked)) => {
+ // Already in pattern mode β toggle the individual pattern.
+ if let Some(pos) = checked.iter().position(|&i| i == action.pattern_index) {
+ checked.swap_remove(pos);
+ } else {
+ checked.push(action.pattern_index);
+ }
+ }
+ _ => {
+ // First click: activate "Select options" with all patterns checked.
+ let thread = self.thread.read(cx);
+ let pattern_count = thread
+ .entries()
+ .iter()
+ .find_map(|entry| {
+ if let AgentThreadEntry::ToolCall(call) = entry {
+ if call.id == tool_call_id {
+ if let ToolCallStatus::WaitingForConfirmation { options, .. } =
+ &call.status
+ {
+ if let PermissionOptions::DropdownWithPatterns {
+ patterns,
+ ..
+ } = options
+ {
+ return Some(patterns.len());
+ }
+ }
+ }
+ }
+ None
+ })
+ .unwrap_or(0);
+ self.permission_selections.insert(
+ tool_call_id,
+ PermissionSelection::SelectedPatterns((0..pattern_count).collect()),
+ );
+ }
+ }
+ cx.notify();
+ }
+
fn authorize_pending_with_granularity(
&mut self,
is_allow: bool,
@@ -1602,20 +1722,77 @@ impl ThreadView {
) -> Option<()> {
let (session_id, tool_call_id, options) =
self.conversation.read(cx).pending_tool_call(&self.id, cx)?;
- 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);
+ let options = options.clone();
+ self.authorize_with_granularity(session_id, tool_call_id, &options, is_allow, window, cx)
+ }
+
+ fn authorize_with_granularity(
+ &mut self,
+ session_id: acp::SessionId,
+ tool_call_id: acp::ToolCallId,
+ options: &PermissionOptions,
+ is_allow: bool,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Option<()> {
+ let (choices, dropdown_with_patterns) = match options {
+ PermissionOptions::Dropdown(choices) => (choices.as_slice(), None),
+ PermissionOptions::DropdownWithPatterns {
+ choices,
+ patterns,
+ tool_name,
+ } => (
+ choices.as_slice(),
+ Some((patterns.as_slice(), tool_name.as_str())),
+ ),
+ _ => {
+ 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
- .conversation
- .read(cx)
- .selected_permission_granularity(&session_id, &tool_call_id)
+ let selection = self.permission_selections.get(&tool_call_id);
+
+ // When in per-command pattern mode, use the checked patterns.
+ if let Some(PermissionSelection::SelectedPatterns(checked)) = selection
+ && let Some((patterns, tool_name)) = dropdown_with_patterns
+ {
+ let checked_patterns: Vec<_> = patterns
+ .iter()
+ .enumerate()
+ .filter(|(index, _)| checked.contains(index))
+ .map(|(_, cp)| cp.pattern.clone())
+ .collect();
+
+ if !checked_patterns.is_empty() {
+ let (option_id_str, kind) = if is_allow {
+ (
+ format!("always_allow:{}", tool_name),
+ acp::PermissionOptionKind::AllowAlways,
+ )
+ } else {
+ (
+ format!("always_deny:{}", tool_name),
+ acp::PermissionOptionKind::RejectAlways,
+ )
+ };
+ let outcome =
+ SelectedPermissionOutcome::new(acp::PermissionOptionId::new(option_id_str))
+ .params(Some(SelectedPermissionParams::Terminal {
+ patterns: checked_patterns,
+ }));
+ self.authorize_tool_call(session_id, tool_call_id, outcome, kind, window, cx);
+ return Some(());
+ }
+ }
+
+ // Use the selected granularity choice ("Always for terminal" or "Only this time")
+ let selected_index = selection
+ .and_then(|s| s.choice_index())
.unwrap_or_else(|| choices.len().saturating_sub(1));
let selected_choice = choices.get(selected_index).or(choices.last())?;
@@ -1626,10 +1803,21 @@ impl ThreadView {
&selected_choice.deny
};
+ let params = if !selected_choice.sub_patterns.is_empty() {
+ Some(SelectedPermissionParams::Terminal {
+ patterns: selected_choice.sub_patterns.clone(),
+ })
+ } else {
+ None
+ };
+
+ let outcome =
+ SelectedPermissionOutcome::new(selected_option.option_id.clone()).params(params);
+
self.authorize_tool_call(
session_id,
tool_call_id,
- selected_option.option_id.clone(),
+ outcome,
selected_option.kind,
window,
cx,
@@ -5772,10 +5960,23 @@ impl ThreadView {
focus_handle,
cx,
),
- PermissionOptions::Dropdown(options) => self.render_permission_buttons_dropdown(
- session_id,
+ PermissionOptions::Dropdown(choices) => self.render_permission_buttons_with_dropdown(
is_first,
- options,
+ choices,
+ None,
+ entry_ix,
+ tool_call_id,
+ focus_handle,
+ cx,
+ ),
+ PermissionOptions::DropdownWithPatterns {
+ choices,
+ patterns,
+ tool_name,
+ } => self.render_permission_buttons_with_dropdown(
+ is_first,
+ choices,
+ Some((patterns, tool_name)),
entry_ix,
tool_call_id,
focus_handle,
@@ -5784,46 +5985,56 @@ impl ThreadView {
}
}
- fn render_permission_buttons_dropdown(
+ fn render_permission_buttons_with_dropdown(
&self,
- session_id: acp::SessionId,
is_first: bool,
choices: &[PermissionOptionChoice],
+ patterns: Option<(&[PermissionPattern], &str)>,
entry_ix: usize,
tool_call_id: acp::ToolCallId,
focus_handle: &FocusHandle,
cx: &Context<Self>,
) -> Div {
- // Get the selected granularity index, defaulting to the last option ("Only this time")
- let selected_index = self
- .conversation
- .read(cx)
- .selected_permission_granularity(&session_id, &tool_call_id)
- .unwrap_or_else(|| choices.len().saturating_sub(1));
+ let selection = self.permission_selections.get(&tool_call_id);
- 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 selected_index = selection
+ .and_then(|s| s.choice_index())
+ .unwrap_or_else(|| choices.len().saturating_sub(1));
- 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,
- )
+ let dropdown_label: SharedString =
+ if matches!(selection, Some(PermissionSelection::SelectedPatterns(_))) {
+ "Always for selected commands".into()
} else {
- (
- acp::PermissionOptionId::new("allow"),
- acp::PermissionOptionKind::AllowOnce,
- acp::PermissionOptionId::new("deny"),
- acp::PermissionOptionKind::RejectOnce,
- )
+ choices
+ .get(selected_index)
+ .or(choices.last())
+ .map(|choice| choice.label())
+ .unwrap_or_else(|| "Only this time".into())
};
+ let dropdown = if let Some((pattern_list, tool_name)) = patterns {
+ self.render_permission_granularity_dropdown_with_patterns(
+ choices,
+ pattern_list,
+ tool_name,
+ dropdown_label,
+ entry_ix,
+ tool_call_id.clone(),
+ is_first,
+ cx,
+ )
+ } else {
+ self.render_permission_granularity_dropdown(
+ choices,
+ dropdown_label,
+ entry_ix,
+ tool_call_id.clone(),
+ selected_index,
+ is_first,
+ cx,
+ )
+ };
+
h_flex()
.w_full()
.p_1()
@@ -5853,19 +6064,8 @@ impl ThreadView {
)
})
.on_click(cx.listener({
- let session_id = session_id.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_tool_call(
- session_id.clone(),
- tool_call_id.clone(),
- option_id.clone(),
- option_kind,
- window,
- cx,
- );
+ this.authorize_pending_with_granularity(true, window, cx);
}
})),
)
@@ -5888,33 +6088,13 @@ impl ThreadView {
)
})
.on_click(cx.listener({
- let session_id = session_id.clone();
- 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_tool_call(
- session_id.clone(),
- tool_call_id.clone(),
- option_id.clone(),
- option_kind,
- window,
- cx,
- );
+ this.authorize_pending_with_granularity(false, window, cx);
}
})),
),
)
- .child(self.render_permission_granularity_dropdown(
- choices,
- dropdown_label,
- entry_ix,
- session_id,
- tool_call_id,
- selected_index,
- is_first,
- cx,
- ))
+ .child(dropdown)
}
fn render_permission_granularity_dropdown(
@@ -5922,7 +6102,6 @@ impl ThreadView {
choices: &[PermissionOptionChoice],
current_label: SharedString,
entry_ix: usize,
- session_id: acp::SessionId,
tool_call_id: acp::ToolCallId,
selected_index: usize,
is_first: bool,
@@ -5936,8 +6115,6 @@ impl ThreadView {
let permission_dropdown_handle = self.permission_dropdown_handle.clone();
- let conversation = self.conversation.clone();
-
PopoverMenu::new(("permission-granularity", entry_ix))
.with_handle(permission_dropdown_handle)
.trigger(
@@ -5960,8 +6137,6 @@ impl ThreadView {
}),
)
.menu(move |window, cx| {
- let session_id = session_id.clone();
- let conversation = conversation.clone();
let tool_call_id = tool_call_id.clone();
let options = menu_options.clone();
@@ -5969,23 +6144,22 @@ impl ThreadView {
for (index, display_name) in options.iter() {
let display_name = display_name.clone();
let index = *index;
- let session_id = session_id.clone();
- let conversation = conversation.clone();
- let tool_call_id = tool_call_id.clone();
+ 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| {
- conversation.update(cx, |conversation, _cx| {
- conversation.set_selected_permission_granularity(
- session_id.clone(),
- tool_call_id.clone(),
+ move |window, cx| {
+ window.dispatch_action(
+ SelectPermissionGranularity {
+ tool_call_id: tool_call_id_for_entry.0.to_string(),
index,
- );
- });
+ }
+ .boxed_clone(),
+ cx,
+ );
},
);
}
@@ -5996,6 +6170,193 @@ impl ThreadView {
.into_any_element()
}
+ fn render_permission_granularity_dropdown_with_patterns(
+ &self,
+ choices: &[PermissionOptionChoice],
+ patterns: &[PermissionPattern],
+ _tool_name: &str,
+ current_label: SharedString,
+ entry_ix: usize,
+ tool_call_id: acp::ToolCallId,
+ is_first: bool,
+ cx: &Context<Self>,
+ ) -> AnyElement {
+ let default_choice_index = choices.len().saturating_sub(1);
+ let menu_options: Vec<(usize, SharedString)> = choices
+ .iter()
+ .enumerate()
+ .map(|(i, choice)| (i, choice.label()))
+ .collect();
+
+ let pattern_options: Vec<(usize, SharedString)> = patterns
+ .iter()
+ .enumerate()
+ .map(|(i, cp)| {
+ (
+ i,
+ SharedString::from(format!("Always for `{}` commands", cp.display_name)),
+ )
+ })
+ .collect();
+
+ let pattern_count = patterns.len();
+ let permission_dropdown_handle = self.permission_dropdown_handle.clone();
+ let view = cx.entity().downgrade();
+
+ PopoverMenu::new(("permission-granularity", entry_ix))
+ .with_handle(permission_dropdown_handle.clone())
+ .anchor(Corner::TopRight)
+ .attach(Corner::BottomRight)
+ .trigger(
+ Button::new(("granularity-trigger", entry_ix), current_label)
+ .end_icon(
+ Icon::new(IconName::ChevronDown)
+ .size(IconSize::XSmall)
+ .color(Color::Muted),
+ )
+ .label_size(LabelSize::Small)
+ .when(is_first, |this| {
+ this.key_binding(
+ KeyBinding::for_action_in(
+ &crate::OpenPermissionDropdown as &dyn Action,
+ &self.focus_handle(cx),
+ cx,
+ )
+ .map(|kb| kb.size(rems_from_px(10.))),
+ )
+ }),
+ )
+ .menu(move |window, cx| {
+ let tool_call_id = tool_call_id.clone();
+ let options = menu_options.clone();
+ let patterns = pattern_options.clone();
+ let view = view.clone();
+ let dropdown_handle = permission_dropdown_handle.clone();
+
+ Some(ContextMenu::build_persistent(
+ window,
+ cx,
+ move |menu, _window, cx| {
+ let mut menu = menu;
+
+ // Read fresh selection state from the view on each rebuild.
+ let selection: Option<PermissionSelection> = view.upgrade().and_then(|v| {
+ let view = v.read(cx);
+ view.permission_selections.get(&tool_call_id).cloned()
+ });
+
+ let is_pattern_mode =
+ matches!(selection, Some(PermissionSelection::SelectedPatterns(_)));
+
+ // Granularity choices: "Always for terminal", "Only this time"
+ 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 = !is_pattern_mode
+ && selection
+ .as_ref()
+ .and_then(|s| s.choice_index())
+ .map_or(index == default_choice_index, |ci| ci == index);
+
+ let view = view.clone();
+ menu = menu.toggleable_entry(
+ display_name,
+ is_selected,
+ IconPosition::End,
+ None,
+ move |_window, cx| {
+ view.update(cx, |this, cx| {
+ this.permission_selections.insert(
+ tool_call_id_for_entry.clone(),
+ PermissionSelection::Choice(index),
+ );
+ cx.notify();
+ })
+ .log_err();
+ },
+ );
+ }
+
+ menu = menu.separator().header("Select Optionsβ¦");
+
+ for (pattern_index, label) in patterns.iter() {
+ let label = label.clone();
+ let pattern_index = *pattern_index;
+ let tool_call_id_for_pattern = tool_call_id.clone();
+ let is_checked = selection
+ .as_ref()
+ .is_some_and(|s| s.is_pattern_checked(pattern_index));
+
+ let view = view.clone();
+ menu = menu.toggleable_entry(
+ label,
+ is_checked,
+ IconPosition::End,
+ None,
+ move |_window, cx| {
+ view.update(cx, |this, cx| {
+ let selection = this
+ .permission_selections
+ .get_mut(&tool_call_id_for_pattern);
+
+ match selection {
+ Some(PermissionSelection::SelectedPatterns(_)) => {
+ // Already in pattern mode β toggle.
+ this.permission_selections
+ .get_mut(&tool_call_id_for_pattern)
+ .expect("just matched above")
+ .toggle_pattern(pattern_index);
+ }
+ _ => {
+ // First click: activate pattern mode
+ // with all patterns checked.
+ this.permission_selections.insert(
+ tool_call_id_for_pattern.clone(),
+ PermissionSelection::SelectedPatterns(
+ (0..pattern_count).collect(),
+ ),
+ );
+ }
+ }
+ cx.notify();
+ })
+ .log_err();
+ },
+ );
+ }
+
+ let any_patterns_checked = selection
+ .as_ref()
+ .is_some_and(|s| s.has_any_checked_patterns());
+ let dropdown_handle = dropdown_handle.clone();
+ menu = menu.custom_row(move |_window, _cx| {
+ div()
+ .py_1()
+ .w_full()
+ .child(
+ Button::new("apply-patterns", "Apply")
+ .full_width()
+ .style(ButtonStyle::Outlined)
+ .label_size(LabelSize::Small)
+ .disabled(!any_patterns_checked)
+ .on_click({
+ let dropdown_handle = dropdown_handle.clone();
+ move |_event, _window, cx| {
+ dropdown_handle.hide(cx);
+ }
+ }),
+ )
+ .into_any_element()
+ });
+
+ menu
+ },
+ ))
+ })
+ .into_any_element()
+ }
+
fn render_permission_buttons_flat(
&self,
session_id: acp::SessionId,
@@ -6073,7 +6434,7 @@ impl ThreadView {
this.authorize_tool_call(
session_id.clone(),
tool_call_id.clone(),
- option_id.clone(),
+ option_id.clone().into(),
option_kind,
window,
cx,
@@ -7677,7 +8038,10 @@ impl ThreadView {
window: &mut Window,
cx: &mut Context<Self>,
) {
- self.permission_dropdown_handle.clone().toggle(window, cx);
+ let menu_handle = self.permission_dropdown_handle.clone();
+ window.defer(cx, move |window, cx| {
+ menu_handle.toggle(window, cx);
+ });
}
fn open_add_context_menu(
@@ -7816,6 +8180,8 @@ impl Render for ThreadView {
.on_action(cx.listener(Self::allow_once))
.on_action(cx.listener(Self::reject_once))
.on_action(cx.listener(Self::handle_authorize_tool_call))
+ .on_action(cx.listener(Self::handle_select_permission_granularity))
+ .on_action(cx.listener(Self::handle_toggle_command_pattern))
.on_action(cx.listener(Self::open_permission_dropdown))
.on_action(cx.listener(Self::open_add_context_menu))
.on_action(cx.listener(|this, _: &ToggleFastMode, _window, cx| {