Skip to content
This repository was archived by the owner on Nov 10, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/run_unit_evals.yml
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
123 changes: 89 additions & 34 deletions crates/agent_ui/src/acp/thread_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1506,6 +1506,12 @@ impl AcpThreadView {
})
.unwrap_or_default();

// Run SpawnInTerminal in the same dir as the ACP server
let cwd = connection
.clone()
.downcast::<agent_servers::AcpConnection>()
.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)),
Expand All @@ -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,
Expand All @@ -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, {
Expand Down Expand Up @@ -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(()))
}
Expand Down Expand Up @@ -1721,17 +1732,40 @@ impl AcpThreadView {
fn spawn_external_agent_login(
login: task::SpawnInTerminal,
workspace: Entity<Workspace>,
project: Entity<Project>,
previous_attempt: bool,
check_exit_code: bool,
window: &mut Window,
cx: &mut App,
) -> Task<Result<()>> {
let Some(terminal_panel) = workspace.read(cx).panel::<TerminalPanel>(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),
Expand All @@ -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(())
})
}

Expand Down
4 changes: 4 additions & 0 deletions crates/extension/src/extension_manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, TargetConfig>,
}

Expand Down
8 changes: 8 additions & 0 deletions crates/gpui/src/platform/keystroke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
63 changes: 61 additions & 2 deletions crates/gpui/src/platform/mac/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1124,15 +1124,40 @@ 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);
}
}
}

// 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
}

Expand Down Expand Up @@ -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) };
Expand Down
6 changes: 3 additions & 3 deletions crates/gpui/src/platform/mac/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
54 changes: 53 additions & 1 deletion crates/project/src/agent_server_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,13 @@ impl AgentServerStore {
cx.emit(AgentServersUpdated);
}

pub fn node_runtime(&self) -> Option<NodeRuntime> {
match &self.state {
AgentServerStoreState::Local { node_runtime, .. } => Some(node_runtime.clone()),
_ => None,
}
}

pub fn local(
node_runtime: NodeRuntime,
fs: Arc<dyn Fs>,
Expand Down Expand Up @@ -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))
})
}

Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion tooling/xtask/src/tasks/workflows/run_agent_evals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down