diff --git a/.github/workflows/run_unit_evals.yml b/.github/workflows/run_unit_evals.yml index e4a22c3f164b78..86adb72ec93e53 100644 --- a/.github/workflows/run_unit_evals.yml +++ b/.github/workflows/run_unit_evals.yml @@ -1,6 +1,6 @@ -# Generated from xtask::workflows::run_agent_evals +# Generated from xtask::workflows::run_unit_evals # Rebuild with `cargo xtask workflows`. -name: run_agent_evals +name: run_unit_evals env: CARGO_TERM_COLOR: always CARGO_INCREMENTAL: '0' diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 8ef7c7bbb0597e..db43fe5f8a27d8 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -1506,6 +1506,12 @@ impl AcpThreadView { }) .unwrap_or_default(); + // Run SpawnInTerminal in the same dir as the ACP server + let cwd = connection + .clone() + .downcast::() + .map(|acp_conn| acp_conn.root_dir().to_path_buf()); + // Build SpawnInTerminal from _meta let login = task::SpawnInTerminal { id: task::TaskId(format!("external-agent-{}-login", label)), @@ -1514,6 +1520,7 @@ impl AcpThreadView { command: Some(command.to_string()), args, command_label: label.to_string(), + cwd, env, use_new_terminal: true, allow_concurrent_runs: true, @@ -1526,8 +1533,9 @@ impl AcpThreadView { pending_auth_method.replace(method.clone()); if let Some(workspace) = self.workspace.upgrade() { + let project = self.project.clone(); let authenticate = Self::spawn_external_agent_login( - login, workspace, false, window, cx, + login, workspace, project, false, true, window, cx, ); cx.notify(); self.auth_task = Some(cx.spawn_in(window, { @@ -1671,7 +1679,10 @@ impl AcpThreadView { && let Some(login) = self.login.clone() { if let Some(workspace) = self.workspace.upgrade() { - Self::spawn_external_agent_login(login, workspace, false, window, cx) + let project = self.project.clone(); + Self::spawn_external_agent_login( + login, workspace, project, false, false, window, cx, + ) } else { Task::ready(Ok(())) } @@ -1721,17 +1732,40 @@ impl AcpThreadView { fn spawn_external_agent_login( login: task::SpawnInTerminal, workspace: Entity, + project: Entity, previous_attempt: bool, + check_exit_code: bool, window: &mut Window, cx: &mut App, ) -> Task> { let Some(terminal_panel) = workspace.read(cx).panel::(cx) else { return Task::ready(Ok(())); }; - let project = workspace.read(cx).project().clone(); window.spawn(cx, async move |cx| { let mut task = login.clone(); + if let Some(cmd) = &task.command { + // Have "node" command use Zed's managed Node runtime by default + if cmd == "node" { + let resolved_node_runtime = project + .update(cx, |project, cx| { + let agent_server_store = project.agent_server_store().clone(); + agent_server_store.update(cx, |store, cx| { + store.node_runtime().map(|node_runtime| { + cx.background_spawn(async move { + node_runtime.binary_path().await + }) + }) + }) + }); + + if let Ok(Some(resolve_task)) = resolved_node_runtime { + if let Ok(node_path) = resolve_task.await { + task.command = Some(node_path.to_string_lossy().to_string()); + } + } + } + } task.shell = task::Shell::WithArguments { program: task.command.take().expect("login command should be set"), args: std::mem::take(&mut task.args), @@ -1749,44 +1783,65 @@ impl AcpThreadView { })?; let terminal = terminal.await?; - let mut exit_status = terminal - .read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))? - .fuse(); - - let logged_in = cx - .spawn({ - let terminal = terminal.clone(); - async move |cx| { - loop { - cx.background_executor().timer(Duration::from_secs(1)).await; - let content = - terminal.update(cx, |terminal, _cx| terminal.get_content())?; - if content.contains("Login successful") - || content.contains("Type your message") - { - return anyhow::Ok(()); + + if check_exit_code { + // For extension-based auth, wait for the process to exit and check exit code + let exit_status = terminal + .read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))? + .await; + + match exit_status { + Some(status) if status.success() => { + Ok(()) + } + Some(status) => { + Err(anyhow!("Login command failed with exit code: {:?}", status.code())) + } + None => { + Err(anyhow!("Login command terminated without exit status")) + } + } + } else { + // For hardcoded agents (claude-login, gemini-cli): look for specific output + let mut exit_status = terminal + .read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))? + .fuse(); + + let logged_in = cx + .spawn({ + let terminal = terminal.clone(); + async move |cx| { + loop { + cx.background_executor().timer(Duration::from_secs(1)).await; + let content = + terminal.update(cx, |terminal, _cx| terminal.get_content())?; + if content.contains("Login successful") + || content.contains("Type your message") + { + return anyhow::Ok(()); + } } } + }) + .fuse(); + futures::pin_mut!(logged_in); + futures::select_biased! { + result = logged_in => { + if let Err(e) = result { + log::error!("{e}"); + return Err(anyhow!("exited before logging in")); + } } - }) - .fuse(); - futures::pin_mut!(logged_in); - futures::select_biased! { - result = logged_in => { - if let Err(e) = result { - log::error!("{e}"); + _ = exit_status => { + if !previous_attempt && project.read_with(cx, |project, _| project.is_via_remote_server())? && login.label.contains("gemini") { + return cx.update(|window, cx| Self::spawn_external_agent_login(login, workspace, project.clone(), true, false, window, cx))?.await + } return Err(anyhow!("exited before logging in")); } } - _ = exit_status => { - if !previous_attempt && project.read_with(cx, |project, _| project.is_via_remote_server())? && login.label.contains("gemini") { - return cx.update(|window, cx| Self::spawn_external_agent_login(login, workspace, true, window, cx))?.await - } - return Err(anyhow!("exited before logging in")); - } + terminal.update(cx, |terminal, _| terminal.kill_active_task())?; + Ok(()) } - terminal.update(cx, |terminal, _| terminal.kill_active_task())?; - Ok(()) }) } diff --git a/crates/extension/src/extension_manifest.rs b/crates/extension/src/extension_manifest.rs index ab620ec6dbfc02..7e074ffcab77ce 100644 --- a/crates/extension/src/extension_manifest.rs +++ b/crates/extension/src/extension_manifest.rs @@ -173,6 +173,10 @@ pub struct AgentServerManifestEntry { /// cmd = "node" /// args = ["index.js", "--port", "3000"] /// ``` + /// + /// Note: All commands are executed with the archive extraction directory as the + /// working directory, so relative paths in args (like "index.js") will resolve + /// relative to the extracted archive contents. pub targets: HashMap, } diff --git a/crates/gpui/src/platform/keystroke.rs b/crates/gpui/src/platform/keystroke.rs index 4a2bfc785e3eb7..e1f1b0c9fbba53 100644 --- a/crates/gpui/src/platform/keystroke.rs +++ b/crates/gpui/src/platform/keystroke.rs @@ -572,6 +572,14 @@ impl Modifiers { } } + /// Returns [`Modifiers`] with just function. + pub fn function() -> Modifiers { + Modifiers { + function: true, + ..Default::default() + } + } + /// Returns [`Modifiers`] with command + shift. pub fn command_shift() -> Modifiers { Modifiers { diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index 244350169caffe..4e0ee9f2c5773c 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -1124,7 +1124,32 @@ impl Platform for MacPlatform { } } - // If it wasn't a string, try the various supported image types. + // Next, check for URL flavors (including file URLs). Some tools only provide a URL + // with no plain text entry. + { + // Try the modern UTType identifiers first. + let file_url_type: id = ns_string("public.file-url"); + let url_type: id = ns_string("public.url"); + + let url_data = if msg_send![types, containsObject: file_url_type] { + pasteboard.dataForType(file_url_type) + } else if msg_send![types, containsObject: url_type] { + pasteboard.dataForType(url_type) + } else { + nil + }; + + if url_data != nil && !url_data.bytes().is_null() { + let bytes = slice::from_raw_parts( + url_data.bytes() as *mut u8, + url_data.length() as usize, + ); + + return Some(self.read_string_from_clipboard(&state, bytes)); + } + } + + // If it wasn't a string or URL, try the various supported image types. for format in ImageFormat::iter() { if let Some(item) = try_clipboard_image(pasteboard, format) { return Some(item); @@ -1132,7 +1157,7 @@ impl Platform for MacPlatform { } } - // If it wasn't a string or a supported image type, give up. + // If it wasn't a string, URL, or a supported image type, give up. None } @@ -1707,6 +1732,40 @@ mod tests { ); } + #[test] + fn test_file_url_reads_as_url_string() { + let platform = build_platform(); + + // Create a file URL for an arbitrary test path and write it to the pasteboard. + // This path does not need to exist; we only validate URL→path conversion. + let mock_path = "/tmp/zed-clipboard-file-url-test"; + unsafe { + // Build an NSURL from the file path + let url: id = msg_send![class!(NSURL), fileURLWithPath: ns_string(mock_path)]; + let abs: id = msg_send![url, absoluteString]; + + // Encode the URL string as UTF-8 bytes + let len: usize = msg_send![abs, lengthOfBytesUsingEncoding: NSUTF8StringEncoding]; + let bytes_ptr = abs.UTF8String() as *const u8; + let data = NSData::dataWithBytes_length_(nil, bytes_ptr as *const c_void, len as u64); + + // Write as public.file-url to the unique pasteboard + let file_url_type: id = ns_string("public.file-url"); + platform + .0 + .lock() + .pasteboard + .setData_forType(data, file_url_type); + } + + // Ensure the clipboard read returns the URL string, not a converted path + let expected_url = format!("file://{}", mock_path); + assert_eq!( + platform.read_from_clipboard(), + Some(ClipboardItem::new_string(expected_url)) + ); + } + fn build_platform() -> MacPlatform { let platform = MacPlatform::new(false); platform.0.lock().pasteboard = unsafe { NSPasteboard::pasteboardWithUniqueName(nil) }; diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index 9c56d24e6857ca..11ea4fb7e272c0 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -1753,9 +1753,9 @@ extern "C" fn handle_key_event(this: &Object, native_event: id, key_equivalent: } } - // Don't send key equivalents to the input handler, - // or macOS shortcuts like cmd-` will stop working. - if key_equivalent { + // Don't send key equivalents to the input handler if there are key modifiers other + // than Function key, or macOS shortcuts like cmd-` will stop working. + if key_equivalent && key_down_event.keystroke.modifiers != Modifiers::function() { return NO; } diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index c710d96efa2752..2fc218cf0c1930 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -438,6 +438,13 @@ impl AgentServerStore { cx.emit(AgentServersUpdated); } + pub fn node_runtime(&self) -> Option { + match &self.state { + AgentServerStoreState::Local { node_runtime, .. } => Some(node_runtime.clone()), + _ => None, + } + } + pub fn local( node_runtime: NodeRuntime, fs: Arc, @@ -1560,7 +1567,7 @@ impl ExternalAgentServer for LocalExtensionArchiveAgent { env: Some(env), }; - Ok((command, root_dir.to_string_lossy().into_owned(), None)) + Ok((command, version_dir.to_string_lossy().into_owned(), None)) }) } @@ -1946,6 +1953,51 @@ mod extension_agent_tests { assert_eq!(target.args, vec!["index.js"]); } + #[gpui::test] + async fn test_commands_run_in_extraction_directory(cx: &mut TestAppContext) { + let fs = fs::FakeFs::new(cx.background_executor.clone()); + let http_client = http_client::FakeHttpClient::with_404_response(); + let node_runtime = NodeRuntime::unavailable(); + let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone())); + let project_environment = cx.new(|cx| { + crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx) + }); + + let agent = LocalExtensionArchiveAgent { + fs: fs.clone(), + http_client, + node_runtime, + project_environment, + extension_id: Arc::from("test-ext"), + agent_id: Arc::from("test-agent"), + targets: { + let mut map = HashMap::default(); + map.insert( + "darwin-aarch64".to_string(), + extension::TargetConfig { + archive: "https://example.com/test.zip".into(), + cmd: "node".into(), + args: vec![ + "server.js".into(), + "--config".into(), + "./config.json".into(), + ], + sha256: None, + }, + ); + map + }, + env: HashMap::default(), + }; + + // Verify the agent is configured with relative paths in args + let target = agent.targets.get("darwin-aarch64").unwrap(); + assert_eq!(target.args[0], "server.js"); + assert_eq!(target.args[2], "./config.json"); + // These relative paths will resolve relative to the extraction directory + // when the command is executed + } + #[test] fn test_tilde_expansion_in_settings() { let settings = settings::BuiltinAgentServerSettings { diff --git a/tooling/xtask/src/tasks/workflows/run_agent_evals.rs b/tooling/xtask/src/tasks/workflows/run_agent_evals.rs index 1af09f6ca8fa0b..8b520971d4b3c0 100644 --- a/tooling/xtask/src/tasks/workflows/run_agent_evals.rs +++ b/tooling/xtask/src/tasks/workflows/run_agent_evals.rs @@ -57,7 +57,8 @@ fn agent_evals() -> NamedJob { pub(crate) fn run_unit_evals() -> Workflow { let unit_evals = unit_evals(); - named::workflow() + Workflow::default() + .name("run_unit_evals".to_string()) .on(Event::default() .schedule([ // GitHub might drop jobs at busy times, so we choose a random time in the middle of the night.