Terminal permissions: Per-command pipeline UI (#49547)
## Summary Adds a new permission UI for terminal pipeline commands (e.g. `cargo test | tail`) that lets users selectively always-allow individual commands in the pipeline, rather than only offering a blanket always-allow for the first command. ## Screenshot <img width="464" height="293" alt="Screenshot 2026-03-18 at 3 27 48 PM" src="https://github.com/user-attachments/assets/e027eeec-c2b3-4f73-a596-95f42a9adad2" /> Release notes: - The terminal permissions UI now allows you to select individual subcommands independently. --------- Co-authored-by: Danilo Leal <daniloleal09@gmail.com> Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
This commit is contained in:
21 files changed
+1129
-342
No files matched your search
@@ -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]
|
||||
|
||||
+182
-134
@@ -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(());
|
||||
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"))
|
||||
}
|
||||
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(());
|
||||
}
|
||||
|
||||
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,91 +3735,119 @@ 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"));
|
||||
}
|
||||
|
||||
// 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(());
|
||||
}
|
||||
|
||||
// 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" (allow once)
|
||||
if response_str == "allow" {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Handle simple "deny" (deny once)
|
||||
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))
|
||||
});
|
||||
|
||||
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 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";
|
||||
}
|
||||
|
||||
debug_assert!(false, "unexpected permission option_id: {option_id}");
|
||||
false
|
||||
}
|
||||
|
||||
/// 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
|
||||
@@ -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)
|
||||
let selection = self.permission_selections.get(&tool_call_id);
|
||||
|
||||
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());
|
||||
|
||||
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,
|
||||
)
|
||||
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| {
|
||||
|
||||
Reference in new issue
Block a user