diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 2973403397..258d222876 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -162,6 +162,8 @@ impl SandboxPermission { #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] pub struct SandboxAuthorizationDetails { + #[serde(default)] + pub command: Option, #[serde(default)] pub network: bool, #[serde(default)] diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 2b212bb9e7..9300a31442 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -5242,6 +5242,7 @@ impl ToolCallEventStream { pub(crate) fn authorize_sandbox( &self, title: impl Into, + command: Option, request: SandboxRequest, cx: &mut App, ) -> Task> { @@ -5251,6 +5252,7 @@ impl ToolCallEventStream { let title = title.into(); let sandbox_authorization_details = acp_thread::SandboxAuthorizationDetails { + command, network: request.network, allow_fs_write_all: request.allow_fs_write_all, unsandboxed: request.unsandboxed, @@ -6822,12 +6824,13 @@ mod tests { }; let authorize = cx.update(|cx| { - event_stream.authorize_sandbox("Allow write access?", request.clone(), cx) + event_stream.authorize_sandbox("Allow write access?", None, request.clone(), cx) }); let authorization = receiver.expect_authorization().await; let details = acp_thread::sandbox_authorization_details_from_meta(&authorization.tool_call.meta) .expect("sandbox authorization should include request details"); + assert_eq!(details.command, None); assert_eq!(details.network, request.network); assert_eq!(details.allow_fs_write_all, request.allow_fs_write_all); assert_eq!(details.unsandboxed, request.unsandboxed); diff --git a/crates/agent/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs index 241299dbf8..fd1f333c9e 100644 --- a/crates/agent/src/tools/terminal_tool.rs +++ b/crates/agent/src/tools/terminal_tool.rs @@ -348,7 +348,9 @@ async fn run_terminal_tool( if request.needs_escalation() { let title = sandbox_approval_title(&request); - let approve = cx.update(|cx| event_stream.authorize_sandbox(title, request.clone(), cx)); + let command = Some(input.command.clone()); + let approve = + cx.update(|cx| event_stream.authorize_sandbox(title, command, request.clone(), cx)); if let Err(error) = approve.await { if want_unsandboxed { return Ok(format!( @@ -2490,6 +2492,7 @@ mod tests { let details = acp_thread::sandbox_authorization_details_from_meta(&authorization.tool_call.meta) .expect("legacy allow_fs_write should request sandbox authorization details"); + assert_eq!(details.command.as_deref(), Some("echo hi")); assert!(!details.network); assert!(details.allow_fs_write_all); assert!(!details.unsandboxed); @@ -2586,6 +2589,7 @@ mod tests { let details = acp_thread::sandbox_authorization_details_from_meta(&authorization.tool_call.meta) .expect("unsandboxed should request sandbox authorization details"); + assert_eq!(details.command.as_deref(), Some("echo hi")); assert!(!details.network); assert!(!details.allow_fs_write_all); assert!(details.unsandboxed); diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 691e676f8e..222eb23089 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -7789,7 +7789,11 @@ impl ThreadView { details: &SandboxAuthorizationDetails, cx: &Context, ) -> AnyElement { - if details.write_paths.is_empty() { + let command = details + .command + .as_deref() + .filter(|command| !command.trim().is_empty()); + if command.is_none() && details.write_paths.is_empty() { return Empty.into_any_element(); } @@ -7798,63 +7802,75 @@ impl ThreadView { .contains(tool_call_id); let mut paths = details.write_paths.clone(); paths.sort(); + let has_command = command.is_some(); v_flex() .border_t_1() .border_color(self.tool_card_border_color(cx)) - .child( - h_flex() - .id(("sandbox-authorization-details-header", entry_ix)) - .p_1() - .justify_between() - .cursor_pointer() - .hover(|style| style.bg(cx.theme().colors().element_hover)) - .child( - h_flex() - .gap_1() - .child( - Label::new("Write access") + .when_some(command, |this, command| { + this.child(Self::render_sandbox_authorization_command( + entry_ix, command, cx, + )) + }) + .when(!paths.is_empty(), |this| { + this.child( + h_flex() + .id(("sandbox-authorization-details-header", entry_ix)) + .p_1() + .justify_between() + .cursor_pointer() + .hover(|style| style.bg(cx.theme().colors().element_hover)) + .when(has_command, |this| { + this.border_t_1() + .border_color(self.tool_card_border_color(cx)) + }) + .child( + h_flex() + .gap_1() + .child( + Label::new("Write access") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + Label::new("•") + .size(LabelSize::XSmall) + .color(Color::Disabled), + ) + .child( + Label::new(format!( + "{} {}", + paths.len(), + if paths.len() == 1 { "path" } else { "paths" } + )) .size(LabelSize::Small) .color(Color::Muted), - ) - .child( - Label::new("•") - .size(LabelSize::XSmall) - .color(Color::Disabled), - ) - .child( - Label::new(format!( - "{} {}", - paths.len(), - if paths.len() == 1 { "path" } else { "paths" } - )) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .child( - Disclosure::new(("sandbox-authorization-details", entry_ix), is_open) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown), - ) - .on_click(cx.listener({ - let tool_call_id = tool_call_id.clone(); - move |this, _event, _window, cx| { - if this - .collapsed_sandbox_authorization_details - .remove(&tool_call_id) - { - cx.notify(); - return; - } + ), + ) + .child( + Disclosure::new(("sandbox-authorization-details", entry_ix), is_open) + .opened_icon(IconName::ChevronUp) + .closed_icon(IconName::ChevronDown), + ) + .on_click(cx.listener({ + let tool_call_id = tool_call_id.clone(); + move |this, _event, _window, cx| { + if this + .collapsed_sandbox_authorization_details + .remove(&tool_call_id) + { + cx.notify(); + return; + } - this.collapsed_sandbox_authorization_details - .insert(tool_call_id.clone()); - cx.notify(); - } - })), - ) - .when(is_open, |this| { + this.collapsed_sandbox_authorization_details + .insert(tool_call_id.clone()); + cx.notify(); + } + })), + ) + }) + .when(is_open && !paths.is_empty(), |this| { this.child( v_flex() .id(("sandbox-authorization-paths-list", entry_ix)) @@ -7874,6 +7890,49 @@ impl ThreadView { .into_any_element() } + fn render_sandbox_authorization_command(entry_ix: usize, command: &str, cx: &App) -> Div { + let group = SharedString::from(format!("sandbox-authorization-command-{entry_ix}")); + let command = SharedString::from(command.to_string()); + + v_flex() + .group(group.clone()) + .relative() + .p_1p5() + .gap_1() + .bg(cx.theme().colors().editor_background) + .child( + Label::new("Command") + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .child( + div() + .id(("sandbox-authorization-command-scroll", entry_ix)) + .flex() + .flex_1() + .w_full() + .min_w_0() + .overflow_x_scroll() + .whitespace_nowrap() + .rounded_sm() + .border_1() + .border_color(cx.theme().colors().border) + .p_1() + .child( + Label::new(command.clone()) + .buffer_font(cx) + .size(LabelSize::XSmall), + ), + ) + .child( + div().absolute().top_1().right_1().child( + CopyButton::new("copy-sandbox-authorization-command", command) + .tooltip_label("Copy Command") + .visible_on_hover(group), + ), + ) + } + fn render_sandbox_authorization_path_row( &self, entry_ix: usize,