From 23f2fb6089b014910ce47c49c2ccd17a5070f728 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Wed, 5 Nov 2025 18:17:50 -0500 Subject: [PATCH 1/4] Run ACP login from same cwd as agent server (#42038) This makes it possible to do login via things like `cmd: "node", args: ["my-node-file.js", "login"]` Also, that command will now use Zed's managed `node` instance. Release Notes: - ACP extensions can now run terminal login commands using relative paths --- crates/agent_ui/src/acp/thread_view.rs | 123 +++++++++++++++------ crates/extension/src/extension_manifest.rs | 4 + crates/project/src/agent_server_store.rs | 54 ++++++++- 3 files changed, 146 insertions(+), 35 deletions(-) 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/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 { From 2664596a34a2c59968f85d74eef45c5c5120e92d Mon Sep 17 00:00:00 2001 From: Cyandev Date: Thu, 6 Nov 2025 07:19:32 +0800 Subject: [PATCH 2/4] gpui: Fix incorrect handling of Function key modifier on macOS (#38518) On macOS, the Function key is reserved for system use and should not be used in application code. This commit updated keystroke matching and key event handling to ignore the Function key modifier while users are typing or pressing keybindings. For some keyboards with compact layout (like my 65% keyboard), there is no separated backtick key. Esc and it shares the same physical key. To input backtick, users may press `Fn-Esc`. However, macOS will still deliver events with Fn key modifier to applications. Cocoa framework can handle this correctly, which typically ignore the Fn directly. GPUI should also follow the same rule, otherwise, the backtick key on those keyboards won't work. Release Notes: - Fixed a bug where typing fn-\` on macOS would not insert a `. --- crates/gpui/src/platform/keystroke.rs | 8 ++++++++ crates/gpui/src/platform/mac/window.rs | 6 +++--- 2 files changed, 11 insertions(+), 3 deletions(-) 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/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; } From eccdfed32b6cfd3d7bb687e32f2c1de7665afb31 Mon Sep 17 00:00:00 2001 From: Sean Timm Date: Wed, 5 Nov 2025 17:35:52 -0700 Subject: [PATCH 3/4] gpui: Convert macOS clipboard file URLs to paths for paste (#36848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - On macOS, pasting now inserts the actual file path when the clipboard contains a file URL (public.file-url/public.url) - Terminal paste remains text-only; no temp files or data URLs are created. If only raw image bytes exist on the clipboard, paste is a no-op. - Scope: macOS only; no dependency changes. - Added a test (test_file_url_converts_to_path) that verifies URL→path conversion using a unique pasteboard. Release Notes: - Improved pasting on macOS: now inserts the actual file path when the clipboard contains a file URL (enables image paste support for Claude Code) --- crates/gpui/src/platform/mac/platform.rs | 63 +++++++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) 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) }; From 39513e2831e89ba45250108088f5a310938cfaa2 Mon Sep 17 00:00:00 2001 From: Cleiton Moura Loura Date: Wed, 5 Nov 2025 21:58:40 -0300 Subject: [PATCH 4/4] fix: Correct workflow name for run_unit_evals The run_unit_evals.yml workflow was incorrectly named 'run_agent_evals' due to both workflows being generated from the same module. This caused duplicate workflow names in GitHub Actions. Changes: - Updated run_agent_evals.rs to explicitly set the workflow name for run_unit_evals to 'run_unit_evals' - Regenerated workflows using 'cargo xtask workflows' - The workflow file now correctly identifies itself as 'run_unit_evals' This fixes potential CI/CD issues where GitHub Actions would be confused by duplicate workflow names. --- .github/workflows/run_unit_evals.yml | 4 ++-- tooling/xtask/src/tasks/workflows/run_agent_evals.rs | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) 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/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.