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/editor/src/element.rs b/crates/editor/src/element.rs index 0053afe1637309..0f0d9f6e0abc25 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -1701,9 +1701,7 @@ impl EditorElement { len, font, color, - background_color: None, - strikethrough: None, - underline: None, + ..Default::default() }], None, ) @@ -3583,9 +3581,7 @@ impl EditorElement { len: line.len(), font: style.text.font(), color: placeholder_color, - background_color: None, - underline: None, - strikethrough: None, + ..Default::default() }; let line = window.text_system().shape_line( line.to_string().into(), @@ -7440,9 +7436,7 @@ impl EditorElement { len: column, font: style.text.font(), color: Hsla::default(), - background_color: None, - underline: None, - strikethrough: None, + ..Default::default() }], None, ); @@ -7465,9 +7459,7 @@ impl EditorElement { len: text.len(), font: self.style.text.font(), color, - background_color: None, - underline: None, - strikethrough: None, + ..Default::default() }; window.text_system().shape_line( text, @@ -9568,9 +9560,7 @@ impl Element for EditorElement { len: tab_len, font: self.style.text.font(), color: cx.theme().colors().editor_invisible, - background_color: None, - underline: None, - strikethrough: None, + ..Default::default() }], None, ); @@ -9584,9 +9574,7 @@ impl Element for EditorElement { len: space_len, font: self.style.text.font(), color: cx.theme().colors().editor_invisible, - background_color: None, - underline: None, - strikethrough: None, + ..Default::default() }], None, ); @@ -11573,11 +11561,8 @@ mod tests { fn generate_test_run(len: usize, color: Hsla) -> TextRun { TextRun { len, - font: gpui::font(".SystemUIFont"), color, - background_color: None, - underline: None, - strikethrough: None, + ..Default::default() } } 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/gpui/src/text_system.rs b/crates/gpui/src/text_system.rs index 0c8a32b16c5273..39f68e3226a816 100644 --- a/crates/gpui/src/text_system.rs +++ b/crates/gpui/src/text_system.rs @@ -739,7 +739,7 @@ impl Display for FontStyle { } /// A styled run of text, for use in [`crate::TextLayout`]. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Default)] pub struct TextRun { /// A number of utf8 bytes pub len: usize, @@ -813,6 +813,12 @@ pub struct Font { pub style: FontStyle, } +impl Default for Font { + fn default() -> Self { + font(".SystemUIFont") + } +} + /// Get a [`Font`] for a given name. pub fn font(family: impl Into) -> Font { Font { diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index 0192a03a3238e8..45159313b43c50 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -315,9 +315,7 @@ impl Boundary { #[cfg(test)] mod tests { use super::*; - use crate::{ - Font, FontFeatures, FontStyle, FontWeight, Hsla, TestAppContext, TestDispatcher, font, - }; + use crate::{Font, FontFeatures, FontStyle, FontWeight, TestAppContext, TestDispatcher, font}; #[cfg(target_os = "macos")] use crate::{TextRun, WindowTextSystem, WrapBoundary}; use rand::prelude::*; @@ -341,10 +339,7 @@ mod tests { weight: FontWeight::default(), style: FontStyle::Normal, }, - color: Hsla::default(), - background_color: None, - underline: None, - strikethrough: None, + ..Default::default() }) .collect() } @@ -691,16 +686,12 @@ mod tests { font: font("Helvetica"), color: Default::default(), underline: Default::default(), - strikethrough: None, - background_color: None, + ..Default::default() }; let bold = TextRun { len: 0, font: font("Helvetica").bold(), - color: Default::default(), - underline: Default::default(), - strikethrough: None, - background_color: None, + ..Default::default() }; let text = "aa bbb cccc ddddd eeee".into(); diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index f6d9be68ab0773..ef7a720e7f6f06 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -918,26 +918,85 @@ pub(crate) struct ElementStateBox { } fn default_bounds(display_id: Option, cx: &mut App) -> Bounds { - const DEFAULT_WINDOW_OFFSET: Point = point(px(0.), px(35.)); - - // TODO, BUG: if you open a window with the currently active window - // on the stack, this will erroneously select the 'unwrap_or_else' - // code path - cx.active_window() - .and_then(|w| w.update(cx, |_, window, _| window.bounds()).ok()) - .map(|mut bounds| { - bounds.origin += DEFAULT_WINDOW_OFFSET; - bounds - }) - .unwrap_or_else(|| { - let display = display_id - .map(|id| cx.find_display(id)) - .unwrap_or_else(|| cx.primary_display()); - - display - .map(|display| display.default_bounds()) - .unwrap_or_else(|| Bounds::new(point(px(0.), px(0.)), DEFAULT_WINDOW_SIZE)) - }) + #[cfg(target_os = "macos")] + { + const CASCADE_OFFSET: f32 = 25.0; + + let display = display_id + .map(|id| cx.find_display(id)) + .unwrap_or_else(|| cx.primary_display()); + + let display_bounds = display + .as_ref() + .map(|d| d.bounds()) + .unwrap_or_else(|| Bounds::new(point(px(0.), px(0.)), DEFAULT_WINDOW_SIZE)); + + // TODO, BUG: if you open a window with the currently active window + // on the stack, this will erroneously select the 'unwrap_or_else' + // code path + let (base_origin, base_size) = cx + .active_window() + .and_then(|w| { + w.update(cx, |_, window, _| { + let bounds = window.bounds(); + (bounds.origin, bounds.size) + }) + .ok() + }) + .unwrap_or_else(|| { + let default_bounds = display + .as_ref() + .map(|d| d.default_bounds()) + .unwrap_or_else(|| Bounds::new(point(px(0.), px(0.)), DEFAULT_WINDOW_SIZE)); + (default_bounds.origin, default_bounds.size) + }); + + let cascade_offset = point(px(CASCADE_OFFSET), px(CASCADE_OFFSET)); + let proposed_origin = base_origin + cascade_offset; + let proposed_bounds = Bounds::new(proposed_origin, base_size); + + let display_right = display_bounds.origin.x + display_bounds.size.width; + let display_bottom = display_bounds.origin.y + display_bounds.size.height; + let window_right = proposed_bounds.origin.x + proposed_bounds.size.width; + let window_bottom = proposed_bounds.origin.y + proposed_bounds.size.height; + + let fits_horizontally = window_right <= display_right; + let fits_vertically = window_bottom <= display_bottom; + + let final_origin = match (fits_horizontally, fits_vertically) { + (true, true) => proposed_origin, + (false, true) => point(display_bounds.origin.x, base_origin.y), + (true, false) => point(base_origin.x, display_bounds.origin.y), + (false, false) => display_bounds.origin, + }; + + Bounds::new(final_origin, base_size) + } + + #[cfg(not(target_os = "macos"))] + { + const DEFAULT_WINDOW_OFFSET: Point = point(px(0.), px(35.)); + + // TODO, BUG: if you open a window with the currently active window + // on the stack, this will erroneously select the 'unwrap_or_else' + // code path + cx.active_window() + .and_then(|w| w.update(cx, |_, window, _| window.bounds()).ok()) + .map(|mut bounds| { + bounds.origin += DEFAULT_WINDOW_OFFSET; + bounds + }) + .unwrap_or_else(|| { + let display = display_id + .map(|id| cx.find_display(id)) + .unwrap_or_else(|| cx.primary_display()); + + display + .as_ref() + .map(|display| display.default_bounds()) + .unwrap_or_else(|| Bounds::new(point(px(0.), px(0.)), DEFAULT_WINDOW_SIZE)) + }) + } } impl Window { 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/crates/repl/src/outputs/table.rs b/crates/repl/src/outputs/table.rs index 2a5864c7bca5f1..f6bf30f394d223 100644 --- a/crates/repl/src/outputs/table.rs +++ b/crates/repl/src/outputs/table.rs @@ -101,9 +101,7 @@ impl TableView { len: 0, font: text_font, color: text_style.color, - background_color: None, - underline: None, - strikethrough: None, + ..Default::default() }]; for field in table.schema.fields.iter() { diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index e06e8e9c636337..4187f5bc3a5228 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -1112,9 +1112,7 @@ impl Element for TerminalElement { len, font: text_style.font(), color: theme.colors().terminal_ansi_background, - background_color: None, - underline: Default::default(), - strikethrough: None, + ..Default::default() }], None, ) @@ -1322,9 +1320,8 @@ impl Element for TerminalElement { len: text_to_mark.len(), font: ime_style.font(), color: ime_style.color, - background_color: None, underline: ime_style.underline, - strikethrough: None, + ..Default::default() }], None ); @@ -1842,27 +1839,21 @@ mod tests { len: 1, font: font("Helvetica"), color: Hsla::red(), - background_color: None, - underline: None, - strikethrough: None, + ..Default::default() }; let style2 = TextRun { len: 1, font: font("Helvetica"), color: Hsla::red(), - background_color: None, - underline: None, - strikethrough: None, + ..Default::default() }; let style3 = TextRun { len: 1, font: font("Helvetica"), color: Hsla::blue(), // Different color - background_color: None, - underline: None, - strikethrough: None, + ..Default::default() }; let font_size = AbsoluteLength::Pixels(px(12.0)); @@ -1881,9 +1872,7 @@ mod tests { len: 1, font: font("Helvetica"), color: Hsla::red(), - background_color: None, - underline: None, - strikethrough: None, + ..Default::default() }; let font_size = AbsoluteLength::Pixels(px(12.0)); @@ -1912,9 +1901,7 @@ mod tests { len: 1, font: font("Helvetica"), color: Hsla::red(), - background_color: None, - underline: None, - strikethrough: None, + ..Default::default() }; let font_size = AbsoluteLength::Pixels(px(12.0)); @@ -1944,9 +1931,7 @@ mod tests { len: 1, font: font("Helvetica"), color: Hsla::red(), - background_color: None, - underline: None, - strikethrough: None, + ..Default::default() }; let font_size = AbsoluteLength::Pixels(px(12.0)); diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 1a4783cdf5342c..789d9025a32f7b 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -80,6 +80,7 @@ - [Theme Extensions](./extensions/themes.md) - [Icon Theme Extensions](./extensions/icon-themes.md) - [Slash Command Extensions](./extensions/slash-commands.md) +- [Agent Server Extensions](./extensions/agent-servers.md) - [MCP Server Extensions](./extensions/mcp-extensions.md) # Language Support diff --git a/docs/src/extensions.md b/docs/src/extensions.md index 5378222e56f3b4..029cae1245dfef 100644 --- a/docs/src/extensions.md +++ b/docs/src/extensions.md @@ -9,4 +9,5 @@ Zed lets you add new functionality using user-defined extensions. - [Developing Themes](./extensions/themes.md) - [Developing Icon Themes](./extensions/icon-themes.md) - [Developing Slash Commands](./extensions/slash-commands.md) + - [Developing Agent Servers](./extensions/agent-servers.md) - [Developing MCP Servers](./extensions/mcp-extensions.md) diff --git a/docs/src/extensions/agent-servers.md b/docs/src/extensions/agent-servers.md new file mode 100644 index 00000000000000..dc9f55b4d77a9e --- /dev/null +++ b/docs/src/extensions/agent-servers.md @@ -0,0 +1,156 @@ +# Agent Server Extensions + +Agent Servers are programs that provide AI agent implementations through the [Agent Client Protocol (ACP)](https://agentclientprotocol.com). Agent Server Extensions let you package up an Agent Server so that users can install the extension and have your agent easily available to use in Zed. + +You can see the current Agent Server Extensions either by opening the Extensions tab in Zed (execute the `zed: extensions` command) and changing the filter from `All` to `Agent Servers`, or by visiting [https://zed.dev/extensions?filter=agent-servers](https://zed.dev/extensions?filter=agent-servers). + +## Defining Agent Server Extensions + +An extension can register one or more agent servers in the `extension.toml` like so: + +```toml +[agent_servers.my-agent] +name = "My Agent" + +[agent_servers.my-agent.targets.darwin-aarch64] +archive = "https://github.com/owner/repo/releases/download/v1.0.0/agent-darwin-arm64.tar.gz" +cmd = "./agent" +args = ["--serve"] + +[agent_servers.my-agent.targets.linux-x86_64] +archive = "https://github.com/owner/repo/releases/download/v1.0.0/agent-linux-x64.tar.gz" +cmd = "./agent" +args = ["--serve"] + +[agent_servers.my-agent.targets.windows-x86_64] +archive = "https://github.com/owner/repo/releases/download/v1.0.0/agent-windows-x64.zip" +cmd = "./agent.exe" +args = ["--serve"] +``` + +### Required Fields + +- `name`: A human-readable display name for the agent server (shown in menus) +- `targets`: Platform-specific configurations for downloading and running the agent + +### Target Configuration + +Each target key uses the format `{os}-{arch}` where: + +- **os**: `darwin` (macOS), `linux`, or `windows` +- **arch**: `aarch64` (ARM64) or `x86_64` + +Each target must specify: + +- `archive`: URL to download the archive from (supports `.tar.gz`, `.zip`, etc.) +- `cmd`: Command to run the agent server (relative to the extracted archive) +- `args`: Command-line arguments to pass to the agent server (optional) + +### Optional Fields + +You can also optionally specify: + +- `sha256`: SHA-256 hash string of the archive's bytes. Zed will check this after the archive is downloaded and give an error if it doesn't match, so doing this improves security. +- `env`: Environment variables to set in the agent's spawned process. +- `icon`: Path to an SVG icon (relative to extension root) for display in menus. + +### Complete Example + +Here's a more complete example with all optional fields: + +```toml +[agent_servers.example-agent] +name = "Example Agent" +icon = "icon/agent.svg" + +[agent_servers.example-agent.env] +AGENT_LOG_LEVEL = "info" +AGENT_MODE = "production" + +[agent_servers.example-agent.targets.darwin-aarch64] +archive = "https://github.com/example/agent/releases/download/v2.0.0/agent-darwin-arm64.tar.gz" +cmd = "./bin/agent" +args = ["serve", "--port", "8080"] +sha256 = "abc123def456..." + +[agent_servers.example-agent.targets.linux-x86_64] +archive = "https://github.com/example/agent/releases/download/v2.0.0/agent-linux-x64.tar.gz" +cmd = "./bin/agent" +args = ["serve", "--port", "8080"] +sha256 = "def456abc123..." +``` + +## Installation Process + +When a user installs your extension and selects the agent server: + +1. Zed downloads the appropriate archive for the user's platform +2. The archive is extracted to a cache directory +3. Zed launches the agent using the specified command and arguments +4. Environment variables are set as configured +5. The agent server runs in the background, ready to assist the user + +Archives are cached locally, so subsequent launches are fast. + +## Distribution Best Practices + +### Use GitHub Releases + +GitHub Releases are a reliable way to distribute agent server binaries: + +1. Build your agent for each platform (macOS ARM64, macOS x86_64, Linux x86_64, Windows x86_64) +2. Package each build as a compressed archive (`.tar.gz` or `.zip`) +3. Create a GitHub release and upload the archives +4. Use the release URLs in your `extension.toml` + +## SHA-256 Hashes + +It's good for security to include SHA-256 hashes of your archives in `extension.toml`. Here's how to generate it: + +### macOS and Linux + +```bash +shasum -a 256 agent-darwin-arm64.tar.gz +``` + +### Windows + +```bash +certutil -hashfile agent-windows-x64.zip SHA256 +``` + +Then add that string to your target configuration: + +```toml +[agent_servers.my-agent.targets.darwin-aarch64] +archive = "https://github.com/owner/repo/releases/download/v1.0.0/agent-darwin-arm64.tar.gz" +cmd = "./agent" +sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" +``` + +## Testing + +To test your Agent Server Extension: + +1. [Install it as a dev extension](./developing-extensions.md#developing-an-extension-locally) +2. Open the [Agent Panel](../ai/agent-panel.md) +3. Select your Agent Server from the list +4. Verify that it downloads, installs, and launches correctly +5. Test its functionality by conversing with it and watching the [ACP logs](../ai/external-agents.md#debugging-agents) + +## Icon Guideline + +In case your agent server has a logo, we highly recommend adding it as an SVG icon. +For optimal display, follow these guidelines: + +- Make sure you resize your SVG to fit a 16x16 bounding box, with a padding of around one or two pixels +- Ensure you have a clean SVG code by processing it through [SVGOMG](https://jakearchibald.github.io/svgomg/) +- Avoid including icons with gradients as they will often make the SVG more complicated and possibly not render perfectly + +Note that we'll automatically convert your icon to monochrome to preserve Zed's design consistency. (You can still use opacity in different paths of your SVG to add visual layering.) + +This is all you need to distribute an agent server through Zed's extension system! + +## Publishing + +Once your extension is ready, see [Publishing your extension](./developing-extensions.md#publishing-your-extension) to learn how to submit it to the Zed extension registry.